Skip to content

Flutter & Dart

The frontmail package lets a Flutter app send emails through your templates with the public key – no backend of your own needed. It’s pure Dart (its only dependency is package:http), so the same code runs on Android, iOS, macOS, Windows, Linux, the web and in Dart programs.

Requirements: Dart 3.5+ (Flutter 3.24+).

Terminal window
flutter pub add frontmail

Package: pub.dev/packages/frontmail. Source code and releases: github.com/frontmail-dev/frontmail-flutter.

Release builds on Android need the INTERNET permission in android/app/src/main/AndroidManifest.xml (Flutter adds it to debug builds only):

<uses-permission android:name="android.permission.INTERNET"/>

On macOS add the com.apple.security.network.client entitlement to both macos/Runner/DebugProfile.entitlements and macos/Runner/Release.entitlements.

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.

Flutter web runs in the browser, which always sends Origin: add your web app’s origin to the allowed websites instead – the switch isn’t needed there.

Create the client once and share it (a top-level final, a provider, your DI) and call send:

import 'package:frontmail/frontmail.dart';
final frontmail = Frontmail(publicKey: 'pk_4f2a…');
final result = await frontmail.send(
templateId: 'tpl_contact',
params: {'email': 'jan@example.com', 'message': 'Hello!', 'qty': 2, 'items': [{'sku': 'A-1'}]},
serviceId: 'svc_01J9…', // optional – null uses the template's default service
);
if (result.status == DeliveryStatus.held) {
// Received – it will be delivered shortly (the organization is waiting for credits).
}

send returns SendResult with messageId, status (queued or held) and statusToken. The constructor throws when you pass a private key (see Security).

Options of the constructor:

final frontmail = Frontmail(
publicKey: 'pk_4f2a…',
apiUrl: Uri.parse('https://api.frontmail.dev'), // default
retry: const RetryPolicy(maxRetries: 3), // default; RetryPolicy.disabled = no retries
timeout: const Duration(seconds: 15), // per attempt (default)
httpClient: myClient, // any package:http Client, e.g. cupertino_http or cronet_http
);

close() closes the HTTP client the SDK created itself; a client you passed in stays open. Every request carries the header X-Frontmail-Client: frontmail-flutter/<version>.

Params are a Map<String, Object?> of JSON values: String, num, bool, null, lists and maps. DateTime values are sent as ISO-8601 UTC strings, and objects with a toJson() method (for example from json_serializable or freezed) are encoded with it:

await frontmail.send(templateId: 'tpl_contact', params: contact.toJson());

Values that aren’t JSON (double.nan, an object without toJson()) throw invalid_template_params before anything is sent.

SendOptions carries the Turnstile token, attachments, the template language, an explicit idempotency key and an abort trigger:

await frontmail.send(
templateId: 'tpl_order',
params: {'order': '1042'},
options: SendOptions(
turnstileToken: token,
attachments: [
Attachment.bytes(pdfBytes, filename: 'invoice.pdf', contentType: 'application/pdf'),
const Attachment.upload('upl_…'), // uploaded earlier via POST /v1/uploads
],
),
);

SendOptions(locale: …) sends the email in a language of the template (a BCP 47 tag). Use Localizations.localeOf(context).toLanguageTag() (e.g. de-AT) – Locale.toString() uses _ and is rejected as invalid_body. An unknown language falls back to the same base language, then to the template’s default language; result.locale is the language used:

final result = await frontmail.send(
templateId: 'tpl_contact',
params: {'name': 'Jan'},
options: SendOptions(locale: Localizations.localeOf(context).toLanguageTag()),
);
print(result.locale ?? 'default'); // "de"

Dart futures can’t be cancelled, so send and status take an abortTrigger instead: when that future completes – for example in dispose() – the request or the wait before a retry stops with the code aborted:

final abort = Completer<void>();
frontmail.send(templateId: 'tpl_contact', options: SendOptions(abortTrigger: abort.future));
// later
abort.complete();

