From 80037157929518f96201984ca804ad424a7dab27 Mon Sep 17 00:00:00 2001 From: TekH Date: Wed, 22 Jul 2026 11:49:57 +0200 Subject: [PATCH] feat: Add email sending feature with background worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Workers/EmailSenderWorker.cs | 98 +++++++++++++++++++ .../Mappings/EmailAccountMappingProfile.cs | 10 -- .../Mappings/EmailOutboxMappingProfile.cs | 23 +++++ .../Mappings/EmailProfileMappingProfile.cs | 25 ----- .../EmailSending/Commands/SendEmailCommand.cs | 70 +++++++++++++ .../Validators/SendEmailCommandValidator.cs | 45 +++++++++ 6 files changed, 236 insertions(+), 35 deletions(-) create mode 100644 src/DigitalData.EmailProfiler.API/Workers/EmailSenderWorker.cs create mode 100644 src/DigitalData.EmailProfiler.Application/Common/Mappings/EmailOutboxMappingProfile.cs create mode 100644 src/DigitalData.EmailProfiler.Application/EmailSending/Commands/SendEmailCommand.cs create mode 100644 src/DigitalData.EmailProfiler.Application/EmailSending/Validators/SendEmailCommandValidator.cs diff --git a/src/DigitalData.EmailProfiler.API/Workers/EmailSenderWorker.cs b/src/DigitalData.EmailProfiler.API/Workers/EmailSenderWorker.cs new file mode 100644 index 0000000..058e40f --- /dev/null +++ b/src/DigitalData.EmailProfiler.API/Workers/EmailSenderWorker.cs @@ -0,0 +1,98 @@ +using DigitalData.EmailProfiler.Application.Common.Interfaces; +using DigitalData.EmailProfiler.Domain.Entities; + +namespace DigitalData.EmailProfiler.API.Workers; + +/// +/// 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. +/// +public class EmailSenderWorker( + IEmailQueue EmailQueue, + IEmailService EmailService, + ILogger Logger, + IConfiguration configuration) : BackgroundService +{ + private readonly EmailSenderWorkerConfiguration _config = + configuration.GetSection(EmailSenderWorkerConfiguration.SectionName) + .Get() ?? 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"); + } + + /// + /// Process single email message (callback from RabbitMQ consumer) + /// NO database operations - only send email and log result + /// + 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; + } + } +} + +/// +/// Configuration for EmailSenderWorker +/// +public class EmailSenderWorkerConfiguration +{ + public const string SectionName = "Workers:EmailSender"; + + public bool Enabled { get; set; } = true; + + /// + /// Maximum retry attempts before moving to Dead Letter Queue + /// NOTE: Currently NOT used - all failed emails move directly to DLQ without retry + /// + public int MaxRetryCount { get; set; } = 3; +} diff --git a/src/DigitalData.EmailProfiler.Application/Common/Mappings/EmailAccountMappingProfile.cs b/src/DigitalData.EmailProfiler.Application/Common/Mappings/EmailAccountMappingProfile.cs index e596e7b..0873da3 100644 --- a/src/DigitalData.EmailProfiler.Application/Common/Mappings/EmailAccountMappingProfile.cs +++ b/src/DigitalData.EmailProfiler.Application/Common/Mappings/EmailAccountMappingProfile.cs @@ -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() - .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(); } diff --git a/src/DigitalData.EmailProfiler.Application/Common/Mappings/EmailOutboxMappingProfile.cs b/src/DigitalData.EmailProfiler.Application/Common/Mappings/EmailOutboxMappingProfile.cs new file mode 100644 index 0000000..128d7bf --- /dev/null +++ b/src/DigitalData.EmailProfiler.Application/Common/Mappings/EmailOutboxMappingProfile.cs @@ -0,0 +1,23 @@ +using AutoMapper; +using DigitalData.EmailProfiler.Application.EmailSending.Commands; +using DigitalData.EmailProfiler.Domain.Entities; + +namespace DigitalData.EmailProfiler.Application.Common.Mappings; + +/// +/// AutoMapper profile for EmailOutbox entity +/// +public class EmailOutboxMappingProfile : Profile +{ + public EmailOutboxMappingProfile() + { + // SendEmailCommand -> EmailOutbox + CreateMap() + .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 + } +} diff --git a/src/DigitalData.EmailProfiler.Application/Common/Mappings/EmailProfileMappingProfile.cs b/src/DigitalData.EmailProfiler.Application/Common/Mappings/EmailProfileMappingProfile.cs index 4f5151c..b57bc2b 100644 --- a/src/DigitalData.EmailProfiler.Application/Common/Mappings/EmailProfileMappingProfile.cs +++ b/src/DigitalData.EmailProfiler.Application/Common/Mappings/EmailProfileMappingProfile.cs @@ -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() - .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() - .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() .ForMember(dest => dest.EmailAccountName, opt => opt.MapFrom(src => src.EmailAccount != null ? src.EmailAccount.AccountName : null)) diff --git a/src/DigitalData.EmailProfiler.Application/EmailSending/Commands/SendEmailCommand.cs b/src/DigitalData.EmailProfiler.Application/EmailSending/Commands/SendEmailCommand.cs new file mode 100644 index 0000000..543c432 --- /dev/null +++ b/src/DigitalData.EmailProfiler.Application/EmailSending/Commands/SendEmailCommand.cs @@ -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; + +/// +/// Command to send an email (enqueue to RabbitMQ) +/// +public record SendEmailCommand : IRequest +{ + /// + /// Email account ID to use for sending + /// + public required int EmailAccountId { get; init; } + + /// + /// Recipient email address + /// + public required string Recipient { get; init; } + + /// + /// Email subject + /// + public required string Subject { get; init; } + + /// + /// Email body (HTML or plain text) + /// + public required string Body { get; init; } + + /// + /// Is HTML email (default: true) + /// + public bool IsHtml { get; init; } = true; + + /// + /// Optional reference string (e.g., ticket number, order ID) + /// + public string? ReferenceId { get; init; } + + /// + /// Optional comment + /// + public string? Comment { get; init; } +} + +/// +/// Handler for SendEmailCommand +/// Creates EmailOutbox entity via AutoMapper and enqueues to RabbitMQ +/// +public class SendEmailCommandHandler(IEmailQueue EmailQueue, IMapper Mapper) : IRequestHandler +{ + public async Task Handle(SendEmailCommand request, CancellationToken cancellationToken) + { + // Map command to EmailOutbox entity using AutoMapper + var emailOutbox = Mapper.Map(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; + } +} diff --git a/src/DigitalData.EmailProfiler.Application/EmailSending/Validators/SendEmailCommandValidator.cs b/src/DigitalData.EmailProfiler.Application/EmailSending/Validators/SendEmailCommandValidator.cs new file mode 100644 index 0000000..7388ef2 --- /dev/null +++ b/src/DigitalData.EmailProfiler.Application/EmailSending/Validators/SendEmailCommandValidator.cs @@ -0,0 +1,45 @@ +using DigitalData.EmailProfiler.Application.EmailSending.Commands; +using FluentValidation; + +namespace DigitalData.EmailProfiler.Application.EmailSending.Validators; + +/// +/// Validator for SendEmailCommand +/// +public class SendEmailCommandValidator : AbstractValidator +{ + 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"); + } +}