Compare commits
83 Commits
2e69fac250
...
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 | |||
| 86a07e5017 | |||
| 25d0c009ee | |||
| 14180860d3 | |||
| 50eaefb868 | |||
| 84da5cb646 | |||
| 25b55ef651 | |||
| 8b4d1e48f5 | |||
| 893addb45d | |||
| 00ae8e1ba0 | |||
| 5c37b2ef92 | |||
| be28a61d9c | |||
| b2857c558f | |||
| 91581649ba | |||
| 4dd0974c6f | |||
| 5e0de6d52a | |||
| 176e6dd6c5 | |||
| 48796d9917 | |||
| f6ada2ad9e | |||
| 7b5596f3e5 | |||
| 5d3997b7e1 | |||
| cb3b2e09ab | |||
| 118612206e | |||
| a446162afa | |||
| f521683608 | |||
| f3761e96d9 | |||
| e840d026aa | |||
| 962cb52e0b | |||
| 1162a07454 | |||
| 59e8780345 | |||
| 5ff38391d0 | |||
| 1fb7dcf98a | |||
| ee279c407b | |||
| 74ec00ddd3 | |||
| 763ba67d34 | |||
| 8e2e9af451 | |||
| 53ba40b316 | |||
| 52a416f6d9 | |||
| 79db1c3a69 | |||
| 18c5dca9f4 | |||
| c47d78112c | |||
| 20de7da93d |
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
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingServic
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Client", "src\presentation\DigitalData.MessagingService.Client\DigitalData.MessagingService.Client.csproj", "{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Client", "src\presentation\DigitalData.MessagingService.Client\DigitalData.MessagingService.Client.csproj", "{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Abstraction", "src\core\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj", "{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -77,10 +75,6 @@ Global
|
|||||||
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Debug|Any CPU.Build.0 = Release|Any CPU
|
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Debug|Any CPU.Build.0 = Release|Any CPU
|
||||||
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Release|Any CPU.Build.0 = Release|Any CPU
|
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -97,7 +91,6 @@ Global
|
|||||||
{8BF22107-3CB9-C326-B94B-C40C99DA9B68} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
|
{8BF22107-3CB9-C326-B94B-C40C99DA9B68} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
|
||||||
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
|
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
|
||||||
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
|
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
|
||||||
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}
|
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}
|
||||||
|
|||||||
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,10 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<LangVersion>latest</LangVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Abstraction;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for a single email account configuration.
|
|
||||||
/// </summary>
|
|
||||||
public class EmailAccountDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Logical name to identify this account (e.g. "default", "support").
|
|
||||||
/// </summary>
|
|
||||||
public int Id { get; set; }
|
|
||||||
|
|
||||||
#if NET
|
|
||||||
public required string Username { get; set; }
|
|
||||||
#else
|
|
||||||
public string Username { get; set; } = null!;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if NET
|
|
||||||
public required string Password { get; set; }
|
|
||||||
#else
|
|
||||||
public string Password { get; set; } = null!;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
public bool PasswordEncrypted { get; set; } = false;
|
|
||||||
|
|
||||||
#if NET
|
|
||||||
public required string SmtpServer { get; set; }
|
|
||||||
#else
|
|
||||||
public string SmtpServer { get; set; } = null!;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
public int SmtpPort { get; set; }
|
|
||||||
|
|
||||||
public bool SmtpUseSsl { get; set; }
|
|
||||||
|
|
||||||
public bool UseOAuth2 { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Abstraction;
|
|
||||||
|
|
||||||
public record SendingEmailEvent
|
|
||||||
{
|
|
||||||
#if NETFRAMEWORK
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
#else
|
|
||||||
public required Guid Id { get; init; }
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if NETFRAMEWORK
|
|
||||||
public EmailContext Mail { get; set; } = null!;
|
|
||||||
#else
|
|
||||||
public required EmailContext Mail { get; init; }
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if NETFRAMEWORK
|
|
||||||
public DateTime QueuedAt { get; set; }
|
|
||||||
#else
|
|
||||||
public required DateTime QueuedAt { get; init; }
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using DigitalData.MessagingService.Domain.Enums;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for a single email account configuration.
|
||||||
|
/// </summary>
|
||||||
|
public record EmailAccountDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Logical name to identify this account (e.g. "default", "support").
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public required string Username { get; set; }
|
||||||
|
#else
|
||||||
|
public string Username { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public required string SmtpServer { get; set; }
|
||||||
|
#else
|
||||||
|
public string SmtpServer { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public int SmtpPort { get; set; }
|
||||||
|
|
||||||
|
public bool SmtpUseSsl { get; set; }
|
||||||
|
|
||||||
|
public bool UseOAuth2 { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP server hostname (e.g. "imap.example.com").
|
||||||
|
/// Leave empty when this account is send-only.
|
||||||
|
/// </summary>
|
||||||
|
public string? ImapServer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
|
||||||
|
/// </summary>
|
||||||
|
public int ImapPort { get; set; } = 993;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use SSL/TLS when connecting to the IMAP server.
|
||||||
|
/// </summary>
|
||||||
|
public bool ImapUseSsl { get; set; } = true;
|
||||||
|
|
||||||
|
public string? Pop3Server { get; set; }
|
||||||
|
|
||||||
|
public int Pop3Port { get; set; } = 995;
|
||||||
|
|
||||||
|
public bool Pop3UseSsl { get; set; } = true;
|
||||||
|
|
||||||
|
public string? OAuth2ClientId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth2 refresh token (Google only). Obtained via OAuth Playground or authorization flow.
|
||||||
|
/// Leave empty for Microsoft.
|
||||||
|
/// </summary>
|
||||||
|
public string? OAuth2RefreshToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tenant ID for Microsoft OAuth2 (GUID, full domain, or "common"). Not used for Google.
|
||||||
|
/// </summary>
|
||||||
|
public string? OAuth2TenantId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies which OAuth2 identity provider to use when <see cref="UseOAuth2"/> is true.
|
||||||
|
/// </summary>
|
||||||
|
public OAuth2Provider OAuth2Provider { get; set; } = OAuth2Provider.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The protocol used to receive (sync) incoming emails.
|
||||||
|
/// </summary>
|
||||||
|
public IncomingProtocol IncomingProtocol { get; set; } = IncomingProtocol.None;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using DigitalData.MessagingService.Domain.Enums;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for a single email account configuration.
|
||||||
|
/// </summary>
|
||||||
|
public record EmailAccountModificationDto
|
||||||
|
{
|
||||||
|
#if NET
|
||||||
|
public required string Username { get; set; }
|
||||||
|
#else
|
||||||
|
public string Username { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public required string Password { get; set; }
|
||||||
|
#else
|
||||||
|
public string Password { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public required string SmtpServer { get; set; }
|
||||||
|
#else
|
||||||
|
public string SmtpServer { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public int SmtpPort { get; set; }
|
||||||
|
|
||||||
|
public bool SmtpUseSsl { get; set; }
|
||||||
|
|
||||||
|
public bool UseOAuth2 { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP server hostname (e.g. "imap.example.com").
|
||||||
|
/// Leave empty when this account is send-only.
|
||||||
|
/// </summary>
|
||||||
|
public string? ImapServer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
|
||||||
|
/// </summary>
|
||||||
|
public int ImapPort { get; set; } = 993;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use SSL/TLS when connecting to the IMAP server.
|
||||||
|
/// </summary>
|
||||||
|
public bool ImapUseSsl { get; set; } = true;
|
||||||
|
|
||||||
|
public string? Pop3Server { get; set; }
|
||||||
|
|
||||||
|
public int Pop3Port { get; set; } = 995;
|
||||||
|
|
||||||
|
public bool Pop3UseSsl { get; set; } = true;
|
||||||
|
|
||||||
|
public string? OAuth2ClientId { get; set; }
|
||||||
|
|
||||||
|
public string? OAuth2ClientSecret { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth2 refresh token (Google only). Obtained via OAuth Playground or authorization flow.
|
||||||
|
/// Leave empty for Microsoft.
|
||||||
|
/// </summary>
|
||||||
|
public string? OAuth2RefreshToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tenant ID for Microsoft OAuth2 (GUID, full domain, or "common"). Not used for Google.
|
||||||
|
/// </summary>
|
||||||
|
public string? OAuth2TenantId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies which OAuth2 identity provider to use when <see cref="UseOAuth2"/> is true.
|
||||||
|
/// </summary>
|
||||||
|
public OAuth2Provider OAuth2Provider { get; set; } = OAuth2Provider.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The protocol used to receive (sync) incoming emails.
|
||||||
|
/// </summary>
|
||||||
|
public IncomingProtocol IncomingProtocol { get; set; } = IncomingProtocol.None;
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
namespace DigitalData.MessagingService.Abstraction;
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a single email attachment.
|
/// Represents a single email attachment.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class EmailAttachmentContext
|
public sealed class EmailAttachmentDto
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Display name of the attachment (e.g. "invoice.pdf").
|
/// Display name of the attachment (e.g. "invoice.pdf").
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
namespace DigitalData.MessagingService.Abstraction;
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
public record EmailContext
|
public record EmailContext
|
||||||
{
|
{
|
||||||
#if NETFRAMEWORK
|
#if NETFRAMEWORK
|
||||||
public EmailAccountDto Sender { get; set; } = null!;
|
public EmailAccount Sender { get; set; } = null!;
|
||||||
#else
|
#else
|
||||||
public required EmailAccountDto Sender { get; init; }
|
public required EmailAccount Sender { get; init; }
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -48,8 +50,8 @@ public record EmailContext
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional list of attachments to include with the email.
|
/// Optional list of attachments to include with the email.
|
||||||
/// Each entry may carry its content as a byte array (<see cref="EmailAttachmentContext.Content"/>)
|
/// Each entry may carry its content as a byte array (<see cref="EmailAttachmentDto.Content"/>)
|
||||||
/// or reference a file on disk via <see cref="EmailAttachmentContext.FilePath"/>.
|
/// or reference a file on disk via <see cref="EmailAttachmentDto.FilePath"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IEnumerable<EmailAttachmentContext> Attachments { get; set; } = [];
|
public IEnumerable<EmailAttachmentDto> Attachments { get; set; } = [];
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#if NET
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
public record EmailSyncResult(int ProcessedCount = 0, int FailedCount = 0);
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#if NET
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constrains the search to messages within an arrival-date range.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// At least one of <see cref="After"/> or <see cref="Before"/> must be provided.
|
||||||
|
/// When both are set <see cref="After"/> must be earlier than <see cref="Before"/>.
|
||||||
|
/// Both bounds are <b>inclusive</b>.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="After">Earliest date to include (inclusive). Maps to IMAP <c>SINCE</c>.</param>
|
||||||
|
/// <param name="Before">Latest date to include (inclusive). Maps to IMAP <c>BEFORE</c> (next day is used internally).</param>
|
||||||
|
public record DateFilter(DateTime? After = null, DateTime? Before = null);
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#if NET
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes all criteria and options used when searching an IMAP mailbox.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// All text-match properties are <b>case-insensitive</b> substring searches performed server-side via IMAP SEARCH.
|
||||||
|
/// Combine multiple criteria freely; an implicit AND is applied across all non-null fields.
|
||||||
|
/// </remarks>
|
||||||
|
public record MailSearchFilter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Mailbox folder to search. Defaults to <c>"INBOX"</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Folder { get; init; } = "INBOX";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When <see langword="true"/>, only unread (UNSEEN) messages are returned.
|
||||||
|
/// </summary>
|
||||||
|
public bool UnseenOnly { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When <see langword="true"/>, attachment data is included in the results; otherwise attachments are omitted.
|
||||||
|
/// Defaults to <see langword="false"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool WithAttachments { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum number of messages to retrieve. <c>0</c> means unlimited.
|
||||||
|
/// Applied after sorting; defaults to <c>50</c>.
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxCount { get; init; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controls the order of the returned messages. Defaults to <see cref="MailSortOrder.NewestFirst"/>.
|
||||||
|
/// </summary>
|
||||||
|
public MailSortOrder SortOrder { get; init; } = MailSortOrder.NewestFirst;
|
||||||
|
|
||||||
|
// ── Text filters ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Case-insensitive substring the message subject must contain.
|
||||||
|
/// Maps to IMAP <c>SUBJECT</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? SubjectContains { get; init; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Case-insensitive substring that must appear in the <c>From</c> header.
|
||||||
|
/// Maps to IMAP <c>FROM</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? SenderContains { get; init; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Case-insensitive substring that must appear in <c>To</c> or <c>Cc</c>.
|
||||||
|
/// Maps to IMAP <c>TO</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? RecipientContains { get; init; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Case-insensitive substring that must appear anywhere in the message body (text or HTML part).
|
||||||
|
/// Maps to IMAP <c>BODY</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? BodyContains { get; init; } = null;
|
||||||
|
|
||||||
|
// ── Structured filters ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constrains results to a specific UID or a UID range.
|
||||||
|
/// See <see cref="UidFilter"/> for mutual-exclusion rules between its fields.
|
||||||
|
/// </summary>
|
||||||
|
public UidFilter? Uid { get; init; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constrains results to messages received within a date range.
|
||||||
|
/// See <see cref="DateFilter"/> for rules between its fields.
|
||||||
|
/// </summary>
|
||||||
|
public DateFilter? Date { get; init; } = null;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controls the order in which fetched messages are returned.
|
||||||
|
/// </summary>
|
||||||
|
public enum MailSortOrder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Newest messages first (default).
|
||||||
|
/// </summary>
|
||||||
|
NewestFirst,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Oldest messages first.
|
||||||
|
/// </summary>
|
||||||
|
OldestFirst
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#if NET
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constrains the search to a specific UID or a UID range.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Use <see cref="Absolute"/> for an exact single-message lookup.
|
||||||
|
/// Use <see cref="Min"/> and/or <see cref="Max"/> for an open or closed range.
|
||||||
|
/// Mixing <see cref="Absolute"/> with <see cref="Min"/> or <see cref="Max"/> is not allowed.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Min">Lower bound of the UID range (inclusive). Ignored when <see cref="Absolute"/> is set.</param>
|
||||||
|
/// <param name="Max">Upper bound of the UID range (inclusive). Ignored when <see cref="Absolute"/> is set.</param>
|
||||||
|
/// <param name="Absolute">Exact UID to match. When set, <see cref="Min"/> and <see cref="Max"/> must be <see langword="null"/>.</param>
|
||||||
|
public record UidFilter(long? Min = null, long? Max = null, long? Absolute = null);
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an email message received via IMAP.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ReceivedEmailDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier of the message on the IMAP server (UID).
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public long Uid { get; init; }
|
||||||
|
#else
|
||||||
|
public long Uid { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID of the email account this message belongs to.
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public int AccountId { get; init; }
|
||||||
|
#else
|
||||||
|
public int AccountId { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sender address (From header).
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public string From { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string From { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recipient addresses (To header).
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public IEnumerable<string> To { get; init; } = [];
|
||||||
|
#else
|
||||||
|
public IEnumerable<string> To { get; set; } = [];
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CC addresses.
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public IEnumerable<string> Cc { get; init; } = [];
|
||||||
|
#else
|
||||||
|
public IEnumerable<string> Cc { get; set; } = [];
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email subject.
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plain-text body (may be empty when only HTML is present).
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public string TextBody { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string TextBody { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTML body (may be empty when only plain-text is present).
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public string HtmlBody { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string HtmlBody { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Date/time the message was sent (Date header).
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public DateTime Date { get; init; }
|
||||||
|
#else
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attachments included with this message.
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public IEnumerable<EmailAttachmentDto> Attachments { get; init; } = [];
|
||||||
|
#else
|
||||||
|
public IEnumerable<EmailAttachmentDto> Attachments { get; set; } = [];
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the message has been marked as seen/read on the server.
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public bool IsSeen { get; init; }
|
||||||
|
#else
|
||||||
|
public bool IsSeen { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public required string Folder { get; init; }
|
||||||
|
#else
|
||||||
|
public string Folder { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace DigitalData.MessagingService.Abstraction;
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
public record SendingEmailCreateDto
|
public record SendingEmailCreateDto
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
public record SendingEmailEvent
|
||||||
|
{
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
#else
|
||||||
|
public required Guid Id { get; init; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
public EmailContext Mail { get; set; } = null!;
|
||||||
|
#else
|
||||||
|
public required EmailContext Mail { get; init; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
public DateTime QueuedAt { get; set; }
|
||||||
|
#else
|
||||||
|
public required DateTime QueuedAt { get; init; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, the sent message will be appended to the IMAP Sent folder after sending.
|
||||||
|
/// </summary>
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
public bool UseImapAppend { get; set; } = false;
|
||||||
|
#else
|
||||||
|
public bool UseImapAppend { get; init; } = false;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP folder to append the sent message to (used when <see cref="UseImapAppend"/> is true).
|
||||||
|
/// </summary>
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
public string SentFolder { get; set; } = "Sent";
|
||||||
|
#else
|
||||||
|
public string SentFolder { get; init; } = "Sent";
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using DigitalData.MessagingService.Abstraction;
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
public interface IEmailSyncService
|
||||||
|
{
|
||||||
|
public DateTime ForceTriggerSync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service interface for reading emails via IMAP.
|
||||||
|
/// </summary>
|
||||||
|
public interface IImapEmailService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="account"></param>
|
||||||
|
/// <param name="folder"></param>
|
||||||
|
/// <param name="cancel"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task<EmailSyncResult> SyncEmailsAsync(
|
||||||
|
EmailAccount account,
|
||||||
|
string folder = "INBOX",
|
||||||
|
CancellationToken cancel = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a message as seen (read) on the server.
|
||||||
|
/// </summary>
|
||||||
|
Task MarkAsSeenAsync(
|
||||||
|
EmailAccount account,
|
||||||
|
long uid,
|
||||||
|
string folder = "INBOX",
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the last IMAP sync date for the specified account and folder.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="accountId"></param>
|
||||||
|
/// <param name="folder"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
DateTime? GetLastImapSyncDate(int accountId, string folder = "INBOX");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an email via SMTP using the account credentials and appends the sent message
|
||||||
|
/// to the account's IMAP Sent Items folder.
|
||||||
|
/// </summary>
|
||||||
|
Task SendAndAppendAsync(
|
||||||
|
EmailContext context,
|
||||||
|
string sentFolder = "Sent",
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace DigitalData.MessagingService.Abstraction;
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Email queue interface for outgoing emails.
|
/// Email queue interface for outgoing emails.
|
||||||
@@ -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,6 +11,8 @@ public interface IRepository<TEntity> where TEntity : class
|
|||||||
// CREATE
|
// CREATE
|
||||||
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
|
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<IEnumerable<TEntity>> CreateRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
// READ
|
// READ
|
||||||
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
|
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
@@ -20,6 +22,11 @@ public interface IRepository<TEntity> where TEntity : class
|
|||||||
Task<int> CountAsync(Expression<Func<TEntity, bool>>? predicate = null, CancellationToken cancellationToken = default);
|
Task<int> CountAsync(Expression<Func<TEntity, bool>>? predicate = null, CancellationToken cancellationToken = default);
|
||||||
Task<bool> AnyAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
Task<bool> AnyAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
// UPSERT
|
||||||
|
Task<(TEntity Entity, bool Created)> UpsertAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<(TEntity Entity, bool Created)> UpsertSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
// UPDATE
|
// UPDATE
|
||||||
Task UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
Task UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
||||||
Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
#if NET
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||||
using DigitalData.MessagingService.Abstraction;
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.Common.Mappings;
|
namespace DigitalData.MessagingService.Application.Common.Mappings;
|
||||||
|
|
||||||
@@ -11,10 +14,43 @@ public class EmailMappingProfile : Profile
|
|||||||
{
|
{
|
||||||
public EmailMappingProfile()
|
public EmailMappingProfile()
|
||||||
{
|
{
|
||||||
// SendEmailCommand -> Email
|
// PublishEmailCommand -> Email
|
||||||
// Sender is resolved via MediatR in the handler and set separately after mapping.
|
// Sender is resolved via MediatR in the handler and set separately after mapping.
|
||||||
CreateMap<SendEmailCommand, EmailContext>()
|
CreateMap<PublishEmailCommand, EmailContext>()
|
||||||
.ForMember(dest => dest.Sender, opt => opt.Ignore())
|
.ForMember(dest => dest.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
|
||||||
|
CreateMap<EmailAccount, EmailAccountDto>();
|
||||||
|
CreateMap<EmailAccountModificationDto, EmailAccount>()
|
||||||
|
// Do not overwrite OAuth2RefreshToken if the source value is null or empty.
|
||||||
|
// This prevents the seed process from erasing a token that was obtained via
|
||||||
|
// the OAuth2 authorization flow and saved to the database at runtime.
|
||||||
|
.ForMember(dest => dest.OAuth2RefreshToken,
|
||||||
|
opt => opt.Condition((src, dest, srcMember) => !string.IsNullOrEmpty(srcMember)));
|
||||||
|
|
||||||
|
// ReceivedEmailDto <-> ReceivedEmail
|
||||||
|
CreateMap<ReceivedEmailDto, ReceivedEmail>()
|
||||||
|
.ForMember(dest => dest.Id, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Account, opt => opt.Ignore());
|
||||||
|
CreateMap<ReceivedEmail, ReceivedEmailDto>();
|
||||||
|
|
||||||
|
// EmailAttachmentDto <-> EmailAttachment
|
||||||
|
CreateMap<EmailAttachmentDto, EmailAttachment>()
|
||||||
|
.ForMember(dest => dest.Id, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailId, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Email, opt => opt.Ignore());
|
||||||
|
CreateMap<EmailAttachment, EmailAttachmentDto>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -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
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
using DigitalData.MessagingService.Abstraction;
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||||
|
using DigitalData.MessagingService.Application.EmailAccounts.Commands;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.Common.Options;
|
namespace DigitalData.MessagingService.Application.Common.Options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wrapper options class that holds a list of <see cref="EmailAccountDto"/> entries
|
/// Wrapper options class that holds a list of <see cref="Dto.EmailAccount"/> entries
|
||||||
/// bound from the <c>EmailAccounts</c> configuration section.
|
/// bound from the <c>EmailAccounts</c> configuration section.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class EmailAccountsOptions
|
public class EmailAccountsOptions
|
||||||
@@ -13,5 +17,18 @@ public class EmailAccountsOptions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of configured email accounts.
|
/// The list of configured email accounts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required IEnumerable<EmailAccountDto> Accounts { get; init; } = [];
|
public required IEnumerable<EmailAccountModificationDto> Accounts { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How often the IMAP sync worker polls for new emails, in seconds.
|
||||||
|
/// Defaults to 300 seconds (5 minutes).
|
||||||
|
/// </summary>
|
||||||
|
public int SyncIntervalSeconds { get; init; } = 300;
|
||||||
|
|
||||||
|
/// <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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.ValueObjects;
|
||||||
|
|
||||||
|
public enum Modification
|
||||||
|
{
|
||||||
|
Upsert
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
#if NET
|
||||||
using DigitalData.MessagingService.Application.Common.Options;
|
using DigitalData.MessagingService.Application.Common.Options;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application;
|
namespace DigitalData.MessagingService.Application;
|
||||||
@@ -43,3 +43,4 @@ public static class DependencyInjection
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
|
<ProjectReference Include="..\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
|
||||||
<ProjectReference Include="..\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
<PackageReference Include="MediatR" Version="14.2.0" />
|
<PackageReference Include="MediatR" Version="14.2.0" />
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
using DigitalData.MessagingService.Application.Common.Options;
|
|
||||||
using DigitalData.MessagingService.Abstraction;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.EmailAccount.Queries;
|
|
||||||
|
|
||||||
public record GetSenderQuery : IRequest<EmailAccountDto?>
|
|
||||||
{
|
|
||||||
public int? Id { get; init; }
|
|
||||||
|
|
||||||
public string? Username { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="Options"></param>
|
|
||||||
/// <param name="Logger"></param>
|
|
||||||
public class GetSenderQueryHandler(IOptions<EmailAccountsOptions> Options, ILogger<GetSenderQueryHandler> Logger) : IRequestHandler<GetSenderQuery, EmailAccountDto?>
|
|
||||||
{
|
|
||||||
public Task<EmailAccountDto?> Handle(GetSenderQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var accounts = request.Id is not null
|
|
||||||
? Options.Value.Accounts.Where(a => a.Id == request.Id)
|
|
||||||
: Options.Value.Accounts.Where(a => a.Username == request.Username);
|
|
||||||
|
|
||||||
if(accounts.Count() > 1)
|
|
||||||
{
|
|
||||||
Logger.LogWarning(
|
|
||||||
"Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.",
|
|
||||||
request.Id is not null ? $"Id: {request.Id}" : $"Username: {request.Username}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.FromResult(accounts.FirstOrDefault());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
using DigitalData.MessagingService.Application.EmailAccount.Queries;
|
|
||||||
using FluentValidation;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.EmailAccount.Validators;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validator for <see cref="GetSenderQuery"/>.
|
|
||||||
/// Either <see cref="GetSenderQuery.Id"/> or <see cref="GetSenderQuery.Username"/> must be provided, but not both.
|
|
||||||
/// </summary>
|
|
||||||
public class GetSenderQueryValidator : AbstractValidator<GetSenderQuery>
|
|
||||||
{
|
|
||||||
public GetSenderQueryValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x)
|
|
||||||
.Must(x => (x.Id is not null) ^ (x.Username is not null))
|
|
||||||
.WithMessage("Either Id or Username must be provided, but not both.");
|
|
||||||
|
|
||||||
When(x => x.Username is not null, () =>
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Username)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("Username must not be empty.")
|
|
||||||
.MaximumLength(200)
|
|
||||||
.WithMessage("Username must not exceed 200 characters.");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Application.Common.ValueObjects;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using MediatR;
|
||||||
|
using System.Security.Principal;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailAccounts.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for a single email account configuration.
|
||||||
|
/// </summary>
|
||||||
|
public record EmailAccountModificationCommand : IRequest<(EmailAccount, bool)>
|
||||||
|
{
|
||||||
|
public required EmailAccountModificationDto ModifiedAccount { get; init; }
|
||||||
|
|
||||||
|
public required Modification Modification { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Repo"></param>
|
||||||
|
public class EmailAccountModificationCommandHandler(IRepository<EmailAccount> Repo) : IRequestHandler<EmailAccountModificationCommand, (EmailAccount, bool)>
|
||||||
|
{
|
||||||
|
public async Task<(EmailAccount, bool)> Handle(EmailAccountModificationCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return request.Modification switch
|
||||||
|
{
|
||||||
|
Modification.Upsert => await Repo.UpsertAsync(a => a.Username == request.ModifiedAccount.Username, request.ModifiedAccount, cancellationToken),
|
||||||
|
_ => throw new NotSupportedException($"Modification type '{request.Modification}' is not supported in email account modification."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#if NET
|
||||||
|
using MediatR;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||||
|
|
||||||
|
public record GetEmailAccountQuery : IRequest<IEnumerable<EmailAccountDto>>
|
||||||
|
{
|
||||||
|
public int? Id { get; init; }
|
||||||
|
|
||||||
|
public string? Username { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles queries for retrieving email accounts.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Repo"></param>
|
||||||
|
/// <param name="Mapper"></param>
|
||||||
|
public class GetEmailAccountQueryHandler(IRepository<EmailAccount> Repo, IMapper Mapper) : IRequestHandler<GetEmailAccountQuery, IEnumerable<EmailAccountDto>>
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<EmailAccountDto>> Handle(GetEmailAccountQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var accounts = request.Id is null && request.Username is null
|
||||||
|
? await Repo.GetAllAsync(cancellationToken)
|
||||||
|
: await Repo.FindAsync(request.Id is int id ? x => x.Id == id : x => x.Username == request.Username, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (accounts.Any())
|
||||||
|
return Mapper.Map<IEnumerable<EmailAccountDto>>(accounts);
|
||||||
|
else
|
||||||
|
throw new NotFoundException($"No email account found for the given criteria ({(request.Id is not null ? $"Id: {request.Id}" : $"Username: {request.Username}")}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailAccounts.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validator for <see cref="GetEmailAccountQuery"/>.
|
||||||
|
/// Either <see cref="GetEmailAccountQuery.Id"/> or <see cref="GetEmailAccountQuery.Username"/> must be provided, but not both.
|
||||||
|
/// </summary>
|
||||||
|
public class GetSenderQueryValidator : AbstractValidator<GetEmailAccountQuery>
|
||||||
|
{
|
||||||
|
public GetSenderQueryValidator()
|
||||||
|
{
|
||||||
|
When(x => x.Username is not null, () =>
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Username)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Username must not be empty.")
|
||||||
|
.MaximumLength(200)
|
||||||
|
.WithMessage("Username must not exceed 200 characters.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to mark a single IMAP message as seen (read).
|
||||||
|
/// </summary>
|
||||||
|
public record MarkEmailAsSeenCommand : IRequest
|
||||||
|
{
|
||||||
|
public required GetEmailAccountQuery Account { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UID of the message to mark as seen.
|
||||||
|
/// </summary>
|
||||||
|
public required long Uid { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mailbox folder the message resides in (default: "INBOX").
|
||||||
|
/// </summary>
|
||||||
|
public string Folder { get; init; } = "INBOX";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MarkEmailAsSeenCommandHandler(IImapEmailService ImapService, ILogger<MarkEmailAsSeenCommandHandler> Logger, IRepository<EmailAccount> Repo) : IRequestHandler<MarkEmailAsSeenCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(MarkEmailAsSeenCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var accounts = await Repo.FindAsync(request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (accounts.Count() > 1)
|
||||||
|
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
|
||||||
|
|
||||||
|
var account = accounts.FirstOrDefault()
|
||||||
|
?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(account.ImapServer))
|
||||||
|
throw new BadRequestException($"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
|
||||||
|
|
||||||
|
await ImapService.MarkAsSeenAsync(account, request.Uid, request.Folder, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#if NET
|
||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query to fetch emails from an IMAP mailbox.
|
||||||
|
/// </summary>
|
||||||
|
public record ReadEmailQuery : IRequest<ReadEmailQueryResponse>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies the email account to use.
|
||||||
|
/// </summary>
|
||||||
|
public required GetEmailAccountQuery Account { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mail query used to filter and limit the emails retrieved.
|
||||||
|
/// </summary>
|
||||||
|
public MailSearchFilter Mail { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ReadEmailQueryHandler(IMapper Mapper, ILogger<ReadEmailQueryHandler> Logger, IRepository<EmailAccount> EmailAccountRepo, IReceivedEmailRepository MailRepo, IImapEmailService imapEmailService) : IRequestHandler<ReadEmailQuery, ReadEmailQueryResponse>
|
||||||
|
{
|
||||||
|
public async Task<ReadEmailQueryResponse> Handle(ReadEmailQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var accounts = await EmailAccountRepo.FindAsync(request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (accounts.Count() > 1)
|
||||||
|
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
|
||||||
|
|
||||||
|
var account = accounts.FirstOrDefault()
|
||||||
|
?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(account.ImapServer))
|
||||||
|
throw new BadRequestException(
|
||||||
|
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
|
||||||
|
|
||||||
|
var mails = await MailRepo.FindAsync(request.Mail, account, cancellationToken);
|
||||||
|
|
||||||
|
var lastSync = imapEmailService.GetLastImapSyncDate(account.Id, request.Mail.Folder);
|
||||||
|
|
||||||
|
return new ReadEmailQueryResponse
|
||||||
|
{
|
||||||
|
LastSync = lastSync,
|
||||||
|
Emails = Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a <see cref="DateFilter"/> value object.
|
||||||
|
/// </summary>
|
||||||
|
public class DateFilterValidator : AbstractValidator<DateFilter>
|
||||||
|
{
|
||||||
|
public DateFilterValidator()
|
||||||
|
{
|
||||||
|
// At least one field must be provided
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(d => d.After is not null || d.Before is not null)
|
||||||
|
.WithName("Date")
|
||||||
|
.WithMessage("DateFilter must specify at least one of: After or Before.");
|
||||||
|
|
||||||
|
// Dates must not be in the future
|
||||||
|
RuleFor(x => x.After)
|
||||||
|
.LessThanOrEqualTo(DateTime.UtcNow)
|
||||||
|
.WithMessage("Date.After must not be in the future.")
|
||||||
|
.When(x => x.After is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.Before)
|
||||||
|
.LessThanOrEqualTo(DateTime.UtcNow)
|
||||||
|
.WithMessage("Date.Before must not be in the future.")
|
||||||
|
.When(x => x.Before is not null);
|
||||||
|
|
||||||
|
// Range coherence: After must be earlier than Before
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(d => d.After!.Value < d.Before!.Value)
|
||||||
|
.WithName("Date.Range")
|
||||||
|
.WithMessage("Date.After must be earlier than Date.Before.")
|
||||||
|
.When(x => x.After is not null && x.Before is not null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a <see cref="ReadEmailQuery"/> before it is handled by <see cref="ReadEmailQueryHandler"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class FetchEmailsQueryValidator : AbstractValidator<ReadEmailQuery>
|
||||||
|
{
|
||||||
|
public FetchEmailsQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Account)
|
||||||
|
.NotNull()
|
||||||
|
.WithMessage("Account query must not be null.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Mail)
|
||||||
|
.NotNull()
|
||||||
|
.WithMessage("Mail search filter must not be null.")
|
||||||
|
.SetValidator(new MailSearchFilterValidator());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a <see cref="MailSearchFilter"/> instance before it is dispatched to the IMAP service.
|
||||||
|
/// </summary>
|
||||||
|
public class MailSearchFilterValidator : AbstractValidator<MailSearchFilter>
|
||||||
|
{
|
||||||
|
public MailSearchFilterValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Folder)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Folder must not be empty.")
|
||||||
|
.MaximumLength(255)
|
||||||
|
.WithMessage("Folder name must not exceed 255 characters.");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxCount)
|
||||||
|
.GreaterThanOrEqualTo(0)
|
||||||
|
.WithMessage("MaxCount must be 0 (unlimited) or a positive number.");
|
||||||
|
|
||||||
|
RuleFor(x => x.SubjectContains)
|
||||||
|
.MaximumLength(500)
|
||||||
|
.WithMessage("SubjectContains must not exceed 500 characters.")
|
||||||
|
.When(x => x.SubjectContains is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.SenderContains)
|
||||||
|
.MaximumLength(320)
|
||||||
|
.WithMessage("SenderContains must not exceed 320 characters.")
|
||||||
|
.When(x => x.SenderContains is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.RecipientContains)
|
||||||
|
.MaximumLength(320)
|
||||||
|
.WithMessage("RecipientContains must not exceed 320 characters.")
|
||||||
|
.When(x => x.RecipientContains is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.BodyContains)
|
||||||
|
.MaximumLength(1000)
|
||||||
|
.WithMessage("BodyContains must not exceed 1000 characters.")
|
||||||
|
.When(x => x.BodyContains is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.Uid)
|
||||||
|
.SetValidator(new UidFilterValidator()!)
|
||||||
|
.When(x => x.Uid is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.Date)
|
||||||
|
.SetValidator(new DateFilterValidator()!)
|
||||||
|
.When(x => x.Date is not null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a <see cref="UidFilter"/> value object.
|
||||||
|
/// </summary>
|
||||||
|
public class UidFilterValidator : AbstractValidator<UidFilter>
|
||||||
|
{
|
||||||
|
public UidFilterValidator()
|
||||||
|
{
|
||||||
|
// Absolute and range are mutually exclusive
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(u => u.Absolute is null || (u.Min is null && u.Max is null))
|
||||||
|
.WithName("Uid.Absolute")
|
||||||
|
.WithMessage("Uid.Absolute cannot be combined with Uid.Min or Uid.Max. Use either an exact UID or a range.");
|
||||||
|
|
||||||
|
// At least one field must be provided
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(u => u.Absolute is not null || u.Min is not null || u.Max is not null)
|
||||||
|
.WithName("Uid")
|
||||||
|
.WithMessage("UidFilter must specify at least one of: Absolute, Min, or Max.");
|
||||||
|
|
||||||
|
// All UID values must be positive
|
||||||
|
RuleFor(x => x.Absolute)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.WithMessage("Uid.Absolute must be a positive number.")
|
||||||
|
.When(x => x.Absolute is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.Min)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.WithMessage("Uid.Min must be a positive number.")
|
||||||
|
.When(x => x.Min is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.Max)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.WithMessage("Uid.Max must be a positive number.")
|
||||||
|
.When(x => x.Max is not null);
|
||||||
|
|
||||||
|
// Range coherence: Min must be less than or equal to Max
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(u => u.Min!.Value <= u.Max!.Value)
|
||||||
|
.WithName("Uid.Range")
|
||||||
|
.WithMessage("Uid.Min must be less than or equal to Uid.Max.")
|
||||||
|
.When(x => x.Min is not null && x.Max is not null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#if NET
|
||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to send an email (enqueue to RabbitMQ)
|
||||||
|
/// </summary>
|
||||||
|
public record PublishEmailCommand : IRequest<Guid>
|
||||||
|
{
|
||||||
|
public required GetEmailAccountQuery Sender { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recipient email addresses
|
||||||
|
/// </summary>
|
||||||
|
public required IEnumerable<string> Recipients { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email subject
|
||||||
|
/// </summary>
|
||||||
|
public required string Subject { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email body (HTML or plain text)
|
||||||
|
/// </summary>
|
||||||
|
public required string Body { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Is HTML email (default: true)
|
||||||
|
/// </summary>
|
||||||
|
public bool IsHtml { get; init; } = true;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
internal IEnumerable<EmailAttachmentDto> Attachments { get; private init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new command instance with the supplied attachments.
|
||||||
|
/// Called by the controller after resolving uploaded files.
|
||||||
|
/// </summary>
|
||||||
|
public PublishEmailCommand WithAttachments(IEnumerable<EmailAttachmentDto> attachments)
|
||||||
|
=> this with { Attachments = attachments };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler for PublishEmailCommand
|
||||||
|
/// Resolves the sender account via MediatR, maps to SendingEmailEvent and enqueues to RabbitMQ
|
||||||
|
/// </summary>
|
||||||
|
public class PublishEmailCommandHandler(IRepository<EmailAccount> Repo, ISendingEmailPublisher Publisher, IMapper Mapper, ILogger<PublishEmailCommandHandler> Logger) : IRequestHandler<PublishEmailCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(PublishEmailCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var senderAccounts = await Repo.FindAsync(request.Sender.Id is int id ? x => x.Id == id : x => x.Username == request.Sender.Username, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (senderAccounts.Count() > 1)
|
||||||
|
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Sender.Id is not null ? $"Id: {request.Sender.Id}" : $"Username: {request.Sender.Username}");
|
||||||
|
|
||||||
|
var senderAccount = senderAccounts.FirstOrDefault()
|
||||||
|
?? throw new NotFoundException($"No email account found for the given sender criteria (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
|
||||||
|
|
||||||
|
var email = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
|
||||||
|
|
||||||
|
// Enqueue to RabbitMQ
|
||||||
|
var sendingEmailEvent = new SendingEmailEvent()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mail = email,
|
||||||
|
QueuedAt = DateTime.Now
|
||||||
|
};
|
||||||
|
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
|
||||||
|
return sendingEmailEvent.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -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
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
using AutoMapper;
|
|
||||||
using DigitalData.MessagingService.Application.EmailAccount.Queries;
|
|
||||||
using DigitalData.MessagingService.Domain.Exceptions;
|
|
||||||
using DigitalData.MessagingService.Abstraction;
|
|
||||||
using MediatR;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command to send an email (enqueue to RabbitMQ)
|
|
||||||
/// </summary>
|
|
||||||
public record SendEmailCommand : IRequest<Guid>
|
|
||||||
{
|
|
||||||
public required GetSenderQuery Sender { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recipient email addresses
|
|
||||||
/// </summary>
|
|
||||||
public required IEnumerable<string> Recipients { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Email subject
|
|
||||||
/// </summary>
|
|
||||||
public required string Subject { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Email body (HTML or plain text)
|
|
||||||
/// </summary>
|
|
||||||
public required string Body { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Is HTML email (default: true)
|
|
||||||
/// </summary>
|
|
||||||
public bool IsHtml { get; init; } = true;
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
internal IEnumerable<EmailAttachmentContext> Attachments { get; private init; } = [];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns a new command instance with the supplied attachments.
|
|
||||||
/// Called by the controller after resolving uploaded files.
|
|
||||||
/// </summary>
|
|
||||||
public SendEmailCommand WithAttachments(IEnumerable<EmailAttachmentContext> attachments)
|
|
||||||
=> this with { Attachments = attachments };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler for SendEmailCommand
|
|
||||||
/// Resolves the sender account via MediatR, maps to SendingEmailEvent and enqueues to RabbitMQ
|
|
||||||
/// </summary>
|
|
||||||
public class SendEmailCommandHandler(ISender Sender, ISendingEmailPublisher Publisher, IMapper Mapper) : IRequestHandler<SendEmailCommand, Guid>
|
|
||||||
{
|
|
||||||
public async Task<Guid> Handle(SendEmailCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var senderAccount = await Sender.Send(request.Sender, cancellationToken)
|
|
||||||
?? throw new NotFoundException(
|
|
||||||
$"No email account found for the given sender criteria (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
|
|
||||||
|
|
||||||
var email = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
|
|
||||||
|
|
||||||
// Enqueue to RabbitMQ
|
|
||||||
var sendingEmailEvent = new SendingEmailEvent()
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
Mail = email,
|
|
||||||
QueuedAt = DateTime.Now
|
|
||||||
};
|
|
||||||
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
|
|
||||||
return sendingEmailEvent.Id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
|
#if NET
|
||||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.EmailSending.Validators;
|
namespace DigitalData.MessagingService.Application.EmailSending.Validators;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validator for SendEmailCommand
|
/// Validator for PublishEmailCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SendEmailCommandValidator : AbstractValidator<SendEmailCommand>
|
public class PublishEmailCommandValidator : AbstractValidator<PublishEmailCommand>
|
||||||
{
|
{
|
||||||
public SendEmailCommandValidator()
|
public PublishEmailCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Recipients)
|
RuleFor(x => x.Recipients)
|
||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
@@ -32,3 +33,4 @@ public class SendEmailCommandValidator : AbstractValidator<SendEmailCommand>
|
|||||||
.WithMessage("Body is required");
|
.WithMessage("Body is required");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#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
|
||||||
@@ -11,4 +11,8 @@
|
|||||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="'$(TargetFramework)' == 'net462'">
|
||||||
|
<Reference Include="System.ComponentModel.DataAnnotations" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using DigitalData.MessagingService.Domain.Enums;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for a single email account configuration.
|
||||||
|
/// </summary>
|
||||||
|
[Table("EMAIL_ACCOUNT")]
|
||||||
|
public class EmailAccount
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Logical name to identify this account (e.g. "default", "support").
|
||||||
|
/// </summary>
|
||||||
|
[Key]
|
||||||
|
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
|
[Column("ID", TypeName = "int")]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(256)]
|
||||||
|
[Column("USERNAME", TypeName = "nvarchar(256)")]
|
||||||
|
#if NET
|
||||||
|
public required string Username { get; set; }
|
||||||
|
#else
|
||||||
|
public string Username { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(512)]
|
||||||
|
[Column("PASSWORD", TypeName = "nvarchar(512)")]
|
||||||
|
#if NET
|
||||||
|
public required string Password { get; set; }
|
||||||
|
#else
|
||||||
|
public string Password { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(256)]
|
||||||
|
[Column("SMTP_SERVER", TypeName = "nvarchar(256)")]
|
||||||
|
#if NET
|
||||||
|
public required string SmtpServer { get; set; }
|
||||||
|
#else
|
||||||
|
public string SmtpServer { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Column("SMTP_PORT", TypeName = "int")]
|
||||||
|
public int SmtpPort { get; set; }
|
||||||
|
|
||||||
|
[Column("SMTP_USE_SSL", TypeName = "bit")]
|
||||||
|
public bool SmtpUseSsl { get; set; }
|
||||||
|
|
||||||
|
[Column("USE_OAUTH2", TypeName = "bit")]
|
||||||
|
public bool UseOAuth2 { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP server hostname (e.g. "imap.example.com").
|
||||||
|
/// Leave empty when this account is send-only.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(256)]
|
||||||
|
[Column("IMAP_SERVER", TypeName = "nvarchar(256)")]
|
||||||
|
public string? ImapServer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
|
||||||
|
/// </summary>
|
||||||
|
[Column("IMAP_PORT", TypeName = "int")]
|
||||||
|
public int ImapPort { get; set; } = 993;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use SSL/TLS when connecting to the IMAP server.
|
||||||
|
/// </summary>
|
||||||
|
[Column("IMAP_USE_SSL", TypeName = "bit")]
|
||||||
|
public bool ImapUseSsl { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POP3 server hostname (e.g. "pop.example.com").
|
||||||
|
/// Leave empty when this account does not use POP3.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(256)]
|
||||||
|
[Column("POP3_SERVER", TypeName = "nvarchar(256)")]
|
||||||
|
public string? Pop3Server { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POP3 server port (995 for SSL, 110 for plain).
|
||||||
|
/// </summary>
|
||||||
|
[Column("POP3_PORT", TypeName = "int")]
|
||||||
|
public int Pop3Port { get; set; } = 995;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use SSL/TLS when connecting to the POP3 server.
|
||||||
|
/// </summary>
|
||||||
|
[Column("POP3_USE_SSL", TypeName = "bit")]
|
||||||
|
public bool Pop3UseSsl { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth2 client ID (required when <see cref="UseOAuth2"/> is true).
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(512)]
|
||||||
|
[Column("OAUTH2_CLIENT_ID", TypeName = "nvarchar(512)")]
|
||||||
|
public string? OAuth2ClientId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth2 client secret (required when <see cref="UseOAuth2"/> is true).
|
||||||
|
/// For Microsoft: the app registration client secret value from Azure Portal.
|
||||||
|
/// For Google: the client secret from Google Cloud Console credentials JSON.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(512)]
|
||||||
|
[Column("OAUTH2_CLIENT_SECRET", TypeName = "nvarchar(512)")]
|
||||||
|
public string? OAuth2ClientSecret { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth2 refresh token (Google only).
|
||||||
|
/// Obtained once via the OAuth2 authorization flow (e.g. OAuth Playground).
|
||||||
|
/// Used to exchange for short-lived access tokens without user interaction.
|
||||||
|
/// Leave empty for Microsoft — MSAL handles token refresh internally.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(1024)]
|
||||||
|
[Column("OAUTH2_REFRESH_TOKEN", TypeName = "nvarchar(1024)")]
|
||||||
|
public string? OAuth2RefreshToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth2 tenant ID (e.g. for Microsoft 365: tenant GUID or "common").
|
||||||
|
/// Not required for Google — leave empty.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(256)]
|
||||||
|
[Column("OAUTH2_TENANT_ID", TypeName = "nvarchar(256)")]
|
||||||
|
public string? OAuth2TenantId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies which OAuth2 identity provider to use when <see cref="UseOAuth2"/> is true.
|
||||||
|
/// Determines which token acquisition strategy is applied.
|
||||||
|
/// </summary>
|
||||||
|
[Column("OAUTH2_PROVIDER", TypeName = "int")]
|
||||||
|
public OAuth2Provider OAuth2Provider { get; set; } = OAuth2Provider.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The protocol used to receive (sync) incoming emails.
|
||||||
|
/// When set to <see cref="IncomingProtocol.None"/>, this account is send-only and will be skipped by the sync worker.
|
||||||
|
/// Takes priority over the presence of <see cref="ImapServer"/> or <see cref="Pop3Server"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Column("INCOMING_PROTOCOL", TypeName = "int")]
|
||||||
|
public IncomingProtocol IncomingProtocol { get; set; } = IncomingProtocol.None;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single email attachment.
|
||||||
|
/// </summary>
|
||||||
|
[Table("EMAIL_ATTACHMENT")]
|
||||||
|
public sealed class EmailAttachment
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
|
[Column("ID", TypeName = "bigint")]
|
||||||
|
#if NET
|
||||||
|
public long Id { get; init; }
|
||||||
|
#else
|
||||||
|
public long Id { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display name of the attachment (e.g. "invoice.pdf").
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[MaxLength(260)]
|
||||||
|
[Column("FILE_NAME", TypeName = "nvarchar(260)")]
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
public string FileName { get; set; } = null!;
|
||||||
|
#else
|
||||||
|
public required string FileName { get; init; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raw content of the attachment.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[Column("CONTENT", TypeName = "varbinary(max)")]
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
public byte[] Content { get; set; } = null!;
|
||||||
|
#else
|
||||||
|
public required byte[] Content { get; init; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MIME content-type (e.g. "application/pdf", "image/png").
|
||||||
|
/// Defaults to "application/octet-stream" when not specified.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[MaxLength(256)]
|
||||||
|
[Column("CONTENT_TYPE", TypeName = "nvarchar(256)")]
|
||||||
|
public string ContentType { get; set; } = "application/octet-stream";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When <see langword="true"/> the attachment is embedded inline and displayed
|
||||||
|
/// directly inside the email body via a CID reference (e.g. <img src="cid:logo">).
|
||||||
|
/// When <see langword="false"/> (default) it appears as a regular downloadable attachment.
|
||||||
|
/// </summary>
|
||||||
|
[Column("IS_INLINE", TypeName = "bit")]
|
||||||
|
public bool IsInline { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Content-ID used when <see cref="IsInline"/> is <see langword="true"/>.
|
||||||
|
/// Reference it in HTML body as <c>cid:{ContentId}</c>.
|
||||||
|
/// Auto-generated from <see cref="FileName"/> when left empty.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(512)]
|
||||||
|
[Column("CONTENT_ID", TypeName = "nvarchar(512)")]
|
||||||
|
public string? ContentId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(Email))]
|
||||||
|
[Column("EMAIL_ID", TypeName = "bigint")]
|
||||||
|
#if NET
|
||||||
|
public long EmailId { get; init; }
|
||||||
|
#else
|
||||||
|
public long EmailId { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public ReceivedEmail? Email { get; init; }
|
||||||
|
#else
|
||||||
|
public ReceivedEmail? Email { get; set; }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an email message received via IMAP.
|
||||||
|
/// </summary>
|
||||||
|
[Table("RECEIVED_EMAIL")]
|
||||||
|
public sealed record ReceivedEmail
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
|
[Column("ID", TypeName = "bigint")]
|
||||||
|
#if NET
|
||||||
|
public long Id { get; init; }
|
||||||
|
#else
|
||||||
|
public long Id { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier of the message on the IMAP server (UID).
|
||||||
|
/// </summary>
|
||||||
|
[Column("UID", TypeName = "bigint")]
|
||||||
|
#if NET
|
||||||
|
public long Uid { get; init; }
|
||||||
|
#else
|
||||||
|
public long Uid { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sender address (From header).
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[MaxLength(512)]
|
||||||
|
[Column("FROM", TypeName = "nvarchar(512)")]
|
||||||
|
#if NET
|
||||||
|
public string From { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string From { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recipient addresses (To header).
|
||||||
|
/// </summary>
|
||||||
|
[Column("TO", TypeName = "nvarchar(max)")]
|
||||||
|
#if NET
|
||||||
|
public List<string> To { get; init; } = [];
|
||||||
|
#else
|
||||||
|
public List<string> To { get; set; } = [];
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CC addresses.
|
||||||
|
/// </summary>
|
||||||
|
[Column("CC", TypeName = "nvarchar(max)")]
|
||||||
|
#if NET
|
||||||
|
public List<string> Cc { get; init; } = [];
|
||||||
|
#else
|
||||||
|
public List<string> Cc { get; set; } = [];
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email subject.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(998)]
|
||||||
|
[Column("SUBJECT", TypeName = "nvarchar(998)")]
|
||||||
|
#if NET
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plain-text body (may be empty when only HTML is present).
|
||||||
|
/// </summary>
|
||||||
|
[Column("TEXT_BODY", TypeName = "nvarchar(max)")]
|
||||||
|
#if NET
|
||||||
|
public string TextBody { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string TextBody { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTML body (may be empty when only plain-text is present).
|
||||||
|
/// </summary>
|
||||||
|
[Column("HTML_BODY", TypeName = "nvarchar(max)")]
|
||||||
|
#if NET
|
||||||
|
public string HtmlBody { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string HtmlBody { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Date/time the message was sent (Date header).
|
||||||
|
/// </summary>
|
||||||
|
[Column("DATE", TypeName = "datetime2")]
|
||||||
|
#if NET
|
||||||
|
public DateTime Date { get; init; }
|
||||||
|
#else
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the message has been marked as seen/read on the server.
|
||||||
|
/// </summary>
|
||||||
|
[Column("IS_SEEN", TypeName = "bit")]
|
||||||
|
#if NET
|
||||||
|
public bool IsSeen { get; init; }
|
||||||
|
#else
|
||||||
|
public bool IsSeen { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[ForeignKey(nameof(Account))]
|
||||||
|
[Column("ACCOUNT_ID", TypeName = "int")]
|
||||||
|
#if NET
|
||||||
|
public int AccountId { get; init; }
|
||||||
|
#else
|
||||||
|
public int AccountId { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attachments included with this message.
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public IEnumerable<EmailAttachment>? Attachments { get; init; }
|
||||||
|
#else
|
||||||
|
public IEnumerable<EmailAttachment>? Attachments { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public EmailAccount? Account { get; init; }
|
||||||
|
#else
|
||||||
|
public EmailAccount? Account { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public required string Folder { get; init; }
|
||||||
|
#else
|
||||||
|
public string Folder { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace DigitalData.MessagingService.Domain.Exceptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exception thrown when a requested entity is not found.
|
||||||
|
/// </summary>
|
||||||
|
public class BadRequestException : Exception
|
||||||
|
{
|
||||||
|
public BadRequestException(string entityName, object key) : base($"{entityName} with key '{key}' was not found.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public BadRequestException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public BadRequestException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
|
using AutoMapper;
|
||||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Infrastructure.Mappings;
|
||||||
|
using DigitalData.MessagingService.Infrastructure.Persistence;
|
||||||
using DigitalData.MessagingService.Infrastructure.Queue;
|
using DigitalData.MessagingService.Infrastructure.Queue;
|
||||||
|
using DigitalData.MessagingService.Infrastructure.Repositories;
|
||||||
using DigitalData.MessagingService.Infrastructure.Services;
|
using DigitalData.MessagingService.Infrastructure.Services;
|
||||||
using DigitalData.MessagingService.Infrastructure.Services.Background;
|
using DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||||
using DigitalData.MessagingService.Publisher;
|
using DigitalData.MessagingService.Publisher;
|
||||||
using DigitalData.MessagingService.RabbitMQ;
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure;
|
namespace DigitalData.MessagingService.Infrastructure;
|
||||||
|
|
||||||
@@ -23,8 +30,25 @@ public static class DependencyInjection
|
|||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// --- External Services ---
|
// --- External Services ---
|
||||||
// Email Service (using Limilabs Mail.dll - Singleton for use in EmailSenderWorker)
|
// OAuth2 token services — provider-specific implementations registered separately,
|
||||||
services.AddSingleton<IEmailService, LimilabsEmailService>();
|
// dispatcher is the single IOAuth2TokenService consumed by all other services.
|
||||||
|
services.AddSingleton<MicrosoftOAuth2TokenService>();
|
||||||
|
services.AddSingleton<GoogleOAuth2TokenService>();
|
||||||
|
services.AddSingleton<IOAuth2TokenService, OAuth2TokenServiceDispatcher>();
|
||||||
|
|
||||||
|
services.AddHttpClient(nameof(GoogleOAuth2TokenService));
|
||||||
|
services.AddHttpClient(nameof(GoogleOAuth2AuthorizationService));
|
||||||
|
services.AddSingleton<IOAuth2AuthorizationService, GoogleOAuth2AuthorizationService>();
|
||||||
|
|
||||||
|
// Email Service - SMTP outbound (Limilabs Mail.dll) - OAuth2 aware
|
||||||
|
services.AddSingleton<LimilabsEmailService>();
|
||||||
|
services.AddSingleton<IEmailService>(sp => sp.GetRequiredService<LimilabsEmailService>());
|
||||||
|
|
||||||
|
// Email Service - IMAP inbound (Limilabs Mail.dll)
|
||||||
|
services.AddScoped<IImapEmailService, LimilabsImapEmailService>();
|
||||||
|
|
||||||
|
// Email Service - POP3 inbound (Limilabs Mail.dll)
|
||||||
|
services.AddScoped<IPop3EmailService, LimilabsPop3EmailService>();
|
||||||
|
|
||||||
// PDF Processing Service (using DevExpress.Pdf)
|
// PDF Processing Service (using DevExpress.Pdf)
|
||||||
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
|
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
|
||||||
@@ -46,7 +70,29 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
// Register Background Workers
|
// Register Background Workers
|
||||||
services.AddHostedService<AsyncInitWorker>();
|
services.AddHostedService<AsyncInitWorker>();
|
||||||
|
services.AddHostedService<EmailSyncWorker>();
|
||||||
|
|
||||||
|
services.AddSingleton<IEmailSyncService>(p =>
|
||||||
|
{
|
||||||
|
var hostedServices = p.GetRequiredService<IEnumerable<IHostedService>>();
|
||||||
|
var emailSyncWorkers = hostedServices.OfType<EmailSyncWorker>();
|
||||||
|
return emailSyncWorkers.FirstOrDefault() ?? throw new InvalidOperationException("EmailSyncWorker is not registered.");
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddMemoryCache();
|
||||||
|
|
||||||
|
// --- Database (InMemory) ---
|
||||||
|
services.AddDbContext<MessagingServiceDbContext>(options =>
|
||||||
|
options.UseInMemoryDatabase("MessagingServiceDb"));
|
||||||
|
|
||||||
|
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||||
|
|
||||||
|
services.AddScoped<IReceivedEmailRepository, ReceivedEmailRepository>();
|
||||||
|
|
||||||
|
// AutoMapper - Register entity self-mappings (T -> T) for generic repository
|
||||||
|
services.AddAutoMapper(config => config.AddMaps(typeof(EntitySelfMappingProfile).Assembly));
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,10 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
|
||||||
<PackageReference Include="Microsoft.Identity.Client" Version="4.65.0" />
|
<PackageReference Include="Microsoft.Identity.Client" Version="4.65.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.10" />
|
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.10" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Infrastructure.Mappings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AutoMapper profile that registers self-mappings (T -> T) for all domain entities.
|
||||||
|
/// This allows AutoMapper to be used uniformly in the generic repository
|
||||||
|
/// regardless of whether TDto is a DTO or the entity type itself.
|
||||||
|
/// </summary>
|
||||||
|
public class EntitySelfMappingProfile : Profile
|
||||||
|
{
|
||||||
|
public EntitySelfMappingProfile()
|
||||||
|
{
|
||||||
|
CreateMap<EmailAccount, EmailAccount>();
|
||||||
|
CreateMap<ReceivedEmail, ReceivedEmail>();
|
||||||
|
CreateMap<EmailAttachment, EmailAttachment>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,38 @@
|
|||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Persistence;
|
namespace DigitalData.MessagingService.Infrastructure.Persistence;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entity Framework Core DbContext for MessagingService.
|
/// Entity Framework Core DbContext for MessagingService.
|
||||||
/// IMPORTANT: This context maps to a LEGACY database - NO schema modifications allowed!
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MessagingServiceDbContext(DbContextOptions<MessagingServiceDbContext> options) : DbContext(options)
|
public class MessagingServiceDbContext(DbContextOptions<MessagingServiceDbContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
|
public DbSet<EmailAccount> EmailAccounts => Set<EmailAccount>();
|
||||||
|
public DbSet<ReceivedEmail> ReceivedEmails => Set<ReceivedEmail>();
|
||||||
|
public DbSet<EmailAttachment> EmailAttachments => Set<EmailAttachment>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<EmailAccount>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.Username)
|
||||||
|
.UseCollation("SQL_Latin1_General_CP1_CI_AS");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ReceivedEmail>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasMany(e => e.Attachments)
|
||||||
|
.WithOne(a => a.Email)
|
||||||
|
.HasForeignKey(a => a.EmailId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Account)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(e => e.AccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
using DigitalData.MessagingService.Abstraction;
|
|
||||||
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;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
||||||
|
|
||||||
@@ -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,28 +13,36 @@ namespace DigitalData.MessagingService.Infrastructure.Repositories;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapper) : IRepository<TEntity> where TEntity : class
|
public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapper) : IRepository<TEntity> where TEntity : class
|
||||||
{
|
{
|
||||||
private readonly DbSet<TEntity> _dbSet = Context.Set<TEntity>();
|
protected readonly DbSet<TEntity> DbSet = Context.Set<TEntity>();
|
||||||
|
|
||||||
// --- CREATE ---
|
// --- CREATE ---
|
||||||
|
|
||||||
public async Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default)
|
public async Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entity = Mapper.Map<TEntity>(dto);
|
var entity = Mapper.Map<TEntity>(dto);
|
||||||
await _dbSet.AddAsync(entity, cancellationToken);
|
await DbSet.AddAsync(entity, cancellationToken);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TEntity>> CreateRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entities = Mapper.Map<IEnumerable<TEntity>>(dtos);
|
||||||
|
await DbSet.AddRangeAsync(entities, cancellationToken);
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
// --- READ ---
|
// --- READ ---
|
||||||
|
|
||||||
public async Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.FindAsync([id], cancellationToken);
|
return await DbSet.FindAsync([id], cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
|
public async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.ToListAsync(cancellationToken);
|
return await DbSet.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<TEntity>> FindAsync(
|
public async Task<IEnumerable<TEntity>> FindAsync(
|
||||||
@@ -43,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);
|
||||||
@@ -58,14 +66,14 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
return await DbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TEntity?> FindSingleAsync(
|
public async Task<TEntity?> FindSingleAsync(
|
||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
return await DbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> CountAsync(
|
public async Task<int> CountAsync(
|
||||||
@@ -73,15 +81,68 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return predicate == null
|
return predicate == null
|
||||||
? await _dbSet.CountAsync(cancellationToken)
|
? await DbSet.CountAsync(cancellationToken)
|
||||||
: await _dbSet.CountAsync(predicate, cancellationToken);
|
: await DbSet.CountAsync(predicate, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> AnyAsync(
|
public async Task<bool> AnyAsync(
|
||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.AnyAsync(predicate, cancellationToken);
|
return await DbSet.AnyAsync(predicate, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UPSERT ---
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upsert: if no record matches the predicate, creates a new entity;
|
||||||
|
/// if one or more match, updates the FIRST match.
|
||||||
|
/// Returns the entity and a flag indicating whether it was created (true) or updated (false).
|
||||||
|
/// Auto-saves changes.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(TEntity Entity, bool Created)> UpsertAsync<TDto>(
|
||||||
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
|
TDto dto,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entity = await DbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
||||||
|
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
entity = Mapper.Map<TEntity>(dto);
|
||||||
|
await DbSet.AddAsync(entity, cancellationToken);
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
return (entity, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Mapper.Map(dto, entity);
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
return (entity, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upsert (single-safe): if no record matches the predicate, creates a new entity;
|
||||||
|
/// if exactly one matches, updates it. Throws InvalidOperationException if 2+ match.
|
||||||
|
/// Auto-saves changes.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(TEntity Entity, bool Created)> UpsertSingleAsync<TDto>(
|
||||||
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
|
TDto dto,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
||||||
|
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
entity = Mapper.Map<TEntity>(dto);
|
||||||
|
await DbSet.AddAsync(entity, cancellationToken);
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
return (entity, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Mapper.Map(dto, entity);
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
return (entity, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- UPDATE ---
|
// --- UPDATE ---
|
||||||
@@ -96,7 +157,7 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
TDto dto,
|
TDto dto,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
||||||
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
||||||
Mapper.Map(dto, entity);
|
Mapper.Map(dto, entity);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
@@ -112,13 +173,8 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
TDto dto,
|
TDto dto,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
|
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||||
|
entities.ForEach(entity => Mapper.Map(dto, entity));
|
||||||
foreach (var entity in entities)
|
|
||||||
{
|
|
||||||
Mapper.Map(dto, entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return entities.Count;
|
return entities.Count;
|
||||||
}
|
}
|
||||||
@@ -134,9 +190,9 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
||||||
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
||||||
_dbSet.Remove(entity);
|
DbSet.Remove(entity);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +205,8 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
|
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||||
|
DbSet.RemoveRange(entities);
|
||||||
_dbSet.RemoveRange(entities);
|
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return entities.Count;
|
return entities.Count;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Options;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Domain.Enums;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||||
|
|
||||||
|
public class EmailSyncWorker(IOptions<EmailAccountsOptions> Options, IServiceProvider Provider, ILogger<EmailSyncWorker> Logger, IMemoryCache Cache) : BackgroundService, IEmailSyncService
|
||||||
|
{
|
||||||
|
private static string ForcedSyncDateCacheKey => $"{nameof(EmailSyncWorker)}_TriggerSync";
|
||||||
|
|
||||||
|
private readonly string DefaultFolder = "INBOX";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signals the current <see cref="Task.Delay"/> to complete immediately,
|
||||||
|
/// causing the sync loop to start the next cycle without waiting.
|
||||||
|
/// A new TCS is created at the start of each delay so repeated triggers work correctly.
|
||||||
|
/// </summary>
|
||||||
|
private volatile TaskCompletionSource<bool> _syncTrigger = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Triggers an immediate sync cycle by completing the current delay early.
|
||||||
|
/// Safe to call from any thread or HTTP request at any time.
|
||||||
|
/// If a sync is already running, the trigger is ignored — the next cycle starts normally.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime ForceTriggerSync()
|
||||||
|
{
|
||||||
|
return Cache.GetOrCreate(ForcedSyncDateCacheKey, e =>
|
||||||
|
{
|
||||||
|
e.SetAbsoluteExpiration(TimeSpan.FromSeconds(Options.Value.ForcedSyncIntervalSeconds));
|
||||||
|
_syncTrigger.TrySetResult(true);
|
||||||
|
return DateTime.UtcNow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await UpsertSeedEmailAccount(stoppingToken);
|
||||||
|
|
||||||
|
var interval = TimeSpan.FromSeconds(Options.Value.SyncIntervalSeconds);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await using var scope = Provider.CreateAsyncScope();
|
||||||
|
|
||||||
|
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
|
||||||
|
var imapService = scope.ServiceProvider.GetRequiredService<IImapEmailService>();
|
||||||
|
var pop3Service = scope.ServiceProvider.GetRequiredService<IPop3EmailService>();
|
||||||
|
|
||||||
|
foreach (var account in await emailAccountRepo.GetAllAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
await SyncAccountAsync(account, imapService, pop3Service, stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset trigger before waiting so any TriggerSync() call during the delay is caught
|
||||||
|
_syncTrigger = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
var delay = Task.Delay(interval, stoppingToken);
|
||||||
|
var triggered = _syncTrigger.Task;
|
||||||
|
await Task.WhenAny(delay, triggered).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Propagate cancellation if the host is stopping
|
||||||
|
stoppingToken.ThrowIfCancellationRequested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SyncAccountAsync(
|
||||||
|
EmailAccount account,
|
||||||
|
IImapEmailService imapService,
|
||||||
|
IPop3EmailService pop3Service,
|
||||||
|
CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (account.IncomingProtocol == IncomingProtocol.None)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Logger.LogDebug(
|
||||||
|
"Email sync started. Account={Username}, Protocol={Protocol}.",
|
||||||
|
account.Username, account.IncomingProtocol);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = account.IncomingProtocol switch
|
||||||
|
{
|
||||||
|
IncomingProtocol.Imap or IncomingProtocol.ImapOAuth2
|
||||||
|
=> await imapService.SyncEmailsAsync(account, DefaultFolder, stoppingToken),
|
||||||
|
|
||||||
|
IncomingProtocol.Pop3 or IncomingProtocol.Pop3OAuth2
|
||||||
|
=> await pop3Service.SyncEmailsAsync(account, stoppingToken),
|
||||||
|
|
||||||
|
_ => throw new NotSupportedException(
|
||||||
|
$"IncomingProtocol '{account.IncomingProtocol}' is not supported by the sync worker.")
|
||||||
|
};
|
||||||
|
|
||||||
|
Logger.LogDebug(
|
||||||
|
"Email sync completed. Account={Username}, Protocol={Protocol}, Processed={Processed}, Failed={Failed}.",
|
||||||
|
account.Username, account.IncomingProtocol, result.ProcessedCount, result.FailedCount);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex,
|
||||||
|
"Email sync failed. Account={Username}, Protocol={Protocol}.",
|
||||||
|
account.Username, account.IncomingProtocol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpsertSeedEmailAccount(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await using var scope = Provider.CreateAsyncScope();
|
||||||
|
|
||||||
|
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
|
||||||
|
|
||||||
|
foreach (var account in Options.Value.Accounts)
|
||||||
|
await emailAccountRepo.UpsertAsync(a => a.Username == account.Username, account, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,16 @@
|
|||||||
|
using Limilabs.Client.IMAP;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||||
|
|
||||||
|
public static class ImapExtensions
|
||||||
|
{
|
||||||
|
public static async Task CloseSafelyAsync(this Imap imap)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (imap.Connected)
|
||||||
|
await imap.CloseAsync();
|
||||||
|
}
|
||||||
|
catch { /* Ignore disconnect errors */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,21 +5,17 @@ using Limilabs.Client.SMTP;
|
|||||||
using Limilabs.Mail;
|
using Limilabs.Mail;
|
||||||
using Limilabs.Mail.Headers;
|
using Limilabs.Mail.Headers;
|
||||||
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||||
using DigitalData.MessagingService.Abstraction;
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
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="EmailAccountDto.Name"/> equals <c>"default"</c>,
|
|
||||||
/// or falls back to the first account if none is named "default".
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LimilabsEmailService(
|
public class LimilabsEmailService(IOAuth2TokenService oauth2TokenService) : IEmailService
|
||||||
IEncryptionService encryptionService) : 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(
|
|||||||
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,39 +58,43 @@ public class LimilabsEmailService(
|
|||||||
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 async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccountDto 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
|
||||||
{
|
{
|
||||||
var password = smtpAccount.PasswordEncrypted ? encryptionService.Decrypt(smtpAccount.Password) : smtpAccount.Password;
|
await smtp.LoginAsync(smtpAccount.Username, smtpAccount.Password, cancellationToken);
|
||||||
|
|
||||||
await smtp.LoginAsync(smtpAccount.Username, password);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -111,7 +111,7 @@ public class LimilabsEmailService(
|
|||||||
return message.ToString();
|
return message.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentContext> attachments)
|
internal static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentDto> attachments)
|
||||||
{
|
{
|
||||||
foreach (var attachment in attachments)
|
foreach (var attachment in attachments)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||||
|
using Limilabs.Client.IMAP;
|
||||||
|
using Limilabs.Mail;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP email service using Limilabs Mail.dll.
|
||||||
|
/// Opens a fresh connection per call — stateless and thread-safe.
|
||||||
|
/// Supports both password and OAuth2 (XOAUTH2) authentication.
|
||||||
|
/// </summary>
|
||||||
|
public class LimilabsImapEmailService(
|
||||||
|
ILogger<LimilabsImapEmailService> Logger,
|
||||||
|
IRepository<ReceivedEmail> Repository,
|
||||||
|
IOAuth2TokenService oauth2TokenService,
|
||||||
|
LimilabsEmailService smtpService) : IImapEmailService
|
||||||
|
{
|
||||||
|
private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
static LimilabsImapEmailService()
|
||||||
|
{
|
||||||
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
public async Task<EmailSyncResult> SyncEmailsAsync(EmailAccount account, string folder = "INBOX", CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var imap = await OpenAsync(account, folder, cancel: cancel);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
#region Find UIDs
|
||||||
|
// Server-side: only date range; all other filters are applied in-process after cache retrieval
|
||||||
|
List<ICriterion> criterions = [];
|
||||||
|
|
||||||
|
var since = GetLastImapSyncDate(account.Id, folder);
|
||||||
|
|
||||||
|
if (since is not null && since != default)
|
||||||
|
criterions.Add(Expression.SentSince(since.Value));
|
||||||
|
|
||||||
|
var searchExpression = criterions.Count > 0 ? Expression.And([.. criterions]) : Expression.All();
|
||||||
|
|
||||||
|
var operationStartTime = DateTime.UtcNow;
|
||||||
|
|
||||||
|
List<long> uids = [.. await imap.SearchAsync(searchExpression, cancel)];
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
if (uids.Count == 0)
|
||||||
|
return new EmailSyncResult();
|
||||||
|
|
||||||
|
var emails = new List<ReceivedEmailDto>(uids.Count);
|
||||||
|
|
||||||
|
var failedCount = 0;
|
||||||
|
|
||||||
|
foreach (var uid in uids)
|
||||||
|
{
|
||||||
|
if (await Repository.AnyAsync(x => x.Uid == uid, cancel))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
cancel.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
#region Read email
|
||||||
|
var eml = await imap.GetMessageByUIDAsync(uid, cancel);
|
||||||
|
var mail = new MailBuilder().CreateFromEml(eml);
|
||||||
|
var flags = await imap.GetFlagsByUIDAsync(uid, cancel);
|
||||||
|
|
||||||
|
var attachments = new List<EmailAttachmentDto>();
|
||||||
|
|
||||||
|
foreach (var att in mail.Attachments)
|
||||||
|
{
|
||||||
|
attachments.Add(new EmailAttachmentDto
|
||||||
|
{
|
||||||
|
FileName = att.FileName ?? "attachment",
|
||||||
|
Content = att.Data,
|
||||||
|
ContentType = att.ContentType?.ToString() ?? "application/octet-stream",
|
||||||
|
IsInline = false,
|
||||||
|
ContentId = att.ContentId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var vis in mail.Visuals)
|
||||||
|
{
|
||||||
|
attachments.Add(new EmailAttachmentDto
|
||||||
|
{
|
||||||
|
FileName = vis.FileName ?? "inline",
|
||||||
|
Content = vis.Data,
|
||||||
|
ContentType = vis.ContentType?.ToString() ?? "application/octet-stream",
|
||||||
|
IsInline = true,
|
||||||
|
ContentId = vis.ContentId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var email = new ReceivedEmailDto
|
||||||
|
{
|
||||||
|
Uid = uid,
|
||||||
|
AccountId = account.Id,
|
||||||
|
From = mail.From.FirstOrDefault()?.Address ?? string.Empty,
|
||||||
|
To = [.. mail.To.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
|
||||||
|
Cc = [.. mail.Cc.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
|
||||||
|
Subject = mail.Subject ?? string.Empty,
|
||||||
|
TextBody = mail.Text ?? string.Empty,
|
||||||
|
HtmlBody = mail.Html ?? string.Empty,
|
||||||
|
Date = mail.Date ?? DateTime.MinValue,
|
||||||
|
IsSeen = flags.Contains(Flag.Seen),
|
||||||
|
Attachments = attachments,
|
||||||
|
Folder = folder
|
||||||
|
};
|
||||||
|
#endregion Read email
|
||||||
|
|
||||||
|
emails.Add(email);
|
||||||
|
|
||||||
|
SetLastImapSyncDate(account.Id, folder, operationStartTime);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failedCount += 1;
|
||||||
|
Logger.LogWarning(ex,
|
||||||
|
"Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.",
|
||||||
|
uid, folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await imap.CloseAsync(cancel);
|
||||||
|
|
||||||
|
await Repository.CreateRangeAsync(emails, cancel);
|
||||||
|
|
||||||
|
return new EmailSyncResult(ProcessedCount: emails.Count, FailedCount: failedCount);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await imap.CloseSafelyAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkAsSeenAsync(EmailAccount account, long uid, string folder = "INBOX", CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var imap = await OpenAsync(account, folder, cancel: cancel);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await imap.MarkMessageSeenByUIDAsync(uid, cancel);
|
||||||
|
await imap.CloseAsync(cancel);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await imap.CloseSafelyAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendAndAppendAsync(EmailContext context, string sentFolder = "Sent", CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Send via SMTP first
|
||||||
|
await smtpService.SendEmailAsync(context, cancellationToken);
|
||||||
|
|
||||||
|
// Then upload a copy to the IMAP Sent folder (no need to SELECT first)
|
||||||
|
using var imap = await OpenAsync(context.Sender, "INBOX", cancel: cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var builder = new MailBuilder();
|
||||||
|
builder.From.Add(new Limilabs.Mail.Headers.MailBox(context.Sender.Username));
|
||||||
|
|
||||||
|
foreach (var recipient in context.Recipients)
|
||||||
|
builder.To.Add(new Limilabs.Mail.Headers.MailBox(recipient));
|
||||||
|
|
||||||
|
builder.Subject = context.Subject;
|
||||||
|
|
||||||
|
if (context.IsHtml)
|
||||||
|
builder.Html = context.Body;
|
||||||
|
else
|
||||||
|
builder.Text = context.Body;
|
||||||
|
|
||||||
|
LimilabsEmailService.AddAttachments(builder, context.Attachments);
|
||||||
|
|
||||||
|
var mail = builder.Create();
|
||||||
|
|
||||||
|
var uploadInfo = new Limilabs.Client.IMAP.UploadMessageInfo
|
||||||
|
{
|
||||||
|
Flags = [Limilabs.Client.IMAP.Flag.Seen]
|
||||||
|
};
|
||||||
|
|
||||||
|
await imap.UploadMessageAsync(sentFolder, mail, uploadInfo, cancellationToken);
|
||||||
|
await imap.CloseAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await imap.CloseSafelyAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Imap> OpenAsync(EmailAccount account, string folder = "INBOX", bool createIfMissing = false, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
var imap = new Imap();
|
||||||
|
|
||||||
|
if (account.ImapUseSsl)
|
||||||
|
await imap.ConnectSSLAsync(account.ImapServer!, account.ImapPort, cancel: cancel);
|
||||||
|
else
|
||||||
|
await imap.ConnectAsync(account.ImapServer!, account.ImapPort, cancel: cancel);
|
||||||
|
|
||||||
|
if (account.UseOAuth2)
|
||||||
|
{
|
||||||
|
var token = await oauth2TokenService.GetAccessTokenAsync(account, cancel);
|
||||||
|
await imap.LoginOAUTH2Async(account.Username, token, cancel);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await imap.LoginAsync(account.Username, account.Password, cancel);
|
||||||
|
|
||||||
|
if (string.Equals(folder, "INBOX", StringComparison.OrdinalIgnoreCase))
|
||||||
|
await imap.SelectInboxAsync(cancel);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (createIfMissing)
|
||||||
|
{
|
||||||
|
var folders = await imap.GetFoldersAsync(cancel);
|
||||||
|
if (!folders.Any(f => string.Equals(f.Name, folder, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
await imap.CreateFolderAsync(folder, cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
await imap.SelectAsync(folder, cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return imap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region IMAP Last Sync Date Cache
|
||||||
|
private readonly ConcurrentDictionary<ImapCacheKey, DateTime> _cache = new();
|
||||||
|
|
||||||
|
private record ImapCacheKey(int AccountId, string Folder);
|
||||||
|
|
||||||
|
public DateTime? GetLastImapSyncDate(int accountId, string folder = "INBOX")
|
||||||
|
{
|
||||||
|
return _cache.GetValueOrDefault(new ImapCacheKey(accountId, folder));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetLastImapSyncDate(int accountId, string folder, DateTime date)
|
||||||
|
{
|
||||||
|
var key = new ImapCacheKey(accountId, folder);
|
||||||
|
_cache[key] = date;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using DigitalData.MessagingService.Abstraction;
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
using DigitalData.MessagingService.RabbitMQ;
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Publisher;
|
namespace DigitalData.MessagingService.Publisher;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj" />
|
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" />
|
||||||
<ProjectReference Include="..\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
|
<ProjectReference Include="..\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using RabbitMQ.Client;
|
using RabbitMQ.Client;
|
||||||
using DigitalData.MessagingService.RabbitMQ;
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
using DigitalData.MessagingService.Abstraction;
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Publisher;
|
namespace DigitalData.MessagingService.Publisher;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public class EmailAccountController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query"></param>
|
||||||
|
/// <param name="cancellationToken"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetEmailAccount([FromQuery] GetEmailAccountQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await mediator.Send(query, cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||||
|
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email sending API controller.
|
||||||
|
/// Enqueues outgoing emails to RabbitMQ for async processing.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class EmailController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public enum OnlyFilter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
HtmlBody,
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
Uid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Send
|
||||||
|
/// <summary>
|
||||||
|
/// Send an email, optionally with file attachments.
|
||||||
|
/// Omit the <c>attachments</c> field for a plain send.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="command">Email fields as form values</param>
|
||||||
|
/// <param name="attachments">Optional uploaded files</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
/// <returns>HTTP 202 Accepted with the queued event ID</returns>
|
||||||
|
[HttpPost]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> SendEmail(
|
||||||
|
[FromForm] PublishEmailCommand command,
|
||||||
|
IFormFileCollection? attachments,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var commandWithAttachments = command.WithAttachments(
|
||||||
|
await BuildAttachmentsAsync(attachments, cancellationToken));
|
||||||
|
|
||||||
|
var eventId = await mediator.Send(commandWithAttachments, cancellationToken);
|
||||||
|
return Accepted(new { Id = eventId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IEnumerable<EmailAttachmentDto>> BuildAttachmentsAsync(
|
||||||
|
IFormFileCollection? files,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (files is null || files.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var result = new List<EmailAttachmentDto>(files.Count);
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await file.CopyToAsync(ms, cancellationToken);
|
||||||
|
|
||||||
|
result.Add(new EmailAttachmentDto
|
||||||
|
{
|
||||||
|
FileName = file.FileName,
|
||||||
|
Content = ms.ToArray(),
|
||||||
|
ContentType = file.ContentType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
#endregion Send
|
||||||
|
|
||||||
|
#region Receive
|
||||||
|
/// <summary>
|
||||||
|
/// Fetch emails from an IMAP mailbox.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">Query parameters for filtering and fetching emails from the IMAP mailbox.</param>
|
||||||
|
/// <param name="only"></param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>HTTP 200 with list of received emails.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> FetchEmails([FromQuery] ReadEmailQuery query, [FromQuery] OnlyFilter? only = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var res = await mediator.Send(query, cancellationToken);
|
||||||
|
|
||||||
|
if(!res.Emails.Any())
|
||||||
|
return NotFound("No emails found matching the specified criteria.");
|
||||||
|
|
||||||
|
if (only == OnlyFilter.HtmlBody)
|
||||||
|
{
|
||||||
|
if (res.Emails.FirstOrDefault()?.HtmlBody is string htmlBody)
|
||||||
|
return Content(htmlBody, "text/html");
|
||||||
|
else
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
else if (only == OnlyFilter.Uid)
|
||||||
|
return Ok(res.Emails.Select(e => e.Uid).ToList());
|
||||||
|
else
|
||||||
|
return Ok(res);
|
||||||
|
}
|
||||||
|
#endregion Receive
|
||||||
|
|
||||||
|
#region IMAP Send
|
||||||
|
/// <summary>
|
||||||
|
/// Send an email using IMAP account credentials (via RabbitMQ queue).
|
||||||
|
/// After the message is sent, it is appended to the IMAP Sent Items folder.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="command">Email fields as form values</param>
|
||||||
|
/// <param name="attachments">Optional uploaded files</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
/// <returns>HTTP 202 Accepted with the queued event ID</returns>
|
||||||
|
[HttpPost("imap/send")]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> SendEmailViaImap(
|
||||||
|
[FromForm] PublishEmailViaImapCommand command,
|
||||||
|
IFormFileCollection? attachments,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var commandWithAttachments = command.WithAttachments(
|
||||||
|
await BuildAttachmentsAsync(attachments, cancellationToken));
|
||||||
|
|
||||||
|
var eventId = await mediator.Send(commandWithAttachments, cancellationToken);
|
||||||
|
return Accepted(new { Id = eventId });
|
||||||
|
}
|
||||||
|
#endregion IMAP Send
|
||||||
|
|
||||||
|
#region POP3 Receive
|
||||||
|
/// <summary>
|
||||||
|
/// Fetch emails from a POP3 mailbox.
|
||||||
|
/// Triggers an on-demand POP3 sync before returning results.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">Query parameters for filtering and fetching emails from the POP3 mailbox.</param>
|
||||||
|
/// <param name="only">Optional filter to return only specific fields.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>HTTP 200 with list of received emails.</returns>
|
||||||
|
[HttpGet("pop3")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> FetchEmailsViaPop3(
|
||||||
|
[FromQuery] ReadEmailViaPop3Query query,
|
||||||
|
[FromQuery] OnlyFilter? only = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var res = await mediator.Send(query, cancellationToken);
|
||||||
|
|
||||||
|
if (!res.Emails.Any())
|
||||||
|
return NotFound("No emails found matching the specified criteria.");
|
||||||
|
|
||||||
|
if (only == OnlyFilter.HtmlBody)
|
||||||
|
{
|
||||||
|
if (res.Emails.FirstOrDefault()?.HtmlBody is string htmlBody)
|
||||||
|
return Content(htmlBody, "text/html");
|
||||||
|
else
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
else if (only == OnlyFilter.Uid)
|
||||||
|
return Ok(res.Emails.Select(e => e.Uid).ToList());
|
||||||
|
else
|
||||||
|
return Ok(res);
|
||||||
|
}
|
||||||
|
#endregion POP3 Receive
|
||||||
|
|
||||||
|
#region OAuth2 Send
|
||||||
|
/// <summary>
|
||||||
|
/// Send an email via SMTP using OAuth2 authentication (via RabbitMQ queue).
|
||||||
|
/// The account must have <c>UseOAuth2 = true</c> and valid OAuth2 credentials configured.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="command">Email fields as form values</param>
|
||||||
|
/// <param name="attachments">Optional uploaded files</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
/// <returns>HTTP 202 Accepted with the queued event ID</returns>
|
||||||
|
[HttpPost("oauth2/send")]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> SendEmailViaOAuth2(
|
||||||
|
[FromForm] PublishEmailViaOAuth2Command command,
|
||||||
|
IFormFileCollection? attachments,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var commandWithAttachments = command.WithAttachments(
|
||||||
|
await BuildAttachmentsAsync(attachments, cancellationToken));
|
||||||
|
|
||||||
|
var eventId = await mediator.Send(commandWithAttachments, cancellationToken);
|
||||||
|
return Accepted(new { Id = eventId });
|
||||||
|
}
|
||||||
|
#endregion OAuth2 Send
|
||||||
|
|
||||||
|
#region OAuth2 Receive
|
||||||
|
/// <summary>
|
||||||
|
/// Fetch emails from an IMAP mailbox using OAuth2 authentication.
|
||||||
|
/// The account must have <c>UseOAuth2 = true</c> and valid OAuth2 credentials configured.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">Query parameters for filtering and fetching emails.</param>
|
||||||
|
/// <param name="only">Optional filter to return only specific fields.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>HTTP 200 with list of received emails.</returns>
|
||||||
|
[HttpGet("oauth2/imap")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> FetchEmailsViaOAuth2(
|
||||||
|
[FromQuery] ReadEmailViaOAuth2Query query,
|
||||||
|
[FromQuery] OnlyFilter? only = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var res = await mediator.Send(query, cancellationToken);
|
||||||
|
|
||||||
|
if (!res.Emails.Any())
|
||||||
|
return NotFound("No emails found matching the specified criteria.");
|
||||||
|
|
||||||
|
if (only == OnlyFilter.HtmlBody)
|
||||||
|
{
|
||||||
|
if (res.Emails.FirstOrDefault()?.HtmlBody is string htmlBody)
|
||||||
|
return Content(htmlBody, "text/html");
|
||||||
|
else
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
else if (only == OnlyFilter.Uid)
|
||||||
|
return Ok(res.Emails.Select(e => e.Uid).ToList());
|
||||||
|
else
|
||||||
|
return Ok(res);
|
||||||
|
}
|
||||||
|
#endregion OAuth2 Receive
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
|
||||||
using DigitalData.MessagingService.Abstraction;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Email sending API controller.
|
|
||||||
/// Enqueues outgoing emails to RabbitMQ for async processing.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class EmailsController(IMediator mediator) : ControllerBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Send an email, optionally with file attachments.
|
|
||||||
/// Omit the <c>attachments</c> field for a plain send.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">Email fields as form values</param>
|
|
||||||
/// <param name="attachments">Optional uploaded files</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>HTTP 202 Accepted with the queued event ID</returns>
|
|
||||||
[HttpPost]
|
|
||||||
[Consumes("multipart/form-data")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
||||||
public async Task<IActionResult> SendEmail(
|
|
||||||
[FromForm] SendEmailCommand command,
|
|
||||||
IFormFileCollection? attachments,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var commandWithAttachments = command.WithAttachments(
|
|
||||||
await BuildAttachmentsAsync(attachments, cancellationToken));
|
|
||||||
|
|
||||||
var eventId = await mediator.Send(commandWithAttachments, cancellationToken);
|
|
||||||
return Accepted(new { Id = eventId });
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IEnumerable<EmailAttachmentContext>> BuildAttachmentsAsync(
|
|
||||||
IFormFileCollection? files,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (files is null || files.Count == 0)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
var result = new List<EmailAttachmentContext>(files.Count);
|
|
||||||
|
|
||||||
foreach (var file in files)
|
|
||||||
{
|
|
||||||
using var ms = new MemoryStream();
|
|
||||||
await file.CopyToAsync(ms, cancellationToken);
|
|
||||||
|
|
||||||
result.Add(new EmailAttachmentContext
|
|
||||||
{
|
|
||||||
FileName = file.FileName,
|
|
||||||
Content = ms.ToArray(),
|
|
||||||
ContentType = file.ContentType
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,89 +1,82 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DigitalData.MessagingService.Domain.Exceptions;
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.API.Middleware;
|
namespace DigitalData.MessagingService.API.Middleware;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Global exception handling middleware
|
/// Global exception handling middleware
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ExceptionHandlingMiddleware
|
public class ExceptionHandlingMiddleware(RequestDelegate Next, ILogger<ExceptionHandlingMiddleware> Logger)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
|
private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly RequestDelegate _next;
|
/// <summary>
|
||||||
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
|
///
|
||||||
|
/// </summary>
|
||||||
public ExceptionHandlingMiddleware(
|
/// <param name="context"></param>
|
||||||
RequestDelegate next,
|
/// <returns></returns>
|
||||||
ILogger<ExceptionHandlingMiddleware> logger)
|
|
||||||
{
|
|
||||||
_next = next;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _next(context);
|
await Next(context);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await HandleExceptionAsync(context, ex);
|
context.Response.ContentType = "application/json";
|
||||||
|
|
||||||
|
var (statusCode, message) = ex switch
|
||||||
|
{
|
||||||
|
NotFoundException notFoundEx =>
|
||||||
|
(HttpStatusCode.NotFound, notFoundEx.Message),
|
||||||
|
|
||||||
|
BadRequestException badRequestEx =>
|
||||||
|
(HttpStatusCode.BadRequest, badRequestEx.Message),
|
||||||
|
|
||||||
|
AuthenticationFailedException authEx =>
|
||||||
|
(HttpStatusCode.Unauthorized, authEx.Message),
|
||||||
|
|
||||||
|
ValidationException validationEx =>
|
||||||
|
(HttpStatusCode.BadRequest, FormatValidationErrors(validationEx)),
|
||||||
|
|
||||||
|
_ => (HttpStatusCode.InternalServerError, "An internal server error occurred")
|
||||||
|
};
|
||||||
|
|
||||||
|
context.Response.StatusCode = (int)statusCode;
|
||||||
|
|
||||||
|
// Log the exception
|
||||||
|
if (statusCode == HttpStatusCode.InternalServerError)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Unhandled exception: {Message}", ex.Message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Exception handled: {StatusCode} - {Message}",
|
||||||
|
statusCode, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = new
|
||||||
|
{
|
||||||
|
StatusCode = (int)statusCode,
|
||||||
|
Message = message,
|
||||||
|
DetailedMessage = statusCode == HttpStatusCode.InternalServerError
|
||||||
|
? ex.Message
|
||||||
|
: null,
|
||||||
|
Timestamp = DateTime.Now
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(response, _jsonSerializerOptions);
|
||||||
|
|
||||||
|
await context.Response.WriteAsync(json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
private static string FormatValidationErrors(ValidationException exception)
|
||||||
{
|
|
||||||
context.Response.ContentType = "application/json";
|
|
||||||
|
|
||||||
var (statusCode, message) = exception switch
|
|
||||||
{
|
|
||||||
NotFoundException notFoundEx =>
|
|
||||||
(HttpStatusCode.NotFound, notFoundEx.Message),
|
|
||||||
|
|
||||||
AuthenticationFailedException authEx =>
|
|
||||||
(HttpStatusCode.Unauthorized, authEx.Message),
|
|
||||||
|
|
||||||
FluentValidation.ValidationException validationEx =>
|
|
||||||
(HttpStatusCode.BadRequest, FormatValidationErrors(validationEx)),
|
|
||||||
|
|
||||||
_ => (HttpStatusCode.InternalServerError, "An internal server error occurred")
|
|
||||||
};
|
|
||||||
|
|
||||||
context.Response.StatusCode = (int)statusCode;
|
|
||||||
|
|
||||||
// Log the exception
|
|
||||||
if (statusCode == HttpStatusCode.InternalServerError)
|
|
||||||
{
|
|
||||||
_logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning(exception, "Exception handled: {StatusCode} - {Message}",
|
|
||||||
statusCode, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = new
|
|
||||||
{
|
|
||||||
StatusCode = (int)statusCode,
|
|
||||||
Message = message,
|
|
||||||
DetailedMessage = statusCode == HttpStatusCode.InternalServerError
|
|
||||||
? exception.Message
|
|
||||||
: null,
|
|
||||||
Timestamp = DateTime.Now
|
|
||||||
};
|
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(response, _jsonSerializerOptions);
|
|
||||||
|
|
||||||
await context.Response.WriteAsync(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatValidationErrors(FluentValidation.ValidationException exception)
|
|
||||||
{
|
{
|
||||||
var errors = exception.Errors
|
var errors = exception.Errors
|
||||||
.Select(e => $"{e.PropertyName}: {e.ErrorMessage}")
|
.Select(e => $"{e.PropertyName}: {e.ErrorMessage}")
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -35,9 +35,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</ProjectReference>
|
|
||||||
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj">
|
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</ProjectReference>
|
</ProjectReference>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
using DigitalData.MessagingService.Publisher;
|
using DigitalData.MessagingService.Publisher;
|
||||||
using DigitalData.MessagingService.Abstraction;
|
|
||||||
using DigitalData.MessagingService.RabbitMQ;
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Client;
|
namespace DigitalData.MessagingService.Client;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
using DigitalData.MessagingService.Client;
|
using DigitalData.MessagingService.Client;
|
||||||
using DigitalData.MessagingService.Abstraction;
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Tests.Integration;
|
namespace DigitalData.MessagingService.Tests.Integration;
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ public sealed class EmailSenderTests
|
|||||||
{
|
{
|
||||||
var email = new EmailContext
|
var email = new EmailContext
|
||||||
{
|
{
|
||||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||||
Recipients = ["hakanttek@gmail.com"],
|
Recipients = ["hakanttek@gmail.com"],
|
||||||
Subject = "EmailSender.Send Integration Test",
|
Subject = "EmailSender.Send Integration Test",
|
||||||
Body = "<p>Sent via EmailSender static client.</p>",
|
Body = "<p>Sent via EmailSender static client.</p>",
|
||||||
@@ -94,7 +95,7 @@ public sealed class EmailSenderTests
|
|||||||
{
|
{
|
||||||
var email = new EmailContext
|
var email = new EmailContext
|
||||||
{
|
{
|
||||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||||
Recipients = ["hakanttek@gmail.com"],
|
Recipients = ["hakanttek@gmail.com"],
|
||||||
Subject = "Plain Text Test",
|
Subject = "Plain Text Test",
|
||||||
Body = "This is a plain text email.",
|
Body = "This is a plain text email.",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
using DigitalData.MessagingService.Client;
|
using DigitalData.MessagingService.Client;
|
||||||
using DigitalData.MessagingService.Abstraction;
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Tests.Integration;
|
namespace DigitalData.MessagingService.Tests.Integration;
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ public sealed class EmailSenderUrlOverloadTests
|
|||||||
{
|
{
|
||||||
var email = new EmailContext
|
var email = new EmailContext
|
||||||
{
|
{
|
||||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||||
Recipients = ["url-overload-test@example.com"],
|
Recipients = ["url-overload-test@example.com"],
|
||||||
Subject = "URL Overload Integration Test",
|
Subject = "URL Overload Integration Test",
|
||||||
Body = "<p>Sent after URL-based connection.</p>",
|
Body = "<p>Sent after URL-based connection.</p>",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DigitalData.MessagingService.Publisher;
|
using DigitalData.MessagingService.Publisher;
|
||||||
using DigitalData.MessagingService.Abstraction;
|
|
||||||
using DigitalData.MessagingService.RabbitMQ;
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
using RabbitMQ.Client;
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Tests.Integration;
|
namespace DigitalData.MessagingService.Tests.Integration;
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ public sealed class SendingEmailPublisherTests : IAsyncDisposable
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Mail = new EmailContext
|
Mail = new EmailContext
|
||||||
{
|
{
|
||||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||||
Recipients = ["test@example.com"],
|
Recipients = ["test@example.com"],
|
||||||
Subject = "Integration Test - EnqueueAsync",
|
Subject = "Integration Test - EnqueueAsync",
|
||||||
Body = "<p>Hello from integration test.</p>",
|
Body = "<p>Hello from integration test.</p>",
|
||||||
@@ -68,8 +68,8 @@ public sealed class SendingEmailPublisherTests : IAsyncDisposable
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Mail = new EmailContext
|
Mail = new EmailContext
|
||||||
{
|
{
|
||||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||||
Recipients = new List<string> { $"recipient{i}@example.com" },
|
Recipients = [$"recipient{i}@example.com"],
|
||||||
Subject = $"Integration Test - Batch #{i}",
|
Subject = $"Integration Test - Batch #{i}",
|
||||||
Body = $"Batch message {i}",
|
Body = $"Batch message {i}",
|
||||||
IsHtml = false,
|
IsHtml = false,
|
||||||
@@ -95,7 +95,7 @@ public sealed class SendingEmailPublisherTests : IAsyncDisposable
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Mail = new EmailContext
|
Mail = new EmailContext
|
||||||
{
|
{
|
||||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||||
Recipients = ["depth-test@example.com"],
|
Recipients = ["depth-test@example.com"],
|
||||||
Subject = "Integration Test - GetQueueDepth",
|
Subject = "Integration Test - GetQueueDepth",
|
||||||
Body = "Queue depth test",
|
Body = "Queue depth test",
|
||||||
@@ -122,7 +122,7 @@ public sealed class SendingEmailPublisherTests : IAsyncDisposable
|
|||||||
Id = id,
|
Id = id,
|
||||||
Mail = new EmailContext
|
Mail = new EmailContext
|
||||||
{
|
{
|
||||||
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||||
Recipients = new List<string> { "serialize@example.com" },
|
Recipients = new List<string> { "serialize@example.com" },
|
||||||
Subject = "Serialization Test",
|
Subject = "Serialization Test",
|
||||||
Body = "<strong>Bold</strong>",
|
Body = "<strong>Bold</strong>",
|
||||||
|
|||||||
Reference in New Issue
Block a user