Symfony Mailer: Guida Completa alle Email Transazionali

2026-03-26 · 9 min read · gen:2m 25s · tok:20091
#symfony #symfony-mailer #email-transazionali #php #intermediate-tutorial #italiano

Costruisci email transazionali professionali con Symfony Mailer: template Twig, testing con Mailpit, architettura scalabile e tassi di apertura migliori.

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:

1
2
composer require symfony/mailer symfony/twig-bundle symfony/messenger
composer require --dev symfony/mailpit-mailer

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:

  1. Separazione logica/presentazione: Il servizio non sa nulla di HTML, i template non contengono logica di business
  2. Factory pattern: Ogni tipo di email ha il suo factory method con validazione dei dati
  3. Transport agnostico: Cambio provider senza toccare una riga di codice applicativo
  4. 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# config/packages/mailer.yaml
framework:
    mailer:
        dsn: '%env(MAILER_DSN)%'
        
        # Envelope globale per consistenza
        envelope:
            sender: '%env(MAILER_SENDER)%'
        
        # Headers di default
        headers:
            From: '%env(MAILER_FROM_NAME)% <%env(MAILER_FROM_ADDRESS)%>'
            
when@dev:
    framework:
        mailer:
            dsn: 'smtp://mailpit:1025'
            
when@test:
    framework:
        mailer:
            dsn: 'null://null'
            
when@prod:
    framework:
        mailer:
            dsn: '%env(MAILER_DSN)%'

Configuriamo le variabili d’ambiente per ogni scenario:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# .env.local (development)
MAILER_DSN=smtp://mailpit:1025
MAILER_SENDER=noreply@myapp.local
MAILER_FROM_NAME="MyApp Dev"
MAILER_FROM_ADDRESS=noreply@myapp.local

# .env.prod (production con SendGrid)
MAILER_DSN=sendgrid+api://API_KEY@default
MAILER_SENDER=noreply@myapp.com
MAILER_FROM_NAME="MyApp"
MAILER_FROM_ADDRESS=noreply@myapp.com

# Alternativa con Mailgun
# MAILER_DSN=mailgun+https://API_KEY:DOMAIN@default

Per il testing locale, Mailpit Γ¨ essenziale. Aggiungiamolo al docker-compose:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# docker-compose.yml
services:
  mailpit:
    image: axllent/mailpit:latest
    container_name: mailpit
    ports:
      - "1025:1025"   # SMTP
      - "8025:8025"   # Web UI
    environment:
      MP_SMTP_AUTH_ACCEPT_ANY: 1
      MP_SMTP_AUTH_ALLOW_INSECURE: 1

