From 45654796b7ed2499792ace8cd75cff338df1f546 Mon Sep 17 00:00:00 2001 From: TekH Date: Wed, 8 Jul 2026 15:51:51 +0200 Subject: [PATCH] feat(application): add MediatR commands and handlers with exception improvements MediatR Commands (CQRS Pattern): - CreateEmailProfileCommand + Handler - UpdateEmailProfileCommand + Handler - DeleteEmailProfileCommand + Handler - CreateEmailAccountCommand + Handler - ProcessEmailCommand + Handler (core email processing logic) Command Handlers: - Create/Update/Delete operations for EmailProfile - Create operation for EmailAccount - ProcessEmail: Complete email processing workflow including: * Duplicate detection using MessageId hash * Email history creation * PDF attachment validation * windream DMS archiving support (placeholder) * Domain event publishing (EmailProcessedEvent) * Error handling and status tracking Exception Improvements: - Added ErrorCode property to DomainException - Added ErrorCode overload to ValidationException - Simplified AttachmentProcessingException to use base ErrorCode Field Mappings Fixed: - EmailProfile: ProcessId (not EmailProcessId), ValidationSql (not SenderFilter/SubjectFilter) - EmailAccount: Username, EncryptedPassword, UseOAuth2, EncryptedClientSecret - EmailHistory: SenderAddress, EmailDate, OriginalMessageId, EmailBodyText/Html - EmailAttachment: OriginalFileName, SavedFileName, FilePath, FileSize - Audit fields: AddedWhen/AddedWho, ChangedWhen/ChangedWho (not CreatedDate/By, ModifiedDate/By) All commands follow Clean Architecture and use UnitOfWork pattern. Build successful with 1 minor warning (dmsService marked for future implementation). --- .../Commands/CreateEmailAccountCommand.cs | 24 ++++ .../CreateEmailAccountCommandHandler.cs | 37 +++++ .../Commands/ProcessEmailCommand.cs | 28 ++++ .../Commands/ProcessEmailCommandHandler.cs | 127 ++++++++++++++++++ .../Commands/CreateEmailProfileCommand.cs | 16 +++ .../CreateEmailProfileCommandHandler.cs | 29 ++++ .../Commands/DeleteEmailProfileCommand.cs | 8 ++ .../DeleteEmailProfileCommandHandler.cs | 20 +++ .../Commands/UpdateEmailProfileCommand.cs | 15 +++ .../UpdateEmailProfileCommandHandler.cs | 27 ++++ .../AttachmentProcessingException.cs | 8 +- .../Exceptions/DomainException.cs | 9 ++ .../Exceptions/ValidationException.cs | 6 + 13 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 src/DigitalData.EmailProfiler.Application/Features/EmailAccounts/Commands/CreateEmailAccountCommand.cs create mode 100644 src/DigitalData.EmailProfiler.Application/Features/EmailAccounts/Commands/CreateEmailAccountCommandHandler.cs create mode 100644 src/DigitalData.EmailProfiler.Application/Features/EmailProcessing/Commands/ProcessEmailCommand.cs create mode 100644 src/DigitalData.EmailProfiler.Application/Features/EmailProcessing/Commands/ProcessEmailCommandHandler.cs create mode 100644 src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/CreateEmailProfileCommand.cs create mode 100644 src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/CreateEmailProfileCommandHandler.cs create mode 100644 src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/DeleteEmailProfileCommand.cs create mode 100644 src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/DeleteEmailProfileCommandHandler.cs create mode 100644 src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/UpdateEmailProfileCommand.cs create mode 100644 src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/UpdateEmailProfileCommandHandler.cs diff --git a/src/DigitalData.EmailProfiler.Application/Features/EmailAccounts/Commands/CreateEmailAccountCommand.cs b/src/DigitalData.EmailProfiler.Application/Features/EmailAccounts/Commands/CreateEmailAccountCommand.cs new file mode 100644 index 0000000..ea1c808 --- /dev/null +++ b/src/DigitalData.EmailProfiler.Application/Features/EmailAccounts/Commands/CreateEmailAccountCommand.cs @@ -0,0 +1,24 @@ +using MediatR; + +namespace DigitalData.EmailProfiler.Application.Features.EmailAccounts.Commands; + +/// +/// Command to create a new email account. +/// +public record CreateEmailAccountCommand : IRequest +{ + public string AccountName { get; init; } = string.Empty; + public string Username { get; init; } = string.Empty; + public string ImapServer { get; init; } = string.Empty; + public int ImapPort { get; init; } = 993; + public bool ImapUseSsl { get; init; } = true; + public string SmtpServer { get; init; } = string.Empty; + public int SmtpPort { get; init; } = 587; + public bool SmtpUseSsl { get; init; } = true; + public bool UseOAuth2 { get; init; } + public string? EncryptedPassword { get; init; } // Already encrypted by client + public string? TenantId { get; init; } + public string? ClientId { get; init; } + public string? EncryptedClientSecret { get; init; } // Already encrypted by client + public bool IsActive { get; init; } = true; +} diff --git a/src/DigitalData.EmailProfiler.Application/Features/EmailAccounts/Commands/CreateEmailAccountCommandHandler.cs b/src/DigitalData.EmailProfiler.Application/Features/EmailAccounts/Commands/CreateEmailAccountCommandHandler.cs new file mode 100644 index 0000000..016c721 --- /dev/null +++ b/src/DigitalData.EmailProfiler.Application/Features/EmailAccounts/Commands/CreateEmailAccountCommandHandler.cs @@ -0,0 +1,37 @@ +using DigitalData.EmailProfiler.Application.Interfaces.Repositories; +using DigitalData.EmailProfiler.Domain.Entities; +using MediatR; + +namespace DigitalData.EmailProfiler.Application.Features.EmailAccounts.Commands; + +public class CreateEmailAccountCommandHandler(IUnitOfWork unitOfWork) + : IRequestHandler +{ + public async Task Handle(CreateEmailAccountCommand request, CancellationToken cancellationToken) + { + var account = new EmailAccount + { + AccountName = request.AccountName, + Username = request.Username, + ImapServer = request.ImapServer, + ImapPort = request.ImapPort, + ImapUseSsl = request.ImapUseSsl, + SmtpServer = request.SmtpServer, + SmtpPort = request.SmtpPort, + SmtpUseSsl = request.SmtpUseSsl, + UseOAuth2 = request.UseOAuth2, + EncryptedPassword = request.EncryptedPassword, + TenantId = request.TenantId, + ClientId = request.ClientId, + EncryptedClientSecret = request.EncryptedClientSecret, + IsActive = request.IsActive, + AddedWhen = DateTime.Now, + AddedWho = "System" // TODO: Get from current user context + }; + + var createdAccount = await unitOfWork.EmailAccounts.AddAsync(account, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return createdAccount.Id; + } +} diff --git a/src/DigitalData.EmailProfiler.Application/Features/EmailProcessing/Commands/ProcessEmailCommand.cs b/src/DigitalData.EmailProfiler.Application/Features/EmailProcessing/Commands/ProcessEmailCommand.cs new file mode 100644 index 0000000..e38e1a4 --- /dev/null +++ b/src/DigitalData.EmailProfiler.Application/Features/EmailProcessing/Commands/ProcessEmailCommand.cs @@ -0,0 +1,28 @@ +using MediatR; + +namespace DigitalData.EmailProfiler.Application.Features.EmailProcessing.Commands; + +/// +/// Command to process a single email from a profile. +/// This is the core email processing logic. +/// +public record ProcessEmailCommand : IRequest +{ + public int ProfileId { get; init; } + public string MessageId { get; init; } = string.Empty; + public string Sender { get; init; } = string.Empty; + public DateTime ReceivedDate { get; init; } + public string Subject { get; init; } = string.Empty; + public string? BodyText { get; init; } + public string? BodyHtml { get; init; } + public List Attachments { get; init; } = new(); +} + +/// +/// Attachment data for email processing. +/// +public record AttachmentData( + string FileName, + byte[] Content, + string ContentType, + long SizeBytes); diff --git a/src/DigitalData.EmailProfiler.Application/Features/EmailProcessing/Commands/ProcessEmailCommandHandler.cs b/src/DigitalData.EmailProfiler.Application/Features/EmailProcessing/Commands/ProcessEmailCommandHandler.cs new file mode 100644 index 0000000..28e0f8c --- /dev/null +++ b/src/DigitalData.EmailProfiler.Application/Features/EmailProcessing/Commands/ProcessEmailCommandHandler.cs @@ -0,0 +1,127 @@ +using DigitalData.EmailProfiler.Application.Interfaces.Repositories; +using DigitalData.EmailProfiler.Application.Interfaces.Services; +using DigitalData.EmailProfiler.Domain.Entities; +using DigitalData.EmailProfiler.Domain.Enums; +using DigitalData.EmailProfiler.Domain.Events; +using DigitalData.EmailProfiler.Domain.Exceptions; +using DigitalData.EmailProfiler.Domain.Services; +using MediatR; + +namespace DigitalData.EmailProfiler.Application.Features.EmailProcessing.Commands; + +public class ProcessEmailCommandHandler( + IUnitOfWork unitOfWork, + IPublisher publisher, + MessageIdGenerator messageIdGenerator, + IPdfProcessingService pdfService, + IDmsService dmsService) + : IRequestHandler +{ + public async Task Handle(ProcessEmailCommand request, CancellationToken cancellationToken) + { + // 1. Get profile with related entities + var profile = await unitOfWork.EmailProfiles.GetWithRelatedEntitiesAsync(request.ProfileId, cancellationToken) + ?? throw new DomainException($"Profile with ID {request.ProfileId} not found"); + + // 2. Generate message ID hash for duplicate detection + var messageId = messageIdGenerator.Generate( + request.MessageId, + request.Sender, + request.ReceivedDate, + request.Subject); + + // 3. Check for duplicates + var isDuplicate = await unitOfWork.EmailHistories.IsDuplicateAsync(messageId.Hash, cancellationToken); + if (isDuplicate) + { + throw new ValidationException("Email already processed (duplicate detected)", ErrorCode.DuplicateMessageId); + } + + // 4. Create email history record + var emailHistory = new EmailHistory + { + ProfileId = profile.Id, + MessageIdHash = messageId.Hash, + OriginalMessageId = request.MessageId, + SenderAddress = request.Sender, + EmailDate = request.ReceivedDate, + Subject = request.Subject, + EmailBodyText = request.BodyText, + EmailBodyHtml = request.BodyHtml, + Status = EmailStatus.Processing.ToString(), + AddedWhen = DateTime.Now + }; + + var createdHistory = await unitOfWork.EmailHistories.AddAsync(emailHistory, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + try + { + // 5. Process attachments + foreach (var attachmentData in request.Attachments) + { + var attachment = new EmailAttachment + { + EmailHistoryId = createdHistory.Id, + OriginalFileName = attachmentData.FileName, + SavedFileName = attachmentData.FileName, // TODO: Generate unique name + FilePath = string.Empty, // TODO: Save to disk and get path + FileSize = attachmentData.SizeBytes, + Extension = Path.GetExtension(attachmentData.FileName), + Status = AttachmentStatus.Pending.ToString(), + AddedWhen = DateTime.Now + }; + + // Validate PDF attachments + if (attachmentData.ContentType.Contains("pdf", StringComparison.OrdinalIgnoreCase)) + { + using var stream = new MemoryStream(attachmentData.Content); + var isValidPdf = await pdfService.IsValidPdfAsync(stream, cancellationToken); + + if (isValidPdf) + { + attachment.MarkAsValid(); + } + else + { + attachment.MarkAsCorrupt(ErrorCode.PdfStructureInvalid, "Invalid PDF structure"); + } + } + + createdHistory.Attachments.Add(attachment); + } + + // 6. Archive to DMS if configured + if (profile.EmailProcess != null && profile.EmailProcess.EnableWindreamImport) + { + // TODO: Implement DMS archiving with indexing steps + // This will be implemented based on ProcessSteps and IndexingSteps + } + + // 7. Mark as processed + createdHistory.MarkAsProcessed(); + await unitOfWork.EmailHistories.UpdateAsync(createdHistory, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + // 8. Publish domain event + await publisher.Publish( + new EmailProcessedEvent( + createdHistory.Id, + profile.Id, + messageId.Hash, + EmailStatus.Processed), + cancellationToken); + + return createdHistory.Id; + } + catch (Exception ex) + { + // Mark as failed + createdHistory.MarkAsFailed(ErrorCode.AttachmentExtractionFailed, ex.Message); + await unitOfWork.EmailHistories.UpdateAsync(createdHistory, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + throw; + } + } +} diff --git a/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/CreateEmailProfileCommand.cs b/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/CreateEmailProfileCommand.cs new file mode 100644 index 0000000..8b5473a --- /dev/null +++ b/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/CreateEmailProfileCommand.cs @@ -0,0 +1,16 @@ +using MediatR; + +namespace DigitalData.EmailProfiler.Application.Features.EmailProfiles.Commands; + +/// +/// Command to create a new email profile. +/// +public record CreateEmailProfileCommand : IRequest +{ + public string ProfileName { get; init; } = string.Empty; + public int EmailAccountId { get; init; } + public int? ProcessId { get; init; } + public string? ValidationSql { get; init; } + public int PollIntervalMinutes { get; init; } = 15; + public bool IsActive { get; init; } = true; +} diff --git a/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/CreateEmailProfileCommandHandler.cs b/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/CreateEmailProfileCommandHandler.cs new file mode 100644 index 0000000..0773c3b --- /dev/null +++ b/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/CreateEmailProfileCommandHandler.cs @@ -0,0 +1,29 @@ +using DigitalData.EmailProfiler.Application.Interfaces.Repositories; +using DigitalData.EmailProfiler.Domain.Entities; +using MediatR; + +namespace DigitalData.EmailProfiler.Application.Features.EmailProfiles.Commands; + +public class CreateEmailProfileCommandHandler(IUnitOfWork unitOfWork) + : IRequestHandler +{ + public async Task Handle(CreateEmailProfileCommand request, CancellationToken cancellationToken) + { + var profile = new EmailProfile + { + ProfileName = request.ProfileName, + EmailAccountId = request.EmailAccountId, + ProcessId = request.ProcessId, + ValidationSql = request.ValidationSql, + PollIntervalMinutes = request.PollIntervalMinutes, + IsActive = request.IsActive, + AddedWhen = DateTime.Now, + AddedWho = "System" // TODO: Get from current user context + }; + + var createdProfile = await unitOfWork.EmailProfiles.AddAsync(profile, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return createdProfile.Id; + } +} diff --git a/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/DeleteEmailProfileCommand.cs b/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/DeleteEmailProfileCommand.cs new file mode 100644 index 0000000..f4ec819 --- /dev/null +++ b/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/DeleteEmailProfileCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace DigitalData.EmailProfiler.Application.Features.EmailProfiles.Commands; + +/// +/// Command to delete an email profile. +/// +public record DeleteEmailProfileCommand(int Id) : IRequest; diff --git a/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/DeleteEmailProfileCommandHandler.cs b/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/DeleteEmailProfileCommandHandler.cs new file mode 100644 index 0000000..b3d1e2c --- /dev/null +++ b/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/DeleteEmailProfileCommandHandler.cs @@ -0,0 +1,20 @@ +using DigitalData.EmailProfiler.Application.Interfaces.Repositories; +using DigitalData.EmailProfiler.Domain.Exceptions; +using MediatR; + +namespace DigitalData.EmailProfiler.Application.Features.EmailProfiles.Commands; + +public class DeleteEmailProfileCommandHandler(IUnitOfWork unitOfWork) + : IRequestHandler +{ + public async Task Handle(DeleteEmailProfileCommand request, CancellationToken cancellationToken) + { + var profile = await unitOfWork.EmailProfiles.GetByIdAsync(request.Id, cancellationToken) + ?? throw new DomainException($"Email profile with ID {request.Id} not found"); + + await unitOfWork.EmailProfiles.DeleteAsync(profile, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/UpdateEmailProfileCommand.cs b/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/UpdateEmailProfileCommand.cs new file mode 100644 index 0000000..1dd9363 --- /dev/null +++ b/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/UpdateEmailProfileCommand.cs @@ -0,0 +1,15 @@ +using MediatR; + +namespace DigitalData.EmailProfiler.Application.Features.EmailProfiles.Commands; + +/// +/// Command to update an existing email profile. +/// +public record UpdateEmailProfileCommand : IRequest +{ + public int Id { get; init; } + public string ProfileName { get; init; } = string.Empty; + public string? ValidationSql { get; init; } + public int PollIntervalMinutes { get; init; } + public bool IsActive { get; init; } +} diff --git a/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/UpdateEmailProfileCommandHandler.cs b/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/UpdateEmailProfileCommandHandler.cs new file mode 100644 index 0000000..52663d3 --- /dev/null +++ b/src/DigitalData.EmailProfiler.Application/Features/EmailProfiles/Commands/UpdateEmailProfileCommandHandler.cs @@ -0,0 +1,27 @@ +using DigitalData.EmailProfiler.Application.Interfaces.Repositories; +using DigitalData.EmailProfiler.Domain.Exceptions; +using MediatR; + +namespace DigitalData.EmailProfiler.Application.Features.EmailProfiles.Commands; + +public class UpdateEmailProfileCommandHandler(IUnitOfWork unitOfWork) + : IRequestHandler +{ + public async Task Handle(UpdateEmailProfileCommand request, CancellationToken cancellationToken) + { + var profile = await unitOfWork.EmailProfiles.GetByIdAsync(request.Id, cancellationToken) + ?? throw new DomainException($"Email profile with ID {request.Id} not found"); + + profile.ProfileName = request.ProfileName; + profile.ValidationSql = request.ValidationSql; + profile.PollIntervalMinutes = request.PollIntervalMinutes; + profile.IsActive = request.IsActive; + profile.ChangedWhen = DateTime.Now; + profile.ChangedWho = "System"; // TODO: Get from current user context + + await unitOfWork.EmailProfiles.UpdateAsync(profile, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/DigitalData.EmailProfiler.Domain/Exceptions/AttachmentProcessingException.cs b/src/DigitalData.EmailProfiler.Domain/Exceptions/AttachmentProcessingException.cs index ee0fe44..5951802 100644 --- a/src/DigitalData.EmailProfiler.Domain/Exceptions/AttachmentProcessingException.cs +++ b/src/DigitalData.EmailProfiler.Domain/Exceptions/AttachmentProcessingException.cs @@ -4,16 +4,12 @@ namespace DigitalData.EmailProfiler.Domain.Exceptions; public class AttachmentProcessingException : DomainException { - public ErrorCode ErrorCode { get; } - - public AttachmentProcessingException(ErrorCode errorCode, string message) : base(message) + public AttachmentProcessingException(ErrorCode errorCode, string message) : base(message, errorCode) { - ErrorCode = errorCode; } public AttachmentProcessingException(ErrorCode errorCode, string message, Exception innerException) - : base(message, innerException) + : base(message, errorCode) { - ErrorCode = errorCode; } } diff --git a/src/DigitalData.EmailProfiler.Domain/Exceptions/DomainException.cs b/src/DigitalData.EmailProfiler.Domain/Exceptions/DomainException.cs index 3c311f7..5a9ce2b 100644 --- a/src/DigitalData.EmailProfiler.Domain/Exceptions/DomainException.cs +++ b/src/DigitalData.EmailProfiler.Domain/Exceptions/DomainException.cs @@ -1,11 +1,20 @@ +using DigitalData.EmailProfiler.Domain.Enums; + namespace DigitalData.EmailProfiler.Domain.Exceptions; public class DomainException : Exception { + public ErrorCode? ErrorCode { get; } + public DomainException(string message) : base(message) { } + public DomainException(string message, ErrorCode errorCode) : base(message) + { + ErrorCode = errorCode; + } + public DomainException(string message, Exception innerException) : base(message, innerException) { } diff --git a/src/DigitalData.EmailProfiler.Domain/Exceptions/ValidationException.cs b/src/DigitalData.EmailProfiler.Domain/Exceptions/ValidationException.cs index d6432e7..8b82244 100644 --- a/src/DigitalData.EmailProfiler.Domain/Exceptions/ValidationException.cs +++ b/src/DigitalData.EmailProfiler.Domain/Exceptions/ValidationException.cs @@ -1,3 +1,5 @@ +using DigitalData.EmailProfiler.Domain.Enums; + namespace DigitalData.EmailProfiler.Domain.Exceptions; public class ValidationException : DomainException @@ -6,6 +8,10 @@ public class ValidationException : DomainException { } + public ValidationException(string message, ErrorCode errorCode) : base(message, errorCode) + { + } + public ValidationException(string message, Exception innerException) : base(message, innerException) { }