home / skills / aaronontheweb / dotnet-skills / transactional-emails
/skills/aspnetcore/transactional-emails
This skill helps developers render transactional MJML emails into responsive HTML with variable substitution and local testing support.
npx playbooks add skill aaronontheweb/dotnet-skills --skill transactional-emailsReview the files below or copy the command above to add this skill to your agents.
---
name: transactional-emails
description: Build transactional emails using MJML templates with variable substitution. Render responsive HTML that works across email clients. Test with Mailpit/Mailhog in development via Aspire.
---
# Transactional Emails with MJML
## 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 email testing infrastructure in development
- Implementing email preview/approval workflows
---
## 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
<!-- 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.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Email Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ MJML Template ──► Mjml.Net Renderer ──► HTML Email │
│ (embedded resource) (compile-time) (rendered) │
│ │ │ │
│ │ ▼ │
│ │ ┌───────────────────┐│
│ └──────────────────────────────►│ SMTP Gateway ││
│ Variable substitution │ - Production ││
│ {{UserName}}, {{Link}} │ - Mailpit (dev) ││
│ └───────────────────┘│
└─────────────────────────────────────────────────────────────┘
```
---
## 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
```
---
## Installation
### Add Mjml.Net
```bash
dotnet add package Mjml.Net
```
### Embed Templates as Resources
In your `.csproj`:
```xml
<ItemGroup>
<EmbeddedResource Include="Templates\**\*.mjml" />
</ItemGroup>
```
---
## Template Structure
### Layout Template (_Layout.mjml)
```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">
<!-- 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>
```
### Content Template
```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>
```
---
## Template Renderer
```csharp
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();
}
```
---
## Email Composer Pattern
Separate template rendering from email composition:
```csharp
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);
}
}
```
---
## Development Testing with Mailpit
Use Mailpit (or Mailhog) to capture emails locally without sending them.
### Aspire Integration
See `aspire/integration-testing` skill for full Aspire setup. Add Mailpit:
```csharp
// AppHost/Program.cs
var mailpit = builder.AddContainer("mailpit", "axllent/mailpit")
.WithHttpEndpoint(port: 8025, targetPort: 8025, name: "ui")
.WithEndpoint(port: 1025, targetPort: 1025, name: "smtp");
var api = builder.AddProject<Projects.MyApp_Api>("api")
.WithReference(mailpit.GetEndpoint("smtp"))
.WithEnvironment("Smtp__Host", mailpit.GetEndpoint("smtp"));
```
### Configure SMTP Client
```csharp
// In development, use Mailpit
services.AddSingleton<IEmailSender>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var host = config["Smtp:Host"] ?? "localhost";
var port = int.Parse(config["Smtp:Port"] ?? "1025");
return new SmtpEmailSender(host, port);
});
```
### View Captured Emails
Navigate to `http://localhost:8025` to see all captured emails with:
- Full HTML rendering
- Source view
- Headers inspection
- Attachment handling
---
## Snapshot Testing Emails
Use Verify to catch template regressions (see `testing/snapshot-testing` skill):
```csharp
[Fact]
public async Task UserSignupInvitation_RendersCorrectly()
{
var renderer = _services.GetRequiredService<IMjmlTemplateRenderer>();
var variables = new Dictionary<string, string>
{
{ "PreviewText", "You've been invited to join Acme Corp" },
{ "OrganizationName", "Acme Corporation" },
{ "InviteeName", "John Doe" },
{ "InviterName", "Jane Admin" },
{ "InvitationLink", "https://example.com/invite/abc123" },
{ "ExpirationDate", "December 31, 2025" }
};
var html = await renderer.RenderTemplateAsync(
"UserInvitations/UserSignupInvitation",
variables);
await Verify(html, extension: "html");
}
```
Creates `UserSignupInvitation_RendersCorrectly.verified.html` - review in browser or diff tool.
---
## 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
<!-- 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" />
```
### Variable Handling
```csharp
// 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);
```
### Testing
```csharp
// DO: Test each template variant
[Fact] Task WelcomeEmail_NewUser_RendersCorrectly()
[Fact] Task WelcomeEmail_InvitedUser_RendersCorrectly()
// DO: Use Mailpit in integration tests
// DO: Snapshot test rendered HTML
// DON'T: Skip email testing
// DON'T: Only test in production
```
---
## MJML Components Reference
| 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 |
| `<mj-spacer>` | Vertical spacing |
| `<mj-table>` | Data tables |
| `<mj-social>` | 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
- **Mailpit**: https://github.com/axllent/mailpit
- **Email on Acid** (testing): https://www.emailonacid.com/
This skill builds production-ready transactional emails using MJML templates with variable substitution and compiles them to responsive HTML that works across email clients. It integrates a .NET MJML renderer, embeds templates as resources, and returns fully inlined HTML suitable for SMTP delivery. Development-friendly features include Mailpit/Mailhog testing via Aspire and snapshot testing support.
Templates are authored as MJML files and a shared layout (_Layout.mjml) is used to wrap content templates. At runtime a renderer loads embedded resources, substitutes {{variables}} from a provided dictionary, compiles MJML to email-safe HTML using Mjml.Net, and returns the final HtmlBody. For development you can route SMTP to Mailpit/Mailhog via Aspire so emails are captured locally rather than sent.
How are variables substituted in templates?
The renderer replaces {{Name}} tokens using a compiled regex; missing variables are left unchanged so rendering still succeeds.
Can I preview emails during development?
Yes. Add Mailpit via Aspire and point your SMTP config to the Mailpit container, then open http://localhost:8025 to inspect captured messages.