diff --git a/packages/imap-server/docs/deployment.md b/packages/imap-server/docs/deployment.md index 23bbc43..e4ccae7 100644 --- a/packages/imap-server/docs/deployment.md +++ b/packages/imap-server/docs/deployment.md @@ -10,15 +10,30 @@ Best option for most deployments. $5-15/mo. TLS handled automatically. ### Dockerfile +Multi-stage build: compile TypeScript to `dist/`, then run the compiled output with plain Node. No `tsx` in production -- compiled JavaScript only. + ```dockerfile -FROM node:24-alpine +# Build stage: install dev deps, compile to dist/ +FROM node:24-alpine AS build WORKDIR /app +RUN corepack enable COPY package.json pnpm-lock.yaml ./ -RUN corepack enable && pnpm install --frozen-lockfile --prod +RUN pnpm install --frozen-lockfile COPY . . -CMD ["node", "--import", "tsx", "src/main.ts"] +RUN pnpm build + +# Runtime stage: prod deps only, run compiled output +FROM node:24-alpine +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod +COPY --from=build /app/dist ./dist +CMD ["node", "dist/main.js"] ``` +This assumes your consumer app has a `build` script that produces `dist/main.js`. Most TypeScript setups use `tsup`, `tsc`, or `esbuild` -- any of them work. Your `main.js` is the file that calls `createImapServer` and `server.listen()`. + ### fly.toml ```toml @@ -72,12 +87,14 @@ Similar to Fly. TLS termination via Railway's proxy. { "build": { "builder": "DOCKERFILE" }, "deploy": { - "startCommand": "node --import tsx src/main.ts", + "startCommand": "node dist/main.js", "healthcheckPath": null } } ``` +Uses the same multi-stage Dockerfile as Fly.io. Compile TypeScript to `dist/` at image build time, run the compiled output at startup. + ### Settings - Custom domain: `mail.yourdomain.com` diff --git a/packages/imap-server/docs/quickstart.md b/packages/imap-server/docs/quickstart.md index e74103f..0f4e7c7 100644 --- a/packages/imap-server/docs/quickstart.md +++ b/packages/imap-server/docs/quickstart.md @@ -151,11 +151,17 @@ WebSocket to `wss://mail-imap.yourdomain.workers.dev/?email=user@example.com&mai --- -## App passwords +## Authentication -IMAP uses app-specific passwords, not your regular login credentials. Generate them in your dashboard (ctrl) and store hashed (argon2) in your database. +The IMAP server delegates all authentication to your own auth system via the `AuthAdapter` interface: -The `AuthAdapter.verifyAppPassword(email, appPassword)` method is called on every LOGIN. Implement it to check the hash against your database. +```typescript +interface AuthAdapter { + verifyAppPassword(email: string, appPassword: string): Promise; +} +``` + +You bring the storage, hashing, generation, and revocation. The server calls `verifyAppPassword` on every LOGIN and trusts the return value. See [`authentication.md`](./authentication.md) on the `@rafters/mail-imap` package for the full contract. --- @@ -169,6 +175,21 @@ Both runtimes support multiple domains from a single deployment. The email addre 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. +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: + +```typescript +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); +} +``` -For the Node runtime: call the server's notification mechanism after storing a new message in your inbound handler. +`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. diff --git a/packages/imap-server/src/server.ts b/packages/imap-server/src/server.ts index e7e75f0..74ff220 100644 --- a/packages/imap-server/src/server.ts +++ b/packages/imap-server/src/server.ts @@ -17,6 +17,7 @@ import { ImapSession, UidMap, generateGreeting, + generateIdleNotification, handleCapability, handleLogin, handleLogout, @@ -81,10 +82,36 @@ interface ConnectionState { buffer: string; } +interface ConnectionEntry { + socket: net.Socket; + state: ConnectionState; +} + export interface ImapServer { listen(): Promise; close(): Promise; readonly connections: number; + /** + * The port the server is bound to, available after `listen()` resolves. + * Returns `null` before `listen()` or after `close()`. Useful when the + * server is started on port `0` (ephemeral) and the caller needs the + * actual assigned port -- typical in tests and integration harnesses. + */ + readonly port: number | null; + /** + * Push an EXISTS notification to all IDLE sessions for a specific mailbox. + * + * Call after storing a new inbound message to wake any connected email + * clients that are currently in IMAP IDLE (RFC 2177). Sessions that are + * not in IDLE, or that belong to a different mailbox, are not affected. + * + * @param mailboxId The mailbox to notify. Matches `resolveMailboxId` output. + * @param newMessageCount Total messages in the mailbox after the insertion. + * This becomes the `EXISTS` value. IMAP clients read it as the new total, + * not a delta. Pass the mailbox's full message count, not the number + * appended. + */ + notify(mailboxId: string, newMessageCount: number): void; } const DEFAULT_HOST = "0.0.0.0"; @@ -99,20 +126,18 @@ export function createImapServer(config: ImapServerConfig): ImapServer { const sessionTimeoutMs = config.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS; const { adapters } = config; - let activeConnections = 0; + const activeConnections = new Set(); let server: net.Server | tls.Server | null = null; const MAX_BUFFER_SIZE = 10 * 1024 * 1024; function handleConnection(socket: net.Socket): void { - if (activeConnections >= maxConnections) { + if (activeConnections.size >= maxConnections) { socket.write(formatBye("Server too busy")); socket.end(); return; } - activeConnections++; - const state: ConnectionState = { session: new ImapSession(), uidMap: null, @@ -122,12 +147,15 @@ export function createImapServer(config: ImapServerConfig): ImapServer { buffer: "", }; - // Prevent double-decrement: error fires before close on Node sockets + const entry: ConnectionEntry = { socket, state }; + activeConnections.add(entry); + + // Prevent double-removal: error fires before close on Node sockets let cleaned = false; function cleanup(): void { if (cleaned) return; cleaned = true; - activeConnections--; + activeConnections.delete(entry); clearTimeout(timeout); } @@ -480,7 +508,28 @@ export function createImapServer(config: ImapServerConfig): ImapServer { return { get connections() { - return activeConnections; + return activeConnections.size; + }, + + get port() { + if (!server) return null; + const addr = server.address(); + // net.Server.address() returns an object for TCP sockets, or a string + // for Unix sockets. This server only binds TCP, so the object branch + // is the only one we care about. + return addr && typeof addr === "object" ? addr.port : null; + }, + + notify(mailboxId: string, newMessageCount: number): void { + // RFC 2177: EXISTS is a broadcast of the total message count for the + // selected mailbox. Only deliver to sessions currently in IDLE state + // and bound to the target mailbox. + const notification = generateIdleNotification(newMessageCount); + for (const { socket, state } of activeConnections) { + if (state.mailboxId === mailboxId && state.idleState?.active) { + socket.write(notification); + } + } }, listen() { diff --git a/packages/imap-server/tests/server.test.ts b/packages/imap-server/tests/server.test.ts index 81800f1..59a4cde 100644 --- a/packages/imap-server/tests/server.test.ts +++ b/packages/imap-server/tests/server.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi } from "vitest"; +import { connect } from "node:net"; +import type { Socket } from "node:net"; import { createImapServer } from "../src/server.ts"; import type { ImapServerConfig } from "../src/server.ts"; import type { AuthAdapter, MailboxAdapter, MessageAdapter } from "@rafters/mail-imap"; @@ -8,8 +10,14 @@ function mockConfig(overrides: Partial = {}): ImapServerConfig adapters: { authAdapter: { verifyAppPassword: vi.fn(async () => true) } as AuthAdapter, mailboxAdapter: { - listFolders: vi.fn(async () => []), - getFolderByName: vi.fn(async () => undefined), + listFolders: vi.fn(async () => [ + { id: "folder-1", name: "INBOX", type: "inbox", mailboxId: "mbx-1" }, + ]), + getFolderByName: vi.fn(async (_mailboxId: string, name: string) => + name === "INBOX" + ? { id: "folder-1", name: "INBOX", type: "inbox", mailboxId: "mbx-1" } + : undefined, + ), getFolderStats: vi.fn(async () => ({ messages: 0, recent: 0, @@ -38,10 +46,11 @@ function mockConfig(overrides: Partial = {}): ImapServerConfig } describe("createImapServer", () => { - it("returns a server object with listen, close, and connections", () => { + it("returns a server object with listen, close, connections, and notify", () => { const server = createImapServer(mockConfig()); expect(typeof server.listen).toBe("function"); expect(typeof server.close).toBe("function"); + expect(typeof server.notify).toBe("function"); expect(server.connections).toBe(0); }); @@ -65,3 +74,208 @@ describe("createImapServer", () => { expect(server.connections).toBe(0); }); }); + +describe("ImapServer.notify (RFC 2177 IDLE push)", () => { + it("is safe to call with no active connections", () => { + const server = createImapServer(mockConfig()); + expect(() => server.notify("mbx-1", 5)).not.toThrow(); + }); + + it("is safe to call with zero as the new message count", () => { + const server = createImapServer(mockConfig()); + expect(() => server.notify("mbx-1", 0)).not.toThrow(); + }); + + it("delivers EXISTS to an IDLE client bound to the matching mailbox", async () => { + const server = await startPlainTextServer(mockConfig()); + try { + const client = await connectAndAuthenticate(server.port); + + try { + client.socket.write("a002 SELECT INBOX\r\n"); + await readUntilLine(client, (line) => line.startsWith("a002 ")); + + client.socket.write("a003 IDLE\r\n"); + await readUntilLine(client, (line) => line.startsWith("+ ")); + + // Push notification from outside the IMAP session. This simulates + // the inbound email worker notifying the IMAP server after storing + // a new message. + server.notify("mbx-1", 7); + + // RFC 2177: EXISTS response during IDLE has the form "* EXISTS" + const line = await readUntilLine(client, (l) => l.endsWith("EXISTS")); + expect(line).toBe("* 7 EXISTS"); + } finally { + client.socket.end(); + } + } finally { + await server.close(); + } + }); + + it("does not deliver EXISTS to a client bound to a different mailbox", async () => { + // Use a resolver that routes a specific login to a different mailbox. + const config = mockConfig({ + async resolveMailboxId(email) { + return email === "other@example.com" ? "mbx-other" : "mbx-1"; + }, + }); + + const server = await startPlainTextServer(config); + try { + const client = await connectAndAuthenticate(server.port, "other@example.com"); + + try { + client.socket.write("a002 SELECT INBOX\r\n"); + await readUntilLine(client, (line) => line.startsWith("a002 ")); + + client.socket.write("a003 IDLE\r\n"); + await readUntilLine(client, (line) => line.startsWith("+ ")); + + // Notify a mailbox the client is NOT bound to. Client should + // not receive the EXISTS. + server.notify("mbx-1", 7); + + const line = await readLineWithTimeout(client, 150); + // No notification should arrive within the timeout window. + expect(line).toBeNull(); + } finally { + client.socket.end(); + } + } finally { + await server.close(); + } + }); + + it("does not deliver EXISTS to a client that is not in IDLE", async () => { + const server = await startPlainTextServer(mockConfig()); + try { + const client = await connectAndAuthenticate(server.port); + + try { + client.socket.write("a002 SELECT INBOX\r\n"); + await readUntilLine(client, (line) => line.startsWith("a002 ")); + + // Authenticated and selected, but not in IDLE. + server.notify("mbx-1", 7); + + const line = await readLineWithTimeout(client, 150); + expect(line).toBeNull(); + } finally { + client.socket.end(); + } + } finally { + await server.close(); + } + }); +}); + +// ---------- test helpers ---------- + +interface ServerHandle { + port: number; + close(): Promise; + notify(mailboxId: string, count: number): void; +} + +/** + * Start an IMAP server on a random plain TCP port (no TLS). The test + * helper intentionally omits the tls config to get plain TCP mode, which + * the server supports for deployment behind a TLS-terminating proxy. + * + * Uses port 0 (ephemeral) so tests can run in parallel without colliding + * on a fixed port. The server's `port` getter returns the actual assigned + * port after `listen()` resolves. + */ +async function startPlainTextServer(config: ImapServerConfig): Promise { + const { tls: _tls, ...rest } = config; + const server = createImapServer({ ...rest, host: "127.0.0.1", port: 0 }); + await server.listen(); + + const port = server.port; + if (port === null) { + throw new Error("server did not report a bound port after listen"); + } + + return { + port, + close: () => server.close(), + notify: (mailboxId: string, count: number) => server.notify(mailboxId, count), + }; +} + +interface Client { + socket: Socket; + buffer: string; +} + +async function connectAndAuthenticate( + port: number, + email: string = "user@example.com", +): Promise { + const socket = connect({ host: "127.0.0.1", port }); + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + + const client: Client = { socket, buffer: "" }; + socket.on("data", (data: Buffer) => { + client.buffer += data.toString("utf-8"); + }); + + // Consume the greeting: "* OK [CAPABILITY ...] @rafters/mail ready" + await readUntilLine(client, (line) => line.startsWith("* OK")); + + // Authenticate. LOGIN emits a tagged completion response on success. + client.socket.write(`a001 LOGIN ${email} password\r\n`); + await readUntilLine(client, (line) => line.startsWith("a001 ")); + + return client; +} + +/** + * Read and consume lines from the buffer until one matches the predicate. + * Returns the matching line. Lines that do not match are still consumed -- + * this mirrors an IMAP client draining untagged responses before the + * tagged completion. + */ +async function readUntilLine( + client: Client, + matcher: (line: string) => boolean, + timeoutMs: number = 2000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const line = tryConsumeLine(client); + if (line !== null) { + if (matcher(line)) return line; + continue; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Timed out waiting for matching line\nbuffer: ${client.buffer}`); +} + +/** + * Read the next line but return null if nothing arrives within the timeout. + * Used for negative-path assertions ("client should NOT receive X"). + */ +async function readLineWithTimeout(client: Client, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const line = tryConsumeLine(client); + if (line !== null) return line; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return null; +} + +function tryConsumeLine(client: Client): string | null { + const idx = client.buffer.indexOf("\r\n"); + if (idx < 0) return null; + const line = client.buffer.slice(0, idx); + client.buffer = client.buffer.slice(idx + 2); + return line; +}