feat: Add email sending feature with background worker
Application Layer: - Add SendEmailCommand with handler (CQRS pattern) - Add SendEmailCommandValidator (FluentValidation) - Add EmailOutboxMappingProfile for EmailOutbox entity mappings - Update EmailAccountMappingProfile with latest field mappings - Update EmailProfileMappingProfile with latest field mappings API Layer: - Add EmailSenderWorker background service - Worker polls EmailOutbox queue every 5 seconds - Dequeues emails and processes via SendEmailCommand (MediatR) - Uses IEmailService (Limilabs) for actual SMTP sending This implements the outgoing email queue processing pipeline: EmailOutbox (DB) → IEmailQueue (RabbitMQ) → EmailSenderWorker → SendEmailCommand → IEmailService
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
using DigitalData.EmailProfiler.Application.Common.Interfaces;
|
||||
using DigitalData.EmailProfiler.Domain.Entities;
|
||||
|
||||
namespace DigitalData.EmailProfiler.API.Workers;
|
||||
|
||||
/// <summary>
|
||||
/// Background worker that processes outgoing emails from RabbitMQ queue.
|
||||
/// Uses event-driven RabbitMQ consumer (push-based) instead of polling.
|
||||
/// NO database operations - email account configuration comes from appsettings.
|
||||
/// </summary>
|
||||
public class EmailSenderWorker(
|
||||
IEmailQueue EmailQueue,
|
||||
IEmailService EmailService,
|
||||
ILogger<EmailSenderWorker> Logger,
|
||||
IConfiguration configuration) : BackgroundService
|
||||
{
|
||||
private readonly EmailSenderWorkerConfiguration _config =
|
||||
configuration.GetSection(EmailSenderWorkerConfiguration.SectionName)
|
||||
.Get<EmailSenderWorkerConfiguration>() ?? new EmailSenderWorkerConfiguration();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_config.Enabled)
|
||||
{
|
||||
Logger.LogInformation("EmailSenderWorker is disabled in configuration");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogInformation("EmailSenderWorker started (event-driven RabbitMQ consumer)");
|
||||
|
||||
try
|
||||
{
|
||||
// Start RabbitMQ consumer (event-driven, non-blocking)
|
||||
await EmailQueue.StartConsumerAsync(ProcessEmailAsync, stoppingToken);
|
||||
|
||||
// Keep worker alive until cancellation
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Logger.LogInformation("EmailSenderWorker is stopping");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "EmailSenderWorker failed to start");
|
||||
throw;
|
||||
}
|
||||
|
||||
Logger.LogInformation("EmailSenderWorker stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process single email message (callback from RabbitMQ consumer)
|
||||
/// NO database operations - only send email and log result
|
||||
/// </summary>
|
||||
private async Task ProcessEmailAsync(EmailOutbox emailOutbox)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.LogInformation("Processing outgoing email: To={To}, Subject={Subject}",
|
||||
emailOutbox.Recipient, emailOutbox.Subject);
|
||||
|
||||
// Send email via SMTP (SMTP config is injected in IEmailService via IOptions)
|
||||
await EmailService.SendEmailAsync(
|
||||
emailOutbox.Recipient,
|
||||
emailOutbox.Subject,
|
||||
emailOutbox.Body,
|
||||
isHtml: emailOutbox.IsHtml);
|
||||
|
||||
Logger.LogInformation("Email sent successfully: To={To}, Subject={Subject}",
|
||||
emailOutbox.Recipient, emailOutbox.Subject);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to send email: To={To}, Subject={Subject}. Moving to DLQ.",
|
||||
emailOutbox.Recipient, emailOutbox.Subject);
|
||||
|
||||
// Re-throw to trigger NACK in RabbitMQ consumer (requeue=false → DLQ)
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for EmailSenderWorker
|
||||
/// </summary>
|
||||
public class EmailSenderWorkerConfiguration
|
||||
{
|
||||
public const string SectionName = "Workers:EmailSender";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retry attempts before moving to Dead Letter Queue
|
||||
/// NOTE: Currently NOT used - all failed emails move directly to DLQ without retry
|
||||
/// </summary>
|
||||
public int MaxRetryCount { get; set; } = 3;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using AutoMapper;
|
||||
using DigitalData.EmailProfiler.Application.Common.Dtos;
|
||||
using DigitalData.EmailProfiler.Application.EmailAccounts.Commands;
|
||||
using DigitalData.EmailProfiler.Domain.Entities;
|
||||
|
||||
namespace DigitalData.EmailProfiler.Application.Common.Mappings;
|
||||
@@ -12,15 +11,6 @@ public class EmailAccountMappingProfile : Profile
|
||||
{
|
||||
public EmailAccountMappingProfile()
|
||||
{
|
||||
// Command -> Entity (for Create)
|
||||
CreateMap<CreateEmailAccountCommand, EmailAccount>()
|
||||
.ForMember(dest => dest.Id, opt => opt.Ignore()) // Auto-generated
|
||||
.ForMember(dest => dest.AddedWhen, opt => opt.MapFrom(_ => DateTime.Now))
|
||||
.ForMember(dest => dest.AddedWho, opt => opt.MapFrom(_ => "System")) // TODO: Get from user context
|
||||
.ForMember(dest => dest.ChangedWhen, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.ChangedWho, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.Profiles, opt => opt.Ignore());
|
||||
|
||||
// Entity -> DTO (for Queries)
|
||||
CreateMap<EmailAccount, EmailAccountDto>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using AutoMapper;
|
||||
using DigitalData.EmailProfiler.Application.EmailSending.Commands;
|
||||
using DigitalData.EmailProfiler.Domain.Entities;
|
||||
|
||||
namespace DigitalData.EmailProfiler.Application.Common.Mappings;
|
||||
|
||||
/// <summary>
|
||||
/// AutoMapper profile for EmailOutbox entity
|
||||
/// </summary>
|
||||
public class EmailOutboxMappingProfile : Profile
|
||||
{
|
||||
public EmailOutboxMappingProfile()
|
||||
{
|
||||
// SendEmailCommand -> EmailOutbox
|
||||
CreateMap<SendEmailCommand, EmailOutbox>()
|
||||
.ForMember(dest => dest.Id, opt => opt.Ignore()) // Auto-generated
|
||||
.ForMember(dest => dest.Sent, opt => opt.Ignore()) // Set by handler
|
||||
.ForMember(dest => dest.SentDate, opt => opt.Ignore()) // Set when sent
|
||||
.ForMember(dest => dest.RetryCount, opt => opt.Ignore()) // Set by handler
|
||||
.ForMember(dest => dest.AddedWhen, opt => opt.Ignore()) // Set by handler
|
||||
.ForMember(dest => dest.EmailAccount, opt => opt.Ignore()); // Navigation property
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using AutoMapper;
|
||||
using DigitalData.EmailProfiler.Application.Common.Dtos;
|
||||
using DigitalData.EmailProfiler.Application.EmailProfiles.Commands;
|
||||
using DigitalData.EmailProfiler.Domain.Entities;
|
||||
|
||||
namespace DigitalData.EmailProfiler.Application.Common.Mappings;
|
||||
@@ -12,30 +11,6 @@ public class EmailProfileMappingProfile : Profile
|
||||
{
|
||||
public EmailProfileMappingProfile()
|
||||
{
|
||||
// Command -> Entity (for Create)
|
||||
CreateMap<CreateEmailProfileCommand, EmailProfile>()
|
||||
.ForMember(dest => dest.Id, opt => opt.Ignore()) // Auto-generated
|
||||
.ForMember(dest => dest.AddedWhen, opt => opt.MapFrom(_ => DateTime.Now))
|
||||
.ForMember(dest => dest.AddedWho, opt => opt.MapFrom(_ => "System")) // TODO: Get from user context
|
||||
.ForMember(dest => dest.ChangedWhen, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.ChangedWho, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.LastPollTime, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.EmailAccount, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.EmailProcess, opt => opt.Ignore());
|
||||
|
||||
// Command -> Entity (for Update)
|
||||
CreateMap<UpdateEmailProfileCommand, EmailProfile>()
|
||||
.ForMember(dest => dest.Id, opt => opt.Ignore()) // Don't update ID
|
||||
.ForMember(dest => dest.EmailAccountId, opt => opt.Ignore()) // Don't change account
|
||||
.ForMember(dest => dest.ProcessId, opt => opt.Ignore()) // Don't change process
|
||||
.ForMember(dest => dest.AddedWhen, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.AddedWho, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.ChangedWhen, opt => opt.MapFrom(_ => DateTime.Now))
|
||||
.ForMember(dest => dest.ChangedWho, opt => opt.MapFrom(_ => "System")) // TODO: Get from user context
|
||||
.ForMember(dest => dest.LastPollTime, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.EmailAccount, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.EmailProcess, opt => opt.Ignore());
|
||||
|
||||
// Entity -> DTO (for Queries)
|
||||
CreateMap<EmailProfile, EmailProfileDto>()
|
||||
.ForMember(dest => dest.EmailAccountName, opt => opt.MapFrom(src => src.EmailAccount != null ? src.EmailAccount.AccountName : null))
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using AutoMapper;
|
||||
using DigitalData.EmailProfiler.Application.Common.Interfaces;
|
||||
using DigitalData.EmailProfiler.Domain.Entities;
|
||||
using MediatR;
|
||||
|
||||
namespace DigitalData.EmailProfiler.Application.EmailSending.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command to send an email (enqueue to RabbitMQ)
|
||||
/// </summary>
|
||||
public record SendEmailCommand : IRequest<int>
|
||||
{
|
||||
/// <summary>
|
||||
/// Email account ID to use for sending
|
||||
/// </summary>
|
||||
public required int EmailAccountId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recipient email address
|
||||
/// </summary>
|
||||
public required string Recipient { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Email subject
|
||||
/// </summary>
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Email body (HTML or plain text)
|
||||
/// </summary>
|
||||
public required string Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Is HTML email (default: true)
|
||||
/// </summary>
|
||||
public bool IsHtml { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional reference string (e.g., ticket number, order ID)
|
||||
/// </summary>
|
||||
public string? ReferenceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment
|
||||
/// </summary>
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for SendEmailCommand
|
||||
/// Creates EmailOutbox entity via AutoMapper and enqueues to RabbitMQ
|
||||
/// </summary>
|
||||
public class SendEmailCommandHandler(IEmailQueue EmailQueue, IMapper Mapper) : IRequestHandler<SendEmailCommand, int>
|
||||
{
|
||||
public async Task<int> Handle(SendEmailCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Map command to EmailOutbox entity using AutoMapper
|
||||
var emailOutbox = Mapper.Map<EmailOutbox>(request);
|
||||
|
||||
// Set audit fields
|
||||
emailOutbox.AddedWhen = DateTime.Now;
|
||||
emailOutbox.Sent = false;
|
||||
emailOutbox.RetryCount = 0;
|
||||
|
||||
// Enqueue to RabbitMQ
|
||||
await EmailQueue.EnqueueAsync(emailOutbox, cancellationToken);
|
||||
|
||||
return emailOutbox.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using DigitalData.EmailProfiler.Application.EmailSending.Commands;
|
||||
using FluentValidation;
|
||||
|
||||
namespace DigitalData.EmailProfiler.Application.EmailSending.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for SendEmailCommand
|
||||
/// </summary>
|
||||
public class SendEmailCommandValidator : AbstractValidator<SendEmailCommand>
|
||||
{
|
||||
public SendEmailCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.EmailAccountId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("EmailAccountId must be greater than 0");
|
||||
|
||||
RuleFor(x => x.Recipient)
|
||||
.NotEmpty()
|
||||
.WithMessage("Recipient is required")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Recipient must not exceed 200 characters")
|
||||
.EmailAddress()
|
||||
.WithMessage("Recipient must be a valid email address");
|
||||
|
||||
RuleFor(x => x.Subject)
|
||||
.NotEmpty()
|
||||
.WithMessage("Subject is required")
|
||||
.MaximumLength(500)
|
||||
.WithMessage("Subject must not exceed 500 characters");
|
||||
|
||||
RuleFor(x => x.Body)
|
||||
.NotEmpty()
|
||||
.WithMessage("Body is required");
|
||||
|
||||
RuleFor(x => x.ReferenceId)
|
||||
.MaximumLength(200)
|
||||
.When(x => !string.IsNullOrEmpty(x.ReferenceId))
|
||||
.WithMessage("ReferenceId must not exceed 200 characters");
|
||||
|
||||
RuleFor(x => x.Comment)
|
||||
.MaximumLength(500)
|
||||
.When(x => !string.IsNullOrEmpty(x.Comment))
|
||||
.WithMessage("Comment must not exceed 500 characters");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user