diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 4c7d4ffc97..4901c5b43c 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -16,7 +16,9 @@ - + + + @@ -72,7 +74,7 @@ - + diff --git a/src/Core/Platform/Mailer/BaseMail.cs b/src/Core/Platform/Mailer/BaseMail.cs new file mode 100644 index 0000000000..5ba82699f2 --- /dev/null +++ b/src/Core/Platform/Mailer/BaseMail.cs @@ -0,0 +1,54 @@ +namespace Bit.Core.Platform.Mailer; + +#nullable enable + +/// +/// 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. +/// +public abstract class BaseMail where TView : BaseMailView +{ + /// + /// Email recipients. + /// + public required IEnumerable ToEmails { get; set; } + + /// + /// The subject of the email. + /// + public abstract string Subject { get; } + + /// + /// An optional category for processing at the upstream email delivery service. + /// + public string? Category { get; } + + /// + /// 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. + /// + public virtual bool IgnoreSuppressList { get; } = false; + + /// + /// View model for the email body. + /// + public required TView View { get; set; } +} + +/// +/// 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 +/// +public abstract class BaseMailView +{ + /// + /// Current year. + /// + public string CurrentYear => DateTime.UtcNow.Year.ToString(); +} diff --git a/src/Core/Platform/Mailer/HandlebarMailRenderer.cs b/src/Core/Platform/Mailer/HandlebarMailRenderer.cs new file mode 100644 index 0000000000..49de6832b1 --- /dev/null +++ b/src/Core/Platform/Mailer/HandlebarMailRenderer.cs @@ -0,0 +1,80 @@ +#nullable enable +using System.Collections.Concurrent; +using System.Reflection; +using HandlebarsDotNet; + +namespace Bit.Core.Platform.Mailer; + +public class HandlebarMailRenderer : IMailRenderer +{ + /// + /// Lazy-initialized Handlebars instance. Thread-safe and ensures initialization occurs only once. + /// + private readonly Lazy> _handlebarsTask = new(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication); + + /// + /// Helper function that returns the handlebar instance. + /// + private Task GetHandlebars() => _handlebarsTask.Value; + + /// + /// This dictionary is used to cache compiled templates in a thread-safe manner. + /// + private readonly ConcurrentDictionary>>> _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 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>>( + () => CompileTemplateInternalAsync(assembly, key), + LazyThreadSafetyMode.ExecutionAndPublication)); + + var template = await lazyTemplate.Value; + return template(model); + } + + private async Task> CompileTemplateInternalAsync(Assembly assembly, string templateName) + { + var source = await ReadSourceAsync(assembly, templateName); + var handlebars = await GetHandlebars(); + return handlebars.Compile(source); + } + + private static async Task 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 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; + } +} diff --git a/src/Core/Platform/Mailer/IMailRenderer.cs b/src/Core/Platform/Mailer/IMailRenderer.cs new file mode 100644 index 0000000000..9a4c620b81 --- /dev/null +++ b/src/Core/Platform/Mailer/IMailRenderer.cs @@ -0,0 +1,7 @@ +#nullable enable +namespace Bit.Core.Platform.Mailer; + +public interface IMailRenderer +{ + Task<(string html, string txt)> RenderAsync(BaseMailView model); +} diff --git a/src/Core/Platform/Mailer/IMailer.cs b/src/Core/Platform/Mailer/IMailer.cs new file mode 100644 index 0000000000..84c3baf649 --- /dev/null +++ b/src/Core/Platform/Mailer/IMailer.cs @@ -0,0 +1,15 @@ +namespace Bit.Core.Platform.Mailer; + +#nullable enable + +/// +/// Generic mailer interface for sending email messages. +/// +public interface IMailer +{ + /// + /// Sends an email message. + /// + /// + public Task SendEmail(BaseMail message) where T : BaseMailView; +} diff --git a/src/Core/Platform/Mailer/Mailer.cs b/src/Core/Platform/Mailer/Mailer.cs new file mode 100644 index 0000000000..5daf80b664 --- /dev/null +++ b/src/Core/Platform/Mailer/Mailer.cs @@ -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(BaseMail message) where T : BaseMailView + { + var content = await renderer.RenderAsync(message.View); + + var metadata = new Dictionary(); + 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); + } +} diff --git a/src/Core/Platform/Mailer/MailerServiceCollectionExtensions.cs b/src/Core/Platform/Mailer/MailerServiceCollectionExtensions.cs new file mode 100644 index 0000000000..b0847ec90f --- /dev/null +++ b/src/Core/Platform/Mailer/MailerServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.Core.Platform.Mailer; + +#nullable enable + +/// +/// Extension methods for adding the Mailer feature to the service collection. +/// +public static class MailerServiceCollectionExtensions +{ + /// + /// Adds the Mailer services to the . + /// This includes the mail renderer and mailer for sending templated emails. + /// This method is safe to be run multiple times. + /// + /// The to add services to. + /// The for additional chaining. + public static IServiceCollection AddMailer(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/Core/Platform/Mailer/README.md b/src/Core/Platform/Mailer/README.md new file mode 100644 index 0000000000..ff62386b10 --- /dev/null +++ b/src/Core/Platform/Mailer/README.md @@ -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** - 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` 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 +{ + 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 +

