La plupart des applications web communiquent avec leurs utilisateurs par un système de notifications par email. Même si ceux-ci peuvent être très basiques, l'implémentation d'un système simple et accessible dans un framework peut vite devenir une tâche compliquée.
Chez Wisembly nous avons itéré plusieurs fois sur notre système d'email pour arriver à une implémentation qui nous satisfasse et réponde à nos attentes. Notre système doit être capable :
- de composer des emails avec un moteur de template (Twig)
- d'être internationalisé
- de gérer les pluriels
- d'envoyer un mail n'importe où dans notre code base de manière très simple (via un système de container)
- d'avoir une version texte
- d'avoir chaque email disponnible en version web (feature view in browser)
- d'être testable
Stack :
- Symfony 2
- Swiftmailer
- Twig
- Translator Component
- Redis
- Behat
La façon la plus simple de construire ses emails est d'utiliser un moteur de template comme Twig. Celui-ci vous permettra de découpler vos emails en parties réutilisables et plus facilement maintenable (Introduction Twig). Libre à vous de tirer parti des composants du moteur et d'organiser vos templates comme bon vous semble.
Par exemple, Twig permet de définir des paths afin de créer des alias et cibler plus facilement les chemins où sont stockés vos templates. Vous pouvez ainsi, définir le tag @email_templates
et @email_includes
comme ceci :
#config.yml
twig:
paths:
"%kernel.root_dir%/../src/Acme/UserBundle/Resources/views/Emails/Templates": email_templates
"%kernel.root_dir%/../src/Acme/UserBundle/Resources/views/Emails/Includes": email_includes
L'API de la fonction de traduction de Twig nous permet de passer des variables à votre clé et de cibler un fichier de langue en particuler, ce qui rend l'internationalisation vraiment très simple (fichiers séparés pour les traductions du reste de l'appication).
{% include "@email_includes/boutons/primary.html.twig" with {'content': ('content_primary_buton' | trans({}, 'emails', lang))} %}
Comme Symfony 2 nous offre un système de Dependency Injection, nous avons créé un service Mailer accessible partout dans notre code base. L'unique API de ce service est une méthode Mailer::send()
qui ne prend en paramètres que le/les addrese(s) email du/des destinataire(s) ainsi qu'un Value Object représentant la configuration de notre email (nom du template à utiliser, ses paramètres, la langue, ...).
# Value Object représentant un email entièrement construit
use Acme\DemoBundle\Mail\Mail;
# Value Object représentation la configuration d'un email
use Acme\DemoBundle\Mail\MailTemplateConfiguration;
# Service permettant de transformer un template Twig en plain text
use Acme\DemoBundle\Service\HtmlToText;
# Notre service custom utilisant Predis
use Acme\DemoBundle\Service\Redis;
class Mailer
{
private $mailer;
private $redis;
private $twig;
private $htmlToText;
public function __construct(\Swift_Mailer $mailer, Redis $redis, \Twig_Environment $twig, HtmlToText $htmlToText)
{
$this->mailer = $mailer;
$this->redis = $redis;
$this->twig = $twig;
$this->htmlToText = $htmlToText;
}
/**
* Send an email
*
* the addresses can be built by differents ways :
* - With a simple string : '[email protected]'
* - For a single recipient without name : ['[email protected]' => null]
* - For a single recipient with name : ['[email protected]' => 'Foo Bar']
* - For multiple recipients with/without names : ['[email protected]' => 'Foo Bar', '[email protected]' => null, ...]
*
* @param mixed $address See below
* @param MailTemplateConfiguration $mailTemplateConfiguration The twig template informations will be used by the MailBuilder
*/
public function send($addresses, MailTemplateConfiguration $mailTemplateConfiguration)
{
if (!is_array($addresses)) {
$addresses = [$addresses => null];
}
foreach ($addresses as $address => $name) {
$mail = $this->buildEmail($mailTemplateConfiguration);
$message = \Swift_Message::newInstance()
->setSubject($mail->getSubject())
->setFrom('[email protected]', 'Wisembly')
->setTo([$address => $name])
->setBody($mail->getHtml(), 'text/html')
->addPart($mail->getText(), 'text/plain')
;
try {
$this->mailer->send($message);
$this->redis->set('email:' . $mailTemplateConfiguration->getToken(), base64_encode($mail->getHtml()));
} catch (\Exception $e) {
// Logs
}
}
}
private function buildEmail(MailTemplateConfiguration $configuration)
{
$language = $configuration->getLanguage();
$parameters = $configuration->getParameters();
$parameters['template'] = $configuration->getTemplateName();
$parameters['lang'] = $language;
// Load the template
try {
$template = $this->twig->loadTemplate("@email_templates_v6/{$configuration->getTemplateName()}.html.twig");
} catch (\Twig_Error_Loader $e) {
// Logs
}
// Create the raw text version
// Use the Twig_Template::renderBlock to generate the html of a particular block
$body = $template->renderBlock('body', $parameters);
// Use extern service to convert the html to raw text
$text = $this->htmlToText->convertLinks($body);
$text = $this->htmlToText->convertBlocks($text);
$text = trim(strip_tags($text));
return new Mail(
html_entity_decode($template->renderBlock('subject', $parameters), ENT_QUOTES),
$template->renderBlock('main', $parameters),
$text
);
}
}
Comme vous pouvez le constater dans la classe Mailer ci-dessus, nous récupérons le sujet de notre email grâce à la méthode Twig_Template::renderBlock('subject', $parameters)
. Ceci à l'avantage de laisser votre template Twig gérer la traduction et plurialiser votre contenu si besoin.
Le second avantage est d'avoir à la fois le sujet et le contenu de l'email dans le même template. Ainsi votre template représente vraiment le contenu complet de votre emails et vous centralisez les contenus dans un même fichier.
{% block subject -%}
{{ 'user_welcome_subject' | trans({'%name%': user.name}, 'emails', lang) | raw }}
{%- endblock %}
{% block content %}
...
{% endblock %}
Chacun de nos emails peut être visualisés dans un navigateur plutôt que dans sa boite email.
// Screenshot
Ceci nous permet d'offrir une expérience complète à nos clients BtoB malgrès leurs boites mails parfois capricieuses. Chaque fois qu'un email est envoyé à l'un de nos utilisateurs, nous enregistrons tout le contenu html de celui-ci dans Redis afin de pouvoir le restitué dans son navigateur lorsqu'il le demande. Redis nous offre également la possibilité d'établir une expiration sur la clé nous permettant de ne pas engorger notre base avec le temps à cause de vieux emails.
Chez Wisembly nous accordons une grande importance à tester toute notre application. Les tests sur nos emails ne pouvaient pas échapper à la règle.
Bien que tester unitairement le service Mailer et ses composantes soit un jeux d'enfant, il nous a été plus difficle de trouver un moyen efficace et simple de tester nos emails fonctionnellement.
Nous configurons Swiftmailer en fonction de l'environnement comme suivant :
#config_dev.yml
swiftmailer:
disable_delivery: true
#delivery_address: [email protected]
#config_test.yml
swiftmailer:
disable_delivery: true
#delivery_address: [email protected]
spool:
type: service
id: wisembly.swiftmailer.spool
Swiftmailer vous propose un paramètre disable_delivery afin de désactiver tout les envois d'email en environnement de developpement. Les emails ne sont pas envoyés mais sont quand même disponnibles dans votre Profiler si vous avez besoin de visualiser ou vérifier un contenu envoyé. Il est donc facile de visualiser les emails envoyer sans qu'ils ne partent rééllement.
Si vous souhaiter tester réellement l'envoi d'un email, il vous faudra simplement passer ce paramètre à false et de décommenter le paramètre delivery_address afin que tous les emails soient redirigés vers votre adresse (Attention à cette pratique, surtout si vous travaillez avec un dump de votre base de production).
En environnement de test, l'envoi des emails est également désactiver. Pour stocker les emails envoyés, nous utilsons le système natif de spool géré par notre propre service wisembly.swiftmailer.spool
. Celui-ci va stocker chaque email représentés par un objet Swift_Message envoyé dans Redis.
class WisemblySpool implements \Swift_Spool
{
...
/**
* {@inheritdoc}
*/
public function queueMessage(\Swift_Mime_Message $message)
{
$redis = $this->redis->authenticate();
foreach ($message->getTo() as $recipient => $name) {
$redis->set('email:spool:' . sha1($recipient), serialize($message));
}
$redis->disconnect();
$redis->quit();
}
...
}
De cette manière, tous ces messages peuvent être récupérés par notre Behat et leur contenu peut-être testé facilement ainsi que la liste des destinataires.
Avec le temps et les bons outils, nous avons réussi à mettre en place un système simple, efficasse et surtout fiable grâce à nos tests. Ecrire de nouveau emails est un jeux d'enfant. Les emails sont votre moyen de communiquer avec vos utilisateurs, ne n'égligez pas cette partie de votre application.
N'hésitez pas à partager votre propre implémentation et vos idées sur le sujet.