mjml-email-templates by aaronontheweb/dotnet-skills
npx skills add https://github.com/aaronontheweb/dotnet-skills --skill mjml-email-templates在以下情况下使用此技能:
相关技能:
aspire/mailpit-integration - 使用 Mailpit 在本地测试电子邮件testing/verify-email-snapshots - 对渲染的 HTML 进行快照测试问题:电子邮件 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,并带有内联样式,可在任何地方正常工作。
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
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
<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">
© 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);
| 组件 | 用途 |
|---|---|
<mj-section> | 水平容器(类似于行) |
<mj-column> | 区域内的垂直容器 |
<mj-text> | 带样式的文本内容 |
<mj-button> | 号召性用语按钮 |
<mj-image> | 响应式图片 |
<mj-divider> | 水平线 |
<mj-spacer> | 垂直间距 |
<mj-table> | 数据表格 |
<mj-social> | 社交媒体图标 |
每周安装次数
86
代码仓库
GitHub 星标数
488
首次出现
2026年2月2日
安全审计
安装于
claude-code60
codex58
opencode55
gemini-cli54
github-copilot53
kimi-cli51
Use this skill when:
Related skills:
aspire/mailpit-integration - Test emails locally with Mailpittesting/verify-email-snapshots - Snapshot test rendered HTMLProblem : Email HTML is notoriously difficult. Each email client (Outlook, Gmail, Apple Mail) renders differently, requiring complex table-based layouts and inline styles.
Solution : MJML is a markup language that compiles to responsive, cross-client HTML:
<!-- MJML - simple and readable -->
<mj-section>
<mj-column>
<mj-text>Hello {{UserName}}</mj-text>
<mj-button href="{{ActionUrl}}">Click Here</mj-button>
</mj-column>
</mj-section>
Compiles to ~200 lines of table-based HTML with inline styles that works everywhere.
dotnet add package Mjml.Net
In your .csproj:
<ItemGroup>
<EmbeddedResource Include="Templates\**\*.mjml" />
</ItemGroup>
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
<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">
<!-- Header -->
<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>
<!-- Content injected here -->
<mj-section background-color="#ffffff" padding-top="20px" padding-bottom="40px">
<mj-column>
{{Content}}
</mj-column>
</mj-section>
<!-- Footer -->
<mj-section background-color="#f9fafb" padding="20px 25px">
<mj-column>
<mj-text align="center" font-size="12px" color="#9ca3af">
© 2025 MyApp Inc. All rights reserved.
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
<!-- UserInvitations/UserSignupInvitation.mjml -->
<!-- Wrapped in _Layout.mjml automatically -->
<mj-text font-size="16px" color="#111827" font-weight="600" padding-bottom="20px">
You've been invited to join {{OrganizationName}}
</mj-text>
<mj-text padding-bottom="15px">
Hi {{InviteeName}},
</mj-text>
<mj-text padding-bottom="15px">
{{InviterName}} has invited you to join <strong>{{OrganizationName}}</strong>.
</mj-text>
<mj-text padding-bottom="25px">
Click the button below to accept your invitation:
</mj-text>
<mj-button background-color="#2563eb" color="#ffffff" font-size="16px" href="{{InvitationLink}}">
Accept Invitation
</mj-button>
<mj-text padding-top="25px" font-size="13px" color="#6b7280">
This invitation expires on {{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)
{
// 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<string, string>
{
{ "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<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($"Template '{templateName}' not found");
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();
}
Separate template rendering from email composition with strongly-typed value objects:
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", $"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);
}
}
Add an admin endpoint to preview emails during development:
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");
<!-- DO: Use MJML components for layout -->
<mj-section>
<mj-column>
<mj-text>Content</mj-text>
</mj-column>
</mj-section>
<!-- DON'T: Use raw HTML tables -->
<table><tr><td>Content</td></tr></table>
<!-- DO: Use production URLs for images -->
<mj-image src="https://myapp.com/logo.png" />
<!-- DON'T: Use relative paths -->
<mj-image src="/img/logo.png" />
// DO: Use strongly-typed value objects
Task<EmailMessage> ComposeAsync(
EmailAddress to,
PersonName name,
AbsoluteUri actionUrl);
// DON'T: Use raw strings
Task<EmailMessage> ComposeAsync(
string email,
string name,
string url);
| Component | Purpose |
|---|---|
<mj-section> | Horizontal container (like a row) |
<mj-column> | Vertical container within section |
<mj-text> | Text content with styling |
<mj-button> | Call-to-action button |
<mj-image> | Responsive image |
<mj-divider> | Horizontal line |
Weekly Installs
86
Repository
GitHub Stars
488
First Seen
Feb 2, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
claude-code60
codex58
opencode55
gemini-cli54
github-copilot53
kimi-cli51
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
118,000 周安装
Web应用项目规划助手 - 自动生成实施阶段与文档 | 项目规划技能
576 周安装
代码重构指南:原则、异味识别与重构技术详解 | 提升代码质量
577 周安装
Google Drive 文件所有权转移方案 - Google Workspace CLI 自动化操作指南
582 周安装
Google Workspace 安全警报处理教程 - 使用 gws CLI 列出、查看和确认警报
583 周安装
交互式仪表板构建指南:使用Chart.js创建HTML/JS数据可视化面板
584 周安装
OpenClaw 自动更新器技能 - 保持网关持续更新的自动化解决方案
601 周安装
<mj-spacer> | Vertical spacing |
<mj-table> | Data tables |
<mj-social> | Social media icons |