Simplify IMAP email fetching API and refactor logic

Removed `markAsSeen` parameter from `FetchEmailsAsync` and
`FetchEmailByUidAsync` methods in `IImapEmailService` to
simplify the API. Updated `MailSearchFilter` to make `MaxCount`
nullable for greater flexibility.

Removed `markAsSeen` from `FetchEmailByUidQuery` and
`FetchEmailsQuery` records and their handlers. Deleted
`FetchEmailByUidQueryValidator` as it is no longer needed.

Refactored `LimilabsImapEmailService`:
- Introduced `FetchEmailUidsAsync` to centralize UID fetching logic.
- Simplified `FetchEmailByUidAsync` using a new helper method.
- Consolidated connection, authentication, and folder selection
  into reusable private methods.
- Removed redundant code for search criteria and flag fetching.

Removed `IsSeen` from `ReceivedEmailContext` and improved
overall code readability and maintainability by reducing
duplication and centralizing logic.
This commit is contained in:
2026-08-12 10:04:48 +02:00
parent f3761e96d9
commit f521683608
6 changed files with 124 additions and 212 deletions

View File

@@ -13,7 +13,6 @@ public interface IImapEmailService
/// </summary> /// </summary>
/// <param name="account">Account whose IMAP settings will be used.</param> /// <param name="account">Account whose IMAP settings will be used.</param>
/// <param name="filter">Filter to apply when fetching emails.</param> /// <param name="filter">Filter to apply when fetching emails.</param>
/// <param name="markAsSeen">
/// When <see langword="true"/> (default), fetched messages are marked as <c>\Seen</c> on the server. /// When <see langword="true"/> (default), fetched messages are marked as <c>\Seen</c> on the server.
/// Set to <see langword="false"/> for a non-destructive read (uses <c>BODY.PEEK</c> internally). /// Set to <see langword="false"/> for a non-destructive read (uses <c>BODY.PEEK</c> internally).
/// </param> /// </param>
@@ -21,7 +20,6 @@ public interface IImapEmailService
Task<IEnumerable<ReceivedEmailContext>> FetchEmailsAsync( Task<IEnumerable<ReceivedEmailContext>> FetchEmailsAsync(
EmailAccountDto account, EmailAccountDto account,
MailSearchFilter filter, MailSearchFilter filter,
bool markAsSeen = true,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary> /// <summary>
@@ -35,7 +33,6 @@ public interface IImapEmailService
/// <summary> /// <summary>
/// Fetches a single email by its UID. /// Fetches a single email by its UID.
/// </summary> /// </summary>
/// <param name="markAsSeen">
/// When <see langword="true"/> (default), the message is marked as <c>\Seen</c> on the server. /// When <see langword="true"/> (default), the message is marked as <c>\Seen</c> on the server.
/// Set to <see langword="false"/> for a non-destructive read. /// Set to <see langword="false"/> for a non-destructive read.
/// </param> /// </param>
@@ -44,7 +41,6 @@ public interface IImapEmailService
long uid, long uid,
string folder = "INBOX", string folder = "INBOX",
bool withAttachments = false, bool withAttachments = false,
bool markAsSeen = true,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary> /// <summary>

View File

@@ -29,7 +29,7 @@ public record MailSearchFilter
/// Maximum number of messages to retrieve. <c>0</c> means unlimited. /// Maximum number of messages to retrieve. <c>0</c> means unlimited.
/// Applied after sorting; defaults to <c>50</c>. /// Applied after sorting; defaults to <c>50</c>.
/// </summary> /// </summary>
public int MaxCount { get; init; } = 50; public int? MaxCount { get; init; } = null;
/// <summary> /// <summary>
/// Controls the order of the returned messages. Defaults to <see cref="MailSortOrder.NewestFirst"/>. /// Controls the order of the returned messages. Defaults to <see cref="MailSortOrder.NewestFirst"/>.

View File

@@ -36,11 +36,6 @@ public record FetchEmailByUidQuery : IRequest<ReceivedEmailContext?>
/// When <see langword="true"/>, attachment data is included in the result. /// When <see langword="true"/>, attachment data is included in the result.
/// </summary> /// </summary>
public bool WithAttachments { get; init; } = false; public bool WithAttachments { get; init; } = false;
/// <summary>
/// When <see langword="true"/> (default), the message is marked as <c>\Seen</c> on the server.
/// Set to <see langword="false"/> for a non-destructive peek.
/// </summary>
public bool MarkAsSeen { get; init; } = true;
} }
public class FetchEmailByUidQueryHandler( public class FetchEmailByUidQueryHandler(
@@ -62,7 +57,6 @@ public class FetchEmailByUidQueryHandler(
(long)request.Uid!, (long)request.Uid!,
request.Folder, request.Folder,
request.WithAttachments, request.WithAttachments,
request.MarkAsSeen,
cancellationToken); cancellationToken);
} }
} }

View File

