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

@@ -35,4 +35,20 @@ public class EmailAccountDto
public bool SmtpUseSsl { get; set; } public bool SmtpUseSsl { get; set; }
public bool UseOAuth2 { get; set; } public bool UseOAuth2 { get; set; }
/// <summary>
/// IMAP server hostname (e.g. "imap.example.com").
/// Leave empty when this account is send-only.
/// </summary>
public string? ImapServer { get; set; }
/// <summary>
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
/// </summary>
public int ImapPort { get; set; } = 993;
/// <summary>
/// Use SSL/TLS when connecting to the IMAP server.
/// </summary>
public bool ImapUseSsl { get; set; } = true;
} }

View File

@@ -0,0 +1,97 @@
namespace DigitalData.MessagingService.Abstraction;
/// <summary>
/// Represents an email message received via IMAP.
/// </summary>
public sealed class ReceivedEmailContext
{
/// <summary>
/// Unique identifier of the message on the IMAP server (UID).
/// </summary>
#if NET
public long Uid { get; init; }
#else
public long Uid { get; set; }
#endif
/// <summary>
/// Sender address (From header).
/// </summary>
#if NET
public string From { get; init; } = string.Empty;
#else
public string From { get; set; } = string.Empty;
#endif
/// <summary>
/// Recipient addresses (To header).
/// </summary>
#if NET
public IEnumerable<string> To { get; init; } = [];
#else
public IEnumerable<string> To { get; set; } = [];
#endif
/// <summary>
/// CC addresses.
/// </summary>
#if NET
public IEnumerable<string> Cc { get; init; } = [];
#else
public IEnumerable<string> Cc { get; set; } = [];
#endif
/// <summary>
/// Email subject.
/// </summary>
#if NET
public string Subject { get; init; } = string.Empty;
#else
public string Subject { get; set; } = string.Empty;
#endif
/// <summary>
/// Plain-text body (may be empty when only HTML is present).
/// </summary>
#if NET
public string TextBody { get; init; } = string.Empty;
#else
public string TextBody { get; set; } = string.Empty;
#endif
/// <summary>
/// HTML body (may be empty when only plain-text is present).
/// </summary>
#if NET
public string HtmlBody { get; init; } = string.Empty;
#else
public string HtmlBody { get; set; } = string.Empty;
#endif
/// <summary>
/// Date/time the message was sent (Date header).
/// </summary>
#if NET
public DateTime Date { get; init; }
#else
public DateTime Date { get; set; }
#endif
/// <summary>
/// Attachments included with this message.
/// </summary>
#if NET
public IEnumerable<EmailAttachmentContext> Attachments { get; init; } = [];
#else
public IEnumerable<EmailAttachmentContext> Attachments { get; set; } = [];
#endif
/// <summary>
/// Whether the message has been marked as seen/read on the server.
/// </summary>
#if NET
public bool IsSeen { get; init; }
#else
public bool IsSeen { get; set; }
#endif
}

View File

