Compare commits

..

185 Commits

Author SHA1 Message Date
d01a0ceaa6 Refactor IMAP sync caching and improve thread safety
Replaced `IMemoryCache` with a thread-safe `ConcurrentDictionary`
for managing IMAP sync dates in `LimilabsImapEmailService`. Added
`GetLastImapSyncDate` and `SetLastImapSyncDate` methods to handle
cache operations. Updated the `IImapEmailService` interface to
include `GetLastImapSyncDate`. Simplified the `MarkAsSeenAsync`
method signature for better readability. Introduced a private
record type `ImapCacheKey` to encapsulate cache keys.
2026-08-14 08:40:56 +02:00
fd234618f7 chore: add legacy system analysis files for reference 2026-08-13 16:49:22 +02:00
f99bd8c399 fix(infrastructure): change IImapEmailService registration from Singleton to Scoped to support scoped DbContext dependency 2026-08-13 16:49:10 +02:00
c0bd391297 refactor(infrastructure): resolve IImapEmailService per-iteration from scoped DI in EmailSyncWorker; add structured logging and per-account error handling 2026-08-13 16:48:59 +02:00
cda70c8ced feat(infrastructure): replace DateFilter-based sync with IMemoryCache last-sync tracking in LimilabsImapEmailService; add CacheExtensions helper; return EmailSyncResult with processed/failed counts 2026-08-13 16:48:47 +02:00
578ecc7ba1 refactor(infrastructure): rename CreateAsync bulk overload to CreateRangeAsync and use AsNoTracking in ReceivedEmailRepository 2026-08-13 16:48:34 +02:00
6abe0e18f6 feat(application): add EmailSyncResult DTO, Folder/AccountId to ReceivedEmailDto, rename CreateRangeAsync in IRepository, update IImapEmailService signature, add AutoMapper profiles for ReceivedEmail and EmailAttachment 2026-08-13 16:48:22 +02:00
c9b7d99ecc feat(domain): add Folder property and change To/Cc to List<string> in ReceivedEmail 2026-08-13 16:48:06 +02:00
4964fa4344 Refactor IMAP service for repository integration
Replaced `FetchEmailsAsync` with `SyncEmailsAsync` in `IImapEmailService` to simplify email synchronization. Updated method signatures to use `DateFilter` and added default parameters for `folder` and `cancel`.

Refactored `LimilabsImapEmailService` to remove client-side filtering logic and delegate storage to `IRepository<ReceivedEmail>`. Simplified IMAP search criteria to focus on date-based filtering and added checks to skip already-synced emails.

Updated `EmailSyncWorker` to use the new `SyncEmailsAsync` method. Removed unused code, streamlined exception handling, and improved maintainability by reducing complexity and focusing on server-side filtering.
2026-08-13 14:18:43 +02:00
7207c5b4d9 Refactor email repository and query handling
Replaced `IMailRepository` with `IReceivedEmailRepository` to improve modularity and functionality. Updated `IReceivedEmailRepository` to make the `EmailAccount` parameter optional in the `FindAsync` method.

Added `ReceivedEmailRepository` with advanced filtering capabilities, including account, flags, text, UID, date, and recipient filters. Implemented deferred materialization for recipient filtering due to EF Core limitations.

Registered `IReceivedEmailRepository` in dependency injection. Updated `ReadEmailQueryHandler` to use the new repository. Improved query performance by applying filters conditionally.
2026-08-13 13:28:19 +02:00
7f04c4b09f Rename IMailRepository and add FindAsync method
Renamed the `IMailRepository` interface to `IReceivedEmailRepository`
to better reflect its purpose. Added a new `FindAsync` method to
the interface, which supports searching for received emails using
a `MailSearchFilter`, an `EmailAccount`, and an optional
`CancellationToken`. The method returns a `Task` resolving to
an `IEnumerable<ReceivedEmail>`.
2026-08-13 13:18:37 +02:00
43d7c393bb Refactor Repository to use protected DbSet field
Renamed `_dbSet` to `DbSet` and changed its accessibility from
`private` to `protected` to allow access in derived classes.

Updated all methods in the `Repository` class to use the new
`DbSet` field for querying, adding, updating, and removing
entities. This includes methods like `CreateAsync`,
`GetByIdAsync`, `FindAsync`, `UpsertAsync`, `UpdateAsync`,
and `DeleteAsync`.

Improved code consistency and readability by removing
redundant `_dbSet` references and standardizing on the
`DbSet` field.
2026-08-13 13:17:43 +02:00
e73bead2f2 Refactor email fetching to use IMailRepository
Introduced a new `IMailRepository` interface to abstract email-fetching logic, replacing the direct dependency on `IImapEmailService` in `ReadEmailQueryHandler`. Updated `ReadEmailQueryHandler` to use `IMailRepository` for querying emails and added `IMapper` for mapping entities to DTOs.

Modified the constructor of `ReadEmailQueryHandler` to inject `IMailRepository`, `IMapper`, and renamed `IRepository<EmailAccount>` to `EmailAccountRepo`. Replaced `IImapEmailService.FetchEmailsAsync` with `IMailRepository.FindAsync` in the handler's `Handle` method.

Wrapped all changes in `#if NET` preprocessor directives to ensure compatibility with specific build configurations. These changes improve modularity, testability, and separation of concerns.
2026-08-13 13:11:18 +02:00
606ff94a77 Rename FetchEmailsQuery to ReadEmailQuery
Renamed `FetchEmailsQuery` to `ReadEmailQuery` across the codebase, including its handler, validator, and usages in `EmailController`. Updated method signatures, dependencies, and XML documentation to reflect the new naming convention. Adjusted `ILogger` dependency in the handler to match the renamed class.
2026-08-13 12:09:44 +02:00
1c9af0e560 Remove caching from LimilabsImapEmailService
Simplified the `LimilabsImapEmailService` by removing the dependency on `IMemoryCache` and eliminating all caching logic. The constructor no longer accepts an `IMemoryCache` parameter, and the static `CacheKeyPrefix` field has been removed.

Replaced the caching mechanism with direct email fetching using `imap.GetMessageByUIDAsync`. Refactored the logic for processing attachments and visuals into `EmailAttachmentDto` objects, and streamlined the construction of `ReceivedEmailDto` to include metadata directly from the fetched email data.

These changes reduce complexity, improve maintainability, and ensure the service always retrieves the latest email data from the IMAP server.
2026-08-13 12:07:20 +02:00
86a07e5017 feat(api): handle BadRequestException as HTTP 400 and add EmailAccountController
- ExceptionHandlingMiddleware: map BadRequestException to 400 Bad Request response
- Add EmailAccountController: GET /email-accounts (with optional Id/Username filter),
  POST/PUT/DELETE via EmailAccountModificationCommand for full CRUD over email accounts
2026-08-13 11:58:25 +02:00
25d0c009ee refactor(infrastructure): migrate EmailSyncWorker to DB-backed account source with upsert seed
- EmailSyncWorker now fetches active email accounts from IRepository<EmailAccount>
  on every polling cycle instead of reading from static options list
- Add UpsertSeedEmailAccount: on startup, seed accounts from EmailAccountsOptions
  into the database via IRepository.UpsertAsync (insert or update by Username)
- Remove standalone EmailAccountSyncWorker (merged into EmailSyncWorker)
2026-08-13 11:58:12 +02:00
14180860d3 refactor(application): replace ISender dispatch with direct IRepository usage in email handlers
- FetchEmailsQuery, MarkEmailAsSeenCommand, PublishEmailCommand: resolve EmailAccount
  via IRepository<EmailAccount> instead of dispatching GetEmailAccountQuery through ISender
- Swap InvalidOperationException for BadRequestException when IMAP server is not configured
- Add ILogger to handlers; warn when multiple accounts match the lookup criteria
- EmailAccountsOptions.Accounts now typed as IEnumerable<EmailAccountModificationDto>
  instead of IEnumerable<EmailAccount> to decouple config from domain entity
- EmailMappingProfile: add EmailAccount <-> EmailAccountDto/EmailAccountModificationDto maps
2026-08-13 11:57:57 +02:00
50eaefb868 refactor(application): replace GetSenderQuery with GetEmailAccountQuery and add EmailAccountModificationCommand
- Rename GetSenderQuery to GetEmailAccountQuery with updated return type (IEnumerable<EmailAccountDto>)
- Handler now queries IRepository<EmailAccount> directly instead of chaining MediatR dispatches
- Add EmailAccountModificationCommand for create/update/delete operations on email accounts
- Update GetSenderQueryValidator to reference renamed query type
- Remove exclusive Id/Username validation rule (now handled by repo query logic)
2026-08-13 11:57:39 +02:00
84da5cb646 feat(application): add EmailAccount DTOs and Modification value object 2026-08-13 11:57:25 +02:00
25b55ef651 feat(domain): add BadRequestException for invalid request errors 2026-08-13 11:57:12 +02:00
8b4d1e48f5 Add EmailAccountSyncWorker and configure EmailAccount entity
Introduced the `EmailAccountSyncWorker` background service to initialize and synchronize email accounts using RabbitMQ for event-driven processing. The service resolves email account configurations from application settings and upserts them into the repository.

Updated `MessagingServiceDbContext` to configure the `EmailAccount` entity, setting the `Username` property to use the `SQL_Latin1_General_CP1_CI_AS` collation for case-insensitive comparisons.

Added necessary `using` directives in `DependencyInjection.cs` to integrate new dependencies for AutoMapper, repositories, services, and background processing.
2026-08-13 09:37:39 +02:00
893addb45d Add AutoMapper profile for entity self-mappings
Introduce `EntitySelfMappingProfile` to enable self-mappings (T -> T) for domain entities (`EmailAccount`, `ReceivedEmail`, `EmailAttachment`). This ensures uniform AutoMapper usage in the generic repository, regardless of whether the target type is a DTO or the entity itself. Added necessary `using` directives for `AutoMapper` and domain entities.
2026-08-13 09:37:14 +02:00
00ae8e1ba0 Add bulk create and upsert methods to repository
Added `CreateAsync` for bulk entity creation and `UpsertAsync`
and `UpsertSingleAsync` for upsert operations in `IRepository`
and `Repository`. Improved error handling in `UpdateSingleAsync`
and `DeleteSingleAsync` by throwing `NotFoundException` when no
matching entity is found. Refactored `FindAsync` and `UpdateAsync`
for better readability and performance. Cleaned up formatting in
`DeleteAsync`.
2026-08-13 09:36:48 +02:00
5c37b2ef92 Add EF Core In-Memory DB and repository pattern support
Added the `Microsoft.EntityFrameworkCore.InMemory` package to enable an in-memory database for testing and lightweight storage. Introduced `MessagingServiceDbContext` with `DbSet` properties for `EmailAccount`, `ReceivedEmail`, and `EmailAttachment`. Configured entity relationships in `OnModelCreating`.

Registered `MessagingServiceDbContext` and a generic repository (`IRepository<>`) in the dependency injection container. Updated namespaces and imports to support the new DbContext and repository.

Improved project structure to enhance modularity and testability by leveraging EF Core's in-memory database.
2026-08-12 16:18:35 +02:00
be28a61d9c Add email domain entities and .NET Framework support
Introduced `EmailAccount`, `EmailAttachment`, and `ReceivedEmail`
entities with database mappings using data annotations. These
entities represent email account configurations, attachments,
and received emails, respectively.

Added conditional compilation to ensure compatibility between
.NET and .NET Framework. Updated `DigitalData.MessagingService.Domain.csproj`
to include `System.ComponentModel.DataAnnotations` for `net462`.

Defined relationships between entities, including navigation
properties and foreign key constraints.
2026-08-12 16:08:29 +02:00
b2857c558f Refactor: Replace EmailAccountDto with EmailAccount
Replaced the `EmailAccountDto` class with the `EmailAccount` class across the codebase to consolidate the `EmailAccount` entity into the domain layer. Updated namespaces, method signatures, property types, and test cases to reflect this change.

Moved `EmailAccount` from `DigitalData.MessagingService.Application.Common.Dto` to `DigitalData.MessagingService.Domain.Entities`. Updated XML documentation and removed redundant project file entries. Adjusted namespaces for related queries, validators, and commands to align with the new structure.

These changes improve separation of concerns and align with domain-driven design principles.
2026-08-12 15:26:15 +02:00
91581649ba Refactor namespaces and remove Abstraction project
Replaced `DigitalData.MessagingService.Abstraction` with
`DigitalData.MessagingService.Application.Common.Dto` and
`DigitalData.MessagingService.Application.Common.Dto.MailSearch`
to improve modularity and organization.

Removed the `Abstraction` project and updated all references
to use the `Application` project. Updated namespaces, `using`
directives, and dependencies across the codebase.

Refactored interfaces, commands, queries, validators, and
services to use the new DTOs. Updated RabbitMQ integration,
AutoMapper profiles, background services, and tests to align
with the new structure.

Performed general cleanup by removing redundant `using`
directives and obsolete references.
2026-08-12 15:13:15 +02:00
4dd0974c6f Add multi-targeting support for .NET frameworks
Introduced conditional compilation using `#if NET` directives to
enable support for multiple frameworks (`net462`, `net480`, and
`net8.0`). Updated the project file to support multi-targeting
and added framework-specific dependencies conditionally.

Refactored namespaces, interfaces, classes, validators, and
handlers to ensure compatibility with targeted frameworks.
Enhanced dependency injection setup and adjusted logic in
`GetSenderQueryHandler` for framework-specific behavior.

Ensured consistency and maintainability by wrapping framework-
specific code blocks with `#if NET` directives.
2026-08-12 14:40:47 +02:00
5e0de6d52a Refactor email services and update password handling
Updated `EmailAccountDto` to use `required` properties for .NET 7+ compatibility, removing the `PasswordEncrypted` property and simplifying password handling.

Removed `IEncryptionService` dependency from `LimilabsEmailService` and `LimilabsImapEmailService`. Updated `ConnectAndAuthenticateSmtpAsync` and `OpenAsync` methods to remove password decryption logic.

Introduced `CancellationToken` support in `LimilabsImapEmailService` methods to improve cancellation handling for IMAP operations.

Added `Entities\` folder reference in `DigitalData.MessagingService.Domain.csproj`.
2026-08-12 14:24:17 +02:00
176e6dd6c5 Refactor: Replace EmailAttachmentContext with DTO
Replaced `EmailAttachmentContext` with `EmailAttachmentDto` across the codebase to align with the updated DTO naming convention.

- Renamed `EmailAttachmentContext` to `EmailAttachmentDto`.
- Updated property types, method signatures, and return types to use `EmailAttachmentDto`.
- Modified XML documentation references to reflect the new class name.
- Updated `ReceivedEmailContext` to `ReceivedEmailDto` and adjusted related methods and properties.
- Refactored `FetchEmailsAsync` methods and handlers to use `ReceivedEmailDto`.
- Adjusted `BuildAttachmentsAsync` and attachment handling logic in `EmailController` and `LimilabsImapEmailService`.

This refactor ensures consistency and improves code clarity while maintaining functionality.
2026-08-12 14:17:24 +02:00
48796d9917 Refactor email filtering logic in LimilabsImapEmailService
Simplify email filtering by removing server-side filtering logic
and replacing it with in-process filtering after email retrieval.
Eliminate the use of `ICriterion` and `Expression` constructs,
and move all filtering conditions (e.g., `UnseenOnly`,
`SubjectContains`, `SenderContains`, etc.) into the processing
loop.

Add an `IsSeen` property to `ReceivedEmailContext` to track
email read status. Apply `filter.MaxCount` after all filtering
and processing are complete. Update `filter.WithAttachments`
logic to conditionally include attachments in results.

Improve code readability and maintainability by consolidating
filtering logic into a single location, ensuring consistent
application of all filters.
2026-08-12 13:41:35 +02:00
f6ada2ad9e Refactor LimilabsImapEmailService for clarity and efficiency
Refactored the `LimilabsImapEmailService` class to improve email fetching and processing. Consolidated logic by inlining and removing redundant private helper methods (`FetchEmailUidsAsync` and `FetchEmailByUidAsync`). Introduced structured `#region` blocks for UID fetching and email reading.

Enhanced filtering capabilities with support for unseen emails, subject, sender, recipient, body, UID ranges, and date ranges. Improved attachment handling by categorizing inline and regular attachments into `EmailAttachmentContext`. Added conditional attachment inclusion based on `filter.WithAttachments`.

Integrated caching (`Cache.GetOrCreateAsync`) to avoid redundant email fetches. Improved error handling and logging for better fault tolerance. Overall, the changes simplify the codebase, improve readability, and enhance functionality.
2026-08-12 13:01:49 +02:00
7b5596f3e5 Refactor email fetching methods in Limilabs service
Removed public methods `FetchEmailUidsAsync` and `FetchEmailByUidAsync` from `LimilabsImapEmailService` to simplify the public API.

Refactored `FetchEmailUidsAsync` into a private static helper method that operates directly on an `Imap` instance.

Removed exception handling logic specific to the removed methods. These changes aim to encapsulate email fetching functionality and streamline the service's design.
2026-08-12 12:45:52 +02:00
5d3997b7e1 Remove MarkAsSeen endpoint from EmailController
The `MarkAsSeen` method in the `EmailController` class has been removed. This method provided an HTTP PATCH endpoint at the route `"seen"` to mark a single IMAP message as seen (read). It accepted a `MarkEmailAsSeenCommand` and a `CancellationToken`, used `mediator.Send` to process the command, and returned an HTTP 204 No Content response upon success.

The removal of this method eliminates the ability to mark emails as seen via this endpoint.
2026-08-12 12:43:31 +02:00
cb3b2e09ab Refactor email fetching logic in EmailController
Consolidated email fetching endpoints by removing `FetchEmailUids`
and `FetchEmailByUid` endpoints and integrating their functionality
into the `FetchEmails` method. Introduced an `OnlyFilter` enum to
allow filtering responses for HTML body or UIDs. Removed related
methods (`FetchEmailUidsAsync`, `FetchEmailByUidAsync`) from
`IImapEmailService` and deleted associated query classes and
handlers.
2026-08-12 12:42:15 +02:00
118612206e Refactor IMAP initialization with OpenAsync helper
Refactored IMAP object initialization and folder selection into a new `OpenAsync` helper method to reduce code duplication and improve maintainability.

- Replaced repetitive connection, authentication, and folder selection logic in multiple methods (`FetchEmailsAsync`, `FetchEmailUidsAsync`, etc.) with `OpenAsync`.
- Removed `ConnectAndAuthenticateAsync` and `SelectFolderAsync` methods, as their functionality is now encapsulated in `OpenAsync`.
- Updated `FetchEmailUidsAsync` to remove the `EmailAccountDto account` parameter, delegating connection logic to `OpenAsync`.
- Ensured all methods now use `OpenAsync` to obtain a fully prepared `Imap` instance, improving clarity and reducing error risk.
2026-08-12 12:33:52 +02:00
a446162afa Add EmailSyncWorker and enhance IMAP service with caching
Refactored `ReceivedEmailContext` to use `record` for immutability
and value-based equality. Added `SyncIntervalSeconds` to
`EmailAccountsOptions` for configuring IMAP sync intervals.

Introduced `EmailSyncWorker` as a background service for periodic
email synchronization. Registered `IMemoryCache` and integrated
caching in `LimilabsImapEmailService` to reduce redundant fetch
operations. Optimized `FetchEmailByUidAsync` to conditionally
handle attachments and improve performance.

Refactored logging and improved code readability by adopting modern
C# features like `record`, `with` expressions, and `IOptions`.
Performed general cleanup and streamlined method implementations.
2026-08-12 11:48:29 +02:00
f521683608 /s
Simplify IMAP email fetching API and refactor logic

Removed `markAsSeen` parameter from `FetchEmailsAsync` and
`FetchEmailByUidAsync` methods in `IImapEmailService` to
simplify the API. Updated `MailSearchFilter` to make `MaxCount`
nullable for greater flexibility.

Removed `markAsSeen` from `FetchEmailByUidQuery` and
`FetchEmailsQuery` records and their handlers. Deleted
`FetchEmailByUidQueryValidator` as it is no longer needed.

Refactored `LimilabsImapEmailService`:
- Introduced `FetchEmailUidsAsync` to centralize UID fetching logic.
- Simplified `FetchEmailByUidAsync` using a new helper method.
- Consolidated connection, authentication, and folder selection
  into reusable private methods.
- Removed redundant code for search criteria and flag fetching.

Removed `IsSeen` from `ReceivedEmailContext` and improved
overall code readability and maintainability by reducing
duplication and centralizing logic.
2026-08-12 10:04:48 +02:00
f3761e96d9 refactor(imap): remove connection pool, revert to per-call Imap, add batch flags fetch
- ImapConnectionPool removed (over-engineered; IMAP server allows multiple concurrent connections)
- LimilabsImapEmailService reverted to stateless per-call new Imap() pattern (same as original)
- GetFlagsByUIDAsync(List<long>) replaces per-message GetFlagsByUID — one round-trip for all flags
- markAsSeen wired through: GetMessageByUIDAsync (true) vs PeekMessageByUIDAsync (false)
- DependencyInjection: pool registration removed, service registered directly as before
2026-08-11 15:52:17 +02:00
e840d026aa feat(imap): add markAsSeen option to fetch queries and interface
- IImapEmailService.FetchEmailsAsync / FetchEmailByUidAsync now accept
  a bool markAsSeen = true parameter
- FetchEmailsQuery and FetchEmailByUidQuery expose MarkAsSeen { init; } = true
- markAsSeen=true  uses BODY[]      (server sets \\Seen, legacy-compatible)
- markAsSeen=false uses BODY.PEEK[] (non-destructive read, flag untouched)
2026-08-11 15:51:57 +02:00
962cb52e0b Enhance EmailController with new endpoint and improvements
- Changed HTML body response handling to return Content with
  "text/html" content type for better response clarity.
- Added a new `FetchEmailUids` endpoint to retrieve only email
  UIDs based on query filters, with proper status handling.
- Updated `FetchEmailByUid` to support an optional `htmlBodyOnly`
  parameter, allowing clients to fetch only the HTML body.
- Added XML documentation for new and updated methods.
- Improved response handling and ensured proper HTTP status codes.
2026-08-11 10:03:22 +02:00
1162a07454 Add FetchEmailByUid endpoint and improve middleware
Added a new endpoint to `EmailController` for fetching a single email by UID, including route and query parameter handling, and appropriate Swagger annotations.

Refactored `ExceptionHandlingMiddleware` to use constructor injection, simplified exception handling with a `switch` expression, and improved logging and response formatting. Added support for `ValidationException`.

Updated `FormatValidationErrors` to handle `ValidationException` and improved error formatting.

Made minor documentation updates in `EmailController` and performed general code cleanup for readability and modernization.
2026-08-11 09:48:22 +02:00
59e8780345 Add FetchEmailUidsQuery and its handler for IMAP UIDs
Introduced `FetchEmailUidsQuery` to retrieve UIDs of matching
emails from an IMAP mailbox. Added the `FetchEmailUidsQuery`
record with properties for email account (`GetSenderQuery`)
and mail filtering (`MailSearchFilter`).

Implemented `FetchEmailUidsQueryHandler` to handle the query.
The handler validates the email account, ensures IMAP
configuration, and uses `IImapEmailService.FetchEmailUidsAsync`
to fetch UIDs. Added necessary `using` directives for
dependencies.
2026-08-11 09:46:26 +02:00
5ff38391d0 Add FetchEmailByUidQuery with handler and validation
Introduced `FetchEmailByUidQuery` to fetch a single email by its
UID from an IMAP mailbox. The query includes properties for
email account, UID, folder, and attachment inclusion, with a
fluent method `WithUid(long uid)` to set the UID.

Added `FetchEmailByUidQueryHandler` to process the query,
leveraging `ISender` for account retrieval and `IImapEmailService`
for email fetching. Included error handling for missing accounts
and misconfigured IMAP settings.

Implemented `FetchEmailByUidQueryValidator` to validate the
query, ensuring the `Account` is not null, `Uid` is set and
greater than 0, and `Folder` is not empty.
2026-08-11 09:45:21 +02:00
1fb7dcf98a Add methods for fetching email UIDs and emails by UID
Added `FetchEmailUidsAsync` to `IImapEmailService` for retrieving UIDs of emails matching a filter, optimizing scenarios where only identifiers are needed. Added `FetchEmailByUidAsync` to fetch a single email by UID, with optional attachment handling.

Implemented `FetchEmailUidsAsync` in `LimilabsImapEmailService` to connect to the IMAP server, construct search criteria, retrieve UIDs, and handle sorting and result limits. Added robust error handling for authentication and other failures.

Implemented `FetchEmailByUidAsync` in `LimilabsImapEmailService` to fetch email content and flags for a specific UID, map the data to `ReceivedEmailContext`, and handle errors with logging for non-critical failures.
2026-08-11 09:42:04 +02:00
ee279c407b Remove HasAttachments filter; enhance FetchEmails response
The `MailSearchFilter` class was updated to remove the `HasAttachments` property, simplifying the filtering logic. Corresponding client-side filtering logic in `LimilabsImapEmailService` was also removed. The IMAP search query now defaults to `Expression.All` when no criteria are provided.

The `FetchEmails` method in `EmailController` was enhanced with a new optional `firstHtmlBodyOnly` parameter. This allows returning only the HTML body of the first email or a `404 Not Found` response if no emails match the criteria. These changes improve flexibility and simplify the codebase.
2026-08-10 14:22:45 +02:00
74ec00ddd3 Refactor attachment handling in email fetching
Consolidate `WithAttachments` behavior into `MailSearchFilter` to simplify the API and reduce redundancy.

- Removed `withAttachments` parameter from `FetchEmailsAsync` in `IImapEmailService`.
- Added `WithAttachments` property to `MailSearchFilter` to control attachment inclusion.
- Removed `WithAttachments` property from `FetchEmailsQuery` as it is now encapsulated in `MailSearchFilter`.
- Updated `FetchEmailsQueryHandler` to use `MailSearchFilter` for attachment handling.
- Refactored `LimilabsImapEmailService` to use `MailSearchFilter.WithAttachments` for mapping email data.

These changes improve maintainability and clarity by centralizing attachment-related options in `MailSearchFilter`.
2026-08-10 12:05:27 +02:00
763ba67d34 Add support for optional email attachments fetching
Introduced a `withAttachments` parameter to the `FetchEmailsAsync` method in `IImapEmailService` and related layers, allowing callers to include or exclude attachment data when fetching emails. Updated `FetchEmailsQuery` and `FetchEmailsQueryHandler` to propagate this parameter.

Refactored `LimilabsImapEmailService` to conditionally process attachments and inline visuals based on the `withAttachments` flag, improving performance when attachments are not required. Replaced `Flag.Unseen` with `Expression.HasFlag(Flag.Unseen)` for better criteria handling. Cleaned up attachment-processing logic for improved readability and maintainability.
2026-08-10 11:54:35 +02:00
8e2e9af451 Refactor email search with new filter models
Replaced the old `SearchFilter` with the new `MailSearchFilter` model to enable more flexible and granular email search criteria. Introduced `DateFilter`, `UidFilter`, and `MailSortOrder` to support advanced filtering options such as date ranges, UID ranges, and sorting order. Updated `IImapEmailService` and `FetchEmailsQuery` to use the new models.

Added validators (`DateFilterValidator`, `MailSearchFilterValidator`, `UidFilterValidator`) to ensure input correctness. Refactored `LimilabsImapEmailService` to dynamically construct IMAP search queries based on the new filter properties, supporting unseen messages, text filters, and client-side attachment filtering.

Improved maintainability and scalability by cleaning up redundant code and leveraging the new models and validation framework.
2026-08-07 14:52:28 +02:00
53ba40b316 Refactor IMAP email fetching to use SearchFilter
Replaced individual parameters (`folder`, `unseenOnly`, `maxCount`) in `IImapEmailService` with a consolidated `SearchFilter` object to simplify method signatures and improve maintainability.

Renamed `MailQuery` to `SearchFilter` in `FetchEmailsQuery` for better clarity. Updated `FetchEmailsQueryHandler` and `LimilabsImapEmailService` to use the new `SearchFilter` object, ensuring consistent handling of folder selection, unread message filtering, and message count limits.

Improved logging in `LimilabsImapEmailService` to reflect the updated `SearchFilter` structure.
2026-08-07 13:25:13 +02:00
52a416f6d9 Refactor FetchEmailsQuery and improve UID handling
Moved the `Folder` property from `FetchEmailsQuery` to the
`MailQuery` record to better encapsulate query parameters.
Updated `FetchEmailsQueryHandler` to use `MailQuery.Folder`
for improved modularity. Simplified UID limiting logic in
`LimilabsImapEmailService` by replacing `Take().ToList()`
with the more concise `[.. Take()]` syntax.
2026-08-07 12:51:32 +02:00
79db1c3a69 Refactor email query and controller endpoints
Refactored `FetchEmailsQuery` to encapsulate filtering and retrieval parameters (`UnseenOnly` and `MaxCount`) within a nested `MailQuery` record for better organization. Updated `FetchEmailsQueryHandler` to use the new structure.

Simplified `EmailController` endpoints:
- Replaced multiple query parameters in `FetchEmails` with a single `FetchEmailsQuery` object.
- Replaced multiple parameters in `MarkAsSeen` with a `MarkEmailAsSeenCommand` object.
- Adjusted the HTTP route for `MarkAsSeen` from `"{uid}/seen"` to `"seen"`.

Updated XML documentation to reflect these changes, improving maintainability and scalability.
2026-08-07 12:43:41 +02:00
18c5dca9f4 Rename EmailsController to EmailController
Updated the class name from EmailsController to EmailController to follow a singular naming convention for controller classes. The namespace and constructor signature remain unchanged. This change improves consistency and readability in the codebase without introducing any functional modifications.
2026-08-07 11:50:48 +02:00
c47d78112c Add IMAP support for email fetching and marking as seen
Introduced IMAP functionality to enable fetching emails from an
IMAP server and marking messages as seen. Updated the
`EmailAccountDto` class with IMAP-related properties
(`ImapServer`, `ImapPort`, `ImapUseSsl`) for configuration.

Added `ReceivedEmailContext` to represent received emails and
created the `IImapEmailService` interface with methods
`FetchEmailsAsync` and `MarkAsSeenAsync`. Implemented the
`LimilabsImapEmailService` class using Limilabs Mail.dll for
IMAP operations, including connection handling, email fetching,
and marking messages as seen.

Added `FetchEmailsQuery` and `MarkEmailAsSeenCommand` with
handlers to encapsulate IMAP logic. Updated `EmailsController`
with new endpoints for fetching emails and marking messages as
seen. Registered `IImapEmailService` in `DependencyInjection`.

Included exception handling and logging for robust error
management during IMAP operations.
2026-08-07 11:48:12 +02:00
20de7da93d Rename SendEmailCommand to PublishEmailCommand
Refactor the codebase to rename `SendEmailCommand` and its associated components to `PublishEmailCommand` for improved clarity and consistency.

- Updated mapping in `EmailMappingProfile.cs` to use `PublishEmailCommand` instead of `SendEmailCommand`.
- Renamed `SendEmailCommand` to `PublishEmailCommand` in `PublishEmailCommand.cs`, including its methods like `WithAttachments`.
- Renamed `SendEmailCommandHandler` to `PublishEmailCommandHandler` and updated its method signature to handle the new command.
- Renamed `SendEmailCommandValidator` to `PublishEmailCommandValidator` and updated validation rules accordingly.
- Updated the `SendEmail` action in `EmailsController.cs` to accept `PublishEmailCommand` instead of `SendEmailCommand`.

These changes ensure consistency across the codebase and better reflect the purpose of the command.
2026-08-06 12:07:39 +02:00
2e69fac250 Add support for email attachments in SendEmailCommand
Enhanced the email-sending workflow to support attachments:
- Updated `SendEmailCommand` with an `Attachments` property.
- Added `WithAttachments` method to handle attachment initialization.
- Modified `EmailsController` to accept file uploads via `IFormFileCollection`.
- Implemented `BuildAttachmentsAsync` to process uploaded files.
- Updated `EmailMappingProfile` to map `Attachments` to `EmailContext`.
- Adjusted `SendEmail` endpoint to consume `multipart/form-data`.
- Enhanced `SendEmail` response to include the queued event ID.
- Updated project file to include Swagger infrastructure folder.

These changes enable handling of email attachments and improve API functionality.
2026-08-05 17:14:44 +02:00
890c32f1c8 Add support for email attachments in messaging service
Introduced the `EmailAttachmentContext` class to represent email
attachments, with properties for file name, content, content type,
inline display behavior, and content ID. Used conditional compilation
to support both .NET Framework and .NET versions.

Updated the `EmailContext` class to include an `Attachments`
property, enabling emails to include attachments as byte arrays or
file paths.

Enhanced the `LimilabsEmailService` class to handle attachments:
- Added the `AddAttachments` method to process inline and regular
  attachments.
- Integrated attachment handling into the email-building process.
2026-08-05 16:00:55 +02:00
fa9b4973b9 Introduce RabbitMQ consumer pool for parallel processing
Enhanced RabbitMQ email processing by introducing a `SendingEmailConsumerPool` to enable the competing consumers pattern. Each consumer operates on its own channel, improving scalability and thread safety.

- Added `SendingEmailConsumerPool` to manage multiple consumers.
- Updated `DependencyInjection` to register the consumer pool.
- Refactored `SendingEmailConsumer` for better logging and error handling.
- Updated `AsyncInitWorker` to initialize the consumer pool.
- Added `ConsumerConcurrency` to RabbitMQ configuration.
- Improved error handling in `LimilabsEmailService` with detailed SMTP error messages.
2026-08-05 15:34:33 +02:00
bd31bfe528 Add Serilog configuration to appsettings.Development.json
Introduced a new "Serilog" configuration section to enhance
logging control in the development environment. Set the
default logging level to "Debug" and added overrides for
specific namespaces ("Microsoft", "Microsoft.AspNetCore",
"Microsoft.EntityFrameworkCore", and "System") to "Warning".
2026-08-05 15:33:56 +02:00
dbf78653b4 Refactor project structure and improve security
- Updated `.gitignore` to exclude `FodyWeavers.xsd`.
- Added or modified `/EnvelopeGenerator.Server/tekh_softHSM_test.md`.
- Added or modified `/EnvelopeGenerator.Server/publish-output`.
- Added or modified `/legacy/App`.
- Moved `appsettings.Secrets.json` to `/src/presentation/`.
- Removed sensitive configuration data from `appsettings.Secrets.json`, including RabbitMQ credentials and email account settings.
- Improved security by removing hardcoded secrets and restructuring configuration files.
2026-08-05 15:33:38 +02:00
28f7a3607a Update EmailContext mapping to ignore Sender property
The `EmailMappingProfile` class was updated to modify the mapping
configuration between `SendEmailCommand` and `EmailContext`.
A `.ForMember` configuration was added to explicitly ignore the
`Sender` property in the destination (`EmailContext`) during the
mapping process. This ensures that `Sender` is not mapped from
the source object and must be set separately.
2026-08-05 14:34:20 +02:00
42b30d4ac4 Refactor SendEmailAsync to use EmailContext object
Simplified the `SendEmailAsync` method in the `IEmailService`
interface to accept a single `EmailContext` object instead of
multiple parameters. Updated the `SendingEmailConsumer` and
`LimilabsEmailService` classes to align with this change.

In `LimilabsEmailService`, refactored email construction logic
to use properties from the `EmailContext` object, including
`Sender`, `Recipients`, `Subject`, `Body`, and `IsHtml`.
Updated `ConnectAndAuthenticateSmtpAsync` to use the `Sender`
property from `EmailContext`.

These changes improve code readability, reduce parameter
complexity, and ensure consistency across the email service
implementation.
2026-08-05 14:14:27 +02:00
3cd8841e80 Refactor Email to EmailContext across codebase
Replaced the `Email` record with the new `EmailContext` record to introduce additional context and functionality in email handling. Updated property definitions to distinguish between .NET Framework (`set`) and other frameworks (`init`).

Modified `SendingEmailEvent` to use `EmailContext` for the `Mail` property. Updated mappings in `EmailMappingProfile` to map `SendEmailCommand` to `EmailContext`. Adjusted `SendEmailCommandHandler` to use `EmailContext` when mapping requests.

Refactored `EmailSender` to use `EmailContext` in its `Send` method, including updates to method signatures and documentation. Updated all related test classes (`EmailSenderTests`, `EmailSenderUrlOverloadTests`, `SendingEmailPublisherTests`) to validate the behavior of `EmailContext`, ensuring consistency and thorough testing of the transition.

These changes ensure compatibility across frameworks and improve the maintainability of the email handling process.
2026-08-05 14:06:41 +02:00
c6e67c0f99 Refactor email handling for improved structure
Refactored `Email` and `SendingEmailEvent` to use `record` types, consolidating email-related data into the `Email` class. Updated `SendEmailCommand` to return a `Guid` and simplified mapping logic in `EmailMappingProfile`. Adjusted `SendEmailCommandHandler` to construct `SendingEmailEvent` manually.

Updated `SendingEmailConsumer`, `EmailsController`, and `EmailSender` to reflect the new structure. Removed the old `Email` implementation. Improved logging to reference the `Mail` property.

