Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d48a48d
fix: STORE accepts exactly the flags PERMANENTFLAGS lists
andris9 Oct 8, 2026
371f99a
fix: LIST patterns match full names with a prefixed personal namespace
andris9 Oct 8, 2026
bcf100c
docs: BINARY of a part that does not exist is empty, like BODY
andris9 Oct 8, 2026
41c6856
fix: report new messages and flag changes before FETCH, STORE and SEA…
andris9 Oct 8, 2026
ed82da8
fix: end UID SEARCH with OK [EXPUNGEISSUED] when it holds back an EXP…
andris9 Oct 8, 2026
1d7d7e7
fix: CREATE-SPECIAL-USE answers BAD for USE entries that are not use-…
andris9 Oct 8, 2026
6363543
fix: ENVELOPE sends an obsolete source route as addr-adl
andris9 Oct 8, 2026
953e965
fix: deprecate the ignored xoauth2.sessionTimeout user option
andris9 Oct 8, 2026
0747678
fix: AUTHENTICATE checks the connection state before the mechanism
andris9 Oct 8, 2026
e25c9a8
fix: METADATA and METADATA-SERVER load ENABLE
andris9 Oct 8, 2026
04babcd
fix: refuse storage internal dates that are not RFC 3501 date-time va…
andris9 Oct 8, 2026
172d6be
fix: send RECENT after the EXISTS of new messages to IMAP4rev1 sessions
andris9 Oct 8, 2026
a8b13c1
fix: refuse quirk presets that remove a plugin IMAP4rev2 requires
andris9 Oct 8, 2026
49d07ad
docs: describe EXISTS, RECENT and EXPUNGEISSUED timing for multiple s…
andris9 Oct 8, 2026
5b94f49
Merge branch 'worktree-agent-af58a7292e00d2717' into fix/review-bugs
andris9 Oct 8, 2026
b18bb3c
Merge branch 'worktree-agent-ac28a97eda60441e9' into fix/review-bugs
andris9 Oct 8, 2026
99d50c2
fix: LIST treats # as a break out character that overrides the reference
andris9 Oct 8, 2026
8691cff
refactor: simplify the review bug fixes
andris9 Oct 8, 2026
f08389c
test: LIST lists the child mailboxes of INBOX
andris9 Oct 8, 2026
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Always check RFC text against the real source document at `https://www.rfc-edito
Almost everything lives in `src/server.ts`, which defines two classes:

- **`IMAPServer`**: holds the shared single-user storage, registered capabilities, command handlers, and the plugin extension arrays. Builds `folderCache` (path to mailbox object) via `indexFolders()` / `processMailbox()` from the namespace-keyed `storage` object (keys like `"INBOX"`, `""`, `"INBOX."`, each with `separator`, `type`, nested `folders`, `messages`). Subscriptions are names in `server.subscriptions`, not part of the mailbox objects (`mailbox.subscribed` is an accessor for it, see `trackSubscription()`), so they survive DELETE and stay with the old name on RENAME; `getSubscriptionTree()` gives LSUB and `LIST (SUBSCRIBED)` the subscribed names, with `\Noselect` stand-ins for names that are not mailboxes. Cross-connection updates go through `server.notify()`, which emits a `notify` event that every connection listens to; `server.notifyFilters` (`filter(connection, notification)`) lets a plugin keep a notification from some connections (ACL does for METADATA). Every notification carries `origin`, the session whose command caused it (`server.activeConnection` while a command handler runs, null for SMTP), and the EXISTS of a new message carries the `message`.
- **`IMAPConnection`**: one per socket. Parses lines and literals with `imap-handler`, queues commands (`scheduleCommand` / `processQueue`, strictly one at a time), refuses commands in the wrong state, with arguments when they take none, and mailbox name arguments that are not valid modified UTF-7 (options from `src/command-states.ts` or `setCommandHandler`, checked centrally in `processQueue`, not per handler), refuses ambiguous pipelining (RFC 3501 5.5), tracks `state` (`"Not Authenticated"`, `"Authenticated"`, `"Selected"`), `username` (set by LOGIN and the AUTHENTICATE plugins) and `selectedMailbox`, and buffers notifications from other connections, flushing them before tagged responses (but not during FETCH/STORE/SEARCH) and before a UID command runs. Messages expunged by another session stay in the session's view (`getSessionMessages()`, marked `ghost`) until the EXPUNGE is reported; README "Multiple sessions" lists the RFC 2180 strategies FETCH, STORE, SEARCH, COPY, MOVE, DELETE and RENAME follow. `connection.inputHandler` lets a plugin (e.g. IDLE, AUTHENTICATE) take over raw input lines, and a plugin can override `connection.canSetSeen()` (FETCH sets `\Seen`) and `connection.canExpunge()` (CLOSE expunges), both `!readOnly` by default, as ACL does. All output goes through `connection.write()` (also raw `+` continuations) and `connection.end()` closes after the output is written, never `connection.socket.write/end`: `connection.transport` is an optional layer between the protocol and the socket with `write`, `receive`, `end(callback)` and `destroy`, which passes data on with `connection.writeRaw()` and `connection.onData()`, so it is always above TLS (COMPRESS uses `src/deflate-layer.ts`, which the mock client, the session helper and the compare tool share). `connection.resetSession()` returns to the Not Authenticated state (UNAUTHENTICATE, RFC 8437), `connection.discardInput()` drops unprocessed input. LITERAL+ and LITERAL- set `server.literalPlus` and `server.nonSyncLiteralLimit`.
- **`IMAPConnection`**: one per socket. Parses lines and literals with `imap-handler`, queues commands (`scheduleCommand` / `processQueue`, strictly one at a time), refuses commands in the wrong state, with arguments when they take none, and mailbox name arguments that are not valid modified UTF-7 (options from `src/command-states.ts` or `setCommandHandler`, checked centrally in `processQueue`, not per handler), refuses ambiguous pipelining (RFC 3501 5.5), tracks `state` (`"Not Authenticated"`, `"Authenticated"`, `"Selected"`), `username` (set by LOGIN and the AUTHENTICATE plugins) and `selectedMailbox`, and buffers notifications from other connections, flushing them before tagged responses and, for commands that refer to messages, before the command runs (`processNotifications(data, beforeCommand)`): new-message EXISTS (followed by RECENT for IMAP4rev1 sessions) and flag updates go out first, EXPUNGE waits during FETCH/STORE/SEARCH (`noExpunge`, RFC 3501 section 7.4.1) together with everything queued after it, and the tagged OK then carries `[EXPUNGEISSUED]`. Messages expunged by another session stay in the session's view (`getSessionMessages()`, marked `ghost`) until the EXPUNGE is reported; README "Multiple sessions" lists the RFC 2180 strategies FETCH, STORE, SEARCH, COPY, MOVE, DELETE and RENAME follow. `connection.inputHandler` lets a plugin (e.g. IDLE, AUTHENTICATE) take over raw input lines, and a plugin can override `connection.canSetSeen()` (FETCH sets `\Seen`) and `connection.canExpunge()` (CLOSE expunges), both `!readOnly` by default, as ACL does. All output goes through `connection.write()` (also raw `+` continuations) and `connection.end()` closes after the output is written, never `connection.socket.write/end`: `connection.transport` is an optional layer between the protocol and the socket with `write`, `receive`, `end(callback)` and `destroy`, which passes data on with `connection.writeRaw()` and `connection.onData()`, so it is always above TLS (COMPRESS uses `src/deflate-layer.ts`, which the mock client, the session helper and the compare tool share). `connection.resetSession()` returns to the Not Authenticated state (UNAUTHENTICATE, RFC 8437), `connection.discardInput()` drops unprocessed input. LITERAL+ and LITERAL- set `server.literalPlus` and `server.nonSyncLiteralLimit`.

**Store operations and control API**: `src/store-operations.ts` holds the changes that commands and the control API share: `expungeMessages` (`connection.expungeSpecificMessages` calls it), `notifyFlagChanges`, `changeFlags` (flag changes outside STORE, emits the `flags` event `(mailbox, messages, origin)` that CONDSTORE uses to bump MODSEQ; STORE keeps its own path because of UNCHANGEDSINCE), and `deleteMailbox` (with BYE for other sessions), `renameMailbox`, `subscribeMailbox`, `unsubscribeMailbox`, which the commands of the same name call. They throw `ImapKitError` (`storeError()`, also used by `server.createMailbox` and `server.deleteMailbox`) with a RFC 5530 `code` or `INVALID`, and take the origin from `server.activeConnection`, which `server.withOrigin(origin, fn)` scopes (`processQueue` runs a handler with its session, the control API with null). `src/control.ts` is `server.control` (README "Control API"): it checks every argument and runs each change through `withOrigin(null, ...)`, so sessions see it like a change from another session. The `command` event and the `session` events `login` and `logout` go out from `send()` when the tagged response of the command goes out (`commandCompleted`, comparing with `connection._commandStart`), `select` from SELECT, `unselect` from `connection.closeMailbox()`, `waiting` from `processQueue` when a command takes over input (IDLE, AUTHENTICATE), `open` and `close` from the connection. Plugins add control operations and REST routes with `server.control.register(name, fn, routes)` (ACL, QUOTA, METADATA) and put non-JSON data into `snapshot()` with `server.control.snapshotHandlers` (ACL); `server.control` exists before the plugins load. Plugin data that follows mailbox changes (ACL and annotations on DELETE, ACL inheritance on CREATE through the `created` list of the event, annotations on RENAME INBOX) is handled in `mailbox` event listeners, never in command wrappers, so the control API gets the same behavior. Script rules also have the `quiet` event (`quietFor`, timers in `connection.watchQuiet()`, every input and output calls `connection.touch()`), `chance` (random numbers of `server.script.random`, seeded with `scriptSeed`, see `src/random.ts`) and `chunkDelay: 'tick'`. `src/quirks.ts` holds the quirk presets of the `quirks` option (script rules and plugins to leave out). `server.now()` (the `now` option) is the clock of the dates the server sets. `src/storage-schema.ts` checks the `storage` option in `loadStorage()` (types of the known keys, typos of them; plugin keys stay allowed, add a new plugin storage key to `PLUGIN_KEYS` there) and exports the JSON Schema. `connection.describe()` is the session description of `sessions()` and the events, `connection.isOpen()` tells if output can still be sent. `server.start()` / `server.stop()` are the promise forms of `listen()` / `close()`, `start()` also starts the SMTP listener of the `smtp` option. smtp-server is an optional peer dependency (and a devDependency for the tests): only `src/smtp-listener.ts` imports it, loaded with a dynamic `import()` by `server.loadSmtpListener()` and by the CLI for `--smtpPort`, so `require('imapkit')` never loads it (`test/package.test.ts` checks this). `src/rest.ts` is the REST API (README "REST API", `rest` option, `--rest-port`): a route table over `server.control` on `node:http`, which also generates `GET /v1/openapi.json`. `GET /v1/events` streams server events as Server-Sent Events (a route handler can return `{ stream(res, req) }`). Its access rules are deliberate: loopback by default, a token for any other address, a loopback Host header without a token (DNS rebinding), JSON bodies only and no CORS headers (no cross-origin requests from browsers).

Expand Down
Loading