@@ -0,0 +1,33 @@
using DigitalData.MessagingService.Abstraction;
namespace DigitalData.MessagingService.Application.Common.Interfaces;
/// <summary>
/// Service interface for reading emails via IMAP.
/// </summary>
public interface IImapEmailService
{
/// <summary>
/// Fetches emails from the specified mailbox folder.
/// </summary>
/// <param name="account">Account whose IMAP settings will be used.</param>
/// <param name="folder">Mailbox folder name (e.g. "INBOX"). Defaults to INBOX.</param>
/// <param name="unseenOnly">When <see langword="true"/> returns only unread messages.</param>
/// <param name="maxCount">Maximum number of messages to retrieve (most-recent first). 0 = unlimited.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IEnumerable<ReceivedEmailContext>> FetchEmailsAsync(
EmailAccountDto account,
string folder = "INBOX",
bool unseenOnly = false,
int maxCount = 50,
CancellationToken cancellationToken = default);
/// <summary>
/// Marks a message as seen (read) on the server.
/// </summary>
Task MarkAsSeenAsync(
EmailAccountDto account,
long uid,
string folder = "INBOX",
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,42 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.EmailAccount.Queries;
using DigitalData.MessagingService.Domain.Exceptions;
using MediatR;
namespace DigitalData.MessagingService.Application.EmailReceiving.Commands;
/// <summary>
/// Command to mark a single IMAP message as seen (read).
/// </summary>
public record MarkEmailAsSeenCommand : IRequest
{
public required GetSenderQuery Account { get; init; }
/// <summary>
/// UID of the message to mark as seen.
/// </summary>
public required long Uid { get; init; }
/// <summary>
/// Mailbox folder the message resides in (default: "INBOX").
/// </summary>
public string Folder { get; init; } = "INBOX";
}
public class MarkEmailAsSeenCommandHandler(
ISender Sender,
IImapEmailService ImapService) : IRequestHandler<MarkEmailAsSeenCommand>
{
public async Task Handle(MarkEmailAsSeenCommand request, CancellationToken cancellationToken)
{
var account = await Sender.Send(request.Account, cancellationToken)
?? throw new NotFoundException(
$"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
if (string.IsNullOrWhiteSpace(account.ImapServer))
throw new InvalidOperationException(
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
await ImapService.MarkAsSeenAsync(account, request.Uid, request.Folder, cancellationToken);
}
}

View File

@@ -0,0 +1,56 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.EmailAccount.Queries;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.Domain.Exceptions;
using MediatR;
namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
/// <summary>
/// Query to fetch emails from an IMAP mailbox.
/// </summary>
public record FetchEmailsQuery : IRequest<IEnumerable<ReceivedEmailContext>>
{
/// <summary>
/// Identifies the email account to use.
/// </summary>
public required GetSenderQuery Account { get; init; }
/// <summary>
/// Mailbox folder to read from (default: "INBOX").
/// </summary>
public string Folder { get; init; } = "INBOX";
/// <summary>
/// When <see langword="true"/> returns only unread messages.
/// </summary>
public bool UnseenOnly { get; init; } = false;
/// <summary>
/// Maximum number of messages to retrieve (most-recent first). 0 = unlimited.
/// </summary>
public int MaxCount { get; init; } = 50;
}
public class FetchEmailsQueryHandler(
ISender Sender,
IImapEmailService ImapService) : IRequestHandler<FetchEmailsQuery, IEnumerable<ReceivedEmailContext>>
{
public async Task<IEnumerable<ReceivedEmailContext>> Handle(FetchEmailsQuery request, CancellationToken cancellationToken)
{
var account = await Sender.Send(request.Account, cancellationToken)
?? throw new NotFoundException(
$"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
if (string.IsNullOrWhiteSpace(account.ImapServer))
throw new InvalidOperationException(
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
return await ImapService.FetchEmailsAsync(
account,
request.Folder,
request.UnseenOnly,
request.MaxCount,
cancellationToken);
}
}

View File

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

View File

@@ -1,4 +1,7 @@
using DigitalData.MessagingService.Application.EmailSending.Commands; using DigitalData.MessagingService.Application.EmailSending.Commands;
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
using DigitalData.MessagingService.Application.EmailReceiving.Commands;
using DigitalData.MessagingService.Application.EmailAccount.Queries;
using DigitalData.MessagingService.Abstraction; using DigitalData.MessagingService.Abstraction;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -13,6 +16,7 @@ namespace DigitalData.MessagingService.API.Controllers;
[Route("api/[controller]")] [Route("api/[controller]")]
public class EmailsController(IMediator mediator) : ControllerBase public class EmailsController(IMediator mediator) : ControllerBase
{ {
#region Send
/// <summary> /// <summary>
/// Send an email, optionally with file attachments. /// Send an email, optionally with file attachments.
/// Omit the <c>attachments</c> field for a plain send. /// Omit the <c>attachments</c> field for a plain send.
@@ -61,4 +65,68 @@ public class EmailsController(IMediator mediator) : ControllerBase
return result; return result;
} }
#endregion Send
#region Receive
/// <summary>
/// Fetch emails from an IMAP mailbox.
/// </summary>
/// <param name="accountId">Id of the email account (must have ImapServer configured).</param>
/// <param name="folder">Mailbox folder to read (default: INBOX).</param>
/// <param name="unseenOnly">Return only unread messages.</param>
/// <param name="maxCount">Maximum number of messages to return (most-recent first, default: 50).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>HTTP 200 with list of received emails.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> FetchEmails(
[FromQuery] int accountId,
[FromQuery] string folder = "INBOX",
[FromQuery] bool unseenOnly = false,
[FromQuery] int maxCount = 50,
CancellationToken cancellationToken = default)
{
var query = new FetchEmailsQuery
{
Account = new GetSenderQuery { Id = accountId },
Folder = folder,
UnseenOnly = unseenOnly,
MaxCount = maxCount
};
var emails = await mediator.Send(query, cancellationToken);
return Ok(emails);
}
/// <summary>
/// Mark a single IMAP message as seen (read).
/// </summary>
/// <param name="accountId">Id of the email account.</param>
/// <param name="uid">UID of the message on the IMAP server.</param>
/// <param name="folder">Mailbox folder the message resides in (default: INBOX).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>HTTP 204 No Content.</returns>
[HttpPatch("{uid}/seen")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> MarkAsSeen(
[FromRoute] long uid,
[FromQuery] int accountId,
[FromQuery] string folder = "INBOX",
CancellationToken cancellationToken = default)
{
var command = new MarkEmailAsSeenCommand
{
Account = new GetSenderQuery { Id = accountId },
Uid = uid,
Folder = folder
};
await mediator.Send(command, cancellationToken);
return NoContent();
}
#endregion Receive
} }