Aller au contenu

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

Fenêtre de terminal
flutter pub add frontmail

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

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

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.

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

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

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.

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.

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.

  • 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 par sk_ avec l’erreur private_key_in_browser. Voir Clés publiques et privées.
  • Avec Autoriser les applications mobiles activé, les requêtes sans en-tête Origin sont 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.

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.