Email deliverability para developers: SPF, DKIM y DMARC en formularios
Un usuario rellena el formulario de contacto de tu web. Tu backend construye el email, lo manda, el servidor SMTP responde 250 OK y tu log dice “enviado”. El usuario no recibe nada. Ni en la bandeja de entrada, ni en spam. Nada.
Abres un ticket con soporte del hosting. Te dicen que el email salió. Y tienen razón: salió. El problema es que Gmail lo rechazó y el rebote acabó en un buzón que nadie lee. Ese 250 OK solo dice que tu servidor aceptó el envío.
Llevo años montando formularios que envían correo, y el correo de un formulario es el que menos se vigila. En Wedding30s, el formulario de confirmación de asistencia manda un email al invitado y otro a los novios. Si ese email no llega, el producto no existe. Así que os cuento lo que hay que saber para que llegue.
El error de base: enviar “en nombre de” quien rellena el formulario
El patrón más habitual en un formulario de contacto lo he visto en decenas de proyectos PHP:
mail(
'hola@miempresa.com',
'Nuevo contacto desde la web',
$mensaje,
'From: ' . $_POST['email']
);
Pones como remitente la dirección del usuario para que al responder desde el cliente de correo salga su email. Cómodo, sí. Pero acabas de enviar un email desde tu servidor diciendo que viene de usuario@gmail.com. Y Gmail tiene un registro DMARC con p=reject que dice: “si un email con From: de mi dominio no lo firmé yo, recházalo”. Tu servidor no es Gmail. Ese correo no llega.
Lo mismo pasa con Yahoo, con Outlook y con cualquier dominio corporativo que haya hecho los deberes. Es decir, cada vez con más remitentes. Y cuando llega, llega mal: cada uno de esos envíos cuenta como un intento de suplantación en los reportes DMARC del dominio del usuario. Estás enseñando a los receptores que tu IP hace cosas raras.
La solución tiene dos líneas:
$email = (new Email())
->from('web@miempresa.com')
->replyTo($contactEmail)
->to('hola@miempresa.com')
->subject('Nuevo contacto desde la web')
->text($mensaje);
El From: es siempre tu dominio. El Reply-To: lleva la dirección del usuario. Al pulsar responder, el cliente de correo usa el Reply-To. Ganas lo mismo y dejas de suplantar a nadie.
Esto es lo primero que reviso cuando alguien me dice que “los emails del formulario no llegan”. Muchas veces el problema termina aquí.
Los tres registros DNS, explicados para quien programa el envío
Hay cientos de tutoriales de SPF, DKIM y DMARC. Casi todos están escritos para administradores de sistemas. Voy a explicarlos desde el otro lado: qué comprueba el receptor cuando tu código hace ->send().
SPF: quién puede enviar desde tu dominio
SPF es un registro TXT en tu dominio con la lista de IPs y servicios autorizados a enviar correo usando ese dominio en el sobre SMTP. Un ejemplo real de un dominio que usa Amazon SES y Google Workspace:
miempresa.com. TXT "v=spf1 include:amazonses.com include:_spf.google.com -all"
El receptor mira la IP que le está entregando el email y la busca en esa lista. Si no está, SPF falla. El -all del final dice “cualquier otra IP, rechazo”. Con ~all (softfail) dices “cualquier otra IP, sospecha pero no rechaces”. Para un dominio nuevo empieza con ~all y pasa a -all cuando estés seguro de que todos tus servicios están en la lista.
La trampa que más he visto en formularios es que SPF no valida el From: que ve el usuario. Valida el MAIL FROM del sobre SMTP, que en la cabecera final aparece como Return-Path. Si envías con Amazon SES, ese Return-Path es un dominio de Amazon, no el tuyo. SPF pasa, pero pasa para Amazon. Para que cuente a favor de tu dominio hace falta alineamiento, y eso lo vemos en DMARC.
Segunda trampa: el límite de 10 lookups DNS. Cada include: cuenta, y los includes de dentro de los includes también. Si tu SPF tiene Google, Microsoft 365, SES, el CRM y la herramienta de marketing, es fácil pasarte. Cuando te pasas, SPF devuelve permerror y los receptores lo tratan como fallo. Cuenta los lookups antes de añadir el siguiente servicio. El comprobador de SPF de DMARC Examiner te los cuenta y avisa si te pasas.
DKIM: la firma que sobrevive al reenvío
DKIM es una firma criptográfica que tu servidor de envío añade en una cabecera. La clave pública vive en un registro TXT de tu DNS bajo un selector:
ses._domainkey.miempresa.com. TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEF..."
El receptor lee la cabecera DKIM-Signature, saca el selector, consulta el DNS, y verifica que el contenido firmado no ha cambiado. Si alguien reenvía el email, DKIM sigue pasando porque la firma viaja dentro del mensaje. SPF, en cambio, se rompe con el reenvío, porque la IP del que reenvía no está en tu lista.
Por eso DKIM es la autenticación que de verdad importa para un formulario. Si solo puedes hacer bien una de las tres cosas, haz DKIM.
Con Amazon SES la configuración se llama Easy DKIM: te da tres registros CNAME y se acabó. Con Postfix propio toca OpenDKIM y rotar claves a mano. Con Symfony Mailer puedes firmar tú mismo antes de enviar:
use Symfony\Component\Mime\Crypto\DkimSigner;
$signer = new DkimSigner(
'file:///etc/dkim/miempresa.com.private.pem',
'miempresa.com',
'web',
);
$signedEmail = $signer->sign($email);
$mailer->send($signedEmail);
Firmar en la aplicación tiene sentido cuando el servidor de salida no lo hace por ti. Si usas SES, Postmark, Resend o similar, ellos firman. No firmes dos veces.
DMARC: la política y el alineamiento
DMARC es el registro que junta las otras dos piezas y añade lo que faltaba: el alineamiento con el From: que ve el usuario.
_dmarc.miempresa.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@miempresa.com; adkim=r; aspf=r"
Un email pasa DMARC si al menos una de estas dos cosas se cumple:
| Autenticación | Qué tiene que pasar | Qué tiene que alinear con el From: |
|---|---|---|
| SPF | La IP está en el SPF del dominio del Return-Path | El dominio del Return-Path |
| DKIM | La firma verifica | El dominio d= de la firma |
Con alineamiento relajado (adkim=r, aspf=r), vale que sea el mismo dominio organizativo: mail.miempresa.com alinea con miempresa.com. Con estricto (s), tiene que ser exactamente el mismo.
Vuelve al ejemplo de SES. El Return-Path es de Amazon, así que SPF no alinea. Pero la firma DKIM lleva d=miempresa.com porque configuraste Easy DKIM en tu dominio. DKIM alinea. DMARC pasa. Si no hubieras configurado DKIM en SES, el email sale firmado por amazonses.com, no alinea nada y con p=reject en tu dominio te lo rechazan a ti mismo.
La p= es la política que pides a los receptores para los correos que fallan: none (solo reporta), quarantine (a spam) o reject. Se empieza en none para recibir reportes y ver quién envía en tu nombre. Se sube cuando el 100% de tus fuentes legítimas pasan. Si no quieres escribir el registro a mano, el generador de DMARC te lo monta con la política y el rua=.
Y desde febrero de 2024, Google y Yahoo exigen DMARC a quien envíe más de 5.000 emails al día a sus usuarios. Si tu producto crece, esto deja de ser opcional en el momento menos oportuno.
Lo que tu código sí controla (y casi nadie mira)
Los registros DNS son la mitad del trabajo. La otra mitad la decide lo que construyes en el mensaje.
El dominio del From: tiene que existir de verdad. Enviar desde noreply@app.miempresa.com sin que app.miempresa.com tenga registro MX ni DMARC propio es una señal mala. Si usas un subdominio para transaccional (y es buena idea, separa reputación), configúralo entero: SPF, DKIM, DMARC y un MX que acepte rebotes.
El Message-ID tiene que ser de tu dominio. Symfony Mailer lo genera solo con el dominio del From:. Si construyes emails a mano con mail() y no lo pones, lo pone el MTA con el hostname de la máquina, que a veces es ip-172-31-4-12.eu-west-1.compute.internal. Los filtros lo miran.
Manda versión de texto plano además del HTML. Un email solo HTML con un enlace y una imagen se parece mucho a phishing. Con Symfony es ->text() y ->html() en el mismo objeto.
Los rebotes tienen que ir a algún sitio que los procese. Si el Return-Path apunta a un buzón que nadie lee, sigues enviando a direcciones que no existen. Cada rebote repetido te resta reputación. En SES, esto va por SNS a un endpoint tuyo. En Postfix, un buzón con un script que marque la dirección como inválida. Si envías a 500 direcciones muertas cada semana, ningún registro DNS te salva.
Este es el flujo completo en un controlador de Symfony para un formulario de contacto, con las decisiones ya tomadas:
final class ContactController extends AbstractController
{
public function __construct(
private readonly MailerInterface $mailer,
) {}
#[Route('/contacto', methods: ['POST'])]
public function __invoke(Request $request): Response
{
$form = $this->createForm(ContactType::class);
$form->handleRequest($request);
if (!$form->isSubmitted() || !$form->isValid()) {
return $this->render('contact/form.html.twig', ['form' => $form]);
}
/** @var ContactMessage $contact */
$contact = $form->getData();
$email = (new TemplatedEmail())
->from(new Address('web@miempresa.com', 'Web miempresa'))
->replyTo(new Address($contact->email, $contact->name))
->to('hola@miempresa.com')
->subject('Nuevo contacto: ' . $contact->name)
->textTemplate('emails/contact.txt.twig')
->htmlTemplate('emails/contact.html.twig')
->context(['contact' => $contact]);
$this->mailer->send($email);
return $this->redirectToRoute('contact_sent');
}
}
Y el DSN en .env, apuntando a SES por API en vez de por SMTP (menos latencia y sin bloquear el request en el handshake):
MAILER_DSN=ses+api://ACCESS_KEY:SECRET_KEY@default?region=eu-west-1
Si el formulario tiene tráfico, mete el envío en Messenger con un transporte asíncrono. El usuario ve la confirmación al instante y el envío se reintenta solo si SES devuelve un error temporal.
Cómo saber si has hecho bien los deberes
Un 250 OK no te dice nada. Lo que te dice la verdad es la cabecera Authentication-Results del email recibido. Mándate un email de prueba a una cuenta de Gmail, abre “Mostrar original” y busca esta línea:
Authentication-Results: mx.google.com;
dkim=pass header.i=@miempresa.com header.s=ses header.b=k2Qx8Lm3;
spf=pass (google.com: domain of 0100018b...@eu-west-1.amazonses.com designates 54.240.x.x as permitted sender) smtp.mailfrom=eu-west-1.amazonses.com;
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=miempresa.com
Si no quieres leerla a ojo, pega las cabeceras completas en el analizador de cabeceras y te enseña el resultado de SPF, DKIM y DMARC y la ruta de entrega salto a salto.
Tres pass. Fíjate en que spf=pass es para amazonses.com y dkim=pass es para miempresa.com. Eso es el alineamiento por DKIM del que hablaba arriba, funcionando.
Ese test te dice cómo va un email. Para saber cómo van todos, hacen falta los reportes DMARC. Cada receptor grande te envía un XML diario a la dirección del rua= con todas las IPs que enviaron con tu dominio y cuántos pasaron. Leerlos a mano es un castigo (para uno suelto, el conversor de XML te lo pasa a tabla). Yo los mando a DMARC Examiner, que los parsea y me avisa cuando aparece una IP nueva o cuando el porcentaje de alineados cae. Los dominios de envío de Wedding30s están ahí, con el mismo rua= que cualquier otro dominio mío.
📦 ¿Quieres probar DMARC Examiner? Usa el código
LMMARTINB20y obtén un 20% de descuento los 3 primeros meses en cualquier plan. Está también en mi página de descuentos.
De cómo montar esa monitorización en el día a día ya escribí en Alertas DMARC: monitorizar emails sin volverte loco.
Checklist antes de dar por bueno un formulario
Pásala por cada formulario que envíe correo. Cada “no” es un email que no llega.
- ¿El
From:es siempre un dominio tuyo? El email del usuario va enReply-To. - ¿El dominio del
From:tiene SPF con todos tus servicios y menos de 10 lookups? - ¿El proveedor de envío firma DKIM con
d=de TU dominio, no del suyo? - ¿Hay registro DMARC, aunque sea
p=none, con unrua=que alguien lea? - ¿Un email de prueba a Gmail muestra
dmarc=passenAuthentication-Results? - ¿Mandas texto plano además de HTML?
- ¿Los rebotes se procesan y las direcciones inválidas dejan de recibir?
- ¿El envío es asíncrono para no bloquear el request del formulario?
Con esos ocho puntos, el “no me llegan los emails del formulario” pasa de ser un misterio a ser un bug con una causa concreta. Y con reportes DMARC delante, la causa se ve.
PD: Si tienes un formulario que envía y no sabes por qué no llega, pégame la cabecera Authentication-Results por DM en twitter y lo miramos. Y si vais a montar la web de vuestra boda, Wedding30s tiene el formulario de confirmación de asistencia ya hecho, con los emails llegando.
¿Te ha gustado este artículo?
Explora más artículos sobre desarrollo, buenas prácticas y herramientas.