frontmail 0.1.0
frontmail: ^0.1.0 copied to clipboard
Send emails from Flutter and Dart apps through your Frontmail templates with the public key – retries with idempotency, delivery status and Cloudflare Turnstile helpers.
Frontmail for Flutter and Dart #
Send emails from Flutter apps (Android, iOS, macOS, Windows, Linux, web) and Dart programs through your Frontmail templates – with the public key, no backend of your own.
async/awaitAPI, sound null safety- automatic retries (network errors, timeouts,
5xx,429) with one idempotency key per send - delivery status of sent messages
- Cloudflare Turnstile helpers for
webview_flutter(CAPTCHA for templates that need it) - pure Dart, depends only on
package:http
Requirements: Dart 3.5+ (Flutter 3.24+).
Documentation: https://docs.frontmail.dev/sdks/flutter/
Install #
flutter pub add frontmail
# or, without Flutter:
dart pub add frontmail
Android: release builds need the INTERNET permission in android/app/src/main/AndroidManifest.xml
(<uses-permission android:name="android.permission.INTERNET"/> – Flutter adds it only to debug
builds). macOS: add com.apple.security.network.client to both macos/Runner/*.entitlements.
Allow mobile apps #
Apps don't send an Origin or Referer header, so the API rejects their requests with
403 origin_not_allowed until you turn on Security → Mobile apps → Allow mobile apps in the
Frontmail dashboard and click Save. Requests from websites are still checked against your
allowed origins.
Flutter web runs in a browser, which always sends Origin: add your web app's origin to the
allowed origins instead (the switch isn't needed there).
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) {
print('Received – it will be delivered shortly.'); // waiting for credits
} else {
print('Sent ${result.messageId}');
}
Create the client once and share it (e.g. as a top-level final or through your DI). Options:
final frontmail = Frontmail(
publicKey: 'pk_4f2a…',
apiUrl: Uri.parse('https://api.frontmail.dev'), // default
retry: const RetryPolicy(maxRetries: 3, baseDelay: Duration(milliseconds: 300), maxDelay: Duration(seconds: 10)), // default; RetryPolicy.disabled turns retries off
timeout: const Duration(seconds: 15), // per attempt (default)
httpClient: myClient, // any package:http Client (e.g. cupertino_http, cronet_http); default: http.Client()
);
close() closes the HTTP client the SDK created itself (a client you passed in stays open).
Params are a Map<String, Object?> of JSON values (String, num, bool, null, lists, maps).
DateTime values are sent as ISO-8601 UTC strings and objects with a toJson() method (e.g. from
json_serializable) are encoded with it:
await frontmail.send(templateId: 'tpl_contact', params: contact.toJson());
SendOptions carries the Turnstile token, attachments, 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: 'de') picks a language variant of the template (BCP 47 tag). The API falls
back to the same base language (de-AT → de), then to the template's default language – an
unknown language is not an error (a malformed tag is invalid_body, so pass
Localizations.localeOf(context).toLanguageTag() – e.g. de-AT – rather than toString(), which
uses _). SendResult.locale is the language the email was rendered in:
final result = await frontmail.send(
templateId: 'tpl_contact',
params: {'name': 'Jan'},
options: const SendOptions(locale: 'de'),
);
print(result.locale ?? 'default'); // "de"
Every request carries X-Frontmail-Client: frontmail-flutter/<version>.
Aborting #
Dart futures can't be cancelled, so send and status take an abortTrigger: when that future
completes, the request (or the wait before a retry) stops and throws aborted.
final abort = Completer<void>();
final future = frontmail.send(templateId: 'tpl_contact', options: SendOptions(abortTrigger: abort.future));
// e.g. in dispose():
abort.complete();
Delivery status #
send returns a statusToken that lets the app read the status of that message (the token is
sent 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, …)
Errors #
Everything throws FrontmailException:
| Property | |
|---|---|
code |
API code (rate_limited, captcha_failed, …) or a client code: network_error, timeout, aborted, invalid_response, private_key_in_browser – constants in FrontmailErrorCode |
message |
English description |
status |
HTTP status, null for client-side errors |
docsUrl |
documentation of the code |
details |
extra data from the API (decoded JSON), e.g. {'reason': 'no_origin'} |
retryAfter |
Retry-After as a Duration |
Category checks: isAuth (401 and key/origin 403s), isValidation (400/422), isRateLimit (429),
isInsufficientCredits (402), isNetwork (network_error/timeout), isAborted.
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);
}
}
Retries and idempotency #
Network errors, timeouts, 5xx and 429 are retried (3 retries by default) with exponential
backoff and full jitter (base 300 ms, max 10 s). A Retry-After header is honored; if it asks for
more than 60 s, the error is thrown instead. Other 4xx responses are never retried. All attempts
of one send reuse the same Idempotency-Key (a random UUID unless you pass one), so a retry never
sends the email twice.
Turnstile #
Templates with CAPTCHA need a single-use Cloudflare Turnstile
token. The widget runs in a web view (webview_flutter)
that loads an inline HTML page – you don't need your own site key: by default Frontmail's shared
mobile widget is used (its key comes from GET /v1/public-config, cached in memory). The page
pretends to be https://mobile.frontmail.dev; nothing is loaded from that address.
The SDK provides the pieces; the widget itself lives in your app (so the SDK stays pure Dart):
Turnstile.site(client:)– site key + page URL (+ theturnstileKeyto send tokens with)Turnstile.html(siteKey:, theme:, size:, action:, language:)– the page; it posts JSON strings to the JavaScript channelTurnstile.javaScriptChannelName(FrontmailTurnstile)Turnstile.parseMessage(message)– validates a message →TurnstileToken,TurnstileExpired,TurnstileErrorTurnstile.navigationAction(url:, baseUrl:, isTopFrame:)– navigation guard: the web view stays on the widget, Cloudflare's links open in the system browserTurnstile.resetScript
A complete widget for Android, iOS and macOS (flutter pub add webview_flutter url_launcher, then
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 – get 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),
]);
}
}
A token is valid for about 5 minutes and can be used once. webview_flutter has no web
implementation: on Flutter web render Turnstile with your own site key in the page itself
(e.g. through HtmlElementView) and send its token with turnstileKey: TurnstileKey.org.
Your own site key #
To use your own Turnstile widget, pass its site key and a baseUrl whose hostname is one of
your widget's hostnames (Cloudflare dashboard → Turnstile → the widget → Hostnames) – otherwise
Turnstile fails with error 110200. Nothing is loaded from baseUrl, so it needs no DNS or server:
final site = TurnstileSite.custom(siteKey: 'YOUR_TURNSTILE_SITE_KEY', baseUrl: Uri.parse('https://example.com'));
Its turnstileKey is TurnstileKey.org: send tokens with
SendOptions(turnstileToken: token, turnstileKey: TurnstileKey.org) and Frontmail verifies them
with the secret from Security → Bot protection (Turnstile).
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_(private_key_in_browser). - 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. - Dynamic recipients mean locked content. A template whose To/CC/BCC comes from params (e.g. a
confirmation sent to the address the user typed) is sent with a public key only when it requires
CAPTCHA, has a fixed sender (From, From name, Reply-To), gets no
htmlparams, strings of at most 500 characters (URLs 2048), links only to your allowed domains, and stays under the per-recipient daily limit (default 5). See Link allowlist & content lock.
Privacy #
The SDK doesn't track users and collects no data of its own. What your templates send (e.g. an email address) is data your app collects – declare it in your App Store privacy details and Google Play data safety form.
Development #
dart pub get
dart analyze
dart format --set-exit-if-changed .
dart test
The version lives in pubspec.yaml and in lib/src/version.dart (a test keeps them in sync);
releases are git tags vX.Y.Z, published to pub.dev by GitHub Actions.
License #
MIT