@@ -21,12 +21,6 @@ public record FetchEmailsQuery : IRequest<IEnumerable<ReceivedEmailContext>>
/// Mail query used to filter and limit the emails retrieved. /// Mail query used to filter and limit the emails retrieved.
/// </summary> /// </summary>
public MailSearchFilter Mail { get; init; } = new(); public MailSearchFilter Mail { get; init; } = new();
/// <summary>
/// When <see langword="true"/> (default), fetched messages are marked as <c>\Seen</c> on the server.
/// Set to <see langword="false"/> for a non-destructive peek.
/// </summary>
public bool MarkAsSeen { get; init; } = true;
} }
public class FetchEmailsQueryHandler( public class FetchEmailsQueryHandler(
@@ -46,7 +40,6 @@ public class FetchEmailsQueryHandler(
return await ImapService.FetchEmailsAsync( return await ImapService.FetchEmailsAsync(
account, account,
request.Mail, request.Mail,
request.MarkAsSeen,
cancellationToken); cancellationToken);
} }
} }

View File

@@ -1,27 +0,0 @@
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
/// <summary>
/// Validates a <see cref="FetchEmailByUidQuery"/> before it is handled by <see cref="FetchEmailByUidQueryHandler"/>.
/// </summary>
public class FetchEmailByUidQueryValidator : AbstractValidator<FetchEmailByUidQuery>
{
public FetchEmailByUidQueryValidator()
{
RuleFor(x => x.Account)
.NotNull()
.WithMessage("Account query must not be null.");
RuleFor(x => x.Uid)
.NotNull()
.WithMessage("UID must be provided. Use WithUid() to set the UID before dispatching the query.")
.GreaterThan(0)
.WithMessage("UID must be greater than 0.");
RuleFor(x => x.Folder)
.NotEmpty()
.WithMessage("Folder must not be empty.");
}
}

View File