Revised tests to align with the new structure, ensuring immutability and better separation of concerns.
2026-08-05 13:59:54 +02:00
66afdefbd8 refactor: rename OutgoingEmail files to SendingEmail 2026-08-05 13:29:53 +02:00
58ad50b96b Refactor: Rename OutgoingEmail to SendingEmail
This commit renames and refactors all instances of `OutgoingEmail` to `SendingEmail` across the codebase to improve terminology consistency and align with domain language.

- Renamed classes, interfaces, and records (e.g., `OutgoingEmailPublisher` → `SendingEmailPublisher`, `OutgoingEmailEvent` → `SendingEmailEvent`).
- Updated method signatures, parameters, and return types to use `SendingEmail`.
- Adjusted dependency injection registrations to reflect the new naming.
- Updated mappings in `EmailMappingProfile` to map `SendEmailCommand` to `SendingEmailEvent`.
- Refactored `SendEmailCommand` and its handler to work with `SendingEmailEvent`.
- Updated `EmailsController` to use `SendingEmailEvent` in the `SendEmail` action.
- Refactored integration tests to test `SendingEmailPublisher` and updated test data accordingly.
- Updated log messages, error handling, and comments to reflect the new terminology.
- Revised documentation and utility methods to use `SendingEmailEvent`.

This refactor ensures consistency, improves readability, and reduces ambiguity in the codebase.
2026-08-05 13:26:21 +02:00
e550db8789 Update project references with PrivateAssets
Updated `<ProjectReference>` entries for `DigitalData.MessagingService.Abstraction` and `DigitalData.MessagingService.Publisher` to include `<PrivateAssets>all</PrivateAssets>`, ensuring they are marked as private assets.

Added a new `<ProjectReference>` for `DigitalData.MessagingService.RabbitMQ` with `<PrivateAssets>all</PrivateAssets>`, making it a private dependency.
2026-08-05 13:23:18 +02:00
601fd9be5f Refactor: Consolidate Publisher.Abstraction into Abstraction
The `DigitalData.MessagingService.Publisher.Abstraction` project has been removed, and its functionality has been merged into a new project named `DigitalData.MessagingService.Abstraction`.

- Updated namespaces from `Publisher.Abstraction` to `Abstraction` across all relevant files, including DTOs, interfaces, and classes.
- Modified the solution file to remove `Publisher.Abstraction` and add `Abstraction`, updating solution configurations and nested project mappings.
- Replaced project references to `Publisher.Abstraction` with `Abstraction` in all affected project files.
- Updated tests and integration tests to reflect the namespace and project changes.
- Refactored application-level files such as `EmailMappingProfile` and `DependencyInjection.cs` to use the new namespace.

This refactor simplifies the project structure and ensures consistency across the solution.
2026-08-05 13:16:03 +02:00
0d9d15032f Refactor email handling for multiple recipients
Refactored the `Email` and `OutgoingEmailEvent` classes to replace the `Recipient` property with a `Recipients` collection, enabling support for multiple recipients. Updated all related test cases, including `EmailSenderTests`, `EmailSenderUrlOverloadTests`, and `OutgoingEmailPublisherTests`, to reflect this change.

Moved the `EmailAccountDto` class and its references from the `DigitalData.MessagingService.Application.Common.Dtos` namespace to the `DigitalData.MessagingService.Publisher.Abstraction` namespace for better code organization. Updated `using` directives across affected files.

Removed unused `using` directives and updated the `Email` class's `ToEvent` method to map the new `Recipients` property. Adjusted test assertions to validate collections instead of single recipient strings.
2026-08-05 13:11:19 +02:00
e47333cd1d Support multiple email recipients in email-sending flow
Updated the `IEmailService` interface and related components to
support multiple recipients in the `SendEmailAsync` method.

- Replaced `Recipient` with `Recipients` in `SendEmailCommand`,
  `OutgoingEmailEvent`, and `EmailsController`.
- Updated `SendEmailCommandValidator` to validate a collection
  of recipients, ensuring at least one valid email address.
- Modified `LimilabsEmailService` to handle multiple recipients
  by iterating over the collection and adding each to the email.
- Adjusted `OutgoingEmailConsumer` to process and log multiple
  recipients.
- Updated logging and response structures to reflect the changes.

These changes enable the system to handle emails with multiple
recipients while maintaining proper validation and logging.
2026-08-05 13:02:57 +02:00
740bb8c313 Refactor email account handling for dynamic resolution
Reintroduced `EmailAccountDto` with conditional compilation to support both .NET and non-.NET environments. Updated `IEmailService` to accept `EmailAccountDto` as the sender, replacing reliance on pre-configured SMTP credentials.

Added `GetSenderQuery` and its handler to dynamically resolve email accounts based on `Id` or `Username`. Introduced `GetSenderQueryValidator` for validation, ensuring proper usage of the query.

Modified `SendEmailCommand` to include sender resolution via MediatR. Updated `OutgoingEmailEvent` to include sender information and adjusted `OutgoingEmailConsumer` and `LimilabsEmailService` to use the dynamically resolved sender.

Updated `EmailMappingProfile` to ignore the `Sender` property during mapping. Replaced `Name` with `Id` in `appsettings.Secrets.json` for email accounts. Removed the old `EmailAccountDto` folder and performed general cleanup and restructuring.
2026-08-05 12:32:08 +02:00
7fa3c4888a Refactor repository namespaces for better organization
Updated the namespace in `IRepository.cs` and `Repository.cs`
from `DigitalData.MessagingService.Application.Common.Interfaces`
to `DigitalData.MessagingService.Application.Common.Interfaces.Repositories`.
This change improves code organization by grouping repository-related
interfaces and classes under a dedicated `Repositories` namespace.
Updated `using` directives accordingly to reflect the new structure.
2026-08-05 11:06:42 +02:00
e0e399f5ed Refactor LimilabsEmailService for async operations
Modernized LimilabsEmailService by replacing synchronous SMTP
operations with asynchronous counterparts (e.g., SendMessageAsync,
CloseAsync). Introduced a new SmtpExtensions class with a
CloseSafelyAsync method for safe disconnection. Removed the
DisconnectSafely method and replaced its usage with the new
extension method. Improved exception handling and removed
redundant Task.CompletedTask calls. These changes enhance
code readability, ensure safe resource cleanup, and align
with asynchronous programming practices.
2026-08-05 11:03:59 +02:00
e53fabcda2 Refactor email account configuration for multi-account support
Refactored `EmailAccountDto` to represent a single account with
immutable properties and added an `Id` field. Introduced
`EmailAccountsOptions` to manage multiple accounts and bound it
to the `EmailAccounts` configuration section.

Updated `DependencyInjection` to register `EmailAccountsOptions`
and removed the old single-account binding. Refactored
`LimilabsEmailService` to use `EmailAccountsOptions` and select
the appropriate account dynamically.

Replaced the `EmailAccount` section in `appsettings.Secrets.json`
with a new `EmailAccounts` section supporting multiple accounts.
Added a package reference for `Microsoft.Extensions.Options.
ConfigurationExtensions` to support the options pattern.
2026-08-05 10:46:43 +02:00
e6df623538 Update project version to 1.0.0 stable release
The `<Version>` tag in the `DigitalData.MessagingService.Client.csproj` file was updated from `1.0.0-beta` to `1.0.0`. This marks the transition from a beta release to a stable release, indicating the software is now ready for general use.
2026-07-29 14:49:56 +02:00
7773c8aa7f Update OnReconnect docs and README examples
Updated XML documentation in `OnReconnect.cs` to reference the updated `EmailSender.ConnectRabbitMq` method signature, clarifying its behavior when a connection is already established.

Revised `README.md` examples to replace `OutgoingEmailEvent` with `Email` and removed `Id` and `QueuedAt` properties, as they are now set internally. Added a note to clarify required fields for the `Email` object.

Added a new section to the `README.md` demonstrating how to check the connection status using `EmailSender.IsConnected`.
2026-07-29 14:36:47 +02:00
c73d8d8f36 Refactor EmailSender to use new Email abstraction
Introduced a new `Email` record to simplify the representation of outgoing email messages. Updated the `EmailSender.Send` method to accept `Email` instead of `OutgoingEmailEvent`, with a `ToEvent()` method handling the conversion internally.

Refactored test cases to use the `Email` record, removing redundant properties (`Id` and `QueuedAt`) that are now auto-generated. Updated XML documentation to reflect these changes. Adjusted namespaces and added necessary `using` directives for proper referencing.

These changes improve code readability, maintainability, and centralize the mapping logic for outgoing email events.
2026-07-29 14:36:10 +02:00
2f39db94ca Refactor and add OutgoingEmailCreateDto record
Introduced a new namespace `DigitalData.MessagingService.Publisher.Abstraction` in `OutgoingEmailCreateDto.cs` and `OutgoingEmailEvent.cs`.

Added a new record `OutgoingEmailCreateDto` with properties for recipient, subject, body, HTML flag, and queue timestamp.

Converted `OutgoingEmailEvent` from a class to a record.

Removed the unnecessary `using System;` directive from `OutgoingEmailEvent.cs`.
2026-07-29 13:43:21 +02:00
e78ceb522c Enhance logging and update solution structure
Replaced `DigitalData.MessagingService.Client.DependencyInjection`
with `DigitalData.MessagingService.Client` in the solution file,
updating project references and build configurations.

Improved logging by integrating Serilog's SQLite sink and `Serilog.UI`
for web-based log visualization. Added dynamic log directory
resolution and SQLite-based log storage in `Program.cs`.

Enhanced `DigitalData.MessagingService.API.csproj` with metadata
properties for better documentation and packaging. Added new NuGet
dependencies for logging and removed unused `<Folder>` entries.

Updated `appsettings.json` to include `Application:LogDirectory`
and `Swagger:Enabled` settings for configurable logging and Swagger
availability.
2026-07-29 13:08:42 +02:00
d7878d8ff8 Refactor namespace and project structure
Replaced `DigitalData.MessagingService.Client.DependencyInjection`
namespace with `DigitalData.MessagingService.Client` across the
codebase to simplify and streamline the project structure.

Removed unused `Microsoft.Extensions.Logging` and `System`
dependencies from `EmailSender.cs`. Updated project references
in `DigitalData.MessagingService.Tests.csproj` to reflect the
namespace and project restructuring. Adjusted `using` directives
in test files to align with the new namespace.
2026-07-29 12:35:57 +02:00
868c447a6c Add README for DigitalData.MessagingService.Client
Introduce a comprehensive README.md for the `DigitalData.MessagingService.Client` library. The README provides detailed instructions for installation, usage, and configuration, including:

- Installation via NuGet.
- Quickstart guide for connecting to RabbitMQ, sending emails, and checking connection status.
- Explanation of RabbitMQ URL format and server details.
- Prerequisites for .NET versions and RabbitMQ setup.
- Advanced configuration options for custom exchange, queue, and routing key names.
- Error handling documentation for common scenarios.
- Licensing information for Digital Data GmbH.
2026-07-29 11:20:33 +02:00
220d3f441f Add RabbitMQ integration tests and URL parsing logic
Enhanced test coverage for RabbitMQ integration by adding:
- New package references for dependency injection, logging, and RabbitMQ.
- `EmailSenderCollection` and `EmailSenderFixture` for managing static client lifecycle in integration tests.
- Integration tests for `EmailSender` and `OutgoingEmailPublisher` to verify connection management, message publishing, and serialization.
- `EmailSenderUrlOverloadTests` to validate URL-based connection overload behavior.
- `RabbitMqTestConfig` for centralized RabbitMQ test configuration.
- Unit tests for URL parsing logic in `EmailSenderUrlParsingTests`.

These changes improve reliability, maintainability, and test coverage for the messaging service.
2026-07-29 11:02:21 +02:00
5d9197f54a Update DI packages and enhance logging support
Updated `Microsoft.Extensions.DependencyInjection` to version 10.0.10 and added `Microsoft.Extensions.Logging` as a new dependency. Updated `EmailSender.cs` to include logging support by registering logging services in the DI container. Modified XML documentation to reflect the new `ConnectRabbitMq(Action<RabbitMqConfiguration>, OnReconnect)` method signature. Updated exception documentation to align with the new method signature.
2026-07-29 11:01:45 +02:00
6183ea613f Add ConnectRabbitMq method to EmailSender class
Introduce a new static method `ConnectRabbitMq` in the `EmailSender` class to configure and establish RabbitMQ connections using a URL, username, and password. The method extracts connection details (host, port, virtual host) from the URL and supports optional behavior for reconnection scenarios via the `onReconnect` parameter. Added `using System;` to support the `Uri` class.
2026-07-28 17:26:23 +02:00
e68fa989e1 Add defaults and docs to RabbitMqConfiguration properties
Updated the `RabbitMqConfiguration` class to include:
- XML documentation for all properties, improving clarity.
- Default values for `QueueName`, `ExchangeName`, `RoutingKey`,
  `DlqQueueName`, `DlqExchangeName`, and `DlqRoutingKey` to define
  RabbitMQ naming conventions.
Replaced undocumented properties with documented versions to enhance
code readability and maintainability.
2026-07-28 15:54:38 +02:00
de44b1967f Remove unused project references from test project 2026-07-28 14:33:51 +02:00
6eebfed97e Add EmailSender static client with lazy-initialized DI container
- Add EmailSender class with ConnectRabbitMq and Send methods
- Add OnReconnect enum for reconnection behavior control
- Add required NuGet packages (DependencyInjection, Hosting.Abstractions)
- Add project references to Publisher.Abstraction and Publisher
2026-07-28 14:33:47 +02:00
e8359abafd Simplify Publisher DI by removing Configuration wrapper class 2026-07-28 14:33:41 +02:00
a38e8f9680 Add Action<RabbitMqConfiguration> overload to RabbitMQ DI registration 2026-07-28 14:33:36 +02:00
7f55d97352 Refactor email publisher integration
Replaced direct registration of `OutgoingEmailPublisher` with `AddMessagingServicePublisher()` to centralize publisher setup. Removed `OutgoingEmailPublisher.cs` and its dependencies, indicating a shift to a new implementation. Updated `DependencyInjection.cs` to use `DigitalData.MessagingService.Publisher` instead of the abstraction layer. Added a project reference to `DigitalData.MessagingService.Publisher` in the infrastructure project file.
2026-07-28 11:14:42 +02:00
472c9506f9 Add RabbitMQ-based email publisher and DI support
Introduced `OutgoingEmailPublisher` for RabbitMQ-based email
queueing with message persistence, scalability, and reliability.
Added dependency injection support via `AddMessagingServicePublisher`
extension method. Enhanced RabbitMQ topology setup with exchanges,
queues, and Dead Letter Queues (DLQ).

Updated `DigitalData.MessagingService.Publisher.csproj` and
`DigitalData.MessagingService.RabbitMQ.csproj` to support
`net462`, `net480`, and `net8.0`. Added project references
and conditional package references for compatibility.

Integrated logging with `Microsoft.Extensions.Logging` and
used `System.Text.Json` for serialization. Implemented lazy
initialization for RabbitMQ channels to improve performance.
2026-07-28 11:06:16 +02:00
47f4553986 Refactor project structure and update dependencies
Reorganized project structure by introducing `core` and
`infrastructure` directories:
- Moved `Application` and `Domain` projects to `core`.
- Moved `Infrastructure` project to `infrastructure`.

Updated project references in `API`, `Infrastructure`, and
`Application` projects to reflect the new directory structure.

Added a new dependency on `Publisher.Abstraction` in the
`Application` project.
2026-07-28 10:49:32 +02:00
61c6e34b2d Add DigitalData.MessagingService.Publisher project
A new project, `DigitalData.MessagingService.Publisher`, has been added to the solution. The solution file (`DigitalData.MessagingService.sln`) was updated to include the project declaration, build configurations, and nesting under the appropriate parent project.

The new project targets `.NET 8.0` and includes the following configurations:
- Implicit Usings enabled.
- Nullable reference types enabled.
- Latest C# language version specified.
2026-07-28 10:40:55 +02:00
ac4682575c Enable implicit usings in project files
Added `<ImplicitUsings>` property with the value `enable` to both `DigitalData.MessagingService.Publisher.Abstraction.csproj` and `DigitalData.MessagingService.Client.DependencyInjection.csproj` to enable implicit global using directives. Adjusted the order of `<LangVersion>` to follow `<Nullable>` for consistency.
2026-07-28 10:40:21 +02:00
cd221e710e Add new Client Dependency Injection project
A new project, `DigitalData.MessagingService.Client.DependencyInjection`, has been added to the solution.

- Updated `DigitalData.MessagingService.sln` to include the new project with its build configurations and nested project structure.
- Created `DigitalData.MessagingService.Client.DependencyInjection.csproj` targeting `net462`, `net480`, and `net8.0`.
- Added project metadata such as `PackageId`, `Authors`, `Version`, and more.
- Included a dependency on `RabbitMQ.Client` (v7.2.1).
- Configured nullable reference types and set the language version to `latest`.
- Added `icon.png` for NuGet packaging and enabled XML documentation generation.
2026-07-28 10:30:18 +02:00
78c82bf129 Refactor solution structure and add RabbitMQ config
Reorganized the solution structure to align with a layered architecture:
- Replaced `src` folder with `core`, `infrastructure`, and `presentation`.
- Moved projects to their respective folders.
- Added `DigitalData.MessagingService.Publisher.Abstraction` project.
- Removed `DigitalData.MessagingService.Client` project.

Updated project configurations and nesting in the solution file.

Added `appsettings.Secrets.json` with RabbitMQ and email account settings:
- RabbitMQ configuration includes hostname, port, credentials, and queue/exchange details.
- Email configuration includes SMTP server details and credentials.
2026-07-28 10:26:15 +02:00
2ad2dc6b4d Remove unused projects and simplify solution structure
The following projects were removed:
- `DigitalData.MessagingService.Client.Infrastructure`
- `DigitalData.MessagingService.Client`
- `DigitalData.MessagingService.Publisher.Abstraction`

The solution file (`DigitalData.MessagingService.sln`) was updated to remove references to these projects, including solution configuration platforms and nested project sections.

Additionally, the `DigitalData.MessagingService.Application.csproj` file was updated to remove a `ProjectReference` to `DigitalData.MessagingService.Publisher.Abstraction.csproj`.

These changes streamline the solution by removing unused or redundant components and reducing dependencies.
2026-07-28 10:21:07 +02:00
3149bb1cf9 Add Publisher.Abstraction project and refactor namespaces
Introduced a new project, `DigitalData.MessagingService.Publisher.Abstraction`, to encapsulate the `OutgoingEmailEvent` class and `IOutgoingEmailPublisher` interface. The project targets multiple frameworks (`net462`, `net480`, `net8.0`) and enables nullable reference types and the latest C# language version.

Moved `OutgoingEmailEvent` and `IOutgoingEmailPublisher` to the new project, updating their namespaces and replacing `required` properties with mutable ones in `OutgoingEmailEvent`. Updated all dependent files to reference the new namespace.

Updated the solution file to include the new project and adjusted build configurations. Cleaned up unused `using` directives and removed redundant `LangVersion` property in the new project file.
2026-07-28 10:18:54 +02:00
0b3ff7cae9 Add new Client.Infrastructure project to solution
A new project, `DigitalData.MessagingService.Client.Infrastructure`, has been added to the solution. This project targets `net462` and `net8.0`, with Implicit Usings, Nullable reference types, and the latest C# language version enabled. It includes dependencies on `Microsoft.Extensions.Logging.Abstractions`, `RabbitMQ.Client`, and `Microsoft.Extensions.Options.ConfigurationExtensions`.

The solution file has been updated to include the new project, along with its build configurations (Debug and Release for Any CPU). The project hierarchy has also been updated to reflect the addition of the new project and the reassignment of the `DigitalData.MessagingService.Client` project to a different parent group.
2026-07-28 09:40:37 +02:00
977a20fd97 Refactor RabbitMQ DI into extension method
Refactored the registration of `RabbitMqConnectionFactory` and
RabbitMQ configuration into a new `AddRabbitMqConnectionFactory`
extension method for improved modularity and reusability.

- Removed direct calls to `AddSingleton<RabbitMqConnectionFactory>`
  and `Configure<RabbitMqConfiguration>` from the main
  `DependencyInjection` class.
- Added a new static `DependencyInjection` class under the
  `DigitalData.MessagingService.RabbitMQ` namespace.
- The new `AddRabbitMqConnectionFactory` method encapsulates
  RabbitMQ DI logic and accepts `IServiceCollection` and
  `IConfiguration` as parameters.
- Updated `DependencyInjection.cs` to use the new extension method.
2026-07-27 17:10:45 +02:00
56ac720615 Add net480 target, enable nullable, and set LangVersion
Updated the `<TargetFrameworks>` property to include `net480`
to support .NET Framework 4.8. Added `<LangVersion>` set to
`latest` to use the latest C# language features. Enabled
nullable reference types by adding `<Nullable>` set to
`enable` for improved null safety.
2026-07-27 17:06:50 +02:00
becb608331 Make OutgoingEmailConsumer more robust and maintainable
- Added null-safety checks (`?.`) for `ILogger` usage in `OutgoingEmailConsumer`.
- Removed unused `AsyncEventingBasicConsumer` and `_lazyInit` fields.
- Updated `DisposeAsync` to check `_lazyChannel.IsValueCreated` before accessing it.
- Added infinite delay in `AsyncInitWorker` to keep the background service active until cancellation.
- Improved overall maintainability and reduced potential runtime issues.
2026-07-27 16:55:31 +02:00
37009b8e1e Update target frameworks and enable latest C# features
Updated the project to target .NET 8.0 alongside .NET Framework 4.6.2.
Added `<LangVersion>` property set to `latest` to enable the use of
the latest C# language features. Existing `<ImplicitUsings>` and
`<Nullable>` properties remain unchanged. No changes were made to
the `MediatR` package reference.
2026-07-27 16:45:03 +02:00
b2d478c335 Refactor OutgoingEmailConsumer for better initialization
Refactored the `OutgoingEmailConsumer` class to improve maintainability, readability, and robustness. Changed the class to explicitly inherit from `IAsyncDisposable` and introduced lazy initialization for RabbitMQ connections and consumers via `_lazyInit`.

Enhanced error handling in `consumer.ReceivedAsync` by adding detailed logging, `BasicNack` for invalid messages, and placeholders for error reporting strategies. Improved logging for consumer startup and added safeguards against multiple initializations.

Removed outdated comments, updated documentation, and ensured proper resource cleanup in `DisposeAsync`.
2026-07-27 16:32:34 +02:00
bf06b31656 Support multi-targeting and reformat exception classes
Updated the project file to support multi-targeting for both
.NET Framework 4.6.2 and .NET 8.0 by replacing `<TargetFramework>`
with `<TargetFrameworks>`.

Reformatted `AuthenticationFailedException` and
`NotFoundException` classes to use braces `{ }` for namespaces
and constructors for consistency. No functional changes were made
to the exception classes.
2026-07-27 15:40:52 +02:00
77e7c796fa Refactor domain model and exception handling
Significantly restructured the `DigitalData.MessagingService.Domain` project by removing unused domain-specific classes, enums, and value objects. Key changes include:

- Updated `ExceptionHandlingMiddleware` to handle `FluentValidation.ValidationException` with formatted validation errors mapped to `HttpStatusCode.BadRequest`.
- Removed foundational domain classes such as `BaseEntity`, `ValueObject`, and `IAggregateRoot`.
- Deleted enums (`AttachmentStatus`, `AuthenticationType`, `EmailStatus`, `ErrorCode`, `ProcessType`) and domain exceptions (`DomainException`, `AttachmentProcessingException`, `DmsNotAvailableException`, `InvalidPdfException`, `ValidationException`).
- Removed value objects (`EmailAddress`, `MessageId`) and the `MessageIdGenerator` service.
- Cleaned up the project structure by removing the `Events` folder reference.

These changes simplify the domain model, reduce unused code, and align the project with updated architectural goals.
2026-07-27 15:37:34 +02:00
2d7af80cd3 Refactor RabbitMQ integration for lazy initialization
Refactored `OutgoingEmailConsumer` and `OutgoingEmailPublisher` to use `Lazy<Task<IChannel>>` for channel initialization, ensuring channels are created only when needed. Simplified initialization and disposal logic by centralizing channel management.

Replaced `CancellationTokenSource` in both classes with the `CancellationToken` provided by `RabbitMqConnectionFactory`, centralizing token management. Updated methods to use the new lazy initialization pattern.

Removed `RabbitMqConnectionFactory.InitAsync` and introduced `CreateChannelAsync` and `CreateConsumerAsync` methods for simplified channel and consumer creation. Managed cancellation tokens internally with a `CancellationTokenSource`.

Simplified `AsyncInitWorker` by removing dependencies on `RabbitMqConnectionFactory` and `OutgoingEmailPublisher`. Removed redundant initialization logic.

Cleaned up unused imports, improved logging consistency, and enhanced code readability and maintainability.
2026-07-27 15:24:26 +02:00
a2373f242a Refactor RabbitMQ connection handling logic
Updated `OutgoingEmailConsumer` and `OutgoingEmailPublisher` to use `GetDefaultConnectionAsync` instead of `GetConnectionAsync` for initializing RabbitMQ connections.

Renamed `GetConnectionAsync` to `GetDefaultConnectionAsync` in `RabbitMqConnectionFactory` to improve clarity and align with naming conventions. Updated `InitAsync` in `RabbitMqConnectionFactory` to use the renamed method.

These changes improve consistency, maintainability, and clarity in RabbitMQ connection management.
2026-07-27 13:05:17 +02:00
3611d527d4 Refactor RabbitMQ functionality to new project
Moved RabbitMQ-related functionality from the
`DigitalData.MessagingService.Infrastructure` project to a new
dedicated project/namespace `DigitalData.MessagingService.RabbitMQ`.

- Updated `DependencyInjection.cs` to use the new namespace.
- Added a project reference to `RabbitMQ.csproj` in the
  `Infrastructure.csproj` file.
- Removed `RabbitMqConfiguration.cs` and `RabbitMqConnectionFactory.cs`
  from the `Infrastructure` project.
- Updated namespaces in `OutgoingEmailConsumer.cs`,
  `OutgoingEmailPublisher.cs`, and `AsyncInitWorker.cs` to use
  `DigitalData.MessagingService.RabbitMQ`.

This refactor improves modularity, maintainability, and separation
of concerns by isolating RabbitMQ functionality in its own project.
2026-07-27 13:03:39 +02:00
885365df76 Add RabbitMQ support with configuration and connection
Added support for RabbitMQ integration:
- Updated project to target `net462` and `net8.0`.
- Added NuGet dependencies: `RabbitMQ.Client`, `Microsoft.Extensions.Logging.Abstractions`, and `Microsoft.Extensions.Options.ConfigurationExtensions`.
- Introduced `RabbitMqConfiguration` class for managing RabbitMQ settings.
- Implemented `RabbitMqConnectionFactory` for creating and managing RabbitMQ connections with lazy initialization, async disposal, and logging support.
2026-07-27 12:58:49 +02:00
94d1b73c4a Add NuGet metadata and package icon to project
Updated `DigitalData.MessagingService.Client.csproj`:
- Added NuGet metadata (PackageId, Authors, Company, etc.).
- Included `icon.png` as the package icon.
- Configured multi-targeting for `net462` and `net8.0`.
- Added XML documentation file generation.

Added `icon.png` binary file to the project.
2026-07-27 10:38:34 +02:00
84c72af993 Refactor solution structure and add RabbitMQ project
Simplified `DigitalData.MessagingService.Client.csproj` by removing metadata properties and adding a `RabbitMQ.Client` package reference.

Added a new project `DigitalData.MessagingService.RabbitMQ` targeting `net462` and `net8.0` with a `RabbitMQ.Client` package reference.

Updated `DigitalData.MessagingService.sln` to reflect the new project structure, including new paths, GUIDs, and solution configuration mappings.
2026-07-27 10:32:52 +02:00
0eda732d49 Remove MailKit dependency from project
The `MailKit` package reference (version 4.17.0) was removed from the `DigitalData.MessagingService.Infrastructure.csproj` file. This change suggests that the functionality provided by `MailKit` is no longer required or has been replaced by an alternative solution.
2026-07-27 10:12:19 +02:00
42d35d9a01 Add new MessagingService.Client project to solution
A new project `DigitalData.MessagingService.Client` has been added, targeting `net462` and `net8.0`. The project includes metadata for packaging and distribution, such as `PackageId`, `Authors`, `Version`, and more.

The solution file `DigitalData.MessagingService.sln` has been updated to include the new project, with build configurations for `Debug|Any CPU` and `Release|Any CPU`. The project is nested under the `infrastructure` folder in the solution structure.
2026-07-27 10:10:49 +02:00
23d52b9427 Add "presentation", "core", and "infrastructure" projects
Added three new projects ("presentation", "core", and
"infrastructure") to the solution file with unique GUIDs.
Updated the `GlobalSection(NestedProjects)` to reflect the
new project structure, nesting the new projects under the
parent project with GUID `{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}`.
Removed outdated nested project mappings and replaced them
with mappings for the new projects. Updated the solution
file to ensure proper integration of the new projects.
2026-07-27 10:06:20 +02:00
370872e126 Remove MimeKit package reference
The `MimeKit` package reference (version `4.17.0`) has been removed from the `DigitalData.MessagingService.Application.csproj` file. This change indicates that the project no longer relies on the `MimeKit` library for its functionality.
2026-07-27 10:04:24 +02:00
b6470fce5a Refactor: Remove IOutgoingEmailConsumer interface
Simplified the codebase by removing the `IOutgoingEmailConsumer` interface and directly using the `OutgoingEmailConsumer` class.

- Removed `IOutgoingEmailConsumer` from the application.
- Updated `DependencyInjection` to register `OutgoingEmailConsumer` directly.
- Modified `OutgoingEmailConsumer` to no longer implement the removed interface.
- Updated `AsyncInitWorker` to depend directly on `OutgoingEmailConsumer`.
- Simplified initialization logic for email consumer and publisher.

These changes eliminate an unnecessary abstraction layer, making the code easier to maintain while preserving functionality.
2026-07-27 10:04:05 +02:00
1617d4ab43 Refactor RabbitMQ email queue into publisher/consumer
Refactored the RabbitMQ-based email queue system by splitting
`OutgoingEmailQueue` into `OutgoingEmailPublisher` and
`OutgoingEmailConsumer` to separate publishing and consuming
responsibilities.

- Introduced `IOutgoingEmailConsumer` and renamed
  `IOutgoingEmailQueue` to `IOutgoingEmailPublisher` for clarity.
- Updated `SendEmailCommandHandler` to use the new publisher
  abstraction.
- Added `RabbitMqConnectionFactory` to centralize RabbitMQ
  connection management.
- Updated dependency injection to register new services.
- Simplified RabbitMQ initialization logic by delegating it to
  `RabbitMqConnectionFactory`.
- Enhanced logging for better observability.
- Improved modularity and maintainability by separating concerns
  between message publishing and consuming.
2026-07-24 19:01:42 +02:00
5e587da957 refactor: Rename EmailPorifler to MessagingService 2026-07-24 13:59:43 +02:00
77d3b52d16 Refactor OutgoingEmailQueue for RabbitMQ best practices
Refactored the `OutgoingEmailQueue` class to use separate RabbitMQ
channels for publishing and consuming, improving thread safety and
aligning with RabbitMQ best practices. Replaced the synchronous
`Dispose` method with an asynchronous `DisposeAsync` for proper
cleanup of resources.

Introduced a `CancellationTokenSource` to manage the consumer's
lifetime independently, ensuring graceful shutdown. Updated the
`InitAsync` method to initialize dedicated channels and adjusted
RabbitMQ topology declarations to use the publish channel.

Replaced `_logger` with the injected `Logger` instance for
consistency and updated RabbitMQ operations to use the appropriate
channels with cancellation token support. Improved error handling
and logging in the consumer, and ensured acknowledgments operate
on the consume channel.

Simplified the class structure by removing redundant fields and
adopting C# 12 primary constructor syntax. Adjusted method
parameters for clarity and added comments to explain design
decisions. These changes enhance maintainability, scalability,
and reliability.
2026-07-24 13:42:53 +02:00
3cba69ec42 Refactor email queue worker and interface cleanup
Removed `InitAsync` from `IOutgoingEmailQueue` to decouple RabbitMQ initialization from the interface. Replaced `AsyncIniteWorker` with the correctly named `AsyncInitWorker` in `DependencyInjection.cs` and introduced a new, improved implementation of `AsyncInitWorker`.

The new `AsyncInitWorker` class initializes the outgoing email queue consumer using an event-driven RabbitMQ approach, with enhanced logging and error handling. Removed the outdated `AsyncIniteWorker` class to streamline the codebase.

These changes improve code clarity, maintainability, and correctness.
2026-07-24 12:30:36 +02:00
d91ed70001 Refactor: Replace EmailSenderWorker with AsyncIniteWorker
Removed EmailSenderWorker and its configuration, including
EmailSenderWorkerConfiguration. Introduced AsyncIniteWorker
as a replacement, simplifying the design by removing
configuration-based toggling.

- Deleted EmailSenderWorkerConfiguration.cs.
- Removed EmailSenderWorker and its registration from Program.cs.
- Registered AsyncIniteWorker in DependencyInjection.cs.
- Renamed and refactored EmailSenderWorker to AsyncIniteWorker.
- Updated namespace and removed unused configuration dependencies.
2026-07-24 11:54:34 +02:00
4a6af885de Refactor email queue interface and RabbitMQ handling
Removed `DequeueAsync` from `IOutgoingEmailQueue` and added `GetQueueDepthAsync` to query the queue's message count. Updated RabbitMQ connection and channel creation methods to support `CancellationToken`. Removed `DequeueAsync` implementation from `OutgoingEmailQueue`, signaling a shift away from direct message consumption. These changes improve cancellation handling and simplify the queue's responsibilities.
2026-07-23 17:00:59 +02:00
55e5d689ad Refactor IEmailQueue to IOutgoingEmailQueue
Renamed the `IEmailQueue` interface to `IOutgoingEmailQueue` to improve clarity and better reflect its purpose as an outgoing email queue. Updated all references to the interface across the codebase, including:

- Replaced `IEmailQueue` with `IOutgoingEmailQueue` in `EmailSenderWorker.cs`.
- Renamed the interface in `IOutgoingEmailQueue.cs`.
- Updated `SendEmailCommandHandler` in `SendEmailCommand.cs` to use `IOutgoingEmailQueue`.
- Modified dependency injection in `DependencyInjection.cs` to register `OutgoingEmailQueue` with `IOutgoingEmailQueue`.
- Updated `OutgoingEmailQueue.cs` to implement `IOutgoingEmailQueue`.

These changes improve code readability, maintainability, and naming consistency.
2026-07-23 16:55:52 +02:00
42e9361f1d Refactor RabbitMqEmailQueue to OutgoingEmailQueue
Replaced `RabbitMqEmailQueue` with `OutgoingEmailQueue` in the
dependency injection container to reflect the updated class name.
Renamed the class `RabbitMqEmailQueue` to `OutgoingEmailQueue`
in `OutgoingEmailQueue.cs`, including updates to the constructor
and logger type. This refactor aligns the class name with its
purpose and improves clarity in the codebase.
2026-07-23 16:55:04 +02:00
fbd6c0c521 Refactor email processing and RabbitMQ initialization
Centralized email processing logic in `RabbitMqEmailQueue` by moving it from `EmailSenderWorker`. Updated `IEmailQueue` to replace `StartConsumerAsync` with `InitAsync`, shifting to an initialization-based model for RabbitMQ.

Refactored `RabbitMqEmailQueue` to handle email processing inline, including deserialization, logging, and sending emails via `IEmailService`. Enhanced error handling with detailed logging for failures. Removed lazy initialization (`Lazy<Task>`) in favor of explicit initialization via `InitAsync`.

Simplified `EmailSenderWorker` by removing `ProcessEmailAsync` and its dependency on `IEmailService`. Updated it to call `EmailQueue.InitAsync` for initialization.

Improved logging and error handling for better visibility into email processing and failure scenarios. Updated RabbitMQ acknowledgment and rejection logic to use `args.CancellationToken`.
2026-07-23 16:47:14 +02:00
55feaed361 Make RabbitMQ configuration dynamic
Updated `RabbitMqConfiguration` to include properties for queue and exchange names, replacing hardcoded constants in `RabbitMqEmailQueue`. All RabbitMQ operations now use dynamic values from the configuration object, improving flexibility and configurability. Updated logging to reflect these changes.
2026-07-23 15:53:57 +02:00
169ef7d86b Remove RabbitMQ messaging functionality
The application no longer uses RabbitMQ for command publishing and consumption. This commit removes all RabbitMQ-related code, including:

- Removed RabbitMQ service registrations in `DependencyInjection.cs`.
- Deleted `RabbitMqCommandConsumer.cs`, which implemented a background service for consuming commands.
- Deleted `RabbitMqCommandPublisher.cs`, which implemented a publisher for RabbitMQ-based commands.
- Removed RabbitMQ-specific properties (`ExchangeName`, `QueueName`, `RoutingKey`) from `RabbitMqConfiguration.cs`.

