Zum Inhalt springen

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+).

Terminal-Fenster
flutter pub add frontmail

Paket: 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.

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 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.

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äter
abort.complete();

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, …)

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.

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.

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.

  • 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äfix sk_ mit dem Fehler private_key_in_browser ab. 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.

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.