--- name: mjml-email-templates description: Build responsive email templates using MJML markup language. Compiles to cross-client HTML that works in Outlook, Gmail, and Apple Mail. Includes template renderer, layout patterns, and variable substitution. invocable: false --- # MJML Email Templates ## When to Use This Skill Use this skill when: - Building transactional emails (signup, password reset, invoices, notifications) - Creating responsive email templates that work across clients - Setting up MJML template rendering in .NET **Related skills:** - `aspire/mailpit-integration` - Test emails locally with Mailpit - `testing/verify-email-snapshots` - Snapshot test rendered HTML --- ## Why MJML? **Problem**: Email HTML is notoriously difficult. Each email client (Outlook, Gmail, Apple Mail) renders differently, requiring complex table-based layouts and inline styles. **Solution**: [MJML](https://mjml.io/) is a markup language that compiles to responsive, cross-client HTML: ```mjml Hello {{UserName}} Click Here ``` Compiles to ~200 lines of table-based HTML with inline styles that works everywhere. --- ## Installation ### Add Mjml.Net ```bash dotnet add package Mjml.Net ``` ### Embed Templates as Resources In your `.csproj`: ```xml ``` --- ## Project Structure ``` src/ Infrastructure/ MyApp.Infrastructure.Mailing/ Templates/ _Layout.mjml # Shared layout (header, footer) UserInvitations/ UserSignupInvitation.mjml InvitationExpired.mjml PasswordReset/ PasswordReset.mjml Billing/ PaymentReceipt.mjml RenewalReminder.mjml Mjml/ IMjmlTemplateRenderer.cs MjmlTemplateRenderer.cs MjmlEmailMessage.cs Composers/ IUserEmailComposer.cs UserEmailComposer.cs MyApp.Infrastructure.Mailing.csproj ``` --- ## Layout Template (_Layout.mjml) ```mjml MyApp {{PreviewText}} a { color: #2563eb; text-decoration: none; } a:hover { text-decoration: underline; } {{Content}} © 2025 MyApp Inc. All rights reserved. ``` --- ## Content Template ```mjml You've been invited to join {{OrganizationName}} Hi {{InviteeName}}, {{InviterName}} has invited you to join {{OrganizationName}}. Click the button below to accept your invitation: Accept Invitation This invitation expires on {{ExpirationDate}}. ``` --- ## Template Renderer ```csharp public interface IMjmlTemplateRenderer { Task RenderTemplateAsync( string templateName, IReadOnlyDictionary variables, CancellationToken ct = default); } public sealed partial class MjmlTemplateRenderer : IMjmlTemplateRenderer { private readonly MjmlRenderer _mjmlRenderer = new(); private readonly Assembly _assembly; private readonly string _siteUrl; public MjmlTemplateRenderer(IConfiguration config) { _assembly = typeof(MjmlTemplateRenderer).Assembly; _siteUrl = config["SiteUrl"] ?? "https://myapp.com"; } public async Task RenderTemplateAsync( string templateName, IReadOnlyDictionary variables, CancellationToken ct = default) { // Load content template var contentMjml = await LoadTemplateAsync(templateName, ct); // Load layout and inject content var layoutMjml = await LoadTemplateAsync("_Layout", ct); var combinedMjml = layoutMjml.Replace("{{Content}}", contentMjml); // Merge variables (layout + template-specific) var allVariables = new Dictionary { { "SiteUrl", _siteUrl } }; foreach (var kvp in variables) allVariables[kvp.Key] = kvp.Value; // Substitute variables var processedMjml = SubstituteVariables(combinedMjml, allVariables); // Compile to HTML var result = await _mjmlRenderer.RenderAsync(processedMjml, null, ct); if (result.Errors.Any()) throw new InvalidOperationException( $"MJML compilation failed: {string.Join(", ", result.Errors.Select(e => e.Error))}"); return result.Html; } private async Task LoadTemplateAsync(string templateName, CancellationToken ct) { var resourceName = $"MyApp.Infrastructure.Mailing.Templates.{templateName.Replace('/', '.')}.mjml"; await using var stream = _assembly.GetManifestResourceStream(resourceName) ?? throw new FileNotFoundException($"Template '{templateName}' not found"); using var reader = new StreamReader(stream); return await reader.ReadToEndAsync(ct); } private static string SubstituteVariables(string mjml, IReadOnlyDictionary variables) { return VariableRegex().Replace(mjml, match => { var name = match.Groups[1].Value; return variables.TryGetValue(name, out var value) ? value : match.Value; }); } [GeneratedRegex(@"\{\{([^}]+)\}\}", RegexOptions.Compiled)] private static partial Regex VariableRegex(); } ``` --- ## Email Composer Pattern Separate template rendering from email composition with strongly-typed value objects: ```csharp public interface IUserEmailComposer { Task ComposeSignupInvitationAsync( EmailAddress recipientEmail, PersonName recipientName, PersonName inviterName, OrganizationName organizationName, AbsoluteUri invitationUrl, DateTimeOffset expiresAt, CancellationToken ct = default); } public sealed class UserEmailComposer : IUserEmailComposer { private readonly IMjmlTemplateRenderer _renderer; public UserEmailComposer(IMjmlTemplateRenderer renderer) { _renderer = renderer; } public async Task ComposeSignupInvitationAsync( EmailAddress recipientEmail, PersonName recipientName, PersonName inviterName, OrganizationName organizationName, AbsoluteUri invitationUrl, DateTimeOffset expiresAt, CancellationToken ct = default) { var variables = new Dictionary { { "PreviewText", $"You've been invited to join {organizationName.Value}" }, { "InviteeName", recipientName.Value }, { "InviterName", inviterName.Value }, { "OrganizationName", organizationName.Value }, { "InvitationLink", invitationUrl.ToString() }, { "ExpirationDate", expiresAt.ToString("MMMM d, yyyy") } }; var html = await _renderer.RenderTemplateAsync( "UserInvitations/UserSignupInvitation", variables, ct); return new EmailMessage( To: recipientEmail, Subject: $"You've been invited to join {organizationName.Value}", HtmlBody: html); } } ``` --- ## Email Preview Endpoint Add an admin endpoint to preview emails during development: ```csharp app.MapGet("/admin/emails/preview/{template}", async ( string template, IMjmlTemplateRenderer renderer) => { var sampleVariables = GetSampleVariables(template); var html = await renderer.RenderTemplateAsync(template, sampleVariables); return Results.Content(html, "text/html"); }) .RequireAuthorization("AdminOnly"); ``` --- ## Best Practices ### Template Design ```mjml Content
Content
``` ### Variable Handling ```csharp // DO: Use strongly-typed value objects Task ComposeAsync( EmailAddress to, PersonName name, AbsoluteUri actionUrl); // DON'T: Use raw strings Task ComposeAsync( string email, string name, string url); ``` --- ## MJML Components Reference | Component | Purpose | |-----------|---------| | `` | Horizontal container (like a row) | | `` | Vertical container within section | | `` | Text content with styling | | `` | Call-to-action button | | `` | Responsive image | | `` | Horizontal line | | `` | Vertical spacing | | `` | Data tables | | `` | Social media icons | --- ## Resources - **MJML Documentation**: https://documentation.mjml.io/ - **MJML Playground**: https://mjml.io/try-it-live - **Mjml.Net**: https://github.com/ArtZab/Mjml.Net