
N'utilisez pas mail() : sans serveur de messagerie local elle renvoie false, et ses messages ne passent plus les filtres. Passez par SMTP authentifié avec PHPMailer ou Symfony Mailer, et publiez SPF, DKIM et DMARC sur le domaine. Le From doit appartenir à votre domaine, l'adresse du visiteur va en Reply-To.
Envoyer un e-mail depuis PHP est facile. Le faire arriver dans la boîte de réception l’est beaucoup moins. La fonction mail(), que presque tous les tutoriels montrent en premier, échoue sur les deux plans en 2026 : elle ne fonctionne pas sans serveur de messagerie local, et quand elle fonctionne, ses messages finissent en indésirables. Cet article montre ce qui marche vraiment, et pourquoi.
Pourquoi mail() ne suffit plus
La fonction mail() ne parle pas SMTP. Elle passe la main à un binaire local, sendmail, désigné par la directive sendmail_path. S’il n’y a pas de serveur de messagerie sur la machine, il ne se passe rien.
<?php
echo ini_get('sendmail_path'), "\n"; // /usr/sbin/sendmail -t -i
$ok = mail('destinataire@example.com', 'Test', 'Message de test');
var_dump($ok);Sur un conteneur PHP 8.5 standard, sans MTA installé :
sh: 1: /usr/sbin/sendmail: not found
Warning: mail(): Sendmail exited with non-zero exit code 127
bool(false)C’est la situation par défaut de tout conteneur Docker, de la plupart des images de développement, et d’une partie des hébergements. Le code ne signale rien d’explicite : il renvoie false, et la valeur de retour est presque toujours ignorée.
Même quand un MTA existe, true ne veut pas dire « message délivré ». Il veut dire « le message a été accepté par le programme local ». Ce qui se passe ensuite, refus du serveur distant, mise en quarantaine, rejet silencieux, est invisible depuis PHP.
Le vrai problème : l’authentification du domaine
Depuis février 2024, Gmail et Yahoo imposent aux expéditeurs de volume une authentification complète. Microsoft a suivi en 2025. Un message qui n’est ni signé ni aligné n’arrive plus, ou arrive en indésirables. Trois mécanismes se combinent.
SPF
Un enregistrement DNS de type TXT sur votre domaine, qui liste les serveurs autorisés à envoyer en son nom.
exemple.com. IN TXT "v=spf1 include:_spf.mon-fournisseur.com -all"Le -all final signifie « tout autre serveur est illégitime ». Un ~all est plus permissif et suffit pendant la mise en place, mais le -all est la cible.
DKIM
Une signature cryptographique ajoutée en en-tête du message. Le destinataire récupère la clé publique dans le DNS et vérifie que le message n’a pas été modifié et vient bien du domaine annoncé.
# Générer la paire de clés
openssl genrsa -out dkim-prive.pem 2048
openssl rsa -in dkim-prive.pem -pubout -out dkim-public.pemLa clé publique se publie sur <selecteur>._domainkey.exemple.com :
gk2026._domainkey.exemple.com. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."La clé privée reste sur le serveur, hors du dépôt Git, lisible seulement par l’utilisateur qui exécute PHP.
DMARC
La politique qui dit au destinataire quoi faire quand SPF et DKIM échouent, et où envoyer les rapports.
_dmarc.exemple.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@exemple.com; adkim=s; aspf=s"On commence toujours par p=none, qui ne rejette rien mais fait remonter des rapports. On lit ces rapports pendant quelques semaines, on corrige, puis on passe à p=quarantine, puis p=reject. Passer directement à p=reject bloque vos propres messages.
Le point que ces trois enregistrements ne résolvent pas seuls s’appelle l’alignement : l’adresse du From: visible doit appartenir au même domaine que celui validé par SPF et signé par DKIM. Un formulaire de contact qui met l’adresse du visiteur en From: casse l’alignement et échoue au DMARC de son domaine à lui. La bonne forme est d’envoyer depuis votre domaine et de mettre le visiteur en Reply-To:.
Envoyer en SMTP authentifié avec PHPMailer
PHPMailer gère SMTP, l’encodage, les pièces jointes et la signature DKIM. C’est la bibliothèque la plus répandue et elle est maintenue.
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 : les erreurs lèvent une exception
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;
// Signature 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 version en texte brut (AltBody) n’est pas facultative. Un message uniquement HTML est un signal négatif pour les filtres, et certains lecteurs n’affichent que le texte.
Sur notre environnement de test, le message part en 148 ms et arrive avec l’en-tête suivant :
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=;La même chose avec Symfony Mailer
Si le projet utilise déjà des composants Symfony, Symfony Mailer s’intègre mieux et gère la file d’attente.
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://utilisateur:motdepasse@hote: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);Tester sans polluer de vraies boîtes
Envoyer des essais vers des adresses réelles abîme la réputation du domaine. Un serveur SMTP de test intercepte tout et propose une interface web. Mailpit fait ça en un conteneur.
services:
mail:
image: axllent/mailpit:latest
ports:
- "8025:8025" # interface web
- "1025:1025" # SMTP
environment:
MP_SMTP_AUTH_ACCEPT_ANY: "1"
MP_SMTP_AUTH_ALLOW_INSECURE: "1"L’application pointe alors sur smtp://mail:1025, et tous les messages s’ouvrent sur http://localhost:8025, avec leur source complète. C’est le seul moyen de vérifier qu’un en-tête DKIM est bien présent avant de passer en production.
Le formulaire de contact, cas particulier
Trois erreurs reviennent systématiquement, et toutes ont des conséquences.
// À ne pas faire
$mail->setFrom($_POST['email']); // casse SPF et DKIM
$mail->Subject = $_POST['sujet']; // injection d'en-têtes
$mail->addAddress($_POST['destinataire']); // relais ouvert
// Correct
$expediteur = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($expediteur === false) {
throw new InvalidArgumentException('Adresse invalide');
}
$mail->setFrom('contact@exemple.com', 'Formulaire du site'); // toujours votre domaine
$mail->addReplyTo($expediteur); // la réponse ira au visiteur
$mail->addAddress('contact@exemple.com'); // destinataire fixe
$mail->Subject = 'Message du formulaire de contact'; // sujet fixe
$mail->Body = nl2br(htmlspecialchars(
(string) ($_POST['message'] ?? ''),
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8',
));L’injection d’en-têtes mérite un mot. Un retour à la ligne glissé dans un champ passé à mail() permettait d’ajouter des destinataires cachés et de transformer le formulaire en relais à pourriels. Nous avons envoyé le sujet "Sujet normalrnBcc: victime@example.com" avec les deux bibliothèques : aucune ne lève d’exception, mais aucune ne crée l’en-tête Bcc. Elles neutralisent la coupure de ligne à l’encodage.
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é.Le résultat est sûr, mais il n’est pas signalé : le sujet arrive déformé sans que rien ne prévienne. Valider les champs en amont reste nécessaire, et assembler soi-même les en-têtes reste à proscrire.
Quand passer par un service d’envoi
Un SMTP monté soi-même sur un VPS fonctionne, jusqu’au jour où l’adresse IP se retrouve sur une liste noire. Les fournisseurs d’envoi transactionnel, Brevo, Postmark, Amazon SES, Mailgun, gèrent la réputation des adresses IP, les boucles de retour et les rapports DMARC. Ils s’utilisent soit en SMTP classique, le code ci-dessus ne change pas, soit par API.
C’est le terrain d’Email Impact, le module PrestaShop que nous éditons pour les e-mails transactionnels d’une boutique : modèles, délivrabilité et suivi des envois. Les mécanismes décrits ici, SMTP authentifié, alignement du From:, signature DKIM, sont exactement ceux sur lesquels il repose, et les mêmes réglages DNS s’appliquent quel que soit l’outil d’envoi.
Envoyer beaucoup de messages
Une boucle qui envoie mille messages dans une requête HTTP se termine en délai dépassé. Il faut une file : la requête enregistre les messages à envoyer, une tâche planifiée les traite par lots.
// Un envoi par lots, appelé depuis une tâche planifiée
$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); // respecte les limites de débit du fournisseur
}Un échec sur un message ne doit jamais interrompre le lot. Et chaque message d’une liste de diffusion doit porter un en-tête List-Unsubscribe avec un lien de désinscription fonctionnel : c’est une exigence des grands fournisseurs depuis 2024, pas une politesse.
Récapitulatif
mail(): pour un script local dont personne ne dépend, et rien d’autre.- SMTP authentifié via PHPMailer ou Symfony Mailer, avec SPF, DKIM et DMARC publiés : le minimum pour être délivré.
- Un serveur SMTP de test en développement, jamais de vraies adresses.
- Un service d’envoi transactionnel dès que le volume dépasse quelques messages par jour.
Pour joindre un fichier envoyé par un visiteur, validez-le d’abord comme décrit dans uploader un fichier en PHP. Pour piloter un service d’envoi par son API plutôt qu’en SMTP, voir faire une requête cURL en PHP. Les deux bibliothèques citées ici s’installent avec Composer.
Voir aussi valider une adresse e-mail et le hub Développement web.
Erreurs fréquentes
false avec Sendmail exited with non-zero exit code 127. C'est le cas par défaut de tout conteneur Docker et d'une partie des hébergements.Reply-To.p=none, on lit les rapports, puis on durcit.AltBody."SujetrnBcc: victime@example.com" ne crée pas d'en-tête Bcc avec PHPMailer ni Symfony Mailer, mais aucune des deux ne lève d'exception : le sujet arrive déformé sans avertir.

