feat(infrastructure): add OAuth2 auth support and SendAndAppend capability to LimilabsEmailService and LimilabsImapEmailService

This commit is contained in:
2026-08-17 10:15:38 +02:00
parent a13380016d
commit 1d7f269fe1
2 changed files with 84 additions and 21 deletions

View File

@@ -12,14 +12,10 @@ namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// Email service using Limilabs Mail.dll for SMTP operations (send-only).
/// Commercial-grade library with superior Exchange support.
/// SMTP configuration is injected via IOptions&lt;EmailAccountsOptions&gt; from appsettings.json.
/// Uses the first account in the list whose <see cref="EmailAccount.Name"/> equals <c>"default"</c>,
/// or falls back to the first account if none is named "default".
/// Supports both plain/STARTTLS and OAuth2 (XOAUTH2) authentication.
/// </summary>
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<EmailAttachmentDto> attachments)
internal static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentDto> attachments)
{
foreach (var attachment in attachments)
{

View File

@@ -14,8 +14,13 @@ namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// IMAP 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 LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger, IRepository<ReceivedEmail> Repository) : IImapEmailService
public class LimilabsImapEmailService(
ILogger<LimilabsImapEmailService> Logger,
IRepository<ReceivedEmail> Repository,
IOAuth2TokenService oauth2TokenService,
LimilabsEmailService smtpService) : IImapEmailService
{
private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString();
@@ -27,7 +32,7 @@ public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger,
// Public API
public async Task<EmailSyncResult> 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<LimilabsImapEmailService> 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<LimilabsImapEmailService> Logger,
}
}
private static async Task<Imap> 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<Imap> OpenAsync(EmailAccount account, string folder = "INBOX", bool createIfMissing = false, CancellationToken cancel = default)
{
var imap = new Imap();
@@ -160,12 +206,27 @@ public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> 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;
}