
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
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:
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.
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.
# Het sleutelpaar genereren
openssl genrsa -out dkim-prive.pem 2048
openssl rsa -in dkim-prive.pem -pubout -out dkim-public.pemDe publieke sleutel publiceer je op <selecteur>._domainkey.exemple.com:
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.
_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.
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: 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:
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.
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);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.
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.
// 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.
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.
// 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
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.Reply-To.p=none, leest de rapporten en verscherpt daarna.AltBody."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.

