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