feat(infrastructure): implement MicrosoftOAuth2TokenService and LimilabsPop3EmailService

This commit is contained in:
2026-08-17 10:15:26 +02:00
parent 822c7b1352
commit a13380016d
2 changed files with 218 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Domain.Entities;
using Limilabs.Client.POP3;
using Limilabs.Mail;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Text;
namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// POP3 email service using Limilabs Mail.dll.
/// Opens a fresh connection per call — stateless and thread-safe.
/// Supports both password and OAuth2 (XOAUTH2) authentication.
/// </summary>
public class LimilabsPop3EmailService(
ILogger<LimilabsPop3EmailService> Logger,
IRepository<ReceivedEmail> Repository,
IOAuth2TokenService oauth2TokenService) : IPop3EmailService
{
private const string Pop3Folder = "INBOX";
static LimilabsPop3EmailService()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
public async Task<EmailSyncResult> SyncEmailsAsync(EmailAccount account, CancellationToken cancellationToken = default)
{
using var pop3 = await OpenAsync(account, cancellationToken);
try
{
// POP3 uses string UIDs (UIDL command)
var uidMap = await pop3.GetUIDAsync(cancellationToken);
if (uidMap.Count == 0)
{
await pop3.CloseAsync(false, cancellationToken);
return new EmailSyncResult();
}
var emails = new List<ReceivedEmailDto>(uidMap.Count);
var failedCount = 0;
foreach (var kvp in uidMap)
{
// kvp.Key = message number (long), kvp.Value = POP3 UID (string)
var msgNumber = kvp.Key;
var pop3Uid = kvp.Value;
// Use a stable numeric hash of the string UID for storage (ReceivedEmail.Uid is long)
var numericUid = (long)Math.Abs((uint)pop3Uid.GetHashCode());
if (await Repository.AnyAsync(x => x.Uid == numericUid && x.AccountId == account.Id, cancellationToken))
continue;
cancellationToken.ThrowIfCancellationRequested();
try
{
var eml = await pop3.GetMessageByNumberAsync(msgNumber, cancellationToken);
var mail = new MailBuilder().CreateFromEml(eml);
var attachments = new List<EmailAttachmentDto>();
foreach (var att in mail.Attachments)
{
attachments.Add(new EmailAttachmentDto
{
FileName = att.FileName ?? "attachment",
Content = att.Data,
ContentType = att.ContentType?.ToString() ?? "application/octet-stream",
IsInline = false,
ContentId = att.ContentId
});
}
foreach (var vis in mail.Visuals)
{
attachments.Add(new EmailAttachmentDto
{
FileName = vis.FileName ?? "inline",
Content = vis.Data,
ContentType = vis.ContentType?.ToString() ?? "application/octet-stream",
IsInline = true,
ContentId = vis.ContentId
});
}
var email = new ReceivedEmailDto
{
Uid = numericUid,
AccountId = account.Id,
From = mail.From.FirstOrDefault()?.Address ?? string.Empty,
To = [.. mail.To.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
Cc = [.. mail.Cc.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
Subject = mail.Subject ?? string.Empty,
TextBody = mail.Text ?? string.Empty,
HtmlBody = mail.Html ?? string.Empty,
Date = mail.Date ?? DateTime.MinValue,
IsSeen = false, // POP3 has no seen/unseen flags
Attachments = attachments,
Folder = Pop3Folder
};
emails.Add(email);
SetLastPop3SyncDate(account.Id, DateTime.UtcNow);
}
catch (Exception ex)
{
failedCount += 1;
Logger.LogWarning(ex,
"Failed to fetch POP3 message number={Number} for account {Username}. Skipping.",
msgNumber, account.Username);
}
}
// Close without deleting messages (leaveOnServer = false means do not delete = leave on server)
await pop3.CloseAsync(false, cancellationToken);
await Repository.CreateRangeAsync(emails, cancellationToken);
return new EmailSyncResult(ProcessedCount: emails.Count, FailedCount: failedCount);
}
catch
{
try { await pop3.CloseAsync(false, cancellationToken); } catch { /* ignore */ }
throw;
}
}
private async Task<Pop3> OpenAsync(EmailAccount account, CancellationToken cancellationToken)
{
var pop3 = new Pop3();
if (account.Pop3UseSsl)
await pop3.ConnectSSLAsync(account.Pop3Server!, cancellationToken);
else
await pop3.ConnectAsync(account.Pop3Server!, cancellationToken);
if (account.UseOAuth2)
{
var token = await oauth2TokenService.GetAccessTokenAsync(account, cancellationToken);
await pop3.LoginOAUTH2Async(account.Username, token, cancellationToken);
}
else
{
await pop3.LoginAsync(account.Username, account.Password, cancellationToken);
}
return pop3;
}
#region POP3 Last Sync Date Cache
private readonly ConcurrentDictionary<int, DateTime> _cache = new();
public DateTime? GetLastPop3SyncDate(int accountId)
=> _cache.GetValueOrDefault(accountId);
private void SetLastPop3SyncDate(int accountId, DateTime date)
=> _cache[accountId] = date;
#endregion
}

View File

@@ -0,0 +1,52 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Domain.Entities;
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.
/// Tokens are cached in-memory and reused until 5 minutes before expiry.
/// </summary>
public class MicrosoftOAuth2TokenService(ILogger<MicrosoftOAuth2TokenService> Logger) : IOAuth2TokenService
{
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 (_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;
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;
}
}