Compare commits

...

122 Commits

Author SHA1 Message Date
993d9cfea0 refactor(api): inject IEmailSyncService into SyncController; change OAuth2 authorize route to username param; add Swagger UI metadata and description links 2026-08-17 16:05:43 +02:00
8925428e46 feat(infrastructure): implement IEmailSyncService on EmailSyncWorker with rate-limited ForceTriggerSync via IMemoryCache; register as singleton in DI 2026-08-17 16:05:30 +02:00
c189a2d6ef refactor(application): change GetOAuth2AuthorizationUrlQuery to look up account by Username instead of Id 2026-08-17 16:05:16 +02:00
408ad14c9f feat(application): add IEmailSyncService interface and ForcedSyncIntervalSeconds option to EmailAccountsOptions 2026-08-17 16:05:02 +02:00
08547a8768 docs: add full OAuth2 setup guide for Google and Microsoft to README; exclude Google OAuth2 token JSON from version control 2026-08-17 15:06:51 +02:00
5746d36665 feat(api): add OAuth2Controller (Google authorize/callback), SyncController (on-demand trigger); register IHttpContextAccessor 2026-08-17 15:06:38 +02:00
a9046e957b feat(infrastructure): add TriggerSync to EmailSyncWorker for on-demand immediate sync; fix cancellation propagation after delay 2026-08-17 15:06:24 +02:00
3ddd5e83a2 feat(infrastructure): register Google OAuth2 services and dispatcher; add Microsoft.Extensions.Http package reference 2026-08-17 15:06:11 +02:00
6ff01b364c refactor(infrastructure): add provider guard and tenant ID validation to MicrosoftOAuth2TokenService; improve XML docs 2026-08-17 15:05:57 +02:00
bb9ff9c9ed feat(infrastructure): add GoogleOAuth2TokenService, GoogleOAuth2AuthorizationService and OAuth2TokenServiceDispatcher 2026-08-17 15:05:43 +02:00
ba8f701223 feat(application): add OAuth2RefreshToken/OAuth2Provider to DTOs; guard refresh token from being overwritten on seed; add OAuth2MappingProfile 2026-08-17 15:05:28 +02:00
6d6e876d94 feat(application): add OAuth2 authorization flow commands and queries (GetGoogleAuthorizationUrl, CompleteGoogleAuthorization) 2026-08-17 15:05:12 +02:00
502ae5e07b feat(application): add IOAuth2AuthorizationService interface and UpdateAccountOAuth2RefreshTokenCommand handler 2026-08-17 15:04:57 +02:00
c90040db67 feat(domain): add OAuth2Provider enum and OAuth2RefreshToken/OAuth2Provider fields to EmailAccount 2026-08-17 15:04:41 +02:00
f0c698856d refactor(infrastructure): dispatch EmailSyncWorker by IncomingProtocol, add POP3 sync path, use await using for scopes 2026-08-17 10:48:56 +02:00
158b562007 feat(domain/application): add IncomingProtocol field to EmailAccount entity and account DTOs 2026-08-17 10:48:45 +02:00
a0f1fcddac feat(domain): add IncomingProtocol enum (None, Imap, Pop3, ImapOAuth2, Pop3OAuth2) 2026-08-17 10:48:33 +02:00
f7d5abb132 feat(api): add IMAP send, POP3 receive, OAuth2 send/receive endpoints to EmailController 2026-08-17 10:16:04 +02:00
7782e86db3 feat(infrastructure): inject IServiceScopeFactory into consumer pool for scoped IMAP access and register new services (OAuth2, POP3) 2026-08-17 10:15:52 +02:00
1d7f269fe1 feat(infrastructure): add OAuth2 auth support and SendAndAppend capability to LimilabsEmailService and LimilabsImapEmailService 2026-08-17 10:15:38 +02:00
a13380016d feat(infrastructure): implement MicrosoftOAuth2TokenService and LimilabsPop3EmailService 2026-08-17 10:15:26 +02:00
822c7b1352 feat(application): register AutoMapper mappings for new commands and add SendAndAppend to IImapEmailService 2026-08-17 10:15:13 +02:00
f7e95eb7f7 feat(application): add PublishEmailViaImap/OAuth2 commands and ReadEmailViaPop3/OAuth2 queries with handlers 2026-08-17 10:14:59 +02:00
a57429718d feat(application): extend DTOs with POP3/OAuth2 fields and add IMAP append flags to SendingEmailEvent 2026-08-17 10:14:44 +02:00
ef8df96e65 feat(application): add IOAuth2TokenService and IPop3EmailService interfaces 2026-08-17 10:14:31 +02:00
194ae11a40 feat(domain): add POP3 and OAuth2 credential fields to EmailAccount entity 2026-08-17 10:14:18 +02:00
6c698c95be Refactor ReadEmailQuery to return structured response
Updated ReadEmailQuery to return a new response type,
ReadEmailQueryResponse, which includes both email data
and metadata (e.g., LastSync). Added the Account property
to ReadEmailQuery for specifying the email account.

Refactored ReadEmailQueryHandler to:
- Use IImapEmailService to fetch LastSync metadata.
- Return ReadEmailQueryResponse instead of a collection
  of ReceivedEmailDto.

Introduced ReadEmailQueryResponse class to encapsulate
LastSync and Emails properties.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Converted `OutgoingEmailEvent` from a class to a record.

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

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

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

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

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

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

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

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

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

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

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

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

3
.gitignore vendored
View File

@@ -371,3 +371,6 @@ FodyWeavers.xsd
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md /EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
/legacy/App /legacy/App
/src/DigitalData.MessagingService.API/appsettings.Secrets.json /src/DigitalData.MessagingService.API/appsettings.Secrets.json
/src/presentation/DigitalData.MessagingService.API/appsettings.Secrets.json
/src/presentation/DigitalData.MessagingService.API/appsettings.Secrets.json
/src/presentation/DigitalData.MessagingService.API/oauth.htek0100@gmail.com.json

View File