The statusToken from send lets the app read the status of that message. The SDK sends it in the X-Frontmail-Status-Token header, never in the URL:

final status = await frontmail.status(messageId: result.messageId, statusToken: result.statusToken);
print(status.status); // queued, sending, sent, delivered, bounced, … (unknown values are kept)
print(status.events.map((e) => e.type)); // (accepted, sent, …)

If the template requires Turnstile, show the Cloudflare widget in a webview_flutter web view and pass the token to send. You don’t need a site key: by default Frontmail’s shared mobile key is used. Turnstile.site(client:) fetches it from GET /v1/public-config (cached in memory, so it’s loaded once), and the widget runs as an inline HTML page with the URL https://mobile.frontmail.dev – nothing is loaded from that address.

The SDK gives you the pieces, the widget lives in your app (so the SDK itself stays pure Dart):

API What it does
Turnstile.site(client:) site key + page URL + the turnstileKey to send tokens with
Turnstile.html(siteKey:, theme:, size:, action:, language:) the widget page; it posts to the JavaScript channel FrontmailTurnstile
Turnstile.parseMessage(message) validates a message → TurnstileToken, TurnstileExpired, TurnstileError
Turnstile.navigationAction(url:, baseUrl:, isTopFrame:) navigation guard: TurnstileAllow, TurnstileCancel, TurnstileOpenExternally
Turnstile.resetScript JavaScript that resets the widget for a new token

A complete widget for Android, iOS and macOS – add webview_flutter and url_launcher (flutter pub add webview_flutter url_launcher) and copy it into your app:

import 'package:flutter/material.dart';
import 'package:frontmail/frontmail.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
class TurnstileView extends StatefulWidget {
const TurnstileView({
super.key,
required this.site,
required this.onMessage,
this.theme = TurnstileTheme.auto,
this.size = TurnstileSize.flexible,
this.resetId = 0,
});
final TurnstileSite site;
final ValueChanged<TurnstileMessage> onMessage;
final TurnstileTheme theme;
final TurnstileSize size;
/// Change the value to reset the widget and get a new token (tokens are single use).
final int resetId;
@override
State<TurnstileView> createState() => _TurnstileViewState();
}
class _TurnstileViewState extends State<TurnstileView> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..addJavaScriptChannel(
Turnstile.javaScriptChannelName,
onMessageReceived: (message) {
final parsed = Turnstile.parseMessage(message.message);
if (parsed != null && mounted) widget.onMessage(parsed);
},
)
// The top frame stays on the widget; links inside it open in the system browser.
..setNavigationDelegate(NavigationDelegate(
onNavigationRequest: (request) {
final action = Turnstile.navigationAction(
url: request.url,
baseUrl: widget.site.baseUrl,
isTopFrame: request.isMainFrame,
);
switch (action) {
case TurnstileAllow():
return NavigationDecision.navigate;
case TurnstileCancel():
return NavigationDecision.prevent;
case TurnstileOpenExternally(:final url):
launchUrl(url, mode: LaunchMode.externalApplication);
return NavigationDecision.prevent;
}
},
))
..loadHtmlString(
Turnstile.html(siteKey: widget.site.siteKey, theme: widget.theme, size: widget.size),
baseUrl: widget.site.baseUrl.toString(),
);
}
@override
void didUpdateWidget(TurnstileView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.resetId != widget.resetId) _controller.runJavaScript(Turnstile.resetScript);
}
@override
Widget build(BuildContext context) => SizedBox(
width: widget.size.recommendedWidth,
height: widget.size.recommendedHeight,
child: WebViewWidget(controller: _controller),
);
}

Use it in a form – wait for a token, send, then reset the widget:

