Preskočiť na obsah

Flutter a Dart

Balík frontmail umožní aplikácii vo Flutteri odosielať e-maily cez vaše šablóny s verejným kľúčom – bez vlastného backendu. Je napísaný čisto v Darte (jedinou závislosťou je package:http), takže rovnaký kód beží na Androide, iOS, macOS, Windows, Linuxe, na webe aj v programoch v Darte.

Požiadavky: Dart 3.5+ (Flutter 3.24+).

Terminal window
flutter pub add frontmail

Balík: pub.dev/packages/frontmail. Zdrojový kód a vydania: github.com/frontmail-dev/frontmail-flutter.

Release buildy pre Android potrebujú oprávnenie INTERNET v android/app/src/main/AndroidManifest.xml (Flutter ho sám pridáva len do debug buildov):

<uses-permission android:name="android.permission.INTERNET"/>

Na macOS pridajte entitlement com.apple.security.network.client do oboch súborov macos/Runner/DebugProfile.entitlements aj macos/Runner/Release.entitlements.

Natívne aplikácie neposielajú hlavičku Origin ani Referer, takže požiadavky z aplikácie (bez ohľadu na to, či máte nastavené povolené weby) skončia chybou 403 origin_not_allowed, kým v dashboarde nezapnete Zabezpečenie → Mobilné aplikácie → Povoliť mobilné aplikácie a nekliknete na Uložiť. Pozrite Nastavenia zabezpečenia.

Požiadavky z webov sa naďalej kontrolujú podľa zoznamu, takže prepínač ochranu vašich webových formulárov neoslabí. Prepínač je potrebný aj pri prázdnom zozname.

Flutter web beží v prehliadači, ktorý hlavičku Origin posiela vždy: namiesto prepínača pridajte origin svojej webovej aplikácie medzi povolené weby.

Klienta vytvorte raz a zdieľajte ho (top-level final, provider, vaše DI) a volajte 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ý deň!', 'qty': 2, 'items': [{'sku': 'A-1'}]},
serviceId: 'svc_01J9…', // voliteľné – null použije predvolenú službu šablóny
);
if (result.status == DeliveryStatus.held) {
// Prijaté – doručí sa o chvíľu (organizácia čaká na kredity).
}

send vracia SendResult s messageId, status (queued alebo held) a statusToken. Keď odovzdáte súkromný kľúč, konštruktor vyhodí chybu (pozrite Zabezpečenie).

Voľby konštruktora:

final frontmail = Frontmail(
publicKey: 'pk_4f2a…',
apiUrl: Uri.parse('https://api.frontmail.dev'), // predvolené
retry: const RetryPolicy(maxRetries: 3), // predvolené; RetryPolicy.disabled = bez opakovania
timeout: const Duration(seconds: 15), // na jeden pokus (predvolené)
httpClient: myClient, // ľubovoľný Client z package:http, napr. cupertino_http alebo cronet_http
);

close() zatvorí HTTP klienta, ktorého si SDK vytvorilo samo; klient, ktorého ste odovzdali vy, zostane otvorený. Každá požiadavka nesie hlavičku X-Frontmail-Client: frontmail-flutter/<verzia>.

Parametre sú Map<String, Object?> s hodnotami JSON: String, num, bool, null, zoznamy a mapy. Hodnoty DateTime sa pošlú ako reťazce ISO-8601 v UTC a objekty s metódou toJson() (napríklad z json_serializable alebo freezed) sa zakódujú cez ňu:

await frontmail.send(templateId: 'tpl_contact', params: contact.toJson());

Hodnoty, ktoré sa do JSON previesť nedajú (double.nan, objekt bez toJson()), skončia chybou invalid_template_params ešte pred odoslaním.

SendOptions nesie token Turnstile, prílohy, jazyk šablóny, prípadne vlastný idempotency key a spúšťač prerušenia:

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_…'), // nahrané skôr cez POST /v1/uploads
],
),
);

SendOptions(locale: …) pošle e-mail v niektorom jazyku šablóny (značka BCP 47). Použite Localizations.localeOf(context).toLanguageTag() (napr. de-AT) – Locale.toString() používa _ a odmietne sa ako invalid_body. Pri neznámom jazyku sa použije rovnaký základný jazyk a potom predvolený jazyk šablóny; 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 Darte sa zrušiť nedá, preto send aj status prijímajú abortTrigger: keď sa táto future dokončí – napríklad v dispose() – požiadavka alebo čakanie pred ďalším pokusom skončí s kódom aborted:

final abort = Completer<void>();
frontmail.send(templateId: 'tpl_contact', options: SendOptions(abortTrigger: abort.future));
// neskôr
abort.complete();

So statusToken z výsledku send si aplikácia prečíta stav danej správy. SDK token posiela v hlavičke 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áme hodnoty sa zachovajú)
print(status.events.map((e) => e.type)); // (accepted, sent, …)

Ak šablóna vyžaduje Turnstile, zobrazte widget Cloudflare vo web view z webview_flutter a token odovzdajte do send. Site key nepotrebujete: v predvolenom stave sa použije zdieľaný mobilný kľúč Frontmailu. Turnstile.site(client:) ho načíta z GET /v1/public-config (s cache v pamäti, takže len raz) a widget beží ako vložená HTML stránka s adresou https://mobile.frontmail.dev – z tej adresy sa nič nesťahuje.

SDK dodáva jednotlivé diely, samotný widget patrí do vašej aplikácie (vďaka tomu SDK zostáva v čistom Darte):

