Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Feature/Sources/IMAP/Capability.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import NIOCore
import NIOIMAP

public typealias Capability = NIOIMAPCore.Capability

extension NIOIMAPCore.Capability: @retroactive CustomStringConvertible {

// MARK: CustomStringConvertible
public var description: String { name }
}
4 changes: 1 addition & 3 deletions Feature/Sources/IMAP/CapabilityCommand.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import NIOCore
import NIOIMAPCore

public typealias Capability = NIOIMAPCore.Capability
import NIOIMAP

// Fetch advertised server capabilities
// https://www.iana.org/assignments/imap-capabilities/imap-capabilities.xhtml
Expand Down
38 changes: 2 additions & 36 deletions Feature/Sources/IMAP/FetchCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,34 +61,8 @@ class FetchHandler: IMAPCommandHandler, @unchecked Sendable {
case .start(let sequenceNumber):
self.sequenceNumber = sequenceNumber
case .simpleAttribute(let attribute):
switch attribute {
case .body(let structure, let hasExtensionData):
components.append(.bodyStructure(structure, hasExtensionData))
case .emailID(let emailID):
components.append(.emailID(String(emailID)))
case .envelope(let envelope):
components.append(.envelope(Envelope(envelope)))
case .flags(let flags):
components.append(.flags(Set(flags)))
case .gmailLabels(let labels):
components.append(.gmailLabels(labels))
case .gmailMessageID(let id):
components.append(.gmailMessageID(id))
case .gmailThreadID(let id):
components.append(.gmailThreadID(id))
case .internalDate(let serverMessageDate):
if let date: Date = try? Date(serverMessageDate: serverMessageDate) {
components.append(.internalDate(date))
}
case .threadID(let threadID):
if let threadID: String = String(threadID: threadID) {
components.append(.threadID(threadID))
}
case .uid(let uid):
components.append(.uid(uid))
default:
break
}
guard let component: Message.Component = Message.Component(attribute) else { break }
components.append(component)
case .streamingBegin(let kind, let byteCount):
streaming = (kind, Data(), byteCount)
case .streamingBytes(let bytes):
Expand Down Expand Up @@ -116,13 +90,5 @@ class FetchHandler: IMAPCommandHandler, @unchecked Sendable {
}
context.fireChannelRead(data)
}
}

