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:
2026-07-22 11:49:57 +02:00
parent d346ed3176
commit 8003715792
6 changed files with 236 additions and 35 deletions

View File

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

View File

@@ -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>();
}

View File

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

View File

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

View File

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

View File

@@ -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");
}
}