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}}}