MJML电子邮件模板 mjml-email-templates

本技能用于使用 MJML 标记语言构建和渲染响应式、跨客户端兼容的电子邮件模板。它解决了传统电子邮件 HTML 开发复杂、兼容性差的问题,通过编译技术生成可在 Outlook、Gmail、Apple Mail 等主流邮件客户端中完美显示的 HTML 代码。适用于 .NET 后端开发中的事务性邮件(如注册、密码重置、通知、账单)系统构建,提供模板管理、变量替换和邮件内容组合等功能。关键词:MJML 邮件模板,响应式电子邮件,跨客户端兼容,.NET 邮件渲染,事务性邮件系统,邮件模板开发。

后端开发 0 次安装 0 次浏览 更新于 2/26/2026

name: mjml-email-templates description: 使用 MJML 标记语言构建响应式电子邮件模板。编译为可在 Outlook、Gmail 和 Apple Mail 中使用的跨客户端 HTML。包括模板渲染器、布局模式和变量替换功能。 invocable: false

MJML 电子邮件模板

何时使用此技能

在以下情况下使用此技能:

  • 构建事务性电子邮件(注册、密码重置、发票、通知)
  • 创建可在不同客户端中正常工作的响应式电子邮件模板
  • 在 .NET 中设置 MJML 模板渲染

相关技能:

  • aspire/mailpit-integration - 使用 Mailpit 在本地测试电子邮件
  • testing/verify-email-snapshots - 对渲染的 HTML 进行快照测试

为什么选择 MJML?

问题:电子邮件 HTML 是出了名的困难。每个电子邮件客户端(Outlook、Gmail、Apple Mail)的渲染方式都不同,需要复杂的基于表格的布局和内联样式。

解决方案MJML 是一种标记语言,可编译为响应式、跨客户端的 HTML:

<!-- MJML - 简单易读 -->
<mj-section>
  <mj-column>
    <mj-text>你好 {{UserName}}</mj-text>
    <mj-button href="{{ActionUrl}}">点击这里</mj-button>
  </mj-column>
</mj-section>

编译为约 200 行基于表格、带有内联样式的 HTML,可在任何地方工作。


安装

添加 Mjml.Net

dotnet add package Mjml.Net

将模板嵌入为资源

在您的 .csproj 文件中:

<ItemGroup>
  <EmbeddedResource Include="Templates\**\*.mjml" />
</ItemGroup>

项目结构

src/
  Infrastructure/
    MyApp.Infrastructure.Mailing/
      Templates/
        _Layout.mjml              # 共享布局(页眉、页脚)
        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.mjml)