These changes reflect a shift in the application's messaging strategy or architecture.
2026-07-23 15:42:03 +02:00
0e53e8f726 Refactor EmailSenderWorker configuration handling
Introduced a dedicated `EmailSenderWorkerConfiguration` class to centralize and simplify configuration management for the `EmailSenderWorker`. Updated `Program.cs` to use this class for dependency injection and removed the inline configuration logic from `EmailSenderWorker.cs`.

Simplified the worker's constructor by leveraging `IOptions<EmailSenderWorkerConfiguration>`. Removed the unused `MaxRetryCount` property from the configuration class and `appsettings.json`.

Cleaned up `using` directives in `Program.cs` and `EmailSenderWorker.cs` to include the new namespace and remove redundant imports. These changes improve maintainability and align with best practices.
2026-07-23 15:01:33 +02:00
615bf555f8 Add System.Security.Cryptography.Xml package reference
Added a reference to the `System.Security.Cryptography.Xml`
package (version 10.0.10) in the project file to enable
XML cryptographic operations such as signing, verifying,
and encrypting XML data. This change enhances the project's
capabilities for handling secure XML processing.
2026-07-23 13:42:02 +02:00
851a4e94f9 Refactor password handling in EmailAccountDto
Replaced the `EncryptedPassword` property in `EmailAccountDto` with `Password` and `PasswordEncrypted` to support both plain text and encrypted passwords. Updated `LimilabsEmailService` to use the new properties, checking the `PasswordEncrypted` flag to determine whether decryption is needed.
2026-07-23 13:31:27 +02:00
3e04fd7b63 Remove EmailAccount config section from appsettings.json
The `EmailAccount` section has been removed entirely, including
properties such as `Username`, `SmtpServer`, `SmtpPort`,
`SmtpUseSsl`, and `UseOAuth2`. No changes were made to the
`EmailSender` section or the `LuckyPennySoftLicenseKey`.
2026-07-23 13:06:55 +02:00
6a5e0a6086 Refactor EmailAccountDto and improve encryption logic
Refactored `EmailAccountDto` to focus on SMTP-related properties, removing unused fields. Updated `DependencyInjection` to configure data protection with a new key storage path. Simplified `DataProtectionEncryptionService` by removing redundant checks and error handling for encryption and decryption methods.
2026-07-23 13:06:42 +02:00
0ee6e4f96e fix: Register CodePagesEncodingProvider for Limilabs Mail.dll compatibility
- Add static constructor to register System.Text.Encoding.CodePages
- Required for windows-1252 and other extended code page support
- Fixes encoding issues with international email content
2026-07-23 12:41:53 +02:00
4e164a9162 feat: Add LuckyPennySoft license key configuration for AutoMapper and MediatR
- Add license key reading from appsettings.json
- Configure AutoMapper 16.2.0+ with built-in DI extension and license key
- Configure MediatR 14.2.0+ with license key
- Update Program.cs to pass IConfiguration to AddApplicationServices
2026-07-23 12:41:42 +02:00
3d13d10615 chore: Update NuGet package versions
- AutoMapper: 12.0.1 → 16.2.0
- Microsoft.Extensions.Hosting: 10.0.9 → 10.0.10
- Microsoft.Extensions.Options.ConfigurationExtensions: 10.0.9 → 10.0.10
- Add Microsoft.Extensions.Configuration.Abstractions 10.0.10
- Add Microsoft.Extensions.Configuration.Binder 10.0.10
- Add System.Text.Encoding.CodePages 10.0.10
2026-07-23 12:41:30 +02:00
f3eb4bb69b Downgrade AutoMapper and add FluentValidation
The `DigitalData.EmailProfiler.Application.csproj` file was updated to downgrade `AutoMapper` and `AutoMapper.Extensions.Microsoft.DependencyInjection` from versions `16.2.0` and `12.0.0` to `12.0.1`. Additionally, a new dependency on `FluentValidation.DependencyInjectionExtensions` version `12.1.1` was added.

The `DigitalData.EmailProfiler.Infrastructure.csproj` file was updated to downgrade the `AutoMapper` package from version `16.2.0` to `12.0.1`.

These changes address potential compatibility issues and introduce FluentValidation for dependency injection.
2026-07-23 11:27:30 +02:00
27513e73f5 feat(application): Add EmailProcessedEvent domain event 2026-07-23 11:20:14 +02:00
658040bd96 refactor(api): Update EmailsController and EmailSenderWorker for simplified email sending 2026-07-23 11:20:09 +02:00
0adc74e19f refactor(infrastructure): Update RabbitMqCommandConsumer with improved error handling 2026-07-23 11:20:04 +02:00
3be6e28477 refactor(infrastructure): Refactor RabbitMqEmailQueue and remove InMemoryEmailQueue, update DbContext 2026-07-23 11:19:59 +02:00
71e29ac3bb refactor(application): Simplify SendEmailCommand and IEmailQueue/IEmailService interfaces, add Shared reference 2026-07-23 11:19:54 +02:00
828bb168eb refactor(application): Remove old DTOs and mapping profiles, consolidate into EmailMappingProfile 2026-07-23 11:19:47 +02:00
70dd210555 refactor(application): Remove old repository and service interfaces 2026-07-23 11:19:41 +02:00
860ce41192 refactor(domain): Remove entity and event files, add DigitalData.EmailProfiler.Shared reference 2026-07-23 11:19:36 +02:00
1404f90729 remove default Worker 2026-07-22 11:57:53 +02:00
958352a720 feat: Add domain constants, API infrastructure, and configuration
Domain Layer:
- Add DomainConstants for email, attachment, and process constants

API Layer:
- Add EmailsController (minimal REST API endpoints)
- Add ExceptionHandlingMiddleware for global exception handling
- Update Program.cs:
  * Add EmailProfilerDbContext registration (SQL Server)
  * Add Generic Repository<T> scoped registration
  * Add ExceptionHandlingMiddleware to pipeline
  * Add EmailSenderWorker as hosted service
  * Configure Serilog file logging
  * Add Scalar OpenAPI documentation

Configuration:
- Add EmailAccount section in appsettings.json (SMTP credentials)
- Add RabbitMq section (message queue configuration)
- Add Serilog file sink configuration
- Update .csproj with required NuGet packages
- Update solution file

This commit completes the basic API infrastructure setup.
2026-07-22 11:50:17 +02:00
8003715792 feat: Add email sending feature with background worker
Application Layer:
- Add SendEmailCommand with handler (CQRS pattern)
- Add SendEmailCommandValidator (FluentValidation)
- Add EmailOutboxMappingProfile for EmailOutbox entity mappings
- Update EmailAccountMappingProfile with latest field mappings
- Update EmailProfileMappingProfile with latest field mappings

API Layer:
- Add EmailSenderWorker background service
- Worker polls EmailOutbox queue every 5 seconds
- Dequeues emails and processes via SendEmailCommand (MediatR)
- Uses IEmailService (Limilabs) for actual SMTP sending

This implements the outgoing email queue processing pipeline:
EmailOutbox (DB) → IEmailQueue (RabbitMQ) → EmailSenderWorker → SendEmailCommand → IEmailService
2026-07-22 11:49:57 +02:00
d346ed3176 refactor(application): Remove old CQRS commands/queries for minimal API migration
- Remove EmailAccounts CQRS layer (4 files: commands, queries, validators)
- Remove EmailHistories CQRS layer (2 files: queries)
- Remove EmailProcessing CQRS layer (2 files: commands, validators)
- Remove EmailProfiles CQRS layer (8 files: commands, queries, validators)
- Remove corresponding API controllers (3 files)

Total: 19 files removed

Reason: Migrating from full CQRS pattern to minimal API with direct repository access
Note: SendEmailCommand will be added separately for EmailSenderWorker
2026-07-22 11:49:37 +02:00
3f9bfc78a8 feat(infrastructure): Add Limilabs email service and RabbitMQ email queue
- Add LimilabsEmailService for SMTP operations (IEmailService implementation)
- Add RabbitMqEmailQueue for production email queue (RabbitMQ-based)
- Update InMemoryEmailQueue for improved error handling
- Update IEmailQueue interface for RabbitMQ compatibility
- Update DependencyInjection.cs:
  * Switch IEmailService to LimilabsEmailService (Singleton)
  * Switch IEmailQueue to RabbitMqEmailQueue (Singleton)
  * Change IEncryptionService to Singleton (thread-safe)
  * Remove IDmsService registration
- Add required NuGet package references to .csproj

TODO: Add Limilabs.Mail NuGet package (commercial license required)
2026-07-22 11:48:37 +02:00
dbd0d35ba3 refactor: Remove MailKit and windream DMS dependencies
- Remove IDmsService interface (DMS integration deferred to Phase 5)
- Remove MailKitEmailService implementation
- Remove WindreamDmsService implementation
- Simplify IEmailService to SMTP-only operations
- Preparing for Limilabs Mail.dll migration

BREAKING CHANGE: IEmailService no longer supports IMAP/POP3 operations
Reason: Migrating from MailKit to Limilabs Mail.dll
2026-07-22 11:48:19 +02:00
751ef87506 refactor(infrastructure): Improve service implementations and remove legacy references
**Services Refactored:**
- DevExpressPdfProcessingService: Remove unnecessary try-catch (lines 80-87), add stream position validation
- WindreamDmsService: Mark as [Obsolete] - application now only provides email sending functionality
- MailKitEmailService: Keep MailKit implementation (Limilabs DLL to be added separately)

**Custom Exceptions Added:**
- AuthenticationFailedException: OAuth2/IMAP/SMTP authentication failures
- DmsNotAvailableException: windream COM unavailable
- InvalidPdfException: Invalid PDF stream
- NotFoundException: Entity not found in Repository operations

**Legacy Cleanup:**
- Remove legacy VB.NET projects from solution (EmailProfiler.Common, EmailProfiler.Service)
- Delete legacy/ folder reference
- Clean solution file structure

**Stream Validation:**
- All PDF processing methods now validate stream position (reset to 0 if needed)
- Add CanSeek validation for stream-based operations

**Build Status:**  Successful (0 errors, 15 warnings - all acceptable)
2026-07-20 16:36:17 +02:00
8f2365d048 remove Features-directory and move the files to the root directory 2026-07-15 15:03:12 +02:00
bfe24eba06 feat(api): Add REST API controllers with RabbitMQ for POST/PUT/DELETE
Controllers:
- EmailProfilesController: CRUD operations (GET sync, POST/PUT/DELETE async via RabbitMQ)
  * GET /api/emailprofiles - List all profiles
  * GET /api/emailprofiles/{id} - Get profile by ID
  * GET /api/emailprofiles/active - List active profiles
  * POST /api/emailprofiles - Create (202 Accepted, queued to RabbitMQ)
  * PUT /api/emailprofiles/{id} - Update (202 Accepted, queued to RabbitMQ)
  * DELETE /api/emailprofiles/{id} - Delete (202 Accepted, queued to RabbitMQ)

- EmailAccountsController: CRUD operations
  * GET /api/emailaccounts - List all accounts
  * GET /api/emailaccounts/{id} - Get account by ID
  * POST /api/emailaccounts - Create (202 Accepted, queued to RabbitMQ)

- EmailHistoryController: Read-only operations
  * GET /api/emailhistory/profile/{profileId} - Get history with pagination
  * GET /api/emailhistory/{id} - Get history by ID

Changes:
- Fix ICommandPublisher constraint: IRequest → IBaseRequest (supports IRequest<T>)
- All POST/PUT/DELETE return HTTP 202 Accepted (async processing)
- All GET operations return HTTP 200 OK (synchronous via MediatR)
- Proper error handling: 404 Not Found for missing resources
2026-07-14 16:39:35 +02:00
0d22fe0b5c docs: Update AGENTS.md and STATUS.md with RabbitMQ and Phase 2 completion
AGENTS.md:
- Add Section 7: RabbitMQ Command Bus Integration (IMPLEMENTED)
- Document ICommandPublisher, RabbitMqCommandPublisher, RabbitMqCommandConsumer
- Add configuration, DI setup, and usage examples
- Document benefits: async processing, horizontal scaling, retries, persistence

STATUS.md:
- Mark Phase 2 (Application Layer) as 100% complete
- Update Phase 3 (Infrastructure Layer) to 15% (RabbitMQ done)
- Document all completed components: DTOs, Commands, Queries, Validators, Mappings
- Update last modified date to 2026-07-14
2026-07-14 16:37:22 +02:00
1ed489532d feat(application): Add AutoMapper profiles for all entities
- EmailProfileMappingProfile: Command→Entity, DTO→Entity, Entity→DTO
- EmailAccountMappingProfile: Command→Entity, Entity→DTO
- EmailHistoryMappingProfile: CreateDto→Entity, UpdateDto partial mapping, Entity→DTO
- EmailAttachmentMappingProfile: CreateDto→Entity, UpdateDto partial mapping, Entity→DTO

All mappings follow Repository<T> pattern with AutoMapper-based CRUD
2026-07-14 16:37:11 +02:00
eda6257145 feat(application): Add MediatR Commands, Queries, and FluentValidation
Commands (5):
- CreateEmailProfileCommand, UpdateEmailProfileCommand, DeleteEmailProfileCommand
- CreateEmailAccountCommand (OAuth2/password conditional validation)
- ProcessEmailCommand (with CreateEmailHistoryDto, UpdateEmailHistoryStatusDto)

Queries (7):
- GetEmailProfilesQuery, GetEmailProfileByIdQuery, GetActiveEmailProfilesQuery
- GetEmailAccountsQuery, GetEmailAccountByIdQuery
- GetEmailHistoryByProfileQuery (with pagination), GetEmailHistoryByIdQuery

Validators (4):
- CreateEmailProfileCommandValidator, UpdateEmailProfileCommandValidator
- CreateEmailAccountCommandValidator, ProcessEmailCommandValidator