@@ -27,11 +27,150 @@ public class LimilabsImapEmailService(
public async Task<IEnumerable<ReceivedEmailContext>> FetchEmailsAsync( public async Task<IEnumerable<ReceivedEmailContext>> FetchEmailsAsync(
EmailAccountDto account, EmailAccountDto account,
MailSearchFilter filter, MailSearchFilter filter,
bool markAsSeen = true,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
using var imap = new Imap(); using var imap = new Imap();
try try
{
var uids = await FetchEmailUidsAsync(imap, account, filter, cancellationToken);
if (uids.Count == 0)
return [];
var results = new List<ReceivedEmailContext>(uids.Count);
foreach (var uid in uids)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var email = await FetchEmailByUidAsync(imap, uid, filter.WithAttachments, cancellationToken)
?? throw new NotFoundException($"Email with UID={uid} not found in folder '{filter.Folder}' in {account.Username}.");
results.Add(email);
}
catch (Exception ex)
{
logger.LogWarning(ex,
"Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.",
uid, filter.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<IEnumerable<long>> FetchEmailUidsAsync(
EmailAccountDto account,
MailSearchFilter filter,
CancellationToken cancellationToken = default)
{
using var imap = new Imap();
try
{
await ConnectAndAuthenticateAsync(imap, account);
await SelectFolderAsync(imap, filter.Folder);
List<long> uids = await FetchEmailUidsAsync(imap, account, filter, cancellationToken);
await imap.CloseAsync(cancellationToken);
return uids;
}
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 email UIDs from IMAP server '{account.ImapServer}'.", ex);
}
}
public async Task<ReceivedEmailContext?> FetchEmailByUidAsync(
EmailAccountDto account,
long uid,
string folder = "INBOX",
bool withAttachments = false,
CancellationToken cancellationToken = default)
{
using var imap = new Imap();
try
{
await ConnectAndAuthenticateAsync(imap, account);
await SelectFolderAsync(imap, folder);
var mail = await FetchEmailByUidAsync(imap, uid, withAttachments, cancellationToken);
await imap.CloseAsync(cancellationToken);
return mail;
}
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();
logger.LogWarning(ex, "Failed to fetch IMAP message UID={Uid} from folder {Folder}.", uid, folder);
return null;
}
}
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<List<long>> FetchEmailUidsAsync(
Imap imap,
EmailAccountDto account,
MailSearchFilter filter,
CancellationToken cancellationToken = default)
{ {
await ConnectAndAuthenticateAsync(imap, account); await ConnectAndAuthenticateAsync(imap, account);
await SelectFolderAsync(imap, filter.Folder); await SelectFolderAsync(imap, filter.Folder);
@@ -81,225 +220,21 @@ public class LimilabsImapEmailService(
if (filter.SortOrder == MailSortOrder.NewestFirst) if (filter.SortOrder == MailSortOrder.NewestFirst)
uids.Reverse(); uids.Reverse();
if (filter.MaxCount > 0 && uids.Count > filter.MaxCount) if (filter.MaxCount is int maxCount && maxCount > 0 && uids.Count > maxCount)
uids = [.. uids.Take(filter.MaxCount)]; uids = [.. uids.Take(maxCount)];
if (uids.Count == 0)
return [];
// Fetch flags for all UIDs in one round-trip
var allFlags = await imap.GetFlagsByUIDAsync(uids, cancellationToken);
var flagsById = allFlags
.Where(f => f.UID.HasValue)
.ToDictionary(f => f.UID!.Value, f => f.Flags);
// markAsSeen=true → BODY[] (server sets \Seen automatically)
// markAsSeen=false → BODY.PEEK[] (\Seen untouched, non-destructive read)
var results = new List<ReceivedEmailContext>(uids.Count);
foreach (var uid in uids)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var eml = markAsSeen
? await imap.GetMessageByUIDAsync(uid, cancellationToken)
: await imap.PeekMessageByUIDAsync(uid, cancellationToken);
var mail = new MailBuilder().CreateFromEml(eml);
flagsById.TryGetValue(uid, out var flags);
results.Add(MapToContext(uid, mail, flags, filter.WithAttachments));
}
catch (Exception ex)
{
logger.LogWarning(ex,
"Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.",
uid, filter.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<IEnumerable<long>> FetchEmailUidsAsync(
EmailAccountDto account,
MailSearchFilter filter,
CancellationToken cancellationToken = default)
{
using var imap = new Imap();
try
{
await ConnectAndAuthenticateAsync(imap, account);
await SelectFolderAsync(imap, filter.Folder);
List<ICriterion> criterions = [];
if (filter.UnseenOnly)
criterions.Add(Expression.HasFlag(Flag.Unseen));
if (filter.SubjectContains is not null)
criterions.Add(Expression.Subject(filter.SubjectContains));
if (filter.SenderContains is not null)
criterions.Add(Expression.From(filter.SenderContains));
if (filter.RecipientContains is not null)
criterions.Add(Expression.To(filter.RecipientContains));
if (filter.BodyContains is not null)
criterions.Add(Expression.Body(filter.BodyContains));
if (filter.Uid is UidFilter uidF)
{
if (uidF.Absolute is long exactUid)
criterions.Add(Expression.UID(new Limilabs.Client.IMAP.Range(exactUid, exactUid)));
else
{
long lo = uidF.Min ?? 1L;
long? hi = uidF.Max;
criterions.Add(Expression.UID(new Limilabs.Client.IMAP.Range(lo, hi)));
}
}
if (filter.Date is DateFilter dateF)
{
if (dateF.After is DateTime after)
criterions.Add(Expression.SentSince(after.Date));
if (dateF.Before is DateTime before)
criterions.Add(Expression.SentBefore(before.Date.AddDays(1)));
}
var searchExpression = criterions.Count > 0 ? Expression.And([.. criterions]) : Expression.All();
List<long> uids = [.. await imap.SearchAsync(searchExpression, cancellationToken)];
if (filter.SortOrder == MailSortOrder.NewestFirst)
uids.Reverse();
if (filter.MaxCount > 0 && uids.Count > filter.MaxCount)
uids = [.. uids.Take(filter.MaxCount)];
await imap.CloseAsync(cancellationToken);
return uids; return uids;
} }
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 email UIDs from IMAP server '{account.ImapServer}'.", ex);
}
}
public async Task<ReceivedEmailContext?> FetchEmailByUidAsync( private static async Task<ReceivedEmailContext?> FetchEmailByUidAsync(
EmailAccountDto account, Imap imap,
long uid, long uid,
string folder = "INBOX",
bool withAttachments = false, bool withAttachments = false,
bool markAsSeen = true,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
using var imap = new Imap(); var eml = await imap.GetMessageByUIDAsync(uid, cancellationToken);
try
{
await ConnectAndAuthenticateAsync(imap, account);
await SelectFolderAsync(imap, folder);
var eml = markAsSeen
? await imap.GetMessageByUIDAsync(uid, cancellationToken)
: await imap.PeekMessageByUIDAsync(uid, cancellationToken);
var mail = new MailBuilder().CreateFromEml(eml); var mail = new MailBuilder().CreateFromEml(eml);
var flags = await imap.GetFlagsByUIDAsync(uid, cancellationToken);
await imap.CloseAsync(cancellationToken);
return MapToContext(uid, mail, flags, withAttachments);
}
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();
logger.LogWarning(ex, "Failed to fetch IMAP message UID={Uid} from folder {Folder}.", uid, folder);
return null;
}
}
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, bool withAttachments = false)
{
var attachments = new List<EmailAttachmentContext>(); var attachments = new List<EmailAttachmentContext>();
if (withAttachments) if (withAttachments)
@@ -340,7 +275,28 @@ public class LimilabsImapEmailService(
HtmlBody = mail.Html ?? string.Empty, HtmlBody = mail.Html ?? string.Empty,
Date = mail.Date ?? DateTime.MinValue, Date = mail.Date ?? DateTime.MinValue,
Attachments = attachments, Attachments = attachments,
IsSeen = flags?.Contains(Flag.Seen) ?? false
}; };
} }
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);
}
} }