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:
- 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.
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 #}
‌ ‌ ‌ ‌ ‌
</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
|