All handlers in same file as commands/queries (AGENTS.md rule #5)
2026-07-14 16:37:00 +02:00
a708799587 refactor(application): Reorganize DTOs - flatten single-DTO folders
- Move EmailAccountDto.cs to Dtos/ (was in EmailAccounts/ subfolder)
- Move EmailProfileDto.cs to Dtos/ (was in EmailProfiles/ subfolder)
- Keep EmailAttachments/ (3 DTOs) and EmailHistories/ (3 DTOs) subfolders
- Update all namespace imports from Dtos.EmailAccounts/EmailProfiles to Dtos
- Simpler structure: single DTOs at root, multiple DTOs in subfolders
2026-07-14 16:36:50 +02:00
b7d65d7d5c refactor(application): Remove IUnitOfWork, add generic IRepository<T> with AutoMapper
- Remove IUnitOfWork pattern (not needed with auto-save repositories)
- Add generic IRepository<T> with CreateAsync<TDto>, UpdateSingleAsync<TDto>, DeleteSingleAsync
- Add UpdateAsync/DeleteAsync for bulk operations
- Add ICommandPublisher interface for RabbitMQ integration
- Add service interfaces: IEmailService, IPdfProcessingService, IDmsService, IEncryptionService, IEmailQueue
- Configure Application DI with MediatR, AutoMapper, FluentValidation
2026-07-14 16:36:41 +02:00
5e8e6a06fe feat(infrastructure): Add RabbitMQ Command Bus integration
- Add RabbitMQ.Client 7.2.1, Microsoft.Extensions.Hosting 10.0.9
- Implement ICommandPublisher interface for async command publishing
- Create RabbitMqCommandPublisher with persistent message delivery
- Create RabbitMqCommandConsumer BackgroundService for command processing
- Add RabbitMqConfiguration with appsettings.json binding
- Configure Infrastructure DI with RabbitMQ services
- Update Program.cs to support appsettings.Secrets.json
- Server: 172.24.12.56:5672, Exchange: emailprofiler.commands
2026-07-14 16:36:23 +02:00
50c21ee628 Refactor MediatR commands and update solution structure
- Consolidated commands and handlers into single files for better organization.
- Updated file naming conventions for commands and queries.
- Added explicit Git operation rules to prevent automatic commits/pushes.
- Introduced new projects and restructured solution file (`legacy` folder).
- Refactored `CreateEmailAccountCommand`, `ProcessEmailCommand`, and others to use `IUnitOfWork`.
- Enhanced `ProcessEmailCommandHandler` with attachment validation and error handling.
- Removed redundant handler files after consolidation.
- Improved code consistency and added `TODO` comments for future enhancements.
2026-07-09 14:37:12 +02:00
45654796b7 feat(application): add MediatR commands and handlers with exception improvements
MediatR Commands (CQRS Pattern):
- CreateEmailProfileCommand + Handler
- UpdateEmailProfileCommand + Handler
- DeleteEmailProfileCommand + Handler
- CreateEmailAccountCommand + Handler
- ProcessEmailCommand + Handler (core email processing logic)

Command Handlers:
- Create/Update/Delete operations for EmailProfile
- Create operation for EmailAccount
- ProcessEmail: Complete email processing workflow including:
  * Duplicate detection using MessageId hash
  * Email history creation
  * PDF attachment validation
  * windream DMS archiving support (placeholder)
  * Domain event publishing (EmailProcessedEvent)
  * Error handling and status tracking

Exception Improvements:
- Added ErrorCode property to DomainException
- Added ErrorCode overload to ValidationException
- Simplified AttachmentProcessingException to use base ErrorCode

Field Mappings Fixed:
- EmailProfile: ProcessId (not EmailProcessId), ValidationSql (not SenderFilter/SubjectFilter)
- EmailAccount: Username, EncryptedPassword, UseOAuth2, EncryptedClientSecret
- EmailHistory: SenderAddress, EmailDate, OriginalMessageId, EmailBodyText/Html
- EmailAttachment: OriginalFileName, SavedFileName, FilePath, FileSize
- Audit fields: AddedWhen/AddedWho, ChangedWhen/ChangedWho (not CreatedDate/By, ModifiedDate/By)

All commands follow Clean Architecture and use UnitOfWork pattern.
Build successful with 1 minor warning (dmsService marked for future implementation).
2026-07-08 15:51:51 +02:00
3778c0b338 feat(application): add repository and service interfaces
Repository Interfaces (Clean Architecture - Application Layer):
- IRepository<T>: Base repository interface with common CRUD operations
- IEmailAccountRepository: Email account operations (GetActive, GetByName, GetWithProfiles)
- IEmailProfileRepository: Profile operations (GetActive, GetDueForPolling, GetWithRelated)
- IEmailProcessRepository: Process operations (GetWithSteps, GetByType)
- IEmailHistoryRepository: History operations (pagination, duplicate detection, date range queries)
- IEmailOutboxRepository: Outbox operations (GetPending, GetForRetry, MarkAsSent/Failed)
- IUnitOfWork: Transaction management and repository aggregation

Service Interfaces (Abstraction for Infrastructure):
- IEmailService: IMAP/SMTP operations with OAuth2 support (MailKit wrapper)
- IPdfProcessingService: PDF validation, embedded file extraction, ZUGFeRD support
- IDmsService: windream DMS integration (archive, search, update index fields)
- IEncryptionService: Data protection for passwords and OAuth tokens
- IEmailQueue: Async email queue (in-memory Channel, future: RabbitMQ)

Dependencies:
- Added MimeKit 4.17.0 for email service interface definitions

All interfaces follow Clean Architecture principles:
- Interfaces in Application layer
- Implementations will be in Infrastructure layer
2026-07-08 10:39:16 +02:00
111d2bf264 fix(domain): use DateTime.Now instead of DateTime.UtcNow for legacy compatibility
CRITICAL FIX: Replace all DateTime.UtcNow with DateTime.Now throughout the application.

Reason: Legacy VB.NET system uses local server time, and database stores all
timestamps as local time. Using UTC breaks compatibility and causes incorrect
time comparisons.

Changes:
- EmailProcessedEvent: ProcessedDate now uses DateTime.Now
- EmailHistory.MarkAsProcessed(): ProcessedDate now uses DateTime.Now
- EmailHistory.MarkAsFailed(): ProcessedDate now uses DateTime.Now
- EmailProfile.UpdateLastPollTime(): LastPollTime now uses DateTime.Now
- EmailProfile.ShouldPoll(): Poll interval comparison now uses DateTime.Now

Documentation:
- Added critical note to agents.md about DateTime usage
- Includes examples and detailed explanation for future developers

This ensures all date/time operations remain compatible with legacy database.
2026-07-08 10:36:13 +02:00
c9251fa622 Add "Solution Items" folder with project documentation
A new "Solution Items" folder has been added to the solution, represented by the GUID `{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}`.

This folder includes the following files:
- `agents.md`
- `IMPLEMENTATION_GUIDE.md`
- `README.md`
- `STATUS.md`

These files are now part of the solution structure, providing better organization and accessibility for project-related documentation.
2026-07-07 19:29:30 +02:00
2393b2649a docs: add project status tracking document
Current implementation status:
-  Phase 1 Complete: Domain Layer (100%)
- 🚧 Phase 2 In Progress: Application Layer (5%)
-  Phases 3-6 Pending

Detailed tracking:
- All completed entities, value objects, enums, services
- All pending repository interfaces, commands, queries
- All pending infrastructure implementations
- All pending API controllers and workers
- Build status confirmation
- Progress visualization (~15% complete)
- Prioritized next steps for future agents

This document provides quick overview of what's done and what's next.
2026-07-07 19:00:07 +02:00
331b73000e docs: add critical notes and future enhancements for agents
Important notes:
- Database schema must NEVER be modified
- MessageId hash algorithm must match legacy system exactly
- No git commits without explicit permission
- Naming conventions (SNAKE_CASE DB, PascalCase C#)

Future enhancements:
- RabbitMQ queue implementation plan (replacing in-memory queue)
- Complete migration path and configuration examples
- Pending implementation tasks for each phase
- Known issues and limitations (PdfSharp, windream COM)

Architecture decisions:
- Clean Architecture with DDD
- CQRS pattern with MediatR
- Repository pattern

Development guidelines:
- Code style conventions
- Logging with Serilog
- Configuration management
- Error handling strategies
- Deployment scenarios (IIS/Windows Service)
2026-07-07 18:59:57 +02:00
e789afe26a docs: add comprehensive implementation guide for AI agents
Step-by-step guide covering all remaining phases:
- Phase 2: Application Layer (Repositories, Services, Commands, Queries, Validators)
- Phase 3: Infrastructure Layer (DbContext, Repositories, External Services)
- Phase 4: API Layer (Controllers, Workers, Middleware)
- Phase 5: Configuration (appsettings, Serilog, Scalar)
- Phase 6: Testing (Unit tests, Integration tests)
- Phase 7: Documentation (README.md in German)
- Phase 8: Build and deployment

Includes complete code examples, best practices, and verification steps.
Future AI agents can follow this guide to continue development systematically.
2026-07-07 18:59:46 +02:00
dd04cd6cba docs: add legacy system analysis documentation
Comprehensive analysis of the VB.NET legacy system:
- Complete database schema documentation
- All table structures (TBDD_*, TBEMLP_* tables)
- Legacy business logic analysis
- VB.NET code patterns and conventions
- Migration considerations

This documentation ensures new implementation maintains compatibility
with existing database and business rules.
2026-07-07 18:59:37 +02:00
43101a6e61 feat(application): add DTOs and application layer dependencies
DTOs:
- EmailProfileDto: Profile data transfer object
- EmailAccountDto: Email account data transfer object
- EmailHistoryDto: Email history data transfer object
- EmailAttachmentDto: Attachment data transfer object

Dependencies:
- MediatR 14.2.0 for CQRS (Commands/Queries)
- AutoMapper 12.0.1 for entity-DTO mapping
- FluentValidation 12.1.1 for input validation

This provides the foundation for the application layer implementation.
2026-07-07 18:59:31 +02:00
146b56ff85 build(domain): add MediatR dependency for domain events
- Add MediatR 12.2.0 package for event-driven architecture
- Enables domain events like EmailProcessedEvent
2026-07-07 18:59:22 +02:00
721603bb47 feat(domain): add domain services and events
- MessageIdGenerator: Generates unique message IDs using legacy-compatible SHA256 hash
  Algorithm matches VB.NET system for duplicate detection
- EmailProcessedEvent: MediatR domain event for email processing completion

Domain services encapsulate business logic that doesn't belong to entities.
2026-07-07 18:59:17 +02:00
c97073775b feat(domain): add all domain entities with legacy database mapping
Entities mapped to legacy database tables using [Table] and [Column] attributes:
- EmailAccount → TBDD_EMAIL_ACCOUNT (OAuth2 and password auth support)
- EmailProfile → TBEMLP_POLL_PROFILES (polling configuration)
- EmailProcess → TBEMLP_POLL_PROCESS (process definitions)
- ProcessStep → TBEMLP_POLL_STEPS (indexing steps)
- IndexingStep → TBEMLP_POLL_INDEXING_STEPS (DMS indexing fields)
- EmailHistory → TBEMLP_HISTORY (processed emails)
- EmailAttachment → TBEMLP_HISTORY_ATTACHMENT (email attachments)
- EmailOutbox → TBEMLP_EMAIL_OUT (outgoing email queue)

All entities follow Clean Architecture and DDD principles.
Database schema is read-only - no migrations will modify existing tables.
SNAKE_CASE columns mapped to PascalCase properties.
2026-07-07 18:59:10 +02:00
18bb07cd93 feat(domain): add domain exception hierarchy
- DomainException: Base exception for all domain errors
- ValidationException: Business rule validation failures
- AttachmentProcessingException: Attachment-specific processing errors

Exceptions maintain error code compatibility with legacy system.
2026-07-07 18:58:59 +02:00
f6946d812a feat(domain): add value objects for email domain
- MessageId: Unique message identifier with SHA256 hash
  Uses same algorithm as legacy system for duplicate detection compatibility
  Hash format: SHA256({originalMessageId}|{sender}|{date}|{subject})
- EmailAddress: Email address validation and parsing with name support

Value objects ensure immutability and value-based equality.
2026-07-07 18:58:54 +02:00
6098112bb4 feat(domain): add domain enumerations
- ErrorCode: All error codes from legacy system (10001-10010)
- ProcessType: Email process types (ProcessManager, AttachmentSniffer, ZugFeRDParser)
- AuthenticationType: Authentication methods (UsernamePassword, OAuth2)
- EmailStatus: Email processing status tracking
- AttachmentStatus: Attachment validation status

These enums maintain compatibility with the legacy VB.NET system.
2026-07-07 18:58:18 +02:00
05a36e8045 feat(domain): add common base classes and interfaces
- Add BaseEntity with audit fields (CreatedDate, CreatedBy, ModifiedDate, ModifiedBy)
- Add IAggregateRoot marker interface for DDD aggregate roots
- Add ValueObject base class with equality comparison by value

These classes provide the foundation for all domain entities and value objects.
2026-07-07 18:58:10 +02:00
915d01fc03 Add project references and fix encoding issue in tests
Added project references to establish dependencies between
the API, Application, Domain, and Infrastructure projects.
Updated `DigitalData.EmailProfiler.Tests.csproj` to include
references to all layers for testing purposes. Fixed a BOM
encoding issue in the test project file. Added xUnit usage
directive to ensure proper test framework integration.
2026-07-07 14:38:55 +02:00
a88702d9e2 Update .gitignore to exclude specific files and paths
Updated the .gitignore file to ignore the following files and directories:
- `FodyWeavers.xsd`
- `/EnvelopeGenerator.Tests.Application/annotations.json`
- `/EnvelopeGenerator.Server/EnvelopeGenerator.Server/TekH - SoftHSM Test.md`
- `/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md`
- `/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output`
- `/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md+/legacy/App`

These changes ensure that unnecessary or sensitive files are excluded from version control.
2026-07-07 13:36:37 +02:00
1144f58ebb Add DigitalData.EmailProfiler.Tests project
A new test project, `DigitalData.EmailProfiler.Tests`, has been added to the solution. The project is configured as a .NET 8.0 test project with xUnit as the testing framework. It includes necessary NuGet dependencies such as `coverlet.collector`, `Microsoft.NET.Test.Sdk`, and `xunit.runner.visualstudio`.

The solution file has been updated to include the new project, along with its build configurations (`Debug|Any CPU` and `Release|Any CPU`). A new solution folder, `tests`, has been added, and the test project is nested under it.
2026-07-07 13:34:08 +02:00
690aee02dd Update solution and add new projects targeting .NET 8.0
Updated Visual Studio version in the solution file to 17.14.36717.8.
Added three new projects: Infrastructure, Domain, and Application,
all targeting .NET 8.0. Enabled implicit global usings and nullable
reference types in the new projects. Updated solution configuration
and nested the new projects under the `src` folder.
2026-07-07 13:31:56 +02:00
f3552dbdaa Add background service and update project configuration
Added a `Worker` class as a hosted background service to log
periodic messages. Updated `DigitalData.EmailProfiler.API.csproj`
to include `UserSecretsId` for secure development storage and
added `Microsoft.Extensions.Hosting` package. Replaced the
`Controllers` folder reference with `Properties`. Updated
`Program.cs` to register the `Worker` service, enable API
exploration, and retain Swagger configuration.
2026-07-07 13:25:58 +02:00
8a19a8a8bb init API 2026-07-07 13:18:24 +02:00
34baa6fbd9 Update .gitattributes and .gitignore for repo consistency
Improved repository configuration by updating `.gitattributes` to:
- Normalize line endings automatically.
- Define diff behavior for C# files and common document formats.
- Add optional merge driver settings for Visual Studio project files.
- Treat image files as binary.

Enhanced `.gitignore` to:
- Exclude Visual Studio-specific files, build outputs, and temporary files.
- Ignore files generated by add-ons, testing frameworks, and tools.
- Add project-specific exclusions for `EnvelopeGenerator`.

These changes enhance maintainability, reduce clutter, and prevent unnecessary files from being committed.
2026-07-07 13:18:15 +02:00
94 changed files with 7455 additions and 1 deletions

63
.gitattributes vendored Normal file
View File

@@ -0,0 +1,63 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

375
.gitignore vendored Normal file
View File

@@ -0,0 +1,375 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
/EnvelopeGenerator.Web/.config/dotnet-tools.json
/EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/.vscode
/EnvelopeGenerator.Tests.Application/Services/BugFixTests.cs
/EnvelopeGenerator.Tests.Application/annotations.json
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/TekH - SoftHSM Test.md
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
/legacy/App
/src/DigitalData.MessagingService.API/appsettings.Secrets.json
/src/presentation/DigitalData.MessagingService.API/appsettings.Secrets.json
/src/presentation/DigitalData.MessagingService.API/appsettings.Secrets.json

714
AGENTS.md Normal file
View File

@@ -0,0 +1,714 @@
# MessagingService - Agent Notes and Future Enhancements
## Purpose
This document contains important notes, decisions, and future enhancement plans for the MessagingService application. This is intended for AI agents and developers who will continue development.
---
## Important Notes
### 1. Database Schema - DO NOT MODIFY
**CRITICAL**: The database schema must NEVER be modified. All Entity Framework entities must map to existing legacy tables using `[Table]` and `[Column]` attributes.
**Naming Convention**:
- Database: `SNAKE_CASE` with prefixes (TBEMLP_, TBDD_)
- C# Entities: `PascalCase` without prefixes
- Use `[Table("TBDD_FOO")]` and `[Column("COLUMN_NAME")]` attributes
**Example**:
```csharp
[Table("TBDD_EMAIL_ACCOUNT")]
public class EmailAccount
{
[Column("EMAIL_ACCOUNT_ID")]
public int Id { get; set; }
[Column("ACCOUNT_NAME")]
public string AccountName { get; set; }
}
```
### 2. Message ID Hash Algorithm
The `MessageIdGenerator` in `Domain.Services` must use **exactly the same algorithm** as the legacy system to ensure duplicate detection works correctly.
**Algorithm**: SHA256 hash of `{originalMessageId}|{sender}|{date:yyyyMMddHHmmss}|{subject}`
### 3. DateTime Usage - ALWAYS Use Local Time
**CRITICAL**: Always use `DateTime.Now` instead of `DateTime.UtcNow` throughout the entire application.
**Reason**: The legacy system uses local server time, and the database stores all timestamps as local time. Using UTC would break compatibility and cause incorrect time comparisons.
**Examples**:
```csharp
// ✅ CORRECT
profile.CreatedDate = DateTime.Now;
var lastPoll = DateTime.Now.AddMinutes(-profile.PollIntervalMinutes);
// ❌ WRONG - DO NOT USE
profile.CreatedDate = DateTime.UtcNow; // NEVER USE UTC
var lastPoll = DateTime.UtcNow.AddMinutes(-profile.PollIntervalMinutes); // NEVER USE UTC
```
**Important**: This applies to:
- All entity audit fields (CreatedDate, ModifiedDate, LastPollDate, etc.)
- All date comparisons in business logic
- All timestamps in logs and error messages
- All date parameters in queries
### 4. Git Operations - NEVER Without Explicit Permission
**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`)
**Folder Structure** (NO Features/ prefix):
```
Application/
├── EmailProfiles/
│ ├── Commands/CreateEmailProfileCommand.cs
│ ├── Queries/GetEmailProfilesQuery.cs
│ └── Validators/CreateEmailProfileCommandValidator.cs
├── EmailAccounts/
│ ├── Commands/CreateEmailAccountCommand.cs
│ └── Queries/GetEmailAccountsQuery.cs
└── Common/
├── Dtos/EmailProfileDto.cs (single DTOs at root)
├── Dtos/EmailHistories/ (multiple DTOs in subfolder)
└── Interfaces/IEmailService.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
### 7. RabbitMQ Command Bus Integration (IMPLEMENTED)
**Purpose**: Asynchronous command processing via RabbitMQ message broker for POST/PUT/DELETE operations.
**Architecture**:
- **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**:
1. **Add NuGet Package**:
```bash
dotnet add package RabbitMQ.Client
```
2. **Create RabbitMqEmailQueue.cs**:
```csharp
// src/DigitalData.MessagingService.Infrastructure/Queue/RabbitMqEmailQueue.cs
public class RabbitMqEmailQueue : IEmailQueue
{
private readonly IConnection _connection;
private readonly IModel _channel;
private const string QueueName = "email-outbox";
public RabbitMqEmailQueue(IOptions<RabbitMqConfiguration> config)
{
var factory = new ConnectionFactory
{
HostName = config.Value.HostName,
Port = config.Value.Port,
UserName = config.Value.UserName,
Password = config.Value.Password
};
_connection = factory.CreateConnection();
_channel = _connection.CreateModel();
_channel.QueueDeclare(
queue: QueueName,
durable: true,
exclusive: false,
autoDelete: false,
arguments: null);
}
public async Task EnqueueAsync(SendingEmail email, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(email);
var body = Encoding.UTF8.GetBytes(json);
var properties = _channel.CreateBasicProperties();
properties.Persistent = true;
_channel.BasicPublish(
exchange: "",
routingKey: QueueName,
basicProperties: properties,
body: body);
await Task.CompletedTask;
}
public async Task<SendingEmail?> DequeueAsync(CancellationToken cancellationToken)
{
var result = _channel.BasicGet(QueueName, autoAck: false);
if (result == null)
return null;
var json = Encoding.UTF8.GetString(result.Body.ToArray());
var email = JsonSerializer.Deserialize<SendingEmail>(json);
_channel.BasicAck(result.DeliveryTag, false);
return await Task.FromResult(email);
}
}
```
3. **Configuration** (appsettings.json):
```json
{
"RabbitMq": {
"HostName": "localhost",
"Port": 5672,
"UserName": "guest",
"Password": "guest"
}
}
```
4. **Dependency Injection** (Program.cs):
```csharp
// Replace InMemoryEmailQueue with RabbitMqEmailQueue
// builder.Services.AddSingleton<IEmailQueue, InMemoryEmailQueue>();
builder.Services.AddSingleton<IEmailQueue, RabbitMqEmailQueue>();
```
**Benefits**:
- Message persistence (survives application restarts)
- Scalability (multiple worker instances can consume from queue)
- Reliability (automatic retries, dead letter queues)
- Monitoring (RabbitMQ management UI)
**Migration Path**:
1. Deploy RabbitMQ server (Docker recommended)
2. Test RabbitMqEmailQueue in staging environment
3. Switch DI registration from InMemoryEmailQueue to RabbitMqEmailQueue
4. Monitor queue depth and worker performance
---
## Pending Implementation Tasks
### Phase 2: Application Layer (COMPLETE)
**Status**: ✅ Complete - All Commands, Queries, Handlers, Validators, AutoMapper Profiles, and Interfaces implemented
**Completed**:
- ✅ MediatR Commands (CreateEmailProfileCommand, ProcessEmailCommand, etc.)
- ✅ MediatR Queries (GetEmailProfilesQuery, GetEmailHistoryQuery, etc.)
- ✅ Command/Query Handlers
- ✅ FluentValidation Validators
- ✅ AutoMapper Profiles
- ✅ Application Interfaces (IEmailService, IPdfProcessingService, IDmsService, etc.)
**Example Command**:
```csharp
// src/DigitalData.MessagingService.Application/EmailProfiles/Commands/CreateEmailProfileCommand.cs
public record CreateEmailProfileCommand(string ProfileName, int EmailAccountId) : IRequest<int>;
public class CreateEmailProfileCommandHandler : IRequestHandler<CreateEmailProfileCommand, int>
{
private readonly IRepository<EmailProfile> _repository;
public async Task<int> Handle(CreateEmailProfileCommand request, CancellationToken cancellationToken)
{
var profile = await _repository.CreateAsync(request, cancellationToken);
return profile.Id;
}
}
```
### 8. Email Library - Limilabs Mail.dll
**IMPORTANT**: This project uses **Limilabs Mail.dll** (https://www.limilabs.com/) for email operations, NOT MailKit/MimeKit.
**Why Limilabs?**:
- Commercial-grade IMAP/POP3/SMTP library
- Better OAuth2 support (Microsoft 365, Gmail)
- More reliable with Exchange servers
- Superior attachment handling
- Built-in retry mechanisms
**NuGet Package**:
```bash
dotnet add package Limilabs.Mail
```
**Key Classes**:
- `Imap` - IMAP client for receiving emails
- `Smtp` - SMTP client for sending emails
- `Mail.Message` - Email message representation
- `OAuth2` - OAuth2 authentication helper
**Implementation Example**:
```csharp
// Limilabs IMAP with OAuth2
using Limilabs.Client.IMAP;
using Limilabs.Mail;
public class LimilabsEmailService : IEmailService
{
public async Task<IEnumerable<EmailMessage>> ReceiveEmailsAsync(EmailAccountDto account)
{
using var imap = new Imap();
if (account.UseOAuth2)
{
await imap.ConnectSSLAsync(account.ImapServer, account.ImapPort);
await imap.LoginOAUTH2Async(account.Username, account.OAuth2AccessToken);
}
else
{
await imap.ConnectSSLAsync(account.ImapServer, account.ImapPort);
await imap.LoginAsync(account.Username, account.EncryptedPassword);
}
imap.SelectInbox();
var uids = imap.Search(Flag.Unseen);
var messages = new List<EmailMessage>();
foreach (var uid in uids)
{
var eml = imap.GetMessageByUID(uid);
var mail = new MailBuilder().CreateFromEml(eml);
messages.Add(ConvertToEmailMessage(mail));
}
imap.Close();
return messages;
}
}
```
**DO NOT USE**:
- ❌ MailKit
- ❌ MimeKit
- ❌ System.Net.Mail (obsolete)
### Phase 3: Infrastructure Layer (COMPLETE)
**Status**: ✅ Complete - DbContext, Repository, Services, RabbitMQ, and DI implemented
**Completed**:
- ✅ MessagingServiceDbContext with DbSet<T> for all entities (attribute-only config, no overrides)
- ✅ Generic Repository<T> implementing IRepository<T> with AutoMapper-based CRUD
- ✅ LimilabsEmailService (IMAP/SMTP with OAuth2 using Limilabs Mail.dll - TODO: Add Limilabs.Mail NuGet)
- ✅ GdPicturePdfProcessingService (using GdPicture.NET 14 - TODO: Add GdPicture NuGet and license)
- ✅ WindreamDmsService (COM Interop - TODO: Add windream COM Interop references)
- ✅ DataProtectionEncryptionService (Data Protection API)
- ✅ InMemoryEmailQueue (TODO: Upgrade to RabbitMqEmailQueue later)
- ✅ RabbitMqCommandPublisher and RabbitMqCommandConsumer
- ✅ DependencyInjection.cs with all service registrations
**Implementation Notes**:
- All services have real implementations with commented TODO blocks for external dependencies
- LimilabsEmailService uses Microsoft.Identity.Client for OAuth2 token acquisition
- GdPicturePdfProcessingService uses GdPicture.NET 14.x API (GetAttachmentCount, ExtractEmbeddedFile)
- WindreamDmsService uses COM Interop (WMSession, WMConnect, WMObjects) based on legacy patterns
- NO EF Core migrations (legacy DB must not be modified)
**DbContext Example**:
```csharp
public class MessagingServiceDbContext : DbContext
{
public DbSet<EmailAccount> EmailAccounts { get; set; }
public DbSet<EmailProfile> EmailProfiles { get; set; }
// ... other DbSets
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
// Important: Check for triggers
modelBuilder.Entity<EmailHistory>().ToTable(tb => tb.HasTrigger("TR_TBEMLP_HISTORY_AUDIT"));
}
}
```
### Phase 4: API Layer
**Status**: Minimal structure exists
**TODO**:
- [ ] Create Controllers (EmailProfilesController, EmailAccountsController, EmailHistoryController)
- [ ] Create Background Workers (EmailPollingWorker, EmailSenderWorker)
- [ ] Configure Serilog
- [ ] Configure Scalar (OpenAPI documentation)
- [ ] Add Exception Handling Middleware
- [ ] Configure DI for all layers
- [ ] Support both IIS and Windows Service hosting
**Worker Configuration** (appsettings.json):
```json
{
"Workers": {
"EmailPolling": {
"Enabled": true,
"IntervalSeconds": 60
},
"EmailSender": {
"Enabled": true,
"IntervalSeconds": 5
}
},
"Hosting": {
"Mode": "IIS" // or "WindowsService"
}
}
```
### Phase 5: Testing
**Status**: Not started
**TODO**:
- [ ] Unit tests for Domain entities
- [ ] Unit tests for Application handlers (using FakeItEasy)
- [ ] Integration tests for Repositories (using Testcontainers)
- [ ] API tests (using WebApplicationFactory)
- [ ] Generate fake test data (using Bogus)
**Test Example**:
```csharp
public class MessageIdGeneratorTests
{
[Fact]
public void Generate_ShouldProduceSameHashAsLegacy()
{
// Arrange
var generator = new MessageIdGenerator();
var original = "msg-123";
var sender = "test@example.com";
var date = new DateTime(2026, 1, 1, 12, 0, 0);
var subject = "Test Subject";
// Act
var messageId = generator.Generate(original, sender, date, subject);
// Assert
messageId.Hash.Should().NotBeNullOrEmpty();
// TODO: Verify against known legacy hash
}
}
```
---
## Architecture Decisions
### Clean Architecture Layers
1. **Domain**: Core business logic, no dependencies
2. **Application**: Use cases, depends on Domain
3. **Infrastructure**: External concerns, depends on Domain + Application
4. **API**: Entry point, depends on all
### CQRS Pattern with MediatR
- **Commands**: Modify state (Create, Update, Delete)
- **Queries**: Read data (Get, List)
- Separate models for read and write operations
### Repository Pattern
- Interface in Application layer
- Implementation in Infrastructure layer
- One repository per Aggregate Root
---
## Known Issues and Limitations
### 1. PdfSharp Embedded File Extraction
PdfSharp has limited support for embedded file extraction from PDFs. If advanced PDF processing is needed, consider:
- **iText7** (AGPL or commercial license)
- **Aspose.PDF** (commercial license)
- Custom PDF parsing using PDF specification
### 2. windream COM Interop
The windream DMS integration uses COM Interop which is Windows-only. The application cannot be fully cross-platform unless windream provides a REST API alternative.
### 3. OAuth2 Token Refresh
Current implementation acquires new tokens on each request. Consider implementing token caching:
- Use `Microsoft.Identity.Web` for automatic token management
- Cache tokens in memory or distributed cache (Redis)
---
## Development Guidelines
### 1. Code Style
- All code and comments: **English**
- README.md and user documentation: **German**
- Follow C# naming conventions (PascalCase, camelCase)
- Use nullable reference types (`#nullable enable`)
### 2. Logging
Use Serilog with structured logging:
```csharp
_logger.LogInformation("Processing email {MessageId} from profile {ProfileId}", messageId, profileId);
```
### 3. Configuration
- Development: `appsettings.Development.json` + User Secrets
- Production: `appsettings.json` + Environment Variables + Azure Key Vault
### 4. Error Handling
- Domain: Throw `DomainException` for business rule violations
- Application: Use `FluentValidation` for input validation
- API: Use exception handling middleware to return proper HTTP status codes
---
## Deployment Scenarios
### IIS Hosting (Default)
```json
{
"Hosting": {
"Mode": "IIS"
}
}
```
### Windows Service Hosting
```json
{
"Hosting": {
"Mode": "WindowsService"
}
}
```
In `Program.cs`:
```csharp
var builder = WebApplication.CreateBuilder(args);
if (builder.Configuration["Hosting:Mode"] == "WindowsService")
{
builder.Host.UseWindowsService();
}
```
Install as Windows Service:
```bash
sc create MessagingService binPath="C:\Path\To\DigitalData.MessagingService.API.exe"
```
---
## Contact and Support
For questions about this implementation, consult:
- Legacy system analysis: `legacy/PROJECT_ANALYSIS.md`
- Migration plan: `MIGRATION_PLAN.md` (if created)
- This document: `agents.md`
---
**Last Updated**: 2026-07-07
**Version**: 1.0
**Status**: Phase 1 Complete (Domain Layer), Phase 2-8 Pending

View File

@@ -0,0 +1,98 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36717.8
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Tests", "tests\DigitalData.MessagingService.Tests\DigitalData.MessagingService.Tests.csproj", "{211FB65F-2406-474E-A426-DA246B250AB8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4F20FEFD-9289-42C6-ABA6-8DB236D74559}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
ProjectSection(SolutionItems) = preProject
agents.md = agents.md
IMPLEMENTATION_GUIDE.md = IMPLEMENTATION_GUIDE.md
README.md = README.md
STATUS.md = STATUS.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "presentation", "presentation", "{B52B4CEE-1C67-424B-8659-370FEA7EAF2A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "core", "core", "{DD9D4A3A-AB55-456E-80D3-54A2D4025E64}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "infrastructure", "infrastructure", "{71BEA4D0-7835-4A8C-B11E-1088E0801DCE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Application", "src\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj", "{7CBE8648-F259-CC91-87FF-5859280867A8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Domain", "src\core\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj", "{8E44FA5B-43DD-E273-C682-FC382A854A6D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Infrastructure", "src\infrastructure\DigitalData.MessagingService.Infrastructure\DigitalData.MessagingService.Infrastructure.csproj", "{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.RabbitMQ", "src\infrastructure\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj", "{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.API", "src\presentation\DigitalData.MessagingService.API\DigitalData.MessagingService.API.csproj", "{8BF22107-3CB9-C326-B94B-C40C99DA9B68}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Publisher", "src\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj", "{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Client", "src\presentation\DigitalData.MessagingService.Client\DigitalData.MessagingService.Client.csproj", "{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{211FB65F-2406-474E-A426-DA246B250AB8}.Debug|Any CPU.ActiveCfg = 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.Build.0 = Release|Any CPU
{7CBE8648-F259-CC91-87FF-5859280867A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7CBE8648-F259-CC91-87FF-5859280867A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7CBE8648-F259-CC91-87FF-5859280867A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7CBE8648-F259-CC91-87FF-5859280867A8}.Release|Any CPU.Build.0 = Release|Any CPU
{8E44FA5B-43DD-E273-C682-FC382A854A6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8E44FA5B-43DD-E273-C682-FC382A854A6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8E44FA5B-43DD-E273-C682-FC382A854A6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8E44FA5B-43DD-E273-C682-FC382A854A6D}.Release|Any CPU.Build.0 = Release|Any CPU
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Release|Any CPU.Build.0 = Release|Any CPU
{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}.Release|Any CPU.Build.0 = Release|Any CPU
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Release|Any CPU.Build.0 = Release|Any CPU
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Release|Any CPU.Build.0 = Release|Any CPU
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Debug|Any CPU.ActiveCfg = Release|Any CPU
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Debug|Any CPU.Build.0 = Release|Any CPU
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Release|Any CPU.ActiveCfg = Release|Any CPU
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{211FB65F-2406-474E-A426-DA246B250AB8} = {4F20FEFD-9289-42C6-ABA6-8DB236D74559}
{B52B4CEE-1C67-424B-8659-370FEA7EAF2A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{DD9D4A3A-AB55-456E-80D3-54A2D4025E64} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{71BEA4D0-7835-4A8C-B11E-1088E0801DCE} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{7CBE8648-F259-CC91-87FF-5859280867A8} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
{8E44FA5B-43DD-E273-C682-FC382A854A6D} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
{8BF22107-3CB9-C326-B94B-C40C99DA9B68} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}
EndGlobalSection
EndGlobal

954
IMPLEMENTATION_GUIDE.md Normal file
View File

@@ -0,0 +1,954 @@
# MessagingService - Implementation Guide for AI Agents
## Overview
This guide provides step-by-step instructions for AI agents to continue the implementation of the MessagingService application. The project is a modern .NET 8.0 rewrite of a legacy VB.NET email automation system.
---
## Current Status (2026-07-07)
**COMPLETED**:
- Domain Layer (100%)
- All entities with proper `[Table]` and `[Column]` attributes
- Value Objects (MessageId, EmailAddress)
- Enums (ErrorCode, ProcessType, etc.)
- Domain Services (MessageIdGenerator)
- Domain Events (EmailProcessedEvent)
- Exceptions (DomainException, ValidationException, AttachmentProcessingException)
- agents.md documentation
- Project builds successfully
🚧 **IN PROGRESS**:
- Application Layer (5% - only DTOs created)
**PENDING**:
- Application Layer (95%)
- Infrastructure Layer (0%)
- API Layer (minimal structure only)
- Testing (0%)
- README.md documentation (0%)
---
## Architecture Overview
```
DigitalData.MessagingService/
├── src/
│ ├── Domain/ ✅ COMPLETE
│ ├── Application/ 🚧 IN PROGRESS (5%)
│ ├── Infrastructure/ ❌ TODO
│ └── API/ ❌ TODO (minimal structure exists)
├── tests/
│ └── Tests/ ❌ TODO
├── legacy/ 📖 Reference only
├── agents.md ✅ COMPLETE
├── README.md ❌ TODO
└── IMPLEMENTATION_GUIDE.md 📄 This file
```
---
## Phase-by-Phase Implementation Plan
### PHASE 2: Application Layer (Current Focus)
#### 2.1. Create Repository Interfaces
**Location**: `src/DigitalData.MessagingService.Application/Interfaces/Repositories/`
Create these files:
**IEmailProfileRepository.cs**:
```csharp
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Interfaces.Repositories;
public interface IEmailProfileRepository
{
Task<EmailProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<List<EmailProfile>> GetAllAsync(CancellationToken cancellationToken = default);
Task<List<EmailProfile>> GetActiveProfilesAsync(CancellationToken cancellationToken = default);
Task<List<EmailProfile>> GetProfilesDueForPollingAsync(CancellationToken cancellationToken = default);
Task<int> AddAsync(EmailProfile profile, CancellationToken cancellationToken = default);
Task UpdateAsync(EmailProfile profile, CancellationToken cancellationToken = default);
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
}
```
**IEmailAccountRepository.cs**:
```csharp
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Interfaces.Repositories;
public interface IEmailAccountRepository
{
Task<EmailAccount?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<List<EmailAccount>> GetAllAsync(CancellationToken cancellationToken = default);
Task<List<EmailAccount>> GetActiveAccountsAsync(CancellationToken cancellationToken = default);
Task<int> AddAsync(EmailAccount account, CancellationToken cancellationToken = default);
Task UpdateAsync(EmailAccount account, CancellationToken cancellationToken = default);
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
}
```
**IEmailHistoryRepository.cs**:
```csharp
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Interfaces.Repositories;
public interface IEmailHistoryRepository
{
Task<EmailHistory?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<EmailHistory?> GetByMessageIdHashAsync(string hash, CancellationToken cancellationToken = default);
Task<bool> ExistsAsync(string messageIdHash, CancellationToken cancellationToken = default);
Task<List<EmailHistory>> GetByProfileIdAsync(int profileId, DateTime? from, DateTime? to, CancellationToken cancellationToken = default);
Task<int> AddAsync(EmailHistory history, CancellationToken cancellationToken = default);
Task UpdateAsync(EmailHistory history, CancellationToken cancellationToken = default);
}
```
**IEmailProcessRepository.cs**, **IEmailOutboxRepository.cs** - Similar patterns.
#### 2.2. Create Service Interfaces
**Location**: `src/DigitalData.MessagingService.Application/Interfaces/Services/`
**IEmailService.cs**:
```csharp
namespace DigitalData.MessagingService.Application.Interfaces.Services;
public interface IEmailService
{
Task<List<EmailMessage>> FetchUnreadEmailsAsync(
EmailAccount account,
CancellationToken cancellationToken = default);
Task<bool> TestConnectionAsync(
EmailAccount account,
CancellationToken cancellationToken = default);
Task SendEmailAsync(
EmailAccount account,
string recipient,
string subject,
string body,
bool isHtml = true,
CancellationToken cancellationToken = default);
Task DeleteEmailAsync(EmailAccount account, int imapUid, CancellationToken cancellationToken = default);
Task MoveEmailAsync(EmailAccount account, int imapUid, string folderName, CancellationToken cancellationToken = default);
}
public class EmailMessage
{
public int ImapUid { get; set; }
public string MessageId { get; set; } = string.Empty;
public string From { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public DateTime Date { get; set; }
public string BodyHtml { get; set; } = string.Empty;
public string BodyText { get; set; } = string.Empty;
public List<EmailAttachmentData> Attachments { get; set; } = new();
public byte[] RawEmailData { get; set; } = Array.Empty<byte>();
}
public class EmailAttachmentData
{
public string FileName { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public byte[] Data { get; set; } = Array.Empty<byte>();
}
```
**IPdfProcessingService.cs**, **IDmsService.cs**, **IEncryptionService.cs**, **IEmailQueue.cs** - See agents.md for examples.
#### 2.3. Create MediatR Commands
**Location**: `src/DigitalData.MessagingService.Application/EmailProfiles/Commands/`
**CreateEmailProfileCommand.cs**:
```csharp
using MediatR;
namespace DigitalData.MessagingService.Application.EmailProfiles.Commands;
public record CreateEmailProfileCommand(
string ProfileName,
int EmailAccountId,
int? ProcessId,
int PollIntervalMinutes) : IRequest<int>;
public class CreateEmailProfileCommandHandler : IRequestHandler<CreateEmailProfileCommand, int>
{
private readonly IEmailProfileRepository _repository;
public CreateEmailProfileCommandHandler(IEmailProfileRepository repository)
{
_repository = repository;
}
public async Task<int> Handle(CreateEmailProfileCommand request, CancellationToken cancellationToken)
{
var profile = new EmailProfile
{
ProfileName = request.ProfileName,
EmailAccountId = request.EmailAccountId,
ProcessId = request.ProcessId,
PollIntervalMinutes = request.PollIntervalMinutes,
IsActive = true,
AddedWhen = DateTime.UtcNow
};
return await _repository.AddAsync(profile, cancellationToken);
}
}
```
**UpdateEmailProfileCommand.cs**, **DeleteEmailProfileCommand.cs**, **ActivateProfileCommand.cs** - Similar patterns.
#### 2.4. Create MediatR Queries
**Location**: `src/DigitalData.MessagingService.Application/EmailProfiles/Queries/`
**GetEmailProfilesQuery.cs**:
```csharp
using MediatR;
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Dtos;
namespace DigitalData.MessagingService.Application.EmailProfiles.Queries;
public record GetEmailProfilesQuery : IRequest<List<EmailProfileDto>>;
public class GetEmailProfilesQueryHandler : IRequestHandler<GetEmailProfilesQuery, List<EmailProfileDto>>
{
private readonly IEmailProfileRepository _repository;
private readonly IMapper _mapper;
public GetEmailProfilesQueryHandler(IEmailProfileRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<List<EmailProfileDto>> Handle(GetEmailProfilesQuery request, CancellationToken cancellationToken)
{
var profiles = await _repository.GetAllAsync(cancellationToken);
return _mapper.Map<List<EmailProfileDto>>(profiles);
}
}
```
**GetEmailProfileByIdQuery.cs**, **GetActiveProfilesQuery.cs**, **GetProfilesDueForPollingQuery.cs** - Similar patterns.
#### 2.5. Create Validators
**Location**: `src/DigitalData.MessagingService.Application/EmailProfiles/Validators/`
**CreateEmailProfileCommandValidator.cs**:
```csharp
using FluentValidation;
using DigitalData.MessagingService.Application.EmailProfiles.Commands;
namespace DigitalData.MessagingService.Application.EmailProfiles.Validators;
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)
.GreaterThan(0).WithMessage("Poll interval must be greater than 0")
.LessThanOrEqualTo(1440).WithMessage("Poll interval must not exceed 1440 minutes (24 hours)");
}
}
```
#### 2.6. Create AutoMapper Profiles
**Location**: `src/DigitalData.MessagingService.Application/Common/Mappings/`
**MappingProfile.cs**:
```csharp
using AutoMapper;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Application.Common.Dtos;
namespace DigitalData.MessagingService.Application.Common.Mappings;
public class MappingProfile : Profile
{
public MappingProfile()
{
// EmailProfile mappings
CreateMap<EmailProfile, EmailProfileDto>()
.ForMember(d => d.EmailAccountName, opt => opt.MapFrom(s => s.EmailAccount != null ? s.EmailAccount.AccountName : null))
.ForMember(d => d.ProcessName, opt => opt.MapFrom(s => s.EmailProcess != null ? s.EmailProcess.ProcessName : null));
// EmailAccount mappings
CreateMap<EmailAccount, EmailAccountDto>();
// EmailHistory mappings
CreateMap<EmailHistory, EmailHistoryDto>()
.ForMember(d => d.ProfileName, opt => opt.MapFrom(s => s.Profile != null ? s.Profile.ProfileName : null))
.ForMember(d => d.Attachments, opt => opt.MapFrom(s => s.Attachments));
// EmailAttachment mappings
CreateMap<EmailAttachment, EmailAttachmentDto>();
}
}
```
#### 2.7. Create DependencyInjection.cs
**Location**: `src/DigitalData.MessagingService.Application/DependencyInjection.cs`
```csharp
using Microsoft.Extensions.DependencyInjection;
using FluentValidation;
using System.Reflection;
namespace DigitalData.MessagingService.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
var assembly = Assembly.GetExecutingAssembly();
// MediatR
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
// AutoMapper
services.AddAutoMapper(assembly);
// FluentValidation
services.AddValidatorsFromAssembly(assembly);
return services;
}
}
```
---
### PHASE 3: Infrastructure Layer
#### 3.1. Add NuGet Packages
```bash
cd src/DigitalData.MessagingService.Infrastructure
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package MailKit
dotnet add package MimeKit
dotnet add package PdfSharp
dotnet add package Microsoft.Identity.Client
dotnet add package Microsoft.AspNetCore.DataProtection
```
#### 3.2. Create DbContext
**Location**: `src/DigitalData.MessagingService.Infrastructure/Persistence/MessagingServiceDbContext.cs`
```csharp
using Microsoft.EntityFrameworkCore;
using DigitalData.MessagingService.Domain.Entities;
using System.Reflection;
namespace DigitalData.MessagingService.Infrastructure.Persistence;
public class MessagingServiceDbContext : DbContext
{
public MessagingServiceDbContext(DbContextOptions<MessagingServiceDbContext> options) : base(options) { }
public DbSet<EmailAccount> EmailAccounts { get; set; }
public DbSet<EmailProfile> EmailProfiles { get; set; }
public DbSet<EmailProcess> EmailProcesses { get; set; }
public DbSet<ProcessStep> ProcessSteps { get; set; }
public DbSet<IndexingStep> IndexingSteps { get; set; }
public DbSet<EmailHistory> EmailHistories { get; set; }
public DbSet<EmailAttachment> EmailAttachments { get; set; }
public DbSet<EmailOutbox> EmailOutbox { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Apply configurations from assembly (if you create IEntityTypeConfiguration classes)
// modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
// Note: All entity configurations are already done via attributes in Domain entities
// This is important - DO NOT modify database schema here!
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// Auto-populate audit fields
var entries = ChangeTracker.Entries<BaseEntity>();
foreach (var entry in entries)
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedDate = DateTime.UtcNow;
entry.Entity.CreatedBy = "System"; // TODO: Get from current user context
}
if (entry.State == EntityState.Modified)
{
entry.Entity.ModifiedDate = DateTime.UtcNow;
entry.Entity.ModifiedBy = "System"; // TODO: Get from current user context
}
}
return base.SaveChangesAsync(cancellationToken);
}
}
```
#### 3.3. Create Repositories
**Location**: `src/DigitalData.MessagingService.Infrastructure/Persistence/Repositories/`
**EmailProfileRepository.cs**:
```csharp
using Microsoft.EntityFrameworkCore;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Application.Interfaces.Repositories;
namespace DigitalData.MessagingService.Infrastructure.Persistence.Repositories;
public class EmailProfileRepository : IEmailProfileRepository
{
private readonly MessagingServiceDbContext _context;
public EmailProfileRepository(MessagingServiceDbContext context)
{
_context = context;
}
public async Task<EmailProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.EmailProfiles
.Include(p => p.EmailAccount)
.Include(p => p.EmailProcess)
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
}
public async Task<List<EmailProfile>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.EmailProfiles
.Include(p => p.EmailAccount)
.Include(p => p.EmailProcess)
.OrderBy(p => p.Sequence)
.ToListAsync(cancellationToken);
}
public async Task<List<EmailProfile>> GetActiveProfilesAsync(CancellationToken cancellationToken = default)
{
return await _context.EmailProfiles
.Include(p => p.EmailAccount)
.Include(p => p.EmailProcess)
.Where(p => p.IsActive && p.EmailAccount!.IsActive)
.OrderBy(p => p.Sequence)
.ToListAsync(cancellationToken);
}
public async Task<List<EmailProfile>> GetProfilesDueForPollingAsync(CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
return await _context.EmailProfiles
.Include(p => p.EmailAccount)
.Include(p => p.EmailProcess)
.Where(p => p.IsActive
&& p.EmailAccount!.IsActive
&& (!p.LastPollTime.HasValue ||
EF.Functions.DateDiffMinute(p.LastPollTime.Value, now) >= p.PollIntervalMinutes))
.OrderBy(p => p.Sequence)
.ToListAsync(cancellationToken);
}
public async Task<int> AddAsync(EmailProfile profile, CancellationToken cancellationToken = default)
{
_context.EmailProfiles.Add(profile);
await _context.SaveChangesAsync(cancellationToken);
return profile.Id;
}
public async Task UpdateAsync(EmailProfile profile, CancellationToken cancellationToken = default)
{
_context.EmailProfiles.Update(profile);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
{
var profile = await GetByIdAsync(id, cancellationToken);
if (profile != null)
{
_context.EmailProfiles.Remove(profile);
await _context.SaveChangesAsync(cancellationToken);
}
}
}
```
Create similar repositories for **EmailAccountRepository**, **EmailHistoryRepository**, etc.
#### 3.4. Create External Services
**MailKitEmailService.cs**, **PdfSharpProcessingService.cs**, **WindreamDmsService.cs**, **EncryptionService.cs**, **InMemoryEmailQueue.cs**
(See agents.md for examples - these are complex services)
#### 3.5. Create DependencyInjection.cs
**Location**: `src/DigitalData.MessagingService.Infrastructure/DependencyInjection.cs`
```csharp
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using DigitalData.MessagingService.Infrastructure.Persistence;
using DigitalData.MessagingService.Application.Interfaces.Repositories;
using DigitalData.MessagingService.Infrastructure.Persistence.Repositories;
namespace DigitalData.MessagingService.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// DbContext
services.AddDbContext<MessagingServiceDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
sqlOptions.CommandTimeout(60);
}));
// Repositories
services.AddScoped<IEmailProfileRepository, EmailProfileRepository>();
services.AddScoped<IEmailAccountRepository, EmailAccountRepository>();
services.AddScoped<IEmailHistoryRepository, EmailHistoryRepository>();
// ... add other repositories
// External Services
// services.AddScoped<IEmailService, MailKitEmailService>();
// services.AddScoped<IPdfProcessingService, PdfSharpProcessingService>();
// services.AddScoped<IDmsService, WindreamDmsService>();
// services.AddScoped<IEncryptionService, DataProtectionEncryptionService>();
// services.AddSingleton<IEmailQueue, InMemoryEmailQueue>();
return services;
}
}
```
---
### PHASE 4: API Layer
#### 4.1. Add NuGet Packages
```bash
cd src/DigitalData.MessagingService.API
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Sinks.MSSqlServer
dotnet add package Scalar.AspNetCore
```
#### 4.2. Update Program.cs
**Location**: `src/DigitalData.MessagingService.API/Program.cs`
```csharp
using DigitalData.MessagingService.API;
using DigitalData.MessagingService.Application;
using DigitalData.MessagingService.Infrastructure;
using Serilog;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File("logs/emailprofiler-.log", rollingInterval: RollingInterval.Day)
.CreateLogger();
builder.Host.UseSerilog();
// Check for Windows Service mode
if (builder.Configuration["Hosting:Mode"] == "WindowsService")
{
builder.Host.UseWindowsService();
}
// Add services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Add Application and Infrastructure layers
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
// Add Background Workers
// builder.Services.AddHostedService<EmailPollingWorker>();
// builder.Services.AddHostedService<EmailSenderWorker>();
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
// Add Scalar
app.MapScalarApiReference();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
try
{
Log.Information("Starting MessagingService API");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application start-up failed");
}
finally
{
Log.CloseAndFlush();
}
```
#### 4.3. Create Controllers
**Location**: `src/DigitalData.MessagingService.API/Controllers/`
**EmailProfilesController.cs**:
```csharp
using Microsoft.AspNetCore.Mvc;
using MediatR;
using DigitalData.MessagingService.Application.EmailProfiles.Commands;
using DigitalData.MessagingService.Application.EmailProfiles.Queries;
namespace DigitalData.MessagingService.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class EmailProfilesController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<EmailProfilesController> _logger;
public EmailProfilesController(IMediator mediator, ILogger<EmailProfilesController> logger)
{
_mediator = mediator;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> GetAll(CancellationToken cancellationToken)
{
var query = new GetEmailProfilesQuery();
var result = await _mediator.Send(query, cancellationToken);
return Ok(result);
}
[HttpGet("{id}")]
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();
return Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateEmailProfileCommand command, CancellationToken cancellationToken)
{
var id = await _mediator.Send(command, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id }, id);
}
// Add Update, Delete, Activate, Deactivate endpoints
}
```
Create similar controllers for **EmailAccountsController**, **EmailHistoryController**, **DashboardController**.
#### 4.4. Create Background Workers
**Location**: `src/DigitalData.MessagingService.API/Workers/`
**EmailPollingWorker.cs** and **EmailSenderWorker.cs** (See agents.md for implementation examples)
#### 4.5. Update appsettings.json
**Location**: `src/DigitalData.MessagingService.API/appsettings.json`
```json
{
"ConnectionStrings": {
"DefaultConnection": "Server=(local);Database=DD_ECM;Integrated Security=true;TrustServerCertificate=true"
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
}
},
"Workers": {
"EmailPolling": {
"Enabled": true,
"IntervalSeconds": 60
},
"EmailSender": {
"Enabled": true,
"IntervalSeconds": 5
}
},
"Hosting": {
"Mode": "IIS"
}
}
```
---
### PHASE 5: Testing
#### 5.1. Add NuGet Packages
```bash
cd tests/DigitalData.MessagingService.Tests
dotnet add package FakeItEasy
dotnet add package Bogus
dotnet add package FluentAssertions
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package Testcontainers.MsSql
```
#### 5.2. Create Unit Tests
**Location**: `tests/DigitalData.MessagingService.Tests/Unit/Domain/`
**MessageIdGeneratorTests.cs**:
```csharp
using Xunit;
using FluentAssertions;
using DigitalData.MessagingService.Domain.Services;
namespace DigitalData.MessagingService.Tests.Unit.Domain;
public class MessageIdGeneratorTests
{
[Fact]
public void Generate_ShouldCreateValidMessageId()
{
// Arrange
var generator = new MessageIdGenerator();
var original = "test-msg-123";
var sender = "sender@example.com";
var date = new DateTime(2026, 1, 1, 12, 0, 0);
var subject = "Test Subject";
// Act
var messageId = generator.Generate(original, sender, date, subject);
// Assert
messageId.Should().NotBeNull();
messageId.Hash.Should().NotBeNullOrEmpty();
messageId.Value.Should().Contain(original);
messageId.Value.Should().Contain(sender);
}
[Fact]
public void Generate_SameInput_ShouldProduceSameHash()
{
// Arrange
var generator = new MessageIdGenerator();
var original = "test-msg-123";
var sender = "sender@example.com";
var date = new DateTime(2026, 1, 1, 12, 0, 0);
var subject = "Test Subject";
// Act
var messageId1 = generator.Generate(original, sender, date, subject);
var messageId2 = generator.Generate(original, sender, date, subject);
// Assert
messageId1.Hash.Should().Be(messageId2.Hash);
}
}
```
#### 5.3. Create Integration Tests
Use Testcontainers for database integration tests.
---
### PHASE 6: Documentation
#### 6.1. Create README.md (in German)
**Location**: `README.md`
The README should include (in German):
- Application overview
- Architecture diagram
- API endpoints documentation
- Worker processes description
- Database tables documentation
- Configuration guide (appsettings.json)
- Deployment instructions (IIS and Windows Service)
- Troubleshooting guide
**Template structure**:
```markdown
# DigitalData MessagingService
## Übersicht
[Application overview in German]
## Architektur
[Architecture description]
## API Endpunkte
### Email Profile Management
- GET /api/emailprofiles - Alle Profile abrufen
- GET /api/emailprofiles/{id} - Profil nach ID abrufen
- POST /api/emailprofiles - Neues Profil erstellen
- PUT /api/emailprofiles/{id} - Profil aktualisieren
- DELETE /api/emailprofiles/{id} - Profil löschen
[... continue for all controllers]
## Background Workers
### EmailPollingWorker
Überwacht E-Mail-Konten und verarbeitet eingehende E-Mails.
**Konfiguration**:
```json
"Workers": {
"EmailPolling": {
"Enabled": true,
"IntervalSeconds": 60
}
}
```
[... continue for all workers]
## Datenbank Tabellen
### TBDD_EMAIL_ACCOUNT
[Table description]
[... continue for all tables]
## Konfiguration
[Detailed configuration guide]
## Deployment
### IIS Deployment
[Step-by-step guide]
### Windows Service Deployment
[Step-by-step guide]
```
---
## Build and Test Commands
```bash
# Build solution
dotnet build
# Run tests
dotnet test
# Run API
cd src/DigitalData.MessagingService.API
dotnet run
# Create migration
cd src/DigitalData.MessagingService.Infrastructure
dotnet ef migrations add InitialCreate --startup-project ../DigitalData.MessagingService.API
# Update database
dotnet ef database update --startup-project ../DigitalData.MessagingService.API
```
---
## Important Reminders for AI Agents
1. **NEVER modify database schema** - use `[Table]` and `[Column]` attributes
2. **NEVER commit to git** - wait for user instruction
3. **All code and comments in English** - except README.md (German)
4. **Use Serilog for logging** - structured logging
5. **Worker intervals configurable** - via appsettings.json
6. **Support IIS and Windows Service** - via configuration
7. **Check for database triggers** - add to DbContext if they exist
8. **RabbitMQ is future enhancement** - currently use InMemoryEmailQueue
---
## Next Steps for Continuation
1. Complete Application Layer (Commands, Queries, Validators)
2. Complete Infrastructure Layer (DbContext, Repositories, Services)
3. Complete API Layer (Controllers, Workers, Middleware)
4. Create comprehensive tests
5. Write README.md in German
6. Build and test the complete application
---
**Document Version**: 1.0
**Last Updated**: 2026-07-07
**Status**: Phase 1 Complete, Phase 2-6 Pending

View File

@@ -1,2 +1,2 @@
# DigitalData.EmailProfiler
# DigitalData.MessagingService

506
STATUS.md Normal file
View File

@@ -0,0 +1,506 @@
# MessagingService - Implementation Status Report
**Last Updated**: 2026-07-20
**Overall Progress**: 75% Complete (3 of 4 phases done)
---
## Executive Summary
The MessagingService migration from legacy VB.NET to modern C# .NET 8.0 Clean Architecture is **75% complete**. All core layers (Domain, Application, Infrastructure) are fully implemented with real service stubs ready for integration. Only API layer controllers and workers remain.
### ✅ What's Working
- Complete Domain model with 8 entities mapped to legacy database
- Full CQRS implementation with MediatR (5 Commands, 7 Queries)
- Generic repository with AutoMapper-based CRUD
- RabbitMQ integration for async command processing
- Real service implementations (pending external dependencies)
- Data Protection encryption service
- Database context with legacy table mapping
### ⚠️ What's Missing
- Limilabs.Mail NuGet package (for email operations)
- GdPicture.NET 14 or DevExpress.Pdf NuGet (for PDF processing)
- windream COM Interop DLLs (for DMS integration)
- API Controllers and Background Workers
- Unit and integration tests
---
## Phase Breakdown
### Phase 1: Domain Layer ✅ COMPLETE (100%)
**Entities** (8 total):
-`EmailAccount` - Email server configuration (IMAP/SMTP/OAuth2)
-`EmailProfile` - Email polling profiles with archiving rules
-`EmailHistory` - Email import history with duplicate detection
-`EmailAttachment` - Attachment metadata and file paths
-`EmailFilterKeyword` - Keyword-based filtering rules
-`EmailFilterRule` - Sender/recipient filtering rules
-`WindreamArchive` - windream DMS archive metadata
-`LogEmailOut` - Outgoing email queue
**Value Objects** (3 total):
-`MessageId` - SHA256-based message ID with duplicate detection
-`EmailAddress` - Validated email address with display name
-`FilePathValue` - Validated file system paths
**Enums** (5 total):
-`ArchiveMode` - Email archiving strategies
-`EmailAccountType` - Account types (Exchange/IMAP/Office365)
-`EmailProtocol` - Email protocols (POP3/IMAP)
-`FilterActionType` - Filter actions (Delete/MoveFolder)
-`ProcessingStatus` - Processing states (Pending/Success/Error)
**Domain Events** (2 total):
-`EmailProcessedEvent` - Published after successful email processing
-`EmailArchivedEvent` - Published after windream archiving
**Domain Services** (1 total):
-`MessageIdGenerator` - Generates SHA256 message IDs (legacy-compatible)
**Key Features**:
- All entities use `[Table]` and `[Column]` attributes for legacy database mapping
- NO database modifications allowed (read-only schema)
- DateTime fields use `DateTime.Now` (local server time, not UTC)
- Entities handle all configuration (NO Fluent API in DbContext)
**Files**:
```
src/DigitalData.MessagingService.Domain/
├── Entities/ (8 files)
├── ValueObjects/ (3 files)
├── Enums/ (5 files)
├── Events/ (2 files)
└── Services/ (1 file)
```
---
### Phase 2: Application Layer ✅ COMPLETE (100%)
**Commands** (5 total):
-`CreateEmailProfileCommand` - Create new email profile
-`UpdateEmailProfileCommand` - Update existing profile
-`DeleteEmailProfileCommand` - Delete profile
-`CreateEmailAccountCommand` - Create email account
-`ProcessEmailCommand` - Process incoming email
**Queries** (7 total):
-`GetEmailProfilesQuery` - Get all profiles
-`GetEmailProfileByIdQuery` - Get profile by ID
-`GetEmailAccountsQuery` - Get all accounts
-`GetEmailAccountByIdQuery` - Get account by ID
-`GetEmailHistoryQuery` - Get email history with filters
-`GetWindreamArchivesQuery` - Get windream archives
-`GetLogEmailOutQuery` - Get outgoing email queue
**Validators** (4 total):
-`CreateEmailProfileCommandValidator` - FluentValidation for CreateEmailProfileCommand
-`UpdateEmailProfileCommandValidator` - FluentValidation for UpdateEmailProfileCommand
-`CreateEmailAccountCommandValidator` - FluentValidation for CreateEmailAccountCommand
-`ProcessEmailCommandValidator` - FluentValidation for ProcessEmailCommand
**AutoMapper Profiles** (4 total):
-`EmailProfileMappingProfile` - Maps EmailProfile DTOs ↔ Entities
-`EmailAccountMappingProfile` - Maps EmailAccount DTOs ↔ Entities
-`EmailHistoryMappingProfile` - Maps EmailHistory DTOs ↔ Entities
-`WindreamArchiveMappingProfile` - Maps WindreamArchive DTOs ↔ Entities
**DTOs** (8 total):
-`EmailAccountDto` - Email account configuration
-`EmailProfileDto` - Email profile configuration
-`CreateEmailProfileDto` - Create profile request
-`UpdateEmailProfileDto` - Update profile request
-`EmailHistoryDto` - Email history record
-`EmailAttachmentDto` - Attachment metadata
-`WindreamArchiveDto` - windream archive record
-`LogEmailOutDto` - Outgoing email record
**Interfaces** (6 total):
-`IRepository<T>` - Generic repository with AutoMapper CRUD
-`IEmailService` - Email operations (IMAP/SMTP/OAuth2)
-`IPdfProcessingService` - PDF validation and embedded file extraction
-`IDmsService` - windream DMS integration
-`IEncryptionService` - Encryption/decryption for passwords
-`IEmailQueue` - Outgoing email queue
**Key Features**:
- Commands/Queries/Handlers in SAME file (MediatR pattern)
- AutoMapper-based repository operations (no manual mapping)
- FluentValidation for all commands
- Direct folder structure: `Application/{Entity}/Commands`, `Application/{Entity}/Queries` (NO Features/ parent)
- DTOs organized: Single DTOs at root (`Common/Dtos/EmailAccountDto.cs`), Multiple DTOs in subfolders (`Common/Dtos/EmailHistories/`)
**Files**:
```
src/DigitalData.MessagingService.Application/
├── EmailProfiles/Commands/ (3 files)
├── EmailProfiles/Queries/ (2 files)
├── EmailProfiles/Validators/ (2 files)
├── EmailAccounts/Commands/ (1 file)
├── EmailAccounts/Queries/ (2 files)
├── EmailAccounts/Validators/ (1 file)
├── EmailProcessing/Commands/ (1 file)
├── EmailProcessing/Validators/ (1 file)
├── EmailHistory/Queries/ (1 file)
├── WindreamArchives/Queries/ (1 file)
├── LogEmailOut/Queries/ (1 file)
└── Common/
├── Dtos/ (8 files)
├── Interfaces/ (6 files)
└── Mappings/ (4 files)
```
---
### Phase 3: Infrastructure Layer ✅ COMPLETE (100%)
**Database**:
-`MessagingServiceDbContext` - EF Core DbContext with 8 DbSets
- NO `OnModelCreating` override (attribute-only configuration)
- NO `SaveChangesAsync` override (Repository handles this)
- Connection string: `DefaultConnection` from appsettings
**Repository**:
-`Repository<T>` - Generic repository implementing `IRepository<T>`
- AutoMapper-based CRUD: `CreateAsync<TDto>`, `UpdateAsync<TDto>`, `DeleteAsync`
- Safe single-record operations: `UpdateSingleAsync`, `DeleteSingleAsync` (throw if 0 or 2+ records)
- Query methods: `GetByIdAsync`, `GetAllAsync`, `FindAsync`, `FindFirstAsync`, `FindSingleAsync`
- All operations auto-save changes (NO explicit SaveChangesAsync needed)
**Services** (6 total):
-`LimilabsEmailService` - Email operations using Limilabs Mail.dll
- IMAP: `ConnectSSLAsync`, `LoginOAUTH2Async`, `Search(Flag.Unseen)`, `GetMessageByUID`
- SMTP: `SendMessageAsync`
- OAuth2: `GetOAuth2TokenAsync` via `Microsoft.Identity.Client` (MSAL)
- **TODO**: Add Limilabs.Mail NuGet package to uncomment implementation
-`GdPicturePdfProcessingService` - PDF processing using GdPicture.NET 14
- `ValidatePdfAsync` - PDF validation
- `ExtractEmbeddedFilesAsync` - Extract embedded files via `GetAttachmentCount`, `ExtractEmbeddedFile`
- `GetPageCountAsync` - Get PDF page count
- **TODO**: Add GdPicture.NET.14 NuGet package and license key
-`WindreamDmsService` - windream DMS integration using COM Interop
- `ImportDocumentAsync` - Import document with metadata (WMSession, WMConnect, WMObjects)
- `DocumentExistsAsync` - Check if document exists
- `UpdateMetadataAsync` - Update document metadata
- **TODO**: Add windream COM Interop DLL references (WINDREAMLib, WMOBRWSLib)
-`DataProtectionEncryptionService` - Encryption using ASP.NET Core Data Protection
- `Encrypt(plainText)` - Encrypt passwords/secrets
- `Decrypt(cipherText)` - Decrypt passwords/secrets
-`InMemoryEmailQueue` - Temporary in-memory queue for outgoing emails
- `EnqueueAsync` - Add email to queue
- `DequeueAsync` - Get next email from queue
- **TODO**: Replace with `RabbitMqEmailQueue` for production
-`RabbitMqCommandPublisher` - Publishes commands to RabbitMQ
- Implements `ICommandPublisher`
- Serializes commands to JSON with metadata envelope
- Publishes to `emailprofiler.commands` exchange
-`RabbitMqCommandConsumer` - Consumes commands from RabbitMQ (BackgroundService)
- Consumes from `emailprofiler.command.queue`
- Deserializes and executes commands via MediatR
- Acknowledges or requeues messages
**Configuration**:
-`RabbitMqConfiguration` - RabbitMQ connection settings (binds to `appsettings.json`)
**Dependency Injection**:
-`DependencyInjection.cs` - Infrastructure service registration
- DbContext with SQL Server retry policy
- Generic repository (scoped)
- All services (scoped)
- RabbitMQ publisher (singleton) and consumer (hosted service)
- Data Protection with default key storage
**NuGet Packages**:
- ✅ Microsoft.EntityFrameworkCore.SqlServer 8.0.11
- ✅ Microsoft.EntityFrameworkCore.Tools 8.0.11
- ✅ Microsoft.AspNetCore.DataProtection 8.0.11
- ✅ Microsoft.Identity.Client 4.65.0
- ✅ AutoMapper 12.0.1 (warning: vulnerability in 12.0.0-12.0.1 - acceptable for internal use)
- ✅ RabbitMQ.Client 7.2.1
- ⚠️ Limilabs.Mail (NOT YET ADDED - required for LimilabsEmailService)
- ⚠️ GdPicture.NET.14 (NOT YET ADDED - required for GdPicturePdfProcessingService)
**Files**:
```
src/DigitalData.MessagingService.Infrastructure/
├── Persistence/MessagingServiceDbContext.cs
├── Repositories/Repository.cs
├── Services/ (6 files)
├── Messaging/ (3 files)
├── Queue/InMemoryEmailQueue.cs
└── DependencyInjection.cs
```
---
### Phase 4: API Layer ⚠️ IN PROGRESS (30%)
**Controllers** (3 total):
-`EmailProfilesController` - CRUD operations for email profiles
- GET /api/emailprofiles - Get all profiles (synchronous via MediatR)
- GET /api/emailprofiles/{id} - Get profile by ID
- POST /api/emailprofiles - Create profile (async via RabbitMQ, returns HTTP 202)
- PUT /api/emailprofiles/{id} - Update profile (async via RabbitMQ, returns HTTP 202)
- DELETE /api/emailprofiles/{id} - Delete profile (async via RabbitMQ, returns HTTP 202)
-`EmailAccountsController` - CRUD operations for email accounts
- GET /api/emailaccounts - Get all accounts
- GET /api/emailaccounts/{id} - Get account by ID
- POST /api/emailaccounts - Create account (async via RabbitMQ)
-`EmailHistoryController` - Query email history
- GET /api/emailhistory - Get email history with filters
**Workers** (Background Services):
-`EmailPollingWorker` - Polls email accounts for new messages (NOT STARTED)
-`EmailSenderWorker` - Sends outgoing emails from queue (NOT STARTED)
**Configuration**:
-`appsettings.json` - Application configuration
-`appsettings.Secrets.json` - External secrets file (ignored by Git)
- ✅ RabbitMQ configuration section
- ❌ Serilog configuration (NOT CONFIGURED)
- ❌ Worker configuration (NOT CONFIGURED)
**Middleware**:
- ❌ Exception Handling Middleware (NOT IMPLEMENTED)
- ❌ Request Logging Middleware (NOT IMPLEMENTED)
**Documentation**:
- ❌ Scalar OpenAPI documentation (NOT CONFIGURED)
**TODO**:
- [ ] Create `EmailPollingWorker` - Background service to poll email accounts
- [ ] Create `EmailSenderWorker` - Background service to send outgoing emails
- [ ] Configure Serilog for structured logging
- [ ] Configure Scalar for OpenAPI documentation
- [ ] Add exception handling middleware
- [ ] Add request logging middleware
- [ ] Add worker configuration to `appsettings.json`
- [ ] Add IIS and Windows Service hosting support
**Files**:
```
src/DigitalData.MessagingService.API/
├── Controllers/ (3 files)
├── appsettings.json
├── appsettings.Secrets.json
└── Program.cs
```
---
### Phase 5: Testing ❌ NOT STARTED (0%)
**TODO**:
- [ ] Unit tests for Domain entities (MessageIdGenerator, Value Objects)
- [ ] Unit tests for Application handlers (using FakeItEasy for mocks)
- [ ] Integration tests for Repository (using Testcontainers for SQL Server)
- [ ] Integration tests for EmailService (using test email account)
- [ ] API tests (using WebApplicationFactory)
- [ ] Generate fake test data (using Bogus library)
**Test Structure**:
```
tests/DigitalData.MessagingService.Tests/
├── Domain/
│ ├── Services/MessageIdGeneratorTests.cs
│ ├── ValueObjects/EmailAddressTests.cs
│ └── ValueObjects/MessageIdTests.cs
├── Application/
│ ├── EmailProfiles/CreateEmailProfileCommandHandlerTests.cs
│ ├── EmailProfiles/GetEmailProfilesQueryHandlerTests.cs
│ └── EmailProcessing/ProcessEmailCommandHandlerTests.cs
├── Infrastructure/
│ ├── Repositories/RepositoryTests.cs
│ ├── Services/LimilabsEmailServiceTests.cs
│ └── Services/GdPicturePdfProcessingServiceTests.cs
└── API/
├── Controllers/EmailProfilesControllerTests.cs
└── Workers/EmailPollingWorkerTests.cs
```
---
## External Dependencies Status
### 1. Limilabs.Mail ⚠️ REQUIRED
**Status**: Not added
**Action**: `dotnet add package Limilabs.Mail`
**Impact**: Email operations (IMAP/SMTP/OAuth2) will not work
**Files Affected**: `LimilabsEmailService.cs`
### 2. GdPicture.NET 14 ⚠️ REQUIRED
**Status**: Not added
**Action**: Add GdPicture.NET.14 NuGet package + license key
**Impact**: PDF processing and embedded file extraction will not work
**Files Affected**: `GdPicturePdfProcessingService.cs`
**Alternative**: Use DevExpress.Pdf (already licensed)
### 3. windream COM Interop ⚠️ REQUIRED
**Status**: DLLs not referenced
**Action**: Add COM references for WINDREAMLib, WMOBRWSLib
**Impact**: windream DMS archiving will not work
**Files Affected**: `WindreamDmsService.cs`
**Legacy Path**: `M:\Bibliotheken\3rdParty\windream\Interop.WINDREAMLib.dll`
### 4. RabbitMQ Server ✅ AVAILABLE
**Status**: Running at `172.24.12.56:5672`
**Management UI**: `http://172.24.12.56:15672`
**Action**: None - already configured
**Files Affected**: `RabbitMqCommandPublisher.cs`, `RabbitMqCommandConsumer.cs`
### 5. SQL Server Database ✅ AVAILABLE
**Status**: Legacy database exists
**Action**: Update connection string in `appsettings.Secrets.json`
**Files Affected**: `MessagingServiceDbContext.cs`
---
## Build Status
**Last Build**: 2026-07-20
**Result**: ✅ Success
**Warnings**: 1
**Errors**: 0
**Warnings**:
- `CS9113`: Parameter 'dmsService' is unread in `ProcessEmailCommandHandler`
- **Reason**: Service implementation pending windream COM Interop integration
- **Action**: Will be used when windream integration is complete
**Build Command**:
```bash
dotnet build src/DigitalData.MessagingService.Infrastructure/DigitalData.MessagingService.Infrastructure.csproj
```
---
## Next Steps (Priority Order)
### 1. Add External Dependencies (HIGH PRIORITY)
- [ ] Add Limilabs.Mail NuGet package
- [ ] Add GdPicture.NET 14 (or DevExpress.Pdf) NuGet package
- [ ] Add windream COM Interop DLL references
- [ ] Uncomment service implementations once dependencies are available
### 2. Complete API Layer (HIGH PRIORITY)
- [ ] Create `EmailPollingWorker` background service
- [ ] Create `EmailSenderWorker` background service
- [ ] Configure Serilog for structured logging
- [ ] Configure Scalar for OpenAPI documentation
- [ ] Add exception handling middleware
- [ ] Test API endpoints with Postman/Swagger
### 3. Integration Testing (MEDIUM PRIORITY)
- [ ] Set up test SQL Server database (or use Testcontainers)
- [ ] Write repository integration tests
- [ ] Write email service integration tests (with test account)
- [ ] Write API integration tests
### 4. Unit Testing (MEDIUM PRIORITY)
- [ ] Write Domain entity tests
- [ ] Write Application handler tests (with FakeItEasy mocks)
- [ ] Write validation tests
### 5. Deployment Preparation (LOW PRIORITY)
- [ ] Configure IIS hosting
- [ ] Configure Windows Service hosting
- [ ] Set up production appsettings
- [ ] Configure Azure Key Vault (if needed)
- [ ] Create deployment scripts
---
## Known Issues and Limitations
### 1. AutoMapper Vulnerability Warning
**Issue**: NuGet package `AutoMapper 12.0.1` has a known vulnerability
**Severity**: Moderate (only affects 12.0.0-12.0.1)
**Impact**: Internal application - acceptable risk
**Resolution**: Upgrade to AutoMapper 13.0+ when stable
### 2. RabbitMQ Email Queue Not Implemented
**Issue**: Using `InMemoryEmailQueue` instead of `RabbitMqEmailQueue`
**Impact**: Outgoing emails lost on application restart
**Resolution**: Implement `RabbitMqEmailQueue` before production deployment
### 3. No Database Migrations
**Issue**: EF Core migrations disabled (legacy database must not be modified)
**Impact**: Cannot use `dotnet ef database update`
**Resolution**: All schema changes must be done manually in legacy system
### 4. DateTime.Now vs DateTime.UtcNow
**Issue**: Must use `DateTime.Now` (local server time) throughout application
**Impact**: Non-standard practice (industry standard is UTC)
**Reason**: Legacy database stores local time, not UTC
**Resolution**: Document clearly and enforce in code reviews
### 5. windream COM Interop Windows-Only
**Issue**: windream DMS integration uses COM Interop (Windows-only)
**Impact**: Application cannot be deployed on Linux/Docker
**Resolution**: windream must provide REST API, or accept Windows-only deployment
---
## Documentation
### Files Created
-`AGENTS.md` - Agent notes, decisions, and future enhancements
-`STATUS.md` - This file - implementation status report
-`README.md` - Project overview and getting started guide (German)
### Code Documentation
- ✅ XML comments on all public classes, methods, and properties
- ✅ TODO comments in service implementations for external dependencies
- ✅ Example usage in command/query handlers
---
## Team Handoff Notes
### For Developers Continuing This Project
**What You Can Do Right Now**:
1. Build the solution: `dotnet build`
2. Review the Domain layer: `src/DigitalData.MessagingService.Domain/`
3. Review the Application layer: `src/DigitalData.MessagingService.Application/`
4. Review the Infrastructure layer: `src/DigitalData.MessagingService.Infrastructure/`
5. Review the API layer: `src/DigitalData.MessagingService.API/`
**What You Need to Complete**:
1. Add Limilabs.Mail NuGet package: `dotnet add package Limilabs.Mail`
2. Add GdPicture.NET 14 or DevExpress.Pdf NuGet package
3. Add windream COM Interop DLL references (from legacy project)
4. Uncomment service implementations in:
- `LimilabsEmailService.cs`
- `GdPicturePdfProcessingService.cs`
- `WindreamDmsService.cs`
5. Create background workers:
- `EmailPollingWorker.cs`
- `EmailSenderWorker.cs`
6. Write tests
**Important Files to Read**:
- `AGENTS.md` - Critical decisions and constraints
- `legacy/PROJECT_ANALYSIS.md` - Legacy system analysis
- This file - Current status and next steps
**Questions? Issues?**
- Check `AGENTS.md` for design decisions
- Check legacy code in `legacy/` folder for reference implementations
- All database operations use generic repository pattern (see `Repository.cs`)
- All external service interfaces documented in `Application/Common/Interfaces/`
---
**End of Status Report**

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

1
legacy Submodule

Submodule legacy added at e59b936181

View File

@@ -0,0 +1,46 @@
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
/// <summary>
/// DTO for a single email account configuration.
/// </summary>
public record EmailAccountDto
{
/// <summary>
/// Logical name to identify this account (e.g. "default", "support").
/// </summary>
public int Id { get; set; }
#if NET
public required string Username { get; set; }
#else
public string Username { get; set; } = null!;
#endif
#if NET
public required string SmtpServer { get; set; }
#else
public string SmtpServer { get; set; } = null!;
#endif
public int SmtpPort { get; set; }
public bool SmtpUseSsl { get; set; }
public bool UseOAuth2 { get; set; }
/// <summary>
/// IMAP server hostname (e.g. "imap.example.com").
/// Leave empty when this account is send-only.
/// </summary>
public string? ImapServer { get; set; }
/// <summary>
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
/// </summary>
public int ImapPort { get; set; } = 993;
/// <summary>
/// Use SSL/TLS when connecting to the IMAP server.
/// </summary>
public bool ImapUseSsl { get; set; } = true;
}

View File

@@ -0,0 +1,47 @@
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
/// <summary>
/// DTO for a single email account configuration.
/// </summary>
public record EmailAccountModificationDto
{
#if NET
public required string Username { get; set; }
#else
public string Username { get; set; } = null!;
#endif
#if NET
public required string Password { get; set; }
#else
public string Password { get; set; } = null!;
#endif
#if NET
public required string SmtpServer { get; set; }
#else
public string SmtpServer { get; set; } = null!;
#endif
public int SmtpPort { get; set; }
public bool SmtpUseSsl { get; set; }
public bool UseOAuth2 { get; set; }
/// <summary>
/// IMAP server hostname (e.g. "imap.example.com").
/// Leave empty when this account is send-only.
/// </summary>
public string? ImapServer { get; set; }
/// <summary>
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
/// </summary>
public int ImapPort { get; set; } = 993;
/// <summary>
/// Use SSL/TLS when connecting to the IMAP server.
/// </summary>
public bool ImapUseSsl { get; set; } = true;
}

View File

@@ -0,0 +1,45 @@
namespace DigitalData.MessagingService.Application.Common.Dto;
/// <summary>
/// Represents a single email attachment.
/// </summary>
public sealed class EmailAttachmentDto
{
/// <summary>
/// Display name of the attachment (e.g. "invoice.pdf").
/// </summary>
#if NETFRAMEWORK
public string FileName { get; set; } = null!;
#else
public required string FileName { get; init; }
#endif
/// <summary>
/// Raw content of the attachment.
/// </summary>
#if NETFRAMEWORK
public byte[] Content { get; set; } = null!;
#else
public required byte[] Content { get; init; }
#endif
/// <summary>
/// MIME content-type (e.g. "application/pdf", "image/png").
/// Defaults to "application/octet-stream" when not specified.
/// </summary>
public string ContentType { get; set; } = "application/octet-stream";
/// <summary>
/// When <see langword="true"/> the attachment is embedded inline and displayed
/// directly inside the email body via a CID reference (e.g. &lt;img src="cid:logo"&gt;).
/// When <see langword="false"/> (default) it appears as a regular downloadable attachment.
/// </summary>
public bool IsInline { get; set; } = false;
/// <summary>
/// Content-ID used when <see cref="IsInline"/> is <see langword="true"/>.
/// Reference it in HTML body as <c>cid:{ContentId}</c>.
/// Auto-generated from <see cref="FileName"/> when left empty.
/// </summary>
public string? ContentId { get; set; }
}

View File

@@ -0,0 +1,57 @@
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Common.Dto;
public record EmailContext
{
#if NETFRAMEWORK
public EmailAccount Sender { get; set; } = null!;
#else
public required EmailAccount Sender { get; init; }
#endif
/// <summary>
/// Recipient email address
/// </summary>
#if NETFRAMEWORK
public IEnumerable<string> Recipients { get; set; } = null!;
#else
public required IEnumerable<string> Recipients { get; init; }
#endif
/// <summary>
/// Email subject
/// </summary>
#if NETFRAMEWORK
public string Subject { get; set; } = null!;
#else
public required string Subject { get; init; }
#endif
/// <summary>
/// Email body (HTML or plain text)
/// </summary>
#if NETFRAMEWORK
public string Body { get; set; } = null!;
#else
public required string Body { get; init; }
#endif
/// <summary>
/// Is HTML email (default: true)
/// </summary>
#if NETFRAMEWORK
public bool IsHtml { get; set; } = true;
#else
public bool IsHtml { get; init; } = true;
#endif
/// <summary>
/// Optional list of attachments to include with the email.
/// Each entry may carry its content as a byte array (<see cref="EmailAttachmentDto.Content"/>)
/// or reference a file on disk via <see cref="EmailAttachmentDto.FilePath"/>.
/// </summary>
public IEnumerable<EmailAttachmentDto> Attachments { get; set; } = [];
}

View File

@@ -0,0 +1,5 @@
#if NET
namespace DigitalData.MessagingService.Application.Common.Dto;
public record EmailSyncResult(int ProcessedCount = 0, int FailedCount = 0);
#endif

View File

@@ -0,0 +1,15 @@
#if NET
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
/// <summary>
/// Constrains the search to messages within an arrival-date range.
/// </summary>
/// <remarks>
/// At least one of <see cref="After"/> or <see cref="Before"/> must be provided.
/// When both are set <see cref="After"/> must be earlier than <see cref="Before"/>.
/// Both bounds are <b>inclusive</b>.
/// </remarks>
/// <param name="After">Earliest date to include (inclusive). Maps to IMAP <c>SINCE</c>.</param>
/// <param name="Before">Latest date to include (inclusive). Maps to IMAP <c>BEFORE</c> (next day is used internally).</param>
public record DateFilter(DateTime? After = null, DateTime? Before = null);
#endif

View File

@@ -0,0 +1,80 @@
#if NET
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
/// <summary>
/// Describes all criteria and options used when searching an IMAP mailbox.
/// </summary>
/// <remarks>
/// All text-match properties are <b>case-insensitive</b> substring searches performed server-side via IMAP SEARCH.
/// Combine multiple criteria freely; an implicit AND is applied across all non-null fields.
/// </remarks>
public record MailSearchFilter
{
/// <summary>
/// Mailbox folder to search. Defaults to <c>"INBOX"</c>.
/// </summary>
public string Folder { get; init; } = "INBOX";
/// <summary>
/// When <see langword="true"/>, only unread (UNSEEN) messages are returned.
/// </summary>
public bool UnseenOnly { get; init; } = false;
/// <summary>
/// When <see langword="true"/>, attachment data is included in the results; otherwise attachments are omitted.
/// Defaults to <see langword="false"/>.
/// </summary>
public bool WithAttachments { get; init; } = false;
/// <summary>
/// Maximum number of messages to retrieve. <c>0</c> means unlimited.
/// Applied after sorting; defaults to <c>50</c>.
/// </summary>
public int? MaxCount { get; init; } = null;
/// <summary>
/// Controls the order of the returned messages. Defaults to <see cref="MailSortOrder.NewestFirst"/>.
/// </summary>
public MailSortOrder SortOrder { get; init; } = MailSortOrder.NewestFirst;
// ── Text filters ───────────────────────────────────────────────────────────
/// <summary>
/// Case-insensitive substring the message subject must contain.
/// Maps to IMAP <c>SUBJECT</c>.
/// </summary>
public string? SubjectContains { get; init; } = null;
/// <summary>
/// Case-insensitive substring that must appear in the <c>From</c> header.
/// Maps to IMAP <c>FROM</c>.
/// </summary>
public string? SenderContains { get; init; } = null;
/// <summary>
/// Case-insensitive substring that must appear in <c>To</c> or <c>Cc</c>.
/// Maps to IMAP <c>TO</c>.
/// </summary>
public string? RecipientContains { get; init; } = null;
/// <summary>
/// Case-insensitive substring that must appear anywhere in the message body (text or HTML part).
/// Maps to IMAP <c>BODY</c>.
/// </summary>
public string? BodyContains { get; init; } = null;
// ── Structured filters ─────────────────────────────────────────────────────
/// <summary>
/// Constrains results to a specific UID or a UID range.
/// See <see cref="UidFilter"/> for mutual-exclusion rules between its fields.
/// </summary>
public UidFilter? Uid { get; init; } = null;
/// <summary>
/// Constrains results to messages received within a date range.
/// See <see cref="DateFilter"/> for rules between its fields.
/// </summary>
public DateFilter? Date { get; init; } = null;
}
#endif

View File

@@ -0,0 +1,17 @@
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
/// <summary>
/// Controls the order in which fetched messages are returned.
/// </summary>
public enum MailSortOrder
{
/// <summary>
/// Newest messages first (default).
/// </summary>
NewestFirst,
/// <summary>
/// Oldest messages first.
/// </summary>
OldestFirst
}

View File

@@ -0,0 +1,16 @@
#if NET
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
/// <summary>
/// Constrains the search to a specific UID or a UID range.
/// </summary>
/// <remarks>
/// Use <see cref="Absolute"/> for an exact single-message lookup.
/// Use <see cref="Min"/> and/or <see cref="Max"/> for an open or closed range.
/// Mixing <see cref="Absolute"/> with <see cref="Min"/> or <see cref="Max"/> is not allowed.
/// </remarks>
/// <param name="Min">Lower bound of the UID range (inclusive). Ignored when <see cref="Absolute"/> is set.</param>
/// <param name="Max">Upper bound of the UID range (inclusive). Ignored when <see cref="Absolute"/> is set.</param>
/// <param name="Absolute">Exact UID to match. When set, <see cref="Min"/> and <see cref="Max"/> must be <see langword="null"/>.</param>
public record UidFilter(long? Min = null, long? Max = null, long? Absolute = null);
#endif

View File

@@ -0,0 +1,112 @@
namespace DigitalData.MessagingService.Application.Common.Dto;
/// <summary>
/// Represents an email message received via IMAP.
/// </summary>
public sealed record ReceivedEmailDto
{
/// <summary>
/// Unique identifier of the message on the IMAP server (UID).
/// </summary>
#if NET
public long Uid { get; init; }
#else
public long Uid { get; set; }
#endif
/// <summary>
/// ID of the email account this message belongs to.
/// </summary>
#if NET
public int AccountId { get; init; }
#else
public int AccountId { get; set; }
#endif
/// <summary>
/// Sender address (From header).
/// </summary>
#if NET
public string From { get; init; } = string.Empty;
#else
public string From { get; set; } = string.Empty;
#endif
/// <summary>
/// Recipient addresses (To header).
/// </summary>
#if NET
public IEnumerable<string> To { get; init; } = [];
#else
public IEnumerable<string> To { get; set; } = [];
#endif
/// <summary>
/// CC addresses.
/// </summary>
#if NET
public IEnumerable<string> Cc { get; init; } = [];
#else
public IEnumerable<string> Cc { get; set; } = [];
#endif
/// <summary>
/// Email subject.
/// </summary>
#if NET
public string Subject { get; init; } = string.Empty;
#else
public string Subject { get; set; } = string.Empty;
#endif
/// <summary>
/// Plain-text body (may be empty when only HTML is present).
/// </summary>
#if NET
public string TextBody { get; init; } = string.Empty;
#else
public string TextBody { get; set; } = string.Empty;
#endif
/// <summary>
/// HTML body (may be empty when only plain-text is present).
/// </summary>
#if NET
public string HtmlBody { get; init; } = string.Empty;
#else
public string HtmlBody { get; set; } = string.Empty;
#endif
/// <summary>
/// Date/time the message was sent (Date header).
/// </summary>
#if NET
public DateTime Date { get; init; }
#else
public DateTime Date { get; set; }
#endif
/// <summary>
/// Attachments included with this message.
/// </summary>
#if NET
public IEnumerable<EmailAttachmentDto> Attachments { get; init; } = [];
#else
public IEnumerable<EmailAttachmentDto> Attachments { get; set; } = [];
#endif
/// <summary>
/// Whether the message has been marked as seen/read on the server.
/// </summary>
#if NET
public bool IsSeen { get; init; }
#else
public bool IsSeen { get; set; }
#endif
#if NET
public required string Folder { get; init; }
#else
public string Folder { get; set; } = null!;
#endif
}

View File

@@ -0,0 +1,26 @@
namespace DigitalData.MessagingService.Application.Common.Dto;
public record SendingEmailCreateDto
{
/// <summary>
/// Recipient email address
/// </summary>
public string Recipient { get; set; } = null!;
/// <summary>
/// Email subject
/// </summary>
public string Subject { get; set; } = null!;
/// <summary>
/// Email body (HTML or plain text)
/// </summary>
public string Body { get; set; } = null!;
/// <summary>
/// Is HTML email (default: true)
/// </summary>
public bool IsHtml { get; set; } = true;
public DateTime QueuedAt { get; set; }
}

View File

@@ -0,0 +1,22 @@
namespace DigitalData.MessagingService.Application.Common.Dto;
public record SendingEmailEvent
{
#if NETFRAMEWORK
public Guid Id { get; set; }
#else
public required Guid Id { get; init; }
#endif
#if NETFRAMEWORK
public EmailContext Mail { get; set; } = null!;
#else
public required EmailContext Mail { get; init; }
#endif
#if NETFRAMEWORK
public DateTime QueuedAt { get; set; }
#else
public required DateTime QueuedAt { get; init; }
#endif
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace DigitalData.MessagingService.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;
}

View File

@@ -0,0 +1,18 @@
using DigitalData.MessagingService.Application.Common.Dto;
namespace DigitalData.MessagingService.Application.Common.Interfaces;
/// <summary>
/// Email service interface for SMTP operations.
/// Implementation uses Limilabs Mail.dll for production email sending.
/// SMTP configuration is injected via IOptions&lt;EmailAccountDto&gt; in appsettings.json.
/// Throws AuthenticationFailedException when SMTP authentication fails.
/// </summary>
public interface IEmailService
{
/// <summary>
/// Sends an email using the configured SMTP account.
/// SMTP credentials are configured in appsettings.json (EmailAccount section).
/// </summary>
Task SendEmailAsync(EmailContext context, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
namespace DigitalData.MessagingService.Application.Common.Interfaces;
/// <summary>
/// Encryption service interface for password encryption.
/// </summary>
public interface IEncryptionService
{
string Encrypt(string plainText);
string Decrypt(string cipherText);
}

View File

@@ -0,0 +1,41 @@
#if NET
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Common.Interfaces;
/// <summary>
/// Service interface for reading emails via IMAP.
/// </summary>
public interface IImapEmailService
{
/// <summary>
///
/// </summary>
/// <param name="account"></param>
/// <param name="folder"></param>
/// <param name="cancel"></param>
/// <returns></returns>
Task<EmailSyncResult> SyncEmailsAsync(
EmailAccount account,
string folder = "INBOX",
CancellationToken cancel = default);
/// <summary>
/// Marks a message as seen (read) on the server.
/// </summary>
Task MarkAsSeenAsync(
EmailAccount account,
long uid,
string folder = "INBOX",
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the last IMAP sync date for the specified account and folder.
/// </summary>
/// <param name="accountId"></param>
/// <param name="folder"></param>
/// <returns></returns>
DateTime? GetLastImapSyncDate(int accountId, string folder = "INBOX");
}
#endif

View File

@@ -0,0 +1,26 @@
namespace DigitalData.MessagingService.Application.Common.Interfaces;
/// <summary>
/// PDF processing service interface.
/// Operates on streams instead of file paths for flexibility.
/// </summary>
public interface IPdfProcessingService
{
/// <summary>
/// Validates if the provided stream contains a valid PDF document.
/// Throws InvalidPdfException if the stream is not a valid PDF.
/// </summary>
Task<bool> ValidatePdfAsync(Stream pdfStream, CancellationToken cancellationToken = default);
/// <summary>
/// Extracts embedded files from PDF stream to the specified output directory.
/// Returns a list of paths to extracted files.
/// </summary>
Task<IEnumerable<string>> ExtractEmbeddedFilesAsync(Stream pdfStream, string outputDirectory, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the page count of the PDF document.
/// Throws InvalidPdfException if the stream is not a valid PDF.
/// </summary>
Task<int> GetPageCountAsync(Stream pdfStream, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using DigitalData.MessagingService.Application.Common.Dto;
namespace DigitalData.MessagingService.Application.Common.Interfaces;
/// <summary>
/// Email queue interface for outgoing emails.
/// </summary>
public interface ISendingEmailPublisher
{
Task EnqueueAsync(SendingEmailEvent sendingEmailEvent, CancellationToken cancellationToken = default);
Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,11 @@
#if NET
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
public interface IReceivedEmailRepository : IRepository<ReceivedEmail>
{
public Task<IEnumerable<ReceivedEmail>> FindAsync(MailSearchFilter mailSearchFilter, EmailAccount? accountQuery = null, CancellationToken cancellationToken = default);
}
#endif

View File

@@ -0,0 +1,37 @@
using System.Linq.Expressions;
namespace DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
/// <summary>
/// Generic repository interface for CRUD operations.
/// All operations auto-save changes - NO explicit SaveChangesAsync needed!
/// </summary>
public interface IRepository<TEntity> where TEntity : class
{
// CREATE
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
Task<IEnumerable<TEntity>> CreateRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default);
// READ
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, int? skip = null, int? take = null, CancellationToken cancellationToken = default);
Task<TEntity?> FindFirstAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
Task<TEntity?> FindSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
Task<int> CountAsync(Expression<Func<TEntity, bool>>? predicate = null, CancellationToken cancellationToken = default);
Task<bool> AnyAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
// UPSERT
Task<(TEntity Entity, bool Created)> UpsertAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
Task<(TEntity Entity, bool Created)> UpsertSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
// UPDATE
Task UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
// DELETE
Task DeleteSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
Task<int> DeleteAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,41 @@
#if NET
using AutoMapper;
using DigitalData.MessagingService.Application.EmailSending.Commands;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
namespace DigitalData.MessagingService.Application.Common.Mappings;
/// <summary>
/// AutoMapper profile for Emails
/// </summary>
public class EmailMappingProfile : Profile
{
public EmailMappingProfile()
{
// PublishEmailCommand -> Email
// Sender is resolved via MediatR in the handler and set separately after mapping.
CreateMap<PublishEmailCommand, EmailContext>()
.ForMember(dest => dest.Sender, opt => opt.Ignore())
.ForMember(dest => dest.Attachments, opt => opt.MapFrom(src => src.Attachments));
// EmailAccountDto -> EmailAccount
CreateMap<EmailAccount, EmailAccountDto>();
CreateMap<EmailAccountModificationDto, EmailAccount>();
// ReceivedEmailDto <-> ReceivedEmail
CreateMap<ReceivedEmailDto, ReceivedEmail>()
.ForMember(dest => dest.Id, opt => opt.Ignore())
.ForMember(dest => dest.Account, opt => opt.Ignore());
CreateMap<ReceivedEmail, ReceivedEmailDto>();
// EmailAttachmentDto <-> EmailAttachment
CreateMap<EmailAttachmentDto, EmailAttachment>()
.ForMember(dest => dest.Id, opt => opt.Ignore())
.ForMember(dest => dest.EmailId, opt => opt.Ignore())
.ForMember(dest => dest.Email, opt => opt.Ignore());
CreateMap<EmailAttachment, EmailAttachmentDto>();
}
}
#endif

View File

@@ -0,0 +1,28 @@
#if NET
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
using DigitalData.MessagingService.Application.EmailAccounts.Commands;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Common.Options;
/// <summary>
/// Wrapper options class that holds a list of <see cref="Dto.EmailAccount"/> entries
/// bound from the <c>EmailAccounts</c> configuration section.
/// </summary>
public class EmailAccountsOptions
{
public const string SectionName = "EmailAccounts";
/// <summary>
/// The list of configured email accounts.
/// </summary>
public required IEnumerable<EmailAccountModificationDto> Accounts { get; init; } = [];
/// <summary>
/// How often the IMAP sync worker polls for new emails, in seconds.
/// Defaults to 300 seconds (5 minutes).
/// </summary>
public int SyncIntervalSeconds { get; init; } = 300;
}
#endif

View File

@@ -0,0 +1,6 @@
namespace DigitalData.MessagingService.Application.Common.ValueObjects;
public enum Modification
{
Upsert
}

View File

@@ -0,0 +1,46 @@
#if NET
using DigitalData.MessagingService.Application.Common.Options;
using FluentValidation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
namespace DigitalData.MessagingService.Application;
/// <summary>
/// Dependency injection configuration for Application layer.
/// </summary>
public static class DependencyInjection
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration)
{
var assembly = Assembly.GetExecutingAssembly();
// Read LuckyPennySoft license key from appsettings.json
var licenseKey = configuration.GetValue<string>("LuckyPennySoftLicenseKey")
?? throw new InvalidOperationException("LuckyPennySoftLicenseKey not found in configuration");
// MediatR - Register all handlers
services.AddMediatR(config =>
{
config.LicenseKey = licenseKey;
config.RegisterServicesFromAssembly(assembly);
});
// AutoMapper - Use built-in DI extension (AutoMapper 16.2.0+)
services.AddAutoMapper(config =>
{
config.LicenseKey = licenseKey;
config.AddMaps(assembly);
});
// FluentValidation - Register all validators
services.AddValidatorsFromAssembly(assembly);
// Register EmailAccounts configuration (IOptions<EmailAccountsOptions>)
services.Configure<EmailAccountsOptions>(configuration.GetSection(EmailAccountsOptions.SectionName));
return services;
}
}
#endif

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="AutoMapper" Version="16.2.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="MediatR" Version="14.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,36 @@
#if NET
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Application.Common.ValueObjects;
using DigitalData.MessagingService.Domain.Entities;
using MediatR;
using System.Security.Principal;
namespace DigitalData.MessagingService.Application.EmailAccounts.Commands;
/// <summary>
/// DTO for a single email account configuration.
/// </summary>
public record EmailAccountModificationCommand : IRequest<(EmailAccount, bool)>
{
public required EmailAccountModificationDto ModifiedAccount { get; init; }
public required Modification Modification { get; init; }
}
/// <summary>
///
/// </summary>
/// <param name="Repo"></param>
public class EmailAccountModificationCommandHandler(IRepository<EmailAccount> Repo) : IRequestHandler<EmailAccountModificationCommand, (EmailAccount, bool)>
{
public async Task<(EmailAccount, bool)> Handle(EmailAccountModificationCommand request, CancellationToken cancellationToken)
{
return request.Modification switch
{
Modification.Upsert => await Repo.UpsertAsync(a => a.Username == request.ModifiedAccount.Username, request.ModifiedAccount, cancellationToken),
_ => throw new NotSupportedException($"Modification type '{request.Modification}' is not supported in email account modification."),
};
}
}
#endif

View File

@@ -0,0 +1,37 @@
#if NET
using MediatR;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using AutoMapper;
using DigitalData.MessagingService.Domain.Exceptions;
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
namespace DigitalData.MessagingService.Application.EmailAccounts.Queries;
public record GetEmailAccountQuery : IRequest<IEnumerable<EmailAccountDto>>
{
public int? Id { get; init; }
public string? Username { get; init; }
}
/// <summary>
/// Handles queries for retrieving email accounts.
/// </summary>
/// <param name="Repo"></param>
/// <param name="Mapper"></param>
public class GetEmailAccountQueryHandler(IRepository<EmailAccount> Repo, IMapper Mapper) : IRequestHandler<GetEmailAccountQuery, IEnumerable<EmailAccountDto>>
{
public async Task<IEnumerable<EmailAccountDto>> Handle(GetEmailAccountQuery request, CancellationToken cancellationToken)
{
var accounts = request.Id is null && request.Username is null
? await Repo.GetAllAsync(cancellationToken)
: await Repo.FindAsync(request.Id is int id ? x => x.Id == id : x => x.Username == request.Username, cancellationToken: cancellationToken);
if (accounts.Any())
return Mapper.Map<IEnumerable<EmailAccountDto>>(accounts);
else
throw new NotFoundException($"No email account found for the given criteria ({(request.Id is not null ? $"Id: {request.Id}" : $"Username: {request.Username}")}).");
}
}
#endif

View File

@@ -0,0 +1,25 @@
#if NET
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailAccounts.Validators;
/// <summary>
/// Validator for <see cref="GetEmailAccountQuery"/>.
/// Either <see cref="GetEmailAccountQuery.Id"/> or <see cref="GetEmailAccountQuery.Username"/> must be provided, but not both.
/// </summary>
public class GetSenderQueryValidator : AbstractValidator<GetEmailAccountQuery>
{
public GetSenderQueryValidator()
{
When(x => x.Username is not null, () =>
{
RuleFor(x => x.Username)
.NotEmpty()
.WithMessage("Username must not be empty.")
.MaximumLength(200)
.WithMessage("Username must not exceed 200 characters.");
});
}
}
#endif

View File

@@ -0,0 +1,48 @@
#if NET
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Exceptions;
using MediatR;
using Microsoft.Extensions.Logging;
namespace DigitalData.MessagingService.Application.EmailReceiving.Commands;
/// <summary>
/// Command to mark a single IMAP message as seen (read).
/// </summary>
public record MarkEmailAsSeenCommand : IRequest
{
public required GetEmailAccountQuery Account { get; init; }
/// <summary>
/// UID of the message to mark as seen.
/// </summary>
public required long Uid { get; init; }
/// <summary>
/// Mailbox folder the message resides in (default: "INBOX").
/// </summary>
public string Folder { get; init; } = "INBOX";
}
public class MarkEmailAsSeenCommandHandler(IImapEmailService ImapService, ILogger<MarkEmailAsSeenCommandHandler> Logger, IRepository<EmailAccount> Repo) : IRequestHandler<MarkEmailAsSeenCommand>
{
public async Task Handle(MarkEmailAsSeenCommand request, CancellationToken cancellationToken)
{
var accounts = await Repo.FindAsync(request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username, cancellationToken: cancellationToken);
if (accounts.Count() > 1)
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
var account = accounts.FirstOrDefault()
?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
if (string.IsNullOrWhiteSpace(account.ImapServer))
throw new BadRequestException($"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
await ImapService.MarkAsSeenAsync(account, request.Uid, request.Folder, cancellationToken);
}
}
#endif

View File

@@ -0,0 +1,51 @@
#if NET
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Exceptions;
using MediatR;
using Microsoft.Extensions.Logging;
namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
/// <summary>
/// Query to fetch emails from an IMAP mailbox.
/// </summary>
public record ReadEmailQuery : IRequest<IEnumerable<ReceivedEmailDto>>
{
/// <summary>
/// Identifies the email account to use.
/// </summary>
public required GetEmailAccountQuery Account { get; init; }
/// <summary>
/// Mail query used to filter and limit the emails retrieved.
/// </summary>
public MailSearchFilter Mail { get; init; } = new();
}
public class ReadEmailQueryHandler(IMapper Mapper, ILogger<ReadEmailQueryHandler> Logger, IRepository<EmailAccount> EmailAccountRepo, IReceivedEmailRepository MailRepo) : IRequestHandler<ReadEmailQuery, IEnumerable<ReceivedEmailDto>>
{
public async Task<IEnumerable<ReceivedEmailDto>> Handle(ReadEmailQuery request, CancellationToken cancellationToken)
{
var accounts = await EmailAccountRepo.FindAsync(request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username, cancellationToken: cancellationToken);
if (accounts.Count() > 1)
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
var account = accounts.FirstOrDefault()
?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
if (string.IsNullOrWhiteSpace(account.ImapServer))
throw new BadRequestException(
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
var mails = await MailRepo.FindAsync(request.Mail, account, cancellationToken);
return Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails);
}
}
#endif

View File

@@ -0,0 +1,39 @@
#if NET
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
/// <summary>
/// Validates a <see cref="DateFilter"/> value object.
/// </summary>
public class DateFilterValidator : AbstractValidator<DateFilter>
{
public DateFilterValidator()
{
// At least one field must be provided
RuleFor(x => x)
.Must(d => d.After is not null || d.Before is not null)
.WithName("Date")
.WithMessage("DateFilter must specify at least one of: After or Before.");
// Dates must not be in the future
RuleFor(x => x.After)
.LessThanOrEqualTo(DateTime.UtcNow)
.WithMessage("Date.After must not be in the future.")
.When(x => x.After is not null);
RuleFor(x => x.Before)
.LessThanOrEqualTo(DateTime.UtcNow)
.WithMessage("Date.Before must not be in the future.")
.When(x => x.Before is not null);
// Range coherence: After must be earlier than Before
RuleFor(x => x)
.Must(d => d.After!.Value < d.Before!.Value)
.WithName("Date.Range")
.WithMessage("Date.After must be earlier than Date.Before.")
.When(x => x.After is not null && x.Before is not null);
}
}
#endif

View File

@@ -0,0 +1,24 @@
#if NET
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
/// <summary>
/// Validates a <see cref="ReadEmailQuery"/> before it is handled by <see cref="ReadEmailQueryHandler"/>.
/// </summary>
public class FetchEmailsQueryValidator : AbstractValidator<ReadEmailQuery>
{
public FetchEmailsQueryValidator()
{
RuleFor(x => x.Account)
.NotNull()
.WithMessage("Account query must not be null.");
RuleFor(x => x.Mail)
.NotNull()
.WithMessage("Mail search filter must not be null.")
.SetValidator(new MailSearchFilterValidator());
}
}
#endif

View File

@@ -0,0 +1,53 @@
#if NET
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
/// <summary>
/// Validates a <see cref="MailSearchFilter"/> instance before it is dispatched to the IMAP service.
/// </summary>
public class MailSearchFilterValidator : AbstractValidator<MailSearchFilter>
{
public MailSearchFilterValidator()
{
RuleFor(x => x.Folder)
.NotEmpty()
.WithMessage("Folder must not be empty.")
.MaximumLength(255)
.WithMessage("Folder name must not exceed 255 characters.");
RuleFor(x => x.MaxCount)
.GreaterThanOrEqualTo(0)
.WithMessage("MaxCount must be 0 (unlimited) or a positive number.");
RuleFor(x => x.SubjectContains)
.MaximumLength(500)
.WithMessage("SubjectContains must not exceed 500 characters.")
.When(x => x.SubjectContains is not null);
RuleFor(x => x.SenderContains)
.MaximumLength(320)
.WithMessage("SenderContains must not exceed 320 characters.")
.When(x => x.SenderContains is not null);
RuleFor(x => x.RecipientContains)
.MaximumLength(320)
.WithMessage("RecipientContains must not exceed 320 characters.")
.When(x => x.RecipientContains is not null);
RuleFor(x => x.BodyContains)
.MaximumLength(1000)
.WithMessage("BodyContains must not exceed 1000 characters.")
.When(x => x.BodyContains is not null);
RuleFor(x => x.Uid)
.SetValidator(new UidFilterValidator()!)
.When(x => x.Uid is not null);
RuleFor(x => x.Date)
.SetValidator(new DateFilterValidator()!)
.When(x => x.Date is not null);
}
}
#endif

View File

@@ -0,0 +1,50 @@
#if NET
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
/// <summary>
/// Validates a <see cref="UidFilter"/> value object.
/// </summary>
public class UidFilterValidator : AbstractValidator<UidFilter>
{
public UidFilterValidator()
{
// Absolute and range are mutually exclusive
RuleFor(x => x)
.Must(u => u.Absolute is null || (u.Min is null && u.Max is null))
.WithName("Uid.Absolute")
.WithMessage("Uid.Absolute cannot be combined with Uid.Min or Uid.Max. Use either an exact UID or a range.");
// At least one field must be provided
RuleFor(x => x)
.Must(u => u.Absolute is not null || u.Min is not null || u.Max is not null)
.WithName("Uid")
.WithMessage("UidFilter must specify at least one of: Absolute, Min, or Max.");
// All UID values must be positive
RuleFor(x => x.Absolute)
.GreaterThan(0)
.WithMessage("Uid.Absolute must be a positive number.")
.When(x => x.Absolute is not null);
RuleFor(x => x.Min)
.GreaterThan(0)
.WithMessage("Uid.Min must be a positive number.")
.When(x => x.Min is not null);
RuleFor(x => x.Max)
.GreaterThan(0)
.WithMessage("Uid.Max must be a positive number.")
.When(x => x.Max is not null);
// Range coherence: Min must be less than or equal to Max
RuleFor(x => x)
.Must(u => u.Min!.Value <= u.Max!.Value)
.WithName("Uid.Range")
.WithMessage("Uid.Min must be less than or equal to Uid.Max.")
.When(x => x.Min is not null && x.Max is not null);
}
}
#endif

View File

@@ -0,0 +1,82 @@
#if NET
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Exceptions;
using MediatR;
using Microsoft.Extensions.Logging;
using System.Text.Json.Serialization;
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
/// <summary>
/// Command to send an email (enqueue to RabbitMQ)
/// </summary>
public record PublishEmailCommand : IRequest<Guid>
{
public required GetEmailAccountQuery Sender { get; init; }
/// <summary>
/// Recipient email addresses
/// </summary>
public required IEnumerable<string> Recipients { get; init; }
/// <summary>
/// Email subject
/// </summary>
public required string Subject { get; init; }
/// <summary>
/// Email body (HTML or plain text)
/// </summary>
public required string Body { get; init; }
/// <summary>
/// Is HTML email (default: true)
/// </summary>
public bool IsHtml { get; init; } = true;
[JsonIgnore]
internal IEnumerable<EmailAttachmentDto> Attachments { get; private init; } = [];
/// <summary>
/// Returns a new command instance with the supplied attachments.
/// Called by the controller after resolving uploaded files.
/// </summary>
public PublishEmailCommand WithAttachments(IEnumerable<EmailAttachmentDto> attachments)
=> this with { Attachments = attachments };
}
/// <summary>
/// Handler for PublishEmailCommand
/// Resolves the sender account via MediatR, maps to SendingEmailEvent and enqueues to RabbitMQ
/// </summary>
public class PublishEmailCommandHandler(IRepository<EmailAccount> Repo, ISendingEmailPublisher Publisher, IMapper Mapper, ILogger<PublishEmailCommandHandler> Logger) : IRequestHandler<PublishEmailCommand, Guid>
{
public async Task<Guid> Handle(PublishEmailCommand request, CancellationToken cancellationToken)
{
var senderAccounts = await Repo.FindAsync(request.Sender.Id is int id ? x => x.Id == id : x => x.Username == request.Sender.Username, cancellationToken: cancellationToken);
if (senderAccounts.Count() > 1)
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Sender.Id is not null ? $"Id: {request.Sender.Id}" : $"Username: {request.Sender.Username}");
var senderAccount = senderAccounts.FirstOrDefault()
?? throw new NotFoundException($"No email account found for the given sender criteria (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
var email = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
// Enqueue to RabbitMQ
var sendingEmailEvent = new SendingEmailEvent()
{
Id = Guid.NewGuid(),
Mail = email,
QueuedAt = DateTime.Now
};
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
return sendingEmailEvent.Id;
}
}
#endif

View File

@@ -0,0 +1,36 @@
#if NET
using DigitalData.MessagingService.Application.EmailSending.Commands;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailSending.Validators;
/// <summary>
/// Validator for PublishEmailCommand
/// </summary>
public class PublishEmailCommandValidator : AbstractValidator<PublishEmailCommand>
{
public PublishEmailCommandValidator()
{
RuleFor(x => x.Recipients)
.NotEmpty()
.WithMessage("Recipients are required")
.Must(x => x.Any())
.WithMessage("At least one recipient is required")
.ForEach(recipient => recipient
.NotEmpty()
.WithMessage("Recipient email must not be empty")
.EmailAddress()
.WithMessage("Invalid email address format"));
RuleFor(x => x.Subject)
.NotEmpty()
.WithMessage("Subject is required")
.MaximumLength(500)
.WithMessage("Subject must not exceed 500 characters");
RuleFor(x => x.Body)
.NotEmpty()
.WithMessage("Body is required");
}
}
#endif

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net462;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="12.2.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net462'">
<Reference Include="System.ComponentModel.DataAnnotations" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,75 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace DigitalData.MessagingService.Domain.Entities;
/// <summary>
/// DTO for a single email account configuration.
/// </summary>
[Table("EMAIL_ACCOUNT")]
public class EmailAccount
{
/// <summary>
/// Logical name to identify this account (e.g. "default", "support").
/// </summary>
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Column("ID", TypeName = "int")]
public int Id { get; set; }
[Required]
[MaxLength(256)]
[Column("USERNAME", TypeName = "nvarchar(256)")]
#if NET
public required string Username { get; set; }
#else
public string Username { get; set; } = null!;
#endif
[Required]
[MaxLength(512)]
[Column("PASSWORD", TypeName = "nvarchar(512)")]
#if NET
public required string Password { get; set; }
#else
public string Password { get; set; } = null!;
#endif
[Required]
[MaxLength(256)]
[Column("SMTP_SERVER", TypeName = "nvarchar(256)")]
#if NET
public required string SmtpServer { get; set; }
#else
public string SmtpServer { get; set; } = null!;
#endif
[Column("SMTP_PORT", TypeName = "int")]
public int SmtpPort { get; set; }
[Column("SMTP_USE_SSL", TypeName = "bit")]
public bool SmtpUseSsl { get; set; }
[Column("USE_OAUTH2", TypeName = "bit")]
public bool UseOAuth2 { get; set; }
/// <summary>
/// IMAP server hostname (e.g. "imap.example.com").
/// Leave empty when this account is send-only.
/// </summary>
[MaxLength(256)]
[Column("IMAP_SERVER", TypeName = "nvarchar(256)")]
public string? ImapServer { get; set; }
/// <summary>
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
/// </summary>
[Column("IMAP_PORT", TypeName = "int")]
public int ImapPort { get; set; } = 993;
/// <summary>
/// Use SSL/TLS when connecting to the IMAP server.
/// </summary>
[Column("IMAP_USE_SSL", TypeName = "bit")]
public bool ImapUseSsl { get; set; } = true;
}

View File

@@ -0,0 +1,83 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace DigitalData.MessagingService.Domain.Entities;
/// <summary>
/// Represents a single email attachment.
/// </summary>
[Table("EMAIL_ATTACHMENT")]
public sealed class EmailAttachment
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Column("ID", TypeName = "bigint")]
#if NET
public long Id { get; init; }
#else
public long Id { get; set; }
#endif
/// <summary>
/// Display name of the attachment (e.g. "invoice.pdf").
/// </summary>
[Required]
[MaxLength(260)]
[Column("FILE_NAME", TypeName = "nvarchar(260)")]
#if NETFRAMEWORK
public string FileName { get; set; } = null!;
#else
public required string FileName { get; init; }
#endif
/// <summary>
/// Raw content of the attachment.
/// </summary>
[Required]
[Column("CONTENT", TypeName = "varbinary(max)")]
#if NETFRAMEWORK
public byte[] Content { get; set; } = null!;
#else
public required byte[] Content { get; init; }
#endif
/// <summary>
/// MIME content-type (e.g. "application/pdf", "image/png").
/// Defaults to "application/octet-stream" when not specified.
/// </summary>
[Required]
[MaxLength(256)]
[Column("CONTENT_TYPE", TypeName = "nvarchar(256)")]
public string ContentType { get; set; } = "application/octet-stream";
/// <summary>
/// When <see langword="true"/> the attachment is embedded inline and displayed
/// directly inside the email body via a CID reference (e.g. &lt;img src="cid:logo"&gt;).
/// When <see langword="false"/> (default) it appears as a regular downloadable attachment.
/// </summary>
[Column("IS_INLINE", TypeName = "bit")]
public bool IsInline { get; set; } = false;
/// <summary>
/// Content-ID used when <see cref="IsInline"/> is <see langword="true"/>.
/// Reference it in HTML body as <c>cid:{ContentId}</c>.
/// Auto-generated from <see cref="FileName"/> when left empty.
/// </summary>
[MaxLength(512)]
[Column("CONTENT_ID", TypeName = "nvarchar(512)")]
public string? ContentId { get; set; }
[ForeignKey(nameof(Email))]
[Column("EMAIL_ID", TypeName = "bigint")]
#if NET
public long EmailId { get; init; }
#else
public long EmailId { get; set; }
#endif
#if NET
public ReceivedEmail? Email { get; init; }
#else
public ReceivedEmail? Email { get; set; }
#endif
}

View File

@@ -0,0 +1,142 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace DigitalData.MessagingService.Domain.Entities;
/// <summary>
/// Represents an email message received via IMAP.
/// </summary>
[Table("RECEIVED_EMAIL")]
public sealed record ReceivedEmail
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Column("ID", TypeName = "bigint")]
#if NET
public long Id { get; init; }
#else
public long Id { get; set; }
#endif
/// <summary>
/// Unique identifier of the message on the IMAP server (UID).
/// </summary>
[Column("UID", TypeName = "bigint")]
#if NET
public long Uid { get; init; }
#else
public long Uid { get; set; }
#endif
/// <summary>
/// Sender address (From header).
/// </summary>
[Required]
[MaxLength(512)]
[Column("FROM", TypeName = "nvarchar(512)")]
#if NET
public string From { get; init; } = string.Empty;
#else
public string From { get; set; } = string.Empty;
#endif
/// <summary>
/// Recipient addresses (To header).
/// </summary>
[Column("TO", TypeName = "nvarchar(max)")]
#if NET
public List<string> To { get; init; } = [];
#else
public List<string> To { get; set; } = [];
#endif
/// <summary>
/// CC addresses.
/// </summary>
[Column("CC", TypeName = "nvarchar(max)")]
#if NET
public List<string> Cc { get; init; } = [];
#else
public List<string> Cc { get; set; } = [];
#endif
/// <summary>
/// Email subject.
/// </summary>
[MaxLength(998)]
[Column("SUBJECT", TypeName = "nvarchar(998)")]
#if NET
public string Subject { get; init; } = string.Empty;
#else
public string Subject { get; set; } = string.Empty;
#endif
/// <summary>
/// Plain-text body (may be empty when only HTML is present).
/// </summary>
[Column("TEXT_BODY", TypeName = "nvarchar(max)")]
#if NET
public string TextBody { get; init; } = string.Empty;
#else
public string TextBody { get; set; } = string.Empty;
#endif
/// <summary>
/// HTML body (may be empty when only plain-text is present).
/// </summary>
[Column("HTML_BODY", TypeName = "nvarchar(max)")]
#if NET
public string HtmlBody { get; init; } = string.Empty;
#else
public string HtmlBody { get; set; } = string.Empty;
#endif
/// <summary>
/// Date/time the message was sent (Date header).
/// </summary>
[Column("DATE", TypeName = "datetime2")]
#if NET
public DateTime Date { get; init; }
#else
public DateTime Date { get; set; }
#endif
/// <summary>
/// Whether the message has been marked as seen/read on the server.
/// </summary>
[Column("IS_SEEN", TypeName = "bit")]
#if NET
public bool IsSeen { get; init; }
#else
public bool IsSeen { get; set; }
#endif
[ForeignKey(nameof(Account))]
[Column("ACCOUNT_ID", TypeName = "int")]
#if NET
public int AccountId { get; init; }
#else
public int AccountId { get; set; }
#endif
/// <summary>
/// Attachments included with this message.
/// </summary>
#if NET
public IEnumerable<EmailAttachment>? Attachments { get; init; }
#else
public IEnumerable<EmailAttachment>? Attachments { get; set; }
#endif
#if NET
public EmailAccount? Account { get; init; }
#else
public EmailAccount? Account { get; set; }
#endif
#if NET
public required string Folder { get; init; }
#else
public string Folder { get; set; } = null!;
#endif
}

View File

@@ -0,0 +1,16 @@
namespace DigitalData.MessagingService.Domain.Exceptions
{
/// <summary>
/// Exception thrown when OAuth2 authentication fails.
/// </summary>
public class AuthenticationFailedException : Exception
{
public AuthenticationFailedException(string message) : base(message)
{
}
public AuthenticationFailedException(string message, Exception innerException) : base(message, innerException)
{
}
}
}

View File

@@ -0,0 +1,21 @@
namespace DigitalData.MessagingService.Domain.Exceptions
{
/// <summary>
/// Exception thrown when a requested entity is not found.
/// </summary>
public class BadRequestException : Exception
{
public BadRequestException(string entityName, object key) : base($"{entityName} with key '{key}' was not found.")
{
}
public BadRequestException(string message) : base(message)
{
}
public BadRequestException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}

View File

@@ -0,0 +1,21 @@
namespace DigitalData.MessagingService.Domain.Exceptions
{
/// <summary>
/// Exception thrown when a requested entity is not found.
/// </summary>
public class NotFoundException : Exception
{
public NotFoundException(string entityName, object key) : base($"{entityName} with key '{key}' was not found.")
{
}
public NotFoundException(string message) : base(message)
{
}
public NotFoundException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}

View File

@@ -0,0 +1,77 @@
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Infrastructure.Mappings;
using DigitalData.MessagingService.Infrastructure.Persistence;
using DigitalData.MessagingService.Infrastructure.Queue;
using DigitalData.MessagingService.Infrastructure.Repositories;
using DigitalData.MessagingService.Infrastructure.Services;
using DigitalData.MessagingService.Infrastructure.Services.Background;
using DigitalData.MessagingService.Publisher;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace DigitalData.MessagingService.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)
{
// --- External Services ---
// Email Service - SMTP outbound (Limilabs Mail.dll)
services.AddSingleton<IEmailService, LimilabsEmailService>();
// Email Service - IMAP inbound (Limilabs Mail.dll)
// Fresh connection per call — stateless and thread-safe.
services.AddScoped<IImapEmailService, LimilabsImapEmailService>();
// PDF Processing Service (using DevExpress.Pdf)
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
// Encryption Service (using Data Protection API - Singleton, thread-safe)
services.AddSingleton<IEncryptionService, DataProtectionEncryptionService>();
// --- Email Queue (RabbitMQ) ---
services.AddSingleton<SendingEmailConsumerPool>();
services.AddMessagingServicePublisher();
// --- RabbitMQ Configuration ---
services.AddRabbitMqConnectionFactory(configuration);
// --- Data Protection (for encryption) ---
services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(@"C:\ProgramData\EmailService\Keys"))
.SetApplicationName("MessagingService");
// Register Background Workers
services.AddHostedService<AsyncInitWorker>();
services.AddHostedService<EmailSyncWorker>();
services.AddMemoryCache();
// --- Database (InMemory) ---
services.AddDbContext<MessagingServiceDbContext>(options =>
options.UseInMemoryDatabase("MessagingServiceDb"));
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddScoped<IReceivedEmailRepository, ReceivedEmailRepository>();
// AutoMapper - Register entity self-mappings (T -> T) for generic repository
services.AddAutoMapper(config => config.AddMaps(typeof(EntitySelfMappingProfile).Assembly));
return services;
}
}

View File

@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" />
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
<ProjectReference Include="..\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj" />
<ProjectReference Include="..\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="16.2.0" />
<PackageReference Include="DevExpress.Document.Processor" Version="26.1.3" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.11">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.65.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
<Reference Include="Mail">
<HintPath>M:\Bibliotheken\3rdParty\Limilabs\Mail\Redistributables\net8.0\Mail.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Folder Include="Messaging\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,19 @@
using AutoMapper;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Infrastructure.Mappings;
/// <summary>
/// AutoMapper profile that registers self-mappings (T -> T) for all domain entities.
/// This allows AutoMapper to be used uniformly in the generic repository
/// regardless of whether TDto is a DTO or the entity type itself.
/// </summary>
public class EntitySelfMappingProfile : Profile
{
public EntitySelfMappingProfile()
{
CreateMap<EmailAccount, EmailAccount>();
CreateMap<ReceivedEmail, ReceivedEmail>();
CreateMap<EmailAttachment, EmailAttachment>();
}
}

View File

@@ -0,0 +1,38 @@
using DigitalData.MessagingService.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace DigitalData.MessagingService.Infrastructure.Persistence;
/// <summary>
/// Entity Framework Core DbContext for MessagingService.
/// </summary>
public class MessagingServiceDbContext(DbContextOptions<MessagingServiceDbContext> options) : DbContext(options)
{
public DbSet<EmailAccount> EmailAccounts => Set<EmailAccount>();
public DbSet<ReceivedEmail> ReceivedEmails => Set<ReceivedEmail>();
public DbSet<EmailAttachment> EmailAttachments => Set<EmailAttachment>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<EmailAccount>(entity =>
{
entity.Property(e => e.Username)
.UseCollation("SQL_Latin1_General_CP1_CI_AS");
});
modelBuilder.Entity<ReceivedEmail>(entity =>
{
entity.HasMany(e => e.Attachments)
.WithOne(a => a.Email)
.HasForeignKey(a => a.EmailId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Account)
.WithMany()
.HasForeignKey(e => e.AccountId)
.OnDelete(DeleteBehavior.Restrict);
});
}
}

View File

@@ -0,0 +1,140 @@
using System.Text;
using System.Text.Json;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.Logging;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using DigitalData.MessagingService.Application.Common.Dto;
namespace DigitalData.MessagingService.Infrastructure.Queue;
/// <summary>
/// A single RabbitMQ consumer that processes one email message at a time on its own dedicated channel.
/// Multiple instances run in parallel via <see cref="SendingEmailConsumerPool"/> (competing consumers pattern).
/// Each instance owns exactly one channel — channels are not thread-safe and must not be shared.
/// </summary>
public sealed class SendingEmailConsumer : IAsyncDisposable
{
private readonly string _queueName;
private readonly Lazy<Task<IChannel>> _lazyChannel;
private readonly Lazy<Task> _lazyInit;
private readonly ILogger<SendingEmailConsumer>? _logger;
/// <summary>
/// Transient identifier assigned to this consumer instance at runtime.
/// A new value is generated each time the application starts or a new consumer is created.
/// Use this to correlate log entries belonging to the same consumer session across competing instances.
/// </summary>
public Guid RuntimeId { get; } = Guid.NewGuid();
public SendingEmailConsumer(string queueName, IEmailService emailService, RabbitMqConnectionFactory cnnFactory, ILogger<SendingEmailConsumer>? logger = null)
{
_logger = logger;
_queueName = queueName;
_lazyChannel = new(cnnFactory.CreateChannelAsync);
_lazyInit = new(async () =>
{
var channel = await _lazyChannel.Value;
// prefetchCount=1 ensures this consumer processes one message at a time before acking.
// Parallelism comes from running multiple consumer instances, not from within a single channel.
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (sender, args) =>
{
SendingEmailEvent? oMailEvent = null;
try
{
var json = Encoding.UTF8.GetString(args.Body.ToArray());
oMailEvent = JsonSerializer.Deserialize<SendingEmailEvent>(json);
if (oMailEvent is not null)
{
// Send email via SMTP (SMTP config is injected in IEmailService via IOptions)
await emailService.SendEmailAsync(oMailEvent.Mail, args.CancellationToken);
// Acknowledge message after successful processing
await channel.BasicAckAsync(args.DeliveryTag, false, args.CancellationToken);
logger?.LogDebug(
"Email successfully sent and acknowledged. RuntimeId={RuntimeId}, Queue={QueueName}, DeliveryTag={DeliveryTag}, To={Recipients}, Subject={Subject}, EventId={EventId}",
RuntimeId, _queueName, args.DeliveryTag, oMailEvent.Mail.Recipients, oMailEvent.Mail.Subject, oMailEvent.Id);
}
else
{
logger?.LogWarning("Failed to deserialize email message: DeliveryTag={DeliveryTag}", args.DeliveryTag);
await channel.BasicNackAsync(args.DeliveryTag, false, false, args.CancellationToken); // Don't requeue invalid messages
}
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed to process email [To={To}, Subject={Subject}] message: DeliveryTag={DeliveryTag}. Moving to DLQ (NO retry).", oMailEvent?.Mail.Recipients, oMailEvent?.Mail.Subject, args.DeliveryTag);
// TODO: Error Reporting Strategy
// Option 1: Separate RabbitMQ Queue (emailprofiler.errors)
// - Create EmailErrorReport entity { SendingEmailEventId, Exception, StackTrace, Timestamp, RetryAttempt }
// - Publish to error queue: await _errorQueue.EnqueueAsync(errorReport)
// - Separate worker processes error queue → Log to DB/File/External monitoring
//
// Option 2: Database Table (TBEMLP_ERROR_LOG)
// - Columns: ERROR_ID, OUTBOX_ID, ERROR_MESSAGE, STACK_TRACE, ERROR_DATE
// - Insert via IErrorLogRepository.CreateAsync(errorLog)
//
// Option 3: External Monitoring Service
// - Sentry: SentrySdk.CaptureException(ex)
// - Application Insights: _telemetryClient.TrackException(ex)
// - Elasticsearch: _elasticClient.IndexDocument(errorLog)
//
// Recommended: Option 1 (RabbitMQ Error Queue) + Option 2 (DB persistence)
// - Fast async error logging (non-blocking)
// - Persistent storage for audit
// - Real-time alerting via monitoring worker
// NO RETRY - All failures move directly to DLQ
await channel.BasicNackAsync(args.DeliveryTag, false, false, args.CancellationToken); // requeue=false → DLQ
}
};
// Start consuming messages (event-driven, non-blocking)
await channel.BasicConsumeAsync(
queue: _queueName,
autoAck: false,
consumer: consumer,
cancellationToken: cnnFactory.CancellationToken);
logger?.LogInformation("RabbitMQ consumer started for queue: {QueueName}", _queueName);
});
}
/// <summary>
/// Starts the consumer: opens a channel, sets QoS, and registers the event handler.
/// Called by <see cref="SendingEmailConsumerPool.InitAsync"/>.
/// </summary>
public async Task InitAsync()
{
if (_lazyInit.IsValueCreated)
_logger?.LogWarning("SendingEmailConsumer already initialized. InitAsync() called multiple times.");
await _lazyInit.Value;
}
public async ValueTask DisposeAsync()
{
if (!_lazyChannel.IsValueCreated)
return;
var channel = await _lazyChannel.Value;
if (channel is not null)
{
await channel.CloseAsync();
await channel.DisposeAsync();
}
}
}

View File

@@ -0,0 +1,51 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DigitalData.MessagingService.Infrastructure.Queue;
/// <summary>
/// Manages a pool of <see cref="SendingEmailConsumer"/> instances that compete for messages
/// on the same RabbitMQ queue (competing consumers pattern).
/// Each consumer owns a dedicated channel, so they process messages fully in parallel
/// without any shared locking or synchronization primitives.
/// </summary>
public sealed class SendingEmailConsumerPool : IAsyncDisposable
{
private readonly List<SendingEmailConsumer> _consumers;
private readonly ILogger<SendingEmailConsumerPool>? _logger;
private readonly int _concurrency;
public SendingEmailConsumerPool(
IOptions<RabbitMqConfiguration> config,
IEmailService emailService,
RabbitMqConnectionFactory cnnFactory,
ILogger<SendingEmailConsumerPool>? logger = null,
ILogger<SendingEmailConsumer>? consumerLogger = null)
{
_logger = logger;
_concurrency = config.Value.ConsumerConcurrency;
_consumers = [.. Enumerable
.Range(0, _concurrency)
.Select(_ => new SendingEmailConsumer(config.Value.QueueName, emailService, cnnFactory, consumerLogger))];
}
/// <summary>
/// Starts all consumers in parallel. Each consumer opens its own channel and begins listening.
/// </summary>
public async Task InitAsync()
{
_logger?.LogInformation("Starting {Count} competing email consumers.", _concurrency);
await Task.WhenAll(_consumers.Select(c => c.InitAsync()));
_logger?.LogInformation("All {Count} email consumers started.", _concurrency);
}
public async ValueTask DisposeAsync()
{
await Task.WhenAll(_consumers.Select(async c => await c.DisposeAsync().AsTask()));
}
}

View File

@@ -0,0 +1,83 @@
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace DigitalData.MessagingService.Infrastructure.Repositories;
public class ReceivedEmailRepository(MessagingServiceDbContext Context, IMapper Mapper) : Repository<ReceivedEmail>(Context, Mapper), IReceivedEmailRepository
{
public async Task<IEnumerable<ReceivedEmail>> FindAsync(MailSearchFilter mailSearchFilter, EmailAccount? accountQuery = null, CancellationToken cancellationToken = default)
{
var query = DbSet.AsNoTracking();
// ── Account filter ─────────────────────────────────────────────────────
if (accountQuery is not null)
query = query.Where(x => x.AccountId == accountQuery.Id);
// ── Flag filters ───────────────────────────────────────────────────────
if (mailSearchFilter.UnseenOnly)
query = query.Where(x => !x.IsSeen);
// ── Text filters ───────────────────────────────────────────────────────
if (!string.IsNullOrWhiteSpace(mailSearchFilter.SubjectContains))
query = query.Where(x => x.Subject.Contains(mailSearchFilter.SubjectContains));
if (!string.IsNullOrWhiteSpace(mailSearchFilter.SenderContains))
query = query.Where(x => x.From.Contains(mailSearchFilter.SenderContains));
if (!string.IsNullOrWhiteSpace(mailSearchFilter.BodyContains))
query = query.Where(x => x.TextBody.Contains(mailSearchFilter.BodyContains)
|| x.HtmlBody.Contains(mailSearchFilter.BodyContains));
// ── UID filter ─────────────────────────────────────────────────────────
if (mailSearchFilter.Uid is { } uid)
{
if (uid.Absolute.HasValue)
query = query.Where(x => x.Uid == uid.Absolute.Value);
else
{
if (uid.Min.HasValue)
query = query.Where(x => x.Uid >= uid.Min.Value);
if (uid.Max.HasValue)
query = query.Where(x => x.Uid <= uid.Max.Value);
}
}
// ── Date filter ────────────────────────────────────────────────────────
if (mailSearchFilter.Date is { } date)
{
if (date.After.HasValue)
query = query.Where(x => x.Date >= date.After.Value);
if (date.Before.HasValue)
query = query.Where(x => x.Date <= date.Before.Value);
}
// ── Attachments ────────────────────────────────────────────────────────
if (mailSearchFilter.WithAttachments)
query = query.Include(x => x.Attachments);
// ── Sort ───────────────────────────────────────────────────────────────
query = mailSearchFilter.SortOrder == MailSortOrder.OldestFirst
? query.OrderBy(x => x.Date)
: query.OrderByDescending(x => x.Date);
// ── Limit ──────────────────────────────────────────────────────────────
if (mailSearchFilter.MaxCount.HasValue)
query = query.Take(mailSearchFilter.MaxCount.Value);
// ── RecipientContains: To/Cc are IEnumerable<string> (nvarchar(max)) ──
// EF Core cannot translate collection predicates on these columns to SQL.
// Materialization is deferred until after other DB-side filters narrow the set.
var results = await query.ToListAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(mailSearchFilter.RecipientContains))
results = [.. results
.Where(x => x.To.Any(t => t.Contains(mailSearchFilter.RecipientContains, StringComparison.OrdinalIgnoreCase))
|| x.Cc.Any(c => c.Contains(mailSearchFilter.RecipientContains, StringComparison.OrdinalIgnoreCase)))];
return results;
}
}

