Compare commits

...

41 Commits

Author SHA1 Message Date
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
63 changed files with 1839 additions and 362 deletions

View File

@@ -37,8 +37,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingServic
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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Abstraction", "src\core\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj", "{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -77,10 +75,6 @@ Global
{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
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -97,7 +91,6 @@ Global
{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}
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}

View File

@@ -1,10 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>

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

@@ -1,15 +1,10 @@
namespace DigitalData.MessagingService.Abstraction;
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
/// <summary>
/// DTO for a single email account configuration.
/// </summary>
public class EmailAccountDto
public record EmailAccountModificationDto
{
/// <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
@@ -22,8 +17,6 @@ public class EmailAccountDto
public string Password { get; set; } = null!;
#endif
public bool PasswordEncrypted { get; set; } = false;
#if NET
public required string SmtpServer { get; set; }
#else
@@ -35,4 +28,20 @@ public class EmailAccountDto
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

@@ -1,9 +1,9 @@
namespace DigitalData.MessagingService.Abstraction;
namespace DigitalData.MessagingService.Application.Common.Dto;
/// <summary>
/// Represents a single email attachment.
/// </summary>
public sealed class EmailAttachmentContext
public sealed class EmailAttachmentDto
{
/// <summary>
/// Display name of the attachment (e.g. "invoice.pdf").

View File

@@ -1,11 +1,13 @@
namespace DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Common.Dto;
public record EmailContext
{
#if NETFRAMEWORK
public EmailAccountDto Sender { get; set; } = null!;
public EmailAccount Sender { get; set; } = null!;
#else
public required EmailAccountDto Sender { get; init; }
public required EmailAccount Sender { get; init; }
#endif
/// <summary>
@@ -48,8 +50,8 @@ public record EmailContext
/// <summary>
/// Optional list of attachments to include with the email.
/// Each entry may carry its content as a byte array (<see cref="EmailAttachmentContext.Content"/>)
/// or reference a file on disk via <see cref="EmailAttachmentContext.FilePath"/>.
/// 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<EmailAttachmentContext> Attachments { get; set; } = [];
public IEnumerable<EmailAttachmentDto> Attachments { get; set; } = [];
}

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,97 @@
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>
/// 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
}

View File

@@ -1,4 +1,4 @@
namespace DigitalData.MessagingService.Abstraction;
namespace DigitalData.MessagingService.Application.Common.Dto;
public record SendingEmailCreateDto
{

View File

@@ -1,4 +1,4 @@
namespace DigitalData.MessagingService.Abstraction;
namespace DigitalData.MessagingService.Application.Common.Dto;
public record SendingEmailEvent
{

View File

@@ -1,4 +1,4 @@
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.Application.Common.Dto;
namespace DigitalData.MessagingService.Application.Common.Interfaces;

View File

@@ -0,0 +1,36 @@
#if NET
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Common.Interfaces;
/// <summary>
/// Service interface for reading emails via IMAP.
/// </summary>
public interface IImapEmailService
{
/// <summary>
/// Fetches emails from the specified mailbox folder.
/// </summary>
/// <param name="account">Account whose IMAP settings will be used.</param>
/// <param name="filter">Filter to apply when fetching emails.</param>
/// When <see langword="true"/> (default), fetched messages are marked as <c>\Seen</c> on the server.
/// Set to <see langword="false"/> for a non-destructive read (uses <c>BODY.PEEK</c> internally).
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IEnumerable<ReceivedEmailDto>> FetchEmailsAsync(
EmailAccount account,
MailSearchFilter filter,
CancellationToken cancellationToken = default);
/// <summary>
/// Marks a message as seen (read) on the server.
/// </summary>
Task MarkAsSeenAsync(
EmailAccount account,
long uid,
string folder = "INBOX",
CancellationToken cancellationToken = default);
}
#endif

View File

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

View File

@@ -11,6 +11,8 @@ public interface IRepository<TEntity> where TEntity : class
// CREATE
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
Task<IEnumerable<TEntity>> CreateAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default);
// READ
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
@@ -20,6 +22,11 @@ public interface IRepository<TEntity> where TEntity : class
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);

View File

@@ -1,6 +1,9 @@
#if NET
using AutoMapper;
using DigitalData.MessagingService.Application.EmailSending.Commands;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
namespace DigitalData.MessagingService.Application.Common.Mappings;
@@ -11,10 +14,15 @@ public class EmailMappingProfile : Profile
{
public EmailMappingProfile()
{
// SendEmailCommand -> Email
// PublishEmailCommand -> Email
// Sender is resolved via MediatR in the handler and set separately after mapping.
CreateMap<SendEmailCommand, EmailContext>()
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>();
}
}
#endif

View File

@@ -1,9 +1,13 @@
using DigitalData.MessagingService.Abstraction;
#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="EmailAccountDto"/> entries
/// 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
@@ -13,5 +17,12 @@ public class EmailAccountsOptions
/// <summary>
/// The list of configured email accounts.
/// </summary>
public required IEnumerable<EmailAccountDto> Accounts { get; init; } = [];
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

@@ -1,8 +1,8 @@
#if NET
using DigitalData.MessagingService.Application.Common.Options;
using FluentValidation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.Reflection;
namespace DigitalData.MessagingService.Application;
@@ -43,3 +43,4 @@ public static class DependencyInjection
return services;
}
}
#endif

