From 7ae6424bc355698771ece2ccf6f7725f81f6c0d2 Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 12 Jun 2025 12:30:16 +0200 Subject: [PATCH] Implement Mailer --- .../VerifyEmail/VerifyEmail.html.hbs | 24 ---- .../VerifyEmail/VerifyEmail.txt.hbs | 8 -- src/Core/Platform/Services/BaseMail.cs | 54 ++++++++ .../Services/HandlebarMailRenderer.cs | 98 +++++++++++++ src/Core/Platform/Services/IMailModel.cs | 30 ---- src/Core/Platform/Services/IMailRenderer.cs | 7 + src/Core/Platform/Services/IMailer.cs | 12 +- src/Core/Platform/Services/Mailer.cs | 131 ++---------------- test/Core.Test/Core.Test.csproj | 3 + test/Core.Test/Platform/MailerTest.cs | 30 +++- .../Platform/TestMail/TestMailView.cs | 18 +-- .../Platform/TestMail/TestMailView.html.hbs | 3 + .../Platform/TestMail/TestMailView.txt.hbs | 3 + 13 files changed, 217 insertions(+), 204 deletions(-) delete mode 100644 src/Core/Auth/UserFeatures/Registration/VerifyEmail/VerifyEmail.html.hbs delete mode 100644 src/Core/Auth/UserFeatures/Registration/VerifyEmail/VerifyEmail.txt.hbs create mode 100644 src/Core/Platform/Services/BaseMail.cs create mode 100644 src/Core/Platform/Services/HandlebarMailRenderer.cs delete mode 100644 src/Core/Platform/Services/IMailModel.cs create mode 100644 src/Core/Platform/Services/IMailRenderer.cs rename src/Core/Auth/UserFeatures/Registration/VerifyEmail/VerifyEmail.cs => test/Core.Test/Platform/TestMail/TestMailView.cs (63%) create mode 100644 test/Core.Test/Platform/TestMail/TestMailView.html.hbs create mode 100644 test/Core.Test/Platform/TestMail/TestMailView.txt.hbs diff --git a/src/Core/Auth/UserFeatures/Registration/VerifyEmail/VerifyEmail.html.hbs b/src/Core/Auth/UserFeatures/Registration/VerifyEmail/VerifyEmail.html.hbs deleted file mode 100644 index f9290a46fb..0000000000 --- a/src/Core/Auth/UserFeatures/Registration/VerifyEmail/VerifyEmail.html.hbs +++ /dev/null @@ -1,24 +0,0 @@ -{{#>FullHtmlLayout}} - - - - - - - - - - -
- Verify this email address for your Bitwarden account by clicking the link below. -
- If you did not request to verify a Bitwarden account, you can safely ignore this email. -
-
-
- - Verify Email Address Now - -
-
-{{/FullHtmlLayout}} diff --git a/src/Core/Auth/UserFeatures/Registration/VerifyEmail/VerifyEmail.txt.hbs b/src/Core/Auth/UserFeatures/Registration/VerifyEmail/VerifyEmail.txt.hbs deleted file mode 100644 index 42faf94eab..0000000000 --- a/src/Core/Auth/UserFeatures/Registration/VerifyEmail/VerifyEmail.txt.hbs +++ /dev/null @@ -1,8 +0,0 @@ -{{#>BasicTextLayout}} - Verify this email address for your Bitwarden account by clicking the link below. - - If you did not request to verify a Bitwarden account, you can safely ignore this email. - - {{{Url}}} - -{{/BasicTextLayout}} diff --git a/src/Core/Platform/Services/BaseMail.cs b/src/Core/Platform/Services/BaseMail.cs new file mode 100644 index 0000000000..87ed51fe54 --- /dev/null +++ b/src/Core/Platform/Services/BaseMail.cs @@ -0,0 +1,54 @@ +namespace Bit.Core.Platform.Services; + +#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 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}.txt.hbs` for the text part +/// +public abstract class BaseMailView +{ + /// + /// Current year. + /// + public string CurrentYear => DateTime.UtcNow.Year.ToString(); +} diff --git a/src/Core/Platform/Services/HandlebarMailRenderer.cs b/src/Core/Platform/Services/HandlebarMailRenderer.cs new file mode 100644 index 0000000000..652185ff6a --- /dev/null +++ b/src/Core/Platform/Services/HandlebarMailRenderer.cs @@ -0,0 +1,98 @@ +#nullable enable +using System.Reflection; +using HandlebarsDotNet; + +namespace Bit.Core.Platform.Services; + +public class HandlebarMailRenderer : IMailRenderer +{ + /// + /// This field holds the handlebars instance. + /// + /// This should never be used directly, rather use the GetHandlebars() method to ensure that it is initialized properly. + /// + private IHandlebars? _handlebars; + + /// + /// This task is used to ensure that the handlebars instance is initialized only once. + /// + private Task? _initTask; + /// + /// This lock is used to ensure that the handlebars instance is initialized only once, + /// even if multiple threads call GetHandlebars() at the same time. + /// + private readonly object _initLock = new(); + + /// + /// This dictionary is used to cache compiled templates. + /// + private readonly Dictionary> _templateCache = new(); + + public async Task<(string html, string? txt)> RenderAsync(BaseMailView model) + { + var html = await CompileTemplateAsync(model, "html"); + var txt = await CompileTemplateAsync(model, "txt"); + + return (html, txt); + } + + private async Task CompileTemplateAsync(BaseMailView model, string type) + { + var handlebars = await GetHandlebars(); + + var templateName = $"{model.GetType().FullName}.{type}.hbs"; + + if (!_templateCache.TryGetValue(templateName, out var template)) + { + var assembly = model.GetType().Assembly; + var source = await ReadSourceAsync(assembly, templateName); + template = handlebars.Compile(source); + _templateCache.Add(templateName, template); + } + + return template(model); + } + + 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(); + } + + /// + /// Helper function that returns the handlebar instance, initializing it if necessary. + /// + /// Protects against initializing the same instance multiple times in parallel. + /// + private Task GetHandlebars() + { + if (_handlebars != null) + { + return Task.FromResult(_handlebars); + } + + lock (_initLock) + { + _initTask ??= InitializeHandlebarsAsync(); + return _initTask; + } + } + + private async Task InitializeHandlebarsAsync() + { + var handlebars = Handlebars.Create(); + + var assembly = typeof(HandlebarMailRenderer).Assembly; + var layoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.Full.html.hbs"); + handlebars.RegisterTemplate("FullHtmlLayout", layoutSource); + + _handlebars = handlebars; + return handlebars; + } +} diff --git a/src/Core/Platform/Services/IMailModel.cs b/src/Core/Platform/Services/IMailModel.cs deleted file mode 100644 index 196ca2091b..0000000000 --- a/src/Core/Platform/Services/IMailModel.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace Bit.Core.Platform.Services; - -#nullable enable - -/// -/// IMail describes a view model for emails. Any propery in the view model are available for usage -/// in the email templates. -/// -/// Each Mail 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}.txt.hbs` for the text part -/// -public abstract class BaseMailModel2 -{ - /// - /// The subject of the email. - /// - public abstract string Subject { get; set; } - - /// - /// An optional category for processing at the upstream email delivery service. - /// - public string? Category { get; set; } - - /// - /// Current year. - /// - public string CurrentYear => DateTime.UtcNow.Year.ToString(); -} diff --git a/src/Core/Platform/Services/IMailRenderer.cs b/src/Core/Platform/Services/IMailRenderer.cs new file mode 100644 index 0000000000..b9aa1f6030 --- /dev/null +++ b/src/Core/Platform/Services/IMailRenderer.cs @@ -0,0 +1,7 @@ +#nullable enable +namespace Bit.Core.Platform.Services; + +public interface IMailRenderer +{ + Task<(string html, string? txt)> RenderAsync(BaseMailView model); +} diff --git a/src/Core/Platform/Services/IMailer.cs b/src/Core/Platform/Services/IMailer.cs index acb410871d..2bcc7a00a7 100644 --- a/src/Core/Platform/Services/IMailer.cs +++ b/src/Core/Platform/Services/IMailer.cs @@ -8,16 +8,8 @@ public interface IMailer { /// - /// Sends an email message to the specified recipient. + /// Sends an email message. /// /// - /// Recipient email - public Task SendEmail(BaseMailModel2 message, string recipient); - - /// - /// Sends multiple emails message to the specified recipients. - /// - /// - /// Recipient emails - public void SendEmails(BaseMailModel2 message, string[] recipients); + public Task SendEmail(BaseMail message) where T : BaseMailView; } diff --git a/src/Core/Platform/Services/Mailer.cs b/src/Core/Platform/Services/Mailer.cs index 5582a713e5..640bf01315 100644 --- a/src/Core/Platform/Services/Mailer.cs +++ b/src/Core/Platform/Services/Mailer.cs @@ -1,129 +1,28 @@ -using System.Reflection; -using HandlebarsDotNet; +using Bit.Core.Models.Mail; +using Bit.Core.Services; namespace Bit.Core.Platform.Services; #nullable enable -public class Mailer : IMailer +public class Mailer(IMailRenderer renderer, IMailDeliveryService mailDeliveryService) + : IMailer { - private readonly IMailRenderer _renderer; - - public Mailer(IMailRenderer renderer) + public async Task SendEmail(BaseMail message) where T : BaseMailView { - _renderer = renderer; - } + var content = await renderer.RenderAsync(message.View); - public async Task SendEmail(BaseMailModel2 message, string recipient) - { - var htmlContent = await _renderer.RenderAsync(message); + var metadata = new Dictionary(); - Console.WriteLine(htmlContent); - } - - public void SendEmails(BaseMailModel2 message, string[] recipients) => throw new NotImplementedException(); -} - -public interface IMailRenderer -{ - Task<(string html, string? txt)> RenderAsync(BaseMailModel2 model); -} - -public enum TemplateType -{ - Html, - Txt -} - -public class HandlebarMailRenderer : IMailRenderer -{ - /// - /// This field holds the handlebars instance. - /// - /// This should never be used directly, rather use the GetHandlebars() method to ensure that it is initialized properly. - /// - private IHandlebars? _handlebars; - - /// - /// This task is used to ensure that the handlebars instance is initialized only once. - /// - private Task? _initTask; - /// - /// This lock is used to ensure that the handlebars instance is initialized only once, - /// even if multiple threads call GetHandlebars() at the same time. - /// - private readonly object _initLock = new(); - - /// - /// This dictionary is used to cache compiled templates. - /// - private readonly Dictionary> _templateCache = new(); - - public async Task<(string html, string? txt)> RenderAsync(BaseMailModel2 model) - { - var handlebars = await GetHandlebars(); - - var html = await CompileTemplateAsync(handlebars, model, TemplateType.Html); - var txt = await CompileTemplateAsync(handlebars, model, TemplateType.Txt); - - return (html, txt); - } - - private async Task CompileTemplateAsync(IHandlebars handlebars, BaseMailModel2 model, TemplateType type) - { - var templateName = $"{model.GetType().FullName}.{type.ToString().ToLower()}.hbs"; - - if (!_templateCache.TryGetValue(templateName, out var template)) + var mailMessage = new MailMessage { - var assembly = model.GetType().Assembly; - var source = await ReadSourceAsync(assembly, templateName); - template = Handlebars.Compile(source); - _templateCache.Add(templateName, template); - } + ToEmails = message.ToEmails, + Subject = message.Subject, + MetaData = metadata, + HtmlContent = content.html, + TextContent = content.txt, + }; - return template(model); - } - - 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(); - } - - /// - /// Helper function that returns the handlebar instance, initializing it if necessary. - /// - /// Protects against initializing the same instance multiple times in parallel. - /// - private Task GetHandlebars() - { - if (_handlebars != null) - { - return Task.FromResult(_handlebars); - } - - lock (_initLock) - { - _initTask ??= InitializeHandlebarsAsync(); - return _initTask; - } - } - - private async Task InitializeHandlebarsAsync() - { - var handlebars = Handlebars.Create(); - - var assembly = typeof(HandlebarMailRenderer).Assembly; - var layoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.Full.html.hbs"); - handlebars.RegisterTemplate("FullHtmlLayout", layoutSource); - - _handlebars = handlebars; - return handlebars; + await mailDeliveryService.SendEmailAsync(mailMessage); } } 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/MailerTest.cs b/test/Core.Test/Platform/MailerTest.cs index b636bda20a..ac59312c64 100644 --- a/test/Core.Test/Platform/MailerTest.cs +++ b/test/Core.Test/Platform/MailerTest.cs @@ -1,5 +1,8 @@ -using Bit.Core.Auth.UserFeatures.Registration.VerifyEmail; +using Bit.Core.Models.Mail; using Bit.Core.Platform.Services; +using Bit.Core.Services; +using Bit.Core.Test.Platform.TestMail; +using NSubstitute; using Xunit; namespace Bit.Core.Test.Platform; @@ -9,15 +12,28 @@ public class MailerTest [Fact] public async Task SendEmailAsync() { - var mailer = new Mailer(new HandlebarMailRenderer()); + var deliveryService = Substitute.For(); + var mailer = new Mailer(new HandlebarMailRenderer(), deliveryService); - var mail = new VerifyEmail + var mail = new TestMail.TestMail() { - Token = "test-token", - Email = "test@bitwarden.com", - WebVaultUrl = "https://vault.bitwarden.com" + ToEmails = ["test@bw.com"], + View = new TestMailView() { Token = "", Email = "", WebVaultUrl = "" } }; - await mailer.SendEmail(mail, "test@bitwarden.com"); + 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("Test Email\n\n/redirect-connector.html#finish-signup?token=&email=&fromEmail=true\n", + sentMessage.TextContent); + Assert.Equivalent("Test Email\n\nTest\n", + sentMessage.HtmlContent); } } diff --git a/src/Core/Auth/UserFeatures/Registration/VerifyEmail/VerifyEmail.cs b/test/Core.Test/Platform/TestMail/TestMailView.cs similarity index 63% rename from src/Core/Auth/UserFeatures/Registration/VerifyEmail/VerifyEmail.cs rename to test/Core.Test/Platform/TestMail/TestMailView.cs index 8f9417df43..f41d3b19df 100644 --- a/src/Core/Auth/UserFeatures/Registration/VerifyEmail/VerifyEmail.cs +++ b/test/Core.Test/Platform/TestMail/TestMailView.cs @@ -1,24 +1,24 @@ using System.Net; using Bit.Core.Platform.Services; -namespace Bit.Core.Auth.UserFeatures.Registration.VerifyEmail; +namespace Bit.Core.Test.Platform.TestMail; -public class VerifyEmail() : BaseMailModel2 +public class TestMailView : BaseMailView { - public override string Subject { get; set; } = "Verify Your Email"; - public required string Token { get; init; } public required string Email { get; init; } public required string WebVaultUrl { get; init; } - public string Url - { - get => string.Format( + public string Url => + string.Format( "{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true", WebVaultUrl, WebUtility.UrlEncode(Token), WebUtility.UrlEncode(Email) ); - } - +} + +public class TestMail : BaseMail +{ + public override string Subject { get; } = "Test Email"; } diff --git a/test/Core.Test/Platform/TestMail/TestMailView.html.hbs b/test/Core.Test/Platform/TestMail/TestMailView.html.hbs new file mode 100644 index 0000000000..b1a40d1ab0 --- /dev/null +++ b/test/Core.Test/Platform/TestMail/TestMailView.html.hbs @@ -0,0 +1,3 @@ +Test Email + +Test diff --git a/test/Core.Test/Platform/TestMail/TestMailView.txt.hbs b/test/Core.Test/Platform/TestMail/TestMailView.txt.hbs new file mode 100644 index 0000000000..72a7446a44 --- /dev/null +++ b/test/Core.Test/Platform/TestMail/TestMailView.txt.hbs @@ -0,0 +1,3 @@ +Test Email + +{{{Url}}}