πŸ’‘ 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
{# templates/emails/base.html.twig #}
<!DOCTYPE html>
<html lang="{{ app.request.locale ?? 'it' }}">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>{% block title %}{{ subject|default('') }}{% endblock %}</title>
    <!--[if mso]>
    <noscript>
        <xml>
            <o:OfficeDocumentSettings>
                <o:PixelsPerInch>96</o:PixelsPerInch>
            </o:OfficeDocumentSettings>
        </xml>
    </noscript>
    <![endif]-->
    <style>
        /* Reset per client email */
        body, table, td, p, a, li, blockquote {
            -webkit-text-size-adjust: 100%;
            -ms-text-size-adjust: 100%;
        }
        table, td {
            mso-table-lspace: 0pt;
            mso-table-rspace: 0pt;
        }
        img {
            -ms-interpolation-mode: bicubic;
            border: 0;
            height: auto;
            line-height: 100%;
            outline: none;
            text-decoration: none;
        }
        /* Stili custom - verranno inlineati in produzione */
        .email-body {
            background-color: #f4f4f4;
            margin: 0;
            padding: 0;
            width: 100%;
        }
        .email-container {
            max-width: 600px;
            margin: 0 auto;
            background-color: #ffffff;
        }
        .email-content {
            padding: 40px 30px;
        }
        .text-primary { color: #2563eb; }
        .text-muted { color: #6b7280; }
        .text-center { text-align: center; }
    </style>
</head>
<body class="email-body" style="margin: 0; padding: 0; background-color: #f4f4f4;">
    <!-- Preheader nascosto per anteprima client email -->
    {% if preheader is defined and preheader %}
    <div style="display: none; max-height: 0px; overflow: hidden;">
        {{ preheader }}
        {# Padding per evitare che il contenuto dell'email appaia nel preheader #}
        &nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;
    </div>
    {% endif %}
    
    <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
        <tr>
            <td style="padding: 20px 0;">
                <table role="presentation" cellspacing="0" cellpadding="0" border="0" 
                       class="email-container" align="center" 
                       style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
                    
                    {# Header #}
                    {% block header %}
                        {{ include('emails/components/_header.html.twig') }}
                    {% endblock %}
                    
                    {# Contenuto principale #}
                    <tr>
                        <td class="email-content" style="padding: 40px 30px;">
                            {% block content %}{% endblock %}
                        </td>
                    </tr>
                    
                    {# Footer #}
                    {% block footer %}
                        {{ include('emails/components/_footer.html.twig') }}
                    {% endblock %}
                    
                </table>
            </td>
        </tr>
    </table>
</body>
</html>

Creiamo i componenti riutilizzabili:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{# templates/emails/components/_header.html.twig #}
<tr>
    <td style="padding: 30px 30px 20px; text-align: center; background-color: #1e40af;">
        {% if logo_url is defined and logo_url %}
            <img src="{{ logo_url }}" alt="{{ app_name|default('MyApp') }}" 
                 width="150" style="max-width: 150px; height: auto;">
        {% else %}
            <h1 style="margin: 0; color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 24px; font-weight: 700;">
                {{ app_name|default('MyApp') }}
            </h1>
        {% endif %}
    </td>
</tr>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{# templates/emails/components/_footer.html.twig #}
<tr>
    <td style="padding: 30px; background-color: #f9fafb; border-top: 1px solid #e5e7eb;">
        <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
            <tr>
                <td style="text-align: center; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #6b7280;">
                    <p style="margin: 0 0 10px;">
                        {{ 'email.footer.company_info'|trans({}, 'emails') }}
                    </p>
                    <p style="margin: 0 0 10px;">
                        {{ 'email.footer.address'|trans({}, 'emails') }}
                    </p>
                    <p style="margin: 0;">
                        <a href="{{ unsubscribe_url|default('#') }}" 
                           style="color: #6b7280; text-decoration: underline;">
                            {{ 'email.footer.unsubscribe'|trans({}, 'emails') }}
                        </a>
                    </p>
                </td>
            </tr>
        </table>
    </td>
</tr>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{# templates/emails/components/_button.html.twig #}
{# 
   Parametri:
   - url: URL del link (obbligatorio)
   - label: Testo del bottone (obbligatorio)
   - style: 'primary' | 'secondary' | 'success' | 'danger' (default: 'primary')
   - align: 'left' | 'center' | 'right' (default: 'center')
#}
{% set styles = {
    'primary': { bg: '#2563eb', text: '#ffffff', border: '#2563eb' },
    'secondary': { bg: '#ffffff', text: '#374151', border: '#d1d5db' },
    'success': { bg: '#059669', text: '#ffffff', border: '#059669' },
    'danger': { bg: '#dc2626', text: '#ffffff', border: '#dc2626' }
} %}
{% set currentStyle = styles[style|default('primary')] %}

<table role="presentation" cellspacing="0" cellpadding="0" border="0" 
       style="margin: 20px 0;" align="{{ align|default('center') }}">
    <tr>
        <td style="border-radius: 6px; background-color: {{ currentStyle.bg }};">
            <a href="{{ url }}" target="_blank" 
               style="display: inline-block; padding: 14px 28px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; font-weight: 600; color: {{ currentStyle.text }}; text-decoration: none; border-radius: 6px; border: 2px solid {{ currentStyle.border }};">
                {{ label }}
            </a>
        </td>
    </tr>
</table>

Ora creiamo un template per una conferma ordine:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
{# templates/emails/order/confirmation.html.twig #}
{% extends 'emails/base.html.twig' %}

{% block title %}{{ 'email.order.confirmation.subject'|trans({'%order_number%': order.number}, 'emails') }}{% endblock %}

{% block content %}
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
    {# Saluto #}
    <tr>
        <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
            <h1 style="margin: 0 0 20px; font-size: 24px; color: #111827;">
                {{ 'email.order.confirmation.title'|trans({}, 'emails') }}
            </h1>
            <p style="margin: 0 0 20px; font-size: 16px; color: #374151; line-height: 1.6;">
                {{ 'email.order.confirmation.greeting'|trans({'%name%': customer.firstName}, 'emails') }}
            </p>
        </td>
    </tr>
    
    {# Riepilogo ordine #}
    <tr>
        <td style="padding: 20px; background-color: #f9fafb; border-radius: 8px; margin: 20px 0;">
            <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
                <tr>
                    <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
                        <p style="margin: 0 0 10px; font-size: 14px; color: #6b7280;">
                            {{ 'email.order.confirmation.order_number'|trans({}, 'emails') }}
                        </p>
                        <p style="margin: 0 0 20px; font-size: 20px; font-weight: 700; color: #111827;">
                            #{{ order.number }}
                        </p>
                    </td>
                </tr>
                
                {# Lista prodotti #}
                {% for item in order.items %}
                <tr>
                    <td style="padding: 15px 0; border-top: 1px solid #e5e7eb;">
                        <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
                            <tr>
                                <td width="60" style="vertical-align: top;">
                                    {% if item.product.imageUrl %}
                                    <img src="{{ item.product.imageUrl }}" alt="{{ item.product.name }}"
                                         width="50" height="50" 
                                         style="border-radius: 4px; object-fit: cover;">
                                    {% endif %}
                                </td>
                                <td style="vertical-align: top; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
                                    <p style="margin: 0 0 5px; font-size: 14px; font-weight: 600; color: #111827;">
                                        {{ item.product.name }}
                                    </p>
                                    <p style="margin: 0; font-size: 13px; color: #6b7280;">
                                        {{ 'email.order.confirmation.quantity'|trans({'%qty%': item.quantity}, 'emails') }}
                                    </p>
                                </td>
                                <td width="80" style="text-align: right; vertical-align: top; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
                                    <p style="margin: 0; font-size: 14px; font-weight: 600; color: #111827;">
                                        {{ item.total|format_currency('EUR') }}
                                    </p>
                                </td>
                            </tr>
                        </table>
                    </td>
                </tr>
                {% endfor %}
                
                {# Totale #}
                <tr>
                    <td style="padding-top: 15px; border-top: 2px solid #111827;">
                        <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
                            <tr>
                                <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
                                    <p style="margin: 0; font-size: 16px; font-weight: 700; color: #111827;">
                                        {{ 'email.order.confirmation.total'|trans({}, 'emails') }}
                                    </p>
                                </td>
                                <td style="text-align: right; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
                                    <p style="margin: 0; font-size: 20px; font-weight: 700; color: #111827;">
                                        {{ order.total|format_currency('EUR') }}
                                    </p>
                                </td>
                            </tr>
                        </table>
                    </td>
                </tr>
            </table>
        </td>
    </tr>
    
    {# CTA #}
    <tr>
        <td style="padding-top: 30px; text-align: center;">
            {{ include('emails/components/_button.html.twig', {
                url: order_tracking_url,
                label: 'email.order.confirmation.track_order'|trans({}, 'emails'),
                style: 'primary'
            }) }}
        </td>
    </tr>
</table>
{% endblock %}

⚠️ 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<?php
// src/Email/EmailService.php

declare(strict_types=1);

namespace App\Email;

use App\Email\Message\AbstractEmailMessage;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Psr\Log\LoggerInterface;

final class EmailService
{
    public function __construct(
        private readonly MailerInterface $mailer,
        private readonly MessageBusInterface $messageBus,
        private readonly EmailFactory $emailFactory,
        private readonly LoggerInterface $logger,
        private readonly string $defaultFromAddress,
        private readonly string $defaultFromName,
        private readonly string $appName,
    ) {
    }

    /**
     * Invio asincrono (default per tutto ciΓ² che non Γ¨ time-critical):
     * l'email viene messa in coda e spedita da un worker Messenger.
     */
    public function send(AbstractEmailMessage $message): void
    {
        $this->messageBus->dispatch($message);
    }

    /**
     * Invio sincrono: usalo solo per email critiche (reset password, codici 2FA).
     */
    public function sendNow(AbstractEmailMessage $message): void
    {
        $email = $this->emailFactory->create($message, [
            'from'     => new Address($this->defaultFromAddress, $this->defaultFromName),
            'app_name' => $this->appName,
        ]);

        try {
            $this->mailer->send($email);
            $this->logger->info('Email transazionale inviata', [
                'type'      => $message->getType(),
                'recipient' => $message->getRecipient(),
            ]);
        } catch (TransportExceptionInterface $e) {
            $this->logger->error('Invio email fallito', [
                'type'      => $message->getType(),
                'recipient' => $message->getRecipient(),
                'error'     => $e->getMessage(),
            ]);
            throw $e; // rilancia: Messenger farΓ  il retry
        }
    }
}

L’handler che gira per il path asincrono:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
// src/Email/MessageHandler/SendEmailHandler.php

declare(strict_types=1);

namespace App\Email\MessageHandler;

use App\Email\EmailService;
use App\Email\Message\AbstractEmailMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class SendEmailHandler
{
    public function __construct(private readonly EmailService $emailService) {}

    public function __invoke(AbstractEmailMessage $message): void
    {
        // Riusa il path sincrono; un throw qui fa scattare il retry di Messenger
        $this->emailService->sendNow($message);
    }
}

Configura il transport con retry a backoff e coda di fallimento:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# config/packages/messenger.yaml
framework:
    messenger:
        failure_transport: failed
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 5000        # 5s, poi 10s, poi 20s
                    multiplier: 2
            failed:
                dsn: 'doctrine://default?queue_name=failed'
        routing:
            App\Email\Message\AbstractEmailMessage: async

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:
1
php bin/console messenger:consume async --limit=100 --memory-limit=128M --time-limit=3600
  • 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-Unsubscribe e rispettalo β€” Gmail e Yahoo ora lo richiedono per i mittenti bulk e migliora l’inbox placement.
  • From reale, evita no-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 WHERE sbagliata 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 --limit e piΓΉ processi worker, invece di un worker che fa migliaia di invii per boot.
  • Cache dei template compilati (APP_ENV=prod lo fa) β€” compilare Twig a ogni email Γ¨ puro spreco.
  • Usa il transport API del provider, non SMTP, a volume: mailgun+https, sendgrid+api, ses+api evitano il setup di connessione per messaggio.
  • Tieni d’occhio il failure transport. Una coda failed che 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.

Risorse Aggiuntive