Přeskočit na obsah

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

Terminál
flutter pub add frontmail

Balíč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.

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 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ěji
abort.complete();

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

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.

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.

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.

  • 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 chybou private_key_in_browser. Viz Public key a private key.
  • Se zapnutým Povolit mobilní aplikace přijmeme požadavky bez hlavičky Origin odkudkoli – 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.

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.