Ir al contenido

Email

Kevin CMS manda correo por el binding send_email de Cloudflare Email Sending. Solo transaccional: verificar una cuenta y restablecer una contraseña. No hay newsletters, ni listas, ni seguimiento de aperturas, y no está previsto que los haya.

Es la parte que más veces falla, y ninguna de las tres se arregla desde el código:

  1. Un plan Workers de pago. Email Sending no está en el gratuito.
  2. Un dominio que use DNS de Cloudflare, dado de alta en Compute → Email Service → Email Sending. Cloudflare añade los MX, SPF, DKIM y DMARC del subdominio cf-bounce.
  3. Que from pertenezca a ese dominio. Cualquier otro remitente se rechaza.

El mensaje de MISSING_EMAIL las enumera las tres, porque saber solo que «falta el binding» manda a mirar al sitio equivocado dos de cada tres veces.

  1. El binding, en tu wrangler.jsonc:

    wrangler.jsonc
    {
    "send_email": [{ "name": "EMAIL" }]
    }
  2. El remitente, en tu config:

    cms.config.ts
    import { defineConfig } from '@kevolution-co/cms'
    export default defineConfig({
    email: {
    from: 'noreply@tudominio.com',
    siteName: 'Mi sitio',
    replyTo: 'hola@tudominio.com',
    },
    collections: [],
    })
Clave Por defecto Qué hace
from Remitente. Tiene que pertenecer al dominio dado de alta
siteName 'Kevin CMS' Firma las plantillas: aparece en el asunto y en el pie
replyTo sin cabecera Dirección de respuesta, si quieres una distinta del remitente

El nombre del binding se cambia con bindings.email en la integración, igual que los de D1 y R2.

wrangler dev simula el envío: no manda nada a ninguna parte, escribe el mensaje en un fichero temporal e imprime su ruta en la consola.

[wrangler:info] send_email binding called with MessageBuilder:
From: noreply@example.com
To: kevin@example.com
Subject: Verifica tu cuenta en Kevin CMS
Text: /tmp/miniflare-…/email-text/<id>.txt

Con eso se desarrollan los flujos completos sin dominio, sin plan de pago y sin buzón: te das de alta, intentas iniciar sesión, abres el fichero, copias el enlace y lo sigues.

Correo Cuándo sale
Verificación de la cuenta En el primer intento de iniciar sesión sin verificar, no en el alta
Restablecimiento de contraseña Al pedirlo con POST /api/auth/request-password-reset

Los dos los dispara better-auth, no tu código. El de verificación se puede reenviar con POST /api/auth/send-verification-email.

Son HTML con tablas y estilos en línea —lo único que se ve igual en todos los clientes de correo— y siempre viajan con una versión en texto plano al lado. Sin ella el mensaje puntúa peor en los filtros de spam y se ve mal en lo que no renderiza HTML. El enlace aparece tal cual, en su propia línea, dentro de esa versión, para que se pueda copiar cuando el botón no se puede pulsar.

Todo valor que se interpola en el HTML se escapa, así que un nombre con <script> llega como texto y no como marcado.

Astro.locals.cms.email expone el mismo emisor:

src/pages/contacto.astro
---
const { email } = Astro.locals.cms
const { messageId } = await email.send({
to: 'kevin@example.com',
subject: 'Gracias por escribir',
html: '<p>Te respondemos en menos de 24 h.</p>',
text: 'Te respondemos en menos de 24 h.',
})
---
Campo
to Una dirección o varias
subject
html
text Obligatorio junto a html
from Opcional. Por defecto el de la config
replyTo Opcional. Por defecto el de la config

Devuelve { messageId }.

Es un accesor perezoso: una página que no manda correo no resuelve el binding.

send() no reintenta. El error sube al que llamó, que es quien sabe qué hacer con él:

  • Si falla el correo de verificación, el alta no se revierte. La cuenta se queda creada y sin verificar, y el siguiente intento de iniciar sesión manda otro correo. Perder la cuenta por una caída temporal del correo sería peor.
  • Si falla el de restablecimiento, el error llega al usuario, porque sin ese correo el flujo no sigue.

En ambos casos se registra un console.error con el destinatario y el asunto, y nada más. Ni el cuerpo ni el enlace: los dos llevan un token vivo, y los logs duran bastante más que la hora que dura el token.