Flutter a Dart
Balíček frontmail umožní aplikaci ve Flutteru odesílat e-maily přes vaše šablony s public key –
bez vlastního backendu. Je napsaný čistě v Dartu (jedinou závislostí je package:http), takže
stejný kód běží na Androidu, iOS, macOS, Windows, Linuxu, na webu i v programech v Dartu.
Požadavky: Dart 3.5+ (Flutter 3.24+).
Instalace
Sekce “Instalace”flutter pub add frontmailBalíček: pub.dev/packages/frontmail. Zdrojový kód a vydání: github.com/frontmail-dev/frontmail-flutter.
Release buildy pro Android potřebují oprávnění INTERNET v
android/app/src/main/AndroidManifest.xml (Flutter ho sám přidává jen do debug buildů):
<uses-permission android:name="android.permission.INTERNET"/>Na macOS přidejte entitlement com.apple.security.network.client do obou souborů
macos/Runner/DebugProfile.entitlements i macos/Runner/Release.entitlements.
Povolte mobilní aplikace
Sekce “Povolte mobilní aplikace”Nativní aplikace neposílají hlavičku Origin ani Referer, takže požadavky z aplikace (bez ohledu
na to, jestli máte nastavené povolené weby) skončí chybou
403 origin_not_allowed, dokud v dashboardu nezapnete
Zabezpečení → Mobilní aplikace → Povolit mobilní aplikace a nekliknete na Uložit. Viz
Nastavení zabezpečení.
Požadavky z webů se dál kontrolují podle seznamu, takže přepínač ochranu vašich webových formulářů neoslabí. Přepínač je potřeba i s prázdným seznamem.
Flutter web běží v prohlížeči, který hlavičku Origin posílá vždy: místo přepínače přidejte
origin své webové aplikace mezi povolené weby.
Odesílání
Sekce “Odesílání”Klienta vytvořte jednou a sdílejte ho (top-level final, provider, vaše DI) a volejte 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': 'Dobrý den!', 'qty': 2, 'items': [{'sku': 'A-1'}]}, serviceId: 'svc_01J9…', // volitelné – null použije výchozí službu šablony);if (result.status == DeliveryStatus.held) { // Přijato – doručí se za chvíli (organizace čeká na kredity).}send vrací SendResult s messageId, status (queued nebo held) a statusToken. Když
předáte private key, konstruktor vyhodí chybu (viz Zabezpečení).
Volby konstruktoru:
final frontmail = Frontmail( publicKey: 'pk_4f2a…', apiUrl: Uri.parse('https://api.frontmail.dev'), // výchozí retry: const RetryPolicy(maxRetries: 3), // výchozí; RetryPolicy.disabled = bez opakování timeout: const Duration(seconds: 15), // na jeden pokus (výchozí) httpClient: myClient, // libovolný Client z package:http, např. cupertino_http nebo cronet_http);close() zavře HTTP klienta, kterého si SDK vytvořilo samo; klient, kterého jste předali vy,
zůstane otevřený. Každý požadavek nese hlavičku X-Frontmail-Client: frontmail-flutter/<verze>.
Parametry
Sekce “Parametry”Parametry jsou Map<String, Object?> s hodnotami JSON: String, num, bool, null, seznamy
a mapy. Hodnoty DateTime se pošlou jako řetězce ISO-8601 v UTC a objekty s metodou toJson()
(například z json_serializable nebo freezed) se zakódují přes ni:
await frontmail.send(templateId: 'tpl_contact', params: contact.toJson());Hodnoty, které do JSON převést nejde (double.nan, objekt bez toJson()), skončí chybou
invalid_template_params ještě před odesláním.
Přílohy a další volby
Sekce “Přílohy a další volby”SendOptions nese token Turnstile, přílohy, jazyk šablony, případně vlastní idempotency key
a spouštěč přerušení:
await frontmail.send( templateId: 'tpl_order', params: {'order': '1042'}, options: SendOptions( turnstileToken: token, attachments: [ Attachment.bytes(pdfBytes, filename: 'faktura.pdf', contentType: 'application/pdf'), const Attachment.upload('upl_…'), // nahráno dřív přes POST /v1/uploads ], ),);SendOptions(locale: …) pošle e-mail v některém jazyce šablony
(značka BCP 47). Použijte Localizations.localeOf(context).toLanguageTag() (např. de-AT) –
Locale.toString() používá _ a skončí chybou invalid_body. U neznámého jazyka se použije
stejný základní jazyk, jinak výchozí jazyk šablony; result.locale je použitý jazyk:
final result = await frontmail.send( templateId: 'tpl_contact', params: {'name': 'Jan'}, options: SendOptions(locale: Localizations.localeOf(context).toLanguageTag()),);print(result.locale ?? 'default'); // "de"Future v Dartu zrušit nejde, proto send i status přijímají abortTrigger: jakmile se tahle
future dokončí – třeba v dispose() – požadavek nebo čekání před dalším pokusem skončí s kódem
aborted:
final abort = Completer<void>();frontmail.send(templateId: 'tpl_contact', options: SendOptions(abortTrigger: abort.future));// pozdějiabort.complete();Stav doručení
Sekce “Stav doručení”S statusToken z výsledku send si aplikace přečte stav dané zprávy. SDK token posílá
v hlavičce X-Frontmail-Status-Token, nikdy v URL:
final status = await frontmail.status(messageId: result.messageId, statusToken: result.statusToken);print(status.status); // queued, sending, sent, delivered, bounced, … (neznámé hodnoty se zachovají)print(status.events.map((e) => e.type)); // (accepted, sent, …)Turnstile
Sekce “Turnstile”Pokud šablona vyžaduje Turnstile, zobrazte widget Cloudflaru ve web view
z webview_flutter a token předejte do send.
Site key nepotřebujete: ve výchozím stavu se použije sdílený mobilní klíč Frontmailu.
Turnstile.site(client:) ho načte z GET /v1/public-config
(s cache v paměti, takže jen jednou) a widget běží jako vložená HTML stránka s adresou
https://mobile.frontmail.dev – z té adresy se nic nestahuje.
SDK dodává jednotlivé díly, samotný widget patří do vaší aplikace (díky tomu SDK zůstává v čistém Dartu):
| API | Co dělá |
|---|---|
Turnstile.site(client:) |
site key + adresa stránky + turnstileKey, se kterým se tokeny posílají |
Turnstile.html(siteKey:, theme:, size:, action:, language:) |
stránka s widgetem; zprávy posílá do JavaScript kanálu FrontmailTurnstile |
Turnstile.parseMessage(message) |
ověří zprávu → TurnstileToken, TurnstileExpired, TurnstileError |
Turnstile.navigationAction(url:, baseUrl:, isTopFrame:) |
hlídání navigace: TurnstileAllow, TurnstileCancel, TurnstileOpenExternally |
Turnstile.resetScript |
JavaScript, který widget resetuje a vyžádá nový token |
Kompletní widget pro Android, iOS a macOS – přidejte webview_flutter a url_launcher
(flutter pub add webview_flutter url_launcher) a zkopírujte si ho do aplikace:
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;
/// Změnou hodnoty widget resetujete a získáte nový token (tokeny jdou použít jen jednou). 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); }, ) // Hlavní rámec zůstane na widgetu, odkazy z něj se otevřou v systémovém prohlížeči. ..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), );}Použití ve formuláři: počkat na token, odeslat a widget resetovat:
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; // Token jde použít jen jednou – nový si vyžádejte bez ohledu na výsledek. 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 ? 'Přijato – doručíme za chvíli.' : 'Děkujeme!'); } 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: 'Zpráva')), 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 = 'Chyba Turnstile ${code ?? ''}'; } }), ), FilledButton(onPressed: _token == null ? null : _submit, child: const Text('Odeslat')), Text(_info), ]); }}Web view je uzamčené na widget: načte jen vloženou stránku (baseUrl) a výzvu Cloudflaru, odkazy
ve widgetu se otevřou v systémovém prohlížeči a parseMessage přijme jen zprávy přesně v té
podobě, jakou posílá stránka widgetu.
Požadavky z aplikace nemají hlavičku Origin, takže jejich tokeny Frontmail ověří svým sdíleným
mobilním tajným klíčem. Vlastní klíče Turnstile k tomu nepotřebujete – se zapnutým Povolit
mobilní aplikace můžete CAPTCHA u šablon aplikace zapnout, i když organizace klíče zatím nemá.
webview_flutter nemá implementaci pro web. Na Flutter webu požadavky hlavičku Origin mají,
takže Turnstile potřebuje vlastní klíče: widget vykreslete přímo ve
stránce (třeba přes HtmlElementView) a jeho token pošlete s turnstileKey: TurnstileKey.org.
Vlastní site key
Sekce “Vlastní site key”Pokud chcete místo toho použít vlastní widget Turnstile, vytvořte si site sami:
final site = TurnstileSite.custom(siteKey: 'YOUR_TURNSTILE_SITE_KEY', baseUrl: Uri.parse('https://example.com'));Jeho turnstileKey je TurnstileKey.org, takže
SendOptions(turnstileToken: token, turnstileKey: site.turnstileKey) pošle turnstile_key: "org"
a Frontmail token ověří secret key ze Zabezpečení → Ochrana proti botům (Turnstile).
Token platí asi 5 minut a jde použít jen jednou: po každém odeslání, úspěšném i neúspěšném, widget resetujte.
Opakování a idempotence
Sekce “Opakování a idempotence”Chyby sítě, vypršení časového limitu, 5xx a 429 se automaticky opakují (ve výchozím stavu
třikrát, s exponenciálním čekáním a náhodným rozptylem). SDK respektuje hlavičku Retry-After;
pokud žádá víc než 60 sekund, chybu vyhodí hned. Ostatní odpovědi 4xx se nikdy neopakují. Všechny
pokusy jednoho odeslání sdílejí stejný idempotency key (náhodné UUID, pokud nepředáte vlastní
idempotencyKey), takže opakování e-mail nikdy nepošle dvakrát. Viz
Retry a idempotence.
Chyby
Sekce “Chyby”Všechno vyhazuje FrontmailException s vlastnostmi code, message, status (null u chyb na
straně klienta), docsUrl, details a retryAfter. Klientské kódy jsou network_error,
timeout, aborted, invalid_response a private_key_in_browser (konstanty ve
FrontmailErrorCode); kontroly isAuth, isValidation, isRateLimit, isInsufficientCredits,
isNetwork a isAborted je seskupují:
try { await frontmail.send(templateId: 'tpl_contact', params: params);} on FrontmailException catch (e) { if (e.isRateLimit) { show('Příliš mnoho zpráv – zkuste to znovu za ${e.retryAfter?.inSeconds ?? 60} s.'); } else if (e.code == FrontmailErrorCode.originNotAllowed) { assert(false, 'V dashboardu zapněte Zabezpečení → Mobilní aplikace → Povolit mobilní aplikace.'); } else { show(e.message); }}Jak reagovat na jednotlivé kódy, popisuje Zpracování chyb.
Zabezpečení
Sekce “Zabezpečení”- Do aplikace patří jen public key (
pk_…). Cokoli v balíčku aplikace se dá vytáhnout, takže private key v ní je jako zveřejněný. Klíče začínajícísk_SDK odmítne s chybouprivate_key_in_browser. Viz Public key a private key. - Se zapnutým Povolit mobilní aplikace přijmeme požadavky bez hlavičky
Originodkudkoli – poslat je může jakýkoli skript, který zná váš public key. Spolehněte se na limit na IP adresu, Turnstile u všech šablon, které aplikace používá, a na seznam blokovaných hodnot. - Šablona, která bere příjemce z parametrů (třeba potvrzení na adresu, kterou uživatel zadal), je uzamčená: potřebuje Turnstile, pevného odesílatele, krátké parametry bez HTML a odkazy jen na vaše domény. Viz Povolené odkazy a zámek obsahu.
Soukromí
Sekce “Soukromí”SDK uživatele nesleduje a samo žádná data nesbírá. To, co posílají vaše šablony (například e-mailová adresa), jsou data, která sbírá vaše aplikace: uveďte je v údajích o soukromí v App Storu a ve formuláři Zabezpečení dat na Google Play.