Flutter y Dart
El paquete frontmail permite que una app de Flutter envíe correos a través de tus plantillas con
la clave pública, sin necesidad de un backend propio. Está escrito en Dart puro (su única
dependencia es package:http), así que el mismo código funciona en Android, iOS, macOS, Windows,
Linux, la web y en programas Dart.
Requisitos: Dart 3.5+ (Flutter 3.24+).
Instalación
Sección titulada «Instalación»flutter pub add frontmailPaquete: pub.dev/packages/frontmail. Código fuente y versiones: github.com/frontmail-dev/frontmail-flutter.
Las builds de release en Android necesitan el permiso INTERNET en
android/app/src/main/AndroidManifest.xml (Flutter solo lo añade a las builds de depuración):
<uses-permission android:name="android.permission.INTERNET"/>En macOS, añade el entitlement com.apple.security.network.client tanto a
macos/Runner/DebugProfile.entitlements como a macos/Runner/Release.entitlements.
Permitir apps móviles
Sección titulada «Permitir apps móviles»Las apps nativas no envían cabecera Origin ni Referer, así que las solicitudes desde la app se
rechazan (tengas o no configurados sitios web permitidos) con
403 origin_not_allowed hasta que actives
Seguridad → Apps móviles → Permitir apps móviles en el panel y hagas clic en Guardar.
Consulta Ajustes de seguridad.
Las solicitudes desde sitios web se siguen comprobando contra la lista, así que el interruptor no debilita la protección de tus formularios web. El interruptor es necesario incluso con la lista vacía.
Flutter web se ejecuta en el navegador, que siempre envía Origin: en ese caso añade el origen
de tu app web a los sitios web permitidos; ahí el interruptor no
hace falta.
Crea el cliente una vez y compártelo (un final de nivel superior, un provider, tu inyección de
dependencias) y llama a 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': '¡Hola!', 'qty': 2, 'items': [{'sku': 'A-1'}]}, serviceId: 'svc_01J9…', // opcional: null usa el servicio por defecto de la plantilla);if (result.status == DeliveryStatus.held) { // Recibido: se entregará en breve (la organización está esperando créditos).}send devuelve un SendResult con messageId, status (queued o held) y statusToken. El
constructor lanza un error cuando le pasas una clave privada (consulta Seguridad).
Opciones del constructor:
final frontmail = Frontmail( publicKey: 'pk_4f2a…', apiUrl: Uri.parse('https://api.frontmail.dev'), // por defecto retry: const RetryPolicy(maxRetries: 3), // por defecto; RetryPolicy.disabled = sin reintentos timeout: const Duration(seconds: 15), // por intento (por defecto) httpClient: myClient, // cualquier Client de package:http, p. ej. cupertino_http o cronet_http);close() cierra el cliente HTTP que creó el propio SDK; un cliente que le hayas pasado tú sigue
abierto. Todas las solicitudes llevan la cabecera X-Frontmail-Client: frontmail-flutter/<version>.
Parámetros
Sección titulada «Parámetros»Los parámetros son un Map<String, Object?> de valores JSON: String, num, bool, null,
listas y mapas. Los valores DateTime se envían como cadenas ISO-8601 en UTC, y los objetos con un
método toJson() (por ejemplo, de json_serializable o freezed) se codifican con él:
await frontmail.send(templateId: 'tpl_contact', params: contact.toJson());Los valores que no son JSON (double.nan, un objeto sin toJson()) lanzan
invalid_template_params antes de enviar nada.
Adjuntos y otras opciones
Sección titulada «Adjuntos y otras opciones»SendOptions lleva el token de Turnstile, los adjuntos, el idioma de la plantilla, una clave de
idempotencia explícita y un disparador de cancelación:
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_…'), // subido antes mediante POST /v1/uploads ], ),);SendOptions(locale: …) envía el correo en un idioma de la plantilla
(una etiqueta BCP 47). Usa Localizations.localeOf(context).toLanguageTag() (p. ej. de-AT):
Locale.toString() usa _ y se rechaza como invalid_body. Un idioma desconocido recurre al mismo
idioma base y, si no, al idioma predeterminado de la plantilla; result.locale es el idioma usado:
final result = await frontmail.send( templateId: 'tpl_contact', params: {'name': 'Jan'}, options: SendOptions(locale: Localizations.localeOf(context).toLanguageTag()),);print(result.locale ?? 'default'); // "de"Los futures de Dart no se pueden cancelar, así que send y status aceptan en su lugar un
abortTrigger: cuando ese future se completa (por ejemplo, en dispose()), la solicitud o la
espera antes de un reintento se detiene con el código aborted:
final abort = Completer<void>();frontmail.send(templateId: 'tpl_contact', options: SendOptions(abortTrigger: abort.future));// más tardeabort.complete();Estado de entrega
Sección titulada «Estado de entrega»El statusToken de send permite a la app leer el estado de ese mensaje. El SDK lo envía en la
cabecera X-Frontmail-Status-Token, nunca en la URL:
final status = await frontmail.status(messageId: result.messageId, statusToken: result.statusToken);print(status.status); // queued, sending, sent, delivered, bounced, … (los valores desconocidos se conservan)print(status.events.map((e) => e.type)); // (accepted, sent, …)Turnstile
Sección titulada «Turnstile»Si la plantilla exige Turnstile, muestra el widget de Cloudflare en una
vista web de webview_flutter y pasa el token a send.
No necesitas site key: por defecto se usa la clave móvil compartida de Frontmail.
Turnstile.site(client:) la obtiene de GET /v1/public-config
(en caché en memoria, así que solo se carga una vez), y el widget se ejecuta como una página HTML
incrustada con la URL https://mobile.frontmail.dev; desde esa dirección no se carga nada.
El SDK te da las piezas; el widget vive en tu app (así el SDK sigue siendo Dart puro):
| API | Qué hace |
|---|---|
Turnstile.site(client:) |
site key + URL de la página + la turnstileKey con la que enviar los tokens |
Turnstile.html(siteKey:, theme:, size:, action:, language:) |
la página del widget; envía mensajes al canal de JavaScript FrontmailTurnstile |
Turnstile.parseMessage(message) |
valida un mensaje → TurnstileToken, TurnstileExpired, TurnstileError |
Turnstile.navigationAction(url:, baseUrl:, isTopFrame:) |
protección de navegación: TurnstileAllow, TurnstileCancel, TurnstileOpenExternally |
Turnstile.resetScript |
JavaScript que restablece el widget para obtener un token nuevo |
Un widget completo para Android, iOS y macOS: añade webview_flutter y url_launcher
(flutter pub add webview_flutter url_launcher) y cópialo en tu 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 el valor para restablecer el widget y obtener un token nuevo (los tokens son de un solo uso). 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); }, ) // El marco principal se queda en el widget; sus enlaces se abren en el navegador del 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), );}Úsalo en un formulario: espera un token, envía y luego restablece el 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 sirve una sola vez: pide uno nuevo sea cual sea el resultado. 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 ? 'Recibido: lo entregaremos en breve.' : '¡Gracias!'); } 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: 'Correo')), TextField(controller: _message, decoration: const InputDecoration(labelText: 'Mensaje')), 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 = 'Error de Turnstile ${code ?? ''}'; } }), ), FilledButton(onPressed: _token == null ? null : _submit, child: const Text('Enviar')), Text(_info), ]); }}La vista web está limitada al widget: solo carga la página incrustada (baseUrl) y el desafío de
Cloudflare, los enlaces dentro del widget se abren en el navegador del sistema y parseMessage
solo acepta mensajes con exactamente la forma que publica la página del widget.
Las solicitudes de la app no llevan cabecera Origin, así que Frontmail verifica sus tokens con su
secreto móvil compartido. No necesitas tus propias claves de Turnstile para esto: con Permitir
apps móviles activado, puedes activar la CAPTCHA en las plantillas de la app aunque tu
organización todavía no tenga claves.
webview_flutter no tiene implementación para la web. En Flutter web las solicitudes sí llevan
Origin, así que Turnstile necesita tus propias claves: renderiza el
widget en la página (por ejemplo, mediante HtmlElementView) y envía su token con
turnstileKey: TurnstileKey.org.
Tu propia site key
Sección titulada «Tu propia site key»Para usar en su lugar tu propio widget de Turnstile, crea tú el sitio:
final site = TurnstileSite.custom(siteKey: 'YOUR_TURNSTILE_SITE_KEY', baseUrl: Uri.parse('https://example.com'));Su turnstileKey es TurnstileKey.org, así que
SendOptions(turnstileToken: token, turnstileKey: site.turnstileKey) envía turnstile_key: "org"
y Frontmail verifica el token con la secret key de Seguridad → Protección contra bots (Turnstile).
Un token es válido durante unos 5 minutos y se puede usar una sola vez: restablece el widget después de cada envío, tenga éxito o no.
Reintentos e idempotencia
Sección titulada «Reintentos e idempotencia»Los errores de red, los tiempos de espera agotados, los 5xx y los 429 se reintentan
automáticamente (3 reintentos por defecto, con espera exponencial y jitter completo). Se respeta la
cabecera Retry-After; si pide más de 60 segundos, el error se lanza de inmediato. Las demás
respuestas 4xx no se reintentan nunca. Todos los intentos de un mismo envío reutilizan la misma
clave de idempotencia (un UUID aleatorio, salvo que pases idempotencyKey), así que un reintento
nunca envía el correo dos veces. Consulta
Reintentos e idempotencia.
Errores
Sección titulada «Errores»Todo lanza FrontmailException con code, message, status (null en los errores del lado del
cliente), docsUrl, details y retryAfter. Los códigos del cliente son network_error,
timeout, aborted, invalid_response y private_key_in_browser (constantes en
FrontmailErrorCode); las comprobaciones isAuth, isValidation, isRateLimit,
isInsufficientCredits, isNetwork e isAborted los agrupan:
try { await frontmail.send(templateId: 'tpl_contact', params: params);} on FrontmailException catch (e) { if (e.isRateLimit) { show('Demasiados mensajes: vuelve a intentarlo dentro de ${e.retryAfter?.inSeconds ?? 60} s.'); } else if (e.code == FrontmailErrorCode.originNotAllowed) { assert(false, 'Activa Seguridad → Apps móviles → Permitir apps móviles en el panel.'); } else { show(e.message); }}Cómo tratar cada código se explica en Gestión de errores.
Seguridad
Sección titulada «Seguridad»- En una app pon siempre solo la clave pública (
pk_…). Todo lo que hay en el paquete de una app se puede extraer, así que una clave privada ahí es como si estuviera publicada. El SDK rechaza las claves que empiezan porsk_con el errorprivate_key_in_browser. Consulta Claves públicas y privadas. - Con Permitir apps móviles activado, las solicitudes sin cabecera
Originse aceptan desde cualquier lugar: cualquier script que conozca tu clave pública puede enviarlas. Apóyate en el límite de solicitudes por IP, en Turnstile en todas las plantillas que use la app y en la lista de bloqueo. - Una plantilla que toma el destinatario de los parámetros (por ejemplo, una confirmación enviada a la dirección que ha escrito el usuario) tiene el contenido bloqueado: necesita Turnstile, un remitente fijo, parámetros cortos en texto plano y enlaces solo a tus dominios. Consulta Lista de enlaces permitidos y bloqueo de contenido.
Privacidad
Sección titulada «Privacidad»El SDK no rastrea a los usuarios y no recopila datos propios. Lo que envían tus plantillas (como una dirección de correo) son datos que recopila tu app: decláralos en los detalles de privacidad de la App Store y en el formulario de seguridad de los datos de Google Play.