View File

@@ -0,0 +1,213 @@
using System.Linq.Expressions;
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Domain.Exceptions;
using DigitalData.MessagingService.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace DigitalData.MessagingService.Infrastructure.Repositories;
/// <summary>
/// Generic repository implementation with AutoMapper-based CRUD operations.
/// IMPORTANT: Each operation auto-saves changes - NO explicit SaveChangesAsync needed!
/// </summary>
public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapper) : IRepository<TEntity> where TEntity : class
{
protected readonly DbSet<TEntity> DbSet = Context.Set<TEntity>();
// --- CREATE ---
public async Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default)
{
var entity = Mapper.Map<TEntity>(dto);
await DbSet.AddAsync(entity, cancellationToken);
await Context.SaveChangesAsync(cancellationToken);
return entity;
}
public async Task<IEnumerable<TEntity>> CreateRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default)
{
var entities = Mapper.Map<IEnumerable<TEntity>>(dtos);
await DbSet.AddRangeAsync(entities, cancellationToken);
await Context.SaveChangesAsync(cancellationToken);
return entities;
}
// --- READ ---
public async Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await DbSet.FindAsync([id], cancellationToken);
}
public async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await DbSet.ToListAsync(cancellationToken);
}
public async Task<IEnumerable<TEntity>> FindAsync(
Expression<Func<TEntity, bool>> predicate,
int? skip = null,
int? take = null,
CancellationToken cancellationToken = default)
{
var query = DbSet.Where(predicate);
if (skip.HasValue)
query = query.Skip(skip.Value);
if (take.HasValue)
query = query.Take(take.Value);
return await query.ToListAsync(cancellationToken);
}
public async Task<TEntity?> FindFirstAsync(
Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default)
{
return await DbSet.FirstOrDefaultAsync(predicate, cancellationToken);
}
public async Task<TEntity?> FindSingleAsync(
Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default)
{
return await DbSet.SingleOrDefaultAsync(predicate, cancellationToken);
}
public async Task<int> CountAsync(
Expression<Func<TEntity, bool>>? predicate = null,
CancellationToken cancellationToken = default)
{
return predicate == null
? await DbSet.CountAsync(cancellationToken)
: await DbSet.CountAsync(predicate, cancellationToken);
}
public async Task<bool> AnyAsync(
Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default)
{
return await DbSet.AnyAsync(predicate, cancellationToken);
}
// --- UPSERT ---
/// <summary>
/// Upsert: if no record matches the predicate, creates a new entity;
/// if one or more match, updates the FIRST match.
/// Returns the entity and a flag indicating whether it was created (true) or updated (false).
/// Auto-saves changes.
/// </summary>
public async Task<(TEntity Entity, bool Created)> UpsertAsync<TDto>(
Expression<Func<TEntity, bool>> predicate,
TDto dto,
CancellationToken cancellationToken = default)
{
var entity = await DbSet.FirstOrDefaultAsync(predicate, cancellationToken);
if (entity is null)
{
entity = Mapper.Map<TEntity>(dto);
await DbSet.AddAsync(entity, cancellationToken);
await Context.SaveChangesAsync(cancellationToken);
return (entity, true);
}
Mapper.Map(dto, entity);
await Context.SaveChangesAsync(cancellationToken);
return (entity, false);
}
/// <summary>
/// Upsert (single-safe): if no record matches the predicate, creates a new entity;
/// if exactly one matches, updates it. Throws InvalidOperationException if 2+ match.
/// Auto-saves changes.
/// </summary>
public async Task<(TEntity Entity, bool Created)> UpsertSingleAsync<TDto>(
Expression<Func<TEntity, bool>> predicate,
TDto dto,
CancellationToken cancellationToken = default)
{
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken);
if (entity is null)
{
entity = Mapper.Map<TEntity>(dto);
await DbSet.AddAsync(entity, cancellationToken);
await Context.SaveChangesAsync(cancellationToken);
return (entity, true);
}
Mapper.Map(dto, entity);
await Context.SaveChangesAsync(cancellationToken);
return (entity, false);
}
// --- UPDATE ---
/// <summary>
/// Updates a SINGLE entity that matches the predicate.
/// Throws NotFoundException if 0 or 2+ records match.
/// Auto-saves changes.
/// </summary>
public async Task UpdateSingleAsync<TDto>(
Expression<Func<TEntity, bool>> predicate,
TDto dto,
CancellationToken cancellationToken = default)
{
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken)
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
Mapper.Map(dto, entity);
await Context.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// Updates ALL entities that match the predicate (bulk operation).
/// Returns count of updated records.
/// Auto-saves changes.
/// </summary>
public async Task<int> UpdateAsync<TDto>(
Expression<Func<TEntity, bool>> predicate,
TDto dto,
CancellationToken cancellationToken = default)
{
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
entities.ForEach(entity => Mapper.Map(dto, entity));
await Context.SaveChangesAsync(cancellationToken);
return entities.Count;
}
// --- DELETE ---
/// <summary>
/// Deletes a SINGLE entity that matches the predicate.
/// Throws NotFoundException if 0 or 2+ records match.
/// Auto-saves changes.
/// </summary>
public async Task DeleteSingleAsync(
Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default)
{
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken)
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
DbSet.Remove(entity);
await Context.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// Deletes ALL entities that match the predicate (bulk operation).
/// Returns count of deleted records.
/// Auto-saves changes.
/// </summary>
public async Task<int> DeleteAsync(
Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default)
{
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
DbSet.RemoveRange(entities);
await Context.SaveChangesAsync(cancellationToken);
return entities.Count;
}
}

