From 86663e56bdffd80900d661899111351c844adb73 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sat, 7 Mar 2026 19:52:23 +0100 Subject: [PATCH 1/4] feat: add IMAP WITHIN search extension support (RFC 5032) Add SearchCriteria.older(seconds:) and SearchCriteria.younger(seconds:) for relative time-based message searching. Maps directly to NIOIMAPCore's .older and .younger SearchKey cases. Works with both search() and extendedSearch() APIs. Closes #112 --- README.md | 2 +- .../IMAP/Models/SearchCriteria.swift | 14 +++ Tests/SwiftIMAPTests/WithinSearchTests.swift | 99 +++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 Tests/SwiftIMAPTests/WithinSearchTests.swift diff --git a/README.md b/README.md index fc6e21b..97d7592 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ implements support for each capability. | **SORT** | Server-side message sorting (RFC 5256) | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | **ESORT** | Extended SORT results (RFC 5267) | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | **CONTEXT=SORT** | Persistent sort context | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | -| **WITHIN** | Search by relative time (RFC 5032) | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | +| **WITHIN** | Search by relative time (RFC 5032) | ❌ | ✅ | ✅ | ❌ | ✅ | ✅ | | **SASL-IR** | Initial SASL response support (RFC 4959) | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | | **XAPPLEPUSHSERVICE** | Apple push integration for Mail app | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | | **XAPPLELITERAL** | Apple literal transmission optimization | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | diff --git a/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift b/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift index e06a39c..8d470f9 100644 --- a/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift +++ b/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift @@ -120,6 +120,16 @@ public indirect enum SearchCriteria: Sendable { /** Matches messages that do not have the `\Seen` flag set. */ case unseen + /** Matches messages older than the specified number of seconds (RFC 5032 WITHIN extension). + * Requires the server to advertise the `WITHIN` capability. + */ + case older(Int) + + /** Matches messages younger than the specified number of seconds (RFC 5032 WITHIN extension). + * Requires the server to advertise the `WITHIN` capability. + */ + case younger(Int) + /** Converts a Swift string to an NIO ByteBuffer. * - Parameter str: The string to convert. * - Returns: A ByteBuffer containing the string data. @@ -242,6 +252,10 @@ public indirect enum SearchCriteria: Sendable { return .unkeyword(stringToKeyword(value)) case .unseen: return .unseen + case .older(let seconds): + return .older(seconds) + case .younger(let seconds): + return .younger(seconds) } } } diff --git a/Tests/SwiftIMAPTests/WithinSearchTests.swift b/Tests/SwiftIMAPTests/WithinSearchTests.swift new file mode 100644 index 0000000..6218b4f --- /dev/null +++ b/Tests/SwiftIMAPTests/WithinSearchTests.swift @@ -0,0 +1,99 @@ +import Foundation +import NIO +import NIOEmbedded +@preconcurrency import NIOIMAP +@preconcurrency import NIOIMAPCore +import Testing +@testable import SwiftMail + +struct WithinSearchTests { + @Test + func testYoungerSearchKeyWireFormat() async throws { + let channel = EmbeddedChannel() + defer { _ = try? channel.finish() } + + try await channel.pipeline.addHandler(IMAPClientHandler()) + + let command = SearchCommand(criteria: [SearchCriteria.younger(3600)]) + let tagged = command.toTaggedCommand(tag: "W001") + let wrapped = IMAPClientHandler.OutboundIn.part(CommandStreamPart.tagged(tagged)) + try await channel.writeAndFlush(wrapped) + + guard var outbound = try channel.readOutbound(as: ByteBuffer.self) else { + Issue.record("Expected outbound bytes") + return + } + let wireString = outbound.readString(length: outbound.readableBytes) ?? "" + + #expect(wireString.contains("UID SEARCH")) + #expect(wireString.contains("YOUNGER 3600")) + } + + @Test + func testOlderSearchKeyWireFormat() async throws { + let channel = EmbeddedChannel() + defer { _ = try? channel.finish() } + + try await channel.pipeline.addHandler(IMAPClientHandler()) + + let command = SearchCommand(criteria: [SearchCriteria.older(86400)]) + let tagged = command.toTaggedCommand(tag: "W002") + let wrapped = IMAPClientHandler.OutboundIn.part(CommandStreamPart.tagged(tagged)) + try await channel.writeAndFlush(wrapped) + + guard var outbound = try channel.readOutbound(as: ByteBuffer.self) else { + Issue.record("Expected outbound bytes") + return + } + let wireString = outbound.readString(length: outbound.readableBytes) ?? "" + + #expect(wireString.contains("UID SEARCH")) + #expect(wireString.contains("OLDER 86400")) + } + + @Test + func testWithinCriteriaWithExtendedSearch() async throws { + let channel = EmbeddedChannel() + defer { _ = try? channel.finish() } + + try await channel.pipeline.addHandler(IMAPClientHandler()) + + let command = ExtendedSearchCommand(criteria: [SearchCriteria.younger(600)], useEsearch: true) + let tagged = command.toTaggedCommand(tag: "W003") + let wrapped = IMAPClientHandler.OutboundIn.part(CommandStreamPart.tagged(tagged)) + try await channel.writeAndFlush(wrapped) + + guard var outbound = try channel.readOutbound(as: ByteBuffer.self) else { + Issue.record("Expected outbound bytes") + return + } + let wireString = outbound.readString(length: outbound.readableBytes) ?? "" + + #expect(wireString.contains("UID SEARCH")) + #expect(wireString.contains("YOUNGER 600")) + #expect(wireString.contains("RETURN")) + } + + @Test + func testCombinedWithinAndOtherCriteria() async throws { + let channel = EmbeddedChannel() + defer { _ = try? channel.finish() } + + try await channel.pipeline.addHandler(IMAPClientHandler()) + + let command = SearchCommand(criteria: [SearchCriteria.younger(3600), SearchCriteria.unseen]) + let tagged = command.toTaggedCommand(tag: "W004") + let wrapped = IMAPClientHandler.OutboundIn.part(CommandStreamPart.tagged(tagged)) + try await channel.writeAndFlush(wrapped) + + guard var outbound = try channel.readOutbound(as: ByteBuffer.self) else { + Issue.record("Expected outbound bytes") + return + } + let wireString = outbound.readString(length: outbound.readableBytes) ?? "" + + #expect(wireString.contains("UID SEARCH")) + #expect(wireString.contains("YOUNGER 3600")) + #expect(wireString.contains("UNSEEN")) + } +} From 2bfe3fd6af6c48bc327464bfa4fc6f0db97bce7d Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sat, 7 Mar 2026 20:00:27 +0100 Subject: [PATCH 2/4] fix: reject non-positive WITHIN intervals before encoding Addresses Codex review: validate that older/younger seconds are > 0 per RFC 5032 (non-zero positive). Validation runs recursively through and/or/not criteria in both SearchCommand and ExtendedSearchCommand. Added 2 tests for zero and negative interval rejection. --- .../IMAP/Commands/ExtendedSearchCommand.swift | 1 + .../IMAP/IMAP/Commands/SearchCommand.swift | 1 + .../IMAP/Models/SearchCriteria.swift | 23 +++++++++++++++++-- Tests/SwiftIMAPTests/WithinSearchTests.swift | 20 ++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftMail/IMAP/IMAP/Commands/ExtendedSearchCommand.swift b/Sources/SwiftMail/IMAP/IMAP/Commands/ExtendedSearchCommand.swift index 7914af7..5b442d7 100644 --- a/Sources/SwiftMail/IMAP/IMAP/Commands/ExtendedSearchCommand.swift +++ b/Sources/SwiftMail/IMAP/IMAP/Commands/ExtendedSearchCommand.swift @@ -45,6 +45,7 @@ struct ExtendedSearchCommand: IMAPTaggedCommand, Sendable guard !criteria.isEmpty else { throw IMAPError.invalidArgument("Search criteria cannot be empty") } + for criterion in criteria { try criterion.validate() } } func toTaggedCommand(tag: String) -> TaggedCommand { diff --git a/Sources/SwiftMail/IMAP/IMAP/Commands/SearchCommand.swift b/Sources/SwiftMail/IMAP/IMAP/Commands/SearchCommand.swift index aba0f72..e10792c 100644 --- a/Sources/SwiftMail/IMAP/IMAP/Commands/SearchCommand.swift +++ b/Sources/SwiftMail/IMAP/IMAP/Commands/SearchCommand.swift @@ -44,6 +44,7 @@ struct SearchCommand: IMAPTaggedCommand, Sendable { guard !criteria.isEmpty else { throw IMAPError.invalidArgument("Search criteria cannot be empty") } + for criterion in criteria { try criterion.validate() } } /** diff --git a/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift b/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift index 8d470f9..95d0564 100644 --- a/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift +++ b/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift @@ -121,15 +121,34 @@ public indirect enum SearchCriteria: Sendable { case unseen /** Matches messages older than the specified number of seconds (RFC 5032 WITHIN extension). - * Requires the server to advertise the `WITHIN` capability. + * The interval must be a positive integer (≥ 1). Requires the server to advertise the `WITHIN` capability. */ case older(Int) /** Matches messages younger than the specified number of seconds (RFC 5032 WITHIN extension). - * Requires the server to advertise the `WITHIN` capability. + * The interval must be a positive integer (≥ 1). Requires the server to advertise the `WITHIN` capability. */ case younger(Int) + /** Validates this search criteria, throwing if any values are out of range. */ + func validate() throws { + switch self { + case .older(let seconds), .younger(let seconds): + guard seconds > 0 else { + throw IMAPError.invalidArgument("WITHIN interval must be a positive integer (got \(seconds))") + } + case .and(let criterias): + for c in criterias { try c.validate() } + case .not(let criteria): + try criteria.validate() + case .or(let c1, let c2): + try c1.validate() + try c2.validate() + default: + break + } + } + /** Converts a Swift string to an NIO ByteBuffer. * - Parameter str: The string to convert. * - Returns: A ByteBuffer containing the string data. diff --git a/Tests/SwiftIMAPTests/WithinSearchTests.swift b/Tests/SwiftIMAPTests/WithinSearchTests.swift index 6218b4f..ff29166 100644 --- a/Tests/SwiftIMAPTests/WithinSearchTests.swift +++ b/Tests/SwiftIMAPTests/WithinSearchTests.swift @@ -96,4 +96,24 @@ struct WithinSearchTests { #expect(wireString.contains("YOUNGER 3600")) #expect(wireString.contains("UNSEEN")) } + + @Test + func testZeroIntervalThrows() async throws { + #expect(throws: (any Error).self) { + try SearchCriteria.younger(0).validate() + } + #expect(throws: (any Error).self) { + try SearchCriteria.older(0).validate() + } + } + + @Test + func testNegativeIntervalThrows() async throws { + #expect(throws: (any Error).self) { + try SearchCriteria.younger(-100).validate() + } + #expect(throws: (any Error).self) { + try SearchCriteria.older(-1).validate() + } + } } From f24aa1e4ff77640d46ae433862137b279daccc00 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sat, 7 Mar 2026 20:03:10 +0100 Subject: [PATCH 3/4] fix: gate WITHIN search on server capability Pre-check that the server advertises WITHIN before sending OLDER/YOUNGER search keys. Throws IMAPError.commandNotSupported with a clear message instead of letting the server reject with BAD. Applies to both search() and extendedSearch() methods. --- Sources/SwiftMail/IMAP/IMAPServer.swift | 6 ++++++ .../SwiftMail/IMAP/Models/SearchCriteria.swift | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/Sources/SwiftMail/IMAP/IMAPServer.swift b/Sources/SwiftMail/IMAP/IMAPServer.swift index 1ae7709..afbe2ad 100644 --- a/Sources/SwiftMail/IMAP/IMAPServer.swift +++ b/Sources/SwiftMail/IMAP/IMAPServer.swift @@ -1158,6 +1158,9 @@ public actor IMAPServer { - Note: Logs search operations at debug level with criteria count and results count */ public func search(identifierSet: MessageIdentifierSet? = nil, criteria: [SearchCriteria], calendar: Calendar = Calendar(identifier: .gregorian)) async throws -> MessageIdentifierSet { + if criteria.contains(where: { $0.requiresWithin }) && !capabilities.contains(.within) { + throw IMAPError.commandNotSupported("WITHIN extension not supported by server (required for OLDER/YOUNGER search)") + } let command = SearchCommand(identifierSet: identifierSet, criteria: criteria, calendar: calendar) return try await executeCommand(command) } @@ -1187,6 +1190,9 @@ public actor IMAPServer { - `IMAPError.connectionFailed` if not connected */ public func extendedSearch(identifierSet: MessageIdentifierSet? = nil, criteria: [SearchCriteria], calendar: Calendar = Calendar(identifier: .gregorian), partialRange: PartialRange? = nil) async throws -> ExtendedSearchResult { + if criteria.contains(where: { $0.requiresWithin }) && !capabilities.contains(.within) { + throw IMAPError.commandNotSupported("WITHIN extension not supported by server (required for OLDER/YOUNGER search)") + } let useEsearch = capabilities.contains(.extendedSearch) let command = ExtendedSearchCommand(identifierSet: identifierSet, criteria: criteria, calendar: calendar, useEsearch: useEsearch, partialRange: partialRange) return try await executeCommand(command) diff --git a/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift b/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift index 95d0564..3a57b7f 100644 --- a/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift +++ b/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift @@ -149,6 +149,22 @@ public indirect enum SearchCriteria: Sendable { } } + /// Whether this criteria (or any nested child) requires the WITHIN extension. + var requiresWithin: Bool { + switch self { + case .older, .younger: + return true + case .and(let criterias): + return criterias.contains { $0.requiresWithin } + case .not(let criteria): + return criteria.requiresWithin + case .or(let c1, let c2): + return c1.requiresWithin || c2.requiresWithin + default: + return false + } + } + /** Converts a Swift string to an NIO ByteBuffer. * - Parameter str: The string to convert. * - Returns: A ByteBuffer containing the string data. From 3766ef43c073f02c21dd3628947081575032117d Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sat, 7 Mar 2026 20:05:20 +0100 Subject: [PATCH 4/4] refactor: add named parameter labels to older/younger .older(seconds: 86400) and .younger(seconds: 3600) are now self-documenting at call sites. --- .../SwiftMail/IMAP/Models/SearchCriteria.swift | 4 ++-- Tests/SwiftIMAPTests/WithinSearchTests.swift | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift b/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift index 3a57b7f..86fc277 100644 --- a/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift +++ b/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift @@ -123,12 +123,12 @@ public indirect enum SearchCriteria: Sendable { /** Matches messages older than the specified number of seconds (RFC 5032 WITHIN extension). * The interval must be a positive integer (≥ 1). Requires the server to advertise the `WITHIN` capability. */ - case older(Int) + case older(seconds: Int) /** Matches messages younger than the specified number of seconds (RFC 5032 WITHIN extension). * The interval must be a positive integer (≥ 1). Requires the server to advertise the `WITHIN` capability. */ - case younger(Int) + case younger(seconds: Int) /** Validates this search criteria, throwing if any values are out of range. */ func validate() throws { diff --git a/Tests/SwiftIMAPTests/WithinSearchTests.swift b/Tests/SwiftIMAPTests/WithinSearchTests.swift index ff29166..3d90df5 100644 --- a/Tests/SwiftIMAPTests/WithinSearchTests.swift +++ b/Tests/SwiftIMAPTests/WithinSearchTests.swift @@ -14,7 +14,7 @@ struct WithinSearchTests { try await channel.pipeline.addHandler(IMAPClientHandler()) - let command = SearchCommand(criteria: [SearchCriteria.younger(3600)]) + let command = SearchCommand(criteria: [SearchCriteria.younger(seconds: 3600)]) let tagged = command.toTaggedCommand(tag: "W001") let wrapped = IMAPClientHandler.OutboundIn.part(CommandStreamPart.tagged(tagged)) try await channel.writeAndFlush(wrapped) @@ -36,7 +36,7 @@ struct WithinSearchTests { try await channel.pipeline.addHandler(IMAPClientHandler()) - let command = SearchCommand(criteria: [SearchCriteria.older(86400)]) + let command = SearchCommand(criteria: [SearchCriteria.older(seconds: 86400)]) let tagged = command.toTaggedCommand(tag: "W002") let wrapped = IMAPClientHandler.OutboundIn.part(CommandStreamPart.tagged(tagged)) try await channel.writeAndFlush(wrapped) @@ -58,7 +58,7 @@ struct WithinSearchTests { try await channel.pipeline.addHandler(IMAPClientHandler()) - let command = ExtendedSearchCommand(criteria: [SearchCriteria.younger(600)], useEsearch: true) + let command = ExtendedSearchCommand(criteria: [SearchCriteria.younger(seconds: 600)], useEsearch: true) let tagged = command.toTaggedCommand(tag: "W003") let wrapped = IMAPClientHandler.OutboundIn.part(CommandStreamPart.tagged(tagged)) try await channel.writeAndFlush(wrapped) @@ -81,7 +81,7 @@ struct WithinSearchTests { try await channel.pipeline.addHandler(IMAPClientHandler()) - let command = SearchCommand(criteria: [SearchCriteria.younger(3600), SearchCriteria.unseen]) + let command = SearchCommand(criteria: [SearchCriteria.younger(seconds: 3600), SearchCriteria.unseen]) let tagged = command.toTaggedCommand(tag: "W004") let wrapped = IMAPClientHandler.OutboundIn.part(CommandStreamPart.tagged(tagged)) try await channel.writeAndFlush(wrapped) @@ -100,20 +100,20 @@ struct WithinSearchTests { @Test func testZeroIntervalThrows() async throws { #expect(throws: (any Error).self) { - try SearchCriteria.younger(0).validate() + try SearchCriteria.younger(seconds: 0).validate() } #expect(throws: (any Error).self) { - try SearchCriteria.older(0).validate() + try SearchCriteria.older(seconds: 0).validate() } } @Test func testNegativeIntervalThrows() async throws { #expect(throws: (any Error).self) { - try SearchCriteria.younger(-100).validate() + try SearchCriteria.younger(seconds: -100).validate() } #expect(throws: (any Error).self) { - try SearchCriteria.older(-1).validate() + try SearchCriteria.older(seconds: -1).validate() } } }