[Pitch] An imperative-style async/await IMAP client for swift-nio-imap

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 — SwiftNIO ChannelHandlers (IMAPClientHandler / IMAPServerHandler) that plug the parser and encoder into a ChannelPipeline. 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:

  • withConnection scopes 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 an AsyncSequence of the Response values the server produces while it runs, up to and including that command's final TaggedResponse.
  • Pipelining falls out of structured concurrency. Send commands concurrently from a task group and they pipeline; each send still awaits its own command's tagged completion.
  • Commands and responses are still the shared NIOIMAPCore types — 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:

  • sendIdle runs IDLE, ending it when the closure returns.
  • sendAuthenticate(mechanism:initialResponse:) runs AUTHENTICATE, giving the closure a writer to answer the server's challenges.
  • append(to:writeClosure:readClosure:) streams an APPEND, 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:

  • IMAPCommands is command-centric — reach for it when you'd rather await each command as a unit of work.
  • NIOIMAP is 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!

9 Likes