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).
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.Features.EmailAccounts.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to create a new email account.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateEmailAccountCommand : IRequest<int>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<CreateEmailAccountCommand, int>
|
||||||
|
{
|
||||||
|
public async Task<int> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.Features.EmailProcessing.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to process a single email from a profile.
|
||||||
|
/// This is the core email processing logic.
|
||||||
|
/// </summary>
|
||||||
|
public record ProcessEmailCommand : IRequest<int>
|
||||||
|
{
|
||||||
|
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<AttachmentData> Attachments { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attachment data for email processing.
|
||||||
|
/// </summary>
|
||||||
|
public record AttachmentData(
|
||||||
|
string FileName,
|
||||||
|
byte[] Content,
|
||||||
|
string ContentType,
|
||||||
|
long SizeBytes);
|
||||||
@@ -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<ProcessEmailCommand, int>
|
||||||
|
{
|
||||||
|
public async Task<int> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.Features.EmailProfiles.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to create a new email profile.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateEmailProfileCommand : IRequest<int>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<CreateEmailProfileCommand, int>
|
||||||
|
{
|
||||||
|
public async Task<int> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.Features.EmailProfiles.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to delete an email profile.
|
||||||
|
/// </summary>
|
||||||
|
public record DeleteEmailProfileCommand(int Id) : IRequest<Unit>;
|
||||||
@@ -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<DeleteEmailProfileCommand, Unit>
|
||||||
|
{
|
||||||
|
public async Task<Unit> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.Features.EmailProfiles.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to update an existing email profile.
|
||||||
|
/// </summary>
|
||||||
|
public record UpdateEmailProfileCommand : IRequest<Unit>
|
||||||
|
{
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -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<UpdateEmailProfileCommand, Unit>
|
||||||
|
{
|
||||||
|
public async Task<Unit> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,12 @@ namespace DigitalData.EmailProfiler.Domain.Exceptions;
|
|||||||
|
|
||||||
public class AttachmentProcessingException : DomainException
|
public class AttachmentProcessingException : DomainException
|
||||||
{
|
{
|
||||||
public ErrorCode ErrorCode { get; }
|
public AttachmentProcessingException(ErrorCode errorCode, string message) : base(message, errorCode)
|
||||||
|
|
||||||
public AttachmentProcessingException(ErrorCode errorCode, string message) : base(message)
|
|
||||||
{
|
{
|
||||||
ErrorCode = errorCode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public AttachmentProcessingException(ErrorCode errorCode, string message, Exception innerException)
|
public AttachmentProcessingException(ErrorCode errorCode, string message, Exception innerException)
|
||||||
: base(message, innerException)
|
: base(message, errorCode)
|
||||||
{
|
{
|
||||||
ErrorCode = errorCode;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
|
using DigitalData.EmailProfiler.Domain.Enums;
|
||||||
|
|
||||||
namespace DigitalData.EmailProfiler.Domain.Exceptions;
|
namespace DigitalData.EmailProfiler.Domain.Exceptions;
|
||||||
|
|
||||||
public class DomainException : Exception
|
public class DomainException : Exception
|
||||||
{
|
{
|
||||||
|
public ErrorCode? ErrorCode { get; }
|
||||||
|
|
||||||
public DomainException(string message) : base(message)
|
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)
|
public DomainException(string message, Exception innerException) : base(message, innerException)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using DigitalData.EmailProfiler.Domain.Enums;
|
||||||
|
|
||||||
namespace DigitalData.EmailProfiler.Domain.Exceptions;
|
namespace DigitalData.EmailProfiler.Domain.Exceptions;
|
||||||
|
|
||||||
public class ValidationException : DomainException
|
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)
|
public ValidationException(string message, Exception innerException) : base(message, innerException)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user