Ir al contenido

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

Ventana de terminal
flutter pub add frontmail

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

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

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.

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

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

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.

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.

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.

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.

  • 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 por sk_ con el error private_key_in_browser. Consulta Claves públicas y privadas.
  • Con Permitir apps móviles activado, las solicitudes sin cabecera Origin se 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.

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.