Flutter & Dart
Le package frontmail permet à une application Flutter d’envoyer des e-mails à partir de vos
modèles avec la clé publique – sans backend à vous. Il est écrit en Dart pur (sa seule dépendance
est package:http) : le même code tourne donc sur Android, iOS, macOS, Windows, Linux, sur le web et
dans des programmes Dart.
Prérequis : Dart 3.5+ (Flutter 3.24+).
Installation
Section intitulée « Installation »flutter pub add frontmailPackage : pub.dev/packages/frontmail. Code source et versions : github.com/frontmail-dev/frontmail-flutter.
Sur Android, les builds de release ont besoin de la permission INTERNET dans
android/app/src/main/AndroidManifest.xml (Flutter ne l’ajoute qu’aux builds de debug) :
<uses-permission android:name="android.permission.INTERNET"/>Sur macOS, ajoutez l’entitlement com.apple.security.network.client à la fois dans
macos/Runner/DebugProfile.entitlements et dans macos/Runner/Release.entitlements.
Autoriser les applications mobiles
Section intitulée « Autoriser les applications mobiles »Les applications natives n’envoient pas d’en-tête Origin ni Referer : les requêtes de
l’application sont donc refusées (que vous ayez défini des sites autorisés
ou non) avec 403 origin_not_allowed tant que vous
n’avez pas activé Sécurité → Applications mobiles → Autoriser les applications mobiles dans le
tableau de bord et cliqué sur Enregistrer. Voir
Paramètres de sécurité.
Les requêtes provenant de sites web restent comparées à la liste : l’option n’affaiblit donc pas la protection de vos formulaires web. L’option est nécessaire même avec une liste vide.
Flutter web s’exécute dans le navigateur, qui envoie toujours un Origin : ajoutez plutôt
l’origine de votre application web aux sites autorisés – l’option
n’y est pas nécessaire.
Créez le client une seule fois et partagez-le (un final de premier niveau, un provider, votre
injection de dépendances), puis appelez 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': 'Bonjour !', 'qty': 2, 'items': [{'sku': 'A-1'}]}, serviceId: 'svc_01J9…', // facultatif – null utilise le service par défaut du modèle);if (result.status == DeliveryStatus.held) { // Bien reçu – il sera transmis sous peu (l’organisation attend des crédits).}send renvoie un SendResult avec messageId, status (queued ou held) et statusToken. Le
constructeur lève une erreur si vous passez une clé privée (voir Sécurité).
Options du constructeur :
final frontmail = Frontmail( publicKey: 'pk_4f2a…', apiUrl: Uri.parse('https://api.frontmail.dev'), // par défaut retry: const RetryPolicy(maxRetries: 3), // par défaut ; RetryPolicy.disabled = aucune nouvelle tentative timeout: const Duration(seconds: 15), // par tentative (par défaut) httpClient: myClient, // n’importe quel Client package:http, p. ex. cupertino_http ou cronet_http);close() ferme le client HTTP que le SDK a créé lui-même ; un client que vous avez passé reste
ouvert. Chaque requête porte l’en-tête X-Frontmail-Client: frontmail-flutter/<version>.
Paramètres
Section intitulée « Paramètres »Les paramètres sont une Map<String, Object?> de valeurs JSON : String, num, bool, null,
listes et maps. Les valeurs DateTime sont envoyées sous forme de chaînes ISO-8601 en UTC, et les
objets dotés d’une méthode toJson() (issus par exemple de json_serializable ou de freezed) sont
encodés avec elle :
await frontmail.send(templateId: 'tpl_contact', params: contact.toJson());Les valeurs qui ne sont pas du JSON (double.nan, un objet sans toJson()) lèvent
invalid_template_params avant tout envoi.
Pièces jointes et autres options
Section intitulée « Pièces jointes et autres options »SendOptions transporte le jeton Turnstile, les pièces jointes, la langue du modèle, une clé
d’idempotence explicite et un déclencheur d’annulation :
await frontmail.send( templateId: 'tpl_order', params: {'order': '1042'}, options: SendOptions( turnstileToken: token, attachments: [ Attachment.bytes(pdfBytes, filename: 'facture.pdf', contentType: 'application/pdf'), const Attachment.upload('upl_…'), // uploadé au préalable via POST /v1/uploads ], ),);SendOptions(locale: …) envoie l’e-mail dans une langue du modèle
(une balise BCP 47). Utilisez Localizations.localeOf(context).toLanguageTag() (p. ex. de-AT) –
Locale.toString() utilise _ et est refusé avec invalid_body. Une langue inconnue se rabat sur
la même langue de base, puis sur la langue par défaut du modèle ; result.locale est la langue
utilisée :
final result = await frontmail.send( templateId: 'tpl_contact', params: {'name': 'Jan'}, options: SendOptions(locale: Localizations.localeOf(context).toLanguageTag()),);print(result.locale ?? 'default'); // "de"Les futures Dart ne peuvent pas être annulées : send et status acceptent donc à la place un
abortTrigger. Dès que cette future se termine – par exemple dans dispose() –, la requête ou
l’attente avant une nouvelle tentative s’arrête avec le code aborted :
final abort = Completer<void>();frontmail.send(templateId: 'tpl_contact', options: SendOptions(abortTrigger: abort.future));// plus tardabort.complete();Statut de livraison
Section intitulée « Statut de livraison »Le statusToken renvoyé par send permet à l’application de lire le statut de ce message. Le SDK
l’envoie dans l’en-tête X-Frontmail-Status-Token, jamais dans l’URL :
final status = await frontmail.status(messageId: result.messageId, statusToken: result.statusToken);print(status.status); // queued, sending, sent, delivered, bounced, … (les valeurs inconnues sont conservées)print(status.events.map((e) => e.type)); // (accepted, sent, …)Turnstile
Section intitulée « Turnstile »Si le modèle exige Turnstile, affichez le widget Cloudflare dans une web
view webview_flutter et passez le jeton à send.
Vous n’avez pas besoin de clé de site : par défaut, la clé mobile partagée de Frontmail est
utilisée. Turnstile.site(client:) la récupère depuis
GET /v1/public-config (mise en cache en mémoire, elle
n’est donc chargée qu’une fois), et le widget s’exécute dans une page HTML intégrée à l’URL
https://mobile.frontmail.dev – rien n’est chargé depuis cette adresse.
Le SDK vous fournit les briques, le widget vit dans votre application (le SDK lui-même reste ainsi en Dart pur) :
| API | Rôle |
|---|---|
Turnstile.site(client:) |
clé de site + URL de la page + la turnstileKey avec laquelle envoyer les jetons |
Turnstile.html(siteKey:, theme:, size:, action:, language:) |
la page du widget ; elle envoie ses messages au canal JavaScript FrontmailTurnstile |
Turnstile.parseMessage(message) |
valide un message → TurnstileToken, TurnstileExpired, TurnstileError |
Turnstile.navigationAction(url:, baseUrl:, isTopFrame:) |
garde de navigation : TurnstileAllow, TurnstileCancel, TurnstileOpenExternally |
Turnstile.resetScript |
JavaScript qui réinitialise le widget pour obtenir un nouveau jeton |
Un widget complet pour Android, iOS et macOS – ajoutez webview_flutter et url_launcher
(flutter pub add webview_flutter url_launcher) et copiez-le dans votre application :
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;
/// Changez la valeur pour réinitialiser le widget et obtenir un nouveau jeton (les jetons ne servent qu’une fois). 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); }, ) // Le cadre principal reste sur le widget ; ses liens s’ouvrent dans le navigateur du système. ..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), );}Utilisez-le dans un formulaire : attendez un jeton, envoyez, puis réinitialisez le 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 jeton ne sert qu’une fois – demandez-en un nouveau quel que soit le résultat. 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 ? 'Bien reçu – il sera transmis sous peu.' : 'Merci !'); } 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: 'Message')), 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 = 'Erreur Turnstile ${code ?? ''}'; } }), ), FilledButton(onPressed: _token == null ? null : _submit, child: const Text('Envoyer')), Text(_info), ]); }}La web view est verrouillée sur le widget : elle ne charge que la page intégrée (baseUrl) et le
challenge Cloudflare, les liens du widget s’ouvrent dans le navigateur du système et
parseMessage n’accepte que les messages ayant exactement la forme de ceux que publie la page du
widget.
Les requêtes de l’application n’ont pas d’en-tête Origin : Frontmail vérifie donc leurs jetons avec
sa clé secrète mobile partagée. Vous n’avez pas besoin de vos propres clés Turnstile pour cela – avec
Autoriser les applications mobiles activé, vous pouvez activer le CAPTCHA sur les modèles de
l’application même si votre organisation n’a pas encore de clés.
webview_flutter n’a pas d’implémentation web. Sur Flutter web, les requêtes ont bien un
Origin : Turnstile y nécessite donc vos propres clés. Affichez le widget
dans la page (par exemple via HtmlElementView) et envoyez son jeton avec
turnstileKey: TurnstileKey.org.
Votre propre clé de site
Section intitulée « Votre propre clé de site »Pour utiliser plutôt votre propre widget Turnstile, créez vous-même le site :
final site = TurnstileSite.custom(siteKey: 'YOUR_TURNSTILE_SITE_KEY', baseUrl: Uri.parse('https://example.com'));Sa turnstileKey vaut TurnstileKey.org : SendOptions(turnstileToken: token, turnstileKey: site.turnstileKey)
envoie donc turnstile_key: "org" et Frontmail vérifie le jeton avec la clé secrète de
Sécurité → Protection contre les bots (Turnstile).
Un jeton est valable environ 5 minutes et ne peut être utilisé qu’une fois : réinitialisez le widget après chaque envoi, réussi ou non.
Nouvelles tentatives et idempotence
Section intitulée « Nouvelles tentatives et idempotence »Les erreurs réseau, les délais dépassés, les 5xx et les 429 sont relancés automatiquement
(3 nouvelles tentatives par défaut, backoff exponentiel avec jitter complet). Un en-tête
Retry-After est respecté ; s’il demande plus de 60 secondes, l’erreur est levée immédiatement. Les
autres réponses 4xx ne sont jamais relancées. Toutes les tentatives d’un même envoi réutilisent la
même clé d’idempotence (un UUID aléatoire, sauf si vous passez idempotencyKey) : une nouvelle
tentative n’envoie donc jamais l’e-mail deux fois. Voir
Nouvelles tentatives et idempotence.
Tout lève une FrontmailException avec code, message, status (null pour les erreurs côté
client), docsUrl, details et retryAfter. Les codes côté client sont network_error,
timeout, aborted, invalid_response et private_key_in_browser (constantes dans
FrontmailErrorCode) ; les tests isAuth, isValidation, isRateLimit, isInsufficientCredits,
isNetwork et isAborted les regroupent :
try { await frontmail.send(templateId: 'tpl_contact', params: params);} on FrontmailException catch (e) { if (e.isRateLimit) { show('Trop de messages – réessayez dans ${e.retryAfter?.inSeconds ?? 60} s.'); } else if (e.code == FrontmailErrorCode.originNotAllowed) { assert(false, 'Activez Sécurité → Applications mobiles → Autoriser les applications mobiles dans le tableau de bord.'); } else { show(e.message); }}La façon de traiter chaque code est décrite dans Gestion des erreurs.
Sécurité
Section intitulée « Sécurité »- Ne placez jamais que la clé publique (
pk_…) dans une application. Tout ce qui se trouve dans un bundle d’application peut être extrait : une clé privée y serait comme publiée. Le SDK refuse les clés commençant parsk_avec l’erreurprivate_key_in_browser. Voir Clés publiques et privées. - Avec Autoriser les applications mobiles activé, les requêtes sans en-tête
Originsont acceptées de n’importe où – tout script qui connaît votre clé publique peut les envoyer. Appuyez-vous sur le rate limit par IP, sur Turnstile pour chaque modèle utilisé par l’application et sur la liste de blocage. - Un modèle qui prend son destinataire dans les paramètres (par exemple une confirmation envoyée à l’adresse saisie par l’utilisateur) est verrouillé : il exige Turnstile, un expéditeur fixe, des paramètres courts en texte brut et des liens uniquement vers vos domaines. Voir Liens autorisés et verrouillage du contenu.
Confidentialité
Section intitulée « Confidentialité »Le SDK ne suit pas les utilisateurs et ne collecte aucune donnée pour lui-même. Ce que vos modèles envoient (comme une adresse e-mail) constitue des données collectées par votre application : déclarez-les dans les informations de confidentialité de l’App Store et dans la section « Sécurité des données » de Google Play.