View File

@@ -1,17 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<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" />
<ProjectReference Include="..\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj" />
</ItemGroup>
<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" />

View File

@@ -1,39 +0,0 @@
using DigitalData.MessagingService.Application.Common.Options;
using DigitalData.MessagingService.Abstraction;
using MediatR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DigitalData.MessagingService.Application.EmailAccount.Queries;
public record GetSenderQuery : IRequest<EmailAccountDto?>
{
public int? Id { get; init; }
public string? Username { get; init; }
}
/// <summary>
///
/// </summary>
/// <param name="Options"></param>
/// <param name="Logger"></param>
public class GetSenderQueryHandler(IOptions<EmailAccountsOptions> Options, ILogger<GetSenderQueryHandler> Logger) : IRequestHandler<GetSenderQuery, EmailAccountDto?>
{
public Task<EmailAccountDto?> Handle(GetSenderQuery request, CancellationToken cancellationToken)
{
var accounts = request.Id is not null
? Options.Value.Accounts.Where(a => a.Id == request.Id)
: Options.Value.Accounts.Where(a => a.Username == request.Username);
if(accounts.Count() > 1)
{
Logger.LogWarning(
"Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.",
request.Id is not null ? $"Id: {request.Id}" : $"Username: {request.Username}"
);
}
return Task.FromResult(accounts.FirstOrDefault());
}
}

View File

