Enviar un email con PHP: SMTP, DKIM y entregabilidad

Enviar correos electrónicos es de lo más habitual en una aplicación web: el mensaje de bienvenida cuando alguien crea una cuenta, el boletín que reciben los usuarios registrados, el simple formulario de contacto del sitio, y así sucesivamente. Puedes usar la función integrada de PHP

Enviar un email con PHP: SMTP, DKIM y entregabilidad
Respuesta rápida

No uses mail(): sin un servidor de correo local devuelve false, y sus mensajes ya no pasan los filtros. Usa SMTP autenticado con PHPMailer o Symfony Mailer, y publica SPF, DKIM y DMARC en el dominio. El From debe pertenecer a tu dominio, la dirección del visitante va en Reply-To.

Enviar un email desde PHP es fácil. Conseguir que llegue a la bandeja de entrada lo es mucho menos. La función mail(), que casi todos los tutoriales enseñan primero, falla en los dos frentes en 2026: no funciona sin un servidor de correo local y, cuando funciona, sus mensajes acaban en spam. Este artículo muestra lo que sí funciona, y por qué.

Por qué mail() ya no basta

La función mail() no habla SMTP. Delega en un binario local, sendmail, señalado por la directiva sendmail_path. Si no hay servidor de correo en la máquina, no ocurre nada.

php
<?php
echo ini_get('sendmail_path'), "\n";  // /usr/sbin/sendmail -t -i

$ok = mail('destinataire@example.com', 'Test', 'Message de test');
var_dump($ok);

En un contenedor PHP 8.5 estándar, sin ningún MTA instalado:

code
sh: 1: /usr/sbin/sendmail: not found
Warning: mail(): Sendmail exited with non-zero exit code 127
bool(false)

Es la situación por defecto de cualquier contenedor Docker, de la mayoría de las imágenes de desarrollo y de una parte de los alojamientos. El código no avisa de nada explícito: devuelve false, y ese valor de retorno casi siempre se ignora.

Y aunque exista un MTA, true no significa «mensaje entregado». Significa «el programa local ha aceptado el mensaje». Lo que pasa después, rechazo del servidor remoto, cuarentena, descarte silencioso, es invisible desde PHP.

El problema de verdad: autenticar el dominio

Desde febrero de 2024, Gmail y Yahoo exigen una autenticación completa a los remitentes con volumen. Microsoft hizo lo mismo en 2025. Un mensaje que no está firmado ni alineado ya no llega, o llega a spam. Se combinan tres mecanismos.

SPF

Un registro DNS de tipo TXT en tu dominio, que enumera los servidores autorizados a enviar en su nombre.

code
exemple.com.  IN  TXT  "v=spf1 include:_spf.mon-fournisseur.com -all"

El -all final significa «cualquier otro servidor es ilegítimo». Un ~all es más permisivo y sirve durante la puesta en marcha, pero el objetivo es el -all.

DKIM

Una firma criptográfica añadida a las cabeceras del mensaje. El destinatario recupera la clave pública en el DNS y comprueba que el mensaje no se ha modificado y que viene realmente del dominio anunciado.

bash
# Generar el par de claves
openssl genrsa -out dkim-prive.pem 2048
openssl rsa -in dkim-prive.pem -pubout -out dkim-public.pem

La clave pública se publica en <selecteur>._domainkey.exemple.com:

code
gk2026._domainkey.exemple.com.  IN  TXT  "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."

La clave privada se queda en el servidor, fuera del repositorio Git, y solo la puede leer el usuario que ejecuta PHP.

DMARC

La política que le dice al destinatario qué hacer cuando SPF y DKIM fallan, y adónde enviar los informes.

code
_dmarc.exemple.com.  IN  TXT  "v=DMARC1; p=none; rua=mailto:dmarc@exemple.com; adkim=s; aspf=s"

Siempre se empieza por p=none, que no rechaza nada pero devuelve informes. Se leen esos informes durante unas semanas, se corrige, y luego se pasa a p=quarantine y después a p=reject. Ir directamente a p=reject bloquea tus propios mensajes.

Hay un punto que estos tres registros no resuelven por sí solos: la alineación. La dirección del From: visible tiene que pertenecer al mismo dominio que valida SPF y que firma DKIM. Un formulario de contacto que pone la dirección del visitante en el From: rompe la alineación y falla el DMARC del dominio del visitante. Lo correcto es enviar desde tu dominio y poner al visitante en Reply-To:.

Enviar por SMTP autenticado con PHPMailer

PHPMailer se encarga de SMTP, de la codificación, de los adjuntos y de la firma DKIM. Es la biblioteca más extendida y tiene mantenimiento activo.

bash
composer require phpmailer/phpmailer
src/envoi.php
<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as MailException;

