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)