Merge pull request #115 from Cocoanetics/fix/issue-112

feat: add IMAP WITHIN search extension support (RFC 5032)
This commit is contained in:
Oliver Drobnik
2026-03-07 20:48:17 +01:00
committed by GitHub
6 changed files with 177 additions and 1 deletions
+1 -1
View File
@@ -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 | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
@@ -45,6 +45,7 @@ struct ExtendedSearchCommand<T: MessageIdentifier>: 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 {
@@ -44,6 +44,7 @@ struct SearchCommand<T: MessageIdentifier>: IMAPTaggedCommand, Sendable {
guard !criteria.isEmpty else {
throw IMAPError.invalidArgument("Search criteria cannot be empty")
}
for criterion in criteria { try criterion.validate() }
}
/**
+6
View File
@@ -1158,6 +1158,9 @@ public actor IMAPServer {
- Note: Logs search operations at debug level with criteria count and results count
*/
public func search<T: MessageIdentifier>(identifierSet: MessageIdentifierSet<T>? = nil, criteria: [SearchCriteria], calendar: Calendar = Calendar(identifier: .gregorian)) async throws -> MessageIdentifierSet<T> {
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<T: MessageIdentifier>(identifierSet: MessageIdentifierSet<T>? = nil, criteria: [SearchCriteria], calendar: Calendar = Calendar(identifier: .gregorian), partialRange: PartialRange? = nil) async throws -> ExtendedSearchResult<T> {
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<T>(identifierSet: identifierSet, criteria: criteria, calendar: calendar, useEsearch: useEsearch, partialRange: partialRange)
return try await executeCommand(command)
@@ -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)
}
}
}
@@ -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<SwiftMail.UID>(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<SwiftMail.UID>(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<SwiftMail.UID>(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<SwiftMail.UID>(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()
}
}
}