$mail = new PHPMailer(true); // true: los errores lanzan una excepción

try {
    $mail->isSMTP();
    $mail->Host       = getenv('SMTP_HOST') ?: throw new RuntimeException('SMTP_HOST manquant');
    $mail->Port       = (int) (getenv('SMTP_PORT') ?: 587);
    $mail->SMTPAuth   = true;
    $mail->Username   = getenv('SMTP_USER') ?: '';
    $mail->Password   = getenv('SMTP_PASS') ?: '';
    $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // 587
    $mail->CharSet    = PHPMailer::CHARSET_UTF8;
    $mail->Timeout    = 15;

    // Firma DKIM
    $mail->DKIM_domain   = 'exemple.com';
    $mail->DKIM_private  = '/chemin/hors/depot/dkim-prive.pem';
    $mail->DKIM_selector = 'gk2026';
    $mail->DKIM_identity = 'contact@exemple.com';

    $mail->setFrom('contact@exemple.com', 'Exemple');
    $mail->addAddress('lecteur@example.com', 'Lecteur');
    $mail->addReplyTo('contact@exemple.com');

    $mail->Subject = 'Confirmation de votre inscription';
    $mail->isHTML(true);
    $mail->Body    = '<h1>Bienvenue</h1><p>Votre compte est actif.</p>';
    $mail->AltBody = "Bienvenue\nVotre compte est actif.";

    $mail->send();
} catch (MailException $e) {
    error_log('Envoi impossible : ' . $mail->ErrorInfo);
    throw new RuntimeException('Envoi impossible', previous: $e);
}

La versión en texto plano (AltBody) no es opcional. Un mensaje solo en HTML es una señal negativa para los filtros, y algunos clientes de correo únicamente muestran el texto.

En nuestro entorno de pruebas, el mensaje sale en 148 ms y llega con esta cabecera:

code
DKIM-Signature: v=1; d=gekkode.com; s=gk2026;
 a=rsa-sha256; q=dns/txt; t=1788378681; c=relaxed/simple;
 h=Date:To:From:Reply-To:Subject:Message-ID:X-Mailer:MIME-Version:Content-Type;
 i=contact@gekkode.com;
 bh=amLL8hgBhL3CiuV3ieZS24St8xKkkSLjcdpx2cHdvoI=;

Lo mismo con Symfony Mailer

Si el proyecto ya usa componentes de Symfony, Symfony Mailer se integra mejor y gestiona la cola de envío.

bash
composer require symfony/mailer
php
<?php

declare(strict_types=1);

use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;

$dsn       = getenv('MAILER_DSN'); // smtp://usuario:contrasena@host:587
$transport = Transport::fromDsn($dsn);
$mailer    = new Mailer($transport);

$email = (new Email())
    ->from('contact@exemple.com')
    ->to('lecteur@example.com')
    ->replyTo('contact@exemple.com')
    ->subject('Confirmation de votre inscription')
    ->text("Bienvenue\nVotre compte est actif.")
    ->html('<h1>Bienvenue</h1><p>Votre compte est actif.</p>');

$mailer->send($email);

Probar sin ensuciar buzones reales

Mandar pruebas a direcciones reales estropea la reputación del dominio. Un servidor SMTP de pruebas lo intercepta todo y ofrece una interfaz web. Mailpit hace exactamente eso, en un contenedor.

docker-compose.yml
services:
  mail:
    image: axllent/mailpit:latest
    ports:
      - "8025:8025"   # interfaz web
      - "1025:1025"   # SMTP
    environment:
      MP_SMTP_AUTH_ACCEPT_ANY: "1"
      MP_SMTP_AUTH_ALLOW_INSECURE: "1"

La aplicación apunta entonces a smtp://mail:1025, y todos los mensajes se abren en http://localhost:8025, con su fuente completa. Es la única forma de comprobar que la cabecera DKIM está ahí antes de pasar a producción.

El formulario de contacto, un caso aparte

Tres errores se repiten siempre, y los tres tienen consecuencias.

php
// No hagas esto
$mail->setFrom($_POST['email']);                    // rompe SPF y DKIM
$mail->Subject = $_POST['sujet'];                   // inyección de cabeceras
$mail->addAddress($_POST['destinataire']);          // relé abierto

// Correcto
$expediteur = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($expediteur === false) {
    throw new InvalidArgumentException('Adresse invalide');
}

$mail->setFrom('contact@exemple.com', 'Formulaire du site'); // siempre tu dominio
$mail->addReplyTo($expediteur);                              // la respuesta irá al visitante
$mail->addAddress('contact@exemple.com');                    // destinatario fijo
$mail->Subject = 'Message du formulaire de contact';         // asunto fijo
$mail->Body    = nl2br(htmlspecialchars(
    (string) ($_POST['message'] ?? ''),
    ENT_QUOTES | ENT_SUBSTITUTE,
    'UTF-8',
));

