From d1e4d1f33115277c890f398390ef1add29deb8e6 Mon Sep 17 00:00:00 2001 From: Andris Reinman Date: Thu, 8 Oct 2026 18:54:16 +0300 Subject: [PATCH 1/2] fix: CLI startup errors and port, event streams end on graceful shutdown - The imapkit command prints the port it really listens on (also for -p 0), secureConnection from a --config file switches the default port to 993, the REST host of a config file shows in the startup line, and an unknown plugin or quirk name ends with "Failed to start ImapKit" instead of a stack trace. - A graceful shutdown ends open GET /v1/events streams, so it no longer waits for their clients forever. - ControlRoute, RouteRequest, FlagMode, UidMode and UidValidityOptions are exported as types. Co-Authored-By: Claude Opus 5.5 --- bin/imapkit.js | 20 +++++++++++++++----- src/index.ts | 3 ++- src/rest.ts | 23 ++++++++++++++++------- src/server.ts | 6 ++++-- test/cli.test.ts | 26 +++++++++++++++++++++++++- test/rest.test.ts | 17 +++++++++++++++++ 6 files changed, 79 insertions(+), 16 deletions(-) diff --git a/bin/imapkit.js b/bin/imapkit.js index fb17a91..dfe1371 100755 --- a/bin/imapkit.js +++ b/bin/imapkit.js @@ -83,7 +83,9 @@ if (debug) { config.debug = true; } -const port = argv.port || process.env.IMAPKIT_PORT || config.port || (secure ? 993 : 143); +// secureConnection can also come from the --config file +const isSecure = secure || config.secureConnection === true; +const port = argv.port || process.env.IMAPKIT_PORT || config.port || (isSecure ? 993 : 143); if (argv.help) { const help = fs.readFileSync(path.join(__dirname, 'help.txt'), 'utf-8'); @@ -120,13 +122,21 @@ if (argv.help) { // smtp-server is an optional dependency, start() loads it only when SMTP is enabled config.smtp = { port: Number(smtpPort) }; } - const server = imapkit(config); + let server; + try { + server = imapkit(config); + } catch (err) { + // an unknown plugin or quirk name, invalid storage ... + console.error('Failed to start ImapKit: %s', err.message); + process.exit(1); + } console.log('Starting ImapKit ...'); server.start(Number(port)).then( - () => { - console.log('ImapKit successfully%s listening on port %s', secure ? ' and securely' : '', port); + listening => { + // the actual port, also for -p 0 + console.log('ImapKit successfully%s listening on port %s', isSecure ? ' and securely' : '', listening); if (server.restServer) { - console.log('REST API listening on %s:%s', restHost || '127.0.0.1', server.restServer.address().port); + console.log('REST API listening on %s:%s', (config.rest && config.rest.host) || '127.0.0.1', server.restServer.address().port); } if (server.smtpServer) { console.log('Incoming SMTP server up and running on port %s', server.smtpServer.server.address().port); diff --git a/src/index.ts b/src/index.ts index 080bc77..f1520f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,8 @@ export type { } from './types.js'; export type { ScriptRule, ScriptContext, ScriptEvent, ScriptBytes, ScriptHandle } from './script.js'; export type { Quirk } from './quirks.js'; -export type { Control, MailboxInfo, MessageInfo, SessionInfo, NewMessage, UserOptions, SessionFilter } from './control.js'; +export type { Control, MailboxInfo, MessageInfo, SessionInfo, NewMessage, UserOptions, SessionFilter, ControlRoute, RouteRequest } from './control.js'; +export type { FlagMode, UidMode, UidValidityOptions } from './store-operations.js'; // `imapkit(options)` creates a server. TAG_REGEX was a property of the CommonJS export, it and the // classes are properties of the factory with both module formats diff --git a/src/rest.ts b/src/rest.ts index 1febb19..b1249ab 100644 --- a/src/rest.ts +++ b/src/rest.ts @@ -13,6 +13,9 @@ import type { IMAPConnection, IMAPServer, Mailbox, Message } from './types.js'; import type { ControlRoute as Route, MessageInfo } from './control.js'; import type { ScriptHandle, ScriptRule } from './script.js'; +/** The HTTP server of the REST API, `endStreams()` ends the open event streams */ +type RestServer = http.Server & { endStreams?: () => void }; + /** Options of the REST API, the `rest` server option */ interface RestOptions { port?: number | undefined; @@ -121,8 +124,9 @@ function eventData(type: string, args: any[]): unknown { * @param {Array} types Event types to send * @return {Function} writes the stream to a response */ -function eventStream(server: IMAPServer, types: string[]) { +function eventStream(server: IMAPServer, types: string[], streams: Set) { return (res: http.ServerResponse, req: http.IncomingMessage) => { + streams.add(res); res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store' }); res.write(': connected\n\n'); const listeners = types.map(type => { @@ -134,6 +138,7 @@ function eventStream(server: IMAPServer, types: string[]) { const ping = setInterval(() => res.write(': ping\n\n'), 15000); ping.unref(); req.on('close', () => { + streams.delete(res); clearInterval(ping); listeners.forEach(({ type, listener }) => server.removeListener(type, listener)); }); @@ -146,7 +151,7 @@ function eventStream(server: IMAPServer, types: string[]) { * @param {Object} server IMAP server * @return {Array} routes */ -function getRoutes(server: IMAPServer): Route[] { +function getRoutes(server: IMAPServer, streams: Set = new Set()): Route[] { const control = server.control; const ruleJson = (handle: ScriptHandle) => ({ id: handle.id, rule: handle.rule, matched: handle.matched, hits: handle.hits }); @@ -183,7 +188,7 @@ function getRoutes(server: IMAPServer): Route[] { if (unknown.length) { throw storeError('Unknown event type ' + unknown.join(', ') + ', expected ' + EVENT_TYPES.join(', '), 'INVALID'); } - return { stream: eventStream(server, types) }; + return { stream: eventStream(server, types, streams) }; } }, { @@ -523,7 +528,7 @@ function readBody(req: http.IncomingMessage): Promise { * @return {Object} HTTP server * @throws {Error} for a host that is not a loopback address without a token */ -function createRestServer(server: IMAPServer, options: RestOptions): http.Server { +function createRestServer(server: IMAPServer, options: RestOptions): RestServer { const host = options.host || '127.0.0.1'; const token = options.token; if (token !== undefined && (typeof token !== 'string' || !token)) { @@ -532,7 +537,9 @@ function createRestServer(server: IMAPServer, options: RestOptions): http.Server if (!token && !isLoopback(host)) { throw new Error('The REST API controls the whole server, it needs a token (rest.token, --rest-token) to listen on ' + host); } - const routes = getRoutes(server); + // open event streams, a shutdown ends them so that the server can close + const streams = new Set(); + const routes = getRoutes(server, streams); const send = (res: http.ServerResponse, status: number, body: unknown) => { const json = JSON.stringify(body); @@ -574,7 +581,7 @@ function createRestServer(server: IMAPServer, options: RestOptions): http.Server send(res, 200, result === undefined ? {} : result); }; - return http.createServer((req, res) => { + const rest: RestServer = http.createServer((req, res) => { handle(req, res).catch((err: Error & { code?: string }) => { const code = err instanceof ImapKitError ? err.code : 'SERVERERROR'; // RFC 5530 codes of failed mailbox operations (CANNOT, HASCHILDREN ...) are conflicts with the state @@ -584,7 +591,9 @@ function createRestServer(server: IMAPServer, options: RestOptions): http.Server } }); }); + rest.endStreams = () => streams.forEach(res => res.end()); + return rest; } export { createRestServer, isLoopback, getRoutes, openApi }; -export type { RestOptions }; +export type { RestOptions, RestServer }; diff --git a/src/server.ts b/src/server.ts index c9cab70..12f4aa3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,5 @@ import { Stream } from 'node:stream'; import net from 'node:net'; -import http from 'node:http'; import tls from 'node:tls'; import imapHandler from 'imap-handler'; import type { CompilerOptions, ParserOptions } from 'imap-handler'; @@ -21,6 +20,7 @@ import { Control } from './control.js'; import { resolveQuirks } from './quirks.js'; import { validateStorage } from './storage-schema.js'; import { createRestServer } from './rest.js'; +import type { RestServer } from './rest.js'; import type { SessionInfo } from './control.js'; import * as bundledCert from './cert.js'; import { ServerScript, sendOutput, handleLine, handleQuiet, literalResponse, releaseDeferred } from './script.js'; @@ -210,7 +210,7 @@ class IMAPServer extends Stream { /** the SMTP server that `start()` runs for the `smtp` option */ declare smtpServer: SMTPListener | null; /** the HTTP server of the REST API that `start()` runs for the `rest` option */ - declare restServer: http.Server | null; + declare restServer: RestServer | null; constructor(options?: IMAPServerOptions) { super(); @@ -468,6 +468,8 @@ class IMAPServer extends Stream { this.smtpServer = null; } if (this.restServer) { + // event streams never end on their own, a graceful shutdown would wait for them forever + this.restServer.endStreams?.(); this.restServer.close(); // keep-alive connections would hold the server open if (force) { diff --git a/test/cli.test.ts b/test/cli.test.ts index 9117665..8fcbaf5 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -77,6 +77,27 @@ describe('imapkit command', { skip: !built && 'run npm run build first' }, () => } }); + it('prints the port it listens on, also for -p 0', async () => { + const child = spawn(process.execPath, [bin, '-p', '0'], { stdio: ['ignore', 'pipe', 'inherit'] }); + try { + const port = await new Promise((resolve, reject) => { + let output = ''; + child.stdout?.on('data', chunk => { + output += chunk; + const match = output.match(/listening on port (\d+)/); + if (match) { + resolve(Number(match[1])); + } + }); + child.once('exit', code => reject(new Error('imapkit exited with ' + code))); + }); + assert.ok(port > 0); + assert.match(await capability(port), /^A1 OK/m); + } finally { + child.kill(); + } + }); + it('loads script rules with --script', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'imapkit-script-')); const file = path.join(dir, 'script.json'); @@ -114,7 +135,10 @@ describe('imapkit command', { skip: !built && 'run npm run build first' }, () => }); it('refuses an unknown quirk', () => { - assert.throws(() => execFileSync(process.execPath, [bin, '-p', '0', '--quirk=nope'], { encoding: 'utf-8', stdio: 'pipe' }), /Unknown quirk "nope"/); + assert.throws( + () => execFileSync(process.execPath, [bin, '-p', '0', '--quirk=nope'], { encoding: 'utf-8', stdio: 'pipe' }), + /Failed to start ImapKit: Unknown quirk "nope"/ + ); }); it('starts the REST API with --rest-port', async () => { diff --git a/test/rest.test.ts b/test/rest.test.ts index 783ace2..38cfb98 100644 --- a/test/rest.test.ts +++ b/test/rest.test.ts @@ -245,6 +245,23 @@ describe('REST API', () => { assert.strictEqual((await call('GET', '/v1/events?types=nope')).status, 400); }); + it('ends open event streams on a graceful shutdown', async () => { + const res = await fetch(base + '/v1/events?types=session'); + const reader = (res.body as ReadableStream).getReader(); + const rest = server.restServer!; + const closed = new Promise(resolve => rest.once('close', resolve)); + await server.control.shutdown(); + // the stream ends instead of keeping the shutdown waiting + for (;;) { + const { done } = await reader.read(); + if (done) { + break; + } + } + await closed; + assert.strictEqual(server.server.listening, false); + }); + it('describes itself as OpenAPI', async () => { const doc = (await call('GET', '/v1/openapi.json')).body; assert.strictEqual(doc.openapi, '3.1.0'); From d8610dede6935675c2bf530db3a02c4240ecd33c Mon Sep 17 00:00:00 2001 From: Andris Reinman Date: Thu, 8 Oct 2026 18:54:17 +0300 Subject: [PATCH 2/2] docs: full documentation for imapkit.com The site has 39 pages: getting started, guides (writing client tests, storage, authentication, strict by design, multiple sessions, other languages), fault injection, the control API, the REST API, every extension, a reference (server options, server API, custom plugins, migrating from 4.x, known issues) and contributing. Every page was checked against the source and its examples and transcripts come from real runs. The homepage and the graphics are new SVG drawings. README, the CLI help text, CLAUDE.md and a few code comments were corrected where the review found them out of date. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 2 +- CLAUDE.md | 2 +- README.md | 19 +- bin/help.txt | 5 +- compare/compare.ts | 2 +- .../contributing/comparing-with-dovecot.md | 126 ++ docs/docs/contributing/running-tests.md | 114 ++ docs/docs/control-api/events.md | 182 +++ .../control-api/mailboxes-and-messages.md | 337 ++++ docs/docs/control-api/overview.md | 124 ++ docs/docs/control-api/plugin-operations.md | 248 +++ docs/docs/control-api/uidvalidity.md | 125 ++ docs/docs/control-api/users-and-sessions.md | 195 +++ docs/docs/extensions/access-control.md | 156 ++ .../authentication-and-transport.md | 173 ++ docs/docs/extensions/gmail.md | 135 ++ docs/docs/extensions/imap4rev2.md | 121 ++ docs/docs/extensions/mailboxes.md | 262 +++ docs/docs/extensions/messages.md | 377 +++++ docs/docs/extensions/metadata-and-quota.md | 177 +++ docs/docs/extensions/overview.md | 170 ++ docs/docs/extensions/search-and-sort.md | 229 +++ docs/docs/extensions/synchronization.md | 288 ++++ docs/docs/faults/quirk-presets.md | 245 +++ docs/docs/faults/repeatable-tests.md | 201 +++ docs/docs/faults/scripted-faults.md | 632 ++++++++ docs/docs/getting-started/command-line.md | 213 +++ docs/docs/getting-started/installation.md | 53 +- docs/docs/getting-started/quick-start.md | 122 +- docs/docs/guides/authentication.md | 217 +++ docs/docs/guides/multiple-sessions.md | 228 +++ docs/docs/guides/storage.md | 371 +++++ docs/docs/guides/strict-by-design.md | 173 ++ .../guides/testing-from-other-languages.md | 481 ++++++ docs/docs/guides/writing-client-tests.md | 614 ++++++++ docs/docs/intro.md | 67 +- docs/docs/reference/custom-plugins.md | 433 +++++ docs/docs/reference/known-issues.md | 63 + docs/docs/reference/migrating-from-4.md | 152 ++ docs/docs/reference/server-api.md | 140 ++ docs/docs/reference/server-options.md | 124 ++ docs/docs/rest-api/endpoints.md | 417 +++++ docs/docs/rest-api/event-stream.md | 204 +++ docs/docs/rest-api/overview.md | 158 ++ docs/docusaurus.config.ts | 6 + docs/package-lock.json | 1403 +++++++++++++++-- docs/package.json | 1 + docs/sidebars.ts | 84 +- .../src/components/HomepageFeatures/index.tsx | 62 +- .../HomepageFeatures/styles.module.css | 26 +- docs/src/css/custom.css | 34 + docs/src/pages/index.module.css | 74 +- docs/src/pages/index.tsx | 102 +- docs/static/img/features/control.svg | 6 + docs/static/img/features/extensions.svg | 3 + docs/static/img/features/faults.svg | 3 + docs/static/img/features/rest.svg | 5 + docs/static/img/features/sessions.svg | 5 + docs/static/img/features/strict.svg | 4 + docs/static/img/hero.svg | 71 + docs/static/img/logo.svg | 6 +- docs/static/img/social-card.png | Bin 0 -> 47891 bytes docs/static/img/social-card.svg | 15 + src/control.ts | 4 +- src/store-operations.ts | 2 +- 65 files changed, 10598 insertions(+), 195 deletions(-) create mode 100644 docs/docs/contributing/comparing-with-dovecot.md create mode 100644 docs/docs/contributing/running-tests.md create mode 100644 docs/docs/control-api/events.md create mode 100644 docs/docs/control-api/mailboxes-and-messages.md create mode 100644 docs/docs/control-api/overview.md create mode 100644 docs/docs/control-api/plugin-operations.md create mode 100644 docs/docs/control-api/uidvalidity.md create mode 100644 docs/docs/control-api/users-and-sessions.md create mode 100644 docs/docs/extensions/access-control.md create mode 100644 docs/docs/extensions/authentication-and-transport.md create mode 100644 docs/docs/extensions/gmail.md create mode 100644 docs/docs/extensions/imap4rev2.md create mode 100644 docs/docs/extensions/mailboxes.md create mode 100644 docs/docs/extensions/messages.md create mode 100644 docs/docs/extensions/metadata-and-quota.md create mode 100644 docs/docs/extensions/overview.md create mode 100644 docs/docs/extensions/search-and-sort.md create mode 100644 docs/docs/extensions/synchronization.md create mode 100644 docs/docs/faults/quirk-presets.md create mode 100644 docs/docs/faults/repeatable-tests.md create mode 100644 docs/docs/faults/scripted-faults.md create mode 100644 docs/docs/getting-started/command-line.md create mode 100644 docs/docs/guides/authentication.md create mode 100644 docs/docs/guides/multiple-sessions.md create mode 100644 docs/docs/guides/storage.md create mode 100644 docs/docs/guides/strict-by-design.md create mode 100644 docs/docs/guides/testing-from-other-languages.md create mode 100644 docs/docs/guides/writing-client-tests.md create mode 100644 docs/docs/reference/custom-plugins.md create mode 100644 docs/docs/reference/known-issues.md create mode 100644 docs/docs/reference/migrating-from-4.md create mode 100644 docs/docs/reference/server-api.md create mode 100644 docs/docs/reference/server-options.md create mode 100644 docs/docs/rest-api/endpoints.md create mode 100644 docs/docs/rest-api/event-stream.md create mode 100644 docs/docs/rest-api/overview.md create mode 100644 docs/static/img/features/control.svg create mode 100644 docs/static/img/features/extensions.svg create mode 100644 docs/static/img/features/faults.svg create mode 100644 docs/static/img/features/rest.svg create mode 100644 docs/static/img/features/sessions.svg create mode 100644 docs/static/img/features/strict.svg create mode 100644 docs/static/img/hero.svg create mode 100644 docs/static/img/social-card.png create mode 100644 docs/static/img/social-card.svg diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc709e5..ad58901 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -63,7 +63,7 @@ jobs: if: ${{ matrix.node != '24' }} run: npm run test:unit # Node 24 runs the same tests with coverage; the run fails if line coverage of src/ - # drops below the threshold in package.json (measured at 86.8% when it was added) + # drops below the threshold in package.json - name: Run tests with coverage if: ${{ matrix.node == '24' }} run: npm run test:coverage diff --git a/CLAUDE.md b/CLAUDE.md index 953b9f9..1409b49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ Test layers: - MIME fidelity and golden BODYSTRUCTURE/ENVELOPE wire forms (checked against Dovecot) in `test/mime-fidelity.test.ts` with fixtures in `test/fixtures/mime/` - parser level tests of the MIME code in `test/mime.test.ts` -Every transcript from `ctx.run` and `openSession` goes through `test/helpers/validate-responses.ts` before the test sees it: CRLF framing and literals, the RFC 3501 section 9 shape of tagged, untagged and `+` responses (status text is required, so every OK/NO/BAD/BYE response, including untagged ones with only a response code, must carry human readable text; no 8-bit outside literals, nz-numbers for FETCH/EXPUNGE, FETCH lists in pairs), and ImapFlow's response parser. A failure there means ImapKit sent something a compliant client can not parse, so fix the server rather than the check. `test/fuzz.test.ts` replays mutated commands under the same guardrail; on failure it prints `FUZZ_SEED`, the iteration and the input, and `FUZZ_SEED= FUZZ_ITERATIONS= node --test test/fuzz.js` reproduces or widens a run. +Every transcript from `ctx.run` and `openSession` goes through `test/helpers/validate-responses.ts` before the test sees it: CRLF framing and literals, the RFC 3501 section 9 shape of tagged, untagged and `+` responses (status text is required, so every OK/NO/BAD/BYE response, including untagged ones with only a response code, must carry human readable text; no 8-bit outside literals, nz-numbers for FETCH/EXPUNGE, FETCH lists in pairs), and ImapFlow's response parser. A failure there means ImapKit sent something a compliant client can not parse, so fix the server rather than the check. `test/fuzz.test.ts` replays mutated commands under the same guardrail; on failure it prints `FUZZ_SEED`, the iteration and the input, and `FUZZ_SEED= FUZZ_ITERATIONS= node --import tsx --test test/fuzz.test.ts` reproduces or widens a run. ## Comparing with Dovecot diff --git a/README.md b/README.md index 0a0f38f..4f7b0f0 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ An unknown plugin name throws an error, and a plugin listed more than once is lo - **SASL-IR** Enables SASL-IR [RFC4959] capability - **QRESYNC** Adds QRESYNC [RFC7162] capability, also loads CONDSTORE and ENABLE. After `ENABLE QRESYNC`: `SELECT`/`EXAMINE` with `(QRESYNC (uidvalidity modseq [known-uids] [seq-match-data]))` reports `VANISHED (EARLIER)` and the changed flags, `UID FETCH ... (CHANGEDSINCE n VANISHED)` works, and expunges (EXPUNGE, UID EXPUNGE, MOVE, other sessions, IDLE) are reported with `VANISHED` instead of `EXPUNGE`. Expunged UIDs are remembered with their mod-sequence; UIDs missing from the initial storage count as expunged before the server started - **SAVELIMIT** Adds SAVELIMIT [RFC9738] capability, advertised as `SAVELIMIT=` (server option `messageLimit`, default 1000): only COPY and APPEND (MULTIAPPEND) of more messages fail with `NO [MESSAGELIMIT ...]`. Can not be loaded together with MESSAGELIMIT -- **SAVEDATE** Adds SAVEDATE [RFC8514] capability: the `SAVEDATE` FETCH item and the `SAVEDBEFORE`, `SAVEDON`, `SAVEDSINCE` and `SAVEDATESUPPORTED` SEARCH keys. APPEND, COPY and MOVE set the save date to the current time, messages in storage can set it with a `SAVEDATE` value (a date-time string or a Date) and get the time the server was started otherwise. A mailbox with `"SAVEDATE": false` in storage does not support save dates: FETCH returns NIL and the SEARCH keys use the internal date +- **SAVEDATE** Adds SAVEDATE [RFC8514] capability: the `SAVEDATE` FETCH item and the `SAVEDBEFORE`, `SAVEDON`, `SAVEDSINCE` and `SAVEDATESUPPORTED` SEARCH keys. APPEND, COPY and MOVE set the save date to the current time, messages in storage can set it with a `SAVEDATE` value (a date-time string or a Date) and get the the time the storage was loaded otherwise (the current time, or the `now` option). A mailbox with `"SAVEDATE": false` in storage does not support save dates: FETCH returns NIL and the SEARCH keys use the internal date - **SEARCHRES** Adds SEARCHRES [RFC5182] capability, also loads ESEARCH: `SEARCH RETURN (SAVE)` stores the result and `$` refers to it in FETCH, STORE, COPY, MOVE, UID EXPUNGE, SEARCH and their UID variants. `$` must be used alone, not combined with numbers like `1,$` - **SORT** Adds SORT [RFC5256] capability (SORT and UID SORT with all RFC 5256 sort keys). Strings are compared with the i;unicode-casemap collation (RFC 5051), base subjects follow RFC 5256 section 2.1 and sent dates section 2.2. With CONDSTORE, a MODSEQ search key appends the highest mod-sequence (RFC 7162 section 3.1.9). I18NLEVEL=1 is not advertised, as SEARCH matches strings with ASCII case folding only - **SORT=DISPLAY** Adds SORT=DISPLAY [RFC5957] capability (DISPLAYFROM and DISPLAYTO sort keys), also loads SORT @@ -408,7 +408,7 @@ Mailboxes are addressed by their storage name (modified UTF-7, the name a client | `listUsers()`, `addUser(name, { password, xoauth2 })`, `updateUser(name, {...})`, `deleteUser(name)` | `xoauth2` is `{ accessToken, sessionTimeout }`. Deleting a user disconnects its sessions, `{ disconnect: false }` keeps them. No credentials in `listUsers()` | | `disconnect(session or { user }, { text, reset })` | Disconnects sessions with an untagged `BYE`, or resets the TCP connection | | `inject(session, data)` | Writes bytes to a session as they are, e.g. `* OK [ALERT] ...` between commands | -| `reset()` | Restores the mailboxes and users of the server options and disconnects every session with `BYE`, so a long running server can be reused between tests. Script rules stay | +| `reset()` | Restores the mailboxes and users of the server options and disconnects every session with `BYE`, so a long running server can be reused between tests. Script rules stay, with their `hits` counts | | `shutdown({ graceful })` | Stops accepting connections and resolves once the last client is gone, `graceful: false` closes the sessions | Plugins add operations for their own data, they exist only when the plugin is loaded: @@ -430,9 +430,10 @@ A plugin of your own adds operations with `server.control.register(name, fn, rou Tests can wait for events instead of polling: - `session`: `{ type, session }`, `type` is `open`, `login`, `select` (also when the same mailbox is selected again), `unselect` (CLOSE, UNSELECT, a failed SELECT, BYE), `logout` (UNAUTHENTICATE), `waiting` (a command waits for client input, with `command`, e.g. `IDLE` after its continuation) or `close`, `session` is the session as `sessions()` describes it. -- `command`: `{ session, tag, command, status, user }` when the tagged response of a command goes out, `status` is `OK`, `NO` or `BAD`. +- `command`: `{ session, tag, command, status, user }` when the tagged response of a command goes out, `status` is `OK`, `NO` or `BAD`. A line that is refused before it runs (it does not parse, or it is pipelined ambiguously) and a command that a script rule answers have no `command` event. - `mailbox`: `{ type, path, oldPath, mailbox, origin }` for CREATE, DELETE, RENAME, SUBSCRIBE and UNSUBSCRIBE, `origin` is null for the control API. - `expunge`: `(mailbox, messages, origin)` before the sessions are told, and `flags`: `(mailbox, messages, origin)` for flag changes of the control API. +- `acl`: `(mailbox, previousAcl)` when the ACL of a mailbox changes (ACL plugin), `script`: `{ rule, event, session, tag, command }` when a script rule fires, `reset`: after `control.reset()`. ```javascript const idling = new Promise(resolve => server.on('session', event => event.type === 'waiting' && event.command === 'IDLE' && resolve(event))); @@ -454,7 +455,7 @@ curl -X POST http://127.0.0.1:8143/v1/mailboxes/INBOX/messages \ The REST API controls the whole server, including the mail of every user, so it is careful by default. It listens on `127.0.0.1` unless `host` (`--rest-host`) says otherwise, and any other address than a loopback one needs a bearer token (`token`, `--rest-token`, `IMAPKIT_REST_TOKEN`) that every request sends as `Authorization: Bearer `. Without a token it only answers requests for a loopback host name. It sends no CORS headers and takes JSON bodies only (`Content-Type: application/json`, at most 64 MiB), so a web page can not call it. Do not expose it outside a test environment. -Requests and responses are JSON. Errors are `{ "error": { "code", "message" } }` with HTTP status 404 for `NONEXISTENT`, 409 for `ALREADYEXISTS` and the RFC 5530 codes of a failed mailbox operation, 400 for `INVALID`, 401 for a missing or wrong token. A mailbox is its storage name (modified UTF-7) in the URL, URL encoded with a `/` in the name as `%2F` (`/v1/mailboxes/Work%2FProjects`). Message sources are base64 in responses (`"encoding": "base64"`), a request sends `raw` as text or as base64 with `"encoding": "base64"`. `GET /v1/openapi.json` describes every endpoint. +Requests and responses are JSON, and every POST needs `Content-Type: application/json`, also without a body (HTTP 415 otherwise). Errors are `{ "error": { "code", "message" } }` with HTTP status 404 for `NONEXISTENT`, 409 for `ALREADYEXISTS` and the response codes of a failed mailbox operation (`CANNOT`, `HASCHILDREN` ...), 400 for `INVALID`, 401 for a missing or wrong token, 404 `NOTFOUND` and 405 `METHOD` for an unknown route or method, 413 `TOOBIG` for a body over 64 MiB (and `addMessage` checks refused by APPENDLIMIT), 500 for a server error. A mailbox is its storage name (modified UTF-7) in the URL, URL encoded with a `/` in the name as `%2F` (`/v1/mailboxes/Work%2FProjects`). Message sources are base64 in responses (`"encoding": "base64"`), a request sends `raw` as text or as base64 with `"encoding": "base64"`. `GET /v1/openapi.json` describes every endpoint. `GET /v1/events` streams the [events](#events) as [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html) (`event: ` with JSON `data`), so a test in another language can wait for "the client selected INBOX" or "a script rule fired" without polling. `?types=session,command` chooses the types (`session`, `command`, `mailbox`, `expunge`, `flags`, `acl`, `script`, `reset`), mailboxes are paths, messages UIDs and sessions numbers in the data: @@ -543,19 +544,19 @@ The actions say what happens instead of the usual behavior. Strings are sent as | --------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `send` | all | output events: bytes sent instead of the output. command and input: bytes sent instead of processing the line | | `run` | command, input | process the line as usual after `send` (to add output before the real response) | -| `drop` | all | output events: send nothing. command and input: ignore the line, the client gets no answer | +| `drop` | all but quiet | output events: send nothing. command and input: ignore the line, the client gets no answer | | `mutate` | response, continuation | `(response, context)` gets a copy of the response object before it is compiled and changes it, or returns another one. Continuation requests have a response object only when a plugin sends them with `connection.send()` (the error challenges of XOAUTH2 and OAUTHBEARER), other events ignore `mutate`. A tagged response that does not compile then is sent as `NO [SERVERBUG]`, an untagged one is dropped. Use `send` for output that is not valid IMAP | | `literals` | response | sends every string of the response that the grammar allows (string, nstring, astring) as a literal. The positions that take only a quoted string stay quoted: the hierarchy delimiter of LIST, LSUB and NAMESPACE, CHILDINFO values, INTERNALDATE and SAVEDATE, and the media types `"TEXT"` and `"MESSAGE" "RFC822"` in a body structure (RFC 9051 section 9, RFC 5258 section 6). Atoms, response codes and text stay as they are. Works together with `mutate`, after it | | `defer` | response | holds an untagged response back: `'tagged'` sends it right after the tagged response of its command, `'next'` with the answer to the next command, before its first response. Needs `untagged: true`. `send`, `before` and `after` change the held output, `drop`, `delay`, `chunk`, `truncate` and `close` can not be combined with it. A response that does not belong to a command (one that arrives in IDLE belongs to IDLE) is held for the next tagged response or command. Held responses are dropped when the connection closes | | `before`, `after` | output events | bytes sent before or after the output, e.g. an unsolicited response | -| `delay` | all but input | milliseconds to wait before the output goes out, all later output waits behind it. For a command, the wait before the rule acts or the command runs, later commands wait too | +| `delay` | all but input, quiet | milliseconds to wait before the output goes out, all later output waits behind it. For a command, the wait before the rule acts or the command runs, later commands wait too | | `chunk`, `chunkDelay` | all | write the bytes in pieces of `chunk` octets, `chunkDelay` milliseconds apart (default 10). `chunkDelay: 0` or `'tick'` sends each piece after the earlier one was handed to the system, on the next event loop turn, so the pieces leave as separate TCP segments without a wall clock delay (on Deno a zero timer, about 2 ms a piece) | | `truncate` | all | send only this many octets of the bytes, then close the connection | | `close` | all | close the connection after the bytes are sent, `'reset'` destroys the socket instead (a TCP RST where the runtime supports it), 20 ms after the bytes so that the RST does not overtake them. Input that arrives meanwhile is not processed | A rule needs at least one action, and for command and input events `chunk` and `truncate` need `send`. A rule with only `delay` (and `run`) delays the line and then processes it as usual. Rules are checked when they are added: an unknown option, an option that does not apply to the event, or an invalid value throws a `TypeError`, so a typo can not turn into a rule that never fires. -The event context, which `when`, `mutate` and `send` functions get, has `event`, `connection`, `session`, `state`, `user`, `mailbox`, `tag`, `command`, `data` (the command line or the output bytes, as a binary string), and for responses `description` and `response`. +The event context, which `when`, `mutate` and `send` functions get, has `event`, `connection`, `session`, `state`, `user`, `mailbox`, `tag`, `command`, `data` (the command line or the output bytes, as a binary string), `quiet` (milliseconds without input or output, quiet events), and for responses `description` and `response`. `server.script.add(rule)` returns a handle with `rule`, `matched` (events that matched the rule, also before `nth`), `hits` (events the rule handled) and `remove()`, `server.script.add([rules])` returns a list of handles. `server.script.rules` lists the handles in order, `server.script.clear()` removes every rule. The server emits a `script` event `{ rule, event, session, tag, command }` every time a rule fires. @@ -705,7 +706,7 @@ Where server.getCommandOptions(name) -> Object server.getCommandStates(name) -> Array|false -`getCommandOptions` returns the options of a command (see above) with every key set, `getCommandStates` only the connection states it is valid in, or `false` if any state is fine. +`getCommandOptions` returns the options of a command (see above) with every key set, `getCommandStates` only the connection states it is valid in (`false` for a command of a plugin that did not limit them). #### Run after every plugin is loaded @@ -807,7 +808,7 @@ server.outputHandlers.push(function (connection, response, description) { #### Other extension points - `server.messageHandlers` and `server.mailboxHandlers` run on every message and mailbox when it is loaded from storage or created, `(server, message, mailbox)` and `(server, mailbox)` -- `server.statusHandlers[ITEM]` returns the value of a STATUS item that a plugin adds to `server.allowedStatus`, `(connection, mailbox)` +- `server.statusHandlers[ITEM]` returns the value of a STATUS item that a plugin adds to `server.allowedStatus`, `(connection, mailbox, status)` where `status` holds the counters of `server.getStatus()` - `server.appendChecks` are consulted before APPEND, COPY and MOVE add messages to a mailbox, `(connection, mailbox, messages, options)`. A check returns nothing to allow it, or `{ code, text }` to fail the command with a tagged `NO [code] text` (`{ code, text, soft: true }` only sends an untagged `NO` warning) - `server.closedChecks` are consulted when SELECT or EXAMINE closes the selected mailbox, `(connection)`. If any returns true, `* OK [CLOSED]` marks where the responses for the new mailbox start - `server.copyHandlers` run when COPY, MOVE or RENAME INBOX copies a message, `(server, source, properties, mailbox)`. Properties set on `properties` are given to the copy before the message handlers run diff --git a/bin/help.txt b/bin/help.txt index aefcf79..23641b5 100644 --- a/bin/help.txt +++ b/bin/help.txt @@ -72,7 +72,7 @@ Configuration file takes the following structure } }, "id":{ - "name": "hoodecrow", + "name": "imapkit", "version": "__VERSION__", "key": "value" } @@ -82,7 +82,8 @@ Scripted faults --------------- A script file holds a list of rules. Each rule watches one event -("greeting", "command", "input", "response" or "continuation") and +("greeting", "command", "input", "response", "continuation" or +"quiet", no input and output for "quietFor" milliseconds) and changes what the server does when its matchers fit. For example, this script answers the first FETCH with a literal that is too short and then drops the connection: diff --git a/compare/compare.ts b/compare/compare.ts index 4c1b171..b38a1de 100755 --- a/compare/compare.ts +++ b/compare/compare.ts @@ -722,7 +722,7 @@ Replays IMAP commands against imapkit and Dovecot (start it with -v, --verbose also show greetings, login and seeding notes --json print machine readable results -Scenario syntax: see the comment on parseScenario() in compare/compare.js.`); +Scenario syntax: see the comment on parseScenario() in compare/compare.ts.`); return; } diff --git a/docs/docs/contributing/comparing-with-dovecot.md b/docs/docs/contributing/comparing-with-dovecot.md new file mode 100644 index 0000000..716112c --- /dev/null +++ b/docs/docs/contributing/comparing-with-dovecot.md @@ -0,0 +1,126 @@ +--- +title: Comparing with Dovecot +sidebar_position: 2 +description: Replay the same IMAP commands against ImapKit and a real Dovecot server and see where the responses differ. +--- + +# Comparing with Dovecot + +`compare/` holds a development aid, not a test suite. It replays the same IMAP commands against ImapKit and a real Dovecot 2.4 server and shows where the responses differ. Use it when building or fixing a feature, to see what RFC compliant input and output look like in practice. + +Dovecot is the most spec compliant server around, but it has its own bugs, quirks and extensions. Treat a difference as a hint to check the RFC (from `https://www.rfc-editor.org/rfc/rfcNNNN.txt`), not as proof that ImapKit is wrong, and do not copy Dovecot behavior that the RFC does not require. ImapKit is [strict by design](../guides/strict-by-design.md), so it refuses input Dovecot accepts in many places. + +## Starting Dovecot + +The tool needs Docker. Dovecot runs as a long-running container named `imapkit-dovecot` (image `dovecot/dovecot:2.4.4`) with plain IMAP on `127.0.0.1:32143`: + +```bash +npm run dovecot:start +npm run dovecot:stop +bash compare/dovecot.sh status # also: restart, logs +``` + +`compare/dovecot.conf` is mounted as a drop-in. It allows cleartext login, turns off FTS so SEARCH is plain substring matching, stops auto-creating special-use mailboxes, and turns on QUOTA (count driver, 10M and 1000 messages), APPENDLIMIT (5M), ACL (vfile) and METADATA for comparison. + +| Variable | What it changes | +| -------------------------- | -------------------------------------------------------------------------------------- | +| `IMAPKIT_DOVECOT_IMAGE` | the image to run | +| `IMAPKIT_DOVECOT_PLATFORM` | the platform, e.g. `linux/amd64`. Forcing `linux/amd64` on Apple Silicon does not work | +| `IMAPKIT_DOVECOT_PORT` | the host port, read by both `dovecot.sh` and the compare tool (default `32143`) | +| `IMAPKIT_DOVECOT_HOST` | the host the compare tool connects to (default `127.0.0.1`) | + +## Running a comparison + +```bash +npm run compare -- compare/scenarios/fetch.txt +node --import tsx compare/compare.ts -c 'SELECT INBOX' -c 'FETCH 1 BODYSTRUCTURE' +node --import tsx compare/compare.ts --plugin IDLE,MOVE compare/scenarios/basic.txt +``` + +Every run starts an in-process ImapKit on a random port, and logs into Dovecot as a brand-new user (static passdb, password `pass`), so runs never see each other's mail. The new user is seeded from the same storage JSON through a hidden setup connection: CREATE, SUBSCRIBE, and APPEND with flags and internal date. Only personal namespaces with `/` as separator are seeded and `\Recent` is dropped. A warning is shown when Dovecot assigns other UIDs than the storage specifies. + +For each step the output shows `= same` (with the ImapKit output dimmed), or both outputs one after the other. The last line counts the steps that differ (or says that all steps match). The exit code is 0 even when steps differ, it is 1 only when the run fails (for example when Dovecot is not reachable). + +### Options + +| Option | What it does | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `-c`, `--command ` | A command to run, repeatable, instead of a scenario file. | +| `--storage ` | The ImapKit storage JSON, also seeded into Dovecot. Default `compare/storage.json`. | +| `--plugin ` | ImapKit plugins, comma separated, repeatable. | +| `--target ` | `both` (default), `imapkit` or `dovecot`. `--target imapkit` needs no Docker. | +| `--manual-login` | Do not log in automatically, the scenario logs in with `$USER` and `$PASS`. | +| `--keep-text` | Also compare the human readable text of OK, NO, BAD and BYE responses and `+` continuations. | +| `--exact` | Do not sort LIST responses and flag lists, and do not unquote mailbox names, before comparing. | +| `--timeout ` | How long to wait for a tagged response. Default 3000. | +| `--settle ` | How long to wait after each step for extra output. Default 100. | +| `-v`, `--verbose` | Also show greetings, login and seeding notes. | +| `--json` | Machine readable results: the warnings per target, and for every step the line, session, what was sent, whether both sides matched (`same`) and the responses of each target. | + +`node --import tsx compare/compare.ts --help` prints the same list. + +## Scenario files + +Scenario files in `compare/scenarios/*.txt` hold one step per line: + +| Line | What it does | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `SELECT INBOX` | A command, sent with an automatic tag (`A1`, `A2`, ...). | +| `2: SELECT INBOX` | A command on session 2. Sessions open and log in on first use, the default is session 1. | +| `> DONE` | Sent verbatim with no tag (also `2:> DONE`). Waits for the tagged response of the command a continuation left open (IDLE, AUTHENTICATE). | +| `!wait 500` | Pauses, then collects whatever the sessions received. | +| `# comment` | Ignored, as are blank lines. | + +Inside commands and verbatim lines: + +- the four characters `\r\n` become CRLF +- `{file:path}` and `{file+:path}` become a synchronizing or non-synchronizing literal with the contents of the file, line endings converted to CRLF. The path is relative to the scenario file, sample messages are in `compare/messages/` +- `~{file:path}` and `~{file+:path}` become a literal8 ([RFC 3516](https://www.rfc-editor.org/rfc/rfc3516)) with the file's octets as they are, binary samples are in `compare/messages/literal8/` +- `$USER` and `$PASS` expand to the credentials of each server, for `--manual-login` +- `$UIDVALIDITY` expands to the last UIDVALIDITY the server sent, for `SELECT INBOX (QRESYNC ($UIDVALIDITY 1))` + +Synchronizing literals wait for the `+` continuation. After a tagged OK to `COMPRESS DEFLATE` a session compresses, and its output is compared decompressed (`compare/scenarios/compress.txt`). + +A scenario saved in `compare/scenarios/`, run with `--plugin IDLE`: + +```text title="compare/scenarios/my-idle.txt" +# a second session adds a message while the first one idles +SELECT INBOX +IDLE +2: APPEND INBOX {file:../messages/simple.eml} +!wait 1000 +> DONE +``` + +Some scenarios need their own storage or plugins, the comment at the top of the file says how to run them. For example `compare/scenarios/sort-thread.txt` runs against `compare/storage-sort-thread.json`: + +```bash +npm run compare -- --storage compare/storage-sort-thread.json \ + --plugin SORT,SORT=DISPLAY,THREAD=REFERENCES,THREAD=ORDEREDSUBJECT \ + compare/scenarios/sort-thread.txt +``` + +## Normalization + +Both outputs are normalized before they are compared, so only meaningful differences show: + +- Dovecot's `(0.001 + 0.000 secs)` timings are removed +- the human readable text of OK, NO, BAD and BYE responses and of `+` continuations is dropped, unless `--keep-text` is set +- UIDVALIDITY values are masked, also inside COPYUID and APPENDUID +- LIST and LSUB responses and flag lists are sorted, and quoted mailbox names that are valid atoms are unquoted, unless `--exact` is set + +## Known differences + +Many differences are expected and are not ImapKit bugs. The main groups: + +- **Capabilities and extensions.** Dovecot advertises many more extensions, and some (IMAP4rev2, CONDSTORE) change its output once enabled. Dovecot does not support OBJECTID, PARTIAL or MULTISEARCH. Dovecot advertises COMPRESS=DEFLATE only after login. +- **Values that differ by design.** Lowercase BODYSTRUCTURE strings, INTERNALDATE in UTC, its own mod-sequences, UIDs when seeding, `\Recent` for the first session that selects a seeded mailbox, and quota limits from its own config. +- **Timing.** IDLE and NOTIFY notifications can arrive late in Dovecot, so put a `!wait 1000` after the step that triggers them. +- **Places where Dovecot is lenient.** Dovecot accepts input that ImapKit refuses with `BAD` or `NO` because the RFC grammar or a MUST forbids it: for example relative CATENATE URLs, `REVERSE REVERSE` in SORT, items RFC 9051 removed after `ENABLE IMAP4rev2`, and 8-bit headers in APPEND before `ENABLE UTF8=ACCEPT`. +- **Places where Dovecot differs from an RFC.** For example `* OK [CLOSED]` on every mailbox switch, SEARCH answered without ESEARCH after `ENABLE IMAP4rev2`, missing UID in unsolicited NOTIFY flag updates, and `NO` instead of `BAD` for a second COMPRESS. + +The full list, with the RFC sections behind each difference, is in the "Comparing with Dovecot" section of [CLAUDE.md](https://github.com/postalsys/imapkit/blob/master/CLAUDE.md). Add to it when you find a new difference that is not an ImapKit bug. + +## Tests of the tool + +`test/compare.test.ts` covers the tool's scenario parsing, normalizing and seeding against ImapKit only, so `npm test` stays Docker-free. diff --git a/docs/docs/contributing/running-tests.md b/docs/docs/contributing/running-tests.md new file mode 100644 index 0000000..58c6854 --- /dev/null +++ b/docs/docs/contributing/running-tests.md @@ -0,0 +1,114 @@ +--- +title: Running Tests +sidebar_position: 1 +description: Build ImapKit from source, run the test suite under Node.js, Bun and Deno, check coverage and formatting, and understand the test layers. +--- + +# Running Tests + +ImapKit is written in TypeScript under `src/`. Tests use the built-in Node.js test runner, and TypeScript test files run through [tsx](https://tsx.is/). Linting uses ESLint and the TypeScript compiler, formatting uses Prettier. + +```bash +git clone https://github.com/postalsys/imapkit.git +cd imapkit +npm install +npm test +``` + +`npm install` also builds the package (the `prepare` script) and points `core.hooksPath` at `.githooks`. + +## Commands + +| Command | What it does | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `npm test` | ESLint and the type check (`npm run lint`), the build, then every test. This is what to run before a commit. | +| `npm run test:unit` | Every test, without lint and build: `node --import tsx --test test/*.test.ts`. | +| `npm run test:coverage` | The tests with Node's built-in coverage for `src/`. Fails below **94%** line coverage. Needs Node.js 22.8 or newer, CI runs it on Node 24. | +| `npm run test:bun` | The suite under [Bun](https://bun.sh/). | +| `npm run test:deno` | The suite under [Deno](https://deno.com/). | +| `npm run build` | Compiles `src/` into `dist/esm` and `dist/cjs` with type declarations, and collects the plugin help text for `imapkit --help`. | +| `npm run lint` | ESLint and `tsc` type checking. | +| `npm run format` | Prettier on every file. `npm run format:check` only checks, CI fails on unformatted files. | + +Tests import `src/` directly through tsx, so most of them do not need a build. Only `test/package.test.ts` and `test/cli.test.ts` load the built `dist/`, and they skip with "run npm run build first" when it is missing. + +### A single file or test case + +```bash +node --import tsx --test test/uid-fetch.test.ts +node --import tsx --test --test-name-pattern="returns server ID" test/id.test.ts +``` + +Test files start their servers on random ports, so the runner runs them in parallel. + +### Bun and Deno + +CI runs the whole suite on the latest Bun and Deno releases next to Node.js 20, 22 and 24. Deno does not pass a `done` callback to `node:test` hooks, so hooks in the tests return promises. Keep that pattern when adding hooks. + +## Formatting + +Prettier uses single quotes, 4 spaces and 160 columns. The pre-commit hook in `.githooks/pre-commit` formats staged files and adds them back to the commit. A staged file that also has unstaged changes is only checked, not rewritten, so the commit is aborted if it is unformatted: run `npm run format` and stage again. + +## The response guardrail + +Every transcript a test gets from the test helpers first goes through `test/helpers/validate-responses.ts`. It checks that a compliant client can parse everything the server sent: + +- CRLF framing and literals +- the [RFC 3501](https://www.rfc-editor.org/rfc/rfc3501) section 9 shape of tagged, untagged and `+` responses: every OK, NO, BAD and BYE response carries human readable text (also untagged ones with only a response code), no 8-bit data outside literals, nz-numbers for FETCH and EXPUNGE, FETCH lists in pairs +- ImapFlow's response parser accepts every response + +A failure there means ImapKit sent something a compliant client can not parse. Fix the server, not the check. + +## The fuzz test + +`test/fuzz.test.ts` mutates valid IMAP commands and replays them against a server with every plugin loaded except STARTTLS, COMPRESS, LOGINDISABLED, METADATA-SERVER and the alternatives LITERAL- and SAVELIMIT. It checks that the server answered every command once, that its output passes the response guardrail and that it still responds afterwards. + +The seed is fixed by default, so CI is deterministic. On a failure the test prints `FUZZ_SEED`, the iteration and the input. Reproduce a run, or widen it with more iterations or another seed: + +```bash +FUZZ_SEED=7 FUZZ_ITERATIONS=5000 node --import tsx --test test/fuzz.test.ts +``` + +`FUZZ_ITERATIONS` defaults to 400. + +## Test layers + +| Layer | Where | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Protocol tests per command | `test/.test.ts` and `test/.test.ts` | +| Strictness checks | `test/conformance.test.ts`, table driven, one row per RFC rule (see [Strict by Design](../guides/strict-by-design.md)) | +| Multiple sessions | `test/sessions.test.ts` (EXPUNGE timing, flag updates, IDLE, `\Recent`), `test/multi-access.test.ts` (RFC 2180 scenarios) | +| RFC 2683 recommendations | `test/implementation-recommendations.test.ts` | +| A real client end to end | `test/imapflow.test.ts`, ImapFlow with all plugins and with none | +| MIME fidelity | `test/mime-fidelity.test.ts`, golden BODYSTRUCTURE and ENVELOPE wire forms checked against Dovecot, fixtures in `test/fixtures/mime/` | +| MIME parser | `test/mime.test.ts` | +| Package shape | `test/package.test.ts`, the ES module and CommonJS exports of the built package | +| Compare tool | `test/compare.test.ts`, the parsing, normalizing and seeding of [the Dovecot compare tool](./comparing-with-dovecot.md), against ImapKit only, so `npm test` needs no Docker | +| Fuzzing | `test/fuzz.test.ts` | + +## Writing a test + +The usual pattern uses `setupServer()` from `test/helpers/`. It registers hooks that start a fresh server on a random port before every test of the enclosing `describe` block and close it afterwards. `ctx.run(commands, callback)` replays IMAP command strings like a compliant client: it waits for each tagged response, sends literal data only after the `+` continuation, and sends the next list entry as continuation data when the server asks for one (`DONE`, SASL responses). + +```typescript +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { setupServer } from './helpers/index.js'; + +describe('XYZ', () => { + const ctx = setupServer(() => ({ plugins: ['IDLE'], storage: { INBOX: {}, '': {} } })); + + it('refuses a missing mailbox', (t, done) => { + ctx.run(['A1 LOGIN testuser testpass', 'A2 SELECT Nope', 'A3 LOGOUT'], resp => { + assert.match(resp.toString(), /^A2 NO /m); + done(); + }); + }); +}); +``` + +Assert on the full transcript, preferably with line anchored regular expressions (`/^A3 NO \[TRYCREATE\]/m`). `ctx.server` is the live server for inspecting state. For interleaved connections use `openSession()` or `useSessions()` from `test/helpers/session.ts`. + +Keep helpers out of the top level of `test/`: every `test/*.test.ts` file there runs as a test file. + +Changes to RFC behavior cite the section in code comments and tests, checked against the source text at `https://www.rfc-editor.org/rfc/rfcNNNN.txt`. diff --git a/docs/docs/control-api/events.md b/docs/docs/control-api/events.md new file mode 100644 index 0000000..cfd2735 --- /dev/null +++ b/docs/docs/control-api/events.md @@ -0,0 +1,182 @@ +--- +title: Events +sidebar_position: 5 +description: Server events for sessions, commands, mailbox changes, expunges, flag changes, ACL changes, script rules and resets, and how to wait for them in tests instead of polling. +--- + +# Events + +The server is a Node.js `EventEmitter`. Its events tell a test what the client is doing, so the test can wait for "the client is idling" or "the client selected INBOX" and then act, instead of sleeping or polling. Tests in other languages get the same events from the [REST event stream](../rest-api/event-stream.md). + +| Event | Arguments | When | +| --------- | ---------------------------------------------------- | ----------------------------------------------------------------------- | +| `session` | `{ type, session, command? }` | a session opens, logs in, selects, unselects, logs out, waits or closes | +| `command` | `{ session, tag, command, status, user }` | the tagged response of a command goes out | +| `mailbox` | `{ type, path, oldPath, mailbox, origin, created? }` | a mailbox is created, deleted, renamed, subscribed or unsubscribed | +| `expunge` | `(mailbox, messages, origin)` | messages are removed | +| `flags` | `(mailbox, messages, origin)` | the control API changed flags | +| `acl` | `(mailbox, previousAcl)` | an ACL changed (ACL plugin) | +| `script` | `{ rule, event, session, tag, command }` | a [script rule](../faults/scripted-faults.md) fired | +| `reset` | none | `control.reset()` restored the server | + +Listeners run synchronously, while the server is in the middle of the change. Calling the control API from a listener is fine, its changes have no session as their origin like any other control API call. + +## session + +`{ type, session }`, where `session` is the session as [`sessions()`](./users-and-sessions.md#sessions) describes it, at the moment of the event. `type` is one of: + +| `type` | When | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `open` | a client connected | +| `login` | a command authenticated the session (LOGIN, AUTHENTICATE), right after the `command` event of that command | +| `select` | SELECT or EXAMINE opened a mailbox, also when the same mailbox is selected again | +| `unselect` | the selected mailbox was closed: CLOSE, UNSELECT, a failed SELECT, or a BYE from the server | +| `logout` | the session went back to Not Authenticated (UNAUTHENTICATE) | +| `waiting` | a command waits for client input after its continuation, the event has `command` too, e.g. `IDLE` after `+ idling`, or `AUTHENTICATE` | +| `close` | the connection closed | + +A real sequence, from a client that logs in as `alice`, selects INBOX and starts IDLE: + +```json +{"type":"open","session":{"session":1,"user":null,"state":"Not Authenticated","mailbox":null,"readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}} +{"type":"login","session":{"session":1,"user":"alice","state":"Authenticated","mailbox":null,"readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}} +{"type":"select","session":{"session":1,"user":"alice","state":"Selected","mailbox":"INBOX","readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}} +{"type":"waiting","session":{"session":1,"user":"alice","state":"Selected","mailbox":"INBOX","readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"},"command":"IDLE"} +``` + +In the `close` event `remoteAddress` is `null`, as the socket is gone. After LOGOUT or a BYE, `state` is `Logout`. + +## command + +`{ session, tag, command, status, user }` when the tagged response of a command is about to be written: + +| Field | Description | +| --------- | ------------------------------------------------------------ | +| `session` | session number | +| `tag` | the command tag | +| `command` | the command name in upper case, `UID FETCH` for UID commands | +| `status` | `OK`, `NO` or `BAD` | +| `user` | the logged in user after the command, null before login | + +```json +{"session":1,"tag":"A1","command":"LOGIN","status":"OK","user":"alice"} +{"session":1,"tag":"A2","command":"SELECT","status":"OK","user":"alice"} +``` + +The event fires when the server sends the response, the client may not have read it yet. A command that a script rule answered instead of the command handler has no `command` event, the `script` event reports it instead. + +## mailbox + +`{ type, path, oldPath, mailbox, origin }` for a mailbox change, from a command or the control API: + +| Field | Description | +| --------- | --------------------------------------------------------------------------------------- | +| `type` | `create`, `delete`, `rename`, `subscribe` or `unsubscribe` | +| `path` | storage name of the mailbox, the new name for `rename` | +| `oldPath` | the old name for `rename`, otherwise null | +| `mailbox` | the removed mailbox object for `delete`, otherwise null | +| `origin` | the `IMAPConnection` whose command made the change, null for the control API (and SMTP) | +| `created` | `create` only: every mailbox the create made, superior levels first | + +`subscribe` and `unsubscribe` fire only when the subscription changed. Renaming INBOX is a `rename` from `INBOX` to the new name. + +```javascript +server.on('mailbox', event => console.log(event.type, event.path, event.created)); +server.control.createMailbox('Archive/2024'); +// create Archive/2024 [ 'Archive', 'Archive/2024' ] +``` + +## expunge + +`(mailbox, messages, origin)`: the mailbox object, the removed message objects and the session that caused it (null for the control API). It fires for every removal, from EXPUNGE, CLOSE, MOVE, UID EXPUNGE and the control API, before the sessions get their EXPUNGE or VANISHED responses. + +```javascript +server.on('expunge', (mailbox, messages, origin) => { + console.log( + mailbox.path, + messages.map(message => message.uid), + origin && origin.sessionNumber + ); +}); +``` + +## flags + +`(mailbox, messages, origin)` for flag changes of `control.setFlags()`, with only the messages whose flags changed. STORE commands of the sessions do not emit it, watch the `command` event for them. + +## acl + +`(mailbox, previousAcl)` when an ACL changes, from SETACL, DELETEACL or the control API, with the ACL plugin loaded. `previousAcl` is a `Map` of identifier to a `Set` of rights. Read the new ACL with `control.getAcl(mailbox.path)`. + +## script + +`{ rule, event, session, tag, command }` when a script rule fires: the rule as it was added, the event it watched (`greeting`, `command`, `input`, `response` ...), the session number, and the tag and command name of the command it belongs to. See [Scripted faults](../faults/scripted-faults.md). + +## reset + +No arguments. Emitted by `control.reset()` after the storage and users are restored. Plugins listen to it to restore their own state, and a long running test harness can use it to clear what it tracks. + +## Recipes + +### Wait for one event + +`events.once()` from Node.js resolves with the arguments of the next event. Add a filter for a particular event: + +```javascript +import { once } from 'node:events'; + +function waitFor(server, name, predicate = () => true) { + return new Promise(resolve => { + const listener = (...args) => { + if (predicate(...args)) { + server.removeListener(name, listener); + resolve(args[0]); + } + }; + server.on(name, listener); + }); +} + +const [opened] = await once(server, 'session'); // the next session event, whatever it is +``` + +Create the promise **before** you let the client act, so the event can not fire before the listener is in place. + +### Deliver a message while the client idles + +```javascript +const server = imapkit({ plugins: ['IDLE'] }); +const port = await server.start(); + +const idling = waitFor(server, 'session', event => event.type === 'waiting' && event.command === 'IDLE'); +// ... start the client, let it select INBOX and IDLE +await idling; +server.control.addMessage('INBOX', { raw: 'Subject: hello\r\n\r\nHi!\r\n' }); +// the idling client gets * 1 EXISTS right away +``` + +### Wait for the client to select a mailbox + +```javascript +const selected = waitFor(server, 'session', event => event.type === 'select' && event.session.mailbox === 'INBOX'); +// ... let the client open INBOX +const { session } = await selected; +server.control.expungeMessages('INBOX', [1]); // or anything the client must cope with while INBOX is open +``` + +### Wait for a command to finish + +```javascript +const stored = waitFor(server, 'command', event => event.command === 'UID STORE' && event.status === 'OK'); +// ... let the client mark a message as read +await stored; +assert.deepStrictEqual(server.control.getMessage('INBOX', 1, { raw: false }).flags, ['\\Seen']); +``` + +### Wait for a session to go away + +```javascript +const closed = waitFor(server, 'session', event => event.type === 'close' && event.session.session === 1); +server.control.disconnect(1, { reset: true }); +await closed; +``` diff --git a/docs/docs/control-api/mailboxes-and-messages.md b/docs/docs/control-api/mailboxes-and-messages.md new file mode 100644 index 0000000..6db415e --- /dev/null +++ b/docs/docs/control-api/mailboxes-and-messages.md @@ -0,0 +1,337 @@ +--- +title: Mailboxes and Messages +sidebar_position: 2 +description: Reference for the control API methods that inspect and change mailboxes and messages, with what connected IMAP sessions see for each change. +--- + +# Mailboxes and messages + +These methods of `server.control` read and change the message store. Mailboxes are [storage names](./overview.md#mailbox-names-are-storage-names), messages are UIDs. Errors are [`ImapKitError`s](./overview.md#imapkiterror) with a `code`. The examples on this page are real outputs from a server created with `imapkit()` and the plugins named in each example. + +## Inspection + +### snapshot() + +```typescript +snapshot(): Record +``` + +Returns the whole store as JSON data in the shape of the [`storage` option](../guides/storage.md): namespaces, mailboxes, messages with their UIDs, flags and internal dates, UIDNEXT and UIDVALIDITY values, and subscriptions. A new server created from it starts from the same state: + +```javascript +const copy = imapkit({ storage: server.control.snapshot(), plugins: ['CONDSTORE'] }); +``` + +Message sources are binary strings, as in the storage option. Plugin data on mailboxes and messages is included as far as it is JSON data, for example `MODSEQ` and `HIGHESTMODSEQ` of CONDSTORE, `acl` of ACL, `metadata` of METADATA and `special-use` of SPECIAL-USE. Subscriptions of names that are not mailboxes (a subscription outlives DELETE) are not part of it. + +Use it to write a failing test's state to a file, or to compare the whole store with an expected one after the client ran. + +### listMailboxes() + +```typescript +listMailboxes(): MailboxInfo[] +``` + +Describes every mailbox, ordered by name, including `\Noselect` hierarchy levels. + +### getMailbox(path) + +```typescript +getMailbox(path: string): MailboxInfo +``` + +Describes one mailbox, also a `\Noselect` level. Throws `NONEXISTENT` for a name that is not a mailbox. + +A `MailboxInfo` has these fields: + +| Field | Type | Description | +| ---------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `path` | `string` | storage name | +| `delimiter` | `string` | hierarchy delimiter of the namespace | +| `flags` | `string[]` | mailbox attributes as LIST shows them, e.g. `\Noselect`, `\HasChildren`, `\HasNoChildren` | +| `selectable` | `boolean` | false for a `\Noselect` level that only holds child mailboxes | +| `subscribed` | `boolean` | whether the name is subscribed | +| `messages` | `number` | number of messages | +| `unseen` | `number` | number of messages without `\Seen` | +| `uidnext` | `number` | the next UID | +| `uidvalidity` | `number` | the UIDVALIDITY | +| `permanentFlags` | `string[]` | the flags the mailbox defines, the list SELECT sends in `* FLAGS` (system flags and keywords that are in use). It has no `\*`, even when SELECT's PERMANENTFLAGS allows new keywords | +| `highestModseq` | `number` | only with CONDSTORE (or QRESYNC) loaded | +| `specialUse` | `string[]` | only with SPECIAL-USE loaded, see [Plugin operations](./plugin-operations.md#special-use) | +| `mailboxId` | `string` | only with OBJECTID loaded, not for `\Noselect` levels | + +```javascript +server.control.getMailbox('INBOX'); +``` + +```javascript +{ + path: 'INBOX', + delimiter: '/', + flags: [ '\\HasNoChildren' ], + selectable: true, + subscribed: true, + messages: 1, + unseen: 0, + uidnext: 2, + uidvalidity: 1, + permanentFlags: [ '\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen' ], + highestModseq: 3 +} +``` + +### listMessages(path, options) + +```typescript +listMessages(path: string, options?: { uids?: number[]; raw?: boolean }): MessageInfo[] +``` + +| Parameter | Description | +| -------------- | --------------------------------------------------------------- | +| `path` | storage name of a selectable mailbox | +| `options.uids` | only these UIDs. Every UID must exist (`NONEXISTENT` otherwise) | +| `options.raw` | true includes the message source in `raw`. Default `false` | + +Returns the messages ordered by UID. Throws `NONEXISTENT` for a mailbox that does not exist or is `\Noselect`. + +### getMessage(path, uid, options) + +```typescript +getMessage(path: string, uid: number, options?: { raw?: boolean }): MessageInfo +``` + +Describes one message. Unlike `listMessages()`, the source is included unless `raw` is `false`. + +A `MessageInfo` has these fields: + +| Field | Type | Description | +| -------------- | ---------- | ----------------------------------------------------- | +| `uid` | `number` | UID | +| `flags` | `string[]` | flags, without `\Recent` (which belongs to a session) | +| `internaldate` | `string` | internal date as an RFC 3501 date-time string | +| `size` | `number` | size of the source in octets (RFC822.SIZE) | +| `modseq` | `number` | only with CONDSTORE (or QRESYNC) loaded | +| `emailId` | `string` | only with OBJECTID loaded | +| `threadId` | `string` | only with OBJECTID loaded | +| `raw` | `Buffer` | the source, only when asked for | + +```javascript +server.control.getMessage('INBOX', 1); +``` + +```javascript +{ + uid: 1, + flags: [ '\\Seen', '\\Flagged' ], + internaldate: '08-Oct-2026 18:20:56 +0300', + size: 23, + modseq: 3, + raw: +} +``` + +## Messages + +### addMessage(path, message, options) + +```typescript +addMessage( + path: string, + message: { raw: string | Uint8Array; flags?: string[]; internaldate?: Date | string }, + options?: { checks?: boolean } +): { uid: number; uidvalidity: number } +``` + +Adds a message like a delivery from outside. + +| Parameter | Description | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `path` | storage name of a selectable mailbox | +| `message.raw` | the message source. A string is encoded as UTF-8, a `Buffer` or `Uint8Array` is stored as it is (use it for 8-bit or binary data) | +| `message.flags` | flags of the new message, default none | +| `message.internaldate` | a `Date` or an RFC 3501 date-time string such as `"17-Jul-1996 02:44:25 -0700"`, default the current time | +| `options.checks` | true runs the checks APPEND runs and refuses the message like APPEND would. Default `false` | + +Returns the new UID and the UIDVALIDITY of the mailbox. A `Date` is stored in the local time zone of the process, a string is kept as it is. + +**What sessions see:** sessions that have the mailbox selected get `* n EXISTS`. The first read-write session that has it selected sees the message as `\Recent`, like a delivery. + +```javascript +const server = imapkit({ plugins: ['IDLE'] }); +// ... the client logs in, selects INBOX and starts IDLE +server.control.addMessage('INBOX', { raw: 'Subject: three\r\n\r\nz\r\n' }); +// the idling client receives: * 2 EXISTS +``` + +**The `checks` option.** Without it, the message is added whatever the limits are, which is how you fill a mailbox above its quota. With `checks: true` the checks of the loaded plugins run first: QUOTA refuses a message over a hard limit with `OVERQUOTA`, APPENDLIMIT one over the limit with `TOOBIG`. A soft quota does not refuse anything. + +```javascript +const server = imapkit({ plugins: ['QUOTA'], quota: { STORAGE: 1 } }); +server.control.addMessage('INBOX', { raw: 'x'.repeat(5000) }, { checks: true }); +// ImapKitError OVERQUOTA: Quota exceeded +server.control.addMessage('INBOX', { raw: 'x'.repeat(5000) }); +// { uid: 1, uidvalidity: 1 } +``` + +**Errors:** `NONEXISTENT` for a missing or `\Noselect` mailbox, `INVALID` for an empty or missing source, a flag STORE would refuse (such as `\Recent` or an unknown system flag) or an internal date in another format, and the code of a failed check. + +### setFlags(path, uids, flags, mode) + +```typescript +setFlags(path: string, uids: number[], flags: string[], mode?: 'set' | 'add' | 'remove'): { uid: number; flags: string[] }[] +``` + +Changes the flags of messages, like `STORE FLAGS`, `+FLAGS` and `-FLAGS`. + +| `mode` | Effect | +| --------------- | ------------------------------------------------------------ | +| `set` (default) | replaces the flags of every message with `flags` | +| `add` | adds `flags` | +| `remove` | removes `flags`. Flags that are not permanent may be removed | + +Returns `{ uid, flags }` of every listed message, in the order of `uids`. + +**What sessions see:** sessions that have the mailbox selected get an unsolicited FETCH with the UID and the new flags, for messages whose flags actually changed. After `ENABLE CONDSTORE` the response also has the new `MODSEQ`, as every changed message gets a new mod-sequence. The session's own `\Recent` is part of the FLAGS list it sees: + +``` +* 1 FETCH (UID 1 FLAGS (\Seen \Flagged \Recent) MODSEQ (3)) +``` + +**Errors:** `NONEXISTENT` for a missing mailbox or UID, `INVALID` for a UID list that is not positive integers, an unknown `mode` (`Invalid flag mode "toggle", expected set, add or remove`), or a flag STORE would refuse. + +### expungeMessages(path, uids) + +```typescript +expungeMessages(path: string, uids: number[]): number[] +``` + +Removes messages, whatever their flags are. Returns the removed UIDs. + +**What sessions see:** `* n EXPUNGE` for every message and then `* n EXISTS` with the new count. After `ENABLE QRESYNC` a session gets `* VANISHED` with the UIDs instead of the EXPUNGE responses. The [RFC 2180](https://www.rfc-editor.org/rfc/rfc2180) rules apply as for an EXPUNGE in another session, see [Multiple sessions](../guides/multiple-sessions.md). + +``` +* 1 EXPUNGE +* 0 EXISTS +``` + +With QRESYNC enabled, an idling session receives: + +``` +* VANISHED 1 +* 1 EXISTS +``` + +**Errors:** `NONEXISTENT` for a missing mailbox or UID, `INVALID` for a UID list that is not positive integers. + +### copyMessages(path, uids, target) + +```typescript +copyMessages(path: string, uids: number[], target: string): { uidvalidity: number; uids: { uid: number; targetUid: number }[] } +``` + +Copies messages to another mailbox, in UID order like COPY. The copies keep the flags and internal date and get new UIDs in the target. Returns the UIDVALIDITY of the target and the new UID of every message, the same information COPYUID carries. + +**What sessions see:** sessions that have the target selected get `EXISTS`. + +```javascript +server.control.copyMessages('INBOX', [2, 1], 'Archive/2024'); +// { uidvalidity: 3, uids: [ { uid: 1, targetUid: 1 }, { uid: 2, targetUid: 2 } ] } +``` + +**Errors:** `NONEXISTENT` for a missing source, target or UID. + +### moveMessages(path, uids, target) + +```typescript +moveMessages(path: string, uids: number[], target: string): { uidvalidity: number; uids: { uid: number; targetUid: number }[] } +``` + +Copies the messages like `copyMessages()` and then expunges them from the source. The return value has the same shape. It does not need the MOVE plugin. + +**What sessions see:** `EXISTS` in the target, `EXPUNGE` (or `VANISHED`) and `EXISTS` in the source. + +### replaceMessage(path, uid, message) + +```typescript +replaceMessage(path: string, uid: number, message: { raw: string | Uint8Array; flags?: string[]; internaldate?: Date | string }): { uid: number; uidvalidity: number } +``` + +Replaces a message with a new source. The content of a UID never changes ([RFC 9051 section 2.3.1.1](https://www.rfc-editor.org/rfc/rfc9051#section-2.3.1.1)), so this adds the new message and expunges the old one, and the new message gets a new UID. Flags and internal date of the old message are kept unless `message` gives new ones. Returns the new UID. + +**What sessions see:** `EXISTS` for the new message, then `EXPUNGE` (or `VANISHED`) and `EXISTS` for the old one: + +``` +* 4 EXISTS +* 1 EXPUNGE +* 3 EXISTS +``` + +**Errors:** as `addMessage()` (without the checks) and `NONEXISTENT` for a UID that does not exist. + +## Mailboxes + +### createMailbox(path, options) + +```typescript +createMailbox(path: string, options?: { subscribed?: boolean }): MailboxInfo +``` + +Creates a mailbox like CREATE, with any missing superior levels as normal mailboxes. Creating the name of a `\Noselect` level turns it into a normal mailbox. `subscribed: true` subscribes the new mailbox (not the superior levels). Returns the new mailbox's `MailboxInfo`. + +```javascript +server.control.createMailbox('Archive/2024', { subscribed: true }); +// creates Archive and Archive/2024, the mailbox event lists created: [ 'Archive', 'Archive/2024' ] +``` + +**What sessions see:** nothing unsolicited, except NOTIFY sessions that asked for mailbox events. With ACL loaded, a new mailbox gets the ACL of its parent. + +**Errors:** `INVALID` for a name that is not valid modified UTF-7, `ALREADYEXISTS` for an existing mailbox (also `INBOX`). + +### deleteMailbox(path) + +```typescript +deleteMailbox(path: string): void +``` + +Deletes a mailbox like DELETE. A mailbox with children stays as a `\Noselect` level. Subscriptions stay, as subscriptions are names. + +**What sessions see:** sessions that have the mailbox selected get `* BYE Selected mailbox was deleted` and are disconnected, since they can not be told about the deletion any other way ([RFC 2180 section 3.3](https://www.rfc-editor.org/rfc/rfc2180#section-3.3)). + +**Errors:** `NONEXISTENT` for a name that is not a mailbox, `CANNOT` for INBOX, `HASCHILDREN` for a `\Noselect` level that still has children. + +### renameMailbox(path, newPath) + +```typescript +renameMailbox(path: string, newPath: string): MailboxInfo +``` + +Renames a mailbox like RENAME, with its children. Returns the renamed mailbox's `MailboxInfo`. Sessions that have the mailbox selected stay connected. Subscriptions stay with the old names. + +Renaming INBOX follows [RFC 3501 section 6.3.5](https://www.rfc-editor.org/rfc/rfc3501#section-6.3.5): the messages of INBOX move to a new mailbox with the new name, which gets a new UIDVALIDITY, and INBOX stays, empty. Sessions that have INBOX selected get `EXPUNGE` (or `VANISHED`) for every message. + +```javascript +server.control.renameMailbox('INBOX', 'FromInbox'); +// { path: 'FromInbox', ..., messages: 1, uidnext: 2, uidvalidity: 4, ... } +``` + +**Errors:** `NONEXISTENT` for a source that does not exist or is `\Noselect`, `ALREADYEXISTS` for a target that exists, `CANNOT` for a rename into the mailbox itself (`Can not move a mailbox into itself`), `INVALID` for a new name that is not valid modified UTF-7. + +### subscribe(path) + +```typescript +subscribe(path: string): boolean +``` + +Subscribes a mailbox. Returns `true` if the subscription changed, `false` if it was subscribed already. Throws `NONEXISTENT` for a name that is not a selectable mailbox. + +### unsubscribe(path) + +```typescript +unsubscribe(path: string): boolean +``` + +Removes a name from the subscriptions. The name does not have to be a mailbox, so a subscription left behind by a deleted or renamed mailbox can be removed. Returns `true` if the subscription changed, `false` if the name was not subscribed (not an error, as in [RFC 9051 section 6.3.8](https://www.rfc-editor.org/rfc/rfc9051#section-6.3.8)). + +## Events + +The mailbox operations emit the `mailbox` event, `setFlags()` the `flags` event, and every operation that removes messages the `expunge` event, all with `origin: null`. `addMessage()` and `copyMessages()` emit no event, and `replaceMessage()` only the `expunge` event of the old message. See [Events](./events.md). diff --git a/docs/docs/control-api/overview.md b/docs/docs/control-api/overview.md new file mode 100644 index 0000000..4e45e3b --- /dev/null +++ b/docs/docs/control-api/overview.md @@ -0,0 +1,124 @@ +--- +title: Control API Overview +sidebar_position: 1 +description: What server.control is, how its changes reach connected IMAP sessions, how arguments are checked, and the ImapKitError codes it throws. +--- + +# Control API overview + +`server.control` inspects and changes the server from your test code, without an IMAP session. Use it to deliver a message while the client idles, flip flags behind the client's back, delete the mailbox the client has selected, or reset UIDVALIDITY to test a resync. Every method is synchronous except `shutdown()`, returns plain data, and throws an `ImapKitError` when an argument is wrong. + +```javascript +import imapkit from 'imapkit'; + +const server = imapkit({ plugins: ['IDLE', 'CONDSTORE'] }); +const port = await server.start(); + +const { uid } = server.control.addMessage('INBOX', { raw: 'Subject: hello\r\n\r\nHi!\r\n', flags: ['\\Seen'] }); +server.control.setFlags('INBOX', [uid], ['\\Flagged'], 'add'); +server.control.expungeMessages('INBOX', [uid]); + +await server.stop(); +``` + +The same operations are available over HTTP through the [REST API](../rest-api/overview.md), for test suites that are not written in JavaScript. + +## Changes look like another session's + +The control API changes the shared store the same way an IMAP command from another session would, so the connected sessions learn about it through the normal protocol: + +| Change | What a session that has the mailbox selected sees | +| ----------------------------------------- | ------------------------------------------------------------------------------ | +| a new message (`addMessage`, copy, move) | `* n EXISTS`, and the message is `\Recent` for the first read-write session | +| removed messages (expunge, move, replace) | `* n EXPUNGE` and a new `EXISTS`, or `* VANISHED` after `ENABLE QRESYNC` | +| changed flags (`setFlags`) | `* n FETCH (UID u FLAGS (...))`, with `MODSEQ` after `ENABLE CONDSTORE` | +| the mailbox is deleted | `* BYE Selected mailbox was deleted`, and the connection closes | +| new UIDVALIDITY (`resetUidValidity`) | `* BYE UIDVALIDITY of the selected mailbox changed`, and the connection closes | + +A session in IDLE gets these responses right away. Any other session gets them before the tagged response of its next command (`NOOP` is the usual way to ask), following the same rules as changes from a real second session: FETCH, STORE, SEARCH, SORT and THREAD hold back EXPUNGE responses (RFC 3501 section 7.4.1), and messages expunged while a session can not be told stay in its view until it can. See [Multiple sessions](../guides/multiple-sessions.md) for the details. Sessions that use NOTIFY or CONTEXT=SEARCH get their updates too. + +Every change has no session as its **origin**. Inside the server, a change made by a command carries the session that ran it, so that, for example, NOTIFY does not report a session's own changes back to it. A control API change has `origin: null`, which means every session is told, including one that is running a command at that moment. The `mailbox`, `expunge` and `flags` [events](./events.md) carry the same `origin` value, `null` for the control API. This also holds when you call the control API from inside an event listener that fires during a command. + +```mermaid +sequenceDiagram + participant T as Test code + participant C as server.control + participant S as Store + participant A as Session 1 (IDLE in INBOX) + participant B as Session 2 (INBOX selected) + T->>C: addMessage('INBOX', { raw }) + C->>S: append, origin null + S-->>A: * 1 EXISTS (right away) + S-->>B: queued + C-->>T: { uid: 1, uidvalidity: 1 } + B->>S: A5 NOOP + S-->>B: * 1 EXISTS + S-->>B: A5 OK +``` + +## ACL does not apply + +The control API is the operator, not a user. The [ACL](../extensions/access-control.md) plugin does not check its calls, so a test can add a message to a mailbox no user may write to, or read one no user may see. Use `setAcl()` to change what the IMAP users may do. + +## Every argument is checked + +The control API refuses what the IMAP commands would refuse, before anything changes: + +- Mailbox names must be non-empty strings. A new name (`createMailbox`, `renameMailbox`) must be valid modified UTF-7. +- UIDs must be an array of positive integers, and every UID must exist in the mailbox. Duplicates are ignored. +- Flags follow the STORE rules: system flags the server knows (never `\Recent`), valid keywords, and in a mailbox that does not allow new keywords only its permanent flags. +- A message source must be a non-empty string or `Buffer`/`Uint8Array`, an internal date a `Date` or an RFC 3501 date-time string. +- Operations on mailboxes follow the CREATE, DELETE and RENAME rules: INBOX can not be created or deleted, a mailbox with children becomes a `\Noselect` level when deleted, a mailbox can not be renamed into itself. + +## ImapKitError + +Every refused call throws an `ImapKitError`. Its `name` is `"ImapKitError"`, `message` is a readable explanation and `code` is one of: + +| `code` | Meaning | REST status | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `NONEXISTENT` | the mailbox, message UID, user, session or ACL entry does not exist | 404 | +| `ALREADYEXISTS` | the mailbox or user exists already | 409 | +| `INVALID` | an argument has the wrong type or value | 400 | +| `TOOBIG` | `addMessage` with `checks: true` refused the message because of [APPENDLIMIT](../extensions/messages.md) | 413 | +| other codes | the IMAP response code of a failed operation, such as `CANNOT` and `OVERQUOTA` ([RFC 5530](https://www.rfc-editor.org/rfc/rfc5530)) or `HASCHILDREN` ([RFC 9051](https://www.rfc-editor.org/rfc/rfc9051)) | 409 | + +The class is exported by the package, so a test can check for it: + +```javascript +import imapkit, { ImapKitError } from 'imapkit'; + +const server = imapkit(); +try { + server.control.addMessage('Nope', { raw: 'x' }); +} catch (err) { + console.log(err instanceof ImapKitError, err.code, err.message); + // true NONEXISTENT Mailbox "Nope" does not exist +} +``` + +The CommonJS build has it too: `require('imapkit').ImapKitError`. + +## Mailbox names are storage names + +Mailboxes are addressed by their **storage name**: the name in modified UTF-7 ([RFC 3501 section 5.1.3](https://www.rfc-editor.org/rfc/rfc3501#section-5.1.3)) with the hierarchy delimiter of its namespace, the same name a client sees in `LIST` before `ENABLE UTF8=ACCEPT`. A mailbox called `Café` is `Caf&AOk-`, a child of `Work` is `Work/Projects` with the default `/` delimiter. `INBOX` matches in any case. Names with non-ASCII characters are refused: + +```javascript +server.control.createMailbox('Café'); +// ImapKitError INVALID: Mailbox name must use modified UTF-7 for non-ASCII characters (RFC 3501 section 5.1.3) +server.control.createMailbox('Caf&AOk-').path; +// 'Caf&AOk-' +``` + +The [Storage](../guides/storage.md) guide describes namespaces and delimiters. + +## Messages are addressed by UID + +Messages are always identified by mailbox and UID, never by sequence number, since sequence numbers belong to a session. Methods that create messages return the new UID together with the UIDVALIDITY of the mailbox, so a test can compare them with what the client reports. + +## Where to go next + +- [Mailboxes and messages](./mailboxes-and-messages.md): inspect and change the store. +- [UIDVALIDITY](./uidvalidity.md): reset UIDVALIDITY and renumber UIDs to test resync logic. +- [Users and sessions](./users-and-sessions.md): accounts, connected sessions, disconnects, raw output, reset and shutdown. +- [Events](./events.md): wait for "the client selected INBOX" instead of polling. +- [Plugin operations](./plugin-operations.md): ACL, QUOTA, METADATA, SPECIAL-USE, OBJECTID and operations of your own plugins. diff --git a/docs/docs/control-api/plugin-operations.md b/docs/docs/control-api/plugin-operations.md new file mode 100644 index 0000000..66e8ebc --- /dev/null +++ b/docs/docs/control-api/plugin-operations.md @@ -0,0 +1,248 @@ +--- +title: Plugin Operations +sidebar_position: 6 +description: Control API operations of the ACL, QUOTA, METADATA, SPECIAL-USE and OBJECTID plugins, and how a plugin of your own adds operations, REST routes and fields. +--- + +# Plugin operations + +Plugins add control API methods for their own data. A method exists only while its plugin is loaded, so `server.control.setAcl` is `undefined` without the ACL plugin, and its REST routes answer 404. All of them follow the rules of the [overview](./overview.md): no session as the origin, ACL does not apply, and refused arguments throw an `ImapKitError`. + +| Plugin | Methods and fields | +| ------------------------- | ----------------------------------------------------------------------- | +| ACL | `getAcl()`, `setAcl()`, `deleteAcl()` | +| QUOTA | `getQuota()`, `setQuota()` | +| METADATA, METADATA-SERVER | `getMetadata()`, `setMetadata()` | +| SPECIAL-USE | `setSpecialUse()`, `specialUse` in `MailboxInfo` | +| OBJECTID | `mailboxId` in `MailboxInfo`, `emailId` and `threadId` in `MessageInfo` | + +## ACL + +ACLs are objects of identifier to rights string, the same form as the `acl` property in the [storage option](../guides/storage.md). See [Access control](../extensions/access-control.md) for how the rights apply to sessions. Changes take effect for the next command of every session, and emit the [`acl` event](./events.md#acl). + +### getAcl(path) + +```typescript +getAcl(path: string): Record +``` + +Returns the ACL of a mailbox, `{}` when it has none. + +### setAcl(path, identifier, rights) + +```typescript +setAcl(path: string, identifier: string, rights: string): Record +``` + +Sets the rights of an identifier like SETACL ([RFC 4314 section 3.1](https://www.rfc-editor.org/rfc/rfc4314#section-3.1)): `rights` replaces the identifier's rights, `+rights` adds to them, `-rights` removes from them, and an identifier left without rights is removed. Returns the new ACL. + +```javascript +const server = imapkit({ plugins: ['ACL'] }); +server.control.createMailbox('Shared'); +server.control.setAcl('Shared', 'alice', 'lrs'); // { alice: 'lrs' } +server.control.setAcl('Shared', 'alice', '+w'); // { alice: 'lrsw' } +server.control.setAcl('Shared', 'alice', '-s'); // { alice: 'lrw' } +server.control.setAcl('Shared', 'bob', 'lr'); // { alice: 'lrw', bob: 'lr' } +server.control.setAcl('Shared', 'bob', ''); // { alice: 'lrw' } +``` + +**Errors:** `NONEXISTENT` for a missing mailbox, `INVALID` for an empty identifier, rights that are not a string, or rights RFC 4314 does not define (`Uppercase rights are not allowed`). + +### deleteAcl(path, identifier) + +```typescript +deleteAcl(path: string, identifier: string): Record +``` + +Removes an identifier from the ACL like DELETEACL. Returns the new ACL. + +**Errors:** `NONEXISTENT` for a missing mailbox or an identifier that is not in the ACL. + +A new mailbox, from CREATE or `createMailbox()`, gets the ACL of its parent. A deleted mailbox loses its ACL, also when it stays as a `\Noselect` level. The [snapshot](./mailboxes-and-messages.md#snapshot) keeps ACLs in the storage form. + +## QUOTA + +There is one quota root for the user's mailboxes, named `User quota` unless the `quota.root` option says otherwise. See [Metadata and quota](../extensions/metadata-and-quota.md). + +### getQuota() + +```typescript +getQuota(): { root: string; limits: { STORAGE?: number; MESSAGE?: number; MAILBOX?: number }; usage: { STORAGE: number; MESSAGE: number; MAILBOX: number } } +``` + +Returns the quota root, its limits and the current usage. STORAGE is in units of 1024 octets, rounded up, as in GETQUOTA. A resource without a limit is not in `limits`. + +```javascript +const server = imapkit({ plugins: ['QUOTA'], quota: { STORAGE: 10 } }); +server.control.getQuota(); +// { root: 'User quota', limits: { STORAGE: 10 }, usage: { STORAGE: 0, MESSAGE: 0, MAILBOX: 1 } } +``` + +### setQuota(limits) + +```typescript +setQuota(limits: { STORAGE?: number; MESSAGE?: number; MAILBOX?: number }): QuotaInfo +``` + +Replaces all limits, like SETQUOTA ([RFC 9208 section 4.1.3](https://www.rfc-editor.org/rfc/rfc9208#section-4.1.3)): a resource that is not given has no limit afterwards, `setQuota({})` removes every limit. Resource names are case-insensitive. Returns the same object as `getQuota()`. `control.reset()` restores the limits of the `quota` option. + +The new limits apply to the next APPEND, COPY, MOVE or CREATE. The control API itself ignores them unless `addMessage()` is called with `checks: true`, so a test can fill a mailbox above its quota and then check how the client handles `NO [OVERQUOTA]`: + +```javascript +server.control.addMessage('INBOX', { raw: 'x'.repeat(3000) }); +server.control.setQuota({ STORAGE: 100, MESSAGE: 5 }); +// { root: 'User quota', limits: { STORAGE: 100, MESSAGE: 5 }, usage: { STORAGE: 3, MESSAGE: 1, MAILBOX: 2 } } +``` + +**Errors:** `INVALID` for an argument that is not an object, an unknown resource (`Unknown quota resource FOO, expected STORAGE, MESSAGE, MAILBOX`) or a limit that is not a non-negative integer. + +## METADATA + +Annotations ([RFC 5464](https://www.rfc-editor.org/rfc/rfc5464)) are objects of entry name to value. Values are UTF-8 text. The mailbox `""` stands for the server annotations. With METADATA-SERVER only the server annotations exist, the methods answer `INVALID` for any mailbox. See [Metadata and quota](../extensions/metadata-and-quota.md). + +### getMetadata(path) + +```typescript +getMetadata(path: string): Record +``` + +Returns the stored annotations of a mailbox, or of the server for `""`. + +### setMetadata(path, values) + +```typescript +setMetadata(path: string, values: Record): Record +``` + +Sets annotations: a string sets an entry, `null` removes it. Entry names must start with `/private/` or `/shared/` and follow the RFC 5464 rules, and the limits of the plugin apply. As the operator, the control API can also set entries that clients can not, like `/shared/admin` of the server. Returns all annotations of the mailbox or server afterwards. + +**What sessions see:** sessions that have sent `ENABLE METADATA` get an unsolicited METADATA response that lists the changed entries, with their next command: + +```javascript +server.control.setMetadata('INBOX', { '/private/comment': 'hello', '/shared/comment': 'shared' }); +// a session after ENABLE METADATA receives: * METADATA INBOX /private/comment /shared/comment +server.control.setMetadata('', { '/shared/comment': 'srv' }); +// * METADATA "" /shared/comment +``` + +`control.reset()` restores the server annotations of the options. Deleting a mailbox drops its annotations, renaming INBOX copies them to the new mailbox. + +**Errors:** `NONEXISTENT` for a missing mailbox, `INVALID` for an argument that is not an object, an entry name the RFC does not allow (`Entry name must begin with /private or /shared`), a value that is neither a string nor null, or a mailbox with METADATA-SERVER. A value over the size limit, too many annotations, or a `/private` entry when private annotations are turned off also fail with `INVALID`. A computed entry, like `/private/specialuse` of SPECIAL-USE, fails with `CANNOT`. + +## SPECIAL-USE + +### setSpecialUse(path, uses) + +```typescript +setSpecialUse(path: string, uses: string[]): MailboxInfo +``` + +Replaces the special-use attributes of a mailbox ([RFC 6154](https://www.rfc-editor.org/rfc/rfc6154)): `\All`, `\Archive`, `\Drafts`, `\Flagged`, `\Junk`, `\Sent`, `\Trash` or `\Important` ([RFC 8457](https://www.rfc-editor.org/rfc/rfc8457)). Duplicates are dropped, `[]` removes them all. Returns the mailbox's `MailboxInfo`, whose `specialUse` field lists them. LIST shows them from the next command: + +```javascript +server.control.setSpecialUse('Shared', ['\\Archive', '\\Archive']).specialUse; +// [ '\\Archive' ] +// LIST "" "*" now answers: * LIST (\HasNoChildren \Archive) "/" "Shared" +``` + +With SPECIAL-USE loaded, every `MailboxInfo` has `specialUse`, an empty array for a mailbox without attributes. + +**Errors:** `NONEXISTENT` for a missing or `\Noselect` mailbox, `INVALID` for a list with an unknown attribute or an argument that is not a list. + +## OBJECTID + +OBJECTID ([RFC 8474](https://www.rfc-editor.org/rfc/rfc8474)) adds read-only fields, so a test can compare the ids a client stores with the server's: + +| Field | In | Description | +| ----------- | ------------- | -------------------------------------------------------------------- | +| `mailboxId` | `MailboxInfo` | MAILBOXID of the mailbox, e.g. `F2`. Not set for a `\Noselect` level | +| `emailId` | `MessageInfo` | EMAILID of the message, e.g. `M1`. A copy keeps the EMAILID | +| `threadId` | `MessageInfo` | THREADID of the message, e.g. `T1` | + +```javascript +const server = imapkit({ plugins: ['OBJECTID'] }); +server.control.addMessage('INBOX', { raw: 'x'.repeat(3000) }); +server.control.listMessages('INBOX'); +// [ { uid: 1, flags: [], internaldate: '...', size: 3000, emailId: 'M1', threadId: 'T1' } ] +``` + +## Operations of your own plugins + +A [custom plugin](../reference/custom-plugins.md) can extend the control API the same way the built-in plugins do. It adds nothing while it is not loaded. + +### control.register(name, fn, routes) + +```typescript +register(name: string, fn: (...args: any[]) => unknown, routes?: ControlRoute[]): void +``` + +Adds `server.control[name]`, which calls `fn` with the same arguments, with no session as the origin of the changes it makes. Throws an `Error` if the name is taken. `fn` should throw `ImapKitError`s with a fitting `code`, so that both the control API and the REST API report errors the usual way. + +`routes` are REST routes for the operation, served after the built-in ones and listed in `GET /v1/openapi.json`: + +| Field | Description | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `method` | `GET`, `POST`, `PUT` or `DELETE` | +| `path` | URL path, `{name}` matches one URL encoded path segment, e.g. `/v1/mailboxes/{path}/note` | +| `summary` | one line for the OpenAPI document | +| `handler` | `({ params, query, body }) => result`: `params` are the decoded path parameters, `query` a `URLSearchParams`, `body` the parsed JSON body (`{}` without one). The result is sent as JSON with status 200, return `{ status, body }` for another status | + +### Plugin data in mailbox and message info + +| Array | Function | Use | +| ----------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `control.mailboxInfoHandlers` | `(mailbox, info) => void` | add fields to the `MailboxInfo` of `getMailbox()`, `listMailboxes()` and the operations that return one | +| `control.messageInfoHandlers` | `(message, info) => void` | add fields to the `MessageInfo` of `getMessage()` and `listMessages()` | +| `control.snapshotHandlers` | `(mailbox, copy) => void` | put plugin data that is not JSON (a `Map`, a `Set`) into the snapshot of a mailbox, in the form the storage option takes | + +Plugin properties on mailbox and message objects that are JSON data are copied into the snapshot without any help. + +### Example + +A plugin that keeps a note per mailbox: + +```javascript +import imapkit, { ImapKitError } from 'imapkit'; + +function notesPlugin(server) { + server.control.mailboxInfoHandlers.push((mailbox, info) => { + info.note = mailbox.note || null; + }); + server.control.register( + 'setNote', + (path, note) => { + const mailbox = server.control.requireMailbox(path); + if (typeof note !== 'string') { + throw new ImapKitError('Note must be a string', 'INVALID'); + } + mailbox.note = note; + return server.control.getMailbox(mailbox.path); + }, + [ + { + method: 'PUT', + path: '/v1/mailboxes/{path}/note', + summary: 'Sets the note of a mailbox', + handler: ({ params, body }) => server.control.setNote(params.path, body.note) + } + ] + ); +} + +const server = imapkit({ plugins: [notesPlugin], rest: { port: 0 } }); +await server.start(); +server.control.setNote('INBOX', 'hello').note; // 'hello' +``` + +`control.requireMailbox(path, selectable)` finds a mailbox by storage name and throws `NONEXISTENT` the way the built-in operations do, with `selectable: true` also for a `\Noselect` level. Over REST: + +```bash +curl -X PUT http://127.0.0.1:8143/v1/mailboxes/INBOX/note \ + -H 'Content-Type: application/json' -d '{"note": "via REST"}' +# {"path":"INBOX","delimiter":"/",...,"note":"via REST"} +curl -X PUT http://127.0.0.1:8143/v1/mailboxes/INBOX/note \ + -H 'Content-Type: application/json' -d '{"note": 5}' +# HTTP 400 {"error":{"code":"INVALID","message":"Note must be a string"}} +``` + +Since `mailbox.note` is a string, `control.snapshot()` includes it as `"note"`. diff --git a/docs/docs/control-api/uidvalidity.md b/docs/docs/control-api/uidvalidity.md new file mode 100644 index 0000000..3cbb75e --- /dev/null +++ b/docs/docs/control-api/uidvalidity.md @@ -0,0 +1,125 @@ +--- +title: UIDVALIDITY Resets +sidebar_position: 3 +description: Give a mailbox a new UIDVALIDITY and renumber, shuffle or shift its UIDs with resetUidValidity(), to test how a client resynchronizes its cache. +--- + +# UIDVALIDITY resets + +A client that caches messages by UID must check UIDVALIDITY every time it selects a mailbox. When the value changes, every cached UID is meaningless and the client has to throw its cache away and sync again ([RFC 3501 section 2.3.1.1](https://www.rfc-editor.org/rfc/rfc3501#section-2.3.1.1), [RFC 9051 section 2.3.1.1](https://www.rfc-editor.org/rfc/rfc9051#section-2.3.1.1)). Real servers do this after a mailbox is restored from a backup, rebuilt or migrated, which is rare enough that the code path often goes untested. `resetUidValidity()` makes it happen on demand. + +## resetUidValidity(path, options) + +```typescript +resetUidValidity( + path: string, + options?: { uidvalidity?: number; uids?: 'keep' | 'renumber' | 'shuffle' | 'offset'; offset?: number; seed?: number } +): { uidvalidity: number; uidnext: number; uids: { uid: number; newUid: number }[] } +``` + +| Option | Description | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `uidvalidity` | the new UIDVALIDITY. It must be an integer greater than the current value and below 2^32. Default: one above every UIDVALIDITY the server has used, so it is unique as well | +| `uids` | what happens to the UIDs: `keep` (default), `renumber`, `shuffle` or `offset`, see below | +| `offset` | extra gap for `offset`, a non-negative integer. Default `0` | +| `seed` | an integer that fixes the `shuffle` order, so a test is repeatable. Default: a random order | + +Returns the new UIDVALIDITY and UIDNEXT, and `uids`, which maps every old UID to its new one in the old order. + +**Errors:** `NONEXISTENT` for a missing or `\Noselect` mailbox, `INVALID` for a UIDVALIDITY that is not greater than the current one (`UIDVALIDITY must be an integer above 1 and below 2^32`), an unknown `uids` mode, a negative or fractional `offset` or a seed that is not an integer. + +## The UID modes + +The examples start from a mailbox with UIDs 1, 3 and 4 (UID 2 was expunged), UIDNEXT 5 and UIDVALIDITY 1. + +| Mode | New UIDs | Result of the example | +| ---------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `keep` | unchanged | `1→1, 3→3, 4→4`, UIDNEXT 5 | +| `renumber` | 1 to n in the current order, UIDNEXT n+1 | `1→1, 3→2, 4→3`, UIDNEXT 4 | +| `shuffle` | 1 to n in a random order (repeatable with `seed`), UIDNEXT n+1 | with `seed: 42`: `1→3, 3→1, 4→2`, UIDNEXT 4 | +| `offset` | every UID plus (old UIDNEXT - 1 + `offset`), UIDNEXT moves the same way | `1→5, 3→7, 4→8`, UIDNEXT 9. With `offset: 100`: `1→105, 3→107, 4→108`, UIDNEXT 109 | + +```javascript +server.control.resetUidValidity('INBOX', { uids: 'shuffle', seed: 42 }); +``` + +```javascript +{ + uidvalidity: 2, + uidnext: 4, + uids: [ { uid: 1, newUid: 3 }, { uid: 3, newUid: 1 }, { uid: 4, newUid: 2 } ] +} +``` + +With `shuffle`, the messages are reordered by their new UIDs, so the sequence numbers change too. The other modes keep the order. + +### Why each mode is useful + +Each mode catches a different kind of client bug: + +- **`keep`**: only UIDVALIDITY changes. A correct client still discards its cache and downloads everything again. A client that ignores UIDVALIDITY looks fine here, since its cached UIDs still point to the same messages. Use it to check that the client notices the change at all, for example by counting the FETCH commands it sends. +- **`renumber`**: UIDs become 1 to n, like a server that rebuilt its index. Cached UIDs above n find nothing, lower ones find other messages. Shows up clients that look up messages by a cached UID without checking UIDVALIDITY. +- **`shuffle`**: every old UID points to a different message of the same mailbox. A client that keeps its cache shows the wrong subject for a message, sets flags on the wrong message or deletes the wrong one, all without any error. This is the most dangerous case, use `seed` to make a failure repeatable. +- **`offset`**: every UID moves above the old UIDNEXT, so no old UID points to any message. A client that keeps its cache sees empty FETCH results for everything it knows, and every message looks new. + +## What sessions see + +A UID must not change during a session, so every session that has the mailbox selected is disconnected right away: + +``` +* BYE UIDVALIDITY of the selected mailbox changed +``` + +Sessions that have other mailboxes selected, or none, are not affected. The client has to reconnect and select the mailbox again, and then it sees the new value: + +``` +* OK [UIDVALIDITY 2] UIDs valid +* OK [UIDNEXT 4] Predicted next UID +``` + +## CONDSTORE and QRESYNC + +The mod-sequences of the messages stay as they are, they move with the messages to their new UIDs, and HIGHESTMODSEQ does not change. This matters for clients that use [CONDSTORE and QRESYNC](../extensions/synchronization.md): + +- A QRESYNC client that sends `SELECT INBOX (QRESYNC (1 3 1:4))` with the old UIDVALIDITY gets a plain SELECT answer, with no VANISHED and no FETCH responses, as [RFC 7162 section 3.2.5](https://www.rfc-editor.org/rfc/rfc7162#section-3.2.5) requires for a UIDVALIDITY that does not match (the server ignores the other parameters). The client must notice the new `UIDVALIDITY` in the response and do a full sync. +- A client that compares only HIGHESTMODSEQ with its cached value sees the same number as before and may conclude that nothing changed. [RFC 7162 section 3.1.2.1](https://www.rfc-editor.org/rfc/rfc7162#section-3.1.2.1) says the client MUST delete its cached HIGHESTMODSEQ when UIDVALIDITY changed. Testing with `shuffle` makes this bug visible: the client's cached flags and envelopes belong to other messages. + +A real session after a `shuffle` reset, reconnecting with QRESYNC and its old state: + +``` +A3 SELECT INBOX (QRESYNC (1 3 1:4)) +* FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +* OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted +* 3 EXISTS +* 0 RECENT +* OK [UNSEEN 1] First unseen message +* OK [UIDVALIDITY 2] UIDs valid +* OK [UIDNEXT 4] Predicted next UID +* OK [HIGHESTMODSEQ 6] Highest +A3 OK [READ-WRITE] Completed +``` + +## Example: test a client's resync + +```javascript +import imapkit from 'imapkit'; +import assert from 'node:assert'; + +const server = imapkit({ plugins: ['IDLE', 'QRESYNC'] }); +const port = await server.start(); +for (let i = 1; i <= 3; i++) { + server.control.addMessage('INBOX', { raw: `Subject: message ${i}\r\n\r\n${i}\r\n` }); +} + +// 1. the client syncs INBOX and caches it +// 2. the store changes under it +const { uidvalidity } = server.control.resetUidValidity('INBOX', { uids: 'shuffle', seed: 1 }); +// 3. the client was disconnected with BYE, it reconnects and syncs again +// 4. the client's view must match the server +const expected = server.control.listMessages('INBOX', { raw: true }); +// compare expected[i].uid and expected[i].raw with what the client shows, and the client's UIDVALIDITY with uidvalidity + +await server.stop(); +``` + +The same operation over HTTP is `POST /v1/mailboxes/{path}/uidvalidity`, see [REST endpoints](../rest-api/endpoints.md#reset-uidvalidity). diff --git a/docs/docs/control-api/users-and-sessions.md b/docs/docs/control-api/users-and-sessions.md new file mode 100644 index 0000000..402c7e9 --- /dev/null +++ b/docs/docs/control-api/users-and-sessions.md @@ -0,0 +1,195 @@ +--- +title: Users and Sessions +sidebar_position: 4 +description: Manage user accounts at runtime, list connected sessions, disconnect them, write raw bytes to them, reset the server between tests and shut it down. +--- + +# Users and sessions + +These methods of `server.control` manage accounts and connected sessions, and control the server's lifecycle. All users share the same mailbox tree, see [Authentication](../guides/authentication.md) for how users log in. + +## Users + +### listUsers() + +```typescript +listUsers(): { name: string; xoauth2: boolean }[] +``` + +Lists the users ordered by name, without credentials. `xoauth2` is true for a user with an access token for XOAUTH2 and OAUTHBEARER. + +```javascript +server.control.listUsers(); +// [ { name: 'testuser', xoauth2: true } ] +``` + +### addUser(name, options) + +```typescript +addUser(name: string, options: { password?: string; xoauth2?: { accessToken: string; sessionTimeout?: number } }): void +``` + +| Parameter | Description | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `name` | user name, a non-empty string | +| `options.password` | password for the LOGIN command and AUTHENTICATE PLAIN | +| `options.xoauth2.accessToken` | access token for XOAUTH2 and OAUTHBEARER | +| `options.xoauth2.sessionTimeout` | stored with the token in milliseconds, default 3600000. ImapKit does not expire tokens, so it has no effect on logins | + +**Errors:** `INVALID` for an empty name, a password that is not a string or an `xoauth2` without an `accessToken` string, `ALREADYEXISTS` for an existing user. + +```javascript +server.control.addUser('alice', { password: 'secret' }); +server.control.addUser('bob', { password: 'pw', xoauth2: { accessToken: 'tok' } }); +server.control.listUsers(); +// [ { name: 'alice', xoauth2: false }, { name: 'bob', xoauth2: true }, { name: 'testuser', xoauth2: true } ] +``` + +### updateUser(name, options) + +```typescript +updateUser(name: string, options: { password?: string; xoauth2?: { accessToken: string; sessionTimeout?: number } | null }): void +``` + +Changes the password or the access token of a user. Options that are not given stay as they are, `xoauth2: null` removes the token. Sessions that are logged in already stay logged in. Use it to test how a client handles a password change or an expired token on its next login. + +**Errors:** `NONEXISTENT` for an unknown user, `INVALID` as for `addUser()`. + +### deleteUser(name, options) + +```typescript +deleteUser(name: string, options?: { disconnect?: boolean }): void +``` + +Deletes a user. Its sessions are disconnected with `* BYE User was deleted`, unless `disconnect` is `false`. The mailboxes stay, as they are shared by all users. + +**Errors:** `NONEXISTENT` for an unknown user. + +## Sessions + +### sessions() + +```typescript +sessions(): SessionInfo[] +``` + +Describes the connected sessions, ordered by session number. Every connection gets a number when it is accepted, counting from 1 for the life of the server. A `SessionInfo` has these fields: + +| Field | Type | Description | +| --------------- | ---------------- | ---------------------------------------------------------------- | +| `session` | `number` | session number | +| `user` | `string \| null` | the logged in user, null before login | +| `state` | `string` | `Not Authenticated`, `Authenticated`, `Selected` or `Logout` | +| `mailbox` | `string \| null` | storage name of the selected mailbox | +| `readOnly` | `boolean` | true if the mailbox was opened with EXAMINE | +| `enabled` | `string[]` | extensions enabled with ENABLE, e.g. `CONDSTORE` | +| `secure` | `boolean` | true for a TLS connection (`secureConnection` or after STARTTLS) | +| `compressed` | `boolean` | true after COMPRESS DEFLATE | +| `remoteAddress` | `string \| null` | the client's address | + +```javascript +server.control.sessions(); +``` + +```javascript +[ + { + session: 1, + user: 'testuser', + state: 'Selected', + mailbox: 'INBOX', + readOnly: false, + enabled: ['CONDSTORE'], + secure: false, + compressed: false, + remoteAddress: '::ffff:127.0.0.1' + } +]; +``` + +The same objects are in the `session` field of the [session events](./events.md#session). + +### disconnect(filter, options) + +```typescript +disconnect(filter: number | { session?: number; user?: string }, options?: { text?: string; reset?: boolean }): number +``` + +Disconnects the sessions a filter selects: a session number, or `{ session, user }` (both must match when both are given). Returns how many sessions were disconnected, 0 if none matched. Sessions that are closing already are not counted. + +| Option | Description | +| ------- | --------------------------------------------------------------------------------------------------------------- | +| `text` | text of the untagged BYE, default `Disconnected by the server` | +| `reset` | true closes the TCP connection with a reset (RST) and sends nothing, like a crashed server or a dropped network | + +```javascript +server.control.disconnect(2, { text: 'Go away' }); +// session 2 receives: * BYE Go away +server.control.disconnect({ user: 'testuser' }, { reset: true }); +// the client sees ECONNRESET +``` + +**Errors:** `INVALID` for a filter with neither `session` nor `user`. + +### inject(session, data) + +```typescript +inject(session: number, data: string | Uint8Array): void +``` + +Writes bytes to a session as they are. A string is encoded as UTF-8. Nothing checks that the bytes are valid IMAP, so you can send anything: an `ALERT`, an unexpected `EXISTS`, a `BYE` without closing the connection, or broken syntax. + +```javascript +server.control.inject(1, '* OK [ALERT] Maintenance in 5 minutes\r\n'); +``` + +An idling client receives the line right away. Since the bytes are not tied to any command, prefer [scripted faults](../faults/scripted-faults.md) to change the response of a particular command. + +**Errors:** `NONEXISTENT` for a session that is not connected, `INVALID` for data that is neither a string nor bytes. + +## Server lifecycle + +### reset() + +```typescript +reset(): void +``` + +Restores the mailboxes and users of the server options and disconnects every session with `* BYE Server reset`. Plugins restore their own state on the `reset` [event](./events.md#reset): QUOTA its limits, METADATA its server annotations. Script rules stay, `server.script.clear()` removes them. + +Use it to reuse one long running server between tests, for example a server started with the `imapkit` command and reset with `POST /v1/reset` before every test. + +### shutdown(options) + +```typescript +shutdown(options?: { graceful?: boolean }): Promise +``` + +Stops the server. A graceful shutdown (the default) stops accepting connections, closes the SMTP and REST listeners, and resolves once the last client has disconnected on its own. `graceful: false` closes every session right away. + +```javascript +const done = server.control.shutdown(); +// new connections are refused, the connected client logs out +await done; +``` + +### server.start(port, host) and server.stop() + +These are methods of the server itself, not of `server.control`: + +```typescript +server.start(port?: number, host?: string): Promise +server.stop(): Promise +``` + +`start()` listens on `port` (a free port when it is not set) and `host` (all addresses when it is not set), starts the SMTP listener of the `smtp` option and the REST API of the `rest` option, and resolves with the IMAP port. It rejects if the port is taken. `stop()` is `shutdown({ graceful: false })`: it closes every session and resolves when the server is closed. + +```javascript +const server = imapkit({ rest: { port: 0 } }); +const port = await server.start(); +const restPort = server.restServer.address().port; +// ... +await server.stop(); +``` + +`server.listen()` and `server.close()` from earlier versions still work, but only `start()` starts the SMTP and REST listeners. See [Server API](../reference/server-api.md). diff --git a/docs/docs/extensions/access-control.md b/docs/docs/extensions/access-control.md new file mode 100644 index 0000000..cb0381d --- /dev/null +++ b/docs/docs/extensions/access-control.md @@ -0,0 +1,156 @@ +--- +title: Access Control +sidebar_position: 6 +description: The ACL plugin of ImapKit, with LIST-MYRIGHTS, the aclOwner option, ACLs in storage and how the rights of users other than the owner are enforced. +--- + +# Access control + +| | | +| ------------- | ------------------------------------------------------------------------------------------------------ | +| Plugin | `ACL` | +| Capabilities | `ACL`, `RIGHTS=texk`, and `LIST-MYRIGHTS` when LIST-EXTENDED is loaded | +| RFC | [RFC 4314](https://www.rfc-editor.org/rfc/rfc4314), [RFC 8440](https://www.rfc-editor.org/rfc/rfc8440) | +| Server option | `aclOwner` (default `"testuser"`) | +| Commands | `SETACL`, `DELETEACL`, `GETACL`, `LISTRIGHTS`, `MYRIGHTS`, and the `MYRIGHTS` return option of LIST | + +ImapKit has a single mailbox tree that every user shares (see [Authentication](../guides/authentication.md)). With the ACL plugin, the owner has every right on every mailbox, and every other user only gets what the ACL of a mailbox grants them. This is how you test a client against shared mailboxes, read-only folders and permission errors. + +## Setting up users and ACLs + +The owner is the user named by the `aclOwner` option, `testuser` by default. Add other users with the `users` option, and give them rights with the `acl` property of a mailbox in the storage: + +```javascript +const server = imapkit({ + plugins: ['ACL', 'LIST-EXTENDED'], + users: { + testuser: { password: 'testpass' }, + otheruser: { password: 'secret' } + }, + storage: { + INBOX: { acl: { otheruser: 'lr', anyone: 'l' }, messages: [/* four messages */] }, + '': { + separator: '/', + folders: { + Archive: { acl: { otheruser: 'lrswite' } }, + Shared: { acl: { otheruser: 'lrswikte', '-otheruser': 't' } }, + Sent: {} + } + } + } +}); +``` + +A user other than the owner gets the rights granted to their user name and to `anyone`, minus the negative rights of `-username` and `-anyone` (RFC 4314 section 2). An invalid ACL in the storage is not checked when the server is created: the first command that needs the ACL of that mailbox fails with `NO [SERVERBUG]`. + +ACLs can also be changed with SETACL and DELETEACL, or from your test with the control API (`server.control.getAcl()`, `setAcl()`, `deleteAcl()`, see [Plugin operations](../control-api/plugin-operations.md)). + +## Rights + +| Right | Meaning | +| ----- | -------------------------------------------------- | +| `l` | lookup: the mailbox is visible in LIST and LSUB | +| `r` | read: SELECT, EXAMINE and STATUS | +| `s` | keep the `\Seen` flag | +| `w` | write flags other than `\Seen` and `\Deleted` | +| `i` | insert: APPEND, COPY and MOVE into the mailbox | +| `p` | post (accepted and listed, there is no submission) | +| `k` | create mailboxes below this one | +| `x` | delete the mailbox, rename it | +| `t` | set and clear `\Deleted` | +| `e` | expunge | +| `a` | administer: change the ACL | + +`RIGHTS=texk` tells clients that `t`, `e`, `x` and `k` can be granted separately. The obsolete `c` and `d` rights are accepted as `kx` and `et`, and are added to ACL and MYRIGHTS responses (RFC 4314 section 2.1.1). Unknown rights and uppercase rights are `BAD`, and so are empty identifiers and identifiers with control characters or invalid UTF-8 (RFC 4314 section 3). + +The rights of the owner can not be changed: + +```text +C: A3 GETACL INBOX +S: * ACL INBOX testuser lrswipkxteacd otheruser lr anyone l +S: A3 OK Getacl complete +C: A4 SETACL Archive otheruser +a +S: A4 OK Setacl complete +C: A5 LISTRIGHTS INBOX otheruser +S: * LISTRIGHTS INBOX otheruser "" l r s w i p k x t e a c d +S: A5 OK Listrights complete +C: A6 SETACL INBOX otheruser lrX +S: A6 BAD Uppercase rights are not allowed +C: A7 SETACL INBOX testuser lr +S: A7 NO [CANNOT] Rights of the mailbox owner can not be changed +``` + +## Enforcement + +The owner bypasses every check. For other users, ImapKit enforces the rights as RFC 4314 section 4 describes: + +| Command | Needs | +| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| LIST, LSUB | mailboxes without `l` are left out | +| SELECT, EXAMINE, STATUS | `r` | +| SUBSCRIBE | `l` | +| SELECT read-write | any of `i`, `e`, `s`, `w`, `t`, otherwise the mailbox is opened `READ-ONLY`. PERMANENTFLAGS only lists the flags the user can change | +| STORE | `s` for `\Seen`, `t` for `\Deleted`, `w` for other flags. Only the flags the user has rights for change, and `NO [NOPERM]` if none could | +| FETCH | without `s`, FETCH does not set `\Seen` | +| APPEND, COPY | `i` on the target. Only the flags the user has rights for are kept | +| MOVE | `i` on the target, `t` and `e` on the source (RFC 6851 section 4.2) | +| REPLACE | like MOVE (RFC 8508 section 4.1) | +| EXPUNGE | `e`. CLOSE without `e` closes the mailbox without expunging | +| CREATE | `k` on the nearest existing parent, so other users can not create top level mailboxes | +| DELETE | `x` | +| RENAME | `x` on the mailbox and `k` on the new parent | +| GETACL, SETACL, DELETEACL, LISTRIGHTS | `a` | +| MYRIGHTS | any of `l`, `r`, `i`, `k`, `x`, `a` | + +APPEND and REPLACE are refused before the message literal is sent. The rights on the selected mailbox are taken when it is selected. A new mailbox inherits the ACL of its parent, and DELETE removes the ACL. + +Missing rights are answered with `NO [NOPERM]`. When the user does not have `l` either, the answer is the same as for a mailbox that does not exist, so the existence of the mailbox is not disclosed (RFC 4314 section 6). + +Logged in as `otheruser` with the storage above: + +```text +C: A2 MYRIGHTS INBOX +S: * MYRIGHTS INBOX lr +S: A2 OK Myrights complete +C: A3 LIST "" "*" RETURN (MYRIGHTS) +S: * LIST (\HasNoChildren) "/" "INBOX" +S: * MYRIGHTS INBOX lr +S: * LIST (\HasNoChildren) "/" "Archive" +S: * MYRIGHTS Archive lrswited +S: * LIST (\HasNoChildren) "/" "Shared" +S: * MYRIGHTS Shared lrswikecd +S: A3 OK Completed +C: A4 SELECT INBOX +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +S: * OK [PERMANENTFLAGS ()] No permanent flags permitted +S: * 4 EXISTS +S: * 0 RECENT +S: * OK [UNSEEN 2] First unseen message +S: * OK [UIDVALIDITY 1] UIDs valid +S: * OK [UIDNEXT 5] Predicted next UID +S: A4 OK [READ-ONLY] Completed +C: A5 STORE 2 +FLAGS (\Flagged) +S: A5 NO [CLIENTBUG] Mailbox is read-only +C: A6 GETACL INBOX +S: A6 NO [NOPERM] Permission denied +C: A7 SELECT Sent +S: A7 NO [NONEXISTENT] Mailbox does not exist +C: A8 CREATE Mine +S: A8 NO [NOPERM] Permission denied +``` + +`Sent` has no ACL, so `otheruser` does not have `l` and gets the same answer as for a missing mailbox. INBOX opens read-only, as `lr` holds none of `i`, `e`, `s`, `w` and `t`. + +## With other plugins + +| Plugin | Effect | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| LIST-EXTENDED | LIST-MYRIGHTS: the `MYRIGHTS` return option of LIST (RFC 8440) | +| LIST-STATUS | mailboxes without `r` get no STATUS response and are listed with `\Noselect` (RFC 5819 section 2) | +| METADATA | GETMETADATA and SETMETADATA on a mailbox need `l` and any of `r`, `s`, `w`, `i`, `p` (RFC 5464 section 3.3). Unsolicited METADATA responses only go to sessions with these rights | +| QUOTA | GETQUOTAROOT only lists the MAILBOX resource without `r` on the mailbox. SETQUOTA needs `a` on every mailbox of the quota root (RFC 9208 section 6) | +| MULTISEARCH | mailboxes without `r` are skipped, and without `l` unless named under `mailboxes` or as a subtree root | +| NOTIFY | only mailboxes with `l` and `r` are reported, granting or revoking `l` counts as a MailboxName event | +| CATENATE | URLs of mailboxes the user can not read are refused with `NO [BADURL]` | + +The ACL plugin wraps the commands of other plugins too (MOVE, UID EXPUNGE, REPLACE ...), in any load order. diff --git a/docs/docs/extensions/authentication-and-transport.md b/docs/docs/extensions/authentication-and-transport.md new file mode 100644 index 0000000..2be0736 --- /dev/null +++ b/docs/docs/extensions/authentication-and-transport.md @@ -0,0 +1,173 @@ +--- +title: Authentication and Transport +sidebar_position: 9 +description: AUTH=PLAIN, SASL-IR, XOAUTH2, OAUTHBEARER, LOGINDISABLED, STARTTLS, COMPRESS=DEFLATE, UNAUTHENTICATE and ID in ImapKit. +--- + +# Authentication and transport + +| Plugin | Capability | RFC | +| ---------------- | ------------------ | --------------------------------------------------------------------------- | +| `AUTH-PLAIN` | `AUTH=PLAIN` | [RFC 4616](https://www.rfc-editor.org/rfc/rfc4616) | +| `SASL-IR` | `SASL-IR` | [RFC 4959](https://www.rfc-editor.org/rfc/rfc4959) | +| `XOAUTH2` | `AUTH=XOAUTH2` | [Google XOAUTH2](https://developers.google.com/gmail/imap/xoauth2-protocol) | +| `OAUTHBEARER` | `AUTH=OAUTHBEARER` | [RFC 7628](https://www.rfc-editor.org/rfc/rfc7628) | +| `LOGINDISABLED` | `LOGINDISABLED` | [RFC 3501](https://www.rfc-editor.org/rfc/rfc3501) | +| `STARTTLS` | `STARTTLS` | [RFC 3501](https://www.rfc-editor.org/rfc/rfc3501) | +| `COMPRESS` | `COMPRESS=DEFLATE` | [RFC 4978](https://www.rfc-editor.org/rfc/rfc4978) | +| `UNAUTHENTICATE` | `UNAUTHENTICATE` | [RFC 8437](https://www.rfc-editor.org/rfc/rfc8437) | +| `ID` | `ID` | [RFC 2971](https://www.rfc-editor.org/rfc/rfc2971) | + +The default account is `testuser` with password `testpass` and access token `testtoken`. The `users` option defines other accounts, see [Authentication](../guides/authentication.md) for a walkthrough of every login method. `LOGIN` is part of the core and needs no plugin. The AUTH capabilities and `SASL-IR` are only advertised before login. + +## AUTH=PLAIN + +Adds `AUTHENTICATE PLAIN`. Without [SASL-IR](#sasl-ir) the client waits for an empty continuation request and sends the base64 encoded `authzid NUL authcid NUL password` message on its own line. With SASL-IR it can send it as an initial response in the command. + +```text +C: A1 AUTHENTICATE PLAIN AHRlc3R1c2VyAHRlc3RwYXNz +S: A1 BAD SASL-IR must be enabled to send Initial Response with the request +C: A2 AUTHENTICATE PLAIN +S: + +C: AHRlc3R1c2VyAHRlc3RwYXNz +S: A2 OK User logged in +``` + +- Wrong credentials get `NO [AUTHENTICATIONFAILED]`. +- The authzid must be empty or the same as the user name, acting as another user gets `NO [AUTHORIZATIONFAILED]`. +- `*` instead of the response cancels the exchange with `BAD`. Invalid base64, and invalid UTF-8 in the message (RFC 4616 section 2), are `BAD`. + +User names are unicode strings. UTF-8 user names need AUTHENTICATE, since `LOGIN` refuses 8-bit user names and passwords (RFC 9755 section 5). + +## SASL-IR + +Advertises `SASL-IR` before login, so AUTHENTICATE can carry the initial response (RFC 4959). AUTH=PLAIN and OAUTHBEARER accept an initial response only when SASL-IR is loaded, and XOAUTH2 needs it. + +```text +C: A1 CAPABILITY +S: * CAPABILITY IMAP4rev1 AUTH=PLAIN SASL-IR +S: A1 OK Completed +C: A2 AUTHENTICATE PLAIN AHRlc3R1c2VyAHRlc3RwYXNz +S: A2 OK User logged in +``` + +## XOAUTH2 + +Adds the Gmail `AUTHENTICATE XOAUTH2` mechanism. The initial response is the base64 encoded `user=^Aauth=Bearer ^A^A`, where `^A` is the octet 0x01. + +ImapKit needs SASL-IR loaded and used, Gmail does not: load both plugins. + +```javascript +const server = imapkit({ plugins: ['XOAUTH2', 'SASL-IR'] }); +``` + +```text +C: A1 CAPABILITY +S: * CAPABILITY IMAP4rev1 AUTH=XOAUTH2 SASL-IR +S: A1 OK Completed +C: A2 AUTHENTICATE XOAUTH2 dXNlcj10ZXN0dXNlcgFhdXRoPUJlYXJlciB0ZXN0dG9rZW4BAQ== +S: A2 OK User logged in +``` + +The token of a user is `users[name].xoauth2.accessToken` (`testtoken` for the default `testuser`). A user defined with the `users` option needs this property to log in with XOAUTH2 or OAUTHBEARER: + +```javascript +users: { testuser: { password: 'testpass', xoauth2: { accessToken: 'testtoken' } } } +``` + +A wrong token gets a continuation request with the base64 encoded Gmail error JSON (`{"status":"400","schemes":"Bearer","scope":"https://mail.google.com/"}`), and after the client's next line (an empty one, as Gmail expects) a `NO [AUTHENTICATIONFAILED]`. An unknown user gets `NO [AUTHENTICATIONFAILED]` at once, a malformed response `NO`, and an AUTHENTICATE XOAUTH2 without SASL-IR loaded `BAD`. + +## OAUTHBEARER + +Adds `AUTHENTICATE OAUTHBEARER` (RFC 7628), with or without SASL-IR. It uses the same tokens as XOAUTH2. The authzid in the GS2 header (`n,a=testuser,`) is optional, without it the token identifies the user. + +A failed login gets the JSON error result as a continuation request (`invalid_token` or `invalid_request`), and the client must answer it with `AQ==` (a single `%x01`) to get the final `NO [AUTHENTICATIONFAILED]`: + +```text +C: A1 AUTHENTICATE OAUTHBEARER bixhPXRlc3R1c2VyLAFhdXRoPUJlYXJlciB3cm9uZwEB +S: + eyJzdGF0dXMiOiJpbnZhbGlkX3Rva2VuIn0= +C: AQ== +S: A1 NO [AUTHENTICATIONFAILED] SASL authentication failed +C: A2 AUTHENTICATE OAUTHBEARER biwsAWF1dGg9QmVhcmVyIHRlc3R0b2tlbgEB +S: A2 OK SASL authentication succeeded +``` + +The continuation decodes to `{"status":"invalid_token"}`. Client responses that break the RFC 7628 or GS2 (RFC 5801) grammar are `BAD`, and so is anything other than `AQ==` after an error result. + +## LOGINDISABLED + +On a connection without TLS, advertises `LOGINDISABLED` before login and refuses `LOGIN` with `NO [PRIVACYREQUIRED]`. Load it together with STARTTLS to test that a client upgrades the connection before it logs in. AUTHENTICATE is not affected. + +```text +C: A1 CAPABILITY +S: * CAPABILITY IMAP4rev1 STARTTLS LOGINDISABLED +S: A1 OK Completed +C: A2 LOGIN testuser testpass +S: A2 NO [PRIVACYREQUIRED] Run STARTTLS first +C: A3 STARTTLS +S: A3 OK Server ready to start TLS negotiation +C: A4 CAPABILITY +S: * CAPABILITY IMAP4rev1 +S: A4 OK Completed +C: A5 LOGIN testuser testpass +S: A5 OK User logged in +``` + +## STARTTLS + +Adds the `STARTTLS` command, advertised on connections without TLS. After the tagged OK the connection is upgraded to TLS. The server uses a bundled self-signed certificate for `localhost` unless the `credentials` option gives `{ key, cert }`. With `secureConnection: true` (`--secure` on the command line) the server uses TLS from the start instead, and STARTTLS is not advertised. + +- STARTTLS on a connection that already uses TLS is `BAD`. +- STARTTLS with commands pipelined after it is `BAD` (RFC 9051 section 6.2.1): TLS is not started and the pipelined commands are refused with `BAD` without running, since they were sent before the TLS layer. + +## COMPRESS=DEFLATE + +Adds `COMPRESS DEFLATE` (RFC 4978), valid after login. Data is compressed with raw DEFLATE in both directions right after the CRLF of the tagged OK, and every burst of responses ends with a sync flush. + +```text +C: A3 COMPRESS DEFLATE +S: A3 OK DEFLATE active +C: A4 COMPRESS DEFLATE +S: A4 BAD [COMPRESSIONACTIVE] DEFLATE active via COMPRESS +``` + +(The lines after A3 are compressed on the wire.) An unknown mechanism is `BAD`, a second COMPRESS gets `BAD [COMPRESSIONACTIVE]` (RFC 4978 section 3), and COMPRESS with commands pipelined after it is refused like STARTTLS. Input that can not be decompressed ends the connection with `* BYE`. UNAUTHENTICATE ends compression after its tagged OK. + +## UNAUTHENTICATE + +Adds `UNAUTHENTICATE` (RFC 8437), which returns to the Not Authenticated state so the client can log in again, possibly as another user. It resets the session: the selected mailbox is closed without expunging, ENABLEd extensions and CONDSTORE are turned off, the SEARCHRES result is emptied, and COMPRESS ends after the tagged OK. TLS stays. + +```text +C: A3 COMPRESS DEFLATE +S: A3 OK DEFLATE active +C: A5 ENABLE CONDSTORE +S: * ENABLED CONDSTORE +S: A5 OK ENABLE completed +C: A6 UNAUTHENTICATE +S: A6 OK Completed, now in not authenticated state +C: A7 LOGIN testuser testpass +S: A7 OK User logged in +``` + +UNAUTHENTICATE takes no arguments and is only valid after login. It never fails with `NO` (RFC 8437 section 3). + +## ID + +Adds the `ID` command (RFC 2971), valid in every state. The server answers with the fields of the `id` server option, or `NIL` without it. + +```javascript +const server = imapkit({ plugins: ['ID'], id: { name: 'ImapKit', version: '5.0.0' } }); +``` + +```text +C: A1 ID ("name" "my-client" "version" "1.2") +S: * ID ("name" "ImapKit" "version" "5.0.0") +S: A1 OK ID command completed +C: A2 ID NIL +S: * ID ("name" "ImapKit" "version" "5.0.0") +S: A2 OK ID command completed +C: A3 ID ("name" "a" "NAME" "b") +S: A3 BAD ID field names must not repeat +``` + +The client's list must follow the RFC 2971 section 3.3 limits, anything else is `BAD`: at most 30 field-value pairs, field names of at most 30 octets that do not repeat (case-insensitively), values of at most 1024 octets, strings or NIL. diff --git a/docs/docs/extensions/gmail.md b/docs/docs/extensions/gmail.md new file mode 100644 index 0000000..444ee8d --- /dev/null +++ b/docs/docs/extensions/gmail.md @@ -0,0 +1,135 @@ +--- +title: Gmail Extensions +sidebar_position: 10 +description: The X-GM-EXT-1 plugin of ImapKit with X-GM-MSGID, X-GM-THRID, X-GM-LABELS and a subset of X-GM-RAW, the storage keys it reads, and a Gmail like storage layout. +--- + +# Gmail extensions + +| | | +| ------------- | ------------------------------------------------------------------------------------------- | +| Plugin | `X-GM-EXT-1` | +| Capability | `X-GM-EXT-1` | +| Reference | [Gmail IMAP extensions](https://developers.google.com/workspace/gmail/imap/imap-extensions) | +| Server option | `HIGHESTX-GM-MSGID` (default `1278455344230334865`) | + +The plugin adds the Gmail specific message ids, thread ids, labels and the `X-GM-RAW` search key, so a client that has a Gmail code path can be tested without a Gmail account. Combine it with SPECIAL-USE and a Gmail shaped storage (below) and, for the login, with [XOAUTH2](./authentication-and-transport.md#xoauth2). + +```javascript +const server = imapkit({ plugins: ['X-GM-EXT-1', 'SPECIAL-USE', 'XOAUTH2', 'SASL-IR'], storage }); +``` + +## X-GM-MSGID and X-GM-THRID + +Both are FETCH items and SEARCH keys, 64-bit unsigned numbers. + +- Every message gets an `X-GM-MSGID` when the storage is loaded or the message is added: the server keeps a counter that starts at the `HIGHESTX-GM-MSGID` option and adds one for every message. A message in the storage can set its own `X-GM-MSGID`, and the counter continues above the largest one. +- COPY and MOVE keep the X-GM-MSGID and X-GM-THRID of the source message, as Gmail does for a message that is in several mailboxes. +- `X-GM-THRID` is the X-GM-MSGID of the message, so every message is its own thread, unless the storage sets an `X-GM-THRID` value for it. With [OBJECTID](./synchronization.md#objectid) loaded, the messages of a THREADID share the X-GM-THRID of the first message of that thread, so both thread ids group the same messages. +- A SEARCH argument that is not a number up to 2^64 - 1 is `BAD`. + +## X-GM-LABELS + +A FETCH item, a STORE item (`X-GM-LABELS`, `+X-GM-LABELS`, `-X-GM-LABELS`, each also with `.SILENT`) and a SEARCH key. + +- System labels are atoms that start with `\`. A message in INBOX has `\Inbox`, a message in a special-use mailbox has that attribute (`\Sent`, `\Important` ...). +- Other labels are mailbox names, sent and read in the form the session uses for mailbox names: modified UTF-7, or UTF-8 after `ENABLE UTF8=ACCEPT`. They are quoted when they are not atoms, and STORE accepts literals. +- A message in a mailbox without a special-use attribute gets the mailbox name as a label. +- Labels from the storage come from the `X-GM-LABELS` array of a message. +- In SEARCH, a label that starts with `\` is a system label and matches without case. +- Labels have no side effects: setting a label does not copy the message to that mailbox, and removing one does not remove it. + +## X-GM-RAW + +The SEARCH key `X-GM-RAW "query"` supports a subset of the Gmail search syntax: + +| Syntax | Meaning | +| ------------------------------------------------------------------------------------ | --------------------------------------------- | +| `word`, `"a phrase"` | TEXT search | +| `-term` | NOT | +| `a OR b`, `( ... )`, `{a b}` | OR, grouping, any of | +| `from:`, `to:`, `cc:`, `bcc:`, `subject:` | header search | +| `label:name` | X-GM-LABELS | +| `in:inbox`, `in:sent`, `in:drafts`, `in:trash`, `in:spam`, `in:anywhere`, `in:label` | system labels, or any label | +| `is:read`, `is:unread`, `is:starred`, `is:important` | SEEN, UNSEEN, FLAGGED, the `\Important` label | +| `larger:`, `smaller:` | size, with `k` or `m` suffixes | +| `after:`, `before:` | dates as `YYYY/MM/DD` | +| `rfc822msgid:` | the Message-ID header | + +Other Gmail operators (`has:`, `older_than:`, `newer_than:`, `filename:`, `category:` ...) are answered with `NO`, rather than with a wrong result. + +## Example + +With the four message INBOX used elsewhere in these docs, where the first message has `"X-GM-LABELS": ["Work", "\\Important"]` and the second `"X-GM-THRID": "1278455344230334866"` in the storage: + +```text +C: A2 CAPABILITY +S: * CAPABILITY IMAP4rev1 X-GM-EXT-1 SPECIAL-USE +S: A2 OK Completed +C: A3 SELECT INBOX +S: ... +S: A3 OK [READ-WRITE] Completed +C: A4 FETCH 1:2 (X-GM-MSGID X-GM-THRID X-GM-LABELS) +S: * 1 FETCH (X-GM-MSGID 1278455344230334867 X-GM-THRID 1278455344230334867 X-GM-LABELS (Work \Important \Inbox)) +S: * 2 FETCH (X-GM-MSGID 1278455344230334868 X-GM-THRID 1278455344230334866 X-GM-LABELS (\Inbox)) +S: A4 OK FETCH Completed +C: A5 STORE 2 +X-GM-LABELS (Work "Project X") +S: * 2 FETCH (X-GM-LABELS (\Inbox Work "Project X")) +S: A5 OK STORE completed +C: A6 SEARCH X-GM-LABELS Work +S: * SEARCH 1 2 +S: A6 OK SEARCH completed +C: A7 SEARCH X-GM-RAW "from:alice is:unread" +S: * SEARCH 4 +S: A7 OK SEARCH completed +C: A8 UID SEARCH X-GM-RAW "subject:(lunch) OR label:work" +S: * SEARCH 1 2 +S: A8 OK UID SEARCH completed +C: A9 SEARCH X-GM-RAW "has:attachment" +S: A9 NO X-GM-RAW operator has:attachment is not supported by imapkit +C: A11 SELECT "[Gmail]/Sent Mail" +S: ... +S: A11 OK [READ-WRITE] Completed +C: A12 FETCH 1 (X-GM-LABELS) +S: * 1 FETCH (X-GM-LABELS (\Sent)) +S: A12 OK FETCH Completed +``` + +## Storage keys + +| Where | Key | Value | +| ------------- | ------------------- | -------------------------------------------------------------------------------------------- | +| message | `X-GM-MSGID` | decimal string, the message id | +| message | `X-GM-THRID` | decimal string, the thread id | +| message | `X-GM-LABELS` | array of labels, system labels with a leading `\` | +| mailbox | `special-use` | the system label of the messages in it (with the SPECIAL-USE plugin also the LIST attribute) | +| server option | `HIGHESTX-GM-MSGID` | where the X-GM-MSGID counter starts | + +## Gmail storage layout + +A storage that looks like a Gmail account, with the `[Gmail]` level and its special-use mailboxes: + +```json +{ + "INBOX": {}, + "": { + "separator": "/", + "folders": { + "[Gmail]": { + "flags": ["\\Noselect"], + "folders": { + "All Mail": { "special-use": "\\All" }, + "Drafts": { "special-use": "\\Drafts" }, + "Important": { "special-use": "\\Important" }, + "Sent Mail": { "special-use": "\\Sent" }, + "Spam": { "special-use": "\\Junk" }, + "Starred": { "special-use": "\\Flagged" }, + "Trash": { "special-use": "\\Trash" } + } + } + } + } +} +``` + +Unlike Gmail, ImapKit does not show a message in `[Gmail]/All Mail` or in label mailboxes automatically. Put the messages where your test needs them. See [Storage](../guides/storage.md) for the storage format. diff --git a/docs/docs/extensions/imap4rev2.md b/docs/docs/extensions/imap4rev2.md new file mode 100644 index 0000000..774731b --- /dev/null +++ b/docs/docs/extensions/imap4rev2.md @@ -0,0 +1,121 @@ +--- +title: IMAP4rev2 +sidebar_position: 2 +description: The IMAP4rev2 plugin advertises RFC 9051 next to IMAP4rev1, loads the extensions IMAP4rev2 folds in, and switches a session to RFC 9051 rules after ENABLE IMAP4rev2. +--- + +# IMAP4rev2 + +| | | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Plugin | `IMAP4rev2` | +| Capability | `IMAP4rev2`, advertised next to `IMAP4rev1` | +| RFC | [RFC 9051](https://www.rfc-editor.org/rfc/rfc9051) | +| Loads | ENABLE, NAMESPACE, UNSELECT, UIDPLUS, ESEARCH, SEARCHRES, IDLE, SASL-IR, LIST-EXTENDED, LIST-STATUS, MOVE, BINARY, SPECIAL-USE, STATUS=SIZE, AUTH=PLAIN, and LITERAL- unless LITERAL+ is loaded | + +ImapKit implements IMAP4rev2 the way RFC 9051 Appendix A describes for a server that supports both revisions: it advertises `IMAP4rev1` and `IMAP4rev2`, every session starts as IMAP4rev1, and a client that wants IMAP4rev2 sends `ENABLE IMAP4rev2`. One server can therefore test an IMAP4rev1 client and an IMAP4rev2 client side by side, and a client that supports both can be checked for picking the right one. + +```javascript +const server = imapkit({ plugins: ['IMAP4rev2'] }); +``` + +## Capabilities + +The plugin loads every extension that RFC 9051 Appendix E item 2 folds into IMAP4rev2, and each of them advertises its own capability. AUTH=PLAIN is required by RFC 9051 section 6.1.1. + +```text +C: A0 CAPABILITY +S: * CAPABILITY IMAP4rev1 ENABLE NAMESPACE UNSELECT UIDPLUS ESEARCH SEARCHRES IDLE SASL-IR LIST-EXTENDED LIST-STATUS MOVE BINARY SPECIAL-USE STATUS=SIZE AUTH=PLAIN IMAP4rev2 LITERAL- +S: A0 OK Completed +``` + +- LITERAL- (non-synchronizing literals up to 4096 octets, RFC 9051 section 4.3) is loaded only if neither LITERAL+ nor LITERAL- is loaded already. Load `LITERAL+` as well to allow non-synchronizing literals of any size, it replaces the implied LITERAL- in any load order. Listing `LITERAL-` and `LITERAL+` by name still throws. +- STARTTLS and LOGINDISABLED (RFC 9051 section 6.1.1) are not loaded, as they change how clients log in. Load them yourself when you need them. +- `ENABLE IMAP4rev2` does not change the CAPABILITY list. + +## Sessions before ENABLE + +A session that does not send `ENABLE IMAP4rev2` behaves like it would on an IMAP4rev1 server with the same extensions. It gets `RECENT`, `[UNSEEN n]` and plain `SEARCH` responses, can use `CHECK`, `LSUB` and the `RFC822` FETCH items, and 8-bit characters in quoted strings are refused (RFC 9051 Appendix A). Partial FETCH ranges and the LARGER and SMALLER search keys take 32-bit numbers (RFC 3501 section 9). + +```text +C: A3 SELECT INBOX +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted +S: * 4 EXISTS +S: * 0 RECENT +S: * OK [UNSEEN 2] First unseen message +S: * OK [UIDVALIDITY 1] UIDs valid +S: * OK [UIDNEXT 5] Predicted next UID +S: A3 OK [READ-WRITE] Completed +C: A4 SEARCH UNSEEN +S: * SEARCH 2 3 4 +S: A4 OK SEARCH completed +``` + +## Sessions after ENABLE IMAP4rev2 + +`ENABLE IMAP4rev2` must come before the first SELECT or EXAMINE (RFC 5161 section 3.1), as for every ENABLE. After it, the session follows RFC 9051: + +| Area | IMAP4rev2 behavior | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SELECT, EXAMINE | No `RECENT` response and no `[UNSEEN n]` code. An untagged `LIST` response for the selected mailbox, with its special-use attributes. `* OK [CLOSED]` when a mailbox was selected before (section 6.3.2) | +| `\Recent` | Not sent in FLAGS of FETCH responses (section 2.3.2) | +| SEARCH | Answers with an `ESEARCH` response, `RETURN (ALL)` when no result option is given (section 6.4.4). UTF-8 is assumed without `CHARSET`, and `CHARSET` is still allowed | +| STATUS | `DELETED` is allowed (section 6.3.11), `RECENT` is not | +| Strings, mailbox names | UTF-8 in quoted strings and mailbox names, which must be Net-Unicode in Normalization Form C (section 5.1) | +| APPEND | A message with 8-bit header fields can be appended (section 6.3.12) | +| message/global | Described in BODYSTRUCTURE and numbered in sections like message/rfc822 | +| Numbers | Partial FETCH ranges and LARGER/SMALLER take 63-bit numbers (number64) | + +Items that RFC 9051 removed (Appendix E) are refused with `BAD`: `CHECK` (use NOOP), `LSUB` (use `LIST (SUBSCRIBED)`), the `RFC822`, `RFC822.HEADER` and `RFC822.TEXT` FETCH items (use `BODY[]`, `BODY.PEEK[HEADER]`, `BODY[TEXT]`), the `NEW`, `OLD` and `RECENT` SEARCH keys and the `RECENT` STATUS item. + +```text +C: A2 ENABLE IMAP4rev2 +S: * ENABLED IMAP4rev2 +S: A2 OK ENABLE completed +C: A3 SELECT INBOX +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted +S: * 4 EXISTS +S: * OK [UIDVALIDITY 1] UIDs valid +S: * OK [UIDNEXT 5] Predicted next UID +S: * LIST (\HasNoChildren) "/" INBOX +S: A3 OK [READ-WRITE] Completed +C: A4 SEARCH UNSEEN +S: * ESEARCH (TAG "A4") ALL 2:4 +S: A4 OK SEARCH completed +C: A6 CHECK +S: A6 BAD CHECK is not part of IMAP4rev2, use NOOP (RFC 9051 Appendix E) +C: A7 FETCH 1 (RFC822.HEADER) +S: A7 BAD RFC822, RFC822.HEADER and RFC822.TEXT are not part of IMAP4rev2, use BODY[] (RFC 9051 Appendix E) +C: A8 SEARCH RECENT +S: A8 BAD Invalid search key RECENT +C: A9 STATUS Archive (MESSAGES DELETED SIZE) +S: * STATUS Archive (MESSAGES 0 DELETED 0 SIZE 0) +S: A9 OK Status completed +C: A11 LSUB "" "*" +S: A11 BAD LSUB is not part of IMAP4rev2, use LIST (SUBSCRIBED) (RFC 9051 Appendix E) +C: A12 SELECT Sent +S: * OK [CLOSED] Previous mailbox closed +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted +S: * 0 EXISTS +S: * OK [UIDVALIDITY 1] UIDs valid +S: * OK [UIDNEXT 1] Predicted next UID +S: * LIST (\HasNoChildren \Sent) "/" Sent +S: A12 OK [READ-WRITE] Completed +``` + +`ENABLE` matches capability names case-insensitively and lists them in the advertised spelling, so `ENABLE imap4rev2` is answered with `* ENABLED IMAP4rev2`. With the [UNAUTHENTICATE](./authentication-and-transport.md#unauthenticate) plugin, `UNAUTHENTICATE` turns IMAP4rev2 off again, as it does for every enabled extension (RFC 8437 section 4.1). + +## Keywords + +RFC 9051 section 2.3.2 asks servers to keep the `$Forwarded`, `$MDNSent`, `$Junk`, `$NotJunk` and `$Phishing` keywords. With the plugin loaded, a mailbox that does not allow new keywords (no `\*` in PERMANENTFLAGS) still lists these five in PERMANENTFLAGS and keeps them on its messages. + +## Not implemented + +- A server that advertises only IMAP4rev2. +- UTF-8 in response text. +- The `OLDNAME` extended data item of LIST responses (section 6.3.9.7). Only [NOTIFY](./synchronization.md#notify) sends it, for RENAME. + +Other extensions that need IMAP4rev2 semantics, like [UTF8=ACCEPT](./messages.md#utf8accept), share the same session state, so the two plugins work together in any load order. diff --git a/docs/docs/extensions/mailboxes.md b/docs/docs/extensions/mailboxes.md new file mode 100644 index 0000000..51d7390 --- /dev/null +++ b/docs/docs/extensions/mailboxes.md @@ -0,0 +1,262 @@ +--- +title: Mailboxes +sidebar_position: 5 +description: Core LIST, LSUB and subscription behavior in ImapKit, and the NAMESPACE, LIST-EXTENDED, LIST-STATUS, SPECIAL-USE, CREATE-SPECIAL-USE, STATUS=SIZE and UNSELECT plugins. +--- + +# Mailboxes + +| Plugin | Capability | RFC | +| -------------------- | -------------------- | -------------------------------------------------- | +| `NAMESPACE` | `NAMESPACE` | [RFC 2342](https://www.rfc-editor.org/rfc/rfc2342) | +| `LIST-EXTENDED` | `LIST-EXTENDED` | [RFC 5258](https://www.rfc-editor.org/rfc/rfc5258) | +| `LIST-STATUS` | `LIST-STATUS` | [RFC 5819](https://www.rfc-editor.org/rfc/rfc5819) | +| `SPECIAL-USE` | `SPECIAL-USE` | [RFC 6154](https://www.rfc-editor.org/rfc/rfc6154) | +| `CREATE-SPECIAL-USE` | `CREATE-SPECIAL-USE` | [RFC 6154](https://www.rfc-editor.org/rfc/rfc6154) | +| `STATUS=SIZE` | `STATUS=SIZE` | [RFC 8438](https://www.rfc-editor.org/rfc/rfc8438) | +| `UNSELECT` | `UNSELECT` | [RFC 3691](https://www.rfc-editor.org/rfc/rfc3691) | + +The mailbox tree comes from the `storage` option, see [Storage](../guides/storage.md). The transcripts on this page use this storage: + +```javascript +const storage = { + INBOX: { messages: [/* four messages */] }, + '': { + separator: '/', + folders: { + Archive: { 'special-use': '\\Archive' }, + Sent: { 'special-use': '\\Sent' }, + Trash: { 'special-use': '\\Trash' }, + Projects: { folders: { Alpha: {}, Beta: { subscribed: false } } } + } + } +}; +``` + +## Core LIST, LSUB and subscriptions + +Without plugins, LIST and LSUB follow RFC 3501. `\HasChildren` and `\HasNoChildren` are always sent. + +- The subscription list holds names, not mailboxes (RFC 3501 section 6.3.6). A mailbox from the storage is subscribed unless it has `"subscribed": false`, a mailbox created later is not. +- DELETE does not unsubscribe, so LSUB keeps listing the name until UNSUBSCRIBE, and a mailbox created again under that name is subscribed. RENAME leaves the subscription with the old name. +- SUBSCRIBE refuses names that are not mailboxes, UNSUBSCRIBE accepts any name. +- LSUB sends the LIST attributes of an existing mailbox, without `\Noselect`, and `()` for a subscribed name that is no longer a mailbox. +- LIST concatenates the reference and the pattern as they are, without inserting a hierarchy delimiter (RFC 9051 section 6.3.9). + +```text +C: A2 LIST "" "*" +S: * LIST (\HasNoChildren) "/" "INBOX" +S: * LIST (\HasNoChildren) "/" "Archive" +S: * LIST (\HasNoChildren) "/" "Sent" +S: * LIST (\HasNoChildren) "/" "Trash" +S: * LIST (\HasChildren) "/" "Projects" +S: * LIST (\HasNoChildren) "/" "Projects/Alpha" +S: * LIST (\HasNoChildren) "/" "Projects/Beta" +S: A2 OK Completed +C: A3 LSUB "" "*" +S: * LSUB (\HasNoChildren) "/" "INBOX" +S: * LSUB (\HasNoChildren) "/" "Archive" +S: * LSUB (\HasNoChildren) "/" "Sent" +S: * LSUB (\HasNoChildren) "/" "Trash" +S: * LSUB (\HasChildren) "/" "Projects" +S: * LSUB (\HasNoChildren) "/" "Projects/Alpha" +S: A3 OK Completed +C: A4 DELETE Projects/Alpha +S: A4 OK DELETE completed +C: A5 LSUB "" "Projects/*" +S: * LSUB () "/" "Projects/Alpha" +S: A5 OK Completed +C: A6 SUBSCRIBE Nope +S: A6 NO [NONEXISTENT] Mailbox does not exist +C: A7 UNSUBSCRIBE Nope +S: A7 OK UNSUBSCRIBE completed +``` + +CREATE `a/b` also creates `a` as a normal mailbox, DELETE of a mailbox with children leaves a `\Noselect` level, and mailbox names must be valid modified UTF-7. STATUS of the selected mailbox works but carries `[CLIENTBUG]`, as RFC 2683 section 3.1.1 asks clients not to do it: + +```text +C: A4 STATUS INBOX (MESSAGES) +S: * STATUS INBOX (MESSAGES 4) +S: A4 OK [CLIENTBUG] Status completed, STATUS SHOULD NOT be used on the selected mailbox +``` + +## NAMESPACE + +Adds the `NAMESPACE` command. The personal, other users' and shared namespaces come from the keys of the storage object and their `type` (`personal`, which is the default, `user` or `shared`). The `INBOX` key is a mailbox, not a namespace. + +With the default storage (`INBOX` and `""` with `/` as separator): + +```text +C: A2 NAMESPACE +S: * NAMESPACE (("" "/")) NIL NIL +S: A2 OK Completed +``` + +With a Cyrus style storage: + +```json +{ + "INBOX": {}, + "INBOX.": {}, + "user.": { "type": "user" }, + "": { "type": "shared" } +} +``` + +```text +C: A2 NAMESPACE +S: * NAMESPACE (("INBOX." ".")) (("user." ".")) (("" "/")) +S: A2 OK Completed +``` + +Namespace prefixes are converted for the session like mailbox names (modified UTF-7, or UTF-8 after `ENABLE UTF8=ACCEPT`). Anonymous namespaces are not supported. + +## LIST-EXTENDED + +Adds the extended LIST syntax of RFC 5258: + +- selection options `SUBSCRIBED`, `REMOTE` (there are no remote mailboxes, so it changes nothing) and `RECURSIVEMATCH` +- return options `SUBSCRIBED` and `CHILDREN` (children attributes are always returned) +- several mailbox patterns in one command, `LIST "" ("INBOX" "Projects/%")` +- the `CHILDINFO` extended data item +- `\Noselect` mailboxes are listed as `\NonExistent`, also subscribed names that are not mailboxes + +The plain RFC 3501 LIST is not changed. With [SPECIAL-USE](#special-use) loaded, the `SPECIAL-USE` selection and return options combine with the others, with [LIST-STATUS](#list-status) the `STATUS` return option, and with [ACL](./access-control.md) the `MYRIGHTS` return option (LIST-MYRIGHTS). + +```text +C: A2 LIST (SUBSCRIBED) "" "*" RETURN (CHILDREN) +S: * LIST (\Subscribed \HasNoChildren) "/" "INBOX" +S: * LIST (\Subscribed \HasNoChildren \Archive) "/" "Archive" +S: * LIST (\Subscribed \HasNoChildren \Sent) "/" "Sent" +S: * LIST (\Subscribed \HasNoChildren \Trash) "/" "Trash" +S: * LIST (\Subscribed \HasChildren) "/" "Projects" +S: * LIST (\Subscribed \HasNoChildren) "/" "Projects/Alpha" +S: A2 OK Completed +C: A3 LIST "" ("INBOX" "Projects/%") +S: * LIST (\HasNoChildren) "/" "INBOX" +S: * LIST (\HasNoChildren) "/" "Projects/Alpha" +S: * LIST (\HasNoChildren) "/" "Projects/Beta" +S: A3 OK Completed +C: A7 LIST (SUBSCRIBED RECURSIVEMATCH) "" "%" RETURN (CHILDREN) +S: * LIST (\Subscribed \HasNoChildren) "/" "INBOX" +S: * LIST (\Subscribed \HasNoChildren \Archive) "/" "Archive" +S: * LIST (\Subscribed \HasNoChildren \Sent) "/" "Sent" +S: * LIST (\Subscribed \HasNoChildren \Trash) "/" "Trash" +S: * LIST (\Subscribed \HasChildren) "/" "Projects" ("CHILDINFO" ("SUBSCRIBED")) +S: A7 OK Completed +``` + +(This server has SPECIAL-USE loaded too, which adds `\Archive`, `\Sent` and `\Trash`.) + +Refused with `BAD`: + +- unknown selection or return options, and options with values they do not take +- `RECURSIVEMATCH` without a base option like `SUBSCRIBED`, also `(SPECIAL-USE RECURSIVEMATCH)` (RFC 6154 section 6) +- an empty pattern list +- a repeated `STATUS` return option with different items + +```text +C: A5 LIST (RECURSIVEMATCH) "" "*" +S: A5 BAD RECURSIVEMATCH must be used together with a selection option like SUBSCRIBED +C: A6 LIST "" () +S: A6 BAD LIST expects a mailbox pattern or a list of patterns +``` + +## LIST-STATUS + +Loads LIST-EXTENDED and adds the `STATUS` return option of RFC 5819. A STATUS response follows the LIST response of every selectable mailbox that matches the selection criteria, including the selected mailbox. Mailboxes listed only for CHILDINFO or as `\NonExistent` get none. Every STATUS item works here, also those added by plugins (`SIZE`, `HIGHESTMODSEQ`, `APPENDLIMIT`, `MAILBOXID` ...). Invalid items are `BAD`. + +```text +C: A2 LIST "" "%" RETURN (STATUS (MESSAGES UNSEEN SIZE)) +S: * LIST (\HasNoChildren) "/" "INBOX" +S: * STATUS INBOX (MESSAGES 4 UNSEEN 3 SIZE 787) +S: * LIST (\HasNoChildren) "/" "Archive" +S: * STATUS Archive (MESSAGES 0 UNSEEN 0 SIZE 0) +S: * LIST (\HasNoChildren) "/" "Sent" +S: * STATUS Sent (MESSAGES 0 UNSEEN 0 SIZE 0) +S: * LIST (\HasNoChildren) "/" "Trash" +S: * STATUS Trash (MESSAGES 0 UNSEEN 0 SIZE 0) +S: * LIST (\HasChildren) "/" "Projects" +S: * STATUS Projects (MESSAGES 0 UNSEEN 0 SIZE 0) +S: A2 OK Completed +C: A4 LIST "" "%" RETURN (STATUS (FOO)) +S: A4 BAD Invalid status element (1) +``` + +With ACL, mailboxes without the `r` right get no STATUS response and are listed with `\Noselect` (RFC 5819 section 2). + +## SPECIAL-USE + +Adds the special-use attributes of RFC 6154 to LIST and LSUB responses. A mailbox in the storage gets them from its `special-use` property, a string or an array: + +```json +{ "Sent Mail": { "special-use": "\\Sent" } } +``` + +- Without LIST-EXTENDED, `LIST (SPECIAL-USE) "" "*"` lists only the special-use mailboxes and `RETURN (SPECIAL-USE)` is accepted. +- With LIST-EXTENDED, `SPECIAL-USE` is a selection option that implies the return option, and both combine with the other LIST options. +- With [METADATA](./metadata-and-quota.md#metadata), the read-only `/private/specialuse` entry of a mailbox shows its attributes (RFC 6154 section 4). +- After `ENABLE IMAP4rev2`, the untagged LIST response of SELECT includes them. +- The control API can change them at runtime: `server.control.setSpecialUse(path, ['\\Sent'])`, which accepts `\All`, `\Archive`, `\Drafts`, `\Flagged`, `\Junk`, `\Sent`, `\Trash` and `\Important` (RFC 8457). See [Plugin operations](../control-api/plugin-operations.md). + +```text +C: A4 LIST (SPECIAL-USE) "" "*" +S: * LIST (\HasNoChildren \Archive) "/" "Archive" +S: * LIST (\HasNoChildren \Sent) "/" "Sent" +S: * LIST (\HasNoChildren \Trash) "/" "Trash" +S: A4 OK Completed +``` + +## CREATE-SPECIAL-USE + +Lets CREATE set special-use attributes: `CREATE name (USE (\Drafts))`. Attribute names are matched case-insensitively and stored in their canonical form. + +The allowed attributes are `\Archive`, `\Drafts`, `\Flagged`, `\Junk`, `\Sent` and `\Trash` by default. The `special-use` server option replaces the list: + +```javascript +const server = imapkit({ + plugins: ['SPECIAL-USE', 'CREATE-SPECIAL-USE'], + 'special-use': ['\\Drafts', '\\Sent', '\\Trash', '\\Important'] +}); +``` + +An attribute that is not allowed gets `NO [USEATTR]`, and an attribute that is not an atom or a string gets `BAD`. Load SPECIAL-USE as well so that LIST shows the attributes. + +```text +C: A2 CREATE Drafts (USE (\Drafts)) +S: A2 OK CREATE completed +C: A3 CREATE Stuff (USE (\Important)) +S: A3 NO [USEATTR] \Important not supported +C: A4 LIST "" "Drafts" +S: * LIST (\HasNoChildren \Drafts) "/" "Drafts" +S: A4 OK Completed +``` + +## STATUS=SIZE + +Adds the `SIZE` STATUS item: the sum of the RFC822.SIZE values of the messages in the mailbox. It works with LIST-STATUS too. + +```text +C: A3 STATUS INBOX (SIZE MESSAGES) +S: * STATUS INBOX (SIZE 787 MESSAGES 4) +S: A3 OK Status completed +``` + +## UNSELECT + +Adds `UNSELECT`, which closes the selected mailbox like CLOSE but without expunging `\Deleted` messages. It is valid in the selected state only and takes no arguments. + +```text +C: A3 STORE 1 +FLAGS (\Deleted) +S: * 1 FETCH (FLAGS (\Seen \Deleted)) +S: A3 OK STORE completed +C: A4 UNSELECT +S: A4 OK Mailbox closed +C: A5 SELECT INBOX +S: ... +S: * 4 EXISTS +S: ... +S: A5 OK [READ-WRITE] Completed +C: A6 UNSELECT x +S: A6 BAD UNSELECT does not take any arguments +``` diff --git a/docs/docs/extensions/messages.md b/docs/docs/extensions/messages.md new file mode 100644 index 0000000..2ed7474 --- /dev/null +++ b/docs/docs/extensions/messages.md @@ -0,0 +1,377 @@ +--- +title: Messages +sidebar_position: 8 +description: ImapKit plugins for adding, moving and reading messages, namely MOVE, UIDPLUS, MULTIAPPEND, CATENATE, REPLACE, APPENDLIMIT, BINARY, PREVIEW, SAVEDATE, MESSAGELIMIT, SAVELIMIT, UTF8=ACCEPT, LITERAL+ and LITERAL-. +--- + +# Messages + +| Plugin | Capability | RFC | +| ---------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `MOVE` | `MOVE` | [RFC 6851](https://www.rfc-editor.org/rfc/rfc6851) | +| `UIDPLUS` | `UIDPLUS` | [RFC 4315](https://www.rfc-editor.org/rfc/rfc4315) | +| `MULTIAPPEND` | `MULTIAPPEND` | [RFC 3502](https://www.rfc-editor.org/rfc/rfc3502) | +| `CATENATE` | `CATENATE`, `URL-PARTIAL` | [RFC 4469](https://www.rfc-editor.org/rfc/rfc4469), [RFC 5550](https://www.rfc-editor.org/rfc/rfc5550) | +| `REPLACE` | `REPLACE` | [RFC 8508](https://www.rfc-editor.org/rfc/rfc8508) | +| `APPENDLIMIT` | `APPENDLIMIT`, `APPENDLIMIT=` | [RFC 7889](https://www.rfc-editor.org/rfc/rfc7889) | +| `BINARY` | `BINARY` | [RFC 3516](https://www.rfc-editor.org/rfc/rfc3516) | +| `PREVIEW` | `PREVIEW` | [RFC 8970](https://www.rfc-editor.org/rfc/rfc8970) | +| `SAVEDATE` | `SAVEDATE` | [RFC 8514](https://www.rfc-editor.org/rfc/rfc8514) | +| `MESSAGELIMIT` | `MESSAGELIMIT=` | [RFC 9738](https://www.rfc-editor.org/rfc/rfc9738) | +| `SAVELIMIT` | `SAVELIMIT=` | [RFC 9738](https://www.rfc-editor.org/rfc/rfc9738) | +| `UTF8=ACCEPT` | `UTF8=ACCEPT` | [RFC 9755](https://www.rfc-editor.org/rfc/rfc9755) | +| `LITERAL+`, `LITERAL-` | `LITERAL+`, `LITERAL-` | [RFC 7888](https://www.rfc-editor.org/rfc/rfc7888) | + +The transcripts use an INBOX with four messages and empty `Archive`, `Sent` and `Trash` mailboxes. The untagged SELECT responses are shortened to `...` where they do not matter. + +## MOVE + +Adds `MOVE` and `UID MOVE`. The messages are copied to the target and expunged from the selected mailbox. With UIDPLUS, the `COPYUID` code comes in an untagged OK before the EXPUNGE responses (RFC 6851 section 4.3). + +```text +C: A3 MOVE 1:2 Archive +S: * OK [COPYUID 1 1,2 1,2] Copied +S: * 1 EXPUNGE +S: * 1 EXPUNGE +S: A3 OK Done +C: A5 MOVE 1 Nope +S: A5 NO [TRYCREATE] Target mailbox does not exist +``` + +MOVE in a mailbox opened with EXAMINE gets `NO [CLIENTBUG]`, as it would expunge from a read-only mailbox. Without the plugin, `MOVE` is an unknown command, which is how you test the COPY, STORE and EXPUNGE fallback of a client (the `no-move` [quirk preset](../faults/quirk-presets.md) removes MOVE even if listed). + +## UIDPLUS + +Adds the `APPENDUID` and `COPYUID` response codes and the `UID EXPUNGE` command, which only expunges the messages of the UID set that have the `\Deleted` flag. `UIDNOTSTICKY` is not implemented, UIDs are always sticky. + +```text +C: A4 UID COPY 3 Archive +S: A4 OK [COPYUID 1 3 1] UID COPY Completed +C: A5 APPEND Archive (\Seen) {20} +S: + Go ahead +C: Subject: hi +C: +C: hello +S: A5 OK [APPENDUID 1 2] APPEND Completed +``` + +- With MULTIAPPEND, APPENDUID lists the UIDs of all appended messages as a UID set. +- With REPLACE, APPENDUID is sent in an untagged OK before the EXPUNGE. +- A COPY that copied nothing gets no COPYUID. +- UID EXPUNGE in a read-only mailbox gets `NO [CLIENTBUG]`. + +## MULTIAPPEND + +APPEND takes several messages, each with its own optional flags and date, and appends all or none of them. A zero-length message literal cancels the whole APPEND with `NO` (RFC 3502 section 6.3.11). + +```text +C: A2 APPEND Archive {20} +S: + Go ahead +C: Subject: hi +C: +C: hello (\Seen) {20} +S: + Go ahead +C: Subject: hi +C: +C: hello +S: A2 OK [APPENDUID 1 1:2] APPEND Completed +C: A3 APPEND Archive {20} +S: + Go ahead +C: Subject: hi +C: +C: hello {0} +S: + Go ahead +C: +S: A3 NO Zero-length message literal, APPEND cancelled +``` + +Without the plugin, a second message in APPEND is `BAD`: + +```text +S: A2 BAD Only a single message can be appended +``` + +## CATENATE + +APPEND (and REPLACE) can build a message from `TEXT` literals and `URL` parts that point to messages or message parts on the server (RFC 4469). `URL-PARTIAL` (RFC 5550) is advertised too, so URLs can take `;PARTIAL=offset.length`. + +Only absolute-path URLs are accepted, for example `/INBOX;UIDVALIDITY=1/;UID=2/;SECTION=1.MIME/;PARTIAL=0.100`. Relative-path URLs like `;UID=1` (RFC 5092 section 7.2), other URLs and URLs that do not resolve fail with `NO [BADURL ...]`. A message over the literal size limit (the `maxLiteralSize` option) fails with `NO [TOOBIG]`. A literal8 `TEXT` part is refused. + +```text +C: A2 APPEND Archive CATENATE (URL "/INBOX;UIDVALIDITY=1/;UID=1/;SECTION=HEADER" TEXT {7} +S: + Go ahead +C: Hello +C: ) +S: A2 OK [APPENDUID 1 1] APPEND Completed +C: A3 APPEND Archive CATENATE (URL ";UID=1") +S: A3 NO [BADURL ;UID=1] Relative-path URLs are not allowed (RFC 5092 section 7.2) +C: A4 APPEND Archive CATENATE (URL "/INBOX/;UID=99") +S: A4 NO [BADURL /INBOX/;UID=99] Message does not exist +C: A5 APPEND Archive CATENATE (URL "/INBOX/;UID=1/;PARTIAL=0.20") +S: A5 OK [APPENDUID 1 2] APPEND Completed +``` + +With ACL loaded, URLs of mailboxes the user can not read are refused with `NO [BADURL]`. + +## REPLACE + +Adds `REPLACE` and `UID REPLACE`: append a new version of a message and expunge the old one in one step (RFC 8508). The target can be the selected mailbox or another one. + +- Only the replaced message is expunged, not every `\Deleted` message. If the new message can not be appended, nothing changes. +- With UIDPLUS, `APPENDUID` comes in an untagged OK before the EXPUNGE. When the target is the selected mailbox, EXISTS comes before EXPUNGE, like the RFC 8508 section 3.2 example. +- REPLACE takes a single message even with MULTIAPPEND. It works with CATENATE and, with BINARY, with a literal8 message. +- A message number past the end is `BAD`, a UID that does not exist is `NO`, and REPLACE in a read-only mailbox is `NO`. When the message to replace is known to be invalid, the literal is refused before the client sends it. +- With QUOTA only the net usage counts. + +```text +C: A3 UID REPLACE 2 INBOX {20} +S: + Go ahead +C: Subject: hi +C: +C: hello +S: * OK [APPENDUID 1 5] Replacement message saved +S: * 5 EXISTS +S: * 2 EXPUNGE +S: A3 OK UID REPLACE completed +C: A4 REPLACE 9 INBOX {20} +S: A4 BAD Invalid message sequence number +``` + +## APPENDLIMIT + +Announces the largest message APPEND accepts (RFC 7889). + +- The server option `appendLimit` (octets) sets the limit for every mailbox, advertised as `APPENDLIMIT=`. Without the option there is no server wide limit. +- A mailbox in the storage can set its own `appendLimit`, a number, or `null` for no limit. Then the capability is a plain `APPENDLIMIT` and clients read the limits with `STATUS (APPENDLIMIT)`, which also works with LIST-STATUS. +- A larger message in APPEND or REPLACE fails with `NO [TOOBIG]`. A synchronizing literal that is too large is refused before the client sends it. An `appendLimit` of `0` refuses every message. +- COPY and MOVE are not limited, they are not uploads (RFC 7889 section 1). + +```javascript +const server = imapkit({ plugins: ['APPENDLIMIT'], appendLimit: 10 }); +``` + +```text +C: A2 CAPABILITY +S: * CAPABILITY IMAP4rev1 APPENDLIMIT=10 +S: A2 OK Completed +C: A3 APPEND INBOX {20} +S: A3 NO [TOOBIG] Message exceeds the APPENDLIMIT of the mailbox +C: A4 STATUS INBOX (APPENDLIMIT) +S: * STATUS INBOX (APPENDLIMIT 10) +S: A4 OK Status completed +``` + +With `appendLimit: 10` and `"appendLimit": null` on the Archive mailbox: + +```text +C: A2 CAPABILITY +S: * CAPABILITY IMAP4rev1 APPENDLIMIT +S: A2 OK Completed +C: A3 STATUS Archive (APPENDLIMIT) +S: * STATUS Archive (APPENDLIMIT NIL) +S: A3 OK Status completed +C: A4 STATUS INBOX (APPENDLIMIT) +S: * STATUS INBOX (APPENDLIMIT 10) +S: A4 OK Status completed +``` + +## BINARY + +Adds the `BINARY[]<>`, `BINARY.PEEK[]<>` and `BINARY.SIZE[]` FETCH items, which remove the base64 and quoted-printable transfer encodings. Other encodings fail with `NO [UNKNOWN-CTE]`. Decoded data is sent as a literal8 (`~{n}`) only when it contains NUL, otherwise as a normal string (RFC 3516 section 4.3). + +APPEND, MULTIAPPEND and REPLACE accept literal8 messages (`~{n}`, and `~{n+}` with LITERAL+ or LITERAL-). Binary parts of an appended message, and parts with NUL octets, are stored base64 encoded, so `BODY[]` stays valid IMAP4rev1. + +```text +C: A3 FETCH 5 (BINARY.SIZE[1] BINARY.PEEK[1]) +S: * 5 FETCH (BINARY.SIZE[1] 13 BINARY[1] {13} +S: Café au lait) +S: A3 OK FETCH Completed +C: A5 FETCH 5 (BINARY[]) +S: A5 BAD BINARY[] is not allowed, BINARY applies to leaf body parts only (RFC 9051 section 6.4.5) +C: A6 FETCH 5 (BINARY.PEEK[1.MIME]) +S: A6 BAD Invalid BINARY.PEEK section, expecting a part number (RFC 3516 section 7) +``` + +Here message 5 is a multipart message whose first part is quoted-printable `Caf=C3=A9 au lait`. + +A message appended as a literal8 with a binary part, read back with BODY and BINARY: + +```text +C: A2 APPEND Archive ~{96} +S: + Go ahead +C: ...message with a part that holds the octets 00 01 02... +S: A2 OK [APPENDUID 1 1] APPEND Completed +C: A4 FETCH 1 (BODY.PEEK[TEXT] BINARY.PEEK[1]) +S: * 1 FETCH (BODY[TEXT] {8} +S: AAECDQo= BINARY[1] ~{5} +S: <00 01 02 CR LF>) +S: A4 OK FETCH Completed +``` + +The same message in a normal `{96}` literal is `BAD`, as NUL octets are not allowed there. + +Refused with `BAD`: `BINARY[]`, BINARY of multipart or message/rfc822 parts (RFC 9051 section 6.4.5 allows leaf body parts only), `HEADER`, `TEXT` or `MIME` sections, and a partial range on `BINARY.SIZE`. A literal8 is refused without a continuation request anywhere but in an APPEND or REPLACE message (and a SETMETADATA value with METADATA). + +## PREVIEW + +Adds the `PREVIEW` FETCH item with the `LAZY` modifier (RFC 8970). + +- Previews are generated from the first text/plain or text/html part. text/plain is preferred in multipart/alternative, and attachments, attached messages and encrypted content are skipped. +- Transfer encoding and charset are decoded, HTML markup and quoted text are removed, whitespace is collapsed, and the result is cut to 200 characters. +- A message in the storage can set its own `"preview"` string. +- `PREVIEW (LAZY)` returns NIL until the preview of the message has been generated by a FETCH without LAZY, or comes from the storage. +- PREVIEW does not set `\Seen`. + +```text +C: A3 FETCH 1 (PREVIEW (LAZY)) +S: * 1 FETCH (PREVIEW NIL) +S: A3 OK FETCH Completed +C: A4 FETCH 1:2 (PREVIEW) +S: * 1 FETCH (PREVIEW "Shall we have lunch?") +S: * 2 FETCH (PREVIEW "Yes, at noon.") +S: A4 OK FETCH Completed +C: A5 FETCH 1 (PREVIEW (LAZY)) +S: * 1 FETCH (PREVIEW "Shall we have lunch?") +S: A5 OK FETCH Completed +C: A7 FETCH 1 (PREVIEW ()) +S: A7 BAD PREVIEW modifier list can not be empty +``` + +Unknown modifiers are `BAD` too. + +## SAVEDATE + +Adds the `SAVEDATE` FETCH item and the `SAVEDBEFORE`, `SAVEDON`, `SAVEDSINCE` and `SAVEDATESUPPORTED` SEARCH keys (RFC 8514). + +- APPEND, COPY and MOVE set the save date to the current time. It is never copied from the source message. +- Messages in the storage can set it with a `SAVEDATE` value (an IMAP date-time string like `"08-Oct-2026 11:00:00 +0300"`, or a Date). Others get the current server time when the storage is loaded, or the time of the `now` option when it is set (see [Repeatable tests](../faults/repeatable-tests.md)). +- A mailbox with `"SAVEDATE": false` in the storage does not support save dates: FETCH returns NIL, `SAVEDATESUPPORTED` matches nothing, and the other keys use the internal date. + +```text +C: A3 FETCH 1:2 (SAVEDATE) +S: * 1 FETCH (SAVEDATE "01-Oct-2026 12:00:00 +0000") +S: * 2 FETCH (SAVEDATE "08-Oct-2026 11:00:00 +0300") +S: A3 OK FETCH Completed +C: A4 SEARCH SAVEDBEFORE 2-Oct-2026 +S: * SEARCH 1 +S: A4 OK SEARCH completed +C: A5 SEARCH SAVEDATESUPPORTED +S: * SEARCH 1 2 3 4 +S: A5 OK SEARCH completed +C: A6 SELECT Trash +S: ... +S: A6 OK [READ-WRITE] Completed +C: A7 SEARCH SAVEDATESUPPORTED +S: * SEARCH +S: A7 OK SEARCH completed +``` + +## MESSAGELIMIT and SAVELIMIT + +RFC 9738 lets a server limit how many messages one command works on. Both plugins read the limit from the `messageLimit` server option (default 1000). Any positive number is accepted, so a small test mailbox can hit it. The two plugins can not be loaded together. + +**MESSAGELIMIT** is advertised as `MESSAGELIMIT=`: + +- FETCH, STORE, SEARCH, MOVE, UID EXPUNGE and their UID variants only work on the n messages with the highest UIDs (UID EXPUNGE counts the `\Deleted` ones), from the highest down. They add `[MESSAGELIMIT n uid]` with the lowest processed UID to the tagged OK, or send it in an untagged `NO` when the tagged OK already has a response code (like `HIGHESTMODSEQ` or `MODIFIED`). +- SEARCH counts the searched messages, which its top level sequence set and the `UID`, `UIDAFTER` and `UIDBEFORE` keys narrow down. +- COPY, APPEND (MULTIAPPEND), SORT and THREAD of more messages, and a FETCH `PARTIAL` range of more messages, fail with `NO [MESSAGELIMIT ...]`. +- EXPUNGE, CLOSE and STATUS are not limited. +- Adds the `UIDAFTER` and `UIDBEFORE` search keys, which take a single UID (anything else is `BAD`). + +```text +C: A2 CAPABILITY +S: * CAPABILITY IMAP4rev1 MESSAGELIMIT=2 +S: A2 OK Completed +C: A4 FETCH 1:* (FLAGS) +S: * 4 FETCH (FLAGS ()) +S: * 3 FETCH (FLAGS (\Flagged)) +S: A4 OK [MESSAGELIMIT 2 3] FETCH Completed +C: A5 COPY 1:* Archive +S: A5 NO [MESSAGELIMIT 2 3] Too many messages to copy, try a smaller subset +C: A6 UID SEARCH UIDAFTER 1 +S: * SEARCH 4 3 +S: A6 OK [MESSAGELIMIT 2 3] UID SEARCH completed +``` + +**SAVELIMIT** is advertised as `SAVELIMIT=`. Only COPY and APPEND (MULTIAPPEND) of more messages fail with `NO [MESSAGELIMIT ...]`, other commands are not limited: + +```text +C: A2 CAPABILITY +S: * CAPABILITY IMAP4rev1 SAVELIMIT=2 MOVE +S: A2 OK Completed +C: A5 COPY 1:* Archive +S: A5 NO [MESSAGELIMIT 2 3] Too many messages to copy, try a smaller subset +C: A6 MOVE 1:* Archive +S: * 1 EXPUNGE +S: * 1 EXPUNGE +S: * 1 EXPUNGE +S: * 1 EXPUNGE +S: A6 OK Done +``` + +## UTF8=ACCEPT + +Loads ENABLE. A server that advertises UTF8=ACCEPT accepts UTF-8 in quoted strings from every session (RFC 9755 section 3). Before `ENABLE UTF8=ACCEPT`, mailbox names must still be modified UTF-7, SEARCH needs `CHARSET UTF-8` for 8-bit strings, and APPEND of a message with 8-bit header fields gets `NO` (RFC 9755 section 4). After it: + +- Mailbox names are UTF-8 in both directions. The storage keeps modified UTF-7 names, and `&` is an ordinary character in UTF-8 names. +- Strings that are valid UTF-8 are sent quoted. +- SEARCH strings are UTF-8 without `CHARSET`, and `SEARCH CHARSET` is `BAD`. SORT and THREAD accept only UTF-8. +- A message with 8-bit header fields can be appended. + +```text +C: A3 CREATE "Gr&APw-n" +S: A3 OK CREATE completed +C: A4 APPEND INBOX {22} +S: + Go ahead +C: Subject: Grüße +C: +C: hi +S: A4 NO Message header has 8-bit characters, ENABLE UTF8=ACCEPT first (RFC 9755 section 4) +C: A5 ENABLE UTF8=ACCEPT +S: * ENABLED UTF8=ACCEPT +S: A5 OK ENABLE completed +C: A6 LIST "" "Gr*" +S: * LIST (\HasNoChildren) "/" "Grün" +S: A6 OK Completed +C: A7 CREATE "Café" +S: A7 OK CREATE completed +C: A10 SEARCH CHARSET UTF-8 SUBJECT x +S: A10 BAD CHARSET is not allowed, search strings are always UTF-8 in this session +``` + +Refused with `BAD` after ENABLE: invalid UTF-8 in quoted strings, and mailbox names with control characters, U+2028, U+2029, a leading BOM, unassigned code points, or a name that is not in Unicode Normalization Form C. While the plugin is loaded, the same name rules apply before ENABLE to modified UTF-7 names, so `CREATE "a&AA0-"` (an encoded CR) is `BAD` too. + +Not implemented: `UTF8=ONLY`, the obsolete `APPEND ... UTF8 (...)` data item of RFC 6855, and downgrading 8-bit headers for clients that did not enable UTF-8 (RFC 9755 section 8). The plugin shares its session state with [IMAP4rev2](./imap4rev2.md), so both work in any load order. + +## LITERAL+ and LITERAL- + +Without either plugin, every literal is synchronizing: the client sends `{n}`, waits for the `+` continuation and then the data. ImapKit answers literal data sent before the continuation with `BAD` (RFC 3501 section 4.3), and `{n+}` is a syntax error: + +```text +C: A2 APPEND Archive {20+} +S: * BAD [SYNTAX] Unexpected char at position 21 +S: A2 BAD Error parsing command +``` + +The rest of the message is then read as commands and gets more `BAD` responses, which is the client bug made visible. + +- **LITERAL+** allows non-synchronizing literals `{n+}` of any size. +- **LITERAL-** allows them up to 4096 octets. A larger one is read and dropped, and the command is answered with `BAD [TOOBIG]` (RFC 7888 section 5). + +```text +C: A3 APPEND Archive {20+} +C: Subject: hi +C: +C: hello +S: A3 OK APPEND Completed +``` + +```text +C: A3 APPEND Archive {5000+} +C: ...5000 octets... +S: A3 BAD [TOOBIG] Non-synchronizing literals are limited to 4096 octets +C: A4 NOOP +S: A4 OK Completed +``` + +The two plugins can not be loaded together (RFC 7888 section 5). IMAP4rev2 loads LITERAL- unless LITERAL+ is loaded, and LITERAL+ replaces that implied LITERAL-. diff --git a/docs/docs/extensions/metadata-and-quota.md b/docs/docs/extensions/metadata-and-quota.md new file mode 100644 index 0000000..1442f52 --- /dev/null +++ b/docs/docs/extensions/metadata-and-quota.md @@ -0,0 +1,177 @@ +--- +title: Metadata and Quota +sidebar_position: 7 +description: The METADATA, METADATA-SERVER and QUOTA plugins of ImapKit, with the metadata, metadataMaxSize, metadataMaxEntries, metadataPrivate and quota server options. +--- + +# Metadata and quota + +| Plugin | Capability | RFC | +| ----------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------- | +| `METADATA` | `METADATA` | [RFC 5464](https://www.rfc-editor.org/rfc/rfc5464) | +| `METADATA-SERVER` | `METADATA-SERVER` | [RFC 5464](https://www.rfc-editor.org/rfc/rfc5464) | +| `QUOTA` | `QUOTA`, `QUOTA=RES-STORAGE`, `QUOTA=RES-MESSAGE`, `QUOTA=RES-MAILBOX`, `QUOTASET` | [RFC 9208](https://www.rfc-editor.org/rfc/rfc9208) | + +APPENDLIMIT, the other size limit a server can announce, is on the [Messages](./messages.md#appendlimit) page. + +## METADATA + +Adds `GETMETADATA` and `SETMETADATA` for server annotations (mailbox name `""`) and mailbox annotations. + +| Option | Default | Effect | +| -------------------- | ------- | -------------------------------------------------------------------------- | +| `metadata` | none | Initial server annotations, an object of entry names and string values | +| `metadataMaxSize` | `65536` | Largest value in octets. A larger value gets `NO [METADATA MAXSIZE n]` | +| `metadataMaxEntries` | `100` | Entries per mailbox, and for the server. Above it, `NO [METADATA TOOMANY]` | +| `metadataPrivate` | `true` | `false` refuses `/private` entries with `NO [METADATA NOPRIVATE]` | + +Initial mailbox annotations come from a `metadata` object on the mailbox in the storage: + +```javascript +const server = imapkit({ + plugins: ['METADATA', 'SPECIAL-USE'], + metadata: { '/shared/admin': 'mailto:admin@example.com' }, + metadataMaxSize: 16, + storage: { + INBOX: { metadata: { '/private/comment': 'My inbox' } }, + '': { separator: '/', folders: { Sent: { 'special-use': '\\Sent' } } } + } +}); +``` + +```text +C: A3 GETMETADATA "" /shared/admin +S: * METADATA "" (/shared/admin "mailto:admin@example.com") +S: A3 OK GETMETADATA completed +C: A4 GETMETADATA INBOX (/private/comment /shared/comment) +S: * METADATA INBOX (/private/comment "My inbox" /shared/comment NIL) +S: A4 OK GETMETADATA completed +C: A5 SETMETADATA INBOX (/shared/comment "Team inbox") +S: A5 OK SETMETADATA completed +C: A6 GETMETADATA (DEPTH infinity) INBOX /shared +S: * METADATA INBOX (/shared/comment "Team inbox") +S: A6 OK GETMETADATA completed +C: A7 SETMETADATA INBOX (/shared/comment "This value is too long") +S: A7 NO [METADATA MAXSIZE 16] Value of /shared/comment is too large +C: A8 SETMETADATA "" (/shared/admin "x") +S: A8 NO [CANNOT] The /shared/admin entry is read-only +C: A9 GETMETADATA Sent /private/specialuse +S: * METADATA Sent (/private/specialuse "\\Sent") +S: A9 OK GETMETADATA completed +C: A10 SETMETADATA INBOX (/shared/comment//x "a") +S: A10 BAD Entry name must not contain consecutive "/" characters +C: A11 SETMETADATA INBOX (/shared/comment NIL) +S: A11 OK SETMETADATA completed +``` + +What is implemented: + +- GETMETADATA takes the `MAXSIZE` and `DEPTH` (`0`, `1`, `infinity`) options before the mailbox name. Values larger than MAXSIZE are left out and the tagged OK reports the size of the largest one with `[METADATA LONGENTRIES n]`. +- SETMETADATA with `NIL` removes an entry. +- Values can be binary: SETMETADATA takes a literal8 (`~{n}`), and values with NUL octets are sent back as a literal8. +- `/shared/admin` on the server is read-only (`NO [CANNOT]`). +- RENAME moves the annotations of a mailbox, renaming INBOX copies them (RFC 5464 section 4.1). DELETE removes them. +- After `ENABLE METADATA` (needs the ENABLE plugin), changes made by other sessions are announced with unsolicited `METADATA` responses. +- With SPECIAL-USE loaded, the read-only `/private/specialuse` entry shows the special-use attributes of a mailbox (RFC 6154 section 4). +- With NOTIFY loaded, the `MailboxMetadataChange` and `ServerMetadataChange` events are available. +- With ACL loaded, mailbox annotations need the rights listed on [Access control](./access-control.md#with-other-plugins). +- The control API reads and writes annotations with `server.control.getMetadata()` and `setMetadata()`, see [Plugin operations](../control-api/plugin-operations.md). + +Refused with `BAD`, following RFC 5464 section 3.2: entry names with `//`, a trailing `/`, `*`, `%`, 8-bit or control characters, or a scope other than `/private` or `/shared`; values that are atoms or use bare CR or LF as line ends; empty entry or option lists; and GETMETADATA options after the mailbox name (errata 2785): + +```text +C: A4 GETMETADATA INBOX (DEPTH 1) /shared +S: A4 BAD GETMETADATA expects options, a mailbox name and entries +``` + +## METADATA-SERVER + +The same as METADATA, but only for server annotations (mailbox name `""`). A mailbox annotation command gets `NO`. The same options apply, and `ENABLE METADATA-SERVER` turns on unsolicited responses. When METADATA is loaded too, only `METADATA` is advertised. + +```javascript +const server = imapkit({ plugins: ['METADATA-SERVER'], metadataPrivate: false }); +``` + +```text +C: A2 CAPABILITY +S: * CAPABILITY IMAP4rev1 METADATA-SERVER +S: A2 OK Completed +C: A3 SETMETADATA "" (/private/comment "x") +S: A3 NO [METADATA NOPRIVATE] Private annotations are not supported +C: A4 GETMETADATA INBOX /private/comment +S: A4 NO Mailbox annotations are not supported, only server annotations +``` + +## QUOTA + +Adds `GETQUOTA`, `GETQUOTAROOT` and `SETQUOTA`, the `STORAGE`, `MESSAGE` and `MAILBOX` resources, and the `DELETED` and `DELETED-STORAGE` STATUS items. INBOX and the personal namespaces share one quota root, other namespaces have none. + +Configure it with the `quota` server option: + +```javascript +const server = imapkit({ + plugins: ['QUOTA'], + quota: { + root: 'User quota', // name of the quota root, this is the default + STORAGE: 10240, // in units of 1024 octets + MESSAGE: 1000, + MAILBOX: 100, + soft: false + } +}); +``` + +A resource that is missing from the option is not limited. Without the option there are no limits at all, and an invalid limit throws when the server is created. + +- APPEND, COPY and MOVE (from outside the quota root) fail with `NO [OVERQUOTA]` when they would go over a limit, and CREATE or RENAME of INBOX when they would go over the MAILBOX limit. Nothing is stored then. +- With `soft: true` these commands succeed, with an untagged `NO [OVERQUOTA]` warning. +- With REPLACE, only the net usage counts (RFC 8508 section 3.4). +- `SETQUOTA` replaces all limits of the root at runtime (RFC 9208 section 4.1.3). Unknown resources, or a resource named twice, get `NO [CANNOT]`, and a quota root other than the one configured gets `NO [NONEXISTENT]`. +- The control API reads and changes the quota with `server.control.getQuota()` and `setQuota()`, see [Plugin operations](../control-api/plugin-operations.md). + +With `quota: { STORAGE: 1, MESSAGE: 5, MAILBOX: 100 }` and the four message INBOX used elsewhere in these docs: + +```text +C: A2 CAPABILITY +S: * CAPABILITY IMAP4rev1 QUOTA QUOTA=RES-STORAGE QUOTA=RES-MESSAGE QUOTA=RES-MAILBOX QUOTASET +S: A2 OK Completed +C: A3 GETQUOTAROOT INBOX +S: * QUOTAROOT INBOX "User quota" +S: * QUOTA "User quota" (STORAGE 1 1 MESSAGE 4 5 MAILBOX 7 100) +S: A3 OK GETQUOTAROOT completed +C: A6 SETQUOTA "User quota" (STORAGE 100) +S: * QUOTA "User quota" (STORAGE 1 100) +S: A6 OK SETQUOTA completed +C: A7 APPEND INBOX {20} +S: + Go ahead +C: Subject: hi +C: +C: hello +S: A7 OK APPEND Completed +C: A8 STATUS INBOX (MESSAGES DELETED DELETED-STORAGE) +S: * STATUS INBOX (MESSAGES 5 DELETED 0 DELETED-STORAGE 0) +S: A8 OK Status completed +``` + +With `quota: { STORAGE: 1 }` (1024 octets) and an empty INBOX, a 2000 octet message does not fit: + +```text +C: A2 APPEND INBOX {2000} +S: + Go ahead +C: ... +S: A2 NO [OVERQUOTA] Quota exceeded +``` + +With `quota: { MESSAGE: 4, soft: true }`, the fifth message is stored with a warning: + +```text +C: A2 APPEND INBOX {20} +S: + Go ahead +C: Subject: hi +C: +C: hello +S: * NO [OVERQUOTA] Soft quota has been exceeded +S: A2 OK APPEND Completed +``` + +With ACL loaded, GETQUOTAROOT only lists the MAILBOX resource when the user lacks `r` on the mailbox, and SETQUOTA needs `a` on every mailbox of the quota root (RFC 9208 section 6). diff --git a/docs/docs/extensions/overview.md b/docs/docs/extensions/overview.md new file mode 100644 index 0000000..e04f375 --- /dev/null +++ b/docs/docs/extensions/overview.md @@ -0,0 +1,170 @@ +--- +title: Extensions Overview +sidebar_position: 1 +description: How to enable ImapKit plugins, which IMAP extensions each built-in plugin implements, and what the core IMAP4rev1 server supports without any plugin. +--- + +# Extensions overview + +ImapKit implements IMAP4rev1 ([RFC 3501](https://www.rfc-editor.org/rfc/rfc3501)) in its core. Every IMAP extension, including IMAP4rev2 itself, is a plugin that you turn on per server instance. A server without plugins is a plain IMAP4rev1 server, so you can test how your client behaves against a minimal server and against a server with every extension, using the same storage. + +## Enabling plugins + +Pass the plugin names in the `plugins` option: + +```javascript +import imapkit from 'imapkit'; + +const server = imapkit({ + plugins: ['IDLE', 'MOVE', 'UIDPLUS', 'CONDSTORE', 'ENABLE'] +}); +await server.start(1143); +``` + +From the command line, use `--plugin` (repeated or comma separated) or the `IMAPKIT_PLUGINS` environment variable. A config file given with `--config` can list them in its `plugins` array too. See [Command line](../getting-started/command-line.md). + +```bash +imapkit -p 1143 --plugin=IDLE,MOVE --plugin=CONDSTORE +IMAPKIT_PLUGINS=IDLE,MOVE imapkit -p 1143 +``` + +Plugins are loaded when the server is created. They can not be loaded or unloaded while it runs. + +### Plugin names + +- Names are case-insensitive: `IDLE`, `idle` and `Idle` all load the same plugin. +- The name is the plugin's file name (`status-size`, `x-gm-ext-1`, `literalplus`), and the capability spelling works too where it differs: `LITERAL+`, `LITERAL-`, `AUTH=PLAIN`, `AUTH=XOAUTH2`, `AUTH=OAUTHBEARER`, `COMPRESS=DEFLATE`, `STATUS=SIZE`, `SORT=DISPLAY`, `THREAD=ORDEREDSUBJECT`, `THREAD=REFERENCES`, `UTF8=ACCEPT`, `CONTEXT=SEARCH` and `CONTEXT=SORT`. +- A plugin listed more than once is loaded once. +- An unknown name throws an error that lists the available plugins: + +```text +Unknown plugin "FOO". Available plugins: acl, appendlimit, auth-plain, binary, ... +``` + +- Besides names, `plugins` accepts functions, which is how [custom plugins](../reference/custom-plugins.md) are loaded. + +### Plugins that load other plugins + +Some extensions are defined on top of others, so their plugins load what they need. The load order does not matter. + +| Plugin | Also loads | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `IMAP4rev2` | ENABLE, NAMESPACE, UNSELECT, UIDPLUS, ESEARCH, SEARCHRES, IDLE, SASL-IR, LIST-EXTENDED, LIST-STATUS, MOVE, BINARY, SPECIAL-USE, STATUS=SIZE, AUTH=PLAIN, and LITERAL- unless LITERAL+ is loaded | +| `QRESYNC` | ENABLE, CONDSTORE | +| `UIDONLY`, `UTF8=ACCEPT` | ENABLE | +| `SEARCHRES`, `PARTIAL`, `MULTISEARCH`, `CONTEXT=SEARCH` | ESEARCH | +| `ESORT` | SORT, ESEARCH | +| `CONTEXT=SORT` | ESORT, SORT, ESEARCH, CONTEXT=SEARCH | +| `SORT=DISPLAY` | SORT | +| `LIST-STATUS` | LIST-EXTENDED | + +Plugins that can be turned on with `ENABLE` (CONDSTORE, QRESYNC, UIDONLY, UTF8=ACCEPT, IMAP4rev2, METADATA, METADATA-SERVER) work in any order with the ENABLE plugin. CONDSTORE also works without ENABLE, through `SELECT ... (CONDSTORE)` and the other commands that turn it on. + +Some plugins only add to others when both are loaded: LIST-MYRIGHTS comes with ACL plus LIST-EXTENDED, the `SPECIAL-USE` LIST options combine with LIST-EXTENDED, `/private/specialuse` comes with METADATA plus SPECIAL-USE, and so on. Each page below lists these combinations. + +### Combinations that throw + +A few extensions exclude each other, and loading both throws an error when the server is created: + +| Combination | Error | Reason | +| ------------------------------ | ----------------------------------------------------------------------------- | ------------------------------------------------------ | +| `LITERAL+` and `LITERAL-` | `LITERAL- can not be enabled together with LITERAL+` (or the other way round) | RFC 7888 section 5: a server must not advertise both | +| `MESSAGELIMIT` and `SAVELIMIT` | `SAVELIMIT can not be enabled together with MESSAGELIMIT` | RFC 9738 section 3: a server advertises one of the two | + +IMAP4rev2 loads LITERAL- only when LITERAL+ is not loaded, and a LITERAL+ loaded after it replaces that implied LITERAL-, so `['IMAP4rev2', 'LITERAL+']` works. + +### Self-contained plugins + +A plugin that is not loaded leaves no trace. Without CONDSTORE, messages have no MODSEQ value and `SELECT INBOX (CONDSTORE)` is answered with `BAD`. Without MOVE, `MOVE` is an unknown command. This lets you check that your client only uses what the server advertises. + +The `no-uidplus` and `no-move` [quirk presets](../faults/quirk-presets.md) remove UIDPLUS and MOVE even when the plugin list names them. + +### Capabilities + +Every loaded plugin adds its capability to the `CAPABILITY` response, after `IMAP4rev1`. Some capabilities depend on the session state. For example `AUTH=PLAIN`, `SASL-IR` and `LOGINDISABLED` are only listed before login, `STARTTLS` only on a connection without TLS: + +```text +C: A1 CAPABILITY +S: * CAPABILITY IMAP4rev1 AUTH=PLAIN SASL-IR +S: A1 OK Completed +C: A2 AUTHENTICATE PLAIN AHRlc3R1c2VyAHRlc3RwYXNz +S: A2 OK User logged in +C: A3 CAPABILITY +S: * CAPABILITY IMAP4rev1 +S: A3 OK Completed +``` + +## Built-in plugins + +| Plugin | Capability | RFC | Summary | Page | +| ----------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `ACL` | `ACL`, `RIGHTS=texk`, `LIST-MYRIGHTS` | [4314](https://www.rfc-editor.org/rfc/rfc4314), [8440](https://www.rfc-editor.org/rfc/rfc8440) | Access control lists, enforced for users other than the owner | [Access control](./access-control.md) | +| `APPENDLIMIT` | `APPENDLIMIT`, `APPENDLIMIT=` | [7889](https://www.rfc-editor.org/rfc/rfc7889) | Largest message APPEND accepts, per server or per mailbox | [Messages](./messages.md#appendlimit) | +| `AUTH-PLAIN` | `AUTH=PLAIN` | [4616](https://www.rfc-editor.org/rfc/rfc4616) | `AUTHENTICATE PLAIN` | [Authentication and transport](./authentication-and-transport.md#authplain) | +| `BINARY` | `BINARY` | [3516](https://www.rfc-editor.org/rfc/rfc3516) | Decoded body parts in FETCH, literal8 messages in APPEND | [Messages](./messages.md#binary) | +| `CATENATE` | `CATENATE`, `URL-PARTIAL` | [4469](https://www.rfc-editor.org/rfc/rfc4469), [5550](https://www.rfc-editor.org/rfc/rfc5550) | APPEND builds a message from literals and IMAP URLs | [Messages](./messages.md#catenate) | +| `COMPRESS` | `COMPRESS=DEFLATE` | [4978](https://www.rfc-editor.org/rfc/rfc4978) | DEFLATE compression of the connection | [Authentication and transport](./authentication-and-transport.md#compressdeflate) | +| `CONDSTORE` | `CONDSTORE` | [7162](https://www.rfc-editor.org/rfc/rfc7162) | Mod-sequences, `CHANGEDSINCE`, `UNCHANGEDSINCE` | [Synchronization](./synchronization.md#condstore) | +| `CONTEXT-SEARCH` | `CONTEXT=SEARCH` | [5267](https://www.rfc-editor.org/rfc/rfc5267) | Updating search results, `CANCELUPDATE` | [Search and sort](./search-and-sort.md#contextsearch-and-contextsort) | +| `CONTEXT-SORT` | `CONTEXT=SORT` | [5267](https://www.rfc-editor.org/rfc/rfc5267) | Updating sort results | [Search and sort](./search-and-sort.md#contextsearch-and-contextsort) | +| `CREATE-SPECIAL-USE` | `CREATE-SPECIAL-USE` | [6154](https://www.rfc-editor.org/rfc/rfc6154) | `CREATE name (USE (...))` | [Mailboxes](./mailboxes.md#create-special-use) | +| `ENABLE` | `ENABLE` | [5161](https://www.rfc-editor.org/rfc/rfc5161) | The ENABLE command | [Synchronization](./synchronization.md#enable) | +| `ESEARCH` | `ESEARCH` | [4731](https://www.rfc-editor.org/rfc/rfc4731) | `SEARCH RETURN (MIN MAX ALL COUNT)` | [Search and sort](./search-and-sort.md#esearch) | +| `ESORT` | `ESORT` | [5267](https://www.rfc-editor.org/rfc/rfc5267) | `SORT RETURN (...)` | [Search and sort](./search-and-sort.md#esort) | +| `ID` | `ID` | [2971](https://www.rfc-editor.org/rfc/rfc2971) | The ID command | [Authentication and transport](./authentication-and-transport.md#id) | +| `IDLE` | `IDLE` | [2177](https://www.rfc-editor.org/rfc/rfc2177) | Push notifications while idling | [Synchronization](./synchronization.md#idle) | +| `IMAP4rev2` | `IMAP4rev2` | [9051](https://www.rfc-editor.org/rfc/rfc9051) | IMAP4rev2 for sessions that ENABLE it | [IMAP4rev2](./imap4rev2.md) | +| `LIST-EXTENDED` | `LIST-EXTENDED` | [5258](https://www.rfc-editor.org/rfc/rfc5258) | LIST selection and return options | [Mailboxes](./mailboxes.md#list-extended) | +| `LIST-STATUS` | `LIST-STATUS` | [5819](https://www.rfc-editor.org/rfc/rfc5819) | `LIST ... RETURN (STATUS (...))` | [Mailboxes](./mailboxes.md#list-status) | +| `LITERALMINUS` | `LITERAL-` | [7888](https://www.rfc-editor.org/rfc/rfc7888) | Non-synchronizing literals up to 4096 octets | [Messages](./messages.md#literal-and-literal-) | +| `LITERALPLUS` | `LITERAL+` | [7888](https://www.rfc-editor.org/rfc/rfc7888) | Non-synchronizing literals of any size | [Messages](./messages.md#literal-and-literal-) | +| `LOGINDISABLED` | `LOGINDISABLED` | [3501](https://www.rfc-editor.org/rfc/rfc3501) | LOGIN refused without TLS | [Authentication and transport](./authentication-and-transport.md#logindisabled) | +| `MESSAGELIMIT` | `MESSAGELIMIT=` | [9738](https://www.rfc-editor.org/rfc/rfc9738) | Commands work on at most n messages | [Messages](./messages.md#messagelimit-and-savelimit) | +| `METADATA` | `METADATA` | [5464](https://www.rfc-editor.org/rfc/rfc5464) | Server and mailbox annotations | [Metadata and quota](./metadata-and-quota.md#metadata) | +| `METADATA-SERVER` | `METADATA-SERVER` | [5464](https://www.rfc-editor.org/rfc/rfc5464) | Server annotations only | [Metadata and quota](./metadata-and-quota.md#metadata-server) | +| `MOVE` | `MOVE` | [6851](https://www.rfc-editor.org/rfc/rfc6851) | `MOVE` and `UID MOVE` | [Messages](./messages.md#move) | +| `MULTIAPPEND` | `MULTIAPPEND` | [3502](https://www.rfc-editor.org/rfc/rfc3502) | Several messages in one APPEND | [Messages](./messages.md#multiappend) | +| `MULTISEARCH` | `MULTISEARCH` | [7377](https://www.rfc-editor.org/rfc/rfc7377) | The ESEARCH command over several mailboxes | [Search and sort](./search-and-sort.md#multisearch) | +| `NAMESPACE` | `NAMESPACE` | [2342](https://www.rfc-editor.org/rfc/rfc2342) | The NAMESPACE command | [Mailboxes](./mailboxes.md#namespace) | +| `NOTIFY` | `NOTIFY` | [5465](https://www.rfc-editor.org/rfc/rfc5465) | Events for the selected and other mailboxes | [Synchronization](./synchronization.md#notify) | +| `OAUTHBEARER` | `AUTH=OAUTHBEARER` | [7628](https://www.rfc-editor.org/rfc/rfc7628) | OAuth 2.0 bearer token login | [Authentication and transport](./authentication-and-transport.md#oauthbearer) | +| `OBJECTID` | `OBJECTID` | [8474](https://www.rfc-editor.org/rfc/rfc8474) | `MAILBOXID`, `EMAILID`, `THREADID` | [Synchronization](./synchronization.md#objectid) | +| `PARTIAL` | `PARTIAL` | [9394](https://www.rfc-editor.org/rfc/rfc9394) | Paged SEARCH results and FETCH | [Search and sort](./search-and-sort.md#partial) | +| `PREVIEW` | `PREVIEW` | [8970](https://www.rfc-editor.org/rfc/rfc8970) | The PREVIEW FETCH item | [Messages](./messages.md#preview) | +| `QRESYNC` | `QRESYNC` | [7162](https://www.rfc-editor.org/rfc/rfc7162) | Quick resynchronization, `VANISHED` | [Synchronization](./synchronization.md#qresync) | +| `QUOTA` | `QUOTA`, `QUOTA=RES-STORAGE`, `QUOTA=RES-MESSAGE`, `QUOTA=RES-MAILBOX`, `QUOTASET` | [9208](https://www.rfc-editor.org/rfc/rfc9208) | Quota roots, limits and `OVERQUOTA` | [Metadata and quota](./metadata-and-quota.md#quota) | +| `REPLACE` | `REPLACE` | [8508](https://www.rfc-editor.org/rfc/rfc8508) | `REPLACE` and `UID REPLACE` | [Messages](./messages.md#replace) | +| `SASL-IR` | `SASL-IR` | [4959](https://www.rfc-editor.org/rfc/rfc4959) | Initial response in AUTHENTICATE | [Authentication and transport](./authentication-and-transport.md#sasl-ir) | +| `SAVEDATE` | `SAVEDATE` | [8514](https://www.rfc-editor.org/rfc/rfc8514) | The save date of a message | [Messages](./messages.md#savedate) | +| `SAVELIMIT` | `SAVELIMIT=` | [9738](https://www.rfc-editor.org/rfc/rfc9738) | COPY and APPEND of at most n messages | [Messages](./messages.md#messagelimit-and-savelimit) | +| `SEARCHRES` | `SEARCHRES` | [5182](https://www.rfc-editor.org/rfc/rfc5182) | `SEARCH RETURN (SAVE)` and `$` | [Search and sort](./search-and-sort.md#searchres) | +| `SORT` | `SORT` | [5256](https://www.rfc-editor.org/rfc/rfc5256) | `SORT` and `UID SORT` | [Search and sort](./search-and-sort.md#sort) | +| `SORT-DISPLAY` | `SORT=DISPLAY` | [5957](https://www.rfc-editor.org/rfc/rfc5957) | `DISPLAYFROM` and `DISPLAYTO` sort keys | [Search and sort](./search-and-sort.md#sortdisplay) | +| `SPECIAL-USE` | `SPECIAL-USE` | [6154](https://www.rfc-editor.org/rfc/rfc6154) | Special-use mailbox attributes | [Mailboxes](./mailboxes.md#special-use) | +| `STARTTLS` | `STARTTLS` | [3501](https://www.rfc-editor.org/rfc/rfc3501) | The STARTTLS command | [Authentication and transport](./authentication-and-transport.md#starttls) | +| `STATUS-SIZE` | `STATUS=SIZE` | [8438](https://www.rfc-editor.org/rfc/rfc8438) | The SIZE STATUS item | [Mailboxes](./mailboxes.md#statussize) | +| `THREAD-ORDEREDSUBJECT` | `THREAD=ORDEREDSUBJECT` | [5256](https://www.rfc-editor.org/rfc/rfc5256) | THREAD with the ORDEREDSUBJECT algorithm | [Search and sort](./search-and-sort.md#thread) | +| `THREAD-REFERENCES` | `THREAD=REFERENCES` | [5256](https://www.rfc-editor.org/rfc/rfc5256) | THREAD with the REFERENCES algorithm | [Search and sort](./search-and-sort.md#thread) | +| `UIDONLY` | `UIDONLY` | [9586](https://www.rfc-editor.org/rfc/rfc9586) | No message sequence numbers after ENABLE | [Synchronization](./synchronization.md#uidonly) | +| `UIDPLUS` | `UIDPLUS` | [4315](https://www.rfc-editor.org/rfc/rfc4315) | `APPENDUID`, `COPYUID`, `UID EXPUNGE` | [Messages](./messages.md#uidplus) | +| `UNAUTHENTICATE` | `UNAUTHENTICATE` | [8437](https://www.rfc-editor.org/rfc/rfc8437) | Back to the Not Authenticated state | [Authentication and transport](./authentication-and-transport.md#unauthenticate) | +| `UNSELECT` | `UNSELECT` | [3691](https://www.rfc-editor.org/rfc/rfc3691) | Close a mailbox without expunging | [Mailboxes](./mailboxes.md#unselect) | +| `UTF8-ACCEPT` | `UTF8=ACCEPT` | [9755](https://www.rfc-editor.org/rfc/rfc9755) | UTF-8 mailbox names and strings after ENABLE | [Messages](./messages.md#utf8accept) | +| `X-GM-EXT-1` | `X-GM-EXT-1` | [Gmail](https://developers.google.com/workspace/gmail/imap/imap-extensions) | Gmail message ids, thread ids, labels and `X-GM-RAW` | [Gmail](./gmail.md) | +| `XOAUTH2` | `AUTH=XOAUTH2` | [Google](https://developers.google.com/gmail/imap/xoauth2-protocol) | Gmail style OAuth 2.0 login | [Authentication and transport](./authentication-and-transport.md#xoauth2) | + +The Plugin column shows the file name spelling. The capability spellings listed under [Plugin names](#plugin-names) work as well, and so do the spellings with `=` for the names that have it (`CONTEXT=SEARCH`, `SORT=DISPLAY`, `STATUS=SIZE`, `UTF8=ACCEPT` ...). `imapkit --help` prints the same list with a short description of every plugin. + +## What core IMAP4rev1 supports + +Without any plugin, ImapKit supports every RFC 3501 command: `CAPABILITY`, `NOOP`, `LOGOUT`, `LOGIN`, `AUTHENTICATE` (no mechanism is built in, so it answers `NO Unsupported authentication mechanism` until an AUTH plugin is loaded), `SELECT`, `EXAMINE`, `CREATE`, `DELETE`, `RENAME`, `SUBSCRIBE`, `UNSUBSCRIBE`, `LIST`, `LSUB`, `STATUS`, `APPEND`, `CHECK`, `CLOSE`, `EXPUNGE`, `SEARCH`, `FETCH`, `STORE`, `COPY` and the `UID` variants of `COPY`, `FETCH`, `STORE` and `SEARCH`. `STARTTLS` is a plugin. + +Some choices that the RFCs leave to the server: + +- The subscription list holds names, not mailboxes (RFC 3501 section 6.3.6). DELETE does not unsubscribe, so LSUB and `LIST (SUBSCRIBED)` keep listing the name until UNSUBSCRIBE, and a mailbox created again under that name is subscribed. RENAME leaves the subscription with the old name. A mailbox from the storage object is subscribed unless it has `"subscribed": false`, a new mailbox is not. SUBSCRIBE refuses names that are not mailboxes, UNSUBSCRIBE accepts any name. +- CREATE `a/b` also creates `a` as a normal mailbox if it does not exist (RFC 3501 section 6.3.3). An existing `\Noselect` level stays `\Noselect`. +- DELETE of a mailbox with children leaves a `\Noselect` level that keeps nothing but the children. CREATE of that name makes a new mailbox with a new UIDVALIDITY. +- A keyword stays in the FLAGS and PERMANENTFLAGS of a mailbox once a message in it had the keyword, also after that message is expunged (RFC 3501 section 7.2.6). +- SEARCH, SORT and THREAD support the `US-ASCII` and `UTF-8` charsets. Any other charset gets `NO [BADCHARSET (US-ASCII UTF-8)]`. + +The [Mailboxes](./mailboxes.md#core-list-lsub-and-subscriptions) page shows these in transcripts, and [Strict by design](../guides/strict-by-design.md) lists what the core refuses. diff --git a/docs/docs/extensions/search-and-sort.md b/docs/docs/extensions/search-and-sort.md new file mode 100644 index 0000000..92ac6ed --- /dev/null +++ b/docs/docs/extensions/search-and-sort.md @@ -0,0 +1,229 @@ +--- +title: Search and Sort +sidebar_position: 3 +description: ESEARCH, SEARCHRES, SORT, SORT=DISPLAY, ESORT, THREAD, CONTEXT=SEARCH, CONTEXT=SORT, PARTIAL and MULTISEARCH in ImapKit, with the supported charsets and the strict checks. +--- + +# Search and sort + +The core server implements `SEARCH` and `UID SEARCH` with every RFC 3501 search key. The plugins on this page add result options, saved results, sorting, threading, live updates, paging and searching across mailboxes. + +| Plugin | Capability | RFC | +| ----------------------- | ----------------------- | -------------------------------------------------- | +| `ESEARCH` | `ESEARCH` | [RFC 4731](https://www.rfc-editor.org/rfc/rfc4731) | +| `SEARCHRES` | `SEARCHRES` | [RFC 5182](https://www.rfc-editor.org/rfc/rfc5182) | +| `SORT` | `SORT` | [RFC 5256](https://www.rfc-editor.org/rfc/rfc5256) | +| `SORT=DISPLAY` | `SORT=DISPLAY` | [RFC 5957](https://www.rfc-editor.org/rfc/rfc5957) | +| `ESORT` | `ESORT` | [RFC 5267](https://www.rfc-editor.org/rfc/rfc5267) | +| `THREAD=ORDEREDSUBJECT` | `THREAD=ORDEREDSUBJECT` | [RFC 5256](https://www.rfc-editor.org/rfc/rfc5256) | +| `THREAD=REFERENCES` | `THREAD=REFERENCES` | [RFC 5256](https://www.rfc-editor.org/rfc/rfc5256) | +| `CONTEXT=SEARCH` | `CONTEXT=SEARCH` | [RFC 5267](https://www.rfc-editor.org/rfc/rfc5267) | +| `CONTEXT=SORT` | `CONTEXT=SORT` | [RFC 5267](https://www.rfc-editor.org/rfc/rfc5267) | +| `PARTIAL` | `PARTIAL` | [RFC 9394](https://www.rfc-editor.org/rfc/rfc9394) | +| `MULTISEARCH` | `MULTISEARCH` | [RFC 7377](https://www.rfc-editor.org/rfc/rfc7377) | + +The transcripts on this page use an INBOX with four messages: two "Lunch" messages from Alice and Bob, and two "Report" messages from Carol and Alice, where message 1 is `\Seen` and message 3 is `\Flagged`. + +## Charsets + +SEARCH, SORT and THREAD support the `US-ASCII` and `UTF-8` charsets. Any other charset is answered with `NO [BADCHARSET (US-ASCII UTF-8)]`. Search strings with 8-bit characters need `CHARSET UTF-8` (and a literal, since IMAP4rev1 does not allow 8-bit characters in quoted strings): + +```text +C: A3 SEARCH CHARSET UTF-8 SUBJECT {6} +S: + Go ahead +C: Lünch +S: * SEARCH +S: A3 OK SEARCH completed +C: A4 SEARCH SUBJECT {6} +S: + Go ahead +C: Lünch +S: A4 BAD SUBJECT argument has 8-bit characters, use CHARSET UTF-8 +C: A5 SEARCH CHARSET KOI8-R SUBJECT x +S: A5 NO [BADCHARSET (US-ASCII UTF-8)] Unsupported charset KOI8-R +``` + +Strings are matched with ASCII case folding. After `ENABLE UTF8=ACCEPT` or `ENABLE IMAP4rev2` the search charset changes, see [UTF8=ACCEPT](./messages.md#utf8accept) and [IMAP4rev2](./imap4rev2.md). + +## ESEARCH + +`SEARCH` and `UID SEARCH` take the result options `MIN`, `MAX`, `ALL` and `COUNT` and answer with one `ESEARCH` response instead of `SEARCH`. With CONDSTORE loaded, a search with the `MODSEQ` key adds the highest mod-sequence of the returned messages. + +```text +C: A3 SEARCH RETURN (MIN MAX COUNT) UNSEEN +S: * ESEARCH (TAG "A3") MIN 2 MAX 4 COUNT 3 +S: A3 OK SEARCH completed +C: A4 UID SEARCH RETURN (ALL) FROM alice +S: * ESEARCH (TAG "A4") UID ALL 1,4 +S: A4 OK UID SEARCH completed +C: A5 SEARCH RETURN (FOO) ALL +S: A5 BAD Unknown SEARCH result option FOO +C: A6 SEARCH CHARSET UTF-8 RETURN (ALL) ALL +S: A6 BAD Invalid search key RETURN +``` + +Unknown result options are `BAD` (RFC 4466 section 2.6.1), and so is `RETURN` after `CHARSET`, because the grammar puts the result options first. + +## SEARCHRES + +Loads ESEARCH. `SEARCH RETURN (SAVE)` stores the result in the session's search result variable, and `$` refers to it in FETCH, STORE, COPY, MOVE, UID EXPUNGE, SEARCH and their UID variants. With ESORT, `SAVE` works for SORT too, and with MULTISEARCH for the ESEARCH command when the selected mailbox is the only one searched. + +```text +C: A3 SEARCH RETURN (SAVE) FROM alice +S: A3 OK SEARCH completed +C: A4 FETCH $ (FLAGS) +S: * 1 FETCH (FLAGS (\Seen)) +S: * 4 FETCH (FLAGS ()) +S: A4 OK FETCH Completed +C: A5 FETCH 1,$ (FLAGS) +S: A5 BAD Invalid sequence set +``` + +- `$` must be used alone. `1,$` and other combinations with numbers are `BAD`. +- `SAVE` alone sends no ESEARCH response. With `MIN` or `MAX`, only those messages are saved (RFC 5182 section 2.4). +- An `OK` stores the result, a `NO` empties the variable and a `BAD` leaves it as it was (RFC 5182 section 2.1). SELECT, EXAMINE, CLOSE, UNSELECT and UNAUTHENTICATE empty it. +- Expunged messages drop out of the saved result. As `$` holds no message numbers, commands that use it can be pipelined. + +## SORT + +`SORT` and `UID SORT` with all RFC 5256 sort keys: `ARRIVAL`, `CC`, `DATE`, `FROM`, `SIZE`, `SUBJECT` and `TO`, each optionally preceded by `REVERSE`. Strings are compared with the `i;unicode-casemap` collation (RFC 5051), base subjects follow RFC 5256 section 2.1 and sent dates section 2.2. Ties are broken by mailbox order. With CONDSTORE, a `MODSEQ` search key appends the highest mod-sequence to the SORT response (RFC 7162 section 3.1.9). + +```text +C: A3 SORT (REVERSE DATE) UTF-8 ALL +S: * SORT 4 3 2 1 +S: A3 OK SORT completed +C: A4 UID SORT (FROM SUBJECT) US-ASCII ALL +S: * SORT 1 4 2 3 +S: A4 OK UID SORT completed +C: A5 SORT (REVERSE REVERSE DATE) UTF-8 ALL +S: A5 BAD Invalid sort criterion REVERSE +C: A6 SORT (DATE) ISO-8859-1 ALL +S: A6 NO [BADCHARSET (US-ASCII UTF-8)] Unsupported charset ISO-8859-1 +C: A7 SORT () UTF-8 ALL +S: A7 BAD SORT expects a list of sort criteria +``` + +Following RFC 5256 section 5, SORT and THREAD refuse with `BAD` a charset that is not an atom or a quoted string (a literal), an empty sort criteria list, and `REVERSE` that is not followed by a sort key. `I18NLEVEL=1` is not advertised, as SEARCH matches strings with ASCII case folding only. + +For a Date header with an invalid or missing time, the sent date is 00:00:00 of that date. A message without a usable Date header sorts by its internal date (RFC 5256 section 2.2). + +## SORT=DISPLAY + +Loads SORT and adds the `DISPLAYFROM` and `DISPLAYTO` sort keys (RFC 5957), which sort by the display name of the first From or To address, or by the address when there is no display name. + +```text +C: A3 SORT (DISPLAYFROM) UTF-8 ALL +S: * SORT 1 4 2 3 +S: A3 OK SORT completed +``` + +## ESORT + +Loads SORT and ESEARCH. `SORT RETURN (...)` and `UID SORT RETURN (...)` take the `MIN`, `MAX`, `ALL` and `COUNT` result options and answer with an ESEARCH response. `MIN` is the first and `MAX` the last message in sort order, and `ALL` lists the messages in sort order. + +```text +C: A3 SORT RETURN (MIN MAX ALL COUNT) (REVERSE ARRIVAL) UTF-8 ALL +S: * ESEARCH (TAG "A3") MIN 4 MAX 1 ALL 4,3,2,1 COUNT 4 +S: A3 OK SORT completed +``` + +The `CONTEXT`, `UPDATE` and `PARTIAL` result options are `BAD` for SORT unless CONTEXT=SORT is loaded. `PARTIAL` also works for SORT when the PARTIAL plugin is loaded. + +## THREAD + +`THREAD` and `UID THREAD` with the `ORDEREDSUBJECT` and `REFERENCES` algorithms of RFC 5256 section 3. Each algorithm is its own plugin and capability, load both to support both. REFERENCES is the full algorithm, threading by the Message-ID, In-Reply-To and References headers. + +```javascript +const server = imapkit({ plugins: ['THREAD=ORDEREDSUBJECT', 'THREAD=REFERENCES'] }); +``` + +```text +C: A2 CAPABILITY +S: * CAPABILITY IMAP4rev1 THREAD=ORDEREDSUBJECT THREAD=REFERENCES +S: A2 OK Completed +C: A4 THREAD REFERENCES UTF-8 ALL +S: * THREAD (1 2)(3 4) +S: A4 OK THREAD completed +C: A5 UID THREAD ORDEREDSUBJECT UTF-8 ALL +S: * THREAD (1 2)(3 4) +S: A5 OK UID THREAD completed +C: A6 THREAD "REFERENCES" UTF-8 ALL +S: A6 BAD THREAD expects a threading algorithm atom +``` + +The threading algorithm must be an atom, as in the RFC 5256 grammar. The same charset checks as for SORT apply. + +## CONTEXT=SEARCH and CONTEXT=SORT + +`CONTEXT=SEARCH` loads ESEARCH and adds the `UPDATE`, `CONTEXT` and `PARTIAL` result options to SEARCH and UID SEARCH, and the `CANCELUPDATE` command. `CONTEXT=SORT` loads ESORT and CONTEXT=SEARCH and adds the same options to SORT and UID SORT. + +With `UPDATE`, the session receives `ADDTO` and `REMOVEFROM` ESEARCH updates as messages start or stop matching, whether this session or another one changed them. REMOVEFROM comes before the EXPUNGE response, ADDTO after EXISTS and FETCH. For SORT, the updates carry context positions in sort order. + +In this transcript, sessions A and B both have INBOX selected. `A C:` lines are sent by session A, `A S:` lines are what it receives: + +```text +A C: A3 SEARCH RETURN (UPDATE COUNT) UNSEEN +A S: * ESEARCH (TAG "A3") COUNT 3 +A S: A3 OK SEARCH completed +B C: B3 STORE 2 +FLAGS (\Seen) +B S: * 2 FETCH (FLAGS (\Seen)) +B S: B3 OK STORE completed +A C: A4 NOOP +A S: * 2 FETCH (UID 2 FLAGS (\Seen)) +A S: * ESEARCH (TAG "A3") REMOVEFROM (0 2) +A S: A4 OK Completed +A C: A5 CANCELUPDATE "A3" +A S: A5 OK Updates cancelled +``` + +- Updates end with `CANCELUPDATE "tag"` or when the mailbox is closed (SELECT, EXAMINE, CLOSE, UNSELECT). `CANCELUPDATE` takes one or more quoted tags, a tag that is not an updating search gets `NO Unknown tag`. +- The server option `maxSearchContexts` (default 10) limits the updating searches of a session. Above it, the search still runs and its other result options are honored, but an untagged `NO [NOUPDATE "tag"]` tells the client that it gets no updates (RFC 5267 section 4.3.1). +- Reusing the tag of an active updating search is `BAD`. +- `CONTEXT` is accepted as a hint and ignored. +- `PARTIAL` takes a positive range like `1:100`. The negative ranges of RFC 9394 need the PARTIAL plugin. +- Message numbers in the search program are taken as they were when the search ran (RFC 5267 section 4.3). + +## PARTIAL + +Loads ESEARCH. Adds the `PARTIAL` result option of SEARCH (and of SORT with ESORT) and the `PARTIAL` modifier of FETCH and UID FETCH. A range counts results from 1, and a negative range like `-1:-100` counts from the last result. The FETCH modifier picks a window of the messages the sequence set matched and combines with `CHANGEDSINCE` of CONDSTORE. + +```text +C: A3 SEARCH RETURN (PARTIAL 1:2) ALL +S: * ESEARCH (TAG "A3") PARTIAL (1:2 1:2) +S: A3 OK SEARCH completed +C: A4 UID SEARCH RETURN (PARTIAL -1:-2 COUNT) ALL +S: * ESEARCH (TAG "A4") UID PARTIAL (-1:-2 3:4) COUNT 4 +S: A4 OK UID SEARCH completed +C: A5 UID FETCH 1:* (FLAGS) (PARTIAL -1:-2) +S: * 3 FETCH (FLAGS (\Flagged) UID 3) +S: * 4 FETCH (FLAGS () UID 4) +S: A5 OK UID FETCH Completed +C: A6 SEARCH RETURN (PARTIAL 1:2 PARTIAL 3:4) ALL +S: A6 BAD SEARCH result option PARTIAL can be used only once +``` + +A command takes only one `PARTIAL` or `ALL` result option, and `PARTIAL` together with `ALL` is `BAD` (RFC 9394 section 3.1). A range must use the same sign on both ends and may not contain `*` or `0`. + +## MULTISEARCH + +Loads ESEARCH. Adds the `ESEARCH` command, which searches several mailboxes, also in the authenticated state: + +```text +ESEARCH IN (mailboxes "a" subtree "b" subtree-one "c" personal subscribed inboxes selected) RETURN (...) criteria +``` + +It sends one ESEARCH response with UIDs and the `TAG`, `MAILBOX` and `UIDVALIDITY` correlators for every mailbox with matches. + +```text +C: A2 ESEARCH IN (personal) RETURN (COUNT) ALL +S: * ESEARCH (TAG "A2" MAILBOX INBOX UIDVALIDITY 1) UID COUNT 4 +S: A2 OK ESEARCH completed +C: A3 ESEARCH IN (mailboxes INBOX) FROM alice +S: * ESEARCH (TAG "A3" MAILBOX INBOX UIDVALIDITY 1) UID ALL 1,4 +S: A3 OK ESEARCH completed +``` + +- Mailboxes that do not exist or are `\Noselect` are skipped. With [ACL](./access-control.md), so are mailboxes without the `r` right, and without `l` unless they are named under `mailboxes` or as a subtree root. +- A mailbox named twice is searched once. `inboxes` is INBOX. +- `SAVE` (SEARCHRES) is only allowed when the selected mailbox is the only one searched. `UPDATE` (CONTEXT=SEARCH) only applies to the selected mailbox. +- `selected-delayed` and scope options are `BAD`, RFC 7377 defines no scope options. +- With [UIDONLY](./synchronization.md#uidonly), message numbers in the criteria are refused like in UID SEARCH. diff --git a/docs/docs/extensions/synchronization.md b/docs/docs/extensions/synchronization.md new file mode 100644 index 0000000..1a27c84 --- /dev/null +++ b/docs/docs/extensions/synchronization.md @@ -0,0 +1,288 @@ +--- +title: Synchronization +sidebar_position: 4 +description: ENABLE, IDLE, CONDSTORE, QRESYNC, OBJECTID, NOTIFY and UIDONLY in ImapKit, the extensions a client uses to keep its cache in sync with the server. +--- + +# Synchronization + +These plugins cover how a client learns about changes: push notifications, mod-sequences, quick resynchronization, stable object ids and UID-only sessions. + +| Plugin | Capability | RFC | +| ----------- | ----------- | -------------------------------------------------- | +| `ENABLE` | `ENABLE` | [RFC 5161](https://www.rfc-editor.org/rfc/rfc5161) | +| `IDLE` | `IDLE` | [RFC 2177](https://www.rfc-editor.org/rfc/rfc2177) | +| `CONDSTORE` | `CONDSTORE` | [RFC 7162](https://www.rfc-editor.org/rfc/rfc7162) | +| `QRESYNC` | `QRESYNC` | [RFC 7162](https://www.rfc-editor.org/rfc/rfc7162) | +| `OBJECTID` | `OBJECTID` | [RFC 8474](https://www.rfc-editor.org/rfc/rfc8474) | +| `NOTIFY` | `NOTIFY` | [RFC 5465](https://www.rfc-editor.org/rfc/rfc5465) | +| `UIDONLY` | `UIDONLY` | [RFC 9586](https://www.rfc-editor.org/rfc/rfc9586) | + +Changes made by another session, or through the [control API](../control-api/overview.md), reach a session the same way. See [Multiple sessions](../guides/multiple-sessions.md) for when ImapKit reports them. + +## ENABLE + +Adds the `ENABLE` command. The extensions that can be enabled are those whose plugins are loaded: `CONDSTORE`, `QRESYNC`, `UIDONLY`, `UTF8=ACCEPT`, `IMAP4rev2` and `METADATA` (or `METADATA-SERVER`). They can be loaded in any order with ENABLE, and QRESYNC, UIDONLY, UTF8=ACCEPT and IMAP4rev2 load ENABLE themselves. + +- Capability names are matched case-insensitively. The `ENABLED` response lists only what this command enabled, in the advertised spelling (`IMAP4rev2`, `UTF8=ACCEPT`). Unknown names are ignored, as RFC 5161 section 3.1 requires. +- ENABLE needs a logged in session, and ImapKit refuses it with `BAD` once the session has selected a mailbox, since RFC 5161 section 3.1 says clients MUST NOT issue ENABLE once they SELECT or EXAMINE a mailbox. Servers do not have to check this, ImapKit does to catch the client bug. +- [UNAUTHENTICATE](./authentication-and-transport.md#unauthenticate) turns every enabled extension off. + +```text +C: A2 ENABLE CONDSTORE +S: * ENABLED CONDSTORE +S: A2 OK ENABLE completed +``` + +```text +C: A2 SELECT INBOX +S: ... +S: A2 OK [READ-WRITE] Completed +C: A3 ENABLE CONDSTORE +S: A3 BAD ENABLE is not allowed after SELECT or EXAMINE +``` + +## IDLE + +Adds the `IDLE` command. After the `+ idling` continuation the server sends changes as soon as they happen: new messages, expunges and flag changes by other sessions or the control API. The client ends IDLE with `DONE` (case-insensitive); any other line ends it with `BAD`. + +In the transcripts with two sessions, `A C:` lines are sent by session A and `A S:` lines are what it receives, the same for session B. Here A has INBOX selected and idles while B appends a message: + +```text +A C: A3 IDLE +A S: + idling +B C: B2 APPEND INBOX {20} +B S: + Go ahead +B C: Subject: hi +B C: +B C: hello +A S: * 5 EXISTS +B S: B2 OK APPEND Completed +A C: DONE +A S: A3 OK IDLE terminated +``` + +```text +C: A3 IDLE +S: + idling +C: NOOP +S: A3 BAD Invalid Idle continuation +``` + +An IDLE that lasts 30 minutes is ended by the server with `* BYE IDLE terminated` and the connection is closed. RFC 2177 lets a server log out a client that idles longer than that, which is why clients restart IDLE at least every 29 minutes. The timer does not keep the Node.js process alive. + +## CONDSTORE + +Adds mod-sequences (RFC 7162 section 3.1): + +- Every message has a MODSEQ value, and every mailbox a HIGHESTMODSEQ. A message in storage can set its own `MODSEQ`, others get the next value when the storage is loaded or the message is added. +- SELECT and EXAMINE report `[HIGHESTMODSEQ n]`, and take the `(CONDSTORE)` parameter. +- Changing flags increments the MODSEQ of each changed message. Expunging increments HIGHESTMODSEQ. +- `FETCH ... (MODSEQ)` and the `CHANGEDSINCE` FETCH modifier. +- The `UNCHANGEDSINCE` STORE modifier. Messages changed since then are not stored and are listed in `[MODIFIED ...]`. +- The `MODSEQ` search key. The entry name and type are checked but ignored, as the mod-sequence is not stored per flag. +- `STATUS (HIGHESTMODSEQ)`. +- Flag changes by other sessions include MODSEQ once CONDSTORE is enabled in the session. +- SELECT and EXAMINE send `* OK [CLOSED]` when they close the selected mailbox (RFC 7162 section 3.2.11). + +CONDSTORE is enabled in a session by `ENABLE CONDSTORE` (with the ENABLE plugin) or by the first CONDSTORE enabling command: `SELECT`/`EXAMINE` with `(CONDSTORE)`, `STATUS (HIGHESTMODSEQ)`, FETCH of `MODSEQ` or with `CHANGEDSINCE`, STORE with `UNCHANGEDSINCE`, or a search with the `MODSEQ` key. ImapKit gives every message changed by one STORE its own MODSEQ. + +```text +C: A2 SELECT INBOX (CONDSTORE) +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted +S: * 4 EXISTS +S: * 0 RECENT +S: * OK [UNSEEN 2] First unseen message +S: * OK [UIDVALIDITY 1] UIDs valid +S: * OK [UIDNEXT 5] Predicted next UID +S: * OK [HIGHESTMODSEQ 5] Highest +S: A2 OK [READ-WRITE] Completed, CONDSTORE is now enabled +C: A3 FETCH 1:* (FLAGS) (CHANGEDSINCE 1) +S: * 1 FETCH (FLAGS (\Seen) MODSEQ (2)) +S: * 2 FETCH (FLAGS () MODSEQ (3)) +S: * 3 FETCH (FLAGS (\Flagged) MODSEQ (4)) +S: * 4 FETCH (FLAGS () MODSEQ (5)) +S: A3 OK FETCH Completed +C: A4 STORE 1:2 (UNCHANGEDSINCE 1) +FLAGS (\Answered) +S: A4 OK [MODIFIED 1,2] STORE completed +C: A5 UID SEARCH MODSEQ 1 +S: * SEARCH 1 2 3 4 (MODSEQ 5) +S: A5 OK UID SEARCH completed +C: A6 SELECT Archive +S: * OK [CLOSED] Previous mailbox closed +S: ... +S: * OK [HIGHESTMODSEQ 1] Highest +S: A6 OK [READ-WRITE] Completed +``` + +Mod-sequence values that break the RFC 7162 grammar (not a number, `0` for CHANGEDSINCE, more than 63 bits) are `BAD`. Without the CONDSTORE plugin, messages have no MODSEQ and the CONDSTORE syntax is not accepted. + +## QRESYNC + +Loads CONDSTORE and ENABLE. After `ENABLE QRESYNC`: + +- `SELECT` and `EXAMINE` take `(QRESYNC (uidvalidity modseq [known-uids] [(known-sequence-set known-uid-set)]))` and report `VANISHED (EARLIER)` for the expunged UIDs and FETCH responses for the messages whose flags changed. +- `UID FETCH ... (CHANGEDSINCE n VANISHED)` reports the UIDs expunged since mod-sequence n. +- Expunges are reported with `VANISHED` instead of `EXPUNGE`: EXPUNGE, UID EXPUNGE, MOVE, expunges by other sessions and IDLE notifications. + +Expunged UIDs are remembered with their mod-sequence. UIDs missing from the initial storage (gaps below UIDNEXT) count as expunged before the server started. + +```text +C: A2 SELECT INBOX (QRESYNC (1 1)) +S: A2 BAD QRESYNC parameter requires ENABLE QRESYNC +C: A3 ENABLE QRESYNC +S: * ENABLED QRESYNC +S: A3 OK ENABLE completed +C: A4 SELECT INBOX +S: ... +S: * OK [HIGHESTMODSEQ 5] Highest +S: A4 OK [READ-WRITE] Completed +C: A5 STORE 2 +FLAGS (\Deleted) +S: * 2 FETCH (FLAGS (\Deleted) MODSEQ (6) UID 2) +S: A5 OK STORE completed +C: A6 UID EXPUNGE 2 +S: * VANISHED 2 +S: A6 OK [HIGHESTMODSEQ 7] UID EXPUNGE completed +C: A7 SELECT INBOX (QRESYNC (1 5 1:4)) +S: * OK [CLOSED] Previous mailbox closed +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted +S: * 3 EXISTS +S: * 0 RECENT +S: * OK [UNSEEN 2] First unseen message +S: * OK [UIDVALIDITY 1] UIDs valid +S: * OK [UIDNEXT 5] Predicted next UID +S: * OK [HIGHESTMODSEQ 7] Highest +S: * VANISHED (EARLIER) 2 +S: A7 OK [READ-WRITE] Completed +C: A8 UID FETCH 1:* (FLAGS) (CHANGEDSINCE 5 VANISHED) +S: * VANISHED (EARLIER) 2 +S: A8 OK UID FETCH Completed +``` + +(UID EXPUNGE comes from the [UIDPLUS](./messages.md#uidplus) plugin.) + +Strict checks, all answered with `BAD`: + +- the QRESYNC SELECT parameter or the `VANISHED` modifier without `ENABLE QRESYNC` +- `VANISHED` with `FETCH` instead of `UID FETCH`, or without `CHANGEDSINCE` +- a UIDVALIDITY or mod-sequence of `0`, `*` in the UID sets, and sequence match sets that are not in ascending order or not of the same size + +## OBJECTID + +Adds the object identifiers of RFC 8474: + +- `MAILBOXID`: a response code of SELECT, EXAMINE and CREATE, and a STATUS item. +- `EMAILID` and `THREADID`: FETCH items and SEARCH keys. + +Ids are generated (`F1`, `M1`, `T1` ...) unless the storage sets a `MAILBOXID` for a mailbox or an `EMAILID` and `THREADID` for a message. Storage values must be valid object ids (1 to 255 letters, digits, `_` or `-`), a MAILBOXID can not repeat, and messages with the same EMAILID must have the same THREADID, otherwise the server throws when it is created. + +COPY, MOVE and RENAME of INBOX keep the EMAILID and THREADID of a message. Messages are threaded by their `Message-ID`, `In-Reply-To` and `References` headers across all mailboxes, and a message joins the thread of the nearest known parent when it is added. + +```text +C: A2 SELECT INBOX +S: ... +S: * OK [MAILBOXID (F7)] Ok +S: A2 OK [READ-WRITE] Completed +C: A3 FETCH 1:4 (EMAILID THREADID) +S: * 1 FETCH (EMAILID (M1) THREADID (T1)) +S: * 2 FETCH (EMAILID (M2) THREADID (T1)) +S: * 3 FETCH (EMAILID (M3) THREADID (T2)) +S: * 4 FETCH (EMAILID (M4) THREADID (T2)) +S: A3 OK FETCH Completed +C: A4 STATUS Archive (MAILBOXID) +S: * STATUS Archive (MAILBOXID (F1)) +S: A4 OK Status completed +C: A5 CREATE Lists +S: A5 OK [MAILBOXID (F8)] CREATE completed +C: A6 SEARCH THREADID T1 +S: * SEARCH 1 2 +S: A6 OK SEARCH completed +``` + +With [X-GM-EXT-1](./gmail.md) loaded too, the messages of a THREADID share one `X-GM-THRID`. + +## NOTIFY + +Adds `NOTIFY SET [STATUS] (filter (events)) ...` and `NOTIFY NONE` (RFC 5465). + +| Supported | Values | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Filters | `selected`, `selected-delayed`, `inboxes` (same as `personal`), `personal`, `subscribed`, `subtree`, `mailboxes` | +| Events | `MessageNew` (with fetch attributes for the selected mailbox), `MessageExpunge`, `FlagChange`, `MailboxName` (`LIST` with `OLDNAME` for RENAME), `SubscriptionChange`, and with METADATA loaded `MailboxMetadataChange` and `ServerMetadataChange` | + +How events are delivered: + +- Events are sent as soon as they happen, also between commands, except EXPUNGE (or VANISHED) with `selected-delayed`, and except during FETCH, STORE and SEARCH. +- After the first NOTIFY a session only hears about the events it asked for, also for the selected mailbox. Changes made by the session itself are not reported. +- Other mailboxes are reported with STATUS: MESSAGES and UIDNEXT, UNSEEN when the `\Seen` count changed, and HIGHESTMODSEQ when CONDSTORE is enabled. With ACL, only mailboxes with the `l` and `r` rights are reported, and granting or revoking `l` counts as MailboxName. +- Fetch attributes of MessageNew never set `\Seen`. +- `server.notifyOverflow([connection])` sends `* OK [NOTIFICATIONOVERFLOW]` and turns NOTIFY off, for testing how a client recovers. Without an argument it applies to every session with NOTIFY. + +Session A has INBOX selected. Session B creates a mailbox, then selects INBOX and expunges message 4 (its SELECT and STORE are left out): + +```text +A C: A3 NOTIFY SET (selected (MessageNew (UID FLAGS) MessageExpunge)) (personal (MessageNew MessageExpunge MailboxName)) +A S: A3 OK NOTIFY completed +B C: B2 CREATE Receipts +B S: B2 OK CREATE completed +A S: * LIST (\HasNoChildren) "/" Receipts +B C: B5 EXPUNGE +B S: * 4 EXPUNGE +B S: B5 OK EXPUNGE Completed +A S: * 4 EXPUNGE +A S: * 3 EXISTS +``` + +Strict checks (RFC 5465 sections 3.1, 5 and 6.1): + +```text +C: A4 NOTIFY SET (selected (MessageNew MessageExpunge MailboxName)) +S: A4 BAD MailboxName can not be used with SELECTED or SELECTED-DELAYED +C: A5 NOTIFY SET (personal (FlagChange)) +S: A5 BAD FlagChange and AnnotationChange require MessageNew and MessageExpunge +C: A6 NOTIFY SET (inboxes (MessageNew MessageExpunge AnnotationChange)) +S: A6 NO [BADEVENT (MessageNew MessageExpunge FlagChange MailboxName SubscriptionChange)] Unsupported NOTIFY events +``` + +Also `BAD`: MessageNew without MessageExpunge or the other way round, two `selected` filters, fetch attributes outside the selected filters, empty event or mailbox lists, and `NOTIFY SET` without event groups. Unknown events, including AnnotationChange (there is no ANNOTATE support), get `NO [BADEVENT (...)]` listing the supported events. The fetch attributes of the CONTEXT=SEARCH `UPDATE` option (RFC 5465 section 7) are not supported. + +## UIDONLY + +Loads ENABLE. After `ENABLE UIDONLY`, the session must use UIDs everywhere (RFC 9586): + +- `FETCH`, `STORE`, `SEARCH`, `COPY`, `MOVE`, `SORT`, `THREAD` and `REPLACE` are refused with `BAD [UIDREQUIRED]`, use their UID variants. A synchronizing literal of such a command is refused before it is sent. +- Message numbers in the criteria of UID SEARCH, UID SORT, UID THREAD and the ESEARCH command of MULTISEARCH, and the QRESYNC message sequence match data, are refused the same way. +- FETCH responses become `* UIDFETCH (...)`. The `UID` item is only included when UID FETCH asks for it. +- Expunges are reported with `VANISHED`, and SELECT does not send `[UNSEEN n]`. EXISTS and RECENT do not change. + +Load [UIDPLUS](./messages.md#uidplus) as well for `UID EXPUNGE` and `COPYUID`. + +```text +C: A2 ENABLE UIDONLY +S: * ENABLED UIDONLY +S: A2 OK ENABLE completed +C: A3 SELECT INBOX +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted +S: * 4 EXISTS +S: * 0 RECENT +S: * OK [UIDVALIDITY 1] UIDs valid +S: * OK [UIDNEXT 5] Predicted next UID +S: A3 OK [READ-WRITE] Completed +C: A4 FETCH 1 (FLAGS) +S: A4 BAD [UIDREQUIRED] FETCH is not allowed once UIDONLY is enabled, use UID FETCH +C: A5 UID FETCH 1:2 (FLAGS) +S: * 1 UIDFETCH (FLAGS (\Seen)) +S: * 2 UIDFETCH (FLAGS ()) +S: A5 OK UID FETCH Completed +C: A6 UID SEARCH 1:2 +S: A6 BAD [UIDREQUIRED] Message numbers are not allowed in the search criteria once UIDONLY is enabled, use UID +C: A7 UID STORE 2 +FLAGS (\Deleted) +S: * 2 UIDFETCH (FLAGS (\Deleted)) +S: A7 OK UID STORE completed +C: A8 UID EXPUNGE 2 +S: * VANISHED 2 +S: A8 OK UID EXPUNGE completed +``` diff --git a/docs/docs/faults/quirk-presets.md b/docs/docs/faults/quirk-presets.md new file mode 100644 index 0000000..36fbb63 --- /dev/null +++ b/docs/docs/faults/quirk-presets.md @@ -0,0 +1,245 @@ +--- +title: Quirk Presets +sidebar_position: 2 +description: Named sets of script rules that make ImapKit behave like Apache James, Yahoo, Microsoft 365, or a server without MOVE or UIDPLUS. +--- + +# Quirk Presets + +A quirk preset makes ImapKit behave like a known real server, so a client test reproduces that server's bug in every run, without access to the server itself. A preset is a set of [script rules](./scripted-faults.md), plugins it leaves out, or both. + +```javascript +import imapkit from 'imapkit'; + +const server = imapkit({ + plugins: ['IDLE', 'MOVE'], + quirks: ['james-fetchgroup', 'm365-throttle'], + scriptSeed: 42 +}); +``` + +From the command line, `--quirk` takes a comma separated list or can be repeated, and `IMAPKIT_QUIRKS` does the same from the environment: + +```bash +imapkit -p 1143 --plugin=IDLE,MOVE --quirk=no-move --quirk=m365-throttle --script-seed=5 +``` + +## The `quirks` option + +- `quirks` takes a name or a list of names. Names are case-insensitive. +- An unknown name fails the server constructor with the list of known ones: `Unknown quirk "james". Available quirks: james-fetchgroup, james-late-fetch, yahoo-quoted-sections, m365-throttle, no-uidplus, no-move`. +- The rules of the presets are added after the rules of the `script` option, in the order the presets are listed. Rules you add later with `server.script.add()` come after them. As the first matching rule handles an event, a rule of your own in `script` can take an event before a preset sees it. +- The rules of a preset are ordinary script rules: they show up in `server.script.rules`, count `hits`, emit the `script` event, and `server.script.clear()` removes them too. +- `removePlugins` of a preset keeps the plugins out even when the `plugins` option lists them, and also when another plugin requires them. With `IMAP4rev2`, `no-move` and `no-uidplus` still remove MOVE and UIDPLUS, which gives a server that [RFC 9051](https://www.rfc-editor.org/rfc/rfc9051) does not allow. Use them with IMAP4rev1 servers. + +## The presets + +| Quirk | Reproduces | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `james-fetchgroup` | Apache James FetchGroup: only the first section asked for a part in one FETCH is answered, later ones for the same part are empty | +| `james-late-fetch` | Apache James: 1 in 4 FETCH responses come after the tagged OK of their command | +| `yahoo-quoted-sections` | Yahoo: short body sections (up to 100 octets without line breaks) are quoted strings instead of literals | +| `m365-throttle` | Microsoft 365: 1 in 10 commands (not LOGOUT) is refused with `BAD Request is throttled. Suggested Backoff Time: 1000 milliseconds` | +| `no-uidplus` | a server without UIDPLUS: no APPENDUID, COPYUID or UID EXPUNGE | +| `no-move` | a server without MOVE | + +The transcripts below come from real runs against ImapKit, with this message as UID 1 in INBOX: + +```text +From: alice@example.com +Subject: parts +Content-Type: multipart/mixed; boundary=x + +--x +Content-Type: text/plain + +hello +--x +Content-Type: text/plain + +second part +--x-- +``` + +### james-fetchgroup + +A client that wants the MIME headers and the body of an attachment often asks for both in one FETCH. Apache James answers only the first section it is asked for a part, and every later section of the same part comes back as an empty literal. `BODY[2.MIME] BODY[2]` gives a zero-length body, the reverse order loses the headers. Sections of different parts are both answered. + +A client that trusts the server stores an empty attachment. A careful one notices the zero length where `BODYSTRUCTURE` promised more and fetches the section again on its own. + +```text +C: A3 FETCH 1 (BODY.PEEK[2.MIME] BODY.PEEK[2]) +S: * 1 FETCH (BODY[2.MIME] {28} +S: Content-Type: text/plain +S: +S: BODY[2] {0} +S: ) +S: A3 OK FETCH Completed +C: A4 FETCH 1 (BODY.PEEK[2] BODY.PEEK[2.MIME]) +S: * 1 FETCH (BODY[2] {11} +S: second part BODY[2.MIME] {0} +S: ) +S: A4 OK FETCH Completed +C: A5 FETCH 1 (BODY.PEEK[1] BODY.PEEK[2]) +S: * 1 FETCH (BODY[1] {5} +S: hello BODY[2] {11} +S: second part) +S: A5 OK FETCH Completed +``` + +`HEADER`, `TEXT` and `MIME` belong to the part they are on: `2.MIME`, `2.HEADER` and `2` are the same part, `HEADER` and `TEXT` without a number belong to the message itself. The preset applies to `FETCH` and `UID FETCH`. + +### james-late-fetch + +Now and then an untagged FETCH response arrives after the tagged OK of its command, a quarter of the time on average. A client that collects FETCH data only until the tagged response misses those messages, or attributes them to the next command. The responses are held back with [`defer: 'tagged'`](./scripted-faults.md#defer), so every FETCH is still answered, some of them late. With `scriptSeed: 3`, six identical FETCH commands gave: + +```text +C: A3 FETCH 1 (UID) +S: * 1 FETCH (UID 1) +S: A3 OK FETCH Completed +C: A4 FETCH 1 (UID) +S: A4 OK FETCH Completed +S: * 1 FETCH (UID 1) +C: A5 FETCH 1 (UID) +S: * 1 FETCH (UID 1) +S: A5 OK FETCH Completed +C: A6 FETCH 1 (UID) +S: A6 OK FETCH Completed +S: * 1 FETCH (UID 1) +C: A7 FETCH 1 (UID) +S: * 1 FETCH (UID 1) +S: A7 OK FETCH Completed +C: A8 FETCH 1 (UID) +S: * 1 FETCH (UID 1) +S: A8 OK FETCH Completed +``` + +### yahoo-quoted-sections + +The value of a body section is an `nstring`, and a string is either quoted or a literal ([RFC 3501](https://www.rfc-editor.org/rfc/rfc3501) section 9). ImapKit sends sections as literals, Yahoo sends short ones as quoted strings. A client whose parser only expects a literal after `BODY[...]` breaks on the quoted form. + +The preset quotes sections of up to 100 octets of printable ASCII. A section with a line break can not be a quoted string, so it stays a literal: + +```text +C: A3 FETCH 1 (BODY.PEEK[1] BODY.PEEK[1.MIME]) +S: * 1 FETCH (BODY[1] "hello" BODY[1.MIME] {28} +S: Content-Type: text/plain +S: +S: ) +S: A3 OK FETCH Completed +``` + +### m365-throttle + +Microsoft 365 refuses commands it is throttling with a tagged BAD. Many clients treat BAD as a protocol error of their own and give up or reconnect, where they should wait and send the command again. The preset refuses 1 in 10 commands, except LOGOUT. Any other command can be hit, LOGIN included, so the client's login code needs the retry too. + +With `scriptSeed: 5` the fifth command was throttled. This run used an INBOX with two messages, the storage of the [scripted faults cookbook](./scripted-faults.md#cookbook): + +```text +S: * OK ImapKit ready for rumble +C: A1 LOGIN testuser testpass +S: A1 OK User logged in +C: A2 SELECT INBOX +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted +S: * 2 EXISTS +S: * 0 RECENT +S: * OK [UNSEEN 1] First unseen message +S: * OK [UIDVALIDITY 1] UIDs valid +S: * OK [UIDNEXT 3] Predicted next UID +S: A2 OK [READ-WRITE] Completed +C: A3 NOOP +S: A3 OK Completed +C: A4 FETCH 1 (FLAGS) +S: * 1 FETCH (FLAGS ()) +S: A4 OK FETCH Completed +C: A5 FETCH 2 (FLAGS) +S: A5 BAD Request is throttled. Suggested Backoff Time: 1000 milliseconds +C: A6 FETCH 2 (FLAGS) +S: * 2 FETCH (FLAGS ()) +S: A6 OK FETCH Completed +C: A7 LOGOUT +S: * BYE LOGOUT received +S: A7 OK Completed +[connection closed] +``` + +A throttled command did not run: a throttled LOGIN leaves the session unauthenticated, a throttled STORE changes no flags. + +### no-uidplus and no-move + +These presets leave plugins out. The client has to work without APPENDUID, COPYUID and UID EXPUNGE ([RFC 4315](https://www.rfc-editor.org/rfc/rfc4315)), and without MOVE ([RFC 6851](https://www.rfc-editor.org/rfc/rfc6851)), falling back to COPY, STORE `\Deleted` and EXPUNGE. With `plugins: ['IDLE', 'MOVE', 'UIDPLUS']` and `quirks: ['no-move', 'no-uidplus']`: + +```text +C: A2 CAPABILITY +S: * CAPABILITY IMAP4rev1 IDLE +S: A2 OK Completed +C: A3 SELECT INBOX +... +S: A3 OK [READ-WRITE] Completed +C: A4 MOVE 1 INBOX +S: A4 BAD Invalid command MOVE +``` + +## Repeatable runs with scriptSeed + +`james-late-fetch` and `m365-throttle` decide with [`chance`](./scripted-faults.md#matchers). The random numbers come from the `scriptSeed` option (`--script-seed`, `IMAPKIT_SCRIPT_SEED`): with the same seed and a client that sends the same commands in the same order, every run gets the same faults. Without a seed, each server picks a random one. + +All `chance` rules of a server, from presets and from your own rules, draw from the same sequence, in the order the events happen. A client that sends one more command, or two sessions that race, shift which commands get hit. See [Repeatable tests](./repeatable-tests.md). + +## Presets as data + +The presets are exported, so you can read what they do and copy one when it does not fit: + +```javascript +import { quirks } from 'imapkit'; +// CommonJS: const { quirks } = require('imapkit'); + +console.log(Object.keys(quirks)); +// [ 'james-fetchgroup', 'james-late-fetch', 'yahoo-quoted-sections', 'm365-throttle', 'no-uidplus', 'no-move' ] + +console.log(quirks['james-late-fetch']); +// { +// description: 'Apache James: now and then (1 in 4) a FETCH response comes after the tagged OK of its command', +// rules: [ { on: 'response', command: [ 'FETCH', 'UID FETCH' ], untagged: true, chance: 0.25, defer: 'tagged' } ] +// } +``` + +Each preset has a `description`, and `rules` (script rules), `removePlugins` (plugin names), or both. The TypeScript type is `Quirk`. + +To change a preset, copy its rules into the `script` option instead of naming it in `quirks`: + +```javascript +import imapkit, { quirks } from 'imapkit'; + +// late FETCH responses half of the time, and only for UID FETCH +const server = imapkit({ + scriptSeed: 1, + script: quirks['james-late-fetch'].rules.map(rule => ({ ...rule, command: 'UID FETCH', chance: 0.5 })) +}); +``` + +## Writing your own preset + +A preset is nothing more than script rules and a list of plugins, so a quirk of a server you meet in the field becomes a few lines of configuration. Keep the rules in one place and spread them into the `script` option, leaving the plugins out of `plugins` yourself: + +```javascript title="test/quirks/legacy-server.js" +// a server that answers NOOP with BAD now and then, sends LIST names as literals, +// and has no IDLE +export const legacyServer = { + rules: [ + { on: 'command', command: 'NOOP', chance: 0.2, send: '$TAG BAD Try again\r\n' }, + { on: 'response', command: 'LIST', untagged: true, literals: true } + ], + plugins: ['MOVE', 'UIDPLUS'] // IDLE left out +}; +``` + +```javascript +import imapkit from 'imapkit'; +import { legacyServer } from './quirks/legacy-server.js'; + +const server = imapkit({ plugins: legacyServer.plugins, script: legacyServer.rules, scriptSeed: 7 }); +``` + +For the command line, write the rules as JSON (strings for `match` and `send`, no functions) and pass the file with `--script`. Rules that need `mutate`, like `james-fetchgroup` and `yahoo-quoted-sections`, work only from JavaScript. diff --git a/docs/docs/faults/repeatable-tests.md b/docs/docs/faults/repeatable-tests.md new file mode 100644 index 0000000..8b0d025 --- /dev/null +++ b/docs/docs/faults/repeatable-tests.md @@ -0,0 +1,201 @@ +--- +title: Repeatable Tests +sidebar_position: 3 +description: Make every ImapKit run identical with fresh servers per test, scriptSeed for random faults, a fixed clock with the now option, seeded UID shuffles and validated storage fixtures. +--- + +# Repeatable Tests + +A test that fails once in fifty runs is worse than no test. ImapKit keeps everything that could vary between runs under your control: the starting state, the random numbers of script rules, the clock, and the order of shuffled UIDs. + +## A fresh server per test + +Nothing is written to disk, and every server starts from its `storage` option. The storage is copied (with `structuredClone`) when the server is built, so one fixture object can feed any number of servers without one test's changes leaking into the next. + +`server.start()` listens on a free port and resolves with it, so test files can run in parallel. `server.stop()` closes the server and every session. + +```javascript title="test/sync.test.js" +import { describe, it, beforeEach, afterEach } from 'node:test'; +import imapkit from 'imapkit'; +import { inbox } from './fixtures/inbox.js'; + +describe('sync', () => { + let server; + let port; + + beforeEach(async () => { + server = imapkit({ plugins: ['IDLE', 'CONDSTORE'], storage: inbox, now: Date.parse('2026-01-15T09:30:00Z'), scriptSeed: 1 }); + port = await server.start(); + }); + + afterEach(() => server.stop()); + + it('syncs the inbox', async () => { + // connect the client to 127.0.0.1:port as testuser / testpass + }); +}); +``` + +UIDVALIDITY values are not taken from the clock either: a mailbox without `uidvalidity` in the storage gets 1, and a mailbox created later gets one more than the highest value in use. + +When one long running server is shared by many tests (for example the `imapkit` command, used from another language), [`server.control.reset()`](#controlreset) brings it back to the starting state between tests instead. + +## scriptSeed + +Script rules with [`chance`](./scripted-faults.md#matchers), and the [quirk presets](./quirk-presets.md) built on them (`james-late-fetch`, `m365-throttle`), fire on a random share of the events. The random numbers come from a seeded generator: + +```javascript +const server = imapkit({ + scriptSeed: 42, + script: { on: 'command', command: 'NOOP', chance: 0.3, send: '$TAG NO [LIMIT] Too many commands, slow down\r\n' } +}); +``` + +The command line takes `--script-seed=42` or `IMAPKIT_SCRIPT_SEED=42`. Without a seed, every server picks a random one. + +The same seed gives the same faults when the client does the same thing: + +- A random number is drawn only for an event that passed every other matcher of a `chance` rule (and its `nth` and `times`). Events that no `chance` rule matches do not use up numbers. +- All `chance` rules of a server draw from one sequence, in the order the events happen. One extra command, or two sessions whose commands interleave differently, shift which events are hit. For a repeatable run, keep the client's sequence of commands fixed, and give sessions that run in parallel their own rules (the `session` matcher) or their own server. + +To find a seed that hits a particular command, try seeds in a loop and keep the one that gives the run you want, then hard-code it in the test. + +## The `now` option + +Some dates are set by the server itself: the INTERNALDATE of a message that has none (a storage message without `internaldate`, an APPEND without a date, `control.addMessage()` without `internaldate`) and SAVEDATE. The `now` option fixes the clock for those: + +- a `Date` +- a timestamp in milliseconds +- a function that returns either, called every time the server needs the time + +`server.now()` returns the time the server uses, as a `Date`. + +```javascript +const server = imapkit({ + plugins: ['SAVEDATE'], + now: new Date('2026-01-15T09:30:00Z'), + storage: { INBOX: { messages: [{ uid: 1, raw: 'Subject: from storage\r\n\r\nHi\r\n' }] } } +}); + +server.now(); // 2026-01-15T09:30:00.000Z +``` + +Run with `TZ=UTC`, the stored message and an APPEND without a date both get the fixed time: + +```text +C: A2 APPEND INBOX {21} +S: + Go ahead +C: Subject: appended +C: +C: +S: A2 OK APPEND Completed +C: A3 SELECT INBOX +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted +S: * 2 EXISTS +S: * 1 RECENT +S: * OK [UNSEEN 1] First unseen message +S: * OK [UIDVALIDITY 1] UIDs valid +S: * OK [UIDNEXT 3] Predicted next UID +S: A3 OK [READ-WRITE] Completed +C: A4 FETCH 1:2 (INTERNALDATE SAVEDATE) +S: * 1 FETCH (INTERNALDATE "15-Jan-2026 09:30:00 +0000" SAVEDATE "15-Jan-2026 09:30:00 +0000") +S: * 2 FETCH (INTERNALDATE "15-Jan-2026 09:30:00 +0000" SAVEDATE "15-Jan-2026 09:30:00 +0000") +S: A4 OK FETCH Completed +``` + +:::tip +The dates are formatted in the time zone of the process. The same instant is `15-Jan-2026 11:30:00 +0200` with `TZ=Europe/Tallinn`. Set `TZ=UTC` (or any fixed zone) for the test run when you compare date strings. +::: + +A function gives the test a clock it can move: + +```javascript +let clock = Date.parse('2026-01-15T09:30:00Z'); +const server = imapkit({ now: () => clock }); + +server.control.addMessage('INBOX', { raw: 'Subject: a\r\n\r\n' }); +clock += 60 * 60 * 1000; // one hour later +server.control.addMessage('INBOX', { raw: 'Subject: b\r\n\r\n' }); + +server.control.listMessages('INBOX').map(message => message.internaldate); +// [ '15-Jan-2026 09:30:00 +0000', '15-Jan-2026 10:30:00 +0000' ] +``` + +`now` does not change timers: [`quiet`](./scripted-faults.md#events) rules, `delay` and `chunkDelay` run on the real clock. + +## Seeded UID shuffles + +[`server.control.resetUidValidity()`](../control-api/uidvalidity.md) gives a mailbox a new UIDVALIDITY, and with `uids: 'shuffle'` numbers the messages 1 to n in a random order, so an old UID now points to another message. That catches a client that keeps cached UIDs without checking UIDVALIDITY. `seed` makes the order repeatable: + +```javascript +const storage = { INBOX: { messages: ['a', 'b', 'c', 'd', 'e'].map((s, i) => ({ uid: i + 1, raw: 'Subject: ' + s + '\r\n\r\n' })) } }; + +imapkit({ storage }).control.resetUidValidity('INBOX', { uids: 'shuffle', seed: 7 }); +``` + +Two separate servers gave the same result: + +```json +{ + "uidvalidity": 2, + "uidnext": 6, + "uids": [ + { "uid": 1, "newUid": 4 }, + { "uid": 2, "newUid": 2 }, + { "uid": 3, "newUid": 3 }, + { "uid": 4, "newUid": 5 }, + { "uid": 5, "newUid": 1 } + ] +} +``` + +The shuffle has its own generator, so it does not use up numbers of `scriptSeed`. `seed` must be an integer. The other modes, `keep`, `renumber` and `offset`, have no randomness at all. + +## control.reset() + +`server.control.reset()` restores the mailboxes and users of the server options and disconnects every session with `BYE`. For repeatable runs it also matters what it does with the random numbers and the script rules: + +- The `scriptSeed` sequence starts again from the beginning, so the run after a reset gets the same faults as the first run. +- Script rules stay, and so do their `matched` and `hits` counters. A `times: 1` rule that fired before the reset does not fire again. Call `server.script.clear()` and add the rules again when every test needs them fresh. +- The storage is copied from the option again, so messages without `internaldate` get the time of `now` again. Without `now` they get the time of the reset. + +```javascript +afterEach(() => { + server.control.reset(); + server.script.clear(); +}); +``` + +The REST API has the same as `POST /v1/reset` (and `DELETE /v1/script/rules`), see [Testing from other languages](../guides/testing-from-other-languages.md). + +## Storage validation catches fixture typos + +The `storage` option is checked when the server is built. A key that looks like a typo of a known one, or a value of the wrong type, fails with the path of the problem, instead of turning into an empty mailbox and a confusing test failure: + +```javascript +imapkit({ storage: { INBOX: { message: [] } } }); +// Error: Invalid storage at "INBOX": unknown key "message", did you mean "messages"? + +imapkit({ storage: { INBOX: { messages: [{ raw: 'x' }, { raw: 'y' }, { raw: 'Subject: x\r\n\r\n', flag: ['\\Seen'] }] } } }); +// Error: Invalid storage at "INBOX".messages[2]: unknown key "flag", did you mean "flags"? + +imapkit({ storage: { INBOX: { uidValidity: 5 } } }); +// Error: Invalid storage at "INBOX": unknown key "uidValidity", did you mean "uidvalidity"? + +imapkit({ storage: { INBOX: { uidnext: 0 } } }); +// Error: Invalid storage at "INBOX".uidnext: must be an integer from 1 to 4294967295 +``` + +A key counts as a typo when it is a known key in another case, or one edit away from a known key (for keys longer than 3 characters). Plugins keep their own data on mailboxes and messages (`acl`, `metadata`, `MODSEQ` ...), so other keys are allowed. + +The package exports the check and the shape, so fixtures can be checked in a unit test of their own, or in an editor: + +```javascript +import { validateStorage, storageSchema } from 'imapkit'; + +validateStorage(fixture); // throws the same errors as the server +storageSchema; // JSON Schema (draft 2020-12) with $defs message, mailbox and namespace +``` + +`server.control.snapshot()` returns the storage in the same shape, so a state reached in one test can become the fixture of another: `imapkit({ storage: server.control.snapshot() })` starts from it. See [Storage](../guides/storage.md). diff --git a/docs/docs/faults/scripted-faults.md b/docs/docs/faults/scripted-faults.md new file mode 100644 index 0000000..33c8228 --- /dev/null +++ b/docs/docs/faults/scripted-faults.md @@ -0,0 +1,632 @@ +--- +title: Scripted Faults +sidebar_position: 1 +description: Script rules make ImapKit deviate from the protocol on purpose, so you can test how your IMAP client copes with broken, slow or unusual servers. +--- + +# Scripted Faults + +ImapKit is strict and correct by default. Real servers are not: they answer with NO when you least expect it, cut a response in the middle of a literal, send a FETCH after the tagged OK, or drop the connection while the client is idling. Script rules let you put those faults into a test at exactly the point you want, and nowhere else. + +A rule watches one kind of event (a command line from the client, a response the server is about to send, a quiet period ...), narrows it down with matchers, and says what to do instead with actions: + +```javascript title="test/faults.test.js" +import imapkit from 'imapkit'; + +const server = imapkit({ + plugins: ['IDLE'], + script: [ + // the first SELECT gets NO, the next ones run as usual + { on: 'command', command: 'SELECT', times: 1, send: '$TAG NO [UNAVAILABLE] Try again later\r\n' }, + // the body of message 1 is cut short and the connection dropped + { on: 'response', command: 'FETCH', match: /^\* 1 FETCH .*BODY\[\]/, truncate: 40 } + ] +}); +const port = await server.start(); +``` + +Faults change only the output and the handling of the lines a rule matches. The state of the server stays consistent: a LOGIN that a rule answers with `OK` does not log the session in, and a dropped EXPUNGE response still removes the message. Script rules are for tests only, a rule can send anything at all. + +## Adding and removing rules + +Rules come from two places: + +- the `script` server option, a rule or a list of rules, added when the server is built +- `server.script.add(rule)` or `server.script.add([rules])` at runtime, also while clients are connected + +Rules are checked in the order they were added. The rules of [quirk presets](./quirk-presets.md) come right after the rules of the `script` option, and rules added later with `server.script.add()` after those. + +`add()` returns a handle for a rule, or a list of handles for a list of rules: + +| Handle property | Meaning | +| --------------- | --------------------------------------------------------------------------------- | +| `id` | the number of the rule, 1 for the first rule added to the server | +| `rule` | a frozen copy of the rule, changing your object later does not change the rule | +| `matched` | events that matched the rule's matchers, also those before `nth` or after `times` | +| `hits` | events the rule actually handled | +| `remove()` | removes the rule | + +`server.script.rules` lists the handles in the order they are checked, and `server.script.clear()` removes every rule. + +```javascript +const [literals, late] = server.script.add([ + // every string the grammar allows is sent as a literal + { on: 'response', untagged: true, literals: true }, + // the FETCH response of UID 2 arrives after the tagged OK + { on: 'response', command: 'UID FETCH', untagged: true, match: /UID 2\b/, times: 1, defer: 'tagged' } +]); + +// ... run the client + +assert.strictEqual(late.hits, 1); +literals.remove(); +``` + +A list is checked as a whole before any rule of it is added: if one rule is invalid, none are added. + +:::note +`server.control.reset()` restores mailboxes and users but keeps the script rules, together with their `matched` and `hits` counters. A `times: 1` rule that already fired stays used up. Call `server.script.clear()` and add the rules again when a test needs them fresh. See [Repeatable tests](./repeatable-tests.md). +::: + +## Events + +Every rule has an `on` key that names the event it watches: + +| Event | What it is | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `greeting` | the `* OK ImapKit ready for rumble` greeting of a new connection | +| `command` | a complete command line from the client, with its literals. The rule acts instead of the parser and the command handler, so it also matches lines that do not parse and commands that do not exist | +| `input` | a line read by a command that takes over the input: `DONE` of IDLE, or a SASL response of AUTHENTICATE | +| `response` | every response the server sends with `connection.send()`, tagged and untagged, as the exact bytes about to go out, after every plugin and the core changed the response | +| `continuation` | a `+` continuation request: for a synchronizing literal, IDLE, or AUTHENTICATE | +| `quiet` | the session had no input and no output for `quietFor` milliseconds. Whatever the rule sends starts the next quiet time. While IDLE runs, the event belongs to the IDLE command, so `command: 'IDLE'` matches it | + +Some details worth knowing: + +- A `command` rule is chosen when the line arrives, so matchers like `state` see the session as it was at that moment. The rule acts when the command's turn comes, so the responses of pipelined commands stay in order. +- For a command with literals, the `command` event fires once for the whole command. Its `data` is the line with the literal data, without the final CRLF, e.g. `A1 APPEND INBOX {5}\r\nhello`. +- The name of an `AUTHENTICATE` command includes the mechanism (`AUTHENTICATE PLAIN`), and `UID` commands include the subcommand (`UID FETCH`). +- An unsolicited response (for example an EXISTS another session caused) belongs to the command that is running, or to the command that reads input, like IDLE. + +## Matchers + +All the matchers a rule gives have to match. The first rule that matches and is not used up handles the event, so a later rule can handle what an earlier one leaves alone. + +| Matcher | Events | Matches | +| ------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `command` | all but `greeting` | a command name or a list of names, case-insensitive (`'FETCH'`, `['FETCH', 'UID FETCH']`) | +| `tag` | all but `greeting` | the command tag, a string matches exactly, or a RegExp | +| `description` | `response`, `continuation` | the description the server passed to `connection.send()`, or a list of them (see [Finding descriptions](#finding-descriptions)) | +| `untagged` | `response` | `true` for untagged responses only, `false` for tagged ones only | +| `session` | all | the number of the connection, or a list of numbers, 1 for the first connection the server accepted | +| `state` | all | the session state, `'Not Authenticated'`, `'Authenticated'` or `'Selected'`, or a list of them | +| `user` | all | the name of the authenticated user | +| `mailbox` | all | the path of the selected mailbox (`'INBOX'`, `'Archive'`) | +| `match` | all | a RegExp, or a string with a regular expression, tested against the event `data`: the command line, the input line, or the output bytes. Global and sticky flags are dropped | +| `when` | all | a function that gets the [event context](#the-event-context) and returns true to match | +| `nth` | all | the rule fires from the nth matching event on (default 1) | +| `times` | all | the rule fires this many times at most, then lets later rules handle the event | +| `chance` | all | the rule fires on a matching event with this probability, a number from 0 to 1. The random numbers come from the `scriptSeed` option, see [Repeatable tests](./repeatable-tests.md) | +| `quietFor` | `quiet` (required there) | milliseconds without input or output, a positive integer | + +The counting matchers work in this order: an event that passes every other matcher counts toward `matched`, then `nth` and `times` decide whether the rule may fire, and only then is a random number drawn for `chance`. `when` runs last among the other matchers, so it sees only events that passed them. + +## Actions + +Actions say what happens instead of the usual behavior. A rule needs at least one action. + +| Action | Events | Effect | +| ------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `send` | all | output events: bytes sent instead of the output. `command` and `input`: bytes sent instead of processing the line. `quiet`: bytes sent when the time is up | +| `run` | `command`, `input` | process the line as usual after `send`, to add output before the real response | +| `drop` | `greeting`, `command`, `input`, `response`, `continuation` | output events: send nothing. `command` and `input`: ignore the line, the client gets no answer | +| `mutate` | `response`, `continuation` | `(response, context)` gets a copy of the response object before it is compiled, and changes it or returns another one | +| `literals` | `response` | sends every string of the response that the grammar allows as a literal | +| `defer` | `response` | holds an untagged response back: `'tagged'` sends it right after the tagged response of its command, `'next'` with the answer to the next command, before its first response | +| `before` | `greeting`, `response`, `continuation` | bytes sent before the output | +| `after` | `greeting`, `response`, `continuation` | bytes sent after the output | +| `delay` | `greeting`, `command`, `response`, `continuation` | milliseconds to wait. Output: before it goes out, and all later output waits behind it. Command: before the rule acts or the command runs, and later commands wait too | +| `chunk` | all | write the bytes in pieces of this many octets | +| `chunkDelay` | all, needs `chunk` | milliseconds between the pieces, default 10. `0` or `'tick'` sends each piece on its own event loop turn, so the pieces leave as separate TCP segments without a wall clock delay | +| `truncate` | all | send only this many octets of the bytes, then close the connection | +| `close` | all | close the connection after the bytes are sent. `'reset'` destroys the socket instead (a TCP RST where the runtime supports it), 20 ms after the bytes so the RST does not overtake them. Input that arrives meanwhile is not processed | + +### Bytes: strings, Buffers and functions + +`send`, `before` and `after` take a string, a Buffer, or a function that gets the [event context](#the-event-context) and returns a string or a Buffer. + +- Nothing is added: a response needs its own `\r\n`. +- `$TAG` in a string is replaced with the tag of the command, or `*` when the event has no tag (the greeting, unsolicited responses). +- A string is a binary string, one character per octet, like everywhere in ImapKit. If it contains a character above U+00FF, the whole string is sent as UTF-8 instead. So `'caf\xc3\xa9'` and `'café ✓'` both arrive as valid UTF-8, while `'café'` alone (all characters below U+0100) arrives as the single octet `0xE9` for `é`. +- A Buffer is sent as it is, `$TAG` is not replaced in it. + +### mutate + +`mutate` works on the response object (`{ tag, command, attributes }`) instead of bytes, so the result is still valid IMAP as far as the compiler can tell. The object is a copy: a notification that goes to several sessions changes only for the session the rule matched. + +```javascript +// every SELECT reports 5 messages, whatever the mailbox holds +server.script.add({ + on: 'response', + command: 'SELECT', + match: /EXISTS/, + mutate: response => { + response.attributes[0] = 5; + } +}); +``` + +If the changed response does not compile, a tagged response is sent as `NO [SERVERBUG] Failed to compile response` and an untagged one is dropped. Use `send` for output that is not valid IMAP. Continuation requests have a response object only when a plugin sends them with `connection.send()` (the error challenges of XOAUTH2 and OAUTHBEARER), for other continuations `mutate` does nothing. + +### literals + +`literals: true` turns every string of a response into a literal wherever the grammar allows one (string, nstring and astring in [RFC 9051](https://www.rfc-editor.org/rfc/rfc9051) section 9). This is valid IMAP that many clients still get wrong. Positions that take only a quoted string stay quoted: + +- the hierarchy delimiter of LIST, LSUB and NAMESPACE +- CHILDINFO values ([RFC 5258](https://www.rfc-editor.org/rfc/rfc5258) section 6) +- INTERNALDATE and SAVEDATE +- the media types `"TEXT"` and `"MESSAGE" "RFC822"` (or `"GLOBAL"`) in a body structure + +Atoms, numbers, NIL, response codes and human readable text stay as they are. `literals` works together with `mutate`, after it. + +### defer + +`defer` needs `untagged: true`, since a tagged response ends its command and there is nothing to hold it back for. `send`, `before` and `after` change the held output. `drop`, `delay`, `chunk`, `truncate` and `close` can not be combined with it. + +A response that does not belong to a command (one that arrives during IDLE belongs to IDLE) is held for the next tagged response or command. Held responses are dropped when the connection closes. + +### Rules for commands and input lines + +- `send` replaces the processing of the line. Add `run: true` to process the line as usual after the bytes. +- A rule with only `delay` (and `run`) delays the line and then processes it as usual. +- `chunk` and `truncate` need `send`, since a command or input line has no output of its own to cut. +- `drop` can not be combined with `send` or `run`, and `run` can not be combined with `close` or `truncate`. + +## The event context + +`when`, `mutate` and the function form of `send`, `before` and `after` get the event context: + +| Field | Value | +| ------------- | ---------------------------------------------------------------------------------- | +| `event` | the event name | +| `connection` | the `IMAPConnection` of the session | +| `session` | the connection number, 1 for the first one | +| `state` | the session state | +| `user` | the authenticated user, or null | +| `mailbox` | the path of the selected mailbox, or null | +| `tag` | the tag of the command the event belongs to, or null | +| `command` | the upper case name of that command (`'UID FETCH'`), or null | +| `data` | the command or input line, or the bytes about to be sent, as a binary string | +| `description` | `response` and `continuation`: the description passed to `connection.send()` | +| `response` | `response` (and `continuation` sent with `connection.send()`): the response object | +| `quiet` | `quiet`: milliseconds without input or output | + +```javascript +server.script.add({ + on: 'response', + command: 'NOOP', + untagged: false, + send: context => context.tag + ' OK Žluťoučký\r\n' // characters above U+00FF, sent as UTF-8 +}); +``` + +### Finding descriptions + +The `description` matcher compares the name the server gives each response internally. To see them, add a rule whose `when` logs the context and returns false. It never fires, so it changes nothing (`after: ''` is only there because a rule needs an action): + +```javascript +server.script.add([ + { + on: 'response', + when: ctx => (console.log(ctx.command, ctx.description, JSON.stringify(ctx.data)), false), + after: '' + }, + { on: 'continuation', when: ctx => (console.log(ctx.command, ctx.description), false), after: '' } +]); +``` + +For a LOGIN, SELECT, FETCH, IDLE and LOGOUT this printed: + +```text +LOGIN LOGIN SUCCESS "A1 OK User logged in\r\n" +SELECT SELECT FLAGS "* FLAGS (\\Answered \\Flagged \\Draft \\Deleted \\Seen)\r\n" +SELECT SELECT PERMANENTFLAGS "* OK [PERMANENTFLAGS ..." +SELECT SELECT EXISTS "* 1 EXISTS\r\n" +SELECT SELECT RECENT "* 0 RECENT\r\n" +SELECT SELECT UNSEEN "* OK [UNSEEN 1] First unseen message\r\n" +SELECT SELECT UIDVALIDITY "* OK [UIDVALIDITY 1] UIDs valid\r\n" +SELECT SELECT UIDNEXT "* OK [UIDNEXT 2] Predicted next UID\r\n" +SELECT SELECT "A2 OK [READ-WRITE] Completed\r\n" +FETCH FETCH "* 1 FETCH (FLAGS ())\r\n" +FETCH FETCH "A3 OK FETCH Completed\r\n" +IDLE IDLE +IDLE IDLE "A4 OK IDLE terminated\r\n" +LOGOUT LOGOUT UNTAGGED "* BYE LOGOUT received\r\n" +LOGOUT LOGOUT COMPLETED "A5 OK Completed\r\n" +``` + +The continuation requests are `LITERAL`, `IDLE`, `AUTHENTICATE PLAIN` and `AUTHENTICATE OAUTHBEARER`, and the error challenges `AUTHENTICATE XOAUTH2 FAILED` and `AUTHENTICATE OAUTHBEARER CHALLENGE`. + +## The `script` event + +The server emits a `script` event every time a rule fires, with `{ rule, event, session, tag, command }`: + +```javascript +server.on('script', ({ rule, event, session, tag, command }) => { + console.log('rule fired', event, session, tag, command); +}); +``` + +The [REST event stream](../rest-api/event-stream.md) carries it too (`GET /v1/events?types=script`): + +```text +event: script +data: {"rule":{"on":"command","command":"NOOP","times":1,"send":"$TAG NO [UNAVAILABLE] Not now\r\n"},"event":"command","session":3,"tag":"A1","command":"NOOP"} +``` + +## JSON rules: the command line and the REST API + +The `imapkit` command reads rules as JSON from `--script=` (or the `IMAPKIT_SCRIPT` environment variable), or from `script` in the `--config` file. In JSON, `match` is a string with a regular expression and `send`, `before` and `after` are strings. Functions (`when`, `mutate`, and function values of `send`) work only from JavaScript. + +```json title="faults.json" +[ + { "on": "greeting", "send": "* BYE Too many connections\r\n", "close": true, "times": 1 }, + { "on": "response", "command": "FETCH", "untagged": true, "send": "* 1 FETCH (BODY[] {100}\r\nshort", "close": true } +] +``` + +```bash +imapkit -p 1143 --rest-port=8143 --script=faults.json +``` + +The first connection is turned away, the second one gets a literal that announces 100 octets and delivers 5: + +```text +S: * BYE Too many connections +[connection closed] +``` + +```text +S: * OK ImapKit ready for rumble +C: A1 LOGIN testuser testpass +S: A1 OK User logged in +C: A2 SELECT INBOX +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted +S: * 1 EXISTS +S: * 0 RECENT +S: * OK [UNSEEN 1] First unseen message +S: * OK [UIDVALIDITY 1] UIDs valid +S: * OK [UIDNEXT 2] Predicted next UID +S: A2 OK [READ-WRITE] Completed +C: A3 FETCH 1 BODY[] +S: * 1 FETCH (BODY[] {100} +S: short [no CRLF] +[connection closed] +``` + +With the [REST API](../rest-api/endpoints.md) on, rules in the same JSON form can be listed, added and removed at runtime, without a restart: + +| Endpoint | Does | +| ------------------------------ | ------------------------------------------------------------------- | +| `GET /v1/script/rules` | lists the rules as `{ id, rule, matched, hits }` | +| `POST /v1/script/rules` | adds a rule or a list of rules, answers `201` with the same shape | +| `DELETE /v1/script/rules` | removes every rule | +| `DELETE /v1/script/rules/{id}` | removes one rule, `404` with `NONEXISTENT` if there is no such rule | + +```bash +curl -X POST http://127.0.0.1:8143/v1/script/rules \ + -H 'Content-Type: application/json' \ + -d '{"on":"command","command":"NOOP","times":1,"send":"$TAG NO [UNAVAILABLE] Not now\r\n"}' +``` + +```json +{ "id": 3, "rule": { "on": "command", "command": "NOOP", "times": 1, "send": "$TAG NO [UNAVAILABLE] Not now\r\n" }, "matched": 0, "hits": 0 } +``` + +An invalid rule is answered with `400`: + +```json +{ "error": { "code": "INVALID", "message": "Unknown script rule option \"comand\"" } } +``` + +## Validation + +Rules are checked when they are added. A mistake throws a `TypeError` right away (or fails the server constructor, for the `script` option), so a typo can not turn into a rule that silently never fires: + +| Rule | Error | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `{ on: 'response', command: 'FETCH', untagged: true, dorp: true }` | `Unknown script rule option "dorp"` | +| `{ on: 'greeting', command: 'LOGIN', drop: true }` | `Script rule option "command" can not be used with "on": "greeting"` | +| `{ on: 'response', command: 'FETCH' }` | `A script rule needs an action (mutate, literals, defer, send, before, after, run, drop, delay, chunk, truncate, close)` | +| `{ on: 'response', defer: 'tagged' }` | `Script rule option "defer" needs "untagged": true` | +| `{ on: 'quiet', send: 'x' }` | `A quiet rule needs "quietFor", a positive number of milliseconds` | +| `{ on: 'command', chunk: 5 }` | `Script rule options "chunk" and "truncate" need "send" with "on": "command"` | +| `{ on: 'response', match: '(', drop: true }` | `Script rule option "match" is not a valid regular expression: Invalid regular expression: /(/: Unterminated group` | +| `{ on: 'response', chance: 2, drop: true }` | `Script rule option "chance" must be a number from 0 to 1` | + +Other checks: `nth`, `times` and `chunk` must be positive integers, `delay`, `chunkDelay` and `truncate` non-negative integers (`chunkDelay` may also be `'tick'`), `chunkDelay` needs `chunk`, `close` is `true`, `false` or `'reset'`, `defer` is `'tagged'` or `'next'`, and `literals` is `true` or `false`. + +## Cookbook + +Every transcript below comes from a real run against ImapKit, read with a raw socket client. `C:` lines are what the client sent, `S:` lines what the server sent, `[no CRLF]` marks output that ends in the middle of a line. The examples use this storage: + +```javascript +const storage = { + INBOX: { + messages: [ + { uid: 1, raw: 'From: alice@example.com\r\nSubject: hello\r\n\r\nHello world!\r\n' }, + { uid: 2, raw: 'From: bob@example.com\r\nSubject: again\r\n\r\nSecond message\r\n' } + ] + } +}; +``` + +### NO on the first SELECT + +Does the client retry, or give up and report the mailbox as broken? `times: 1` makes the second SELECT run as usual. `UNAVAILABLE` is a response code of [RFC 5530](https://www.rfc-editor.org/rfc/rfc5530). + +```javascript +{ on: 'command', command: 'SELECT', times: 1, send: '$TAG NO [UNAVAILABLE] Try again later\r\n' } +``` + +```text +S: * OK ImapKit ready for rumble +C: A1 LOGIN testuser testpass +S: A1 OK User logged in +C: A2 SELECT INBOX +S: A2 NO [UNAVAILABLE] Try again later +C: A3 SELECT INBOX +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted +S: * 2 EXISTS +S: * 0 RECENT +S: * OK [UNSEEN 1] First unseen message +S: * OK [UIDVALIDITY 1] UIDs valid +S: * OK [UIDNEXT 3] Predicted next UID +S: A3 OK [READ-WRITE] Completed +C: A4 LOGOUT +S: * BYE LOGOUT received +S: A4 OK Completed +[connection closed] +``` + +### A FETCH body cut in the middle of a literal + +The literal announces 57 octets, the connection closes after 16 of them. The client must report an error, not store a partial message. + +```javascript +{ on: 'response', command: 'FETCH', match: /^\* 1 FETCH .*BODY\[\]/, truncate: 40 } +``` + +```text +C: A3 FETCH 1 BODY.PEEK[] +S: * 1 FETCH (BODY[] {57} +S: From: alice@exam [no CRLF] +[connection closed] +``` + +### A FETCH response after the tagged OK + +Some servers now and then send an untagged FETCH after the tagged OK of its command. A client that collects FETCH data only until the tagged response loses the message. + +```javascript +{ on: 'response', command: 'UID FETCH', untagged: true, match: /UID 2\b/, times: 1, defer: 'tagged' } +``` + +```text +C: A3 UID FETCH 1:2 (FLAGS) +S: * 1 FETCH (FLAGS () UID 1) +S: A3 OK UID FETCH Completed +S: * 2 FETCH (FLAGS () UID 2) +C: A4 LOGOUT +S: * BYE LOGOUT received +S: A4 OK Completed +[connection closed] +``` + +With `defer: 'next'` the held response goes out with the answer to the next command instead: + +```javascript +{ on: 'response', command: 'FETCH', untagged: true, match: /^\* 2 FETCH/, defer: 'next' } +``` + +```text +C: A3 FETCH 1:2 (FLAGS) +S: * 1 FETCH (FLAGS ()) +S: A3 OK FETCH Completed +C: A4 NOOP +S: * 2 FETCH (FLAGS ()) +S: A4 OK Completed +C: A5 LOGOUT +S: * BYE LOGOUT received +S: A5 OK Completed +[connection closed] +``` + +### Literals everywhere + +Valid IMAP that trips up clients which expect quoted strings. The LIST delimiter stays quoted, as the grammar requires. + +```javascript +{ on: 'response', untagged: true, literals: true } +``` + +```text +S: * OK ImapKit ready for rumble +C: A1 LOGIN testuser testpass +S: A1 OK User logged in +C: A2 LIST "" "*" +S: * LIST (\HasNoChildren) "/" {5} +S: INBOX +S: A2 OK Completed +C: A3 SELECT INBOX +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted +S: * 2 EXISTS +S: * 0 RECENT +S: * OK [UNSEEN 1] First unseen message +S: * OK [UIDVALIDITY 1] UIDs valid +S: * OK [UIDNEXT 3] Predicted next UID +S: A3 OK [READ-WRITE] Completed +C: A4 FETCH 1 (ENVELOPE) +S: * 1 FETCH (ENVELOPE (NIL {5} +S: hello ((NIL NIL {5} +S: alice {11} +S: example.com)) ((NIL NIL {5} +S: alice {11} +S: example.com)) ((NIL NIL {5} +S: alice {11} +S: example.com)) NIL NIL NIL NIL NIL)) +S: A4 OK FETCH Completed +C: A5 LOGOUT +S: * BYE LOGOUT received +S: A5 OK Completed +[connection closed] +``` + +### Output split into pieces + +A client that assumes one read holds one response, or a whole literal, breaks when the output arrives in small pieces. Here the FETCH response leaves in 16 octet pieces, 50 ms apart. The times are measured from the moment the command was sent: + +```javascript +{ on: 'response', command: 'FETCH', untagged: true, chunk: 16, chunkDelay: 50 } +``` + +```text + 1 ms "* 1 FETCH (BODY[" + 51 ms "HEADER.FIELDS (S" + 101 ms "UBJECT)] {18}\r\nS" + 152 ms "ubject: hello\r\n\r" + 202 ms "\n)\r\n" + 202 ms "A3 OK FETCH Completed\r\n" +``` + +The tagged OK waited behind the pieces, all later output keeps its order. With `chunkDelay: 'tick'` (or `0`) the pieces go out on separate event loop turns without a wall clock delay. On loopback the receiver may still merge a few of them into one read: + +```text + 0 ms "* 1 FETCH (BODY[HEADER.FIELDS (S" + 0 ms "UBJECT)] {18}\r\nS" + 0 ms "ubject: hello\r\n\r" + 0 ms "\n)\r\n" + 0 ms "A3 OK FETCH Completed\r\n" +``` + +### Autologout during IDLE + +[RFC 3501](https://www.rfc-editor.org/rfc/rfc3501) section 5.4 allows an inactivity autologout of at least 30 minutes, and section 7.1.5 shows the `BYE` it announces. A client that idles must re-issue IDLE before that, and handle the BYE when it comes. A real test would use `quietFor: 1800000`, this run uses 2 seconds: + +```javascript +{ on: 'quiet', command: 'IDLE', quietFor: 2000, send: '* BYE Autologout; idle for too long\r\n', close: true } +``` + +```text +C: A3 IDLE +S: + idling +S: * BYE Autologout; idle for too long +[connection closed] +``` + +The connection closed 2003 ms after IDLE was sent. + +### An ALERT between commands + +Unsolicited output while no command is in progress ([RFC 3501](https://www.rfc-editor.org/rfc/rfc3501) section 5.3): + +```javascript +{ on: 'quiet', state: 'Selected', quietFor: 500, times: 1, send: '* OK [ALERT] System shutdown in 10 minutes\r\n' } +``` + +```text +C: A2 SELECT INBOX +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted +S: * 2 EXISTS +S: * 0 RECENT +S: * OK [UNSEEN 1] First unseen message +S: * OK [UIDVALIDITY 1] UIDs valid +S: * OK [UIDNEXT 3] Predicted next UID +S: A2 OK [READ-WRITE] Completed +S: * OK [ALERT] System shutdown in 10 minutes +C: A3 NOOP +S: A3 OK Completed +``` + +When several quiet rules wait for different times, a rule that does not match lets the session wait on for the next longer `quietFor`. + +### Random throttling + +`chance` makes a rule fire on some matching events only. With `scriptSeed` the same client gets the same answers in every run. With seed 42 and a chance of 0.3, NOOPs 5 and 7 were refused: + +```javascript +const server = imapkit({ + scriptSeed: 42, + script: { on: 'command', command: 'NOOP', chance: 0.3, send: '$TAG NO [LIMIT] Too many commands, slow down\r\n' } +}); +``` + +```text +C: A2 NOOP +S: A2 OK Completed +C: A3 NOOP +S: A3 OK Completed +C: A4 NOOP +S: A4 OK Completed +C: A5 NOOP +S: A5 OK Completed +C: A6 NOOP +S: A6 NO [LIMIT] Too many commands, slow down +C: A7 NOOP +S: A7 OK Completed +C: A8 NOOP +S: A8 NO [LIMIT] Too many commands, slow down +C: A9 NOOP +S: A9 OK Completed +``` + +The [`m365-throttle` quirk preset](./quirk-presets.md#m365-throttle) does the same for every command, with the text Microsoft 365 sends. + +### A server that ignores DONE + +The first `DONE` is dropped, the server keeps idling until the client sends another one. A client that waits forever for the tagged OK of IDLE hangs here, a careful one times out. + +```javascript +{ on: 'input', command: 'IDLE', match: /^DONE$/, times: 1, drop: true } +``` + +```text +C: A3 IDLE +S: + idling +C: DONE +C: DONE +S: A3 OK IDLE terminated +``` + +### An unexpected EXISTS before a tagged response + +`before` adds output in front of a response. Here every NOOP reports a third message that does not exist, a client must not fetch it blindly. + +```javascript +{ on: 'response', command: 'NOOP', untagged: false, before: '* 3 EXISTS\r\n' } +``` + +```text +C: A3 NOOP +S: * 3 EXISTS +S: A3 OK Completed +``` + +### A connection reset + +`close: 'reset'` destroys the socket, the client sees `ECONNRESET` instead of an orderly close: + +```javascript +{ on: 'command', command: 'FETCH', close: 'reset' } +``` + +```text +C: A3 FETCH 1:* (FLAGS) +[connection closed with ECONNRESET] +``` + +Bun and Deno may close such a connection without a RST. diff --git a/docs/docs/getting-started/command-line.md b/docs/docs/getting-started/command-line.md new file mode 100644 index 0000000..11fb780 --- /dev/null +++ b/docs/docs/getting-started/command-line.md @@ -0,0 +1,213 @@ +--- +sidebar_position: 3 +title: Command Line +description: Run ImapKit as a standalone IMAP server with the imapkit command, its options, environment variables and configuration file. +--- + +# Command Line + +The `imapkit` command starts a standalone server: for trying a client by hand, for a test suite that is not written in JavaScript, or for a long running server shared by a CI job. It runs the same server as `imapkit(options)` in code, and every option of the command maps to a [server option](../reference/server-options.md). + +```bash +imapkit -p 1143 --plugin=IDLE,MOVE,UIDPLUS --storage=storage.json +``` + +```text +Starting ImapKit ... +ImapKit successfully listening on port 1143 +``` + +Log in as `testuser` / `testpass`. The server runs until you stop it (Ctrl+C), and every change it makes stays in memory, so the next start begins from the same state again. + +Install the command with `npm install -g imapkit`, or run it from a project that has ImapKit as a dependency with `npx imapkit`. See [Installation](installation.md). + +## Options + +| Option | Environment variable | Description | +| ----------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `-p `, `--port=` | `IMAPKIT_PORT` | IMAP port. Defaults to the `port` of the config file, then to 143, or 993 with `--secure`. | +| `-s`, `--secure` | `IMAPKIT_SECURE=true` | Implicit TLS on the IMAP port, with the bundled self-signed certificate for `localhost`. | +| `-d`, `--debug` | `IMAPKIT_DEBUG=true` | Writes the IMAP traffic to the console. | +| `--storage=` | `IMAPKIT_STORAGE` | JSON file with the mailbox tree, the `storage` option. See [Storage](../guides/storage.md). | +| `--config=` | `IMAPKIT_CONFIG` | JSON file with [server options](../reference/server-options.md), see [Configuration file](#configuration-file). | +| `--script=` | `IMAPKIT_SCRIPT` | JSON file with [script rules](../faults/scripted-faults.md), a rule or a list of rules. | +| `--plugin=` | `IMAPKIT_PLUGINS` | Plugins to load. Comma separated (`--plugin=IDLE,MOVE`) or repeated (`--plugin=IDLE --plugin=MOVE`). Names are case-insensitive and capability spellings like `LITERAL+` work too. | +| `--quirk=` | `IMAPKIT_QUIRKS` | [Quirk presets](../faults/quirk-presets.md): `james-fetchgroup`, `james-late-fetch`, `yahoo-quoted-sections`, `m365-throttle`, `no-uidplus`, `no-move`. Comma separated or repeated. | +| `--script-seed=` | `IMAPKIT_SCRIPT_SEED` | Seed of the random numbers of script rules with `chance` and of the quirk presets that use them, so a run can be [repeated](../faults/repeatable-tests.md). | +| `--rest-port=` | `IMAPKIT_REST_PORT` | Starts the [REST API](../rest-api/overview.md) on this port. `0` picks a free port, the startup output shows which. | +| `--rest-host=
` | `IMAPKIT_REST_HOST` | Address of the REST API, `127.0.0.1` by default. Any address that is not a loopback address needs `--rest-token`. Only used together with `--rest-port`. | +| `--rest-token=` | `IMAPKIT_REST_TOKEN` | Bearer token that every REST request must send as `Authorization: Bearer `. Only used together with `--rest-port`. | +| `--smtpPort=`, `--smtp-port=` | `IMAPKIT_SMTPPORT` | Starts an SMTP server on this port that appends every message it receives to INBOX. Needs the optional `smtp-server` package, see [Installation](installation.md#optional-the-smtp-listener). | +| `-h`, `--help` | | Prints the options, every plugin with a short description, and sample config and storage files. | + +A command line option wins over its environment variable, and both win over the config file. `--plugin` replaces the `plugins` list of the config file, `--storage` its `storage`, and `--script` its `script`. + +Ports below 1024 (143 and 993 included) usually need administrator rights. Pick a port such as 1143 for local use. + +## Configuration file + +`--config` takes a JSON object with any [server options](../reference/server-options.md) that JSON can express, plus `port`: + +```json title="imapkit.json" +{ + "plugins": ["IDLE", "MOVE", "UIDPLUS"], + "port": 1143, + "users": { + "testuser": { "password": "testpass" }, + "alice": { "password": "secret" } + }, + "storage": { + "INBOX": { + "messages": [{ "raw": "Subject: Welcome\r\n\r\nHello!\r\n", "flags": ["\\Seen"] }] + }, + "": { "separator": "/", "folders": { "Archive": {} } } + }, + "rest": { "port": 8143 } +} +``` + +```bash +imapkit --config=imapkit.json +``` + +```text +Starting ImapKit ... +ImapKit successfully listening on port 1143 +REST API listening on 127.0.0.1:8143 +``` + +Some useful keys besides the ones above: `debug`, `secureConnection`, `maxLiteralSize` (octets, 64 MiB by default), `appendLimit` and `messageLimit` (for the APPENDLIMIT and MESSAGELIMIT plugins), `id` (the ID plugin's answer), `quirks`, `scriptSeed`, `script`, `smtp` (`{ "port": 1025 }`) and `rest` (`{ "port", "host", "token" }`). `--rest-port` (with `--rest-host` and `--rest-token`) replaces the `rest` object of the file, and `--smtpPort` its `smtp` object. + +:::note +`secureConnection` in the config file turns on TLS but does not change the default port, which stays 143. Set `port` in the file, or use `--secure`, which defaults the port to 993. +::: + +Options that take JavaScript functions, such as plugin functions, `now` as a function, or the `when` and `mutate` functions of script rules, only work from code. + +## Storage file + +`--storage` loads the mailbox tree from a JSON file. The keys are namespaces: `INBOX`, the personal namespace `""` with its `separator` and `folders`, and optionally other namespaces: + +```json title="storage.json" +{ + "INBOX": { + "messages": [{ "raw": "Subject: Test\r\n\r\nHello world!\r\n", "flags": ["\\Seen"] }] + }, + "": { + "separator": "/", + "folders": { + "Archive": {}, + "Sent": { "special-use": "\\Sent" } + } + } +} +``` + +```bash +imapkit -p 1143 --plugin=SPECIAL-USE --storage=storage.json +``` + +Without a storage file the server has an empty INBOX. The file is read once at startup and never written. See [Storage](../guides/storage.md) for every key (UIDs, UIDVALIDITY, internal dates, special-use attributes, ACLs and more). + +## Script file + +`--script` loads [script rules](../faults/scripted-faults.md) that make the server misbehave on purpose. JSON rules use strings for `match` and `send`: + +```json title="faults.json" +[ + { "on": "command", "command": "SELECT", "times": 1, "send": "$TAG NO [UNAVAILABLE] Try again later\r\n" }, + { "on": "response", "command": "FETCH", "untagged": true, "literals": true } +] +``` + +```bash +imapkit -p 1143 --script=faults.json +``` + +The first SELECT gets `NO [UNAVAILABLE]`, the next ones work. Every FETCH response sends its strings as literals. With the [REST API](../rest-api/overview.md) on, rules can also be added and removed at runtime, without a restart. + +## Examples + +A server with the extensions most clients use: + +```bash +imapkit -p 1143 --plugin=ID,IDLE,NAMESPACE,UNSELECT,UIDPLUS,MOVE,SPECIAL-USE,ENABLE,CONDSTORE,LITERALPLUS +``` + +An IMAP4rev2 server (loads the extensions that RFC 9051 folds in): + +```bash +imapkit -p 1143 --plugin=IMAP4rev2 +``` + +Watch the traffic of a client you are debugging: + +```bash +imapkit -p 1143 --plugin=IDLE --debug +``` + +```text +A1 LOGIN testuser testpass +SEND: A1 OK User logged in +A2 SELECT INBOX +SEND: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen) +... +SEND: A2 OK [READ-WRITE] Completed +``` + +A server for a test suite in another language, with the REST API ([Testing from other languages](../guides/testing-from-other-languages.md)): + +```bash +imapkit -p 1143 --plugin=IDLE,UIDPLUS,MOVE --rest-port=8143 +``` + +A REST API that other machines can reach, which needs a token: + +```bash +imapkit -p 1143 --rest-port=8143 --rest-host=0.0.0.0 --rest-token=s3cret +curl -H 'Authorization: Bearer s3cret' http://127.0.0.1:8143/v1/sessions +``` + +Behave like Microsoft 365 under load, the same way in every run: + +```bash +imapkit -p 1143 --plugin=IDLE,MOVE --quirk=m365-throttle --script-seed=42 +``` + +Accept mail over SMTP on port 1025 and show it in INBOX: + +```bash +npm install -g smtp-server +imapkit -p 1143 --smtpPort=1025 +``` + +```text +Starting ImapKit ... +ImapKit successfully listening on port 1143 +Incoming SMTP server up and running on port 1025 +``` + +The same settings through environment variables, for example in a CI service definition: + +```bash +IMAPKIT_PORT=1143 IMAPKIT_PLUGINS=IDLE,MOVE IMAPKIT_REST_PORT=8143 imapkit +``` + +Implicit TLS on port 993, which needs administrator rights, or on a port of your choice: + +```bash +imapkit --secure -p 1993 +``` + +The bundled certificate is self-signed for `localhost`, so the client has to accept it (for example `tls: { rejectUnauthorized: false }` in Node.js). + +## Errors + +The command exits with status 1 and prints the reason when the server can not start, for example when the port is in use or the REST API would listen on a public address without a token: + +```text +Starting ImapKit ... +Failed to start ImapKit: The REST API controls the whole server, it needs a token (rest.token, --rest-token) to listen on 0.0.0.0 +``` + +An unknown plugin or quirk name stops the command with an error that lists the valid names. diff --git a/docs/docs/getting-started/installation.md b/docs/docs/getting-started/installation.md index 524ca6d..33e7437 100644 --- a/docs/docs/getting-started/installation.md +++ b/docs/docs/getting-started/installation.md @@ -1,13 +1,16 @@ --- sidebar_position: 1 title: Installation +description: Install ImapKit as a dev dependency for your test suite, or as a global command that runs a standalone IMAP server. --- # Installation -ImapKit needs Node.js 20 or newer. It also runs on the latest [Bun](https://bun.sh/) and [Deno](https://deno.com/) releases (`npm:imapkit` in Deno). +ImapKit needs Node.js 20 or newer. It also runs on the latest [Bun](https://bun.sh/) and [Deno](https://deno.com/) releases, and its own test suite runs on both in CI. -## As a library +## As a dev dependency + +Most projects use ImapKit from their test suite: ```bash npm install --save-dev imapkit @@ -18,21 +21,59 @@ The package ships ES modules and CommonJS, each with type declarations: ```javascript import imapkit from 'imapkit'; // or with CommonJS: const imapkit = require('imapkit'); + +const server = imapkit({ plugins: ['IDLE'] }); +const port = await server.start(); +``` + +Next to the `imapkit(options)` factory, the package exports `ImapKitError` (the error the [control API](../control-api/overview.md) throws), `quirks` (the [quirk presets](../faults/quirk-presets.md) as data), `validateStorage()` and `storageSchema` (the check and JSON Schema of the [storage option](../guides/storage.md)), the `IMAPServer` and `IMAPConnection` classes, and TypeScript types: + +```typescript +import imapkit, { ImapKitError, type IMAPServerOptions, type Plugin } from 'imapkit'; ``` +With Bun, `bun add -d imapkit`. With Deno, import `npm:imapkit`. + ## As a standalone server +Install the command globally, or run it from the project with `npx imapkit`: + ```bash npm install -g imapkit imapkit -p 1143 ``` -Point your IMAP client to `localhost:1143` and log in with user name `testuser` and password `testpass`. Run `imapkit --help` to see all options. +Point any IMAP client to `localhost:1143` and log in with user name `testuser` and password `testpass`. Without `-p` the server listens on port 143 (993 with `--secure`), which usually needs root privileges. + +The command takes plugins, a storage file, script rules, quirk presets and a REST API port. See [Command line](command-line.md) for every option, or run: + +```bash +imapkit --help +``` -## Optional SMTP listener +## Optional: the SMTP listener -The SMTP listener (`--smtpPort`, or the `smtp` option) appends every message it receives to INBOX. It needs the [smtp-server](https://www.npmjs.com/package/smtp-server) package, which is not installed with ImapKit: +ImapKit can also accept mail over SMTP and append every message it receives to INBOX (`--smtpPort` for the command, the `smtp` option in code). The listener needs the [smtp-server](https://www.npmjs.com/package/smtp-server) package, an optional peer dependency that is not installed with ImapKit: ```bash -npm install smtp-server +npm install --save-dev smtp-server +# or, next to a global install +npm install -g smtp-server ``` + +Without it, `server.start()` rejects with an error that tells you to install it, and the IMAP server works as usual when SMTP is not enabled. + +## Default account + +Every server has one account until you configure others: + +| User name | Password | Access token (XOAUTH2, OAUTHBEARER) | +| ---------- | ---------- | ----------------------------------- | +| `testuser` | `testpass` | `testtoken` | + +The `users` option replaces this list, and the [control API](../control-api/users-and-sessions.md) adds users at runtime. All users share the same mailbox tree; see [Authentication](../guides/authentication.md). + +## Next steps + +- Write your first test in the [Quick Start](quick-start.md). +- Run the server from the shell with the [command line](command-line.md) options. diff --git a/docs/docs/getting-started/quick-start.md b/docs/docs/getting-started/quick-start.md index 0095d21..21ba317 100644 --- a/docs/docs/getting-started/quick-start.md +++ b/docs/docs/getting-started/quick-start.md @@ -1,31 +1,131 @@ --- sidebar_position: 2 title: Quick Start +description: Write and run a complete client integration test against ImapKit with the ImapFlow IMAP client and the Node.js test runner. --- # Quick Start -Start a server on a free port, connect your client, and change the server state from the test: +This page builds a complete integration test: a fresh ImapKit server with one message, a real IMAP client ([ImapFlow](https://imapflow.com/)) that reads it, a change made on the server while the client is connected, and assertions on both sides. It uses the built-in [Node.js test runner](https://nodejs.org/api/test.html), so nothing else is needed. -```javascript title="test/client.test.js" +## 1. Install + +```bash +npm install --save-dev imapkit imapflow +``` + +ImapFlow stands in for the client you are testing. Any IMAP client works the same way: it only needs a host, a port and the credentials `testuser` / `testpass`. + +## 2. Write the test + +```javascript title="test/inbox.test.mjs" import { test } from 'node:test'; -import assert from 'node:assert'; +import assert from 'node:assert/strict'; import imapkit from 'imapkit'; +import { ImapFlow } from 'imapflow'; -test('client sees a new message', async () => { - const server = imapkit({ plugins: ['IDLE'] }); +test('the client reads INBOX and notices new mail', async t => { + // 1. A fresh server with one message in INBOX, on a free port + const server = imapkit({ + plugins: ['IDLE', 'UIDPLUS', 'MOVE'], + storage: { + INBOX: { + messages: [ + { + raw: 'From: Alice \r\nSubject: Welcome\r\n\r\nHello there!\r\n', + flags: ['\\Seen'] + } + ] + } + } + }); const port = await server.start(); + t.after(() => server.stop()); - // ... connect your IMAP client to 127.0.0.1:port as testuser / testpass + // 2. Connect the client under test as the default user + const client = new ImapFlow({ + host: '127.0.0.1', + port, + secure: false, + auth: { user: 'testuser', pass: 'testpass' }, + logger: false + }); + await client.connect(); + + // 3. Exercise the client and assert on what it reports + const mailbox = await client.mailboxOpen('INBOX'); + assert.equal(mailbox.exists, 1); + + const message = await client.fetchOne('1', { envelope: true, flags: true }); + assert.equal(message.envelope.subject, 'Welcome'); + assert.ok(message.flags.has('\\Seen')); + // 4. Change the server from the test, like a new delivery would const { uid } = server.control.addMessage('INBOX', { - raw: 'Subject: hello\r\n\r\nHi!\r\n', - flags: ['\\Seen'] + raw: 'From: Bob \r\nSubject: Second\r\n\r\nAnother one\r\n' }); - assert.strictEqual(uid, 1); + assert.equal(uid, 2); - await server.stop(); + // the client learns about it with its next command + await client.noop(); + assert.equal(client.mailbox.exists, 2); + + // 5. Assert on the server state the client left behind + await client.messageFlagsAdd({ uid: '2' }, ['\\Flagged'], { uid: true }); + assert.deepEqual(server.control.getMessage('INBOX', 2).flags, ['\\Flagged']); + + await client.logout(); }); ``` -Every server starts from the `storage` option, or from an empty INBOX, so tests never depend on each other. +## 3. Run it + +```bash +node --test +``` + +```text +✔ the client reads INBOX and notices new mail (20.156959ms) +ℹ tests 1 +ℹ pass 1 +ℹ fail 0 +``` + +## What happened + +```mermaid +sequenceDiagram + participant T as Test + participant S as ImapKit + participant C as Client (ImapFlow) + T->>S: imapkit({ plugins, storage }), start() + S-->>T: port + T->>C: connect(port) + C->>S: LOGIN, SELECT INBOX, FETCH 1 + S-->>C: 1 EXISTS, FETCH (ENVELOPE FLAGS) + T->>S: control.addMessage('INBOX', ...) + C->>S: NOOP + S-->>C: * 2 EXISTS + C->>S: UID STORE 2 +FLAGS (\Flagged) + T->>S: control.getMessage('INBOX', 2) + S-->>T: { uid: 2, flags: ['\\Flagged'], ... } + T->>S: stop() +``` + +- **`imapkit(options)`** builds a server. `plugins` turns on extensions, `storage` is the mailbox tree it starts from. Without `storage` the server has an empty INBOX. See [Storage](../guides/storage.md) for the format. +- **`server.start()`** listens on a free port and resolves with it, so tests can run in parallel. `server.start(1143, '127.0.0.1')` picks a fixed port and address. +- **`t.after(() => server.stop())`** closes the server and every session when the test ends, even when an assertion failed. +- **`server.control.addMessage()`** adds a message from outside any IMAP session. A session that has INBOX selected gets `* 2 EXISTS`, the same as when another client appends a message. The [control API](../control-api/overview.md) also changes flags, expunges, renames mailboxes, resets UIDVALIDITY and disconnects sessions. +- **`server.control.getMessage()`** reads the server state back, so the test checks what the client really stored, not only what it reports. + +:::tip +Every server keeps its own state in memory. Start a new one in every test instead of sharing one, then no test depends on what an earlier test left behind. +::: + +ImapKit is strict about the protocol. If the client sends something the RFCs do not allow, ImapKit answers with `BAD` and the client method fails, which is the point: see [Strict by design](../guides/strict-by-design.md). + +## Next steps + +- [Writing client tests](../guides/writing-client-tests.md): a reusable helper, waiting for IDLE without sleeps, running one test against several capability sets, and checking what the client sent. +- [Scripted faults](../faults/scripted-faults.md): make the server fail, stall or misbehave at a chosen point. +- [Command line](command-line.md): run ImapKit as a standalone server, for manual testing or for test suites in other languages. diff --git a/docs/docs/guides/authentication.md b/docs/docs/guides/authentication.md new file mode 100644 index 0000000..8c23b94 --- /dev/null +++ b/docs/docs/guides/authentication.md @@ -0,0 +1,217 @@ +--- +title: Authentication +sidebar_position: 3 +description: Users and credentials, LOGIN, AUTHENTICATE PLAIN, XOAUTH2 and OAUTHBEARER, LOGINDISABLED, STARTTLS and implicit TLS with the bundled certificate, UNAUTHENTICATE, and users at runtime. +--- + +# Authentication + +ImapKit is a single mailbox tree server: every user who logs in sees the same mailboxes. Users exist so that clients can test their login code, and so that the ACL plugin can tell the owner apart from other users (see [Access control](../extensions/access-control.md)). + +## Users + +Without a `users` option there is one account: + +| User name | Password | Access token (XOAUTH2, OAUTHBEARER) | +| ---------- | ---------- | ----------------------------------- | +| `testuser` | `testpass` | `testtoken` | + +The `users` option replaces this list. Each key is a user name, each value takes a `password` and an optional `xoauth2` object with the `accessToken` for XOAUTH2 and OAUTHBEARER: + +```javascript title="users.js" +import imapkit from 'imapkit'; + +const server = imapkit({ + plugins: ['AUTH-PLAIN', 'SASL-IR', 'XOAUTH2', 'OAUTHBEARER', 'UNAUTHENTICATE'], + users: { + alice: { password: 'wonderland', xoauth2: { accessToken: 'alice-token' } }, + bob: { password: 'builder' } + } +}); +``` + +`testuser` is not added when `users` is set, so here only `alice` and `bob` can log in, and only `alice` has an access token. User names are Unicode strings, in `users`, in SASL exchanges and in ACL identifiers. + +The `xoauth2` object also takes a `sessionTimeout` (milliseconds, default one hour). It is kept with the user but has no effect on logins. + +## LOGIN + +LOGIN is always available, unless the LOGINDISABLED plugin turns it off on plain connections. A wrong user name or password is answered with `NO [AUTHENTICATIONFAILED]` ([RFC 5530 section 3](https://www.rfc-editor.org/rfc/rfc5530#section-3)): + +```text +C: A2 LOGIN testuser testpass +S: A2 NO [AUTHENTICATIONFAILED] Login failed: authentication failure +C: A3 LOGIN bob builder +S: A3 OK User logged in +``` + +LOGIN takes exactly two strings (atoms, quoted strings or literals). 8-bit user names or passwords are answered with BAD, because a client MUST use AUTHENTICATE for UTF-8 credentials ([RFC 9755 section 5](https://www.rfc-editor.org/rfc/rfc9755#section-5)). + +## Mechanisms + +AUTHENTICATE mechanisms come from plugins. AUTHENTICATE with a mechanism that no loaded plugin provides is answered with NO. + +| Plugin | Capability | Notes | +| ----------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| AUTH-PLAIN | `AUTH=PLAIN` | [RFC 4616](https://www.rfc-editor.org/rfc/rfc4616). The initial response needs SASL-IR. | +| SASL-IR | `SASL-IR` | [RFC 4959](https://www.rfc-editor.org/rfc/rfc4959), lets a client send the first response with the AUTHENTICATE command. | +| XOAUTH2 | `AUTH=XOAUTH2` | [Gmail's XOAUTH2](https://developers.google.com/workspace/gmail/imap/xoauth2-protocol). Needs SASL-IR loaded and used (Gmail does not). | +| OAUTHBEARER | `AUTH=OAUTHBEARER` | [RFC 7628](https://www.rfc-editor.org/rfc/rfc7628), with or without SASL-IR. | + +The `AUTH=` capabilities are only advertised before login. Invalid base64 in a client response is answered with BAD. In PLAIN and OAUTHBEARER, `*` instead of a response cancels the exchange, also with BAD ([RFC 3501 section 6.2.2](https://www.rfc-editor.org/rfc/rfc3501#section-6.2.2)). + +### AUTHENTICATE PLAIN + +The message is `authzid NUL authcid NUL password`, base64 encoded. With SASL-IR it can go on the command line, otherwise the client waits for the empty continuation request: + +```text +C: A5 AUTHENTICATE PLAIN AGFsaWNlAHdvbmRlcmxhbmQ= +S: A5 OK User logged in +``` + +```text +C: A1 AUTHENTICATE PLAIN +S: + +C: AGJvYgBidWlsZGVy +S: A1 OK User logged in +``` + +An initial response without SASL-IR is answered with BAD, as RFC 4959 only allows it when the server advertises SASL-IR. The message must be valid UTF-8 ([RFC 4616 section 2](https://www.rfc-editor.org/rfc/rfc4616#section-2)). The authzid must be empty or the same as the user name, acting as another user is answered with `NO [AUTHORIZATIONFAILED]`. + +### XOAUTH2 + +XOAUTH2 takes `user=^Aauth=Bearer ^A^A` (`^A` is the octet `0x01`), base64 encoded, as the initial response: + +```text +C: A1 AUTHENTICATE XOAUTH2 dXNlcj1hbGljZQFhdXRoPUJlYXJlciBhbGljZS10b2tlbgEB +S: A1 OK User logged in +``` + +A wrong token gets Gmail's JSON error as a continuation request. The client answers it (usually with an empty line) and the server fails the login: + +```text +C: A1 AUTHENTICATE XOAUTH2 dXNlcj1hbGljZQFhdXRoPUJlYXJlciB3cm9uZwEB +S: + eyJzdGF0dXMiOiI0MDAiLCJzY2hlbWVzIjoiQmVhcmVyIiwic2NvcGUiOiJodHRwczovL21haWwuZ29vZ2xlLmNvbS8ifQ== +C: +S: A1 NO [AUTHENTICATIONFAILED] SASL authentication failed +``` + +The continuation decodes to `{"status":"400","schemes":"Bearer","scope":"https://mail.google.com/"}`. An unknown user is answered with `NO [AUTHENTICATIONFAILED]` right away. Without SASL-IR loaded, AUTHENTICATE XOAUTH2 is answered with BAD. + +### OAUTHBEARER + +OAUTHBEARER takes a GS2 header and key/value pairs ([RFC 7628 section 3.1](https://www.rfc-editor.org/rfc/rfc7628#section-3.1)). The authzid in the header (`n,a=alice,`) is optional, without it the user is found by the token: + +```text +C: A1 AUTHENTICATE OAUTHBEARER bixhPWFsaWNlLAFhdXRoPUJlYXJlciBhbGljZS10b2tlbgEB +S: A1 OK SASL authentication succeeded +``` + +A failed login gets the JSON error result as a continuation request (`invalid_token` or `invalid_request`). The client MUST answer it with a single `0x01` octet, `AQ==` in base64 ([RFC 7628 section 3.2.3](https://www.rfc-editor.org/rfc/rfc7628#section-3.2.3)): + +```text +C: A1 AUTHENTICATE OAUTHBEARER biwsAWF1dGg9QmVhcmVyIGV4cGlyZWQBAQ== +S: + eyJzdGF0dXMiOiJpbnZhbGlkX3Rva2VuIn0= +C: AQ== +S: A1 NO [AUTHENTICATIONFAILED] SASL authentication failed +``` + +The continuation decodes to `{"status":"invalid_token"}`. Anything other than `AQ==` after the error result is answered with BAD, and so is a client response that breaks the RFC 7628 or GS2 ([RFC 5801](https://www.rfc-editor.org/rfc/rfc5801)) grammar. Channel binding (`p=...`) is not supported and fails with `invalid_request`. + +## TLS + +### STARTTLS and LOGINDISABLED + +The STARTTLS plugin adds the STARTTLS command to plain connections. The LOGINDISABLED plugin advertises `LOGINDISABLED` and answers LOGIN on a plain connection with `NO [PRIVACYREQUIRED]` ([RFC 3501 section 6.2.3](https://www.rfc-editor.org/rfc/rfc3501#section-6.2.3), [RFC 5530 section 3](https://www.rfc-editor.org/rfc/rfc5530#section-3)). Together they force a client to upgrade before it logs in: + +```text +S: * OK ImapKit ready for rumble +C: A1 CAPABILITY +S: * CAPABILITY IMAP4rev1 STARTTLS LOGINDISABLED +S: A1 OK Completed +C: A2 LOGIN testuser testpass +S: A2 NO [PRIVACYREQUIRED] Run STARTTLS first +C: A3 STARTTLS +S: A3 OK Server ready to start TLS negotiation + +C: A4 CAPABILITY +S: * CAPABILITY IMAP4rev1 +S: A4 OK Completed +C: A5 LOGIN testuser testpass +S: A5 OK User logged in +``` + +LOGINDISABLED only affects LOGIN, AUTHENTICATE mechanisms keep working on plain connections. STARTTLS with more commands pipelined after it is answered with BAD, and the pipelined commands are refused without running ([RFC 9051 section 6.2.1](https://www.rfc-editor.org/rfc/rfc9051#section-6.2.1)). The IMAP4rev2 plugin does not load STARTTLS or LOGINDISABLED, load them yourself when you need them. + +### Implicit TLS + +`secureConnection: true` makes the server accept TLS connections only (port 993 style). On the command line, `--secure` does the same and makes 993 the default port. + +```javascript title="secure.js" +import fs from 'node:fs'; +import imapkit from 'imapkit'; +import { ImapFlow } from 'imapflow'; + +// the bundled self-signed certificate is in the cert/ directory of the package +const ca = fs.readFileSync(new URL('cert/server.crt', import.meta.resolve('imapkit/package.json'))); + +const server = imapkit({ secureConnection: true }); +const port = await server.start(); + +const client = new ImapFlow({ + host: 'localhost', + port, + secure: true, + tls: { ca }, + auth: { user: 'testuser', pass: 'testpass' }, + logger: false +}); +await client.connect(); +console.log(client.secureConnection); // true +await client.logout(); +await server.stop(); +``` + +### Certificates + +Both STARTTLS and `secureConnection` use the `credentials` option, `{ key, cert }` in PEM (strings or Buffers), for example `credentials: { key: fs.readFileSync('server.key'), cert: fs.readFileSync('server.crt') }`. + +Without it, ImapKit uses a bundled self-signed certificate. It is issued to `localhost` and valid for the names `localhost`, `127.0.0.1` and `::1`. The package ships it as `cert/server.crt` (with `cert/server.key`), so a client can trust it with a `ca` option as above, or skip verification in tests (`rejectUnauthorized: false` in Node.js). + +## UNAUTHENTICATE + +The UNAUTHENTICATE plugin ([RFC 8437](https://www.rfc-editor.org/rfc/rfc8437)) returns a session to the Not Authenticated state, so a client can log in as another user on the same connection: + +```text +C: A3 LOGIN bob builder +S: A3 OK User logged in +C: A4 UNAUTHENTICATE +S: A4 OK Completed, now in not authenticated state +C: A5 AUTHENTICATE PLAIN AGFsaWNlAHdvbmRlcmxhbmQ= +S: A5 OK User logged in +``` + +The selected mailbox is closed without expunging, ENABLEd extensions and CONDSTORE are turned off, and COMPRESS ends after the tagged OK ([RFC 8437 section 4.1](https://www.rfc-editor.org/rfc/rfc8437#section-4.1)). TLS stays. UNAUTHENTICATE takes no arguments and is BAD before login. + +## Users at runtime + +The control API changes the user list while the server runs, see [Users and sessions](../control-api/users-and-sessions.md): + +```javascript title="runtime-users.js" +server.control.addUser('carol', { password: 'secret', xoauth2: { accessToken: 'carol-token' } }); +server.control.updateUser('testuser', { password: 'changed' }); +console.log(server.control.listUsers()); +// [ { name: 'carol', xoauth2: true }, { name: 'testuser', xoauth2: true } ] + +server.control.deleteUser('carol'); +``` + +`addUser` throws an `ImapKitError` with code `ALREADYEXISTS` for an existing user. `deleteUser` disconnects the sessions of that user with an untagged BYE, unless you pass `{ disconnect: false }`: + +```text +C: A1 LOGIN carol secret +S: A1 OK User logged in +S: * BYE User was deleted +``` + +The REST API has the same operations, see [REST API endpoints](../rest-api/endpoints.md). diff --git a/docs/docs/guides/multiple-sessions.md b/docs/docs/guides/multiple-sessions.md new file mode 100644 index 0000000..00168bd --- /dev/null +++ b/docs/docs/guides/multiple-sessions.md @@ -0,0 +1,228 @@ +--- +title: Multiple Sessions +sidebar_position: 5 +description: How changes made by one session reach the others, when EXPUNGE responses may be sent, the RFC 2180 strategies for expunged messages, \Recent ownership, DELETE, RENAME and IDLE. +--- + +# Multiple sessions + +Any number of clients can connect to one ImapKit server, and they all work on the same mailbox tree (see [Authentication](./authentication.md)). When one session changes a mailbox, the other sessions that have it selected learn about it through unsolicited responses: `EXISTS` for new messages, `EXPUNGE` for removed ones, and `FETCH` with the new flags. + +Changes made with the [control API](../control-api/overview.md) reach the sessions the same way, as if another client had made them. + +[RFC 2180](https://www.rfc-editor.org/rfc/rfc2180) lists several strategies a server may follow when a mailbox is used by more than one session at a time. ImapKit always follows the same ones, listed on this page, so a client can be tested against one consistent behavior. + +## When notifications arrive + +Notifications from other sessions are queued and sent before the tagged response of the next command the session runs. NOOP is the usual way for a client to collect them. + +```mermaid +sequenceDiagram + participant A as Session A + participant S as ImapKit + participant B as Session B + Note over A,B: both have INBOX selected (3 messages) + B->>S: B3 STORE 1 +FLAGS (\Flagged) + S-->>B: * 1 FETCH (FLAGS (\Flagged)) + S-->>B: B3 OK + B->>S: B4 STORE 2 +FLAGS.SILENT (\Deleted) + B->>S: B5 EXPUNGE + S-->>B: * 2 EXPUNGE + S-->>B: B5 OK + Note over S: A is not running a command, its notifications wait + A->>S: A3 FETCH 1:3 (UID FLAGS) + S-->>A: three FETCH responses, message 2 still there + S-->>A: A3 OK [EXPUNGEISSUED] + A->>S: A4 NOOP + S-->>A: * 1 FETCH (UID 1 FLAGS (\Flagged)) + S-->>A: * 2 EXPUNGE, * 2 EXISTS + S-->>A: A4 OK +``` + +The rules: + +- Nothing is sent while the session has no command in progress ([RFC 3501 section 5.3](https://www.rfc-editor.org/rfc/rfc3501#section-5.3)). The one exception is IDLE, see [IDLE](#idle). +- EXPUNGE responses are not sent during FETCH, STORE and SEARCH ([RFC 3501 section 7.4.1](https://www.rfc-editor.org/rfc/rfc3501#section-7.4.1)), nor during the commands that extensions add to that list, such as SORT and THREAD. The other notifications wait with them, so the session's message numbers stay the same until the command completes. When an EXPUNGE is pending, the tagged OK of such a command carries `[EXPUNGEISSUED]`, which tells the client to send NOOP soon ([RFC 5530 section 3](https://www.rfc-editor.org/rfc/rfc5530#section-3)). +- UID commands report the pending EXPUNGE responses before they run, since EXPUNGE is allowed during UID commands. UID SEARCH with message numbers in its criteria is the exception: it runs on the old numbers and the EXPUNGE waits. +- Unsolicited flag updates always include the UID: `* 1 FETCH (UID 2 FLAGS (\Seen))`. [RFC 9051 section 7.5.2](https://www.rfc-editor.org/rfc/rfc9051#section-7.5.2) requires it, and it is valid in IMAP4rev1 too. A message changed several times is reported once, with its current flags. +- The session that made a change gets the usual responses of its own command, never a notification of it. +- A session that has the mailbox selected gets an EXISTS with the new count after EXPUNGE responses caused by another session. + +With QRESYNC enabled, EXPUNGE responses become `VANISHED`, and the NOTIFY plugin changes when and how events are sent, see [Synchronization](../extensions/synchronization.md). + +## Expunged messages + +A session that has not been told about the EXPUNGE of another session yet keeps its old message numbers. Until then, the expunged messages are "ghosts" in that session's view: they still have their numbers, but they are gone from the mailbox. + +| Command | What happens | RFC 2180 | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| FETCH | Still returns the expunged messages, ends with `OK [EXPUNGEISSUED]` | [Section 4.1.1](https://www.rfc-editor.org/rfc/rfc2180#section-4.1.1) | +| SEARCH | Still finds the expunged messages, ends with `OK [EXPUNGEISSUED]` | [Section 4.3](https://www.rfc-editor.org/rfc/rfc2180#section-4.3) | +| STORE `.SILENT` | Stores the other messages, ends with `OK [EXPUNGEISSUED]` | [Section 4.2.1](https://www.rfc-editor.org/rfc/rfc2180#section-4.2.1) | +| STORE | Stores the other messages with their FETCH responses, ends with `NO [EXPUNGEISSUED]` (with CONDSTORE, `NO [MODIFIED ...]` when that applies, [RFC 7162 section 3.1.3](https://www.rfc-editor.org/rfc/rfc7162#section-3.1.3)) | [Sections 4.2.2 and 4.2.3](https://www.rfc-editor.org/rfc/rfc2180#section-4.2.2) | +| COPY, MOVE | Copy nothing, return the pending EXPUNGE responses and `NO [EXPUNGEISSUED]` | [Section 4.4.1](https://www.rfc-editor.org/rfc/rfc2180#section-4.4.1) | +| UID commands | Report the pending EXPUNGE responses first, then run. The UIDs of the expunged messages no longer exist and are ignored ([RFC 3501 section 6.4.8](https://www.rfc-editor.org/rfc/rfc3501#section-6.4.8)) | | + +The scenario of the diagram, as a real transcript. Lines start with the session (`A` or `B`), and some untagged SELECT responses are left out: + +```text +A C: A2 SELECT INBOX +A S: * 3 EXISTS +A S: A2 OK [READ-WRITE] Completed +B C: B2 SELECT INBOX +B S: * 3 EXISTS +B S: B2 OK [READ-WRITE] Completed +B C: B3 STORE 1 +FLAGS (\Flagged) +B S: * 1 FETCH (FLAGS (\Flagged)) +B S: B3 OK STORE completed +B C: B4 STORE 2 +FLAGS.SILENT (\Deleted) +B S: B4 OK STORE completed +B C: B5 EXPUNGE +B S: * 2 EXPUNGE +B S: B5 OK EXPUNGE Completed +A C: A3 FETCH 1:3 (UID FLAGS) +A S: * 1 FETCH (UID 1 FLAGS (\Flagged)) +A S: * 2 FETCH (UID 2 FLAGS (\Deleted)) +A S: * 3 FETCH (UID 3 FLAGS ()) +A S: A3 OK [EXPUNGEISSUED] FETCH Completed +A C: A4 STORE 2 +FLAGS (\Seen) +A S: A4 NO [EXPUNGEISSUED] Some of the messages no longer exist +A C: A5 COPY 1:3 Archive +A S: * 1 FETCH (UID 1 FLAGS (\Flagged)) +A S: * 2 EXPUNGE +A S: * 2 EXISTS +A S: A5 NO [EXPUNGEISSUED] Some of the requested messages no longer exist +A C: A6 NOOP +A S: A6 OK Completed +A C: A7 FETCH 1:* (UID FLAGS) +A S: * 1 FETCH (UID 1 FLAGS (\Flagged)) +A S: * 2 FETCH (UID 3 FLAGS ()) +A S: A7 OK FETCH Completed +``` + +FETCH `A3` still returns message 2 and shows the new flags of message 1, STORE `A4` touches only the expunged message and fails, and COPY `A5` is the first command that may report the EXPUNGE, so it does, copies nothing and fails. After that, message 2 is UID 3. + +A UID command reports the EXPUNGE before it runs: + +```text +B C: B3 STORE 1 +FLAGS.SILENT (\Deleted) +B S: B3 OK STORE completed +B C: B4 EXPUNGE +B S: * 1 EXPUNGE +B S: B4 OK EXPUNGE Completed +A C: A3 UID FETCH 1:* FLAGS +A S: * 1 EXPUNGE +A S: * 2 EXISTS +A S: * 1 FETCH (FLAGS () UID 2) +A S: * 2 FETCH (FLAGS () UID 3) +A S: A3 OK UID FETCH Completed +``` + +## IDLE + +During IDLE ([RFC 2177](https://www.rfc-editor.org/rfc/rfc2177)) notifications are sent right away, without waiting for DONE. Continuing the session above, A starts IDLE while B appends a message, sets a flag and expunges another message: + +```text +A C: A4 IDLE +A S: + idling +B C: B5 APPEND INBOX {18} +B S: + Go ahead +B C: Subject: new +B C: +B C: Hi +B S: * 3 EXISTS +B S: B5 OK APPEND Completed +B C: B6 STORE 1 +FLAGS (\Seen) +B S: * 1 FETCH (FLAGS (\Seen)) +B S: B6 OK STORE completed +B C: B7 STORE 2 +FLAGS.SILENT (\Deleted) +B S: B7 OK STORE completed +B C: B8 EXPUNGE +B S: * 2 EXPUNGE +B S: B8 OK EXPUNGE Completed +A S: * 3 EXISTS +A S: * 1 FETCH (UID 2 FLAGS (\Seen)) +A S: * 2 FETCH (UID 3 FLAGS (\Deleted)) +A S: * 2 EXPUNGE +A S: * 2 EXISTS +A C: DONE +A S: A4 OK IDLE terminated +``` + +Only changes to the selected mailbox are reported. Anything other than `DONE` while idling is answered with BAD. + +## `\Recent` + +`\Recent` belongs to exactly one session ([RFC 3501 section 2.3.2](https://www.rfc-editor.org/rfc/rfc3501#section-2.3.2)): + +- The first session that selects the mailbox read-write takes the `\Recent` flags. Later sessions see `0 RECENT`. +- EXAMINE shows the `\Recent` flags but does not take them ([RFC 3501 section 6.3.2](https://www.rfc-editor.org/rfc/rfc3501#section-6.3.2)). +- A new message is `\Recent` in one session that has the mailbox selected read-write, or, when there is none, for the next session that selects it. +- STATUS counts every message that is `\Recent` in any session, and does not take the flag. + +Messages from the storage are `\Recent` only with `"recent": true`, see [Storage](./storage.md#recent). With one such message (some untagged responses left out): + +```text +A C: A2 EXAMINE INBOX +A S: * 1 EXISTS +A S: * 1 RECENT +A S: A2 OK [READ-ONLY] Completed +B C: B2 SELECT INBOX +B S: * 1 EXISTS +B S: * 1 RECENT +B S: B2 OK [READ-WRITE] Completed +A C: A3 SELECT INBOX +A S: * 1 EXISTS +A S: * 0 RECENT +A S: A3 OK [READ-WRITE] Completed +``` + +After `ENABLE IMAP4rev2` a session gets no RECENT response and no `\Recent` flag, as RFC 9051 removed them. + +## DELETE and RENAME + +DELETE of a mailbox that other sessions have selected disconnects them with an untagged BYE ([RFC 2180 section 3.3](https://www.rfc-editor.org/rfc/rfc2180#section-3.3)), also when they are in IDLE: + +```text +C C: C2 SELECT Work +C S: * 1 EXISTS +C S: C2 OK [READ-WRITE] Completed +B C: B9 DELETE Work +B S: B9 OK DELETE completed +C S: * BYE Selected mailbox was deleted +``` + +RENAME keeps the messages under the new name ([RFC 2180 section 3.4](https://www.rfc-editor.org/rfc/rfc2180#section-3.4)). Sessions that have the mailbox selected keep working on it and see new messages, but the old name no longer exists: + +```text +A C: A2 SELECT Work +A S: * 1 EXISTS +A S: A2 OK [READ-WRITE] Completed +B C: B2 RENAME Work Projects +B S: B2 OK RENAME completed +B C: B3 APPEND Projects {18} +B S: + Go ahead +B C: Subject: new +B C: +B C: Hi +B S: B3 OK APPEND Completed +A C: A3 NOOP +A S: * 2 EXISTS +A S: A3 OK Completed +A C: A4 FETCH 1:* (UID) +A S: * 1 FETCH (UID 1) +A S: * 2 FETCH (UID 2) +A S: A4 OK FETCH Completed +A C: A5 STATUS Work (MESSAGES) +A S: A5 NO [NONEXISTENT] Mailbox does not exist +``` + +## Disconnects + +A session that ends without LOGOUT or CLOSE does not expunge anything ([RFC 2683 section 3.1.2](https://www.rfc-editor.org/rfc/rfc2683#section-3.1.2)). CLOSE expunges the `\Deleted` messages without sending EXPUNGE responses to the closing session, the other sessions get them. + +There is no inactivity timeout, a session stays open until the client or the test closes it. To test how a client handles a server that drops it, use [`server.control.disconnect()`](../control-api/users-and-sessions.md) or a [script rule](../faults/scripted-faults.md). + +## Writing tests for this + +The control API is the simplest way to be "the other session" in a client test: `server.control.addMessage()`, `setFlags()` and `expungeMessages()` change the mailbox while your client is connected, and the client gets the same unsolicited responses as above. See [Writing client tests](./writing-client-tests.md). diff --git a/docs/docs/guides/storage.md b/docs/docs/guides/storage.md new file mode 100644 index 0000000..208b294 --- /dev/null +++ b/docs/docs/guides/storage.md @@ -0,0 +1,371 @@ +--- +title: Storage +sidebar_position: 2 +description: The storage option in depth, namespaces, mailboxes, messages, \Recent, plugin data, subscriptions, validation and snapshot round trips. +--- + +# Storage + +The `storage` option describes the whole mailbox tree a server starts with: namespaces, mailboxes, messages, flags and UIDs. The command line takes the same object as a JSON file with `--storage=` (or `IMAPKIT_STORAGE`). + +ImapKit deep copies the object when the server is built, so you can reuse one fixture for many servers and nothing a client does ever changes your object. Without a `storage` option the server starts with an empty INBOX and an empty personal namespace, `{ "INBOX": {}, "": {} }`. + +## A first example + +```javascript title="storage-example.js" +import imapkit from 'imapkit'; + +const server = imapkit({ + plugins: ['NAMESPACE', 'SPECIAL-USE'], + storage: { + INBOX: { + uidvalidity: 1700000000, + messages: [ + 'Subject: plain string\r\n\r\nHello\r\n', + { + raw: 'Subject: with options\r\n\r\nHi\r\n', + uid: 45, + flags: ['\\Seen', '$Important'], + internaldate: '14-Sep-2013 21:22:28 -0300' + } + ] + }, + '': { + separator: '/', + folders: { + Archive: { + 'special-use': '\\Archive', + folders: { 2025: {} } + }, + Sent: { 'special-use': '\\Sent', subscribed: false } + } + }, + '#shared/': { type: 'shared' } + } +}); + +const port = await server.start(); +``` + +A client that logs in sees this (`C:` is the client, `S:` the server): + +```text +C: A2 NAMESPACE +S: * NAMESPACE (("" "/")) NIL (("#shared/" "/")) +S: A2 OK Completed +C: A3 LIST "" "*" +S: * LIST (\HasNoChildren) "/" "INBOX" +S: * LIST (\HasChildren \Archive) "/" "Archive" +S: * LIST (\HasNoChildren) "/" "Archive/2025" +S: * LIST (\HasNoChildren \Sent) "/" "Sent" +S: A3 OK Completed +C: A4 LSUB "" "*" +S: * LSUB (\HasNoChildren) "/" "INBOX" +S: * LSUB (\HasChildren \Archive) "/" "Archive" +S: * LSUB (\HasNoChildren) "/" "Archive/2025" +S: A4 OK Completed +C: A5 SELECT INBOX +S: * FLAGS (\Answered \Flagged \Draft \Deleted \Seen $Important) +S: * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen $Important \*)] Flags permitted +S: * 2 EXISTS +S: * 0 RECENT +S: * OK [UNSEEN 2] First unseen message +S: * OK [UIDVALIDITY 1700000000] UIDs valid +S: * OK [UIDNEXT 47] Predicted next UID +S: A5 OK [READ-WRITE] Completed +C: A6 FETCH 1:* (UID FLAGS INTERNALDATE) +S: * 1 FETCH (UID 45 FLAGS (\Seen $Important) INTERNALDATE "14-Sep-2013 21:22:28 -0300") +S: * 2 FETCH (UID 46 FLAGS () INTERNALDATE "08-Oct-2026 18:21:25 +0300") +S: A6 OK FETCH Completed +``` + +A few things to notice, each explained below: + +- The message given as a plain string got UID 46, after the explicit UID 45, and the current time as its internal date. +- `$Important` shows up in FLAGS and PERMANENTFLAGS because a message has it. +- `Sent` has `subscribed: false`, so LSUB leaves it out. +- `\Archive` and `\Sent` are listed because the SPECIAL-USE plugin is loaded. + +## Namespaces + +The top level keys of `storage` are namespaces, keyed by their prefix. The one exception is `INBOX`, which is a mailbox of its own (it can still have child mailboxes in `folders`). + +| Key | Meaning | +| ---------------------------- | -------------------------------------------------------------------------------------------- | +| `"INBOX"` | The INBOX mailbox. Takes the [mailbox keys](#mailboxes) and `separator`. | +| `""` | A namespace without a prefix, mailbox names are used as they are (`Archive/2025`). | +| `"INBOX."` | A namespace with a prefix, as Cyrus uses it: mailboxes are `INBOX.Drafts`, `INBOX.Sent`, ... | +| `"#shared/"`, `"user."`, ... | Other namespaces, usually with `type` set to `"shared"` or `"user"`. | + +A namespace object takes these keys, plus the [mailbox keys](#mailboxes): + +| Key | Default | Description | +| ----------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `separator` | The last character of the key if it is not a letter or a digit, otherwise `"/"` | The hierarchy separator, a single character. `"INBOX."` gets `"."`, `"#shared/"` gets `"/"`, `"#news"` and `""` get `"/"`. | +| `type` | `"personal"` | `"personal"`, `"user"` (other users' mailboxes) or `"shared"`. The NAMESPACE plugin lists the namespaces in these three groups ([RFC 2342](https://www.rfc-editor.org/rfc/rfc2342)). | +| `folders` | `{}` | The mailboxes of the namespace, by name. | + +The first personal namespace is where a LIST with an empty reference looks. If the storage has no personal namespace, ImapKit adds `""` as one. INBOX takes the separator of that namespace unless it sets its own `separator`. + +New mailboxes can only be created in personal namespaces. CREATE of a name in a `user` or `shared` namespace is answered with `NO [NOPERM]`. + +:::note +With a prefixed personal namespace like `"INBOX."`, LIST with an empty reference currently matches the pattern relative to the prefix, see [Known issues](../reference/known-issues.md#list-with-a-prefixed-personal-namespace). +::: + +### Cyrus + +A Cyrus style layout keeps personal mailboxes under `INBOX.`, other users under `user.` and shared mailboxes without a prefix: + +```json title="storage.json" +{ + "INBOX": {}, + "INBOX.": {}, + "user.": { + "type": "user" + }, + "": { + "type": "shared" + } +} +``` + +With the NAMESPACE plugin loaded the server answers: + +```text +C: A2 NAMESPACE +S: * NAMESPACE (("INBOX." ".")) (("user." ".")) (("" "/")) +S: A2 OK Completed +``` + +### Gmail + +Gmail keeps its system mailboxes under a `\Noselect` `[Gmail]` level and marks them with special-use attributes: + +```json title="storage.json" +{ + "INBOX": {}, + "": { + "separator": "/", + "folders": { + "[Gmail]": { + "flags": ["\\Noselect"], + "folders": { + "All Mail": { "special-use": "\\All" }, + "Drafts": { "special-use": "\\Drafts" }, + "Important": { "special-use": "\\Important" }, + "Sent Mail": { "special-use": "\\Sent" }, + "Spam": { "special-use": "\\Junk" }, + "Starred": { "special-use": "\\Flagged" }, + "Trash": { "special-use": "\\Trash" } + } + } + } + } +} +``` + +With the SPECIAL-USE plugin (and X-GM-EXT-1 for the Gmail extensions): + +```text +C: A2 LIST "" "*" +S: * LIST (\HasNoChildren) "/" "INBOX" +S: * LIST (\Noselect \HasChildren) "/" "[Gmail]" +S: * LIST (\HasNoChildren \All) "/" "[Gmail]/All Mail" +S: * LIST (\HasNoChildren \Drafts) "/" "[Gmail]/Drafts" +S: * LIST (\HasNoChildren \Important) "/" "[Gmail]/Important" +S: * LIST (\HasNoChildren \Sent) "/" "[Gmail]/Sent Mail" +S: * LIST (\HasNoChildren \Junk) "/" "[Gmail]/Spam" +S: * LIST (\HasNoChildren \Flagged) "/" "[Gmail]/Starred" +S: * LIST (\HasNoChildren \Trash) "/" "[Gmail]/Trash" +S: A2 OK Completed +``` + +## Mailboxes + +INBOX, every namespace and every entry in a `folders` object take these keys. All of them are optional. + +| Key | Default | Description | +| --------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `uidvalidity` | `1` | UIDVALIDITY, an integer from 1 to 4294967295. A mailbox created later gets a value higher than any in use. | +| `uidnext` | One more than the highest UID | The next UID. A value lower than the highest message UID plus one is raised. | +| `flags` | `[]` | Mailbox attributes, for example `["\\Noselect"]` or `["\\Noinferiors"]`, in any case. `\HasChildren` and `\HasNoChildren` are set by the server. | +| `permanentFlags` | The `systemFlags` option, `\Answered \Flagged \Draft \Deleted \Seen` | The flags clients can store. | +| `allowPermanentFlags` | `true` | When true, clients can create new keywords (PERMANENTFLAGS ends with `\*`). When false, STORE ignores flags that are not in `permanentFlags`. | +| `knownFlags` | `[]` | Keywords that stay in FLAGS and PERMANENTFLAGS even when no message has them. The server adds every flag a message gets here, a snapshot keeps the list. | +| `subscribed` | `true` | Set `false` to leave the mailbox out of the subscription list, see [Subscriptions](#subscriptions). | +| `messages` | `[]` | The messages, see [Messages](#messages). | +| `folders` | none | Child mailboxes by name. | +| `uid` | `1` | Kept from older versions of the format and not used. Snapshots include it. | + +A `\Noselect` mailbox is only a hierarchy level: it is listed, but SELECT answers `NO [NONEXISTENT]`. A `\Noinferiors` mailbox can not get children, CREATE below it is answered with `NO [CANNOT]`. + +Flags of messages in the storage become flags of the mailbox: they are added to FLAGS and PERMANENTFLAGS, and a keyword stays there after the last message with it loses it ([RFC 3501 section 7.2.6](https://www.rfc-editor.org/rfc/rfc3501#section-7.2.6)). + +## Messages + +A message is either a string with the full message source, or an object: + +| Key | Default | Description | +| -------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `raw` | `""` | The message source. A string, or a `Buffer` / `Uint8Array` in JavaScript. | +| `uid` | Assigned | The UID, an integer from 1 to 4294967295. Two messages with the same UID in one mailbox throw `Duplicate UID in mailbox `. | +| `flags` | `[]` | A flag or a list of flags. `\Recent` here is turned into `recent: true`. | +| `internaldate` | The current time | An RFC 3501 date-time string such as `"14-Sep-2013 21:22:28 -0300"`, or a `Date`. The month name can be in any case, it is sent as `Sep`. | +| `recent` | `false` | `true` makes the message `\Recent` for the first session that selects the mailbox, see [`\Recent`](#recent). | + +Messages are sorted by UID when the server loads them. Messages without a `uid` get UIDs after the highest UID of the mailbox, in the order they are listed. That is why the plain string in the first example got UID 46 and not 1. + +The current time for messages without `internaldate` comes from the `now` option when it is set, which keeps dates the same from run to run (see [Repeatable tests](../faults/repeatable-tests.md)). + +:::caution 8-bit message sources +ImapKit keeps a message source as a binary string, one character per octet. A JavaScript string with characters up to U+00FF is taken octet by octet, so `'Subject: café'` stores `é` as the single Latin-1 octet `0xE9`. Only a string with a character above U+00FF is encoded as UTF-8. To store UTF-8 (or any exact octets), pass a `Buffer`: `{ raw: Buffer.from('Subject: café\r\n\r\nx') }`. +::: + +## `\Recent` + +`\Recent` is a session flag, not a stored one ([RFC 3501 section 2.3.2](https://www.rfc-editor.org/rfc/rfc3501#section-2.3.2)). A message from the storage is `\Recent` only when it has `recent: true` (or `\Recent` in its `flags`). The first session that selects the mailbox read-write takes the `\Recent` flags, later sessions see `0 RECENT`. EXAMINE shows them but does not take them. See [Multiple sessions](./multiple-sessions.md#recent) for a transcript. + +Messages added later by APPEND, COPY, MOVE, SMTP or the control API are `\Recent` for one session: a session that has the mailbox selected read-write gets them, otherwise the next session that selects it. + +## Plugin data + +Plugins keep their own data on mailboxes and messages, and the storage can set it up front. A key is only used when its plugin is loaded, otherwise it is kept as it is and ignored. + +| Key | On | Plugin | Description | +| ----------------------------------------- | ---------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `special-use` | mailbox | SPECIAL-USE, CREATE-SPECIAL-USE, X-GM-EXT-1 | A special-use attribute or a list of them, for example `"\\Sent"`. | +| `acl` | mailbox | ACL | Rights by identifier, for example `{ "otheruser": "lrs", "anyone": "l" }`. See [Access control](../extensions/access-control.md). | +| `metadata` | mailbox | METADATA | Mailbox annotations, for example `{ "/private/comment": "My comment" }`. | +| `appendLimit` | mailbox | APPENDLIMIT | The APPEND limit of the mailbox in octets, `null` for no limit. | +| `HIGHESTMODSEQ` | mailbox | CONDSTORE | The highest mod-sequence. | +| `MODSEQ` | message | CONDSTORE | The mod-sequence of the message, a new one is assigned when missing. | +| `qresyncExpunged` | mailbox | QRESYNC | Expunged UIDs with their mod-sequences, written by the server. UIDs missing from the initial storage count as expunged before the server started. | +| `MAILBOXID` | mailbox | OBJECTID | The mailbox id, generated (`F1`, `F2`, ...) when missing. | +| `EMAILID`, `THREADID` | message | OBJECTID | The message and thread ids, generated (`M1`, `T1`, ...) when missing. | +| `SAVEDATE` | mailbox, message | SAVEDATE | On a mailbox, `false` turns save dates off. On a message, the save date (a date-time string or a `Date`), the server start time when missing. | +| `X-GM-MSGID`, `X-GM-THRID`, `X-GM-LABELS` | message | X-GM-EXT-1 | Gmail message id, thread id and labels. | +| `preview` | message | PREVIEW | A fixed PREVIEW text instead of a generated one. | + +```json title="storage.json" +{ + "INBOX": { + "acl": { "otheruser": "lrs" }, + "metadata": { "/private/comment": "My comment" }, + "messages": [{ "raw": "Subject: hi\r\n\r\nHello\r\n", "MODSEQ": 5, "EMAILID": "M100" }] + }, + "": {} +} +``` + +## Subscriptions + +The subscription list holds mailbox names, not mailboxes ([RFC 3501 section 6.3.6](https://www.rfc-editor.org/rfc/rfc3501#section-6.3.6)): + +- A mailbox from the storage is subscribed unless it has `"subscribed": false`. A mailbox created later is not. +- DELETE does not unsubscribe. LSUB and `LIST (SUBSCRIBED)` keep listing the name (as `\NonExistent` in extended LIST) until UNSUBSCRIBE, and a mailbox created again under that name is subscribed. +- RENAME leaves the subscription with the old name. +- SUBSCRIBE refuses names that are not mailboxes, UNSUBSCRIBE accepts any name. + +```text +C: A2 LSUB "" "*" +S: * LSUB (\HasNoChildren) "/" "INBOX" +S: * LSUB (\HasNoChildren) "/" "Old" +S: A2 OK Completed +C: A3 CREATE New +S: A3 OK CREATE completed +C: A4 RENAME Old Renamed +S: A4 OK RENAME completed +C: A5 DELETE Renamed +S: A5 OK DELETE completed +C: A6 LIST (SUBSCRIBED) "" "*" +S: * LIST (\Subscribed \HasNoChildren) "/" "INBOX" +S: * LIST (\NonExistent \Subscribed \HasNoChildren) "/" "Old" +S: A6 OK Completed +C: A7 SUBSCRIBE Missing +S: A7 NO [NONEXISTENT] Mailbox does not exist +``` + +This run used `{ "INBOX": {}, "": { "folders": { "Old": {}, "Hidden": { "subscribed": false } } } }` with the LIST-EXTENDED plugin. `New` is not subscribed because it was created by a client. + +## Validation + +The server checks the `storage` option when it is built and throws an error with the path of the problem, so a typo in a fixture fails loudly instead of giving you an empty mailbox: + +```javascript title="validate.js" +import imapkit, { validateStorage } from 'imapkit'; + +try { + imapkit({ storage: { INBOX: { message: ['Subject: hi\r\n\r\nHello\r\n'] } } }); +} catch (err) { + console.log(err.message); + // Invalid storage at "INBOX": unknown key "message", did you mean "messages"? +} + +try { + validateStorage({ '': { folders: { Sent: { messages: [{ raw: 'x', uid: 0 }] } } } }); +} catch (err) { + console.log(err.message); + // Invalid storage at "".folders["Sent"].messages[0].uid: must be an integer from 1 to 4294967295 +} +``` + +The check covers the types of the known keys, `type` and `separator` of namespaces, and keys that look like a typo of a known key (the same key in another case, or one edit away, such as `uidValidity` or `flagz`). Other unknown keys are allowed, since plugins keep their own data on mailboxes and messages. + +The package exports the same check and a JSON Schema (draft 2020-12) of the format: + +| Export | Description | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `validateStorage(storage)` | Throws `Invalid storage at : ` for an invalid storage object. | +| `storageSchema` | The JSON Schema, with `$defs` for `mailbox`, `message` and `namespace`. Point your editor or a schema validator at it for storage files. | + +```javascript +import { storageSchema, validateStorage } from 'imapkit'; +``` + +## Snapshots + +`server.control.snapshot()` returns the current state in the shape of the `storage` option: mailboxes, messages, UIDs, flags, subscriptions and the JSON data of plugins. A new server built from a snapshot starts where the old one stopped: + +```javascript title="snapshot.js" +import assert from 'node:assert'; +import imapkit from 'imapkit'; + +const server = imapkit({ plugins: ['CONDSTORE'] }); +server.control.createMailbox('Projects'); +server.control.addMessage('Projects', { raw: 'Subject: plan\r\n\r\nDraft\r\n', flags: ['\\Draft'] }); + +const saved = server.control.snapshot(); + +// a new server starts from the saved state +const copy = imapkit({ plugins: ['CONDSTORE'], storage: saved }); +assert.deepStrictEqual(copy.control.snapshot(), saved); +``` + +The `Projects` part of that snapshot looks like this. Every key the server filled in is written out, the message source is the binary string described in [Messages](#messages): + +```json +{ + "uidvalidity": 2, + "uid": 1, + "allowPermanentFlags": true, + "permanentFlags": ["\\Answered", "\\Flagged", "\\Draft", "\\Deleted", "\\Seen"], + "uidnext": 2, + "knownFlags": ["\\Draft"], + "HIGHESTMODSEQ": 2, + "flags": [], + "subscribed": false, + "messages": [ + { + "flags": ["\\Draft"], + "internaldate": "08-Oct-2026 18:21:55 +0300", + "raw": "Subject: plan\r\n\r\nDraft\r\n", + "recent": true, + "uid": 1, + "MODSEQ": 2 + } + ] +} +``` + +Subscriptions of names that are not mailboxes (for example a deleted mailbox that is still subscribed) are not part of a snapshot. See [Mailboxes and messages](../control-api/mailboxes-and-messages.md) for the rest of the control API. diff --git a/docs/docs/guides/strict-by-design.md b/docs/docs/guides/strict-by-design.md new file mode 100644 index 0000000..17a7edd --- /dev/null +++ b/docs/docs/guides/strict-by-design.md @@ -0,0 +1,173 @@ +--- +title: Strict by Design +sidebar_position: 4 +description: Why ImapKit refuses client input that breaks the IMAP RFCs, the full list of rules it enforces, and how to read a BAD in your test transcript. +--- + +# Strict by design + +ImapKit is a guardrail for developing standards compliant IMAP clients. It follows the RFCs strictly instead of accepting whatever a client sends. + +Most production servers are lenient. They guess what a sloppy command meant, accept literals before the continuation request, or ignore extra arguments. A client that works against them can still break against the next, stricter server, and the bug shows up in production. ImapKit answers such input with `BAD` (or `NO` where the RFC asks for it), so the bug shows up in your test suite instead. + +## Reading a BAD + +When your client gets `BAD` from ImapKit, the client broke the protocol grammar or a MUST of an RFC. The human readable text says what was wrong: + +```text +C: A1 SELECT INBOX +S: A1 BAD SELECT is not allowed in the Not Authenticated state +C: A2 LOGIN testuser testpass +S: A2 OK User logged in +C: A3 NOOP now +S: A3 BAD NOOP does not take any arguments +C: A4 SELECT INBOX +S: * 1 EXISTS +S: A4 OK [READ-WRITE] Completed +C: A5 FETCH 2 FLAGS +S: A5 BAD Message sequence number 2 is greater than the number of messages (1) +C: A6 STORE 1 +FLAGS (\Recent) +S: A6 BAD Invalid system flag \Recent +C: A7 SEARCH SUBJECT "café" +S: * BAD [SYNTAX] Unexpected char at position 22 +S: A7 BAD Error parsing command +C: A8 CREATE "Café" +S: * BAD [SYNTAX] Unexpected char at position 14 +S: A8 BAD Error parsing command +C: A9 CREATE "Caf&AOk-" +S: A9 OK CREATE completed +``` + +Some untagged SELECT responses are left out above. `A7` needs `CHARSET UTF-8` for an 8-bit search string, and `A8` must encode the mailbox name in modified UTF-7, as `A9` does. + +`NO` is different: it means the command was valid but could not be carried out (a missing mailbox, a wrong password, an expunged message). Many `NO` responses carry a response code that tells the client what to do next, see [Response codes](#response-codes). + +Some RFC 2683 recommendations that a client SHOULD follow are flagged without failing the command: `STATUS` on the selected mailbox completes, but with `OK [CLIENTBUG]`. + +```text +C: B1 STATUS INBOX (MESSAGES) +S: * STATUS INBOX (MESSAGES 1) +S: B1 OK [CLIENTBUG] Status completed, STATUS SHOULD NOT be used on the selected mailbox +C: B2 EXAMINE INBOX +S: * 1 EXISTS +S: B2 OK [READ-ONLY] Completed +C: B3 STORE 1 +FLAGS (\Seen) +S: B3 NO [CLIENTBUG] Mailbox is read-only +``` + +A simple rule for client tests: fail the test on any tagged `BAD`, and on any `CLIENTBUG` code. + +## The rules + +ImapKit answers these with `BAD`, or `NO` where noted. The list matches the README and is covered by `test/conformance.test.ts` and the tests of each plugin. + +### Commands and arguments + +| Rule | Reference | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Commands sent in the wrong state, for example `FETCH` before `SELECT` or `LOGIN` after login | [RFC 3501 section 3](https://www.rfc-editor.org/rfc/rfc3501#section-3) | +| Arguments to commands that take none (`NOOP x`, `CLOSE x`), missing or extra arguments, and values that break the grammar | [RFC 3501 section 9](https://www.rfc-editor.org/rfc/rfc3501#section-9) | +| Invalid sequence sets (`0`, `abc`, `5:`, `1:2:3`) | RFC 3501 section 9 | +| Message sequence numbers greater than the number of messages in FETCH, STORE, COPY and MOVE, also `*` in an empty mailbox. UID sets and SEARCH keys can point past the end | RFC 3501 section 9, seq-number | +| Flags that are not atoms, `\Recent` in STORE or APPEND, invalid dates | RFC 3501 section 9 | +| `ENABLE` after `SELECT` or `EXAMINE` | [RFC 5161 section 3.1](https://www.rfc-editor.org/rfc/rfc5161#section-3.1) | +| `ID` lists that break the limits of RFC 2971 | [RFC 2971](https://www.rfc-editor.org/rfc/rfc2971) | +| More than one message in `APPEND` without MULTIAPPEND. With MULTIAPPEND, a zero-length message literal cancels the whole `APPEND` with `NO` | [RFC 3502](https://www.rfc-editor.org/rfc/rfc3502) | +| Command lines longer than 1 MiB. RFC 2683 asks servers to accept at least 8000 octets, and clients to stay near 1000 | [RFC 2683 section 3.2.1.5](https://www.rfc-editor.org/rfc/rfc2683#section-3.2.1.5) | + +### Framing, literals and pipelining + +| Rule | Reference | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Command lines that end with a bare LF instead of CRLF | RFC 3501 section 9 | +| Literal data sent before the server's `+` continuation request | [RFC 3501 section 4.3](https://www.rfc-editor.org/rfc/rfc3501#section-4.3) | +| `{n+}` without LITERAL+ or LITERAL-. With LITERAL-, a non-synchronizing literal over 4096 octets gets `BAD [TOOBIG]` | [RFC 7888 section 5](https://www.rfc-editor.org/rfc/rfc7888#section-5) | +| Literals for unknown commands, or for commands that can not run in the current state, are refused without a continuation request | RFC 3501 section 4.3 | +| Literal8 (`~{n}`) anywhere but in an APPEND or REPLACE message with BINARY, or a SETMETADATA value with METADATA, refused without a continuation request. NUL octets in a normal literal. A literal8 `TEXT` part in CATENATE | [RFC 3516](https://www.rfc-editor.org/rfc/rfc3516), [RFC 4469 section 5](https://www.rfc-editor.org/rfc/rfc4469#section-5) | +| Pipelined commands that RFC 3501 calls ambiguous, for example `CHECK` followed by `FETCH` without waiting for the `CHECK` result | [RFC 3501 section 5.5](https://www.rfc-editor.org/rfc/rfc3501#section-5.5) | +| `STARTTLS` and `COMPRESS` with commands pipelined after them. TLS or compression is not started and the pipelined commands are refused without running. `COMPRESS` while compression is active gets `BAD [COMPRESSIONACTIVE]` | [RFC 9051 section 6.2.1](https://www.rfc-editor.org/rfc/rfc9051#section-6.2.1), [RFC 4978 section 3](https://www.rfc-editor.org/rfc/rfc4978#section-3) | +| Anything other than `DONE` while IDLE | [RFC 2177](https://www.rfc-editor.org/rfc/rfc2177) | + +Two of these, each pair sent in a single write (`` marks a bare line feed): + +```text +C: A3 CHECK +C: A4 FETCH 1 FLAGS +S: A3 OK Completed +S: A4 BAD Commands with message sequence numbers must wait for the completion of earlier commands +C: A5 NOOP +C: A6 NOOP +S: A5 BAD Lines must end with CRLF +S: A6 OK Completed +``` + +### Mailbox names + +| Rule | Reference | +| ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | +| Names that are not valid modified UTF-7, including 8-bit names | [RFC 3501 section 5.1.3](https://www.rfc-editor.org/rfc/rfc3501#section-5.1.3) | +| CREATE or RENAME to names with an empty hierarchy level (`foo//bar`, `/foo`, `foo//`), answered with `NO [CANNOT]` | [RFC 5530 section 3](https://www.rfc-editor.org/rfc/rfc5530#section-3) | + +### Search, sort and thread + +| Rule | Reference | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 8-bit SEARCH strings without `CHARSET UTF-8`, invalid UTF-8. An unsupported charset gets `NO [BADCHARSET]` | [RFC 3501 section 6.4.4](https://www.rfc-editor.org/rfc/rfc3501#section-6.4.4) | +| SORT and THREAD with a charset that is not an atom or a quoted string, an empty sort criteria list, `REVERSE` that is not followed by a sort key (`REVERSE REVERSE DATE`), or a threading algorithm that is not an atom | [RFC 5256 section 5](https://www.rfc-editor.org/rfc/rfc5256#section-5) | +| Unknown `SEARCH RETURN` options or `RETURN` after `CHARSET`, `$` combined with numbers, and `SEARCH MODSEQ` values or entry names that break the grammar | [RFC 4466 section 2.6.1](https://www.rfc-editor.org/rfc/rfc4466#section-2.6.1), [RFC 5182](https://www.rfc-editor.org/rfc/rfc5182), [RFC 7162](https://www.rfc-editor.org/rfc/rfc7162) | +| `UIDAFTER` and `UIDBEFORE` (MESSAGELIMIT) with anything but a single UID | [RFC 9738 section 3.2](https://www.rfc-editor.org/rfc/rfc9738#section-3.2) | + +### Authentication + +| Rule | Reference | +| ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Invalid base64 in SASL exchanges | RFC 3501 section 9 | +| 8-bit user names or passwords in `LOGIN` (UTF-8 user names need `AUTHENTICATE`), invalid UTF-8 in an `AUTHENTICATE PLAIN` message | [RFC 9755 section 5](https://www.rfc-editor.org/rfc/rfc9755#section-5), [RFC 4616 section 2](https://www.rfc-editor.org/rfc/rfc4616#section-2) | +| OAUTHBEARER client responses that break the RFC 7628 or GS2 grammar, and anything other than a single `%x01` after an OAUTHBEARER error result | [RFC 7628](https://www.rfc-editor.org/rfc/rfc7628), [RFC 5801](https://www.rfc-editor.org/rfc/rfc5801) | + +See [Authentication](./authentication.md) for transcripts. + +### Extensions + +These apply when the plugin is loaded. Without it, the extension's commands and arguments are unknown and get `BAD` anyway. + +| Extension | Rule | +| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| QRESYNC ([RFC 7162](https://www.rfc-editor.org/rfc/rfc7162)) | The QRESYNC `SELECT` parameter or the `VANISHED` modifier without `ENABLE QRESYNC`, `VANISHED` with `FETCH` or without `CHANGEDSINCE`, and values that break the grammar: UIDVALIDITY or mod-sequence `0`, `*` in the UID sets, sequence match sets that are not ascending or not of the same size | +| LIST-EXTENDED ([RFC 5258](https://www.rfc-editor.org/rfc/rfc5258)) | Unknown options, `RECURSIVEMATCH` without a base option like `SUBSCRIBED` (also `(SPECIAL-USE RECURSIVEMATCH)`, [RFC 6154 section 6](https://www.rfc-editor.org/rfc/rfc6154#section-6)), an empty pattern list, options with values they do not take, a repeated `STATUS` return option with different items, invalid `STATUS` items ([RFC 5819](https://www.rfc-editor.org/rfc/rfc5819)) | +| METADATA ([RFC 5464 section 3.2](https://www.rfc-editor.org/rfc/rfc5464#section-3.2)) | Entry names with `//`, a trailing `/`, `*`, `%`, 8-bit or control characters, or a scope other than `/private` or `/shared`, values that are atoms or use bare CR or LF as line ends, empty entry or option lists, and GETMETADATA options after the mailbox name (errata 2785) | +| ACL ([RFC 4314 section 3](https://www.rfc-editor.org/rfc/rfc4314#section-3)) | Unknown or uppercase rights, empty identifiers, identifiers with control characters or invalid UTF-8 | +| CATENATE ([RFC 4469](https://www.rfc-editor.org/rfc/rfc4469)) | URLs that are not absolute-path references (`/INBOX/;UID=1`), including relative-path references like `;UID=1` that [RFC 5092 section 7.2](https://www.rfc-editor.org/rfc/rfc5092#section-7.2) forbids, and URLs of message parts that do not exist (`NO [BADURL ...]`) | +| UIDONLY ([RFC 9586](https://www.rfc-editor.org/rfc/rfc9586)) | Every command that takes message sequence numbers, and sequence sets in search criteria, get `BAD [UIDREQUIRED]` | +| UTF8=ACCEPT ([RFC 9755](https://www.rfc-editor.org/rfc/rfc9755)) | Invalid UTF-8 in quoted strings, `SEARCH CHARSET` after `ENABLE UTF8=ACCEPT`, mailbox names with control characters (in UTF-8, or encoded in modified UTF-7 like `&AA0-`), U+2028, U+2029, a leading BOM, unassigned code points or a name that is not in Unicode Normalization Form C. `NO` for `APPEND` of a message with an 8-bit header before `ENABLE UTF8=ACCEPT` (section 4) | +| BINARY ([RFC 3516](https://www.rfc-editor.org/rfc/rfc3516)) | `BINARY[]`, `BINARY` of multipart or message/rfc822 parts ([RFC 9051 section 6.4.5](https://www.rfc-editor.org/rfc/rfc9051#section-6.4.5) allows leaf body parts only), `HEADER`, `TEXT` or `MIME` sections, and a partial range on `BINARY.SIZE` | +| NOTIFY ([RFC 5465](https://www.rfc-editor.org/rfc/rfc5465)) | MessageNew without MessageExpunge or the other way round, FlagChange without both (section 5), mailbox events or two selected filters with `selected`/`selected-delayed` (section 6.1), fetch attributes outside the selected filters, empty event or mailbox lists, `NOTIFY SET` without event groups. Unknown events get `NO [BADEVENT (...)]` listing the supported ones (section 3.1) | +| IMAP4rev2 ([RFC 9051](https://www.rfc-editor.org/rfc/rfc9051)) | After `ENABLE IMAP4rev2`: `CHECK`, `LSUB`, the `RFC822`, `RFC822.HEADER` and `RFC822.TEXT` FETCH items, the `NEW`, `OLD` and `RECENT` SEARCH keys and the `RECENT` STATUS item (none are in the RFC 9051 grammar, Appendix E), numbers above 63 bits in LARGER and SMALLER, invalid UTF-8, and mailbox names that are not Net-Unicode (section 5.1). Before it: 8-bit quoted strings (Appendix A), and partial ranges, LARGER and SMALLER values above 32 bits | + +## Response codes + +Failures carry the [RFC 5530](https://www.rfc-editor.org/rfc/rfc5530) response codes that [RFC 9051 section 7.1](https://www.rfc-editor.org/rfc/rfc9051#section-7.1) lists, in both protocol revisions: + +| Code | When | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `AUTHENTICATIONFAILED`, `AUTHORIZATIONFAILED` | Failed logins | +| `ALREADYEXISTS`, `NONEXISTENT`, `CANNOT`, `HASCHILDREN`, `NOPERM` | Mailbox operations | +| `TRYCREATE` | The target of APPEND, COPY or MOVE does not exist or is a `\Noselect` name | +| `CLIENTBUG` | `STATUS` on the selected mailbox, and `STORE`, `EXPUNGE`, `UID EXPUNGE`, `MOVE` and `REPLACE` in a mailbox selected read-only | +| `EXPUNGEISSUED` | FETCH, STORE, SEARCH, SORT or THREAD completes while the EXPUNGE of another session can not be reported yet, see [Multiple sessions](./multiple-sessions.md) | + +## RFC 2683 recommendations + +A few client side recommendations of [RFC 2683](https://www.rfc-editor.org/rfc/rfc2683) are checked as well: + +- `STATUS` on the selected mailbox gets `CLIENTBUG` (section 3.1.1). +- Mailbox names must be valid modified UTF-7 (section 3.4.2). +- EXPUNGE or STORE after EXAMINE, which answered `[READ-ONLY]`, get `NO` (section 3.3.2). + +## The other direction + +Strictness works both ways. ImapKit's own responses follow the grammar too: a string that can not be quoted is sent as a literal, and every status response has human readable text. + +ImapKit's test suite enforces this. Every transcript a test produces goes through a response grammar check before the test looks at it: CRLF framing and literals, the RFC 3501 section 9 shape of tagged, untagged and continuation responses, and ImapFlow's response parser. A fuzz test replays mutated commands under the same check. So if your client fails to parse something ImapKit sent, look at the client's parser first. + +The flip side: ImapKit does not imitate the quirks of real servers by default. To test how your client copes with a server that misbehaves, use [Scripted faults](../faults/scripted-faults.md) and [Quirk presets](../faults/quirk-presets.md). diff --git a/docs/docs/guides/testing-from-other-languages.md b/docs/docs/guides/testing-from-other-languages.md new file mode 100644 index 0000000..d97957e --- /dev/null +++ b/docs/docs/guides/testing-from-other-languages.md @@ -0,0 +1,481 @@ +--- +sidebar_position: 6 +title: Testing from Other Languages +description: Use ImapKit from Python, Go or any other language by running the imapkit command with its REST API and event stream. +--- + +# Testing from Other Languages + +ImapKit is written in TypeScript, but the client you test can be written in anything. Run ImapKit as a standalone process with `--rest-port`, and your test suite controls it over HTTP: the [REST API](../rest-api/overview.md) has every operation of the [control API](../control-api/overview.md), and [`GET /v1/events`](../rest-api/event-stream.md) streams the server events, so a test can wait for "the client selected INBOX" instead of sleeping. + +```mermaid +sequenceDiagram + participant T as Test (Python, Go, ...) + participant R as ImapKit REST API + participant I as ImapKit IMAP + participant C as Client under test + T->>R: POST /v1/reset + T->>R: GET /v1/events?types=session (stream) + C->>I: LOGIN, SELECT INBOX + R-->>T: event: session (type select) + T->>R: POST /v1/mailboxes/INBOX/messages + I-->>C: * 1 EXISTS + T->>R: GET /v1/mailboxes/INBOX/messages/1 +``` + +## Start the server + +Node.js 20 or newer has to be installed on the test machine. Then: + +```bash +npm install -g imapkit +imapkit -p 1143 --plugin=IDLE,UIDPLUS,MOVE --rest-port=8143 +``` + +```text +Starting ImapKit ... +ImapKit successfully listening on port 1143 +REST API listening on 127.0.0.1:8143 +``` + +The `REST API listening` line means both listeners are up, so a test harness can start the process and read its standard output until that line appears. All [command line options](../getting-started/command-line.md) work here: `--storage` for the starting mailboxes, `--config` for users and other server options, `--script` and `--quirk` for faults. + +The REST API listens on `127.0.0.1` only. To reach it from another machine or container, set `--rest-host` and a `--rest-token`, and send `Authorization: Bearer ` with every request. It controls the whole server, so keep it inside the test environment. + +## Reset between tests + +Starting a process per test is slow in most languages, so start one server for the whole suite and reset it before every test: + +```bash +curl -X POST http://127.0.0.1:8143/v1/reset -H 'Content-Type: application/json' +curl -X DELETE http://127.0.0.1:8143/v1/script/rules +``` + +`POST /v1/reset` restores the mailboxes and users of the server options (the `--storage` and `--config` files) and disconnects every session with `BYE`. Script rules survive a reset, so delete them too when tests add their own. + +:::note +Every `POST` needs `Content-Type: application/json`, even without a body. Requests in any other form are refused with HTTP 415, which keeps web pages from calling the API. +::: + +## The requests you need most + +```bash +# deliver a message, like an incoming mail +curl -X POST http://127.0.0.1:8143/v1/mailboxes/INBOX/messages \ + -H 'Content-Type: application/json' \ + -d '{"raw": "Subject: hello\r\n\r\nHi!\r\n", "flags": ["\\Seen"]}' +# {"uid":1,"uidvalidity":1} + +# change flags, mode is set, add or remove +curl -X POST http://127.0.0.1:8143/v1/mailboxes/INBOX/messages/flags \ + -H 'Content-Type: application/json' \ + -d '{"uids": [1], "flags": ["\\Flagged"], "mode": "add"}' +# [{"uid":1,"flags":["\\Seen","\\Flagged"]}] + +# what the client stored, raw is the base64 source +curl http://127.0.0.1:8143/v1/mailboxes/INBOX/messages/1 + +# a mailbox name with a separator is URL encoded +curl -X POST http://127.0.0.1:8143/v1/mailboxes \ + -H 'Content-Type: application/json' -d '{"path": "Work/Projects"}' +curl http://127.0.0.1:8143/v1/mailboxes/Work%2FProjects + +# connected sessions, and a fault for the next SELECT +curl http://127.0.0.1:8143/v1/sessions +curl -X POST http://127.0.0.1:8143/v1/script/rules \ + -H 'Content-Type: application/json' \ + -d '{"on": "command", "command": "SELECT", "times": 1, "send": "$TAG NO [UNAVAILABLE] Try again later\r\n"}' +``` + +Mailboxes are addressed by their storage name (modified UTF-7, the name a client sees in LIST) and messages by UID. Errors come back as `{ "error": { "code", "message" } }`, with HTTP 404 for `NONEXISTENT`, 409 for `ALREADYEXISTS` and failed mailbox operations, 400 for `INVALID` and 401 for a missing or wrong token. [Endpoints](../rest-api/endpoints.md) lists every route, and the running server describes itself at `GET /v1/openapi.json`. + +## Wait for events + +`GET /v1/events` is a [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html) stream. `?types=` picks the event types: `session`, `command`, `mailbox`, `expunge`, `flags`, `acl`, `script` and `reset`. + +```bash +curl -N 'http://127.0.0.1:8143/v1/events?types=session,command' +``` + +```text +: connected + +event: session +data: {"type":"open","session":{"session":1,"user":null,"state":"Not Authenticated","mailbox":null,"readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}} + +event: command +data: {"session":1,"tag":"A1","command":"LOGIN","status":"OK","user":"testuser"} + +event: session +data: {"type":"login","session":{"session":1,"user":"testuser","state":"Authenticated","mailbox":null,"readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}} + +event: session +data: {"type":"select","session":{"session":1,"user":"testuser","state":"Selected","mailbox":"INBOX","readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}} +``` + +Every event is an `event:` line with the type and a `data:` line with JSON, followed by an empty line. Lines that start with `:` are comments (`: connected` when the stream opens, `: ping` every 15 seconds) and can be skipped. Open the stream before the action you wait for: the server only sends events that happen while the stream is open. See [Event stream](../rest-api/event-stream.md) for the data of every type. + +The `session` event with `"type": "waiting"` and `"command": "IDLE"` tells you that a client has entered IDLE, the moment to deliver a message if you test push notifications. + +## Python + +This suite uses only the standard library: `unittest`, `urllib` for the REST API, a thread that reads the event stream, and `imaplib` as the IMAP client. In your project, the client under test takes the place of `imaplib`. + +```python title="test_mail_client.py" +import imaplib +import json +import os +import queue +import shlex +import subprocess +import threading +import time +import unittest +import urllib.error +import urllib.parse +import urllib.request + +IMAP_PORT = 1143 +REST_PORT = 8143 +REST = f"http://127.0.0.1:{REST_PORT}/v1" +# the command that runs ImapKit, e.g. "npx imapkit" in a project that has it as a dependency +IMAPKIT = shlex.split(os.environ.get("IMAPKIT", "imapkit")) + + +def call(method, path, body=None): + """Calls the REST API, returns the decoded JSON answer""" + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request(REST + path, data=data, method=method) + if data is not None: + request.add_header("Content-Type", "application/json") + with urllib.request.urlopen(request) as response: + return json.load(response) + + +class Events: + """Reads GET /v1/events in a thread, wait() returns the first matching event""" + + def __init__(self, types): + self.queue = queue.Queue() + self.response = urllib.request.urlopen(f"{REST}/events?types={types}") + threading.Thread(target=self._read, daemon=True).start() + + def _read(self): + event = None + try: + for raw in self.response: + line = raw.decode("utf-8").rstrip("\r\n") + if line.startswith("event: "): + event = line[len("event: "):] + elif line.startswith("data: "): + self.queue.put((event, json.loads(line[len("data: "):]))) + except Exception: + pass # the stream was closed + + def wait(self, event_type, match=lambda data: True, timeout=3): + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"no matching {event_type} event within {timeout}s") + try: + event, data = self.queue.get(timeout=remaining) + except queue.Empty: + continue + if event == event_type and match(data): + return data + + def close(self): + self.response.close() + + +def setUpModule(): + global server + server = subprocess.Popen( + IMAPKIT + [f"--port={IMAP_PORT}", f"--rest-port={REST_PORT}", "--plugin=IDLE,UIDPLUS,MOVE"], + stdout=subprocess.PIPE, + text=True, + ) + # the command prints this line once both listeners are up + for line in server.stdout: + if line.startswith("REST API listening"): + break + else: + raise RuntimeError("imapkit did not start") + + +def tearDownModule(): + server.terminate() + server.wait() + server.stdout.close() + + +class MailClientTest(unittest.TestCase): + def setUp(self): + # every test starts from the initial mailboxes, without faults from an earlier test + call("POST", "/reset", {}) + call("DELETE", "/script/rules") + + def connect(self): + imap = imaplib.IMAP4("127.0.0.1", IMAP_PORT) + imap.login("testuser", "testpass") + self.addCleanup(imap.logout) + return imap + + def test_delivery_reaches_a_polling_client(self): + events = Events("session") + self.addCleanup(events.close) + subjects = [] + + # stands in for the application under test: it selects INBOX and polls with NOOP + def application(): + imap = imaplib.IMAP4("127.0.0.1", IMAP_PORT) + imap.login("testuser", "testpass") + imap.select("INBOX") + for _ in range(50): + imap.noop() + status, data = imap.uid("SEARCH", "ALL") + if data[0]: + status, fetched = imap.uid("FETCH", data[0].decode(), "(BODY.PEEK[HEADER.FIELDS (SUBJECT)])") + subjects.append(fetched[0][1].decode().strip()) + break + time.sleep(0.05) + imap.logout() + + thread = threading.Thread(target=application) + thread.start() + + # deliver only once the application has opened INBOX, no fixed sleep + events.wait("session", lambda data: data["type"] == "select" and data["session"]["mailbox"] == "INBOX") + added = call("POST", "/mailboxes/INBOX/messages", {"raw": "Subject: hello\r\n\r\nHi!\r\n"}) + self.assertEqual(added["uid"], 1) + + thread.join(timeout=5) + self.assertEqual(subjects, ["Subject: hello"]) + + def test_client_flags_are_stored(self): + call("POST", "/mailboxes/INBOX/messages", {"raw": "Subject: one\r\n\r\n1\r\n"}) + imap = self.connect() + imap.select("INBOX") + imap.uid("STORE", "1", "+FLAGS", "(\\Flagged)") + + message = call("GET", "/mailboxes/INBOX/messages/1") + self.assertIn("\\Flagged", message["flags"]) + + def test_select_fails_once(self): + rule = call("POST", "/script/rules", {"on": "command", "command": "SELECT", "times": 1, "send": "$TAG NO [UNAVAILABLE] Try again later\r\n"}) + imap = self.connect() + self.assertEqual(imap.select("INBOX")[0], "NO") + self.assertEqual(imap.select("INBOX")[0], "OK") + + rules = call("GET", "/script/rules") + self.assertEqual([r["hits"] for r in rules if r["id"] == rule["id"]], [1]) + + def test_errors_are_json(self): + with self.assertRaises(urllib.error.HTTPError) as caught: + call("GET", "/mailboxes/" + urllib.parse.quote("No such", safe="")) + with caught.exception as error: + self.assertEqual(error.code, 404) + self.assertEqual(json.load(error)["error"]["code"], "NONEXISTENT") + + +if __name__ == "__main__": + unittest.main() +``` + +```bash +python3 -m unittest -v test_mail_client.py +# or, without a global install: IMAPKIT="npx imapkit" python3 -m unittest -v test_mail_client.py +``` + +```text +test_client_flags_are_stored (test_mail_client.MailClientTest.test_client_flags_are_stored) ... ok +test_delivery_reaches_a_polling_client (test_mail_client.MailClientTest.test_delivery_reaches_a_polling_client) ... ok +test_errors_are_json (test_mail_client.MailClientTest.test_errors_are_json) ... ok +test_select_fails_once (test_mail_client.MailClientTest.test_select_fails_once) ... ok +``` + +A shorter script that walks through the same calls is in the repository as [examples/rest-api.py](https://github.com/postalsys/imapkit/blob/master/examples/rest-api.py). + +## Go + +The same pattern with the Go standard library: `TestMain` starts one server for the package, `call()` wraps the REST API, and `waitForSession()` reads the event stream in a goroutine. A raw IMAP connection plays the client under test. + +```go title="imapkit_test.go" +package mailclient + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +const ( + imapAddr = "127.0.0.1:1143" + rest = "http://127.0.0.1:8143/v1" +) + +// TestMain runs one ImapKit server for the package, IMAPKIT overrides the command +func TestMain(m *testing.M) { + command := strings.Fields(os.Getenv("IMAPKIT")) + if len(command) == 0 { + command = []string{"imapkit"} + } + server := exec.Command(command[0], append(command[1:], "--port=1143", "--rest-port=8143", "--plugin=IDLE,UIDPLUS")...) + stdout, _ := server.StdoutPipe() + if err := server.Start(); err != nil { + panic(err) + } + lines := bufio.NewScanner(stdout) + ready := false + for !ready && lines.Scan() { + ready = strings.HasPrefix(lines.Text(), "REST API listening") + } + if !ready { + panic("imapkit did not start") + } + code := m.Run() + server.Process.Kill() + os.Exit(code) +} + +// call sends a JSON request to the REST API and decodes the answer into out +func call(t *testing.T, method, path string, body, out any) { + t.Helper() + var payload bytes.Buffer + if body != nil { + json.NewEncoder(&payload).Encode(body) + } + req, _ := http.NewRequest(method, rest+path, &payload) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode >= 300 { + t.Fatalf("%s %s: HTTP %d", method, path, res.StatusCode) + } + if out != nil { + json.NewDecoder(res.Body).Decode(out) + } +} + +type sessionEvent struct { + Type string `json:"type"` + Session struct { + Session int `json:"session"` + Mailbox string `json:"mailbox"` + } `json:"session"` +} + +// waitForSession opens the event stream and returns a function that blocks until a matching session event +func waitForSession(t *testing.T, match func(sessionEvent) bool) func() sessionEvent { + res, err := http.Get(rest + "/events?types=session") + if err != nil { + t.Fatal(err) + } + found := make(chan sessionEvent, 1) + go func() { + defer res.Body.Close() + scanner := bufio.NewScanner(res.Body) + for scanner.Scan() { + data, ok := strings.CutPrefix(scanner.Text(), "data: ") + var event sessionEvent + if ok && json.Unmarshal([]byte(data), &event) == nil && match(event) { + found <- event + return + } + } + }() + return func() sessionEvent { + select { + case event := <-found: + return event + case <-time.After(3 * time.Second): + t.Fatal("no matching session event") + return sessionEvent{} + } + } +} + +// command sends one IMAP command and returns every line up to its tagged response +func command(t *testing.T, conn net.Conn, reader *bufio.Reader, tag, line string) []string { + fmt.Fprintf(conn, "%s %s\r\n", tag, line) + var lines []string + for { + text, err := reader.ReadString('\n') + if err != nil { + t.Fatal(err) + } + lines = append(lines, strings.TrimRight(text, "\r\n")) + if strings.HasPrefix(text, tag+" ") { + return lines + } + } +} + +func TestDeliveryReachesSelectedSession(t *testing.T) { + call(t, "POST", "/reset", map[string]any{}, nil) + selected := waitForSession(t, func(e sessionEvent) bool { return e.Type == "select" && e.Session.Mailbox == "INBOX" }) + + conn, err := net.Dial("tcp", imapAddr) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + reader := bufio.NewReader(conn) + reader.ReadString('\n') // greeting + command(t, conn, reader, "A1", "LOGIN testuser testpass") + command(t, conn, reader, "A2", "SELECT INBOX") + selected() + + var added struct{ UID int } + call(t, "POST", "/mailboxes/INBOX/messages", map[string]any{"raw": "Subject: hello\r\n\r\nHi!\r\n"}, &added) + + lines := command(t, conn, reader, "A3", "NOOP") + if added.UID != 1 || lines[0] != "* 1 EXISTS" { + t.Fatalf("uid %d, NOOP got %q", added.UID, lines) + } +} +``` + +```bash +go test -v ./... +``` + +```text +=== RUN TestDeliveryReachesSelectedSession +--- PASS: TestDeliveryReachesSelectedSession (0.01s) +PASS +``` + +## Other languages + +Any language with an HTTP client works the same way. The pieces to build are always these: + +1. **Start** `imapkit` once per suite with fixed ports, and read its output until `REST API listening`. +2. **Reset** before every test with `POST /v1/reset` and `DELETE /v1/script/rules`. +3. **Wait** on `GET /v1/events` with a timeout, opened before the action you wait for. +4. **Change** the server with the message, flag and mailbox endpoints while the client is connected. +5. **Assert** on `GET /v1/mailboxes/{path}/messages/{uid}`, `GET /v1/snapshot` or `GET /v1/sessions`. +6. **Stop** the process when the suite ends, or call `POST /v1/shutdown`. + +Since one server is shared by the whole suite, run the tests that use it one at a time, or start one server per worker on its own ports. + +## Read more + +- [REST API overview](../rest-api/overview.md): security defaults, request and response formats. +- [Endpoints](../rest-api/endpoints.md): every route and its control API method. +- [Event stream](../rest-api/event-stream.md): the data of every event type. +- [Scripted faults](../faults/scripted-faults.md): the rules you can post to `/v1/script/rules`, in their JSON form. diff --git a/docs/docs/guides/writing-client-tests.md b/docs/docs/guides/writing-client-tests.md new file mode 100644 index 0000000..740bf71 --- /dev/null +++ b/docs/docs/guides/writing-client-tests.md @@ -0,0 +1,614 @@ +--- +sidebar_position: 1 +title: Writing Client Tests +description: Patterns for fast, deterministic IMAP client test suites with ImapKit, from a per-test server helper to capability matrices and chaos tests. +--- + +# Writing Client Tests + +This guide collects the patterns that keep an IMAP client test suite fast, deterministic and free of sleeps. They come from real suites, most of all the ImapKit tests of [ImapFlow](https://github.com/postalsys/imapflow), which run every client feature against ImapKit with dozens of capability sets. + +The examples use the [Node.js test runner](https://nodejs.org/api/test.html) and ImapFlow as the client under test. The ideas carry over to any test framework and any client. + +## One server per test + +ImapKit keeps one mailbox tree in memory, shared by every user and every session of that server. A test that changes it (appends, flags, deletes) would leak into the next one, so start a new server for every test. It takes a few milliseconds. + +```javascript title="test/basic.test.mjs" +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import imapkit from 'imapkit'; +import { ImapFlow } from 'imapflow'; + +describe('my client', () => { + let server; + let port; + + beforeEach(async () => { + server = imapkit({ plugins: ['IDLE', 'UIDPLUS', 'MOVE'] }); + // a free port on the loopback address, so test files can run in parallel + port = await server.start(0, '127.0.0.1'); + }); + + // closes the server and every session that is still open + afterEach(() => server.stop()); + + it('creates a mailbox', async () => { + const client = new ImapFlow({ host: '127.0.0.1', port, secure: false, auth: { user: 'testuser', pass: 'testpass' }, logger: false }); + await client.connect(); + await client.mailboxCreate('Projects'); + await client.logout(); + + assert.equal(server.control.getMailbox('Projects').selectable, true); + }); +}); +``` + +`server.start(port, host)` resolves with the port it listens on, a free one when `port` is `0` or missing. `server.stop()` closes the server and every session right away and resolves when it is closed. + +## A test helper + +A suite soon repeats the same setup: build a server, connect a client with the right options, clean up, collect what went over the wire. Put it in one helper that registers its own cleanup on the test context. This one is modeled on the `startImapKit()` fixture of ImapFlow: + +```javascript title="test/helpers/imapkit.mjs" +import assert from 'node:assert/strict'; +import imapkit from 'imapkit'; +import { ImapFlow } from 'imapflow'; + +// A mailbox tree with the usual special-use folders +export const DEFAULT_STORAGE = { + INBOX: {}, + '': { + separator: '/', + folders: { + Archive: { 'special-use': '\\Archive' }, + Sent: { 'special-use': '\\Sent' }, + Trash: { 'special-use': '\\Trash' } + } + } +}; + +/** + * Starts a fresh ImapKit server for one test and registers its cleanup on the test context. + * Any BAD the server sends fails the test, unless `allowBad` is set. + */ +export async function startImapKit(t, serverOptions = {}, { allowBad = false } = {}) { + const bad = []; + const server = imapkit({ + plugins: ['IDLE', 'UIDPLUS', 'MOVE', 'SPECIAL-USE'], + storage: DEFAULT_STORAGE, + ...serverOptions, + script: [ + // records every tagged BAD; `when` returns false, so the rule never acts + { + on: 'response', + untagged: false, + match: '^\\S+ BAD', + when: context => { + bad.push(context.data.trim()); + return false; + }, + drop: true + }, + ...[].concat(serverOptions.script || []) + ] + }); + const port = await server.start(0, '127.0.0.1'); + + const clients = new Set(); + const wire = []; + + // a connected client under test, with options for this test on top of the defaults + const connect = async (clientOptions = {}) => { + const client = new ImapFlow({ + host: '127.0.0.1', + port, + secure: false, + auth: { user: 'testuser', pass: 'testpass' }, + logger: false, + emitLogs: true, + disableAutoIdle: true, + ...clientOptions + }); + client.on('log', entry => { + if (entry.src === 'c' || entry.src === 's') { + wire.push(entry); + } + }); + // a session the server closes must not crash the test process + client.on('error', () => {}); + clients.add(client); + await client.connect(); + return client; + }; + + // resolves with the first server event that `match` accepts, rejects after `ms` + const serverEvent = (event, match = () => true, ms = 3000) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + server.off(event, listener); + reject(new Error(`no matching ${event} event within ${ms}ms`)); + }, ms); + const listener = data => { + if (match(data)) { + clearTimeout(timer); + server.off(event, listener); + resolve(data); + } + }; + server.on(event, listener); + }); + + const matches = (msg, needle) => (typeof needle === 'string' ? msg.includes(needle) : needle.test(msg)); + + t.after(async () => { + for (const client of clients) { + client.close(); + } + await server.stop(); + if (!allowBad) { + assert.deepEqual(bad, [], 'ImapKit answered client input with BAD'); + } + }); + + return { + server, + port, + control: server.control, + script: server.script, + connect, + serverEvent, + // did the client send, or the server answer, a line that matches? + sent: needle => wire.some(entry => entry.src === 'c' && matches(entry.msg, needle)), + received: needle => wire.some(entry => entry.src === 's' && matches(entry.msg, needle)) + }; +} +``` + +What each part buys you: + +- **Defaults with overrides.** Every test gets a typical server and client, and passes only what it is about: `startImapKit(t, { plugins: ['IMAP4rev2'] })`, `kit.connect({ qresync: true })`. +- **Cleanup on the test context.** `t.after()` runs even when the test fails, so no server or socket outlives its test. +- **A BAD check for free.** ImapKit is [strict by design](strict-by-design.md): it answers client input that breaks the RFCs with `BAD`. The first script rule only looks at tagged responses and never acts, because `when` returns false. Any `BAD` the server sends fails the test, so every test is also a syntax check of what the client sent. Output that other script rules write (a canned `BAD` from a fault) does not go through this rule. +- **`serverEvent()`** waits for something to happen on the server, with a timeout instead of a hang. +- **`sent()` and `received()`** answer "did the client send `UID MOVE`?" from the client's own protocol log (ImapFlow emits it with `emitLogs: true`). + +The rest of this guide uses this helper. + +## Fixtures through the storage option + +The `storage` option is the starting state of the server. Use it for everything that would take many IMAP commands to set up, or that a client can not set up at all: fixed UIDs with gaps, a UIDVALIDITY at the 32-bit limit, internal dates in the past, a mailbox that refuses new keywords, `\Noselect` levels, other namespaces and separators. See [Storage](storage.md) for every key. + +```javascript title="test/storage.test.mjs" +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { startImapKit } from './helpers/imapkit.mjs'; + +// a small RFC 5322 message builder keeps fixtures readable +const rfc822 = (subject, body = 'Hello world') => + `From: Sender \r\nTo: Receiver \r\nSubject: ${subject}\r\nDate: Mon, 6 Oct 2025 10:00:00 +0000\r\n\r\n${body}\r\n`; + +test('UID gaps and a UIDVALIDITY at the 32-bit limit', async t => { + const kit = await startImapKit(t, { + storage: { + INBOX: { + uidvalidity: 4294967295, + uidnext: 5000, + messages: [ + { uid: 7, raw: rfc822('seven'), flags: ['\\Seen'], internaldate: '01-Jan-2020 10:00:00 +0000' }, + { uid: 300, raw: rfc822('three hundred') } + ] + } + } + }); + const client = await kit.connect(); + const mailbox = await client.mailboxOpen('INBOX'); + assert.equal(mailbox.uidValidity, 4294967295n); + assert.equal(mailbox.uidNext, 5000); + const uids = (await client.fetchAll('1:*', { uid: true })).map(message => message.uid); + assert.deepEqual(uids, [7, 300]); + await client.logout(); +}); +``` + +Keep shared fixtures as constants or small builder functions in a helper module. The server copies the storage object, so one constant can serve every test. + +:::tip +The storage is checked when the server is built. A typo like `message` instead of `messages` fails right away with the path of the problem, not with a confusing test failure later. +::: + +## Wait for events, not for time + +A test often has to wait until the client has done something before it changes the server: logged in, selected a mailbox, entered IDLE. A sleep makes the test slow and still flaky. ImapKit emits events at exactly these points: + +| Event | Payload | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `session` | `{ type, session }`, `type` is `open`, `login`, `select`, `unselect`, `logout`, `waiting` (a command waits for client input, with `command`) or `close` | +| `command` | `{ session, tag, command, status, user }` when the tagged response of a command goes out | +| `mailbox` | `{ type, path, oldPath, mailbox, origin }` for CREATE, DELETE, RENAME, SUBSCRIBE and UNSUBSCRIBE | + +[Events](../control-api/events.md) lists them all with their payloads. The most useful one is `waiting` with `command: 'IDLE'`: the server has sent the `+ idling` continuation and the client is now listening for changes. + +```javascript title="test/idle.test.mjs" +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { startImapKit } from './helpers/imapkit.mjs'; + +test('an idling client sees a delivered message', async t => { + const kit = await startImapKit(t); + const client = await kit.connect(); + await client.mailboxOpen('INBOX'); + + // wait until the server has entered IDLE, not a fixed sleep + const waiting = kit.serverEvent('session', event => event.type === 'waiting' && event.command === 'IDLE'); + const idling = client.idle(); + await waiting; + + const exists = once(client, 'exists', { signal: AbortSignal.timeout(3000) }); + kit.control.addMessage('INBOX', { raw: 'Subject: delivered\r\n\r\nHi!\r\n' }); + const [event] = await exists; + assert.deepEqual(event, { path: 'INBOX', count: 1, prevCount: 0 }); + + // any command ends IDLE + await client.noop(); + await idling; + assert.ok(kit.sent(/^DONE$/)); + await client.logout(); +}); +``` + +Two details make this reliable: + +- **Subscribe before you trigger.** Create the `serverEvent()` promise before calling `client.idle()`, and the `once(client, 'exists')` promise before `addMessage()`. An event that fires before anyone listens is lost. +- **Always use a timeout.** `serverEvent()` rejects after 3 seconds, and `once()` gets an `AbortSignal.timeout()`, so a missing event fails the test with a clear message instead of hanging the run. + +```mermaid +sequenceDiagram + participant T as Test + participant S as ImapKit + participant C as Client + T->>S: serverEvent('session', waiting IDLE) + T->>C: client.idle() + C->>S: A5 IDLE + S-->>C: + idling + S-->>T: session { type: 'waiting', command: 'IDLE' } + T->>S: control.addMessage('INBOX', ...) + S-->>C: * 1 EXISTS + C-->>T: 'exists' event + T->>C: client.noop() + C->>S: DONE + S-->>C: A5 OK IDLE terminated +``` + +## Drive changes from the server side + +The [control API](../control-api/overview.md) changes the server while clients are connected, and every change reaches the sessions the way a change by another client would: `EXISTS` for a new message, `EXPUNGE` (or `VANISHED` after `ENABLE QRESYNC`) for a removed one, an unsolicited `FETCH` for new flags, `BYE` when the mailbox is gone. Some situations can only be made this way, such as a new UIDVALIDITY for a mailbox the client already knows. + +```javascript title="test/server-changes.test.mjs" +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { startImapKit } from './helpers/imapkit.mjs'; + +test('a flag change on the server reaches the client', async t => { + const kit = await startImapKit(t, { + plugins: ['IDLE', 'ENABLE', 'CONDSTORE'], + storage: { INBOX: { messages: [{ raw: 'Subject: one\r\n\r\n1\r\n' }] } } + }); + const client = await kit.connect(); + await client.mailboxOpen('INBOX'); + + const flags = once(client, 'flags', { signal: AbortSignal.timeout(3000) }); + kit.control.setFlags('INBOX', [1], ['\\Flagged'], 'add'); + await client.noop(); + const [event] = await flags; + assert.equal(event.uid, 1); + assert.ok(event.flags.has('\\Flagged')); + await client.logout(); +}); + +test('a new UIDVALIDITY ends the session that has the mailbox selected', async t => { + const kit = await startImapKit(t, { storage: { INBOX: { messages: [{ raw: 'Subject: one\r\n\r\n1\r\n' }] } } }); + const client = await kit.connect(); + await client.mailboxOpen('INBOX'); + const closed = new Promise(resolve => client.once('close', resolve)); + const reset = kit.control.resetUidValidity('INBOX', { uids: 'shuffle', seed: 7 }); + await closed; + assert.equal(client.usable, false); + + // a new session sees the new value + const again = await kit.connect(); + const mailbox = await again.mailboxOpen('INBOX'); + assert.equal(mailbox.uidValidity, BigInt(reset.uidvalidity)); + await again.logout(); +}); + +test('a server side disconnect reports the BYE text', async t => { + const kit = await startImapKit(t); + const client = await kit.connect(); + await client.mailboxOpen('INBOX'); + const closed = new Promise(resolve => client.once('close', resolve)); + assert.equal(kit.control.disconnect({ user: 'testuser' }, { text: 'Server maintenance' }), 1); + await closed; + assert.equal(client.byeReason, 'Server maintenance'); +}); + +test('an ALERT between commands does not confuse the client', async t => { + const kit = await startImapKit(t); + const client = await kit.connect(); + await client.mailboxOpen('INBOX'); + + const [{ session }] = kit.control.sessions(); + kit.control.inject(session, '* OK [ALERT] System shutdown in 10 minutes\r\n'); + + await client.noop(); + assert.ok(kit.received('[ALERT] System shutdown in 10 minutes')); + assert.equal(client.usable, true); + await client.logout(); +}); +``` + +With `uids: 'shuffle'`, every old UID points to another message after the reset, so a client that ignores UIDVALIDITY shows the wrong mail. `seed` makes the shuffle the same in every run. See [UIDVALIDITY](../control-api/uidvalidity.md) for the other modes. `inject()` writes bytes to a session exactly as given, which is how a test sends untagged responses the server would not send on its own. + +## Assert on the server state + +A client can report success and still store the wrong thing. Read the result back from the server with the control API: the exact message bytes, the flags, the internal date, which mailboxes exist, which sessions are connected. + +```javascript title="test/state.test.mjs" +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { startImapKit } from './helpers/imapkit.mjs'; + +test('append stores the exact bytes, flags and date', async t => { + const kit = await startImapKit(t); + const client = await kit.connect(); + const source = 'From: a@example.com\r\nSubject: Tere\r\n\r\nÕun ja pirn\r\n'; + const appended = await client.append('INBOX', source, ['\\Seen', '$Label'], new Date('2026-05-01T10:20:30Z')); + + const stored = kit.control.getMessage('INBOX', appended.uid); + assert.ok(stored.raw.equals(Buffer.from(source))); + assert.deepEqual(stored.flags.sort(), ['$Label', '\\Seen']); + assert.equal(stored.internaldate, ' 1-May-2026 10:20:30 +0000'); + await client.logout(); +}); + +test('the client logs out instead of dropping the connection', async t => { + const kit = await startImapKit(t); + const commands = []; + kit.server.on('command', event => commands.push(`${event.command} ${event.status}`)); + const client = await kit.connect(); + assert.equal(kit.control.sessions().length, 1); + assert.equal(kit.control.sessions()[0].user, 'testuser'); + const closed = kit.serverEvent('session', event => event.type === 'close'); + await client.logout(); + await closed; + assert.equal(commands.at(-1), 'LOGOUT OK'); +}); +``` + +`getMessage()` returns the source as a Buffer in `raw`, so the comparison is byte for byte, 8-bit content included. Internal dates come back in the IMAP `date-time` form (a day below 10 has a leading space). `server.control.snapshot()` returns the whole storage in the shape of the `storage` option, handy for a deep comparison or for starting another server from the same state. + +## Check what the client sent + +Three ways to look at the commands, from the most general to the most detailed: + +- **The `command` event** gives the name and status of every command the server ran: `{ session, tag, command: 'UID MOVE', status: 'OK', user }`. It does not depend on the client, so it works for any client. A line that the server could not parse, or a command refused for ambiguous pipelining, gets its `BAD` without a `command` event (a command refused for its state or arguments still gets one, with `status: 'BAD'`). +- **The BAD check of the helper** catches every tagged `BAD`, those included. +- **The client's protocol log** has the full lines, for questions like "did it send `DONE`" or "did it use `COMPRESS DEFLATE`". The helper keeps ImapFlow's log for `sent()` and `received()`. For a client without a log, a script rule with a `when` function that records `context.data` and returns false sees every command line (`on: 'command'`) or every response (`on: 'response'`) without changing anything. + +```javascript title="test/sent.test.mjs" +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { startImapKit } from './helpers/imapkit.mjs'; + +test('the client used UID MOVE', async t => { + const kit = await startImapKit(t, { + storage: { INBOX: { messages: [{ raw: 'Subject: one\r\n\r\n1\r\n' }] }, '': { separator: '/', folders: { Archive: {} } } } + }); + const commands = []; + kit.server.on('command', event => commands.push(event.command)); + const client = await kit.connect(); + await client.mailboxOpen('INBOX'); + await client.messageMove('1:*', 'Archive', { uid: true }); + assert.ok(commands.includes('UID MOVE'), commands.join()); + await client.logout(); +}); +``` + +## Run one test against many capability sets + +A client takes different code paths depending on what the server advertises: MOVE or COPY and EXPUNGE, UIDPLUS or not, LITERAL+ or synchronizing literals, IMAP4rev2 or IMAP4rev1, QRESYNC or plain flags. Plugins make each of these a one-line change, so run the same workflow against a matrix of profiles and assert that the end state is the same: + +```javascript title="test/matrix.test.mjs" +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { startImapKit } from './helpers/imapkit.mjs'; + +const PROFILES = [ + { name: 'bare IMAP4rev1', plugins: [] }, + { name: 'UIDPLUS, no MOVE', plugins: ['UIDPLUS', 'SPECIAL-USE'] }, + { name: 'MOVE, no UIDPLUS', plugins: ['MOVE', 'SPECIAL-USE'] }, + { name: 'LITERAL+', plugins: ['LITERALPLUS', 'UIDPLUS'] }, + { name: 'IMAP4rev2', plugins: ['IMAP4rev2'] }, + { name: 'IMAP4rev2 with QRESYNC', plugins: ['IMAP4rev2', 'QRESYNC'], client: { qresync: true } } +]; + +describe('archive a message', () => { + for (const profile of PROFILES) { + it(profile.name, async t => { + const kit = await startImapKit(t, { + plugins: profile.plugins, + storage: { + INBOX: { messages: [{ raw: 'Subject: one\r\n\r\n1\r\n' }, { raw: 'Subject: two\r\n\r\n2\r\n' }] }, + '': { separator: '/', folders: { Archive: {} } } + } + }); + const client = await kit.connect(profile.client); + await client.mailboxOpen('INBOX'); + await client.messageMove('1', 'Archive'); + + // the same end state, whichever commands the client had to use + assert.deepEqual( + kit.control.listMessages('INBOX').map(message => message.uid), + [2] + ); + assert.equal(kit.control.getMailbox('Archive').messages, 1); + // and the client used MOVE when the server offered it + assert.equal(kit.sent(/ MOVE /), profile.plugins.includes('MOVE') || profile.plugins.includes('IMAP4rev2')); + await client.logout(); + }); + } +}); +``` + +Good profiles to include: a bare RFC 3501 server (`plugins: []`), a "typical" server (ID, IDLE, NAMESPACE, UNSELECT, UIDPLUS, MOVE, SPECIAL-USE, ENABLE, CONDSTORE, LITERALPLUS, ESEARCH), IMAP4rev2, and every plugin at once. Some plugins do not go into an "every plugin" list: + +- LITERALMINUS and LITERALPLUS can not be loaded together, nor can MESSAGELIMIT and SAVELIMIT. +- ACL takes rights away from users other than the owner, LOGINDISABLED refuses LOGIN on plain connections, and UIDONLY refuses message sequence numbers after `ENABLE UIDONLY`. Test these on their own. +- METADATA-SERVER is a subset of METADATA. + +See [Extensions](../extensions/overview.md) for the full list. + +## Several sessions + +Every `kit.connect()` opens another session on the same mailbox tree. ImapKit follows one consistent set of the RFC 2180 strategies, so cases that are timing dependent against a real server are deterministic here. See [Multiple sessions](multiple-sessions.md). + +```javascript title="test/sessions.test.mjs" +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { startImapKit } from './helpers/imapkit.mjs'; + +test('an expunge by another session', async t => { + const kit = await startImapKit(t, { + storage: { INBOX: { messages: [{ raw: 'Subject: doomed\r\n\r\n1\r\n' }, { raw: 'Subject: survivor\r\n\r\n2\r\n' }] } } + }); + const client = await kit.connect(); + await client.mailboxOpen('INBOX'); + + const other = await kit.connect(); + await other.mailboxOpen('INBOX'); + await other.messageDelete('1'); + await other.logout(); + + const expunge = once(client, 'expunge', { signal: AbortSignal.timeout(3000) }); + await client.noop(); + assert.deepEqual((await expunge)[0], { path: 'INBOX', seq: 1, vanished: false }); + assert.equal(client.mailbox.exists, 1); + await client.logout(); +}); +``` + +## Faults + +ImapKit is correct by default. [Script rules](../faults/scripted-faults.md) make it fail at a chosen point, so the error handling of the client gets tested too. Add rules at runtime with `kit.script.add()`, which returns a handle with hit counts, or pass them in the `script` option: + +```javascript title="test/faults.test.mjs" +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { startImapKit } from './helpers/imapkit.mjs'; + +test('the client survives a SELECT that fails once', async t => { + const kit = await startImapKit(t); + const client = await kit.connect(); + const rule = kit.script.add({ on: 'command', command: 'SELECT', times: 1, send: '$TAG NO [UNAVAILABLE] Try again later\r\n' }); + + await assert.rejects(client.mailboxOpen('INBOX')); + const mailbox = await client.mailboxOpen('INBOX'); + assert.equal(mailbox.path, 'INBOX'); + assert.equal(rule.hits, 1); + await client.logout(); +}); + +test('a throttled STATUS returns false, the next one works', async t => { + const kit = await startImapKit(t); + const client = await kit.connect(); + kit.script.add({ on: 'command', command: 'STATUS', times: 1, send: '$TAG BAD Request is throttled\r\n' }); + + assert.equal(await client.status('INBOX', { messages: true }), false); + assert.equal((await client.status('INBOX', { messages: true })).messages, 0); + await client.logout(); +}); + +test('autologout during a long IDLE', async t => { + const kit = await startImapKit(t, { + script: { on: 'quiet', command: 'IDLE', quietFor: 50, send: '* BYE Autologout; idle for too long\r\n', close: true } + }); + const client = await kit.connect(); + await client.mailboxOpen('INBOX'); + const closed = new Promise(resolve => client.once('close', resolve)); + await client.idle().catch(() => false); + await closed; + assert.equal(client.byeReason, 'Autologout; idle for too long'); +}); +``` + +The `BAD` in the second test comes from a script rule, not from the server's own checks, so the helper's BAD check does not count it. When a test makes the server itself answer with `BAD` on purpose, pass `{ allowBad: true }` as the third argument of `startImapKit()`. + +[Quirk presets](../faults/quirk-presets.md) bundle rules that reproduce known server bugs (`quirks: ['m365-throttle']`), and `scriptSeed` makes random rules repeat exactly ([Repeatable tests](../faults/repeatable-tests.md)). + +## Chaos: split output at random points + +Real servers and networks deliver responses in arbitrary pieces: a CRLF split in two, a literal header in one packet and its data in the next. A client must reassemble lines and literals across any boundary. Rules with `chunk` write output in pieces, and `chunkDelay: 'tick'` sends each piece on its own event loop turn, so the pieces leave as separate TCP segments without slowing the test down. + +Run the same workflow once over normal output and then over split output with several seeds, and compare: + +```javascript title="test/chaos.test.mjs" +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { startImapKit } from './helpers/imapkit.mjs'; + +const STORAGE = { + INBOX: { messages: [{ raw: 'Subject: one\r\n\r\n' + 'x'.repeat(3000) + '\r\n' }, { raw: 'Subject: two\r\n\r\nshort\r\n', flags: ['\\Seen'] }] } +}; + +// a small seeded random number generator, so a failing run can be repeated +const random = seed => () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; + +// rules that write a third of the output in pieces of 1 to 64 octets +const splitRules = seed => { + const next = random(seed); + return [1, 2, 3, 8, 64].flatMap(chunk => + ['greeting', 'continuation', 'response'].map(on => ({ + on, + chunk, + chunkDelay: 'tick', + when: context => context.data.length > chunk && context.data.length <= chunk * 200 && next() < 1 / 3 + })) + ); +}; + +const workflow = async client => { + const mailbox = await client.mailboxOpen('INBOX'); + const messages = await client.fetchAll('1:*', { uid: true, flags: true, envelope: true, source: true }); + return { + exists: mailbox.exists, + messages: messages.map(message => [message.uid, [...message.flags].sort(), message.envelope.subject, message.source.length]) + }; +}; + +test('output split at random points gives the same results', async t => { + const expected = await workflow(await (await startImapKit(t, { storage: STORAGE })).connect()); + for (let seed = 1; seed <= 5; seed++) { + const kit = await startImapKit(t, { storage: STORAGE, script: splitRules(seed) }); + const client = await kit.connect(); + assert.deepEqual(await workflow(client), expected, `seed ${seed}`); + await client.logout(); + } +}); +``` + +Rules are checked in order and the first one that matches handles an output, so each rule takes a random share of what the earlier ones left. The `chunk * 200` limit keeps large responses away from the one-octet rule, which keeps the test fast. Put the seed in the assertion message, and a failure can be replayed with that seed alone. ImapFlow's suite runs this over plain, compressed (COMPRESS) and STARTTLS connections. + +## Checklist + +- Start a fresh server per test on port `0`, and stop it in `t.after()` or `afterEach()`. +- Put starting states in `storage`, not in setup commands. +- Wait on `session` and `command` events and on client events, always with a timeout. No sleeps. +- Make server-side changes with `server.control`, and read results back with it. +- Fail on any `BAD`, and allow it only in the tests that ask for one. +- Run the important workflows against several plugin sets. +- Add faults and split output once the happy paths pass. + +For tests that are not written in JavaScript, the same patterns work over HTTP: see [Testing from other languages](testing-from-other-languages.md). diff --git a/docs/docs/intro.md b/docs/docs/intro.md index f43a96e..6ab6c93 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -2,19 +2,72 @@ slug: / sidebar_position: 1 title: Introduction +description: ImapKit is a scriptable, in-memory IMAP server for testing IMAP clients, strict about the RFCs and controllable from your tests. --- # ImapKit -ImapKit is a scriptable, in-memory IMAP server for testing IMAP clients. It implements IMAP4rev1 ([RFC 3501](https://www.rfc-editor.org/rfc/rfc3501)) and, as an optional plugin, IMAP4rev2 ([RFC 9051](https://www.rfc-editor.org/rfc/rfc9051)), with more than 50 extensions that can be turned on and off per server instance. +ImapKit is a scriptable, in-memory IMAP server for testing IMAP clients. It implements IMAP4rev1 ([RFC 3501](https://www.rfc-editor.org/rfc/rfc3501)) and, as an optional plugin, IMAP4rev2 ([RFC 9051](https://www.rfc-editor.org/rfc/rfc9051)), with more than 50 extensions that you turn on and off per server instance. -Nothing is ever written to disk: the mailbox tree comes from a JSON object, so every new server starts from the same known state. +Nothing is ever written to disk. The mailbox tree comes from a JSON object, so every new server starts from the same known state, and a test suite can start a fresh server for every test in a few milliseconds. -## Why ImapKit +## Who it is for -- **Strict by design.** ImapKit answers client input that breaks the RFCs with `BAD` or `NO`, so client bugs show up in your test suite instead of in production. -- **Control from your tests.** The control API (`server.control`) adds messages, changes flags, resets UIDVALIDITY and disconnects sessions while clients are connected, and every change reaches the sessions the way a change by another client would. -- **Any language.** The optional REST API exposes the same operations over HTTP, with server events as Server-Sent Events. -- **Scripted faults.** Script rules and quirk presets make the server misbehave on purpose, like real servers do: late responses, literals everywhere, throttling, split output, dropped connections. +- **Authors of IMAP client libraries** who want every code path (MOVE or COPY with EXPUNGE, LITERAL+ or synchronizing literals, QRESYNC or plain flags) covered against one predictable server. +- **Application developers** whose product reads or syncs mail and who need integration tests that do not depend on a real mail account, a network, or a Docker container. +- **Teams in any language.** ImapKit runs as a standalone command with an HTTP API, so Python, Go, Java or Ruby test suites can use it as well as Node.js ones. + +## A taste + +```javascript title="test/new-mail.test.mjs" +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import imapkit from 'imapkit'; + +test('a delivered message is in INBOX', async () => { + const server = imapkit({ plugins: ['IDLE', 'UIDPLUS', 'MOVE'] }); + const port = await server.start(); // a free port + + // ... connect your IMAP client to 127.0.0.1:port as testuser / testpass + + // change the server while the client is connected, like a new delivery would + const { uid } = server.control.addMessage('INBOX', { + raw: 'Subject: hello\r\n\r\nHi!\r\n', + flags: ['\\Seen'] + }); + assert.equal(uid, 1); + assert.equal(server.control.getMailbox('INBOX').messages, 1); + + await server.stop(); +}); +``` + +The [Quick Start](getting-started/quick-start.md) turns this into a complete test with a real IMAP client. + +## What you get + +| Feature | What it does | Read more | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Fresh state per test | The `storage` option describes namespaces, mailboxes, messages, flags, UIDs and UIDVALIDITY values. Changes live only in memory. | [Storage](guides/storage.md) | +| Strict by design | Client input that breaks the RFCs is answered with `BAD` or `NO`, so client bugs show up in your test suite instead of in production. | [Strict by design](guides/strict-by-design.md) | +| Control API | `server.control` adds and removes messages, changes flags, renames mailboxes, resets UIDVALIDITY and disconnects sessions while clients are connected. Every change reaches the sessions the way a change by another client would. | [Control API](control-api/overview.md) | +| Events | `session`, `command`, `mailbox`, `expunge` and other events tell a test that the client logged in, selected a mailbox or started IDLE, so tests wait for events instead of sleeping. | [Events](control-api/events.md) | +| REST API | The control API over HTTP, with the events as Server-Sent Events, for test suites in any language. | [REST API](rest-api/overview.md), [Testing from other languages](guides/testing-from-other-languages.md) | +| Scripted faults | Script rules make the server misbehave on purpose: canned responses, literals everywhere, late or split output, truncated literals, dropped connections, autologout. | [Scripted faults](faults/scripted-faults.md) | +| Quirk presets | Named presets reproduce the known behavior of real servers (Apache James, Yahoo, Microsoft 365 throttling, servers without UIDPLUS or MOVE). | [Quirk presets](faults/quirk-presets.md), [Repeatable tests](faults/repeatable-tests.md) | +| Extensions | IDLE, CONDSTORE, QRESYNC, MOVE, UIDPLUS, ESEARCH, SORT, THREAD, NOTIFY, ACL, QUOTA, METADATA, COMPRESS, BINARY, UTF8=ACCEPT, OAUTHBEARER, the Gmail extensions and many more, each one a plugin. | [Extensions](extensions/overview.md), [IMAP4rev2](extensions/imap4rev2.md) | +| Several sessions | Any number of clients share one mailbox tree. Expunges and flag changes by other sessions follow one consistent set of the RFC 2180 strategies. | [Multiple sessions](guides/multiple-sessions.md) | +| Users and logins | `LOGIN`, `AUTHENTICATE PLAIN`, XOAUTH2 and OAUTHBEARER, STARTTLS and implicit TLS with a bundled certificate. | [Authentication](guides/authentication.md), [Users and sessions](control-api/users-and-sessions.md) | +| Command line | `imapkit -p 1143 --plugin=IDLE,MOVE` runs a server without writing any code, with options for storage, faults, quirks, SMTP and the REST API. | [Command line](getting-started/command-line.md) | +| Extensible | Any command can be overridden and your own plugins can add commands, capabilities and control operations. | [Custom plugins](reference/custom-plugins.md), [Server API](reference/server-api.md) | + +## Where to start + +1. [Install](getting-started/installation.md) ImapKit as a dev dependency or as a global command. +2. Write your [first test](getting-started/quick-start.md) with a real client. +3. Read [Writing client tests](guides/writing-client-tests.md) for the patterns that keep a large suite fast and free of flaky sleeps. +4. Look up [server options](reference/server-options.md) and the [server API](reference/server-api.md) as you need them. + +Upgrading from ImapKit 4.x or from `hoodiecrow-imap`? See [Migrating from 4.x](reference/migrating-from-4.md). Known gaps are listed under [Known issues](reference/known-issues.md), and [Contributing](contributing/running-tests.md) explains how to run ImapKit's own test suite and [compare it with Dovecot](contributing/comparing-with-dovecot.md). ImapKit is maintained by the team behind [EmailEngine](https://emailengine.app/?utm_source=imapkit.com&utm_medium=docs&utm_campaign=oss-docs) and [ImapFlow](https://imapflow.com/). diff --git a/docs/docs/reference/custom-plugins.md b/docs/docs/reference/custom-plugins.md new file mode 100644 index 0000000..4735b8b --- /dev/null +++ b/docs/docs/reference/custom-plugins.md @@ -0,0 +1,433 @@ +--- +title: Custom Plugins +sidebar_position: 3 +description: Write your own ImapKit plugins, with new capabilities, commands, FETCH items, SEARCH keys, output handlers and control API operations. +--- + +# Custom Plugins + +Every IMAP extension in ImapKit is a plugin, and your own code uses the same API. A plugin is a function that gets the server when it is built: + +```javascript +import imapkit from 'imapkit'; + +function myPlugin(server) { + server.registerCapability('XHELLO'); +} + +const server = imapkit({ + // built-in plugins by name, your own as functions + plugins: ['IDLE', myPlugin] +}); +``` + +Plugins run once, in the constructor, before the storage is loaded. They can not be loaded or unloaded on a running server. A function listed twice is loaded once. + +A plugin should stay self-contained: when it is not loaded, no trace of it remains. Keep its state on the server, the connection, mailboxes or messages, under names that do not collide with other plugins. + +## Requiring other plugins + +A plugin that needs another one lists the names in `requires`. They are loaded first, whatever the order of the `plugins` option: + +```javascript +function myPlugin(server) { + // ENABLE is loaded already +} +myPlugin.requires = ['ENABLE']; +``` + +The TypeScript type is `Plugin` from the package: `(server: IMAPServer) => void` with an optional `requires: string[]`. + +## Capabilities + +```javascript +server.registerCapability(name, availability); +``` + +`name` is the string listed in the CAPABILITY response. The optional `availability(connection)` function decides per session whether it is listed: + +```javascript +// listed only before login +server.registerCapability('XAUTH', connection => connection.state === 'Not Authenticated'); +``` + +## Commands + +```javascript +server.setCommandHandler(name, handler, options); +``` + +`name` is the command name, `"UID XFOO"` for a UID variant. `handler(connection, parsed, data, callback)` runs the command: + +| Argument | What it is | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `connection` | the `IMAPConnection` of the session | +| `parsed` | the command parsed by [imap-handler](https://github.com/postalsys/imap-handler): `{ tag, command, attributes }`, where `attributes` holds `{ type, value }` nodes and arrays for lists | +| `data` | the command as a binary string | +| `callback` | call it, without arguments, when the command is done | + +### The handler contract + +A handler **must send a tagged response and then call `callback()`**. The connection processes commands strictly one at a time, so a handler that never calls back stalls the session. + +```javascript +// an untagged response +connection.send({ tag: '*', command: 'XFOO', attributes: [42] }, 'XFOO', parsed, data); +// the tagged response: OK, NO or BAD, an optional response code, and the human readable text +connection.sendStatus(parsed, data, 'OK', 'XFOO completed'); +connection.sendStatus(parsed, data, 'NO', 'No such mailbox', 'NONEXISTENT'); +callback(); +``` + +`connection.send(response, description, parsed, data, ...extra)` takes an imap-handler response object. `description` is a string that output handlers of other plugins use to recognise the response, `extra` arguments are passed on to them. `connection.sendStatus(parsed, data, command, text, code, description)` builds a status response for you, `code` is an atom (`'TRYCREATE'`) or a list for a code with arguments (`['METADATA', 'MAXSIZE', 1024]`). + +The text of every OK, NO, BAD and BYE response must be non-empty, as RFC 3501 section 9 requires. + +A handler that throws synchronously gets a tagged `NO [SERVERBUG] Server error: ...` sent for it, and the session stays usable. An error with `imapResponse = 'BAD'` is sent as `BAD` with its message instead. + +A mailbox name in a response is sent as `{ type: 'MAILBOX', value: storageName }`, which `send` converts to the form the receiving session uses (modified UTF-7, or UTF-8 after `ENABLE UTF8=ACCEPT`). + +### Command options + +The server checks the options before the handler runs, so the handler only sees valid input: + +| Option | Type | What it does | +| ------------------ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `states` | `string[]` | The connection states the command is valid in: `'Not Authenticated'`, `'Authenticated'`, `'Selected'`. Any state if not set. A command in another state gets `BAD`. A plain array as `options` is read as the states. | +| `noArguments` | `boolean` | The command is refused when it has arguments. | +| `mailboxArguments` | `number[]` | Positions of mailbox name arguments. They must be valid modified UTF-7 ([RFC 3501](https://www.rfc-editor.org/rfc/rfc3501) section 5.1.3) and reach the handler as storage names, also after `ENABLE UTF8=ACCEPT`. | +| `astringArguments` | `number[]` | Positions of other astring arguments (user names, identifiers). In these, in mailbox names and in search criteria an atom `NIL` reaches the handler as an atom, not as `null`. | +| `searchCriteria` | `number` | Position where SEARCH style criteria start. | +| `sequenceSet` | `number` | Position of an argument with message sequence numbers. With `searchCriteria`, used for the RFC 3501 section 5.5 pipelining check, and by UIDONLY. | +| `noExpunge` | `boolean` | EXPUNGE responses are held back while the command runs, as for FETCH, STORE and SEARCH (RFC 3501 section 7.4.1). | +| `literal8` | `boolean` or `string` | The command accepts `~{n}` literals ([RFC 3516](https://www.rfc-editor.org/rfc/rfc3516)). A string names the capability that allows them. | +| `noPipelining` | `boolean` | The command is refused with `BAD` when the client sent more input after it, and so are the commands sent with it (STARTTLS and COMPRESS work this way). | +| `appendMessage` | `boolean` | The command takes a message after its mailbox argument, like APPEND. A message literal to a missing mailbox is refused with `NO [TRYCREATE]` before it is sent. | + +Without options, a command that already exists keeps its settings, so wrapping a built-in command does not change how it is checked. + +`server.getCommandOptions(name)` returns the options of a command with every key set, `server.getCommandStates(name)` only its states, or `false` if any state is fine. + +### Wrapping an existing command + +`server.getCommandHandler(name)` returns the current handler of a command, or `false`. Keep it and call it from your own handler: + +```javascript +const list = server.getCommandHandler('LIST'); +server.setCommandHandler('LIST', (connection, parsed, data, callback) => { + console.log('LIST from session %s', connection.sessionNumber); + list(connection, parsed, data, callback); +}); +``` + +### Run after every plugin is loaded + +A plugin that wraps commands or handlers of other plugins does it once every plugin is loaded, so the load order does not matter. The server emits `pluginsLoaded` at the end of the constructor, listeners run in plugin load order: + +```javascript +function readOnlyArchive(server) { + server.once('pluginsLoaded', () => { + const move = server.getCommandHandler('MOVE'); + if (!move) { + return; // MOVE is not loaded + } + server.setCommandHandler('MOVE', (connection, parsed, data, callback) => { + // mailbox arguments are storage names here + if (parsed.attributes[1].value === 'Archive') { + connection.sendStatus(parsed, data, 'NO', 'Archive is read-only', 'CANNOT'); + return callback(); + } + move(connection, parsed, data, callback); + }); + }); +} + +imapkit({ plugins: [readOnlyArchive, 'MOVE'] }); +``` + +### Reading raw input + +A command that reads the lines that follow it (IDLE waiting for `DONE`, AUTHENTICATE reading SASL responses) sets `connection.inputHandler`. The function gets complete lines without the line break. Clear it to give input back to the command parser: + +```javascript +connection.write('+ idling\r\n'); +connection.inputHandler = line => { + connection.inputHandler = false; + connection.sendStatus(parsed, data, line.toUpperCase() === 'DONE' ? 'OK' : 'BAD', 'Done'); + callback(); +}; +``` + +Raw output, like a `+` continuation request, goes through `connection.write(data)`, and `connection.end()` closes the connection once all output is written. Never write to `connection.socket` directly: a COMPRESS layer (`connection.transport`) may sit between the protocol and the socket. See [idle.ts](https://github.com/postalsys/imapkit/blob/master/src/plugins/idle.ts) for a complete example. + +## Session state + +UNAUTHENTICATE ([RFC 8437](https://www.rfc-editor.org/rfc/rfc8437)) returns a session to the Not Authenticated state. A plugin that keeps per-session state on the connection clears it in a reset handler: + +```javascript +server.resetHandlers.push(connection => { + connection.mySessionState = false; +}); +``` + +`server.connectionHandlers` run on every new connection, `(connection)`, which is the place to set up per-session state or replace a per-connection method. + +## Changing output + +Every response `connection.send()` writes passes through `server.outputHandlers`, with the arguments of `send`: `(connection, response, description, parsed, data, ...extra)`. A handler can change the response object, or set `response.skipResponse = true` to drop it: + +```javascript +server.outputHandlers.push((connection, response, description) => { + if (response.tag === '*' && description === 'XNOTE') { + response.skipResponse = true; + } +}); +``` + +Output handlers are for extensions that change valid responses. They run before the server finishes the response (response codes, mailbox names, the status text) and before the compiler, which refuses output that is not valid IMAP. To make the server send something wrong on purpose, use [script rules](../faults/scripted-faults.md), which see the final bytes. + +An output handler that must see the responses after every other plugin changed them pushes itself in a `pluginsLoaded` listener. + +## Message items, search keys and STATUS items + +| Hook | Signature | What it does | +| ----------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `server.fetchHandlers[ITEM]` | `(connection, message, query)` returns the value | A FETCH item. Consulted before the built-in items. A number is sent as a number, a string as a quoted string, an imap-handler node (`{ type: 'LITERAL', value }`) or an array as that node or list. Set `handler.setsSeen = true` for an item that sets `\Seen` like `BODY[]`. | +| `server.searchHandlers[KEY]` | `(connection, message, index, ...args)` returns true for a match | A SEARCH key. Each parameter after `index` takes one string argument. An `argumentTypes(list)` method on the handler can decide the arguments itself. | +| `server.storeHandlers[ITEM]` | `(connection, message, values, index, parsed, data)` | A STORE item, keyed by the full item name (`+X-FOO`, `X-FOO.SILENT`). | +| `server.statusHandlers[ITEM]` | `(connection, mailbox, status)` returns the value | A STATUS item. Add the name to `server.allowedStatus` too, or STATUS refuses it. | +| `server.fetchFilters` | `(connection, message, parsed, index)` | Returning false leaves a message out of a FETCH. | + +A fetch handler that throws an error with `imapResponse = 'NO'` and a `code` fails the whole FETCH with `NO [code]`. FETCH sends nothing until every message is done, so a failed FETCH has no untagged output and no `\Seen` changes. + +## Messages and mailboxes + +| Hook | Signature | What it does | +| ------------------------ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `server.messageHandlers` | `(server, message, mailbox)` | Runs on every message when it is loaded from storage or added. | +| `server.mailboxHandlers` | `(server, mailbox)` | Runs on every mailbox when it is loaded from storage or created. | +| `server.appendChecks` | `(connection, mailbox, messages, options)` | Consulted before APPEND, REPLACE, COPY and MOVE add messages. Return nothing to allow, `{ code, text }` to fail the command with `NO [code] text`, or `{ code, text, soft: true }` for an untagged `NO` warning only. `options` is `{ move, source }` for COPY and MOVE, `{ command, replaced }` for APPEND and REPLACE. `connection` is null for `control.addMessage(..., { checks: true })`. | +| `server.copyHandlers` | `(server, source, properties, mailbox)` | Runs when COPY, MOVE or RENAME INBOX copies a message. Properties set on `properties` are given to the copy before the message handlers run. | +| `server.closedChecks` | `(connection)` | When any returns true, SELECT and EXAMINE send `* OK [CLOSED]` when they close the selected mailbox. | +| `server.commandChecks` | `(connection, parsed)` | Runs before any command handler. Return `{ command, code, text }` to refuse the command whatever plugin handles it (`command` defaults to `BAD`). | + +The server also emits `expunge` `(mailbox, messages, origin)` before sessions are told about removed messages, and `mailbox` `{ type, path, oldPath, mailbox, origin }` for CREATE, DELETE, RENAME, SUBSCRIBE and UNSUBSCRIBE. See [Server API events](./server-api.md#events). + +The architecture notes in [CLAUDE.md](https://github.com/postalsys/imapkit/blob/master/CLAUDE.md) list the remaining, more specialised hooks (`rangeLimits`, `searchLimits`, `literalFilters`, `appendDataHandlers`, `notifyFilters`, the ESEARCH result option registry and the extended LIST registry) that the built-in plugins use. + +## Control API operations + +`server.control.register(name, fn, routes)` adds an operation to the [control API](../control-api/overview.md), so it only exists when the plugin is loaded. `server.control[name](...args)` then calls `fn`, with no session as the origin of the changes it makes. Throw an `ImapKitError` (exported by the package) with a `code` for errors, the REST API maps the codes to HTTP statuses. Registering a name that exists throws. + +`routes` is an optional list of REST routes for the [REST API](../rest-api/overview.md): + +| Field | What it is | +| --------- | ------------------------------------------------------------------------------------------------------------- | +| `method` | `'GET'`, `'POST'`, `'PUT'` or `'DELETE'` | +| `path` | the URL path, `{name}` matches one URL encoded segment | +| `summary` | one line for the OpenAPI document at `GET /v1/openapi.json` | +| `handler` | `({ params, query, body })` returns the JSON response body, or `{ status, body }` for a status other than 200 | + +## The connection object + +The `IMAPConnection` members plugins use most: + +| Member | What it is | +| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `server` | the `IMAPServer` | +| `state` | `'Not Authenticated'`, `'Authenticated'`, `'Selected'` or `'Logout'` | +| `username` | the logged in user | +| `selectedMailbox`, `readOnly` | the selected mailbox object, and whether it was opened read-only | +| `sessionNumber` | 1 for the first connection the server accepted | +| `send()`, `sendStatus()`, `write()`, `end()` | output, see [the handler contract](#the-handler-contract) | +| `inputHandler` | see [reading raw input](#reading-raw-input) | +| `getSessionMessages()` | the messages of the selected mailbox as this session sees them, including messages another session expunged that were not reported yet | +| `getMessageRange(range, isUid)` | resolves a sequence set argument to `[sequenceNumber, message]` pairs | +| `getFlags(message)` | the flags of a message as this session sees them | +| `canSetSeen()`, `canExpunge()` | whether FETCH may set `\Seen` and CLOSE may expunge, `!readOnly` by default. A plugin can override them per connection (ACL does) | +| `importMailboxName(name)`, `exportMailboxName(path)` | convert between the session's mailbox name form and the storage name | +| `describe()` | the session as plain data, as `control.sessions()` lists it | + +## A complete example + +The XNOTE plugin attaches a text note to a mailbox. It adds a capability, a command with options, a FETCH item, a SEARCH key, a reset handler and control API operations with a REST route: + +```javascript title="xnote.js" +import { ImapKitError } from 'imapkit'; + +/** + * XNOTE: attaches a free text note to a mailbox. + * + * C: A1 XNOTE INBOX "call back" + * S: * XNOTE INBOX "call back" + * S: A1 OK XNOTE completed + * + * Also adds an X-SIZE-KB FETCH item, an X-MINKB SEARCH key and the control operations getNote/setNote. + */ +export default function xnotePlugin(server) { + server.registerCapability('XNOTE'); + + // notes live on the mailbox objects, so control.reset() drops them with the storage + const getNote = path => { + const mailbox = server.getMailbox(path); + if (!mailbox) { + throw new ImapKitError('No such mailbox ' + path, 'NONEXISTENT'); + } + return mailbox.xnote || null; + }; + + server.setCommandHandler( + 'XNOTE', + (connection, parsed, data, callback) => { + const [nameArg, textArg] = parsed.attributes || []; + const mailbox = nameArg && server.getMailbox(nameArg.value); + if (!mailbox) { + connection.sendStatus(parsed, data, 'NO', 'No such mailbox', 'NONEXISTENT'); + return callback(); + } + if (textArg && textArg.value !== undefined) { + mailbox.xnote = String(textArg.value); + connection.notesSet = (connection.notesSet || 0) + 1; + } + connection.send( + { + tag: '*', + command: 'XNOTE', + attributes: [{ type: 'MAILBOX', value: mailbox.path }, mailbox.xnote ? { type: 'STRING', value: mailbox.xnote } : null] + }, + 'XNOTE', + parsed, + data + ); + connection.sendStatus(parsed, data, 'OK', 'XNOTE completed'); + callback(); + }, + { states: ['Authenticated', 'Selected'], mailboxArguments: [0] } + ); + + // per-session state is cleared when UNAUTHENTICATE resets the session + server.resetHandlers.push(connection => { + connection.notesSet = 0; + }); + + // FETCH n (X-SIZE-KB) + server.fetchHandlers['X-SIZE-KB'] = (connection, message) => Math.ceil(message.raw.length / 1024); + + // SEARCH X-MINKB n: messages of at least n KB. The parameter after `index` makes it take one string argument + server.searchHandlers['X-MINKB'] = (connection, message, index, kb) => Math.ceil(message.raw.length / 1024) >= Number(kb); + + // server.control.getNote(path) and server.control.setNote(path, text), plus REST routes + server.control.register('getNote', path => ({ path, note: getNote(path) }), [ + { + method: 'GET', + path: '/v1/notes/{path}', + summary: 'The note of a mailbox (XNOTE plugin)', + handler: ({ params }) => server.control.getNote(params.path) + } + ]); + server.control.register('setNote', (path, note) => { + getNote(path); + server.getMailbox(path).xnote = typeof note === 'string' && note ? note : undefined; + return { path, note: getNote(path) }; + }); +} +``` + +A session with the plugin loaded: + +```text +* OK ImapKit ready for rumble +A0 XNOTE INBOX +A0 BAD XNOTE is not allowed in the Not Authenticated state +A1 LOGIN testuser testpass +A1 OK User logged in +A2 XNOTE INBOX "call back" +* XNOTE INBOX "call back" +A2 OK XNOTE completed +A3 XNOTE Nonexistent "x" +A3 NO [NONEXISTENT] No such mailbox +A4 SELECT INBOX +... +A5 FETCH 1 (X-SIZE-KB) +* 1 FETCH (X-SIZE-KB 1) +A5 OK FETCH Completed +``` + +### Test the plugin + +`imapkit/lib/mock-client` replays commands like a compliant client and returns the transcript, which is all a plugin test needs. Run it with `node --test`: + +```javascript title="xnote.test.js" +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import imapkit from 'imapkit'; +import mockClient from 'imapkit/lib/mock-client'; +import xnotePlugin from './xnote.js'; + +// replays commands like a well behaved client and resolves with the transcript +const run = (port, commands) => new Promise(resolve => mockClient(port, '127.0.0.1', commands, false, resp => resolve(resp.toString('binary')))); + +test('XNOTE stores a note on a mailbox', async () => { + const server = imapkit({ + plugins: ['IDLE', xnotePlugin], + storage: { INBOX: { messages: [{ raw: 'Subject: hi\r\n\r\nHello\r\n' }] }, '': {} } + }); + const port = await server.start(); + try { + const transcript = await run(port, [ + 'A1 CAPABILITY', + 'A2 LOGIN testuser testpass', + 'A3 XNOTE INBOX "call back"', + 'A4 XNOTE Nonexistent "x"', + 'A5 SELECT INBOX', + 'A6 FETCH 1 (X-SIZE-KB)', + 'A7 SEARCH X-MINKB 1', + 'A8 SEARCH X-MINKB 2', + 'A9 LOGOUT' + ]); + assert.match(transcript, /^\* CAPABILITY .*\bXNOTE\b/m); + assert.match(transcript, /^\* XNOTE INBOX "call back"\r$/m); + assert.match(transcript, /^A3 OK XNOTE completed/m); + assert.match(transcript, /^A4 NO \[NONEXISTENT\]/m); + assert.match(transcript, /^\* 1 FETCH \(X-SIZE-KB 1\)/m); + assert.match(transcript, /^\* SEARCH 1\r\nA7 OK/m); + assert.match(transcript, /^\* SEARCH\r\nA8 OK/m); + + // the control operation sees the same state + assert.deepEqual(server.control.getNote('INBOX'), { path: 'INBOX', note: 'call back' }); + assert.throws(() => server.control.getNote('Nope'), { name: 'ImapKitError', code: 'NONEXISTENT' }); + } finally { + await server.stop(); + } +}); + +test('XNOTE is refused before login', async () => { + const server = imapkit({ plugins: [xnotePlugin] }); + const port = await server.start(); + try { + const transcript = await run(port, ['A1 XNOTE INBOX "x"', 'A2 LOGOUT']); + assert.match(transcript, /^A1 BAD /m); + } finally { + await server.stop(); + } +}); + +test('the REST route serves the note', async () => { + const server = imapkit({ plugins: [xnotePlugin], rest: { port: 0 } }); + await server.start(); + try { + server.control.setNote('INBOX', 'from the control API'); + const { port } = server.restServer.address(); + const res = await fetch(`http://127.0.0.1:${port}/v1/notes/INBOX`); + assert.deepEqual(await res.json(), { path: 'INBOX', note: 'from the control API' }); + } finally { + await server.stop(); + } +}); +``` + +```bash +node --test xnote.test.js +``` + +The built-in plugins in [src/plugins/](https://github.com/postalsys/imapkit/tree/master/src/plugins) are the best reference for anything beyond this page: [idle.ts](https://github.com/postalsys/imapkit/blob/master/src/plugins/idle.ts) for raw input, [quota.ts](https://github.com/postalsys/imapkit/blob/master/src/plugins/quota.ts) for append checks, STATUS items and control operations, [acl.ts](https://github.com/postalsys/imapkit/blob/master/src/plugins/acl.ts) for wrapping other plugins' commands on `pluginsLoaded`. diff --git a/docs/docs/reference/known-issues.md b/docs/docs/reference/known-issues.md new file mode 100644 index 0000000..3c51e91 --- /dev/null +++ b/docs/docs/reference/known-issues.md @@ -0,0 +1,63 @@ +--- +title: Known Issues +sidebar_position: 5 +description: What ImapKit does not support, deliberate limitations of the server and its plugins, and known differences from the RFCs. +--- + +# Known issues + +ImapKit implements IMAP4rev1, IMAP4rev2 and more than 50 extensions, but not every corner of every RFC. This page lists what is missing or deliberately limited, so you know where ImapKit can not stand in for a real server. + +## Protocol + +| Area | Issue | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| addr-adl | Address lists in ENVELOPE always have NIL in the at-domain-list (source route) field, values from the message are not used. | +| Anonymous namespaces | Not supported. Namespaces are personal, other users (`user`) or shared, see [Storage](../guides/storage.md#namespaces). | +| LIST reference | A reference without a trailing hierarchy delimiter is concatenated with the mailbox name as it is: `LIST "Work" "Sub"` looks for `WorkSub`, not `Work/Sub`. [RFC 2683 section 3.4.9](https://www.rfc-editor.org/rfc/rfc2683#section-3.4.9) recommends inserting the delimiter, ImapKit concatenates the two as [RFC 9051 section 6.3.9](https://www.rfc-editor.org/rfc/rfc9051#section-6.3.9) describes, like Dovecot does. | +| Search charsets | Only `US-ASCII` and `UTF-8` are supported. Other charsets in SEARCH, SORT and THREAD get `NO [BADCHARSET (US-ASCII UTF-8)]`. | +| Case folding | SEARCH matches strings case-insensitively in the ASCII range only. SORT advertises no `I18NLEVEL`. | +| Single user | All users share the same mailbox tree. The ACL plugin limits what users other than the owner can do, but there are no per-user mailboxes. | + +### LIST with a prefixed personal namespace + +With a personal namespace that has a prefix, such as `"INBOX."` in the [Cyrus layout](../guides/storage.md#cyrus), LIST with an empty reference matches the pattern relative to the prefix instead of interpreting the name as SELECT does ([RFC 9051 section 6.3.9](https://www.rfc-editor.org/rfc/rfc9051#section-6.3.9)). With mailboxes `INBOX.Drafts` and `INBOX.Sent`: + +```text +C: A6 LIST "" "INBOX.%" +S: A6 OK Completed +C: B1 LIST "" "%" +S: * LIST (\HasNoChildren) "." "INBOX.Drafts" +S: * LIST (\HasNoChildren) "." "INBOX.Sent" +S: B1 OK Completed +``` + +`LIST "" "INBOX.%"` should list both mailboxes, and `LIST "" "%"` should list `INBOX` and not the mailboxes below it. `LIST "" "*"` and `LIST "INBOX." "%"` work. The default storage and the Gmail layout, where the personal namespace is `""`, are not affected. + +## Extensions + +| Extension | Limitation | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| IMAP4rev2 | The server always advertises IMAP4rev1 next to IMAP4rev2, a server that advertises only IMAP4rev2 is not implemented. UTF-8 in response text and the `OLDNAME` LIST extended data item for RENAME ([RFC 9051 section 6.3.9.7](https://www.rfc-editor.org/rfc/rfc9051#section-6.3.9.7)) are not implemented. STARTTLS and LOGINDISABLED are not loaded by the plugin, load them yourself. | +| UTF8=ACCEPT | `UTF8=ONLY`, the obsolete `APPEND ... UTF8 (...)` data item of RFC 6855 and downgrading of 8-bit headers for clients that did not enable UTF-8 ([RFC 9755 section 8](https://www.rfc-editor.org/rfc/rfc9755#section-8)) are not implemented. | +| ACL | Identifiers are not prepared with SASLprep, which [RFC 4314 section 3](https://www.rfc-editor.org/rfc/rfc4314#section-3) asks for. Identifiers that SASLprep would refuse (control characters, invalid UTF-8) and empty ones are answered with BAD. The rights of the owner can not be changed. | +| CONDSTORE | `SEARCH MODSEQ` checks the entry name and type but ignores them, mod-sequences are not stored per flag. | +| CONTEXT=SEARCH | The `CONTEXT` result option is accepted as a hint and ignored. | +| NOTIFY | `AnnotationChange` is refused with `NO [BADEVENT]`, as there is no ANNOTATE support. The fetch attributes of the CONTEXT=SEARCH `UPDATE` option ([RFC 5465 section 7](https://www.rfc-editor.org/rfc/rfc5465#section-7)) are not supported. | +| MULTISEARCH | `UPDATE` (with CONTEXT=SEARCH) only applies to the selected mailbox. | +| LIST-EXTENDED | The `REMOTE` selection option is accepted, but there are no remote mailboxes. | +| XOAUTH2 | Needs the SASL-IR plugin and the initial response, Gmail itself does not. | +| OAUTHBEARER | Channel binding (`p=` in the GS2 header) is not supported and fails with `invalid_request`. | +| BINARY | Binary parts of an appended message, and parts with NUL octets, are stored base64 encoded, so `BODY[]` returns a valid IMAP4rev1 message rather than the octets the client appended. | + +## Server + +| Area | Limitation | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Plugins | Plugins are chosen when the server is built. They can not be loaded or unloaded while it runs. | +| Timeouts | There is no inactivity timeout, sessions stay open until the client or the test closes them. Use a [script rule](../faults/scripted-faults.md) to simulate an autologout. | +| `sessionTimeout` | The `xoauth2.sessionTimeout` value of a user is kept, but has no effect: access tokens never expire. | +| Command lines | Up to 1 MiB, a longer line is answered with BAD. | +| Literals | Up to 64 MiB after login (the `maxLiteralSize` option changes it) and 64 KiB before login. | + +For differences between ImapKit and Dovecot that are not ImapKit bugs, see [Comparing with Dovecot](../contributing/comparing-with-dovecot.md). diff --git a/docs/docs/reference/migrating-from-4.md b/docs/docs/reference/migrating-from-4.md new file mode 100644 index 0000000..b65756e --- /dev/null +++ b/docs/docs/reference/migrating-from-4.md @@ -0,0 +1,152 @@ +--- +title: Migrating from 4.x +sidebar_position: 4 +description: The breaking changes of ImapKit 5.0.0 (XTOYBIRD removed, smtp-server optional), how XTOYBIRD commands map to the control API, the new features worth adopting, and the hoodiecrow-imap rename. +--- + +# Migrating from 4.x + +ImapKit 5.0.0 has two breaking changes: + +1. The XTOYBIRD plugin is removed. The IMAP port carries IMAP traffic only, test control moved to the [control API](../control-api/overview.md) and the [REST API](../rest-api/overview.md). +2. `smtp-server` is an optional peer dependency. Install it yourself if you use SMTP. + +Everything else (the options, the plugins, the storage format, the IMAP behavior) carries over, with one thing to watch: the `storage` option is now [validated](#storage-fixtures-are-validated), so a fixture with a typo that 4.x silently accepted fails in 5.0. + +## XTOYBIRD is removed + +Loading `XTOYBIRD` now fails the server constructor: + +```text +Error: XTOYBIRD was removed in 5.0.0, use the control API (server.control.snapshot(), server.control.addUser() ...) instead, see the README +``` + +Remove it from `plugins` (or `--plugin`, `IMAPKIT_PLUGINS`) and replace each command with its control API method: + +| XTOYBIRD command | Control API | +| ------------------------------------ | -------------------------------------------------------------------------------------------- | +| `XTOYBIRD STORAGE` | `server.control.snapshot()` | +| `XTOYBIRD SERVER` | `server.control.listMailboxes()`, `server.control.listUsers()` | +| `XTOYBIRD CONNECTION` | `server.control.sessions()` | +| `XTOYBIRD USERADD "user" "password"` | `server.control.addUser('user', { password })`, or `updateUser()` for an existing user | +| `XTOYBIRD USERDEL "user"` | `server.control.deleteUser('user')` | +| `XTOYBIRD SHUTDOWN` | `await server.control.shutdown()`, stops accepting connections and waits for the last client | + +`XTOYBIRD USERADD` added or updated a user. `addUser()` only adds, and throws an `ImapKitError` with the code `ALREADYEXISTS` for a user that exists, use `updateUser()` to change a password. + +```text title="Before (4.x): commands on an extra IMAP connection, after LOGIN" +C: A1 XTOYBIRD USERADD "otheruser" "secret" +C: A2 XTOYBIRD STORAGE +``` + +```javascript title="After (5.0)" +server.control.addUser('otheruser', { password: 'secret' }); +const storage = server.control.snapshot(); // the storage in the shape of the storage option +``` + +If your tests are not written in JavaScript and talked to XTOYBIRD over IMAP, use the [REST API](../rest-api/endpoints.md) instead. Start the server with `--rest-port`, and every control API method is an HTTP endpoint: + +| XTOYBIRD command | REST API | +| --------------------- | ---------------------------------------- | +| `XTOYBIRD STORAGE` | `GET /v1/snapshot` | +| `XTOYBIRD SERVER` | `GET /v1/mailboxes`, `GET /v1/users` | +| `XTOYBIRD CONNECTION` | `GET /v1/sessions` | +| `XTOYBIRD USERADD` | `POST /v1/users`, `PUT /v1/users/{name}` | +| `XTOYBIRD USERDEL` | `DELETE /v1/users/{name}` | +| `XTOYBIRD SHUTDOWN` | `POST /v1/shutdown` | + +```bash +imapkit -p 1143 --rest-port=8143 +curl http://127.0.0.1:8143/v1/snapshot +``` + +## smtp-server is an optional peer dependency + +In 4.x `smtp-server` was installed with ImapKit. In 5.0 it is an optional peer dependency, so a project that does not use SMTP does not download it. If you use `--smtpPort` (also `--smtp-port`, `IMAPKIT_SMTPPORT`) or the new `smtp` option, install it next to ImapKit: + +```bash +npm install --save-dev smtp-server +# or, for a global install of the command +npm install -g imapkit smtp-server +``` + +Without it, starting the SMTP server fails with an error that says so: `server.start()` rejects with `The smtp option needs the smtp-server package, install it with: npm install smtp-server (...)`, and the `imapkit` command prints `Failed to start ImapKit: ...` and exits. + +The IMAP server itself never needs `smtp-server`. + +## Storage fixtures are validated + +5.0 checks the `storage` option when the server is built. A key that looks like a typo of a known one, or a value of the wrong type, now throws: + +```text +Error: Invalid storage at "INBOX".messages[2]: unknown key "flag", did you mean "flags"? +``` + +In 4.x such a key was ignored, and the test ran against a mailbox without the data you meant to give it. If an upgraded test suite fails here, the fixture had a bug: fix the key. Keys that do not look like typos of known ones are still allowed, plugins keep their own data that way. See [Repeatable tests](../faults/repeatable-tests.md#storage-validation-catches-fixture-typos). + +## New in 5.0, worth adopting + +These are additions, nothing breaks if you ignore them, but most of them replace workarounds that 4.x tests needed. + +### Promise based start and stop + +`server.start(port, host)` resolves with the port, a free one when `port` is not given, and `server.stop()` closes the server and every session. `listen()` and `close()` still work. + +```javascript +const server = imapkit({ plugins: ['IDLE'] }); +const port = await server.start(); // no more hard-coded ports, test files can run in parallel +// ... +await server.stop(); +``` + +With the `smtp` option (`imapkit({ smtp: { port, host } })`), `start()` also starts the SMTP server, and `server.smtpServer` is that server. With the `rest` option it starts the REST API. + +### The control API + +`server.control` changes the server from the test without an IMAP session, and every connected client sees the change the way it would see a change by another session (`EXISTS`, `EXPUNGE`, unsolicited `FETCH`, `BYE`): + +```javascript +const { uid } = server.control.addMessage('INBOX', { raw: 'Subject: hello\r\n\r\nHi!\r\n', flags: ['\\Seen'] }); +server.control.setFlags('INBOX', [uid], ['\\Flagged'], 'add'); +server.control.expungeMessages('INBOX', [uid]); +``` + +Two items from the "ideas for future XTOYBIRD commands" list of the 4.x README exist now: `resetUidValidity()` changes the UIDVALIDITY of a mailbox at runtime, and `reset()` restores the server to its initial state. See [Control API](../control-api/overview.md), [UIDVALIDITY](../control-api/uidvalidity.md) and, for ACL, QUOTA, METADATA, SPECIAL-USE and OBJECTID data, [Plugin operations](../control-api/plugin-operations.md). + +### Events instead of polling + +The server emits `session` (open, login, select, unselect, logout, waiting, close), `command` (a tagged response went out), `mailbox`, `expunge` and `flags` events. A test can wait for "the client is idling" before it adds a message: + +```javascript +const idling = new Promise(resolve => server.on('session', event => event.type === 'waiting' && event.command === 'IDLE' && resolve(event))); +await idling; +server.control.addMessage('INBOX', { raw: message }); // the idling client gets * n EXISTS right away +``` + +See [Events](../control-api/events.md). The REST API streams the same events as Server-Sent Events, see [Event stream](../rest-api/event-stream.md). + +### More script rule features + +Script rules arrived in 4.2 and 4.3. 5.0 adds: + +- the `quiet` event, for unsolicited output between commands and an autologout during IDLE +- `chance` and the `scriptSeed` option, for faults on a random but repeatable share of the events +- `chunkDelay: 0` or `'tick'`, which splits output into separate TCP segments without a wall clock delay +- [quirk presets](../faults/quirk-presets.md) (`quirks` option, `--quirk`) for Apache James, Yahoo and Microsoft 365 behavior, and servers without MOVE or UIDPLUS +- script rules at runtime over REST (`/v1/script/rules`) + +See [Scripted faults](../faults/scripted-faults.md). + +### Repeatable runs + +The `now` option fixes the clock for the dates the server sets itself (INTERNALDATE of a message without one, SAVEDATE), `scriptSeed` the random faults, and `resetUidValidity(path, { uids: 'shuffle', seed })` the order of shuffled UIDs. The package exports `validateStorage()` and the JSON Schema `storageSchema` for fixture tooling. See [Repeatable tests](../faults/repeatable-tests.md). + +## Coming from hoodiecrow-imap + +ImapKit was published as [`hoodiecrow-imap`](https://www.npmjs.com/package/hoodiecrow-imap) up to version 3.3.1, and renamed in 4.0.0. To migrate from Hoodiecrow: + +- install `imapkit` instead of `hoodiecrow-imap`, and use `require('imapkit')` (or `import imapkit from 'imapkit'`) +- the command is `imapkit`, and its environment variables start with `IMAPKIT_` instead of `HOODIECROW_` +- the server greeting and the SMTP banner say ImapKit, so update tests that match the greeting text + +The API, plugins and storage format did not change with the rename. Then follow the 5.0 steps above, as XTOYBIRD, which 4.x still had, is gone now. diff --git a/docs/docs/reference/server-api.md b/docs/docs/reference/server-api.md new file mode 100644 index 0000000..036bb3f --- /dev/null +++ b/docs/docs/reference/server-api.md @@ -0,0 +1,140 @@ +--- +title: Server API +sidebar_position: 2 +description: The package exports, the CommonJS shape, deep imports, and the IMAPServer methods, properties and events a test author uses. +--- + +# Server API + +## Package exports + +```javascript +import imapkit, { IMAPServer, IMAPConnection, ImapKitError, TAG_REGEX, quirks, storageSchema, validateStorage } from 'imapkit'; +``` + +| Export | What it is | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| default `imapkit` | The factory, `imapkit(options)` returns a new `IMAPServer`. See [Server Options](./server-options.md). It also carries `TAG_REGEX`, `IMAPServer`, `IMAPConnection`, `ImapKitError` and `quirks` as properties. | +| `IMAPServer` | The server class, `new IMAPServer(options)` is the same as `imapkit(options)`. | +| `IMAPConnection` | The class of one client session, for `instanceof` checks and plugin typing. | +| `ImapKitError` | The error the control API throws: an `Error` with `name` `'ImapKitError'` and a `code` (`NONEXISTENT`, `ALREADYEXISTS`, `INVALID`, or an RFC 5530 code such as `CANNOT`). `new ImapKitError(message, code)`. | +| `TAG_REGEX` | A regular expression that matches a valid IMAP command tag (RFC 3501 section 9). | +| `quirks` | The quirk presets as data, keyed by name: `{ description, rules, removePlugins }`. Copy one and adjust its rules when it does not fit. See [Quirk Presets](../faults/quirk-presets.md). | +| `storageSchema` | A JSON Schema of the `storage` option, for editors and fixture tooling. | +| `validateStorage` | `validateStorage(storage)` throws an error with the path of the problem for an invalid storage object, the same check the constructor runs. See [Storage](../guides/storage.md). | + +### Types + +The package ships type declarations for both module formats. These types are exported: + +- server and plugins: `IMAPServerOptions`, `Plugin`, `CommandHandler`, `CommandOptions`, `Callback`, `ParsedCommand`, `IMAPResponse`, `Notification`, `IMAPError`, `Attribute` +- storage: `Message`, `Mailbox`, `StorageNamespace`, `UserData` +- control API: `Control`, `MailboxInfo`, `MessageInfo`, `SessionInfo`, `NewMessage`, `UserOptions`, `SessionFilter`, `ControlRoute`, `RouteRequest`, `FlagMode`, `UidMode`, `UidValidityOptions` +- scripted faults: `ScriptRule`, `ScriptContext`, `ScriptEvent`, `ScriptBytes`, `ScriptHandle`, `Quirk` + +```typescript +import imapkit, { type IMAPServerOptions, type Plugin } from 'imapkit'; + +const xhello: Plugin = server => server.registerCapability('XHELLO'); +const options: IMAPServerOptions = { plugins: ['IDLE', xhello] }; +const server = imapkit(options); +``` + +### CommonJS + +`require('imapkit')` returns the factory itself, with the named exports as its properties, so code written for older versions keeps working: + +```javascript +const imapkit = require('imapkit'); + +const server = imapkit({ plugins: ['IDLE'] }); +console.log(server instanceof imapkit.IMAPServer); // true +const { storageSchema, validateStorage, ImapKitError } = imapkit; +``` + +`imapkit.default` is the factory too, which is what TypeScript and Babel compiled `import imapkit from 'imapkit'` code reads. + +### Deep imports + +The `exports` map of the package keeps the old `imapkit/lib/...` paths working in both module formats: + +| Path | Resolves to | +| ---------------------- | ---------------------------------------------------------------------------------------------- | +| `imapkit/lib/server` | the package root, the same as `imapkit` | +| `imapkit/lib/` | a compiled module from `src/`, such as `imapkit/lib/plugins/idle` or `imapkit/lib/mock-client` | + +In CommonJS a module with a default export loads as that export with its named exports as properties (`require('imapkit/lib/plugins/idle')` is the plugin function). A module with only named exports loads as the exports object. + +`imapkit/lib/mock-client` is handy in tests: `mockClient(port, host, commands, debug, callback)` replays IMAP command strings like a well behaved client (it waits for each tagged response and for `+` continuations) and calls `callback` with everything the server sent, as a Buffer. [Custom Plugins](./custom-plugins.md#test-the-plugin) uses it. + +Modules under `lib/` other than the root are internals: they follow the source layout and can change between minor versions. + +## IMAPServer + +### Starting and stopping + +| Member | What it does | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `start(port?, host?)` | Starts accepting connections, resolves with the IMAP port. No port picks a free one, no host listens on all addresses. Also starts the SMTP and REST listeners of the `smtp` and `rest` options. | +| `stop()` | Closes the server, every session and the SMTP and REST listeners. Resolves when the server is closed. | +| `listen(...args)` | Takes the arguments of `net.Server#listen()`. Starts only the IMAP listener. | +| `close(callback?)` | Closes the IMAP listener and destroys every connection, calls `callback` when done. | +| `address()` | `net.Server#address()` of the IMAP listener, `server.address().port` after `listen(0)`. | +| `startRest()` | Starts only the REST API of the `rest` option, resolves with its port. | +| `startSmtp()` | Starts only the SMTP listener of the `smtp` option, resolves with its port. | +| `smtpServer`, `restServer` | The SMTP server and the HTTP server that `start()` started, or `null`. `server.restServer.address().port` gives the REST port when `rest.port` was `0`. | + +```javascript +const server = imapkit({ rest: { port: 0 } }); +const imapPort = await server.start(0, '127.0.0.1'); +const restPort = server.restServer.address().port; +// ... +await server.stop(); +``` + +### Changing and inspecting state + +| Member | What it does | +| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `control` | The control API: add, flag, move and expunge messages, manage mailboxes, users and sessions, `snapshot()` and `reset()`. Every change reaches the connected sessions. See [Control API](../control-api/overview.md). | +| `script` | The scripted faults: `script.add(rule or rules)` returns handles with `id`, `hits`, `matched` and `remove()`, `script.clear()` removes every rule, `script.rules` lists them. See [Scripted Faults](../faults/scripted-faults.md). | +| `connections` | A `Set` of the open `IMAPConnection` objects. `server.control.sessions()` describes them as plain data. | +| `storage` | The live namespaces and mailboxes, built from a deep copy of the `storage` option. Prefer `control.snapshot()` for a JSON copy. | +| `users` | The live user accounts, `{ name: { password, xoauth2 } }`. Prefer `control.addUser()` and `control.updateUser()` to change them. | +| `getMailbox(path)` | The live mailbox object for a storage name (INBOX is case insensitive), or `undefined`. `control.getMailbox(path)` returns a plain description instead. | +| `appendMessage(mailbox, flags, internaldate, raw, ignoreConnection?, properties?)` | Low level append: adds a message to a mailbox (a path or a mailbox object) and sends `EXISTS` to the sessions, returns `{ mailbox, message }`. No argument checks, `control.addMessage()` is the checked way to do this. | +| `now()` | The current time as the server sees it, from the `now` option. | +| `options` | The shallow copy of the options the server was built with. | + +The live objects (`storage`, `users`, mailboxes, messages) are what the server works with. Changing them directly skips the notifications sessions should get, so a test changes state through `server.control` and only reads the live objects. + +## Events + +`IMAPServer` is an event emitter. Tests wait for events instead of polling: + +| Event | Arguments | When | +| --------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `session` | `{ type, session, ... }` | `type` is `open`, `login`, `select`, `unselect`, `logout`, `waiting` (a command waits for client input, with `command`) or `close`. `session` is described like `control.sessions()`. | +| `command` | `{ session, tag, command, status, user }` | the tagged response of a command goes out, `status` is `OK`, `NO` or `BAD` | +| `mailbox` | `{ type, path, oldPath, mailbox, origin }` | CREATE, DELETE, RENAME, SUBSCRIBE and UNSUBSCRIBE, from a session or the control API (`origin` null) | +| `expunge` | `(mailbox, messages, origin)` | messages are removed, before the sessions are told | +| `flags` | `(mailbox, messages, origin)` | the control API changed flags | +| `script` | `{ rule, event, session, tag, command }` | a script rule matched | +| `reset` | none | `control.reset()` restored the storage and users | +| `acl` | `(mailbox, previousAcl)` | SETACL, DELETEACL or the control API changed an ACL (ACL plugin) | +| `pluginsLoaded` | none | every plugin is loaded, emitted once by the constructor, for [plugins](./custom-plugins.md#run-after-every-plugin-is-loaded) | + +```javascript +const idling = new Promise(resolve => { + server.on('session', event => event.type === 'waiting' && event.command === 'IDLE' && resolve(event)); +}); +// ... let the client start IDLE ... +await idling; +server.control.addMessage('INBOX', { raw: 'Subject: new\r\n\r\nHi\r\n' }); // the client gets * n EXISTS right away +``` + +See [Control API events](../control-api/events.md) for the event payloads in detail. The REST API streams the same events, see [Event Stream](../rest-api/event-stream.md). + +## IMAPConnection + +One `IMAPConnection` exists per client socket. Test code rarely touches it (use `control.sessions()`, `control.disconnect()` and `control.inject()`), plugins use it all the time. The members plugins use are listed in [Custom Plugins](./custom-plugins.md#the-connection-object). diff --git a/docs/docs/reference/server-options.md b/docs/docs/reference/server-options.md new file mode 100644 index 0000000..d827c56 --- /dev/null +++ b/docs/docs/reference/server-options.md @@ -0,0 +1,124 @@ +--- +title: Server Options +sidebar_position: 1 +description: Every option of imapkit(options), including the options that plugins read, with types, defaults and what they change. +--- + +# Server Options + +`imapkit(options)` takes one options object. The server makes a shallow copy of it, and deep copies `storage` and `users`, so the same fixture object can build any number of servers and runtime changes never leak back into it. + +```javascript +import imapkit from 'imapkit'; + +const server = imapkit({ + plugins: ['IDLE', 'MOVE', 'CONDSTORE'], + storage: { INBOX: { messages: [{ raw: 'Subject: hi\r\n\r\nHello\r\n' }] }, '': {} }, + users: { testuser: { password: 'testpass' } } +}); +const port = await server.start(); +``` + +The core options are typed in `IMAPServerOptions` (exported by the package). Plugins read their own options from the same object, so the type also accepts any other key. Options a plugin reads have no effect when that plugin is not loaded. + +The `imapkit` command passes its `--config` JSON file to the same factory, so every option on this page also works in a config file. See [Command Line](../getting-started/command-line.md) for the flags and environment variables that set some of them. + +## Core + +| Option | Type | Default | What it does | +| ------------------ | -------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `storage` | `Record` | `{ INBOX: {}, '': {} }` | The mailbox tree, keyed by namespace prefix. Checked with `validateStorage()` when the server is built, a typo or wrong type throws with the path of the problem. See [Storage](../guides/storage.md). | +| `plugins` | `(string \| Plugin)[]`, or a single `string` or `Plugin` | none | Plugins to load: built-in names (case insensitive, capability spellings like `LITERAL+` work too) or plugin functions. An unknown name throws. A single string is one name, not a comma separated list. See [Extensions](../extensions/overview.md) and [Custom Plugins](./custom-plugins.md). | +| `users` | `Record` | `testuser` with password `testpass` and XOAUTH2 token `testtoken` | User accounts. Each user is `{ password, xoauth2: { accessToken, sessionTimeout } }`. Setting this option replaces the default user. See [Authentication](../guides/authentication.md). | +| `secureConnection` | `boolean` | `false` | Implicit TLS: the server accepts only TLS connections (port 993 style). | +| `credentials` | `{ key: string \| Buffer, cert: string \| Buffer }` | the bundled self-signed certificate for `localhost` | TLS key and certificate for `secureConnection`, the STARTTLS plugin and the SMTP listener. | +| `systemFlags` | `string[]` | `['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen']` | The system flags that can be stored. A STORE or APPEND with another `\`-flag is refused. Also the default `permanentFlags` of mailboxes that do not set their own. | +| `maxLiteralSize` | `number` | `67108864` (64 MiB) | Largest literal accepted after login, in octets. A larger literal is answered with `BAD`. Before login the limit is fixed at 64 KiB. | +| `debug` | `boolean` | `false` | Writes client input and every response (`SEND: ...`) to stdout. | + +## Listeners started by `start()` + +`server.start()` starts the IMAP listener and, when these options are set, the SMTP and REST listeners next to it. + +| Option | Type | Default | What it does | +| ------ | -------------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `smtp` | `{ port?: number, host?: string }` | not started | Starts an SMTP server that appends every received message to INBOX. Needs the optional `smtp-server` package (`npm install smtp-server`), `start()` rejects with an error that names it when it is missing. Port `0` or no port picks a free one, no host listens on all addresses. The server is `server.smtpServer`. | +| `rest` | `{ port?: number, host?: string, token?: string }` | not started, host `127.0.0.1` when started | Starts the REST API (the control API over HTTP). A host that is not a loopback address needs a `token`, which requests send as `Authorization: Bearer `. The server is `server.restServer`. See [REST API](../rest-api/overview.md). | + +## Repeatable tests and faults + +| Option | Type | Default | What it does | +| ------------ | ------------------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `script` | `ScriptRule \| ScriptRule[]` | none | Rules that make the server misbehave on purpose. More can be added at runtime with `server.script.add()`. See [Scripted Faults](../faults/scripted-faults.md). | +| `quirks` | `string[] \| string` | none | Quirk presets that make the server behave like a known real server: `james-fetchgroup`, `james-late-fetch`, `yahoo-quoted-sections`, `m365-throttle`, `no-uidplus`, `no-move`. An unknown name throws. See [Quirk Presets](../faults/quirk-presets.md). | +| `scriptSeed` | `number` | random | Seed of the random numbers that script rules with `chance` (and the quirk presets that use them) draw, so a run can be repeated. `server.control.reset()` starts the same sequence again. | +| `now` | `Date \| number \| (() => Date \| number)` | the current time | The time the server uses for the dates it sets itself: the INTERNALDATE of a message without one, and SAVEDATE. The dates are formatted in the time zone of the process. See [Repeatable Tests](../faults/repeatable-tests.md). | + +## Plugin options + +These options are read by one plugin each, when it is loaded. + +### Limits + +| Option | Type | Default | Plugin | What it does | +| ------------------- | ------------------ | -------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `appendLimit` | `number` or `null` | no limit | APPENDLIMIT | Largest message APPEND and REPLACE accept for every mailbox, in octets, advertised as `APPENDLIMIT=`. A non-negative integer, anything else throws. A mailbox in the storage can set its own `appendLimit` instead, then clients read it with `STATUS (APPENDLIMIT)`. | +| `messageLimit` | `number` | `1000` | MESSAGELIMIT, SAVELIMIT | The `n` of `MESSAGELIMIT=` or `SAVELIMIT=`. Any positive integer, so small test mailboxes can hit it. Anything else throws. | +| `maxSearchContexts` | `number` | `10` | CONTEXT=SEARCH | Updating searches (`RETURN (UPDATE)`) one session may keep. Above it the server answers `NO [NOUPDATE "tag"]`. | +| `quota` | `object` | `{}` | QUOTA | `{ root, STORAGE, MESSAGE, MAILBOX, soft }`. `root` is the quota root name (default `"User quota"`), `STORAGE` is in units of 1024 octets, a missing resource is not limited. With `soft: true` going over a limit only sends an untagged `NO [OVERQUOTA]` warning. `SETQUOTA` and `server.control.setQuota()` change the limits at runtime, `control.reset()` restores these. | + +### Metadata + +| Option | Type | Default | Plugin | What it does | +| -------------------- | ------------------------ | ------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `metadata` | `Record` | none | METADATA, METADATA-SERVER | Initial server annotations, for example `{ "/shared/comment": "Test server" }`. Mailbox annotations come from a `metadata` object on the mailbox in storage. | +| `metadataMaxSize` | `number` | `65536` | METADATA, METADATA-SERVER | Largest annotation value in octets, larger SETMETADATA values fail with `[METADATA MAXSIZE n]`. | +| `metadataMaxEntries` | `number` | `100` | METADATA, METADATA-SERVER | Entries per mailbox and for the server, more fail with `[METADATA TOOMANY]`. | +| `metadataPrivate` | `boolean` | `true` | METADATA, METADATA-SERVER | `false` refuses `/private` entries with `[METADATA NOPRIVATE]`. | + +See [Metadata and Quota](../extensions/metadata-and-quota.md). + +### Other plugins + +| Option | Type | Default | Plugin | What it does | +| ------------------- | ------------------------ | ----------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `aclOwner` | `string` | `"testuser"` | ACL | The user that has every right on every mailbox and bypasses ACL enforcement. See [Access Control](../extensions/access-control.md). | +| `id` | `Record` | none, the server answers `* ID NIL` | ID | The field-value pairs of the server's `* ID` response, for example `{ name: 'ImapKit', vendor: 'Example' }`. | +| `special-use` | `string[]` | `['\\Archive', '\\Drafts', '\\Flagged', '\\Junk', '\\Sent', '\\Trash']` | CREATE-SPECIAL-USE | The special-use attributes `CREATE ... (USE (...))` accepts, others fail with `NO [USEATTR]`. Note the hyphen, the key has to be quoted in JavaScript. | +| `HIGHESTX-GM-MSGID` | `string` or `number` | `"1278455344230334865"` | X-GM-EXT-1 | Starting point of the generated `X-GM-MSGID` values. Each message without an `X-GM-MSGID` in storage gets the next number. See [Gmail](../extensions/gmail.md). | + +## Example + +Every option in one server, as a config file or a factory argument: + +```javascript +const server = imapkit({ + plugins: ['ID', 'QUOTA', 'APPENDLIMIT', 'MESSAGELIMIT', 'METADATA', 'CONTEXT=SEARCH', 'ACL', 'CREATE-SPECIAL-USE', 'X-GM-EXT-1'], + users: { testuser: { password: 'testpass' }, other: { password: 'pw' } }, + maxLiteralSize: 1024 * 1024, + now: new Date('2026-01-01T12:00:00Z'), + scriptSeed: 42, + id: { name: 'ImapKit', vendor: 'Example' }, + quota: { root: 'User quota', STORAGE: 10240, MESSAGE: 1000 }, + appendLimit: 5 * 1024 * 1024, + messageLimit: 50, + maxSearchContexts: 2, + metadata: { '/shared/comment': 'Test server' }, + metadataMaxSize: 1024, + 'special-use': ['\\Archive', '\\Sent'], + aclOwner: 'testuser', + 'HIGHESTX-GM-MSGID': '1000' +}); +``` + +## Options that throw + +The constructor fails fast on configuration mistakes, so a broken fixture never starts a server that behaves oddly: + +- an unknown plugin name (`Unknown plugin "IDLE,MOVE". Available plugins: ...`), or `XTOYBIRD`, which was removed in 5.0.0 (see [Migrating from 4.x](./migrating-from-4.md)) +- an unknown quirk name +- a `storage` object that does not pass `validateStorage()` +- an invalid `appendLimit`, `messageLimit` or `quota` limit +- plugins that can not be loaded together: LITERAL+ with LITERAL-, MESSAGELIMIT with SAVELIMIT + +`rest` with a non-loopback `host` and no `token` throws when `start()` (or `startRest()`) starts the listener. diff --git a/docs/docs/rest-api/endpoints.md b/docs/docs/rest-api/endpoints.md new file mode 100644 index 0000000..2e78175 --- /dev/null +++ b/docs/docs/rest-api/endpoints.md @@ -0,0 +1,417 @@ +--- +title: Endpoints +sidebar_position: 2 +description: Every REST API endpoint with its method, path, request body, response and a curl example, including the routes of the ACL, QUOTA, METADATA and SPECIAL-USE plugins. +--- + +# Endpoints + +Every endpoint is under `/v1`. The examples use a server started with + +```bash +imapkit -p 1143 --rest-port=8143 --plugin=IDLE,ACL,QUOTA,METADATA,SPECIAL-USE,CONDSTORE +``` + +and show real responses. `{path}` is a mailbox [storage name, URL encoded](./overview.md#mailbox-names-in-urls) with `/` as `%2F`. Bodies are JSON and need `Content-Type: application/json`. Errors are described in the [overview](./overview.md#errors), and each endpoint answers with the errors of the [control API](../control-api/overview.md) method it calls. + +| Method | Path | Control API | Success | +| -------- | --------------------------------------- | --------------------------------------------- | ------- | +| `GET` | `/v1/snapshot` | `snapshot()` | 200 | +| `POST` | `/v1/reset` | `reset()` | 200 | +| `POST` | `/v1/shutdown` | `shutdown()` | 202 | +| `GET` | `/v1/sessions` | `sessions()` | 200 | +| `DELETE` | `/v1/sessions/{session}` | `disconnect()` | 200 | +| `POST` | `/v1/sessions/{session}/inject` | `inject()` | 200 | +| `GET` | `/v1/events` | [event stream](./event-stream.md) | 200 | +| `GET` | `/v1/users` | `listUsers()` | 200 | +| `POST` | `/v1/users` | `addUser()` | 201 | +| `PUT` | `/v1/users/{name}` | `updateUser()` | 200 | +| `DELETE` | `/v1/users/{name}` | `deleteUser()` | 200 | +| `GET` | `/v1/mailboxes` | `listMailboxes()` | 200 | +| `POST` | `/v1/mailboxes` | `createMailbox()` | 201 | +| `GET` | `/v1/mailboxes/{path}` | `getMailbox()` | 200 | +| `DELETE` | `/v1/mailboxes/{path}` | `deleteMailbox()` | 200 | +| `POST` | `/v1/mailboxes/{path}/rename` | `renameMailbox()` | 200 | +| `PUT` | `/v1/mailboxes/{path}/subscription` | `subscribe()` | 200 | +| `DELETE` | `/v1/mailboxes/{path}/subscription` | `unsubscribe()` | 200 | +| `POST` | `/v1/mailboxes/{path}/uidvalidity` | `resetUidValidity()` | 200 | +| `GET` | `/v1/mailboxes/{path}/messages` | `listMessages()` | 200 | +| `POST` | `/v1/mailboxes/{path}/messages` | `addMessage()` | 201 | +| `POST` | `/v1/mailboxes/{path}/messages/flags` | `setFlags()` | 200 | +| `POST` | `/v1/mailboxes/{path}/messages/expunge` | `expungeMessages()` | 200 | +| `POST` | `/v1/mailboxes/{path}/messages/copy` | `copyMessages()` | 200 | +| `POST` | `/v1/mailboxes/{path}/messages/move` | `moveMessages()` | 200 | +| `GET` | `/v1/mailboxes/{path}/messages/{uid}` | `getMessage()` | 200 | +| `DELETE` | `/v1/mailboxes/{path}/messages/{uid}` | `expungeMessages()` | 200 | +| `GET` | `/v1/script/rules` | `server.script.rules` | 200 | +| `POST` | `/v1/script/rules` | `server.script.add()` | 201 | +| `DELETE` | `/v1/script/rules` | `server.script.clear()` | 200 | +| `DELETE` | `/v1/script/rules/{id}` | `handle.remove()` | 200 | +| `GET` | `/v1/mailboxes/{path}/acl` | `getAcl()` (ACL) | 200 | +| `PUT` | `/v1/mailboxes/{path}/acl/{identifier}` | `setAcl()` (ACL) | 200 | +| `DELETE` | `/v1/mailboxes/{path}/acl/{identifier}` | `deleteAcl()` (ACL) | 200 | +| `GET` | `/v1/quota` | `getQuota()` (QUOTA) | 200 | +| `PUT` | `/v1/quota` | `setQuota()` (QUOTA) | 200 | +| `GET` | `/v1/metadata` | `getMetadata('')` (METADATA, METADATA-SERVER) | 200 | +| `PUT` | `/v1/metadata` | `setMetadata('')` (METADATA, METADATA-SERVER) | 200 | +| `GET` | `/v1/mailboxes/{path}/metadata` | `getMetadata()` (METADATA) | 200 | +| `PUT` | `/v1/mailboxes/{path}/metadata` | `setMetadata()` (METADATA) | 200 | +| `PUT` | `/v1/mailboxes/{path}/special-use` | `setSpecialUse()` (SPECIAL-USE) | 200 | +| `GET` | `/v1/openapi.json` | the OpenAPI document | 200 | + +The plugin routes exist only while their plugin is loaded, without it they answer `404 NOTFOUND`. + +## Server + +### GET /v1/snapshot + +The whole store in the shape of the `storage` option, see [`snapshot()`](../control-api/mailboxes-and-messages.md#snapshot). Message sources are binary strings here, not base64. Save it to start a new server from the same state with `--storage`. + +```bash +curl -s http://127.0.0.1:8143/v1/snapshot > state.json +imapkit -p 1144 --storage=state.json +``` + +### POST /v1/reset + +Restores the mailboxes and users of the server options and disconnects every session with `* BYE Server reset`. Script rules stay. See [`reset()`](../control-api/users-and-sessions.md#reset). + +```bash +curl -X POST http://127.0.0.1:8143/v1/reset -H 'Content-Type: application/json' +# {"reset":true} +``` + +### POST /v1/shutdown + +Body: `{ "graceful": false }` (optional). Answers `202` first, then stops the server. A graceful shutdown (the default) stops accepting connections and waits until the last client is gone, `graceful: false` closes the sessions right away. The `imapkit` command exits when the server is closed. Open [event streams](./event-stream.md) end with the shutdown. + +```bash +curl -X POST http://127.0.0.1:8143/v1/shutdown -H 'Content-Type: application/json' -d '{"graceful": false}' +# {"shutdown":true} +``` + +## Sessions + +### GET /v1/sessions + +The connected sessions, see [`sessions()`](../control-api/users-and-sessions.md#sessions). + +```bash +curl http://127.0.0.1:8143/v1/sessions +# [{"session":1,"user":"testuser","state":"Selected","mailbox":"INBOX","readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}] +``` + +### DELETE /v1/sessions/\{session\} + +Body: `{ "text": "...", "reset": true }` (both optional). Disconnects one session with an untagged BYE (`text`, default `Disconnected by the server`), or with a TCP reset when `reset` is true. Answers `404 NONEXISTENT` for a session that is not connected. + +```bash +curl -X DELETE http://127.0.0.1:8143/v1/sessions/1 -H 'Content-Type: application/json' -d '{"text": "Bye from the test"}' +# {"disconnected":true} +# the client receives: * BYE Bye from the test +``` + +### POST /v1/sessions/\{session\}/inject + +Body: `{ "data": "...", "encoding": "base64" }`. Writes `data` to the session as it is, as UTF-8 text or decoded from base64 with `"encoding": "base64"`. See [`inject()`](../control-api/users-and-sessions.md#injectsession-data). + +```bash +curl -X POST http://127.0.0.1:8143/v1/sessions/1/inject \ + -H 'Content-Type: application/json' \ + -d '{"data": "* OK [ALERT] Maintenance in 5 minutes\r\n"}' +# {"injected":true} +``` + +### GET /v1/events + +Server events as Server-Sent Events, see [Event stream](./event-stream.md). + +## Users + +### GET /v1/users + +The users without credentials. + +```bash +curl http://127.0.0.1:8143/v1/users +# [{"name":"testuser","xoauth2":true}] +``` + +### POST /v1/users + +Body: `{ "name": "...", "password": "...", "xoauth2": { "accessToken": "...", "sessionTimeout": 3600000 } }`. Adds a user, answers `201` with `{ name }`, or `409 ALREADYEXISTS`. + +```bash +curl -X POST http://127.0.0.1:8143/v1/users -H 'Content-Type: application/json' -d '{"name": "alice", "password": "secret"}' +# {"name":"alice"} +``` + +### PUT /v1/users/\{name\} + +Body: `{ "password": "...", "xoauth2": {...} }`. Changes the password or the access token, `"xoauth2": null` removes the token. + +```bash +curl -X PUT http://127.0.0.1:8143/v1/users/alice -H 'Content-Type: application/json' -d '{"xoauth2": {"accessToken": "alice-token"}}' +# {"name":"alice"} +``` + +### DELETE /v1/users/\{name\} + +Body: `{ "disconnect": false }` (optional). Deletes a user and disconnects its sessions, unless `disconnect` is false. + +```bash +curl -X DELETE http://127.0.0.1:8143/v1/users/alice -H 'Content-Type: application/json' -d '{"disconnect": false}' +# {"deleted":true} +curl -X DELETE http://127.0.0.1:8143/v1/users/alice +# {"error":{"code":"NONEXISTENT","message":"User \"alice\" does not exist"}} +``` + +## Mailboxes + +### GET /v1/mailboxes + +Every mailbox as a [`MailboxInfo`](../control-api/mailboxes-and-messages.md#getmailboxpath), ordered by name. + +```bash +curl http://127.0.0.1:8143/v1/mailboxes +# [{"path":"INBOX","delimiter":"/","flags":["\\HasNoChildren"],"selectable":true,"subscribed":true,"messages":0,"unseen":0,"uidnext":1,"uidvalidity":1,"permanentFlags":["\\Answered","\\Flagged","\\Draft","\\Deleted","\\Seen"],"specialUse":[]}] +``` + +### POST /v1/mailboxes + +Body: `{ "path": "...", "subscribed": true }`. Creates a mailbox with any missing superior levels and answers `201` with its `MailboxInfo`. + +```bash +curl -X POST http://127.0.0.1:8143/v1/mailboxes -H 'Content-Type: application/json' -d '{"path": "Work/Projects", "subscribed": true}' +# {"path":"Work/Projects","delimiter":"/","flags":["\\HasNoChildren"],"selectable":true,"subscribed":true,"messages":0,"unseen":0,"uidnext":1,"uidvalidity":3,"permanentFlags":["\\Answered","\\Flagged","\\Draft","\\Deleted","\\Seen"],"specialUse":[]} +``` + +### GET /v1/mailboxes/\{path\} + +One mailbox. + +```bash +curl http://127.0.0.1:8143/v1/mailboxes/Work%2FProjects +# {"path":"Work/Projects","delimiter":"/","flags":["\\HasNoChildren"],"selectable":true,"subscribed":true,"messages":0,"unseen":0,"uidnext":1,"uidvalidity":3,"permanentFlags":["\\Answered","\\Flagged","\\Draft","\\Deleted","\\Seen"],"specialUse":[]} +curl -i http://127.0.0.1:8143/v1/mailboxes/Nope +# HTTP/1.1 404 Not Found +# {"error":{"code":"NONEXISTENT","message":"Mailbox \"Nope\" does not exist"}} +``` + +### DELETE /v1/mailboxes/\{path\} + +Deletes a mailbox. Sessions that have it selected get `BYE`. + +```bash +curl -X DELETE http://127.0.0.1:8143/v1/mailboxes/Archive%2FProjects +# {"deleted":true} +curl -X DELETE http://127.0.0.1:8143/v1/mailboxes/INBOX +# 409 {"error":{"code":"CANNOT","message":"INBOX can not be modified"}} +``` + +### POST /v1/mailboxes/\{path\}/rename + +Body: `{ "newPath": "..." }`. Renames a mailbox with its children and answers with the renamed `MailboxInfo`. Renaming INBOX moves its messages. + +```bash +curl -X POST http://127.0.0.1:8143/v1/mailboxes/Work/rename -H 'Content-Type: application/json' -d '{"newPath": "Archive"}' +# {"path":"Archive","delimiter":"/","flags":["\\HasChildren"],"selectable":true,"subscribed":false,"messages":0,"unseen":0,"uidnext":1,"uidvalidity":2,"permanentFlags":["\\Answered","\\Flagged","\\Draft","\\Deleted","\\Seen"],"specialUse":[]} +``` + +### PUT and DELETE /v1/mailboxes/\{path\}/subscription + +`PUT` subscribes a mailbox, `DELETE` unsubscribes a name (it does not have to be a mailbox). Both answer `{ "changed": true }` if the subscription changed. Subscriptions stay with the old name when a mailbox is renamed, so `Archive/Projects` above is not subscribed: + +```bash +curl -X DELETE http://127.0.0.1:8143/v1/mailboxes/Archive%2FProjects/subscription +# {"changed":false} +curl -X PUT http://127.0.0.1:8143/v1/mailboxes/Archive%2FProjects/subscription +# {"changed":true} +``` + +### Reset UIDVALIDITY + +`POST /v1/mailboxes/{path}/uidvalidity` with the body `{ "uidvalidity": 5000, "uids": "offset", "offset": 100, "seed": 42 }` (all optional). Gives the mailbox a new UIDVALIDITY and optionally new UIDs, sessions that have it selected get `BYE`. See [UIDVALIDITY resets](../control-api/uidvalidity.md). + +```bash +curl -X POST http://127.0.0.1:8143/v1/mailboxes/INBOX/uidvalidity -H 'Content-Type: application/json' -d '{"uids": "offset", "offset": 100}' +# {"uidvalidity":4,"uidnext":105,"uids":[{"uid":1,"newUid":103}]} +``` + +## Messages + +### GET /v1/mailboxes/\{path\}/messages + +Query: `uids=1,2` (only these UIDs), `raw=true` (include the source in base64). The messages as [`MessageInfo`](../control-api/mailboxes-and-messages.md#getmessagepath-uid-options) objects, ordered by UID. + +```bash +curl http://127.0.0.1:8143/v1/mailboxes/INBOX/messages +# [{"uid":1,"flags":["\\Seen"],"internaldate":"08-Oct-2026 18:22:33 +0300","size":23,"modseq":2},{"uid":2,"flags":[],"internaldate":"17-Jul-1996 02:44:25 -0700","size":25,"modseq":3}] +curl 'http://127.0.0.1:8143/v1/mailboxes/INBOX/messages?uids=2&raw=true' +# [{"uid":2,"flags":[],"internaldate":"17-Jul-1996 02:44:25 -0700","size":25,"modseq":3,"raw":"U3ViamVjdDogYmFzZTY0DQoNCkJvZHkNCg==","encoding":"base64"}] +``` + +### POST /v1/mailboxes/\{path\}/messages + +Body: `{ "raw": "...", "encoding": "base64", "flags": [...], "internaldate": "...", "checks": true }`. Adds a message like a delivery and answers `201` with `{ uid, uidvalidity }`. `raw` is text (UTF-8) or base64 with `"encoding": "base64"`, `internaldate` an RFC 3501 date-time string. `"checks": true` refuses the message like APPEND would, for example `409 OVERQUOTA` with QUOTA or `413 TOOBIG` with APPENDLIMIT. + +```bash +curl -X POST http://127.0.0.1:8143/v1/mailboxes/INBOX/messages \ + -H 'Content-Type: application/json' \ + -d '{"raw": "Subject: hello\r\n\r\nHi!\r\n", "flags": ["\\Seen"]}' +# {"uid":1,"uidvalidity":1} +curl -X POST http://127.0.0.1:8143/v1/mailboxes/INBOX/messages \ + -H 'Content-Type: application/json' \ + -d '{"raw": "U3ViamVjdDogYmFzZTY0DQoNCkJvZHkNCg==", "encoding": "base64", "internaldate": "17-Jul-1996 02:44:25 -0700"}' +# {"uid":2,"uidvalidity":1} +``` + +### POST /v1/mailboxes/\{path\}/messages/flags + +Body: `{ "uids": [...], "flags": [...], "mode": "add" }`. Changes flags, `mode` is `set` (default), `add` or `remove`. Answers with the new flags of every message. + +```bash +curl -X POST http://127.0.0.1:8143/v1/mailboxes/INBOX/messages/flags \ + -H 'Content-Type: application/json' \ + -d '{"uids": [1, 2], "flags": ["\\Flagged"], "mode": "add"}' +# [{"uid":1,"flags":["\\Seen","\\Flagged"]},{"uid":2,"flags":["\\Flagged"]}] +``` + +### POST /v1/mailboxes/\{path\}/messages/expunge + +Body: `{ "uids": [...] }`. Removes messages, answers with the removed UIDs. + +```bash +curl -X POST http://127.0.0.1:8143/v1/mailboxes/Work%2FProjects/messages/expunge -H 'Content-Type: application/json' -d '{"uids": [1]}' +# {"uids":[1]} +``` + +### POST /v1/mailboxes/\{path\}/messages/copy and .../move + +Body: `{ "uids": [...], "target": "..." }`. Copies or moves messages to `target` (a plain storage name, not URL encoded). Answers with the UIDVALIDITY of the target and the new UIDs. + +```bash +curl -X POST http://127.0.0.1:8143/v1/mailboxes/INBOX/messages/copy -H 'Content-Type: application/json' -d '{"uids": [1, 2], "target": "Work/Projects"}' +# {"uidvalidity":3,"uids":[{"uid":1,"targetUid":1},{"uid":2,"targetUid":2}]} +curl -X POST http://127.0.0.1:8143/v1/mailboxes/INBOX/messages/move -H 'Content-Type: application/json' -d '{"uids": [2], "target": "Work/Projects"}' +# {"uidvalidity":3,"uids":[{"uid":2,"targetUid":3}]} +``` + +### GET /v1/mailboxes/\{path\}/messages/\{uid\} + +One message, always with its source in base64. + +```bash +curl http://127.0.0.1:8143/v1/mailboxes/INBOX/messages/1 +# {"uid":1,"flags":["\\Seen"],"internaldate":"08-Oct-2026 18:22:33 +0300","size":23,"modseq":2,"raw":"U3ViamVjdDogaGVsbG8NCg0KSGkhDQo=","encoding":"base64"} +``` + +### DELETE /v1/mailboxes/\{path\}/messages/\{uid\} + +Removes one message. + +```bash +curl -X DELETE http://127.0.0.1:8143/v1/mailboxes/Work%2FProjects/messages/2 +# {"uids":[2]} +``` + +## Script rules + +Script rules in the JSON form of the `--script` file, see [Scripted faults](../faults/scripted-faults.md). They change the server's behavior at runtime, without a restart. + +### GET /v1/script/rules + +The rules with their counters: `id`, the `rule`, `matched` (how often the rule matched an event) and `hits` (how often it acted). + +```bash +curl http://127.0.0.1:8143/v1/script/rules +# [{"id":1,"rule":{"on":"command","command":"SELECT","times":1,"send":"$TAG NO [UNAVAILABLE] Try again later\r\n"},"matched":0,"hits":0}] +``` + +### POST /v1/script/rules + +Body: a rule, or an array of rules. Answers `201` with the added rule, or an array of them. If one rule of an array is invalid, none is added and the answer is `400 INVALID`. + +```bash +curl -X POST http://127.0.0.1:8143/v1/script/rules \ + -H 'Content-Type: application/json' \ + -d '{"on": "command", "command": "SELECT", "times": 1, "send": "$TAG NO [UNAVAILABLE] Try again later\r\n"}' +# {"id":1,"rule":{"on":"command","command":"SELECT","times":1,"send":"$TAG NO [UNAVAILABLE] Try again later\r\n"},"matched":0,"hits":0} +``` + +### DELETE /v1/script/rules and /v1/script/rules/\{id\} + +Removes every rule, or one rule by its `id`. + +```bash +curl -X DELETE http://127.0.0.1:8143/v1/script/rules/1 +# {"deleted":true} +curl -X DELETE http://127.0.0.1:8143/v1/script/rules/1 +# {"error":{"code":"NONEXISTENT","message":"Script rule 1 does not exist"}} +curl -X DELETE http://127.0.0.1:8143/v1/script/rules +# {"deleted":true} +``` + +## Plugin routes + +These answer only while their plugin is loaded. See [Plugin operations](../control-api/plugin-operations.md) for the rules. + +### ACL + +`GET /v1/mailboxes/{path}/acl` returns the ACL. `PUT /v1/mailboxes/{path}/acl/{identifier}` with `{ "rights": "lrs" }` sets the rights of an identifier, `+` or `-` in front adds or removes rights. `DELETE /v1/mailboxes/{path}/acl/{identifier}` removes the identifier. All three answer with the new ACL. + +```bash +curl http://127.0.0.1:8143/v1/mailboxes/INBOX/acl +# {} +curl -X PUT http://127.0.0.1:8143/v1/mailboxes/INBOX/acl/alice -H 'Content-Type: application/json' -d '{"rights": "lrs"}' +# {"alice":"lrs"} +curl -X PUT http://127.0.0.1:8143/v1/mailboxes/INBOX/acl/alice -H 'Content-Type: application/json' -d '{"rights": "+w"}' +# {"alice":"lrsw"} +curl -X DELETE http://127.0.0.1:8143/v1/mailboxes/INBOX/acl/alice +# {} +``` + +### QUOTA + +`GET /v1/quota` returns the quota root, limits and usage. `PUT /v1/quota` with `{ "STORAGE": 1024, "MESSAGE": 100, "MAILBOX": 10 }` replaces all limits. + +```bash +curl http://127.0.0.1:8143/v1/quota +# {"root":"User quota","limits":{},"usage":{"STORAGE":1,"MESSAGE":1,"MAILBOX":2}} +curl -X PUT http://127.0.0.1:8143/v1/quota -H 'Content-Type: application/json' -d '{"STORAGE": 1024, "MESSAGE": 100}' +# {"root":"User quota","limits":{"STORAGE":1024,"MESSAGE":100},"usage":{"STORAGE":1,"MESSAGE":1,"MAILBOX":2}} +``` + +### METADATA + +`GET` and `PUT /v1/metadata` read and set the server annotations, `GET` and `PUT /v1/mailboxes/{path}/metadata` those of a mailbox (METADATA only, with METADATA-SERVER they answer `400 INVALID`). A `PUT` body is an object of entry names and values, `null` removes an entry. Both answer with all annotations afterwards. + +```bash +curl -X PUT http://127.0.0.1:8143/v1/metadata -H 'Content-Type: application/json' -d '{"/shared/comment": "Test server"}' +# {"/shared/comment":"Test server"} +curl -X PUT http://127.0.0.1:8143/v1/mailboxes/INBOX/metadata -H 'Content-Type: application/json' -d '{"/private/comment": "My inbox"}' +# {"/private/comment":"My inbox"} +curl http://127.0.0.1:8143/v1/mailboxes/INBOX/metadata +# {"/private/comment":"My inbox"} +``` + +### SPECIAL-USE + +`PUT /v1/mailboxes/{path}/special-use` with `{ "specialUse": ["\\Sent"] }` replaces the special-use attributes and answers with the `MailboxInfo`. + +```bash +curl -X PUT http://127.0.0.1:8143/v1/mailboxes/Sent/special-use -H 'Content-Type: application/json' -d '{"specialUse": ["\\Sent"]}' +# {"path":"Sent","delimiter":"/","flags":["\\HasNoChildren"],"selectable":true,"subscribed":false,"messages":0,"unseen":0,"uidnext":1,"uidvalidity":5,"permanentFlags":["\\Answered","\\Flagged","\\Draft","\\Deleted","\\Seen"],"specialUse":["\\Sent"]} +``` + +### Routes of custom plugins + +A plugin of your own adds routes with [`control.register()`](../control-api/plugin-operations.md#controlregistername-fn-routes). They are served after the built-in ones and listed in the OpenAPI document. + +## GET /v1/openapi.json + +The OpenAPI 3.1 document of the running server, with every route above that the server has. + +```bash +curl -s http://127.0.0.1:8143/v1/openapi.json | jq '.paths["/v1/quota"]' +``` diff --git a/docs/docs/rest-api/event-stream.md b/docs/docs/rest-api/event-stream.md new file mode 100644 index 0000000..981722d --- /dev/null +++ b/docs/docs/rest-api/event-stream.md @@ -0,0 +1,204 @@ +--- +title: Event Stream +sidebar_position: 3 +description: Follow server events over HTTP with GET /v1/events, a Server-Sent Events stream, to wait for client actions from tests in any language. +--- + +# Event stream + +`GET /v1/events` streams the [server events](../control-api/events.md) as [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html). A test in any language can open the stream, let the client act, and wait for "the client is idling" or "a script rule fired" without polling. + +```bash +curl -N 'http://127.0.0.1:8143/v1/events?types=session' +``` + +``` +: connected + +event: session +data: {"type":"open","session":{"session":1,"user":null,"state":"Not Authenticated","mailbox":null,"readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}} + +event: session +data: {"type":"login","session":{"session":1,"user":"testuser","state":"Authenticated","mailbox":null,"readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}} +``` + +The stream starts with the comment `: connected` as soon as the server listens for events, so a test can wait for it before it lets the client act. Each event is an `event:` line with the type and a `data:` line with JSON, followed by an empty line. A `: ping` comment every 15 seconds keeps proxies from closing an idle stream. The stream stays open until the client closes it or the server stops, a shutdown (graceful or not) ends it. A graceful shutdown leaves open streams open, and they keep the `imapkit` command running until their clients close them. Events that happen while no stream is open are not kept. + +## Choosing event types + +`?types=` takes a comma separated list. Without it the stream has every type: + +| Type | When | +| --------- | ----------------------------------------------------------------------- | +| `session` | a session opens, logs in, selects, unselects, logs out, waits or closes | +| `command` | the tagged response of a command goes out | +| `mailbox` | a mailbox is created, deleted, renamed, subscribed or unsubscribed | +| `expunge` | messages are removed | +| `flags` | the control API or the REST API changed flags | +| `acl` | an ACL changed (ACL plugin) | +| `script` | a script rule fired | +| `reset` | the server was reset | + +An unknown type is refused before the stream starts: + +```bash +curl -s 'http://127.0.0.1:8143/v1/events?types=session,foo' +# 400 {"error":{"code":"INVALID","message":"Unknown event type foo, expected session, command, mailbox, expunge, flags, acl, script, reset"}} +``` + +## Payloads + +The data is the JSON form of the [server event](../control-api/events.md): mailboxes are storage names, messages are UIDs and sessions are session numbers (`origin` is `null` for changes of the control and REST APIs). Below is an excerpt from a real run, with an IMAP client that logs in, selects INBOX, runs a NOOP that a script rule answers, creates a mailbox, stores a flag and logs out, followed by REST calls: + +| Type | Data | +| --------- | ------------------------------------------------------------------------------------- | +| `session` | `{ type, session, command? }`, see [session events](../control-api/events.md#session) | +| `command` | `{ session, tag, command, status, user }` | +| `mailbox` | `{ type, path, oldPath, origin }` | +| `expunge` | `{ path, uids, origin }` | +| `flags` | `{ path, messages: [{ uid, flags }], origin }` | +| `acl` | `{ path }` | +| `script` | `{ rule, event, session, tag, command }` | +| `reset` | `{}` | + +``` +event: acl +data: {"path":"INBOX"} + +event: session +data: {"type":"open","session":{"session":2,"user":null,"state":"Not Authenticated","mailbox":null,"readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}} + +event: command +data: {"session":2,"tag":"CHEN1","command":"LOGIN","status":"OK","user":"testuser"} + +event: session +data: {"type":"login","session":{"session":2,"user":"testuser","state":"Authenticated","mailbox":null,"readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}} + +event: session +data: {"type":"select","session":{"session":2,"user":"testuser","state":"Selected","mailbox":"INBOX","readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":"::ffff:127.0.0.1"}} + +event: command +data: {"session":2,"tag":"CHEN3","command":"SELECT","status":"OK","user":"testuser"} + +event: script +data: {"rule":{"on":"command","command":"NOOP","times":1,"send":"$TAG NO Scripted\r\n"},"event":"command","session":2,"tag":"CHEN4","command":"NOOP"} + +event: mailbox +data: {"type":"create","path":"Projects","oldPath":null,"origin":2} + +event: command +data: {"session":2,"tag":"CHEN6","command":"UID STORE","status":"OK","user":"testuser"} + +event: session +data: {"type":"close","session":{"session":2,"user":"testuser","state":"Logout","mailbox":"INBOX","readOnly":false,"enabled":[],"secure":false,"compressed":false,"remoteAddress":null}} + +event: flags +data: {"path":"INBOX","messages":[{"uid":105,"flags":["\\Seen"]}],"origin":null} + +event: expunge +data: {"path":"INBOX","uids":[105],"origin":null} + +event: mailbox +data: {"type":"rename","path":"Done","oldPath":"Projects","origin":null} + +event: reset +data: {} +``` + +Some things to note in this run: + +- The NOOP that the script rule answered has a `script` event and no `command` event. +- The client's `UID STORE` has a `command` event and no `flags` event. `flags` is only for flag changes of the control and REST APIs. +- `acl` events carry only the path. Read the ACL with `GET /v1/mailboxes/{path}/acl`. +- A rule that was added from JavaScript with a `RegExp` in `match` shows `{}` in its place, as JSON has no form for it. Rules added over REST keep the string they were given. + +## Waiting for an event from Python + +The standard library is enough. Start the server with `imapkit -p 1143 --plugin=IDLE --rest-port=8143`: + +```python +import json +import urllib.request + + +def events(base, types): + """Yields (type, data) for every event of the stream""" + response = urllib.request.urlopen(base + "/v1/events?types=" + ",".join(types)) + event_type, data = None, [] + for raw in response: + line = raw.decode("utf-8").rstrip("\r\n") + if line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("data:"): + data.append(line[5:].strip()) + elif line == "" and event_type: + yield event_type, json.loads("\n".join(data)) + event_type, data = None, [] + + +for event_type, data in events("http://127.0.0.1:8143", ["session"]): + print(event_type, data["type"], data["session"]["mailbox"]) + if data["type"] == "select" and data["session"]["mailbox"] == "INBOX": + print("session", data["session"]["session"], "selected INBOX") + break +``` + +Output while a client logs in and selects INBOX: + +``` +session open None +session login None +session select INBOX +session 1 selected INBOX +``` + +In a test, run the generator in a thread (or open the stream before the client starts and read it after), so that the stream is open before the client acts. + +## Waiting for an event from JavaScript + +Node.js 20 and newer have `fetch()`, no packages needed. This waits until a client idles and then delivers a message, which the idling client sees as `* 1 EXISTS` right away: + +```javascript +async function waitForEvent(base, types, predicate) { + const response = await fetch(`${base}/v1/events?types=${types.join(',')}`); + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + let buffer = ''; + for (;;) { + const { value, done } = await reader.read(); + if (done) { + throw new Error('Event stream closed'); + } + buffer += value; + let end; + while ((end = buffer.indexOf('\n\n')) >= 0) { + const block = buffer.slice(0, end); + buffer = buffer.slice(end + 2); + const type = /^event: (.*)$/m.exec(block)?.[1]; + const data = /^data: (.*)$/m.exec(block)?.[1]; + if (type && data && predicate(type, JSON.parse(data))) { + await reader.cancel(); + return JSON.parse(data); + } + } + } +} + +const base = 'http://127.0.0.1:8143'; +const event = await waitForEvent(base, ['session'], (type, data) => data.type === 'waiting' && data.command === 'IDLE'); +console.log('session %d is idling', event.session.session); +const response = await fetch(`${base}/v1/mailboxes/INBOX/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ raw: 'Subject: new\r\n\r\nHello\r\n' }) +}); +console.log(response.status, await response.json()); +``` + +``` +session 1 is idling +201 { uid: 1, uidvalidity: 1 } +``` + +Any Server-Sent Events client library works as well, the stream is plain SSE. A test that runs in Node.js against an in-process server can listen to the [server events](../control-api/events.md) directly instead. + +With a token, send `Authorization: Bearer ` on the stream request like on any other. diff --git a/docs/docs/rest-api/overview.md b/docs/docs/rest-api/overview.md new file mode 100644 index 0000000..c6f59fb --- /dev/null +++ b/docs/docs/rest-api/overview.md @@ -0,0 +1,158 @@ +--- +title: REST API Overview +sidebar_position: 1 +description: Turn on the REST API, the control API over HTTP, and learn its security model, request and error formats, mailbox name encoding and OpenAPI document. +--- + +# REST API overview + +The REST API is the [control API](../control-api/overview.md) over HTTP, for test suites in any language. It inspects and changes the store, manages users and sessions, adds [script rules](../faults/scripted-faults.md) at runtime and streams [server events](./event-stream.md). Changes reach the connected IMAP sessions exactly as control API calls do. + +## Turning it on + +The REST API is off by default. From the command line, `--rest-port` turns it on: + +```bash +imapkit -p 1143 --rest-port=8143 +# Starting ImapKit ... +# ImapKit successfully listening on port 1143 +# REST API listening on 127.0.0.1:8143 +``` + +| Option | Environment variable | Description | +| -------------- | -------------------- | ------------------------------------------------------------ | +| `--rest-port` | `IMAPKIT_REST_PORT` | port of the REST API. Without it the REST API does not start | +| `--rest-host` | `IMAPKIT_REST_HOST` | address to listen on, default `127.0.0.1` | +| `--rest-token` | `IMAPKIT_REST_TOKEN` | bearer token that every request must send | + +A config file given with `--config` can hold the same settings as `"rest": { "port": 8143, "host": "127.0.0.1", "token": "..." }`. See [Command line](../getting-started/command-line.md). + +From Node.js, use the `rest` option together with `server.start()`: + +```javascript +import imapkit from 'imapkit'; + +const server = imapkit({ rest: { port: 8143 } }); +const imapPort = await server.start(); +const restPort = server.restServer.address().port; // useful with port: 0 +``` + +`rest` is `{ port, host, token }` with the same meaning as the command line options. `port: 0` picks a free port. Only `server.start()` starts the REST API, `server.listen()` does not. `server.stop()` and `control.shutdown()` close it. + +Then call it with any HTTP client: + +```bash +curl -X POST http://127.0.0.1:8143/v1/mailboxes/INBOX/messages \ + -H 'Content-Type: application/json' \ + -d '{"raw": "Subject: hello\r\n\r\nHi!\r\n", "flags": ["\\Seen"]}' +# {"uid":1,"uidvalidity":1} +``` + +## Security model + +The REST API controls the whole server, including the mail of every user, so it is careful by default. Do not expose it outside a test environment. + +- **Loopback by default.** It listens on `127.0.0.1` unless `host` says otherwise. +- **Token for other addresses.** Any address that is not a loopback one (`localhost`, `127.0.0.0/8`, `::1`) needs a token, otherwise `start()` fails: + + ``` + Failed to start ImapKit: The REST API controls the whole server, it needs a token (rest.token, --rest-token) to listen on 0.0.0.0 + ``` + + An empty token is refused as well. + +- **Bearer token.** With a token, every request must send `Authorization: Bearer `. A missing or wrong token gets `401` with `WWW-Authenticate: Bearer`. The comparison takes constant time. +- **Host check without a token.** Without a token, a request must name a loopback host in its `Host` header. This stops a web page that rebinds its DNS name to `127.0.0.1` from reaching the API: + + ```bash + curl -H 'Host: evil.example' http://127.0.0.1:8143/v1/mailboxes + # 401 {"error":{"code":"UNAUTHORIZED","message":"Requests must use a loopback host name"}} + ``` + +- **JSON only, no CORS.** Every POST, and every PUT or DELETE with a body, must be `Content-Type: application/json`, otherwise the answer is `415`. A browser can not send such a request to another origin without a CORS preflight, and the REST API sends no CORS headers, so a web page can not call it. +- **Body limit.** A request body can be at most 64 MiB (`413` above that). + +With a token, the server can listen on any address, for example in a container: + +```bash +imapkit -p 1143 --rest-port=8143 --rest-host=0.0.0.0 --rest-token=sekret +curl -H 'Authorization: Bearer sekret' http://127.0.0.1:8143/v1/users +# [{"name":"testuser","xoauth2":true}] +``` + +## Requests and responses + +Request bodies are JSON objects (an array is accepted where the endpoint takes a list, like script rules). A request without a body counts as `{}`. Note that a POST always needs the `Content-Type: application/json` header, even without a body: + +```bash +curl -X POST http://127.0.0.1:8143/v1/reset -H 'Content-Type: application/json' +# {"reset":true} +``` + +Responses are JSON with `Cache-Control: no-store`. Status `200` is the default, operations that create something answer `201`, `POST /v1/shutdown` answers `202`. + +## Errors + +Every error is a JSON object with the `ImapKitError` code and message: + +```json +{ "error": { "code": "NONEXISTENT", "message": "Mailbox \"Nope\" does not exist" } } +``` + +| Status | `code` | When | +| ------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| 400 | `INVALID` | a bad argument, a body that is not JSON or not an object or array, bad URL encoding | +| 401 | `UNAUTHORIZED` | a missing or wrong token, or a host name that is not a loopback one | +| 404 | `NONEXISTENT` | the mailbox, message, user, session, rule or ACL entry does not exist | +| 404 | `NOTFOUND` | no such endpoint | +| 405 | `METHOD` | the path exists, but not with this method | +| 409 | `ALREADYEXISTS` | the mailbox or user exists already | +| 409 | other IMAP response codes: `CANNOT`, `HASCHILDREN`, `OVERQUOTA` ... | the operation is not possible in the current state | +| 413 | `TOOBIG` | the body is larger than 64 MiB, or APPENDLIMIT refused a message added with `checks: true` | +| 415 | `UNSUPPORTED` | the request is not `application/json` | +| 500 | `SERVERERROR` | an unexpected error in the server | + +```bash +curl -s -w '\n%{http_code}\n' -X DELETE http://127.0.0.1:8143/v1/mailboxes/INBOX +# {"error":{"code":"CANNOT","message":"INBOX can not be modified"}} +# 409 +curl -s -w '\n%{http_code}\n' -X PATCH http://127.0.0.1:8143/v1/mailboxes +# {"error":{"code":"METHOD","message":"Method PATCH is not allowed here"}} +# 405 +``` + +## Mailbox names in URLs + +A mailbox is its [storage name](../control-api/overview.md#mailbox-names-are-storage-names) (modified UTF-7) in the URL, URL encoded as one path segment. A `/` in the name must be `%2F`, otherwise it is a path separator: + +| Mailbox | URL | +| --------------- | ------------------------------- | +| `INBOX` | `/v1/mailboxes/INBOX` | +| `Work/Projects` | `/v1/mailboxes/Work%2FProjects` | +| `Café` | `/v1/mailboxes/Caf%26AOk-` | +| `My Mail` | `/v1/mailboxes/My%20Mail` | + +`encodeURIComponent()` in JavaScript, `urllib.parse.quote(name, safe="")` in Python and `url.PathEscape()` in Go produce the right form. In JSON bodies (`path` of `POST /v1/mailboxes`, `newPath`, `target`) names are plain strings. + +## Message sources + +Message sources are base64 in responses, with `"encoding": "base64"` next to `raw`. A request sends `raw` either as text, which is encoded as UTF-8, or as base64 with `"encoding": "base64"`. Use base64 for 8-bit or binary messages that are not valid UTF-8: + +```bash +curl -X POST http://127.0.0.1:8143/v1/mailboxes/INBOX/messages \ + -H 'Content-Type: application/json' \ + -d '{"raw": "U3ViamVjdDogYmFzZTY0DQoNCkJvZHkNCg==", "encoding": "base64"}' +# {"uid":2,"uidvalidity":1} +``` + +`"encoding"` must be `"utf-8"` (the default) or `"base64"`. The same applies to `data` of `POST /v1/sessions/{session}/inject`. + +## OpenAPI document + +`GET /v1/openapi.json` describes every endpoint of the running server as an [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) document, including the routes of the loaded plugins. Use it to generate a client, or to check which endpoints a server has: + +```bash +curl -s http://127.0.0.1:8143/v1/openapi.json | jq '.paths | keys' +``` + +The [Endpoints](./endpoints.md) page documents them all with examples. diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 500f1de..ca15eca 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -22,6 +22,11 @@ const config: Config = { onBrokenLinks: 'throw', + markdown: { + mermaid: true + }, + themes: ['@docusaurus/theme-mermaid'], + i18n: { defaultLocale: 'en', locales: ['en'] @@ -45,6 +50,7 @@ const config: Config = { ], themeConfig: { + image: 'img/social-card.png', colorMode: { respectPrefersColorScheme: true }, diff --git a/docs/package-lock.json b/docs/package-lock.json index a392970..4870cc4 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -11,6 +11,7 @@ "@docusaurus/core": "3.10.2", "@docusaurus/faster": "3.10.2", "@docusaurus/preset-classic": "3.10.2", + "@docusaurus/theme-mermaid": "^3.10.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", @@ -276,6 +277,19 @@ "node": ">= 14.0.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.1.0.tgz", + "integrity": "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.8.0", + "tinyexec": "^1.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1994,6 +2008,49 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-13.2.0.tgz", + "integrity": "sha512-bA9dvWlhAUcHkNwU+8w9yiUxJPlj/Lm3VqS7MD+NbIR/7LmpNLuK/AWNFdCo/T3SK5ND0/UHf3IJvl/Jn/i6yg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "13.2.0", + "@chevrotain/types": "13.2.0" + } + }, + "node_modules/@chevrotain/gast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-13.2.0.tgz", + "integrity": "sha512-bCARdAsG+eEbOveSXcOAC7pVxA04I1ionzeMOwIYf5NX7SaS7zgn4Dgug2BSqoL+iyKeMNSorQjsFSHJHsCNwg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "13.2.0" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-13.2.0.tgz", + "integrity": "sha512-kxpUkLtiaK09ph+dib+Yvm+3sWSnuwlo25gqfKRgTPNmF6H7uW4gVkMjaRdtnN783S9sM6vUNWh9CsxYX4QwKw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-13.2.0.tgz", + "integrity": "sha512-7qrYHRlKfWf2AvBfJi5eVOrKAkbxvMFsy/iIHTGIiApNcEecCaVIdR+mIQhyCBsTDJxBz4JWXHMo6krbhR9hVg==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-13.2.0.tgz", + "integrity": "sha512-SzKYY68rseOrb9KILVzPsl6SjkQtdWw01SRGB5aCVI5kFcHltQsOYV3JB0Ffst72DgkvHCyDQ0NLwxtmCCf97Q==", + "license": "Apache-2.0" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -3996,6 +4053,34 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@docusaurus/theme-mermaid": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.10.2.tgz", + "integrity": "sha512-Stssh5MYQJ+EdYugUXf+ZcpeJFQPKXf0KCd/SWp10o3CmXNaOoh5IEgVjVqY1e1XhQf3on4+Y4BnrMiD95E2SQ==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "mermaid": ">=11.6.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "@mermaid-js/layout-elk": "^0.1.9", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@mermaid-js/layout-elk": { + "optional": true + } + } + }, "node_modules/@docusaurus/theme-search-algolia": { "version": "3.10.2", "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.2.tgz", @@ -4194,6 +4279,23 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz", + "integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^2.0.1", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -4748,6 +4850,18 @@ "react": ">=16" } }, + "node_modules/@mermaid-js/parser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-2.0.1.tgz", + "integrity": "sha512-j37dj/h6p6pots1L7pLPPNuABEEu9WP4OYg9pW1g+nYG331j0+3canE9OagtYdytYvdSAMG+2fKU5cxep6LgjA==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~13.2.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@module-federation/error-codes": { "version": "0.22.0", "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", @@ -6150,6 +6264,259 @@ "@types/node": "*" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -6198,6 +6565,12 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", @@ -6428,6 +6801,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -6464,6 +6844,16 @@ "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "license": "ISC" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -7534,6 +7924,22 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chevrotain": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-13.2.0.tgz", + "integrity": "sha512-GTouk5YzgBxlTPCaDn5q0q2EJKkrYmnPF+HmF8v0Qo4r3iyPG7HJivkN1wthCXZcm3sIcZFpGTRovLPNdJNRCA==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "13.2.0", + "@chevrotain/gast": "13.2.0", + "@chevrotain/regexp-to-ast": "13.2.0", + "@chevrotain/types": "13.2.0", + "@chevrotain/utils": "13.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -8037,6 +8443,15 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -8374,152 +8789,678 @@ ], "license": "MIT-0" }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.1.2.tgz", + "integrity": "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==", + "license": "MIT", + "dependencies": { + "cssnano-preset-default": "^6.1.2", + "lilconfig": "^3.1.1" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-preset-advanced": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-6.1.2.tgz", + "integrity": "sha512-Nhao7eD8ph2DoHolEzQs5CfRpiEP0xa1HBdnFZ82kvqdmbwVBUr2r1QuQ4t1pi+D1ZpqpcO4T+wy/7RxzJ/WPQ==", + "license": "MIT", + "dependencies": { + "autoprefixer": "^10.4.19", + "browserslist": "^4.23.0", + "cssnano-preset-default": "^6.1.2", + "postcss-discard-unused": "^6.0.5", + "postcss-merge-idents": "^6.0.3", + "postcss-reduce-idents": "^6.0.3", + "postcss-zindex": "^6.0.2" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-preset-default": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.2.tgz", + "integrity": "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "css-declaration-sorter": "^7.2.0", + "cssnano-utils": "^4.0.2", + "postcss-calc": "^9.0.1", + "postcss-colormin": "^6.1.0", + "postcss-convert-values": "^6.1.0", + "postcss-discard-comments": "^6.0.2", + "postcss-discard-duplicates": "^6.0.3", + "postcss-discard-empty": "^6.0.3", + "postcss-discard-overridden": "^6.0.2", + "postcss-merge-longhand": "^6.0.5", + "postcss-merge-rules": "^6.1.1", + "postcss-minify-font-values": "^6.1.0", + "postcss-minify-gradients": "^6.0.3", + "postcss-minify-params": "^6.1.0", + "postcss-minify-selectors": "^6.0.4", + "postcss-normalize-charset": "^6.0.2", + "postcss-normalize-display-values": "^6.0.2", + "postcss-normalize-positions": "^6.0.2", + "postcss-normalize-repeat-style": "^6.0.2", + "postcss-normalize-string": "^6.0.2", + "postcss-normalize-timing-functions": "^6.0.2", + "postcss-normalize-unicode": "^6.1.0", + "postcss-normalize-url": "^6.0.2", + "postcss-normalize-whitespace": "^6.0.2", + "postcss-ordered-values": "^6.0.2", + "postcss-reduce-initial": "^6.1.0", + "postcss-reduce-transforms": "^6.0.2", + "postcss-svgo": "^6.0.3", + "postcss-unique-selectors": "^6.0.4" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-utils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz", + "integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.34.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", + "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" }, "engines": { - "node": ">=4" + "node": ">=12" } }, - "node_modules/cssnano": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.1.2.tgz", - "integrity": "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==", - "license": "MIT", + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", "dependencies": { - "cssnano-preset-default": "^6.1.2", - "lilconfig": "^3.1.1" + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" }, "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/cssnano" - }, - "peerDependencies": { - "postcss": "^8.4.31" + "node": ">=12" } }, - "node_modules/cssnano-preset-advanced": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-6.1.2.tgz", - "integrity": "sha512-Nhao7eD8ph2DoHolEzQs5CfRpiEP0xa1HBdnFZ82kvqdmbwVBUr2r1QuQ4t1pi+D1ZpqpcO4T+wy/7RxzJ/WPQ==", - "license": "MIT", + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", "dependencies": { - "autoprefixer": "^10.4.19", - "browserslist": "^4.23.0", - "cssnano-preset-default": "^6.1.2", - "postcss-discard-unused": "^6.0.5", - "postcss-merge-idents": "^6.0.3", - "postcss-reduce-idents": "^6.0.3", - "postcss-zindex": "^6.0.2" + "d3-path": "^3.1.0" }, "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" + "node": ">=12" } }, - "node_modules/cssnano-preset-default": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.1.2.tgz", - "integrity": "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==", - "license": "MIT", + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", "dependencies": { - "browserslist": "^4.23.0", - "css-declaration-sorter": "^7.2.0", - "cssnano-utils": "^4.0.2", - "postcss-calc": "^9.0.1", - "postcss-colormin": "^6.1.0", - "postcss-convert-values": "^6.1.0", - "postcss-discard-comments": "^6.0.2", - "postcss-discard-duplicates": "^6.0.3", - "postcss-discard-empty": "^6.0.3", - "postcss-discard-overridden": "^6.0.2", - "postcss-merge-longhand": "^6.0.5", - "postcss-merge-rules": "^6.1.1", - "postcss-minify-font-values": "^6.1.0", - "postcss-minify-gradients": "^6.0.3", - "postcss-minify-params": "^6.1.0", - "postcss-minify-selectors": "^6.0.4", - "postcss-normalize-charset": "^6.0.2", - "postcss-normalize-display-values": "^6.0.2", - "postcss-normalize-positions": "^6.0.2", - "postcss-normalize-repeat-style": "^6.0.2", - "postcss-normalize-string": "^6.0.2", - "postcss-normalize-timing-functions": "^6.0.2", - "postcss-normalize-unicode": "^6.1.0", - "postcss-normalize-url": "^6.0.2", - "postcss-normalize-whitespace": "^6.0.2", - "postcss-ordered-values": "^6.0.2", - "postcss-reduce-initial": "^6.1.0", - "postcss-reduce-transforms": "^6.0.2", - "postcss-svgo": "^6.0.3", - "postcss-unique-selectors": "^6.0.4" + "d3-array": "2 - 3" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" }, - "peerDependencies": { - "postcss": "^8.4.31" + "engines": { + "node": ">=12" } }, - "node_modules/cssnano-utils": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-4.0.2.tgz", - "integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==", - "license": "MIT", + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", "engines": { - "node": "^14 || ^16 || >=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" + "node": ">=12" } }, - "node_modules/csso": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", - "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", - "license": "MIT", + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", "dependencies": { - "css-tree": "~2.2.0" + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" } }, - "node_modules/csso/node_modules/css-tree": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", - "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", - "license": "MIT", + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", "dependencies": { - "mdn-data": "2.0.28", - "source-map-js": "^1.0.1" + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" + "node": ">=12" } }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.28", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", - "license": "CC0-1.0" + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "license": "MIT" }, "node_modules/debounce": { @@ -8683,6 +9624,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -8829,6 +9779,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -8915,6 +9874,12 @@ "integrity": "sha512-vUNKPrUzw8ByEMOxloq0a71iD/mPuPxVkbDm3G5YhnxTzOw/I6qAg2MUS0cYj/c267lWmw26yW4Vv7foxTxPLg==", "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -9025,6 +9990,18 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/esast-util-from-estree": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", @@ -9938,6 +10915,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -10571,6 +11554,16 @@ "node": ">=8" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -10619,6 +11612,15 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -11124,6 +12126,31 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -11133,6 +12160,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -11176,6 +12208,12 @@ "shell-quote": "^1.10.0" } }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -11499,6 +12537,12 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -11591,6 +12635,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -12142,6 +13198,53 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-12.1.0.tgz", + "integrity": "sha512-wlVCp+8eTupfCeeFvoZNNiTuHrvag0P2jz/ILgb/f/6jkVokUefOcujefi8qUe/j2asHiSePncVsz/xzzA80LQ==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^2.0.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "chevrotain": "~13.2.0", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.21", + "dompurify": "^3.4.12", + "elkjs": "^0.9.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.47", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/mermaid/node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -14624,6 +15727,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-manager-detector": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", + "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", + "license": "MIT" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -14751,6 +15860,12 @@ "tslib": "^2.0.3" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", @@ -14852,6 +15967,22 @@ "node": ">=16.0.0" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", @@ -17278,6 +18409,24 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/rtlcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz", @@ -17331,6 +18480,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -18157,6 +19312,12 @@ "postcss": "^8.4.31" } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -18397,6 +19558,15 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -18472,6 +19642,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/docs/package.json b/docs/package.json index 8098b98..69d2d09 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,6 +17,7 @@ "@docusaurus/core": "3.10.2", "@docusaurus/faster": "3.10.2", "@docusaurus/preset-classic": "3.10.2", + "@docusaurus/theme-mermaid": "^3.10.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 466ed69..0531c8e 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -7,7 +7,89 @@ const sidebars: SidebarsConfig = { type: 'category', label: 'Getting Started', collapsed: false, - items: ['getting-started/installation', 'getting-started/quick-start'] + items: ['getting-started/installation', 'getting-started/quick-start', 'getting-started/command-line'] + }, + { + type: 'category', + label: 'Guides', + collapsed: false, + items: [ + 'guides/writing-client-tests', + 'guides/storage', + 'guides/authentication', + 'guides/strict-by-design', + 'guides/multiple-sessions', + 'guides/testing-from-other-languages' + ] + }, + { + type: 'category', + label: 'Fault Injection', + items: ['faults/scripted-faults', 'faults/quirk-presets', 'faults/repeatable-tests'] + }, + { + type: 'category', + label: 'Control API', + items: [ + 'control-api/overview', + 'control-api/mailboxes-and-messages', + 'control-api/uidvalidity', + 'control-api/users-and-sessions', + 'control-api/events', + 'control-api/plugin-operations' + ] + }, + { + type: 'category', + label: 'REST API', + items: ['rest-api/overview', 'rest-api/endpoints', 'rest-api/event-stream'] + }, + { + type: 'category', + label: 'Extensions', + items: [ + 'extensions/overview', + 'extensions/imap4rev2', + 'extensions/search-and-sort', + 'extensions/synchronization', + 'extensions/mailboxes', + 'extensions/access-control', + 'extensions/metadata-and-quota', + 'extensions/messages', + 'extensions/authentication-and-transport', + 'extensions/gmail' + ] + }, + { + type: 'category', + label: 'Reference', + items: ['reference/server-options', 'reference/server-api', 'reference/custom-plugins', 'reference/migrating-from-4', 'reference/known-issues'] + }, + { + type: 'category', + label: 'Contributing', + items: ['contributing/running-tests', 'contributing/comparing-with-dovecot'] + }, + { + type: 'html', + value: ` + + `, + defaultStyle: false } ] }; diff --git a/docs/src/components/HomepageFeatures/index.tsx b/docs/src/components/HomepageFeatures/index.tsx index 3a9c2bc..5a26f9d 100644 --- a/docs/src/components/HomepageFeatures/index.tsx +++ b/docs/src/components/HomepageFeatures/index.tsx @@ -1,45 +1,79 @@ import type { ReactNode } from 'react'; import clsx from 'clsx'; +import Link from '@docusaurus/Link'; +import useBaseUrl from '@docusaurus/useBaseUrl'; import Heading from '@theme/Heading'; import styles from './styles.module.css'; type FeatureItem = { title: string; + image: string; + link: string; description: ReactNode; }; const FeatureList: FeatureItem[] = [ { title: 'Strict by design', + image: '/img/features/strict.svg', + link: '/docs/guides/strict-by-design', description: ( <> - IMAP4rev1 and IMAP4rev2 with more than 50 extensions. Client input that breaks the RFCs gets BAD or NO, so client bugs - show up in your tests, not in production. + Client input that breaks the RFCs gets BAD or NO, so client bugs show up in your tests instead of against a production + server. ) }, { - title: 'Controlled from your tests', + title: '50+ extensions', + image: '/img/features/extensions.svg', + link: '/docs/extensions/overview', description: ( <> - Add messages, change flags, reset UIDVALIDITY and disconnect sessions with server.control, or over the optional REST API from any - language. + IMAP4rev1 and IMAP4rev2 with IDLE, CONDSTORE, QRESYNC, NOTIFY, ACL, METADATA, QUOTA, SORT, THREAD, COMPRESS and many more, turned on per server. ) }, { - title: 'Misbehaves on purpose', - description: <>Script rules and quirk presets reproduce the bugs of real servers: late responses, split output, throttling and dropped connections. + title: 'Control API', + image: '/img/features/control.svg', + link: '/docs/control-api/overview', + description: ( + <> + Add messages, change flags, reset UIDVALIDITY and disconnect sessions with server.control. Connected clients see every change. + + ) + }, + { + title: 'Scripted faults', + image: '/img/features/faults.svg', + link: '/docs/faults/scripted-faults', + description: ( + <>Script rules and quirk presets reproduce what real servers do: late responses, split output, throttling, autologout and dropped connections. + ) + }, + { + title: 'Multiple sessions', + image: '/img/features/sessions.svg', + link: '/docs/guides/multiple-sessions', + description: <>Several clients on one mailbox get EXISTS, EXPUNGE and flag updates at the points the RFCs allow, following RFC 2180. + }, + { + title: 'Any language', + image: '/img/features/rest.svg', + link: '/docs/rest-api/overview', + description: <>Run the imapkit command with the REST API and server events, and drive it from Python, Go or any other test suite. } ]; -function Feature({ title, description }: FeatureItem) { +function Feature({ title, image, link, description }: FeatureItem) { return ( -
-
+
+ + {title} -

{description}

-
+ +

{description}

); } @@ -49,8 +83,8 @@ export default function HomepageFeatures(): ReactNode {
- {FeatureList.map((props, idx) => ( - + {FeatureList.map(props => ( + ))}
diff --git a/docs/src/components/HomepageFeatures/styles.module.css b/docs/src/components/HomepageFeatures/styles.module.css index e25342f..7220309 100644 --- a/docs/src/components/HomepageFeatures/styles.module.css +++ b/docs/src/components/HomepageFeatures/styles.module.css @@ -1,6 +1,24 @@ .features { - display: flex; - align-items: center; - padding: 2rem 0; - width: 100%; + padding: 3rem 0 1rem; +} + +.feature { + margin-bottom: 2rem; + text-align: center; + padding: 0 1.5rem; +} + +.featureLink { + color: inherit; +} + +.featureLink:hover { + text-decoration: none; + color: var(--ifm-color-primary); +} + +.featureImage { + width: 80px; + height: 80px; + margin-bottom: 0.75rem; } diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index 15fb320..17cb3e1 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -21,3 +21,37 @@ --ifm-color-primary-lightest: #4fddbf; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); } + +.sidebar-ecosystem { + margin: 24px 12px 12px; + padding-top: 14px; + border-top: 1px solid var(--ifm-toc-border-color); +} + +.sidebar-ecosystem__title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ifm-color-emphasis-600); + margin-bottom: 10px; +} + +.sidebar-ecosystem a { + display: block; + margin-bottom: 12px; + color: var(--ifm-menu-color); + text-decoration: none; +} + +.sidebar-ecosystem strong { + display: block; + font-size: 14px; +} + +.sidebar-ecosystem span { + display: block; + font-size: 12px; + line-height: 1.45; + color: var(--ifm-color-emphasis-700); +} diff --git a/docs/src/pages/index.module.css b/docs/src/pages/index.module.css index 5e0b763..9815b79 100644 --- a/docs/src/pages/index.module.css +++ b/docs/src/pages/index.module.css @@ -1,27 +1,81 @@ .heroBanner { padding: 4rem 0; - text-align: center; - position: relative; overflow: hidden; } -@media screen and (max-width: 996px) { - .heroBanner { - padding: 2rem; - } +.heroGrid { + display: flex; + align-items: center; + gap: 3rem; +} + +.heroText { + flex: 1 1 0; + text-align: left; +} + +.heroImage { + flex: 1 1 0; + max-width: 560px; + width: 100%; } .buttons { display: flex; - align-items: center; - justify-content: center; + flex-wrap: wrap; + gap: 1rem; +} + +.outlineButton { + color: #ffffff; + border-color: #ffffff; +} + +.outlineButton:hover { + color: var(--ifm-color-primary-darkest); + background: #ffffff; } .install { display: inline-block; - margin: 1.5rem auto 0; + margin-top: 1.5rem; padding: 0.5rem 1rem; background: rgba(0, 0, 0, 0.25); - color: #fff; + color: #ffffff; border: none; + font-size: 1rem; +} + +@media screen and (max-width: 996px) { + .heroBanner { + padding: 2.5rem 1rem; + } + + .heroGrid { + flex-direction: column; + } + + .heroText { + text-align: center; + } + + .buttons { + justify-content: center; + } +} + +.codeSection { + padding: 3rem 0; + border-top: 1px solid var(--ifm-toc-border-color); +} + +.banner { + padding: 2.5rem 0; + border-top: 1px solid var(--ifm-toc-border-color); +} + +.bannerInner { + max-width: 720px; + margin: 0 auto; + text-align: center; } diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index 34cdbf7..09d204b 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -1,42 +1,122 @@ import type { ReactNode } from 'react'; import clsx from 'clsx'; import Link from '@docusaurus/Link'; +import useBaseUrl from '@docusaurus/useBaseUrl'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import Layout from '@theme/Layout'; +import CodeBlock from '@theme/CodeBlock'; import HomepageFeatures from '@site/src/components/HomepageFeatures'; import Heading from '@theme/Heading'; import styles from './index.module.css'; +const EXAMPLE = `import imapkit from 'imapkit'; + +const server = imapkit({ plugins: ['IDLE', 'CONDSTORE', 'QRESYNC'] }); +const port = await server.start(); + +// ... connect your IMAP client to 127.0.0.1:port as testuser / testpass + +// change the server while the client is connected +server.control.addMessage('INBOX', { raw: 'Subject: hello\\r\\n\\r\\nHi!\\r\\n' }); +server.control.resetUidValidity('INBOX', { uids: 'shuffle', seed: 1 }); + +// and make it misbehave like real servers do +server.script.add({ on: 'command', command: 'SELECT', times: 1, send: '$TAG NO [UNAVAILABLE] Try again\\r\\n' }); + +await server.stop();`; + function HomepageHeader() { const { siteConfig } = useDocusaurusContext(); return (
-
- - {siteConfig.title} - -

{siteConfig.tagline}

-
- - Get started - +
+
+ + {siteConfig.title} + +

{siteConfig.tagline}

+
+ + Quick Start + + + Documentation + +
+ npm install --save-dev imapkit
-
npm install --save-dev imapkit
+ A test drives the ImapKit server, which pushes changes to IMAP clients
); } +function CodeSection() { + return ( +
+
+
+
+ A real IMAP server inside your test +

+ ImapKit runs in the same process as your test suite, starts in milliseconds and keeps nothing on disk. Every test gets a fresh + server with exactly the mailboxes, messages, users and extensions it needs. +

+

+ Your client talks real IMAP over a real socket, while the test changes the server underneath it and checks that the client keeps up. +

+ Writing client tests → +
+
+ + {EXAMPLE} + +
+
+
+
+ ); +} + +function EmailEngineBanner() { + return ( +
+
+
+ Need a production email integration, not a mock server? +

+ + EmailEngine + {' '} + is a self-hosted email API by the team behind ImapKit and ImapFlow. It turns Gmail, Microsoft 365 + and IMAP accounts into REST endpoints, with managed OAuth2 and webhooks for incoming mail. +

+
+
+
+ ); +} + export default function Home(): ReactNode { return (
+ +
); diff --git a/docs/static/img/features/control.svg b/docs/static/img/features/control.svg new file mode 100644 index 0000000..10bbad8 --- /dev/null +++ b/docs/static/img/features/control.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/static/img/features/extensions.svg b/docs/static/img/features/extensions.svg new file mode 100644 index 0000000..6e20c96 --- /dev/null +++ b/docs/static/img/features/extensions.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/static/img/features/faults.svg b/docs/static/img/features/faults.svg new file mode 100644 index 0000000..ee3d2e9 --- /dev/null +++ b/docs/static/img/features/faults.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/static/img/features/rest.svg b/docs/static/img/features/rest.svg new file mode 100644 index 0000000..1d5bea8 --- /dev/null +++ b/docs/static/img/features/rest.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/static/img/features/sessions.svg b/docs/static/img/features/sessions.svg new file mode 100644 index 0000000..98b2d08 --- /dev/null +++ b/docs/static/img/features/sessions.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/static/img/features/strict.svg b/docs/static/img/features/strict.svg new file mode 100644 index 0000000..d5075e0 --- /dev/null +++ b/docs/static/img/features/strict.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/static/img/hero.svg b/docs/static/img/hero.svg new file mode 100644 index 0000000..b349ee0 --- /dev/null +++ b/docs/static/img/hero.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + client.test.js + + constport = + await server.start() + server.control + .addMessage(...) + server.script.add( + { on: 'response' }) + + + + + + 12 tests passed + + + + + + + + + + + + + + + + IMAP + + + + + + + + + + + + + + + * 3 EXISTS + + + + + * 2 EXPUNGE + + + + + A1 BAD + + diff --git a/docs/static/img/logo.svg b/docs/static/img/logo.svg index d5802a2..87efdab 100644 --- a/docs/static/img/logo.svg +++ b/docs/static/img/logo.svg @@ -1,4 +1,6 @@ - - + + + + diff --git a/docs/static/img/social-card.png b/docs/static/img/social-card.png new file mode 100644 index 0000000000000000000000000000000000000000..b54a552a1cd536f349534dea0d07c2740d39754b GIT binary patch literal 47891 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

X@YZ$RlCuKtXpiBV<|LadmzNp{trgtt zqlwyo1qAzbGH{V28wl&zMZE81+k7f(p0R;uU8)N%TA@U!DW|Un0o@JHWT&#RJ!CVL z{p)ArF_yGCM+C}gygq-S5!UlpeKAqGK+d>)$B!I6?!LXH1ZVPzVmIDNzMP=6SA>?{ z+`4o999(4$+=K2LiN8y&*8YT6ux2o}ojBzw1W$a0xgLiX_7Vrb zTI;pn_$73{Z*mNr*6tMu3Z)^M9*rg+LGJb<=gq>=V1k=wmT`IpmsM1SvPFiuf^wJD zZ#~!38=~KaS)Fdi-g#i;T)I7+WfVuq*~^`p?c~Wa$Tm)6&sP zhhDOIiq~zpCZEmS#F31O7t`VP%^4(vPZ>!g3|Yr9-jobI&hErlRL5V+bhaerXB(St zoNTS9>(c~}-}I6jvHy5DTG?;IbClQv%ff;5OmO25upR|Iy|f`Dl*11KN%k$i&f_i7MQO5KZR9Xy6;LA3k6>< zo0!Y8RQxyzwYosA3F!-x9ln6blZXDz&LNfirpJ0M+a`W@4GmK{0k9150TVtNX{krnM) z-hNXg=`HD=O4aR~ZRs`*gvzIrsxECDErtFkMR>_OCYoW?>kJoW4qvM8aLrtrHnfG6 zNjWVR%dek2`=K;dp*wY0->bN7hI zpx-pRBCpf91-7u8x+)ND!s|Dp84;+Wshe6L8&>u;YTUTN%pwxbn!l=YnY|Qn8T|0U384_V=i=beU1E+9&dAmYiiF*@ z*^6b;ViJeD^^oH~tCU3hEE>W#=>F}~rR*vZ%jho#!|Xpi@Rdfa*kB_@T**6*ovIZ| z7X2#K!5RcMLSCpMol=XsT>ZkQZPm(PI||-F_N*;{*her+TK?K@BuBtS19IM83388t zj%)IPe=pvC+yR_eL;jq%jC7)0#tTP>uLarRxadZc`qr5RS57i0mZxOz{y-DN(9?7f zbXyooge6y8oDDuQNUGz2Y@2R?b&Bs~i%)C6fY{k$@vZ13c_D)w=UQox%QtG~X20wN z;0_}JcgeEj;?15}BN{LN23n%>WnM48wO^>mym7lRz$1j9rLFM%+zEA#z3tIeG{l6s zD1<%6HM?0H_;WYtAvGUOKjI|jeie1_be9Cl)_~U9)Df2r)Osc(S!^ff^44T9c|;}n zsEBS`M3aMkva%`wZW&aX)Tj1)awgw-kQ9sBu^Z;sicCx07xT?Zh(5|gSf`m5pC8HF zOjX#B%H%L{}Xm@_d8oGZDTl$MKkkrq^ttg3_Pp3}h>tJNQr8Bir(l7ab z){WvAAy&&_d9quHB1+PJMP>2=CdY+B$F8wWGTi8*w8XiW`aJ<5?l*0cK9{X}ih3A! z#c!6Z>$-rZWy#&e^wR08nNG(7@p_qwI+yW5^^rqe4OfbR-EoJoH^w!=kWus4!2NxW z=&5w|5>J4xkd8s3Z#GGD)kT5P=JBSgIwHEpxkA5Yv`PpM#*D{P-YY%ZD_oXop~$Ds z?}p2q(jhPZI|~S;ZFbI+s3pa2N)Tss(Zx!m2t4dBn9_@t)1xS;Yz>^OTyRxfA;JmQ zFOn1Kdm)fwk;Ua! zDosz6WZ*fcV|Z4yf;oA1wtK72+li*yH09uhUZtAF5fBiQ(2I?f5h(!AXcE7ly62Wr z+sZ7&EYCUVo1($f9er`HzJG_}$J_DFL8AKD9}4+9n_R5T@*g^WGLf3uMw_$QeFcL^ zQ3@v~lWQYF)gfPebpGz9Dx1inJLw-X)#3VeWn*hY|}rsE9INcQE{Fg;!!iYkL_7EWoUw4-mLp)VQ&Md92F4Hr91>Ud^bq zI=$Fdi;bWpK0n^=w;QMliq74&-{8TgSdO1goO0}(m

8h=gvVL6M){%QK?<=3noB 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/social-card.svg b/docs/static/img/social-card.svg new file mode 100644 index 0000000..f064f98 --- /dev/null +++ b/docs/static/img/social-card.svg @@ -0,0 +1,15 @@ + + + + + + + + + + ImapKit + In-memory IMAP server for testing IMAP clients + npm install --save-dev imapkit + imapkit.com + IMAP4rev1 · IMAP4rev2 · 50+ extensions · scripted faults + diff --git a/src/control.ts b/src/control.ts index 85696f3..44641d4 100644 --- a/src/control.ts +++ b/src/control.ts @@ -4,7 +4,7 @@ * unsolicited FETCH, BYE, NOTIFY events, CONDSTORE mod-sequences), with no session as its origin. The API is the * operator: ACL does not apply, but every argument is checked. Mailboxes are addressed by their storage name * (modified UTF-7), messages by mailbox and UID. Methods return plain data and throw an ImapKitError with a `code`: - * NONEXISTENT, ALREADYEXISTS, INVALID, or a RFC 5530 code of a failed mailbox operation (CANNOT, HASCHILDREN ...) + * NONEXISTENT, ALREADYEXISTS, INVALID, or the response code of a failed mailbox operation (CANNOT, HASCHILDREN ...) */ import validateMailboxName from './mailbox-name.js'; @@ -37,7 +37,7 @@ interface MailboxInfo { unseen: number; uidnext: number; uidvalidity: number; - /** flags that can be stored, \* when new keywords are allowed */ + /** the flags of the FLAGS response, SELECT adds \* to PERMANENTFLAGS when new keywords are allowed */ permanentFlags: string[]; /** only with CONDSTORE */ highestModseq?: number; diff --git a/src/store-operations.ts b/src/store-operations.ts index ad57823..f39c618 100644 --- a/src/store-operations.ts +++ b/src/store-operations.ts @@ -264,7 +264,7 @@ function createMailbox(server: IMAPServer, path: string): Mailbox { * * @param {Object} server IMAP server * @param {String} path Storage name of the mailbox - * @throws {Error} with a RFC 5530 `code` (NONEXISTENT, CANNOT, HASCHILDREN ...) + * @throws {Error} with a response `code` (NONEXISTENT, CANNOT, HASCHILDREN ...) */ function deleteMailbox(server: IMAPServer, path: string): void { const mailbox = server.getMailbox(path);