@@ -1,27 +0,0 @@
using DigitalData.MessagingService.Application.EmailAccount.Queries;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailAccount.Validators;
/// <summary>
/// Validator for <see cref="GetSenderQuery"/>.
/// Either <see cref="GetSenderQuery.Id"/> or <see cref="GetSenderQuery.Username"/> must be provided, but not both.
/// </summary>
public class GetSenderQueryValidator : AbstractValidator<GetSenderQuery>
{
public GetSenderQueryValidator()
{
RuleFor(x => x)
.Must(x => (x.Id is not null) ^ (x.Username is not null))
.WithMessage("Either Id or Username must be provided, but not both.");
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.");
});
}
}

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,52 @@
#if NET
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 FetchEmailsQuery : 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 FetchEmailsQueryHandler(IImapEmailService ImapService, ILogger<FetchEmailsQueryHandler> Logger, IRepository<EmailAccount> Repo) : IRequestHandler<FetchEmailsQuery, IEnumerable<ReceivedEmailDto>>
{
public async Task<IEnumerable<ReceivedEmailDto>> Handle(FetchEmailsQuery 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}");
EmailAccount 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.");
return await ImapService.FetchEmailsAsync(
account,
request.Mail,
cancellationToken);
}
}
#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="FetchEmailsQuery"/> before it is handled by <see cref="FetchEmailsQueryHandler"/>.
/// </summary>
public class FetchEmailsQueryValidator : AbstractValidator<FetchEmailsQuery>
{
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

@@ -1,72 +0,0 @@
using AutoMapper;
using DigitalData.MessagingService.Application.EmailAccount.Queries;
using DigitalData.MessagingService.Domain.Exceptions;
using DigitalData.MessagingService.Abstraction;
using MediatR;
using System.Text.Json.Serialization;
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
/// <summary>
/// Command to send an email (enqueue to RabbitMQ)
/// </summary>
public record SendEmailCommand : IRequest<Guid>
{
public required GetSenderQuery 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<EmailAttachmentContext> Attachments { get; private init; } = [];
/// <summary>
/// Returns a new command instance with the supplied attachments.
/// Called by the controller after resolving uploaded files.
/// </summary>
public SendEmailCommand WithAttachments(IEnumerable<EmailAttachmentContext> attachments)
=> this with { Attachments = attachments };
}
/// <summary>
/// Handler for SendEmailCommand
/// Resolves the sender account via MediatR, maps to SendingEmailEvent and enqueues to RabbitMQ
/// </summary>
public class SendEmailCommandHandler(ISender Sender, ISendingEmailPublisher Publisher, IMapper Mapper) : IRequestHandler<SendEmailCommand, Guid>
{
public async Task<Guid> Handle(SendEmailCommand request, CancellationToken cancellationToken)
{
var senderAccount = await Sender.Send(request.Sender, cancellationToken)
?? 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;
}
}

View File

@@ -1,14 +1,15 @@
#if NET
using DigitalData.MessagingService.Application.EmailSending.Commands;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailSending.Validators;
/// <summary>
/// Validator for SendEmailCommand
/// Validator for PublishEmailCommand
/// </summary>
public class SendEmailCommandValidator : AbstractValidator<SendEmailCommand>
public class PublishEmailCommandValidator : AbstractValidator<PublishEmailCommand>
{
public SendEmailCommandValidator()
public PublishEmailCommandValidator()
{
RuleFor(x => x.Recipients)
.NotEmpty()
@@ -32,3 +33,4 @@ public class SendEmailCommandValidator : AbstractValidator<SendEmailCommand>
.WithMessage("Body is required");
}
}
#endif

View File

@@ -11,4 +11,8 @@
<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,136 @@
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 IEnumerable<string> To { get; init; } = [];
#else
public IEnumerable<string> To { get; set; } = [];
#endif
/// <summary>
/// CC addresses.
/// </summary>
[Column("CC", TypeName = "nvarchar(max)")]
#if NET
public IEnumerable<string> Cc { get; init; } = [];
#else
public IEnumerable<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
}

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

@@ -1,10 +1,16 @@
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;
@@ -23,8 +29,12 @@ public static class DependencyInjection
IConfiguration configuration)
{
// --- External Services ---
// Email Service (using Limilabs Mail.dll - Singleton for use in EmailSenderWorker)
// 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.AddSingleton<IImapEmailService, LimilabsImapEmailService>();
// PDF Processing Service (using DevExpress.Pdf)
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
@@ -46,7 +56,20 @@ public static class DependencyInjection
// 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<>));
// AutoMapper - Register entity self-mappings (T -> T) for generic repository
services.AddAutoMapper(config => config.AddMaps(typeof(EntitySelfMappingProfile).Assembly));
return services;
}
}

View File

@@ -25,6 +25,7 @@
<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>

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

@@ -1,11 +1,38 @@
using DigitalData.MessagingService.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace DigitalData.MessagingService.Infrastructure.Persistence;
/// <summary>
/// Entity Framework Core DbContext for MessagingService.
/// IMPORTANT: This context maps to a LEGACY database - NO schema modifications allowed!
/// </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

