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

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