La inyección de cabeceras merece un comentario. Un salto de línea colado en un campo que se pasaba a mail() permitía añadir destinatarios ocultos y convertir el formulario en un relé de spam. Hemos enviado el asunto "Sujet normalrnBcc: victime@example.com" con las dos bibliotecas: ninguna lanza una excepción, pero ninguna crea la cabecera Bcc. Neutralizan el salto de línea al codificar.

code
PHPMailer 7.1.1  -> Subject: Sujet normalBcc: victime@example.com
Symfony Mailer   -> Subject: Sujet =?utf-8?Q?normal?=
Dans les deux cas : aucun en-tête Bcc dans le message livré.

El resultado es seguro, pero nadie te lo dice: el asunto llega deformado sin ningún aviso. Validar los campos antes sigue siendo necesario, y montar las cabeceras a mano sigue estando descartado.

Cuándo pasar a un servicio de envío

Un SMTP montado a mano en un VPS funciona, hasta el día en que la dirección IP acaba en una lista negra. Los proveedores de envío transaccional, Brevo, Postmark, Amazon SES, Mailgun, gestionan la reputación de las direcciones IP, los rebotes y los informes DMARC. Se usan por SMTP clásico, y entonces el código de arriba no cambia, o mediante su API.

Es el terreno de Email Impact, el módulo de PrestaShop que publicamos para los correos transaccionales de una tienda: plantillas, entregabilidad y seguimiento de los envíos. Los mecanismos descritos aquí, SMTP autenticado, alineación del From:, firma DKIM, son exactamente aquellos sobre los que se apoya, y los mismos ajustes de DNS valen sea cual sea la herramienta de envío.

Enviar muchos mensajes

Un bucle que envía mil mensajes dentro de una petición HTTP termina en tiempo de espera agotado. Hace falta una cola: la petición registra los mensajes pendientes y una tarea programada los procesa por lotes.

php
// Un envío por lotes, llamado desde una tarea programada
$parLot = 30;
$aTraiter = $file->prochains($parLot);

foreach ($aTraiter as $message) {
    try {
        $mailer->send($message->email());
        $file->marquerEnvoye($message->id);
    } catch (Throwable $e) {
        $file->marquerEchec($message->id, $e->getMessage());
    }
    usleep(200_000); // respeta los límites de envío del proveedor
}

Un fallo en un mensaje nunca debe interrumpir el lote. Y cada mensaje de una lista de distribución tiene que llevar una cabecera List-Unsubscribe con un enlace de baja que funcione: es una exigencia de los grandes proveedores desde 2024, no un detalle de cortesía.

Resumen

  • mail(): para un script local del que no dependa nadie, y para nada más.
  • SMTP autenticado con PHPMailer o Symfony Mailer, con SPF, DKIM y DMARC publicados: el mínimo para que te entreguen.
  • Un servidor SMTP de pruebas en desarrollo, nunca direcciones reales.
  • Un servicio de envío transaccional en cuanto el volumen pase de unos pocos mensajes al día.

Para adjuntar un archivo enviado por un visitante, valídalo antes tal y como se explica en subir un archivo en PHP. Para manejar un servicio de envío por su API en lugar de por SMTP, consulta hacer una petición cURL en PHP. Las dos bibliotecas citadas aquí se instalan con Composer.

Consulta también validar una dirección de email y el hub de Desarrollo web.

Errores frecuentes

mail() sin servidor de correo Devuelve false con Sendmail exited with non-zero exit code 127. Es el caso por defecto de cualquier contenedor Docker y de una parte de los alojamientos.
El valor true de mail() no significa «entregado» Significa «aceptado por el programa local». El rechazo del servidor remoto es invisible desde PHP.
From con el nombre del visitante Rompe la alineación de SPF y DKIM, y hace fallar el DMARC del dominio del visitante. El remitente es tu dominio, el visitante va en Reply-To.
DMARC puesto directamente en p=reject Bloquea tus propios mensajes. Se empieza por p=none, se leen los informes y luego se endurece.
Mensaje solo en HTML Señal negativa para los filtros. Facilita siempre una versión en texto con AltBody.
Inyección de cabeceras El asunto "SujetrnBcc: victime@example.com" no crea ninguna cabecera Bcc ni con PHPMailer ni con Symfony Mailer, pero ninguna de las dos lanza una excepción: el asunto llega deformado y sin aviso.
Envío masivo dentro de una petición HTTP Acaba en tiempo de espera agotado. Hace falta una cola procesada por lotes desde una tarea programada.

DélivrabilitéDKIMEmailPHPSMTP

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.