---
title: "Email deliverability para developers: SPF, DKIM y DMARC en formularios"
description: "Tu formulario envía el email, el servidor dice 250 OK y el usuario no lo recibe. SPF, DKIM y DMARC explicados para quien programa el envío, en PHP y Symfony."
pubDate: "2026-11-08"
category: "programacion"
language: "es"
tags: ["email", "spf", "dkim", "dmarc", "formularios", "php", "symfony"]
---
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](https://wedding30s.com), 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:

```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:

```php
$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:

```dns
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](https://dmarc-examiner.com/es/herramientas/spf?utm_source=lmmartinb&utm_medium=blog&utm_campaign=email-deliverability-formularios&utm_content=tool-spf) 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:

```dns
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:

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

```dns
_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](https://dmarc-examiner.com/es/herramientas/generador-dmarc?utm_source=lmmartinb&utm_medium=blog&utm_campaign=email-deliverability-formularios&utm_content=tool-generador-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:

```php
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):

```dotenv
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](https://dmarc-examiner.com/es/herramientas/analizador-cabeceras?utm_source=lmmartinb&utm_medium=blog&utm_campaign=email-deliverability-formularios&utm_content=tool-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](https://dmarc-examiner.com/es/herramientas/xml-a-humano?utm_source=lmmartinb&utm_medium=blog&utm_campaign=email-deliverability-formularios&utm_content=tool-xml) te lo pasa a tabla). Yo los mando a [DMARC Examiner](https://dmarc-examiner.com?utm_source=lmmartinb&utm_medium=blog&utm_campaign=email-deliverability-formularios&utm_content=cta-inline), 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 **`LMMARTINB20`** y obtén un 20% de descuento los 3 primeros meses en cualquier plan. Está también en mi página de [descuentos](/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](/blog/monitorizar-emails-organizacion-alertas-dmarc/).

## 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 en `Reply-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 un `rua=` que alguien lea?
- [ ] ¿Un email de prueba a Gmail muestra `dmarc=pass` en `Authentication-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](https://x.com/lm_martinbar) y lo miramos. Y si vais a montar la web de vuestra boda, [Wedding30s](https://wedding30s.com) tiene el formulario de confirmación de asistencia ya hecho, con los emails llegando.