Skip to main content

frontmail 0.1.0 copy "frontmail: ^0.1.0" to clipboard
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/await API, 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 (+ the turnstileKey to send tokens with)
  • Turnstile.html(siteKey:, theme:, size:, action:, language:) – the page; it posts JSON strings to the JavaScript channel Turnstile.javaScriptChannelName (FrontmailTurnstile)
  • Turnstile.parseMessage(message) – validates a message → TurnstileToken, TurnstileExpired, TurnstileError
  • Turnstile.navigationAction(url:, baseUrl:, isTopFrame:) – navigation guard: the web view stays on the widget, Cloudflare's links open in the system browser
  • Turnstile.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 with sk_ (private_key_in_browser).
  • 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.
  • 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 html params, 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

0
likes
160
points
71
downloads

Documentation

Documentation
API reference

Publisher

verified publisherfrontmail.dev

Weekly Downloads

Send emails from Flutter and Dart apps through your Frontmail templates with the public key – retries with idempotency, delivery status and Cloudflare Turnstile helpers.

Homepage
Repository (GitHub)
View/report issues

Topics

#email #form #turnstile #api-client

License

MIT (license)

Dependencies

http, http_parser

More

Packages that depend on frontmail