Inviare un’e-mail con PHP: SMTP autenticato, SPF e DKIM

Inviare messaggi di posta è un'esigenza costante di qualsiasi applicazione web: l'e-mail di benvenuto alla creazione di un account, la newsletter agli iscritti, il semplice form di contatto del sito. PHP mette a disposizione una funzione integrata per farlo, ma nel 2026 non basta più: senza SMTP autenticato e senza autenticazione del dominio i messaggi non arrivano.

Inviare un’e-mail con PHP: SMTP autenticato, SPF e DKIM
Risposta rapida

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

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

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

bash
# Genera la coppia di chiavi
openssl genrsa -out dkim-prive.pem 2048
openssl rsa -in dkim-prive.pem -pubout -out dkim-public.pem

La chiave pubblica si pubblica su <selecteur>._domainkey.exemple.com:

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

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

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

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

La stessa cosa con Symfony Mailer

Se il progetto usa già dei componenti Symfony, Symfony Mailer si integra meglio e gestisce la coda.

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

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.

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

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

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

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.

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

  • 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

mail() senza server di posta Restituisce false con Sendmail exited with non-zero exit code 127. È il caso predefinito di qualsiasi container Docker e di una parte degli hosting.
Il valore true di mail() non significa « consegnato » Significa « accettato dal programma locale ». Il rifiuto del server remoto da PHP è invisibile.
From a nome del visitatore Rompe l'allineamento SPF e DKIM, e fa fallire il DMARC del dominio del visitatore. Il mittente è il tuo dominio, il visitatore va in Reply-To.
DMARC messo subito in p=reject Blocca i tuoi stessi messaggi. Si comincia da p=none, si leggono i rapporti, poi si stringe.
Messaggio solo in HTML Segnale negativo per i filtri. Fornisci sempre una versione testuale in AltBody.
Iniezione di intestazioni L'oggetto "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.
Invio di massa dentro una richiesta HTTP Finisce in timeout. Serve una coda elaborata a lotti da un task pianificato.

DélivrabilitéDKIMEmailPHPSMTP

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.