class ContactForm extends StatefulWidget {
const ContactForm({super.key, required this.frontmail});
final Frontmail frontmail;
@override
State<ContactForm> createState() => _ContactFormState();
}
class _ContactFormState extends State<ContactForm> {
final _email = TextEditingController();
final _message = TextEditingController();
TurnstileSite? _site;
String? _token;
int _resetId = 0;
String _info = '';
@override
void initState() {
super.initState();
Turnstile.site(client: widget.frontmail).then(
(site) => setState(() => _site = site),
onError: (Object e) => setState(() => _info = '$e'),
);
}
@override
void dispose() {
_email.dispose();
_message.dispose();
super.dispose();
}
Future<void> _submit() async {
final site = _site, token = _token;
if (site == null || token == null) return;
// A token works once – ask for a new one whatever the outcome.
setState(() {
_token = null;
_resetId++;
});
try {
final result = await widget.frontmail.send(
templateId: 'tpl_contact',
params: {'email': _email.text, 'message': _message.text},
options: SendOptions(turnstileToken: token, turnstileKey: site.turnstileKey),
);
setState(() => _info = result.status == DeliveryStatus.held ? 'Received – it will be delivered shortly.' : 'Thank you!');
} on FrontmailException catch (e) {
setState(() => _info = e.message);
}
}
@override
Widget build(BuildContext context) {
final site = _site;
return Column(children: [
TextField(controller: _email, decoration: const InputDecoration(labelText: 'Email')),
TextField(controller: _message, decoration: const InputDecoration(labelText: 'Message')),
if (site != null)
TurnstileView(
site: site,
resetId: _resetId,
onMessage: (message) => setState(() {
switch (message) {
case TurnstileToken(:final token):
_token = token;
case TurnstileExpired():
_token = null;
case TurnstileError(:final code):
_info = 'Turnstile error ${code ?? ''}';
}
}),
),
FilledButton(onPressed: _token == null ? null : _submit, child: const Text('Send')),
Text(_info),
]);
}
}

The web view 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 parseMessage accepts only messages of exactly the shape the widget page posts.

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.

webview_flutter has no web implementation. On Flutter web requests do have an Origin, so Turnstile needs your own keys: render the widget in the page (for example through HtmlElementView) and send its token with turnstileKey: TurnstileKey.org.

To use your own Turnstile widget instead, create the site yourself:

final site = TurnstileSite.custom(siteKey: 'YOUR_TURNSTILE_SITE_KEY', baseUrl: Uri.parse('https://example.com'));

Its turnstileKey is TurnstileKey.org, so SendOptions(turnstileToken: token, turnstileKey: site.turnstileKey) sends turnstile_key: "org" and Frontmail verifies the token with the secret key from Security → Bot protection (Turnstile).

A token is valid for about 5 minutes and can be used once: reset the widget after every send, successful or not.

Network errors, timeouts, 5xx and 429 are retried automatically (3 retries by default, exponential backoff with full jitter). A Retry-After header is honored; if it asks for more than 60 seconds, the error is thrown right away. Other 4xx responses are never retried. All attempts of one send reuse the same idempotency key (a random UUID unless you pass idempotencyKey), so a retry never sends the email twice. See Retries and idempotency.

Everything throws FrontmailException with code, message, status (null for client-side errors), docsUrl, details and retryAfter. Client codes are network_error, timeout, aborted, invalid_response and private_key_in_browser (constants in FrontmailErrorCode); the checks isAuth, isValidation, isRateLimit, isInsufficientCredits, isNetwork and isAborted group them:

try {
await frontmail.send(templateId: 'tpl_contact', params: params);
} on FrontmailException catch (e) {
if (e.isRateLimit) {
show('Too many messages – try again in ${e.retryAfter?.inSeconds ?? 60} s.');
} else if (e.code == FrontmailErrorCode.originNotAllowed) {
assert(false, 'Turn on Security → Mobile apps → Allow mobile apps in the dashboard.');
} else {
show(e.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. The SDK refuses keys starting with sk_ with the error private_key_in_browser. 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.
  • A template that takes its recipient from params (for example a confirmation sent to the address the user typed) is locked: it needs Turnstile, a fixed sender, short plain params and links only to your domains. See Link allowlist & content lock.

The SDK doesn’t track users and collects no data of its own. What your templates send (such as an email address) is data your app collects: declare it in the App Store privacy details and in the Google Play data safety form.