private extension String {
init?(threadID: ThreadID?) {
guard let threadID else {
return nil
}
self = Self(threadID)
}
}
93 changes: 87 additions & 6 deletions Feature/Sources/IMAP/IMAPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ public class IMAPClient {

public private(set) var capabilities: Set<Capability> = []
public var isConnected: Bool { channel != nil && channel!.isActive }
public var isIdling: Bool { idleHandler != nil }

public func isSupported(_ capability: Capability) -> Bool {
capabilities.contains(capability)
public func isSupported(_ capability: Capability) throws {
guard capabilities.contains(capability) else {
throw IMAPError.capabilityNotSupported(capability)
}
}

/// Bootstrap NIO channel, connect to the configured ``Server``.
Expand Down Expand Up @@ -81,14 +84,38 @@ public class IMAPClient {
return namespace
}

/// Start idle on connected ``Server``; handle ``IdleEvent`` pushes.
@discardableResult public func idle() async throws -> AsyncStream<IdleEvent> {
try isSupported(.idle)
logger?.info("Idle start…")
return try await idleStart()
}

/// Stop idling on connected ``Server``.
public func done() async throws {
logger?.info("Idle done…")
try await idleDone()
}

/// Poll ``Server`` for ``IdleEvent`` when not idling.
public func noop() async throws -> [IdleEvent] {
logger?.info("Noop…")
guard !isIdling else {
// NIOIMAP automatically (1) keeps idle alive and (2) streams idle events
// Manual `noop` polling is blocked by NIOIMAP during idle
throw IMAPError.commandNotSupported("Noop during idle")
}
return try await execute(command: NoopCommand())
}

/// List all mailboxes on logged-in IMAP ``Server``.
public func list(wildcard: Character = .wildcard) async throws -> [Mailbox] {
logger?.info("Listing mailboxes…")
return try await execute(command: ListCommand(wildcard: wildcard))
}

/// Select current working mailbox in read/write mode.
public func select(mailbox: Mailbox) async throws -> Mailbox.Status {
@discardableResult public func select(mailbox: Mailbox) async throws -> Mailbox.Status {
logger?.info("Selecting mailbox \(mailbox.path.name)…")
return try await execute(command: SelectCommand(mailbox.path.name))
}
Expand Down Expand Up @@ -175,7 +202,7 @@ public class IMAPClient {
}

// Run IMAP command through NIO `IMAPClientHandler` in channel and handle results
func execute<T: IMAPCommand>(command: T) async throws -> T.Result {
private func execute<T: IMAPCommand>(command: T) async throws -> T.Result {
let logger: Logger? = logger // Copy logger instead of capturing
logger?.debug("Executing \(command)…")
guard let channel, channel.isActive else {
Expand Down Expand Up @@ -212,18 +239,72 @@ public class IMAPClient {
}
}

func refreshCapabilities() async throws {
// Start idle through NIO `IMAPClientHandler` and stream results from special, long-running handler
private func idleStart() async throws -> AsyncStream<IdleEvent> {
let logger: Logger? = logger // Copy logger instead of capturing
logger?.debug("Executing idle start…")
guard !isIdling else {
throw IMAPError.commandFailed("Idle already started")
}
guard let channel, channel.isActive else {
logger?.error("\(IMAPError.notConnected)")
throw IMAPError.notConnected
}
var continuation: AsyncStream<IdleEvent>.Continuation?
let idleEvents: AsyncStream<IdleEvent> = AsyncStream { continuation = $0 }
let promise: EventLoopPromise<Void> = channel.eventLoop.makePromise(of: Void.self)
let tag: String = UUID().uuidString(1) // Hold onto specific auto-generated tag
idleHandler = IdleHandler(tag: tag, promise: promise, continuation: continuation)
let seconds: Int64 = .timeout
let task: Scheduled = group.next().scheduleTask(in: .seconds(seconds)) {
let error: IMAPError = .timedOut(seconds: seconds)
logger?.error("\(error)")
promise.fail(error)
}
defer { task.cancel() }
do {
try await channel.pipeline.addHandler(idleHandler!).get()
let command: TaggedCommand = TaggedCommand(tag: tag, command: .idleStart)
let message: IMAPClientHandler.Message = IMAPClientHandler.OutboundIn.part(.tagged(command))
try await channel.writeAndFlush(message).get()
return idleEvents
} catch {
promise.fail(error)
logger?.error("\(error)")
idleHandler = nil
throw IMAPError.commandFailed("Idle start failed")
}
}

// End idle and clean up
private func idleDone() async throws {
guard let channel, channel.isActive else {
logger?.error("\(IMAPError.notConnected)")
throw IMAPError.notConnected
}
guard let idleHandler else { return }
try? await channel.writeAndFlush(IMAPClientHandler.OutboundIn.part(.idleDone)).get()
logger?.info("Idle done")
defer {
channel.pipeline.removeHandler(idleHandler, promise: nil)
self.idleHandler = nil
}
try await idleHandler.promise.futureResult.get()
}

private func refreshCapabilities() async throws {
logger?.info("Refreshing capabilities…")
capabilities = Set(try await execute(command: CapabilityCommand()))
logger?.info("Capabilities: \(self.capabilities)")
}

func resetInactiveChannel() {
private func resetInactiveChannel() {
guard let channel, !channel.isActive else { return }
self.channel = nil
logger?.info("Channel reset; ready to connect")
}

private var idleHandler: IdleHandler?
private var channel: Channel?
private var count: Int = 0
private let group: EventLoopGroup
Expand Down
6 changes: 5 additions & 1 deletion Feature/Sources/IMAP/IMAPCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ protocol IMAPCommand: CustomStringConvertible, Equatable where Result: Sendable
extension IMAPCommand {

// MARK: IMAPCommand
var timeout: Int64 { 30 } // Practical default
var timeout: Int64 { .timeout }
var description: String { "\(name) command" }
}

Expand Down Expand Up @@ -73,6 +73,10 @@ extension IMAPCommandHandler where InboundIn == Response, Result == Void {
}
}

extension Int64 {
static let timeout: Self = 30 // Practical default
}

// IMAP [CLIENTBUG] is an error code mail servers include in responses to
// indicate that a client-sent command was understood, but malformed somehow,
// anything from byte order to, typically, whitespace formatting.
Expand Down
7 changes: 6 additions & 1 deletion Feature/Sources/IMAP/IMAPError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import NIOIMAPCore
/// ``IMAPClient`` throws `IMAPError`.
public enum IMAPError: Error, CustomStringConvertible, Equatable {
case alreadyConnected
case capabilityNotSupported(_ description: String)
case commandFailed(_ description: String)
case commandNotSupported(_ description: String)
case notConnected
Expand All @@ -16,6 +17,10 @@ public enum IMAPError: Error, CustomStringConvertible, Equatable {
self = error as? Self ?? .underlying(error)
}

static func capabilityNotSupported(_ capability: Capability) -> Self {
.capabilityNotSupported("\(capability)")
}

static func commandFailed(_ command: any IMAPCommand) -> Self {
.commandFailed("\(command) failed")
}
Expand All @@ -32,8 +37,8 @@ public enum IMAPError: Error, CustomStringConvertible, Equatable {
public var description: String {
switch self {
case .alreadyConnected: "Already connected"
case .capabilityNotSupported(let description), .commandNotSupported(let description): "\(description.capitalized(.sentence)) not supported"
case .commandFailed(let description): "\(description.capitalized(.sentence))"
case .commandNotSupported(let description): "\(description.capitalized(.sentence)) not supported"
case .notConnected: "Not connected"
case .serverDisconnected: "Server disconnected"
case .timedOut(let seconds): "Timed out after \(seconds) \(seconds == 1 ? "second" : "seconds")"
Expand Down
15 changes: 15 additions & 0 deletions Feature/Sources/IMAP/IdleEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/// Updates to selected ``Mailbox`` during ``IMAPClient`` idle
public enum IdleEvent: Sendable {

/// IMAP ``Server`` disconnected during idle
case bye(String?)

/// ``Message`` deleted from selected ``Mailbox`` during idle
case expunge(SequenceNumber)

/// Fetched updates to a specific ``Message``, collected as ``Message.Component``
case fetch(SequenceNumber, [Message.Component])

/// Message count changed for selected ``Mailbox``
case status(Mailbox.Status)
}
99 changes: 99 additions & 0 deletions Feature/Sources/IMAP/IdleHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import NIOCore
import NIOIMAP

// Long-running handler streams pushed idle events
class IdleHandler: IMAPCommandHandler, @unchecked Sendable {
typealias Continuation = AsyncStream<IdleEvent>.Continuation
private(set) var continuation: Continuation? = nil

convenience init(tag: String, promise: EventLoopPromise<Void>, continuation: Continuation?) {
self.init(tag: tag, promise: promise)
self.continuation = continuation
}

private var components: [Message.Component] = []
private var sequenceNumber: SequenceNumber?

// MARK: IMAPCommandHandler
typealias InboundIn = Response
typealias InboundOut = Response
typealias Result = Void

var clientBug: String? = nil
let promise: EventLoopPromise<Result>
let tag: String

required init(tag: String, promise: EventLoopPromise<Result>) {
self.promise = promise
self.tag = tag
}

func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let response: Response = unwrapInboundIn(data)
clientBug = response.clientBug
switch response {
case .tagged(let taggedResponse):
// Always finish continuation first, then fulfill promise
continuation?.finish()
switch taggedResponse.state {
case .bad(let text), .no(let text):
promise.fail(IMAPError.commandFailed(text.text))
case .ok:
promise.succeed()
}
case .untagged(let payload):
switch payload {
case .conditionalState(let status):
switch status {
case .bye(let text):
continuation?.yield(.bye(text.text))
continuation?.finish()
promise.succeed()
default:
break
}
case .mailboxData(let data):
switch data {
case .exists(let count):
continuation?.yield(.status(MailboxStatus(messageCount: count)))
case .recent(let count):
continuation?.yield(.status(MailboxStatus(recentCount: count)))
default:
break
}
case .messageData(let data):
switch data {
case .expunge(let sequenceNumber):
continuation?.yield(.expunge(sequenceNumber))
default:
break
}
default:
break
}
case .fetch(let response):
switch response {
case .start(let sequenceNumber):
self.sequenceNumber = sequenceNumber
components = []
case .simpleAttribute(let attribute):
guard let component: Message.Component = Message.Component(attribute) else { break }
components.append(component)
case .finish:
guard let sequenceNumber, !components.isEmpty else { break }
continuation?.yield(.fetch(sequenceNumber, components))
self.sequenceNumber = nil
components = []
default:
break
}
case .fatal(let text):
continuation?.yield(.bye(text.text))
continuation?.finish()
promise.succeed()
default:
break
}
context.fireChannelRead(data)
}
}
Loading
Loading