From 1d7f269fe1c185f8a3328ad0ac4abc3808c67093 Mon Sep 17 00:00:00 2001 From: TekH Date: Mon, 17 Aug 2026 10:15:38 +0200 Subject: [PATCH] feat(infrastructure): add OAuth2 auth support and SendAndAppend capability to LimilabsEmailService and LimilabsImapEmailService --- .../Services/LimilabsEmailService.cs | 34 ++++----- .../Services/LimilabsImapEmailService.cs | 71 +++++++++++++++++-- 2 files changed, 84 insertions(+), 21 deletions(-) diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsEmailService.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsEmailService.cs index 05971a1..7192b5c 100644 --- a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsEmailService.cs +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsEmailService.cs @@ -12,14 +12,10 @@ namespace DigitalData.MessagingService.Infrastructure.Services; /// /// Email service using Limilabs Mail.dll for SMTP operations (send-only). -/// Commercial-grade library with superior Exchange support. -/// SMTP configuration is injected via IOptions<EmailAccountsOptions> from appsettings.json. -/// Uses the first account in the list whose equals "default", -/// or falls back to the first account if none is named "default". +/// Supports both plain/STARTTLS and OAuth2 (XOAUTH2) authentication. /// -public class LimilabsEmailService() : IEmailService +public class LimilabsEmailService(IOAuth2TokenService oauth2TokenService) : IEmailService { - // Register encoding provider for Limilabs (requires windows-1252 and other code pages) static LimilabsEmailService() { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); @@ -31,7 +27,7 @@ public class LimilabsEmailService() : IEmailService ISendMessageResult? result = null; try { - await ConnectAndAuthenticateSmtpAsync(smtp, context.Sender); + await ConnectAndAuthenticateSmtpAsync(smtp, context.Sender, cancellationToken); var builder = new MailBuilder(); builder.From.Add(new MailBox(context.Sender.Username)); @@ -62,37 +58,43 @@ public class LimilabsEmailService() : IEmailService catch (Limilabs.Client.ServerException ex) { await smtp.CloseSafelyAsync(); - throw new AuthenticationFailedException($"SMTP authentication failed. Check credentials or OAuth2 configuration. {ErrorMessageBuilder(result)}", ex); + throw new AuthenticationFailedException($"SMTP authentication failed. {ErrorMessageBuilder(result)}", ex); } - catch (Exception ex) + catch (Exception ex) when (ex is not AuthenticationFailedException && ex is not InvalidOperationException) { await smtp.CloseSafelyAsync(); throw new InvalidOperationException($"Failed to send email via SMTP server. {ErrorMessageBuilder(result)}", ex); } + catch + { + await smtp.CloseSafelyAsync(); + throw; + } } - private static async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccount smtpAccount) + internal async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccount smtpAccount, CancellationToken cancellationToken = default) { if (smtpAccount.SmtpUseSsl) { - await smtp.ConnectSSLAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort); + await smtp.ConnectSSLAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort, cancellationToken); } else { - await smtp.ConnectAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort); + await smtp.ConnectAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort, cancellationToken); } if (smtpAccount.UseOAuth2) { - throw new NotSupportedException("OAuth2 is not configured for this SMTP account. UseOAuth2 must be false."); + var token = await oauth2TokenService.GetAccessTokenAsync(smtpAccount, cancellationToken); + await smtp.LoginOAUTH2Async(smtpAccount.Username, token, cancellationToken); } else { - await smtp.LoginAsync(smtpAccount.Username, smtpAccount.Password); + await smtp.LoginAsync(smtpAccount.Username, smtpAccount.Password, cancellationToken); } } - private static string ErrorMessageBuilder(ISendMessageResult? result = null) + internal static string ErrorMessageBuilder(ISendMessageResult? result = null) { if(result is null || result.GeneralErrors.Count == 0) return string.Empty; @@ -109,7 +111,7 @@ public class LimilabsEmailService() : IEmailService return message.ToString(); } - private static void AddAttachments(MailBuilder builder, IEnumerable attachments) + internal static void AddAttachments(MailBuilder builder, IEnumerable attachments) { foreach (var attachment in attachments) { diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs index a9a6f55..e6b3769 100644 --- a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs @@ -14,8 +14,13 @@ namespace DigitalData.MessagingService.Infrastructure.Services; /// /// IMAP 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 LimilabsImapEmailService(ILogger Logger, IRepository Repository) : IImapEmailService +public class LimilabsImapEmailService( + ILogger Logger, + IRepository Repository, + IOAuth2TokenService oauth2TokenService, + LimilabsEmailService smtpService) : IImapEmailService { private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString(); @@ -27,7 +32,7 @@ public class LimilabsImapEmailService(ILogger Logger, // Public API public async Task SyncEmailsAsync(EmailAccount account, string folder = "INBOX", CancellationToken cancel = default) { - using var imap = await OpenAsync(account, folder, cancel); + using var imap = await OpenAsync(account, folder, cancel: cancel); try { #region Find UIDs @@ -138,7 +143,7 @@ public class LimilabsImapEmailService(ILogger Logger, public async Task MarkAsSeenAsync(EmailAccount account, long uid, string folder = "INBOX", CancellationToken cancel = default) { - using var imap = await OpenAsync(account, folder, cancel); + using var imap = await OpenAsync(account, folder, cancel: cancel); try { await imap.MarkMessageSeenByUIDAsync(uid, cancel); @@ -151,7 +156,48 @@ public class LimilabsImapEmailService(ILogger Logger, } } - private static async Task OpenAsync(EmailAccount account, string folder = "INBOX", CancellationToken cancel = default) + public async Task SendAndAppendAsync(EmailContext context, string sentFolder = "Sent", CancellationToken cancellationToken = default) + { + // Send via SMTP first + await smtpService.SendEmailAsync(context, cancellationToken); + + // Then upload a copy to the IMAP Sent folder (no need to SELECT first) + using var imap = await OpenAsync(context.Sender, "INBOX", cancel: cancellationToken); + try + { + var builder = new MailBuilder(); + builder.From.Add(new Limilabs.Mail.Headers.MailBox(context.Sender.Username)); + + foreach (var recipient in context.Recipients) + builder.To.Add(new Limilabs.Mail.Headers.MailBox(recipient)); + + builder.Subject = context.Subject; + + if (context.IsHtml) + builder.Html = context.Body; + else + builder.Text = context.Body; + + LimilabsEmailService.AddAttachments(builder, context.Attachments); + + var mail = builder.Create(); + + var uploadInfo = new Limilabs.Client.IMAP.UploadMessageInfo + { + Flags = [Limilabs.Client.IMAP.Flag.Seen] + }; + + await imap.UploadMessageAsync(sentFolder, mail, uploadInfo, cancellationToken); + await imap.CloseAsync(cancellationToken); + } + catch + { + await imap.CloseSafelyAsync(); + throw; + } + } + + private async Task OpenAsync(EmailAccount account, string folder = "INBOX", bool createIfMissing = false, CancellationToken cancel = default) { var imap = new Imap(); @@ -160,12 +206,27 @@ public class LimilabsImapEmailService(ILogger Logger, else await imap.ConnectAsync(account.ImapServer!, account.ImapPort, cancel: cancel); - await imap.LoginAsync(account.Username, account.Password, cancel); + if (account.UseOAuth2) + { + var token = await oauth2TokenService.GetAccessTokenAsync(account, cancel); + await imap.LoginOAUTH2Async(account.Username, token, cancel); + } + else + await imap.LoginAsync(account.Username, account.Password, cancel); if (string.Equals(folder, "INBOX", StringComparison.OrdinalIgnoreCase)) await imap.SelectInboxAsync(cancel); else + { + if (createIfMissing) + { + var folders = await imap.GetFoldersAsync(cancel); + if (!folders.Any(f => string.Equals(f.Name, folder, StringComparison.OrdinalIgnoreCase))) + await imap.CreateFolderAsync(folder, cancel); + } + await imap.SelectAsync(folder, cancel); + } return imap; }