Cómo crear un formulario de contacto en Flutter que envíe correos
Un formulario de contacto en Flutter que te envía cada mensaje por correo – sin backend y sin contraseña SMTP en la app. Funciona en Android, iOS, web y escritorio con el paquete de Dart de Frontmail.
Actualizado:
Vas a añadir a una app de Flutter una pantalla de contacto que envía cada mensaje a tu bandeja de entrada. La app solo guarda una clave pública; Frontmail envía el correo a través de tu propio buzón, así que ninguna contraseña SMTP ni ningún servidor acaban dentro del paquete de la app.
Necesitas: una cuenta gratuita de Frontmail, un proyecto de Flutter 3.24+ y unos quince minutos.
1. Conecta tu buzón y crea la plantilla
- Servicios de correo → Añadir servicio: conecta Gmail, Outlook, SMTP o un proveedor transaccional, activa Marcar como predeterminado y pulsa Verificar y guardar.
- Plantillas → Desde la galería → Formulario de contacto → Usar plantilla. Espera
name,emailymessage(obligatorios) yphone,topic(opcionales). - Copia el ID de la plantilla (
tpl_…) de la pestaña Fragmentos.
2. Permite las apps móviles
Las apps no envían una cabecera Origin como hacen las webs, así que la API las rechaza hasta que
lo permitas: Seguridad → Apps móviles → activa Permitir apps móviles → Guardar.
Después abre Ajustes → Protección de la plantilla y activa Exigir CAPTCHA (Turnstile). Las apps móviles usan el widget compartido de Turnstile de Frontmail, así que no necesitas claves de Cloudflare propias.
3. Instala el paquete
flutter pub add frontmail webview_flutter url_launcher
webview_flutter muestra la verificación de Turnstile. En Android, las compilaciones de release
necesitan además <uses-permission android:name="android.permission.INTERNET"/> en
AndroidManifest.xml.
Copia el widget TurnstileView de la sección Turnstile de la documentación del SDK de Flutter en
lib/turnstile_view.dart: son unas sesenta líneas que son tuyas y puedes adaptar a tu estilo.
4. Crea el formulario
import 'package:flutter/material.dart';
import 'package:frontmail/frontmail.dart';
import 'turnstile_view.dart';
final frontmail = Frontmail(publicKey: 'pk_…'); // create once, share
class ContactScreen extends StatefulWidget {
const ContactScreen({super.key});
@override
State<ContactScreen> createState() => _ContactScreenState();
}
class _ContactScreenState extends State<ContactScreen> {
final _name = TextEditingController();
final _email = TextEditingController();
final _message = TextEditingController();
TurnstileSite? _site;
String? _token;
int _resetId = 0;
bool _sending = false;
String _info = '';
@override
void initState() {
super.initState();
Turnstile.site(client: frontmail).then((site) => setState(() => _site = site));
}
Future<void> _send() async {
final site = _site, token = _token;
if (site == null || token == null) return;
setState(() {
_sending = true;
_token = null; // a token works only once
_resetId++;
});
try {
await frontmail.send(
templateId: 'tpl_…',
params: {'name': _name.text, 'email': _email.text, 'message': _message.text},
options: SendOptions(turnstileToken: token, turnstileKey: site.turnstileKey),
);
setState(() => _info = 'Thank you! We’ll get back to you soon.');
} on FrontmailException catch (e) {
setState(() => _info = e.message);
} finally {
setState(() => _sending = false);
}
}
@override
Widget build(BuildContext context) {
final site = _site;
return Scaffold(
appBar: AppBar(title: const Text('Contact us')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')),
TextField(controller: _email, decoration: const InputDecoration(labelText: 'Email'), keyboardType: TextInputType.emailAddress),
TextField(controller: _message, decoration: const InputDecoration(labelText: 'Message'), maxLines: 5),
const SizedBox(height: 16),
if (site != null)
TurnstileView(
site: site,
resetId: _resetId,
onMessage: (m) => setState(() => _token = m is TurnstileToken ? m.token : null),
),
FilledButton(onPressed: _token == null || _sending ? null : _send, child: const Text('Send')),
Text(_info),
],
),
);
}
}
El SDK reintenta por su cuenta los errores de red y los fallos puntuales del servidor con una misma clave de idempotencia, así que una conexión móvil inestable nunca genera correos duplicados.
5. Pruébalo
Ejecuta la app, rellena el formulario y pulsa Send. El correo llega a tu bandeja y aparece en Historial en el panel.
origin_not_allowed– Permitir apps móviles está desactivado (o no se guardó).captcha_required– falta el token de Turnstile; espera a que termine la verificación.invalid_template_params– un campo obligatorio está vacío o la dirección de correo no es válida.
Si te quedas sin créditos, el mensaje se acepta con el estado
heldy se envía automáticamente en cuanto recargas; conresult.status == DeliveryStatus.heldpuedes avisar al usuario.
Próximos pasos
- Envía una confirmación al usuario con la plantilla Respuesta automática: gracias por tu mensaje de la galería.
- Pasa
SendOptions(locale: …)para enviar el correo en el idioma del usuario cuando la plantilla tenga variantes de idioma.


