Add IMAP support for email fetching and marking as seen

Introduced IMAP functionality to enable fetching emails from an
IMAP server and marking messages as seen. Updated the
`EmailAccountDto` class with IMAP-related properties
(`ImapServer`, `ImapPort`, `ImapUseSsl`) for configuration.

Added `ReceivedEmailContext` to represent received emails and
created the `IImapEmailService` interface with methods
`FetchEmailsAsync` and `MarkAsSeenAsync`. Implemented the
`LimilabsImapEmailService` class using Limilabs Mail.dll for
IMAP operations, including connection handling, email fetching,
and marking messages as seen.

Added `FetchEmailsQuery` and `MarkEmailAsSeenCommand` with
handlers to encapsulate IMAP logic. Updated `EmailsController`
with new endpoints for fetching emails and marking messages as
seen. Registered `IImapEmailService` in `DependencyInjection`.

Included exception handling and logging for robust error
management during IMAP operations.
This commit is contained in:
2026-08-07 11:48:12 +02:00
parent 20de7da93d
commit c47d78112c
9 changed files with 511 additions and 1 deletions

View File

@@ -23,8 +23,11 @@ public static class DependencyInjection
IConfiguration configuration)
{
// --- External Services ---
// Email Service (using Limilabs Mail.dll - Singleton for use in EmailSenderWorker)
// Email Service - SMTP outbound (Limilabs Mail.dll)
services.AddSingleton<IEmailService, LimilabsEmailService>();
// Email Service - IMAP inbound (Limilabs Mail.dll)
services.AddSingleton<IImapEmailService, LimilabsImapEmailService>();
// PDF Processing Service (using DevExpress.Pdf)
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();

View File

@@ -0,0 +1,16 @@
using Limilabs.Client.IMAP;
namespace DigitalData.MessagingService.Infrastructure.Services.Extensions;
public static class ImapExtensions
{
public static async Task CloseSafelyAsync(this Imap imap)
{
try
{
if (imap.Connected)
await imap.CloseAsync();
}
catch { /* Ignore disconnect errors */ }
}
}

View File

@@ -0,0 +1,179 @@
using System.Text;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.Domain.Exceptions;
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
using Limilabs.Client.IMAP;
using Limilabs.Mail;
using Microsoft.Extensions.Logging;
namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// IMAP email service using Limilabs Mail.dll.
/// Opens a fresh connection per call — stateless and thread-safe.
/// </summary>
public class LimilabsImapEmailService(
IEncryptionService encryptionService,
ILogger<LimilabsImapEmailService> logger) : IImapEmailService
{
static LimilabsImapEmailService()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
// Public API
public async Task<IEnumerable<ReceivedEmailContext>> FetchEmailsAsync(
EmailAccountDto account,
string folder = "INBOX",
bool unseenOnly = false,
int maxCount = 50,
CancellationToken cancellationToken = default)
{
using var imap = new Imap();
try
{
await ConnectAndAuthenticateAsync(imap, account);
await SelectFolderAsync(imap, folder);
// Get UIDs to fetch
List<long> uids = unseenOnly
? [.. await imap.SearchAsync(Flag.Unseen, cancellationToken)]
: [.. await imap.GetAllAsync(cancellationToken)];
// Most-recent first; honour maxCount
uids.Reverse();
if (maxCount > 0 && uids.Count > maxCount)
uids = uids.Take(maxCount).ToList();
var results = new List<ReceivedEmailContext>(uids.Count);
foreach (var uid in uids)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var eml = await imap.PeekMessageByUIDAsync(uid, cancellationToken);
var mail = new MailBuilder().CreateFromEml(eml);
var flags = await imap.GetFlagsByUIDAsync(uid, cancellationToken);
results.Add(MapToContext(uid, mail, flags));
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.", uid, folder);
}
}
await imap.CloseAsync(cancellationToken);
return results;
}
catch (Limilabs.Client.ServerException ex)
{
await imap.CloseSafelyAsync();
throw new AuthenticationFailedException(
$"IMAP authentication failed for account '{account.Username}'.", ex);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await imap.CloseSafelyAsync();
throw new InvalidOperationException(
$"Failed to fetch emails from IMAP server '{account.ImapServer}'.", ex);
}
}
public async Task MarkAsSeenAsync(
EmailAccountDto account,
long uid,
string folder = "INBOX",
CancellationToken cancellationToken = default)
{
using var imap = new Imap();
try
{
await ConnectAndAuthenticateAsync(imap, account);
await SelectFolderAsync(imap, folder);
await imap.MarkMessageSeenByUIDAsync(uid, cancellationToken);
await imap.CloseAsync(cancellationToken);
}
catch (Limilabs.Client.ServerException ex)
{
await imap.CloseSafelyAsync();
throw new AuthenticationFailedException(
$"IMAP authentication failed for account '{account.Username}'.", ex);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await imap.CloseSafelyAsync();
throw new InvalidOperationException(
$"Failed to mark message UID={uid} as seen on '{account.ImapServer}'.", ex);
}
}
// Private helpers
private async Task ConnectAndAuthenticateAsync(Imap imap, EmailAccountDto account)
{
if (account.ImapUseSsl)
await imap.ConnectSSLAsync(account.ImapServer!, account.ImapPort);
else
await imap.ConnectAsync(account.ImapServer!, account.ImapPort);
var password = account.PasswordEncrypted
? encryptionService.Decrypt(account.Password)
: account.Password;
await imap.LoginAsync(account.Username, password);
}
private static async Task SelectFolderAsync(Imap imap, string folder)
{
if (string.Equals(folder, "INBOX", StringComparison.OrdinalIgnoreCase))
await imap.SelectInboxAsync();
else
await imap.SelectAsync(folder);
}
private static ReceivedEmailContext MapToContext(long uid, IMail mail, List<Flag> flags)
{
var attachments = new List<EmailAttachmentContext>();
foreach (var att in mail.Attachments)
{
attachments.Add(new EmailAttachmentContext
{
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 EmailAttachmentContext
{
FileName = vis.FileName ?? "inline",
Content = vis.Data,
ContentType = vis.ContentType?.ToString() ?? "application/octet-stream",
IsInline = true,
ContentId = vis.ContentId
});
}
return new ReceivedEmailContext
{
Uid = uid,
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,
Attachments = attachments,
IsSeen = flags?.Contains(Flag.Seen) ?? false
};
}
}