From a13380016dde5a987d4df90f7f6ccc483c4da218 Mon Sep 17 00:00:00 2001 From: TekH Date: Mon, 17 Aug 2026 10:15:26 +0200 Subject: [PATCH] feat(infrastructure): implement MicrosoftOAuth2TokenService and LimilabsPop3EmailService --- .../Services/LimilabsPop3EmailService.cs | 166 ++++++++++++++++++ .../Services/MicrosoftOAuth2TokenService.cs | 52 ++++++ 2 files changed, 218 insertions(+) create mode 100644 src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsPop3EmailService.cs create mode 100644 src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/MicrosoftOAuth2TokenService.cs diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsPop3EmailService.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsPop3EmailService.cs new file mode 100644 index 0000000..8a796c1 --- /dev/null +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsPop3EmailService.cs @@ -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; + +/// +/// POP3 email service using Limilabs Mail.dll. +/// Opens a fresh connection per call — stateless and thread-safe. +/// Supports both password and OAuth2 (XOAUTH2) authentication. +/// +public class LimilabsPop3EmailService( + ILogger Logger, + IRepository Repository, + IOAuth2TokenService oauth2TokenService) : IPop3EmailService +{ + private const string Pop3Folder = "INBOX"; + + static LimilabsPop3EmailService() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + public async Task 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(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(); + + 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 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 _cache = new(); + + public DateTime? GetLastPop3SyncDate(int accountId) + => _cache.GetValueOrDefault(accountId); + + private void SetLastPop3SyncDate(int accountId, DateTime date) + => _cache[accountId] = date; + #endregion +} diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/MicrosoftOAuth2TokenService.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/MicrosoftOAuth2TokenService.cs new file mode 100644 index 0000000..bd9cf0d --- /dev/null +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/MicrosoftOAuth2TokenService.cs @@ -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; + +/// +/// 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. +/// +public class MicrosoftOAuth2TokenService(ILogger Logger) : IOAuth2TokenService +{ + 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 (_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; + } +}