Mail composer
Let users email you (or anyone) from inside the app: open the system mail composer with
recipients, subject, body and attachments already filled in. @symbiote-native/mail-composer
wraps expo-mail-composer
so every SymbioteNative adapter can reach it, not just React. Like
SMS, every export is a free function with no per-instance state, so the
React, Vue, Angular, Svelte, and Solid entry points are plain re-exports of the same core.
Nothing is ever sent on the user’s behalf. Both platforms open their own composer with the draft filled in; the user presses send, edits, or discards it.
| OS platform | Support |
|---|---|
| iOS | live |
| Android | live |
| Framework adapter | Support |
|---|---|
| React | live |
| Vue | live |
| Angular | live |
| Svelte | live |
| Solid | live |
Installation
Section titled “Installation”npm install @symbiote-native/mail-composerScaffolding or extending a SymbioteNative app? npx @symbiote-native/cli new --mail-composer (or
add --mail-composer in an existing app) installs and wires this for you - see
@symbiote-native/cli.
expo-mail-composer and expo-modules-core come along as regular dependencies, pinned to exact
versions. Never install either yourself, and never add the expo meta-package to your project (it
bundles its own Metro/Babel pipeline, which conflicts with this project’s own).
No runtime permission is needed on either platform. Two native details land automatically on install:
- iOS
LSApplicationQueriesSchemes.getClients()checks each candidate mail app withcanOpenURL, which iOS silently refuses for an undeclared scheme. The package’snative-link.jsoncarries the same 22-scheme list upstream’s config plugin writes and merges it into yourInfo.plist. - Android package visibility.
expo-mail-composerships its own<queries>block for Android 11+, which merges in once the Gradle project is included.
All five adapters re-export the exact same functions; there is no per-adapter hook, composable or service, since nothing here holds live state or a subscription.
import { composeAsync, isAvailableAsync } from '@symbiote-native/mail-composer/react';
export default function ContactSupport() { async function onPress() { if (!(await isAvailableAsync())) return; await composeAsync({ recipients: ['support@example.com'], subject: 'Order #1234', body: 'Hi, I have a question about my order.', }); }
return <button title="Email support" onPress={onPress} />;}<script setup lang="ts">import { composeAsync, isAvailableAsync } from '@symbiote-native/mail-composer/vue';
async function onPress() { if (!(await isAvailableAsync())) return; await composeAsync({ recipients: ['support@example.com'], subject: 'Order #1234', body: 'Hi, I have a question about my order.', });}</script>
<template> <button title="Email support" @press="onPress" /></template>import { Component } from '@angular/core';import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';import { composeAsync, isAvailableAsync } from '@symbiote-native/mail-composer/angular';
@Component({ standalone: true, imports: [SYMBIOTE_ELEMENTS], template: `<button title="Email support" (press)="onPress()" />`,})export class ContactSupport { async onPress(): Promise<void> { if (!(await isAvailableAsync())) return; await composeAsync({ recipients: ['support@example.com'], subject: 'Order #1234', body: 'Hi, I have a question about my order.', }); }}There is no service to inject(): every function is a plain export off the core package,
called straight from a template event binding.
<script lang="ts"> import { composeAsync, isAvailableAsync } from '@symbiote-native/mail-composer/svelte';
async function onPress(): Promise<void> { if (!(await isAvailableAsync())) return; await composeAsync({ recipients: ['support@example.com'], subject: 'Order #1234', body: 'Hi, I have a question about my order.', }); }</script>
<button title="Email support" onPress={onPress} />import { composeAsync, isAvailableAsync } from '@symbiote-native/mail-composer/solid';
export function ContactSupport() { async function onPress() { if (!(await isAvailableAsync())) return; await composeAsync({ recipients: ['support@example.com'], subject: 'Order #1234', body: 'Hi, I have a question about my order.', }); }
return <button title="Email support" onPress={onPress} />;}Attach a file
Section titled “Attach a file”await composeAsync({ recipients: ['support@example.com'], attachments: ['file:///path/to/receipt.pdf'],});List installed mail apps
Section titled “List installed mail apps”import { getClients } from '@symbiote-native/mail-composer';
const clients = getClients();// iOS: [{ label: 'Gmail', url: 'googlegmail://' }, ...]// Android: [{ label: 'Gmail', packageName: 'com.google.android.gm' }, ...]Functions
Section titled “Functions”| Signature | Description |
|---|---|
isAvailableAsync(): Promise<boolean> |
Whether a mail composer can open. false on iOS under an MDM profile that blocks outgoing mail |
composeAsync(options): Promise<IMailComposerResult> |
Opens the composer with the draft filled in. Resolves when the composer closes |
getClients(): IMailClient[] |
Lists the installed mail apps. Synchronous: no native round trip |
IMailComposerOptions
Section titled “IMailComposerOptions”| Field | Type | Description |
|---|---|---|
recipients |
string[] | undefined |
Addresses for the To field |
ccRecipients |
string[] | undefined |
Addresses for the Cc field |
bccRecipients |
string[] | undefined |
Addresses for the Bcc field |
subject |
string | undefined |
Subject line |
body |
string | undefined |
Message body. Plain text unless isHtml is true |
isHtml |
boolean | undefined |
Treat body as HTML |
attachments |
string[] | undefined |
File URIs inside the app’s own storage to attach |
IMailComposerResult and IMailClient
Section titled “IMailComposerResult and IMailClient”| Type | Field | Description |
|---|---|---|
IMailComposerResult |
status |
One of 'undetermined', 'sent', 'saved', 'cancelled'. See Notes for what Android reports |
IMailClient |
label |
Display name of the mail app |
IMailClient |
url |
iOS only: the app’s URL scheme |
IMailClient |
packageName |
Android only: the app’s package name |
- Android always resolves
sent. It cannot read the composer’s real outcome back, and upstream reports the same fixed status there. Only iOS distinguishessent,savedandcancelled. - iOS needs a signed-in Mail app.
isAvailableAsyncandcomposeAsyncfail without one. This is the checkMFMailComposeViewController.canSendMail()makes natively, which is also why the composer cannot be used on an iOS simulator (you cannot sign in to a mail account there). CheckisAvailableAsync()first and offer a fallback, such as copying the address. - Attachments must live in the app’s own storage. Pass
file://URIs from the file system package or the cache directory. - Composer behavior can only be verified on a device. The headless tests fake the native module, so they prove argument marshalling, not the composer.
Common questions
Section titled “Common questions”It does nothing on the iOS Simulator. The Simulator cannot sign in to a mail account, so there is no composer to open. Test on a device that has Mail set up.
isAvailableAsync() says true on Android but no composer appears. On Android the check is
weaker than on iOS, and on some devices the system opens a plain share sheet instead of a mail app.
Treat the composer as best-effort and offer a fallback, such as copying the address.
My HTML body shows as plain text on Android. HTML formatting (isHtml: true) is not reliable
across Android mail apps. Send plain text when the layout matters little.
How do I know whether the email was sent? On iOS the result is sent, saved or cancelled.
On Android it is always sent, whatever the user did.
An attachment is missing. Attach files that live in the app’s own storage, with a file://
URI. A path owned by another app can be unreadable by the mail app.
Sources: Expo docs: MailComposer, expo/expo#35466 isAvailableAsync not working on Android, expo/expo#37740 plain text share screen on some Androids, expo/expo#16690 cannot send HTML emails and never resolves, expo/expo#20509 composeAsync does not return a result on Android.
How the wrapper works
Section titled “How the wrapper works”expo-mail-composer’s JS is hand-ported into this package’s core/, resolving
ExpoMailComposer through expo-modules-core rather than the expo meta-package:
packages/mail-composer/src/|-- core/ # composeAsync, isAvailableAsync, getClients; native-module.ts|-- react/ # export * from '../core'|-- vue/ # export * from '../core'|-- svelte/ # export * from '../core'|-- solid/ # export * from '../core'`-- angular/ # export * from '../core' (separate ngc/AOT build)Every export is a stateless free function, so there is nothing for a hook, composable, rune or
service to subscribe to or clean up. The native code is never vendored:
expo-modules-autolinking resolves it from node_modules (see
the native setup guide).