Rafters Mail

IMAP Server Quickstart

Deploy an IMAP server for your edge email inbox. Standard email clients (Apple Mail, Thunderbird, Outlook) connect directly.


Two runtimes, one protocol

RuntimePackageDeploys onTLS
Cloudflare DO@rafters/mail-imap-cloudflareCloudflare WorkersCloudflare edge
Node TCP@rafters/mail-imap-serverFly.io, Railway, Fargate, VPSProxy or self-managed

Both use the same protocol layer (@rafters/mail-imap) and the same adapter interfaces. Pick the runtime that fits your infrastructure.


1. Install

pnpm add @rafters/mail-imap @rafters/mail-imap-server

2. Create the server

import { createImapServer } from "@rafters/mail-imap-server";

const server = createImapServer({
  // No TLS config; Fly handles TLS termination
  port: 1993,
  adapters: {
    authAdapter: {
      async verifyAppPassword(email, password) {
        // Verify against your D1/database
      },
    },
    mailboxAdapter: {
      // Implement: listFolders, getFolderByName, getFolderStats, getMessageUids
    },
    messageAdapter: {
      // Implement: getMessage, getMessagesByIds, updateMessageFlags,
      //            deleteMessage, getBlob, searchMessages
    },
  },
  async resolveMailboxId(email) {
    // Map email address to mailbox ID in your database
  },
});

await server.listen();

3. Configure Fly

# fly.toml
app = "my-mail-imap"

[[services]]
  internal_port = 1993
  protocol = "tcp"

  [[services.ports]]
    port = 993
    handlers = ["tls"]

4. Deploy

fly launch
fly certs add mail.yourdomain.com
fly deploy

5. DNS

CNAME mail.yourdomain.com to my-mail-imap.fly.dev.

6. Connect Apple Mail

  • Incoming Mail Server: mail.yourdomain.com
  • Port: 993
  • SSL: Yes
  • Authentication: Password (use an app-specific password)

Option B: Cloudflare Durable Objects (WebSocket clients)

For web-based email clients or custom apps that connect via WebSocket.

1. Install

pnpm add @rafters/mail-imap @rafters/mail-imap-cloudflare

2. Create the DO and Worker

// src/index.ts
import { createImapDurableObject, createImapWorker } from "@rafters/mail-imap-cloudflare";

export const ImapMailboxDO = createImapDurableObject({
  createAdapters(env) {
    return {
      authAdapter: {
        /* verify against env.DB */
      },
      mailboxAdapter: {
        /* query env.DB */
      },
      messageAdapter: {
        /* query env.DB, fetch env.BLOB_STORAGE */
      },
    };
  },
});

const worker = createImapWorker();
export default worker;

3. Configure wrangler

// wrangler.jsonc
{
  "name": "mail-imap",
  "durable_objects": {
    "bindings": [{ "name": "IMAP_MAILBOX", "class_name": "ImapMailboxDO" }],
  },
  "migrations": [{ "tag": "v1", "new_classes": ["ImapMailboxDO"] }],
}

4. Deploy

wrangler types
wrangler deploy

5. Connect

WebSocket to wss://mail-imap.yourdomain.workers.dev/?email=user@example.com&mailboxId=...


Authentication

The IMAP server delegates all authentication to your own auth system via the AuthAdapter interface:

interface AuthAdapter {
  verifyAppPassword(email: string, appPassword: string): Promise<boolean>;
}

You bring the storage, hashing, generation, and revocation. The server calls verifyAppPassword on every LOGIN and trusts the return value. See authentication.md on the @rafters/mail-imap package for the full contract.


Multi-domain

Both runtimes support multiple domains from a single deployment. The email address is the routing key. user@silvius.me and user@runlegion.dev are served by the same server, routed to different mailboxes by resolveMailboxId.


IDLE (push notifications)

The server supports IMAP IDLE (RFC 2177). When a client enters IDLE, it receives real-time EXISTS notifications when new mail arrives.

For the Cloudflare runtime: the inbound email Worker signals the IMAP DO via POST /notify?count=N. The DO pushes to all IDLE sessions bound to that mailbox.

For the Node runtime: call server.notify(mailboxId, newMessageCount) after storing a new message in your inbound handler:

const server = createImapServer({
  /* ... */
});
await server.listen();

// In your inbound email handler, after the message is persisted:
async function onInboundMessage(mailboxId: string, storedMessage: Message) {
  const totalMessages = await countMessagesInInbox(mailboxId);
  server.notify(mailboxId, totalMessages);
}

notify delivers an EXISTS response to every session that is (a) currently in IDLE state and (b) bound to the specified mailbox. Sessions on other mailboxes, and sessions that are not in IDLE, are not affected. newMessageCount is the total number of messages in the mailbox after the insertion, not a delta. IMAP clients read the EXISTS value as the new total.