@@ -1,11 +1,11 @@
using System.Text;
using System.Text.Json;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Abstraction;
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;

View File

@@ -25,6 +25,14 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
return entity;
}
public async Task<IEnumerable<TEntity>> CreateAsync<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)
@@ -44,13 +52,13 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
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);
}
@@ -84,6 +92,59 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
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>
@@ -96,7 +157,7 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
TDto dto,
CancellationToken cancellationToken = default)
{
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken)
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);
@@ -113,12 +174,7 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
CancellationToken cancellationToken = default)
{
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
foreach (var entity in entities)
{
Mapper.Map(dto, entity);
}
entities.ForEach(entity => Mapper.Map(dto, entity));
await Context.SaveChangesAsync(cancellationToken);
return entities.Count;
}
@@ -134,7 +190,7 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default)
{
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken)
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);
@@ -150,7 +206,6 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
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,67 @@
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
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.Options;
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
public class EmailSyncWorker(IImapEmailService imapService, IOptions<EmailAccountsOptions> Options, IServiceProvider Provider) : BackgroundService
{
private DateFilter? _dateFilter = null;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await UpsertSeedEmailAccount(stoppingToken);
if (imapService is not LimilabsImapEmailService limapService)
{
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
return;
}
var interval = TimeSpan.FromSeconds(Options.Value.SyncIntervalSeconds);
while (!stoppingToken.IsCancellationRequested)
{
using var scope = Provider.CreateAsyncScope();
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
foreach (var account in await emailAccountRepo.GetAllAsync(stoppingToken))
if (account.ImapServer is not null)
{
// init or update last date filter
_dateFilter = _dateFilter is null
? new DateFilter
{
After = null,
Before = DateTime.UtcNow
}
: new DateFilter
{
After = _dateFilter.Before,
Before = DateTime.UtcNow
};
await limapService.FetchEmailsAsync(account, new MailSearchFilter { Date = _dateFilter }, stoppingToken);
}
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,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

@@ -5,7 +5,8 @@ using Limilabs.Client.SMTP;
using Limilabs.Mail;
using Limilabs.Mail.Headers;
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Infrastructure.Services;
@@ -13,11 +14,10 @@ namespace DigitalData.MessagingService.Infrastructure.Services;
/// 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="EmailAccountDto.Name"/> equals <c>"default"</c>,
/// 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(
IEncryptionService encryptionService) : IEmailService
public class LimilabsEmailService() : IEmailService
{
// Register encoding provider for Limilabs (requires windows-1252 and other code pages)
static LimilabsEmailService()
@@ -71,7 +71,7 @@ public class LimilabsEmailService(
}
}
private async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccountDto smtpAccount)
private static async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccount smtpAccount)
{
if (smtpAccount.SmtpUseSsl)
{
@@ -88,9 +88,7 @@ public class LimilabsEmailService(
}
else
{
var password = smtpAccount.PasswordEncrypted ? encryptionService.Decrypt(smtpAccount.Password) : smtpAccount.Password;
await smtp.LoginAsync(smtpAccount.Username, password);
await smtp.LoginAsync(smtpAccount.Username, smtpAccount.Password);
}
}
@@ -111,7 +109,7 @@ public class LimilabsEmailService(
return message.ToString();
}
private static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentContext> attachments)
private static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentDto> attachments)
{
foreach (var attachment in attachments)
{

View File

@@ -0,0 +1,235 @@
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Exceptions;
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
using Limilabs.Client.IMAP;
using Limilabs.Mail;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
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, IMemoryCache Cache) : IImapEmailService
{
private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString();
static LimilabsImapEmailService()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
// Public API
public async Task<IEnumerable<ReceivedEmailDto>> FetchEmailsAsync(
EmailAccount account,
MailSearchFilter filter,
CancellationToken cancel = default)
{
using var imap = await OpenAsync(account, filter.Folder, cancel);
try
{
#region Find UIDs
// Server-side: only date range; all other filters are applied in-process after cache retrieval
List<ICriterion> criterions = [];
if (filter.Date is DateFilter dateF)
{
if (dateF.After is DateTime after)
criterions.Add(Expression.SentSince(after.Date));
// IMAP BEFORE is exclusive, so add one day to make the bound inclusive
if (dateF.Before is DateTime before)
criterions.Add(Expression.SentBefore(before.Date.AddDays(1)));
}
var searchExpression = criterions.Count > 0 ? Expression.And([.. criterions]) : Expression.All();
List<long> uids = [.. await imap.SearchAsync(searchExpression, cancel)];
if (filter.SortOrder == MailSortOrder.NewestFirst)
uids.Reverse();
#endregion
if (uids.Count == 0)
return [];
var results = new List<ReceivedEmailDto>(uids.Count);
foreach (var uid in uids)
{
cancel.ThrowIfCancellationRequested();
try
{
#region Read email
var email = await Cache.GetOrCreateAsync(
CacheKeyPrefix + uid,
async entry =>
{
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
});
}
return new ReceivedEmailDto
{
Uid = uid,
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,
};
});
#endregion Read email
if (email is null)
continue;
if (filter.UnseenOnly && email.IsSeen)
continue;
if (filter.SubjectContains is string subject &&
!email.Subject.Contains(subject, StringComparison.OrdinalIgnoreCase))
continue;
if (filter.SenderContains is string sender &&
!email.From.Contains(sender, StringComparison.OrdinalIgnoreCase))
continue;
if (filter.RecipientContains is string recipient &&
!email.To.Any(t => t.Contains(recipient, StringComparison.OrdinalIgnoreCase)) &&
!email.Cc.Any(c => c.Contains(recipient, StringComparison.OrdinalIgnoreCase)))
continue;
if (filter.BodyContains is string body &&
!email.TextBody.Contains(body, StringComparison.OrdinalIgnoreCase) &&
!email.HtmlBody.Contains(body, StringComparison.OrdinalIgnoreCase))
continue;
if (filter.Uid is UidFilter uidF)
{
if (uidF.Absolute is long exactUid && email.Uid != exactUid)
continue;
if (uidF.Min is long min && email.Uid < min)
continue;
if (uidF.Max is long max && email.Uid > max)
continue;
}
if (filter.WithAttachments)
results.Add(email);
else
results.Add(email with { Attachments = [] });
}
catch (Exception ex)
{
Logger.LogWarning(ex,
"Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.",
uid, filter.Folder);
}
}
await imap.CloseAsync(cancel);
if (filter.MaxCount is int maxCount && maxCount > 0 && results.Count > maxCount)
return results.Take(maxCount);
return results;
}
catch (Limilabs.Client.ServerException ex)
{
await imap.CloseSafelyAsync();
throw new AuthenticationFailedException(
$"IMAP authentication failed for account '{account.Username}'.", ex);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await imap.CloseSafelyAsync();
throw new InvalidOperationException(
$"Failed to fetch emails from IMAP server '{account.ImapServer}'.", ex);
}
}
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 (Limilabs.Client.ServerException ex)
{
await imap.CloseSafelyAsync();
throw new AuthenticationFailedException(
$"IMAP authentication failed for account '{account.Username}'.", ex);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await imap.CloseSafelyAsync();
throw new InvalidOperationException(
$"Failed to mark message UID={uid} as seen on '{account.ImapServer}'.", ex);
}
}
private static async Task<Imap> OpenAsync(EmailAccount account, string folder, CancellationToken cancel)
{
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;
}
}

