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/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/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 e06a39c..86fc277 100644 --- a/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift +++ b/Sources/SwiftMail/IMAP/Models/SearchCriteria.swift @@ -120,6 +120,51 @@ 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). + * The interval must be a positive integer (≥ 1). Requires the server to advertise the `WITHIN` capability. + */ + 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(seconds: 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 + } + } + + /// 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. @@ -242,6 +287,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..3d90df5 --- /dev/null +++ b/Tests/SwiftIMAPTests/WithinSearchTests.swift @@ -0,0 +1,119 @@ +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(seconds: 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(seconds: 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(seconds: 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(seconds: 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")) + } + + @Test + func testZeroIntervalThrows() async throws { + #expect(throws: (any Error).self) { + try SearchCriteria.younger(seconds: 0).validate() + } + #expect(throws: (any Error).self) { + try SearchCriteria.older(seconds: 0).validate() + } + } + + @Test + func testNegativeIntervalThrows() async throws { + #expect(throws: (any Error).self) { + try SearchCriteria.younger(seconds: -100).validate() + } + #expect(throws: (any Error).self) { + try SearchCriteria.older(seconds: -1).validate() + } + } +}