Flutter & Dart
Mit dem Paket frontmail sendet eine Flutter-App E-Mails über Ihre Vorlagen mit dem öffentlichen
Schlüssel – ganz ohne eigenes Backend. Es ist reines Dart (die einzige Abhängigkeit ist
package:http), derselbe Code läuft also auf Android, iOS, macOS, Windows, Linux, im Web und in
Dart-Programmen.
Voraussetzungen: Dart 3.5+ (Flutter 3.24+).
Installation
Abschnitt betitelt „Installation“flutter pub add frontmailPaket: pub.dev/packages/frontmail. Quellcode und Releases: github.com/frontmail-dev/frontmail-flutter.
Release-Builds auf Android benötigen die Berechtigung INTERNET in
android/app/src/main/AndroidManifest.xml (Flutter fügt sie nur Debug-Builds hinzu):
<uses-permission android:name="android.permission.INTERNET"/>Unter macOS fügen Sie das Entitlement com.apple.security.network.client sowohl in
macos/Runner/DebugProfile.entitlements als auch in macos/Runner/Release.entitlements hinzu.
Mobile Apps erlauben
Abschnitt betitelt „Mobile Apps erlauben“Native Apps senden keinen Origin- oder Referer-Header, daher werden Anfragen aus der App (egal ob
Sie erlaubte Websites eingetragen haben oder nicht) mit
403 origin_not_allowed abgelehnt, bis Sie im Dashboard
Sicherheit → Mobile Apps → Mobile Apps erlauben einschalten und auf Speichern klicken. Siehe
Sicherheitseinstellungen.
Anfragen von Websites werden weiterhin mit der Liste abgeglichen – der Schalter schwächt also den Schutz Ihrer Webformulare nicht. Er ist auch bei leerer Liste erforderlich.
Flutter Web läuft im Browser, der immer einen Origin sendet: Tragen Sie stattdessen den Origin
Ihrer Web-App unter erlaubte Websites ein – den Schalter brauchen
Sie dort nicht.
Erstellen Sie den Client einmal und verwenden Sie ihn überall (ein final auf oberster Ebene, ein
Provider, Ihre Dependency Injection), dann rufen Sie send auf:
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': 'Guten Tag!', 'qty': 2, 'items': [{'sku': 'A-1'}]}, serviceId: 'svc_01J9…', // optional – null verwendet den Standarddienst der Vorlage);if (result.status == DeliveryStatus.held) { // Erhalten – wird in Kürze zugestellt (die Organisation wartet auf Credits).}send liefert ein SendResult mit messageId, status (queued oder held) und statusToken.
Der Konstruktor wirft einen Fehler, wenn Sie einen privaten Schlüssel übergeben (siehe
Sicherheit).
Optionen des Konstruktors:
final frontmail = Frontmail( publicKey: 'pk_4f2a…', apiUrl: Uri.parse('https://api.frontmail.dev'), // Standard retry: const RetryPolicy(maxRetries: 3), // Standard; RetryPolicy.disabled = keine Wiederholungen timeout: const Duration(seconds: 15), // pro Versuch (Standard) httpClient: myClient, // ein beliebiger package:http-Client, z. B. cupertino_http oder cronet_http);close() schließt den HTTP-Client, den das SDK selbst erstellt hat; ein von Ihnen übergebener
Client bleibt offen. Jede Anfrage trägt den Header X-Frontmail-Client: frontmail-flutter/<version>.
Parameter
Abschnitt betitelt „Parameter“Parameter sind eine Map<String, Object?> aus JSON-Werten: String, num, bool, null, Listen
und Maps. DateTime-Werte werden als ISO-8601-Strings in UTC gesendet, und Objekte mit einer
toJson()-Methode (etwa aus json_serializable oder freezed) werden damit kodiert:
await frontmail.send(templateId: 'tpl_contact', params: contact.toJson());Werte, die kein JSON sind (double.nan, ein Objekt ohne toJson()), werfen
invalid_template_params, bevor irgendetwas gesendet wird.
Anhänge und weitere Optionen
Abschnitt betitelt „Anhänge und weitere Optionen“SendOptions enthält den Turnstile-Token, Anhänge, die Sprache der Vorlage, einen expliziten
Idempotenzschlüssel und einen Abbruch-Auslöser:
await frontmail.send( templateId: 'tpl_order', params: {'order': '1042'}, options: SendOptions( turnstileToken: token, attachments: [ Attachment.bytes(pdfBytes, filename: 'rechnung.pdf', contentType: 'application/pdf'), const Attachment.upload('upl_…'), // zuvor über POST /v1/uploads hochgeladen ], ),);SendOptions(locale: …) versendet die E-Mail in einer Sprache der Vorlage
(ein BCP-47-Tag). Verwenden Sie Localizations.localeOf(context).toLanguageTag() (z. B. de-AT) –
Locale.toString() verwendet _ und wird mit invalid_body abgelehnt. Eine unbekannte Sprache
fällt auf dieselbe Basissprache zurück, danach auf die Standardsprache der Vorlage; result.locale
ist die verwendete Sprache:
final result = await frontmail.send( templateId: 'tpl_contact', params: {'name': 'Jan'}, options: SendOptions(locale: Localizations.localeOf(context).toLanguageTag()),);print(result.locale ?? 'default'); // "de"Futures lassen sich in Dart nicht abbrechen, deshalb nehmen send und status stattdessen einen
abortTrigger entgegen: Sobald dieses Future abgeschlossen wird – etwa in dispose() –, enden die
Anfrage oder die Wartezeit vor einer Wiederholung mit dem Code aborted:
final abort = Completer<void>();frontmail.send(templateId: 'tpl_contact', options: SendOptions(abortTrigger: abort.future));// späterabort.complete();Zustellstatus
Abschnitt betitelt „Zustellstatus“Mit dem statusToken aus send kann die App den Status dieser Nachricht lesen. Das SDK sendet ihn
im Header X-Frontmail-Status-Token, nie in der URL:
final status = await frontmail.status(messageId: result.messageId, statusToken: result.statusToken);print(status.status); // queued, sending, sent, delivered, bounced, … (unbekannte Werte bleiben erhalten)print(status.events.map((e) => e.type)); // (accepted, sent, …)Turnstile
Abschnitt betitelt „Turnstile“Verlangt die Vorlage Turnstile, zeigen Sie das Cloudflare-Widget in einer
Web-View von webview_flutter an und übergeben den Token
an send. Einen Site-Key brauchen Sie nicht: Standardmäßig wird der gemeinsame mobile Schlüssel
von Frontmail verwendet. Turnstile.site(client:) holt ihn von
GET /v1/public-config (im Speicher zwischengespeichert,
also nur einmal geladen), und das Widget läuft als Inline-HTML-Seite mit der URL
https://mobile.frontmail.dev – von dieser Adresse wird nichts geladen.
Das SDK liefert die Bausteine, das Widget lebt in Ihrer App (so bleibt das SDK selbst reines Dart):
| API | Funktion |
|---|---|
Turnstile.site(client:) |
Site-Key + Seiten-URL + der turnstileKey, mit dem Tokens gesendet werden |
Turnstile.html(siteKey:, theme:, size:, action:, language:) |
die Widget-Seite; sie sendet an den JavaScript-Kanal FrontmailTurnstile |
Turnstile.parseMessage(message) |
validiert eine Nachricht → TurnstileToken, TurnstileExpired, TurnstileError |
Turnstile.navigationAction(url:, baseUrl:, isTopFrame:) |
Navigationsschutz: TurnstileAllow, TurnstileCancel, TurnstileOpenExternally |
Turnstile.resetScript |
JavaScript, das das Widget für einen neuen Token zurücksetzt |
Ein vollständiges Widget für Android, iOS und macOS – fügen Sie webview_flutter und url_launcher
hinzu (flutter pub add webview_flutter url_launcher) und kopieren Sie es in Ihre 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;
/// Ändern Sie den Wert, um das Widget zurückzusetzen und einen neuen Token zu erhalten (Tokens sind nur einmal gültig). 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); }, ) // Der Hauptframe bleibt auf dem Widget; Links darin öffnen sich im Systembrowser. ..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), );}Verwendung in einem Formular: auf einen Token warten, senden, dann das Widget zurücksetzen:
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; // Ein Token funktioniert nur einmal – unabhängig vom Ergebnis einen neuen anfordern. 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 ? 'Erhalten – wird in Kürze zugestellt.' : 'Vielen Dank!'); } 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: 'E-Mail')), TextField(controller: _message, decoration: const InputDecoration(labelText: 'Nachricht')), 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-Fehler ${code ?? ''}'; } }), ), FilledButton(onPressed: _token == null ? null : _submit, child: const Text('Senden')), Text(_info), ]); }}Die Web-View ist auf das Widget beschränkt: Sie lädt nur die Inline-Seite (baseUrl) und die
Challenge von Cloudflare, Links im Widget öffnen sich im Systembrowser, und parseMessage akzeptiert
nur Nachrichten, die genau die Form haben, in der die Widget-Seite sie sendet.
App-Anfragen haben keinen Origin-Header, daher prüft Frontmail ihre Tokens mit seinem gemeinsamen
mobilen Secret. Eigene Turnstile-Schlüssel brauchen Sie dafür nicht – mit aktiviertem
Mobile Apps erlauben können Sie das CAPTCHA für die Vorlagen der App einschalten, auch wenn Ihre
Organisation noch keine Schlüssel hat.
webview_flutter hat keine Web-Implementierung. Unter Flutter Web haben Anfragen sehr wohl
einen Origin, daher braucht Turnstile dort Ihre eigenen Schlüssel:
Rendern Sie das Widget in der Seite (zum Beispiel über HtmlElementView) und senden Sie seinen
Token mit turnstileKey: TurnstileKey.org.
Eigener Site-Key
Abschnitt betitelt „Eigener Site-Key“Um stattdessen Ihr eigenes Turnstile-Widget zu verwenden, erstellen Sie die Site selbst:
final site = TurnstileSite.custom(siteKey: 'YOUR_TURNSTILE_SITE_KEY', baseUrl: Uri.parse('https://example.com'));Ihr turnstileKey ist TurnstileKey.org, daher sendet
SendOptions(turnstileToken: token, turnstileKey: site.turnstileKey) den Wert
turnstile_key: "org", und Frontmail prüft den Token mit dem geheimen Schlüssel aus
Sicherheit → Bot-Schutz (Turnstile).
Ein Token ist etwa 5 Minuten gültig und kann einmal verwendet werden: Setzen Sie das Widget nach jedem Versand zurück, ob erfolgreich oder nicht.
Wiederholungen und Idempotenz
Abschnitt betitelt „Wiederholungen und Idempotenz“Netzwerkfehler, Timeouts, 5xx und 429 werden automatisch wiederholt (standardmäßig 3
Wiederholungen, exponentielles Backoff mit Full Jitter). Ein Retry-After-Header wird beachtet;
verlangt er mehr als 60 Sekunden, wird der Fehler sofort geworfen. Andere 4xx-Antworten werden nie
wiederholt. Alle Versuche einer Sendung verwenden denselben Idempotenzschlüssel (eine zufällige UUID,
sofern Sie kein idempotencyKey übergeben) – eine Wiederholung sendet die E-Mail also nie doppelt.
Siehe Wiederholungen und Idempotenz.
Alles wirft FrontmailException mit code, message, status (null bei Fehlern auf
Clientseite), docsUrl, details und retryAfter. Client-Codes sind network_error, timeout,
aborted, invalid_response und private_key_in_browser (Konstanten in FrontmailErrorCode); die
Prüfungen isAuth, isValidation, isRateLimit, isInsufficientCredits, isNetwork und
isAborted fassen sie zusammen:
try { await frontmail.send(templateId: 'tpl_contact', params: params);} on FrontmailException catch (e) { if (e.isRateLimit) { show('Zu viele Nachrichten – versuchen Sie es in ${e.retryAfter?.inSeconds ?? 60} s erneut.'); } else if (e.code == FrontmailErrorCode.originNotAllowed) { assert(false, 'Schalten Sie im Dashboard Sicherheit → Mobile Apps → Mobile Apps erlauben ein.'); } else { show(e.message); }}Wie Sie mit den einzelnen Codes umgehen, steht unter Fehlerbehandlung.
Sicherheit
Abschnitt betitelt „Sicherheit“- Bauen Sie in eine App immer nur den öffentlichen Schlüssel (
pk_…) ein. Alles in einem App-Bundle lässt sich extrahieren, ein privater Schlüssel darin ist also so gut wie veröffentlicht. Das SDK lehnt Schlüssel mit dem Präfixsk_mit dem Fehlerprivate_key_in_browserab. Siehe Öffentliche und private Schlüssel. - Mit aktiviertem Mobile Apps erlauben werden Anfragen ohne
Origin-Header von überall angenommen – jedes Skript, das Ihren öffentlichen Schlüssel kennt, kann sie senden. Verlassen Sie sich auf das Rate Limit pro IP, Turnstile für jede Vorlage, die die App verwendet, und die Blockliste. - Eine Vorlage, die ihren Empfänger aus Parametern übernimmt (etwa eine Bestätigung an die Adresse, die der Nutzer eingegeben hat), ist gesperrt: Sie braucht Turnstile, einen festen Absender, kurze einfache Parameter und Links nur auf Ihre Domains. Siehe Erlaubte Links & Inhaltssperre.
Datenschutz
Abschnitt betitelt „Datenschutz“Das SDK trackt keine Nutzer und erhebt selbst keine Daten. Was Ihre Vorlagen senden (etwa eine E-Mail-Adresse), sind Daten, die Ihre App erhebt: Geben Sie sie in den Datenschutzangaben im App Store und im Abschnitt „Datensicherheit“ bei Google Play an.