Symfony Mailer: Come Costruire Email Transazionali Professionali che i Tuoi Utenti Apriranno Davvero
Le email transazionali sono il biglietto da visita silenzioso della tua applicazione. Ogni conferma d’ordine, reset password o notifica di spedizione Γ¨ un’opportunitΓ per rafforzare la fiducia dell’utente β o per perderla definitivamente con un messaggio che finisce in spam, si visualizza male su mobile, o peggio, non arriva affatto.
Ho visto troppi progetti dove le email sono un afterthought: template inline nel controller, HTML copiato da Stack Overflow, nessun testing. Il risultato? Tassi di apertura sotto il 20%, supporto intasato di ticket “non ho ricevuto l’email”, e sviluppatori che temono ogni modifica al sistema di notifiche.
In questo articolo costruiremo un sistema di email transazionali production-ready con Symfony Mailer. Non parleremo di teoria astratta: partiremo da casi d’uso concreti (conferma ordine, reset password, notifiche) e costruiremo un’architettura modulare, testabile e scalabile che potrai riutilizzare in ogni progetto.
Prerequisiti
- Symfony 6.4+ o 7.x
- PHP 8.2+
- Conoscenza base di Twig e Dependency Injection
- Composer installato
- Docker (opzionale, per Mailpit)
Installiamo i pacchetti necessari:
| |
Architettura del Sistema Email
Prima di scrivere codice, definiamo l’architettura. Il problema delle email mal progettate nasce quasi sempre dalla mancanza di separazione delle responsabilitΓ .
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Application Layer β
β ββββββββββββββββββββ βββββββββββββββββββ β
β β Controller/Commandβ ββββΆ β EmailService β β
β ββββββββββββββββββββ ββββββββββ¬βββββββββ β
βββββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββ
β Email Domain β β
β ββββββββββββββββΌβββββββββββ β
β β EmailFactory β β
β ββββββββββββββββ¬βββββββββββ β
β β β
β ββββββββββββββββββββββββΌβββββββββββββββββββββββ β
β βΌ βΌ βΌ β
β βββββββββββββββββββ βββββββββββββββββββ ββββββββββββββββ β
β β TemplateBuilder β β Email Message β β Twig Engine β β
β βββββββββββββββββββ βββββββββββββββββββ ββββββββββββββββ β
ββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββ
β Infrastructure β β
β βββββββββββββββββββββββββββββ΄βββββββββββββββββββββ β
β β β β
β ββββββΌβββββ ββββββββΌβββββββ
β β sync β β async ββ
β ββββββ¬βββββ ββββββββ¬βββββββ
β β β β
β β βββββββββββββββββββ β β
β ββββββββββΆβ Symfony Mailer ββββββββββββββββββββββ β
β ββββββββββ¬βββββββββ (via Messenger) β
β β β
β ββββββββββΌβββββββββ β
β β Transport β β
β ββββββββββ¬βββββββββ β
ββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββΌβββββββββββββββββββββ
βΌ βΌ βΌ
βββββββββββ ββββββββββββ ββββββββββββ
β Mailpit β β Mailgun β β SendGrid β
β (dev) β β(staging) β β (prod) β
βββββββββββ ββββββββββββ ββββββββββββ
I principi chiave:
- Separazione logica/presentazione: Il servizio non sa nulla di HTML, i template non contengono logica di business
- Factory pattern: Ogni tipo di email ha il suo factory method con validazione dei dati
- Transport agnostico: Cambio provider senza toccare una riga di codice applicativo
- Async by default: Le email non bloccano mai la request HTTP
Implementazione Passo-Passo
Configurazione Multi-Environment con Transport Switching
Partiamo dalla configurazione. L’errore piΓΉ comune Γ¨ hardcodare il DSN del mailer β un incubo quando devi debuggare in locale o cambiare provider in produzione.
| |
Configuriamo le variabili d’ambiente per ogni scenario:
| |
Per il testing locale, Mailpit Γ¨ essenziale. Aggiungiamolo al docker-compose:
| |
π‘ Mailpit cattura tutte le email inviate e le mostra in una UI web su
localhost:8025. Perfetto per verificare rendering e contenuto senza inviare email reali.
Template Twig Modulari con Componenti Riutilizzabili
La parte piΓΉ critica: costruire template che si visualizzino correttamente su Gmail, Outlook, Apple Mail e i 50+ client email esistenti. Il segreto Γ¨ un sistema di componenti con stili inline.
Iniziamo dal layout base:
| |
Creiamo i componenti riutilizzabili:
| |
| |
| |
Ora creiamo un template per una conferma ordine:
| |
β οΈ Gli stili inline sono obbligatori per la compatibilitΓ con Gmail e Outlook. Non affidarti mai solo ai tag
<style>β molti client li rimuovono completamente.
Servizio Centralizzato con Messenger e Traduzioni
Ora costruiamo il cuore del sistema: un servizio che astrae completamente la complessitΓ dell’invio email.
| |
L’handler che gira per il path asincrono:
| |
Configura il transport con retry a backoff e coda di fallimento:
| |
Configurazione per Produzione
- Fai girare un worker. L’invio asincrono non fa nulla senza
messenger:consume. Mettilo sotto supervisor con tetto di memoria e time-limit, così PHP long-running non accumula leak:
| |
- Il DNS Γ¨ deliverability. Pubblica SPF, DKIM e DMARC per il dominio di invio. SES, Mailgun e SendGrid ti danno i record esatti; senza, Gmail e Outlook ti mandano in spam o rifiutano.
- Usa un sottodominio per la posta transazionale (
mail.example.com) così la sua reputazione è isolata dagli invii di marketing. - Imposta
List-Unsubscribee rispettalo β Gmail e Yahoo ora lo richiedono per i mittenti bulk e migliora l’inbox placement. Fromreale, evitano-reply@quando puoi; un indirizzo di risposta monitorato migliora i segnali di engagement.- Sandbox fuori produzione. Punta lo staging su Mailpit o sulla modalitΓ sandbox del provider, cosΓ¬ una
WHEREsbagliata non scrive a utenti veri.
Errori Comuni e Troubleshooting
Le email sono ok in locale, rotte su Gmail/Outlook. Ti sei affidato ai blocchi <style>. Porta ogni regola inline (filtro inline_css di twig/cssinline-extra, o uno step di build). Outlook usa il motore di rendering di Word: testa con le tabelle, non con flexbox.
In produzione non parte niente. Nessun worker sta consumando async. Controlla php bin/console messenger:stats e il supervisor.
Ogni email parte due volte. O due worker sullo stesso transport senza lock, oppure chiami sia send() che sendNow(). Scegli un path per tipo di email.
TransportException: Connection could not be established. MAILER_DSN sbagliato, porta in uscita bloccata (25/465/587), o credenziali API mancanti. Testa con php bin/console mailer:test tu@example.com.
Le traduzioni ricadono sulla chiave. Il dominio emails non Γ¨ caricato, o il locale non arriva a ->subject() / ->context(). Verifica che esista translations/emails.<locale>.yaml e che il locale venga propagato.
I retry martellano il provider. Un 4xx dal provider (rate limit, destinatario invalido) non deve ritentare all’infinito. Intercetta i fallimenti permanenti nell’handler e throw new UnrecoverableMessageHandlingException() cosΓ¬ Messenger li manda dritti in failed.
Performance e ScalabilitΓ
- Metti sempre in coda di default. Rendering Twig + handshake TLS col provider sono 100β500 ms che non vuoi nel path della request. Riserva
sendNow()a reset password e codici 2FA. - Throughput del worker con
--limite piΓΉ processi worker, invece di un worker che fa migliaia di invii per boot. - Cache dei template compilati (
APP_ENV=prodlo fa) β compilare Twig a ogni email Γ¨ puro spreco. - Usa il transport API del provider, non SMTP, a volume:
mailgun+https,sendgrid+api,ses+apievitano il setup di connessione per messaggio. - Tieni d’occhio il failure transport. Una coda
failedche cresce è il primo segnale di problemi di DNS, credenziali o reputazione. - Rate-limit per destinatario sugli aggiornamenti di stato/ordine, così un retry storm o un loop non manda a qualcuno 200 email.
Conclusioni e Next Steps
Ora hai un sistema di email transazionali con un unico punto d’ingresso (EmailService), una EmailFactory che possiede identitΓ , i18n e header di deliverability, un path asincrono con retry e dead-letter queue, e template che sopravvivono a Gmail e Outlook. L’architettura tiene separato il “cosa inviare” (oggetti messaggio tipizzati) dal “come viene consegnato” (transport, worker, retry).
Prossimi passi:
- Aggiungi i webhook del provider (bounce, complaint, open) e sopprimi gli indirizzi che fanno hard-bounce.
- Genera una parte plaintext dall’HTML, cosΓ¬ client testuali e filtri antispam sono contenti.
- Aggiungi in CI uno smoke test che renderizza ogni template con dati di esempio.
- Traccia una dashboard di consegna per tipo (inviate / fallite / rimbalzate) dai log del worker o dagli eventi webhook.