View File

@@ -1,6 +1,5 @@
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace DigitalData.MessagingService.Publisher;

View File

@@ -12,7 +12,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj" />
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" />
<ProjectReference Include="..\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
</ItemGroup>

View File

@@ -4,7 +4,8 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using DigitalData.MessagingService.RabbitMQ;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Interfaces;
namespace DigitalData.MessagingService.Publisher;

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] FetchEmailsQuery 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

@@ -1,64 +0,0 @@
using DigitalData.MessagingService.Application.EmailSending.Commands;
using DigitalData.MessagingService.Abstraction;
using MediatR;
using Microsoft.AspNetCore.Mvc;
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 EmailsController(IMediator mediator) : ControllerBase
{
/// <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] SendEmailCommand 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<EmailAttachmentContext>> BuildAttachmentsAsync(
IFormFileCollection? files,
CancellationToken cancellationToken)
{
if (files is null || files.Count == 0)
return [];
var result = new List<EmailAttachmentContext>(files.Count);
foreach (var file in files)
{
using var ms = new MemoryStream();
await file.CopyToAsync(ms, cancellationToken);
result.Add(new EmailAttachmentContext
{
FileName = file.FileName,
Content = ms.ToArray(),
ContentType = file.ContentType
});
}
return result;
}
}

