
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
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:
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.
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.
# Generar el par de claves
openssl genrsa -out dkim-prive.pem 2048
openssl rsa -in dkim-prive.pem -pubout -out dkim-public.pemLa clave pública se publica en <selecteur>._domainkey.exemple.com:
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.
_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.
composer require phpmailer/phpmailer<?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:
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.
composer require symfony/mailer<?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.
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.
// 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.
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.
// 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
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.Reply-To.p=none, se leen los informes y luego se endurece.AltBody."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.