<mjml>
  <mj-head>
    <mj-title>MyApp</mj-title>
    <mj-preview>{{PreviewText}}</mj-preview>
    <mj-attributes>
      <mj-all font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" />
      <mj-text font-size="14px" color="#555555" line-height="20px" />
      <mj-section padding="20px" />
    </mj-attributes>
    <mj-style inline="inline">
      a { color: #2563eb; text-decoration: none; }
      a:hover { text-decoration: underline; }
    </mj-style>
  </mj-head>
  <mj-body background-color="#f3f4f6">
    <!-- 页眉 -->
    <mj-section background-color="#ffffff" padding-bottom="0">
      <mj-column>
        <mj-image
          src="https://myapp.com/logo.png"
          alt="MyApp"
          width="150px"
          href="{{SiteUrl}}"
          padding="30px 25px 20px 25px" />
      </mj-column>
    </mj-section>

    <!-- 内容在此注入 -->
    <mj-section background-color="#ffffff" padding-top="20px" padding-bottom="40px">
      <mj-column>
        {{Content}}
      </mj-column>
    </mj-section>

    <!-- 页脚 -->
    <mj-section background-color="#f9fafb" padding="20px 25px">
      <mj-column>
        <mj-text align="center" font-size="12px" color="#9ca3af">
          &copy; 2025 MyApp Inc. 保留所有权利。
        </mj-text>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

内容模板

<!-- UserInvitations/UserSignupInvitation.mjml -->
<!-- 自动包裹在 _Layout.mjml 中 -->

<mj-text font-size="16px" color="#111827" font-weight="600" padding-bottom="20px">
  您已被邀请加入 {{OrganizationName}}
</mj-text>

<mj-text padding-bottom="15px">
  你好 {{InviteeName}},
</mj-text>

<mj-text padding-bottom="15px">
  {{InviterName}} 邀请您加入 <strong>{{OrganizationName}}</strong>。
</mj-text>

<mj-text padding-bottom="25px">
  点击下面的按钮接受您的邀请:
</mj-text>

<mj-button background-color="#2563eb" color="#ffffff" font-size="16px" href="{{InvitationLink}}">
  接受邀请
</mj-button>

<mj-text padding-top="25px" font-size="13px" color="#6b7280">
  此邀请将于 {{ExpirationDate}} 过期。
</mj-text>

模板渲染器

public interface IMjmlTemplateRenderer
{
    Task<string> RenderTemplateAsync(
        string templateName,
        IReadOnlyDictionary<string, string> 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<string> RenderTemplateAsync(
        string templateName,
        IReadOnlyDictionary<string, string> variables,
        CancellationToken ct = default)
    {
        // 加载内容模板
        var contentMjml = await LoadTemplateAsync(templateName, ct);

        // 加载布局并注入内容
        var layoutMjml = await LoadTemplateAsync("_Layout", ct);
        var combinedMjml = layoutMjml.Replace("{{Content}}", contentMjml);

        // 合并变量(布局 + 模板特定)
        var allVariables = new Dictionary<string, string>
        {
            { "SiteUrl", _siteUrl }
        };
        foreach (var kvp in variables)
            allVariables[kvp.Key] = kvp.Value;

        // 替换变量
        var processedMjml = SubstituteVariables(combinedMjml, allVariables);

        // 编译为 HTML
        var result = await _mjmlRenderer.RenderAsync(processedMjml, null, ct);

        if (result.Errors.Any())
            throw new InvalidOperationException(
                $"MJML 编译失败:{string.Join(", ", result.Errors.Select(e => e.Error))}");

        return result.Html;
    }

    private async Task<string> LoadTemplateAsync(string templateName, CancellationToken ct)
    {
        var resourceName = $"MyApp.Infrastructure.Mailing.Templates.{templateName.Replace('/', '.')}.mjml";

        await using var stream = _assembly.GetManifestResourceStream(resourceName)
            ?? throw new FileNotFoundException($"模板 '{templateName}' 未找到");

        using var reader = new StreamReader(stream);
        return await reader.ReadToEndAsync(ct);
    }

    private static string SubstituteVariables(string mjml, IReadOnlyDictionary<string, string> 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();
}

电子邮件编写器模式

使用强类型值对象将模板渲染与电子邮件编写分离:

public interface IUserEmailComposer
{
    Task<EmailMessage> 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<EmailMessage> ComposeSignupInvitationAsync(
        EmailAddress recipientEmail,
        PersonName recipientName,
        PersonName inviterName,
        OrganizationName organizationName,
        AbsoluteUri invitationUrl,
        DateTimeOffset expiresAt,
        CancellationToken ct = default)
    {
        var variables = new Dictionary<string, string>
        {
            { "PreviewText", $"您已被邀请加入 {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: $"您已被邀请加入 {organizationName.Value}",
            HtmlBody: html);
    }
}

电子邮件预览端点

在开发期间添加一个管理员端点来预览电子邮件:

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");

最佳实践

模板设计

<!-- 推荐:使用 MJML 组件进行布局 -->
<mj-section>
  <mj-column>
    <mj-text>内容</mj-text>
  </mj-column>
</mj-section>

<!-- 不推荐:使用原始 HTML 表格 -->
<table><tr><td>内容</td></tr></table>

<!-- 推荐:为图像使用生产环境 URL -->
<mj-image src="https://myapp.com/logo.png" />

<!-- 不推荐:使用相对路径 -->
<mj-image src="/img/logo.png" />

变量处理

// 推荐:使用强类型值对象
Task<EmailMessage> ComposeAsync(
    EmailAddress to,
    PersonName name,
    AbsoluteUri actionUrl);

// 不推荐:使用原始字符串
Task<EmailMessage> ComposeAsync(
    string email,
    string name,
    string url);

MJML 组件参考

组件 用途
<mj-section> 水平容器(类似于行)
<mj-column> 节内的垂直容器
<mj-text> 带有样式的文本内容
<mj-button> 行动号召按钮
<mj-image> 响应式图像
<mj-divider> 水平线
<mj-spacer> 垂直间距
<mj-table> 数据表格
<mj-social> 社交媒体图标

资源