
Nimm nicht mail(): Ohne lokalen Mailserver gibt die Funktion false zurück, und ihre Nachrichten kommen durch die Filter nicht mehr durch. Versende über authentifiziertes SMTP mit PHPMailer oder Symfony Mailer und veröffentliche SPF, DKIM und DMARC auf der Domain. Das From muss zu deiner Domain gehören, die Adresse des Besuchers gehört ins Reply-To.
Eine E-Mail aus PHP heraus zu verschicken ist einfach. Sie in den Posteingang zu bekommen, deutlich weniger. Die Funktion mail(), die fast jedes Tutorial zuerst zeigt, scheitert 2026 an beidem: Ohne lokalen Mailserver tut sie nichts, und wenn sie funktioniert, landen ihre Nachrichten im Spam. Dieser Artikel zeigt, was wirklich trägt, und warum.
Warum mail() nicht mehr reicht
Die Funktion mail() spricht kein SMTP. Sie reicht die Arbeit an ein lokales Binary weiter, sendmail, festgelegt über die Direktive sendmail_path. Gibt es auf der Maschine keinen Mailserver, passiert schlicht nichts.
<?php
echo ini_get('sendmail_path'), "\n"; // /usr/sbin/sendmail -t -i
$ok = mail('destinataire@example.com', 'Test', 'Message de test');
var_dump($ok);In einem gewöhnlichen PHP-8.5-Container ohne installierten MTA:
sh: 1: /usr/sbin/sendmail: not found
Warning: mail(): Sendmail exited with non-zero exit code 127
bool(false)Das ist der Standardzustand jedes Docker-Containers, der meisten Entwicklungs-Images und eines Teils der Hostings. Der Code meldet nichts Deutliches: Er gibt false zurück, und dieser Rückgabewert wird fast immer ignoriert.
Selbst wenn ein MTA vorhanden ist, heißt true nicht „Nachricht zugestellt“. Es heißt „die Nachricht wurde vom lokalen Programm angenommen“. Was danach passiert, Ablehnung durch den entfernten Server, Quarantäne, stilles Verwerfen, ist aus PHP heraus unsichtbar.
Das eigentliche Problem: die Authentifizierung der Domain
Seit Februar 2024 verlangen Gmail und Yahoo von Versendern mit Volumen eine vollständige Authentifizierung. Microsoft ist 2025 nachgezogen. Eine Nachricht, die weder signiert noch ausgerichtet ist, kommt nicht mehr an oder landet im Spam. Drei Mechanismen greifen dabei ineinander.
SPF
Ein DNS-Eintrag vom Typ TXT auf deiner Domain, der die Server auflistet, die in ihrem Namen versenden dürfen.
exemple.com. IN TXT "v=spf1 include:_spf.mon-fournisseur.com -all"Das abschließende -all bedeutet „jeder andere Server ist unberechtigt“. Ein ~all ist nachsichtiger und reicht während der Einrichtung, aber das Ziel bleibt -all.
DKIM
Eine kryptografische Signatur, die den Kopfzeilen der Nachricht hinzugefügt wird. Der Empfänger holt sich den öffentlichen Schlüssel aus dem DNS und prüft, dass die Nachricht nicht verändert wurde und tatsächlich von der angegebenen Domain kommt.
# Schlüsselpaar erzeugen
openssl genrsa -out dkim-prive.pem 2048
openssl rsa -in dkim-prive.pem -pubout -out dkim-public.pemDer öffentliche Schlüssel wird unter <selecteur>._domainkey.exemple.com veröffentlicht:
gk2026._domainkey.exemple.com. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."Der private Schlüssel bleibt auf dem Server, außerhalb des Git-Repositorys, lesbar nur für den Benutzer, unter dem PHP läuft.
DMARC
Die Policy, die dem Empfänger sagt, was er tun soll, wenn SPF und DKIM scheitern, und wohin die Berichte gehen.
_dmarc.exemple.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@exemple.com; adkim=s; aspf=s"Man beginnt immer mit p=none: Das lehnt nichts ab, liefert aber Berichte. Die liest du ein paar Wochen lang, korrigierst, was auffällt, und stellst dann auf p=quarantine und schließlich auf p=reject um. Direkt mit p=reject zu starten blockiert deine eigenen Nachrichten.
Was diese drei Einträge allein nicht lösen, heißt Alignment: Die sichtbare Adresse im From: muss zu derselben Domain gehören, die SPF freigibt und die DKIM signiert. Ein Kontaktformular, das die Adresse des Besuchers ins From: schreibt, bricht das Alignment und scheitert am DMARC von dessen eigener Domain. Richtig ist: aus der eigenen Domain versenden und den Besucher ins Reply-To: setzen.
Authentifiziert per SMTP versenden mit PHPMailer
PHPMailer kümmert sich um SMTP, die Kodierung, die Anhänge und die DKIM-Signatur. Es ist die verbreitetste Bibliothek, und sie wird gepflegt.
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: Fehler werfen eine 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-Signatur
$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);
}Die Textfassung (AltBody) ist nicht optional. Eine reine HTML-Nachricht ist ein negatives Signal für die Filter, und manche Programme zeigen ohnehin nur den Text an.
In unserer Testumgebung geht die Nachricht in 148 ms raus und kommt mit folgender Kopfzeile an:
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=;Dasselbe mit Symfony Mailer
Wenn das Projekt ohnehin Symfony-Komponenten nutzt, fügt sich Symfony Mailer besser ein und bringt die Warteschlange gleich mit.
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, ohne echte Postfächer zuzumüllen
Testmails an echte Adressen ruinieren den Ruf der Domain. Ein Test-SMTP-Server fängt alles ab und bietet eine Weboberfläche dazu. Mailpit erledigt das in einem einzigen Container.
services:
mail:
image: axllent/mailpit:latest
ports:
- "8025:8025" # Weboberfläche
- "1025:1025" # SMTP
environment:
MP_SMTP_AUTH_ACCEPT_ANY: "1"
MP_SMTP_AUTH_ALLOW_INSECURE: "1"Die Anwendung zeigt dann auf smtp://mail:1025, und alle Nachrichten lassen sich unter http://localhost:8025 mitsamt vollständigem Quelltext öffnen. Nur so prüfst du vor dem Produktivgang, dass eine DKIM-Kopfzeile wirklich vorhanden ist.
Der Sonderfall Kontaktformular
Drei Fehler tauchen immer wieder auf, und jeder von ihnen hat Folgen.
// So nicht
$mail->setFrom($_POST['email']); // bricht SPF und DKIM
$mail->Subject = $_POST['sujet']; // Header-Injection
$mail->addAddress($_POST['destinataire']); // offenes Relay
// So ist es richtig
$expediteur = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($expediteur === false) {
throw new InvalidArgumentException('Adresse invalide');
}
$mail->setFrom('contact@exemple.com', 'Formulaire du site'); // immer deine Domain
$mail->addReplyTo($expediteur); // die Antwort geht an den Besucher
$mail->addAddress('contact@exemple.com'); // fester Empfänger
$mail->Subject = 'Message du formulaire de contact'; // fester Betreff
$mail->Body = nl2br(htmlspecialchars(
(string) ($_POST['message'] ?? ''),
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8',
));Die Header-Injection verdient eine eigene Bemerkung. Ein Zeilenumbruch, der in ein Feld geschmuggelt und an mail() weitergereicht wurde, erlaubte es, versteckte Empfänger anzuhängen und das Formular in ein Spam-Relay zu verwandeln. Wir haben den Betreff "Sujet normalrnBcc: victime@example.com" durch beide Bibliotheken geschickt: Keine wirft eine Exception, aber keine erzeugt die Kopfzeile Bcc. Sie entschärfen den Zeilenumbruch beim Kodieren.
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é.Das Ergebnis ist sicher, aber es wird nicht gemeldet: Der Betreff kommt verstümmelt an, ohne dass irgendetwas warnt. Die Felder vorher zu validieren bleibt nötig, und Kopfzeilen selbst zusammenzubauen bleibt verboten.
Wann ein Versanddienst dran ist
Ein selbst aufgesetztes SMTP auf einem VPS funktioniert, bis zu dem Tag, an dem die IP-Adresse auf einer Blacklist landet. Anbieter für transaktionalen Versand, Brevo, Postmark, Amazon SES, Mailgun, kümmern sich um die Reputation der IP-Adressen, um die Feedback-Loops und um die DMARC-Berichte. Nutzen lassen sie sich entweder klassisch per SMTP, der Code oben ändert sich dabei nicht, oder per API.
Das ist das Terrain von Email Impact, dem PrestaShop-Modul, das wir für die transaktionalen E-Mails eines Shops herausgeben: Vorlagen, Zustellbarkeit und Nachverfolgung der Sendungen. Die hier beschriebenen Mechanismen, authentifiziertes SMTP, Alignment des From:, DKIM-Signatur, sind genau die, auf denen es aufsetzt, und dieselben DNS-Einstellungen gelten unabhängig vom eingesetzten Werkzeug.
Viele Nachrichten versenden
Eine Schleife, die tausend Nachrichten innerhalb einer HTTP-Anfrage verschickt, endet im Timeout. Es braucht eine Warteschlange: Die Anfrage schreibt die zu versendenden Nachrichten weg, ein Cronjob arbeitet sie in Stapeln ab.
// Ein Stapelversand, aufgerufen aus einem Cronjob
$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); // bleibt in den Ratenlimits des Anbieters
}Ein Fehlschlag bei einer Nachricht darf den Stapel nie abbrechen. Und jede Nachricht eines Verteilers braucht eine Kopfzeile List-Unsubscribe mit einem funktionierenden Abmeldelink: Seit 2024 ist das eine Anforderung der großen Anbieter, keine Höflichkeit.
Zusammengefasst
mail(): für ein lokales Skript, von dem niemand abhängt, und für sonst nichts.- Authentifiziertes SMTP über PHPMailer oder Symfony Mailer, mit veröffentlichtem SPF, DKIM und DMARC: das Minimum, um zugestellt zu werden.
- In der Entwicklung ein Test-SMTP-Server, niemals echte Adressen.
- Ein Dienst für transaktionalen Versand, sobald das Volumen ein paar Nachrichten am Tag übersteigt.
Um eine Datei anzuhängen, die ein Besucher geschickt hat, prüfe sie zuerst so, wie es unter eine Datei in PHP hochladen beschrieben ist. Um einen Versanddienst über seine API statt per SMTP anzusteuern, siehe einen cURL-Request in PHP ausführen. Die beiden hier genannten Bibliotheken installierst du mit Composer.
Siehe auch eine E-Mail-Adresse validieren und den Hub Webentwicklung.
Häufige Fehler
false zurück, dazu Sendmail exited with non-zero exit code 127. Das ist der Standardfall jedes Docker-Containers und eines Teils der Hostings.Reply-To.p=none, liest die Berichte und zieht dann an.AltBody mitliefern."SujetrnBcc: victime@example.com" erzeugt weder mit PHPMailer noch mit Symfony Mailer eine Bcc-Kopfzeile, aber keine der beiden wirft eine Exception: Der Betreff kommt verstümmelt an, ohne Warnung.