@@ -330,7 +330,7 @@ return Accepted(); // HTTP 202 - command queued for processing
arguments: null); arguments: null);
} }
public async Task EnqueueAsync(OutgoingEmail email, CancellationToken cancellationToken) public async Task EnqueueAsync(SendingEmail email, CancellationToken cancellationToken)
{ {
var json = JsonSerializer.Serialize(email); var json = JsonSerializer.Serialize(email);
var body = Encoding.UTF8.GetBytes(json); var body = Encoding.UTF8.GetBytes(json);
@@ -347,7 +347,7 @@ return Accepted(); // HTTP 202 - command queued for processing
await Task.CompletedTask; await Task.CompletedTask;
} }
public async Task<OutgoingEmail?> DequeueAsync(CancellationToken cancellationToken) public async Task<SendingEmail?> DequeueAsync(CancellationToken cancellationToken)
{ {
var result = _channel.BasicGet(QueueName, autoAck: false); var result = _channel.BasicGet(QueueName, autoAck: false);
@@ -355,7 +355,7 @@ return Accepted(); // HTTP 202 - command queued for processing
return null; return null;
var json = Encoding.UTF8.GetString(result.Body.ToArray()); var json = Encoding.UTF8.GetString(result.Body.ToArray());
var email = JsonSerializer.Deserialize<OutgoingEmail>(json); var email = JsonSerializer.Deserialize<SendingEmail>(json);
_channel.BasicAck(result.DeliveryTag, false); _channel.BasicAck(result.DeliveryTag, false);

View File

@@ -27,15 +27,15 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingServic
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Domain", "src\core\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj", "{8E44FA5B-43DD-E273-C682-FC382A854A6D}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Domain", "src\core\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj", "{8E44FA5B-43DD-E273-C682-FC382A854A6D}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Publisher.Abstraction", "src\core\DigitalData.MessagingService.Publisher.Abstraction\DigitalData.MessagingService.Publisher.Abstraction.csproj", "{8A44EE33-02AA-8B3F-60F7-91BA483740A9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Infrastructure", "src\infrastructure\DigitalData.MessagingService.Infrastructure\DigitalData.MessagingService.Infrastructure.csproj", "{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Infrastructure", "src\infrastructure\DigitalData.MessagingService.Infrastructure\DigitalData.MessagingService.Infrastructure.csproj", "{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.RabbitMQ", "src\infrastructure\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj", "{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.RabbitMQ", "src\infrastructure\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj", "{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.API", "src\presentation\DigitalData.MessagingService.API\DigitalData.MessagingService.API.csproj", "{8BF22107-3CB9-C326-B94B-C40C99DA9B68}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.API", "src\presentation\DigitalData.MessagingService.API\DigitalData.MessagingService.API.csproj", "{8BF22107-3CB9-C326-B94B-C40C99DA9B68}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Client.DependencyInjection", "src\presentation\DigitalData.MessagingService.Client.DependencyInjection\DigitalData.MessagingService.Client.DependencyInjection.csproj", "{B67C6FA8-DA47-41EC-B15A-511C5B19C036}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Publisher", "src\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj", "{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Client", "src\presentation\DigitalData.MessagingService.Client\DigitalData.MessagingService.Client.csproj", "{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -55,10 +55,6 @@ Global
{8E44FA5B-43DD-E273-C682-FC382A854A6D}.Debug|Any CPU.Build.0 = Debug|Any CPU {8E44FA5B-43DD-E273-C682-FC382A854A6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8E44FA5B-43DD-E273-C682-FC382A854A6D}.Release|Any CPU.ActiveCfg = Release|Any CPU {8E44FA5B-43DD-E273-C682-FC382A854A6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8E44FA5B-43DD-E273-C682-FC382A854A6D}.Release|Any CPU.Build.0 = Release|Any CPU {8E44FA5B-43DD-E273-C682-FC382A854A6D}.Release|Any CPU.Build.0 = Release|Any CPU
{8A44EE33-02AA-8B3F-60F7-91BA483740A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8A44EE33-02AA-8B3F-60F7-91BA483740A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8A44EE33-02AA-8B3F-60F7-91BA483740A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8A44EE33-02AA-8B3F-60F7-91BA483740A9}.Release|Any CPU.Build.0 = Release|Any CPU
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Debug|Any CPU.Build.0 = Debug|Any CPU {56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Release|Any CPU.ActiveCfg = Release|Any CPU {56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -71,10 +67,14 @@ Global
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Debug|Any CPU.Build.0 = Debug|Any CPU {8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Release|Any CPU.ActiveCfg = Release|Any CPU {8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Release|Any CPU.Build.0 = Release|Any CPU {8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Release|Any CPU.Build.0 = Release|Any CPU
{B67C6FA8-DA47-41EC-B15A-511C5B19C036}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B67C6FA8-DA47-41EC-B15A-511C5B19C036}.Debug|Any CPU.Build.0 = Debug|Any CPU {8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B67C6FA8-DA47-41EC-B15A-511C5B19C036}.Release|Any CPU.ActiveCfg = Release|Any CPU {8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B67C6FA8-DA47-41EC-B15A-511C5B19C036}.Release|Any CPU.Build.0 = Release|Any CPU {8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Release|Any CPU.Build.0 = Release|Any CPU
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Debug|Any CPU.ActiveCfg = Release|Any CPU
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Debug|Any CPU.Build.0 = Release|Any CPU
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Release|Any CPU.ActiveCfg = Release|Any CPU
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -86,11 +86,11 @@ Global
{71BEA4D0-7835-4A8C-B11E-1088E0801DCE} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {71BEA4D0-7835-4A8C-B11E-1088E0801DCE} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{7CBE8648-F259-CC91-87FF-5859280867A8} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64} {7CBE8648-F259-CC91-87FF-5859280867A8} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
{8E44FA5B-43DD-E273-C682-FC382A854A6D} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64} {8E44FA5B-43DD-E273-C682-FC382A854A6D} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
{8A44EE33-02AA-8B3F-60F7-91BA483740A9} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE} {56607AAB-3DEC-CB78-3062-56A8EEF5E9D2} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE} {4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
{8BF22107-3CB9-C326-B94B-C40C99DA9B68} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A} {8BF22107-3CB9-C326-B94B-C40C99DA9B68} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
{B67C6FA8-DA47-41EC-B15A-511C5B19C036} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A} {8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060} SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}

300
README.md
View File

@@ -1,2 +1,302 @@
# DigitalData.MessagingService # DigitalData.MessagingService
A .NET 8 messaging service for sending and receiving emails via SMTP, IMAP, POP3 and OAuth2, with RabbitMQ-based async delivery.
---
## Email Account Configuration
Each account is configured under `EmailAccounts.Accounts` in `appsettings.Secrets.json`.
> Different providers require different configuration fields. See provider-specific sections below.
### Common fields (all providers)
```json
{
"Id": 1,
"Username": "user@example.com",
"Password": "your_password",
"SmtpServer": "smtp.example.com",
"SmtpPort": 465,
"SmtpUseSsl": true,
"UseOAuth2": false,
"ImapServer": "imap.example.com",
"ImapPort": 993,
"ImapUseSsl": true,
"Pop3Server": "pop.example.com",
"Pop3Port": 995,
"Pop3UseSsl": true,
"IncomingProtocol": 1
}
```
> `Password` is always retained. When `UseOAuth2 = true`, SMTP/IMAP/POP3 connections use OAuth2 tokens
> instead of the password. When `UseOAuth2 = false`, the password is used directly.
> The `IncomingProtocol` field independently controls which protocol is used for receiving emails.
#### `IncomingProtocol` values
| Value | Meaning |
|-------|---------|
| `0` | None — send-only account, skipped by sync worker |
| `1` | IMAP with username/password |
| `2` | POP3 with username/password |
| `3` | IMAP with OAuth2 |
| `4` | POP3 with OAuth2 |
#### `OAuth2Provider` values
| Value | Meaning |
|-------|---------|
| `0` | None |
| `1` | Microsoft (Azure AD / Microsoft 365) |
| `2` | Google (Gmail / Google Workspace) |
---
## Provider-specific OAuth2 Configuration
### Google (Gmail / Google Workspace)
Google uses **user-delegated OAuth2** (authorization code flow). A one-time interactive authorization
is required to obtain a refresh token. The refresh token is then stored in the database and reused
automatically for all subsequent operations.
> The refresh token survives application restarts. It will not be overwritten by the seed process
> unless `OAuth2RefreshToken` is explicitly set to a non-empty value in `appsettings.Secrets.json`.
#### Required fields
```json
{
"UseOAuth2": true,
"OAuth2ClientId": "YOUR_CLIENT_ID.apps.googleusercontent.com",
"OAuth2ClientSecret": "GOCSPX-YOUR_CLIENT_SECRET",
"OAuth2RefreshToken": "",
"OAuth2TenantId": "",
"OAuth2Provider": 2
}
```
#### Google Cloud Console setup (one-time)
1. Go to [console.cloud.google.com](https://console.cloud.google.com) and create or select a project.
2. Enable the **Gmail API** under *APIs & Services ? Library*.
3. Go to *APIs & Services ? Credentials* ? **+ Create Credentials** ? **OAuth 2.0 Client ID**:
- Application type: **Web application**
- **Authorized redirect URIs**: add `https://YOUR_HOST/api/oauth2/google/callback`
(e.g. `https://localhost:7261/api/oauth2/google/callback` for local development)
4. Go to *APIs & Services ? OAuth consent screen*:
- Add the Gmail account under **Test users** (required while app is in Testing mode).
#### Obtaining the refresh token via the built-in authorization endpoint
The application provides a built-in OAuth2 flow — no external tools needed.
1. Open a browser and navigate to:
```
GET /api/oauth2/google/authorize/{accountId}
```
Example: `https://localhost:7261/api/oauth2/google/authorize/3`
2. You will be redirected to Google's consent screen. If you see **"Google hasn't verified this app"**,
click **Continue** — this is expected while the app is in Testing mode.
3. Sign in with the Gmail account and grant access.
4. Google redirects back to `/api/oauth2/google/callback` automatically.
The application exchanges the authorization code for a refresh token and saves it to the database.
5. A success response is returned:
```json
{ "success": true, "username": "user@gmail.com", "message": "..." }
```
6. The sync worker and all IMAP/SMTP operations will now work automatically.
> ?? The refresh token must be re-obtained if `invalid_grant` is returned.
> This happens if the token is unused for 6 months or if the user revokes access.
---
### Microsoft 365 / Exchange Online (Azure AD)
Microsoft uses **application-level OAuth2** (client credentials flow — no user interaction required).
Tokens are acquired automatically using the client ID, secret and tenant ID. No authorization endpoint
needs to be visited.
#### Required fields
```json
{
"UseOAuth2": true,
"OAuth2ClientId": "YOUR_APP_CLIENT_ID",
"OAuth2ClientSecret": "YOUR_APP_CLIENT_SECRET_VALUE",
"OAuth2TenantId": "yourorg.onmicrosoft.com",
"OAuth2Provider": 1
}
```
#### Azure Portal setup (one-time)
1. Go to [portal.azure.com](https://portal.azure.com) ? **Azure Active Directory** ? **App registrations** ? **+ New registration**.
2. Go to **Certificates & secrets** ? **+ New client secret** ? copy the **Value** (not the ID).
- Set this as `OAuth2ClientSecret`.
3. Go to **API permissions** ? **+ Add a permission** ? **APIs my organization uses** ? **Office 365 Exchange Online**:
- Add **Application permissions**: `IMAP.AccessAsApp`, `SMTP.SendAsApp`, `POP.AccessAsApp`
- Click **Grant admin consent**
4. In Exchange Online PowerShell, register the service principal for the mailbox:
```powershell
New-ServicePrincipal -AppId <ClientId> -ServiceId <ObjectId> -DisplayName "MessagingService"
Add-MailboxPermission -Identity "user@yourorg.onmicrosoft.com" -User <ObjectId> -AccessRights FullAccess
```
5. Set `OAuth2TenantId` to the full domain (e.g. `yourorg.onmicrosoft.com`) or tenant GUID.
> ?? `OAuth2ClientSecret` must be the **Value** shown at secret creation time, not the Secret ID (GUID).
> The value is only visible once — if lost, create a new secret.
> ?? `OAuth2TenantId` must be a full domain (`yourorg.onmicrosoft.com`), a tenant GUID,
> or `common`. Short names like `yourorg` are not valid and will cause `AADSTS900023`.
> No browser-based authorization is required for Microsoft — the application acquires tokens
> automatically on first use and caches them in memory until 5 minutes before expiry.
---
## Email Account Configuration
Each account is configured under `EmailAccounts.Accounts` in `appsettings.Secrets.json`.
> Different providers require different configuration fields. See provider-specific sections below.
### Common fields (all providers)
```json
{
"Id": 1,
"Username": "user@example.com",
"Password": "your_password",
"SmtpServer": "smtp.example.com",
"SmtpPort": 465,
"SmtpUseSsl": true,
"UseOAuth2": false,
"ImapServer": "imap.example.com",
"ImapPort": 993,
"ImapUseSsl": true,
"Pop3Server": "pop.example.com",
"Pop3Port": 995,
"Pop3UseSsl": true,
"IncomingProtocol": 1
}
```
> `Password` is always retained. When `UseOAuth2 = true`, SMTP/IMAP/POP3 connections use OAuth2 tokens
> instead of the password. When `UseOAuth2 = false`, the password is used directly.
> The `IncomingProtocol` field independently controls which protocol is used for receiving emails.
#### `IncomingProtocol` values
| Value | Meaning |
|-------|---------|
| `0` | None — send-only account, skipped by sync worker |
| `1` | IMAP with username/password |
| `2` | POP3 with username/password |
| `3` | IMAP with OAuth2 |
| `4` | POP3 with OAuth2 |
#### `OAuth2Provider` values
| Value | Meaning |
|-------|---------|
| `0` | None |
| `1` | Microsoft (Azure AD / Microsoft 365) |
| `2` | Google (Gmail / Google Workspace) |
---
## Provider-specific OAuth2 Configuration
### Google (Gmail / Google Workspace)
Google uses **user-delegated OAuth2** (not client credentials). A one-time authorization flow is required to obtain a refresh token.
#### Required fields
```json
{
"UseOAuth2": true,
"OAuth2ClientId": "YOUR_CLIENT_ID.apps.googleusercontent.com",
"OAuth2ClientSecret": "GOCSPX-YOUR_CLIENT_SECRET",
"OAuth2RefreshToken": "1//04YOUR_REFRESH_TOKEN",
"OAuth2TenantId": "",
"OAuth2Provider": 2
}
```
#### Setup — obtaining the refresh token (one-time)
1. Go to [console.cloud.google.com](https://console.cloud.google.com) and create or select a project.
2. Enable the **Gmail API** under *APIs & Services ? Library*.
3. Go to *APIs & Services ? Credentials* ? **+ Create Credentials** ? **OAuth 2.0 Client ID**.
- Application type: **Web application**
- Authorized redirect URIs: `https://developers.google.com/oauthplayground`
4. Go to *APIs & Services ? OAuth consent screen*:
- Add the Gmail account under **Test users** (required while app is in Testing mode).
5. Go to [developers.google.com/oauthplayground](https://developers.google.com/oauthplayground):
- Click **?? Settings** ? enable **"Use your own OAuth credentials"**
- Enter your **Client ID** and **Client Secret** (from step 3)
- Close settings
6. In the scope input (Step 1), enter `https://mail.google.com/` ? **Authorize APIs**
7. Sign in with the Gmail account ? grant access
8. Click **Exchange authorization code for tokens** (Step 2)
9. Copy the `refresh_token` value from the response
10. Set `OAuth2RefreshToken` in `appsettings.Secrets.json`
> ?? The refresh token must be obtained using **your own client credentials** in Playground settings.
> If obtained with Playground's default credentials, it will not work with your client secret.
> ?? Google refresh tokens expire if unused for 6 months, or if the user revokes access.
> The token must be re-obtained if `invalid_grant` is returned.
---
### Microsoft 365 / Exchange Online (Azure AD)
Microsoft uses **application-level OAuth2** (client credentials flow — no user interaction required).
#### Required fields
```json
{
"UseOAuth2": true,
"OAuth2ClientId": "YOUR_APP_CLIENT_ID",
"OAuth2ClientSecret": "YOUR_APP_CLIENT_SECRET_VALUE",
"OAuth2TenantId": "yourorg.onmicrosoft.com",
"OAuth2Provider": 1
}
```
#### Setup
1. Go to [portal.azure.com](https://portal.azure.com) ? **Azure Active Directory** ? **App registrations** ? **+ New registration**.
2. Go to **Certificates & secrets** ? **+ New client secret** ? copy the **Value** (not the ID).
- Set this as `OAuth2ClientSecret`.
3. Go to **API permissions** ? **+ Add a permission** ? **APIs my organization uses** ? **Office 365 Exchange Online**:
- Add **Application permissions**: `IMAP.AccessAsApp`, `SMTP.SendAsApp`, `POP.AccessAsApp`
- Click **Grant admin consent**
4. In Exchange Online PowerShell, register the service principal for the mailbox:
```powershell
New-ServicePrincipal -AppId <ClientId> -ServiceId <ObjectId> -DisplayName "MessagingService"
Add-MailboxPermission -Identity "user@yourorg.onmicrosoft.com" -User <ObjectId> -AccessRights FullAccess
```
5. Set `OAuth2TenantId` to the full domain (e.g. `yourorg.onmicrosoft.com`) or tenant GUID.
> ?? `OAuth2ClientSecret` must be the **Value** shown at secret creation time, not the Secret ID (GUID).
> The value is only visible once — if lost, create a new secret.
> ?? `OAuth2TenantId` must be a full domain (`yourorg.onmicrosoft.com`), a tenant GUID,
> or `common`. Short names like `yourorg` are not valid and will cause `AADSTS900023`.

1
legacy Submodule

Submodule legacy added at e59b936181

View File

@@ -0,0 +1,77 @@
using DigitalData.MessagingService.Domain.Enums;
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;
public string? Pop3Server { get; set; }
public int Pop3Port { get; set; } = 995;
public bool Pop3UseSsl { get; set; } = true;
public string? OAuth2ClientId { get; set; }
/// <summary>
/// OAuth2 refresh token (Google only). Obtained via OAuth Playground or authorization flow.
/// Leave empty for Microsoft.
/// </summary>
public string? OAuth2RefreshToken { get; set; }
/// <summary>
/// Tenant ID for Microsoft OAuth2 (GUID, full domain, or "common"). Not used for Google.
/// </summary>
public string? OAuth2TenantId { get; set; }
/// <summary>
/// Identifies which OAuth2 identity provider to use when <see cref="UseOAuth2"/> is true.
/// </summary>
public OAuth2Provider OAuth2Provider { get; set; } = OAuth2Provider.None;
/// <summary>
/// The protocol used to receive (sync) incoming emails.
/// </summary>
public IncomingProtocol IncomingProtocol { get; set; } = IncomingProtocol.None;
}

View File

@@ -0,0 +1,80 @@
using DigitalData.MessagingService.Domain.Enums;
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
/// <summary>
/// DTO for a single email account configuration.
/// </summary>
public record EmailAccountModificationDto
{
#if NET
public required string Username { get; set; }
#else
public string Username { get; set; } = null!;
#endif
#if NET
public required string Password { get; set; }
#else
public string Password { get; set; } = null!;
#endif
#if NET
public required string SmtpServer { get; set; }
#else
public string SmtpServer { get; set; } = null!;
#endif
public int SmtpPort { get; set; }
public bool SmtpUseSsl { get; set; }
public bool UseOAuth2 { get; set; }
/// <summary>
/// IMAP server hostname (e.g. "imap.example.com").
/// Leave empty when this account is send-only.
/// </summary>
public string? ImapServer { get; set; }
/// <summary>
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
/// </summary>
public int ImapPort { get; set; } = 993;
/// <summary>
/// Use SSL/TLS when connecting to the IMAP server.
/// </summary>
public bool ImapUseSsl { get; set; } = true;
public string? Pop3Server { get; set; }
public int Pop3Port { get; set; } = 995;
public bool Pop3UseSsl { get; set; } = true;
public string? OAuth2ClientId { get; set; }
public string? OAuth2ClientSecret { get; set; }
/// <summary>
/// OAuth2 refresh token (Google only). Obtained via OAuth Playground or authorization flow.
/// Leave empty for Microsoft.
/// </summary>
public string? OAuth2RefreshToken { get; set; }
/// <summary>
/// Tenant ID for Microsoft OAuth2 (GUID, full domain, or "common"). Not used for Google.
/// </summary>
public string? OAuth2TenantId { get; set; }
/// <summary>
/// Identifies which OAuth2 identity provider to use when <see cref="UseOAuth2"/> is true.
/// </summary>
public OAuth2Provider OAuth2Provider { get; set; } = OAuth2Provider.None;
/// <summary>
/// The protocol used to receive (sync) incoming emails.
/// </summary>
public IncomingProtocol IncomingProtocol { get; set; } = IncomingProtocol.None;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,7 @@
using System; namespace DigitalData.MessagingService.Application.Common.Dto;
namespace DigitalData.MessagingService.Publisher.Abstraction; public record SendingEmailCreateDto
public class OutgoingEmailEvent
{ {
public Guid Id { get; set; }
/// <summary> /// <summary>
/// Recipient email address /// Recipient email address
/// </summary> /// </summary>

View File

@@ -0,0 +1,40 @@
namespace DigitalData.MessagingService.Application.Common.Dto;
public record SendingEmailEvent
{
#if NETFRAMEWORK
public Guid Id { get; set; }
#else
public required Guid Id { get; init; }
#endif
#if NETFRAMEWORK
public EmailContext Mail { get; set; } = null!;
#else
public required EmailContext Mail { get; init; }
#endif
#if NETFRAMEWORK
public DateTime QueuedAt { get; set; }
#else
public required DateTime QueuedAt { get; init; }
#endif
/// <summary>
/// When true, the sent message will be appended to the IMAP Sent folder after sending.
/// </summary>
#if NETFRAMEWORK
public bool UseImapAppend { get; set; } = false;
#else
public bool UseImapAppend { get; init; } = false;
#endif
/// <summary>
/// IMAP folder to append the sent message to (used when <see cref="UseImapAppend"/> is true).
/// </summary>
#if NETFRAMEWORK
public string SentFolder { get; set; } = "Sent";
#else
public string SentFolder { get; init; } = "Sent";
#endif
}

View File

@@ -1,21 +0,0 @@
namespace DigitalData.MessagingService.Application.Common.Dtos;
/// <summary>
/// DTO for EmailAccount query results.
/// </summary>
public class EmailAccountDto
{
public string Username { get; set; } = null!;
public string Password { get; set; } = null!;
public bool PasswordEncrypted { get; set; } = false;
public string SmtpServer { get; set; } = null!;
public int SmtpPort { get; set; }
public bool SmtpUseSsl { get; set; }
public bool UseOAuth2 { get; set; }
}

View File

@@ -1,3 +1,5 @@
using DigitalData.MessagingService.Application.Common.Dto;
namespace DigitalData.MessagingService.Application.Common.Interfaces; namespace DigitalData.MessagingService.Application.Common.Interfaces;
/// <summary> /// <summary>
@@ -12,5 +14,5 @@ public interface IEmailService
/// Sends an email using the configured SMTP account. /// Sends an email using the configured SMTP account.
/// SMTP credentials are configured in appsettings.json (EmailAccount section). /// SMTP credentials are configured in appsettings.json (EmailAccount section).
/// </summary> /// </summary>
Task SendEmailAsync(string to, string subject, string body, bool isHtml = true, CancellationToken cancellationToken = default); Task SendEmailAsync(EmailContext context, CancellationToken cancellationToken = default);
} }

View File

@@ -0,0 +1,6 @@
namespace DigitalData.MessagingService.Application.Common.Interfaces;
public interface IEmailSyncService
{
public DateTime ForceTriggerSync();
}

View File

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

View File

@@ -0,0 +1,35 @@
#if NET
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Common.Interfaces;
/// <summary>
/// Manages the OAuth2 authorization code flow for providers that require
/// user-delegated access (e.g. Google). Generates authorization URLs and
/// exchanges authorization codes for refresh tokens.
/// </summary>
public interface IOAuth2AuthorizationService
{
/// <summary>
/// Builds the authorization URL to redirect the user to for consent.
/// </summary>
/// <param name="account">The email account to authorize.</param>
/// <param name="redirectUri">The callback URI registered with the OAuth2 provider.</param>
/// <returns>The full authorization URL.</returns>
string GetAuthorizationUrl(EmailAccount account, string redirectUri);
/// <summary>
/// Exchanges an authorization code for tokens and returns the refresh token.
/// </summary>
/// <param name="account">The email account being authorized.</param>
/// <param name="code">The authorization code received from the provider callback.</param>
/// <param name="redirectUri">The same redirect URI used in <see cref="GetAuthorizationUrl"/>.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The refresh token to be stored on the account.</returns>
Task<string> ExchangeCodeForRefreshTokenAsync(
EmailAccount account,
string code,
string redirectUri,
CancellationToken cancellationToken = default);
}
#endif

View File

@@ -0,0 +1,18 @@
#if NET
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Common.Interfaces;
/// <summary>
/// Acquires and caches OAuth2 access tokens for email protocols (IMAP, POP3, SMTP).
/// Uses the client credentials flow (application-level auth — no user interaction required).
/// </summary>
public interface IOAuth2TokenService
{
/// <summary>
/// Returns a valid access token for the given email account.
/// Tokens are cached and refreshed automatically before expiry.
/// </summary>
Task<string> GetAccessTokenAsync(EmailAccount account, CancellationToken cancellationToken = default);
}
#endif

View File

@@ -0,0 +1,25 @@
#if NET
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Common.Interfaces;
/// <summary>
/// Service interface for reading emails via POP3.
/// </summary>
public interface IPop3EmailService
{
/// <summary>
/// Fetches new messages from the POP3 server and persists them locally.
/// Because POP3 has no folder concept, all messages are stored under the folder name "INBOX".
/// </summary>
Task<EmailSyncResult> SyncEmailsAsync(
EmailAccount account,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the last POP3 sync date for the specified account.
/// </summary>
DateTime? GetLastPop3SyncDate(int accountId);
}
#endif

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
using System.Linq.Expressions; using System.Linq.Expressions;
namespace DigitalData.MessagingService.Application.Common.Interfaces; namespace DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
/// <summary> /// <summary>
/// Generic repository interface for CRUD operations. /// Generic repository interface for CRUD operations.
@@ -11,6 +11,8 @@ public interface IRepository<TEntity> where TEntity : class
// CREATE // CREATE
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default); Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
Task<IEnumerable<TEntity>> CreateRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default);
// READ // READ
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default); Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<TEntity>> GetAllAsync(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<int> CountAsync(Expression<Func<TEntity, bool>>? predicate = null, CancellationToken cancellationToken = default);
Task<bool> AnyAsync(Expression<Func<TEntity, bool>> predicate, 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 // UPDATE
Task UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default); 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); 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 AutoMapper;
using DigitalData.MessagingService.Application.EmailSending.Commands; using DigitalData.MessagingService.Application.EmailSending.Commands;
using DigitalData.MessagingService.Publisher.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; namespace DigitalData.MessagingService.Application.Common.Mappings;
@@ -11,9 +14,43 @@ public class EmailMappingProfile : Profile
{ {
public EmailMappingProfile() public EmailMappingProfile()
{ {
// SendEmailCommand -> OutgoingEmailEvent // PublishEmailCommand -> Email
CreateMap<SendEmailCommand, OutgoingEmailEvent>() // Sender is resolved via MediatR in the handler and set separately after mapping.
.ForMember(dest => dest.Id, opt => opt.MapFrom(_ => Guid.NewGuid())) CreateMap<PublishEmailCommand, EmailContext>()
.ForMember(dest => dest.QueuedAt, opt => opt.MapFrom(_ => DateTime.Now)); .ForMember(dest => dest.Sender, opt => opt.Ignore())
.ForMember(dest => dest.Attachments, opt => opt.MapFrom(src => src.Attachments));
// PublishEmailViaImapCommand -> EmailContext
CreateMap<PublishEmailViaImapCommand, EmailContext>()
.ForMember(dest => dest.Sender, opt => opt.Ignore())
.ForMember(dest => dest.Attachments, opt => opt.MapFrom(src => src.Attachments));
// PublishEmailViaOAuth2Command -> EmailContext
CreateMap<PublishEmailViaOAuth2Command, 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>()
// Do not overwrite OAuth2RefreshToken if the source value is null or empty.
// This prevents the seed process from erasing a token that was obtained via
// the OAuth2 authorization flow and saved to the database at runtime.
.ForMember(dest => dest.OAuth2RefreshToken,
opt => opt.Condition((src, dest, srcMember) => !string.IsNullOrEmpty(srcMember)));
// ReceivedEmailDto <-> ReceivedEmail
CreateMap<ReceivedEmailDto, ReceivedEmail>()
.ForMember(dest => dest.Id, opt => opt.Ignore())
.ForMember(dest => dest.Account, opt => opt.Ignore());
CreateMap<ReceivedEmail, ReceivedEmailDto>();
// EmailAttachmentDto <-> EmailAttachment
CreateMap<EmailAttachmentDto, EmailAttachment>()
.ForMember(dest => dest.Id, opt => opt.Ignore())
.ForMember(dest => dest.EmailId, opt => opt.Ignore())
.ForMember(dest => dest.Email, opt => opt.Ignore());
CreateMap<EmailAttachment, EmailAttachmentDto>();
} }
} }
#endif

View File

@@ -0,0 +1,19 @@
#if NET
using AutoMapper;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Common.Mappings;
/// <summary>
/// AutoMapper profile for OAuth2 operations.
/// Registers EmailAccount self-mapping so UpdateSingleAsync can update an account
/// entity using another EmailAccount instance (e.g. after setting OAuth2RefreshToken).
/// </summary>
public class OAuth2MappingProfile : Profile
{
public OAuth2MappingProfile()
{
CreateMap<EmailAccount, EmailAccount>();
}
}
#endif

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
using System.Reflection; #if NET
using DigitalData.MessagingService.Application.Common.Options;
using FluentValidation; using FluentValidation;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
namespace DigitalData.MessagingService.Application; namespace DigitalData.MessagingService.Application;
@@ -35,6 +37,10 @@ public static class DependencyInjection
// FluentValidation - Register all validators // FluentValidation - Register all validators
services.AddValidatorsFromAssembly(assembly); services.AddValidatorsFromAssembly(assembly);
// Register EmailAccounts configuration (IOptions<EmailAccountsOptions>)
services.Configure<EmailAccountsOptions>(configuration.GetSection(EmailAccountsOptions.SectionName));
return services; return services;
} }
} }
#endif

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
#if NET
using DigitalData.MessagingService.Application.Common.Dto;
namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
/// <summary>
///
/// </summary>
/// <param name="Emails"></param>
/// <param name="LastSync"></param>
public class ReadEmailQueryResponse
{
public DateTime? LastSync { get; init; } = null;
public IEnumerable<ReceivedEmailDto> Emails { get; init; } = [];
}
#endif

View File

@@ -0,0 +1,77 @@
#if NET
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
using DigitalData.MessagingService.Application.EmailReceiving.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 using OAuth2 authentication.
/// The account must have <c>UseOAuth2 = true</c> and valid OAuth2 credentials configured.
/// </summary>
public record ReadEmailViaOAuth2Query : IRequest<ReadEmailQueryResponse>
{
/// <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 ReadEmailViaOAuth2QueryHandler(
IMapper Mapper,
ILogger<ReadEmailViaOAuth2QueryHandler> Logger,
IRepository<EmailAccount> EmailAccountRepo,
IReceivedEmailRepository MailRepo,
IImapEmailService imapEmailService) : IRequestHandler<ReadEmailViaOAuth2Query, ReadEmailQueryResponse>
{
public async Task<ReadEmailQueryResponse> Handle(ReadEmailViaOAuth2Query request, CancellationToken cancellationToken)
{
var accounts = await EmailAccountRepo.FindAsync(
request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username,
cancellationToken: cancellationToken);
if (accounts.Count() > 1)
Logger.LogWarning("Multiple email accounts found ({Criteria}). Using first.",
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 (Id: {request.Account.Id}, Username: {request.Account.Username}).");
if (!account.UseOAuth2)
throw new BadRequestException(
$"Account '{account.Username}' (Id: {account.Id}) is not configured for OAuth2. Set UseOAuth2 = true.");
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId) ||
string.IsNullOrWhiteSpace(account.OAuth2ClientSecret))
throw new BadRequestException(
$"OAuth2 credentials (ClientId, ClientSecret) are not configured for account '{account.Username}'.");
if (string.IsNullOrWhiteSpace(account.ImapServer))
throw new BadRequestException(
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
var mails = await MailRepo.FindAsync(request.Mail, account, cancellationToken);
var lastSync = imapEmailService.GetLastImapSyncDate(account.Id, request.Mail.Folder);
return new ReadEmailQueryResponse
{
LastSync = lastSync,
Emails = Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails)
};
}
}
#endif

View File

@@ -0,0 +1,74 @@
#if NET
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
using DigitalData.MessagingService.Application.EmailReceiving.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 a POP3 mailbox.
/// Triggers an on-demand sync and returns stored results filtered by <see cref="Mail"/>.
/// </summary>
public record ReadEmailViaPop3Query : IRequest<ReadEmailQueryResponse>
{
/// <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 from local storage.
/// Note: POP3 has no folder concept — all messages are stored under "INBOX".
/// </summary>
public MailSearchFilter Mail { get; init; } = new();
}
public class ReadEmailViaPop3QueryHandler(
IMapper Mapper,
ILogger<ReadEmailViaPop3QueryHandler> Logger,
IRepository<EmailAccount> EmailAccountRepo,
IReceivedEmailRepository MailRepo,
IPop3EmailService pop3EmailService) : IRequestHandler<ReadEmailViaPop3Query, ReadEmailQueryResponse>
{
public async Task<ReadEmailQueryResponse> Handle(ReadEmailViaPop3Query request, CancellationToken cancellationToken)
{
var accounts = await EmailAccountRepo.FindAsync(
request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username,
cancellationToken: cancellationToken);
if (accounts.Count() > 1)
Logger.LogWarning("Multiple email accounts found ({Criteria}). Using first.",
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 (Id: {request.Account.Id}, Username: {request.Account.Username}).");
if (string.IsNullOrWhiteSpace(account.Pop3Server))
throw new BadRequestException(
$"POP3 is not configured for account '{account.Username}' (Id: {account.Id}). Set Pop3Server in EmailAccounts configuration.");
// Trigger on-demand POP3 sync before querying local storage
await pop3EmailService.SyncEmailsAsync(account, cancellationToken);
// POP3 has no folder concept — always query INBOX
var filter = request.Mail with { Folder = "INBOX" };
var mails = await MailRepo.FindAsync(filter, account, cancellationToken);
var lastSync = pop3EmailService.GetLastPop3SyncDate(account.Id);
return new ReadEmailQueryResponse
{
LastSync = lastSync,
Emails = Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails)
};
}
}
#endif

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
#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 using IMAP account credentials (queued via RabbitMQ).
/// After processing, the sent message is appended to the IMAP Sent Items folder.
/// </summary>
public record PublishEmailViaImapCommand : IRequest<Guid>
{
public required GetEmailAccountQuery Sender { get; init; }
public required IEnumerable<string> Recipients { get; init; }
public required string Subject { get; init; }
public required string Body { get; init; }
public bool IsHtml { get; init; } = true;
/// <summary>
/// IMAP folder to which the sent message will be appended (default: "Sent").
/// </summary>
public string SentFolder { get; init; } = "Sent";
[JsonIgnore]
internal IEnumerable<EmailAttachmentDto> Attachments { get; private init; } = [];
public PublishEmailViaImapCommand WithAttachments(IEnumerable<EmailAttachmentDto> attachments)
=> this with { Attachments = attachments };
}
public class PublishEmailViaImapCommandHandler(
IRepository<EmailAccount> Repo,
ISendingEmailPublisher Publisher,
IMapper Mapper,
ILogger<PublishEmailViaImapCommandHandler> Logger) : IRequestHandler<PublishEmailViaImapCommand, Guid>
{
public async Task<Guid> Handle(PublishEmailViaImapCommand 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 ({Criteria}). Using first.",
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 (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
if (string.IsNullOrWhiteSpace(senderAccount.ImapServer))
throw new BadRequestException(
$"IMAP is not configured for account '{senderAccount.Username}' (Id: {senderAccount.Id}). Set ImapServer in EmailAccounts configuration.");
var emailContext = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
var sendingEmailEvent = new SendingEmailEvent
{
Id = Guid.NewGuid(),
Mail = emailContext,
QueuedAt = DateTime.Now,
SentFolder = request.SentFolder,
UseImapAppend = true
};
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
return sendingEmailEvent.Id;
}
}
#endif

View File

@@ -0,0 +1,79 @@
#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 via SMTP using OAuth2 authentication (queued via RabbitMQ).
/// The account must have <c>UseOAuth2 = true</c> and valid OAuth2 credentials configured.
/// </summary>
public record PublishEmailViaOAuth2Command : IRequest<Guid>
{
public required GetEmailAccountQuery Sender { get; init; }
public required IEnumerable<string> Recipients { get; init; }
public required string Subject { get; init; }
public required string Body { get; init; }
public bool IsHtml { get; init; } = true;
[JsonIgnore]
internal IEnumerable<EmailAttachmentDto> Attachments { get; private init; } = [];
public PublishEmailViaOAuth2Command WithAttachments(IEnumerable<EmailAttachmentDto> attachments)
=> this with { Attachments = attachments };
}
public class PublishEmailViaOAuth2CommandHandler(
IRepository<EmailAccount> Repo,
ISendingEmailPublisher Publisher,
IMapper Mapper,
ILogger<PublishEmailViaOAuth2CommandHandler> Logger) : IRequestHandler<PublishEmailViaOAuth2Command, Guid>
{
public async Task<Guid> Handle(PublishEmailViaOAuth2Command 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 ({Criteria}). Using first.",
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 (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
if (!senderAccount.UseOAuth2)
throw new BadRequestException(
$"Account '{senderAccount.Username}' (Id: {senderAccount.Id}) is not configured for OAuth2. Set UseOAuth2 = true.");
if (string.IsNullOrWhiteSpace(senderAccount.OAuth2ClientId) ||
string.IsNullOrWhiteSpace(senderAccount.OAuth2ClientSecret))
throw new BadRequestException(
$"OAuth2 credentials (ClientId, ClientSecret) are not configured for account '{senderAccount.Username}'.");
var emailContext = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
var sendingEmailEvent = new SendingEmailEvent
{
Id = Guid.NewGuid(),
Mail = emailContext,
QueuedAt = DateTime.Now
};
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
return sendingEmailEvent.Id;
}
}
#endif

View File

@@ -1,47 +0,0 @@
using AutoMapper;
using DigitalData.MessagingService.Publisher.Abstraction;
using MediatR;
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
/// <summary>
/// Command to send an email (enqueue to RabbitMQ)
/// </summary>
public record SendEmailCommand : IRequest<OutgoingEmailEvent>
{
/// <summary>
/// Recipient email address
/// </summary>
public required string Recipient { 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;
}
/// <summary>
/// Handler for SendEmailCommand
/// Creates EmailOutbox entity via AutoMapper and enqueues to RabbitMQ
/// </summary>
public class SendEmailCommandHandler(IOutgoingEmailPublisher Publisher, IMapper Mapper) : IRequestHandler<SendEmailCommand, OutgoingEmailEvent>
{
public async Task<OutgoingEmailEvent> Handle(SendEmailCommand request, CancellationToken cancellationToken)
{
var outgoingEmailEvent = Mapper.Map<OutgoingEmailEvent>(request);
// Enqueue to RabbitMQ
await Publisher.EnqueueAsync(outgoingEmailEvent, cancellationToken);
return outgoingEmailEvent;
}
}

View File

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

View File

@@ -0,0 +1,72 @@
#if NET
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Exceptions;
using MediatR;
using Microsoft.Extensions.Logging;
namespace DigitalData.MessagingService.Application.OAuth2.Commands;
/// <summary>
/// Exchanges a Google OAuth2 authorization code for a refresh token
/// and persists it on the email account.
/// Call this from the OAuth2 callback endpoint after the user grants consent.
/// </summary>
public record CompleteOAuth2AuthorizationCommand : IRequest<CompleteOAuth2AuthorizationResult>
{
/// <summary>
/// ID of the email account being authorized.
/// </summary>
public required int AccountId { get; init; }
/// <summary>
/// The authorization code received from the OAuth2 provider callback.
/// </summary>
public required string Code { get; init; }
/// <summary>
/// The redirect URI used in the original authorization request.
/// </summary>
public required string RedirectUri { get; init; }
}
public record CompleteOAuth2AuthorizationResult
{
public required string Username { get; init; }
public required bool Success { get; init; }
public string? ErrorMessage { get; init; }
}
public class CompleteOAuth2AuthorizationCommandHandler(
IRepository<EmailAccount> Repo,
IOAuth2AuthorizationService AuthService,
ILogger<CompleteOAuth2AuthorizationCommandHandler> Logger) : IRequestHandler<CompleteOAuth2AuthorizationCommand, CompleteOAuth2AuthorizationResult>
{
public async Task<CompleteOAuth2AuthorizationResult> Handle(CompleteOAuth2AuthorizationCommand request, CancellationToken cancellationToken)
{
var account = await Repo.GetByIdAsync(request.AccountId, cancellationToken)
?? throw new NotFoundException($"No email account found with Id: {request.AccountId}.");
Logger.LogInformation("Exchanging OAuth2 authorization code for account '{Username}' (Id: {Id}).",
account.Username, account.Id);
var refreshToken = await AuthService.ExchangeCodeForRefreshTokenAsync(
account, request.Code, request.RedirectUri, cancellationToken);
// Set the refresh token directly on the tracked entity — no AutoMapper needed
account.OAuth2RefreshToken = refreshToken;
await Repo.UpdateSingleAsync(a => a.Id == account.Id, account, cancellationToken);
Logger.LogInformation("OAuth2 refresh token saved for account '{Username}' (Id: {Id}).",
account.Username, account.Id);
return new CompleteOAuth2AuthorizationResult
{
Username = account.Username,
Success = true
};
}
}
#endif

View File

@@ -0,0 +1,46 @@
#if NET
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Enums;
using DigitalData.MessagingService.Domain.Exceptions;
using MediatR;
namespace DigitalData.MessagingService.Application.OAuth2.Queries;
/// <summary>
/// Returns the authorization URL the user must visit to grant OAuth2 access.
/// Supported for providers that require user-delegated access (e.g. Google).
/// </summary>
public record GetOAuth2AuthorizationUrlQuery : IRequest<string>
{
/// <summary>
/// ID of the email account to authorize.
/// </summary>
public required string Username { get; init; }
/// <summary>
/// The redirect URI registered with the OAuth2 provider.
/// Must exactly match the URI configured in the provider's developer console.
/// </summary>
public required string RedirectUri { get; init; }
}
public class GetOAuth2AuthorizationUrlQueryHandler(
IRepository<EmailAccount> Repo,
IOAuth2AuthorizationService AuthService) : IRequestHandler<GetOAuth2AuthorizationUrlQuery, string>
{
public async Task<string> Handle(GetOAuth2AuthorizationUrlQuery request, CancellationToken cancellationToken)
{
var account = await Repo.FindFirstAsync(e => e.Username == request.Username, cancellationToken)
?? throw new NotFoundException($"No email account found with username: {request.Username}.");
if (account.OAuth2Provider == OAuth2Provider.None)
throw new BadRequestException(
$"Account '{account.Username}' (Id: {account.Id}) has no OAuth2 provider configured. " +
$"Set OAuth2Provider to a supported value (e.g. Google).");
return AuthService.GetAuthorizationUrl(account, request.RedirectUri);
}
}
#endif

View File

@@ -11,4 +11,8 @@
<PackageReference Include="MediatR" Version="12.2.0" /> <PackageReference Include="MediatR" Version="12.2.0" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net462'">
<Reference Include="System.ComponentModel.DataAnnotations" />
</ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,145 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DigitalData.MessagingService.Domain.Enums;
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;
/// <summary>
/// POP3 server hostname (e.g. "pop.example.com").
/// Leave empty when this account does not use POP3.
/// </summary>
[MaxLength(256)]
[Column("POP3_SERVER", TypeName = "nvarchar(256)")]
public string? Pop3Server { get; set; }
/// <summary>
/// POP3 server port (995 for SSL, 110 for plain).
/// </summary>
[Column("POP3_PORT", TypeName = "int")]
public int Pop3Port { get; set; } = 995;
/// <summary>
/// Use SSL/TLS when connecting to the POP3 server.
/// </summary>
[Column("POP3_USE_SSL", TypeName = "bit")]
public bool Pop3UseSsl { get; set; } = true;
/// <summary>
/// OAuth2 client ID (required when <see cref="UseOAuth2"/> is true).
/// </summary>
[MaxLength(512)]
[Column("OAUTH2_CLIENT_ID", TypeName = "nvarchar(512)")]
public string? OAuth2ClientId { get; set; }
/// <summary>
/// OAuth2 client secret (required when <see cref="UseOAuth2"/> is true).
/// For Microsoft: the app registration client secret value from Azure Portal.
/// For Google: the client secret from Google Cloud Console credentials JSON.
/// </summary>
[MaxLength(512)]
[Column("OAUTH2_CLIENT_SECRET", TypeName = "nvarchar(512)")]
public string? OAuth2ClientSecret { get; set; }
/// <summary>
/// OAuth2 refresh token (Google only).
/// Obtained once via the OAuth2 authorization flow (e.g. OAuth Playground).
/// Used to exchange for short-lived access tokens without user interaction.
/// Leave empty for Microsoft — MSAL handles token refresh internally.
/// </summary>
[MaxLength(1024)]
[Column("OAUTH2_REFRESH_TOKEN", TypeName = "nvarchar(1024)")]
public string? OAuth2RefreshToken { get; set; }
/// <summary>
/// OAuth2 tenant ID (e.g. for Microsoft 365: tenant GUID or "common").
/// Not required for Google — leave empty.
/// </summary>
[MaxLength(256)]
[Column("OAUTH2_TENANT_ID", TypeName = "nvarchar(256)")]
public string? OAuth2TenantId { get; set; }
/// <summary>
/// Identifies which OAuth2 identity provider to use when <see cref="UseOAuth2"/> is true.
/// Determines which token acquisition strategy is applied.
/// </summary>
[Column("OAUTH2_PROVIDER", TypeName = "int")]
public OAuth2Provider OAuth2Provider { get; set; } = OAuth2Provider.None;
/// <summary>
/// The protocol used to receive (sync) incoming emails.
/// When set to <see cref="IncomingProtocol.None"/>, this account is send-only and will be skipped by the sync worker.
/// Takes priority over the presence of <see cref="ImapServer"/> or <see cref="Pop3Server"/>.
/// </summary>
[Column("INCOMING_PROTOCOL", TypeName = "int")]
public IncomingProtocol IncomingProtocol { get; set; } = IncomingProtocol.None;
}

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
namespace DigitalData.MessagingService.Domain.Enums;
/// <summary>
/// Specifies the protocol used to receive (sync) incoming emails for an account.
/// </summary>
public enum IncomingProtocol
{
/// <summary>
/// No incoming protocol configured — account is send-only.
/// </summary>
None = 0,
/// <summary>
/// Sync emails via IMAP using username/password authentication.
/// </summary>
Imap = 1,
/// <summary>
/// Sync emails via POP3 using username/password authentication.
/// </summary>
Pop3 = 2,
/// <summary>
/// Sync emails via IMAP using OAuth2 (XOAUTH2) authentication.
/// Requires <c>UseOAuth2 = true</c> and valid OAuth2 credentials.
/// </summary>
ImapOAuth2 = 3,
/// <summary>
/// Sync emails via POP3 using OAuth2 (XOAUTH2) authentication.
/// Requires <c>UseOAuth2 = true</c> and valid OAuth2 credentials.
/// </summary>
Pop3OAuth2 = 4,
}

View File

@@ -0,0 +1,29 @@
namespace DigitalData.MessagingService.Domain.Enums;
/// <summary>
/// Identifies the OAuth2 identity provider used to acquire access tokens.
/// Only relevant when <c>UseOAuth2 = true</c>.
/// </summary>
public enum OAuth2Provider
{
/// <summary>
/// No OAuth2 provider — account uses plain username/password authentication.
/// </summary>
None = 0,
/// <summary>
/// Microsoft identity platform (Azure AD / Microsoft 365 / Exchange Online).
/// Uses MSAL with the client credentials flow against
/// <c>https://login.microsoftonline.com/{tenant}</c>.
/// Requires <c>OAuth2ClientId</c>, <c>OAuth2ClientSecret</c> and <c>OAuth2TenantId</c>.
/// </summary>
Microsoft = 1,
/// <summary>
/// Google identity platform (Gmail / Google Workspace).
/// Uses the service-account or OAuth2 client credentials flow against
/// <c>https://oauth2.googleapis.com/token</c>.
/// Requires <c>OAuth2ClientId</c> and <c>OAuth2ClientSecret</c>.
/// </summary>
Google = 2,
}

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 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>

View File

@@ -1,14 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace DigitalData.MessagingService.Publisher.Abstraction;
/// <summary>
/// Email queue interface for outgoing emails.
/// </summary>
public interface IOutgoingEmailPublisher
{
Task EnqueueAsync(OutgoingEmailEvent outgoingEmailEvent, CancellationToken cancellationToken = default);
Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default);
}

View File

@@ -1,12 +1,19 @@
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Interfaces; 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.Queue;
using DigitalData.MessagingService.Infrastructure.Repositories;
using DigitalData.MessagingService.Infrastructure.Services; using DigitalData.MessagingService.Infrastructure.Services;
using DigitalData.MessagingService.Infrastructure.Services.Background; using DigitalData.MessagingService.Infrastructure.Services.Background;
using DigitalData.MessagingService.Publisher.Abstraction; using DigitalData.MessagingService.Publisher;
using DigitalData.MessagingService.RabbitMQ; using DigitalData.MessagingService.RabbitMQ;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace DigitalData.MessagingService.Infrastructure; namespace DigitalData.MessagingService.Infrastructure;
@@ -23,9 +30,26 @@ public static class DependencyInjection
IConfiguration configuration) IConfiguration configuration)
{ {
// --- External Services --- // --- External Services ---
// Email Service (using Limilabs Mail.dll - Singleton for use in EmailSenderWorker) // OAuth2 token services — provider-specific implementations registered separately,
services.AddSingleton<IEmailService, LimilabsEmailService>(); // dispatcher is the single IOAuth2TokenService consumed by all other services.
services.AddSingleton<MicrosoftOAuth2TokenService>();
services.AddSingleton<GoogleOAuth2TokenService>();
services.AddSingleton<IOAuth2TokenService, OAuth2TokenServiceDispatcher>();
services.AddHttpClient(nameof(GoogleOAuth2TokenService));
services.AddHttpClient(nameof(GoogleOAuth2AuthorizationService));
services.AddSingleton<IOAuth2AuthorizationService, GoogleOAuth2AuthorizationService>();
// Email Service - SMTP outbound (Limilabs Mail.dll) - OAuth2 aware
services.AddSingleton<LimilabsEmailService>();
services.AddSingleton<IEmailService>(sp => sp.GetRequiredService<LimilabsEmailService>());
// Email Service - IMAP inbound (Limilabs Mail.dll)
services.AddScoped<IImapEmailService, LimilabsImapEmailService>();
// Email Service - POP3 inbound (Limilabs Mail.dll)
services.AddScoped<IPop3EmailService, LimilabsPop3EmailService>();
// PDF Processing Service (using DevExpress.Pdf) // PDF Processing Service (using DevExpress.Pdf)
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>(); services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
@@ -33,8 +57,8 @@ public static class DependencyInjection
services.AddSingleton<IEncryptionService, DataProtectionEncryptionService>(); services.AddSingleton<IEncryptionService, DataProtectionEncryptionService>();
// --- Email Queue (RabbitMQ) --- // --- Email Queue (RabbitMQ) ---
services.AddSingleton<OutgoingEmailConsumer>(); services.AddSingleton<SendingEmailConsumerPool>();
services.AddSingleton<IOutgoingEmailPublisher, OutgoingEmailPublisher>(); services.AddMessagingServicePublisher();
// --- RabbitMQ Configuration --- // --- RabbitMQ Configuration ---
services.AddRabbitMqConnectionFactory(configuration); services.AddRabbitMqConnectionFactory(configuration);
@@ -46,7 +70,29 @@ public static class DependencyInjection
// Register Background Workers // Register Background Workers
services.AddHostedService<AsyncInitWorker>(); services.AddHostedService<AsyncInitWorker>();
services.AddHostedService<EmailSyncWorker>();
services.AddSingleton<IEmailSyncService>(p =>
{
var hostedServices = p.GetRequiredService<IEnumerable<IHostedService>>();
var emailSyncWorkers = hostedServices.OfType<EmailSyncWorker>();
return emailSyncWorkers.FirstOrDefault() ?? throw new InvalidOperationException("EmailSyncWorker is not registered.");
});
services.AddMemoryCache();
// --- Database (InMemory) ---
services.AddDbContext<MessagingServiceDbContext>(options =>
options.UseInMemoryDatabase("MessagingServiceDb"));
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddScoped<IReceivedEmailRepository, ReceivedEmailRepository>();
// AutoMapper - Register entity self-mappings (T -> T) for generic repository
services.AddAutoMapper(config => config.AddMaps(typeof(EntitySelfMappingProfile).Assembly));
return services; return services;
} }
} }

View File

@@ -7,8 +7,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" /> <ProjectReference Include="..\..\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" />
<ProjectReference Include="..\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" /> <ProjectReference Include="..\..\core\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
<ProjectReference Include="..\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj" />
<ProjectReference Include="..\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" /> <ProjectReference Include="..\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
</ItemGroup> </ItemGroup>
@@ -22,8 +23,10 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" 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.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="RabbitMQ.Client" Version="7.2.1" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.10" /> <PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.10" />
</ItemGroup> </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; using Microsoft.EntityFrameworkCore;
namespace DigitalData.MessagingService.Infrastructure.Persistence; namespace DigitalData.MessagingService.Infrastructure.Persistence;
/// <summary> /// <summary>
/// Entity Framework Core DbContext for MessagingService. /// Entity Framework Core DbContext for MessagingService.
/// IMPORTANT: This context maps to a LEGACY database - NO schema modifications allowed!
/// </summary> /// </summary>
public class MessagingServiceDbContext(DbContextOptions<MessagingServiceDbContext> options) : DbContext(options) 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,60 +1,76 @@
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using DigitalData.MessagingService.Application.Common.Interfaces; using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Publisher.Abstraction;
using DigitalData.MessagingService.RabbitMQ; using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RabbitMQ.Client; using RabbitMQ.Client;
using RabbitMQ.Client.Events; using RabbitMQ.Client.Events;
using DigitalData.MessagingService.Application.Common.Dto;
namespace DigitalData.MessagingService.Infrastructure.Queue; namespace DigitalData.MessagingService.Infrastructure.Queue;
/// <summary> /// <summary>
/// RabbitMQ-based email queue implementation for outgoing emails. /// A single RabbitMQ consumer that processes one email message at a time on its own dedicated channel.
/// Provides message persistence, scalability, and reliability. /// Multiple instances run in parallel via <see cref="SendingEmailConsumerPool"/> (competing consumers pattern).
/// Uses Lazy<T> initialization pattern to avoid blocking constructor. /// Each instance owns exactly one channel — channels are not thread-safe and must not be shared.
/// </summary> /// </summary>
public sealed class OutgoingEmailConsumer : IAsyncDisposable public sealed class SendingEmailConsumer : IAsyncDisposable
{ {
private readonly RabbitMqConfiguration _config; private readonly string _queueName;
private readonly Lazy<Task<IChannel>> _lazyChannel; private readonly Lazy<Task<IChannel>> _lazyChannel;
private readonly Lazy<Task> _lazyInit; private readonly Lazy<Task> _lazyInit;
private readonly ILogger<OutgoingEmailConsumer>? _logger; private readonly ILogger<SendingEmailConsumer>? _logger;
public OutgoingEmailConsumer(IOptions<RabbitMqConfiguration> config, IEmailService EmailService, RabbitMqConnectionFactory CnnFactory, ILogger<OutgoingEmailConsumer>? logger = null) /// <summary>
/// Transient identifier assigned to this consumer instance at runtime.
/// A new value is generated each time the application starts or a new consumer is created.
/// Use this to correlate log entries belonging to the same consumer session across competing instances.
/// </summary>
public Guid RuntimeId { get; } = Guid.NewGuid();
public SendingEmailConsumer(string queueName, IEmailService emailService, IServiceScopeFactory scopeFactory, RabbitMqConnectionFactory cnnFactory, ILogger<SendingEmailConsumer>? logger = null)
{ {
_logger = logger; _logger = logger;
_config = config.Value; _queueName = queueName;
_lazyChannel = new(CnnFactory.CreateChannelAsync); _lazyChannel = new(cnnFactory.CreateChannelAsync);
_lazyInit = new(async () => { _lazyInit = new(async () =>
{
var channel = await _lazyChannel.Value; var channel = await _lazyChannel.Value;
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false);
var consumer = new AsyncEventingBasicConsumer(channel); var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (sender, args) => consumer.ReceivedAsync += async (sender, args) =>
{ {
OutgoingEmailEvent? oMailEvent = null; SendingEmailEvent? oMailEvent = null;
try try
{ {
var json = Encoding.UTF8.GetString(args.Body.ToArray()); var json = Encoding.UTF8.GetString(args.Body.ToArray());
oMailEvent = JsonSerializer.Deserialize<OutgoingEmailEvent>(json); oMailEvent = JsonSerializer.Deserialize<SendingEmailEvent>(json);
if (oMailEvent is not null) if (oMailEvent is not null)
{ {
// Send email via SMTP (SMTP config is injected in IEmailService via IOptions) if (oMailEvent.UseImapAppend)
await EmailService.SendEmailAsync( {
oMailEvent.Recipient, await using var scope = scopeFactory.CreateAsyncScope();
oMailEvent.Subject, var imapService = scope.ServiceProvider.GetRequiredService<IImapEmailService>();
oMailEvent.Body, await imapService.SendAndAppendAsync(oMailEvent.Mail, oMailEvent.SentFolder, args.CancellationToken);
isHtml: oMailEvent.IsHtml); }
else
await emailService.SendEmailAsync(oMailEvent.Mail, args.CancellationToken);
// Acknowledge message after successful processing // Acknowledge message after successful processing
await channel.BasicAckAsync(args.DeliveryTag, false, args.CancellationToken); await channel.BasicAckAsync(args.DeliveryTag, false, args.CancellationToken);
logger?.LogDebug(
"Email successfully sent and acknowledged. RuntimeId={RuntimeId}, Queue={QueueName}, DeliveryTag={DeliveryTag}, To={Recipients}, Subject={Subject}, EventId={EventId}",
RuntimeId, _queueName, args.DeliveryTag, oMailEvent.Mail.Recipients, oMailEvent.Mail.Subject, oMailEvent.Id);
} }
else else
{ {
@@ -64,11 +80,11 @@ public sealed class OutgoingEmailConsumer : IAsyncDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
logger?.LogError(ex, "Failed to process email [To={To}, Subject={Subject}] message: DeliveryTag={DeliveryTag}. Moving to DLQ (NO retry).", oMailEvent?.Recipient, oMailEvent?.Subject, args.DeliveryTag); logger?.LogError(ex, "Failed to process email [To={To}, Subject={Subject}] message: DeliveryTag={DeliveryTag}. Moving to DLQ (NO retry).", oMailEvent?.Mail.Recipients, oMailEvent?.Mail.Subject, args.DeliveryTag);
// TODO: Error Reporting Strategy // TODO: Error Reporting Strategy
// Option 1: Separate RabbitMQ Queue (emailprofiler.errors) // Option 1: Separate RabbitMQ Queue (emailprofiler.errors)
// - Create EmailErrorReport entity { OutgoingEmailEventId, Exception, StackTrace, Timestamp, RetryAttempt } // - Create EmailErrorReport entity { SendingEmailEventId, Exception, StackTrace, Timestamp, RetryAttempt }
// - Publish to error queue: await _errorQueue.EnqueueAsync(errorReport) // - Publish to error queue: await _errorQueue.EnqueueAsync(errorReport)
// - Separate worker processes error queue → Log to DB/File/External monitoring // - Separate worker processes error queue → Log to DB/File/External monitoring
// //
@@ -93,24 +109,23 @@ public sealed class OutgoingEmailConsumer : IAsyncDisposable
// Start consuming messages (event-driven, non-blocking) // Start consuming messages (event-driven, non-blocking)
await channel.BasicConsumeAsync( await channel.BasicConsumeAsync(
queue: _config.QueueName, queue: _queueName,
autoAck: false, autoAck: false,
consumer: consumer, consumer: consumer,
cancellationToken: CnnFactory.CancellationToken); cancellationToken: cnnFactory.CancellationToken);
logger?.LogInformation("RabbitMQ consumer started for queue: {QueueName}", _config.QueueName); logger?.LogInformation("RabbitMQ consumer started for queue: {QueueName}", _queueName);
}); });
} }
/// <summary> /// <summary>
/// Initialize RabbitMQ connection, channel, exchanges, and queues asynchronously. /// Starts the consumer: opens a channel, sets QoS, and registers the event handler.
/// Start event-driven consumer that processes messages as they arrive /// Called by <see cref="SendingEmailConsumerPool.InitAsync"/>.
/// Called lazily on first use via EnsureInitializedAsync.
/// </summary> /// </summary>
public async Task InitAsync() public async Task InitAsync()
{ {
if (_lazyInit.IsValueCreated) if (_lazyInit.IsValueCreated)
_logger?.LogWarning("OutgoingEmailConsumer already initialized. InitAsync() called multiple times."); _logger?.LogWarning("SendingEmailConsumer already initialized. InitAsync() called multiple times.");
await _lazyInit.Value; await _lazyInit.Value;
} }

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using AutoMapper; using AutoMapper;
using DigitalData.MessagingService.Application.Common.Interfaces; using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Domain.Exceptions; using DigitalData.MessagingService.Domain.Exceptions;
using DigitalData.MessagingService.Infrastructure.Persistence; using DigitalData.MessagingService.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -13,28 +13,36 @@ namespace DigitalData.MessagingService.Infrastructure.Repositories;
/// </summary> /// </summary>
public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapper) : IRepository<TEntity> where TEntity : class public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapper) : IRepository<TEntity> where TEntity : class
{ {
private readonly DbSet<TEntity> _dbSet = Context.Set<TEntity>(); protected readonly DbSet<TEntity> DbSet = Context.Set<TEntity>();
// --- CREATE --- // --- CREATE ---
public async Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default) public async Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default)
{ {
var entity = Mapper.Map<TEntity>(dto); var entity = Mapper.Map<TEntity>(dto);
await _dbSet.AddAsync(entity, cancellationToken); await DbSet.AddAsync(entity, cancellationToken);
await Context.SaveChangesAsync(cancellationToken); await Context.SaveChangesAsync(cancellationToken);
return entity; return entity;
} }
public async Task<IEnumerable<TEntity>> CreateRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default)
{
var entities = Mapper.Map<IEnumerable<TEntity>>(dtos);
await DbSet.AddRangeAsync(entities, cancellationToken);
await Context.SaveChangesAsync(cancellationToken);
return entities;
}
// --- READ --- // --- READ ---
public async Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default) public async Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{ {
return await _dbSet.FindAsync([id], cancellationToken); return await DbSet.FindAsync([id], cancellationToken);
} }
public async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default) public async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
{ {
return await _dbSet.ToListAsync(cancellationToken); return await DbSet.ToListAsync(cancellationToken);
} }
public async Task<IEnumerable<TEntity>> FindAsync( public async Task<IEnumerable<TEntity>> FindAsync(
@@ -43,14 +51,14 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
int? take = null, int? take = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var query = _dbSet.Where(predicate); var query = DbSet.Where(predicate);
if (skip.HasValue) if (skip.HasValue)
query = query.Skip(skip.Value); query = query.Skip(skip.Value);
if (take.HasValue) if (take.HasValue)
query = query.Take(take.Value); query = query.Take(take.Value);
return await query.ToListAsync(cancellationToken); return await query.ToListAsync(cancellationToken);
} }
@@ -58,14 +66,14 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
Expression<Func<TEntity, bool>> predicate, Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
return await _dbSet.FirstOrDefaultAsync(predicate, cancellationToken); return await DbSet.FirstOrDefaultAsync(predicate, cancellationToken);
} }
public async Task<TEntity?> FindSingleAsync( public async Task<TEntity?> FindSingleAsync(
Expression<Func<TEntity, bool>> predicate, Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
return await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken); return await DbSet.SingleOrDefaultAsync(predicate, cancellationToken);
} }
public async Task<int> CountAsync( public async Task<int> CountAsync(
@@ -73,15 +81,68 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
return predicate == null return predicate == null
? await _dbSet.CountAsync(cancellationToken) ? await DbSet.CountAsync(cancellationToken)
: await _dbSet.CountAsync(predicate, cancellationToken); : await DbSet.CountAsync(predicate, cancellationToken);
} }
public async Task<bool> AnyAsync( public async Task<bool> AnyAsync(
Expression<Func<TEntity, bool>> predicate, Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
return await _dbSet.AnyAsync(predicate, cancellationToken); 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 --- // --- UPDATE ---
@@ -96,7 +157,7 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
TDto dto, TDto dto,
CancellationToken cancellationToken = default) 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."); ?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
Mapper.Map(dto, entity); Mapper.Map(dto, entity);
await Context.SaveChangesAsync(cancellationToken); await Context.SaveChangesAsync(cancellationToken);
@@ -112,13 +173,8 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
TDto dto, TDto dto,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken); var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
entities.ForEach(entity => Mapper.Map(dto, entity));
foreach (var entity in entities)
{
Mapper.Map(dto, entity);
}
await Context.SaveChangesAsync(cancellationToken); await Context.SaveChangesAsync(cancellationToken);
return entities.Count; return entities.Count;
} }
@@ -134,9 +190,9 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
Expression<Func<TEntity, bool>> predicate, Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default) 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."); ?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
_dbSet.Remove(entity); DbSet.Remove(entity);
await Context.SaveChangesAsync(cancellationToken); await Context.SaveChangesAsync(cancellationToken);
} }
@@ -149,9 +205,8 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
Expression<Func<TEntity, bool>> predicate, Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken); var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
DbSet.RemoveRange(entities);
_dbSet.RemoveRange(entities);
await Context.SaveChangesAsync(cancellationToken); await Context.SaveChangesAsync(cancellationToken);
return entities.Count; return entities.Count;
} }

