Envoyer un e-mail avec PHP

L’envoi de messages électroniques est très courant pour une application Web, par exemple, l’envoi d’un courriel de bienvenue lorsqu’un utilisateur crée un compte sur votre site Web, l’envoi de bulletins d’information à vos utilisateurs enregistrés, ou le simple formulaire de contact du site Web, et ainsi de suite. Vous pouvez utiliser la fonction PHP intégrée

Envoyer un e-mail avec PHP
Réponse rapide

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
<?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é :

code
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.

code
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é.

bash
# 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.pem

La clé publique se publie sur <selecteur>._domainkey.exemple.com :

code
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.

code
_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.

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

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=;

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.

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://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.

docker-compose.yml
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.

php
// À 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.

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é.

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.

php
// 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

mail() sans serveur de messagerie Renvoie 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.
La valeur true de mail() ne signifie pas « délivré » Elle signifie « accepté par le programme local ». Le refus du serveur distant est invisible depuis PHP.
From au nom du visiteur Casse l'alignement SPF et DKIM, et fait échouer le DMARC du domaine du visiteur. L'expéditeur est votre domaine, le visiteur va en Reply-To.
DMARC posé directement en p=reject Bloque vos propres messages. On commence par p=none, on lit les rapports, puis on durcit.
Message uniquement en HTML Signal négatif pour les filtres. Toujours fournir une version texte en AltBody.
Injection d'en-tête Le sujet "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.
Envoi en masse dans une requête HTTP Se termine en délai dépassé. Il faut une file traitée par lots depuis une tâche planifiée.

DélivrabilitéDKIMEmailPHPSMTP

Damien Flandrin Développeur web depuis 2010, créateur de Gekkode et d’Email Impact. Chaque article est testé sur un projet réel avant publication. Contact
Newsletter

Les nouveaux tests, tutoriels et projets, par e-mail.

Tests reproductibles, code versionné, résultats datés. Jamais de spam.