Wysyłanie e-maila w PHP: SMTP, DKIM i dostarczalność

Wysyłanie wiadomości elektronicznych to bardzo częste zadanie aplikacji webowej: e-mail powitalny, kiedy użytkownik zakłada konto na twojej stronie, newslettery do zarejestrowanych użytkowników albo zwykły formularz kontaktowy. Możesz użyć wbudowanej funkcji PHP

Wysyłanie e-maila w PHP: SMTP, DKIM i dostarczalność
Szybka odpowiedź

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

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

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

bash
# Wygenerowanie pary kluczy
openssl genrsa -out dkim-prive.pem 2048
openssl rsa -in dkim-prive.pem -pubout -out dkim-public.pem

Klucz publiczny publikuje się pod <selecteur>._domainkey.exemple.com:

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

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

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

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

To samo w Symfony Mailerze

Jeśli projekt korzysta już z komponentów Symfony, Symfony Mailer wpasowuje się lepiej i sam zajmuje się kolejką.

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

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.

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

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

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

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.

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

mail() bez serwera pocztowego Zwraca false z komunikatem Sendmail exited with non-zero exit code 127. To domyślny przypadek każdego kontenera Dockera i części hostingów.
Zwrócone przez mail() true nie znaczy „dostarczone” Znaczy „przyjęte przez lokalny program”. Odmowa zdalnego serwera jest z poziomu PHP niewidoczna.
From z adresem odwiedzającego Łamie wyrównanie SPF i DKIM oraz przewraca DMARC domeny odwiedzającego. Nadawcą jest twoja domena, odwiedzający trafia do Reply-To.
DMARC od razu ustawiony na p=reject Blokuje twoje własne wiadomości. Zaczyna się od p=none, czyta raporty, a dopiero potem zaostrza politykę.
Wiadomość wyłącznie w HTML-u Negatywny sygnał dla filtrów. Zawsze dołączaj wersję tekstową w AltBody.
Wstrzyknięcie nagłówka Temat "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.
Wysyłka masowa w żądaniu HTTP Kończy się przekroczeniem limitu czasu. Potrzebna jest kolejka przetwarzana partiami przez zaplanowane zadanie.

DélivrabilitéDKIMEmailPHPSMTP

Damien Flandrin Web developer od 2010 roku, twórca Gekkode i Email Impact. Każdy artykuł jest sprawdzany na prawdziwym projekcie przed publikacją. Kontakt
Newsletter

Nowe testy, poradniki i projekty — e-mailem.

Powtarzalne testy, wersjonowany kod, datowane wyniki. Nigdy spamu.