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.
Install
Section titled “Install”Expo:
npx expo install @frontmail/react-native @react-native-async-storage/async-storage react-native-webviewBare React Native:
npm install @frontmail/react-native @react-native-async-storage/async-storage react-native-webviewnpx pod-installThe 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> |
Allow mobile apps
Section titled “Allow mobile apps”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.
Provider and useSendEmail
Section titled “Provider and useSendEmail”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>.
Turnstile
Section titled “Turnstile”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.
Your own site key
Section titled “Your own site key”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 and storage
Section titled “limitRate and storage”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
Section titled “getStatus”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 });Retries and idempotency
Section titled “Retries and idempotency”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.
Typed params
Section titled “Typed params”With generated types (npx frontmail types augments
@frontmail/react-native), useSendEmail('svc_…', 'tpl_contact').send({...}) type-checks the
params of that template.
Errors
Section titled “Errors”error is a FrontmailError with code, status and message. How to handle each code is in
Error handling.
Security
Section titled “Security”- 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
Originheader 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.