Compare commits
9 Commits
45654796b7
...
8f2365d048
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f2365d048 | |||
| bfe24eba06 | |||
| 0d22fe0b5c | |||
| 1ed489532d | |||
| eda6257145 | |||
| a708799587 | |||
| b7d65d7d5c | |||
| 5e8e6a06fe | |||
| 50c21ee628 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -370,3 +370,4 @@ FodyWeavers.xsd
|
|||||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
|
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
|
||||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
|
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
|
||||||
/legacy/App
|
/legacy/App
|
||||||
|
/src/DigitalData.EmailProfiler.API/appsettings.Secrets.json
|
||||||
|
|||||||
@@ -55,21 +55,227 @@ var lastPoll = DateTime.UtcNow.AddMinutes(-profile.PollIntervalMinutes); // NEV
|
|||||||
- All timestamps in logs and error messages
|
- All timestamps in logs and error messages
|
||||||
- All date parameters in queries
|
- All date parameters in queries
|
||||||
|
|
||||||
### 4. No Commits Without Permission
|
### 4. Git Operations - NEVER Without Explicit Permission
|
||||||
**NEVER** commit changes to git automatically. Always wait for explicit user instruction to commit.
|
**CRITICAL**: NEVER execute `git commit` or `git push` commands automatically. ALWAYS wait for explicit user instruction.
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
- Only commit when user explicitly says "commit" or "commit this"
|
||||||
|
- Only push when user explicitly says "push" or "push to remote"
|
||||||
|
- Stage files with `git add` ONLY when about to commit per user request
|
||||||
|
|
||||||
|
### 5. MediatR Command/Query File Organization
|
||||||
|
**IMPORTANT**: Commands/Queries and their Handlers must be in the SAME file.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```csharp
|
||||||
|
// ✅ CORRECT - CreateEmailProfileCommand.cs contains BOTH
|
||||||
|
public record CreateEmailProfileCommand : IRequest<int> { ... }
|
||||||
|
|
||||||
|
public class CreateEmailProfileCommandHandler : IRequestHandler<CreateEmailProfileCommand, int> { ... }
|
||||||
|
|
||||||
|
// ❌ WRONG - Separate files
|
||||||
|
// CreateEmailProfileCommand.cs (command only)
|
||||||
|
// CreateEmailProfileCommandHandler.cs (handler only)
|
||||||
|
```
|
||||||
|
|
||||||
|
**File Naming**:
|
||||||
|
- Commands: `{Verb}{Entity}Command.cs` (e.g., `CreateEmailProfileCommand.cs`)
|
||||||
|
- Queries: `{Verb}{Entity}Query.cs` (e.g., `GetEmailProfilesQuery.cs`)
|
||||||
|
|
||||||
|
### 6. Repository Pattern - NO UnitOfWork, Generic CRUD with AutoMapper
|
||||||
|
**CRITICAL**: DO NOT use IUnitOfWork pattern. Use generic repository pattern with AutoMapper-based CRUD operations.
|
||||||
|
|
||||||
|
**Key Principles**:
|
||||||
|
- ✅ Each operation auto-saves changes - NO explicit SaveChangesAsync needed
|
||||||
|
- ✅ Use `UpdateSingleAsync` / `DeleteSingleAsync` for single-record safety
|
||||||
|
- ✅ Use `UpdateAsync` / `DeleteAsync` only when intentionally modifying multiple records
|
||||||
|
- ✅ AutoMapper handles all DTO → Entity mappings
|
||||||
|
|
||||||
|
**Pattern**:
|
||||||
|
```csharp
|
||||||
|
// IRepository<T> generic interface
|
||||||
|
public interface IRepository<TEntity> where TEntity : class
|
||||||
|
{
|
||||||
|
// Query operations
|
||||||
|
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, ...);
|
||||||
|
|
||||||
|
// Create - auto-saves
|
||||||
|
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
// Update - auto-saves
|
||||||
|
Task<int> UpdateAsync<TDto>(Expression<...> predicate, TDto dto, ...); // Multiple records
|
||||||
|
Task UpdateSingleAsync<TDto>(Expression<...> predicate, TDto dto, ...); // SAFE: Single record only
|
||||||
|
|
||||||
|
// Delete - auto-saves
|
||||||
|
Task<int> DeleteAsync(Expression<...> predicate, ...); // Multiple records
|
||||||
|
Task DeleteSingleAsync(Expression<...> predicate, ...); // SAFE: Single record only
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Command Handler Examples**:
|
||||||
|
```csharp
|
||||||
|
// ✅ CORRECT - CreateAsync auto-saves
|
||||||
|
public class CreateEmailProfileCommandHandler(IRepository<EmailProfile> repository)
|
||||||
|
: IRequestHandler<CreateEmailProfileCommand, int>
|
||||||
|
{
|
||||||
|
public async Task<int> Handle(CreateEmailProfileCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var profile = await repository.CreateAsync(request, cancellationToken);
|
||||||
|
return profile.Id; // NO SaveChangesAsync needed!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ CORRECT - UpdateSingleAsync for safety (throws if 0 or 2+ records match)
|
||||||
|
public class UpdateEmailProfileCommandHandler(IRepository<EmailProfile> repository)
|
||||||
|
: IRequestHandler<UpdateEmailProfileCommand, int>
|
||||||
|
{
|
||||||
|
public async Task<int> Handle(UpdateEmailProfileCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await repository.UpdateSingleAsync(p => p.Id == request.Id, request, cancellationToken);
|
||||||
|
return request.Id; // NO SaveChangesAsync needed!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ CORRECT - DeleteSingleAsync for safety (throws if 0 or 2+ records match)
|
||||||
|
public class DeleteEmailProfileCommandHandler(IRepository<EmailProfile> repository)
|
||||||
|
: IRequestHandler<DeleteEmailProfileCommand, int>
|
||||||
|
{
|
||||||
|
public async Task<int> Handle(DeleteEmailProfileCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await repository.DeleteSingleAsync(p => p.Id == request.Id, cancellationToken);
|
||||||
|
return request.Id; // NO SaveChangesAsync needed!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ WRONG - Manual entity creation (use AutoMapper instead)
|
||||||
|
var profile = new EmailProfile
|
||||||
|
{
|
||||||
|
ProfileName = request.ProfileName,
|
||||||
|
EmailAccountId = request.EmailAccountId,
|
||||||
|
// ... 15 more properties
|
||||||
|
};
|
||||||
|
|
||||||
|
// ❌ WRONG - Using IUnitOfWork (removed)
|
||||||
|
public CreateEmailProfileCommandHandler(IUnitOfWork unitOfWork) { ... }
|
||||||
|
|
||||||
|
// ❌ WRONG - Calling SaveChangesAsync (removed)
|
||||||
|
await repository.SaveChangesAsync(cancellationToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Safety Rules**:
|
||||||
|
1. **UpdateSingleAsync** - Use for ID-based updates. Throws `InvalidOperationException` if:
|
||||||
|
- Zero records match (entity not found)
|
||||||
|
- Multiple records match (predicate too broad)
|
||||||
|
|
||||||
|
2. **DeleteSingleAsync** - Use for ID-based deletes. Throws `InvalidOperationException` if:
|
||||||
|
- Zero records match (entity not found)
|
||||||
|
- Multiple records match (predicate too broad)
|
||||||
|
|
||||||
|
3. **UpdateAsync / DeleteAsync** - Use ONLY when intentionally modifying multiple records:
|
||||||
|
```csharp
|
||||||
|
// ✅ CORRECT - Intentional bulk operation
|
||||||
|
await repository.UpdateAsync(
|
||||||
|
p => p.EmailAccountId == accountId,
|
||||||
|
new { IsActive = false },
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
// ✅ Returns count of updated/deleted records
|
||||||
|
var count = await repository.DeleteAsync(p => p.IsActive == false, cancellationToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
**DTO Mapping Responsibility**:
|
||||||
|
- Each DTO creator must define their own AutoMapper profile
|
||||||
|
- Example: `CreateEmailProfileCommand` → `EmailProfile` mapping must be defined in `EmailProfileMappingProfile.cs`
|
||||||
|
- Repository implementation uses `IMapper.Map<TEntity>(dto)` internally
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Future Enhancements
|
## Future Enhancements
|
||||||
|
|
||||||
### HIGH PRIORITY: RabbitMQ Queue Implementation
|
### 7. RabbitMQ Command Bus Integration (IMPLEMENTED)
|
||||||
|
|
||||||
**Current State**:
|
**Purpose**: Asynchronous command processing via RabbitMQ message broker for POST/PUT/DELETE operations.
|
||||||
- Email queue is implemented using in-memory `Channel<T>` in `InMemoryEmailQueue.cs`
|
|
||||||
- Location: `src/DigitalData.EmailProfiler.Infrastructure/Queue/InMemoryEmailQueue.cs`
|
|
||||||
|
|
||||||
**Future Enhancement**:
|
**Architecture**:
|
||||||
Replace the in-memory queue with **RabbitMQ** for production resilience and scalability.
|
- **GET Queries**: Synchronous (immediate response via MediatR)
|
||||||
|
- **POST/PUT/DELETE Commands**: Can be asynchronous (published to RabbitMQ, processed by background worker)
|
||||||
|
|
||||||
|
**RabbitMQ Server**:
|
||||||
|
- Management UI: `http://172.24.12.56:15672`
|
||||||
|
- AMQP Port: `5672` (default)
|
||||||
|
- Exchange: `emailprofiler.commands` (Direct)
|
||||||
|
- Queue: `emailprofiler.command.queue`
|
||||||
|
- Routing Key: `command`
|
||||||
|
|
||||||
|
**Implementation Components**:
|
||||||
|
|
||||||
|
1. **ICommandPublisher** (`Application/Common/Interfaces/ICommandPublisher.cs`):
|
||||||
|
- Interface for publishing commands to message broker
|
||||||
|
- Generic method: `PublishAsync<TCommand>(TCommand command, CancellationToken)`
|
||||||
|
|
||||||
|
2. **RabbitMqCommandPublisher** (`Infrastructure/Messaging/RabbitMqCommandPublisher.cs`):
|
||||||
|
- Implements `ICommandPublisher`
|
||||||
|
- Serializes command to JSON with metadata envelope (CommandType, Payload, CorrelationId, PublishedAt)
|
||||||
|
- Publishes to RabbitMQ exchange with persistent delivery mode
|
||||||
|
|
||||||
|
3. **RabbitMqCommandConsumer** (`Infrastructure/Messaging/RabbitMqCommandConsumer.cs`):
|
||||||
|
- BackgroundService that consumes commands from RabbitMQ
|
||||||
|
- Deserializes command envelope
|
||||||
|
- Resolves command type from assembly
|
||||||
|
- Executes command via MediatR in scoped service
|
||||||
|
- Acknowledges message on success, requeues on error
|
||||||
|
|
||||||
|
4. **RabbitMqConfiguration** (`Infrastructure/Messaging/RabbitMqConfiguration.cs`):
|
||||||
|
- Configuration model for RabbitMQ connection
|
||||||
|
- Binds to `appsettings.json` section: `RabbitMq`
|
||||||
|
|
||||||
|
**Configuration** (`appsettings.json`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"RabbitMq": {
|
||||||
|
"HostName": "172.24.12.56",
|
||||||
|
"Port": 5672,
|
||||||
|
"UserName": "guest",
|
||||||
|
"Password": "guest",
|
||||||
|
"VirtualHost": "/",
|
||||||
|
"ExchangeName": "emailprofiler.commands",
|
||||||
|
"QueueName": "emailprofiler.command.queue",
|
||||||
|
"RoutingKey": "command",
|
||||||
|
"AutomaticRecoveryEnabled": true,
|
||||||
|
"NetworkRecoveryIntervalSeconds": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dependency Injection** (`Infrastructure/DependencyInjection.cs`):
|
||||||
|
```csharp
|
||||||
|
services.Configure<RabbitMqConfiguration>(configuration.GetSection(RabbitMqConfiguration.SectionName));
|
||||||
|
services.AddSingleton<ICommandPublisher, RabbitMqCommandPublisher>();
|
||||||
|
services.AddHostedService<RabbitMqCommandConsumer>();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage in API Controllers** (Future):
|
||||||
|
```csharp
|
||||||
|
// Option 1: Synchronous (immediate execution via MediatR)
|
||||||
|
var result = await _mediator.Send(new CreateEmailProfileCommand(...), cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
|
||||||
|
// Option 2: Asynchronous (publish to RabbitMQ for background processing)
|
||||||
|
await _commandPublisher.PublishAsync(new CreateEmailProfileCommand(...), cancellationToken);
|
||||||
|
return Accepted(); // HTTP 202 - command queued for processing
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Decouples API from long-running command processing
|
||||||
|
- Improves API responsiveness (fire-and-forget)
|
||||||
|
- Enables horizontal scaling (multiple consumers)
|
||||||
|
- Automatic retries on failure (requeue mechanism)
|
||||||
|
- Message persistence (survives application restarts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HIGH PRIORITY: Email Queue for Outgoing Messages (Future)
|
||||||
|
|
||||||
**Implementation Steps**:
|
**Implementation Steps**:
|
||||||
|
|
||||||
@@ -25,6 +25,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
|||||||
STATUS.md = STATUS.md
|
STATUS.md = STATUS.md
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EmailProfiler.Common", "legacy\App\EmailProfiler.Common\EmailProfiler.Common.vbproj", "{9F748DCD-952E-40A0-9DAD-65BF8A39B231}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "legacy", "legacy", "{EAFC1552-2C62-4C00-AE27-47D76FEAE9F5}"
|
||||||
|
EndProject
|
||||||
|
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EmailProfiler.Service", "legacy\App\EmailProfiler.Service\EmailProfiler.Service.vbproj", "{1F3C569B-91DA-427F-8D81-BBCC556B11A4}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -51,6 +57,14 @@ Global
|
|||||||
{211FB65F-2406-474E-A426-DA246B250AB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{211FB65F-2406-474E-A426-DA246B250AB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{211FB65F-2406-474E-A426-DA246B250AB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{211FB65F-2406-474E-A426-DA246B250AB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{211FB65F-2406-474E-A426-DA246B250AB8}.Release|Any CPU.Build.0 = Release|Any CPU
|
{211FB65F-2406-474E-A426-DA246B250AB8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{9F748DCD-952E-40A0-9DAD-65BF8A39B231}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{9F748DCD-952E-40A0-9DAD-65BF8A39B231}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{9F748DCD-952E-40A0-9DAD-65BF8A39B231}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{9F748DCD-952E-40A0-9DAD-65BF8A39B231}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{1F3C569B-91DA-427F-8D81-BBCC556B11A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{1F3C569B-91DA-427F-8D81-BBCC556B11A4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{1F3C569B-91DA-427F-8D81-BBCC556B11A4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{1F3C569B-91DA-427F-8D81-BBCC556B11A4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -61,6 +75,8 @@ Global
|
|||||||
{76ADC1D0-4DFA-0B1E-57C9-2636434A0043} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
{76ADC1D0-4DFA-0B1E-57C9-2636434A0043} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||||
{1874A827-C6A5-EB5E-0FE9-30A7200382B7} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
{1874A827-C6A5-EB5E-0FE9-30A7200382B7} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||||
{211FB65F-2406-474E-A426-DA246B250AB8} = {4F20FEFD-9289-42C6-ABA6-8DB236D74559}
|
{211FB65F-2406-474E-A426-DA246B250AB8} = {4F20FEFD-9289-42C6-ABA6-8DB236D74559}
|
||||||
|
{9F748DCD-952E-40A0-9DAD-65BF8A39B231} = {EAFC1552-2C62-4C00-AE27-47D76FEAE9F5}
|
||||||
|
{1F3C569B-91DA-427F-8D81-BBCC556B11A4} = {EAFC1552-2C62-4C00-AE27-47D76FEAE9F5}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}
|
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}
|
||||||
|
|||||||
105
STATUS.md
105
STATUS.md
@@ -1,6 +1,6 @@
|
|||||||
# EmailProfiler - Current Implementation Status
|
# EmailProfiler - Current Implementation Status
|
||||||
|
|
||||||
**Last Updated**: 2026-07-07
|
**Last Updated**: 2026-07-14
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,10 +48,55 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚧 IN PROGRESS (Phase 2: Application Layer - 5%)
|
## ✅ COMPLETED (Phase 2: Application Layer - 100%)
|
||||||
|
|
||||||
### DTOs
|
### DTOs (Common/Dtos/{Entity}/)
|
||||||
- ✅ `CommonDtos.cs` - EmailProfileDto, EmailAccountDto, EmailHistoryDto, EmailAttachmentDto
|
- ✅ `EmailProfiles/EmailProfileDto.cs`
|
||||||
|
- ✅ `EmailAccounts/EmailAccountDto.cs`
|
||||||
|
- ✅ `EmailHistories/EmailHistoryDto.cs`, `CreateEmailHistoryDto.cs`, `UpdateEmailHistoryStatusDto.cs`
|
||||||
|
- ✅ `EmailAttachments/EmailAttachmentDto.cs`, `CreateEmailAttachmentDto.cs`, `UpdateEmailAttachmentStatusDto.cs`
|
||||||
|
|
||||||
|
### Repository Interfaces (Generic Pattern - NO UnitOfWork)
|
||||||
|
- ✅ `IRepository<T>` - Generic repository with CreateAsync<TDto>, UpdateSingleAsync<TDto>, DeleteSingleAsync, UpdateAsync, DeleteAsync
|
||||||
|
|
||||||
|
### Service Interfaces
|
||||||
|
- ✅ `IEmailService.cs` - IMAP/SMTP operations
|
||||||
|
- ✅ `IPdfProcessingService.cs` - PDF validation and extraction
|
||||||
|
- ✅ `IDmsService.cs` - windream DMS integration
|
||||||
|
- ✅ `IEncryptionService.cs` - Password encryption
|
||||||
|
- ✅ `IEmailQueue.cs` - Email queue operations
|
||||||
|
- ✅ `ICommandPublisher.cs` - RabbitMQ command publishing
|
||||||
|
|
||||||
|
### MediatR Commands (Features/*/Commands/)
|
||||||
|
- ✅ `CreateEmailProfileCommand.cs` + Handler
|
||||||
|
- ✅ `UpdateEmailProfileCommand.cs` + Handler
|
||||||
|
- ✅ `DeleteEmailProfileCommand.cs` + Handler
|
||||||
|
- ✅ `CreateEmailAccountCommand.cs` + Handler
|
||||||
|
- ✅ `ProcessEmailCommand.cs` + Handler
|
||||||
|
|
||||||
|
### MediatR Queries (Features/*/Queries/)
|
||||||
|
- ✅ `GetEmailProfilesQuery.cs` + Handler
|
||||||
|
- ✅ `GetEmailProfileByIdQuery.cs` + Handler
|
||||||
|
- ✅ `GetActiveEmailProfilesQuery.cs` + Handler
|
||||||
|
- ✅ `GetEmailAccountsQuery.cs` + Handler
|
||||||
|
- ✅ `GetEmailAccountByIdQuery.cs` + Handler
|
||||||
|
- ✅ `GetEmailHistoryByProfileQuery.cs` + Handler (with pagination)
|
||||||
|
- ✅ `GetEmailHistoryByIdQuery.cs` + Handler
|
||||||
|
|
||||||
|
### Validators (Features/*/Validators/)
|
||||||
|
- ✅ `CreateEmailProfileCommandValidator.cs`
|
||||||
|
- ✅ `UpdateEmailProfileCommandValidator.cs`
|
||||||
|
- ✅ `CreateEmailAccountCommandValidator.cs` (conditional OAuth2/password validation)
|
||||||
|
- ✅ `ProcessEmailCommandValidator.cs` (with attachment validation)
|
||||||
|
|
||||||
|
### AutoMapper Profiles (Common/Mappings/)
|
||||||
|
- ✅ `EmailProfileMappingProfile.cs` (Command→Entity, DTO→Entity, Entity→DTO)
|
||||||
|
- ✅ `EmailAccountMappingProfile.cs`
|
||||||
|
- ✅ `EmailHistoryMappingProfile.cs`
|
||||||
|
- ✅ `EmailAttachmentMappingProfile.cs`
|
||||||
|
|
||||||
|
### DI Configuration
|
||||||
|
- ✅ `DependencyInjection.cs` - Registers MediatR, AutoMapper, FluentValidation
|
||||||
|
|
||||||
### NuGet Packages
|
### NuGet Packages
|
||||||
- ✅ Application project has:
|
- ✅ Application project has:
|
||||||
@@ -61,51 +106,23 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ❌ TODO (Phase 2: Application Layer - 95%)
|
## 🚧 IN PROGRESS (Phase 3: Infrastructure Layer - 15%)
|
||||||
|
|
||||||
### Repository Interfaces
|
### RabbitMQ Command Bus (COMPLETED)
|
||||||
- ❌ `IEmailProfileRepository.cs`
|
- ✅ `RabbitMqConfiguration.cs` - Configuration model
|
||||||
- ❌ `IEmailAccountRepository.cs`
|
- ✅ `RabbitMqCommandPublisher.cs` - ICommandPublisher implementation
|
||||||
- ❌ `IEmailHistoryRepository.cs`
|
- ✅ `RabbitMqCommandConsumer.cs` - BackgroundService for command consumption
|
||||||
- ❌ `IEmailProcessRepository.cs`
|
- ✅ `DependencyInjection.cs` - Infrastructure DI with RabbitMQ registration
|
||||||
- ❌ `IEmailOutboxRepository.cs`
|
- ✅ Configuration in `appsettings.json` (Server: 172.24.12.56:5672)
|
||||||
|
|
||||||
### Service Interfaces
|
### NuGet Packages (Partial)
|
||||||
- ❌ `IEmailService.cs`
|
- ✅ RabbitMQ.Client 7.2.1
|
||||||
- ❌ `IPdfProcessingService.cs`
|
- ✅ Microsoft.Extensions.Hosting 10.0.9
|
||||||
- ❌ `IDmsService.cs`
|
- ✅ Microsoft.Extensions.Options.ConfigurationExtensions 10.0.9
|
||||||
- ❌ `IEncryptionService.cs`
|
|
||||||
- ❌ `IEmailQueue.cs`
|
|
||||||
|
|
||||||
### MediatR Commands
|
|
||||||
- ❌ `CreateEmailProfileCommand.cs`
|
|
||||||
- ❌ `UpdateEmailProfileCommand.cs`
|
|
||||||
- ❌ `DeleteEmailProfileCommand.cs`
|
|
||||||
- ❌ `ActivateProfileCommand.cs`
|
|
||||||
- ❌ `DeactivateProfileCommand.cs`
|
|
||||||
- ❌ Similar commands for EmailAccount, EmailProcess, etc.
|
|
||||||
|
|
||||||
### MediatR Queries
|
|
||||||
- ❌ `GetEmailProfilesQuery.cs`
|
|
||||||
- ❌ `GetEmailProfileByIdQuery.cs`
|
|
||||||
- ❌ `GetActiveProfilesQuery.cs`
|
|
||||||
- ❌ `GetProfilesDueForPollingQuery.cs`
|
|
||||||
- ❌ Similar queries for other entities
|
|
||||||
|
|
||||||
### Validators
|
|
||||||
- ❌ `CreateEmailProfileCommandValidator.cs`
|
|
||||||
- ❌ `UpdateEmailProfileCommandValidator.cs`
|
|
||||||
- ❌ Similar validators for all commands
|
|
||||||
|
|
||||||
### Mappings
|
|
||||||
- ❌ `MappingProfile.cs` - AutoMapper configuration
|
|
||||||
|
|
||||||
### DI Configuration
|
|
||||||
- ❌ `DependencyInjection.cs` - Application layer DI setup
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ❌ TODO (Phase 3: Infrastructure Layer - 0%)
|
## ❌ TODO (Phase 3: Infrastructure Layer - 85%)
|
||||||
|
|
||||||
### NuGet Packages
|
### NuGet Packages
|
||||||
- ❌ Microsoft.EntityFrameworkCore.SqlServer
|
- ❌ Microsoft.EntityFrameworkCore.SqlServer
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos;
|
||||||
|
using DigitalData.EmailProfiler.Application.Common.Interfaces;
|
||||||
|
using DigitalData.EmailProfiler.Application.EmailAccounts.Commands;
|
||||||
|
using DigitalData.EmailProfiler.Application.EmailAccounts.Queries;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email accounts management API controller
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class EmailAccountsController(
|
||||||
|
IMediator mediator,
|
||||||
|
ICommandPublisher commandPublisher) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get all email accounts
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(IEnumerable<EmailAccountDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> GetAll(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = new GetEmailAccountsQuery();
|
||||||
|
var result = await mediator.Send(query, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get email account by ID
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(EmailAccountDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = new GetEmailAccountByIdQuery(id);
|
||||||
|
var result = await mediator.Send(query, cancellationToken);
|
||||||
|
|
||||||
|
if (result == null)
|
||||||
|
return NotFound(new { Message = $"Email account with ID {id} not found" });
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create new email account (async via RabbitMQ)
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> Create(
|
||||||
|
[FromBody] CreateEmailAccountCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Publish command to RabbitMQ for async processing
|
||||||
|
await commandPublisher.PublishAsync(command, cancellationToken);
|
||||||
|
|
||||||
|
return Accepted(new
|
||||||
|
{
|
||||||
|
Message = "Email account creation request queued for processing",
|
||||||
|
AccountName = command.AccountName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Update and Delete operations can be added similarly
|
||||||
|
// For now, we focus on Create as the main use case
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos.EmailHistories;
|
||||||
|
using DigitalData.EmailProfiler.Application.EmailHistories.Queries;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email history API controller (read-only)
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class EmailHistoryController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get email history by profile ID with pagination
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("profile/{profileId:int}")]
|
||||||
|
[ProducesResponseType(typeof(IEnumerable<EmailHistoryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> GetByProfile(
|
||||||
|
int profileId,
|
||||||
|
[FromQuery] int pageNumber = 1,
|
||||||
|
[FromQuery] int pageSize = 50,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = new GetEmailHistoryByProfileQuery(profileId, pageNumber, pageSize);
|
||||||
|
var result = await mediator.Send(query, cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
ProfileId = profileId,
|
||||||
|
PageNumber = pageNumber,
|
||||||
|
PageSize = pageSize,
|
||||||
|
Data = result
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get email history by ID
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(EmailHistoryDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = new GetEmailHistoryByIdQuery(id);
|
||||||
|
var result = await mediator.Send(query, cancellationToken);
|
||||||
|
|
||||||
|
if (result == null)
|
||||||
|
return NotFound(new { Message = $"Email history with ID {id} not found" });
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Email history is typically managed by ProcessEmailCommand
|
||||||
|
// No direct CREATE/UPDATE/DELETE endpoints needed
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos;
|
||||||
|
using DigitalData.EmailProfiler.Application.Common.Interfaces;
|
||||||
|
using DigitalData.EmailProfiler.Application.EmailProfiles.Commands;
|
||||||
|
using DigitalData.EmailProfiler.Application.EmailProfiles.Queries;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email profiles management API controller
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class EmailProfilesController(
|
||||||
|
IMediator mediator,
|
||||||
|
ICommandPublisher commandPublisher) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get all email profiles
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(IEnumerable<EmailProfileDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> GetAll(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = new GetEmailProfilesQuery();
|
||||||
|
var result = await mediator.Send(query, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get email profile by ID
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(EmailProfileDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = new GetEmailProfileByIdQuery(id);
|
||||||
|
var result = await mediator.Send(query, cancellationToken);
|
||||||
|
|
||||||
|
if (result == null)
|
||||||
|
return NotFound(new { Message = $"Email profile with ID {id} not found" });
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all active email profiles
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("active")]
|
||||||
|
[ProducesResponseType(typeof(IEnumerable<EmailProfileDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> GetActive(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = new GetActiveEmailProfilesQuery();
|
||||||
|
var result = await mediator.Send(query, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create new email profile (async via RabbitMQ)
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> Create(
|
||||||
|
[FromBody] CreateEmailProfileCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Publish command to RabbitMQ for async processing
|
||||||
|
await commandPublisher.PublishAsync(command, cancellationToken);
|
||||||
|
|
||||||
|
return Accepted(new
|
||||||
|
{
|
||||||
|
Message = "Email profile creation request queued for processing",
|
||||||
|
ProfileName = command.ProfileName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update email profile (async via RabbitMQ)
|
||||||
|
/// </summary>
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> Update(
|
||||||
|
int id,
|
||||||
|
[FromBody] UpdateEmailProfileCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Ensure ID matches route
|
||||||
|
if (id != command.Id)
|
||||||
|
return BadRequest(new { Message = "Route ID does not match command ID" });
|
||||||
|
|
||||||
|
// Publish command to RabbitMQ for async processing
|
||||||
|
await commandPublisher.PublishAsync(command, cancellationToken);
|
||||||
|
|
||||||
|
return Accepted(new
|
||||||
|
{
|
||||||
|
Message = "Email profile update request queued for processing",
|
||||||
|
Id = command.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete email profile (async via RabbitMQ)
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||||
|
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var command = new DeleteEmailProfileCommand(id);
|
||||||
|
|
||||||
|
// Publish command to RabbitMQ for async processing
|
||||||
|
await commandPublisher.PublishAsync(command, cancellationToken);
|
||||||
|
|
||||||
|
return Accepted(new
|
||||||
|
{
|
||||||
|
Message = "Email profile deletion request queued for processing",
|
||||||
|
Id = id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
using DigitalData.EmailProfiler.API;
|
using DigitalData.EmailProfiler.API;
|
||||||
|
using DigitalData.EmailProfiler.Application;
|
||||||
|
using DigitalData.EmailProfiler.Infrastructure;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Add appsettings.Secrets.json for sensitive configuration (not committed to git)
|
||||||
|
builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true);
|
||||||
|
|
||||||
|
// Register Application layer (MediatR, AutoMapper, FluentValidation)
|
||||||
|
builder.Services.AddApplicationServices();
|
||||||
|
|
||||||
|
// Register Infrastructure layer (RabbitMQ, Repositories, etc.)
|
||||||
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
builder.Services.AddHostedService<Worker>();
|
builder.Services.AddHostedService<Worker>();
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
namespace DigitalData.EmailProfiler.Application.Common.Dtos;
|
|
||||||
|
|
||||||
public class EmailProfileDto
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
public string ProfileName { get; set; } = string.Empty;
|
|
||||||
public int EmailAccountId { get; set; }
|
|
||||||
public string? EmailAccountName { get; set; }
|
|
||||||
public int? ProcessId { get; set; }
|
|
||||||
public string? ProcessName { get; set; }
|
|
||||||
public int PollIntervalMinutes { get; set; }
|
|
||||||
public DateTime? LastPollTime { get; set; }
|
|
||||||
public bool IsActive { get; set; }
|
|
||||||
public string? Comment { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class EmailAccountDto
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
public string AccountName { get; set; } = string.Empty;
|
|
||||||
public string ImapServer { get; set; } = string.Empty;
|
|
||||||
public int ImapPort { get; set; }
|
|
||||||
public string SmtpServer { get; set; } = string.Empty;
|
|
||||||
public int SmtpPort { get; set; }
|
|
||||||
public string Username { get; set; } = string.Empty;
|
|
||||||
public bool UseOAuth2 { get; set; }
|
|
||||||
public bool IsActive { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class EmailHistoryDto
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
public int? ProfileId { get; set; }
|
|
||||||
public string? ProfileName { get; set; }
|
|
||||||
public string? SenderAddress { get; set; }
|
|
||||||
public string? Subject { get; set; }
|
|
||||||
public DateTime? EmailDate { get; set; }
|
|
||||||
public DateTime? ProcessedDate { get; set; }
|
|
||||||
public string? Status { get; set; }
|
|
||||||
public int? ErrorCodeValue { get; set; }
|
|
||||||
public string? ErrorMessage { get; set; }
|
|
||||||
public List<EmailAttachmentDto> Attachments { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class EmailAttachmentDto
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
public string OriginalFileName { get; set; } = string.Empty;
|
|
||||||
public string FilePath { get; set; } = string.Empty;
|
|
||||||
public long FileSize { get; set; }
|
|
||||||
public bool IsEmbeddedFile { get; set; }
|
|
||||||
public string? Status { get; set; }
|
|
||||||
public string? ValidationErrorMessage { get; set; }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace DigitalData.EmailProfiler.Application.Common.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for EmailAccount query results.
|
||||||
|
/// </summary>
|
||||||
|
public class EmailAccountDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string AccountName { get; set; } = string.Empty;
|
||||||
|
public string ImapServer { get; set; } = string.Empty;
|
||||||
|
public int ImapPort { get; set; }
|
||||||
|
public string SmtpServer { get; set; } = string.Empty;
|
||||||
|
public int SmtpPort { get; set; }
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public bool UseOAuth2 { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace DigitalData.EmailProfiler.Application.Common.Dtos.EmailAttachments;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for creating EmailAttachment record.
|
||||||
|
/// AutoMapper profile required: CreateEmailAttachmentDto -> EmailAttachment
|
||||||
|
/// </summary>
|
||||||
|
public record CreateEmailAttachmentDto(
|
||||||
|
int EmailHistoryId,
|
||||||
|
string OriginalFileName,
|
||||||
|
string SavedFileName,
|
||||||
|
string FilePath,
|
||||||
|
long FileSize,
|
||||||
|
string Extension,
|
||||||
|
string ContentType,
|
||||||
|
byte[] Content);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace DigitalData.EmailProfiler.Application.Common.Dtos.EmailAttachments;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for EmailAttachment query results.
|
||||||
|
/// </summary>
|
||||||
|
public class EmailAttachmentDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string OriginalFileName { get; set; } = string.Empty;
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
public bool IsEmbeddedFile { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public string? ValidationErrorMessage { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace DigitalData.EmailProfiler.Application.Common.Dtos.EmailAttachments;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for updating EmailAttachment status.
|
||||||
|
/// AutoMapper profile required: UpdateEmailAttachmentStatusDto -> EmailAttachment
|
||||||
|
/// </summary>
|
||||||
|
public record UpdateEmailAttachmentStatusDto(
|
||||||
|
string Status,
|
||||||
|
int? ValidationErrorCode = null,
|
||||||
|
string? ValidationErrorMessage = null);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace DigitalData.EmailProfiler.Application.Common.Dtos.EmailHistories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for creating EmailHistory record.
|
||||||
|
/// AutoMapper profile required: CreateEmailHistoryDto -> EmailHistory
|
||||||
|
/// </summary>
|
||||||
|
public record CreateEmailHistoryDto(
|
||||||
|
int ProfileId,
|
||||||
|
string MessageIdHash,
|
||||||
|
string OriginalMessageId,
|
||||||
|
string SenderAddress,
|
||||||
|
DateTime EmailDate,
|
||||||
|
string Subject,
|
||||||
|
string? EmailBodyText,
|
||||||
|
string? EmailBodyHtml);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos.EmailAttachments;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.Common.Dtos.EmailHistories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for EmailHistory query results.
|
||||||
|
/// </summary>
|
||||||
|
public class EmailHistoryDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int? ProfileId { get; set; }
|
||||||
|
public string? ProfileName { get; set; }
|
||||||
|
public string? SenderAddress { get; set; }
|
||||||
|
public string? Subject { get; set; }
|
||||||
|
public DateTime? EmailDate { get; set; }
|
||||||
|
public DateTime? ProcessedDate { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public int? ErrorCodeValue { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public List<EmailAttachmentDto> Attachments { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace DigitalData.EmailProfiler.Application.Common.Dtos.EmailHistories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for updating EmailHistory status.
|
||||||
|
/// AutoMapper profile required: UpdateEmailHistoryStatusDto -> EmailHistory
|
||||||
|
/// </summary>
|
||||||
|
public record UpdateEmailHistoryStatusDto(
|
||||||
|
string Status,
|
||||||
|
DateTime? ProcessedDate = null,
|
||||||
|
int? ErrorCodeValue = null,
|
||||||
|
string? ErrorMessage = null);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace DigitalData.EmailProfiler.Application.Common.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for EmailProfile query results.
|
||||||
|
/// </summary>
|
||||||
|
public class EmailProfileDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string ProfileName { get; set; } = string.Empty;
|
||||||
|
public int EmailAccountId { get; set; }
|
||||||
|
public string? EmailAccountName { get; set; }
|
||||||
|
public int? ProcessId { get; set; }
|
||||||
|
public string? ProcessName { get; set; }
|
||||||
|
public int PollIntervalMinutes { get; set; }
|
||||||
|
public DateTime? LastPollTime { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public string? Comment { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for publishing commands to a message broker (e.g., RabbitMQ)
|
||||||
|
/// </summary>
|
||||||
|
public interface ICommandPublisher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes a command to the message broker for asynchronous processing
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TCommand">The command type (must implement IBaseRequest - covers both IRequest and IRequest<T>)</typeparam>
|
||||||
|
/// <param name="command">The command to publish</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
/// <returns>Task representing the publish operation</returns>
|
||||||
|
Task PublishAsync<TCommand>(TCommand command, CancellationToken cancellationToken = default)
|
||||||
|
where TCommand : IBaseRequest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AutoMapper profile for EmailAccount entity mappings.
|
||||||
|
/// </summary>
|
||||||
|
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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos.EmailAttachments;
|
||||||
|
using DigitalData.EmailProfiler.Domain.Entities;
|
||||||
|
using DigitalData.EmailProfiler.Domain.Enums;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.Common.Mappings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AutoMapper profile for EmailAttachment entity mappings.
|
||||||
|
/// </summary>
|
||||||
|
public class EmailAttachmentMappingProfile : Profile
|
||||||
|
{
|
||||||
|
public EmailAttachmentMappingProfile()
|
||||||
|
{
|
||||||
|
// DTO -> Entity (for Create)
|
||||||
|
CreateMap<CreateEmailAttachmentDto, EmailAttachment>()
|
||||||
|
.ForMember(dest => dest.Id, opt => opt.Ignore()) // Auto-generated
|
||||||
|
.ForMember(dest => dest.Status, opt => opt.MapFrom(_ => AttachmentStatus.Pending.ToString()))
|
||||||
|
.ForMember(dest => dest.AddedWhen, opt => opt.MapFrom(_ => DateTime.Now))
|
||||||
|
.ForMember(dest => dest.IsEmbeddedFile, opt => opt.MapFrom(_ => false))
|
||||||
|
.ForMember(dest => dest.ParentAttachmentId, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.AttachmentPosition, opt => opt.MapFrom(_ => 0))
|
||||||
|
.ForMember(dest => dest.ValidationErrorCode, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.ValidationErrorMessage, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Comment, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailHistory, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.ParentAttachment, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmbeddedFiles, opt => opt.Ignore());
|
||||||
|
|
||||||
|
// DTO -> Entity (for Update Status)
|
||||||
|
CreateMap<UpdateEmailAttachmentStatusDto, EmailAttachment>()
|
||||||
|
.ForMember(dest => dest.Id, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailHistoryId, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.OriginalFileName, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.SavedFileName, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.FilePath, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.FileSize, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Extension, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.IsEmbeddedFile, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.ParentAttachmentId, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.AttachmentPosition, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Comment, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.AddedWhen, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailHistory, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.ParentAttachment, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmbeddedFiles, opt => opt.Ignore());
|
||||||
|
|
||||||
|
// Entity -> DTO (for Queries)
|
||||||
|
CreateMap<EmailAttachment, EmailAttachmentDto>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos.EmailHistories;
|
||||||
|
using DigitalData.EmailProfiler.Domain.Entities;
|
||||||
|
using DigitalData.EmailProfiler.Domain.Enums;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.Common.Mappings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AutoMapper profile for EmailHistory entity mappings.
|
||||||
|
/// </summary>
|
||||||
|
public class EmailHistoryMappingProfile : Profile
|
||||||
|
{
|
||||||
|
public EmailHistoryMappingProfile()
|
||||||
|
{
|
||||||
|
// DTO -> Entity (for Create)
|
||||||
|
CreateMap<CreateEmailHistoryDto, EmailHistory>()
|
||||||
|
.ForMember(dest => dest.Id, opt => opt.Ignore()) // Auto-generated
|
||||||
|
.ForMember(dest => dest.EmailMessageId, opt => opt.Ignore()) // Auto-generated
|
||||||
|
.ForMember(dest => dest.Status, opt => opt.MapFrom(_ => EmailStatus.Processing.ToString()))
|
||||||
|
.ForMember(dest => dest.AddedWhen, opt => opt.MapFrom(_ => DateTime.Now))
|
||||||
|
.ForMember(dest => dest.ProcessedDate, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.ErrorCodeValue, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.ErrorMessage, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.WindreamDocumentId, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Comment, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.WorkProcess, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.ImapUid, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailSubstring1, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailSubstring2, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Profile, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Attachments, opt => opt.Ignore());
|
||||||
|
|
||||||
|
// DTO -> Entity (for Update Status)
|
||||||
|
CreateMap<UpdateEmailHistoryStatusDto, EmailHistory>()
|
||||||
|
.ForMember(dest => dest.Id, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.ProfileId, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.MessageIdHash, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.OriginalMessageId, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.SenderAddress, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailDate, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Subject, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailBodyText, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailBodyHtml, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailMessageId, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.AddedWhen, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.WindreamDocumentId, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Comment, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.WorkProcess, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.ImapUid, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailSubstring1, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailSubstring2, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Profile, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Attachments, opt => opt.Ignore());
|
||||||
|
|
||||||
|
// Entity -> DTO (for Queries)
|
||||||
|
CreateMap<EmailHistory, EmailHistoryDto>()
|
||||||
|
.ForMember(dest => dest.ProfileName, opt => opt.MapFrom(src => src.Profile != null ? src.Profile.ProfileName : null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AutoMapper profile for EmailProfile entity mappings.
|
||||||
|
/// </summary>
|
||||||
|
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))
|
||||||
|
.ForMember(dest => dest.ProcessName, opt => opt.MapFrom(src => src.EmailProcess != null ? src.EmailProcess.ProcessName : null))
|
||||||
|
.ForMember(dest => dest.LastPollTime, opt => opt.MapFrom(src => src.LastPollTime));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dependency injection configuration for Application layer.
|
||||||
|
/// </summary>
|
||||||
|
public static class DependencyInjection
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
|
||||||
|
// MediatR - Register all handlers
|
||||||
|
services.AddMediatR(config =>
|
||||||
|
{
|
||||||
|
config.RegisterServicesFromAssembly(assembly);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AutoMapper - Register all profiles
|
||||||
|
services.AddAutoMapper(assembly);
|
||||||
|
|
||||||
|
// FluentValidation - Register all validators
|
||||||
|
services.AddValidatorsFromAssembly(assembly);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
||||||
|
using DigitalData.EmailProfiler.Domain.Entities;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace DigitalData.EmailProfiler.Application.Features.EmailAccounts.Commands;
|
namespace DigitalData.EmailProfiler.Application.EmailAccounts.Commands;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Command to create a new email account.
|
/// Command to create a new email account.
|
||||||
@@ -22,3 +24,13 @@ public record CreateEmailAccountCommand : IRequest<int>
|
|||||||
public string? EncryptedClientSecret { get; init; } // Already encrypted by client
|
public string? EncryptedClientSecret { get; init; } // Already encrypted by client
|
||||||
public bool IsActive { get; init; } = true;
|
public bool IsActive { get; init; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class CreateEmailAccountCommandHandler(IRepository<EmailAccount> repository)
|
||||||
|
: IRequestHandler<CreateEmailAccountCommand, int>
|
||||||
|
{
|
||||||
|
public async Task<int> Handle(CreateEmailAccountCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var account = await repository.CreateAsync(request, cancellationToken);
|
||||||
|
return account.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos;
|
||||||
|
using DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
||||||
|
using DigitalData.EmailProfiler.Domain.Entities;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.EmailAccounts.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query to get email account by ID.
|
||||||
|
/// </summary>
|
||||||
|
public record GetEmailAccountByIdQuery(int Id) : IRequest<EmailAccountDto?>;
|
||||||
|
|
||||||
|
public class GetEmailAccountByIdQueryHandler(
|
||||||
|
IRepository<EmailAccount> repository,
|
||||||
|
IMapper mapper)
|
||||||
|
: IRequestHandler<GetEmailAccountByIdQuery, EmailAccountDto?>
|
||||||
|
{
|
||||||
|
public async Task<EmailAccountDto?> Handle(
|
||||||
|
GetEmailAccountByIdQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var account = await repository.GetByIdAsync(request.Id, cancellationToken);
|
||||||
|
return account != null ? mapper.Map<EmailAccountDto>(account) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos;
|
||||||
|
using DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
||||||
|
using DigitalData.EmailProfiler.Domain.Entities;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.EmailAccounts.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query to get all email accounts.
|
||||||
|
/// </summary>
|
||||||
|
public record GetEmailAccountsQuery : IRequest<IEnumerable<EmailAccountDto>>;
|
||||||
|
|
||||||
|
public class GetEmailAccountsQueryHandler(
|
||||||
|
IRepository<EmailAccount> repository,
|
||||||
|
IMapper mapper)
|
||||||
|
: IRequestHandler<GetEmailAccountsQuery, IEnumerable<EmailAccountDto>>
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<EmailAccountDto>> Handle(
|
||||||
|
GetEmailAccountsQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var accounts = await repository.GetAllAsync(cancellationToken);
|
||||||
|
return mapper.Map<IEnumerable<EmailAccountDto>>(accounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.EmailAccounts.Commands;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.EmailAccounts.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validator for CreateEmailAccountCommand.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateEmailAccountCommandValidator : AbstractValidator<CreateEmailAccountCommand>
|
||||||
|
{
|
||||||
|
public CreateEmailAccountCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.AccountName)
|
||||||
|
.NotEmpty().WithMessage("Account name is required")
|
||||||
|
.MaximumLength(100).WithMessage("Account name must not exceed 100 characters");
|
||||||
|
|
||||||
|
RuleFor(x => x.Username)
|
||||||
|
.NotEmpty().WithMessage("Username is required")
|
||||||
|
.MaximumLength(200).WithMessage("Username must not exceed 200 characters")
|
||||||
|
.EmailAddress().WithMessage("Username must be a valid email address");
|
||||||
|
|
||||||
|
RuleFor(x => x.ImapServer)
|
||||||
|
.NotEmpty().WithMessage("IMAP server is required")
|
||||||
|
.MaximumLength(200).WithMessage("IMAP server must not exceed 200 characters");
|
||||||
|
|
||||||
|
RuleFor(x => x.ImapPort)
|
||||||
|
.GreaterThan(0).WithMessage("IMAP port must be greater than 0")
|
||||||
|
.LessThanOrEqualTo(65535).WithMessage("IMAP port must not exceed 65535");
|
||||||
|
|
||||||
|
RuleFor(x => x.SmtpServer)
|
||||||
|
.NotEmpty().WithMessage("SMTP server is required")
|
||||||
|
.MaximumLength(200).WithMessage("SMTP server must not exceed 200 characters");
|
||||||
|
|
||||||
|
RuleFor(x => x.SmtpPort)
|
||||||
|
.GreaterThan(0).WithMessage("SMTP port must be greater than 0")
|
||||||
|
.LessThanOrEqualTo(65535).WithMessage("SMTP port must not exceed 65535");
|
||||||
|
|
||||||
|
// OAuth2 validation
|
||||||
|
RuleFor(x => x.TenantId)
|
||||||
|
.NotEmpty().WithMessage("Tenant ID is required for OAuth2")
|
||||||
|
.When(x => x.UseOAuth2);
|
||||||
|
|
||||||
|
RuleFor(x => x.ClientId)
|
||||||
|
.NotEmpty().WithMessage("Client ID is required for OAuth2")
|
||||||
|
.When(x => x.UseOAuth2);
|
||||||
|
|
||||||
|
RuleFor(x => x.EncryptedClientSecret)
|
||||||
|
.NotEmpty().WithMessage("Client secret is required for OAuth2")
|
||||||
|
.When(x => x.UseOAuth2);
|
||||||
|
|
||||||
|
// Password validation (non-OAuth2)
|
||||||
|
RuleFor(x => x.EncryptedPassword)
|
||||||
|
.NotEmpty().WithMessage("Password is required")
|
||||||
|
.When(x => !x.UseOAuth2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos.EmailHistories;
|
||||||
|
using DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.EmailHistories.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query to get email history by ID with attachments.
|
||||||
|
/// </summary>
|
||||||
|
public record GetEmailHistoryByIdQuery(int Id) : IRequest<EmailHistoryDto?>;
|
||||||
|
|
||||||
|
public class GetEmailHistoryByIdQueryHandler(
|
||||||
|
IEmailHistoryRepository repository,
|
||||||
|
IMapper mapper)
|
||||||
|
: IRequestHandler<GetEmailHistoryByIdQuery, EmailHistoryDto?>
|
||||||
|
{
|
||||||
|
public async Task<EmailHistoryDto?> Handle(
|
||||||
|
GetEmailHistoryByIdQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var history = await repository.GetWithAttachmentsAsync(request.Id, cancellationToken);
|
||||||
|
return history != null ? mapper.Map<EmailHistoryDto>(history) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos.EmailHistories;
|
||||||
|
using DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.EmailHistories.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query to get email history by profile with pagination.
|
||||||
|
/// </summary>
|
||||||
|
public record GetEmailHistoryByProfileQuery(
|
||||||
|
int ProfileId,
|
||||||
|
int PageNumber = 1,
|
||||||
|
int PageSize = 50) : IRequest<EmailHistoryPagedResult>;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paged result for email history.
|
||||||
|
/// </summary>
|
||||||
|
public record EmailHistoryPagedResult(
|
||||||
|
IEnumerable<EmailHistoryDto> Items,
|
||||||
|
int TotalCount,
|
||||||
|
int PageNumber,
|
||||||
|
int PageSize);
|
||||||
|
|
||||||
|
public class GetEmailHistoryByProfileQueryHandler(
|
||||||
|
IEmailHistoryRepository repository,
|
||||||
|
IMapper mapper)
|
||||||
|
: IRequestHandler<GetEmailHistoryByProfileQuery, EmailHistoryPagedResult>
|
||||||
|
{
|
||||||
|
public async Task<EmailHistoryPagedResult> Handle(
|
||||||
|
GetEmailHistoryByProfileQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (items, totalCount) = await repository.GetByProfileIdAsync(
|
||||||
|
request.ProfileId,
|
||||||
|
request.PageNumber,
|
||||||
|
request.PageSize,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var dtos = mapper.Map<IEnumerable<EmailHistoryDto>>(items);
|
||||||
|
|
||||||
|
return new EmailHistoryPagedResult(
|
||||||
|
dtos,
|
||||||
|
totalCount,
|
||||||
|
request.PageNumber,
|
||||||
|
request.PageSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos.EmailAttachments;
|
||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos.EmailHistories;
|
||||||
|
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.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);
|
||||||
|
|
||||||
|
public class ProcessEmailCommandHandler(
|
||||||
|
IEmailProfileRepository profileRepository,
|
||||||
|
IEmailHistoryRepository historyRepository,
|
||||||
|
IRepository<EmailAttachment> attachmentRepository,
|
||||||
|
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 profileRepository.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 historyRepository.IsDuplicateAsync(messageId.Hash, cancellationToken);
|
||||||
|
if (isDuplicate)
|
||||||
|
{
|
||||||
|
throw new ValidationException("Email already processed (duplicate detected)", ErrorCode.DuplicateMessageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create email history record using DTO approach
|
||||||
|
var historyDto = new CreateEmailHistoryDto(
|
||||||
|
ProfileId: profile.Id,
|
||||||
|
MessageIdHash: messageId.Hash,
|
||||||
|
OriginalMessageId: request.MessageId,
|
||||||
|
SenderAddress: request.Sender,
|
||||||
|
EmailDate: request.ReceivedDate,
|
||||||
|
Subject: request.Subject,
|
||||||
|
EmailBodyText: request.BodyText,
|
||||||
|
EmailBodyHtml: request.BodyHtml);
|
||||||
|
|
||||||
|
var createdHistory = await historyRepository.CreateAsync(historyDto, cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 5. Process attachments
|
||||||
|
foreach (var attachmentData in request.Attachments)
|
||||||
|
{
|
||||||
|
var attachmentDto = new CreateEmailAttachmentDto(
|
||||||
|
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),
|
||||||
|
ContentType: attachmentData.ContentType,
|
||||||
|
Content: attachmentData.Content);
|
||||||
|
|
||||||
|
var attachment = await attachmentRepository.CreateAsync(attachmentDto, cancellationToken);
|
||||||
|
|
||||||
|
// Validate PDF attachments - need to retrieve and update the entity
|
||||||
|
if (attachmentData.ContentType.Contains("pdf", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(attachmentData.Content);
|
||||||
|
var isValidPdf = await pdfService.IsValidPdfAsync(stream, cancellationToken);
|
||||||
|
|
||||||
|
// For domain methods like MarkAsValid/MarkAsCorrupt, we need to get the entity
|
||||||
|
var attachmentEntity = await attachmentRepository.GetByIdAsync(attachment.Id, cancellationToken);
|
||||||
|
if (attachmentEntity != null)
|
||||||
|
{
|
||||||
|
if (isValidPdf)
|
||||||
|
{
|
||||||
|
attachmentEntity.MarkAsValid();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
attachmentEntity.MarkAsCorrupt(ErrorCode.PdfStructureInvalid, "Invalid PDF structure");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update directly using UpdateSingleAsync for safety
|
||||||
|
await attachmentRepository.UpdateSingleAsync(
|
||||||
|
a => a.Id == attachmentEntity.Id,
|
||||||
|
new UpdateEmailAttachmentStatusDto(
|
||||||
|
Status: attachmentEntity.Status ?? string.Empty,
|
||||||
|
ValidationErrorCode: attachmentEntity.ValidationErrorCode,
|
||||||
|
ValidationErrorMessage: attachmentEntity.ValidationErrorMessage),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 - retrieve entity for domain method
|
||||||
|
var historyEntity = await historyRepository.GetByIdAsync(createdHistory.Id, cancellationToken);
|
||||||
|
if (historyEntity != null)
|
||||||
|
{
|
||||||
|
historyEntity.MarkAsProcessed();
|
||||||
|
await historyRepository.UpdateSingleAsync(
|
||||||
|
h => h.Id == historyEntity.Id,
|
||||||
|
new UpdateEmailHistoryStatusDto(
|
||||||
|
Status: historyEntity.Status ?? string.Empty,
|
||||||
|
ProcessedDate: historyEntity.ProcessedDate),
|
||||||
|
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 - retrieve entity for domain method
|
||||||
|
var historyEntity = await historyRepository.GetByIdAsync(createdHistory.Id, cancellationToken);
|
||||||
|
if (historyEntity != null)
|
||||||
|
{
|
||||||
|
historyEntity.MarkAsFailed(ErrorCode.AttachmentExtractionFailed, ex.Message);
|
||||||
|
await historyRepository.UpdateSingleAsync(
|
||||||
|
h => h.Id == historyEntity.Id,
|
||||||
|
new UpdateEmailHistoryStatusDto(
|
||||||
|
Status: historyEntity.Status ?? string.Empty,
|
||||||
|
ErrorCodeValue: historyEntity.ErrorCodeValue,
|
||||||
|
ErrorMessage: historyEntity.ErrorMessage),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.EmailProcessing.Commands;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.EmailProcessing.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validator for ProcessEmailCommand.
|
||||||
|
/// </summary>
|
||||||
|
public class ProcessEmailCommandValidator : AbstractValidator<ProcessEmailCommand>
|
||||||
|
{
|
||||||
|
public ProcessEmailCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.ProfileId)
|
||||||
|
.GreaterThan(0).WithMessage("Profile ID must be greater than 0");
|
||||||
|
|
||||||
|
RuleFor(x => x.MessageId)
|
||||||
|
.NotEmpty().WithMessage("Message ID is required")
|
||||||
|
.MaximumLength(500).WithMessage("Message ID must not exceed 500 characters");
|
||||||
|
|
||||||
|
RuleFor(x => x.Sender)
|
||||||
|
.NotEmpty().WithMessage("Sender is required")
|
||||||
|
.MaximumLength(200).WithMessage("Sender must not exceed 200 characters")
|
||||||
|
.EmailAddress().WithMessage("Sender 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.ReceivedDate)
|
||||||
|
.NotEmpty().WithMessage("Received date is required")
|
||||||
|
.LessThanOrEqualTo(DateTime.Now.AddDays(1)).WithMessage("Received date cannot be in the future");
|
||||||
|
|
||||||
|
RuleFor(x => x.Attachments)
|
||||||
|
.NotNull().WithMessage("Attachments collection cannot be null");
|
||||||
|
|
||||||
|
RuleForEach(x => x.Attachments).ChildRules(attachment =>
|
||||||
|
{
|
||||||
|
attachment.RuleFor(a => a.FileName)
|
||||||
|
.NotEmpty().WithMessage("Attachment file name is required")
|
||||||
|
.MaximumLength(500).WithMessage("Attachment file name must not exceed 500 characters");
|
||||||
|
|
||||||
|
attachment.RuleFor(a => a.Content)
|
||||||
|
.NotEmpty().WithMessage("Attachment content is required");
|
||||||
|
|
||||||
|
attachment.RuleFor(a => a.SizeBytes)
|
||||||
|
.GreaterThan(0).WithMessage("Attachment size must be greater than 0")
|
||||||
|
.LessThanOrEqualTo(100 * 1024 * 1024).WithMessage("Attachment size must not exceed 100 MB");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
||||||
|
using DigitalData.EmailProfiler.Domain.Entities;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateEmailProfileCommandHandler(IRepository<EmailProfile> repository)
|
||||||
|
: IRequestHandler<CreateEmailProfileCommand, int>
|
||||||
|
{
|
||||||
|
public async Task<int> Handle(CreateEmailProfileCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var profile = await repository.CreateAsync(request, cancellationToken);
|
||||||
|
return profile.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
||||||
|
using DigitalData.EmailProfiler.Domain.Entities;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.EmailProfiles.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to delete an email profile.
|
||||||
|
/// </summary>
|
||||||
|
public record DeleteEmailProfileCommand(int Id) : IRequest<int>;
|
||||||
|
|
||||||
|
public class DeleteEmailProfileCommandHandler(IRepository<EmailProfile> repository)
|
||||||
|
: IRequestHandler<DeleteEmailProfileCommand, int>
|
||||||
|
{
|
||||||
|
public async Task<int> Handle(DeleteEmailProfileCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await repository.DeleteSingleAsync(p => p.Id == request.Id, cancellationToken);
|
||||||
|
return request.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
||||||
|
using DigitalData.EmailProfiler.Domain.Entities;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.EmailProfiles.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to update an existing email profile.
|
||||||
|
/// </summary>
|
||||||
|
public record UpdateEmailProfileCommand : IRequest<int>
|
||||||
|
{
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateEmailProfileCommandHandler(IRepository<EmailProfile> repository)
|
||||||
|
: IRequestHandler<UpdateEmailProfileCommand, int>
|
||||||
|
{
|
||||||
|
public async Task<int> Handle(UpdateEmailProfileCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await repository.UpdateSingleAsync(p => p.Id == request.Id, request, cancellationToken);
|
||||||
|
return request.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos;
|
||||||
|
using DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.EmailProfiles.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query to get all active email profiles.
|
||||||
|
/// </summary>
|
||||||
|
public record GetActiveEmailProfilesQuery : IRequest<IEnumerable<EmailProfileDto>>;
|
||||||
|
|
||||||
|
public class GetActiveEmailProfilesQueryHandler(
|
||||||
|
IEmailProfileRepository repository,
|
||||||
|
IMapper mapper)
|
||||||
|
: IRequestHandler<GetActiveEmailProfilesQuery, IEnumerable<EmailProfileDto>>
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<EmailProfileDto>> Handle(
|
||||||
|
GetActiveEmailProfilesQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var profiles = await repository.GetActiveProfilesAsync(cancellationToken);
|
||||||
|
return mapper.Map<IEnumerable<EmailProfileDto>>(profiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos;
|
||||||
|
using DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.EmailProfiles.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query to get email profile by ID with related entities.
|
||||||
|
/// </summary>
|
||||||
|
public record GetEmailProfileByIdQuery(int Id) : IRequest<EmailProfileDto?>;
|
||||||
|
|
||||||
|
public class GetEmailProfileByIdQueryHandler(
|
||||||
|
IEmailProfileRepository repository,
|
||||||
|
IMapper mapper)
|
||||||
|
: IRequestHandler<GetEmailProfileByIdQuery, EmailProfileDto?>
|
||||||
|
{
|
||||||
|
public async Task<EmailProfileDto?> Handle(
|
||||||
|
GetEmailProfileByIdQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var profile = await repository.GetWithRelatedEntitiesAsync(request.Id, cancellationToken);
|
||||||
|
return profile != null ? mapper.Map<EmailProfileDto>(profile) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.EmailProfiler.Application.Common.Dtos;
|
||||||
|
using DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
||||||
|
using DigitalData.EmailProfiler.Domain.Entities;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.EmailProfiles.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query to get all email profiles.
|
||||||
|
/// </summary>
|
||||||
|
public record GetEmailProfilesQuery : IRequest<IEnumerable<EmailProfileDto>>;
|
||||||
|
|
||||||
|
public class GetEmailProfilesQueryHandler(
|
||||||
|
IRepository<EmailProfile> repository,
|
||||||
|
IMapper mapper)
|
||||||
|
: IRequestHandler<GetEmailProfilesQuery, IEnumerable<EmailProfileDto>>
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<EmailProfileDto>> Handle(
|
||||||
|
GetEmailProfilesQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var profiles = await repository.GetAllAsync(cancellationToken);
|
||||||
|
return mapper.Map<IEnumerable<EmailProfileDto>>(profiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.EmailProfiles.Commands;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.EmailProfiles.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validator for CreateEmailProfileCommand.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateEmailProfileCommandValidator : AbstractValidator<CreateEmailProfileCommand>
|
||||||
|
{
|
||||||
|
public CreateEmailProfileCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.ProfileName)
|
||||||
|
.NotEmpty().WithMessage("Profile name is required")
|
||||||
|
.MaximumLength(100).WithMessage("Profile name must not exceed 100 characters");
|
||||||
|
|
||||||
|
RuleFor(x => x.EmailAccountId)
|
||||||
|
.GreaterThan(0).WithMessage("Email account ID must be greater than 0");
|
||||||
|
|
||||||
|
RuleFor(x => x.PollIntervalMinutes)
|
||||||
|
.GreaterThanOrEqualTo(1).WithMessage("Poll interval must be at least 1 minute")
|
||||||
|
.LessThanOrEqualTo(1440).WithMessage("Poll interval must not exceed 1440 minutes (24 hours)");
|
||||||
|
|
||||||
|
RuleFor(x => x.ValidationSql)
|
||||||
|
.MaximumLength(1000).WithMessage("Validation SQL must not exceed 1000 characters")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.ValidationSql));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.EmailProfiles.Commands;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Application.EmailProfiles.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validator for UpdateEmailProfileCommand.
|
||||||
|
/// </summary>
|
||||||
|
public class UpdateEmailProfileCommandValidator : AbstractValidator<UpdateEmailProfileCommand>
|
||||||
|
{
|
||||||
|
public UpdateEmailProfileCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id)
|
||||||
|
.GreaterThan(0).WithMessage("Profile ID must be greater than 0");
|
||||||
|
|
||||||
|
RuleFor(x => x.ProfileName)
|
||||||
|
.NotEmpty().WithMessage("Profile name is required")
|
||||||
|
.MaximumLength(100).WithMessage("Profile name must not exceed 100 characters");
|
||||||
|
|
||||||
|
RuleFor(x => x.PollIntervalMinutes)
|
||||||
|
.GreaterThanOrEqualTo(1).WithMessage("Poll interval must be at least 1 minute")
|
||||||
|
.LessThanOrEqualTo(1440).WithMessage("Poll interval must not exceed 1440 minutes (24 hours)");
|
||||||
|
|
||||||
|
RuleFor(x => x.ValidationSql)
|
||||||
|
.MaximumLength(1000).WithMessage("Validation SQL must not exceed 1000 characters")
|
||||||
|
.When(x => !string.IsNullOrEmpty(x.ValidationSql));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace DigitalData.EmailProfiler.Application.Features.EmailProfiles.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command to delete an email profile.
|
|
||||||
/// </summary>
|
|
||||||
public record DeleteEmailProfileCommand(int Id) : IRequest<Unit>;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,17 +3,58 @@ using System.Linq.Expressions;
|
|||||||
namespace DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
namespace DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base repository interface for common CRUD operations.
|
/// Base repository interface for common CRUD operations with AutoMapper support.
|
||||||
|
/// Changes are automatically saved after each operation - no explicit SaveChanges needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">Entity type</typeparam>
|
/// <typeparam name="TEntity">Entity type</typeparam>
|
||||||
public interface IRepository<T> where T : class
|
public interface IRepository<TEntity> where TEntity : class
|
||||||
{
|
{
|
||||||
Task<T?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
// ==================== QUERY OPERATIONS ====================
|
||||||
Task<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default);
|
|
||||||
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
|
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
Task<T> AddAsync(T entity, CancellationToken cancellationToken = default);
|
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
Task UpdateAsync(T entity, CancellationToken cancellationToken = default);
|
Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||||
Task DeleteAsync(T entity, CancellationToken cancellationToken = default);
|
Task<TEntity?> FirstOrDefaultAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||||
Task<bool> ExistsAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
|
Task<bool> ExistsAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||||
Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null, CancellationToken cancellationToken = default);
|
Task<int> CountAsync(Expression<Func<TEntity, bool>>? predicate = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
// ==================== CREATE OPERATIONS ====================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create entity from DTO using AutoMapper and save immediately.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Created entity with ID populated</returns>
|
||||||
|
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default) where TDto : class;
|
||||||
|
|
||||||
|
// ==================== UPDATE OPERATIONS ====================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update ALL entities matching expression using DTO via AutoMapper.
|
||||||
|
/// WARNING: This can update multiple records. Use UpdateSingleAsync for single-record updates.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Number of entities updated</returns>
|
||||||
|
Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update SINGLE entity matching expression using DTO via AutoMapper.
|
||||||
|
/// SAFETY: Throws exception if zero or multiple entities match the predicate.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">Thrown when zero or multiple entities match</exception>
|
||||||
|
Task UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default) where TDto : class;
|
||||||
|
|
||||||
|
// ==================== DELETE OPERATIONS ====================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete ALL entities matching expression.
|
||||||
|
/// WARNING: This can delete multiple records. Use DeleteSingleAsync for single-record deletes.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Number of entities deleted</returns>
|
||||||
|
Task<int> DeleteAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete SINGLE entity matching expression.
|
||||||
|
/// SAFETY: Throws exception if zero or multiple entities match the predicate.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">Thrown when zero or multiple entities match</exception>
|
||||||
|
Task DeleteSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
namespace DigitalData.EmailProfiler.Application.Interfaces.Repositories;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unit of Work pattern for transaction management.
|
|
||||||
/// </summary>
|
|
||||||
public interface IUnitOfWork : IDisposable
|
|
||||||
{
|
|
||||||
IEmailAccountRepository EmailAccounts { get; }
|
|
||||||
IEmailProfileRepository EmailProfiles { get; }
|
|
||||||
IEmailProcessRepository EmailProcesses { get; }
|
|
||||||
IEmailHistoryRepository EmailHistories { get; }
|
|
||||||
IEmailOutboxRepository EmailOutbox { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Save all changes to the database.
|
|
||||||
/// </summary>
|
|
||||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Begin a database transaction.
|
|
||||||
/// </summary>
|
|
||||||
Task BeginTransactionAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Commit the current transaction.
|
|
||||||
/// </summary>
|
|
||||||
Task CommitTransactionAsync(CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Rollback the current transaction.
|
|
||||||
/// </summary>
|
|
||||||
Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using DigitalData.EmailProfiler.Application.Common.Interfaces;
|
||||||
|
using DigitalData.EmailProfiler.Infrastructure.Messaging;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dependency injection configuration for Infrastructure layer
|
||||||
|
/// </summary>
|
||||||
|
public static class DependencyInjection
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds Infrastructure layer services to the DI container
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddInfrastructure(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
// Register RabbitMQ configuration
|
||||||
|
services.Configure<RabbitMqConfiguration>(
|
||||||
|
configuration.GetSection(RabbitMqConfiguration.SectionName));
|
||||||
|
|
||||||
|
// Register RabbitMQ command publisher
|
||||||
|
services.AddSingleton<ICommandPublisher, RabbitMqCommandPublisher>();
|
||||||
|
|
||||||
|
// Register RabbitMQ command consumer as hosted service
|
||||||
|
services.AddHostedService<RabbitMqCommandConsumer>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DigitalData.EmailProfiler.Domain\DigitalData.EmailProfiler.Domain.csproj" />
|
<ProjectReference Include="..\DigitalData.EmailProfiler.Domain\DigitalData.EmailProfiler.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\DigitalData.EmailProfiler.Application\DigitalData.EmailProfiler.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.9" />
|
||||||
|
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DigitalData.EmailProfiler.Infrastructure.Messaging;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using RabbitMQ.Client;
|
||||||
|
using RabbitMQ.Client.Events;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Infrastructure.Messaging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that consumes commands from RabbitMQ and executes them via MediatR
|
||||||
|
/// </summary>
|
||||||
|
public class RabbitMqCommandConsumer(
|
||||||
|
IOptions<RabbitMqConfiguration> config,
|
||||||
|
IServiceProvider ServiceProvider,
|
||||||
|
ILogger<RabbitMqCommandConsumer>? Logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly RabbitMqConfiguration Config = config.Value ?? throw new ArgumentNullException(nameof(config));
|
||||||
|
private IConnection? Connection;
|
||||||
|
private IChannel? _channel;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
Logger?.LogInformation("RabbitMQ Command Consumer starting...");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create connection factory
|
||||||
|
var factory = new ConnectionFactory
|
||||||
|
{
|
||||||
|
HostName = Config.HostName,
|
||||||
|
Port = Config.Port,
|
||||||
|
UserName = Config.UserName,
|
||||||
|
Password = Config.Password,
|
||||||
|
VirtualHost = Config.VirtualHost,
|
||||||
|
AutomaticRecoveryEnabled = Config.AutomaticRecoveryEnabled,
|
||||||
|
NetworkRecoveryInterval = TimeSpan.FromSeconds(Config.NetworkRecoveryIntervalSeconds)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create connection and channel
|
||||||
|
Connection = await factory.CreateConnectionAsync(stoppingToken);
|
||||||
|
_channel = await Connection.CreateChannelAsync(cancellationToken: stoppingToken);
|
||||||
|
|
||||||
|
// Set prefetch count (process one message at a time)
|
||||||
|
await _channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false, cancellationToken: stoppingToken);
|
||||||
|
|
||||||
|
// Create async consumer
|
||||||
|
var consumer = new AsyncEventingBasicConsumer(_channel);
|
||||||
|
consumer.ReceivedAsync += async (sender, ea) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessMessageAsync(ea, stoppingToken);
|
||||||
|
await _channel.BasicAckAsync(ea.DeliveryTag, multiple: false, cancellationToken: stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger?.LogError(ex, "Error processing message {MessageId}", ea.BasicProperties?.MessageId);
|
||||||
|
|
||||||
|
// Reject and requeue on error (be careful with infinite loops!)
|
||||||
|
await _channel.BasicNackAsync(ea.DeliveryTag, multiple: false, requeue: true, cancellationToken: stoppingToken);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start consuming
|
||||||
|
await _channel.BasicConsumeAsync(
|
||||||
|
queue: Config.QueueName,
|
||||||
|
autoAck: false,
|
||||||
|
consumer: consumer,
|
||||||
|
cancellationToken: stoppingToken);
|
||||||
|
|
||||||
|
Logger?.LogInformation(
|
||||||
|
"RabbitMQ Command Consumer started. Listening on queue: {QueueName}",
|
||||||
|
Config.QueueName);
|
||||||
|
|
||||||
|
// Keep running until cancellation requested
|
||||||
|
// Consumer will process messages in the background via event handlers
|
||||||
|
var tcs = new TaskCompletionSource();
|
||||||
|
stoppingToken.Register(() => tcs.SetResult());
|
||||||
|
await tcs.Task;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Logger?.LogInformation("RabbitMQ Command Consumer is stopping due to cancellation");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger?.LogError(ex, "Fatal error in RabbitMQ Command Consumer");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessMessageAsync(BasicDeliverEventArgs ea, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var messageId = ea.BasicProperties?.MessageId ?? "unknown";
|
||||||
|
var commandType = ea.BasicProperties?.Type ?? "unknown";
|
||||||
|
|
||||||
|
Logger?.LogInformation(
|
||||||
|
"Processing command {CommandType} with MessageId {MessageId}",
|
||||||
|
commandType, messageId);
|
||||||
|
|
||||||
|
// Deserialize message envelope
|
||||||
|
var json = Encoding.UTF8.GetString(ea.Body.ToArray());
|
||||||
|
var envelope = JsonSerializer.Deserialize<CommandEnvelope>(json);
|
||||||
|
|
||||||
|
if (envelope == null)
|
||||||
|
{
|
||||||
|
Logger?.LogWarning("Failed to deserialize command envelope for MessageId {MessageId}", messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get command type from assembly
|
||||||
|
var type = Type.GetType(envelope.CommandType);
|
||||||
|
if (type == null)
|
||||||
|
{
|
||||||
|
Logger?.LogWarning(
|
||||||
|
"Command type {CommandType} not found in assembly for MessageId {MessageId}",
|
||||||
|
envelope.CommandType, messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deserialize command payload
|
||||||
|
var command = JsonSerializer.Deserialize(envelope.Payload, type);
|
||||||
|
if (command == null)
|
||||||
|
{
|
||||||
|
Logger?.LogWarning(
|
||||||
|
"Failed to deserialize command payload for MessageId {MessageId}",
|
||||||
|
messageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create scope and execute command via MediatR
|
||||||
|
using var scope = ServiceProvider.CreateScope();
|
||||||
|
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Send command to MediatR (fire-and-forget)
|
||||||
|
await mediator.Send(command, cancellationToken);
|
||||||
|
|
||||||
|
Logger?.LogInformation(
|
||||||
|
"Successfully processed command {CommandType} with MessageId {MessageId}",
|
||||||
|
commandType, messageId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger?.LogError(
|
||||||
|
ex,
|
||||||
|
"Error executing command {CommandType} with MessageId {MessageId}",
|
||||||
|
commandType, messageId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Logger?.LogInformation("RabbitMQ Command Consumer stopping...");
|
||||||
|
|
||||||
|
if (_channel != null)
|
||||||
|
{
|
||||||
|
await _channel.CloseAsync(cancellationToken);
|
||||||
|
_channel.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Connection != null)
|
||||||
|
{
|
||||||
|
await Connection.CloseAsync(cancellationToken);
|
||||||
|
Connection.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
await base.StopAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Message envelope for command deserialization
|
||||||
|
/// </summary>
|
||||||
|
private class CommandEnvelope
|
||||||
|
{
|
||||||
|
public string CommandType { get; set; } = string.Empty;
|
||||||
|
public string Payload { get; set; } = string.Empty;
|
||||||
|
public DateTime PublishedAt { get; set; }
|
||||||
|
public string CorrelationId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DigitalData.EmailProfiler.Application.Common.Interfaces;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using RabbitMQ.Client;
|
||||||
|
|
||||||
|
namespace DigitalData.EmailProfiler.Infrastructure.Messaging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RabbitMQ implementation of ICommandPublisher for asynchronous command processing
|
||||||
|
/// </summary>
|
||||||
|
public class RabbitMqCommandPublisher : ICommandPublisher, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILogger<RabbitMqCommandPublisher> Logger;
|
||||||
|
|
||||||
|
private readonly RabbitMqConfiguration Config;
|
||||||
|
|
||||||
|
private readonly IConnection Connection;
|
||||||
|
|
||||||
|
private readonly IChannel Channel;
|
||||||
|
|
||||||
|
private bool Disposed { get; set; }
|
||||||
|
|
||||||
|
public RabbitMqCommandPublisher(
|
||||||
|
IOptions<RabbitMqConfiguration> config,
|
||||||
|
ILogger<RabbitMqCommandPublisher> logger)
|
||||||
|
{
|
||||||
|
Config = config.Value ?? throw new ArgumentNullException(nameof(config));
|
||||||
|
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create connection factory
|
||||||
|
var factory = new ConnectionFactory
|
||||||
|
{
|
||||||
|
HostName = Config.HostName,
|
||||||
|
Port = Config.Port,
|
||||||
|
UserName = Config.UserName,
|
||||||
|
Password = Config.Password,
|
||||||
|
VirtualHost = Config.VirtualHost,
|
||||||
|
AutomaticRecoveryEnabled = Config.AutomaticRecoveryEnabled,
|
||||||
|
NetworkRecoveryInterval = TimeSpan.FromSeconds(Config.NetworkRecoveryIntervalSeconds)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create connection and channel
|
||||||
|
Connection = factory.CreateConnectionAsync().GetAwaiter().GetResult();
|
||||||
|
Channel = Connection.CreateChannelAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// Declare exchange (fanout for broadcasting commands)
|
||||||
|
Channel.ExchangeDeclareAsync(
|
||||||
|
exchange: Config.ExchangeName,
|
||||||
|
type: ExchangeType.Direct,
|
||||||
|
durable: true,
|
||||||
|
autoDelete: false).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// Declare queue
|
||||||
|
Channel.QueueDeclareAsync(
|
||||||
|
queue: Config.QueueName,
|
||||||
|
durable: true,
|
||||||
|
exclusive: false,
|
||||||
|
autoDelete: false,
|
||||||
|
arguments: null).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
// Bind queue to exchange
|
||||||
|
Channel.QueueBindAsync(
|
||||||
|
queue: Config.QueueName,
|
||||||
|
exchange: Config.ExchangeName,
|
||||||
|
routingKey: Config.RoutingKey).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
Logger.LogInformation(
|
||||||
|
"RabbitMQ connection established: {HostName}:{Port}, Exchange: {Exchange}, Queue: {Queue}",
|
||||||
|
Config.HostName, Config.Port, Config.ExchangeName, Config.QueueName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to establish RabbitMQ connection");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes a command to RabbitMQ for asynchronous processing
|
||||||
|
/// </summary>
|
||||||
|
public async Task PublishAsync<TCommand>(TCommand command, CancellationToken cancellationToken = default)
|
||||||
|
where TCommand : IBaseRequest
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(Disposed, typeof(RabbitMqCommandPublisher));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create message envelope with metadata
|
||||||
|
var envelope = new CommandEnvelope
|
||||||
|
{
|
||||||
|
CommandType = typeof(TCommand).AssemblyQualifiedName!,
|
||||||
|
Payload = JsonSerializer.Serialize(command),
|
||||||
|
PublishedAt = DateTime.Now,
|
||||||
|
CorrelationId = Guid.NewGuid().ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serialize to JSON
|
||||||
|
var json = JsonSerializer.Serialize(envelope);
|
||||||
|
var body = Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
|
// Set message properties
|
||||||
|
var properties = new BasicProperties
|
||||||
|
{
|
||||||
|
Persistent = true,
|
||||||
|
ContentType = "application/json",
|
||||||
|
MessageId = envelope.CorrelationId,
|
||||||
|
Timestamp = new AmqpTimestamp(DateTimeOffset.Now.ToUnixTimeSeconds()),
|
||||||
|
Type = typeof(TCommand).Name
|
||||||
|
};
|
||||||
|
|
||||||
|
// Publish to exchange
|
||||||
|
await Channel.BasicPublishAsync(
|
||||||
|
exchange: Config.ExchangeName,
|
||||||
|
routingKey: Config.RoutingKey,
|
||||||
|
mandatory: false,
|
||||||
|
basicProperties: properties,
|
||||||
|
body: body,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
Logger.LogInformation(
|
||||||
|
"Published command {CommandType} with CorrelationId {CorrelationId} to RabbitMQ",
|
||||||
|
typeof(TCommand).Name, envelope.CorrelationId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to publish command {CommandType} to RabbitMQ", typeof(TCommand).Name);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Channel?.Dispose();
|
||||||
|
Connection?.Dispose();
|
||||||
|
Disposed = true;
|
||||||
|
|
||||||
|
Logger.LogInformation("RabbitMQ connection disposed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Message envelope for command serialization
|
||||||
|
/// </summary>
|
||||||
|
private class CommandEnvelope
|
||||||
|
{
|
||||||
|
public string CommandType { get; set; } = string.Empty;
|
||||||
|
public string Payload { get; set; } = string.Empty;
|
||||||
|
public DateTime PublishedAt { get; set; }
|
||||||
|
public string CorrelationId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
namespace DigitalData.EmailProfiler.Infrastructure.Messaging;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for RabbitMQ connection
|
||||||
|
/// </summary>
|
||||||
|
public class RabbitMqConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration section name in appsettings.json
|
||||||
|
/// </summary>
|
||||||
|
public const string SectionName = "RabbitMQ";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RabbitMQ server hostname
|
||||||
|
/// </summary>
|
||||||
|
public string HostName { get; set; } = "localhost";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RabbitMQ AMQP port (default: 5672)
|
||||||
|
/// </summary>
|
||||||
|
public int Port { get; set; } = 5672;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RabbitMQ username
|
||||||
|
/// </summary>
|
||||||
|
public string UserName { get; set; } = "guest";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RabbitMQ password
|
||||||
|
/// </summary>
|
||||||
|
public string Password { get; set; } = "guest";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Virtual host (default: /)
|
||||||
|
/// </summary>
|
||||||
|
public string VirtualHost { get; set; } = "/";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exchange name for commands
|
||||||
|
/// </summary>
|
||||||
|
public string ExchangeName { get; set; } = "emailprofiler.commands";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queue name for commands
|
||||||
|
/// </summary>
|
||||||
|
public string QueueName { get; set; } = "emailprofiler.command.queue";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routing key for commands
|
||||||
|
/// </summary>
|
||||||
|
public string RoutingKey { get; set; } = "command";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enable automatic recovery on connection failure
|
||||||
|
/// </summary>
|
||||||
|
public bool AutomaticRecoveryEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Network recovery interval in seconds
|
||||||
|
/// </summary>
|
||||||
|
public int NetworkRecoveryIntervalSeconds { get; set; } = 10;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user