View File

@@ -0,0 +1,19 @@
using DigitalData.MessagingService.Infrastructure.Queue;
using Microsoft.Extensions.Hosting;
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
/// <summary>
/// A hosted background service responsible for initializing the competing email consumer pool.
/// Leverages a push-based, event-driven RabbitMQ consumer to eliminate polling overhead.
/// Email account configuration is resolved exclusively from application settings; no database access is performed.
/// </summary>
public class AsyncInitWorker(SendingEmailConsumerPool ConsumerPool) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await ConsumerPool.InitAsync();
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
}

View File

@@ -0,0 +1,61 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Application.Common.Options;
using DigitalData.MessagingService.Domain.Entities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
public class EmailSyncWorker(IOptions<EmailAccountsOptions> Options, IServiceProvider Provider, ILogger<EmailSyncWorker> Logger) : BackgroundService
{
private readonly string DefaultFolder = "INBOX";
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await UpsertSeedEmailAccount(stoppingToken);
var interval = TimeSpan.FromSeconds(Options.Value.SyncIntervalSeconds);
while (!stoppingToken.IsCancellationRequested)
{
using var scope = Provider.CreateAsyncScope();
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
var imapService = scope.ServiceProvider.GetRequiredService<IImapEmailService>();
foreach (var account in await emailAccountRepo.GetAllAsync(stoppingToken))
if (account.ImapServer is not null)
{
Logger.LogDebug("Email synchronization has started for account {username} in folder {folder}.", account.Username, DefaultFolder);
try
{
var res = await imapService.SyncEmailsAsync(account, DefaultFolder, stoppingToken);
Logger.LogDebug("Email synchronization has completed for account {username} in folder {folder}. Processed: {processedCount}, Failed: {failedCount}", account.Username, DefaultFolder, res.ProcessedCount, res.FailedCount);
}
catch(Exception ex)
{
// Log the exception or handle it as needed
Logger.LogError(ex, "Error syncing emails for account {username}", account.Username);
}
}
await Task.Delay(interval, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
}
public async Task UpsertSeedEmailAccount(CancellationToken stoppingToken)
{
using var scope = Provider.CreateAsyncScope();
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
// init seed email accounts if not exist
foreach (var account in Options.Value.Accounts)
await emailAccountRepo.UpsertAsync(a => a.Username == account.Username, account, stoppingToken);
}
}

View File

@@ -0,0 +1,23 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using Microsoft.AspNetCore.DataProtection;
namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// Encryption service using ASP.NET Core Data Protection API.
/// Passwords are encrypted at rest in the database.
/// </summary>
public class DataProtectionEncryptionService(IDataProtectionProvider Provider) : IEncryptionService
{
private readonly IDataProtector Protector = Provider.CreateProtector("MessagingService.Passwords");
public string Encrypt(string plainText)
{
return Protector.Protect(plainText);
}
public string Decrypt(string cipherText)
{
return Protector.Unprotect(cipherText);
}
}

View File

@@ -0,0 +1,95 @@
using DevExpress.Pdf;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Domain.Exceptions;
namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// PDF processing service using DevExpress.Pdf.
/// Implements PDF validation and embedded file extraction using streams.
/// </summary>
public class DevExpressPdfProcessingService : IPdfProcessingService
{
public Task<bool> ValidatePdfAsync(Stream pdfStream, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(pdfStream);
if (!pdfStream.CanRead)
throw new ArgumentException("Stream must be readable.", nameof(pdfStream));
if (!pdfStream.CanSeek)
throw new ArgumentException("Stream must be seekable.", nameof(pdfStream));
if (pdfStream.Position != 0)
pdfStream.Position = 0;
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
return Task.FromResult(true);
}
public async Task<IEnumerable<string>> ExtractEmbeddedFilesAsync(
Stream pdfStream,
string outputDirectory,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(pdfStream);
ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory);
if (!pdfStream.CanRead)
throw new ArgumentException("Stream must be readable.", nameof(pdfStream));
if (!pdfStream.CanSeek)
throw new ArgumentException("Stream must be seekable.", nameof(pdfStream));
if (pdfStream.Position != 0)
pdfStream.Position = 0;
if (!Directory.Exists(outputDirectory))
Directory.CreateDirectory(outputDirectory);
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
var extractedFiles = new List<string>();
var attachments = processor.Document.FileAttachments;
if (attachments == null || !attachments.Any())
return extractedFiles;
foreach (var attachment in attachments)
{
var fileName = attachment.FileName ?? $"attachment_{Guid.NewGuid()}.dat";
var outputPath = Path.Combine(outputDirectory, fileName);
var fileData = attachment.Data;
if (fileData == null || fileData.Length == 0)
continue;
await File.WriteAllBytesAsync(outputPath, fileData, cancellationToken);
extractedFiles.Add(outputPath);
}
return extractedFiles;
}
public Task<int> GetPageCountAsync(Stream pdfStream, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(pdfStream);
if (!pdfStream.CanRead)
throw new ArgumentException("Stream must be readable.", nameof(pdfStream));
if (!pdfStream.CanSeek)
throw new ArgumentException("Stream must be seekable.", nameof(pdfStream));
if (pdfStream.Position != 0)
pdfStream.Position = 0;
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(pdfStream);
return Task.FromResult(processor.Document.Pages.Count);
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.Extensions.Caching.Memory;
namespace DigitalData.MessagingService.Infrastructure.Services.Extensions;
public static class CacheExtensions
{
private readonly static string ImapCacheKeyPrefix = Guid.NewGuid().ToString();
private static string CreateImapLastSyncDateCacheKey(int accountId, string folder)
{
return $"{ImapCacheKeyPrefix}_{accountId}_{folder}_LastImapSyncDate";
}
public static DateTime? GetLastImapSyncDate(this IMemoryCache cache, int accountId, string folder)
{
return cache.Get<DateTime?>(CreateImapLastSyncDateCacheKey(accountId, folder));
}
public static void SetLastImapSyncDate(this IMemoryCache cache, int accountId, string folder, DateTime date)
{
var key = CreateImapLastSyncDateCacheKey(accountId, folder);
cache.Set(key, date);
}
}

View File

@@ -0,0 +1,16 @@
using Limilabs.Client.IMAP;
namespace DigitalData.MessagingService.Infrastructure.Services.Extensions;
public static class ImapExtensions
{
public static async Task CloseSafelyAsync(this Imap imap)
{
try
{
if (imap.Connected)
await imap.CloseAsync();
}
catch { /* Ignore disconnect errors */ }
}
}

View File

@@ -0,0 +1,16 @@
using Limilabs.Client.SMTP;
namespace DigitalData.MessagingService.Infrastructure.Services.Extensions;
public static class SmtpExtensions
{
public static async Task CloseSafelyAsync(this Smtp smtp)
{
try
{
if (smtp.Connected)
await smtp.CloseAsync();
}
catch { /* Ignore disconnect errors */ }
}
}

View File

@@ -0,0 +1,140 @@
using System.Text;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Domain.Exceptions;
using Limilabs.Client.SMTP;
using Limilabs.Mail;
using Limilabs.Mail.Headers;
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// Email service using Limilabs Mail.dll for SMTP operations (send-only).
/// Commercial-grade library with superior Exchange support.
/// SMTP configuration is injected via IOptions&lt;EmailAccountsOptions&gt; from appsettings.json.
/// Uses the first account in the list whose <see cref="EmailAccount.Name"/> equals <c>"default"</c>,
/// or falls back to the first account if none is named "default".
/// </summary>
public class LimilabsEmailService() : IEmailService
{
// Register encoding provider for Limilabs (requires windows-1252 and other code pages)
static LimilabsEmailService()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
public async Task SendEmailAsync(EmailContext context, CancellationToken cancellationToken = default)
{
using var smtp = new Smtp();
ISendMessageResult? result = null;
try
{
await ConnectAndAuthenticateSmtpAsync(smtp, context.Sender);
var builder = new MailBuilder();
builder.From.Add(new MailBox(context.Sender.Username));
foreach (var recipient in context.Recipients)
builder.To.Add(new MailBox(recipient));
builder.Subject = context.Subject;
if (context.IsHtml)
builder.Html = context.Body;
else
builder.Text = context.Body;
AddAttachments(builder, context.Attachments);
var mail = builder.Create();
result = await smtp.SendMessageAsync(mail, cancellationToken);
if (result.Status != SendMessageStatus.Success)
{
throw new InvalidOperationException($"Failed to send email. Status: {result.Status}. {ErrorMessageBuilder(result)}");
}
await smtp.CloseAsync(cancellationToken);
}
catch (Limilabs.Client.ServerException ex)
{
await smtp.CloseSafelyAsync();
throw new AuthenticationFailedException($"SMTP authentication failed. Check credentials or OAuth2 configuration. {ErrorMessageBuilder(result)}", ex);
}
catch (Exception ex)
{
await smtp.CloseSafelyAsync();
throw new InvalidOperationException($"Failed to send email via SMTP server. {ErrorMessageBuilder(result)}", ex);
}
}
private static async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccount smtpAccount)
{
if (smtpAccount.SmtpUseSsl)
{
await smtp.ConnectSSLAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort);
}
else
{
await smtp.ConnectAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort);
}
if (smtpAccount.UseOAuth2)
{
throw new NotSupportedException("OAuth2 is not configured for this SMTP account. UseOAuth2 must be false.");
}
else
{
await smtp.LoginAsync(smtpAccount.Username, smtpAccount.Password);
}
}
private static string ErrorMessageBuilder(ISendMessageResult? result = null)
{
if(result is null || result.GeneralErrors.Count == 0)
return string.Empty;
else if(result.GeneralErrors.Count == 1)
return $"Error: {result.GeneralErrors.FirstOrDefault()}";
var message = new StringBuilder("Errors:\n");
foreach (var error in result.GeneralErrors)
{
message.AppendLine($" • {error}");
}
return message.ToString();
}
private static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentDto> attachments)
{
foreach (var attachment in attachments)
{
if (attachment.IsInline)
{
var visual = builder.AddVisual(attachment.Content);
visual.FileName = attachment.FileName;
visual.ContentId = string.IsNullOrWhiteSpace(attachment.ContentId)
? attachment.FileName
: attachment.ContentId;
if (!string.IsNullOrWhiteSpace(attachment.ContentType))
visual.ContentType = ContentType.Parse(attachment.ContentType);
}
else
{
var part = builder.AddAttachment(attachment.Content);
part.FileName = attachment.FileName;
if (!string.IsNullOrWhiteSpace(attachment.ContentType))
part.ContentType = ContentType.Parse(attachment.ContentType);
if (!string.IsNullOrWhiteSpace(attachment.ContentId))
part.ContentId = attachment.ContentId;
}
}
}
}

