1
0
mirror of https://github.com/bitwarden/server synced 2026-01-01 16:13:33 +00:00

chore(docs): Add docs for legacy mail service

* Added docs for legacy mail service.

* Updated namespaces.

* Consolidated under Platform.Mail namespace

* Updated obsolete comment.

* Linting

* Linting

* Replaced documentation in original readme after accidental deletion.
This commit is contained in:
Todd Martin
2025-11-04 11:54:39 -05:00
committed by GitHub
parent 04ed8abf5a
commit 3668a445e5
30 changed files with 73 additions and 51 deletions

View File

@@ -0,0 +1,144 @@
#nullable enable
using Amazon;
using Amazon.SimpleEmail;
using Amazon.SimpleEmail.Model;
using Bit.Core.Models.Mail;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Mail.Delivery;
public class AmazonSesMailDeliveryService : IMailDeliveryService, IDisposable
{
private readonly GlobalSettings _globalSettings;
private readonly IWebHostEnvironment _hostingEnvironment;
private readonly ILogger<AmazonSesMailDeliveryService> _logger;
private readonly IAmazonSimpleEmailService _client;
private readonly string _source;
private readonly string _senderTag;
private readonly string? _configSetName;
public AmazonSesMailDeliveryService(
GlobalSettings globalSettings,
IWebHostEnvironment hostingEnvironment,
ILogger<AmazonSesMailDeliveryService> logger)
: this(globalSettings, hostingEnvironment, logger,
new AmazonSimpleEmailServiceClient(
globalSettings.Amazon.AccessKeyId,
globalSettings.Amazon.AccessKeySecret,
RegionEndpoint.GetBySystemName(globalSettings.Amazon.Region))
)
{
}
public AmazonSesMailDeliveryService(
GlobalSettings globalSettings,
IWebHostEnvironment hostingEnvironment,
ILogger<AmazonSesMailDeliveryService> logger,
IAmazonSimpleEmailService amazonSimpleEmailService)
{
if (string.IsNullOrWhiteSpace(globalSettings.Amazon?.AccessKeyId))
{
throw new ArgumentNullException(nameof(globalSettings.Amazon.AccessKeyId));
}
if (string.IsNullOrWhiteSpace(globalSettings.Amazon?.AccessKeySecret))
{
throw new ArgumentNullException(nameof(globalSettings.Amazon.AccessKeySecret));
}
if (string.IsNullOrWhiteSpace(globalSettings.Amazon?.Region))
{
throw new ArgumentNullException(nameof(globalSettings.Amazon.Region));
}
var replyToEmail = CoreHelpers.PunyEncode(globalSettings.Mail.ReplyToEmail);
_globalSettings = globalSettings;
_hostingEnvironment = hostingEnvironment;
_logger = logger;
_client = amazonSimpleEmailService;
_source = $"\"{globalSettings.SiteName}\" <{replyToEmail}>";
_senderTag = $"Server_{globalSettings.ProjectName?.Replace(' ', '_')}";
if (!string.IsNullOrWhiteSpace(_globalSettings.Mail.AmazonConfigSetName))
{
_configSetName = _globalSettings.Mail.AmazonConfigSetName;
}
}
public void Dispose()
{
_client?.Dispose();
}
public async Task SendEmailAsync(MailMessage message)
{
var request = new SendEmailRequest
{
ConfigurationSetName = _configSetName,
Source = _source,
Destination = new Destination
{
ToAddresses = message.ToEmails
.Select(email => CoreHelpers.PunyEncode(email))
.ToList()
},
Message = new Message
{
Subject = new Content(message.Subject),
Body = new Body
{
Html = new Content
{
Charset = "UTF-8",
Data = message.HtmlContent
},
Text = new Content
{
Charset = "UTF-8",
Data = message.TextContent
}
}
},
Tags = new List<MessageTag>
{
new MessageTag { Name = "Environment", Value = _hostingEnvironment.EnvironmentName },
new MessageTag { Name = "Sender", Value = _senderTag }
}
};
if (message.BccEmails?.Any() ?? false)
{
request.Destination.BccAddresses = message.BccEmails
.Select(email => CoreHelpers.PunyEncode(email))
.ToList();
}
if (!string.IsNullOrWhiteSpace(message.Category))
{
request.Tags.Add(new MessageTag { Name = "Category", Value = message.Category });
}
try
{
await SendAsync(request, false);
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to send email. Retrying...");
await SendAsync(request, true);
throw;
}
}
private async Task SendAsync(SendEmailRequest request, bool retry)
{
if (retry)
{
// wait and try again
await Task.Delay(2000);
}
await _client.SendEmailAsync(request);
}
}