View File

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

View File

@@ -0,0 +1,121 @@
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 DigitalData.MessagingService.Domain.Enums;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
public class EmailSyncWorker(IOptions<EmailAccountsOptions> Options, IServiceProvider Provider, ILogger<EmailSyncWorker> Logger, IMemoryCache Cache) : BackgroundService, IEmailSyncService
{
private static string ForcedSyncDateCacheKey => $"{nameof(EmailSyncWorker)}_TriggerSync";
private readonly string DefaultFolder = "INBOX";
/// <summary>
/// Signals the current <see cref="Task.Delay"/> to complete immediately,
/// causing the sync loop to start the next cycle without waiting.
/// A new TCS is created at the start of each delay so repeated triggers work correctly.
/// </summary>
private volatile TaskCompletionSource<bool> _syncTrigger = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>
/// Triggers an immediate sync cycle by completing the current delay early.
/// Safe to call from any thread or HTTP request at any time.
/// If a sync is already running, the trigger is ignored — the next cycle starts normally.
/// </summary>
public DateTime ForceTriggerSync()
{
return Cache.GetOrCreate(ForcedSyncDateCacheKey, e =>
{
e.SetAbsoluteExpiration(TimeSpan.FromSeconds(Options.Value.ForcedSyncIntervalSeconds));
_syncTrigger.TrySetResult(true);
return DateTime.UtcNow;
});
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await UpsertSeedEmailAccount(stoppingToken);
var interval = TimeSpan.FromSeconds(Options.Value.SyncIntervalSeconds);
while (!stoppingToken.IsCancellationRequested)
{
await using var scope = Provider.CreateAsyncScope();
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
var imapService = scope.ServiceProvider.GetRequiredService<IImapEmailService>();
var pop3Service = scope.ServiceProvider.GetRequiredService<IPop3EmailService>();
foreach (var account in await emailAccountRepo.GetAllAsync(stoppingToken))
{
await SyncAccountAsync(account, imapService, pop3Service, stoppingToken);
}
// Reset trigger before waiting so any TriggerSync() call during the delay is caught
_syncTrigger = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var delay = Task.Delay(interval, stoppingToken);
var triggered = _syncTrigger.Task;
await Task.WhenAny(delay, triggered).ConfigureAwait(false);
// Propagate cancellation if the host is stopping
stoppingToken.ThrowIfCancellationRequested();
}
}
private async Task SyncAccountAsync(
EmailAccount account,
IImapEmailService imapService,
IPop3EmailService pop3Service,
CancellationToken stoppingToken)
{
if (account.IncomingProtocol == IncomingProtocol.None)
return;
Logger.LogDebug(
"Email sync started. Account={Username}, Protocol={Protocol}.",
account.Username, account.IncomingProtocol);
try
{
var result = account.IncomingProtocol switch
{
IncomingProtocol.Imap or IncomingProtocol.ImapOAuth2
=> await imapService.SyncEmailsAsync(account, DefaultFolder, stoppingToken),
IncomingProtocol.Pop3 or IncomingProtocol.Pop3OAuth2
=> await pop3Service.SyncEmailsAsync(account, stoppingToken),
_ => throw new NotSupportedException(
$"IncomingProtocol '{account.IncomingProtocol}' is not supported by the sync worker.")
};
Logger.LogDebug(
"Email sync completed. Account={Username}, Protocol={Protocol}, Processed={Processed}, Failed={Failed}.",
account.Username, account.IncomingProtocol, result.ProcessedCount, result.FailedCount);
}
catch (Exception ex)
{
Logger.LogError(ex,
"Email sync failed. Account={Username}, Protocol={Protocol}.",
account.Username, account.IncomingProtocol);
}
}
private async Task UpsertSeedEmailAccount(CancellationToken stoppingToken)
{
await using var scope = Provider.CreateAsyncScope();
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
foreach (var account in Options.Value.Accounts)
await emailAccountRepo.UpsertAsync(a => a.Username == account.Username, account, stoppingToken);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,119 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Enums;
using Microsoft.Extensions.Logging;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Web;
namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// Implements the Google OAuth2 authorization code flow.
/// Generates consent URLs and exchanges authorization codes for refresh tokens.
/// This is a one-time setup operation per account — the resulting refresh token
/// is stored in <see cref="EmailAccount.OAuth2RefreshToken"/> and reused by
/// <see cref="GoogleOAuth2TokenService"/> for all subsequent token acquisitions.
/// </summary>
public class GoogleOAuth2AuthorizationService(
ILogger<GoogleOAuth2AuthorizationService> Logger,
IHttpClientFactory HttpClientFactory) : IOAuth2AuthorizationService
{
private const string AuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
private const string Scope = "https://mail.google.com/";
public string GetAuthorizationUrl(EmailAccount account, string redirectUri)
{
if (account.OAuth2Provider != OAuth2Provider.Google)
throw new InvalidOperationException(
$"GoogleOAuth2AuthorizationService cannot handle provider '{account.OAuth2Provider}'. Expected '{OAuth2Provider.Google}'.");
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId))
throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id}).");
var query = HttpUtility.ParseQueryString(string.Empty);
query["client_id"] = account.OAuth2ClientId;
query["redirect_uri"] = redirectUri;
query["response_type"] = "code";
query["scope"] = Scope;
query["access_type"] = "offline"; // ensures refresh_token is returned
query["prompt"] = "consent"; // forces refresh_token even if already authorized
query["state"] = account.Id.ToString();
var url = $"{AuthEndpoint}?{query}";
Logger.LogDebug("Generated Google OAuth2 authorization URL for account '{Username}' (Id: {Id}).",
account.Username, account.Id);
return url;
}
public async Task<string> ExchangeCodeForRefreshTokenAsync(
EmailAccount account,
string code,
string redirectUri,
CancellationToken cancellationToken = default)
{
if (account.OAuth2Provider != OAuth2Provider.Google)
throw new InvalidOperationException(
$"GoogleOAuth2AuthorizationService cannot handle provider '{account.OAuth2Provider}'. Expected '{OAuth2Provider.Google}'.");
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId))
throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id}).");
if (string.IsNullOrWhiteSpace(account.OAuth2ClientSecret))
throw new InvalidOperationException($"OAuth2ClientSecret is not configured for account '{account.Username}' (Id: {account.Id}).");
Logger.LogDebug("Exchanging authorization code for refresh token. Account='{Username}' (Id: {Id}).",
account.Username, account.Id);
var httpClient = HttpClientFactory.CreateClient(nameof(GoogleOAuth2AuthorizationService));
var requestBody = new FormUrlEncodedContent([
new("client_id", account.OAuth2ClientId),
new("client_secret", account.OAuth2ClientSecret),
new("code", code),
new("redirect_uri", redirectUri),
new("grant_type", "authorization_code"),
]);
var response = await httpClient.PostAsync(TokenEndpoint, requestBody, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException(
$"Google token endpoint returned {(int)response.StatusCode} while exchanging authorization code " +
$"for account '{account.Username}'. Response: {responseBody}");
var tokenResponse = await response.Content.ReadFromJsonAsync<GoogleTokenResponse>(cancellationToken: cancellationToken)
?? throw new InvalidOperationException($"Failed to deserialize Google token response for account '{account.Username}'.");
if (string.IsNullOrWhiteSpace(tokenResponse.RefreshToken))
throw new InvalidOperationException(
$"Google did not return a refresh_token for account '{account.Username}'. " +
"Ensure 'access_type=offline' and 'prompt=consent' are set in the authorization URL, " +
"and that the user has not previously authorized this app without revoking access.");
Logger.LogInformation("Successfully obtained Google refresh token for account '{Username}' (Id: {Id}).",
account.Username, account.Id);
return tokenResponse.RefreshToken;
}
private sealed class GoogleTokenResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; init; } = string.Empty;
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; init; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; init; }
[JsonPropertyName("token_type")]
public string TokenType { get; init; } = string.Empty;
}
}

