# ImapFlow: a promise-based IMAP client that adapts to what the server supports

> ImapFlow is a TypeScript IMAP client for Node.js that hides the protocol's conditionals behind a promise API, negotiating CONDSTORE, QRESYNC, IDLE and COMPRESS by itself, and it can run on Bun, in Cloudflare Workers and in the browser through pluggable socket transports. It suits a Node service that has to read mailboxes reliably, and it requires Node 20 or newer.

**postalsys/imapflow** — IMAP Client library for EmailEngine Email API (https://emailengine.app)

- Repository: https://github.com/postalsys/imapflow
- Website: https://imapflow.com
- Stars: 576 · Forks: 86
- Language: TypeScript
- License: NOASSERTION
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/postalsys-imapflow

## The problem: one API for servers that disagree about everything

ImapFlow is described as a modern and easy-to-use IMAP client library for Node.js, and the design goal is stated in the first paragraph of the documentation: a clean, promise-based API for working with IMAP, so you do not need in-depth knowledge of the protocol.

That need is real. IMAP is a protocol where almost every useful feature is conditional on what the server supports and on a version number, and the usual result is that client code accumulates branches for features that only work against some providers. ImapFlow's answer is that extensions are detected and handled automatically, you write the same code regardless of server capabilities, and the library adapts behind the scenes.

The specific extensions named in the feature list are CONDSTORE, QRESYNC, IDLE and COMPRESS, with a link to the documentation for more. Each of those changes what a client can do. CONDSTORE and QRESYNC are about knowing what changed on the server, so you can sync a mailbox without re-fetching everything. IDLE is push rather than poll, so a client learns about a new message without asking. COMPRESS=DEFLATE is about bandwidth on the wire. A client that has to branch on all four is a client whose behaviour differs per provider, which is the maintenance cost ImapFlow is trying to remove.

The rest of the feature list is the standard set of things a serious mail client needs: an async/await API where all methods return Promises, message streaming through async iterators for efficient processing, a built-in mailbox locking mechanism for safe concurrent access, TypeScript written in with type definitions included, both ES modules and CommonJS entry points, SOCKS and HTTP CONNECT proxy support, and Gmail specifics including labels and raw search via X-GM-EXT-1.

## Install and first session: a client, a lock, and a fetch

The install is a single command:

```bash
npm install imapflow
```

ImapFlow requires Node.js 20 or newer, and the package ships both an ES module build and a CommonJS build with bundled type declarations, so no separate types package is needed. The quick example in the documentation shows both import styles:

```js
import { ImapFlow } from 'imapflow';
// or in CommonJS: const { ImapFlow } = require('imapflow');

const client = new ImapFlow({
    host: 'imap.example.com',
    port: 993,
    secure: true,
    auth: {
        user: 'user@example.com',
        pass: 'password'
    }
});
```

The constructor options are the connection details and nothing else, which is the point of the library: host, port, a secure flag and credentials. What happens on the wire after that is the library's problem.

The first session follows a shape worth memorising, because the ordering is the contract. You call connect, you take a mailbox lock, you work inside the lock, and you release it. The lock is what makes concurrent access safe, and the documentation's example is explicit about it:

```js
    await client.connect();

    let lock = await client.getMailboxLock('INBOX');
    try {
        // fetch latest message
        let message = await client.fetchOne(client.mailbox.exists, { source: true });
        console.log(message.source.toString());
```

The pattern of acquiring a lock and releasing it in a finally block is not decoration. Two workers in the same process, or two processes against the same account, will otherwise interleave their selected-mailbox state, and the failure mode is a message fetched from the wrong folder. The lock is the primitive that makes multi-consumer processing of one account something you can reason about.

## The transport option is what makes the library portable beyond Node

The most interesting engineering in this project is the part that has nothing to do with IMAP, which is the transport abstraction.

The ES module build runs on Bun, tested against the latest release, and on Cloudflare Workers with the nodejs_compat compatibility flag. On Workers you connect with implicit TLS, usually port 993, or in cleartext, and the documentation explains why: the runtime cannot upgrade an already connected socket, so a STARTTLS negotiation fails with a TLS error. It also refuses tls: { rejectUnauthorized: false } with ERR_OPTION_NOT_IMPLEMENTED. COMPRESS=DEFLATE, IDLE and the default pino logger work as they do on Node.js, and STARTTLS works on Workers when the connection goes through createSocketsTransport().

That last point is where the abstraction comes from. The transport option takes any object with a connect() method, so other socket APIs plug in the same way. For a runtime like celld, the self-hosted runtime for Cloudflare-style Workers, which has no node:net, node:tls or node:dns, you pass the connect() of cloudflare:sockets through it:

```js
import { connect } from 'cloudflare:sockets';
import { ImapFlow, createSocketsTransport } from 'imapflow';

const client = new ImapFlow({
    host: 'imap.example.com',
    port: 993,
    secure: true,
    auth: { user: 'user@example.com', pass: 'secret' },
    transport: createSocketsTransport(connect)
});
```

For an API built on web streams, such as cloudflare:sockets or Chrome's Direct Sockets, createStreamsTransport() does the wrapping, with an open() that returns a promise of a readable and a writable stream and an optional upgrade() for STARTTLS. The opened result may also report remoteAddress, remotePort, localAddress and localPort, and for TLS a tls object with authorized, name and version, which becomes client.tls.

An event-based API implements the transport itself: connect(options, onConnect) returns a Node.js Duplex stream of bytes and calls onConnect once the connection is up, with an optional upgrade(socket, options, onSecure) for TLS. The Duplex also needs setTimeout(ms) emitting timeout after ms without traffic, because that is the watchdog behind the socketTimeout option.

The browser case is handled by shimming what Node has and browsers do not: node:stream from readable-stream, node:events from events, the Buffer global from buffer, the process global and setImmediate and clearImmediate, node:crypto with an MD5 createHash for message ids on servers without OBJECTID, and path with parse() for attachment file names. The empty modules for node:net, node:tls, node:dns and node:zlib mean COMPRESS is skipped. The test/browser/ directory holds the complete esbuild setup with the shims and runs it in headless Chrome.

## What the runtime gives up, stated plainly

The documentation is unusually candid about the limits of the non-Node environments, and that candour is the most useful thing in the readme.

On Workers, the runtime verifies every certificate against its own root store, so tls.rejectUnauthorized: false, a custom tls.ca, client certificates and a servername that differs from host are all refused with UnsupportedTransportOption, and a TLS connection to an IP address needs a certificate issued for that address. COMPRESS=DEFLATE is skipped because there is no streaming zlib. The proxy option cannot be combined with a transport. client.tls stays false, since the runtime reports no cipher details. And a socket is closed when the request, or Durable Object event, that opened it ends, so the whole session has to run inside the handler or inside ctx.waitUntil() to keep it going after the response.

Each of those is a real functional difference, not a rounding error. A client that needs to skip certificate validation, which some people do against a self-hosted mail server with a private certificate authority, cannot do it on Workers. A client that needs a proxy cannot combine it with a custom transport.

The event-based transport is the most general shape, and the note about setTimeout is a small correctness detail that matters more than it looks. The watchdog is what implements the socketTimeout option, so a transport that omits it hangs instead of timing out.

The browser story is the other extreme. ImapFlow runs there from the ES module build when the bundler provides the shims, and the empty node:net, node:tls, node:dns and node:zlib modules mean the library is usable for talking to an IMAP server from a page, which is an unusual thing to want but is now possible with the same codebase.

## Against the alternatives: a library or a mail API

The comparison the project itself points at is EmailEngine, a self-hosted email API built by the same team, which ImapFlow powers as the IMAP engine. The difference in approach is what separates a library from a product.

ImapFlow is a dependency. You install it, you open a socket to a mailbox, you read messages yourself, and you own the storage, the retry policy, the scheduling and the security of the credentials. Nothing leaves your infrastructure, and nothing is abstracted away from you.

EmailEngine is a service. It runs the IMAP engine for you and exposes an HTTP API, so a client that cannot open a socket to port 993 at all, such as a browser application or a platform that blocks arbitrary outbound ports, can still read mail through a REST call. The trade is that you now run a server, you manage its storage, and your application depends on that server's availability rather than only on the mail provider's.

The other comparison, to the older style of Node mail client, is worth making on protocol handling. Where a classic client exposes the raw IMAP verbs and leaves capability negotiation to the caller, ImapFlow detects extensions and adapts, which means a Gmail account and a corporate Exchange account are the same code path. The cost of that abstraction is that you give up the ability to force a particular protocol behaviour, since the library decides which extensions to use.

For a service that must talk to many different mail providers, that trade is normally worth it. For a client that needs to force QRESYNC off, or that needs to send a command ImapFlow does not expose, it is a limitation rather than a feature.

## The repository, the release cadence and the licence

The tree shows an actively worked-on library with a real test matrix.

The version is 2.5.8, and the recent releases are v2.5.6, v2.5.7 and v2.5.8, all published on 2026-10-19 at different hours of the same day, with the last push to the repository on 2026-10-20. Several patch releases in a day is the signature of a project that ships continuously rather than on a schedule, and the release-please configuration and manifest in the tree, along with the CHANGELOG.md, show that the version bumps and the changelog entries are generated rather than typed by hand.

The test setup is the part that shows how broad the support claims really are. The test script runs the node test runner with tsx, single-threaded, over every test file in the test directory that is not under integration, cloudflare, celld or browser. Then there are separate scripts for coverage with c8, for a CommonJS smoke test, for Bun with a 60 second timeout, and the browser run in headless Chrome. Four runtimes, four test paths, each with its own exclusions, which is a defensible way to prove the portability claims rather than assert them.

The examples directory holds example.js, idle.js, local.js, search.js and test.js, which is where you would look for the shape of a normal session, an IDLE session, a local server, a search and a smoke test.

The licence is where the last point of friction sits. The licence field in the repository metadata says Other, a custom licence GitHub cannot classify, with the terms in the LICENSE file, and the published package includes SECURITY.md and SECURITY.txt. A client library that holds mail credentials and sits on the network path of an application deserves a licence you have read rather than one you have assumed, and the metadata field alone will not tell you what the terms are.

## Conclusion

ImapFlow is worth adopting for a Node service that talks to somebody else's mailbox, because the protocol's version and capability differences are negotiated rather than hand-coded, because the mailbox locking model gives you a defined way to avoid racing two workers over the same folder, and because the transport option lets the same client code run on a server, on Bun, in a Worker or in a browser. It is the wrong tool if you want a hosted mail product rather than a library, and it is the wrong tool if your runtime is stuck on Node 18. Before you build on it, check three things: that Node 20 or newer is available, since that is the documented floor; that your target runtime's limits are understood, since on Cloudflare Workers the certificate validation options, COMPRESS=DEFLATE and the proxy option are all refused, and a socket closes when the request that opened it ends; and that the licence suits you, because the repository ships a custom licence in LICENSE.txt that GitHub cannot classify and is not one of the standard permissive licences.

## FAQ

### How do I install ImapFlow and connect to a mailbox?

Run npm install imapflow, which needs Node.js 20 or newer, then create a client with host, port, secure and auth options and call connect(). The package ships both an ES module build and a CommonJS build with bundled type declarations, so no separate types package is required.

### Does ImapFlow work on Cloudflare Workers and Bun?

The ES module build runs on Bun and on Cloudflare Workers with the nodejs_compat flag, where you connect with implicit TLS on port 993 or in cleartext. On Workers the certificate validation options, COMPRESS=DEFLATE and the proxy option are unavailable, and a socket is closed when the request that opened it ends unless the session runs inside ctx.waitUntil().

### How does ImapFlow handle concurrent access to the same mailbox?

It provides a built-in mailbox locking mechanism: you take a lock with getMailboxLock and release it when you are done, which is how you avoid two workers racing over the selected mailbox state. The documented pattern is to acquire the lock, work inside it, and release it in a finally block.

## Sources

- [Issues](https://github.com/postalsys/imapflow/issues)
- [postalsys/imapflow on GitHub](https://github.com/postalsys/imapflow)
- [Project website](https://imapflow.com)
- [README](https://github.com/postalsys/imapflow/blob/master/README.md)
- [Releases](https://github.com/postalsys/imapflow/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/postalsys-imapflow