View File

@@ -0,0 +1,8 @@
using Bit.Core.Models.Mail;
namespace Bit.Core.Platform.Mail.Delivery;
public interface IMailDeliveryService
{
Task SendEmailAsync(MailMessage message);
}

View File

@@ -0,0 +1,123 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Settings;
using Bit.Core.Utilities;
using MailKit.Net.Smtp;
using Microsoft.Extensions.Logging;
using MimeKit;
namespace Bit.Core.Platform.Mail.Delivery;
public class MailKitSmtpMailDeliveryService : IMailDeliveryService
{
private readonly GlobalSettings _globalSettings;
private readonly ILogger<MailKitSmtpMailDeliveryService> _logger;
private readonly string _replyDomain;
private readonly string _replyEmail;
public MailKitSmtpMailDeliveryService(
GlobalSettings globalSettings,
ILogger<MailKitSmtpMailDeliveryService> logger)
{
if (globalSettings.Mail.Smtp?.Host == null)
{
throw new ArgumentNullException(nameof(globalSettings.Mail.Smtp.Host));
}
if (globalSettings.Mail.ReplyToEmail == null)
{
throw new InvalidOperationException("A GlobalSettings.Mail.ReplyToEmail is required to be set up.");
}
_replyEmail = CoreHelpers.PunyEncode(globalSettings.Mail.ReplyToEmail);
if (_replyEmail.Contains("@"))
{
_replyDomain = _replyEmail.Split('@')[1];
}
_globalSettings = globalSettings;
_logger = logger;
}
public async Task SendEmailAsync(Models.Mail.MailMessage message)
=> await SendEmailAsync(message, CancellationToken.None);
public async Task SendEmailAsync(Models.Mail.MailMessage message, CancellationToken cancellationToken)
{
var mimeMessage = new MimeMessage();
mimeMessage.From.Add(new MailboxAddress(_globalSettings.SiteName, _replyEmail));
mimeMessage.Subject = message.Subject;
if (!string.IsNullOrWhiteSpace(_replyDomain))
{
mimeMessage.MessageId = $"<{Guid.NewGuid()}@{_replyDomain}>";
}
foreach (var address in message.ToEmails)
{
var punyencoded = CoreHelpers.PunyEncode(address);
mimeMessage.To.Add(MailboxAddress.Parse(punyencoded));
}
if (message.BccEmails != null)
{
foreach (var address in message.BccEmails)
{
var punyencoded = CoreHelpers.PunyEncode(address);
mimeMessage.Bcc.Add(MailboxAddress.Parse(punyencoded));
}
}
var builder = new BodyBuilder();
if (!string.IsNullOrWhiteSpace(message.TextContent))
{
builder.TextBody = message.TextContent;
}
builder.HtmlBody = message.HtmlContent;
mimeMessage.Body = builder.ToMessageBody();
using (var client = new SmtpClient())
{
if (_globalSettings.Mail.Smtp.TrustServer)
{
client.ServerCertificateValidationCallback = (s, c, h, e) => true;
}
if (!_globalSettings.Mail.Smtp.StartTls && !_globalSettings.Mail.Smtp.Ssl &&
_globalSettings.Mail.Smtp.Port == 25)
{
await client.ConnectAsync(
_globalSettings.Mail.Smtp.Host,
_globalSettings.Mail.Smtp.Port,
MailKit.Security.SecureSocketOptions.None,
cancellationToken
);
}
else
{
var useSsl = _globalSettings.Mail.Smtp.Port == 587 && !_globalSettings.Mail.Smtp.SslOverride ?
false : _globalSettings.Mail.Smtp.Ssl;
await client.ConnectAsync(
_globalSettings.Mail.Smtp.Host,
_globalSettings.Mail.Smtp.Port,
useSsl,
cancellationToken
);
}
if (CoreHelpers.SettingHasValue(_globalSettings.Mail.Smtp.Username) &&
CoreHelpers.SettingHasValue(_globalSettings.Mail.Smtp.Password))
{
await client.AuthenticateAsync(
_globalSettings.Mail.Smtp.Username,
_globalSettings.Mail.Smtp.Password,
cancellationToken
);
}
await client.SendAsync(mimeMessage, cancellationToken);
await client.DisconnectAsync(true, cancellationToken);
}
}
}

