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:
@@ -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\" />
|
||||
|
||||
54
src/Core/Platform/Mailer/BaseMail.cs
Normal file
54
src/Core/Platform/Mailer/BaseMail.cs
Normal 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();
|
||||
}
|
||||
80
src/Core/Platform/Mailer/HandlebarMailRenderer.cs
Normal file
80
src/Core/Platform/Mailer/HandlebarMailRenderer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
7
src/Core/Platform/Mailer/IMailRenderer.cs
Normal file
7
src/Core/Platform/Mailer/IMailRenderer.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
#nullable enable
|
||||
namespace Bit.Core.Platform.Mailer;
|
||||
|
||||
public interface IMailRenderer
|
||||
{
|
||||
Task<(string html, string txt)> RenderAsync(BaseMailView model);
|
||||
}
|
||||
15
src/Core/Platform/Mailer/IMailer.cs
Normal file
15
src/Core/Platform/Mailer/IMailer.cs
Normal 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;
|
||||
}
|
||||
32
src/Core/Platform/Mailer/Mailer.cs
Normal file
32
src/Core/Platform/Mailer/Mailer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
200
src/Core/Platform/Mailer/README.md
Normal file
200
src/Core/Platform/Mailer/README.md
Normal 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>© {{ 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>© {{ 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
|
||||
@@ -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>(_ =>
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
20
test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs
Normal file
20
test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
37
test/Core.Test/Platform/Mailer/MailerTest.cs
Normal file
37
test/Core.Test/Platform/Mailer/MailerTest.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
13
test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs
Normal file
13
test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs
Normal 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";
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Hello <b>{{ Name }}</b>
|
||||
@@ -0,0 +1 @@
|
||||
Hello {{ Name }}
|
||||
Reference in New Issue
Block a user