View File

@@ -1,89 +1,82 @@
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
public class ExceptionHandlingMiddleware(RequestDelegate Next, ILogger<ExceptionHandlingMiddleware> Logger)
{
private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
/// <summary>
///
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
await Next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, 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 async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
var (statusCode, message) = exception switch
{
NotFoundException notFoundEx =>
(HttpStatusCode.NotFound, notFoundEx.Message),
AuthenticationFailedException authEx =>
(HttpStatusCode.Unauthorized, authEx.Message),
FluentValidation.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(exception, "Unhandled exception: {Message}", exception.Message);
}
else
{
_logger.LogWarning(exception, "Exception handled: {StatusCode} - {Message}",
statusCode, message);
}
var response = new
{
StatusCode = (int)statusCode,
Message = message,
DetailedMessage = statusCode == HttpStatusCode.InternalServerError
? exception.Message
: null,
Timestamp = DateTime.Now
};
var json = JsonSerializer.Serialize(response, _jsonSerializerOptions);
await context.Response.WriteAsync(json);
}
private static string FormatValidationErrors(FluentValidation.ValidationException exception)
private static string FormatValidationErrors(ValidationException exception)
{
var errors = exception.Errors
.Select(e => $"{e.PropertyName}: {e.ErrorMessage}")

View File

@@ -35,9 +35,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>

View File

@@ -1,7 +1,8 @@
using DigitalData.MessagingService.Publisher;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.DependencyInjection;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Interfaces;
namespace DigitalData.MessagingService.Client;

View File

@@ -1,5 +1,6 @@
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Client;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Tests.Integration;
@@ -77,7 +78,7 @@ public sealed class EmailSenderTests
{
var email = new EmailContext
{
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
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>",
@@ -94,7 +95,7 @@ public sealed class EmailSenderTests
{
var email = new EmailContext
{
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
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.",

View File

@@ -1,5 +1,6 @@
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Client;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Tests.Integration;
@@ -57,7 +58,7 @@ public sealed class EmailSenderUrlOverloadTests
{
var email = new EmailContext
{
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
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>",

View File

@@ -1,11 +1,11 @@
using System.Text;
using System.Text.Json;
using DigitalData.MessagingService.Publisher;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using RabbitMQ.Client;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Tests.Integration;
@@ -39,7 +39,7 @@ public sealed class SendingEmailPublisherTests : IAsyncDisposable
Id = Guid.NewGuid(),
Mail = new EmailContext
{
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
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>",
@@ -68,8 +68,8 @@ public sealed class SendingEmailPublisherTests : IAsyncDisposable
Id = Guid.NewGuid(),
Mail = new EmailContext
{
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = new List<string> { $"recipient{i}@example.com" },
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,
@@ -95,7 +95,7 @@ public sealed class SendingEmailPublisherTests : IAsyncDisposable
Id = Guid.NewGuid(),
Mail = new EmailContext
{
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
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",
@@ -122,7 +122,7 @@ public sealed class SendingEmailPublisherTests : IAsyncDisposable
Id = id,
Mail = new EmailContext
{
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
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>",