View File

@@ -0,0 +1,41 @@
using Bit.Core.Models.Mail;
using Bit.Core.Settings;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Platform.Mail.Delivery;
public class MultiServiceMailDeliveryService : IMailDeliveryService
{
private readonly IMailDeliveryService _sesService;
private readonly IMailDeliveryService _sendGridService;
private readonly int _sendGridPercentage;
private static Random _random = new Random();
public MultiServiceMailDeliveryService(
GlobalSettings globalSettings,
IWebHostEnvironment hostingEnvironment,
ILogger<AmazonSesMailDeliveryService> sesLogger,
ILogger<SendGridMailDeliveryService> sendGridLogger)
{
_sesService = new AmazonSesMailDeliveryService(globalSettings, hostingEnvironment, sesLogger);
_sendGridService = new SendGridMailDeliveryService(globalSettings, hostingEnvironment, sendGridLogger);
// disabled by default (-1)
_sendGridPercentage = (globalSettings.Mail?.SendGridPercentage).GetValueOrDefault(-1);
}
public async Task SendEmailAsync(MailMessage message)
{
var roll = _random.Next(0, 99);
if (roll < _sendGridPercentage)
{
await _sendGridService.SendEmailAsync(message);
}
else
{
await _sesService.SendEmailAsync(message);
}
}
}

View File

@@ -0,0 +1,11 @@
using Bit.Core.Models.Mail;
namespace Bit.Core.Platform.Mail.Delivery;
public class NoopMailDeliveryService : IMailDeliveryService
{
public Task SendEmailAsync(MailMessage message)
{
return Task.FromResult(0);
}
}

View File

