E-mail versturen met PHP: SMTP, DKIM en deliverability

Berichten versturen is heel gewoon voor een webapplicatie: een welkomstmail wanneer een gebruiker een account aanmaakt op je site, een nieuwsbrief naar je ingeschreven gebruikers, of gewoon het contactformulier van de site, enzovoort. Je kunt daarvoor de ingebouwde PHP-functie

E-mail versturen met PHP: SMTP, DKIM en deliverability
Kort antwoord

Gebruik mail() niet: zonder lokale mailserver geeft ze false terug, en haar berichten komen niet meer langs de filters. Verstuur via geauthenticeerde SMTP met PHPMailer of Symfony Mailer, en publiceer SPF, DKIM en DMARC op het domein. De From moet bij jouw domein horen, het adres van de bezoeker gaat in Reply-To.

Een e-mail versturen vanuit PHP is makkelijk. Hem in de inbox laten aankomen een stuk minder. De functie mail(), die zowat elke tutorial als eerste laat zien, faalt in 2026 op allebei die punten: zonder lokale mailserver werkt ze niet, en werkt ze wel, dan belanden haar berichten in de spam. Dit artikel laat zien wat wel werkt, en waarom.

Waarom mail() niet meer volstaat

De functie mail() spreekt geen SMTP. Ze geeft het werk door aan een lokale binary, sendmail, aangewezen door de directive sendmail_path. Staat er geen mailserver op de machine, dan gebeurt er niets.

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

Op een standaard PHP 8.5-container, zonder MTA:

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

Dat is de standaardsituatie van elke Docker-container, van de meeste ontwikkelimages en van een deel van de hostingpakketten. De code meldt niets expliciets: ze geeft false terug, en die retourwaarde wordt bijna altijd genegeerd.

Ook als er wél een MTA draait, betekent true niet ‘bericht afgeleverd’. Het betekent ‘het bericht is aangenomen door het lokale programma’. Wat er daarna gebeurt, weigering door de ontvangende server, quarantaine, stille afwijzing, is vanuit PHP onzichtbaar.

Het echte probleem: authenticatie van het domein

Sinds februari 2024 eisen Gmail en Yahoo volledige authenticatie van afzenders met volume. Microsoft volgde in 2025. Een bericht dat niet ondertekend en niet uitgelijnd is, komt niet meer aan of belandt in de spam. Drie mechanismen werken samen.

SPF

Een DNS-record van het type TXT op je domein, met daarin de servers die namens dat domein mogen versturen.

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

De afsluitende -all betekent ‘elke andere server is illegitiem’. Een ~all is toegeeflijker en volstaat tijdens de opzet, maar -all is het doel.

DKIM

Een cryptografische handtekening die als header aan het bericht wordt toegevoegd. De ontvanger haalt de publieke sleutel uit het DNS en controleert of het bericht niet is gewijzigd en echt van het aangekondigde domein komt.

bash
# Het sleutelpaar genereren
openssl genrsa -out dkim-prive.pem 2048
openssl rsa -in dkim-prive.pem -pubout -out dkim-public.pem

De publieke sleutel publiceer je op <selecteur>._domainkey.exemple.com:

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

De privésleutel blijft op de server, buiten de Git-repository, en is alleen leesbaar voor de gebruiker die PHP draait.

DMARC

Het beleid dat de ontvanger vertelt wat te doen als SPF en DKIM mislukken, en waar de rapporten heen moeten.

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

Je begint altijd met p=none: dat wijst niets af, maar levert wel rapporten op. Die lees je een paar weken, je corrigeert wat er stukzit, en daarna ga je naar p=quarantine en vervolgens naar p=reject. Meteen op p=reject springen blokkeert je eigen berichten.

Wat die drie records op zichzelf niet oplossen, heet alignment: het zichtbare adres in From: moet bij hetzelfde domein horen als het domein dat SPF valideert en DKIM ondertekent. Een contactformulier dat het adres van de bezoeker in From: zet, breekt het alignment en valt door het DMARC van diens eigen domein. Goed is: versturen vanaf jouw domein, en de bezoeker in Reply-To:.

Versturen via geauthenticeerde SMTP met PHPMailer

PHPMailer regelt SMTP, de encoding, bijlagen en de DKIM-handtekening. Het is de meest gebruikte library en ze wordt onderhouden.

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: fouten gooien een 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;

    // DKIM-handtekening
    $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);
}

De versie in platte tekst (AltBody) is niet optioneel. Een bericht dat alleen uit HTML bestaat, is een negatief signaal voor de filters, en sommige mailclients tonen alleen de tekst.

In onze testomgeving vertrekt het bericht in 148 ms en komt het aan met de volgende header:

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

Hetzelfde met Symfony Mailer

Gebruikt het project al Symfony-componenten, dan integreert Symfony Mailer beter en neemt het de wachtrij voor zijn rekening.

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

Testen zonder echte mailboxen te vervuilen

Testberichten naar echte adressen sturen beschadigt de reputatie van je domein. Een test-SMTP-server vangt alles op en biedt een webinterface. Mailpit doet dat in één container.

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

De applicatie wijst dan naar smtp://mail:1025, en alle berichten open je op http://localhost:8025, met hun volledige bron. Dat is de enige manier om te controleren of er echt een DKIM-header in zit voordat je naar productie gaat.

Het contactformulier, een geval apart

