feat(infrastructure): implement MicrosoftOAuth2TokenService and LimilabsPop3EmailService
This commit is contained in:
@@ -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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user