Compare commits
42 Commits
86a07e5017
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 993d9cfea0 | |||
| 8925428e46 | |||
| c189a2d6ef | |||
| 408ad14c9f | |||
| 08547a8768 | |||
| 5746d36665 | |||
| a9046e957b | |||
| 3ddd5e83a2 | |||
| 6ff01b364c | |||
| bb9ff9c9ed | |||
| ba8f701223 | |||
| 6d6e876d94 | |||
| 502ae5e07b | |||
| c90040db67 | |||
| f0c698856d | |||
| 158b562007 | |||
| a0f1fcddac | |||
| f7d5abb132 | |||
| 7782e86db3 | |||
| 1d7f269fe1 | |||
| a13380016d | |||
| 822c7b1352 | |||
| f7e95eb7f7 | |||
| a57429718d | |||
| ef8df96e65 | |||
| 194ae11a40 | |||
| 6c698c95be | |||
| d01a0ceaa6 | |||
| fd234618f7 | |||
| f99bd8c399 | |||
| c0bd391297 | |||
| cda70c8ced | |||
| 578ecc7ba1 | |||
| 6abe0e18f6 | |||
| c9b7d99ecc | |||
| 4964fa4344 | |||
| 7207c5b4d9 | |||
| 7f04c4b09f | |||
| 43d7c393bb | |||
| e73bead2f2 | |||
| 606ff94a77 | |||
| 1c9af0e560 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
300
README.md
@@ -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
1
legacy
Submodule
Submodule legacy added at e59b936181
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#if NET
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
public record EmailSyncResult(int ProcessedCount = 0, int FailedCount = 0);
|
||||||
|
#endif
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
public interface IEmailSyncService
|
||||||
|
{
|
||||||
|
public DateTime ForceTriggerSync();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<EmailAccountsOptions> 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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 →](/api/OAuth2/google/authorize/htek0100@gmail.com)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Weiterführende Links
|
||||||
|
|
||||||
|
- [Serilog Log-Viewer ↗](/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();
|
||||||
|
|||||||
Reference in New Issue
Block a user