Saltar al contenido

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:

El vídeo está en inglés; los pasos de abajo son el tutorial completo.

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

  1. Servicios de correo → Añadir servicio: conecta Gmail, Outlook, SMTP o un proveedor transaccional, activa Marcar como predeterminado y pulsa Verificar y guardar.
  2. Plantillas → Desde la galería → Formulario de contacto → Usar plantilla. Espera name, email y message (obligatorios) y phone, topic (opcionales).
  3. 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 held y se envía automáticamente en cuanto recargas; con result.status == DeliveryStatus.held puedes 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.
Todos los tutoriales