Salta ai contenuti

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

Finestra del terminale
flutter pub add frontmail

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

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

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.

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ù tardi
abort.complete();

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

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.

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.

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.

  • 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 con sk_ con l’errore private_key_in_browser. Vedi Chiavi pubbliche e private.
  • Con Consenti app mobile attivo, le richieste senza header Origin vengono 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.

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.