View File

@@ -0,0 +1,105 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Enums;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// Acquires OAuth2 access tokens for Google accounts (Gmail / Google Workspace)
/// using the OAuth2 client credentials flow against <c>https://oauth2.googleapis.com/token</c>.
/// Tokens are cached in-memory and reused until 5 minutes before expiry.
///
/// <para><b>Required Google Cloud configuration:</b></para>
/// <list type="bullet">
/// <item>Create a project in <see href="https://console.cloud.google.com/"/>.</item>
/// <item>Enable the <b>Gmail API</b>.</item>
/// <item>Create an <b>OAuth 2.0 Client ID</b> (type: Web application or Desktop).</item>
/// <item>Set <c>OAuth2ClientId</c> and <c>OAuth2ClientSecret</c> in configuration.</item>
/// <item>Leave <c>OAuth2TenantId</c> empty — Google does not use tenant IDs.</item>
/// </list>
///
/// <para>
/// Note: Google's OAuth2 for IMAP/SMTP requires user-level access (Delegated),
/// not application-level (Client Credentials). A valid <b>refresh token</b> must
/// be stored in <c>OAuth2ClientSecret</c> after the initial user authorization flow.
/// The token endpoint is used here to exchange the refresh token for an access token.
/// </para>
/// </summary>
public class GoogleOAuth2TokenService(
ILogger<GoogleOAuth2TokenService> Logger,
IHttpClientFactory HttpClientFactory) : IOAuth2TokenService
{
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
private readonly ConcurrentDictionary<int, (string Token, DateTimeOffset Expiry)> _cache = new();
public async Task<string> GetAccessTokenAsync(EmailAccount account, CancellationToken cancellationToken = default)
{
if (account.OAuth2Provider != OAuth2Provider.Google)
throw new InvalidOperationException(
$"GoogleOAuth2TokenService cannot handle provider '{account.OAuth2Provider}' " +
$"for account '{account.Username}'. Expected '{OAuth2Provider.Google}'.");
if (_cache.TryGetValue(account.Id, out var cached) && cached.Expiry > DateTimeOffset.UtcNow.AddMinutes(5))
{
Logger.LogDebug("Returning cached Google OAuth2 token for account {Username} (Id: {Id}).", account.Username, account.Id);
return cached.Token;
}
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId))
throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id}).");
if (string.IsNullOrWhiteSpace(account.OAuth2ClientSecret))
throw new InvalidOperationException(
$"OAuth2ClientSecret is not configured for account '{account.Username}' (Id: {account.Id}).");
if (string.IsNullOrWhiteSpace(account.OAuth2RefreshToken))
throw new InvalidOperationException(
$"OAuth2RefreshToken is not configured for account '{account.Username}' (Id: {account.Id}). " +
"Obtain a refresh token via https://developers.google.com/oauthplayground " +
"using scope 'https://mail.google.com/' and set it in OAuth2RefreshToken.");
Logger.LogDebug("Acquiring new Google OAuth2 token for account {Username} (Id: {Id}).", account.Username, account.Id);
var httpClient = HttpClientFactory.CreateClient(nameof(GoogleOAuth2TokenService));
var requestBody = new FormUrlEncodedContent([
new("client_id", account.OAuth2ClientId),
new("client_secret", account.OAuth2ClientSecret),
new("grant_type", "refresh_token"),
new("refresh_token", account.OAuth2RefreshToken),
]);
var response = await httpClient.PostAsync(TokenEndpoint, requestBody, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException(
$"Google token endpoint returned {(int)response.StatusCode} for account '{account.Username}'. Response: {responseBody}");
var tokenResponse = await response.Content.ReadFromJsonAsync<GoogleTokenResponse>(cancellationToken: cancellationToken)
?? throw new InvalidOperationException($"Failed to deserialize Google token response for account '{account.Username}'.");
var expiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn);
_cache[account.Id] = (tokenResponse.AccessToken, expiry);
return tokenResponse.AccessToken;
}
private sealed class GoogleTokenResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; init; } = string.Empty;
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; init; }
[JsonPropertyName("token_type")]
public string TokenType { get; init; } = string.Empty;
}
}

