I'd like to pitch a new, imperative-style client interface for apple/swift-nio-imap and get feedback on its shape. The full change is up as a draft PR — #841 — so you can read the actual API and try it out.
Background
swift-nio-imap has provided an event-driven, stream-based IMAP4rev1 implementation for a while. It's layered:
-
NIOIMAPCore— the IMAP grammar as type-safe Swift values, plus the parser and encoder. No networking. -
NIOIMAP— SwiftNIOChannelHandlers (IMAPClientHandler/IMAPServerHandler) that plug the parser and encoder into aChannelPipeline. You write commands and consume a decoded stream of protocol events.
That's a good foundation, but if you just want to *talk to an IMAP server*, you've had to stand up a channel pipeline and drive the stream of protocol events yourself.
First, a word about IMAP itself
For anyone who hasn't worked with IMAP directly, here's the one thing worth knowing up front, because it shapes this entire API: IMAP is a deeply stateful, session-oriented protocol. A single connection carries state — the authentication state, the currently selected mailbox, and more — and a command's meaning depends on the commands that ran before it. Servers can also push unsolicited updates at any time.
This is quite unlike the stateless request/response model people often associate with HTTP client APIs, so an IMAP client can't (and shouldn't) be shaped like URLSession or a typical HTTP client. The design below leans into the protocol's session model deliberately; where a shape looks unusual next to an HTTP client, that's almost always IMAP's statefulness showing through.
So, what is IMAPCommands?
IMAPCommands is a new module that adds an imperative-style async/await client on top of NIOIMAP, centered on a single type: IMAPConnection. You open a connection, send commands, and await their responses as ordinary asynchronous calls.
import IMAPCommands
let configuration = IMAPConnection.Configuration(
hostname: "mail.example.com",
port: 993,
useTLS: true,
logging: .noLogging
)
try await IMAPConnection.withConnection(configuration: configuration) { greeting, connection in
// Log in and wait for the tagged completion response.
try await connection.send(.login(username: "mrc", password: "secret")) { tag, responses in
try await responses.waitForCompletion()
}
// Select a mailbox and inspect the untagged responses it produces.
try await connection.send(.select(MailboxName("INBOX"))) { tag, responses in
try await responses.forEach { response in
print(response)
}
}
}
The design in a nutshell:
withConnectionscopes the connection. It hands you the server greeting and a connection, and closes the connection automatically when your closure returns.send(_:)is the core primitive. Each command is an awaitable unit of work. Its closure receives the command's tag and anAsyncSequenceof theResponsevalues the server produces while it runs, up to and including that command's finalTaggedResponse.- Pipelining falls out of structured concurrency. Send commands concurrently from a task group and they pipeline; each
sendstill awaits its own command's tagged completion. - Commands and responses are still the shared
NIOIMAPCoretypes — the same values used at every layer of the package.
Commands that don't fit request/response
A few commands don't fit the simple "send one command, read its responses" shape, so they get dedicated APIs:
sendIdlerunsIDLE, ending it when the closure returns.sendAuthenticate(mechanism:initialResponse:)runsAUTHENTICATE, giving the closure a writer to answer the server's challenges.append(to:writeClosure:readClosure:)streams anAPPEND, giving the write closure a writer for the message data.
One protocol reality the API surfaces honestly
IMAP does not associate untagged responses with a particular command. So while commands are pipelined, every untagged Response is delivered to all in-flight send handlers — and some untagged responses are unsolicited and belong to no command at all. Only the final TaggedResponse is specific to your command.
This is inherent to the protocol, and the API surfaces it rather than pretending otherwise: each handler inspects only the responses it cares about.
Two interfaces, living side by side
IMAPCommands is entirely an addition. It doesn't replace or deprecate the existing NIOIMAP channel-handler interface — it's a new, different interface intended to live alongside it, and neither is positioned as "the" recommended one:
IMAPCommandsis command-centric — reach for it when you'd ratherawaiteach command as a unit of work.NIOIMAPis event-driven — reach for it when your code already lives in a NIO pipeline, or you want direct control over the stream of protocol events and encoding options.
Both talk to the same server and share the same command/response types, so porting between them doesn't change how you build commands.
Command-line tool
To exercise the API against a real workload, the branch also adds imap-tool, a command-line IMAP debugging tool — search, download, append, move, flag, idle, mailbox management — built entirely on IMAPCommands. To be clear, it's a debugging and inspection tool, not an email client. It's a good end-to-end look at what the API feels like in a real project, but it's included purely for reference; the pitch here is squarely about the IMAPCommands API itself.
Feedback welcome
It's all open source, and the API's shape can still change. I'd love to hear how it reads to people building on swift-nio-imap — in particular, whether the imperative style is comfortable to work with, and whether anything about it feels awkward given the protocol it's modeling. Thanks!