Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/Admin/HostedServices/AzureQueueMailHostedService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Azure.Storage.Queues;
using Azure.Storage.Queues.Models;
using Bit.Core.Models.Mail;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
Expand All @@ -16,6 +17,7 @@ public class AzureQueueMailHostedService : IHostedService
private readonly ILogger<AzureQueueMailHostedService> _logger;
private readonly GlobalSettings _globalSettings;
private readonly IMailService _mailService;
private readonly IMailer _mailer;
private CancellationTokenSource _cts;
private Task _executingTask;

Expand All @@ -24,10 +26,12 @@ public class AzureQueueMailHostedService : IHostedService
public AzureQueueMailHostedService(
ILogger<AzureQueueMailHostedService> logger,
IMailService mailService,
IMailer mailer,
GlobalSettings globalSettings)
{
_logger = logger;
_mailService = mailService;
_mailer = mailer;
_globalSettings = globalSettings;
}

Expand Down Expand Up @@ -72,13 +76,13 @@ private async Task ExecuteAsync(CancellationToken cancellationToken)
{
foreach (var mailQueueMessage in root.Deserialize<List<MailQueueMessage>>())
{
await _mailService.SendEnqueuedMailMessageAsync(mailQueueMessage);
await ProcessMailMessageAsync(mailQueueMessage);
}
}
else if (root.ValueKind == JsonValueKind.Object)
{
var mailQueueMessage = root.Deserialize<MailQueueMessage>();
await _mailService.SendEnqueuedMailMessageAsync(mailQueueMessage);
await ProcessMailMessageAsync(mailQueueMessage);
}
}
catch (Exception e)
Expand All @@ -101,4 +105,21 @@ private async Task<QueueMessage[]> RetrieveMessagesAsync()
{
return (await _mailQueueClient.ReceiveMessagesAsync(maxMessages: 32))?.Value ?? new QueueMessage[] { };
}

/// <summary>
/// Processes a single mail message, checking if it's an IMailer message or HandlebarsMailService message.
/// </summary>
private async Task ProcessMailMessageAsync(MailQueueMessage message)
{
if (message.IsMailerMessage)
{
// IMailer message - delegate to mailer to render view and send
await _mailer.SendEnqueuedMailerMessageAsync(message);
}
else
{
// Template-based message from HandlebarsMailService
await _mailService.SendEnqueuedMailMessageAsync(message);
}
}
}
10 changes: 10 additions & 0 deletions src/Core/Models/Mail/IMailQueueMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,14 @@ public interface IMailQueueMessage
string Category { get; set; }
string TemplateName { get; set; }
object Model { get; set; }

/// <summary>
/// Indicates if this is an IMailer message (uses view rendering) vs HandlebarsMailService message (uses template rendering).
/// </summary>
public bool IsMailerMessage { get; set; }

/// <summary>
/// Additional metadata for delivery (e.g., SendGridBypassListManagement).
/// </summary>
public IDictionary<string, object>? MetaData { get; set; }
}
12 changes: 12 additions & 0 deletions src/Core/Models/Mail/MailQueueMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ public class MailQueueMessage : IMailQueueMessage
[JsonConverter(typeof(HandlebarsObjectJsonConverter))]
public object Model { get; set; }

/// <summary>
/// Indicates if this is an IMailer message (uses view rendering) vs HandlebarsMailService message (uses template rendering).
/// True for IMailer messages, false or null for HandlebarsMailService messages.
/// </summary>
public bool IsMailerMessage { get; set; }

/// <summary>
/// Additional metadata for delivery (e.g., SendGridBypassListManagement).
/// Used by both IMailer and HandlebarsMailService messages.
/// </summary>
public IDictionary<string, object> MetaData { get; set; }

public MailQueueMessage() { }

public MailQueueMessage(MailMessage message, string templateName, object model)
Expand Down
17 changes: 16 additions & 1 deletion src/Core/Platform/Mail/Mailer/IMailer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
ο»Ώnamespace Bit.Core.Platform.Mail.Mailer;
ο»Ώusing Bit.Core.Models.Mail;

namespace Bit.Core.Platform.Mail.Mailer;

#nullable enable

Expand All @@ -12,4 +14,17 @@ public interface IMailer
/// </summary>
/// <param name="message"></param>
public Task SendEmail<T>(BaseMail<T> message) where T : BaseMailView;

/// <summary>
/// Enqueues email messages for asynchronous delivery.
/// </summary>
/// <param name="messages">The email messages to enqueue</param>
/// <typeparam name="T">The type of the mail view</typeparam>
public Task EnqueueEmailsAsync<T>(IEnumerable<BaseMail<T>> messages) where T : BaseMailView;

