diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/MicrosoftOAuth2TokenService.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/MicrosoftOAuth2TokenService.cs
index bd9cf0d..e777b5f 100644
--- a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/MicrosoftOAuth2TokenService.cs
+++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/MicrosoftOAuth2TokenService.cs
@@ -1,5 +1,6 @@
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;
@@ -8,17 +9,45 @@ namespace DigitalData.MessagingService.Infrastructure.Services;
///
/// Acquires OAuth2 access tokens using Microsoft Identity (MSAL) with the client credentials flow.
-/// Supports Microsoft 365 / Exchange Online accounts.
+/// Supports Microsoft 365 / Exchange Online accounts (IMAP, POP3, SMTP via XOAUTH2).
/// Tokens are cached in-memory and reused until 5 minutes before expiry.
+///
+/// Required Azure App Registration permissions (Application, not Delegated):
+///
+/// - IMAP.AccessAsApp — read mail via IMAP
+/// - SMTP.SendAsApp — send mail via SMTP
+/// - POP.AccessAsApp — read mail via POP3 (optional)
+///
+///
+///
+/// The single scope https://outlook.office365.com/.default is used intentionally.
+/// The .default suffix instructs Azure AD to issue a token covering all
+/// Application permissions that have been pre-consented in the App Registration,
+/// so there is no need to list individual scopes here.
+///
+///
+///
+/// OAuth2TenantId accepts either a tenant GUID, a domain name
+/// (e.g. didaloghe or didaloghe.onmicrosoft.com), or "common".
+///
///
public class MicrosoftOAuth2TokenService(ILogger Logger) : IOAuth2TokenService
{
+ ///
+ /// .default requests all Application permissions pre-consented in Azure Portal.
+ /// This covers IMAP.AccessAsApp, SMTP.SendAsApp and POP.AccessAsApp in one token.
+ ///
private static readonly string[] Scopes = ["https://outlook.office365.com/.default"];
private readonly ConcurrentDictionary _cache = new();
public async Task 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);
@@ -33,6 +62,19 @@ public class MicrosoftOAuth2TokenService(ILogger Lo
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)