mirror of
https://github.com/Cocoanetics/SwiftMail.git
synced 2026-03-17 20:02:25 +00:00
Merge pull request #115 from Cocoanetics/fix/issue-112
feat: add IMAP WITHIN search extension support (RFC 5032)
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user