Flutter e Dart
Il pacchetto frontmail permette a un’app Flutter di inviare email tramite i tuoi modelli con la
chiave pubblica – senza bisogno di un tuo backend. È scritto in Dart puro (l’unica dipendenza è
package:http), quindi lo stesso codice gira su Android, iOS, macOS, Windows, Linux, sul web e nei
programmi Dart.
Requisiti: Dart 3.5+ (Flutter 3.24+).
Installazione
Sezione intitolata “Installazione”flutter pub add frontmailPacchetto: pub.dev/packages/frontmail. Codice sorgente e release: github.com/frontmail-dev/frontmail-flutter.
Le build di release su Android richiedono il permesso INTERNET in
android/app/src/main/AndroidManifest.xml (Flutter lo aggiunge solo alle build di debug):
<uses-permission android:name="android.permission.INTERNET"/>Su macOS aggiungi l’entitlement com.apple.security.network.client sia a
macos/Runner/DebugProfile.entitlements sia a macos/Runner/Release.entitlements.
Consenti app mobile
Sezione intitolata “Consenti app mobile”Le app native non inviano l’header Origin né Referer, quindi le richieste dall’app vengono
rifiutate (che tu abbia impostato o meno dei siti consentiti) con
403 origin_not_allowed finché non attivi
Sicurezza → App mobile → Consenti app mobile nella dashboard e fai clic su Salva. Vedi
Impostazioni di sicurezza.
Le richieste dai siti web vengono comunque confrontate con la lista, quindi l’interruttore non indebolisce la protezione dei tuoi moduli web. L’interruttore è necessario anche con la lista vuota.
Flutter web gira nel browser, che invia sempre Origin: in questo caso aggiungi l’origine della
tua web app ai siti consentiti – lì l’interruttore non serve.
Crea il client una sola volta e condividilo (un final di primo livello, un provider, la tua
dependency injection) e chiama send:
import 'package:frontmail/frontmail.dart';
final frontmail = Frontmail(publicKey: 'pk_4f2a…');
final result = await frontmail.send( templateId: 'tpl_contact', params: {'email': 'giulia@example.com', 'message': 'Ciao!', 'qty': 2, 'items': [{'sku': 'A-1'}]}, serviceId: 'svc_01J9…', // facoltativo – null usa il servizio predefinito del modello);if (result.status == DeliveryStatus.held) { // Ricevuto – verrà recapitato a breve (l’organizzazione è in attesa di crediti).}send restituisce SendResult con messageId, status (queued o held) e statusToken. Il
costruttore lancia un errore quando passi una chiave privata (vedi Sicurezza).
Opzioni del costruttore:
final frontmail = Frontmail( publicKey: 'pk_4f2a…', apiUrl: Uri.parse('https://api.frontmail.dev'), // predefinito retry: const RetryPolicy(maxRetries: 3), // predefinito; RetryPolicy.disabled = nessun tentativo timeout: const Duration(seconds: 15), // per tentativo (predefinito) httpClient: myClient, // qualsiasi Client di package:http, ad es. cupertino_http o cronet_http);close() chiude il client HTTP creato dall’SDK stesso; un client che hai passato tu resta aperto.
Ogni richiesta porta l’header X-Frontmail-Client: frontmail-flutter/<version>.
Parametri
Sezione intitolata “Parametri”I parametri sono una Map<String, Object?> di valori JSON: String, num, bool, null, liste e
mappe. I valori DateTime vengono inviati come stringhe ISO-8601 in UTC, e gli oggetti con un
metodo toJson() (per esempio da json_serializable o freezed) vengono codificati con quello:
await frontmail.send(templateId: 'tpl_contact', params: contact.toJson());I valori che non sono JSON (double.nan, un oggetto senza toJson()) lanciano
invalid_template_params prima che venga inviato qualsiasi cosa.
Allegati e altre opzioni
Sezione intitolata “Allegati e altre opzioni”SendOptions contiene il token Turnstile, gli allegati, la lingua del modello, una chiave di
idempotenza esplicita e un trigger di interruzione:
await frontmail.send( templateId: 'tpl_order', params: {'order': '1042'}, options: SendOptions( turnstileToken: token, attachments: [ Attachment.bytes(pdfBytes, filename: 'invoice.pdf', contentType: 'application/pdf'), const Attachment.upload('upl_…'), // caricato in precedenza tramite POST /v1/uploads ], ),);SendOptions(locale: …) invia l’email in una lingua del modello
(un tag BCP 47). Usa Localizations.localeOf(context).toLanguageTag() (ad es. de-AT) –
Locale.toString() usa _ e viene rifiutato come invalid_body. Una lingua sconosciuta ripiega
sulla stessa lingua di base, poi sulla lingua predefinita del modello; result.locale è la lingua
usata:
final result = await frontmail.send( templateId: 'tpl_contact', params: {'name': 'Jan'}, options: SendOptions(locale: Localizations.localeOf(context).toLanguageTag()),);print(result.locale ?? 'default'); // "de"I future di Dart non si possono annullare, quindi send e status accettano invece un
abortTrigger: quando quel future si completa – per esempio in dispose() – la richiesta o l’attesa
prima di un nuovo tentativo si interrompe con il codice aborted:
final abort = Completer<void>();frontmail.send(templateId: 'tpl_contact', options: SendOptions(abortTrigger: abort.future));// più tardiabort.complete();Stato di recapito
Sezione intitolata “Stato di recapito”Lo statusToken restituito da send permette all’app di leggere lo stato di quel messaggio. L’SDK
lo invia nell’header X-Frontmail-Status-Token, mai nell’URL:
final status = await frontmail.status(messageId: result.messageId, statusToken: result.statusToken);print(status.status); // queued, sending, sent, delivered, bounced, … (i valori sconosciuti vengono mantenuti)print(status.events.map((e) => e.type)); // (accepted, sent, …)Turnstile
Sezione intitolata “Turnstile”Se il modello richiede Turnstile, mostra il widget di Cloudflare in una
web view di webview_flutter e passa il token a send.
Non ti serve una chiave del sito: per impostazione predefinita viene usata la chiave mobile
condivisa di Frontmail. Turnstile.site(client:) la recupera da
GET /v1/public-config (con cache in memoria, quindi viene
caricata una volta sola) e il widget gira come pagina HTML inline con l’URL
https://mobile.frontmail.dev – da quell’indirizzo non viene caricato nulla.
L’SDK ti fornisce i pezzi, il widget vive nella tua app (così l’SDK resta Dart puro):
| API | Cosa fa |
|---|---|
Turnstile.site(client:) |
chiave del sito + URL della pagina + il turnstileKey con cui inviare i token |
Turnstile.html(siteKey:, theme:, size:, action:, language:) |
la pagina del widget; invia messaggi al canale JavaScript FrontmailTurnstile |
Turnstile.parseMessage(message) |
valida un messaggio → TurnstileToken, TurnstileExpired, TurnstileError |
Turnstile.navigationAction(url:, baseUrl:, isTopFrame:) |
controllo della navigazione: TurnstileAllow, TurnstileCancel, TurnstileOpenExternally |
Turnstile.resetScript |
JavaScript che reimposta il widget per ottenere un nuovo token |
Un widget completo per Android, iOS e macOS – aggiungi webview_flutter e url_launcher
(flutter pub add webview_flutter url_launcher) e copialo nella tua 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;
/// Cambia il valore per reimpostare il widget e ottenere un nuovo token (i token sono monouso). 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); }, ) // Il frame principale resta sul widget; i link al suo interno si aprono nel browser di sistema. ..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), );}Usalo in un modulo – attendi un token, invia, poi reimposta il widget:
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; // Un token vale una sola volta – chiedine uno nuovo qualunque sia l’esito. 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 ? 'Ricevuto – verrà recapitato a breve.' : 'Grazie!'); } 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: 'Email')), TextField(controller: _message, decoration: const InputDecoration(labelText: 'Messaggio')), 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 = 'Errore Turnstile ${code ?? ''}'; } }), ), FilledButton(onPressed: _token == null ? null : _submit, child: const Text('Invia')), Text(_info), ]); }}La web view è vincolata al widget: carica solo la pagina inline (baseUrl) e la challenge di
Cloudflare, i link all’interno del widget si aprono nel browser di sistema e parseMessage accetta
solo messaggi con esattamente la forma che invia la pagina del widget.
Le richieste dall’app non hanno l’header Origin, quindi Frontmail verifica i loro token con la
sua chiave segreta mobile condivisa. Per questo non ti servono chiavi Turnstile tue – con
Consenti app mobile attivo puoi attivare il CAPTCHA sui modelli dell’app anche se la tua
organizzazione non ha ancora chiavi.
webview_flutter non ha un’implementazione per il web. Su Flutter web le richieste hanno invece
un Origin, quindi Turnstile richiede le tue chiavi: mostra il widget
nella pagina (per esempio tramite HtmlElementView) e invia il suo token con
turnstileKey: TurnstileKey.org.
La tua chiave del sito
Sezione intitolata “La tua chiave del sito”Per usare invece il tuo widget Turnstile, crea tu il sito:
final site = TurnstileSite.custom(siteKey: 'YOUR_TURNSTILE_SITE_KEY', baseUrl: Uri.parse('https://example.com'));Il suo turnstileKey è TurnstileKey.org, quindi
SendOptions(turnstileToken: token, turnstileKey: site.turnstileKey) invia turnstile_key: "org"
e Frontmail verifica il token con la chiave segreta impostata in
Sicurezza → Protezione dai bot (Turnstile).
Un token è valido per circa 5 minuti e può essere usato una sola volta: reimposta il widget dopo ogni invio, riuscito o no.
Tentativi e idempotenza
Sezione intitolata “Tentativi e idempotenza”Gli errori di rete, i timeout, i 5xx e i 429 vengono ripetuti automaticamente (3 tentativi per
impostazione predefinita, backoff esponenziale con full jitter). L’header Retry-After viene
rispettato; se chiede più di 60 secondi, l’errore viene lanciato subito. Le altre risposte 4xx non
vengono mai ripetute. Tutti i tentativi di un invio riutilizzano la stessa chiave di idempotenza (un
UUID casuale, a meno che tu non passi idempotencyKey), quindi un nuovo tentativo non invia mai
l’email due volte. Vedi Tentativi e idempotenza.
Tutto lancia FrontmailException con code, message, status (null per gli errori lato
client), docsUrl, details e retryAfter. I codici lato client sono network_error, timeout,
aborted, invalid_response e private_key_in_browser (costanti in FrontmailErrorCode); i
controlli isAuth, isValidation, isRateLimit, isInsufficientCredits, isNetwork e
isAborted li raggruppano:
try { await frontmail.send(templateId: 'tpl_contact', params: params);} on FrontmailException catch (e) { if (e.isRateLimit) { show('Troppi messaggi – riprova tra ${e.retryAfter?.inSeconds ?? 60} s.'); } else if (e.code == FrontmailErrorCode.originNotAllowed) { assert(false, 'Nella dashboard attiva Sicurezza → App mobile → Consenti app mobile.'); } else { show(e.message); }}Come gestire ciascun codice è spiegato in Gestione degli errori.
Sicurezza
Sezione intitolata “Sicurezza”- In un’app metti sempre e solo la chiave pubblica (
pk_…). Tutto ciò che sta nel pacchetto di un’app può essere estratto, quindi una chiave privata lì dentro è come se fosse pubblicata. L’SDK rifiuta le chiavi che iniziano consk_con l’erroreprivate_key_in_browser. Vedi Chiavi pubbliche e private. - Con Consenti app mobile attivo, le richieste senza header
Originvengono accettate da qualsiasi parte – qualunque script che conosca la tua chiave pubblica può inviarle. Affidati al rate limit per IP, a Turnstile su ogni modello usato dall’app e alla lista dei bloccati. - Un modello che prende il destinatario dai parametri (per esempio una conferma inviata all’indirizzo digitato dall’utente) è bloccato: richiede Turnstile, un mittente fisso, parametri brevi e senza markup e link solo verso i tuoi domini. Vedi Allowlist dei link e blocco del contenuto.
Privacy
Sezione intitolata “Privacy”L’SDK non traccia gli utenti e non raccoglie dati propri. Ciò che inviano i tuoi modelli (come un indirizzo email) sono dati raccolti dalla tua app: dichiarali nei dettagli sulla privacy dell’App Store e nel modulo sulla sicurezza dei dati di Google Play.