@@ -0,0 +1,114 @@
using Bit.Core.Models.Mail;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
using SendGrid;
using SendGrid.Helpers.Mail;
namespace Bit.Core.Platform.Mail.Delivery;
public class SendGridMailDeliveryService : IMailDeliveryService, IDisposable
{
private readonly GlobalSettings _globalSettings;
private readonly IWebHostEnvironment _hostingEnvironment;
private readonly ILogger<SendGridMailDeliveryService> _logger;
private readonly ISendGridClient _client;
private readonly string _senderTag;
private readonly string _replyToEmail;
public SendGridMailDeliveryService(
GlobalSettings globalSettings,
IWebHostEnvironment hostingEnvironment,
ILogger<SendGridMailDeliveryService> logger)
: this(new SendGridClient(globalSettings.Mail.SendGridApiKey, globalSettings.Mail.SendGridApiHost),
globalSettings, hostingEnvironment, logger)
{
}
public void Dispose()
{
// TODO: nothing to dispose
}
public SendGridMailDeliveryService(
ISendGridClient client,
GlobalSettings globalSettings,
IWebHostEnvironment hostingEnvironment,
ILogger<SendGridMailDeliveryService> logger)
{
if (string.IsNullOrWhiteSpace(globalSettings.Mail?.SendGridApiKey))
{
throw new ArgumentNullException(nameof(globalSettings.Mail.SendGridApiKey));
}
_globalSettings = globalSettings;
_hostingEnvironment = hostingEnvironment;
_logger = logger;
_client = client;
_senderTag = $"Server_{globalSettings.ProjectName?.Replace(' ', '_')}";
_replyToEmail = CoreHelpers.PunyEncode(globalSettings.Mail.ReplyToEmail);
}
public async Task SendEmailAsync(MailMessage message)
{
var msg = new SendGridMessage();
msg.SetFrom(new EmailAddress(_replyToEmail, _globalSettings.SiteName));
msg.AddTos(message.ToEmails.Select(e => new EmailAddress(CoreHelpers.PunyEncode(e))).ToList());
if (message.BccEmails?.Any() ?? false)
{
msg.AddBccs(message.BccEmails.Select(e => new EmailAddress(CoreHelpers.PunyEncode(e))).ToList());
}
msg.SetSubject(message.Subject);
msg.AddContent(MimeType.Text, message.TextContent);
msg.AddContent(MimeType.Html, message.HtmlContent);
msg.AddCategory($"type:{message.Category}");
msg.AddCategory($"env:{_hostingEnvironment.EnvironmentName}");
msg.AddCategory($"sender:{_senderTag}");
msg.SetClickTracking(false, false);
msg.SetOpenTracking(false);
if (message.MetaData != null &&
message.MetaData.TryGetValue("SendGridBypassListManagement", out var sendGridBypassListManagement) &&
Convert.ToBoolean(sendGridBypassListManagement))
{
msg.SetBypassListManagement(true);
}
try
{
var success = await SendAsync(msg, false);
if (!success)
{
_logger.LogWarning("Failed to send email. Retrying...");
await SendAsync(msg, true);
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to send email (with exception). Retrying...");
await SendAsync(msg, true);
throw;
}
}
private async Task<bool> SendAsync(SendGridMessage message, bool retry)
{
if (retry)
{
// wait and try again
await Task.Delay(2000);
}
var response = await _client.SendEmailAsync(message);
if (!response.IsSuccessStatusCode)
{
var responseBody = await response.Body.ReadAsStringAsync();
_logger.LogError("SendGrid email sending failed with {0}: {1}", response.StatusCode, responseBody);
}
return response.IsSuccessStatusCode;
}
}

View File

@@ -0,0 +1,20 @@
using Azure.Storage.Queues;
using Bit.Core.Models.Mail;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
namespace Bit.Core.Platform.Mail.Enqueuing;
public class AzureQueueMailService : AzureQueueService<IMailQueueMessage>, IMailEnqueuingService
{
public AzureQueueMailService(GlobalSettings globalSettings) : base(
new QueueClient(globalSettings.Mail.ConnectionString, "mail"),
JsonHelpers.IgnoreWritingNull)
{ }
public Task EnqueueAsync(IMailQueueMessage message, Func<IMailQueueMessage, Task> fallback) =>
CreateManyAsync(new[] { message });
public Task EnqueueManyAsync(IEnumerable<IMailQueueMessage> messages, Func<IMailQueueMessage, Task> fallback) =>
CreateManyAsync(messages);
}

View File

@@ -0,0 +1,18 @@
using Bit.Core.Models.Mail;
namespace Bit.Core.Platform.Mail.Enqueuing;
public class BlockingMailEnqueuingService : IMailEnqueuingService
{
public async Task EnqueueAsync(IMailQueueMessage message, Func<IMailQueueMessage, Task> fallback)
{
await fallback(message);
}
public async Task EnqueueManyAsync(IEnumerable<IMailQueueMessage> messages, Func<IMailQueueMessage, Task> fallback)
{
foreach (var message in messages)
{
await fallback(message);
}
}
}

View File

@@ -0,0 +1,9 @@
using Bit.Core.Models.Mail;
namespace Bit.Core.Platform.Mail.Enqueuing;
public interface IMailEnqueuingService
{
Task EnqueueAsync(IMailQueueMessage message, Func<IMailQueueMessage, Task> fallback);
Task EnqueueManyAsync(IEnumerable<IMailQueueMessage> messages, Func<IMailQueueMessage, Task> fallback);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Mail;
using Bit.Core.Vault.Models.Data;
using Core.Auth.Enums;
namespace Bit.Core.Services;
[Obsolete("The IMailService has been deprecated in favor of the IMailer. All new emails should be sent with an IMailer implementation.")]
public interface IMailService
{
Task SendWelcomeEmailAsync(User user);
Task SendVerifyEmailEmailAsync(string email, Guid userId, string token);
Task SendRegistrationVerificationEmailAsync(string email, string token);
Task SendTrialInitiationSignupEmailAsync(
bool isExistingUser,
string email,
string token,
ProductTierType productTier,
IEnumerable<ProductType> products,
int trialLength);
Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token);
Task SendCannotDeleteClaimedAccountEmailAsync(string email);
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose);
Task SendSendEmailOtpEmailAsync(string email, string token, string subject);
/// <summary>
/// <see cref="DefaultOtpTokenProviderOptions"/> has a default expiry of 5 minutes so we set the expiry to that value int he view model.
/// Sends OTP code token to the specified email address.
/// will replace <see cref="SendSendEmailOtpEmailAsync"/> when MJML templates are fully accepted.
/// </summary>
/// <param name="email">Email address to send the OTP to</param>
/// <param name="token">Otp code token</param>
/// <param name="subject">subject line of the email</param>
/// <returns>Task</returns>
Task SendSendEmailOtpEmailv2Async(string email, string token, string subject);
Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip);
Task SendNoMasterPasswordHintEmailAsync(string email);
Task SendMasterPasswordHintEmailAsync(string email, string hint);
/// <summary>
/// Sends one or many organization invite emails.
/// </summary>
/// <param name="orgInvitesInfo">The information required to send the organization invites.</param>
Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo);
Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails);
Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails, bool hasAccessSecretsManager = false);
Task SendOrganizationConfirmedEmailAsync(string organizationName, string email, bool hasAccessSecretsManager = false);
Task SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(string organizationName, string email);
Task SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(string organizationName, string email);
Task SendPasswordlessSignInAsync(string returnUrl, string token, string email);
Task SendInvoiceUpcoming(
string email,
decimal amount,
DateTime dueDate,
List<string> items,
bool mentionInvoices);
Task SendInvoiceUpcoming(
IEnumerable<string> email,
decimal amount,
DateTime dueDate,
List<string> items,
bool mentionInvoices);
Task SendProviderInvoiceUpcoming(
IEnumerable<string> emails,
decimal amount,
DateTime dueDate,
List<string> items,
string? collectionMethod,
bool hasPaymentMethod,
string? paymentMethodDescription);
Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices);
Task SendAddedCreditAsync(string email, decimal amount);
Task SendLicenseExpiredAsync(IEnumerable<string> emails, string? organizationName = null);
Task SendNewDeviceLoggedInEmail(string email, string deviceType, DateTime timestamp, string ip);
Task SendRecoverTwoFactorEmail(string email, DateTime timestamp, string ip);
Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token);
Task SendEmergencyAccessAcceptedEmailAsync(string granteeEmail, string email);
Task SendEmergencyAccessConfirmedEmailAsync(string grantorName, string email);
Task SendEmergencyAccessRecoveryInitiated(EmergencyAccess emergencyAccess, string initiatingName, string email);
Task SendEmergencyAccessRecoveryApproved(EmergencyAccess emergencyAccess, string approvingName, string email);
Task SendEmergencyAccessRecoveryRejected(EmergencyAccess emergencyAccess, string rejectingName, string email);
Task SendEmergencyAccessRecoveryReminder(EmergencyAccess emergencyAccess, string initiatingName, string email);
Task SendEmergencyAccessRecoveryTimedOut(EmergencyAccess ea, string initiatingName, string email);
Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage);
Task SendAdminResetPasswordEmailAsync(string email, string? userName, string orgName);
Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email);
Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email);
Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email);
Task SendProviderConfirmedEmailAsync(string providerName, string email);
Task SendProviderUserRemoved(string providerName, string email);
Task SendProviderUpdatePaymentMethod(
Guid organizationId,
string organizationName,
string providerName,
IEnumerable<string> emails);
Task SendUpdatedTempPasswordEmailAsync(string email, string userName);
Task SendFamiliesForEnterpriseOfferEmailAsync(string sponsorOrgName, string email, bool existingAccount, string token);
Task BulkSendFamiliesForEnterpriseOfferEmailAsync(string SponsorOrgName, IEnumerable<(string Email, bool ExistingAccount, string Token)> invites);
Task SendFamiliesForEnterpriseRedeemedEmailsAsync(string familyUserEmail, string sponsorEmail);
Task SendFamiliesForEnterpriseSponsorshipRevertingEmailAsync(string email, DateTime expirationDate);
Task SendOTPEmailAsync(string email, string token);
Task SendUnclaimedOrganizationDomainEmailAsync(IEnumerable<string> adminEmails, string organizationId, string domainName);
Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier);
Task SendTrialInitiationEmailAsync(string email);
Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token);
Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token);
Task SendRequestSMAccessToAdminEmailAsync(IEnumerable<string> adminEmails, string organizationName, string userRequestingAccess, string emailContent);
#nullable disable
Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId,
string organizationName);
#nullable enable
Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList);
Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string? userName);
Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable<UserSecurityTasksCount> securityTaskNotifications, IEnumerable<string> adminOwnerEmails);
}

