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+).
Install
Section titled “Install”flutter pub add frontmailPackage: 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.
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.
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
Section titled “Params”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.
Attachments and other options
Section titled “Attachments and other options”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));// laterabort.complete();Delivery status
Section titled “Delivery status”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, …)Turnstile
Section titled “Turnstile”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.
Your own site key
Section titled “Your own site key”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.
Retries and idempotency
Section titled “Retries and idempotency”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.
Errors
Section titled “Errors”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.
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. The SDK refuses keys starting withsk_with the errorprivate_key_in_browser. 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. - 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.
Privacy
Section titled “Privacy”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.