
Nie używaj mail(): bez lokalnego serwera pocztowego zwraca false, a jej wiadomości nie przechodzą już przez filtry. Wysyłaj przez uwierzytelniony SMTP z PHPMailerem albo Symfony Mailerem i opublikuj na domenie SPF, DKIM i DMARC. From musi należeć do twojej domeny, a adres odwiedzającego trafia do Reply-To.
Wysłanie e-maila z poziomu PHP jest łatwe. Dużo trudniej sprawić, żeby trafił do skrzynki odbiorczej. Funkcja mail(), którą niemal każdy poradnik pokazuje jako pierwszą, zawodzi w 2026 roku na obu polach: nie działa bez lokalnego serwera pocztowego, a kiedy działa, jej wiadomości lądują w spamie. Ten artykuł pokazuje, co naprawdę działa i dlaczego.
Dlaczego mail() już nie wystarcza
Funkcja mail() nie mówi po SMTP. Oddaje robotę lokalnemu programowi sendmail, wskazanemu przez dyrektywę sendmail_path. Jeśli na maszynie nie ma serwera pocztowego, nie dzieje się nic.
<?php
echo ini_get('sendmail_path'), "\n"; // /usr/sbin/sendmail -t -i
$ok = mail('destinataire@example.com', 'Test', 'Message de test');
var_dump($ok);Na standardowym kontenerze z PHP 8.5, bez zainstalowanego MTA:
sh: 1: /usr/sbin/sendmail: not found
Warning: mail(): Sendmail exited with non-zero exit code 127
bool(false)To domyślna sytuacja każdego kontenera Dockera, większości obrazów deweloperskich i części hostingów. Kod nie sygnalizuje niczego wprost: zwraca false, a zwracana wartość jest prawie zawsze ignorowana.
Nawet gdy MTA istnieje, true nie znaczy „wiadomość dostarczona”. Znaczy „wiadomość została przyjęta przez lokalny program”. Co dzieje się dalej, odmowa zdalnego serwera, kwarantanna, ciche odrzucenie, jest z poziomu PHP niewidoczne.
Prawdziwy problem: uwierzytelnienie domeny
Od lutego 2024 roku Gmail i Yahoo wymagają od nadawców masowych pełnego uwierzytelnienia. Microsoft dołączył w 2025 roku. Wiadomość, która nie jest ani podpisana, ani wyrównana, już nie dochodzi albo ląduje w spamie. Składają się na to trzy mechanizmy.
SPF
Rekord DNS typu TXT na twojej domenie, który wymienia serwery uprawnione do wysyłki w jej imieniu.
exemple.com. IN TXT "v=spf1 include:_spf.mon-fournisseur.com -all"Końcowe -all znaczy „każdy inny serwer jest nieuprawniony”. ~all jest łagodniejsze i wystarcza na czas wdrożenia, ale celem jest -all.
DKIM
Podpis kryptograficzny dodawany w nagłówku wiadomości. Odbiorca pobiera klucz publiczny z DNS-u i sprawdza, że wiadomość nie została zmieniona i naprawdę pochodzi z deklarowanej domeny.
# Wygenerowanie pary kluczy
openssl genrsa -out dkim-prive.pem 2048
openssl rsa -in dkim-prive.pem -pubout -out dkim-public.pemKlucz publiczny publikuje się pod <selecteur>._domainkey.exemple.com:
gk2026._domainkey.exemple.com. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."Klucz prywatny zostaje na serwerze, poza repozytorium Git, czytelny wyłącznie dla użytkownika, który uruchamia PHP.
DMARC
Polityka, która mówi odbiorcy, co zrobić, gdy SPF i DKIM zawiodą, i dokąd wysłać raporty.
_dmarc.exemple.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@exemple.com; adkim=s; aspf=s"Zaczyna się zawsze od p=none, które niczego nie odrzuca, ale generuje raporty. Czyta się je przez kilka tygodni, poprawia, co trzeba, potem przechodzi na p=quarantine, a na końcu na p=reject. Wejście od razu w p=reject blokuje twoje własne wiadomości.
To, czego te trzy rekordy nie załatwiają same, nazywa się wyrównaniem: adres z widocznego From: musi należeć do tej samej domeny, którą potwierdza SPF i podpisuje DKIM. Formularz kontaktowy, który wstawia adres odwiedzającego w From:, łamie wyrównanie i przewraca DMARC jego własnej domeny. Właściwa forma to wysyłka z twojej domeny, a odwiedzający trafia do Reply-To:.
Wysyłka przez uwierzytelniony SMTP z PHPMailerem
PHPMailer obsługuje SMTP, kodowanie, załączniki i podpis DKIM. To najpowszechniej używana z tych dwóch bibliotek i jest utrzymywana.
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: błędy rzucają wyjątek
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;
// Podpis 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);
}Wersja w czystym tekście (AltBody) nie jest opcjonalna. Wiadomość wyłącznie w HTML-u to negatywny sygnał dla filtrów, a część czytników pokazuje sam tekst.
Na naszym środowisku testowym wiadomość wychodzi w 148 ms i dociera z takim nagłówkiem:
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=;To samo w Symfony Mailerze
Jeśli projekt korzysta już z komponentów Symfony, Symfony Mailer wpasowuje się lepiej i sam zajmuje się kolejką.
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);Testy bez zaśmiecania prawdziwych skrzynek
Wysyłanie prób na realne adresy psuje reputację domeny. Testowy serwer SMTP przechwytuje wszystko i udostępnia interfejs webowy. Mailpit robi to w jednym kontenerze.
services:
mail:
image: axllent/mailpit:latest
ports:
- "8025:8025" # interfejs webowy
- "1025:1025" # SMTP
environment:
MP_SMTP_AUTH_ACCEPT_ANY: "1"
MP_SMTP_AUTH_ALLOW_INSECURE: "1"Aplikacja wskazuje wtedy na smtp://mail:1025, a wszystkie wiadomości otwierają się pod http://localhost:8025, razem z pełnym źródłem. To jedyny sposób, żeby przed wejściem na produkcję sprawdzić, czy nagłówek DKIM naprawdę tam jest.
Formularz kontaktowy, przypadek szczególny
Trzy błędy wracają regularnie i każdy z nich ma konsekwencje.
// Tak nie rób
$mail->setFrom($_POST['email']); // łamie SPF i DKIM
$mail->Subject = $_POST['sujet']; // wstrzyknięcie nagłówków
$mail->addAddress($_POST['destinataire']); // otwarty przekaźnik
// Poprawnie
$expediteur = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($expediteur === false) {
throw new InvalidArgumentException('Adresse invalide');
}
$mail->setFrom('contact@exemple.com', 'Formulaire du site'); // zawsze twoja domena
$mail->addReplyTo($expediteur); // odpowiedź trafi do odwiedzającego
$mail->addAddress('contact@exemple.com'); // stały odbiorca
$mail->Subject = 'Message du formulaire de contact'; // stały temat
$mail->Body = nl2br(htmlspecialchars(
(string) ($_POST['message'] ?? ''),
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8',
));Wstrzyknięcie nagłówków zasługuje na osobne słowo. Złamanie wiersza wsunięte w pole przekazywane do mail() pozwalało dopisać ukrytych odbiorców i zamienić formularz w przekaźnik spamu. Wysłaliśmy temat "Sujet normalrnBcc: victime@example.com" obiema bibliotekami: żadna nie rzuca wyjątku, ale żadna też nie tworzy nagłówka Bcc. Obie neutralizują złamanie wiersza przy kodowaniu.
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é.Wynik jest bezpieczny, tylko że nikt o nim nie informuje: temat dociera zniekształcony, bez żadnego ostrzeżenia. Walidacja pól na wejściu nadal jest konieczna, a samodzielne sklejanie nagłówków nadal odpada.
Kiedy przejść na usługę wysyłkową
Własny SMTP postawiony na VPS-ie działa, do dnia, w którym adres IP trafia na czarną listę. Dostawcy wysyłki transakcyjnej, Brevo, Postmark, Amazon SES, Mailgun, pilnują reputacji adresów IP, pętli zwrotnych i raportów DMARC. Używa się ich albo po zwykłym SMTP, i wtedy powyższy kod się nie zmienia, albo przez API.
To teren Email Impact, modułu do PrestaShopa, który wydajemy z myślą o e-mailach transakcyjnych sklepu: szablony, dostarczalność i śledzenie wysyłek. Opisane tutaj mechanizmy, uwierzytelniony SMTP, wyrównanie From:, podpis DKIM, to dokładnie te, na których się opiera, a te same ustawienia DNS obowiązują niezależnie od narzędzia do wysyłki.
Wysyłka dużej liczby wiadomości
Pętla, która wysyła tysiąc wiadomości w jednym żądaniu HTTP, kończy się przekroczeniem limitu czasu. Potrzebna jest kolejka: żądanie zapisuje wiadomości do wysłania, a zaplanowane zadanie przetwarza je partiami.
// Wysyłka partiami, wywoływana z zaplanowanego zadania
$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); // mieści się w limitach dostawcy
}Niepowodzenie jednej wiadomości nigdy nie może przerwać całej partii. A każda wiadomość z listy mailingowej musi nieść nagłówek List-Unsubscribe z działającym linkiem do wypisania się: to wymóg dużych dostawców od 2024 roku, nie uprzejmość.
Podsumowanie
mail(): do lokalnego skryptu, od którego nikt nie zależy, i do niczego więcej.- Uwierzytelniony SMTP przez PHPMailera albo Symfony Mailera, z opublikowanymi SPF, DKIM i DMARC: minimum, żeby wiadomość dotarła.
- Testowy serwer SMTP na czas developmentu, nigdy prawdziwe adresy.
- Usługa wysyłki transakcyjnej, gdy tylko wolumen przekroczy kilka wiadomości dziennie.
Żeby dołączyć plik przesłany przez odwiedzającego, najpierw go zwaliduj, tak jak opisuje upload pliku w PHP. Żeby sterować usługą wysyłkową przez jej API zamiast po SMTP, zobacz zapytanie cURL w PHP. Obie wymienione tu biblioteki instaluje się Composerem.
Zobacz też walidację adresu e-mail oraz hub Programowanie webowe.
Częste błędy
false z komunikatem Sendmail exited with non-zero exit code 127. To domyślny przypadek każdego kontenera Dockera i części hostingów.Reply-To.p=none, czyta raporty, a dopiero potem zaostrza politykę.AltBody."SujetrnBcc: victime@example.com" nie tworzy nagłówka Bcc ani w PHPMailerze, ani w Symfony Mailerze, ale żaden z nich nie rzuca wyjątku: temat dociera zniekształcony i nic o tym nie mówi.