View File

@@ -1,4 +1,4 @@
namespace Bit.Core.Platform.Mailer;
namespace Bit.Core.Platform.Mail.Mailer;
#nullable enable

View File

@@ -3,8 +3,7 @@ using System.Collections.Concurrent;
using System.Reflection;
using HandlebarsDotNet;
namespace Bit.Core.Platform.Mailer;
namespace Bit.Core.Platform.Mail.Mailer;
public class HandlebarMailRenderer : IMailRenderer
{
/// <summary>

View File

@@ -1,5 +1,5 @@
#nullable enable
namespace Bit.Core.Platform.Mailer;
namespace Bit.Core.Platform.Mail.Mailer;
public interface IMailRenderer
{

View File

@@ -1,4 +1,4 @@
namespace Bit.Core.Platform.Mailer;
namespace Bit.Core.Platform.Mail.Mailer;
#nullable enable

View File

@@ -1,7 +1,7 @@
using Bit.Core.Models.Mail;
using Bit.Core.Services;
using Bit.Core.Platform.Mail.Delivery;
namespace Bit.Core.Platform.Mailer;
namespace Bit.Core.Platform.Mail.Mailer;
#nullable enable

View File

@@ -1,7 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bit.Core.Platform.Mailer;
namespace Bit.Core.Platform.Mail.Mailer;
#nullable enable

View File

@@ -0,0 +1,341 @@
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Mail;
using Bit.Core.Vault.Models.Data;
using Core.Auth.Enums;
namespace Bit.Core.Services;
[Obsolete("The IMailService has been deprecated in favor of the IMailer. All new emails should be sent with an IMailer implementation.")]
public class NoopMailService : IMailService
{
public Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail)
{
return Task.FromResult(0);
}
public Task SendVerifyEmailEmailAsync(string email, Guid userId, string hint)
{
return Task.FromResult(0);
}
public Task SendRegistrationVerificationEmailAsync(string email, string hint)
{
return Task.FromResult(0);
}
public Task SendTrialInitiationSignupEmailAsync(
bool isExistingUser,
string email,
string token,
ProductTierType productTier,
IEnumerable<ProductType> products,
int trailLength)
{
return Task.FromResult(0);
}
public Task SendChangeEmailEmailAsync(string newEmailAddress, string token)
{
return Task.FromResult(0);
}
public Task SendMasterPasswordHintEmailAsync(string email, string hint)
{
return Task.FromResult(0);
}
public Task SendNoMasterPasswordHintEmailAsync(string email)
{
return Task.FromResult(0);
}
public Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails)
{
return Task.FromResult(0);
}
public Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails)
{
return Task.FromResult(0);
}
public Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier,
IEnumerable<string> adminEmails, bool hasAccessSecretsManager = false)
{
return Task.FromResult(0);
}
public Task SendOrganizationConfirmedEmailAsync(string organizationName, string email, bool hasAccessSecretsManager = false)
{
return Task.FromResult(0);
}
public Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo)
{
return Task.FromResult(0);
}
public Task SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(string organizationName, string email) =>
Task.CompletedTask;
public Task SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(string organizationName, string email) =>
Task.CompletedTask;
public Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose)
{
return Task.FromResult(0);
}
public Task SendSendEmailOtpEmailAsync(string email, string token, string subject)
{
return Task.FromResult(0);
}
public Task SendSendEmailOtpEmailv2Async(string email, string token, string subject)
{
return Task.FromResult(0);
}
public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
{
return Task.FromResult(0);
}
public Task SendWelcomeEmailAsync(User user)
{
return Task.FromResult(0);
}
public Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token)
{
return Task.FromResult(0);
}
public Task SendCannotDeleteClaimedAccountEmailAsync(string email)
{
return Task.FromResult(0);
}
public Task SendPasswordlessSignInAsync(string returnUrl, string token, string email)
{
return Task.FromResult(0);
}
public Task SendInvoiceUpcoming(
string email,
decimal amount,
DateTime dueDate,
List<string> items,
bool mentionInvoices) => Task.FromResult(0);
public Task SendInvoiceUpcoming(
IEnumerable<string> emails,
decimal amount,
DateTime dueDate,
List<string> items,
bool mentionInvoices) => Task.FromResult(0);
public Task SendProviderInvoiceUpcoming(
IEnumerable<string> emails,
decimal amount,
DateTime dueDate,
List<string> items,
string? collectionMethod = null,
bool hasPaymentMethod = true,
string? paymentMethodDescription = null) => Task.FromResult(0);
public Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices)
{
return Task.FromResult(0);
}
public Task SendAddedCreditAsync(string email, decimal amount)
{
return Task.FromResult(0);
}
public Task SendLicenseExpiredAsync(IEnumerable<string> emails, string? organizationName = null)
{
return Task.FromResult(0);
}
public Task SendNewDeviceLoggedInEmail(string email, string deviceType, DateTime timestamp, string ip)
{
return Task.FromResult(0);
}
public Task SendRecoverTwoFactorEmail(string email, DateTime timestamp, string ip)
{
return Task.FromResult(0);
}
public Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token)
{
return Task.FromResult(0);
}
public Task SendEmergencyAccessAcceptedEmailAsync(string granteeEmail, string email)
{
return Task.FromResult(0);
}
public Task SendEmergencyAccessConfirmedEmailAsync(string grantorName, string email)
{
return Task.FromResult(0);
}
public Task SendEmergencyAccessRecoveryInitiated(EmergencyAccess emergencyAccess, string initiatingName, string email)
{
return Task.FromResult(0);
}
public Task SendEmergencyAccessRecoveryApproved(EmergencyAccess emergencyAccess, string approvingName, string email)
{
return Task.FromResult(0);
}
public Task SendEmergencyAccessRecoveryRejected(EmergencyAccess emergencyAccess, string rejectingName, string email)
{
return Task.FromResult(0);
}
public Task SendEmergencyAccessRecoveryReminder(EmergencyAccess emergencyAccess, string initiatingName, string email)
{
return Task.FromResult(0);
}
public Task SendEmergencyAccessRecoveryTimedOut(EmergencyAccess ea, string initiatingName, string email)
{
return Task.FromResult(0);
}
public Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage)
{
return Task.FromResult(0);
}
public Task SendAdminResetPasswordEmailAsync(string email, string? userName, string orgName)
{
return Task.FromResult(0);
}
public Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email)
{
return Task.FromResult(0);
}
public Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email)
{
return Task.FromResult(0);
}
public Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email)
{
return Task.FromResult(0);
}
public Task SendProviderConfirmedEmailAsync(string providerName, string email)
{
return Task.FromResult(0);
}
public Task SendProviderUserRemoved(string providerName, string email)
{
return Task.FromResult(0);
}
public Task SendProviderUpdatePaymentMethod(Guid organizationId, string organizationName, string providerName,
IEnumerable<string> emails) => Task.FromResult(0);
public Task SendUpdatedTempPasswordEmailAsync(string email, string userName)
{
return Task.FromResult(0);
}
public Task SendFamiliesForEnterpriseOfferEmailAsync(string SponsorOrgName, string email, bool existingAccount, string token)
{
return Task.FromResult(0);
}
public Task BulkSendFamiliesForEnterpriseOfferEmailAsync(string SponsorOrgName, IEnumerable<(string Email, bool ExistingAccount, string Token)> invites)
{
return Task.FromResult(0);
}
public Task SendFamiliesForEnterpriseRedeemedEmailsAsync(string familyUserEmail, string sponsorEmail)
{
return Task.FromResult(0);
}
public Task SendFamiliesForEnterpriseSponsorshipRevertingEmailAsync(string email, DateTime expirationDate)
{
return Task.FromResult(0);
}
public Task SendOTPEmailAsync(string email, string token)
{
return Task.FromResult(0);
}
public Task SendUnclaimedOrganizationDomainEmailAsync(IEnumerable<string> adminEmails, string organizationId, string domainName)
{
return Task.FromResult(0);
}
public Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount,
IEnumerable<string> ownerEmails)
{
return Task.FromResult(0);
}
public Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization,
int maxSeatCount,
IEnumerable<string> ownerEmails)
{
return Task.FromResult(0);
}
public Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier)
{
return Task.FromResult(0);
}
public Task SendTrialInitiationEmailAsync(string email)
{
return Task.FromResult(0);
}
public Task SendInitiateDeletProviderEmailAsync(string email, Provider provider, string token) => throw new NotImplementedException();
public Task SendInitiateDeleteOrganzationEmailAsync(string email, Organization organization, string token)
{
return Task.FromResult(0);
}
public Task SendRequestSMAccessToAdminEmailAsync(IEnumerable<string> adminEmails, string organizationName, string userRequestingAccess, string emailContent) => throw new NotImplementedException();
public Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate,
string organizationId,
string organizationName)
{
return Task.FromResult(0);
}
public Task SendClaimedDomainUserEmailAsync(ClaimedUserDomainClaimedEmails emailList) => Task.CompletedTask;
public Task SendDeviceApprovalRequestedNotificationEmailAsync(IEnumerable<string> adminEmails, Guid organizationId, string email, string? userName)
{
return Task.FromResult(0);
}
public Task SendBulkSecurityTaskNotificationsAsync(Organization org, IEnumerable<UserSecurityTasksCount> securityTaskNotifications, IEnumerable<string> adminOwnerEmails)
{
return Task.FromResult(0);
}
}

