Compare commits
27 Commits
d01a0ceaa6
...
993d9cfea0
| 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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -373,3 +373,4 @@ FodyWeavers.xsd
|
||||
/src/DigitalData.MessagingService.API/appsettings.Secrets.json
|
||||
/src/presentation/DigitalData.MessagingService.API/appsettings.Secrets.json
|
||||
/src/presentation/DigitalData.MessagingService.API/appsettings.Secrets.json
|
||||
/src/presentation/DigitalData.MessagingService.API/oauth.htek0100@gmail.com.json
|
||||
|
||||
300
README.md
300
README.md
@@ -1,2 +1,302 @@
|
||||
# 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,3 +1,5 @@
|
||||
using DigitalData.MessagingService.Domain.Enums;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||
|
||||
/// <summary>
|
||||
@@ -43,4 +45,33 @@ public record EmailAccountDto
|
||||
/// 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;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using DigitalData.MessagingService.Domain.Enums;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||
|
||||
/// <summary>
|
||||
@@ -44,4 +46,35 @@ public record EmailAccountModificationDto
|
||||
/// 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;
|
||||
}
|
||||
@@ -19,4 +19,22 @@ public record SendingEmailEvent
|
||||
#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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
|
||||
public interface IEmailSyncService
|
||||
{
|
||||
public DateTime ForceTriggerSync();
|
||||
}
|
||||
@@ -37,5 +37,14 @@ public interface IImapEmailService
|
||||
/// <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
|
||||
@@ -20,9 +20,24 @@ public class EmailMappingProfile : Profile
|
||||
.ForMember(dest => dest.Sender, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.Attachments, opt => opt.MapFrom(src => src.Attachments));
|
||||
|
||||
// PublishEmailViaImapCommand -> EmailContext
|
||||
CreateMap<PublishEmailViaImapCommand, EmailContext>()
|
||||
.ForMember(dest => dest.Sender, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.Attachments, opt => opt.MapFrom(src => src.Attachments));
|
||||
|
||||
// PublishEmailViaOAuth2Command -> EmailContext
|
||||
CreateMap<PublishEmailViaOAuth2Command, EmailContext>()
|
||||
.ForMember(dest => dest.Sender, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.Attachments, opt => opt.MapFrom(src => src.Attachments));
|
||||
|
||||
// EmailAccountDto -> EmailAccount
|
||||
CreateMap<EmailAccount, EmailAccountDto>();
|
||||
CreateMap<EmailAccountModificationDto, EmailAccount>();
|
||||
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>()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#if NET
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Common.Mappings;
|
||||
|
||||
/// <summary>
|
||||
/// AutoMapper profile for OAuth2 operations.
|
||||
/// Registers EmailAccount self-mapping so UpdateSingleAsync can update an account
|
||||
/// entity using another EmailAccount instance (e.g. after setting OAuth2RefreshToken).
|
||||
/// </summary>
|
||||
public class OAuth2MappingProfile : Profile
|
||||
{
|
||||
public OAuth2MappingProfile()
|
||||
{
|
||||
CreateMap<EmailAccount, EmailAccount>();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -24,5 +24,11 @@ public class EmailAccountsOptions
|
||||
/// Defaults to 300 seconds (5 minutes).
|
||||
/// </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
|
||||
@@ -15,7 +15,7 @@ namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
|
||||
/// <summary>
|
||||
/// Query to fetch emails from an IMAP mailbox.
|
||||
/// </summary>
|
||||
public record ReadEmailQuery : IRequest<IEnumerable<ReceivedEmailDto>>
|
||||
public record ReadEmailQuery : IRequest<ReadEmailQueryResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifies the email account to use.
|
||||
@@ -28,9 +28,9 @@ public record ReadEmailQuery : IRequest<IEnumerable<ReceivedEmailDto>>
|
||||
public MailSearchFilter Mail { get; init; } = new();
|
||||
}
|
||||
|
||||
public class ReadEmailQueryHandler(IMapper Mapper, ILogger<ReadEmailQueryHandler> Logger, IRepository<EmailAccount> EmailAccountRepo, IReceivedEmailRepository MailRepo) : IRequestHandler<ReadEmailQuery, IEnumerable<ReceivedEmailDto>>
|
||||
public class ReadEmailQueryHandler(IMapper Mapper, ILogger<ReadEmailQueryHandler> Logger, IRepository<EmailAccount> EmailAccountRepo, IReceivedEmailRepository MailRepo, IImapEmailService imapEmailService) : IRequestHandler<ReadEmailQuery, ReadEmailQueryResponse>
|
||||
{
|
||||
public async Task<IEnumerable<ReceivedEmailDto>> Handle(ReadEmailQuery request, CancellationToken cancellationToken)
|
||||
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);
|
||||
|
||||
@@ -45,7 +45,14 @@ public class ReadEmailQueryHandler(IMapper Mapper, ILogger<ReadEmailQueryHandler
|
||||
$"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);
|
||||
return Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails);
|
||||
|
||||
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,81 @@
|
||||
#if NET
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command to send an email using IMAP account credentials (queued via RabbitMQ).
|
||||
/// After processing, the sent message is appended to the IMAP Sent Items folder.
|
||||
/// </summary>
|
||||
public record PublishEmailViaImapCommand : IRequest<Guid>
|
||||
{
|
||||
public required GetEmailAccountQuery Sender { get; init; }
|
||||
|
||||
public required IEnumerable<string> Recipients { get; init; }
|
||||
|
||||
public required string Subject { get; init; }
|
||||
|
||||
public required string Body { get; init; }
|
||||
|
||||
public bool IsHtml { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// IMAP folder to which the sent message will be appended (default: "Sent").
|
||||
/// </summary>
|
||||
public string SentFolder { get; init; } = "Sent";
|
||||
|
||||
[JsonIgnore]
|
||||
internal IEnumerable<EmailAttachmentDto> Attachments { get; private init; } = [];
|
||||
|
||||
public PublishEmailViaImapCommand WithAttachments(IEnumerable<EmailAttachmentDto> attachments)
|
||||
=> this with { Attachments = attachments };
|
||||
}
|
||||
|
||||
public class PublishEmailViaImapCommandHandler(
|
||||
IRepository<EmailAccount> Repo,
|
||||
ISendingEmailPublisher Publisher,
|
||||
IMapper Mapper,
|
||||
ILogger<PublishEmailViaImapCommandHandler> Logger) : IRequestHandler<PublishEmailViaImapCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(PublishEmailViaImapCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var senderAccounts = await Repo.FindAsync(
|
||||
request.Sender.Id is int id ? x => x.Id == id : x => x.Username == request.Sender.Username,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (senderAccounts.Count() > 1)
|
||||
Logger.LogWarning("Multiple email accounts found ({Criteria}). Using first.",
|
||||
request.Sender.Id is not null ? $"Id: {request.Sender.Id}" : $"Username: {request.Sender.Username}");
|
||||
|
||||
var senderAccount = senderAccounts.FirstOrDefault()
|
||||
?? throw new NotFoundException($"No email account found (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(senderAccount.ImapServer))
|
||||
throw new BadRequestException(
|
||||
$"IMAP is not configured for account '{senderAccount.Username}' (Id: {senderAccount.Id}). Set ImapServer in EmailAccounts configuration.");
|
||||
|
||||
var emailContext = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
|
||||
|
||||
var sendingEmailEvent = new SendingEmailEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mail = emailContext,
|
||||
QueuedAt = DateTime.Now,
|
||||
SentFolder = request.SentFolder,
|
||||
UseImapAppend = true
|
||||
};
|
||||
|
||||
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
|
||||
return sendingEmailEvent.Id;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,79 @@
|
||||
#if NET
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command to send an email via SMTP using OAuth2 authentication (queued via RabbitMQ).
|
||||
/// The account must have <c>UseOAuth2 = true</c> and valid OAuth2 credentials configured.
|
||||
/// </summary>
|
||||
public record PublishEmailViaOAuth2Command : IRequest<Guid>
|
||||
{
|
||||
public required GetEmailAccountQuery Sender { get; init; }
|
||||
|
||||
public required IEnumerable<string> Recipients { get; init; }
|
||||
|
||||
public required string Subject { get; init; }
|
||||
|
||||
public required string Body { get; init; }
|
||||
|
||||
public bool IsHtml { get; init; } = true;
|
||||
|
||||
[JsonIgnore]
|
||||
internal IEnumerable<EmailAttachmentDto> Attachments { get; private init; } = [];
|
||||
|
||||
public PublishEmailViaOAuth2Command WithAttachments(IEnumerable<EmailAttachmentDto> attachments)
|
||||
=> this with { Attachments = attachments };
|
||||
}
|
||||
|
||||
public class PublishEmailViaOAuth2CommandHandler(
|
||||
IRepository<EmailAccount> Repo,
|
||||
ISendingEmailPublisher Publisher,
|
||||
IMapper Mapper,
|
||||
ILogger<PublishEmailViaOAuth2CommandHandler> Logger) : IRequestHandler<PublishEmailViaOAuth2Command, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(PublishEmailViaOAuth2Command request, CancellationToken cancellationToken)
|
||||
{
|
||||
var senderAccounts = await Repo.FindAsync(
|
||||
request.Sender.Id is int id ? x => x.Id == id : x => x.Username == request.Sender.Username,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (senderAccounts.Count() > 1)
|
||||
Logger.LogWarning("Multiple email accounts found ({Criteria}). Using first.",
|
||||
request.Sender.Id is not null ? $"Id: {request.Sender.Id}" : $"Username: {request.Sender.Username}");
|
||||
|
||||
var senderAccount = senderAccounts.FirstOrDefault()
|
||||
?? throw new NotFoundException($"No email account found (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
|
||||
|
||||
if (!senderAccount.UseOAuth2)
|
||||
throw new BadRequestException(
|
||||
$"Account '{senderAccount.Username}' (Id: {senderAccount.Id}) is not configured for OAuth2. Set UseOAuth2 = true.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(senderAccount.OAuth2ClientId) ||
|
||||
string.IsNullOrWhiteSpace(senderAccount.OAuth2ClientSecret))
|
||||
throw new BadRequestException(
|
||||
$"OAuth2 credentials (ClientId, ClientSecret) are not configured for account '{senderAccount.Username}'.");
|
||||
|
||||
var emailContext = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
|
||||
|
||||
var sendingEmailEvent = new SendingEmailEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Mail = emailContext,
|
||||
QueuedAt = DateTime.Now
|
||||
};
|
||||
|
||||
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
|
||||
return sendingEmailEvent.Id;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,72 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.OAuth2.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges a Google OAuth2 authorization code for a refresh token
|
||||
/// and persists it on the email account.
|
||||
/// Call this from the OAuth2 callback endpoint after the user grants consent.
|
||||
/// </summary>
|
||||
public record CompleteOAuth2AuthorizationCommand : IRequest<CompleteOAuth2AuthorizationResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// ID of the email account being authorized.
|
||||
/// </summary>
|
||||
public required int AccountId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The authorization code received from the OAuth2 provider callback.
|
||||
/// </summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The redirect URI used in the original authorization request.
|
||||
/// </summary>
|
||||
public required string RedirectUri { get; init; }
|
||||
}
|
||||
|
||||
public record CompleteOAuth2AuthorizationResult
|
||||
{
|
||||
public required string Username { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
public class CompleteOAuth2AuthorizationCommandHandler(
|
||||
IRepository<EmailAccount> Repo,
|
||||
IOAuth2AuthorizationService AuthService,
|
||||
ILogger<CompleteOAuth2AuthorizationCommandHandler> Logger) : IRequestHandler<CompleteOAuth2AuthorizationCommand, CompleteOAuth2AuthorizationResult>
|
||||
{
|
||||
public async Task<CompleteOAuth2AuthorizationResult> Handle(CompleteOAuth2AuthorizationCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var account = await Repo.GetByIdAsync(request.AccountId, cancellationToken)
|
||||
?? throw new NotFoundException($"No email account found with Id: {request.AccountId}.");
|
||||
|
||||
Logger.LogInformation("Exchanging OAuth2 authorization code for account '{Username}' (Id: {Id}).",
|
||||
account.Username, account.Id);
|
||||
|
||||
var refreshToken = await AuthService.ExchangeCodeForRefreshTokenAsync(
|
||||
account, request.Code, request.RedirectUri, cancellationToken);
|
||||
|
||||
// Set the refresh token directly on the tracked entity — no AutoMapper needed
|
||||
account.OAuth2RefreshToken = refreshToken;
|
||||
await Repo.UpdateSingleAsync(a => a.Id == account.Id, account, cancellationToken);
|
||||
|
||||
Logger.LogInformation("OAuth2 refresh token saved for account '{Username}' (Id: {Id}).",
|
||||
account.Username, account.Id);
|
||||
|
||||
return new CompleteOAuth2AuthorizationResult
|
||||
{
|
||||
Username = account.Username,
|
||||
Success = true
|
||||
};
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Enums;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using MediatR;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.OAuth2.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the authorization URL the user must visit to grant OAuth2 access.
|
||||
/// Supported for providers that require user-delegated access (e.g. Google).
|
||||
/// </summary>
|
||||
public record GetOAuth2AuthorizationUrlQuery : IRequest<string>
|
||||
{
|
||||
/// <summary>
|
||||
/// ID of the email account to authorize.
|
||||
/// </summary>
|
||||
public required string Username { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The redirect URI registered with the OAuth2 provider.
|
||||
/// Must exactly match the URI configured in the provider's developer console.
|
||||
/// </summary>
|
||||
public required string RedirectUri { get; init; }
|
||||
}
|
||||
|
||||
public class GetOAuth2AuthorizationUrlQueryHandler(
|
||||
IRepository<EmailAccount> Repo,
|
||||
IOAuth2AuthorizationService AuthService) : IRequestHandler<GetOAuth2AuthorizationUrlQuery, string>
|
||||
{
|
||||
public async Task<string> Handle(GetOAuth2AuthorizationUrlQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var account = await Repo.FindFirstAsync(e => e.Username == request.Username, cancellationToken)
|
||||
?? throw new NotFoundException($"No email account found with username: {request.Username}.");
|
||||
|
||||
if (account.OAuth2Provider == OAuth2Provider.None)
|
||||
throw new BadRequestException(
|
||||
$"Account '{account.Username}' (Id: {account.Id}) has no OAuth2 provider configured. " +
|
||||
$"Set OAuth2Provider to a supported value (e.g. Google).");
|
||||
|
||||
return AuthService.GetAuthorizationUrl(account, request.RedirectUri);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using DigitalData.MessagingService.Domain.Enums;
|
||||
|
||||
namespace DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
@@ -72,4 +73,73 @@ public class EmailAccount
|
||||
/// </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,34 @@
|
||||
namespace DigitalData.MessagingService.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the protocol used to receive (sync) incoming emails for an account.
|
||||
/// </summary>
|
||||
public enum IncomingProtocol
|
||||
{
|
||||
/// <summary>
|
||||
/// No incoming protocol configured — account is send-only.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Sync emails via IMAP using username/password authentication.
|
||||
/// </summary>
|
||||
Imap = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Sync emails via POP3 using username/password authentication.
|
||||
/// </summary>
|
||||
Pop3 = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Sync emails via IMAP using OAuth2 (XOAUTH2) authentication.
|
||||
/// Requires <c>UseOAuth2 = true</c> and valid OAuth2 credentials.
|
||||
/// </summary>
|
||||
ImapOAuth2 = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Sync emails via POP3 using OAuth2 (XOAUTH2) authentication.
|
||||
/// Requires <c>UseOAuth2 = true</c> and valid OAuth2 credentials.
|
||||
/// </summary>
|
||||
Pop3OAuth2 = 4,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace DigitalData.MessagingService.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the OAuth2 identity provider used to acquire access tokens.
|
||||
/// Only relevant when <c>UseOAuth2 = true</c>.
|
||||
/// </summary>
|
||||
public enum OAuth2Provider
|
||||
{
|
||||
/// <summary>
|
||||
/// No OAuth2 provider — account uses plain username/password authentication.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Microsoft identity platform (Azure AD / Microsoft 365 / Exchange Online).
|
||||
/// Uses MSAL with the client credentials flow against
|
||||
/// <c>https://login.microsoftonline.com/{tenant}</c>.
|
||||
/// Requires <c>OAuth2ClientId</c>, <c>OAuth2ClientSecret</c> and <c>OAuth2TenantId</c>.
|
||||
/// </summary>
|
||||
Microsoft = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Google identity platform (Gmail / Google Workspace).
|
||||
/// Uses the service-account or OAuth2 client credentials flow against
|
||||
/// <c>https://oauth2.googleapis.com/token</c>.
|
||||
/// Requires <c>OAuth2ClientId</c> and <c>OAuth2ClientSecret</c>.
|
||||
/// </summary>
|
||||
Google = 2,
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure;
|
||||
|
||||
@@ -29,13 +30,26 @@ public static class DependencyInjection
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// --- External Services ---
|
||||
// Email Service - SMTP outbound (Limilabs Mail.dll)
|
||||
services.AddSingleton<IEmailService, LimilabsEmailService>();
|
||||
// OAuth2 token services — provider-specific implementations registered separately,
|
||||
// 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)
|
||||
// Fresh connection per call — stateless and thread-safe.
|
||||
services.AddScoped<IImapEmailService, LimilabsImapEmailService>();
|
||||
|
||||
|
||||
// Email Service - POP3 inbound (Limilabs Mail.dll)
|
||||
services.AddScoped<IPop3EmailService, LimilabsPop3EmailService>();
|
||||
|
||||
// PDF Processing Service (using DevExpress.Pdf)
|
||||
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
|
||||
|
||||
@@ -58,6 +72,13 @@ public static class DependencyInjection
|
||||
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) ---
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<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.Identity.Client" Version="4.65.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
@@ -31,7 +32,7 @@ public sealed class SendingEmailConsumer : IAsyncDisposable
|
||||
/// </summary>
|
||||
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;
|
||||
_queueName = queueName;
|
||||
@@ -41,8 +42,6 @@ public sealed class SendingEmailConsumer : IAsyncDisposable
|
||||
{
|
||||
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);
|
||||
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
@@ -57,8 +56,14 @@ public sealed class SendingEmailConsumer : IAsyncDisposable
|
||||
|
||||
if (oMailEvent is not null)
|
||||
{
|
||||
// Send email via SMTP (SMTP config is injected in IEmailService via IOptions)
|
||||
await emailService.SendEmailAsync(oMailEvent.Mail, args.CancellationToken);
|
||||
if (oMailEvent.UseImapAppend)
|
||||
{
|
||||
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
|
||||
await channel.BasicAckAsync(args.DeliveryTag, false, args.CancellationToken);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -8,8 +9,6 @@ namespace DigitalData.MessagingService.Infrastructure.Queue;
|
||||
/// <summary>
|
||||
/// Manages a pool of <see cref="SendingEmailConsumer"/> instances that compete for messages
|
||||
/// on the same RabbitMQ queue (competing consumers pattern).
|
||||
/// Each consumer owns a dedicated channel, so they process messages fully in parallel
|
||||
/// without any shared locking or synchronization primitives.
|
||||
/// </summary>
|
||||
public sealed class SendingEmailConsumerPool : IAsyncDisposable
|
||||
{
|
||||
@@ -20,6 +19,7 @@ public sealed class SendingEmailConsumerPool : IAsyncDisposable
|
||||
public SendingEmailConsumerPool(
|
||||
IOptions<RabbitMqConfiguration> config,
|
||||
IEmailService emailService,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
RabbitMqConnectionFactory cnnFactory,
|
||||
ILogger<SendingEmailConsumerPool>? logger = null,
|
||||
ILogger<SendingEmailConsumer>? consumerLogger = null)
|
||||
@@ -29,7 +29,7 @@ public sealed class SendingEmailConsumerPool : IAsyncDisposable
|
||||
|
||||
_consumers = [.. Enumerable
|
||||
.Range(0, _concurrency)
|
||||
.Select(_ => new SendingEmailConsumer(config.Value.QueueName, emailService, cnnFactory, consumerLogger))];
|
||||
.Select(_ => new SendingEmailConsumer(config.Value.QueueName, emailService, scopeFactory, cnnFactory, consumerLogger))];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,6 +2,8 @@ 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;
|
||||
@@ -9,10 +11,34 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||
|
||||
public class EmailSyncWorker(IOptions<EmailAccountsOptions> Options, IServiceProvider Provider, ILogger<EmailSyncWorker> Logger) : BackgroundService
|
||||
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);
|
||||
@@ -21,40 +47,74 @@ public class EmailSyncWorker(IOptions<EmailAccountsOptions> Options, IServicePro
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
using var scope = Provider.CreateAsyncScope();
|
||||
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))
|
||||
if (account.ImapServer is not null)
|
||||
{
|
||||
Logger.LogDebug("Email synchronization has started for account {username} in folder {folder}.", account.Username, DefaultFolder);
|
||||
|
||||
try
|
||||
{
|
||||
var res = await imapService.SyncEmailsAsync(account, DefaultFolder, stoppingToken);
|
||||
Logger.LogDebug("Email synchronization has completed for account {username} in folder {folder}. Processed: {processedCount}, Failed: {failedCount}", account.Username, DefaultFolder, res.ProcessedCount, res.FailedCount);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
// Log the exception or handle it as needed
|
||||
Logger.LogError(ex, "Error syncing emails for account {username}", account.Username);
|
||||
}
|
||||
}
|
||||
{
|
||||
await SyncAccountAsync(account, imapService, pop3Service, stoppingToken);
|
||||
}
|
||||
|
||||
await Task.Delay(interval, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpsertSeedEmailAccount(CancellationToken stoppingToken)
|
||||
private async Task SyncAccountAsync(
|
||||
EmailAccount account,
|
||||
IImapEmailService imapService,
|
||||
IPop3EmailService pop3Service,
|
||||
CancellationToken stoppingToken)
|
||||
{
|
||||
using var scope = Provider.CreateAsyncScope();
|
||||
if (account.IncomingProtocol == IncomingProtocol.None)
|
||||
return;
|
||||
|
||||
Logger.LogDebug(
|
||||
"Email sync started. Account={Username}, Protocol={Protocol}.",
|
||||
account.Username, account.IncomingProtocol);
|
||||
|
||||
try
|
||||
{
|
||||
var result = account.IncomingProtocol switch
|
||||
{
|
||||
IncomingProtocol.Imap or IncomingProtocol.ImapOAuth2
|
||||
=> await imapService.SyncEmailsAsync(account, DefaultFolder, stoppingToken),
|
||||
|
||||
IncomingProtocol.Pop3 or IncomingProtocol.Pop3OAuth2
|
||||
=> await pop3Service.SyncEmailsAsync(account, stoppingToken),
|
||||
|
||||
_ => throw new NotSupportedException(
|
||||
$"IncomingProtocol '{account.IncomingProtocol}' is not supported by the sync worker.")
|
||||
};
|
||||
|
||||
Logger.LogDebug(
|
||||
"Email sync completed. Account={Username}, Protocol={Protocol}, Processed={Processed}, Failed={Failed}.",
|
||||
account.Username, account.IncomingProtocol, result.ProcessedCount, result.FailedCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex,
|
||||
"Email sync failed. Account={Username}, Protocol={Protocol}.",
|
||||
account.Username, account.IncomingProtocol);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpsertSeedEmailAccount(CancellationToken stoppingToken)
|
||||
{
|
||||
await using var scope = Provider.CreateAsyncScope();
|
||||
|
||||
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
|
||||
|
||||
// init seed email accounts if not exist
|
||||
foreach (var account in Options.Value.Accounts)
|
||||
await emailAccountRepo.UpsertAsync(a => a.Username == account.Username, account, stoppingToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Web;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements the Google OAuth2 authorization code flow.
|
||||
/// Generates consent URLs and exchanges authorization codes for refresh tokens.
|
||||
/// This is a one-time setup operation per account — the resulting refresh token
|
||||
/// is stored in <see cref="EmailAccount.OAuth2RefreshToken"/> and reused by
|
||||
/// <see cref="GoogleOAuth2TokenService"/> for all subsequent token acquisitions.
|
||||
/// </summary>
|
||||
public class GoogleOAuth2AuthorizationService(
|
||||
ILogger<GoogleOAuth2AuthorizationService> Logger,
|
||||
IHttpClientFactory HttpClientFactory) : IOAuth2AuthorizationService
|
||||
{
|
||||
private const string AuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
|
||||
private const string Scope = "https://mail.google.com/";
|
||||
|
||||
public string GetAuthorizationUrl(EmailAccount account, string redirectUri)
|
||||
{
|
||||
if (account.OAuth2Provider != OAuth2Provider.Google)
|
||||
throw new InvalidOperationException(
|
||||
$"GoogleOAuth2AuthorizationService cannot handle provider '{account.OAuth2Provider}'. Expected '{OAuth2Provider.Google}'.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId))
|
||||
throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id}).");
|
||||
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
query["client_id"] = account.OAuth2ClientId;
|
||||
query["redirect_uri"] = redirectUri;
|
||||
query["response_type"] = "code";
|
||||
query["scope"] = Scope;
|
||||
query["access_type"] = "offline"; // ensures refresh_token is returned
|
||||
query["prompt"] = "consent"; // forces refresh_token even if already authorized
|
||||
query["state"] = account.Id.ToString();
|
||||
|
||||
var url = $"{AuthEndpoint}?{query}";
|
||||
|
||||
Logger.LogDebug("Generated Google OAuth2 authorization URL for account '{Username}' (Id: {Id}).",
|
||||
account.Username, account.Id);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
public async Task<string> ExchangeCodeForRefreshTokenAsync(
|
||||
EmailAccount account,
|
||||
string code,
|
||||
string redirectUri,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (account.OAuth2Provider != OAuth2Provider.Google)
|
||||
throw new InvalidOperationException(
|
||||
$"GoogleOAuth2AuthorizationService cannot handle provider '{account.OAuth2Provider}'. Expected '{OAuth2Provider.Google}'.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId))
|
||||
throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id}).");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2ClientSecret))
|
||||
throw new InvalidOperationException($"OAuth2ClientSecret is not configured for account '{account.Username}' (Id: {account.Id}).");
|
||||
|
||||
Logger.LogDebug("Exchanging authorization code for refresh token. Account='{Username}' (Id: {Id}).",
|
||||
account.Username, account.Id);
|
||||
|
||||
var httpClient = HttpClientFactory.CreateClient(nameof(GoogleOAuth2AuthorizationService));
|
||||
|
||||
var requestBody = new FormUrlEncodedContent([
|
||||
new("client_id", account.OAuth2ClientId),
|
||||
new("client_secret", account.OAuth2ClientSecret),
|
||||
new("code", code),
|
||||
new("redirect_uri", redirectUri),
|
||||
new("grant_type", "authorization_code"),
|
||||
]);
|
||||
|
||||
var response = await httpClient.PostAsync(TokenEndpoint, requestBody, cancellationToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException(
|
||||
$"Google token endpoint returned {(int)response.StatusCode} while exchanging authorization code " +
|
||||
$"for account '{account.Username}'. Response: {responseBody}");
|
||||
|
||||
var tokenResponse = await response.Content.ReadFromJsonAsync<GoogleTokenResponse>(cancellationToken: cancellationToken)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize Google token response for account '{account.Username}'.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tokenResponse.RefreshToken))
|
||||
throw new InvalidOperationException(
|
||||
$"Google did not return a refresh_token for account '{account.Username}'. " +
|
||||
"Ensure 'access_type=offline' and 'prompt=consent' are set in the authorization URL, " +
|
||||
"and that the user has not previously authorized this app without revoking access.");
|
||||
|
||||
Logger.LogInformation("Successfully obtained Google refresh token for account '{Username}' (Id: {Id}).",
|
||||
account.Username, account.Id);
|
||||
|
||||
return tokenResponse.RefreshToken;
|
||||
}
|
||||
|
||||
private sealed class GoogleTokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("refresh_token")]
|
||||
public string? RefreshToken { get; init; }
|
||||
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int ExpiresIn { get; init; }
|
||||
|
||||
[JsonPropertyName("token_type")]
|
||||
public string TokenType { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Acquires OAuth2 access tokens for Google accounts (Gmail / Google Workspace)
|
||||
/// using the OAuth2 client credentials flow against <c>https://oauth2.googleapis.com/token</c>.
|
||||
/// Tokens are cached in-memory and reused until 5 minutes before expiry.
|
||||
///
|
||||
/// <para><b>Required Google Cloud configuration:</b></para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Create a project in <see href="https://console.cloud.google.com/"/>.</item>
|
||||
/// <item>Enable the <b>Gmail API</b>.</item>
|
||||
/// <item>Create an <b>OAuth 2.0 Client ID</b> (type: Web application or Desktop).</item>
|
||||
/// <item>Set <c>OAuth2ClientId</c> and <c>OAuth2ClientSecret</c> in configuration.</item>
|
||||
/// <item>Leave <c>OAuth2TenantId</c> empty — Google does not use tenant IDs.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Note: Google's OAuth2 for IMAP/SMTP requires user-level access (Delegated),
|
||||
/// not application-level (Client Credentials). A valid <b>refresh token</b> must
|
||||
/// be stored in <c>OAuth2ClientSecret</c> after the initial user authorization flow.
|
||||
/// The token endpoint is used here to exchange the refresh token for an access token.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class GoogleOAuth2TokenService(
|
||||
ILogger<GoogleOAuth2TokenService> Logger,
|
||||
IHttpClientFactory HttpClientFactory) : IOAuth2TokenService
|
||||
{
|
||||
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
|
||||
|
||||
private readonly ConcurrentDictionary<int, (string Token, DateTimeOffset Expiry)> _cache = new();
|
||||
|
||||
public async Task<string> GetAccessTokenAsync(EmailAccount account, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (account.OAuth2Provider != OAuth2Provider.Google)
|
||||
throw new InvalidOperationException(
|
||||
$"GoogleOAuth2TokenService cannot handle provider '{account.OAuth2Provider}' " +
|
||||
$"for account '{account.Username}'. Expected '{OAuth2Provider.Google}'.");
|
||||
|
||||
if (_cache.TryGetValue(account.Id, out var cached) && cached.Expiry > DateTimeOffset.UtcNow.AddMinutes(5))
|
||||
{
|
||||
Logger.LogDebug("Returning cached Google OAuth2 token for account {Username} (Id: {Id}).", account.Username, account.Id);
|
||||
return cached.Token;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId))
|
||||
throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id}).");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2ClientSecret))
|
||||
throw new InvalidOperationException(
|
||||
$"OAuth2ClientSecret is not configured for account '{account.Username}' (Id: {account.Id}).");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2RefreshToken))
|
||||
throw new InvalidOperationException(
|
||||
$"OAuth2RefreshToken is not configured for account '{account.Username}' (Id: {account.Id}). " +
|
||||
"Obtain a refresh token via https://developers.google.com/oauthplayground " +
|
||||
"using scope 'https://mail.google.com/' and set it in OAuth2RefreshToken.");
|
||||
|
||||
Logger.LogDebug("Acquiring new Google OAuth2 token for account {Username} (Id: {Id}).", account.Username, account.Id);
|
||||
|
||||
var httpClient = HttpClientFactory.CreateClient(nameof(GoogleOAuth2TokenService));
|
||||
|
||||
var requestBody = new FormUrlEncodedContent([
|
||||
new("client_id", account.OAuth2ClientId),
|
||||
new("client_secret", account.OAuth2ClientSecret),
|
||||
new("grant_type", "refresh_token"),
|
||||
new("refresh_token", account.OAuth2RefreshToken),
|
||||
]);
|
||||
|
||||
var response = await httpClient.PostAsync(TokenEndpoint, requestBody, cancellationToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException(
|
||||
$"Google token endpoint returned {(int)response.StatusCode} for account '{account.Username}'. Response: {responseBody}");
|
||||
|
||||
var tokenResponse = await response.Content.ReadFromJsonAsync<GoogleTokenResponse>(cancellationToken: cancellationToken)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize Google token response for account '{account.Username}'.");
|
||||
|
||||
var expiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn);
|
||||
_cache[account.Id] = (tokenResponse.AccessToken, expiry);
|
||||
|
||||
return tokenResponse.AccessToken;
|
||||
}
|
||||
|
||||
private sealed class GoogleTokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int ExpiresIn { get; init; }
|
||||
|
||||
[JsonPropertyName("token_type")]
|
||||
public string TokenType { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,10 @@ namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Email service using Limilabs Mail.dll for SMTP operations (send-only).
|
||||
/// Commercial-grade library with superior Exchange support.
|
||||
/// SMTP configuration is injected via IOptions<EmailAccountsOptions> from appsettings.json.
|
||||
/// Uses the first account in the list whose <see cref="EmailAccount.Name"/> equals <c>"default"</c>,
|
||||
/// or falls back to the first account if none is named "default".
|
||||
/// Supports both plain/STARTTLS and OAuth2 (XOAUTH2) authentication.
|
||||
/// </summary>
|
||||
public class LimilabsEmailService() : IEmailService
|
||||
public class LimilabsEmailService(IOAuth2TokenService oauth2TokenService) : IEmailService
|
||||
{
|
||||
// Register encoding provider for Limilabs (requires windows-1252 and other code pages)
|
||||
static LimilabsEmailService()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
@@ -31,7 +27,7 @@ public class LimilabsEmailService() : IEmailService
|
||||
ISendMessageResult? result = null;
|
||||
try
|
||||
{
|
||||
await ConnectAndAuthenticateSmtpAsync(smtp, context.Sender);
|
||||
await ConnectAndAuthenticateSmtpAsync(smtp, context.Sender, cancellationToken);
|
||||
|
||||
var builder = new MailBuilder();
|
||||
builder.From.Add(new MailBox(context.Sender.Username));
|
||||
@@ -62,37 +58,43 @@ public class LimilabsEmailService() : IEmailService
|
||||
catch (Limilabs.Client.ServerException ex)
|
||||
{
|
||||
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();
|
||||
throw new InvalidOperationException($"Failed to send email via SMTP server. {ErrorMessageBuilder(result)}", ex);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await smtp.CloseSafelyAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccount smtpAccount)
|
||||
internal async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccount smtpAccount, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (smtpAccount.SmtpUseSsl)
|
||||
{
|
||||
await smtp.ConnectSSLAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort);
|
||||
await smtp.ConnectSSLAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await smtp.ConnectAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort);
|
||||
await smtp.ConnectAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort, cancellationToken);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
await smtp.LoginAsync(smtpAccount.Username, smtpAccount.Password);
|
||||
await smtp.LoginAsync(smtpAccount.Username, smtpAccount.Password, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ErrorMessageBuilder(ISendMessageResult? result = null)
|
||||
internal static string ErrorMessageBuilder(ISendMessageResult? result = null)
|
||||
{
|
||||
if(result is null || result.GeneralErrors.Count == 0)
|
||||
return string.Empty;
|
||||
@@ -109,7 +111,7 @@ public class LimilabsEmailService() : IEmailService
|
||||
return message.ToString();
|
||||
}
|
||||
|
||||
private static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentDto> attachments)
|
||||
internal static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentDto> attachments)
|
||||
{
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
|
||||
@@ -14,8 +14,13 @@ 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) : IImapEmailService
|
||||
public class LimilabsImapEmailService(
|
||||
ILogger<LimilabsImapEmailService> Logger,
|
||||
IRepository<ReceivedEmail> Repository,
|
||||
IOAuth2TokenService oauth2TokenService,
|
||||
LimilabsEmailService smtpService) : IImapEmailService
|
||||
{
|
||||
private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString();
|
||||
|
||||
@@ -27,7 +32,7 @@ public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger,
|
||||
// Public API
|
||||
public async Task<EmailSyncResult> SyncEmailsAsync(EmailAccount account, string folder = "INBOX", CancellationToken cancel = default)
|
||||
{
|
||||
using var imap = await OpenAsync(account, folder, cancel);
|
||||
using var imap = await OpenAsync(account, folder, cancel: cancel);
|
||||
try
|
||||
{
|
||||
#region Find UIDs
|
||||
@@ -138,7 +143,7 @@ public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger,
|
||||
|
||||
public async Task MarkAsSeenAsync(EmailAccount account, long uid, string folder = "INBOX", CancellationToken cancel = default)
|
||||
{
|
||||
using var imap = await OpenAsync(account, folder, cancel);
|
||||
using var imap = await OpenAsync(account, folder, cancel: cancel);
|
||||
try
|
||||
{
|
||||
await imap.MarkMessageSeenByUIDAsync(uid, cancel);
|
||||
@@ -151,7 +156,48 @@ public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger,
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<Imap> OpenAsync(EmailAccount account, string folder = "INBOX", CancellationToken cancel = default)
|
||||
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();
|
||||
|
||||
@@ -160,12 +206,27 @@ public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger,
|
||||
else
|
||||
await imap.ConnectAsync(account.ImapServer!, account.ImapPort, cancel: cancel);
|
||||
|
||||
await imap.LoginAsync(account.Username, account.Password, cancel);
|
||||
if (account.UseOAuth2)
|
||||
{
|
||||
var token = await oauth2TokenService.GetAccessTokenAsync(account, cancel);
|
||||
await imap.LoginOAUTH2Async(account.Username, token, cancel);
|
||||
}
|
||||
else
|
||||
await imap.LoginAsync(account.Username, account.Password, cancel);
|
||||
|
||||
if (string.Equals(folder, "INBOX", StringComparison.OrdinalIgnoreCase))
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -94,22 +94,148 @@ public class EmailController(IMediator mediator) : ControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> FetchEmails([FromQuery] ReadEmailQuery query, [FromQuery] OnlyFilter? only = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var emails = await mediator.Send(query, cancellationToken);
|
||||
var res = await mediator.Send(query, cancellationToken);
|
||||
|
||||
if(!emails.Any())
|
||||
if(!res.Emails.Any())
|
||||
return NotFound("No emails found matching the specified criteria.");
|
||||
|
||||
if (only == OnlyFilter.HtmlBody)
|
||||
{
|
||||
if (emails.FirstOrDefault()?.HtmlBody is string htmlBody)
|
||||
if (res.Emails.FirstOrDefault()?.HtmlBody is string htmlBody)
|
||||
return Content(htmlBody, "text/html");
|
||||
else
|
||||
return NotFound();
|
||||
}
|
||||
else if (only == OnlyFilter.Uid)
|
||||
return Ok(emails.Select(e => e.Uid).ToList());
|
||||
return Ok(res.Emails.Select(e => e.Uid).ToList());
|
||||
else
|
||||
return Ok(emails);
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using DigitalData.MessagingService.Application.OAuth2.Commands;
|
||||
using DigitalData.MessagingService.Application.OAuth2.Queries;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DigitalData.MessagingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the OAuth2 authorization code flow for email accounts.
|
||||
/// Use these endpoints to authorize Google accounts without manually
|
||||
/// obtaining refresh tokens via external tools.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class OAuth2Controller(IMediator mediator, IHttpContextAccessor httpContextAccessor) : ControllerBase
|
||||
{
|
||||
#region Google Authorization Flow
|
||||
|
||||
/// <summary>
|
||||
/// Step 1: Redirects the user to Google's consent screen for the specified email account.
|
||||
/// After consent, Google redirects to <c>/api/oauth2/google/callback</c> with an authorization code.
|
||||
/// </summary>
|
||||
/// <param name="username">the email account to authorize.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>HTTP 302 redirect to Google consent screen.</returns>
|
||||
[HttpGet("google/authorize/{username}")]
|
||||
[ProducesResponseType(StatusCodes.Status302Found)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> AuthorizeGoogle([FromRoute] string username, CancellationToken cancellationToken)
|
||||
{
|
||||
var redirectUri = BuildCallbackUri();
|
||||
|
||||
var authUrl = await mediator.Send(new GetOAuth2AuthorizationUrlQuery
|
||||
{
|
||||
Username = username,
|
||||
RedirectUri = redirectUri
|
||||
}, cancellationToken);
|
||||
|
||||
return Redirect(authUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step 2: Google callback endpoint. Exchanges the authorization code for a refresh token
|
||||
/// and saves it to the email account. This endpoint is called automatically by Google
|
||||
/// after the user grants consent — do not call it directly.
|
||||
/// </summary>
|
||||
/// <param name="code">Authorization code provided by Google.</param>
|
||||
/// <param name="state">Account ID passed as state in the authorization request.</param>
|
||||
/// <param name="error">Error message if the user denied access.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>HTTP 200 on success, HTTP 400 if access was denied.</returns>
|
||||
[HttpGet("google/callback")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> GoogleCallback(
|
||||
[FromQuery] string? code,
|
||||
[FromQuery] string? state,
|
||||
[FromQuery] string? error,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
return BadRequest(new { Error = error, Message = "User denied access or an error occurred during Google OAuth2 authorization." });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return BadRequest(new { Error = "missing_code", Message = "Authorization code not received from Google." });
|
||||
|
||||
if (!int.TryParse(state, out var accountId))
|
||||
return BadRequest(new { Error = "invalid_state", Message = "Invalid state parameter — could not determine account ID." });
|
||||
|
||||
var redirectUri = BuildCallbackUri();
|
||||
|
||||
var result = await mediator.Send(new CompleteOAuth2AuthorizationCommand
|
||||
{
|
||||
AccountId = accountId,
|
||||
Code = code,
|
||||
RedirectUri = redirectUri
|
||||
}, cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
result.Success,
|
||||
result.Username,
|
||||
Message = $"Google OAuth2 authorization completed. Refresh token saved for account '{result.Username}'."
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private string BuildCallbackUri()
|
||||
{
|
||||
var request = httpContextAccessor.HttpContext!.Request;
|
||||
return $"{request.Scheme}://{request.Host}/api/oauth2/google/callback";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DigitalData.MessagingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controls email synchronization operations.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class SyncController(IEmailSyncService emailSyncService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Triggers an immediate email sync cycle for all configured accounts,
|
||||
/// skipping the remaining interval wait.
|
||||
/// If a sync is already in progress, the next cycle will start immediately after it completes.
|
||||
/// </summary>
|
||||
/// <returns>HTTP 202 Accepted.</returns>
|
||||
[HttpPost("trigger")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public IActionResult TriggerSync()
|
||||
{
|
||||
var syncTime = emailSyncService.ForceTriggerSync();
|
||||
return Accepted(new { syncTime });
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,38 @@ try
|
||||
builder.Services.AddControllers();
|
||||
|
||||
builder.Services.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
|
||||
builder.Services.AddSerilogUi(options =>
|
||||
@@ -89,14 +120,30 @@ try
|
||||
// Add Serilog request logging
|
||||
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()
|
||||
|| app.Configuration.GetValue<bool>("Swagger:Enabled");
|
||||
|
||||
if (swaggerEnabled)
|
||||
{
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user