diff --git a/src/core/DigitalData.MessagingService.Application/Common/Interfaces/IImapEmailService.cs b/src/core/DigitalData.MessagingService.Application/Common/Interfaces/IImapEmailService.cs
index ad27997..0fd507f 100644
--- a/src/core/DigitalData.MessagingService.Application/Common/Interfaces/IImapEmailService.cs
+++ b/src/core/DigitalData.MessagingService.Application/Common/Interfaces/IImapEmailService.cs
@@ -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
/// Cancellation token.
Task> FetchEmailsAsync(
EmailAccountDto account,
- FetchEmailsQuery.SearchFilter filter,
+ MailSearchFilter filter,
CancellationToken cancellationToken = default);
///
diff --git a/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/DateFilter.cs b/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/DateFilter.cs
new file mode 100644
index 0000000..4d57386
--- /dev/null
+++ b/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/DateFilter.cs
@@ -0,0 +1,13 @@
+namespace DigitalData.MessagingService.Application.Common.Models.MailSearch;
+
+///
+/// Constrains the search to messages within an arrival-date range.
+///
+///
+/// At least one of or must be provided.
+/// When both are set must be earlier than .
+/// Both bounds are inclusive.
+///
+/// Earliest date to include (inclusive). Maps to IMAP SINCE.
+/// Latest date to include (inclusive). Maps to IMAP BEFORE (next day is used internally).
+public record DateFilter(DateTime? After = null, DateTime? Before = null);
diff --git a/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/MailSearchFilter.cs b/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/MailSearchFilter.cs
new file mode 100644
index 0000000..9f37b3d
--- /dev/null
+++ b/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/MailSearchFilter.cs
@@ -0,0 +1,78 @@
+namespace DigitalData.MessagingService.Application.Common.Models.MailSearch;
+
+///
+/// Describes all criteria and options used when searching an IMAP mailbox.
+///
+///
+/// All text-match properties are case-insensitive substring searches performed server-side via IMAP SEARCH.
+/// Combine multiple criteria freely; an implicit AND is applied across all non-null fields.
+///
+public record MailSearchFilter
+{
+ ///
+ /// Mailbox folder to search. Defaults to "INBOX".
+ ///
+ public string Folder { get; init; } = "INBOX";
+
+ ///
+ /// When , only unread (UNSEEN) messages are returned.
+ ///
+ public bool UnseenOnly { get; init; } = false;
+
+ ///
+ /// When , 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.
+ ///
+ public bool HasAttachments { get; init; } = false;
+
+ ///
+ /// Maximum number of messages to retrieve. 0 means unlimited.
+ /// Applied after sorting; defaults to 50.
+ ///
+ public int MaxCount { get; init; } = 50;
+
+ ///
+ /// Controls the order of the returned messages. Defaults to .
+ ///
+ public MailSortOrder SortOrder { get; init; } = MailSortOrder.NewestFirst;
+
+ // ── Text filters ───────────────────────────────────────────────────────────
+
+ ///
+ /// Case-insensitive substring the message subject must contain.
+ /// Maps to IMAP SUBJECT.
+ ///
+ public string? SubjectContains { get; init; } = null;
+
+ ///
+ /// Case-insensitive substring that must appear in the From header.
+ /// Maps to IMAP FROM.
+ ///
+ public string? SenderContains { get; init; } = null;
+
+ ///
+ /// Case-insensitive substring that must appear in To or Cc.
+ /// Maps to IMAP TO.
+ ///
+ public string? RecipientContains { get; init; } = null;
+
+ ///
+ /// Case-insensitive substring that must appear anywhere in the message body (text or HTML part).
+ /// Maps to IMAP BODY.
+ ///
+ public string? BodyContains { get; init; } = null;
+
+ // ── Structured filters ─────────────────────────────────────────────────────
+
+ ///
+ /// Constrains results to a specific UID or a UID range.
+ /// See for mutual-exclusion rules between its fields.
+ ///
+ public UidFilter? Uid { get; init; } = null;
+
+ ///
+ /// Constrains results to messages received within a date range.
+ /// See for rules between its fields.
+ ///
+ public DateFilter? Date { get; init; } = null;
+}
\ No newline at end of file
diff --git a/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/MailSortOrder.cs b/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/MailSortOrder.cs
new file mode 100644
index 0000000..c18970d
--- /dev/null
+++ b/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/MailSortOrder.cs
@@ -0,0 +1,17 @@
+namespace DigitalData.MessagingService.Application.Common.Models.MailSearch;
+
+///
+/// Controls the order in which fetched messages are returned.
+///
+public enum MailSortOrder
+{
+ ///
+ /// Newest messages first (default).
+ ///
+ NewestFirst,
+
+ ///
+ /// Oldest messages first.
+ ///
+ OldestFirst
+}
diff --git a/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/UidFilter.cs b/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/UidFilter.cs
new file mode 100644
index 0000000..3981850
--- /dev/null
+++ b/src/core/DigitalData.MessagingService.Application/Common/Models/MailSearch/UidFilter.cs
@@ -0,0 +1,14 @@
+namespace DigitalData.MessagingService.Application.Common.Models.MailSearch;
+
+///
+/// Constrains the search to a specific UID or a UID range.
+///
+///
+/// Use for an exact single-message lookup.
+/// Use and/or for an open or closed range.
+/// Mixing with or is not allowed.
+///
+/// Lower bound of the UID range (inclusive). Ignored when is set.
+/// Upper bound of the UID range (inclusive). Ignored when is set.
+/// Exact UID to match. When set, and must be .
+public record UidFilter(long? Min = null, long? Max = null, long? Absolute = null);
\ No newline at end of file
diff --git a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailsQuery.cs b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailsQuery.cs
index 7d8796e..9b1a6bb 100644
--- a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailsQuery.cs
+++ b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailsQuery.cs
@@ -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>
///
/// Mail query used to filter and limit the emails retrieved.
///
- public SearchFilter Mail { get; init; } = new();
-
- public record SearchFilter
- {
- ///
- /// Mailbox folder to read from (default: "INBOX").
- ///
- public string Folder { get; init; } = "INBOX";
-
- ///
- /// When returns only unread messages.
- ///
- public bool UnseenOnly { get; init; } = false;
-
- ///
- /// Maximum number of messages to retrieve (most-recent first). 0 = unlimited.
- ///
- public int MaxCount { get; init; } = 50;
- }
+ public MailSearchFilter Mail { get; init; } = new();
}
public class FetchEmailsQueryHandler(
diff --git a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/DateFilterValidator.cs b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/DateFilterValidator.cs
new file mode 100644
index 0000000..03b7e36
--- /dev/null
+++ b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/DateFilterValidator.cs
@@ -0,0 +1,37 @@
+using DigitalData.MessagingService.Application.Common.Models.MailSearch;
+using FluentValidation;
+
+namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
+
+///
+/// Validates a value object.
+///
+public class DateFilterValidator : AbstractValidator
+{
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/FetchEmailsQueryValidator.cs b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/FetchEmailsQueryValidator.cs
new file mode 100644
index 0000000..6f19a49
--- /dev/null
+++ b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/FetchEmailsQueryValidator.cs
@@ -0,0 +1,22 @@
+using DigitalData.MessagingService.Application.EmailReceiving.Queries;
+using FluentValidation;
+
+namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
+
+///
+/// Validates a before it is handled by .
+///
+public class FetchEmailsQueryValidator : AbstractValidator
+{
+ 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());
+ }
+}
\ No newline at end of file
diff --git a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/MailSearchFilterValidator.cs b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/MailSearchFilterValidator.cs
new file mode 100644
index 0000000..51696cd
--- /dev/null
+++ b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/MailSearchFilterValidator.cs
@@ -0,0 +1,51 @@
+using DigitalData.MessagingService.Application.Common.Models.MailSearch;
+using FluentValidation;
+
+namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
+
+///
+/// Validates a instance before it is dispatched to the IMAP service.
+///
+public class MailSearchFilterValidator : AbstractValidator
+{
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/UidFilterValidator.cs b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/UidFilterValidator.cs
new file mode 100644
index 0000000..b1e0b0b
--- /dev/null
+++ b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Validators/UidFilterValidator.cs
@@ -0,0 +1,48 @@
+using DigitalData.MessagingService.Application.Common.Models.MailSearch;
+using FluentValidation;
+
+namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
+
+///
+/// Validates a value object.
+///
+public class UidFilterValidator : AbstractValidator
+{
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs
index f55f51a..da57906 100644
--- a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs
+++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs
@@ -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> 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 uids = filter.UnseenOnly
- ? [.. await imap.SearchAsync(Flag.Unseen, cancellationToken)]
- : [.. await imap.GetAllAsync(cancellationToken)];
+ List 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 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(uids.Count());
+ var results = new List(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)