Skip to content

React Native (@frontmail/react-native)

@frontmail/react-native lets an iOS or Android app send emails through your templates with the public key – no backend of your own needed. It works with Expo and with bare React Native.

Expo:

Terminal window
npx expo install @frontmail/react-native @react-native-async-storage/async-storage react-native-webview

Bare React Native:

Terminal window
npm install @frontmail/react-native @react-native-async-storage/async-storage react-native-webview
npx pod-install

The two extra packages are optional peer dependencies:

Package Needed for
@react-native-async-storage/async-storage limitRate that survives app restarts
react-native-webview <TurnstileWebView>

Native apps don’t send an Origin or Referer header, so requests from the app are rejected (whether or not you have allowed websites set) with 403 origin_not_allowed until you turn on Security → Mobile apps → Allow mobile apps in the dashboard and click Save. See Security settings.

Requests from websites are still checked against the list, so the switch doesn’t weaken the protection of your web forms. The switch is required even with an empty list.

Wrap the app in FrontmailProvider and send with useSendEmail. Keep the options object stable – define it at module level or with useMemo:

import { useState } from 'react';
import { Button, Text, TextInput, View } from 'react-native';
import { FrontmailProvider, useSendEmail } from '@frontmail/react-native';
const frontmailOptions = { publicKey: 'pk_4f2a…', limitRate: { id: 'contact', throttle: 30_000 } };
export default function App() {
return (
<FrontmailProvider options={frontmailOptions}>
<ContactScreen />
</FrontmailProvider>
);
}
function ContactScreen() {
const { send, status, error } = useSendEmail('svc_01J9…', 'tpl_contact');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
return (
<View>
<TextInput value={email} onChangeText={setEmail} keyboardType="email-address" autoCapitalize="none" />
<TextInput value={message} onChangeText={setMessage} multiline />
<Button title="Send" disabled={status === 'sending'} onPress={() => send({ email, message })} />
{status === 'sent' && <Text>Thank you!</Text>}
{status === 'held' && <Text>Received – it will be delivered shortly.</Text>}
{error && <Text>{error.message}</Text>}
</View>
);
}

options accepts publicKey, apiUrl, retry, limitRate, storageProvider and blockList. A privateKey isn’t accepted. Alternatively, pass an existing client: <FrontmailProvider client={client}>.

useSendEmail(serviceId, templateId) returns { send, getStatus, status, error, result, reset }. status is idle → sending → sent | held | error. send(params, options?) resolves with the result or undefined on error (the error is in error) – it never rejects, so you don’t need try/catch. reset() returns the hook to idle.

Every request carries the header X-Frontmail-Client: @frontmail/react-native/<version>.

If the template requires Turnstile, render <TurnstileWebView> and pass the token to send. You don’t need a site key: by default the component uses Frontmail’s shared mobile key. It fetches the key from GET /v1/public-config of the API set in the provider (cached, so it’s loaded once) and shows the Cloudflare widget in react-native-webview as an inline HTML page with the URL https://mobile.frontmail.dev – nothing is loaded from that address.

import { useRef, useState } from 'react';
import { Button } from 'react-native';
import { TurnstileWebView, useSendEmail } from '@frontmail/react-native';
import type { TurnstileWebViewHandle } from '@frontmail/react-native';
function ContactScreen() {
const { send, status } = useSendEmail('svc_01J9…', 'tpl_contact');
const turnstile = useRef<TurnstileWebViewHandle>(null);
const [token, setToken] = useState<string>();
const onSubmit = async () => {
await send({ email, message }, { turnstileToken: token });
// Tokens are single use – get a fresh one for the next attempt.
setToken(undefined);
turnstile.current?.reset();
};
return (
<>
{/* …fields… */}
<TurnstileWebView
ref={turnstile}
onToken={setToken}
onExpire={() => setToken(undefined)}
onError={(e) => console.warn(e.message)}
/>
<Button title="Send" disabled={!token || status === 'sending'} onPress={onSubmit} />
</>
);
}

Props: onToken, and optionally siteKey, baseUrl, onError, onExpire, theme (auto | light | dark), size (normal | compact | flexible), action, language, style and webViewProps (passed to the WebView).

The WebView is locked to the widget: it only loads the inline page (baseUrl) and Cloudflare’s challenge, links inside the widget open in the system browser, and only messages from the widget page reach your callbacks. webViewProps can’t override source, originWhitelist, the navigation guard, onMessage or the JavaScript / file-access settings.

App requests have no Origin header, so Frontmail verifies their tokens with its shared mobile secret. You don’t need your own Turnstile keys for this – with Allow mobile apps on, you can turn the CAPTCHA on for the app’s templates even if your organization has no keys yet.

To use your own Turnstile widget instead, pass both siteKey and baseUrl:

<TurnstileWebView
ref={turnstile}
siteKey="YOUR_TURNSTILE_SITE_KEY"
baseUrl="https://example.com"
onToken={setToken}
/>

The SDK then sends tokens from this widget with turnstile_key: "org" automatically, and Frontmail verifies them with the secret key from Security → Bot protection (Turnstile).

A token is valid for about 5 minutes and can be used once: call ref.current.reset() after every send, successful or not.

limitRate throttles sending on the device, e.g. at most one message per 30 seconds. There’s no location.pathname in React Native, so always set limitRate.id:

import AsyncStorage from '@react-native-async-storage/async-storage';
import { asyncStorageProvider } from '@frontmail/react-native';
const frontmailOptions = {
publicKey: 'pk_4f2a…',
limitRate: { id: 'contact', throttle: 30_000 },
storageProvider: asyncStorageProvider(AsyncStorage),
};

Without storageProvider, the SDK uses AsyncStorage when it’s installed; otherwise it keeps the limit in memory only (it resets when the app restarts) and prints a warning in development. Use memoryStorageProvider() to choose memory storage explicitly.

limitRate only protects against accidental repeated taps – the real protection is on the server (see Security).

getStatus() from the hook reads the delivery status of the last accepted message. For anything else, useFrontmail() returns the client from the provider (send, getStatus):

import { useFrontmail } from '@frontmail/react-native';
const client = useFrontmail();
const status = await client.getStatus(messageId, { token });

Network errors, 5xx and 429 are retried automatically, and all attempts of one send reuse the same idempotency key, so a retry never sends the email twice. Keys are UUID v4 – the SDK uses crypto.randomUUID and falls back to its own generator on Hermes, which doesn’t provide it. See Retries and idempotency.

With generated types (npx frontmail types augments @frontmail/react-native), useSendEmail('svc_…', 'tpl_contact').send({...}) type-checks the params of that template.

error is a FrontmailError with code, status and message. How to handle each code is in Error handling.

  • Only ever put the public key (pk_…) into an app. Anything in an app bundle can be extracted, so a private key there is as good as published. See Public and private keys.
  • With Allow mobile apps on, requests without an Origin header are accepted from anywhere – any script that knows your public key can send them. Rely on the per-IP rate limit, Turnstile on every template the app uses and the block list.

Example project: examples/react-native.