View File

@@ -0,0 +1,189 @@
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
using Limilabs.Client.IMAP;
using Limilabs.Mail;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Text;
namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// IMAP email service using Limilabs Mail.dll.
/// Opens a fresh connection per call — stateless and thread-safe.
/// </summary>
public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger, IRepository<ReceivedEmail> Repository) : IImapEmailService
{
private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString();
static LimilabsImapEmailService()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
// Public API
public async Task<EmailSyncResult> SyncEmailsAsync(EmailAccount account, string folder = "INBOX", CancellationToken cancel = default)
{
using var imap = await OpenAsync(account, folder, cancel);
try
{
#region Find UIDs
// Server-side: only date range; all other filters are applied in-process after cache retrieval
List<ICriterion> criterions = [];
var since = GetLastImapSyncDate(account.Id, folder);
if (since is not null && since != default)
criterions.Add(Expression.SentSince(since.Value));
var searchExpression = criterions.Count > 0 ? Expression.And([.. criterions]) : Expression.All();
var operationStartTime = DateTime.UtcNow;
List<long> uids = [.. await imap.SearchAsync(searchExpression, cancel)];
#endregion
if (uids.Count == 0)
return new EmailSyncResult();
var emails = new List<ReceivedEmailDto>(uids.Count);
var failedCount = 0;
foreach (var uid in uids)
{
if (await Repository.AnyAsync(x => x.Uid == uid, cancel))
continue;
cancel.ThrowIfCancellationRequested();
try
{
#region Read email
var eml = await imap.GetMessageByUIDAsync(uid, cancel);
var mail = new MailBuilder().CreateFromEml(eml);
var flags = await imap.GetFlagsByUIDAsync(uid, cancel);
var attachments = new List<EmailAttachmentDto>();
foreach (var att in mail.Attachments)
{
attachments.Add(new EmailAttachmentDto
{
FileName = att.FileName ?? "attachment",
Content = att.Data,
ContentType = att.ContentType?.ToString() ?? "application/octet-stream",
IsInline = false,
ContentId = att.ContentId
});
}
foreach (var vis in mail.Visuals)
{
attachments.Add(new EmailAttachmentDto
{
FileName = vis.FileName ?? "inline",
Content = vis.Data,
ContentType = vis.ContentType?.ToString() ?? "application/octet-stream",
IsInline = true,
ContentId = vis.ContentId
});
}
var email = new ReceivedEmailDto
{
Uid = uid,
AccountId = account.Id,
From = mail.From.FirstOrDefault()?.Address ?? string.Empty,
To = [.. mail.To.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
Cc = [.. mail.Cc.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
Subject = mail.Subject ?? string.Empty,
TextBody = mail.Text ?? string.Empty,
HtmlBody = mail.Html ?? string.Empty,
Date = mail.Date ?? DateTime.MinValue,
IsSeen = flags.Contains(Flag.Seen),
Attachments = attachments,
Folder = folder
};
#endregion Read email
emails.Add(email);
SetLastImapSyncDate(account.Id, folder, operationStartTime);
}
catch (Exception ex)
{
failedCount += 1;
Logger.LogWarning(ex,
"Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.",
uid, folder);
}
}
await imap.CloseAsync(cancel);
await Repository.CreateRangeAsync(emails, cancel);
return new EmailSyncResult(ProcessedCount: emails.Count, FailedCount: failedCount);
}
catch
{
await imap.CloseSafelyAsync();
throw;
}
}
public async Task MarkAsSeenAsync(EmailAccount account, long uid, string folder = "INBOX", CancellationToken cancel = default)
{
using var imap = await OpenAsync(account, folder, cancel);
try
{
await imap.MarkMessageSeenByUIDAsync(uid, cancel);
await imap.CloseAsync(cancel);
}
catch
{
await imap.CloseSafelyAsync();
throw;
}
}
private static async Task<Imap> OpenAsync(EmailAccount account, string folder = "INBOX", CancellationToken cancel = default)
{
var imap = new Imap();
if (account.ImapUseSsl)
await imap.ConnectSSLAsync(account.ImapServer!, account.ImapPort, cancel: cancel);
else
await imap.ConnectAsync(account.ImapServer!, account.ImapPort, cancel: cancel);
await imap.LoginAsync(account.Username, account.Password, cancel);
if (string.Equals(folder, "INBOX", StringComparison.OrdinalIgnoreCase))
await imap.SelectInboxAsync(cancel);
else
await imap.SelectAsync(folder, cancel);
return imap;
}
#region IMAP Last Sync Date Cache
private readonly ConcurrentDictionary<ImapCacheKey, DateTime> _cache = new();
private record ImapCacheKey(int AccountId, string Folder);
public DateTime? GetLastImapSyncDate(int accountId, string folder = "INBOX")
{
return _cache.GetValueOrDefault(new ImapCacheKey(accountId, folder));
}
private void SetLastImapSyncDate(int accountId, string folder, DateTime date)
{
var key = new ImapCacheKey(accountId, folder);
_cache[key] = date;
}
#endregion
}

View File

@@ -0,0 +1,19 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.DependencyInjection;
namespace DigitalData.MessagingService.Publisher;
public static class DependencyInjection
{
public static IServiceCollection AddMessagingServicePublisher(this IServiceCollection services, Action<RabbitMqConfiguration>? configure = null)
{
if(configure is not null)
services.AddRabbitMqConnectionFactory(configure);
// --- Email Queue (RabbitMQ) ---
services.AddSingleton<ISendingEmailPublisher, SendingEmailPublisher>();
return services;
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'">
<PackageReference Include="System.Text.Json" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" />
<ProjectReference Include="..\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,108 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using DigitalData.MessagingService.RabbitMQ;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Interfaces;
namespace DigitalData.MessagingService.Publisher;
/// <summary>
/// RabbitMQ-based email queue implementation for outgoing emails.
/// Provides message persistence, scalability, and reliability.
/// Uses Lazy<T> initialization pattern to avoid blocking constructor.
/// </summary>
public sealed class SendingEmailPublisher : ISendingEmailPublisher, IAsyncDisposable
{
private readonly RabbitMqConfiguration _config;
private readonly ILogger<SendingEmailPublisher> _logger;
private readonly RabbitMqConnectionFactory _cnnFactory;
private readonly Lazy<Task<IChannel>> _lazyChannel;
public SendingEmailPublisher(IOptions<RabbitMqConfiguration> config, ILogger<SendingEmailPublisher> logger, RabbitMqConnectionFactory cnnFactory)
{
_config = config.Value;
_logger = logger;
_cnnFactory = cnnFactory;
_lazyChannel = new(InitChannelAsync);
}
/// <summary>
/// Initialize RabbitMQ connection, channel, exchanges, and queues asynchronously.
/// Called lazily on first use via EnsureInitializedAsync.
/// </summary>
private async Task<IChannel> InitChannelAsync()
{
var channel = await _cnnFactory.CreateChannelAsync();
// Topology declaration can use either channel; use publish channel here
// Declare Dead Letter Queue (DLQ) exchange
await channel.ExchangeDeclareAsync(exchange: _config.DlqExchangeName, type: ExchangeType.Direct, durable: true, autoDelete: false, cancellationToken: _cnnFactory.CancellationToken);
// Declare Dead Letter Queue (DLQ)
await channel.QueueDeclareAsync(queue: _config.DlqQueueName, durable: true, exclusive: false, autoDelete: false, arguments: null, cancellationToken: _cnnFactory.CancellationToken);
// Bind DLQ to DLQ exchange
await channel.QueueBindAsync(queue: _config.DlqQueueName, exchange: _config.DlqExchangeName, routingKey: _config.DlqRoutingKey, cancellationToken: _cnnFactory.CancellationToken);
// Declare main exchange (Direct type for routing)
await channel.ExchangeDeclareAsync(exchange: _config.ExchangeName, type: ExchangeType.Direct, durable: true, autoDelete: false, cancellationToken: _cnnFactory.CancellationToken);
// Declare main queue (durable for persistence) with DLQ arguments
var queueArgs = new Dictionary<string, object?>
{
{ "x-dead-letter-exchange", _config.DlqExchangeName },
{ "x-dead-letter-routing-key", _config.DlqRoutingKey }
};
await channel.QueueDeclareAsync(queue: _config.QueueName, durable: true, exclusive: false, autoDelete: false, arguments: queueArgs, cancellationToken: _cnnFactory.CancellationToken);
// Bind main queue to exchange with routing key
await channel.QueueBindAsync(queue: _config.QueueName, exchange: _config.ExchangeName, routingKey: _config.RoutingKey, cancellationToken: _cnnFactory.CancellationToken);
_logger.LogInformation("RabbitMQ initialized successfully: Queue={QueueName}, DLQ={DlqQueueName}", _config.QueueName, _config.DlqQueueName);
return channel;
}
public async Task EnqueueAsync(SendingEmailEvent sendingEmailEvent, CancellationToken cancellationToken = default)
{
var json = JsonSerializer.Serialize(sendingEmailEvent);
var body = Encoding.UTF8.GetBytes(json);
var properties = new BasicProperties
{
Persistent = true, // Message persistence
ContentType = "application/json",
Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds())
};
var channel = await _lazyChannel.Value;
await channel.BasicPublishAsync(
exchange: _config.ExchangeName,
routingKey: _config.RoutingKey,
mandatory: false,
basicProperties: properties,
body: body,
cancellationToken: cancellationToken);
}
public async Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default)
{
var channel = await _lazyChannel.Value;
var queueInfo = await channel.QueueDeclarePassiveAsync(_config.QueueName, cancellationToken);
return (int)queueInfo.MessageCount;
}
public async ValueTask DisposeAsync()
{
if (await _lazyChannel.Value is IChannel channel)
{
await channel.CloseAsync();
await channel.DisposeAsync();
}
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
namespace DigitalData.MessagingService.RabbitMQ
{
/// <summary>
/// Dependency injection configuration for Infrastructure layer
/// </summary>
public static class DependencyInjection
{
private static IServiceCollection AddDefaultServices(this IServiceCollection services)
{
services.AddSingleton<RabbitMqConnectionFactory>();
return services;
}
/// <summary>
/// Adds Infrastructure layer services to the DI container
/// </summary>
public static IServiceCollection AddRabbitMqConnectionFactory(this IServiceCollection services, Action<RabbitMqConfiguration> configure)
{
services.AddDefaultServices();
// --- RabbitMQ Configuration ---
services.Configure(configure);
return services;
}
/// <summary>
/// Adds Infrastructure layer services to the DI container
/// </summary>
public static IServiceCollection AddRabbitMqConnectionFactory(this IServiceCollection services, IConfiguration configuration)
{
services.AddDefaultServices();
// --- RabbitMQ Configuration ---
services.Configure<RabbitMqConfiguration>(
configuration.GetSection(RabbitMqConfiguration.SectionName));
return services;
}
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,85 @@
namespace DigitalData.MessagingService.RabbitMQ
{
/// <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>
/// 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;
/// <summary>
/// Name of the main queue where outbound email messages are consumed from.
/// </summary>
public string QueueName { get; set; } = "messaging-service.email.outbox";
/// <summary>
/// Name of the exchange to which email messages are published.
/// Messages are routed from this exchange to <see cref="QueueName"/> via <see cref="RoutingKey"/>.
/// </summary>
public string ExchangeName { get; set; } = "messaging-service.emails";
/// <summary>
/// Routing key used to bind <see cref="QueueName"/> to <see cref="ExchangeName"/>.
/// </summary>
public string RoutingKey { get; set; } = "email.outbox";
/// <summary>
/// Name of the Dead Letter Queue (DLQ) where messages that could not be processed are routed.
/// </summary>
public string DlqQueueName { get; set; } = "messaging-service.email.outbox.dlq";
/// <summary>
/// Name of the Dead Letter Exchange (DLX) that routes rejected or expired messages to <see cref="DlqQueueName"/>.
/// </summary>
public string DlqExchangeName { get; set; } = "messaging-service.emails.dlq";
/// <summary>
/// Routing key used to bind <see cref="DlqQueueName"/> to <see cref="DlqExchangeName"/>.
/// </summary>
public string DlqRoutingKey { get; set; } = "email.outbox.dlq";
/// <summary>
/// Maximum number of email messages processed concurrently by the consumer.
/// Maps directly to RabbitMQ prefetchCount. Recommended: 35.
/// </summary>
public ushort ConsumerConcurrency { get; set; } = 5;
}
}

View File

@@ -0,0 +1,80 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace DigitalData.MessagingService.RabbitMQ
{
public sealed class RabbitMqConnectionFactory : IAsyncDisposable
{
private readonly CancellationTokenSource _consumerCts = new CancellationTokenSource();
private readonly RabbitMqConfiguration _config;
private readonly ILogger<RabbitMqConnectionFactory>
#if nullable
?
#endif
_logger;
private readonly Lazy<Task<IConnection>> _lazyConnectionProvider;
public CancellationToken CancellationToken => _consumerCts.Token;
public Task<IConnection> GetDefaultConnectionAsync()
{
return _lazyConnectionProvider.Value;
}
public async Task<IChannel> CreateChannelAsync()
{
var cnn = await GetDefaultConnectionAsync();
return await cnn.CreateChannelAsync(cancellationToken: CancellationToken);
}
public async Task<AsyncEventingBasicConsumer> CreateConsumerAsync()
{
var channel = await CreateChannelAsync();
return new AsyncEventingBasicConsumer(channel);
}
public RabbitMqConnectionFactory(IOptions<RabbitMqConfiguration> config)
{
_config = config.Value;
_lazyConnectionProvider = new Lazy<Task<IConnection>>(async () =>
{
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),
};
return await factory.CreateConnectionAsync(CancellationToken);
});
}
public async ValueTask DisposeAsync()
{
#if NET
await _consumerCts.CancelAsync();
#else
_consumerCts.Cancel();
#endif
var connection = await _lazyConnectionProvider.Value;
if (connection != null)
{
await connection.CloseAsync();
await connection.DisposeAsync();
}
}
}
}

View File

@@ -0,0 +1,27 @@
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DigitalData.MessagingService.API.Controllers;
/// <summary>
///
/// </summary>
[Route("api/[controller]")]
[ApiController]
public class EmailAccountController(IMediator mediator) : ControllerBase
{
/// <summary>
///
/// </summary>
/// <param name="query"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetEmailAccount([FromQuery] GetEmailAccountQuery query, CancellationToken cancellationToken)
{
return Ok(await mediator.Send(query, cancellationToken));
}
}

View File

@@ -0,0 +1,115 @@
using DigitalData.MessagingService.Application.EmailSending.Commands;
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using DigitalData.MessagingService.Application.Common.Dto;
namespace DigitalData.MessagingService.API.Controllers;
/// <summary>
/// Email sending API controller.
/// Enqueues outgoing emails to RabbitMQ for async processing.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class EmailController(IMediator mediator) : ControllerBase
{
/// <summary>
///
/// </summary>
public enum OnlyFilter
{
/// <summary>
///
/// </summary>
HtmlBody,
/// <summary>
///
/// </summary>
Uid,
}
#region Send
/// <summary>
/// Send an email, optionally with file attachments.
/// Omit the <c>attachments</c> field for a plain send.
/// </summary>
/// <param name="command">Email fields as form values</param>
/// <param name="attachments">Optional uploaded files</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>HTTP 202 Accepted with the queued event ID</returns>
[HttpPost]
[Consumes("multipart/form-data")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SendEmail(
[FromForm] PublishEmailCommand command,
IFormFileCollection? attachments,
CancellationToken cancellationToken)
{
var commandWithAttachments = command.WithAttachments(
await BuildAttachmentsAsync(attachments, cancellationToken));
var eventId = await mediator.Send(commandWithAttachments, cancellationToken);
return Accepted(new { Id = eventId });
}
private static async Task<IEnumerable<EmailAttachmentDto>> BuildAttachmentsAsync(
IFormFileCollection? files,
CancellationToken cancellationToken)
{
if (files is null || files.Count == 0)
return [];
var result = new List<EmailAttachmentDto>(files.Count);
foreach (var file in files)
{
using var ms = new MemoryStream();
await file.CopyToAsync(ms, cancellationToken);
result.Add(new EmailAttachmentDto
{
FileName = file.FileName,
Content = ms.ToArray(),
ContentType = file.ContentType
});
}
return result;
}
#endregion Send
#region Receive
/// <summary>
/// Fetch emails from an IMAP mailbox.
/// </summary>
/// <param name="query">Query parameters for filtering and fetching emails from the IMAP mailbox.</param>
/// <param name="only"></param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>HTTP 200 with list of received emails.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> FetchEmails([FromQuery] ReadEmailQuery query, [FromQuery] OnlyFilter? only = null, CancellationToken cancellationToken = default)
{
var emails = await mediator.Send(query, cancellationToken);
if(!emails.Any())
return NotFound("No emails found matching the specified criteria.");
if (only == OnlyFilter.HtmlBody)
{
if (emails.FirstOrDefault()?.HtmlBody is string htmlBody)
return Content(htmlBody, "text/html");
else
return NotFound();
}
else if (only == OnlyFilter.Uid)
return Ok(emails.Select(e => e.Uid).ToList());
else
return Ok(emails);
}
#endregion Receive
}

View File

@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net8.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageId>DigitalData.MessagingService.API</PackageId>
<Title></Title>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>DigitalData.MessagingService.API</Product>
<Version>1.0.0-beta</Version>
<FileVersion>1.0.0.0</FileVersion>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.SQLite" Version="7.0.0" />
<PackageReference Include="Serilog.UI" Version="3.2.0" />
<PackageReference Include="Serilog.UI.SqliteProvider" Version="1.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" />
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.Infrastructure\DigitalData.MessagingService.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Infrastructure\Swagger\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,87 @@
using System.Net;
using System.Text.Json;
using DigitalData.MessagingService.Domain.Exceptions;
using FluentValidation;
namespace DigitalData.MessagingService.API.Middleware;
/// <summary>
/// Global exception handling middleware
/// </summary>
public class ExceptionHandlingMiddleware(RequestDelegate Next, ILogger<ExceptionHandlingMiddleware> Logger)
{
private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
///
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public async Task InvokeAsync(HttpContext context)
{
try
{
await Next(context);
}
catch (Exception ex)
{
context.Response.ContentType = "application/json";
var (statusCode, message) = ex switch
{
NotFoundException notFoundEx =>
(HttpStatusCode.NotFound, notFoundEx.Message),
BadRequestException badRequestEx =>
(HttpStatusCode.BadRequest, badRequestEx.Message),
AuthenticationFailedException authEx =>
(HttpStatusCode.Unauthorized, authEx.Message),
ValidationException validationEx =>
(HttpStatusCode.BadRequest, FormatValidationErrors(validationEx)),
_ => (HttpStatusCode.InternalServerError, "An internal server error occurred")
};
context.Response.StatusCode = (int)statusCode;
// Log the exception
if (statusCode == HttpStatusCode.InternalServerError)
{
Logger.LogError(ex, "Unhandled exception: {Message}", ex.Message);
}
else
{
Logger.LogWarning(ex, "Exception handled: {StatusCode} - {Message}",
statusCode, message);
}
var response = new
{
StatusCode = (int)statusCode,
Message = message,
DetailedMessage = statusCode == HttpStatusCode.InternalServerError
? ex.Message
: null,
Timestamp = DateTime.Now
};
var json = JsonSerializer.Serialize(response, _jsonSerializerOptions);
await context.Response.WriteAsync(json);
}
}
private static string FormatValidationErrors(ValidationException exception)
{
var errors = exception.Errors
.Select(e => $"{e.PropertyName}: {e.ErrorMessage}")
.ToList();
return string.Join("; ", errors);
}
}

View File

@@ -0,0 +1,124 @@
using DigitalData.MessagingService.API.Middleware;
using DigitalData.MessagingService.Application;
using DigitalData.MessagingService.Infrastructure;
using Serilog;
using Serilog.Ui.Core.Extensions;
using Serilog.Ui.SqliteDataProvider.Extensions;
using Serilog.Ui.Web.Extensions;
// Build temporary configuration to read log directory early
var tempConfig = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: false)
.Build();
var logDirectoryRaw = tempConfig.GetValue<string>("Application:LogDirectory") ?? "logs";
// Always resolve to an absolute path so sink and UI provider point to the same file
var logDirectory = Path.IsPathRooted(logDirectoryRaw)
? logDirectoryRaw
: Path.Combine(AppContext.BaseDirectory, logDirectoryRaw);
Directory.CreateDirectory(logDirectory);
var sqliteDbPath = Path.Combine(logDirectory, "logs.db");
// Configure Serilog early (bootstrap + full pipeline)
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("System", Serilog.Events.LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File(
path: Path.Combine(logDirectory, "emailprofiler-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.SQLite(sqliteDbPath, storeTimestampInUtc: true)
.CreateBootstrapLogger();
try
{
Log.Information("Starting MessagingService API");
var builder = WebApplication.CreateBuilder(args);
// Use Serilog for logging
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File(
path: Path.Combine(logDirectory, "emailprofiler-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.SQLite(sqliteDbPath, storeTimestampInUtc: true));
// 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(builder.Configuration);
// Register Infrastructure layer (RabbitMQ, Repositories, etc.)
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Register Serilog.UI with SQLite provider for web log viewer
builder.Services.AddSerilogUi(options =>
{
options.UseSqliteServer(dbOpt =>
{
dbOpt.WithConnectionString($"Data Source={sqliteDbPath}");
dbOpt.WithTable("Logs");
});
});
var app = builder.Build();
// Add global exception handling middleware
app.UseMiddleware<ExceptionHandlingMiddleware>();
// Add Serilog request logging
app.UseSerilogRequestLogging();
// Configure Swagger — enabled in Development always, and in other environments based on appsettings
var swaggerEnabled = app.Environment.IsDevelopment()
|| app.Configuration.GetValue<bool>("Swagger:Enabled");
if (swaggerEnabled)
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// Serve Serilog.UI log viewer at /serilog-ui
app.UseSerilogUi();
app.UseAuthorization();
app.MapControllers();
Log.Information("MessagingService API started successfully");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "MessagingService API failed to start");
throw;
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:60519",
"sslPort": 44302
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5207",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7261;http://localhost:5207",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,19 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning"
}
}
}
}

View File

@@ -0,0 +1,26 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning"
}
}
},
"AllowedHosts": "*",
"Application": {
"LogDirectory": "logs"
},
"Swagger": {
"Enabled": true
},
"Workers": {
"EmailSender": {
"Enabled": true
}
},
"LuckyPennySoftLicenseKey": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxODE2MTI4MDAwIiwiaWF0IjoiMTc4NDYyNDU1NyIsImFjY291bnRfaWQiOiIwMTk4M2M1OWU0YjM3MjhlYmZkMzEwM2MyYTQ4NmU4NSIsImN1c3RvbWVyX2lkIjoiMDE5ODNjNTllNGIzNzI4ZWJmZDMxMDNjMmE0ODZlODUiLCJzdWJfaWQiOiItIiwiZWRpdGlvbiI6IjAiLCJ0eXBlIjoiMiJ9.IUUO926m9crYGYxMjjKD_n9BnUm-EDyjFIn0YmMUCo7C-QTwvB8WhXP8veTSFsBq-leIIDJ4jyl7Pgc_7ciwg1XhUSIs4mkQroEUaSFCGOxw7Pi41WM8MK5YFSaqLTYYXec9zxgiJbGzABbh3CHTSup3okGnVm_CMoPEs91l2c0A6N1JyZy74urd_tF0KGVKf0MOvzdlQIWLQ8o73S4pTv2N-F6UlzI0fdMtTHMLNNQyr0NdWdnuBk_jMBXO-gy5RE_oCRfMTTYRX2n3XLK6pTfXE0Ct338o9F5sH8Ph2lTXSu56cpdsfZOQZGqCH0LoFp1Dd7RJgIgNmBiTGfvDnA"
}