View File

@@ -1,9 +1,16 @@
# Mailer
# Mail Services
## `MailService`
The `MailService` and its implementation in `HandlebarsMailService` has been deprecated in favor of the `Mailer` implementation.
New emails should be implemented using [MJML](../../MailTemplates/README.md) and the `Mailer`.
## `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
### Architecture
The Mailer system consists of four main components:
@@ -12,7 +19,7 @@ The Mailer system consists of four main components:
3. **BaseMailView** - Abstract base class for email template view models
4. **IMailRenderer** - Internal interface for rendering templates (implemented by `HandlebarMailRenderer`)
## How To Use
### 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,
@@ -20,9 +27,9 @@ The Mailer system consists of four main components:
3. Define an email class that inherits from `BaseMail<TView>` with metadata like subject
4. Use `IMailer.SendEmail()` to render and send the email
## Creating a New Email
### Creating a New Email
### Step 1: Define the Email & View Model
#### Step 1: Define the Email & View Model
Create a class that inherits from `BaseMailView`:
@@ -43,7 +50,7 @@ public class WelcomeEmail : BaseMail<WelcomeEmailView>
}
```
### Step 2: Create Handlebars Templates
#### 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.
@@ -80,7 +87,7 @@ Activate your account: {{ ActivationUrl }}
</ItemGroup>
```
### Step 3: Send the Email
#### Step 3: Send the Email
Inject `IMailer` and send the email, this may be done in a service, command or some other application layer.
@@ -111,9 +118,9 @@ public class SomeService
}
```
## Advanced Features
### Advanced Features
### Multiple Recipients
#### Multiple Recipients
Send to multiple recipients by providing multiple email addresses:
@@ -125,7 +132,7 @@ var mail = new WelcomeEmail
};
```
### Bypass Suppression List
#### Bypass Suppression List
For critical emails like account recovery or email OTP, you can bypass the suppression list:
@@ -139,7 +146,7 @@ public class PasswordResetEmail : BaseMail<PasswordResetEmailView>
**Warning**: Only use `IgnoreSuppressList = true` for critical account recovery or authentication emails.
### Email Categories
#### Email Categories
Optionally categorize emails for processing at the upstream email delivery service:
@@ -151,7 +158,7 @@ public class MarketingEmail : BaseMail<MarketingEmailView>
}
```
## Built-in View Properties
### Built-in View Properties
All view models inherit from `BaseMailView`, which provides:
@@ -162,7 +169,7 @@ All view models inherit from `BaseMailView`, which provides:
<footer>&copy; {{ CurrentYear }} Bitwarden Inc.</footer>
```
## Template Naming Convention
### Template Naming Convention
Templates must follow this naming convention:
@@ -193,8 +200,14 @@ services.TryAddSingleton<IMailRenderer, HandlebarMailRenderer>();
services.TryAddSingleton<IMailer, Mailer>();
```
## Performance Notes
### 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
# Overriding email templates from disk
The mail services support loading the mail template from disk. This is intended to be used by self-hosted customers who want to modify their email appearance. These overrides are not intended to be used during local development, as any changes there would not be reflected in the templates used in a normal deployment configuration.
Any customer using this override has worked with Bitwarden support on an approved implementation and has acknowledged that they are responsible for reacting to any changes made to the templates as a part of the Bitwarden development process. This includes, but is not limited to, changes in Handlebars property names, removal of properties from the `ViewModel` classes, and changes in template names. **Bitwarden is not responsible for maintaining backward compatibility between releases in order to support any overridden emails.**