Code

Tester l'envoi de courriels HTML avec smtp4dev

Publié le mardi mardi 28 mai 2024
Identicon de monjici
Par monjici
Blogueur du dimanche

Bien que le courriel soit largement dépassé par la messagerie instantannée et les réseaux sociaux entant qu'outil de communication, il demeure un standard incontournable dans certaines situations telles la confirmation de création de compte et la récupération de mot de passe.

Cependant, il est loin d'être un outil parfait. Avec l'omniprésence de pourriel, les multiples filtres imparfaits peuvent vous faire manquer des messages. Chaque client (Outlook, Gmail, etc.) implémente ses propres standards pour afficher les courriels en format HTML et pour sécuriser les images et les liens. La décentralisation et le concept de réputation peuvent mener votre domaine ou serveur à être banni et se voir rejeter tout ses courriels.

Heureusement, certains outils tel smtp4dev peuvent nous aider lorsqu'on développe nos gabarits de courriels et permettre l'envoi sans craindre d'être identifié comme pourriel dû aux envois répétitifs.

smtp4dev (https://github.com/rnwood/smtp4dev) est un faux serveur SMTP local qu'on peut installer avec Docker. Déjà là, c'est suffisant pour être un outil important pour tester l'envoi de courriel. En plus, son interface client vous permet d'avoir un aperçu visuel du rendu de chaque courriel. Où il devient très intéressant c'est avec la fonctionnalité d'inspection et d'analyse. On peut donc tout faire tout ce dont on a besoin en un seul endroit. Voyons en plus de détails.

Installation

Commençons par l'installation. Si vous n'avez pas Docker, il vous faudra l'installer https://docs.docker.com/get-docker/.

Après avoir ouvert l'interface Docker, vous pouvez télécharger l'image rnwood/smtp4dev. Pour ce démo, j'ai utilisé la version rnwood/smtp4dev:3.4.1-ci20240428110

Console Docker listant les images
Console Docker

Pour démarrer l'image, on peut utiliser l'interface ou exécuter la commande suivante :

docker run --rm -it -p 3000:80 -p 2525:25 rnwood/smtp4dev:3.4.1-ci20240428110

CMD DOS
Démarrage de l'image Docker de smtp4dev

Une fois démarrée, on peut accéder au client Web à l'adresse http://localhost:3000/.

Créer un gabarit (template)

Vous aurez probablement avoir plusieurs type de courriels à envoyer, avec différent contenu. Il est donc de mise de créer un gabarit que sera réutilisé pour tous les courriels. C'est beaucoup de travail initialement mais par la suite, seulement le contenu (body) devra être spécifié pour chaque courriel. Voici l'arbre des fichiers qui seront utilisés :

  • Views
    • Emails/ConfirmAccount.cshtml
    • Emails/ConfirmAccountEmailViewModel.cs
    • Shared/EmailButton.cshtml
    • Shared/EmailLayout.cshtml
    • _ViewStart.cshtml
  • Services
    • Emails/EmailSender.cs

En ASP.net, nous pouvons créer une vue (EmailLayout.cshtml) qui sera la gabarit commun à chaque courriel. Il contient l'entête, le pied de page et injecte le titre et le contenu dynamiquement. Voici un exemple contenant le style avec des polices, etc.

<!DOCTYPE html>
<html lang="fr-ca">
<head>
    <title>Grand Menhir</title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <style type="text/css">
        /* FONTS */
        @@media screen {
            @@font-face {
                font-family: 'Lato';
                font-style: normal;
                font-weight: 400;
                src: local('Lato Regular'), local('Lato-Regular'), url(https://fonts.gstatic.com/s/lato/v11/qIIYRU-oROkIk8vfvxw6QvesZW2xOQ-xsNqO47m55DA.woff) format('woff');
            }

            @@font-face {
                font-family: 'Lato';
                font-style: normal;
                font-weight: 700;
                src: local('Lato Bold'), local('Lato-Bold'), url(https://fonts.gstatic.com/s/lato/v11/qdgUG4U09HnJwhYI-uK18wLUuEpTyoUstqEm5AMlJo4.woff) format('woff');
            }

            @@font-face {
                font-family: 'Lato';
                font-style: italic;
                font-weight: 400;
                src: local('Lato Italic'), local('Lato-Italic'), url(https://fonts.gstatic.com/s/lato/v11/RYyZNoeFgb0l7W3Vu1aSWOvvDin1pK8aKteLpeZ5c0A.woff) format('woff');
            }

            @@font-face {
                font-family: 'Lato';
                font-style: italic;
                font-weight: 700;
                src: local('Lato Bold Italic'), local('Lato-BoldItalic'), url(https://fonts.gstatic.com/s/lato/v11/HkF_qI1x_noxlxhrhMQYELO3LdcAZYWl9Si6vvxL-qU.woff) format('woff');
            }
        }
        /* CLIENT-SPECIFIC STYLES */
        body, table, td, a {
            -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;
        }
        /* RESET STYLES */
        img {
            border: 0;
            height: auto;
            line-height: 100%;
            outline: none;
            text-decoration: none;
        }

        table {
            border-collapse: collapse !important;
            border: 0;
        }

        table td, table th {
            padding: 0;
        }

        .body {
            height: 100% !important;
            margin: 0 !important;
            padding: 0 !important;
            width: 100% !important;
            background-color: #272626;
        }
        /* iOS BLUE LINKS */
        a[x-apple-data-detectors] {
            color: inherit !important;
            text-decoration: none !important;
            font-size: inherit !important;
            font-family: inherit !important;
            font-weight: inherit !important;
            line-height: inherit !important;
        }
        /* MOBILE STYLES */
        @@media screen and (max-width:600px) {
            h1 {
                font-size: 32px !important;
                line-height: 32px !important;
            }
        }
        /* ANDROID CENTER FIX */
        div[style*="margin: 16px 0;"] {
            margin: 0 !important;
        }

        /* Other*/
        /* Mettre vos styles ici */
    </style>
</head>
<body class="body">

    <table class="full-width">
        <tbody>
            <tr>
                <td class="header-block">
                    <table class="fixed-width block-center">
                        <tbody>
                            <tr>
                                <td class="fixed-width">
                                    <table class="full-width capped-width">
                                        <tbody>
                                            <tr>
                                                <td class="banner-block">
                                                    <a href="https://www.grandmenhir.com">
                                                        <img alt="Logo" class="logo-img" src="https://www.grandmenhir.com/images/email/email-banner.png">
                                                    </a>
                                                </td>
                                            </tr>
                                        </tbody>
                                    </table>
                                </td>
                            </tr>
                        </tbody>
                    </table>
                </td>
            </tr>
            <tr>
                <td class="content-block">
                    <table class="block-center fixed-width">
                        <tbody>
                            <tr>
                                <td class="fixed-width">
                                    <table class="full-width capped-width">
                                        <tbody>
                                            <tr>
                                                <td class="title-block">
                                                    <h1 class="title">@ViewData["EmailTitle"]</h1>
                                                </td>
                                            </tr>
                                        </tbody>
                                    </table>
                                </td>
                            </tr>
                        </tbody>
                    </table>
                </td>
            </tr>
            <tr>
                <td class="content-block">
                    <table class="fixed-width block-center">
                        <tbody>
                            <tr>
                                <td class="fixed-width inline-center">
                                    <table class="full-width capped-width">
                                        <tbody>
                                            <tr>
                                                <td class="content-block-body">@RenderBody()</td>
                                            </tr>
                                        </tbody>
                                    </table>
                                </td>
                            </tr>
                        </tbody>
                    </table>
                </td>
            </tr>
            <tr>
                <td class="content-block">
                    <table class="full-width capped-width block-center">
                        <tbody>
                            <tr>
                                <td class="capped-width inline-center">
                                    <table class="full-width capped-width">
                                        <tbody>
                                            <tr>
                                                <td class="copyright-block">
                                                    <p class="copyright">© Grand Menhir. Tous droits réservés.</p>
                                                </td>
                                            </tr>
                                        </tbody>
                                    </table>
                                </td>
                            </tr>
                        </tbody>
                    </table>
                </td>
            </tr>
        </tbody>
    </table>

</body>
</html>

Un autre gabarit utile est celui d'un bouton (EmailButton.cshtml). Cela évitera de recopier le même code dans chaque courriel ayant un bouton, parfois appelé CTA (call to action).

@using GrandMenhir.Web.Views.Shared
@model EmailButtonViewModel

<table class="full-width">
    <tbody>
        <tr>
            <td class="cta-button-wrapper">
                <table>
                    <tbody>
                        <tr>
                            <td class="cta-button-cell">
                                <a href="@Model.Url" target="_blank" class="cta-button">
                                    @Model.Text
                                </a>
                            </td>
                        </tr>
                    </tbody>
                </table>
            </td>
        </tr>
    </tbody>
</table>

Finalement, on peut créer la vue pour le courriel (ConfirmAccount.cshtml). Cette vue utilisera le gabarit et le bouton.

@using GrandMenhir.Web.Views.Emails.ConfirmAccount
@using GrandMenhir.Web.Views.Shared
@model ConfirmAccountEmailViewModel

@{
    ViewData["EmailTitle"] = Model.Title;
}

<p>
    @Model.Content
</p>


<br />

@await Html.PartialAsync("EmailButton", new EmailButtonViewModel("Confirmez votre courriel", Model.ConfirmEmailUrl))

<br />


<p>
    Grand Menhir
</p>

Voici le ViewModel correspondant :

namespace GrandMenhir.Web.Views.Emails.ConfirmAccount
{
    public class ConfirmAccountEmailViewModel
    {
        public ConfirmAccountEmailViewModel(string title, string content, string confirmEmailUrl)
        {
            Title = title;
            Content = content;
            ConfirmEmailUrl = confirmEmailUrl;
        }
        public string Title { get; set; }
        public string Content { get; set; }
        public string ConfirmEmailUrl { get; set; }
    }
}

Note: Pour utiliser le gabarit, on doit le spécifier dans _ViewStart.cshtml placé à la racine de nos vues pour les courriels.

@{
    Layout = "EmailLayout";
}

Envoyer un courriel

Pour créer une instance de courriel et l'envoyer, nous devons premièrement générer le contenu dynamique avec un renderer. Créons tout d'abord l'interface :

using System.Threading.Tasks;

namespace GrandMenhir.Web.Services.Renderer
{
    public interface IRazorViewToStringRenderer
    {
        Task RenderViewToStringAsync<TModel>(string viewName, TModel model);
    }
}

Et ensuite l'implémentation de cet interface. En gros, on exécute dynamiquement une vue et on conserve le résultat qui sera utilisé pour le contenu du courriel :

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Routing;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;

namespace GrandMenhir.Web.Services.Renderer
{
    public class RazorViewToStringRenderer : IRazorViewToStringRenderer
    {
        private IRazorViewEngine _viewEngine;
        private ITempDataProvider _tempDataProvider;
        private IServiceProvider _serviceProvider;

        public RazorViewToStringRenderer(
            IRazorViewEngine viewEngine,
            ITempDataProvider tempDataProvider,
            IServiceProvider serviceProvider)
        {
            _viewEngine = viewEngine;
            _tempDataProvider = tempDataProvider;
            _serviceProvider = serviceProvider;
        }

        public async Task RenderViewToStringAsync(string viewName, TModel model)
        {
            var actionContext = GetActionContext();
            var view = FindView(actionContext, viewName);

            using (var output = new StringWriter())
            {
                var viewContext = new ViewContext(
                    actionContext,
                    view,
                    new ViewDataDictionary(
                        metadataProvider: new EmptyModelMetadataProvider(),
                        modelState: new ModelStateDictionary())
                    {
                        Model = model
                    },
                    new TempDataDictionary(
                        actionContext.HttpContext,
                        _tempDataProvider),
                    output,
                    new HtmlHelperOptions());

                await view.RenderAsync(viewContext);

                return output.ToString();
            }
        }

        private IView FindView(ActionContext actionContext, string viewName)
        {
            var getViewResult = _viewEngine.GetView(executingFilePath: null, viewPath: viewName, isMainPage: true);
            if (getViewResult.Success)
            {
                return getViewResult.View;
            }

            var findViewResult = _viewEngine.FindView(actionContext, viewName, isMainPage: true);
            if (findViewResult.Success)
            {
                return findViewResult.View;
            }

            var searchedLocations = getViewResult.SearchedLocations.Concat(findViewResult.SearchedLocations);
            var errorMessage = string.Join(
                Environment.NewLine,
                new[] { $"Unable to find view '{viewName}'. The following locations were searched:" }.Concat(searchedLocations)); ;

            throw new InvalidOperationException(errorMessage);
        }

        private ActionContext GetActionContext()
        {
            var httpContext = new DefaultHttpContext();
            httpContext.RequestServices = _serviceProvider;
            return new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
        }
    }
}

On exécute ensuite la vue en passant les paramètres entant que ViewModel :

string emailBody = await _razorViewToStringRenderer.RenderViewToStringAsync("/Views/Emails/ConfirmAccount/ConfirmAccount.cshtml", confirmAccountEmailViewModel);

Finalement, on peut envoyer le courriel avec un EmailSender:

var confirmAccountEmailViewModel = new ConfirmAccountEmailViewModel("Bienvenue !",
    "Merci d'avoir créé votre compte...",
    callbackUrl);
await _emailSender.SendEmailAsync(Input.Email,"Confirmer votre courriel", emailBody);

Voici un exemple pour le EmailSender. Les EmailSettings utilisés doivent pointer aux configuration de votre server SMTP local (l'image Docker):

using GrandMenhir.Web.Entities;
using MailKit.Net.Smtp;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Options;
using MimeKit;
using System;
using System.Threading.Tasks;

namespace GrandMenhir.Web.Services.Email
{
    public class EmailSender : Microsoft.AspNetCore.Identity.UI.Services.IEmailSender
    {
        private readonly EmailSettings _emailSettings;
        private readonly IWebHostEnvironment _env;

        public EmailSender(
            IOptions emailSettings,
            IWebHostEnvironment env)
        {
            _emailSettings = emailSettings.Value;
            _env = env;
        }

        public async Task SendEmailAsync(string recipientEmail, string subject, string message)
        {
            try
            {
                var mimeMessage = new MimeMessage();
                mimeMessage.From.Add(new MailboxAddress(_emailSettings.SenderName, _emailSettings.Sender));
                mimeMessage.To.Add(new MailboxAddress("Visiteur", recipientEmail));
                mimeMessage.Subject = subject;
                mimeMessage.Body = new TextPart("html")
                {
                    Text = message
                };

                using (var client = new SmtpClient())
                {

                    if (_env.EnvironmentName == "Development")
                    {
                        // For development, start smtp4dev docker image with the following command
                        // docker run --rm -it -p 3000:80 -p 2525:25 rnwood/smtp4dev:3.4.1-ci20240428110
                        // In dev, localhost connection does not use SSL
                        await client.ConnectAsync(_emailSettings.MailServer, _emailSettings.MailPort, false);
                    }
                    else
                    {
                        // For demo-purposes, accept all SSL certificates (in case the server supports STARTTLS)
                        client.ServerCertificateValidationCallback = (s, c, h, e) => true;
                        await client.ConnectAsync(_emailSettings.MailServer, _emailSettings.MailPort, true);

                        // Note: only needed if the SMTP server requires authentication
                        await client.AuthenticateAsync(_emailSettings.Sender, _emailSettings.Password);
                    }

                    await client.SendAsync(mimeMessage);
                    await client.DisconnectAsync(true);
                }

            }
            catch (Exception ex)
            {
                throw new InvalidOperationException(ex.Message);
            }
        }
    }

}

Valider la syntaxe et compatibilité

Une fois que tout est en place pour générer des courriels, il est maintenant possible d'utiliser l'interface smtp4dev pour s'assurer que le rendu visuel du courriel ainsi que sa syntaxe soit conforme. Voici un exemple de rendu de courriel dans l'interface Web smtp4dev. Si vous avez utilisé le port 3000, l'URL est http://localhost:3000.

Visualisation de courriels avec smtp4dev
Interface smtp4dev

L'interface resemble à un client de courriel bien standard, sauf pour une particularité bien importante, l'onglet Analysis. Sous cet onglet, vous retrouverez deux sous-onlglets: HTML Compatibility et HTML Validation

Analyse de courriels avec smtp4dev
Interface smtp4dev onglet Analysis

HTML Compatibility compile toute utilisation de code HTML qui est partiellement ou pas supporté par diverses plateformes. C'est de l'information très utile car vous n'avez pas à faire vos propres recherches ou tests sur multiples plateformes. Cependant, il semble qu'il soit impossible de satisfaire toutes les plateformes donc c'est à prendre à titre d'indication seulement.

HTML Validation validera votre syntaxe HTML finalement, ce qui est difficile à déterminer lorsqu'on utilise un gabarit et des données injectées dynamiquement. Les balises non fermées, le code CSS inline, etc, seront parmis les items qui seront rapportés. Dans ce cas, il est possible de tout règler, comme vous pouvez le constater dans l'image ci-dessus, le courriel ne comporte aucune erreur de validation.

Le processus est simple, on corrige une ou plusieurs erreurs rapportées ou de rendu visuel, on renvoie un courriel, on revalide et ce jusqu'à ce qu'on soit satisfait du résultat.

Conclusion

smtp4dev est un outil extrêmement pratique. Non seulement il permet de tester l'envoi de courriel localement, ce qui prévient tout problème de spam ou de latence (surtout lors d'envoi par lot), mais il offre aussi toute la gamme d'outils de validation que l'on aurait besoin pour s'assurer que tout est valide visuellement et syntaxiquement. C'est un peu le couteau suisse des courriels.

Cependant, rien n'est parfait dans le monde de courriels. Plusieurs défis font en sorte que des courriels ne finissent qu'être une image pour s'assurer que le rendu soit standard sur tous les clients. C'est peut-être une solution facile pour des courriels publicitaires, mais pour les courriels avec contenu personnalisé, il faut faire l'effort de rendre le tout compatible le plus possible.

Commentaires

Vous devez être connecté pour ajouter un commentaire.