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
<?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 $default