Welcome, {{ UserName }}!

+

Thank you for joining Bitwarden.

+

+ Activate your account +

+

© {{ CurrentYear }} Bitwarden Inc.

+``` + +**WelcomeEmailView.text.hbs** (plain text version): + +```handlebars +Welcome, {{ UserName }}! + +Thank you for joining Bitwarden. + +Activate your account: {{ ActivationUrl }} + +� {{ CurrentYear }} Bitwarden Inc. +``` + +**Important**: Template files must be configured as embedded resources in your `.csproj`: + +```xml + + + + +``` + +### 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 +{ + 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 +{ + 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 + +
© {{ CurrentYear }} Bitwarden Inc.
+``` + +## 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(); +services.TryAddSingleton(); +``` + +## 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 diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index bc8df87599..75094d1b0a 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -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(); services.AddScoped(); services.AddScoped(); + // Legacy mailer service services.AddSingleton(); services.AddSingleton(); + // Modern mailers + services.AddMailer(); services.AddSingleton(); services.AddSingleton(_ => { diff --git a/test/Core.Test/Core.Test.csproj b/test/Core.Test/Core.Test.csproj index c0f91a7bd3..b9e218205c 100644 --- a/test/Core.Test/Core.Test.csproj +++ b/test/Core.Test/Core.Test.csproj @@ -28,6 +28,9 @@
+ + + diff --git a/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs b/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs new file mode 100644 index 0000000000..faedbbc989 --- /dev/null +++ b/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs @@ -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 John Smith", html.Trim()); + Assert.Equal("Hello John Smith", txt.Trim()); + } +} diff --git a/test/Core.Test/Platform/Mailer/MailerTest.cs b/test/Core.Test/Platform/Mailer/MailerTest.cs new file mode 100644 index 0000000000..22d4569fdc --- /dev/null +++ b/test/Core.Test/Platform/Mailer/MailerTest.cs @@ -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(); + 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(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 John Smith", sentMessage.HtmlContent.Trim()); + } +} diff --git a/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs new file mode 100644 index 0000000000..74bcd6dbbf --- /dev/null +++ b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs @@ -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 +{ + public override string Subject { get; } = "Test Email"; +} diff --git a/test/Core.Test/Platform/Mailer/TestMail/TestMailView.html.hbs b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.html.hbs new file mode 100644 index 0000000000..c80512793e --- /dev/null +++ b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.html.hbs @@ -0,0 +1 @@ +Hello {{ Name }} diff --git a/test/Core.Test/Platform/Mailer/TestMail/TestMailView.text.hbs b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.text.hbs new file mode 100644 index 0000000000..a1a5777674 --- /dev/null +++ b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.text.hbs @@ -0,0 +1 @@ +Hello {{ Name }}