Drie fouten komen stelselmatig terug, en alle drie hebben gevolgen.

php
// Zo moet het niet
$mail->setFrom($_POST['email']);                    // breekt SPF en DKIM
$mail->Subject = $_POST['sujet'];                   // header-injectie
$mail->addAddress($_POST['destinataire']);          // open relay

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

$mail->setFrom('contact@exemple.com', 'Formulaire du site'); // altijd jouw domein
$mail->addReplyTo($expediteur);                              // het antwoord gaat naar de bezoeker
$mail->addAddress('contact@exemple.com');                    // vaste ontvanger
$mail->Subject = 'Message du formulaire de contact';         // vast onderwerp
$mail->Body    = nl2br(htmlspecialchars(
    (string) ($_POST['message'] ?? ''),
    ENT_QUOTES | ENT_SUBSTITUTE,
    'UTF-8',
));

Header-injectie verdient een aparte alinea. Een regeleinde dat in een veld werd gesmokkeld en aan mail() werd doorgegeven, maakte het mogelijk verborgen ontvangers toe te voegen en het formulier in een spamrelay te veranderen. Wij stuurden het onderwerp "Sujet normalrnBcc: victime@example.com" door beide libraries: geen van beide gooit een exception, en geen van beide maakt de header Bcc aan. Ze neutraliseren het regeleinde bij het encoderen.

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

Het resultaat is veilig, maar er wordt niets over gemeld: het onderwerp komt vervormd aan zonder dat iets waarschuwt. De velden vooraf valideren blijft nodig, en headers zelf in elkaar zetten blijft uit den boze.

Wanneer je een verzenddienst inschakelt

Een zelf opgezette SMTP op een VPS werkt, tot de dag dat het IP-adres op een blacklist belandt. Aanbieders van transactionele e-mail, Brevo, Postmark, Amazon SES, Mailgun, beheren de reputatie van de IP-adressen, de bounceafhandeling en de DMARC-rapporten. Je gebruikt ze via gewone SMTP, waarbij de code hierboven niet verandert, of via hun API.

Dat is het terrein van Email Impact, de PrestaShop-module die wij uitgeven voor de transactionele e-mails van een webshop: templates, deliverability en opvolging van de verzendingen. De mechanismen die hier beschreven staan, geauthenticeerde SMTP, alignment van de From:, DKIM-handtekening, zijn precies die waarop de module rust, en dezelfde DNS-instellingen gelden ongeacht welk verzendmiddel je kiest.

Veel berichten versturen

Een lus die duizend berichten in één HTTP-request verstuurt, eindigt in een timeout. Er moet een wachtrij komen: het request slaat de te versturen berichten op, een cronjob werkt ze in batches af.

php
// Een batchverzending, aangeroepen vanuit een geplande taak
$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); // blijf binnen de rate limits van de provider
}

Eén mislukt bericht mag de batch nooit onderbreken. En elk bericht van een mailinglijst moet een header List-Unsubscribe dragen met een werkende afmeldlink: dat is sinds 2024 een eis van de grote providers, geen beleefdheid.

Samenvatting

  • mail(): voor een lokaal script waar niemand van afhangt, en verder niets.
  • Geauthenticeerde SMTP via PHPMailer of Symfony Mailer, met SPF, DKIM en DMARC gepubliceerd: het minimum om afgeleverd te worden.
  • Een test-SMTP-server tijdens de ontwikkeling, nooit echte adressen.
  • Een dienst voor transactionele e-mail zodra het volume boven een paar berichten per dag uitkomt.

Wil je een bestand meesturen dat een bezoeker heeft opgestuurd, valideer het dan eerst zoals beschreven in een bestand uploaden in PHP. Wil je een verzenddienst via de API aansturen in plaats van via SMTP, zie dan een cURL-request in PHP. De twee libraries uit dit artikel installeer je met Composer.

Zie ook een e-mailadres valideren en de hub Webontwikkeling.

Veelgemaakte fouten

mail() zonder mailserver Geeft false terug met Sendmail exited with non-zero exit code 127. Dat is het standaardgeval van elke Docker-container en van een deel van de hostingpakketten.
De waarde true van mail() betekent niet ‘afgeleverd’ Ze betekent ‘aangenomen door het lokale programma’. De weigering door de ontvangende server is vanuit PHP onzichtbaar.
From op naam van de bezoeker Breekt het alignment van SPF en DKIM, en laat het DMARC van het domein van de bezoeker mislukken. De afzender is jouw domein, de bezoeker gaat in Reply-To.
DMARC meteen op p=reject gezet Blokkeert je eigen berichten. Je begint met p=none, leest de rapporten en verscherpt daarna.
Bericht met alleen HTML Negatief signaal voor de filters. Lever altijd een tekstversie in AltBody.
Header-injectie Het onderwerp "SujetrnBcc: victime@example.com" maakt geen header Bcc aan, niet met PHPMailer en niet met Symfony Mailer, maar geen van beide gooit een exception: het onderwerp komt vervormd aan zonder waarschuwing.
Massaverzending in één HTTP-request Eindigt in een timeout. Er moet een wachtrij komen die in batches vanuit een cronjob wordt afgewerkt.

DélivrabilitéDKIMEmailPHPSMTP

Damien Flandrin Webdeveloper sinds 2010, maker van Gekkode en Email Impact. Elk artikel wordt vóór publicatie getest op een echt project. Contact
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.