View File

@@ -1,111 +1,142 @@
using System.Text; using System.Text;
using DigitalData.MessagingService.Application.Common.Dtos;
using DigitalData.MessagingService.Application.Common.Interfaces; using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Domain.Exceptions; using DigitalData.MessagingService.Domain.Exceptions;
using Limilabs.Client.SMTP; using Limilabs.Client.SMTP;
using Limilabs.Mail; using Limilabs.Mail;
using Limilabs.Mail.Headers; using Limilabs.Mail.Headers;
using Microsoft.Extensions.Options; using DigitalData.MessagingService.Infrastructure.Services.Extensions;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Infrastructure.Services; namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary> /// <summary>
/// Email service using Limilabs Mail.dll for SMTP operations (send-only). /// Email service using Limilabs Mail.dll for SMTP operations (send-only).
/// Commercial-grade library with superior Exchange support. /// Supports both plain/STARTTLS and OAuth2 (XOAUTH2) authentication.
/// SMTP configuration is injected via IOptions&lt;EmailAccountDto&gt; from appsettings.json.
/// </summary> /// </summary>
public class LimilabsEmailService( public class LimilabsEmailService(IOAuth2TokenService oauth2TokenService) : IEmailService
IEncryptionService encryptionService,
IOptions<EmailAccountDto> smtpConfig) : IEmailService
{ {
private readonly EmailAccountDto _smtpAccount = smtpConfig.Value;
// Register encoding provider for Limilabs (requires windows-1252 and other code pages)
static LimilabsEmailService() static LimilabsEmailService()
{ {
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
} }
public async Task SendEmailAsync(string to, string subject, string body, bool isHtml = true, CancellationToken cancellationToken = default) public async Task SendEmailAsync(EmailContext context, CancellationToken cancellationToken = default)
{ {
using var smtp = new Smtp(); using var smtp = new Smtp();
ISendMessageResult? result = null;
try try
{ {
await ConnectAndAuthenticateSmtpAsync(smtp); await ConnectAndAuthenticateSmtpAsync(smtp, context.Sender, cancellationToken);
var builder = new MailBuilder(); var builder = new MailBuilder();
builder.From.Add(new MailBox(_smtpAccount.Username)); builder.From.Add(new MailBox(context.Sender.Username));
builder.To.Add(new MailBox(to));
builder.Subject = subject;
if (isHtml) foreach (var recipient in context.Recipients)
{ builder.To.Add(new MailBox(recipient));
builder.Html = body;
} builder.Subject = context.Subject;
if (context.IsHtml)
builder.Html = context.Body;
else else
{ builder.Text = context.Body;
builder.Text = body;
} AddAttachments(builder, context.Attachments);
var mail = builder.Create(); var mail = builder.Create();
var result = smtp.SendMessage(mail); result = await smtp.SendMessageAsync(mail, cancellationToken);
if (result.Status != SendMessageStatus.Success) if (result.Status != SendMessageStatus.Success)
{ {
throw new InvalidOperationException($"Failed to send email. Status: {result.Status}"); throw new InvalidOperationException($"Failed to send email. Status: {result.Status}. {ErrorMessageBuilder(result)}");
} }
smtp.Close(); await smtp.CloseAsync(cancellationToken);
await Task.CompletedTask; // For async consistency
} }
catch (Limilabs.Client.ServerException ex) catch (Limilabs.Client.ServerException ex)
{ {
DisconnectSafely(smtp); await smtp.CloseSafelyAsync();
throw new AuthenticationFailedException("SMTP authentication failed. Check credentials or OAuth2 configuration.", ex); throw new AuthenticationFailedException($"SMTP authentication failed. {ErrorMessageBuilder(result)}", ex);
} }
catch (Exception ex) catch (Exception ex) when (ex is not AuthenticationFailedException && ex is not InvalidOperationException)
{ {
DisconnectSafely(smtp); await smtp.CloseSafelyAsync();
throw new InvalidOperationException("Failed to send email via SMTP server.", ex); throw new InvalidOperationException($"Failed to send email via SMTP server. {ErrorMessageBuilder(result)}", ex);
}
catch
{
await smtp.CloseSafelyAsync();
throw;
} }
} }
// --- Private Helper Methods --- internal async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccount smtpAccount, CancellationToken cancellationToken = default)
private async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp)
{ {
if (_smtpAccount.SmtpUseSsl) if (smtpAccount.SmtpUseSsl)
{ {
smtp.ConnectSSL(_smtpAccount.SmtpServer, _smtpAccount.SmtpPort); await smtp.ConnectSSLAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort, cancellationToken);
} }
else else
{ {
smtp.Connect(_smtpAccount.SmtpServer, _smtpAccount.SmtpPort); await smtp.ConnectAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort, cancellationToken);
} }
if (_smtpAccount.UseOAuth2) if (smtpAccount.UseOAuth2)
{ {
throw new NotSupportedException("OAuth2 is not configured for this SMTP account. UseOAuth2 must be false."); var token = await oauth2TokenService.GetAccessTokenAsync(smtpAccount, cancellationToken);
await smtp.LoginOAUTH2Async(smtpAccount.Username, token, cancellationToken);
} }
else else
{ {
var password = _smtpAccount.PasswordEncrypted ? encryptionService.Decrypt(_smtpAccount.Password) : _smtpAccount.Password; await smtp.LoginAsync(smtpAccount.Username, smtpAccount.Password, cancellationToken);
smtp.Login(_smtpAccount.Username, password);
} }
await Task.CompletedTask; // For async consistency
} }
private static void DisconnectSafely(Smtp smtp) internal static string ErrorMessageBuilder(ISendMessageResult? result = null)
{ {
try if(result is null || result.GeneralErrors.Count == 0)
return string.Empty;
else if(result.GeneralErrors.Count == 1)
return $"Error: {result.GeneralErrors.FirstOrDefault()}";
var message = new StringBuilder("Errors:\n");
foreach (var error in result.GeneralErrors)
{ {
if (smtp.Connected) message.AppendLine($" • {error}");
smtp.Close();
} }
catch { /* Ignore disconnect errors */ }
return message.ToString();
} }
}
internal static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentDto> attachments)
{
foreach (var attachment in attachments)
{
if (attachment.IsInline)
{
var visual = builder.AddVisual(attachment.Content);
visual.FileName = attachment.FileName;
visual.ContentId = string.IsNullOrWhiteSpace(attachment.ContentId)
? attachment.FileName
: attachment.ContentId;
if (!string.IsNullOrWhiteSpace(attachment.ContentType))
visual.ContentType = ContentType.Parse(attachment.ContentType);
}
else
{
var part = builder.AddAttachment(attachment.Content);
part.FileName = attachment.FileName;
if (!string.IsNullOrWhiteSpace(attachment.ContentType))
part.ContentType = ContentType.Parse(attachment.ContentType);
if (!string.IsNullOrWhiteSpace(attachment.ContentId))
part.ContentId = attachment.ContentId;
}
}
}
}

