1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

[PM-23493] Generic mailer proposal (#5958)

This implements a new Mailer service which supersedes the `HandlebarsMailService`. It allows teams to create emails without having to extend a generic service.

The `IMailer` only contains a single method, `SendEmail`, which sends an instance of `BaseMail`.
This commit is contained in:
Oscar Hinton
2025-10-28 15:55:36 +01:00
committed by GitHub
parent 62a0936c2e
commit 653de07bd7
15 changed files with 498 additions and 2 deletions

View File

@@ -16,7 +16,9 @@
<ItemGroup>
<EmbeddedResource Include="licensing.cer" />
<EmbeddedResource Include="licensing_dev.cer" />
<EmbeddedResource Include="MailTemplates\Handlebars\**\*.hbs" />
<!-- Email templates uses .hbs extension, they must be included for emails to work -->
<EmbeddedResource Include="**\*.hbs" />
</ItemGroup>
<ItemGroup>
@@ -72,7 +74,7 @@
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Resources\" />
<Folder Include="Properties\" />

View File

@@ -0,0 +1,54 @@
namespace Bit.Core.Platform.Mailer;
#nullable enable
/// <summary>
/// BaseMail describes a model for emails. It contains metadata about the email such as recipients,
/// subject, and an optional category for processing at the upstream email delivery service.
///
/// Each BaseMail must have a view model that inherits from BaseMailView. The view model is used to
/// generate the text part and HTML body.
/// </summary>
public abstract class BaseMail<TView> where TView : BaseMailView
{
/// <summary>
/// Email recipients.
/// </summary>
public required IEnumerable<string> ToEmails { get; set; }
/// <summary>
/// The subject of the email.
/// </summary>
public abstract string Subject { get; }
/// <summary>
/// An optional category for processing at the upstream email delivery service.
/// </summary>
public string? Category { get; }
/// <summary>
/// Allows you to override and ignore the suppression list for this email.
///
/// Warning: This should be used with caution, valid reasons are primarily account recovery, email OTP.
/// </summary>
public virtual bool IgnoreSuppressList { get; } = false;
/// <summary>
/// View model for the email body.
/// </summary>
public required TView View { get; set; }
}
/// <summary>
/// Each MailView consists of two body parts: a text part and an HTML part and the filename must be
/// relative to the viewmodel and match the following pattern:
/// - `{ClassName}.html.hbs` for the HTML part
/// - `{ClassName}.text.hbs` for the text part
/// </summary>
public abstract class BaseMailView
{
/// <summary>
/// Current year.
/// </summary>
public string CurrentYear => DateTime.UtcNow.Year.ToString();
}

View File

@@ -0,0 +1,80 @@
#nullable enable
using System.Collections.Concurrent;
using System.Reflection;
using HandlebarsDotNet;
namespace Bit.Core.Platform.Mailer;
public class HandlebarMailRenderer : IMailRenderer
{
/// <summary>
/// Lazy-initialized Handlebars instance. Thread-safe and ensures initialization occurs only once.
/// </summary>
private readonly Lazy<Task<IHandlebars>> _handlebarsTask = new(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>
/// Helper function that returns the handlebar instance.
/// </summary>
private Task<IHandlebars> GetHandlebars() => _handlebarsTask.Value;
/// <summary>
/// This dictionary is used to cache compiled templates in a thread-safe manner.
/// </summary>
private readonly ConcurrentDictionary<string, Lazy<Task<HandlebarsTemplate<object, object>>>> _templateCache = new();
public async Task<(string html, string txt)> RenderAsync(BaseMailView model)
{
var html = await CompileTemplateAsync(model, "html");
var txt = await CompileTemplateAsync(model, "text");
return (html, txt);
}
private async Task<string> CompileTemplateAsync(BaseMailView model, string type)
{
var templateName = $"{model.GetType().FullName}.{type}.hbs";
var assembly = model.GetType().Assembly;
// GetOrAdd is atomic - only one Lazy will be stored per templateName.
// The Lazy with ExecutionAndPublication ensures the compilation happens exactly once.
var lazyTemplate = _templateCache.GetOrAdd(
templateName,
key => new Lazy<Task<HandlebarsTemplate<object, object>>>(
() => CompileTemplateInternalAsync(assembly, key),
LazyThreadSafetyMode.ExecutionAndPublication));
var template = await lazyTemplate.Value;
return template(model);
}
private async Task<HandlebarsTemplate<object, object>> CompileTemplateInternalAsync(Assembly assembly, string templateName)
{
var source = await ReadSourceAsync(assembly, templateName);
var handlebars = await GetHandlebars();
return handlebars.Compile(source);
}
private static async Task<string> ReadSourceAsync(Assembly assembly, string template)
{
if (assembly.GetManifestResourceNames().All(f => f != template))
{
throw new FileNotFoundException("Template not found: " + template);
}
await using var s = assembly.GetManifestResourceStream(template)!;
using var sr = new StreamReader(s);
return await sr.ReadToEndAsync();
}
private static async Task<IHandlebars> InitializeHandlebarsAsync()
{
var handlebars = Handlebars.Create();
// TODO: Do we still need layouts with MJML?
var assembly = typeof(HandlebarMailRenderer).Assembly;
var layoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.Full.html.hbs");
handlebars.RegisterTemplate("FullHtmlLayout", layoutSource);
return handlebars;
}
}

View File

@@ -0,0 +1,7 @@
#nullable enable
namespace Bit.Core.Platform.Mailer;
public interface IMailRenderer
{
Task<(string html, string txt)> RenderAsync(BaseMailView model);
}

View File

@@ -0,0 +1,15 @@
namespace Bit.Core.Platform.Mailer;
#nullable enable
/// <summary>
/// Generic mailer interface for sending email messages.
/// </summary>
public interface IMailer
{
/// <summary>
/// Sends an email message.
/// </summary>
/// <param name="message"></param>
public Task SendEmail<T>(BaseMail<T> message) where T : BaseMailView;
}

View File

@@ -0,0 +1,32 @@
using Bit.Core.Models.Mail;
using Bit.Core.Services;
namespace Bit.Core.Platform.Mailer;
#nullable enable
public class Mailer(IMailRenderer renderer, IMailDeliveryService mailDeliveryService) : IMailer
{
public async Task SendEmail<T>(BaseMail<T> message) where T : BaseMailView
{
var content = await renderer.RenderAsync(message.View);
var metadata = new Dictionary<string, object>();
if (message.IgnoreSuppressList)
{
metadata.Add("SendGridBypassListManagement", true);
}
var mailMessage = new MailMessage
{
ToEmails = message.ToEmails,
Subject = message.Subject,
MetaData = metadata,
HtmlContent = content.html,
TextContent = content.txt,
Category = message.Category,
};
await mailDeliveryService.SendEmailAsync(mailMessage);
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bit.Core.Platform.Mailer;
#nullable enable
/// <summary>
/// Extension methods for adding the Mailer feature to the service collection.
/// </summary>
public static class MailerServiceCollectionExtensions
{
/// <summary>
/// Adds the Mailer services to the <see cref="IServiceCollection"/>.
/// This includes the mail renderer and mailer for sending templated emails.
/// This method is safe to be run multiple times.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
/// <returns>The <see cref="IServiceCollection"/> for additional chaining.</returns>
public static IServiceCollection AddMailer(this IServiceCollection services)
{
services.TryAddSingleton<IMailRenderer, HandlebarMailRenderer>();
services.TryAddSingleton<IMailer, Mailer>();
return services;
}
}

View File

@@ -0,0 +1,200 @@
# Mailer
The Mailer feature provides a structured, type-safe approach to sending emails in the Bitwarden server application. It
uses Handlebars templates to render both HTML and plain text email content.
## Architecture
The Mailer system consists of four main components:
1. **IMailer** - Service interface for sending emails
2. **BaseMail<TView>** - Abstract base class defining email metadata (recipients, subject, category)
3. **BaseMailView** - Abstract base class for email template view models
4. **IMailRenderer** - Internal interface for rendering templates (implemented by `HandlebarMailRenderer`)
## How To Use
1. Define a view model that inherits from `BaseMailView` with properties for template data
2. Create Handlebars templates (`.html.hbs` and `.text.hbs`) as embedded resources, preferably using the MJML pipeline,
`/src/Core/MailTemplates/Mjml`.
3. Define an email class that inherits from `BaseMail<TView>` with metadata like subject
4. Use `IMailer.SendEmail()` to render and send the email
## Creating a New Email
### Step 1: Define the Email & View Model
Create a class that inherits from `BaseMailView`:
```csharp
using Bit.Core.Platform.Mailer;
namespace MyApp.Emails;
public class WelcomeEmailView : BaseMailView
{
public required string UserName { get; init; }
public required string ActivationUrl { get; init; }
}
public class WelcomeEmail : BaseMail<WelcomeEmailView>
{
public override string Subject => "Welcome to Bitwarden";
}
```
### Step 2: Create Handlebars Templates
Create two template files as embedded resources next to your view model. **Important**: The file names must be located
directly next to the `ViewClass` and match the name of the view.
**WelcomeEmailView.html.hbs** (HTML version):
```handlebars
<h1>Welcome, {{ UserName }}!</h1>
<p>Thank you for joining Bitwarden.</p>
<p>
<a href="{{ ActivationUrl }}">Activate your account</a>
</p>
<p><small>&copy; {{ CurrentYear }} Bitwarden Inc.</small></p>
```
**WelcomeEmailView.text.hbs** (plain text version):
```handlebars
Welcome, {{ UserName }}!
Thank you for joining Bitwarden.
Activate your account: {{ ActivationUrl }}
<EFBFBD> {{ CurrentYear }} Bitwarden Inc.
```
**Important**: Template files must be configured as embedded resources in your `.csproj`:
```xml
<ItemGroup>
<EmbeddedResource Include="**\*.hbs" />
</ItemGroup>
```
### Step 3: Send the Email
Inject `IMailer` and send the email, this may be done in a service, command or some other application layer.
```csharp
public class SomeService
{
private readonly IMailer _mailer;
public SomeService(IMailer mailer)
{
_mailer = mailer;
}
public async Task SendWelcomeEmailAsync(string email, string userName, string activationUrl)
{
var mail = new WelcomeEmail
{
ToEmails = [email],
View = new WelcomeEmailView
{
UserName = userName,
ActivationUrl = activationUrl
}
};
await _mailer.SendEmail(mail);
}
}
```
## Advanced Features
### Multiple Recipients
Send to multiple recipients by providing multiple email addresses:
```csharp
var mail = new WelcomeEmail
{
ToEmails = ["user1@example.com", "user2@example.com"],
View = new WelcomeEmailView { /* ... */ }
};
```
### Bypass Suppression List
For critical emails like account recovery or email OTP, you can bypass the suppression list:
```csharp
public class PasswordResetEmail : BaseMail<PasswordResetEmailView>
{
public override string Subject => "Reset Your Password";
public override bool IgnoreSuppressList => true; // Use with caution
}
```
**Warning**: Only use `IgnoreSuppressList = true` for critical account recovery or authentication emails.
### Email Categories
Optionally categorize emails for processing at the upstream email delivery service:
```csharp
public class MarketingEmail : BaseMail<MarketingEmailView>
{
public override string Subject => "Latest Updates";
public string? Category => "marketing";
}
```
## Built-in View Properties
All view models inherit from `BaseMailView`, which provides:
- **CurrentYear** - The current UTC year (useful for copyright notices)
```handlebars
<footer>&copy; {{ CurrentYear }} Bitwarden Inc.</footer>
```
## Template Naming Convention
Templates must follow this naming convention:
- HTML template: `{ViewModelFullName}.html.hbs`
- Text template: `{ViewModelFullName}.text.hbs`
For example, if your view model is `Bit.Core.Auth.Models.Mail.VerifyEmailView`, the templates must be:
- `Bit.Core.Auth.Models.Mail.VerifyEmailView.html.hbs`
- `Bit.Core.Auth.Models.Mail.VerifyEmailView.text.hbs`
## Dependency Injection
Register the Mailer services in your DI container using the extension method:
```csharp
using Bit.Core.Platform.Mailer;
services.AddMailer();
```
Or manually register the services:
```csharp
using Microsoft.Extensions.DependencyInjection.Extensions;
services.TryAddSingleton<IMailRenderer, HandlebarMailRenderer>();
services.TryAddSingleton<IMailer, Mailer>();
```
## Performance Notes
- **Template caching** - `HandlebarMailRenderer` automatically caches compiled templates
- **Lazy initialization** - Handlebars is initialized only when first needed
- **Thread-safe** - The renderer is thread-safe for concurrent email rendering

View File

@@ -38,6 +38,7 @@ using Bit.Core.KeyManagement;
using Bit.Core.NotificationCenter;
using Bit.Core.OrganizationFeatures;
using Bit.Core.Platform;
using Bit.Core.Platform.Mailer;
using Bit.Core.Platform.Push;
using Bit.Core.Platform.PushRegistration.Internal;
using Bit.Core.Repositories;
@@ -242,8 +243,11 @@ public static class ServiceCollectionExtensions
services.AddScoped<IPaymentService, StripePaymentService>();
services.AddScoped<IPaymentHistoryService, PaymentHistoryService>();
services.AddScoped<ITwoFactorEmailService, TwoFactorEmailService>();
// Legacy mailer service
services.AddSingleton<IStripeSyncService, StripeSyncService>();
services.AddSingleton<IMailService, HandlebarsMailService>();
// Modern mailers
services.AddMailer();
services.AddSingleton<ILicensingService, LicensingService>();
services.AddSingleton<ILookupClient>(_ =>
{

View File

@@ -28,6 +28,9 @@
<None Remove="Utilities\data\embeddedResource.txt" />
</ItemGroup>
<ItemGroup>
<!-- Email templates uses .hbs extension, they must be included for emails to work -->
<EmbeddedResource Include="**\*.hbs" />
<EmbeddedResource Include="Utilities\data\embeddedResource.txt" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,20 @@
using Bit.Core.Platform.Mailer;
using Bit.Core.Test.Platform.Mailer.TestMail;
using Xunit;
namespace Bit.Core.Test.Platform.Mailer;
public class HandlebarMailRendererTests
{
[Fact]
public async Task RenderAsync_ReturnsExpectedHtmlAndTxt()
{
var renderer = new HandlebarMailRenderer();
var view = new TestMailView { Name = "John Smith" };
var (html, txt) = await renderer.RenderAsync(view);
Assert.Equal("Hello <b>John Smith</b>", html.Trim());
Assert.Equal("Hello John Smith", txt.Trim());
}
}

View File

@@ -0,0 +1,37 @@
using Bit.Core.Models.Mail;
using Bit.Core.Platform.Mailer;
using Bit.Core.Services;
using Bit.Core.Test.Platform.Mailer.TestMail;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Platform.Mailer;
public class MailerTest
{
[Fact]
public async Task SendEmailAsync()
{
var deliveryService = Substitute.For<IMailDeliveryService>();
var mailer = new Core.Platform.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService);
var mail = new TestMail.TestMail()
{
ToEmails = ["test@bw.com"],
View = new TestMailView() { Name = "John Smith" }
};
MailMessage? sentMessage = null;
await deliveryService.SendEmailAsync(Arg.Do<MailMessage>(message =>
sentMessage = message
));
await mailer.SendEmail(mail);
Assert.NotNull(sentMessage);
Assert.Contains("test@bw.com", sentMessage.ToEmails);
Assert.Equal("Test Email", sentMessage.Subject);
Assert.Equivalent("Hello John Smith", sentMessage.TextContent.Trim());
Assert.Equivalent("Hello <b>John Smith</b>", sentMessage.HtmlContent.Trim());
}
}

View File

@@ -0,0 +1,13 @@
using Bit.Core.Platform.Mailer;
namespace Bit.Core.Test.Platform.Mailer.TestMail;
public class TestMailView : BaseMailView
{
public required string Name { get; init; }
}
public class TestMail : BaseMail<TestMailView>
{
public override string Subject { get; } = "Test Email";
}

View File

@@ -0,0 +1 @@
Hello <b>{{ Name }}</b>

View File

@@ -0,0 +1 @@
Hello {{ Name }}