View File

@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(MSBuildProjectName).xml</DocumentationFile>
<PackageId>DigitalData.MessagingService.Client</PackageId>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>DigitalData.MessagingService.Client</Product>
<Copyright>Copyright 2026</Copyright>
<PackageIcon>icon.png</PackageIcon>
<RepositoryUrl>http://git.dd:3000/AppStd/Rec.git</RepositoryUrl>
<PackageTags>digital data messaging service api client</PackageTags>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Description></Description>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.10" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\..\assets\icon.png">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,136 @@
using DigitalData.MessagingService.Publisher;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.DependencyInjection;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Interfaces;
namespace DigitalData.MessagingService.Client;
/// <summary>
/// Provides a static, self-contained client for sending emails via RabbitMQ
/// without requiring a host-level dependency injection container.
/// </summary>
/// <remarks>
/// This class manages its own internal <see cref="IServiceProvider"/> using a
/// <see cref="Lazy{T}"/> pattern so the DI container is only built once,
/// on the first call to <see cref="ConnectRabbitMq(Action{RabbitMqConfiguration}, OnReconnect)"/>.
/// </remarks>
public static class EmailSender
{
/// <summary>
/// Internal event used to accumulate service registrations before the
/// <see cref="IServiceProvider"/> is built. Handlers are added by
/// <see cref="ConnectRabbitMq(Action{RabbitMqConfiguration}, OnReconnect)"/> and invoked exactly once during
/// lazy initialization.
/// </summary>
private static event Action<IServiceCollection> ConfigureServices = delegate { };
/// <summary>
/// The lazily-initialized internal service provider.
/// Built on first access by invoking all registered
/// <see cref="ConfigureServices"/> handlers.
/// </summary>
private static readonly Lazy<IServiceProvider> LazyProvider = new(() =>
{
var services = new ServiceCollection();
services.AddLogging();
ConfigureServices?.Invoke(services);
return services.BuildServiceProvider();
});
/// <summary>
/// Gets a value indicating whether the messaging service has been connected
/// and the internal <see cref="IServiceProvider"/> has been initialized.
/// </summary>
/// <value>
/// <see langword="true"/> if <see cref="ConnectRabbitMq(Action{RabbitMqConfiguration}, OnReconnect)"/> has been called
/// and the provider is built; otherwise <see langword="false"/>.
/// </value>
public static bool IsConnected => LazyProvider.IsValueCreated;
/// <summary>
/// Configures and establishes a connection to RabbitMQ, then initializes
/// the internal dependency injection container.
/// </summary>
/// <param name="configure">
/// A delegate used to configure the <see cref="RabbitMqConfiguration"/>,
/// such as host, port, credentials, and exchange settings.
/// </param>
/// <param name="onReconnect">
/// Controls the behavior when this method is called while already connected.
/// Defaults to <see cref="OnReconnect.ThrowException"/>.
/// </param>
/// <exception cref="InvalidOperationException">
/// Thrown when the service is already connected and
/// <paramref name="onReconnect"/> is <see cref="OnReconnect.ThrowException"/>.
/// </exception>
public static void ConnectRabbitMq(Action<RabbitMqConfiguration> configure, OnReconnect onReconnect = OnReconnect.ThrowException)
{
if(IsConnected && onReconnect == OnReconnect.ThrowException)
throw new InvalidOperationException("Messaging service is already connected.");
ConfigureServices += services => services.AddMessagingServicePublisher(configure);
_ = LazyProvider.Value; // Force initialization
}
/// <summary>
/// Configures and establishes a connection to RabbitMQ using a URL, then initializes
/// the internal dependency injection container.
/// </summary>
/// <param name="url">
/// The RabbitMQ server URL (e.g. <c>amqp://hostname:5672/virtualhost</c>).
/// The host, port, and virtual host are extracted from this URL.
/// </param>
/// <param name="username">The username used to authenticate with RabbitMQ.</param>
/// <param name="password">The password used to authenticate with RabbitMQ.</param>
/// <param name="onReconnect">
/// Controls the behavior when this method is called while already connected.
/// Defaults to <see cref="OnReconnect.ThrowException"/>.
/// </param>
/// <exception cref="InvalidOperationException">
/// Thrown when the service is already connected and
/// <paramref name="onReconnect"/> is <see cref="OnReconnect.ThrowException"/>.
/// </exception>
public static void ConnectRabbitMq(string url, string username, string password, OnReconnect onReconnect = OnReconnect.ThrowException)
{
var uri = new Uri(url);
ConnectRabbitMq(cfg =>
{
cfg.HostName = uri.Host;
cfg.Port = uri.IsDefaultPort ? 5672 : uri.Port;
cfg.UserName = username;
cfg.Password = password;
if (!string.IsNullOrEmpty(uri.AbsolutePath) && uri.AbsolutePath != "/")
cfg.VirtualHost = Uri.UnescapeDataString(uri.AbsolutePath.TrimStart('/'));
}, onReconnect);
}
/// <summary>
/// Enqueues the specified email to the RabbitMQ messaging pipeline.
/// </summary>
/// <param name="email">The outgoing email data to enqueue.</param>
/// <exception cref="InvalidOperationException">
/// Thrown when <see cref="ConnectRabbitMq(Action{RabbitMqConfiguration}, OnReconnect)"/> has not been called prior to sending.
/// </exception>
/// <remarks>
/// This method maps <see cref="EmailContext"/> to <see cref="SendingEmailEvent"/>,
/// then resolves <see cref="ISendingEmailPublisher"/> from the internal
/// service provider and calls <c>EnqueueAsync</c> in a fire-and-forget manner.
/// Ensure that any unhandled exceptions from the async operation are handled
/// at the publisher level.
/// </remarks>
public static void Send(EmailContext email)
{
if(!IsConnected)
throw new InvalidOperationException("Messaging service is not connected. Call ConnectRabbitMq first.");
var publisher = LazyProvider.Value.GetRequiredService<ISendingEmailPublisher>();
publisher.EnqueueAsync(new SendingEmailEvent()
{
Id = Guid.NewGuid(),
Mail = email,
QueuedAt = DateTime.Now
});
}
}

View File

@@ -0,0 +1,20 @@
namespace DigitalData.MessagingService.Client;
/// <summary>
/// Defines the behavior when <see cref="EmailSender.ConnectRabbitMq(Action{RabbitMQ.RabbitMqConfiguration}, OnReconnect)"/> is called
/// while a connection has already been established.
/// </summary>
public enum OnReconnect
{
/// <summary>
/// Throws an <see cref="System.InvalidOperationException"/> if the messaging service
/// is already connected. This is the default behavior.
/// </summary>
ThrowException,
/// <summary>
/// Silently ignores the reconnection attempt if the messaging service
/// is already connected.
/// </summary>
Ignore
}

View File

@@ -0,0 +1,185 @@
# DigitalData.MessagingService.Client
Ein schlanker, eigenständiger RabbitMQ-Client zum Versenden von E-Mails über den DigitalData MessagingService ohne eigenen DI-Container oder Hosting-Infrastruktur.
## Installation
```
dotnet add package DigitalData.MessagingService.Client
```
## Schnellstart
### 1. Verbindung herstellen
Einmalig beim Anwendungsstart aufrufen typischerweise in `Program.cs`, `Application_Start` oder dem Konstruktor des Einstiegspunkts:
```csharp
EmailSender.ConnectRabbitMq(
url: "amqp://172.24.12.56:5672",
username: "admin",
password: "geheimespasswort"
);
```
```vb
EmailSender.ConnectRabbitMq(
url:="amqp://172.24.12.56:5672",
username:="admin",
password:="geheimespasswort"
)
```
Mit virtuellem Host:
```csharp
EmailSender.ConnectRabbitMq(
url: "amqp://172.24.12.56:5672/meinvhost",
username: "admin",
password: "geheimespasswort"
);
```
```vb
EmailSender.ConnectRabbitMq(
url:="amqp://172.24.12.56:5672/meinvhost",
username:="admin",
password:="geheimespasswort"
)
```
> **Hinweis:** `ConnectRabbitMq` darf pro Prozess nur einmal erfolgreich aufgerufen werden.
> Ein erneuter Aufruf löst standardmäßig eine `InvalidOperationException` aus.
> Ist dieses Verhalten nicht erwünscht, kann `OnReconnect.Ignore` übergeben werden:
>
> ```csharp
> EmailSender.ConnectRabbitMq("amqp://172.24.12.56:5672", "admin", "geheimespasswort", OnReconnect.Ignore);
> ```
>
> ```vb
> EmailSender.ConnectRabbitMq("amqp://172.24.12.56:5672", "admin", "geheimespasswort", OnReconnect.Ignore)
> ```
### 2. E-Mail versenden
```csharp
EmailSender.Send(new Email
{
Recipient = "empfaenger@beispiel.de",
Subject = "Willkommen",
Body = "<p>Hallo Welt!</p>",
IsHtml = true
});
```
```vb
EmailSender.Send(New Email With {
.Recipient = "empfaenger@beispiel.de",
.Subject = "Willkommen",
.Body = "<p>Hallo Welt!</p>",
.IsHtml = True
})
```
`Send` ist eine **Fire-and-Forget**-Methode: die Nachricht wird in die RabbitMQ-Queue eingereiht und der aufrufende Code wartet nicht auf die eigentliche Zustellung.
> **Hinweis:** `Id` und `QueuedAt` werden intern automatisch gesetzt. Im `Email`-Objekt müssen nur `Recipient`, `Subject`, `Body` und `IsHtml` angegeben werden.
### 3. Verbindungsstatus prüfen
```csharp
if (EmailSender.IsConnected)
{
// Verbindung wurde bereits hergestellt
}
```
```vb
If EmailSender.IsConnected Then
' Verbindung wurde bereits hergestellt
End If
```
---
## URL-Format
```
amqp://<host>:<port>[/<virtualhost>]
```
| Bestandteil | Beschreibung | Beispiel |
|---------------|---------------------------------------------------------|----------------|
| `host` | Hostname oder IP-Adresse des RabbitMQ-Servers | `172.24.12.56` |
| `port` | AMQP-Port (Standard: `5672`) | `5672` |
| `virtualhost` | Optionaler virtueller Host; URL-Encoding wird aufgelöst | `meinvhost` |
Benutzername und Passwort werden **nicht** aus der URL gelesen, sondern immer separat als Parameter übergeben. Dadurch werden Klartext-Credentials in URLs vermieden.
---
## RabbitMQ-Server
| | |
|---|---|
| **AMQP** | `amqp://172.24.12.56:5672` |
| **Management UI** | http://172.24.12.56:15672 (Browser) |
| **Benutzername** | `admin` |
| **Passwort** | Im RDM-Eintrag **`sDD-VMP05-VM06 - 172.24.12.56 - RabbitMQ`** hinterlegt |
---
## Voraussetzungen
- .NET Framework 4.6.2 / 4.8 oder .NET 8+
- Erreichbarer RabbitMQ-Server
- Exchange, Queue und Routing Key müssen auf dem Broker vorhanden sein (werden vom Server-seitigen Consumer angelegt)
---
## Erweiterte Konfiguration
Für spezielle Szenarien etwa abweichende Exchange- oder Queue-Namen steht ein Delegate-basierter Überload zur Verfügung:
```csharp
EmailSender.ConnectRabbitMq(cfg =>
{
cfg.HostName = "172.24.12.56";
cfg.Port = 5672;
cfg.UserName = "admin";
cfg.Password = "geheimespasswort";
cfg.VirtualHost = "/";
// cfg.ExchangeName, cfg.QueueName, cfg.RoutingKey usw. bei Bedarf anpassen
});
```
```vb
EmailSender.ConnectRabbitMq(Sub(cfg)
cfg.HostName = "172.24.12.56"
cfg.Port = 5672
cfg.UserName = "admin"
cfg.Password = "geheimespasswort"
cfg.VirtualHost = "/"
' cfg.ExchangeName, cfg.QueueName, cfg.RoutingKey usw. bei Bedarf anpassen
End Sub)
```
Dieser Überload ist für den Normalbetrieb nicht erforderlich.
---
## Fehlerbehandlung
| Situation | Verhalten |
|---|---|
| `ConnectRabbitMq` noch nicht aufgerufen, dann `Send` | `InvalidOperationException` |
| `ConnectRabbitMq` erneut aufgerufen (Standard) | `InvalidOperationException` |
| `ConnectRabbitMq` erneut aufgerufen mit `OnReconnect.Ignore` | Wird stillschweigend ignoriert |
| RabbitMQ nicht erreichbar beim ersten `Send` | Exception aus dem RabbitMQ-Client |
---
## Lizenz
Copyright © 2026 Digital Data GmbH. Alle Rechte vorbehalten.

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj" />
<ProjectReference Include="..\..\src\infrastructure\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
<ProjectReference Include="..\..\src\presentation\DigitalData.MessagingService.Client\DigitalData.MessagingService.Client.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,109 @@
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Client;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Tests.Integration;
/// <summary>
/// xUnit collection that serializes all <see cref="EmailSenderTests"/> so they share
/// the same process-wide static state of <see cref="EmailSender.LazyProvider"/>.
/// </summary>
[CollectionDefinition(Name)]
public sealed class EmailSenderCollection : ICollectionFixture<EmailSenderFixture>
{
public const string Name = "EmailSender";
}
/// <summary>
/// Fixture that connects <see cref="EmailSender"/> once before all tests in the collection run.
/// </summary>
public sealed class EmailSenderFixture
{
public EmailSenderFixture()
{
// EmailSender is a static class with a Lazy<IServiceProvider>.
// ConnectRabbitMq can only be called successfully once per process.
if (!EmailSender.IsConnected)
{
EmailSender.ConnectRabbitMq(cfg =>
{
cfg.HostName = RabbitMqTestConfig.HostName;
cfg.Port = RabbitMqTestConfig.Port;
cfg.UserName = RabbitMqTestConfig.UserName;
cfg.Password = RabbitMqTestConfig.Password;
cfg.VirtualHost = RabbitMqTestConfig.VirtualHost;
cfg.QueueName = RabbitMqTestConfig.QueueName;
cfg.ExchangeName = RabbitMqTestConfig.ExchangeName;
cfg.RoutingKey = RabbitMqTestConfig.RoutingKey;
cfg.DlqQueueName = RabbitMqTestConfig.DlqQueueName;
cfg.DlqExchangeName = RabbitMqTestConfig.DlqExchangeName;
cfg.DlqRoutingKey = RabbitMqTestConfig.DlqRoutingKey;
});
}
}
}
/// <summary>
/// Integration tests for the <see cref="EmailSender"/> static client.
/// All tests run inside <see cref="EmailSenderCollection"/> to share the single
/// static connection established by <see cref="EmailSenderFixture"/>.
/// </summary>
[Collection(EmailSenderCollection.Name)]
public sealed class EmailSenderTests
{
[Fact]
public void IsConnected_AfterConnectRabbitMq_ReturnsTrue()
{
Assert.True(EmailSender.IsConnected);
}
[Fact]
public void ConnectRabbitMq_WhenAlreadyConnected_WithThrowException_ThrowsInvalidOperationException()
{
Assert.Throws<InvalidOperationException>(() =>
EmailSender.ConnectRabbitMq(cfg => { }, OnReconnect.ThrowException));
}
[Fact]
public void ConnectRabbitMq_WhenAlreadyConnected_WithIgnore_DoesNotThrow()
{
var exception = Record.Exception(() =>
EmailSender.ConnectRabbitMq(cfg => { }, OnReconnect.Ignore));
Assert.Null(exception);
}
[Fact]
public void Send_WithValidEmail_DoesNotThrow()
{
var email = new EmailContext
{
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = ["hakanttek@gmail.com"],
Subject = "EmailSender.Send Integration Test",
Body = "<p>Sent via EmailSender static client.</p>",
IsHtml = true,
};
var exception = Record.Exception(() => EmailSender.Send(email));
Assert.Null(exception);
}
[Fact]
public void Send_WithPlainTextBody_DoesNotThrow()
{
var email = new EmailContext
{
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = ["hakanttek@gmail.com"],
Subject = "Plain Text Test",
Body = "This is a plain text email.",
IsHtml = false
};
var exception = Record.Exception(() => EmailSender.Send(email));
Assert.Null(exception);
}
}

View File

@@ -0,0 +1,74 @@
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Client;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Tests.Integration;
/// <summary>
/// Integration tests for the URL-based <see cref="EmailSender.ConnectRabbitMq(string, string, string, OnReconnect)"/>
/// overload. All tests run inside <see cref="EmailSenderCollection"/> so they share the already-established
/// static connection. The URL overload is exercised via <see cref="OnReconnect.Ignore"/> and
/// <see cref="OnReconnect.ThrowException"/> to verify its delegation and guard behavior.
/// </summary>
[Collection(EmailSenderCollection.Name)]
public sealed class EmailSenderUrlOverloadTests
{
private static readonly string ValidUrl = $"amqp://{RabbitMqTestConfig.HostName}:{RabbitMqTestConfig.Port}";
private static readonly string ValidUrlWithVHost = $"amqp://{RabbitMqTestConfig.HostName}:{RabbitMqTestConfig.Port}/myvhost";
[Fact]
public void ConnectRabbitMq_UrlOverload_WhenAlreadyConnected_WithIgnore_DoesNotThrow()
{
var exception = Record.Exception(() =>
EmailSender.ConnectRabbitMq(
ValidUrl,
RabbitMqTestConfig.UserName,
RabbitMqTestConfig.Password,
OnReconnect.Ignore));
Assert.Null(exception);
}
[Fact]
public void ConnectRabbitMq_UrlOverload_WhenAlreadyConnected_WithThrowException_Throws()
{
Assert.Throws<InvalidOperationException>(() =>
EmailSender.ConnectRabbitMq(
ValidUrl,
RabbitMqTestConfig.UserName,
RabbitMqTestConfig.Password,
OnReconnect.ThrowException));
}
[Fact]
public void ConnectRabbitMq_UrlOverload_WithVHostInPath_WithIgnore_DoesNotThrow()
{
var exception = Record.Exception(() =>
EmailSender.ConnectRabbitMq(
ValidUrlWithVHost,
RabbitMqTestConfig.UserName,
RabbitMqTestConfig.Password,
OnReconnect.Ignore));
Assert.Null(exception);
}
[Fact]
public void Send_AfterUrlOverloadConnection_DoesNotThrow()
{
var email = new EmailContext
{
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = ["url-overload-test@example.com"],
Subject = "URL Overload Integration Test",
Body = "<p>Sent after URL-based connection.</p>",
IsHtml = true
};
// Connection was established via Action<> overload in the fixture;
// Send should work regardless of which overload was used to connect.
var exception = Record.Exception(() => EmailSender.Send(email));
Assert.Null(exception);
}
}

View File

@@ -0,0 +1,211 @@
using System.Text;
using System.Text.Json;
using DigitalData.MessagingService.Publisher;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.DependencyInjection;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Tests.Integration;
/// <summary>
/// Integration tests for <see cref="SendingEmailPublisher"/> against the real RabbitMQ broker.
/// Each test publishes a message and immediately reads it back via BasicGetAsync to verify
/// the full round-trip without starting the consumer (which requires Limilabs Mail.dll).
/// </summary>
public sealed class SendingEmailPublisherTests : IAsyncDisposable
{
private readonly ServiceProvider _serviceProvider;
private readonly ISendingEmailPublisher _publisher;
private readonly RabbitMqConnectionFactory _factory;
public SendingEmailPublisherTests()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMessagingServicePublisher(RabbitMqTestConfig.Apply);
_serviceProvider = services.BuildServiceProvider();
_publisher = _serviceProvider.GetRequiredService<ISendingEmailPublisher>();
_factory = _serviceProvider.GetRequiredService<RabbitMqConnectionFactory>();
}
[Fact]
public async Task EnqueueAsync_PublishesMessage_MessageArrivesInQueue()
{
var email = new SendingEmailEvent
{
Id = Guid.NewGuid(),
Mail = new EmailContext
{
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = ["test@example.com"],
Subject = "Integration Test - EnqueueAsync",
Body = "<p>Hello from integration test.</p>",
IsHtml = true,
},
QueuedAt = DateTime.Now
};
var depthBefore = await _publisher.GetQueueDepthAsync();
await _publisher.EnqueueAsync(email);
await Task.Delay(300);
var depthAfter = await _publisher.GetQueueDepthAsync();
Assert.True(depthAfter >= depthBefore + 1,
$"Expected queue depth to increase by at least 1. Before: {depthBefore}, After: {depthAfter}.");
}
[Fact]
public async Task EnqueueAsync_MultipleMessages_AllArrivesInQueue()
{
var emails = Enumerable.Range(1, 3).Select(i => new SendingEmailEvent
{
Id = Guid.NewGuid(),
Mail = new EmailContext
{
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = [$"recipient{i}@example.com"],
Subject = $"Integration Test - Batch #{i}",
Body = $"Batch message {i}",
IsHtml = false,
},
QueuedAt = DateTime.Now
}).ToList();
foreach (var email in emails)
await _publisher.EnqueueAsync(email);
await Task.Delay(500);
// Verify at least one message is present
var received = await PeekMessageAsync();
Assert.NotNull(received);
}
[Fact]
public async Task GetQueueDepthAsync_AfterPublish_ReturnsPositiveDepth()
{
var email = new SendingEmailEvent
{
Id = Guid.NewGuid(),
Mail = new EmailContext
{
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = ["depth-test@example.com"],
Subject = "Integration Test - GetQueueDepth",
Body = "Queue depth test",
IsHtml = false,
},
QueuedAt = DateTime.Now
};
await _publisher.EnqueueAsync(email);
await Task.Delay(300);
var depth = await _publisher.GetQueueDepthAsync();
Assert.True(depth > 0, $"Expected queue depth > 0, but got {depth}.");
}
[Fact]
public async Task EnqueueAsync_SerializesAllFields_DeserializesCorrectly()
{
var id = Guid.NewGuid();
var email = new SendingEmailEvent
{
Id = id,
Mail = new EmailContext
{
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = new List<string> { "serialize@example.com" },
Subject = "Serialization Test",
Body = "<strong>Bold</strong>",
IsHtml = true,
},
QueuedAt = DateTime.Now
};
await _publisher.EnqueueAsync(email);
await Task.Delay(300);
// Read messages until we find the one we just published
var received = await FindMessageAsync(id);
Assert.NotNull(received);
Assert.Equal(id, received.Id);
Assert.Equal(["serialize@example.com"], received.Mail.Recipients);
Assert.Equal("Serialization Test", received.Mail.Subject);
Assert.Equal("<strong>Bold</strong>", received.Mail.Body);
Assert.True(received.Mail.IsHtml);
}
/// <summary>
/// Reads a single message from the queue without acknowledging it (peek via nack+requeue).
/// </summary>
private async Task<SendingEmailEvent?> PeekMessageAsync()
{
var connection = await _factory.GetDefaultConnectionAsync();
await using var channel = await connection.CreateChannelAsync();
var result = await channel.BasicGetAsync(RabbitMqTestConfig.QueueName, autoAck: false);
if (result is null)
return null;
// Nack with requeue=true so the message stays in the queue for consumer
await channel.BasicNackAsync(result.DeliveryTag, multiple: false, requeue: true);
var json = Encoding.UTF8.GetString(result.Body.ToArray());
return JsonSerializer.Deserialize<SendingEmailEvent>(json);
}
/// <summary>
/// Scans queue messages (up to a limit) to find a message matching the given <paramref name="id"/>.
/// All messages are re-queued after inspection.
/// </summary>
private async Task<SendingEmailEvent?> FindMessageAsync(Guid id, int maxMessages = 50)
{
var connection = await _factory.GetDefaultConnectionAsync();
await using var channel = await connection.CreateChannelAsync();
var requeue = new List<(ulong DeliveryTag, byte[] Body)>();
SendingEmailEvent? found = null;
for (int i = 0; i < maxMessages; i++)
{
var result = await channel.BasicGetAsync(RabbitMqTestConfig.QueueName, autoAck: false);
if (result is null)
break;
requeue.Add((result.DeliveryTag, result.Body.ToArray()));
var json = Encoding.UTF8.GetString(result.Body.ToArray());
var evt = JsonSerializer.Deserialize<SendingEmailEvent>(json);
if (evt?.Id == id)
{
found = evt;
break;
}
}
// Re-queue all inspected messages so the consumer can still process them
foreach (var (tag, _) in requeue)
await channel.BasicNackAsync(tag, multiple: false, requeue: true);
return found;
}
public async ValueTask DisposeAsync()
{
await _factory.DisposeAsync();
await _serviceProvider.DisposeAsync();
}
}

View File

@@ -0,0 +1,39 @@
using DigitalData.MessagingService.RabbitMQ;
namespace DigitalData.MessagingService.Tests;
/// <summary>
/// Shared RabbitMQ connection settings used across integration tests.
/// Reads from the real broker defined in appsettings.Secrets.json.
/// </summary>
internal static class RabbitMqTestConfig
{
public const string HostName = "172.24.12.56";
public const int Port = 5672;
public const string UserName = "admin";
public const string Password = "fl!'D}4;pYBb\\VD&{6]]G*\\0Bq8fVIn0j?Sgm\\2A,6GS47g5Dj";
public const string VirtualHost = "/";
public const string QueueName = "emailprofiler.email.outbox";
public const string ExchangeName = "emailprofiler.emails";
public const string RoutingKey = "email.outbox";
public const string DlqQueueName = "emailprofiler.email.outbox.dlq";
public const string DlqExchangeName = "emailprofiler.emails.dlq";
public const string DlqRoutingKey = "email.outbox.dlq";
public static void Apply(RabbitMqConfiguration cfg)
{
cfg.HostName = HostName;
cfg.Port = Port;
cfg.UserName = UserName;
cfg.Password = Password;
cfg.VirtualHost = VirtualHost;
cfg.QueueName = QueueName;
cfg.ExchangeName = ExchangeName;
cfg.RoutingKey = RoutingKey;
cfg.DlqQueueName = DlqQueueName;
cfg.DlqExchangeName = DlqExchangeName;
cfg.DlqRoutingKey = DlqRoutingKey;
}
}

View File

@@ -0,0 +1,128 @@
using DigitalData.MessagingService.RabbitMQ;
namespace DigitalData.MessagingService.Tests.Unit;
/// <summary>
/// Unit tests for the URL parsing logic inside
/// <see cref="DigitalData.MessagingService.Client.DependencyInjection.EmailSender.ConnectRabbitMq(string, string, string, OnReconnect)"/>.
/// The parsing is replicated here to test it independently of the static client's lifecycle.
/// </summary>
public sealed class EmailSenderUrlParsingTests
{
/// <summary>
/// Applies the same URL-parsing logic used by the URL overload to a fresh
/// <see cref="RabbitMqConfiguration"/> and returns it for assertion.
/// </summary>
private static RabbitMqConfiguration ParseUrl(string url, string username, string password)
{
var uri = new Uri(url);
var cfg = new RabbitMqConfiguration();
cfg.HostName = uri.Host;
cfg.Port = uri.IsDefaultPort ? 5672 : uri.Port;
cfg.UserName = username;
cfg.Password = password;
if (!string.IsNullOrEmpty(uri.AbsolutePath) && uri.AbsolutePath != "/")
cfg.VirtualHost = Uri.UnescapeDataString(uri.AbsolutePath.TrimStart('/'));
return cfg;
}
[Fact]
public void ParseUrl_WithHostAndPort_SetsHostNameAndPort()
{
var cfg = ParseUrl("amqp://mybroker:5672", "user", "pass");
Assert.Equal("mybroker", cfg.HostName);
Assert.Equal(5672, cfg.Port);
}
[Fact]
public void ParseUrl_WithCustomPort_SetsCustomPort()
{
var cfg = ParseUrl("amqp://mybroker:5700", "user", "pass");
Assert.Equal(5700, cfg.Port);
}
[Fact]
public void ParseUrl_WithDefaultAmqpPort_FallsBackTo5672()
{
// amqp:// does not have a registered default port in .NET's Uri,
// so IsDefaultPort is false; the explicit port is used as-is.
var cfg = ParseUrl("amqp://mybroker:5672", "user", "pass");
Assert.Equal(5672, cfg.Port);
}
[Fact]
public void ParseUrl_WithoutPort_DefaultsTo5672()
{
// When no port is specified, Uri.IsDefaultPort is true for known schemes
// or Uri.Port returns -1. The overload falls back to 5672 when IsDefaultPort.
var uri = new Uri("amqp://mybroker");
var cfg = ParseUrl($"amqp://mybroker{(uri.IsDefaultPort ? "" : $":{uri.Port}")}", "user", "pass");
// Either the port in the URL is used, or 5672 is used as default
Assert.True(cfg.Port == 5672 || cfg.Port == uri.Port);
}
[Fact]
public void ParseUrl_SetsUsernameAndPassword()
{
var cfg = ParseUrl("amqp://broker:5672", "admin", "s3cr3t");
Assert.Equal("admin", cfg.UserName);
Assert.Equal("s3cr3t", cfg.Password);
}
[Fact]
public void ParseUrl_WithVHostInPath_SetsVirtualHost()
{
var cfg = ParseUrl("amqp://broker:5672/myvhost", "user", "pass");
Assert.Equal("myvhost", cfg.VirtualHost);
}
[Fact]
public void ParseUrl_WithUrlEncodedVHost_DecodesVirtualHost()
{
var cfg = ParseUrl("amqp://broker:5672/my%2Fvhost", "user", "pass");
Assert.Equal("my/vhost", cfg.VirtualHost);
}
[Fact]
public void ParseUrl_WithRootPath_DoesNotOverrideVirtualHost()
{
var defaultVHost = new RabbitMqConfiguration().VirtualHost;
var cfg = ParseUrl("amqp://broker:5672/", "user", "pass");
Assert.Equal(defaultVHost, cfg.VirtualHost);
}
[Fact]
public void ParseUrl_WithoutPath_DoesNotOverrideVirtualHost()
{
var defaultVHost = new RabbitMqConfiguration().VirtualHost;
var cfg = ParseUrl("amqp://broker:5672", "user", "pass");
Assert.Equal(defaultVHost, cfg.VirtualHost);
}
[Fact]
public void ParseUrl_WithIpAddress_SetsHostName()
{
var cfg = ParseUrl("amqp://172.24.12.56:5672", "admin", "pass");
Assert.Equal("172.24.12.56", cfg.HostName);
Assert.Equal(5672, cfg.Port);
}
[Fact]
public void ParseUrl_InvalidUrl_ThrowsUriFormatException()
{
Assert.Throws<UriFormatException>(() =>
ParseUrl("not-a-valid-url", "user", "pass"));
}
}