View File

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

View File

@@ -0,0 +1,166 @@
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Domain.Entities;
using Limilabs.Client.POP3;
using Limilabs.Mail;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Text;
namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// POP3 email service using Limilabs Mail.dll.
/// Opens a fresh connection per call — stateless and thread-safe.
/// Supports both password and OAuth2 (XOAUTH2) authentication.
/// </summary>
public class LimilabsPop3EmailService(
ILogger<LimilabsPop3EmailService> Logger,
IRepository<ReceivedEmail> Repository,
IOAuth2TokenService oauth2TokenService) : IPop3EmailService
{
private const string Pop3Folder = "INBOX";
static LimilabsPop3EmailService()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
public async Task<EmailSyncResult> SyncEmailsAsync(EmailAccount account, CancellationToken cancellationToken = default)
{
using var pop3 = await OpenAsync(account, cancellationToken);
try
{
// POP3 uses string UIDs (UIDL command)
var uidMap = await pop3.GetUIDAsync(cancellationToken);
if (uidMap.Count == 0)
{
await pop3.CloseAsync(false, cancellationToken);
return new EmailSyncResult();
}
var emails = new List<ReceivedEmailDto>(uidMap.Count);
var failedCount = 0;
foreach (var kvp in uidMap)
{
// kvp.Key = message number (long), kvp.Value = POP3 UID (string)
var msgNumber = kvp.Key;
var pop3Uid = kvp.Value;
// Use a stable numeric hash of the string UID for storage (ReceivedEmail.Uid is long)
var numericUid = (long)Math.Abs((uint)pop3Uid.GetHashCode());
if (await Repository.AnyAsync(x => x.Uid == numericUid && x.AccountId == account.Id, cancellationToken))
continue;
cancellationToken.ThrowIfCancellationRequested();
try
{
var eml = await pop3.GetMessageByNumberAsync(msgNumber, cancellationToken);
var mail = new MailBuilder().CreateFromEml(eml);
var attachments = new List<EmailAttachmentDto>();
foreach (var att in mail.Attachments)
{
attachments.Add(new EmailAttachmentDto
{
FileName = att.FileName ?? "attachment",
Content = att.Data,
ContentType = att.ContentType?.ToString() ?? "application/octet-stream",
IsInline = false,
ContentId = att.ContentId
});
}
foreach (var vis in mail.Visuals)
{
attachments.Add(new EmailAttachmentDto
{
FileName = vis.FileName ?? "inline",
Content = vis.Data,
ContentType = vis.ContentType?.ToString() ?? "application/octet-stream",
IsInline = true,
ContentId = vis.ContentId
});
}
var email = new ReceivedEmailDto
{
Uid = numericUid,
AccountId = account.Id,
From = mail.From.FirstOrDefault()?.Address ?? string.Empty,
To = [.. mail.To.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
Cc = [.. mail.Cc.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
Subject = mail.Subject ?? string.Empty,
TextBody = mail.Text ?? string.Empty,
HtmlBody = mail.Html ?? string.Empty,
Date = mail.Date ?? DateTime.MinValue,
IsSeen = false, // POP3 has no seen/unseen flags
Attachments = attachments,
Folder = Pop3Folder
};
emails.Add(email);
SetLastPop3SyncDate(account.Id, DateTime.UtcNow);
}
catch (Exception ex)
{
failedCount += 1;
Logger.LogWarning(ex,
"Failed to fetch POP3 message number={Number} for account {Username}. Skipping.",
msgNumber, account.Username);
}
}
// Close without deleting messages (leaveOnServer = false means do not delete = leave on server)
await pop3.CloseAsync(false, cancellationToken);
await Repository.CreateRangeAsync(emails, cancellationToken);
return new EmailSyncResult(ProcessedCount: emails.Count, FailedCount: failedCount);
}
catch
{
try { await pop3.CloseAsync(false, cancellationToken); } catch { /* ignore */ }
throw;
}
}
private async Task<Pop3> OpenAsync(EmailAccount account, CancellationToken cancellationToken)
{
var pop3 = new Pop3();
if (account.Pop3UseSsl)
await pop3.ConnectSSLAsync(account.Pop3Server!, cancellationToken);
else
await pop3.ConnectAsync(account.Pop3Server!, cancellationToken);
if (account.UseOAuth2)
{
var token = await oauth2TokenService.GetAccessTokenAsync(account, cancellationToken);
await pop3.LoginOAUTH2Async(account.Username, token, cancellationToken);
}
else
{
await pop3.LoginAsync(account.Username, account.Password, cancellationToken);
}
return pop3;
}
#region POP3 Last Sync Date Cache
private readonly ConcurrentDictionary<int, DateTime> _cache = new();
public DateTime? GetLastPop3SyncDate(int accountId)
=> _cache.GetValueOrDefault(accountId);
private void SetLastPop3SyncDate(int accountId, DateTime date)
=> _cache[accountId] = date;
#endregion
}

View File

@@ -0,0 +1,94 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Enums;
using Microsoft.Identity.Client;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// Acquires OAuth2 access tokens using Microsoft Identity (MSAL) with the client credentials flow.
/// Supports Microsoft 365 / Exchange Online accounts (IMAP, POP3, SMTP via XOAUTH2).
/// Tokens are cached in-memory and reused until 5 minutes before expiry.
///
/// <para><b>Required Azure App Registration permissions (Application, not Delegated):</b></para>
/// <list type="bullet">
/// <item><c>IMAP.AccessAsApp</c> — read mail via IMAP</item>
/// <item><c>SMTP.SendAsApp</c> — send mail via SMTP</item>
/// <item><c>POP.AccessAsApp</c> — read mail via POP3 (optional)</item>
/// </list>
///
/// <para>
/// The single scope <c>https://outlook.office365.com/.default</c> is used intentionally.
/// The <c>.default</c> suffix instructs Azure AD to issue a token covering <em>all</em>
/// Application permissions that have been pre-consented in the App Registration,
/// so there is no need to list individual scopes here.
/// </para>
///
/// <para>
/// <c>OAuth2TenantId</c> accepts either a tenant GUID, a domain name
/// (e.g. <c>didaloghe</c> or <c>didaloghe.onmicrosoft.com</c>), or <c>"common"</c>.
/// </para>
/// </summary>
public class MicrosoftOAuth2TokenService(ILogger<MicrosoftOAuth2TokenService> Logger) : IOAuth2TokenService
{
/// <summary>
/// <c>.default</c> requests all Application permissions pre-consented in Azure Portal.
/// This covers IMAP.AccessAsApp, SMTP.SendAsApp and POP.AccessAsApp in one token.
/// </summary>
private static readonly string[] Scopes = ["https://outlook.office365.com/.default"];
private readonly ConcurrentDictionary<int, (string Token, DateTimeOffset Expiry)> _cache = new();
public async Task<string> GetAccessTokenAsync(EmailAccount account, CancellationToken cancellationToken = default)
{
if (account.OAuth2Provider != OAuth2Provider.Microsoft)
throw new InvalidOperationException(
$"MicrosoftOAuth2TokenService cannot handle provider '{account.OAuth2Provider}' " +
$"for account '{account.Username}'. Expected '{OAuth2Provider.Microsoft}'.");
if (_cache.TryGetValue(account.Id, out var cached) && cached.Expiry > DateTimeOffset.UtcNow.AddMinutes(5))
{
Logger.LogDebug("Returning cached OAuth2 token for account {Username} (Id: {Id}).", account.Username, account.Id);
return cached.Token;
}
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId))
throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id}).");
if (string.IsNullOrWhiteSpace(account.OAuth2ClientSecret))
throw new InvalidOperationException($"OAuth2ClientSecret is not configured for account '{account.Username}' (Id: {account.Id}).");
var tenantId = string.IsNullOrWhiteSpace(account.OAuth2TenantId) ? "common" : account.OAuth2TenantId;
// Azure AD accepts: a tenant GUID, the full domain (e.g. "contoso.onmicrosoft.com"
// or a verified custom domain), "common", or "organizations".
// Short names like "contoso" without a TLD are NOT valid and will cause AADSTS900023.
if (!tenantId.Equals("common", StringComparison.OrdinalIgnoreCase) &&
!tenantId.Equals("organizations", StringComparison.OrdinalIgnoreCase) &&
!Guid.TryParse(tenantId, out _) &&
!tenantId.Contains('.'))
{
throw new InvalidOperationException(
$"OAuth2TenantId '{tenantId}' for account '{account.Username}' is not a valid Azure AD tenant identifier. " +
$"Use the full domain (e.g. '{tenantId}.onmicrosoft.com'), a tenant GUID, or 'common'.");
}
var app = ConfidentialClientApplicationBuilder
.Create(account.OAuth2ClientId)
.WithClientSecret(account.OAuth2ClientSecret)
.WithAuthority($"https://login.microsoftonline.com/{tenantId}")
.Build();
Logger.LogDebug("Acquiring new OAuth2 token for account {Username} (Id: {Id}) from tenant {Tenant}.",
account.Username, account.Id, tenantId);
var result = await app.AcquireTokenForClient(Scopes)
.ExecuteAsync(cancellationToken);
_cache[account.Id] = (result.AccessToken, result.ExpiresOn);
return result.AccessToken;
}
}

