1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-14 06:50:47 -05:00

Implement Mailer

This commit is contained in:
Hinton 2025-06-12 12:30:16 +02:00
parent 7e875bfd8d
commit 7ae6424bc3
No known key found for this signature in database
GPG Key ID: 5F7295599C5D965C
13 changed files with 217 additions and 204 deletions

View File

@ -1,24 +0,0 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
Verify this email address for your Bitwarden account by clicking the link below.
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
If you did not request to verify a Bitwarden account, you can safely ignore this email.
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="{{{Url}}}" clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Verify Email Address Now
</a>
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

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

View File

@ -0,0 +1,54 @@
namespace Bit.Core.Platform.Services;
#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 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}.txt.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,98 @@
#nullable enable
using System.Reflection;
using HandlebarsDotNet;
namespace Bit.Core.Platform.Services;
public class HandlebarMailRenderer : IMailRenderer
{
/// <summary>
/// This field holds the handlebars instance.
///
/// This should never be used directly, rather use the GetHandlebars() method to ensure that it is initialized properly.
/// </summary>
private IHandlebars? _handlebars;
/// <summary>
/// This task is used to ensure that the handlebars instance is initialized only once.
/// </summary>
private Task<IHandlebars>? _initTask;
/// <summary>
/// This lock is used to ensure that the handlebars instance is initialized only once,
/// even if multiple threads call GetHandlebars() at the same time.
/// </summary>
private readonly object _initLock = new();
/// <summary>
/// This dictionary is used to cache compiled templates.
/// </summary>
private readonly Dictionary<string, 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, "txt");
return (html, txt);
}
private async Task<string> 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<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();
}
/// <summary>
/// Helper function that returns the handlebar instance, initializing it if necessary.
///
/// Protects against initializing the same instance multiple times in parallel.
/// </summary>
private Task<IHandlebars> GetHandlebars()
{
if (_handlebars != null)
{
return Task.FromResult(_handlebars);
}
lock (_initLock)
{
_initTask ??= InitializeHandlebarsAsync();
return _initTask;
}
}
private async Task<IHandlebars> 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;
}
}

View File

@ -1,30 +0,0 @@
namespace Bit.Core.Platform.Services;
#nullable enable
/// <summary>
/// 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
/// </summary>
public abstract class BaseMailModel2
{
/// <summary>
/// The subject of the email.
/// </summary>
public abstract string Subject { get; set; }
/// <summary>
/// An optional category for processing at the upstream email delivery service.
/// </summary>
public string? Category { get; set; }
/// <summary>
/// Current year.
/// </summary>
public string CurrentYear => DateTime.UtcNow.Year.ToString();
}

View File

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

View File

@ -8,16 +8,8 @@
public interface IMailer
{
/// <summary>
/// Sends an email message to the specified recipient.
/// Sends an email message.
/// </summary>
/// <param name="message"></param>
/// <param name="recipient">Recipient email</param>
public Task SendEmail(BaseMailModel2 message, string recipient);
/// <summary>
/// Sends multiple emails message to the specified recipients.
/// </summary>
/// <param name="message"></param>
/// <param name="recipients">Recipient emails</param>
public void SendEmails(BaseMailModel2 message, string[] recipients);
public Task SendEmail<T>(BaseMail<T> message) where T : BaseMailView;
}

View File

@ -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<T>(BaseMail<T> 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<string, object>();
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
{
/// <summary>
/// This field holds the handlebars instance.
///
/// This should never be used directly, rather use the GetHandlebars() method to ensure that it is initialized properly.
/// </summary>
private IHandlebars? _handlebars;
/// <summary>
/// This task is used to ensure that the handlebars instance is initialized only once.
/// </summary>
private Task<IHandlebars>? _initTask;
/// <summary>
/// This lock is used to ensure that the handlebars instance is initialized only once,
/// even if multiple threads call GetHandlebars() at the same time.
/// </summary>
private readonly object _initLock = new();
/// <summary>
/// This dictionary is used to cache compiled templates.
/// </summary>
private readonly Dictionary<string, HandlebarsTemplate<object, object>> _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<string> 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<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();
}
/// <summary>
/// Helper function that returns the handlebar instance, initializing it if necessary.
///
/// Protects against initializing the same instance multiple times in parallel.
/// </summary>
private Task<IHandlebars> GetHandlebars()
{
if (_handlebars != null)
{
return Task.FromResult(_handlebars);
}
lock (_initLock)
{
_initTask ??= InitializeHandlebarsAsync();
return _initTask;
}
}
private async Task<IHandlebars> 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);
}
}

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

@ -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<IMailDeliveryService>();
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<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("Test Email\n\n/redirect-connector.html#finish-signup?token=&email=&fromEmail=true\n",
sentMessage.TextContent);
Assert.Equivalent("Test <b>Email</b>\n\n<a href=\"/redirect-connector.html#finish-signup?token=&email=&fromEmail=true\">Test</a>\n",
sentMessage.HtmlContent);
}
}

View File

@ -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<TestMailView>
{
public override string Subject { get; } = "Test Email";
}

View File

@ -0,0 +1,3 @@
Test <b>Email</b>
<a href="{{{Url}}}">Test</a>

View File

@ -0,0 +1,3 @@
Test Email
{{{Url}}}