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);

View File

@@ -1,6 +1,7 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.EmailAccount.Queries;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Models.MailSearch;
using DigitalData.MessagingService.Application.EmailAccount.Queries;
using DigitalData.MessagingService.Domain.Exceptions;
using MediatR;
@@ -19,25 +20,7 @@ public record FetchEmailsQuery : IRequest<IEnumerable<ReceivedEmailContext>>
/// <summary>
/// Mail query used to filter and limit the emails retrieved.
/// </summary>
public SearchFilter Mail { get; init; } = new();
public record SearchFilter
{
/// <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 MailSearchFilter Mail { get; init; } = new();
}
public class FetchEmailsQueryHandler(

View File

@@ -0,0 +1,37 @@
using DigitalData.MessagingService.Application.Common.Models.MailSearch;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
/// <summary>
/// Validates a <see cref="DateFilter"/> value object.
/// </summary>
public class DateFilterValidator : AbstractValidator<DateFilter>
{
public DateFilterValidator()
{
// At least one field must be provided
RuleFor(x => x)
.Must(d => d.After is not null || d.Before is not null)
.WithName("Date")
.WithMessage("DateFilter must specify at least one of: After or Before.");
// Dates must not be in the future
RuleFor(x => x.After)
.LessThanOrEqualTo(DateTime.UtcNow)
.WithMessage("Date.After must not be in the future.")
.When(x => x.After is not null);
RuleFor(x => x.Before)
.LessThanOrEqualTo(DateTime.UtcNow)
.WithMessage("Date.Before must not be in the future.")
.When(x => x.Before is not null);
// Range coherence: After must be earlier than Before
RuleFor(x => x)
.Must(d => d.After!.Value < d.Before!.Value)
.WithName("Date.Range")
.WithMessage("Date.After must be earlier than Date.Before.")
.When(x => x.After is not null && x.Before is not null);
}
}

View File

@@ -0,0 +1,22 @@
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
/// <summary>
/// Validates a <see cref="FetchEmailsQuery"/> before it is handled by <see cref="FetchEmailsQueryHandler"/>.
/// </summary>
public class FetchEmailsQueryValidator : AbstractValidator<FetchEmailsQuery>
{
public FetchEmailsQueryValidator()
{
RuleFor(x => x.Account)
.NotNull()
.WithMessage("Account query must not be null.");
RuleFor(x => x.Mail)
.NotNull()
.WithMessage("Mail search filter must not be null.")
.SetValidator(new MailSearchFilterValidator());
}
}

View File

@@ -0,0 +1,51 @@
using DigitalData.MessagingService.Application.Common.Models.MailSearch;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
/// <summary>
/// Validates a <see cref="MailSearchFilter"/> instance before it is dispatched to the IMAP service.
/// </summary>
public class MailSearchFilterValidator : AbstractValidator<MailSearchFilter>
{
public MailSearchFilterValidator()
{
RuleFor(x => x.Folder)
.NotEmpty()
.WithMessage("Folder must not be empty.")
.MaximumLength(255)
.WithMessage("Folder name must not exceed 255 characters.");
RuleFor(x => x.MaxCount)
.GreaterThanOrEqualTo(0)
.WithMessage("MaxCount must be 0 (unlimited) or a positive number.");
RuleFor(x => x.SubjectContains)
.MaximumLength(500)
.WithMessage("SubjectContains must not exceed 500 characters.")
.When(x => x.SubjectContains is not null);
RuleFor(x => x.SenderContains)
.MaximumLength(320)
.WithMessage("SenderContains must not exceed 320 characters.")
.When(x => x.SenderContains is not null);
RuleFor(x => x.RecipientContains)
.MaximumLength(320)
.WithMessage("RecipientContains must not exceed 320 characters.")
.When(x => x.RecipientContains is not null);
RuleFor(x => x.BodyContains)
.MaximumLength(1000)
.WithMessage("BodyContains must not exceed 1000 characters.")
.When(x => x.BodyContains is not null);
RuleFor(x => x.Uid)
.SetValidator(new UidFilterValidator()!)
.When(x => x.Uid is not null);
RuleFor(x => x.Date)
.SetValidator(new DateFilterValidator()!)
.When(x => x.Date is not null);
}
}

View File

@@ -0,0 +1,48 @@
using DigitalData.MessagingService.Application.Common.Models.MailSearch;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
/// <summary>
/// Validates a <see cref="UidFilter"/> value object.
/// </summary>
public class UidFilterValidator : AbstractValidator<UidFilter>
{
public UidFilterValidator()
{
// Absolute and range are mutually exclusive
RuleFor(x => x)
.Must(u => u.Absolute is null || (u.Min is null && u.Max is null))
.WithName("Uid.Absolute")
.WithMessage("Uid.Absolute cannot be combined with Uid.Min or Uid.Max. Use either an exact UID or a range.");
// At least one field must be provided
RuleFor(x => x)
.Must(u => u.Absolute is not null || u.Min is not null || u.Max is not null)
.WithName("Uid")
.WithMessage("UidFilter must specify at least one of: Absolute, Min, or Max.");
// All UID values must be positive
RuleFor(x => x.Absolute)
.GreaterThan(0)
.WithMessage("Uid.Absolute must be a positive number.")
.When(x => x.Absolute is not null);
RuleFor(x => x.Min)
.GreaterThan(0)
.WithMessage("Uid.Min must be a positive number.")
.When(x => x.Min is not null);
RuleFor(x => x.Max)
.GreaterThan(0)
.WithMessage("Uid.Max must be a positive number.")
.When(x => x.Max is not null);
// Range coherence: Min must be less than or equal to Max
RuleFor(x => x)
.Must(u => u.Min!.Value <= u.Max!.Value)
.WithName("Uid.Range")
.WithMessage("Uid.Min must be less than or equal to Uid.Max.")
.When(x => x.Min is not null && x.Max is not null);
}
}

View File

@@ -1,11 +1,12 @@
using System.Text;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Models.MailSearch;
using DigitalData.MessagingService.Domain.Exceptions;
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
using Limilabs.Client.IMAP;
using Limilabs.Mail;
using Microsoft.Extensions.Logging;
using System.Text;
namespace DigitalData.MessagingService.Infrastructure.Services;
@@ -25,7 +26,7 @@ public class LimilabsImapEmailService(
// Public API
public async Task<IEnumerable<ReceivedEmailContext>> FetchEmailsAsync(
EmailAccountDto account,
FetchEmailsQuery.SearchFilter filter,
MailSearchFilter filter,
CancellationToken cancellationToken = default)
{
using var imap = new Imap();
@@ -34,17 +35,59 @@ public class LimilabsImapEmailService(
await ConnectAndAuthenticateAsync(imap, account);
await SelectFolderAsync(imap, filter.Folder);
// Get UIDs to fetch
List<long> uids = filter.UnseenOnly
? [.. await imap.SearchAsync(Flag.Unseen, cancellationToken)]
: [.. await imap.GetAllAsync(cancellationToken)];
List<ICriterion> criterions = [];
// Most-recent first; honour maxCount
uids.Reverse();
if (filter.MaxCount > 0 && uids.Count() > filter.MaxCount)
if (filter.UnseenOnly)
criterions.Add((ICriterion)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
{
// Open-ended ranges: fall back to 1 / null when one side is omitted
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)));
}
// Get UIDs to fetch
List<long> uids = [.. await imap.SearchAsync(Expression.And([.. criterions]), cancellationToken)];
// Apply requested sort order
if (filter.SortOrder == MailSortOrder.NewestFirst)
uids.Reverse();
if (filter.MaxCount > 0 && uids.Count > filter.MaxCount)
uids = [.. uids.Take(filter.MaxCount)];
var results = new List<ReceivedEmailContext>(uids.Count());
var results = new List<ReceivedEmailContext>(uids.Count);
foreach (var uid in uids)
{
@@ -52,10 +95,14 @@ public class LimilabsImapEmailService(
try
{
var eml = await imap.PeekMessageByUIDAsync(uid, cancellationToken);
var mail = new MailBuilder().CreateFromEml(eml);
var eml = await imap.PeekMessageByUIDAsync(uid, cancellationToken);
var mail = new MailBuilder().CreateFromEml(eml);
var flags = await imap.GetFlagsByUIDAsync(uid, cancellationToken);
// Client-side attachment filter — IMAP has no native criterion for this
if (filter.HasAttachments && mail.Attachments.Count == 0 && mail.Visuals.Count == 0)
continue;
results.Add(MapToContext(uid, mail, flags));
}
catch (Exception ex)