View File

@@ -0,0 +1,34 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Enums;
using Microsoft.Extensions.DependencyInjection;
namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// Routes OAuth2 token requests to the correct provider-specific implementation
/// based on <see cref="EmailAccount.OAuth2Provider"/>.
/// Registered as the single <see cref="IOAuth2TokenService"/> in DI — all other
/// services depend on this dispatcher rather than on a concrete provider directly.
/// </summary>
public class OAuth2TokenServiceDispatcher(IServiceProvider ServiceProvider) : IOAuth2TokenService
{
public Task<string> GetAccessTokenAsync(EmailAccount account, CancellationToken cancellationToken = default)
{
var service = account.OAuth2Provider switch
{
OAuth2Provider.Microsoft => ServiceProvider.GetRequiredService<MicrosoftOAuth2TokenService>(),
OAuth2Provider.Google => (IOAuth2TokenService)ServiceProvider.GetRequiredService<GoogleOAuth2TokenService>(),
OAuth2Provider.None => throw new InvalidOperationException(
$"Account '{account.Username}' (Id: {account.Id}) has OAuth2Provider = None. " +
"Set UseOAuth2 = false or configure a valid OAuth2Provider."),
_ => throw new NotSupportedException(
$"OAuth2Provider '{account.OAuth2Provider}' is not supported. " +
$"Supported providers: {string.Join(", ", Enum.GetNames<OAuth2Provider>())}")
};
return service.GetAccessTokenAsync(account, cancellationToken);
}
}

View File

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

View File

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

View File

@@ -4,23 +4,24 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using RabbitMQ.Client; using RabbitMQ.Client;
using DigitalData.MessagingService.RabbitMQ; using DigitalData.MessagingService.RabbitMQ;
using DigitalData.MessagingService.Publisher.Abstraction; using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Interfaces;
namespace DigitalData.MessagingService.Infrastructure.Queue; namespace DigitalData.MessagingService.Publisher;
/// <summary> /// <summary>
/// RabbitMQ-based email queue implementation for outgoing emails. /// RabbitMQ-based email queue implementation for outgoing emails.
/// Provides message persistence, scalability, and reliability. /// Provides message persistence, scalability, and reliability.
/// Uses Lazy<T> initialization pattern to avoid blocking constructor. /// Uses Lazy<T> initialization pattern to avoid blocking constructor.
/// </summary> /// </summary>
public sealed class OutgoingEmailPublisher : IOutgoingEmailPublisher, IAsyncDisposable public sealed class SendingEmailPublisher : ISendingEmailPublisher, IAsyncDisposable
{ {
private readonly RabbitMqConfiguration _config; private readonly RabbitMqConfiguration _config;
private readonly ILogger<OutgoingEmailPublisher> _logger; private readonly ILogger<SendingEmailPublisher> _logger;
private readonly RabbitMqConnectionFactory _cnnFactory; private readonly RabbitMqConnectionFactory _cnnFactory;
private readonly Lazy<Task<IChannel>> _lazyChannel; private readonly Lazy<Task<IChannel>> _lazyChannel;
public OutgoingEmailPublisher(IOptions<RabbitMqConfiguration> config, ILogger<OutgoingEmailPublisher> logger, RabbitMqConnectionFactory cnnFactory) public SendingEmailPublisher(IOptions<RabbitMqConfiguration> config, ILogger<SendingEmailPublisher> logger, RabbitMqConnectionFactory cnnFactory)
{ {
_config = config.Value; _config = config.Value;
_logger = logger; _logger = logger;
@@ -66,9 +67,9 @@ public sealed class OutgoingEmailPublisher : IOutgoingEmailPublisher, IAsyncDisp
return channel; return channel;
} }
public async Task EnqueueAsync(OutgoingEmailEvent outgoingEmailEvent, CancellationToken cancellationToken = default) public async Task EnqueueAsync(SendingEmailEvent sendingEmailEvent, CancellationToken cancellationToken = default)
{ {
var json = JsonSerializer.Serialize(outgoingEmailEvent); var json = JsonSerializer.Serialize(sendingEmailEvent);
var body = Encoding.UTF8.GetBytes(json); var body = Encoding.UTF8.GetBytes(json);
var properties = new BasicProperties var properties = new BasicProperties

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System;
namespace DigitalData.MessagingService.RabbitMQ namespace DigitalData.MessagingService.RabbitMQ
{ {
@@ -8,14 +9,31 @@ namespace DigitalData.MessagingService.RabbitMQ
/// </summary> /// </summary>
public static class DependencyInjection public static class DependencyInjection
{ {
private static IServiceCollection AddDefaultServices(this IServiceCollection services)
{
services.AddSingleton<RabbitMqConnectionFactory>();
return services;
}
/// <summary> /// <summary>
/// Adds Infrastructure layer services to the DI container /// Adds Infrastructure layer services to the DI container
/// </summary> /// </summary>
public static IServiceCollection AddRabbitMqConnectionFactory( public static IServiceCollection AddRabbitMqConnectionFactory(this IServiceCollection services, Action<RabbitMqConfiguration> configure)
this IServiceCollection services,
IConfiguration configuration)
{ {
services.AddSingleton<RabbitMqConnectionFactory>(); services.AddDefaultServices();
// --- RabbitMQ Configuration ---
services.Configure(configure);
return services;
}
/// <summary>
/// Adds Infrastructure layer services to the DI container
/// </summary>
public static IServiceCollection AddRabbitMqConnectionFactory(this IServiceCollection services, IConfiguration configuration)
{
services.AddDefaultServices();
// --- RabbitMQ Configuration --- // --- RabbitMQ Configuration ---
services.Configure<RabbitMqConfiguration>( services.Configure<RabbitMqConfiguration>(
@@ -24,5 +42,4 @@ namespace DigitalData.MessagingService.RabbitMQ
return services; return services;
} }
} }
} }

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net462;net8.0</TargetFrameworks> <TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

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

View File

@@ -0,0 +1,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,241 @@
using DigitalData.MessagingService.Application.EmailSending.Commands;
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using DigitalData.MessagingService.Application.Common.Dto;
namespace DigitalData.MessagingService.API.Controllers;
/// <summary>
/// Email sending API controller.
/// Enqueues outgoing emails to RabbitMQ for async processing.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class EmailController(IMediator mediator) : ControllerBase
{
/// <summary>
///
/// </summary>
public enum OnlyFilter
{
/// <summary>
///
/// </summary>
HtmlBody,
/// <summary>
///
/// </summary>
Uid,
}
#region Send
/// <summary>
/// Send an email, optionally with file attachments.
/// Omit the <c>attachments</c> field for a plain send.
/// </summary>
/// <param name="command">Email fields as form values</param>
/// <param name="attachments">Optional uploaded files</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>HTTP 202 Accepted with the queued event ID</returns>
[HttpPost]
[Consumes("multipart/form-data")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SendEmail(
[FromForm] PublishEmailCommand command,
IFormFileCollection? attachments,
CancellationToken cancellationToken)
{
var commandWithAttachments = command.WithAttachments(
await BuildAttachmentsAsync(attachments, cancellationToken));
var eventId = await mediator.Send(commandWithAttachments, cancellationToken);
return Accepted(new { Id = eventId });
}
private static async Task<IEnumerable<EmailAttachmentDto>> BuildAttachmentsAsync(
IFormFileCollection? files,
CancellationToken cancellationToken)
{
if (files is null || files.Count == 0)
return [];
var result = new List<EmailAttachmentDto>(files.Count);
foreach (var file in files)
{
using var ms = new MemoryStream();
await file.CopyToAsync(ms, cancellationToken);
result.Add(new EmailAttachmentDto
{
FileName = file.FileName,
Content = ms.ToArray(),
ContentType = file.ContentType
});
}
return result;
}
#endregion Send
#region Receive
/// <summary>
/// Fetch emails from an IMAP mailbox.
/// </summary>
/// <param name="query">Query parameters for filtering and fetching emails from the IMAP mailbox.</param>
/// <param name="only"></param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>HTTP 200 with list of received emails.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> FetchEmails([FromQuery] ReadEmailQuery query, [FromQuery] OnlyFilter? only = null, CancellationToken cancellationToken = default)
{
var res = await mediator.Send(query, cancellationToken);
if(!res.Emails.Any())
return NotFound("No emails found matching the specified criteria.");
if (only == OnlyFilter.HtmlBody)
{
if (res.Emails.FirstOrDefault()?.HtmlBody is string htmlBody)
return Content(htmlBody, "text/html");
else
return NotFound();
}
else if (only == OnlyFilter.Uid)
return Ok(res.Emails.Select(e => e.Uid).ToList());
else
return Ok(res);
}
#endregion Receive
#region IMAP Send
/// <summary>
/// Send an email using IMAP account credentials (via RabbitMQ queue).
/// After the message is sent, it is appended to the IMAP Sent Items folder.
/// </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("imap/send")]
[Consumes("multipart/form-data")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SendEmailViaImap(
[FromForm] PublishEmailViaImapCommand 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 });
}
#endregion IMAP Send
#region POP3 Receive
/// <summary>
/// Fetch emails from a POP3 mailbox.
/// Triggers an on-demand POP3 sync before returning results.
/// </summary>
/// <param name="query">Query parameters for filtering and fetching emails from the POP3 mailbox.</param>
/// <param name="only">Optional filter to return only specific fields.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>HTTP 200 with list of received emails.</returns>
[HttpGet("pop3")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> FetchEmailsViaPop3(
[FromQuery] ReadEmailViaPop3Query query,
[FromQuery] OnlyFilter? only = null,
CancellationToken cancellationToken = default)
{
var res = await mediator.Send(query, cancellationToken);
if (!res.Emails.Any())
return NotFound("No emails found matching the specified criteria.");
if (only == OnlyFilter.HtmlBody)
{
if (res.Emails.FirstOrDefault()?.HtmlBody is string htmlBody)
return Content(htmlBody, "text/html");
else
return NotFound();
}
else if (only == OnlyFilter.Uid)
return Ok(res.Emails.Select(e => e.Uid).ToList());
else
return Ok(res);
}
#endregion POP3 Receive
#region OAuth2 Send
/// <summary>
/// Send an email via SMTP using OAuth2 authentication (via RabbitMQ queue).
/// The account must have <c>UseOAuth2 = true</c> and valid OAuth2 credentials configured.
/// </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("oauth2/send")]
[Consumes("multipart/form-data")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SendEmailViaOAuth2(
[FromForm] PublishEmailViaOAuth2Command 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 });
}
#endregion OAuth2 Send
#region OAuth2 Receive
/// <summary>
/// Fetch emails from an IMAP mailbox using OAuth2 authentication.
/// The account must have <c>UseOAuth2 = true</c> and valid OAuth2 credentials configured.
/// </summary>
/// <param name="query">Query parameters for filtering and fetching emails.</param>
/// <param name="only">Optional filter to return only specific fields.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>HTTP 200 with list of received emails.</returns>
[HttpGet("oauth2/imap")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> FetchEmailsViaOAuth2(
[FromQuery] ReadEmailViaOAuth2Query query,
[FromQuery] OnlyFilter? only = null,
CancellationToken cancellationToken = default)
{
var res = await mediator.Send(query, cancellationToken);
if (!res.Emails.Any())
return NotFound("No emails found matching the specified criteria.");
if (only == OnlyFilter.HtmlBody)
{
if (res.Emails.FirstOrDefault()?.HtmlBody is string htmlBody)
return Content(htmlBody, "text/html");
else
return NotFound();
}
else if (only == OnlyFilter.Uid)
return Ok(res.Emails.Select(e => e.Uid).ToList());
else
return Ok(res);
}
#endregion OAuth2 Receive
}

View File

@@ -1,36 +0,0 @@
using DigitalData.MessagingService.Application.EmailSending.Commands;
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 email (enqueue to RabbitMQ for background processing)
/// </summary>
/// <param name="command">Send email command</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>HTTP 202 Accepted (queued for processing)</returns>
[HttpPost("send")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SendEmail([FromBody] SendEmailCommand command, CancellationToken cancellationToken)
{
var outgoingEmailEvent = await mediator.Send(command, cancellationToken);
return Accepted(new
{
CommandId = outgoingEmailEvent.Id,
To = outgoingEmailEvent.Recipient,
outgoingEmailEvent.Subject,
outgoingEmailEvent.QueuedAt
});
}
}

View File

@@ -0,0 +1,95 @@
using DigitalData.MessagingService.Application.OAuth2.Commands;
using DigitalData.MessagingService.Application.OAuth2.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DigitalData.MessagingService.API.Controllers;
/// <summary>
/// Manages the OAuth2 authorization code flow for email accounts.
/// Use these endpoints to authorize Google accounts without manually
/// obtaining refresh tokens via external tools.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class OAuth2Controller(IMediator mediator, IHttpContextAccessor httpContextAccessor) : ControllerBase
{
#region Google Authorization Flow
/// <summary>
/// Step 1: Redirects the user to Google's consent screen for the specified email account.
/// After consent, Google redirects to <c>/api/oauth2/google/callback</c> with an authorization code.
/// </summary>
/// <param name="username">the email account to authorize.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>HTTP 302 redirect to Google consent screen.</returns>
[HttpGet("google/authorize/{username}")]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AuthorizeGoogle([FromRoute] string username, CancellationToken cancellationToken)
{
var redirectUri = BuildCallbackUri();
var authUrl = await mediator.Send(new GetOAuth2AuthorizationUrlQuery
{
Username = username,
RedirectUri = redirectUri
}, cancellationToken);
return Redirect(authUrl);
}
/// <summary>
/// Step 2: Google callback endpoint. Exchanges the authorization code for a refresh token
/// and saves it to the email account. This endpoint is called automatically by Google
/// after the user grants consent — do not call it directly.
/// </summary>
/// <param name="code">Authorization code provided by Google.</param>
/// <param name="state">Account ID passed as state in the authorization request.</param>
/// <param name="error">Error message if the user denied access.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>HTTP 200 on success, HTTP 400 if access was denied.</returns>
[HttpGet("google/callback")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> GoogleCallback(
[FromQuery] string? code,
[FromQuery] string? state,
[FromQuery] string? error,
CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(error))
return BadRequest(new { Error = error, Message = "User denied access or an error occurred during Google OAuth2 authorization." });
if (string.IsNullOrWhiteSpace(code))
return BadRequest(new { Error = "missing_code", Message = "Authorization code not received from Google." });
if (!int.TryParse(state, out var accountId))
return BadRequest(new { Error = "invalid_state", Message = "Invalid state parameter — could not determine account ID." });
var redirectUri = BuildCallbackUri();
var result = await mediator.Send(new CompleteOAuth2AuthorizationCommand
{
AccountId = accountId,
Code = code,
RedirectUri = redirectUri
}, cancellationToken);
return Ok(new
{
result.Success,
result.Username,
Message = $"Google OAuth2 authorization completed. Refresh token saved for account '{result.Username}'."
});
}
#endregion
private string BuildCallbackUri()
{
var request = httpContextAccessor.HttpContext!.Request;
return $"{request.Scheme}://{request.Host}/api/oauth2/google/callback";
}
}

View File

@@ -0,0 +1,27 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace DigitalData.MessagingService.API.Controllers;
/// <summary>
/// Controls email synchronization operations.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class SyncController(IEmailSyncService emailSyncService) : ControllerBase
{
/// <summary>
/// Triggers an immediate email sync cycle for all configured accounts,
/// skipping the remaining interval wait.
/// If a sync is already in progress, the next cycle will start immediately after it completes.
/// </summary>
/// <returns>HTTP 202 Accepted.</returns>
[HttpPost("trigger")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public IActionResult TriggerSync()
{
var syncTime = emailSyncService.ForceTriggerSync();
return Accepted(new { syncTime });
}
}

View File

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

View File

@@ -1,89 +1,82 @@
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
using DigitalData.MessagingService.Domain.Exceptions; using DigitalData.MessagingService.Domain.Exceptions;
using FluentValidation;
namespace DigitalData.MessagingService.API.Middleware; namespace DigitalData.MessagingService.API.Middleware;
/// <summary> /// <summary>
/// Global exception handling middleware /// Global exception handling middleware
/// </summary> /// </summary>
public class ExceptionHandlingMiddleware public class ExceptionHandlingMiddleware(RequestDelegate Next, ILogger<ExceptionHandlingMiddleware> Logger)
{ {
private static readonly JsonSerializerOptions _jsonSerializerOptions = new() private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}; };
private readonly RequestDelegate _next; /// <summary>
private readonly ILogger<ExceptionHandlingMiddleware> _logger; ///
/// </summary>
public ExceptionHandlingMiddleware( /// <param name="context"></param>
RequestDelegate next, /// <returns></returns>
ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
try try
{ {
await _next(context); await Next(context);
} }
catch (Exception ex) 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) private static string FormatValidationErrors(ValidationException 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)
{ {
var errors = exception.Errors var errors = exception.Errors
.Select(e => $"{e.PropertyName}: {e.ErrorMessage}") .Select(e => $"{e.PropertyName}: {e.ErrorMessage}")

View File

@@ -1,17 +1,41 @@
using DigitalData.MessagingService.API.Middleware; using DigitalData.MessagingService.API.Middleware;
using DigitalData.MessagingService.Application; using DigitalData.MessagingService.Application;
using DigitalData.MessagingService.Application.Common.Dtos;
using DigitalData.MessagingService.Infrastructure; using DigitalData.MessagingService.Infrastructure;
using Serilog; using Serilog;
using Serilog.Ui.Core.Extensions;
using Serilog.Ui.SqliteDataProvider.Extensions;
using Serilog.Ui.Web.Extensions;
// Configure Serilog early // Build temporary configuration to read log directory early
var tempConfig = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: false)
.Build();
var logDirectoryRaw = tempConfig.GetValue<string>("Application:LogDirectory") ?? "logs";
// Always resolve to an absolute path so sink and UI provider point to the same file
var logDirectory = Path.IsPathRooted(logDirectoryRaw)
? logDirectoryRaw
: Path.Combine(AppContext.BaseDirectory, logDirectoryRaw);
Directory.CreateDirectory(logDirectory);
var sqliteDbPath = Path.Combine(logDirectory, "logs.db");
// Configure Serilog early (bootstrap + full pipeline)
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.WriteTo.Console() .MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("System", Serilog.Events.LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File( .WriteTo.File(
path: "logs/emailprofiler-.log", path: Path.Combine(logDirectory, "emailprofiler-.log"),
rollingInterval: RollingInterval.Day, rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30, retainedFileCountLimit: 30,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.SQLite(sqliteDbPath, storeTimestampInUtc: true)
.CreateBootstrapLogger(); .CreateBootstrapLogger();
try try
@@ -25,12 +49,13 @@ try
.ReadFrom.Configuration(context.Configuration) .ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services) .ReadFrom.Services(services)
.Enrich.FromLogContext() .Enrich.FromLogContext()
.WriteTo.Console() .WriteTo.Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File( .WriteTo.File(
path: "logs/emailprofiler-.log", path: Path.Combine(logDirectory, "emailprofiler-.log"),
rollingInterval: RollingInterval.Day, rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30, retainedFileCountLimit: 30,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")); outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.SQLite(sqliteDbPath, storeTimestampInUtc: true));
// Add appsettings.Secrets.json for sensitive configuration (not committed to git) // Add appsettings.Secrets.json for sensitive configuration (not committed to git)
builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true); builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true);
@@ -41,14 +66,51 @@ try
// Register Infrastructure layer (RabbitMQ, Repositories, etc.) // Register Infrastructure layer (RabbitMQ, Repositories, etc.)
builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration);
// Register EmailAccount configuration (IOptions<EmailAccountDto>)
builder.Services.Configure<EmailAccountDto>(
builder.Configuration.GetSection("EmailAccount"));
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
{
Title = "DigitalData MessagingService API",
Version = "v1",
Description = """
Die **DigitalData MessagingService API** stellt Endpunkte zur Verwaltung von E-Mail-Konten,
E-Mail-Profilen sowie zur Verarbeitung und Nachverfolgung eingehender und ausgehender Nachrichten bereit.
---
## Authentifizierung
Für OAuth2-geschützte Endpunkte ist eine Authentifizierung erforderlich.
Rufen Sie den folgenden Endpunkt auf und ersetzen Sie `{E-Mail-Adresse}` durch die zu authentifizierende E-Mail-Adresse:
`/api/OAuth2/google/authorize/{E-Mail-Adresse}`
**Beispiel:** **[/api/OAuth2/google/authorize/htek0100@gmail.com &rarr;](/api/OAuth2/google/authorize/htek0100@gmail.com)**
---
## Weiterführende Links
- [Serilog Log-Viewer &nearr;](/serilog-ui)
"""
});
});
// Required by OAuth2Controller to build callback URIs
builder.Services.AddHttpContextAccessor();
// Register Serilog.UI with SQLite provider for web log viewer
builder.Services.AddSerilogUi(options =>
{
options.UseSqliteServer(dbOpt =>
{
dbOpt.WithConnectionString($"Data Source={sqliteDbPath}");
dbOpt.WithTable("Logs");
});
});
var app = builder.Build(); var app = builder.Build();
@@ -58,15 +120,37 @@ try
// Add Serilog request logging // Add Serilog request logging
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
// Configure the HTTP request pipeline. // Configure Swagger <20> enabled in Development always, and in other environments based on appsettings
if (app.Environment.IsDevelopment()) var swaggerEnabled = app.Environment.IsDevelopment()
|| app.Configuration.GetValue<bool>("Swagger:Enabled");
if (swaggerEnabled)
{ {
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI(ui =>
{
ui.SwaggerEndpoint("/swagger/v1/swagger.json", "DigitalData MessagingService API v1");
// Inject CSS so all description links open in a new tab
ui.InjectStylesheet("data:text/css,.renderedMarkdown a{target:_blank}");
ui.InjectJavascript("data:text/javascript," + Uri.EscapeDataString("""
window.addEventListener('load', () => {
const observer = new MutationObserver(() => {
document.querySelectorAll('.renderedMarkdown a').forEach(a => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
});
observer.observe(document.body, { childList: true, subtree: true });
});
"""));
});
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();
// Serve Serilog.UI log viewer at /serilog-ui
app.UseSerilogUi();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();

View File

@@ -4,5 +4,16 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning"
}
}
} }
} }

View File

@@ -1,27 +0,0 @@
{
"AllowedHosts": "*",
"RabbitMQ": {
"HostName": "172.24.12.56",
"Port": 5672,
"UserName": "admin",
"Password": "fl!'D}4;pYBb\\VD&{6]]G*\\0Bq8fVIn0j?Sgm\\2A,6GS47g5Dj",
"VirtualHost": "/",
"AutomaticRecoveryEnabled": true,
"NetworkRecoveryIntervalSeconds": 10,
"QueueName": "emailprofiler.email.outbox",
"ExchangeName": "emailprofiler.emails",
"RoutingKey": "email.outbox",
"DlqQueueName": "emailprofiler.email.outbox.dlq",
"DlqExchangeName": "emailprofiler.emails.dlq",
"DlqRoutingKey": "email.outbox.dlq"
},
"EmailAccount": {
"Username": "test-flow@digitaldata.works",
"Password": "ddemail108",
"PasswordEncrypted": false,
"SmtpServer": "kundencenter.triplew.de",
"SmtpPort": 465,
"SmtpUseSsl": true,
"UseOAuth2": false
}
}

View File

@@ -11,6 +11,12 @@
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Application": {
"LogDirectory": "logs"
},
"Swagger": {
"Enabled": true
},
"Workers": { "Workers": {
"EmailSender": { "EmailSender": {
"Enabled": true "Enabled": true

View File

@@ -11,16 +11,19 @@
<PackageIcon>icon.png</PackageIcon> <PackageIcon>icon.png</PackageIcon>
<RepositoryUrl>http://git.dd:3000/AppStd/Rec.git</RepositoryUrl> <RepositoryUrl>http://git.dd:3000/AppStd/Rec.git</RepositoryUrl>
<PackageTags>digital data messaging service api client</PackageTags> <PackageTags>digital data messaging service api client</PackageTags>
<Version>1.0.0-beta</Version> <Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion> <AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion> <FileVersion>1.0.0.0</FileVersion>
<Description></Description> <Description></Description>
<LangVersion>latest</LangVersion> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.10" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" /> <PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
</ItemGroup> </ItemGroup>
@@ -31,4 +34,13 @@
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
</ItemGroup>
</Project> </Project>

Some files were not shown because too many files have changed in this diff Show More