
Non usare mail(): senza un server di posta locale restituisce false, e i suoi messaggi non superano più i filtri. Passa da SMTP autenticato con PHPMailer o Symfony Mailer, e pubblica SPF, DKIM e DMARC sul dominio. Il From deve appartenere al tuo dominio, l'indirizzo del visitatore va in Reply-To.
Inviare un’e-mail da PHP è facile. Farla arrivare nella posta in arrivo lo è molto meno. La funzione mail(), che quasi tutti i tutorial mostrano per prima, nel 2026 fallisce su entrambi i fronti: non funziona senza un server di posta locale e, quando funziona, i suoi messaggi finiscono nello spam. Questo articolo mostra che cosa funziona davvero, e perché.
Perché mail() non basta più
La funzione mail() non parla SMTP. Passa la mano a un binario locale, sendmail, indicato dalla direttiva sendmail_path. Se sulla macchina non c’è un server di posta, non succede nulla.
<?php
echo ini_get('sendmail_path'), "\n"; // /usr/sbin/sendmail -t -i
$ok = mail('destinataire@example.com', 'Test', 'Message de test');
var_dump($ok);Su un container PHP 8.5 standard, senza MTA installato:
sh: 1: /usr/sbin/sendmail: not found
Warning: mail(): Sendmail exited with non-zero exit code 127
bool(false)È la situazione predefinita di qualsiasi container Docker, della maggior parte delle immagini di sviluppo e di una parte degli hosting. Il codice non segnala nulla di esplicito: restituisce false, e il valore restituito viene quasi sempre ignorato.
Anche quando un MTA c’è, true non significa « messaggio consegnato ». Significa « messaggio accettato dal programma locale ». Quello che succede dopo, rifiuto del server remoto, quarantena, scarto silenzioso, da PHP è invisibile.
Il vero problema: l’autenticazione del dominio
Da febbraio 2024 Gmail e Yahoo impongono ai mittenti di volume un’autenticazione completa. Microsoft ha fatto lo stesso nel 2025. Un messaggio che non è né firmato né allineato non arriva più, oppure arriva nello spam. Si combinano tre meccanismi.
SPF
Un record DNS di tipo TXT sul tuo dominio, che elenca i server autorizzati a inviare a suo nome.
exemple.com. IN TXT "v=spf1 include:_spf.mon-fournisseur.com -all"Il -all finale significa « qualsiasi altro server è illegittimo ». Un ~all è più permissivo e basta durante la messa in opera, ma l’obiettivo resta il -all.
DKIM
Una firma crittografica aggiunta nelle intestazioni del messaggio. Il destinatario recupera la chiave pubblica nel DNS e verifica che il messaggio non sia stato modificato e provenga davvero dal dominio dichiarato.
# Genera la coppia di chiavi
openssl genrsa -out dkim-prive.pem 2048
openssl rsa -in dkim-prive.pem -pubout -out dkim-public.pemLa chiave pubblica si pubblica su <selecteur>._domainkey.exemple.com:
gk2026._domainkey.exemple.com. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."La chiave privata resta sul server, fuori dal repository Git, leggibile solo dall’utente che esegue PHP.
DMARC
La policy che dice al destinatario che cosa fare quando SPF e DKIM falliscono, e dove inviare i rapporti.
_dmarc.exemple.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@exemple.com; adkim=s; aspf=s"Si comincia sempre da p=none, che non rifiuta nulla ma fa arrivare i rapporti. Li si legge per qualche settimana, si corregge, poi si passa a p=quarantine e infine a p=reject. Andare subito a p=reject blocca i tuoi stessi messaggi.
Il punto che questi tre record da soli non risolvono si chiama allineamento: l’indirizzo del From: visibile deve appartenere allo stesso dominio validato da SPF e firmato da DKIM. Un form di contatto che mette l’indirizzo del visitatore nel From: rompe l’allineamento e fallisce il DMARC del dominio del visitatore. La forma giusta è inviare dal tuo dominio e mettere il visitatore in Reply-To:.
Inviare in SMTP autenticato con PHPMailer
PHPMailer gestisce SMTP, la codifica, gli allegati e la firma DKIM. È la libreria più diffusa ed è mantenuta.
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: gli errori sollevano un'eccezione
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;
// Firma 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);
}La versione in testo semplice (AltBody) non è facoltativa. Un messaggio solo HTML è un segnale negativo per i filtri, e alcuni client mostrano soltanto il testo.
Sul nostro ambiente di test il messaggio parte in 148 ms e arriva con questa intestazione:
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=;La stessa cosa con Symfony Mailer
Se il progetto usa già dei componenti Symfony, Symfony Mailer si integra meglio e gestisce la coda.
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);Testare senza sporcare caselle reali
Mandare prove verso indirizzi reali rovina la reputazione del dominio. Un server SMTP di test intercetta tutto e offre un’interfaccia web. Mailpit fa questo in un container.
services:
mail:
image: axllent/mailpit:latest
ports:
- "8025:8025" # interfaccia web
- "1025:1025" # SMTP
environment:
MP_SMTP_AUTH_ACCEPT_ANY: "1"
MP_SMTP_AUTH_ALLOW_INSECURE: "1"L’applicazione punta allora su smtp://mail:1025, e tutti i messaggi si aprono su http://localhost:8025, con il sorgente completo. È l’unico modo per verificare che l’intestazione DKIM ci sia davvero prima di passare in produzione.
Il form di contatto, un caso a parte
Tre errori tornano sistematicamente, e tutti hanno delle conseguenze.
// Da non fare
$mail->setFrom($_POST['email']); // rompe SPF e DKIM
$mail->Subject = $_POST['sujet']; // iniezione di intestazioni
$mail->addAddress($_POST['destinataire']); // relay aperto
// Corretto
$expediteur = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($expediteur === false) {
throw new InvalidArgumentException('Adresse invalide');
}
$mail->setFrom('contact@exemple.com', 'Formulaire du site'); // sempre il tuo dominio
$mail->addReplyTo($expediteur); // la risposta andrà al visitatore
$mail->addAddress('contact@exemple.com'); // destinatario fisso
$mail->Subject = 'Message du formulaire de contact'; // oggetto fisso
$mail->Body = nl2br(htmlspecialchars(
(string) ($_POST['message'] ?? ''),
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8',
));L’iniezione di intestazioni merita una parola. Un a capo infilato in un campo passato a mail() permetteva di aggiungere destinatari nascosti e di trasformare il form in un relay per spam. Abbiamo inviato l’oggetto "Sujet normalrnBcc: victime@example.com" con entrambe le librerie: nessuna solleva un’eccezione, ma nessuna crea l’intestazione Bcc. Neutralizzano l’interruzione di riga al momento della codifica.
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é.Il risultato è sicuro, ma non viene segnalato: l’oggetto arriva deformato senza che nulla avvisi. Validare i campi a monte resta necessario, e assemblare le intestazioni a mano resta da evitare.
Quando passare a un servizio di invio
Un SMTP montato in proprio su un VPS funziona, fino al giorno in cui l’indirizzo IP finisce in una blacklist. I fornitori di invio transazionale, Brevo, Postmark, Amazon SES, Mailgun, gestiscono la reputazione degli indirizzi IP, i feedback loop e i rapporti DMARC. Si usano in SMTP classico, e il codice qui sopra non cambia, oppure tramite API.
È il terreno di Email Impact, il modulo PrestaShop che pubblichiamo per le e-mail transazionali di un negozio: modelli, recapito e monitoraggio degli invii. I meccanismi descritti qui, SMTP autenticato, allineamento del From:, firma DKIM, sono esattamente quelli su cui si basa, e le stesse impostazioni DNS valgono qualunque sia lo strumento di invio.
Inviare molti messaggi
Un ciclo che invia mille messaggi dentro una richiesta HTTP finisce in timeout. Serve una coda: la richiesta registra i messaggi da inviare, un task pianificato li elabora a lotti.
// Un invio a lotti, chiamato da un task pianificato
$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); // rispetta i limiti di frequenza del fornitore
}Il fallimento di un messaggio non deve mai interrompere il lotto. E ogni messaggio di una lista di distribuzione deve portare un’intestazione List-Unsubscribe con un link di disiscrizione funzionante: dal 2024 è un requisito dei grandi provider, non una cortesia.
Riepilogo
mail(): per uno script locale da cui non dipende nessuno, e nient’altro.- SMTP autenticato con PHPMailer o Symfony Mailer, con SPF, DKIM e DMARC pubblicati: il minimo per essere recapitati.
- Un server SMTP di test in sviluppo, mai indirizzi reali.
- Un servizio di invio transazionale non appena il volume supera qualche messaggio al giorno.
Per allegare un file caricato da un visitatore, validalo prima come descritto in upload di un file in PHP. Per pilotare un servizio di invio tramite la sua API invece che in SMTP, vedi fare una richiesta cURL in PHP. Entrambe le librerie citate qui si installano con Composer.
Vedi anche validare un indirizzo e-mail e l’hub Sviluppo web.
Errori frequenti
false con Sendmail exited with non-zero exit code 127. È il caso predefinito di qualsiasi container Docker e di una parte degli hosting.Reply-To.p=none, si leggono i rapporti, poi si stringe.AltBody."SujetrnBcc: victime@example.com" non crea nessuna intestazione Bcc né con PHPMailer né con Symfony Mailer, ma nessuna delle due solleva un'eccezione: l'oggetto arriva deformato senza avvisare.