API Čo robí
Turnstile.site(client:) site key + adresa stránky + turnstileKey, s ktorým sa tokeny posielajú
Turnstile.html(siteKey:, theme:, size:, action:, language:) stránka s widgetom; správy posiela do JavaScript kanála FrontmailTurnstile
Turnstile.parseMessage(message) overí správu → TurnstileToken, TurnstileExpired, TurnstileError
Turnstile.navigationAction(url:, baseUrl:, isTopFrame:) stráženie navigácie: TurnstileAllow, TurnstileCancel, TurnstileOpenExternally
Turnstile.resetScript JavaScript, ktorý widget resetuje a vyžiada nový token

Kompletný widget pre Android, iOS a macOS – pridajte webview_flutter a url_launcher (flutter pub add webview_flutter url_launcher) a skopírujte si ho do aplikácie:

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;
/// Zmenou hodnoty widget resetujete a získate nový token (tokeny sa dajú použiť len raz).
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 zostane na widgete, odkazy z neho sa otvoria v systémovom prehliadač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žitie vo formulári: počkať na token, odoslať a widget resetovať:

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 sa dá použiť len raz – nový si vyžiadajte bez ohľadu na výsledok.
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 ? 'Prijaté – doručíme o chvíľu.' : 'Ďakujeme!');
} 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: 'Sprá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('Odoslať')),
Text(_info),
]);
}
}

Web view je uzamknuté na widget: načíta len vloženú stránku (baseUrl) a výzvu Cloudflaru, odkazy vo widgete sa otvoria v systémovom prehliadači a parseMessage prijme len správy presne v tej podobe, akú posiela stránka widgetu.

Požiadavky z aplikácie nemajú hlavičku Origin, takže ich tokeny Frontmail overí svojím zdieľaným mobilným tajným kľúčom. Vlastné kľúče Turnstile na to nepotrebujete – so zapnutým Povoliť mobilné aplikácie môžete CAPTCHA pri šablónach aplikácie zapnúť, aj keď organizácia kľúče zatiaľ nemá.

webview_flutter nemá implementáciu pre web. Na Flutter webe požiadavky hlavičku Origin majú, takže Turnstile potrebuje vlastné kľúče: widget vykreslite priamo v stránke (napríklad cez HtmlElementView) a jeho token pošlite s turnstileKey: TurnstileKey.org.

Ak chcete namiesto toho použiť vlastný widget Turnstile, vytvorte 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 overí secret key zo Zabezpečenie → Ochrana pred botmi (Turnstile).

Token platí približne 5 minút a dá sa použiť len raz: po každom odoslaní, úspešnom aj neúspešnom, widget resetujte.

Sieťové chyby, vypršanie časového limitu, 5xx a 429 sa automaticky opakujú (v predvolenom stave trikrát, s exponenciálnym čakaním a náhodným rozptylom). SDK rešpektuje hlavičku Retry-After; ak žiada viac než 60 sekúnd, chybu vyhodí hneď. Ostatné odpovede 4xx sa nikdy neopakujú. Všetky pokusy jedného odoslania zdieľajú rovnaký idempotency key (náhodné UUID, ak neodovzdáte vlastný idempotencyKey), takže opakovanie e-mail nikdy nepošle dvakrát. Pozrite Opakovanie a idempotencia.

Všetko vyhadzuje FrontmailException s vlastnosťami code, message, status (null pri chybách na strane klienta), docsUrl, details a retryAfter. Klientske kódy sú network_error, timeout, aborted, invalid_response a private_key_in_browser (konštanty vo FrontmailErrorCode); kontroly isAuth, isValidation, isRateLimit, isInsufficientCredits, isNetwork a isAborted ich zoskupujú:

try {
await frontmail.send(templateId: 'tpl_contact', params: params);
} on FrontmailException catch (e) {
if (e.isRateLimit) {
show('Príliš veľa správ – skúste to znova o ${e.retryAfter?.inSeconds ?? 60} s.');
} else if (e.code == FrontmailErrorCode.originNotAllowed) {
assert(false, 'V dashboarde zapnite Zabezpečenie → Mobilné aplikácie → Povoliť mobilné aplikácie.');
} else {
show(e.message);
}
}

Ako reagovať na jednotlivé kódy, opisuje Spracovanie chýb.

  • Do aplikácie patrí len verejný kľúč (pk_…). Čokoľvek v balíku aplikácie sa dá vytiahnuť, takže súkromný kľúč v nej je ako zverejnený. Kľúče začínajúce sk_ SDK odmietne s chybou private_key_in_browser. Pozrite Verejný a súkromný kľúč.
  • So zapnutým Povoliť mobilné aplikácie prijmeme požiadavky bez hlavičky Origin odkiaľkoľvek – poslať ich môže akýkoľvek skript, ktorý pozná váš verejný kľúč. Spoľahnite sa na limit na IP adresu, Turnstile pri všetkých šablónach, ktoré aplikácia používa, a na zoznam blokovaných hodnôt.
  • Šablóna, ktorá berie príjemcu z parametrov (napríklad potvrdenie na adresu, ktorú používateľ zadal), je uzamknutá: potrebuje Turnstile, pevného odosielateľa, krátke parametre bez HTML a odkazy len na vaše domény. Pozrite Povolené odkazy a zámok obsahu.

SDK používateľov nesleduje a samo žiadne údaje nezbiera. To, čo posielajú vaše šablóny (napríklad e-mailová adresa), sú údaje, ktoré zbiera vaša aplikácia: uveďte ich v údajoch o súkromí v App Store a vo formulári Bezpečnosť údajov v Google Play.