From 3ea88f6402b798299a1474693bdfa5bf231ceeda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sat, 14 Mar 2026 13:37:16 +0100 Subject: [PATCH] add IMAPIdleHandler: NIO channel handler for IDLE event streaming Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources/IMAPClient/IMAPIdleHandler.swift | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 Packages/MagnumOpusCore/Sources/IMAPClient/IMAPIdleHandler.swift diff --git a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPIdleHandler.swift b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPIdleHandler.swift new file mode 100644 index 0000000..235ed70 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPIdleHandler.swift @@ -0,0 +1,72 @@ +import NIO +import NIOIMAPCore +import NIOIMAP + +/// Events emitted by the IDLE handler +public enum IMAPIdleEvent: Sendable { + case exists(Int) + case expunge(Int) + case idleTerminated +} + +/// NIO ChannelInboundHandler that processes untagged responses during IMAP IDLE. +/// Unlike the standard IMAPResponseHandler (which uses CheckedContinuation for tagged responses), +/// this handler uses AsyncStream to deliver a continuous stream of events. +final class IMAPIdleHandler: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = Response + + private let continuation: AsyncStream.Continuation + private var idleTag: String? + + init(continuation: AsyncStream.Continuation) { + self.continuation = continuation + } + + func setIdleTag(_ tag: String) { + idleTag = tag + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let response = unwrapInboundIn(data) + + switch response { + case .untagged(let payload): + switch payload { + case .mailboxData(let data): + switch data { + case .exists(let count): + continuation.yield(.exists(count)) + default: + break + } + case .messageData(let data): + switch data { + case .expunge(let seqNum): + continuation.yield(.expunge(Int(seqNum.rawValue))) + default: + break + } + default: + break + } + case .tagged(let tagged): + if tagged.tag == idleTag { + continuation.yield(.idleTerminated) + } + case .idleStarted: + // Server acknowledged IDLE — we're now idling + break + case .fetch, .authenticationChallenge, .fatal: + break + } + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + continuation.finish() + context.close(promise: nil) + } + + func channelInactive(context: ChannelHandlerContext) { + continuation.finish() + } +}