/// <summary>
/// Sends a previously enqueued IMailer message by rendering the stored view and sending.
/// </summary>
/// <param name="queueMessage">The enqueued message containing view data</param>
public Task SendEnqueuedMailerMessageAsync(IMailQueueMessage queueMessage);
}
81 changes: 71 additions & 10 deletions src/Core/Platform/Mail/Mailer/Mailer.cs
Original file line number Diff line number Diff line change
@@ -1,32 +1,93 @@
ο»Ώusing Bit.Core.Models.Mail;
using Bit.Core.Platform.Mail.Delivery;
using Bit.Core.Platform.Mail.Enqueuing;

namespace Bit.Core.Platform.Mail.Mailer;

#nullable enable

public class Mailer(IMailRenderer renderer, IMailDeliveryService mailDeliveryService) : IMailer
public class Mailer(
IMailRenderer renderer,
IMailDeliveryService mailDeliveryService,
IMailEnqueuingService mailEnqueuingService) : IMailer
{
public async Task SendEmail<T>(BaseMail<T> message) where T : BaseMailView
{
var content = await renderer.RenderAsync(message.View);

var metadata = new Dictionary<string, object>();
if (message.IgnoreSuppressList)
{
metadata.Add("SendGridBypassListManagement", true);
}

var mailMessage = new MailMessage
{
ToEmails = message.ToEmails,
Subject = message.Subject,
MetaData = metadata,
MetaData = BuildMailMetadata(message.IgnoreSuppressList),
HtmlContent = content.html,
TextContent = content.txt,
Category = message.Category,
};

await mailDeliveryService.SendEmailAsync(mailMessage);
}

public async Task EnqueueEmailsAsync<T>(IEnumerable<BaseMail<T>> messages) where T : BaseMailView
{
var queueMessages = messages.Select(message => new MailQueueMessage
{
Subject = message.Subject,
ToEmails = message.ToEmails,
Category = message.Category ?? "Default",
TemplateName = typeof(T).AssemblyQualifiedName ?? throw new InvalidOperationException(),
Model = message.View,
IsMailerMessage = true,
MetaData = BuildMailMetadata(message.IgnoreSuppressList)
})
.ToList();

await mailEnqueuingService.EnqueueManyAsync(queueMessages, SendEnqueuedMailerMessageAsync);
}

/// <summary>
/// Sends a previously enqueued IMailer message by rendering the stored view and sending.
/// </summary>
public async Task SendEnqueuedMailerMessageAsync(IMailQueueMessage mailQueueMessage)
{
if (!mailQueueMessage.IsMailerMessage)
{
throw new InvalidOperationException(
"Expected IMailer message (IsMailerMessage = true)");
}

var viewType = Type.GetType(mailQueueMessage.TemplateName);
if (viewType == null)
{
throw new InvalidOperationException(
$"Could not resolve view type: {mailQueueMessage.TemplateName}");
}

if (mailQueueMessage.Model is not BaseMailView view)
{
throw new InvalidOperationException(
$"Model is not a BaseMailView: {mailQueueMessage.Model.GetType().FullName}");
}

var content = await renderer.RenderAsync(view);

var mailMessage = new MailMessage
{
ToEmails = mailQueueMessage.ToEmails,
Subject = mailQueueMessage.Subject,
BccEmails = mailQueueMessage.BccEmails,
Category = mailQueueMessage.Category,
MetaData = mailQueueMessage.MetaData ?? new Dictionary<string, object>(),
HtmlContent = content.html,
TextContent = content.txt
};

await mailDeliveryService.SendEmailAsync(mailMessage);
}

/// <summary>
/// Builds metadata dictionary for mail delivery based on mail settings.
/// </summary>
private static Dictionary<string, object> BuildMailMetadata(bool ignoreSuppressList) =>
ignoreSuppressList
? new Dictionary<string, object> { { "SendGridBypassListManagement", true } }
: new Dictionary<string, object>();
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ public async Task SendEmergencyAccessRemoveGranteesEmail_SingleGrantee_Success(
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var globalSettings = new GlobalSettings { SelfHosted = false };
var deliveryService = Substitute.For<IMailDeliveryService>();
var enqueuingService = Substitute.For<Bit.Core.Platform.Mail.Enqueuing.IMailEnqueuingService>();
var mailer = new Mailer(
new HandlebarMailRenderer(logger, globalSettings),
deliveryService);
deliveryService,
enqueuingService);

var mail = new EmergencyAccessRemoveGranteesMail
{
Expand Down Expand Up @@ -73,9 +75,11 @@ public async Task SendEmergencyAccessRemoveGranteesEmail_MultipleGrantees_Render
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var globalSettings = new GlobalSettings { SelfHosted = false };
var deliveryService = Substitute.For<IMailDeliveryService>();
var enqueuingService = Substitute.For<Bit.Core.Platform.Mail.Enqueuing.IMailEnqueuingService>();
var mailer = new Mailer(
new HandlebarMailRenderer(logger, globalSettings),
deliveryService);
deliveryService,
enqueuingService);

var granteeEmails = new[] { "Alice@test.dev", "Bob@test.dev", "Carol@test.dev" };

Expand Down
Loading
Loading