mirror of
https://github.com/bitwarden/server.git
synced 2025-06-14 06:50:47 -05:00
Implement Mailer
This commit is contained in:
parent
7e875bfd8d
commit
7ae6424bc3
@ -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}}
|
@ -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}}
|
54
src/Core/Platform/Services/BaseMail.cs
Normal file
54
src/Core/Platform/Services/BaseMail.cs
Normal 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();
|
||||
}
|
98
src/Core/Platform/Services/HandlebarMailRenderer.cs
Normal file
98
src/Core/Platform/Services/HandlebarMailRenderer.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
7
src/Core/Platform/Services/IMailRenderer.cs
Normal file
7
src/Core/Platform/Services/IMailRenderer.cs
Normal file
@ -0,0 +1,7 @@
|
||||
#nullable enable
|
||||
namespace Bit.Core.Platform.Services;
|
||||
|
||||
public interface IMailRenderer
|
||||
{
|
||||
Task<(string html, string? txt)> RenderAsync(BaseMailView model);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
||||
}
|
3
test/Core.Test/Platform/TestMail/TestMailView.html.hbs
Normal file
3
test/Core.Test/Platform/TestMail/TestMailView.html.hbs
Normal file
@ -0,0 +1,3 @@
|
||||
Test <b>Email</b>
|
||||
|
||||
<a href="{{{Url}}}">Test</a>
|
3
test/Core.Test/Platform/TestMail/TestMailView.txt.hbs
Normal file
3
test/Core.Test/Platform/TestMail/TestMailView.txt.hbs
Normal file
@ -0,0 +1,3 @@
|
||||
Test Email
|
||||
|
||||
{{{Url}}}
|
Loading…
x
Reference in New Issue
Block a user