From f5216836085c2fb518ca95d0cd5ec323d355bf01 Mon Sep 17 00:00:00 2001 From: TekH Date: Wed, 12 Aug 2026 10:04:48 +0200 Subject: [PATCH] /s 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. --- .../Common/Interfaces/IImapEmailService.cs | 4 - .../Models/MailSearch/MailSearchFilter.cs | 2 +- .../Queries/FetchEmailByUidQuery.cs | 6 - .../Queries/FetchEmailsQuery.cs | 7 - .../FetchEmailByUidQueryValidator.cs | 27 -- .../Services/LimilabsImapEmailService.cs | 290 ++++++++---------- 6 files changed, 124 insertions(+), 212 deletions(-) delete mode 100644 src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/FetchEmailByUidQueryValidator.cs diff --git a/src/core/DigitalData.MessagingService.Application/Common/Interfaces/IImapEmailService.cs b/src/core/DigitalData.MessagingService.Application/Common/Interfaces/IImapEmailService.cs index f5a97f1..34145aa 100644 --- a/src/core/DigitalData.MessagingService.Application/Common/Interfaces/IImapEmailService.cs +++ b/src/core/DigitalData.MessagingService.Application/Common/Interfaces/IImapEmailService.cs @@ -13,7 +13,6 @@ public interface IImapEmailService /// /// Account whose IMAP settings will be used. /// Filter to apply when fetching emails. - /// /// When (default), fetched messages are marked as \Seen on the server. /// Set to for a non-destructive read (uses BODY.PEEK internally). /// @@ -21,7 +20,6 @@ public interface IImapEmailService Task> FetchEmailsAsync( EmailAccountDto account, MailSearchFilter filter, - bool markAsSeen = true, CancellationToken cancellationToken = default); /// @@ -35,7 +33,6 @@ public interface IImapEmailService /// /// Fetches a single email by its UID. /// - /// /// When (default), the message is marked as \Seen on the server. /// Set to for a non-destructive read. /// @@ -44,7 +41,6 @@ public interface IImapEmailService long uid, string folder = "INBOX", bool withAttachments = false, - bool markAsSeen = true, CancellationToken cancellationToken = default); /// diff --git a/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/MailSearchFilter.cs b/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/MailSearchFilter.cs index f0fc2f3..8d00d20 100644 --- a/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/MailSearchFilter.cs +++ b/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/MailSearchFilter.cs @@ -29,7 +29,7 @@ public record MailSearchFilter /// Maximum number of messages to retrieve. 0 means unlimited. /// Applied after sorting; defaults to 50. /// - public int MaxCount { get; init; } = 50; + public int? MaxCount { get; init; } = null; /// /// Controls the order of the returned messages. Defaults to . diff --git a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailByUidQuery.cs b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailByUidQuery.cs index 37c4fc2..9597666 100644 --- a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailByUidQuery.cs +++ b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailByUidQuery.cs @@ -36,11 +36,6 @@ public record FetchEmailByUidQuery : IRequest /// When , attachment data is included in the result. /// public bool WithAttachments { get; init; } = false; - /// - /// When (default), the message is marked as \Seen on the server. - /// Set to for a non-destructive peek. - /// - public bool MarkAsSeen { get; init; } = true; } public class FetchEmailByUidQueryHandler( @@ -62,7 +57,6 @@ public class FetchEmailByUidQueryHandler( (long)request.Uid!, request.Folder, request.WithAttachments, - request.MarkAsSeen, cancellationToken); } } diff --git a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailsQuery.cs b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailsQuery.cs index 7074d8f..9b1a6bb 100644 --- a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailsQuery.cs +++ b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailsQuery.cs @@ -21,12 +21,6 @@ public record FetchEmailsQuery : IRequest> /// Mail query used to filter and limit the emails retrieved. /// public MailSearchFilter Mail { get; init; } = new(); - - /// - /// When (default), fetched messages are marked as \Seen on the server. - /// Set to for a non-destructive peek. - /// - public bool MarkAsSeen { get; init; } = true; } public class FetchEmailsQueryHandler( @@ -46,7 +40,6 @@ public class FetchEmailsQueryHandler( return await ImapService.FetchEmailsAsync( account, request.Mail, - request.MarkAsSeen, cancellationToken); } } diff --git a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/FetchEmailByUidQueryValidator.cs b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/FetchEmailByUidQueryValidator.cs deleted file mode 100644 index c9ecfe8..0000000 --- a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/FetchEmailByUidQueryValidator.cs +++ /dev/null @@ -1,27 +0,0 @@ -using DigitalData.MessagingService.Application.EmailReceiving.Queries; -using FluentValidation; - -namespace DigitalData.MessagingService.Application.EmailReceiving.Validators; - -/// -/// Validates a before it is handled by . -/// -public class FetchEmailByUidQueryValidator : AbstractValidator -{ - 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."); - } -} diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs index c74fa21..2a8ec86 100644 --- a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs @@ -27,74 +27,16 @@ public class LimilabsImapEmailService( public async Task> FetchEmailsAsync( EmailAccountDto account, MailSearchFilter filter, - bool markAsSeen = true, CancellationToken cancellationToken = default) { using var imap = new Imap(); try { - await ConnectAndAuthenticateAsync(imap, account); - await SelectFolderAsync(imap, filter.Folder); - - List 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)); - - // IMAP BEFORE is exclusive, so add one day to make the bound inclusive - 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 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)]; + var uids = await FetchEmailUidsAsync(imap, account, filter, cancellationToken); 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(uids.Count); foreach (var uid in uids) @@ -103,12 +45,10 @@ public class LimilabsImapEmailService( 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)); + 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) { @@ -146,52 +86,7 @@ public class LimilabsImapEmailService( await ConnectAndAuthenticateAsync(imap, account); await SelectFolderAsync(imap, filter.Folder); - List 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 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)]; + List uids = await FetchEmailUidsAsync(imap, account, filter, cancellationToken); await imap.CloseAsync(cancellationToken); return uids; @@ -215,7 +110,6 @@ public class LimilabsImapEmailService( long uid, string folder = "INBOX", bool withAttachments = false, - bool markAsSeen = true, CancellationToken cancellationToken = default) { using var imap = new Imap(); @@ -224,28 +118,24 @@ public class LimilabsImapEmailService( 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 flags = await imap.GetFlagsByUIDAsync(uid, cancellationToken); + var mail = await FetchEmailByUidAsync(imap, uid, withAttachments, cancellationToken); await imap.CloseAsync(cancellationToken); - return MapToContext(uid, mail, flags, withAttachments); + 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, @@ -276,6 +166,118 @@ public class LimilabsImapEmailService( } // Private helpers + private async Task> FetchEmailUidsAsync( + Imap imap, + EmailAccountDto account, + MailSearchFilter filter, + CancellationToken cancellationToken = default) + { + await ConnectAndAuthenticateAsync(imap, account); + await SelectFolderAsync(imap, filter.Folder); + + List 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)); + + // IMAP BEFORE is exclusive, so add one day to make the bound inclusive + 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 uids = [.. await imap.SearchAsync(searchExpression, cancellationToken)]; + + if (filter.SortOrder == MailSortOrder.NewestFirst) + uids.Reverse(); + + if (filter.MaxCount is int maxCount && maxCount > 0 && uids.Count > maxCount) + uids = [.. uids.Take(maxCount)]; + + return uids; + } + + private static async Task FetchEmailByUidAsync( + Imap imap, + long uid, + bool withAttachments = false, + CancellationToken cancellationToken = default) + { + var eml = await imap.GetMessageByUIDAsync(uid, cancellationToken); + var mail = new MailBuilder().CreateFromEml(eml); + + var attachments = new List(); + + if (withAttachments) + { + 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, + }; + } + private async Task ConnectAndAuthenticateAsync(Imap imap, EmailAccountDto account) { if (account.ImapUseSsl) @@ -297,50 +299,4 @@ public class LimilabsImapEmailService( else await imap.SelectAsync(folder); } - - private static ReceivedEmailContext MapToContext(long uid, IMail mail, List? flags, bool withAttachments = false) - { - var attachments = new List(); - - if (withAttachments) - { - 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 - }; - } }