Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions packages/imap-server/docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
31 changes: 26 additions & 5 deletions packages/imap-server/docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
}
```

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.

---

Expand All @@ -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.
63 changes: 56 additions & 7 deletions packages/imap-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ImapSession,
UidMap,
generateGreeting,
generateIdleNotification,
handleCapability,
handleLogin,
handleLogout,
Expand Down Expand Up @@ -81,10 +82,36 @@ interface ConnectionState {
buffer: string;
}

interface ConnectionEntry {
socket: net.Socket;
state: ConnectionState;
}

export interface ImapServer {
listen(): Promise<void>;
close(): Promise<void>;
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";
Expand All @@ -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<ConnectionEntry>();
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,
Expand All @@ -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);
}

Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading