Compare commits

..

42 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
51 changed files with 2436 additions and 259 deletions

1
.gitignore vendored
View File

@@ -373,3 +373,4 @@ FodyWeavers.xsd
/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/appsettings.Secrets.json /src/presentation/DigitalData.MessagingService.API/appsettings.Secrets.json
/src/presentation/DigitalData.MessagingService.API/oauth.htek0100@gmail.com.json

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

@@ -1,3 +1,5 @@
using DigitalData.MessagingService.Domain.Enums;
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts; namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
/// <summary> /// <summary>
@@ -43,4 +45,33 @@ public record EmailAccountDto
/// Use SSL/TLS when connecting to the IMAP server. /// Use SSL/TLS when connecting to the IMAP server.
/// </summary> /// </summary>
public bool ImapUseSsl { get; set; } = true; 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

@@ -1,3 +1,5 @@
using DigitalData.MessagingService.Domain.Enums;
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts; namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
/// <summary> /// <summary>
@@ -44,4 +46,35 @@ public record EmailAccountModificationDto
/// Use SSL/TLS when connecting to the IMAP server. /// Use SSL/TLS when connecting to the IMAP server.
/// </summary> /// </summary>
public bool ImapUseSsl { get; set; } = true; 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,5 @@
#if NET
namespace DigitalData.MessagingService.Application.Common.Dto;
public record EmailSyncResult(int ProcessedCount = 0, int FailedCount = 0);
#endif

View File

@@ -14,6 +14,15 @@ public sealed record ReceivedEmailDto
public long Uid { get; set; } public long Uid { get; set; }
#endif #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> /// <summary>
/// Sender address (From header). /// Sender address (From header).
/// </summary> /// </summary>
@@ -94,4 +103,10 @@ public sealed record ReceivedEmailDto
#else #else
public bool IsSeen { get; set; } public bool IsSeen { get; set; }
#endif #endif
#if NET
public required string Folder { get; init; }
#else
public string Folder { get; set; } = null!;
#endif
} }

View File

@@ -19,4 +19,22 @@ public record SendingEmailEvent
#else #else
public required DateTime QueuedAt { get; init; } public required DateTime QueuedAt { get; init; }
#endif #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

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

View File

@@ -1,6 +1,5 @@
#if NET #if NET
using DigitalData.MessagingService.Application.Common.Dto; using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Domain.Entities; using DigitalData.MessagingService.Domain.Entities;
namespace DigitalData.MessagingService.Application.Common.Interfaces; namespace DigitalData.MessagingService.Application.Common.Interfaces;
@@ -11,18 +10,16 @@ namespace DigitalData.MessagingService.Application.Common.Interfaces;
public interface IImapEmailService public interface IImapEmailService
{ {
/// <summary> /// <summary>
/// Fetches emails from the specified mailbox folder. ///
/// </summary> /// </summary>
/// <param name="account">Account whose IMAP settings will be used.</param> /// <param name="account"></param>
/// <param name="filter">Filter to apply when fetching emails.</param> /// <param name="folder"></param>
/// When <see langword="true"/> (default), fetched messages are marked as <c>\Seen</c> on the server. /// <param name="cancel"></param>
/// Set to <see langword="false"/> for a non-destructive read (uses <c>BODY.PEEK</c> internally). /// <returns></returns>
/// </param> Task<EmailSyncResult> SyncEmailsAsync(
/// <param name="cancellationToken">Cancellation token.</param> EmailAccount account,
Task<IEnumerable<ReceivedEmailDto>> FetchEmailsAsync( string folder = "INBOX",
EmailAccount account, CancellationToken cancel = default);
MailSearchFilter filter,
CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Marks a message as seen (read) on the server. /// Marks a message as seen (read) on the server.
@@ -32,5 +29,22 @@ public interface IImapEmailService
long uid, long uid,
string folder = "INBOX", string folder = "INBOX",
CancellationToken cancellationToken = default); 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 #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,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

@@ -11,7 +11,7 @@ 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>> CreateAsync<TDto>(IEnumerable<TDto> dtos, 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);

View File

@@ -20,9 +20,37 @@ public class EmailMappingProfile : Profile
.ForMember(dest => dest.Sender, opt => opt.Ignore()) .ForMember(dest => dest.Sender, opt => opt.Ignore())
.ForMember(dest => dest.Attachments, opt => opt.MapFrom(src => src.Attachments)); .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 // EmailAccountDto -> EmailAccount
CreateMap<EmailAccount, EmailAccountDto>(); CreateMap<EmailAccount, EmailAccountDto>();
CreateMap<EmailAccountModificationDto, EmailAccount>(); 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 #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

@@ -24,5 +24,11 @@ public class EmailAccountsOptions
/// Defaults to 300 seconds (5 minutes). /// Defaults to 300 seconds (5 minutes).
/// </summary> /// </summary>
public int SyncIntervalSeconds { get; init; } = 300; 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 #endif

View File

@@ -1,4 +1,5 @@
#if NET #if NET
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Dto; using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Dto.MailSearch; using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Application.Common.Interfaces; using DigitalData.MessagingService.Application.Common.Interfaces;
@@ -14,7 +15,7 @@ namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
/// <summary> /// <summary>
/// Query to fetch emails from an IMAP mailbox. /// Query to fetch emails from an IMAP mailbox.
/// </summary> /// </summary>
public record FetchEmailsQuery : IRequest<IEnumerable<ReceivedEmailDto>> public record ReadEmailQuery : IRequest<ReadEmailQueryResponse>
{ {
/// <summary> /// <summary>
/// Identifies the email account to use. /// Identifies the email account to use.
@@ -27,26 +28,31 @@ public record FetchEmailsQuery : IRequest<IEnumerable<ReceivedEmailDto>>
public MailSearchFilter Mail { get; init; } = new(); public MailSearchFilter Mail { get; init; } = new();
} }
public class FetchEmailsQueryHandler(IImapEmailService ImapService, ILogger<FetchEmailsQueryHandler> Logger, IRepository<EmailAccount> Repo) : IRequestHandler<FetchEmailsQuery, IEnumerable<ReceivedEmailDto>> public class ReadEmailQueryHandler(IMapper Mapper, ILogger<ReadEmailQueryHandler> Logger, IRepository<EmailAccount> EmailAccountRepo, IReceivedEmailRepository MailRepo, IImapEmailService imapEmailService) : IRequestHandler<ReadEmailQuery, ReadEmailQueryResponse>
{ {
public async Task<IEnumerable<ReceivedEmailDto>> Handle(FetchEmailsQuery request, CancellationToken cancellationToken) public async Task<ReadEmailQueryResponse> Handle(ReadEmailQuery 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); 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) 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}"); Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
EmailAccount account = accounts.FirstOrDefault() var account = accounts.FirstOrDefault()
?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username})."); ?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
if (string.IsNullOrWhiteSpace(account.ImapServer)) if (string.IsNullOrWhiteSpace(account.ImapServer))
throw new BadRequestException( throw new BadRequestException(
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration."); $"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
return await ImapService.FetchEmailsAsync( var mails = await MailRepo.FindAsync(request.Mail, account, cancellationToken);
account,
request.Mail, var lastSync = imapEmailService.GetLastImapSyncDate(account.Id, request.Mail.Folder);
cancellationToken);
return new ReadEmailQueryResponse
{
LastSync = lastSync,
Emails = Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails)
};
} }
} }
#endif #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

@@ -5,9 +5,9 @@ using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators; namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
/// <summary> /// <summary>
/// Validates a <see cref="FetchEmailsQuery"/> before it is handled by <see cref="FetchEmailsQueryHandler"/>. /// Validates a <see cref="ReadEmailQuery"/> before it is handled by <see cref="ReadEmailQueryHandler"/>.
/// </summary> /// </summary>
public class FetchEmailsQueryValidator : AbstractValidator<FetchEmailsQuery> public class FetchEmailsQueryValidator : AbstractValidator<ReadEmailQuery>
{ {
public FetchEmailsQueryValidator() public FetchEmailsQueryValidator()
{ {

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

@@ -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

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using DigitalData.MessagingService.Domain.Enums;
namespace DigitalData.MessagingService.Domain.Entities; namespace DigitalData.MessagingService.Domain.Entities;
@@ -72,4 +73,73 @@ public class EmailAccount
/// </summary> /// </summary>
[Column("IMAP_USE_SSL", TypeName = "bit")] [Column("IMAP_USE_SSL", TypeName = "bit")]
public bool ImapUseSsl { get; set; } = true; 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

@@ -45,9 +45,9 @@ public sealed record ReceivedEmail
/// </summary> /// </summary>
[Column("TO", TypeName = "nvarchar(max)")] [Column("TO", TypeName = "nvarchar(max)")]
#if NET #if NET
public IEnumerable<string> To { get; init; } = []; public List<string> To { get; init; } = [];
#else #else
public IEnumerable<string> To { get; set; } = []; public List<string> To { get; set; } = [];
#endif #endif
/// <summary> /// <summary>
@@ -55,9 +55,9 @@ public sealed record ReceivedEmail
/// </summary> /// </summary>
[Column("CC", TypeName = "nvarchar(max)")] [Column("CC", TypeName = "nvarchar(max)")]
#if NET #if NET
public IEnumerable<string> Cc { get; init; } = []; public List<string> Cc { get; init; } = [];
#else #else
public IEnumerable<string> Cc { get; set; } = []; public List<string> Cc { get; set; } = [];
#endif #endif
/// <summary> /// <summary>
@@ -133,4 +133,10 @@ public sealed record ReceivedEmail
#else #else
public EmailAccount? Account { get; set; } public EmailAccount? Account { get; set; }
#endif #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

@@ -13,6 +13,7 @@ using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore; 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;
@@ -29,13 +30,26 @@ public static class DependencyInjection
IConfiguration configuration) IConfiguration configuration)
{ {
// --- External Services --- // --- External Services ---
// Email Service - SMTP outbound (Limilabs Mail.dll) // 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) // Email Service - IMAP inbound (Limilabs Mail.dll)
// Fresh connection per call — stateless and thread-safe. services.AddScoped<IImapEmailService, LimilabsImapEmailService>();
services.AddSingleton<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>();
@@ -58,6 +72,13 @@ public static class DependencyInjection
services.AddHostedService<AsyncInitWorker>(); services.AddHostedService<AsyncInitWorker>();
services.AddHostedService<EmailSyncWorker>(); 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(); services.AddMemoryCache();
// --- Database (InMemory) --- // --- Database (InMemory) ---
@@ -66,6 +87,8 @@ public static class DependencyInjection
services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddScoped<IReceivedEmailRepository, ReceivedEmailRepository>();
// AutoMapper - Register entity self-mappings (T -> T) for generic repository // AutoMapper - Register entity self-mappings (T -> T) for generic repository
services.AddAutoMapper(config => config.AddMaps(typeof(EntitySelfMappingProfile).Assembly)); services.AddAutoMapper(config => config.AddMaps(typeof(EntitySelfMappingProfile).Assembly));

View File

@@ -23,6 +23,7 @@
<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="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />

View File

@@ -2,6 +2,7 @@ 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.RabbitMQ; using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using RabbitMQ.Client; using RabbitMQ.Client;
using RabbitMQ.Client.Events; using RabbitMQ.Client.Events;
@@ -31,7 +32,7 @@ public sealed class SendingEmailConsumer : IAsyncDisposable
/// </summary> /// </summary>
public Guid RuntimeId { get; } = Guid.NewGuid(); public Guid RuntimeId { get; } = Guid.NewGuid();
public SendingEmailConsumer(string queueName, IEmailService emailService, RabbitMqConnectionFactory cnnFactory, ILogger<SendingEmailConsumer>? logger = null) public SendingEmailConsumer(string queueName, IEmailService emailService, IServiceScopeFactory scopeFactory, RabbitMqConnectionFactory cnnFactory, ILogger<SendingEmailConsumer>? logger = null)
{ {
_logger = logger; _logger = logger;
_queueName = queueName; _queueName = queueName;
@@ -41,8 +42,6 @@ public sealed class SendingEmailConsumer : IAsyncDisposable
{ {
var channel = await _lazyChannel.Value; var channel = await _lazyChannel.Value;
// prefetchCount=1 ensures this consumer processes one message at a time before acking.
// Parallelism comes from running multiple consumer instances, not from within a single channel.
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false); await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false);
var consumer = new AsyncEventingBasicConsumer(channel); var consumer = new AsyncEventingBasicConsumer(channel);
@@ -57,8 +56,14 @@ public sealed class SendingEmailConsumer : IAsyncDisposable
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.Mail, args.CancellationToken); {
await using var scope = scopeFactory.CreateAsyncScope();
var imapService = scope.ServiceProvider.GetRequiredService<IImapEmailService>();
await imapService.SendAndAppendAsync(oMailEvent.Mail, oMailEvent.SentFolder, args.CancellationToken);
}
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);

View File

@@ -1,5 +1,6 @@
using DigitalData.MessagingService.Application.Common.Interfaces; using DigitalData.MessagingService.Application.Common.Interfaces;
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 Microsoft.Extensions.Options;
@@ -8,8 +9,6 @@ namespace DigitalData.MessagingService.Infrastructure.Queue;
/// <summary> /// <summary>
/// Manages a pool of <see cref="SendingEmailConsumer"/> instances that compete for messages /// Manages a pool of <see cref="SendingEmailConsumer"/> instances that compete for messages
/// on the same RabbitMQ queue (competing consumers pattern). /// on the same RabbitMQ queue (competing consumers pattern).
/// Each consumer owns a dedicated channel, so they process messages fully in parallel
/// without any shared locking or synchronization primitives.
/// </summary> /// </summary>
public sealed class SendingEmailConsumerPool : IAsyncDisposable public sealed class SendingEmailConsumerPool : IAsyncDisposable
{ {
@@ -20,6 +19,7 @@ public sealed class SendingEmailConsumerPool : IAsyncDisposable
public SendingEmailConsumerPool( public SendingEmailConsumerPool(
IOptions<RabbitMqConfiguration> config, IOptions<RabbitMqConfiguration> config,
IEmailService emailService, IEmailService emailService,
IServiceScopeFactory scopeFactory,
RabbitMqConnectionFactory cnnFactory, RabbitMqConnectionFactory cnnFactory,
ILogger<SendingEmailConsumerPool>? logger = null, ILogger<SendingEmailConsumerPool>? logger = null,
ILogger<SendingEmailConsumer>? consumerLogger = null) ILogger<SendingEmailConsumer>? consumerLogger = null)
@@ -29,7 +29,7 @@ public sealed class SendingEmailConsumerPool : IAsyncDisposable
_consumers = [.. Enumerable _consumers = [.. Enumerable
.Range(0, _concurrency) .Range(0, _concurrency)
.Select(_ => new SendingEmailConsumer(config.Value.QueueName, emailService, cnnFactory, consumerLogger))]; .Select(_ => new SendingEmailConsumer(config.Value.QueueName, emailService, scopeFactory, cnnFactory, consumerLogger))];
} }
/// <summary> /// <summary>

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

@@ -13,22 +13,22 @@ 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>> CreateAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default) public async Task<IEnumerable<TEntity>> CreateRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default)
{ {
var entities = Mapper.Map<IEnumerable<TEntity>>(dtos); var entities = Mapper.Map<IEnumerable<TEntity>>(dtos);
await _dbSet.AddRangeAsync(entities, cancellationToken); await DbSet.AddRangeAsync(entities, cancellationToken);
await Context.SaveChangesAsync(cancellationToken); await Context.SaveChangesAsync(cancellationToken);
return entities; return entities;
} }
@@ -37,12 +37,12 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
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(
@@ -51,7 +51,7 @@ 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);
@@ -66,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(
@@ -81,15 +81,15 @@ 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 --- // --- UPSERT ---
@@ -105,12 +105,12 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
TDto dto, TDto dto,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var entity = await _dbSet.FirstOrDefaultAsync(predicate, cancellationToken); var entity = await DbSet.FirstOrDefaultAsync(predicate, cancellationToken);
if (entity is null) if (entity is null)
{ {
entity = Mapper.Map<TEntity>(dto); 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, true); return (entity, true);
} }
@@ -130,12 +130,12 @@ 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);
if (entity is null) if (entity is null)
{ {
entity = Mapper.Map<TEntity>(dto); 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, true); return (entity, true);
} }
@@ -157,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);
@@ -173,7 +173,7 @@ 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)); entities.ForEach(entity => Mapper.Map(dto, entity));
await Context.SaveChangesAsync(cancellationToken); await Context.SaveChangesAsync(cancellationToken);
return entities.Count; return entities.Count;
@@ -190,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);
} }
@@ -205,8 +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,66 +1,120 @@
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Application.Common.Interfaces; using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories; using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Application.Common.Options; using DigitalData.MessagingService.Application.Common.Options;
using DigitalData.MessagingService.Domain.Entities; using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Enums;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace DigitalData.MessagingService.Infrastructure.Services.Background; namespace DigitalData.MessagingService.Infrastructure.Services.Background;
public class EmailSyncWorker(IImapEmailService imapService, IOptions<EmailAccountsOptions> Options, IServiceProvider Provider) : BackgroundService public class EmailSyncWorker(IOptions<EmailAccountsOptions> Options, IServiceProvider Provider, ILogger<EmailSyncWorker> Logger, IMemoryCache Cache) : BackgroundService, IEmailSyncService
{ {
private DateFilter? _dateFilter = null; 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) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
await UpsertSeedEmailAccount(stoppingToken); await UpsertSeedEmailAccount(stoppingToken);
if (imapService is not LimilabsImapEmailService limapService)
{
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
return;
}
var interval = TimeSpan.FromSeconds(Options.Value.SyncIntervalSeconds); var interval = TimeSpan.FromSeconds(Options.Value.SyncIntervalSeconds);
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
using var scope = Provider.CreateAsyncScope(); await using var scope = Provider.CreateAsyncScope();
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>(); 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)) foreach (var account in await emailAccountRepo.GetAllAsync(stoppingToken))
if (account.ImapServer is not null) {
{ await SyncAccountAsync(account, imapService, pop3Service, stoppingToken);
// init or update last date filter }
_dateFilter = _dateFilter is null
? new DateFilter
{
After = null,
Before = DateTime.UtcNow
}
: new DateFilter
{
After = _dateFilter.Before,
Before = DateTime.UtcNow
};
await limapService.FetchEmailsAsync(account, new MailSearchFilter { Date = _dateFilter }, stoppingToken); // Reset trigger before waiting so any TriggerSync() call during the delay is caught
} _syncTrigger = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
await Task.Delay(interval, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); 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();
} }
} }
public async Task UpsertSeedEmailAccount(CancellationToken stoppingToken) private async Task SyncAccountAsync(
EmailAccount account,
IImapEmailService imapService,
IPop3EmailService pop3Service,
CancellationToken stoppingToken)
{ {
using var scope = Provider.CreateAsyncScope(); 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>>(); var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
// init seed email accounts if not exist
foreach (var account in Options.Value.Accounts) foreach (var account in Options.Value.Accounts)
await emailAccountRepo.UpsertAsync(a => a.Username == account.Username, account, stoppingToken); 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,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

@@ -12,14 +12,10 @@ 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;EmailAccountsOptions&gt; from appsettings.json.
/// Uses the first account in the list whose <see cref="EmailAccount.Name"/> equals <c>"default"</c>,
/// or falls back to the first account if none is named "default".
/// </summary> /// </summary>
public class LimilabsEmailService() : IEmailService public class LimilabsEmailService(IOAuth2TokenService oauth2TokenService) : IEmailService
{ {
// Register encoding provider for Limilabs (requires windows-1252 and other code pages)
static LimilabsEmailService() static LimilabsEmailService()
{ {
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
@@ -31,7 +27,7 @@ public class LimilabsEmailService() : IEmailService
ISendMessageResult? result = null; ISendMessageResult? result = null;
try try
{ {
await ConnectAndAuthenticateSmtpAsync(smtp, context.Sender); await ConnectAndAuthenticateSmtpAsync(smtp, context.Sender, cancellationToken);
var builder = new MailBuilder(); var builder = new MailBuilder();
builder.From.Add(new MailBox(context.Sender.Username)); builder.From.Add(new MailBox(context.Sender.Username));
@@ -62,37 +58,43 @@ public class LimilabsEmailService() : IEmailService
catch (Limilabs.Client.ServerException ex) catch (Limilabs.Client.ServerException ex)
{ {
await smtp.CloseSafelyAsync(); await smtp.CloseSafelyAsync();
throw new AuthenticationFailedException($"SMTP authentication failed. Check credentials or OAuth2 configuration. {ErrorMessageBuilder(result)}", 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)
{ {
await smtp.CloseSafelyAsync(); await smtp.CloseSafelyAsync();
throw new InvalidOperationException($"Failed to send email via SMTP server. {ErrorMessageBuilder(result)}", ex); throw new InvalidOperationException($"Failed to send email via SMTP server. {ErrorMessageBuilder(result)}", ex);
} }
catch
{
await smtp.CloseSafelyAsync();
throw;
}
} }
private static async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccount smtpAccount) internal async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccount smtpAccount, CancellationToken cancellationToken = default)
{ {
if (smtpAccount.SmtpUseSsl) if (smtpAccount.SmtpUseSsl)
{ {
await smtp.ConnectSSLAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort); await smtp.ConnectSSLAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort, cancellationToken);
} }
else else
{ {
await smtp.ConnectAsync(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
{ {
await smtp.LoginAsync(smtpAccount.Username, smtpAccount.Password); await smtp.LoginAsync(smtpAccount.Username, smtpAccount.Password, cancellationToken);
} }
} }
private static string ErrorMessageBuilder(ISendMessageResult? result = null) internal static string ErrorMessageBuilder(ISendMessageResult? result = null)
{ {
if(result is null || result.GeneralErrors.Count == 0) if(result is null || result.GeneralErrors.Count == 0)
return string.Empty; return string.Empty;
@@ -109,7 +111,7 @@ public class LimilabsEmailService() : IEmailService
return message.ToString(); return message.ToString();
} }
private static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentDto> attachments) internal static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentDto> attachments)
{ {
foreach (var attachment in attachments) foreach (var attachment in attachments)
{ {

View File

@@ -1,13 +1,12 @@
using DigitalData.MessagingService.Application.Common.Dto; 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;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Domain.Entities; using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Exceptions;
using DigitalData.MessagingService.Infrastructure.Services.Extensions; using DigitalData.MessagingService.Infrastructure.Services.Extensions;
using Limilabs.Client.IMAP; using Limilabs.Client.IMAP;
using Limilabs.Mail; using Limilabs.Mail;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Text; using System.Text;
namespace DigitalData.MessagingService.Infrastructure.Services; namespace DigitalData.MessagingService.Infrastructure.Services;
@@ -15,8 +14,13 @@ namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary> /// <summary>
/// IMAP email service using Limilabs Mail.dll. /// IMAP email service using Limilabs Mail.dll.
/// Opens a fresh connection per call — stateless and thread-safe. /// Opens a fresh connection per call — stateless and thread-safe.
/// Supports both password and OAuth2 (XOAUTH2) authentication.
/// </summary> /// </summary>
public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger, IMemoryCache Cache) : IImapEmailService public class LimilabsImapEmailService(
ILogger<LimilabsImapEmailService> Logger,
IRepository<ReceivedEmail> Repository,
IOAuth2TokenService oauth2TokenService,
LimilabsEmailService smtpService) : IImapEmailService
{ {
private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString(); private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString();
@@ -26,195 +30,174 @@ public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger,
} }
// Public API // Public API
public async Task<IEnumerable<ReceivedEmailDto>> FetchEmailsAsync( public async Task<EmailSyncResult> SyncEmailsAsync(EmailAccount account, string folder = "INBOX", CancellationToken cancel = default)
EmailAccount account,
MailSearchFilter filter,
CancellationToken cancel = default)
{ {
using var imap = await OpenAsync(account, filter.Folder, cancel); using var imap = await OpenAsync(account, folder, cancel: cancel);
try try
{ {
#region Find UIDs #region Find UIDs
// Server-side: only date range; all other filters are applied in-process after cache retrieval // Server-side: only date range; all other filters are applied in-process after cache retrieval
List<ICriterion> criterions = []; List<ICriterion> criterions = [];
if (filter.Date is DateFilter dateF) var since = GetLastImapSyncDate(account.Id, folder);
{
if (dateF.After is DateTime after)
criterions.Add(Expression.SentSince(after.Date));
// IMAP BEFORE is exclusive, so add one day to make the bound inclusive if (since is not null && since != default)
if (dateF.Before is DateTime before) criterions.Add(Expression.SentSince(since.Value));
criterions.Add(Expression.SentBefore(before.Date.AddDays(1)));
}
var searchExpression = criterions.Count > 0 ? Expression.And([.. criterions]) : Expression.All(); var searchExpression = criterions.Count > 0 ? Expression.And([.. criterions]) : Expression.All();
var operationStartTime = DateTime.UtcNow;
List<long> uids = [.. await imap.SearchAsync(searchExpression, cancel)]; List<long> uids = [.. await imap.SearchAsync(searchExpression, cancel)];
if (filter.SortOrder == MailSortOrder.NewestFirst)
uids.Reverse();
#endregion #endregion
if (uids.Count == 0)
return [];
var results = new List<ReceivedEmailDto>(uids.Count); if (uids.Count == 0)
return new EmailSyncResult();
var emails = new List<ReceivedEmailDto>(uids.Count);
var failedCount = 0;
foreach (var uid in uids) foreach (var uid in uids)
{ {
if (await Repository.AnyAsync(x => x.Uid == uid, cancel))
continue;
cancel.ThrowIfCancellationRequested(); cancel.ThrowIfCancellationRequested();
try try
{ {
#region Read email #region Read email
var email = await Cache.GetOrCreateAsync( var eml = await imap.GetMessageByUIDAsync(uid, cancel);
CacheKeyPrefix + uid, var mail = new MailBuilder().CreateFromEml(eml);
async entry => var flags = await imap.GetFlagsByUIDAsync(uid, cancel);
{
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>(); var attachments = new List<EmailAttachmentDto>();
foreach (var att in mail.Attachments) foreach (var att in mail.Attachments)
{
attachments.Add(new EmailAttachmentDto
{
FileName = att.FileName ?? "attachment",
Content = att.Data,
ContentType = att.ContentType?.ToString() ?? "application/octet-stream",
IsInline = false,
ContentId = att.ContentId
});
}
foreach (var vis in mail.Visuals)
{
attachments.Add(new EmailAttachmentDto
{
FileName = vis.FileName ?? "inline",
Content = vis.Data,
ContentType = vis.ContentType?.ToString() ?? "application/octet-stream",
IsInline = true,
ContentId = vis.ContentId
});
}
return new ReceivedEmailDto
{
Uid = uid,
From = mail.From.FirstOrDefault()?.Address ?? string.Empty,
To = [.. mail.To.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
Cc = [.. mail.Cc.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
Subject = mail.Subject ?? string.Empty,
TextBody = mail.Text ?? string.Empty,
HtmlBody = mail.Html ?? string.Empty,
Date = mail.Date ?? DateTime.MinValue,
IsSeen = flags.Contains(Flag.Seen),
Attachments = attachments,
};
});
#endregion Read email
if (email is null)
continue;
if (filter.UnseenOnly && email.IsSeen)
continue;
if (filter.SubjectContains is string subject &&
!email.Subject.Contains(subject, StringComparison.OrdinalIgnoreCase))
continue;
if (filter.SenderContains is string sender &&
!email.From.Contains(sender, StringComparison.OrdinalIgnoreCase))
continue;
if (filter.RecipientContains is string recipient &&
!email.To.Any(t => t.Contains(recipient, StringComparison.OrdinalIgnoreCase)) &&
!email.Cc.Any(c => c.Contains(recipient, StringComparison.OrdinalIgnoreCase)))
continue;
if (filter.BodyContains is string body &&
!email.TextBody.Contains(body, StringComparison.OrdinalIgnoreCase) &&
!email.HtmlBody.Contains(body, StringComparison.OrdinalIgnoreCase))
continue;
if (filter.Uid is UidFilter uidF)
{ {
if (uidF.Absolute is long exactUid && email.Uid != exactUid) attachments.Add(new EmailAttachmentDto
continue; {
FileName = att.FileName ?? "attachment",
if (uidF.Min is long min && email.Uid < min) Content = att.Data,
continue; ContentType = att.ContentType?.ToString() ?? "application/octet-stream",
IsInline = false,
if (uidF.Max is long max && email.Uid > max) ContentId = att.ContentId
continue; });
} }
if (filter.WithAttachments) foreach (var vis in mail.Visuals)
results.Add(email); {
else attachments.Add(new EmailAttachmentDto
results.Add(email with { Attachments = [] }); {
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) catch (Exception ex)
{ {
failedCount += 1;
Logger.LogWarning(ex, Logger.LogWarning(ex,
"Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.", "Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.",
uid, filter.Folder); uid, folder);
} }
} }
await imap.CloseAsync(cancel); await imap.CloseAsync(cancel);
if (filter.MaxCount is int maxCount && maxCount > 0 && results.Count > maxCount) await Repository.CreateRangeAsync(emails, cancel);
return results.Take(maxCount);
return results; return new EmailSyncResult(ProcessedCount: emails.Count, FailedCount: failedCount);
} }
catch (Limilabs.Client.ServerException ex) catch
{ {
await imap.CloseSafelyAsync(); await imap.CloseSafelyAsync();
throw new AuthenticationFailedException( throw;
$"IMAP authentication failed for account '{account.Username}'.", ex);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await imap.CloseSafelyAsync();
throw new InvalidOperationException(
$"Failed to fetch emails from IMAP server '{account.ImapServer}'.", ex);
} }
} }
public async Task MarkAsSeenAsync( public async Task MarkAsSeenAsync(EmailAccount account, long uid, string folder = "INBOX", CancellationToken cancel = default)
EmailAccount account,
long uid,
string folder = "INBOX",
CancellationToken cancel = default)
{ {
using var imap = await OpenAsync(account, folder, cancel); using var imap = await OpenAsync(account, folder, cancel: cancel);
try try
{ {
await imap.MarkMessageSeenByUIDAsync(uid, cancel); await imap.MarkMessageSeenByUIDAsync(uid, cancel);
await imap.CloseAsync(cancel); await imap.CloseAsync(cancel);
} }
catch (Limilabs.Client.ServerException ex) catch
{ {
await imap.CloseSafelyAsync(); await imap.CloseSafelyAsync();
throw new AuthenticationFailedException( throw;
$"IMAP authentication failed for account '{account.Username}'.", ex);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await imap.CloseSafelyAsync();
throw new InvalidOperationException(
$"Failed to mark message UID={uid} as seen on '{account.ImapServer}'.", ex);
} }
} }
private static async Task<Imap> OpenAsync(EmailAccount account, string folder, CancellationToken cancel) 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(); var imap = new Imap();
@@ -223,13 +206,45 @@ public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger,
else else
await imap.ConnectAsync(account.ImapServer!, account.ImapPort, cancel: cancel); await imap.ConnectAsync(account.ImapServer!, account.ImapPort, cancel: cancel);
await imap.LoginAsync(account.Username, account.Password, 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)) if (string.Equals(folder, "INBOX", StringComparison.OrdinalIgnoreCase))
await imap.SelectInboxAsync(cancel); await imap.SelectInboxAsync(cancel);
else 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); await imap.SelectAsync(folder, cancel);
}
return imap; 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

@@ -92,24 +92,150 @@ public class EmailController(IMediator mediator) : ControllerBase
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> FetchEmails([FromQuery] FetchEmailsQuery query, [FromQuery] OnlyFilter? only = null, CancellationToken cancellationToken = default) public async Task<IActionResult> FetchEmails([FromQuery] ReadEmailQuery query, [FromQuery] OnlyFilter? only = null, CancellationToken cancellationToken = default)
{ {
var emails = await mediator.Send(query, cancellationToken); var res = await mediator.Send(query, cancellationToken);
if(!emails.Any()) if(!res.Emails.Any())
return NotFound("No emails found matching the specified criteria."); return NotFound("No emails found matching the specified criteria.");
if (only == OnlyFilter.HtmlBody) if (only == OnlyFilter.HtmlBody)
{ {
if (emails.FirstOrDefault()?.HtmlBody is string htmlBody) if (res.Emails.FirstOrDefault()?.HtmlBody is string htmlBody)
return Content(htmlBody, "text/html"); return Content(htmlBody, "text/html");
else else
return NotFound(); return NotFound();
} }
else if (only == OnlyFilter.Uid) else if (only == OnlyFilter.Uid)
return Ok(emails.Select(e => e.Uid).ToList()); return Ok(res.Emails.Select(e => e.Uid).ToList());
else else
return Ok(emails); return Ok(res);
} }
#endregion Receive #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

@@ -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

@@ -69,7 +69,38 @@ try
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 // Register Serilog.UI with SQLite provider for web log viewer
builder.Services.AddSerilogUi(options => builder.Services.AddSerilogUi(options =>
@@ -89,14 +120,30 @@ try
// Add Serilog request logging // Add Serilog request logging
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
// Configure Swagger enabled in Development always, and in other environments based on appsettings // Configure Swagger <EFBFBD> enabled in Development always, and in other environments based on appsettings
var swaggerEnabled = app.Environment.IsDevelopment() var swaggerEnabled = app.Environment.IsDevelopment()
|| app.Configuration.GetValue<bool>("Swagger:Enabled"); || app.Configuration.GetValue<bool>("Swagger:Enabled");
if (swaggerEnabled) 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();