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