Refactor email search with new filter models

Replaced the old `SearchFilter` with the new `MailSearchFilter` model to enable more flexible and granular email search criteria. Introduced `DateFilter`, `UidFilter`, and `MailSortOrder` to support advanced filtering options such as date ranges, UID ranges, and sorting order. Updated `IImapEmailService` and `FetchEmailsQuery` to use the new models.

Added validators (`DateFilterValidator`, `MailSearchFilterValidator`, `UidFilterValidator`) to ensure input correctness. Refactored `LimilabsImapEmailService` to dynamically construct IMAP search queries based on the new filter properties, supporting unseen messages, text filters, and client-side attachment filtering.

Improved maintainability and scalability by cleaning up redundant code and leveraging the new models and validation framework.
This commit is contained in:
2026-08-07 14:52:28 +02:00
parent 53ba40b316
commit 8e2e9af451
11 changed files with 346 additions and 35 deletions

View File

@@ -1,4 +1,5 @@
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.Application.Common.Models.MailSearch;
namespace DigitalData.MessagingService.Application.Common.Interfaces;
@@ -15,7 +16,7 @@ public interface IImapEmailService
/// <param name="cancellationToken">Cancellation token.</param>
Task<IEnumerable<ReceivedEmailContext>> FetchEmailsAsync(
EmailAccountDto account,
FetchEmailsQuery.SearchFilter filter,
MailSearchFilter filter,
CancellationToken cancellationToken = default);
/// <summary>

View File

@@ -0,0 +1,13 @@
namespace DigitalData.MessagingService.Application.Common.Models.MailSearch;
/// <summary>
/// Constrains the search to messages within an arrival-date range.
/// </summary>
/// <remarks>
/// At least one of <see cref="After"/> or <see cref="Before"/> must be provided.
/// When both are set <see cref="After"/> must be earlier than <see cref="Before"/>.
/// Both bounds are <b>inclusive</b>.
/// </remarks>
/// <param name="After">Earliest date to include (inclusive). Maps to IMAP <c>SINCE</c>.</param>
/// <param name="Before">Latest date to include (inclusive). Maps to IMAP <c>BEFORE</c> (next day is used internally).</param>
public record DateFilter(DateTime? After = null, DateTime? Before = null);

View File

@@ -0,0 +1,78 @@
namespace DigitalData.MessagingService.Application.Common.Models.MailSearch;
/// <summary>
/// Describes all criteria and options used when searching an IMAP mailbox.
/// </summary>
/// <remarks>
/// All text-match properties are <b>case-insensitive</b> substring searches performed server-side via IMAP SEARCH.
/// Combine multiple criteria freely; an implicit AND is applied across all non-null fields.
/// </remarks>
public record MailSearchFilter
{
/// <summary>
/// Mailbox folder to search. Defaults to <c>"INBOX"</c>.
/// </summary>
public string Folder { get; init; } = "INBOX";
/// <summary>
/// When <see langword="true"/>, only unread (UNSEEN) messages are returned.
/// </summary>
public bool UnseenOnly { get; init; } = false;
/// <summary>
/// When <see langword="true"/>, only messages that carry at least one attachment are returned.
/// Filtered client-side after fetching the message envelope; does not affect the IMAP SEARCH query.
/// </summary>
public bool HasAttachments { get; init; } = false;
/// <summary>
/// Maximum number of messages to retrieve. <c>0</c> means unlimited.
/// Applied after sorting; defaults to <c>50</c>.
/// </summary>
public int MaxCount { get; init; } = 50;
/// <summary>
/// Controls the order of the returned messages. Defaults to <see cref="MailSortOrder.NewestFirst"/>.
/// </summary>
public MailSortOrder SortOrder { get; init; } = MailSortOrder.NewestFirst;
// ── Text filters ───────────────────────────────────────────────────────────
/// <summary>
/// Case-insensitive substring the message subject must contain.
/// Maps to IMAP <c>SUBJECT</c>.
/// </summary>
public string? SubjectContains { get; init; } = null;
/// <summary>
/// Case-insensitive substring that must appear in the <c>From</c> header.
/// Maps to IMAP <c>FROM</c>.
/// </summary>
public string? SenderContains { get; init; } = null;
/// <summary>
/// Case-insensitive substring that must appear in <c>To</c> or <c>Cc</c>.
/// Maps to IMAP <c>TO</c>.
/// </summary>
public string? RecipientContains { get; init; } = null;
/// <summary>
/// Case-insensitive substring that must appear anywhere in the message body (text or HTML part).
/// Maps to IMAP <c>BODY</c>.
/// </summary>
public string? BodyContains { get; init; } = null;
// ── Structured filters ─────────────────────────────────────────────────────
/// <summary>
/// Constrains results to a specific UID or a UID range.
/// See <see cref="UidFilter"/> for mutual-exclusion rules between its fields.
/// </summary>
public UidFilter? Uid { get; init; } = null;
/// <summary>
/// Constrains results to messages received within a date range.
/// See <see cref="DateFilter"/> for rules between its fields.
/// </summary>
public DateFilter? Date { get; init; } = null;
}

View File

@@ -0,0 +1,17 @@
namespace DigitalData.MessagingService.Application.Common.Models.MailSearch;
/// <summary>
/// Controls the order in which fetched messages are returned.
/// </summary>
public enum MailSortOrder
{
/// <summary>
/// Newest messages first (default).
/// </summary>
NewestFirst,
/// <summary>
/// Oldest messages first.
/// </summary>
OldestFirst
}

View File

@@ -0,0 +1,14 @@
namespace DigitalData.MessagingService.Application.Common.Models.MailSearch;
/// <summary>
/// Constrains the search to a specific UID or a UID range.
/// </summary>
/// <remarks>
/// Use <see cref="Absolute"/> for an exact single-message lookup.
/// Use <see cref="Min"/> and/or <see cref="Max"/> for an open or closed range.
/// Mixing <see cref="Absolute"/> with <see cref="Min"/> or <see cref="Max"/> is not allowed.
/// </remarks>
/// <param name="Min">Lower bound of the UID range (inclusive). Ignored when <see cref="Absolute"/> is set.</param>
/// <param name="Max">Upper bound of the UID range (inclusive). Ignored when <see cref="Absolute"/> is set.</param>
/// <param name="Absolute">Exact UID to match. When set, <see cref="Min"/> and <see cref="Max"/> must be <see langword="null"/>.</param>
public record UidFilter(long? Min = null, long? Max = null, long? Absolute = null);