@blamejs/core 0.9.45 → 0.9.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1064 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mail.server.imap
4
+ * @nav Mail
5
+ * @title Mail IMAP Server
6
+ * @order 546
7
+ *
8
+ * @intro
9
+ * IMAP4rev2 mailbox-access listener (RFC 9051; obsoletes RFC 3501).
10
+ * Modern MUAs (Thunderbird, Apple Mail, mutt, K-9, FairEmail,
11
+ * etc.) connect here to read + manage messages without operators
12
+ * running dovecot / cyrus alongside. Composes the framework's
13
+ * existing substrates:
14
+ *
15
+ * - `b.guardImapCommand` for wire-protocol shape + smuggling
16
+ * defense (literal-injection, bare-CR/LF refusal, per-verb
17
+ * shape, RFC 9051 §2.2.2 literal framing)
18
+ * - `b.mail.server.rateLimit` for per-IP DoS defense (concurrent
19
+ * + rate + AUTH-failure budget + slow-loris)
20
+ * - `b.mailStore` (operator-supplied backend) for the actual
21
+ * mail storage + UIDVALIDITY + modseq tracking
22
+ * - operator-supplied authenticator for SASL credential verify
23
+ * - `b.mail.server.tls` recommended for cert + key loading +
24
+ * rotation
25
+ *
26
+ * ## State machine (RFC 9051 §3)
27
+ *
28
+ * ```
29
+ * NOT-AUTHENTICATED → [STARTTLS → NOT-AUTH-TLS] → AUTH/LOGIN →
30
+ * AUTHENTICATED ↔ SELECTED → LOGOUT
31
+ * ↑ EXAMINE ↓ CLOSE / UNSELECT
32
+ * ```
33
+ *
34
+ * Commands gated by state:
35
+ *
36
+ * - NOT-AUTHENTICATED: STARTTLS / AUTHENTICATE / LOGIN / NOOP /
37
+ * CAPABILITY / LOGOUT / ID
38
+ * - AUTHENTICATED: SELECT / EXAMINE / CREATE / DELETE / RENAME /
39
+ * SUBSCRIBE / UNSUBSCRIBE / LIST / STATUS / APPEND / NAMESPACE /
40
+ * IDLE / ENABLE / NOOP / CAPABILITY / LOGOUT / ID
41
+ * - SELECTED: CHECK / CLOSE / UNSELECT / EXPUNGE / SEARCH / FETCH /
42
+ * STORE / COPY / MOVE / UID … / IDLE / NOOP / CAPABILITY /
43
+ * LOGOUT + every AUTHENTICATED command
44
+ *
45
+ * Tagged response model: every client command carries a tag
46
+ * (`A001 LOGIN …`); server replies with one or more untagged
47
+ * responses (`* …`) then `A001 OK …` / `A001 NO …` / `A001 BAD …`.
48
+ *
49
+ * ## Wire-protocol defenses
50
+ *
51
+ * - **STARTTLS stripping (CVE-2021-33515 Dovecot class)** —
52
+ * STARTTLS upgrade clears pre-handshake receive buffer; any
53
+ * pipelined command queued before TLS is refused with
54
+ * `BAD Pipelined post-STARTTLS not permitted`.
55
+ *
56
+ * - **Literal-injection (CVE-2018-19518 INC IMAP class)** —
57
+ * `{n}` literal continuation MUST come on a line of its own
58
+ * (per `b.guardImapCommand.detectLiteralSmuggling`); oversize
59
+ * literals refused (default 64 MiB); LITERAL+ (RFC 7888) non-
60
+ * synchronizing literals only honored post-AUTH.
61
+ *
62
+ * - **Mailbox-name traversal** — mailbox path components
63
+ * validated through `_validateMailboxName`: refuses `..`, NUL,
64
+ * control chars, oversize. UTF-8 mailbox names (RFC 9051 §5.1)
65
+ * accepted; modified-UTF7 (RFC 3501 §5.1.3 legacy) refused unless
66
+ * `allowLegacyMUtf7: true`.
67
+ *
68
+ * - **APPEND-flood** — per-tenant byte/sec cap surfaces via the
69
+ * `b.mail.server.rateLimit`'s `minBytesPerSecond` floor on the
70
+ * APPEND-literal-body phase (same shape the MX listener uses for
71
+ * DATA-body).
72
+ *
73
+ * - **Resource exhaustion** — per-line cap (default 8 KiB sans
74
+ * literal payload), per-literal cap (64 MiB), per-connection idle
75
+ * cap (default 30 min when not in IDLE; IDLE itself capped at
76
+ * 29 min per RFC 2177 §3 to force re-issue).
77
+ *
78
+ * - **Connection-rate + AUTH-failure budget** — composes
79
+ * `b.mail.server.rateLimit`. Each AUTH failure increments the
80
+ * budget; trip the cap and new AUTH attempts get
81
+ * `* BAD Too many AUTH failures` + connection close.
82
+ *
83
+ * ## Audit lifecycle
84
+ *
85
+ * - `mail.server.imap.connect` — IP, TLS state
86
+ * - `mail.server.imap.auth_attempt` — mechanism, actor-hash
87
+ * - `mail.server.imap.auth_success` — mechanism, tenantId, scopes
88
+ * - `mail.server.imap.auth_failed` — mechanism, reason
89
+ * - `mail.server.imap.select` — mailbox, modseq, exists count
90
+ * - `mail.server.imap.append` — mailbox, size, flags
91
+ * - `mail.server.imap.fetch_bulk` — sequence-set size, BODY parts
92
+ * - `mail.server.imap.expunge` — count, modseq
93
+ * - `mail.server.imap.literal_overflow_refused` — attempt size, cap
94
+ * - `mail.server.imap.rate_limit_refused` — IP, reason
95
+ * - `mail.server.imap.smtp_smuggling_detected` — literal-injection
96
+ *
97
+ * ## What v1 does NOT ship
98
+ *
99
+ * - **SEARCH** — operator wires `opts.search(actor, mailbox, query)`
100
+ * when ready; the listener emits `BAD search-not-configured`
101
+ * until then. SEARCH expressions are operator-domain logic
102
+ * against the mailStore index.
103
+ * - **NOTIFY (RFC 5465)**, **METADATA (RFC 5464)**, **CATENATE
104
+ * (RFC 4469)**, **URLAUTH (RFC 4467)**, **IMAPSIEVE (RFC 6785)**,
105
+ * **COMPRESS=DEFLATE (RFC 4978)** — opt-in / refused.
106
+ * - **CONDSTORE / QRESYNC (RFC 7162)** — modseq is exposed via
107
+ * STATUS but per-FETCH CHANGEDSINCE delta is operator-side
108
+ * follow-up.
109
+ *
110
+ * @card
111
+ * IMAP4rev2 mailbox-access listener (RFC 9051; obsoletes RFC 3501).
112
+ * State machine NOT-AUTH → STARTTLS → AUTH → SELECTED → LOGOUT.
113
+ * Composes b.guardImapCommand (wire-protocol gate), b.mail.server.
114
+ * rateLimit (DoS defense), operator-supplied mailStore + SASL
115
+ * authenticator. Default-on per-IP rate-limit + literal-injection
116
+ * refusal + mailbox-traversal refusal.
117
+ */
118
+
119
+ var net = require("node:net");
120
+ var nodeTls = require("node:tls");
121
+ var lazyRequire = require("./lazy-require");
122
+ var C = require("./constants");
123
+ var bCrypto = require("./crypto");
124
+ var numericBounds = require("./numeric-bounds");
125
+ var validateOpts = require("./validate-opts");
126
+ var guardImapCommand = require("./guard-imap-command");
127
+ var mailServerRateLimit = require("./mail-server-rate-limit");
128
+ var { defineClass } = require("./framework-error");
129
+
130
+ var audit = lazyRequire(function () { return require("./audit"); });
131
+
132
+ var MailServerImapError = defineClass("MailServerImapError", { alwaysPermanent: true });
133
+
134
+ var DEFAULT_MAX_LINE_BYTES = C.BYTES.kib(8);
135
+ var DEFAULT_MAX_LITERAL = C.BYTES.mib(64);
136
+ var DEFAULT_IDLE_TIMEOUT_MS = C.TIME.minutes(30);
137
+ var IDLE_BANDWIDTH_TIMEOUT_MS = C.TIME.minutes(29); // RFC 2177 §3 — re-issue before 30
138
+ var DEFAULT_GREETING_VENDOR = "blamejs IMAP4rev2";
139
+ var pkgVersion = require("../package.json").version;
140
+
141
+ // Error-message clamp bytes — protocol-string clamp, not a byte count.
142
+ // Centralized so the allow:raw-byte-literal marker lives in one place
143
+ // and the per-call sites read cleanly.
144
+ var ERR_CLAMP = 200; // allow:raw-byte-literal — protocol-reply error-message clamp
145
+ var LINE_PREVIEW = 80; // allow:raw-byte-literal — audit-line preview clamp
146
+
147
+ // Mailbox name validator. RFC 9051 §5.1 — UTF-8 hierarchy. Refuse
148
+ // path-traversal (`..`), NUL, C0 controls, leading/trailing slash,
149
+ // oversize.
150
+ function _validateMailboxName(name, opts) {
151
+ if (typeof name !== "string" || name.length === 0) return false;
152
+ if (name.length > 1024) return false; // allow:raw-byte-literal — mailbox name cap
153
+ for (var i = 0; i < name.length; i += 1) {
154
+ var c = name.charCodeAt(i);
155
+ if (c < 0x20 || c === 0x7F) return false; // allow:raw-byte-literal — control-byte refusal
156
+ }
157
+ if (name.indexOf("..") !== -1) return false;
158
+ if (name === "/" || name[0] === "/" || name[name.length - 1] === "/") return false;
159
+ // Modified-UTF7 detection — RFC 3501 §5.1.3. Sequences are
160
+ // `&...-`. Refuse under strict (RFC 9051 uses raw UTF-8).
161
+ if (opts && opts.allowLegacyMUtf7 !== true) {
162
+ if (/&[A-Za-z0-9+/]*-/.test(name)) return false; // allow:regex-no-length-cap — mailbox name already length-capped above
163
+ }
164
+ return true;
165
+ }
166
+
167
+ /**
168
+ * @primitive b.mail.server.imap.create
169
+ * @signature b.mail.server.imap.create(opts)
170
+ * @since 0.9.49
171
+ * @status stable
172
+ * @related b.mail.server.mx.create, b.mail.server.submission.create, b.mailStore.create
173
+ *
174
+ * Build an IMAP4rev2 listener (RFC 9051). The handle exposes
175
+ * `listen({ port, address })` → ephemeral-bind promise resolving to
176
+ * `{ port, address }`, plus `close()` for graceful shutdown.
177
+ *
178
+ * @opts
179
+ * tlsContext: SecureContext, // required (no plaintext mode)
180
+ * greeting: string, // default "blamejs IMAP4rev2"
181
+ * maxLineBytes: number, // default 8192
182
+ * maxLiteralBytes: number, // default 64 MiB
183
+ * idleTimeoutMs: number, // default 30 min
184
+ * profile: "strict" | "balanced" | "permissive",
185
+ * auth: {
186
+ * mechanisms: ["PLAIN", "LOGIN", "SCRAM-SHA-256", "EXTERNAL", "XOAUTH2"],
187
+ * verify: async function (mechanism, credentials) → { ok, actor },
188
+ * },
189
+ * mailStore: b.mailStore handle, // required
190
+ * rateLimit: b.mail.server.rateLimit handle | opts | false,
191
+ * audit: b.audit // optional
192
+ *
193
+ * @example
194
+ * var imap = b.mail.server.imap.create({
195
+ * tlsContext: b.mail.server.tls.context({ certFile, keyFile }).secureContext,
196
+ * auth: {
197
+ * mechanisms: ["PLAIN", "SCRAM-SHA-256"],
198
+ * verify: async function (mech, creds) {
199
+ * return { ok: true, actor: { tenantId: "t1", username: creds.authzid } };
200
+ * },
201
+ * },
202
+ * mailStore: b.mailStore.create({ backend: b.db.handle() }),
203
+ * });
204
+ * await imap.listen({ port: 143 });
205
+ */
206
+ function create(opts) {
207
+ validateOpts.requireObject(opts, "mail.server.imap.create",
208
+ MailServerImapError, "mail-server-imap/bad-opts");
209
+ if (!opts.tlsContext) {
210
+ throw new MailServerImapError("mail-server-imap/no-tls-context",
211
+ "mail.server.imap.create: tlsContext is required (no implicit plaintext mode). " +
212
+ "Use b.mail.server.tls.context({ certFile, keyFile, watch: true }) to load + " +
213
+ "auto-reload a cert/key pair from disk.");
214
+ }
215
+ if (!opts.mailStore || typeof opts.mailStore.appendMessage !== "function") {
216
+ throw new MailServerImapError("mail-server-imap/no-mail-store",
217
+ "mail.server.imap.create: mailStore is required (compose b.mailStore.create({ backend: ... }))");
218
+ }
219
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
220
+ ["maxLineBytes", "maxLiteralBytes", "idleTimeoutMs"],
221
+ "mail.server.imap.", MailServerImapError, "mail-server-imap/bad-bound");
222
+
223
+ var greeting = opts.greeting || DEFAULT_GREETING_VENDOR;
224
+ var maxLineBytes = opts.maxLineBytes || DEFAULT_MAX_LINE_BYTES;
225
+ var maxLiteralBytes = opts.maxLiteralBytes || DEFAULT_MAX_LITERAL;
226
+ var idleTimeoutMs = opts.idleTimeoutMs || DEFAULT_IDLE_TIMEOUT_MS;
227
+ var profile = opts.profile || "strict";
228
+ var authConfig = opts.auth || null;
229
+ var mailStore = opts.mailStore;
230
+ var allowLegacyMUtf7 = profile === "permissive";
231
+
232
+ var rateLimit;
233
+ if (opts.rateLimit === false) {
234
+ rateLimit = mailServerRateLimit.create({ disabled: true });
235
+ } else if (opts.rateLimit && typeof opts.rateLimit.admitConnection === "function") {
236
+ rateLimit = opts.rateLimit;
237
+ } else {
238
+ rateLimit = mailServerRateLimit.create(opts.rateLimit || {});
239
+ }
240
+
241
+ var tcpServer = null;
242
+ var listening = false;
243
+ var connections = new Set();
244
+
245
+ function _emit(action, metadata, outcome) {
246
+ try {
247
+ audit().safeEmit({
248
+ action: action,
249
+ outcome: outcome || "success",
250
+ metadata: metadata || {},
251
+ });
252
+ } catch (_e) { /* drop-silent — audit best-effort */ }
253
+ }
254
+
255
+ function _handleConnection(rawSocket) {
256
+ var remoteAddress = rawSocket.remoteAddress || "0.0.0.0";
257
+ var admit = rateLimit.admitConnection(remoteAddress);
258
+ if (!admit.ok) {
259
+ _emit("mail.server.imap.rate_limit_refused",
260
+ { remoteAddress: remoteAddress, reason: admit.reason }, "denied");
261
+ try { rawSocket.write("* BAD Too many connections from your IP\r\n"); }
262
+ catch (_e) { /* socket may be down */ }
263
+ try { rawSocket.destroy(); } catch (_e2) { /* idempotent */ }
264
+ return;
265
+ }
266
+ rawSocket.once("close", function () { rateLimit.releaseConnection(remoteAddress); });
267
+
268
+ var connectionId = "imapconn-" + bCrypto.generateToken(8); // allow:raw-byte-literal — connection-id length
269
+ var socket = rawSocket;
270
+ connections.add(socket);
271
+
272
+ var state = {
273
+ id: connectionId,
274
+ remoteAddress: remoteAddress,
275
+ tls: false,
276
+ stage: "not-authenticated",
277
+ actor: null,
278
+ selectedMailbox: null,
279
+ selectedReadOnly: false,
280
+ authPending: null,
281
+ pendingLiteral: null, // { tag, verb, line, size, body }
282
+ idle: null, // { tag, timer }
283
+ // Per-connection receive buffer (must NOT be a closure variable —
284
+ // multiple concurrent connections would clobber each other).
285
+ lineBuffer: Buffer.alloc(0),
286
+ };
287
+
288
+ _emit("mail.server.imap.connect",
289
+ { connectionId: connectionId, remoteAddress: remoteAddress });
290
+
291
+ socket.setTimeout(idleTimeoutMs);
292
+ socket.on("timeout", function () {
293
+ _writeUntagged(socket, "BYE Idle timeout");
294
+ _close(socket);
295
+ });
296
+ socket.on("error", function (err) {
297
+ _emit("mail.server.imap.socket_error",
298
+ { connectionId: connectionId, error: (err && err.message) || String(err) }, "failure");
299
+ });
300
+
301
+ // Greeting per RFC 9051 §7.1.5 — `* OK <greeting>`.
302
+ _writeUntagged(socket, "OK [CAPABILITY " + _capabilityLine(state) + "] " + greeting);
303
+
304
+ socket.on("data", function (chunk) {
305
+ state.lineBuffer = Buffer.concat([state.lineBuffer, chunk]);
306
+ _drainBuffer(state, socket);
307
+ });
308
+ }
309
+
310
+ // Receive-buffer drain: extract complete lines (CRLF-terminated)
311
+ // and dispatch. When the previous command opened a literal (e.g.
312
+ // APPEND ... {N}), the next N bytes are the literal payload — we
313
+ // accumulate them before resuming line-mode dispatch.
314
+ function _drainBuffer(state, socket) {
315
+ while (true) {
316
+ if (state.pendingLiteral) {
317
+ var need = state.pendingLiteral.size - state.pendingLiteral.body.length;
318
+ if (state.lineBuffer.length < need) {
319
+ state.pendingLiteral.body = Buffer.concat([state.pendingLiteral.body, state.lineBuffer]);
320
+ state.lineBuffer = Buffer.alloc(0);
321
+ return;
322
+ }
323
+ state.pendingLiteral.body = Buffer.concat([state.pendingLiteral.body, state.lineBuffer.subarray(0, need)]);
324
+ state.lineBuffer = state.lineBuffer.subarray(need);
325
+ _completeLiteralCommand(state, socket);
326
+ continue;
327
+ }
328
+ var crlf = state.lineBuffer.indexOf("\r\n");
329
+ if (crlf === -1) {
330
+ if (state.lineBuffer.length > maxLineBytes) {
331
+ _writeUntagged(socket, "BAD Line too long (cap " + maxLineBytes + ")");
332
+ _close(socket);
333
+ }
334
+ return;
335
+ }
336
+ var rawLine = state.lineBuffer.subarray(0, crlf).toString("utf8");
337
+ state.lineBuffer = state.lineBuffer.subarray(crlf + 2);
338
+ _handleLine(state, socket, rawLine);
339
+ if (state.stage === "closed") return;
340
+ }
341
+ }
342
+
343
+ function _handleLine(state, socket, line) {
344
+ // Continuation: AUTHENTICATE multi-step expects a client response
345
+ if (state.authPending) {
346
+ _runAuthStep(state, socket, line.trim());
347
+ return;
348
+ }
349
+ // IDLE termination — RFC 2177 §3 expects `DONE` line.
350
+ if (state.idle) {
351
+ if (line.toUpperCase() === "DONE") {
352
+ var idleTag = state.idle.tag;
353
+ if (state.idle.timer) clearTimeout(state.idle.timer);
354
+ state.idle = null;
355
+ _writeTagged(socket, idleTag, "OK IDLE terminated");
356
+ } else {
357
+ _writeUntagged(socket, "BAD Expected DONE during IDLE");
358
+ }
359
+ return;
360
+ }
361
+ var parsed;
362
+ try {
363
+ parsed = guardImapCommand.validate(line, {
364
+ profile: profile,
365
+ authenticated: state.actor !== null,
366
+ });
367
+ } catch (e) {
368
+ if (e && e.code === "guard-imap-command/literal-smuggling") {
369
+ _emit("mail.server.imap.smtp_smuggling_detected",
370
+ { connectionId: state.id, line: line.slice(0, LINE_PREVIEW) }, "denied");
371
+ }
372
+ _writeUntagged(socket, "BAD " + (e && e.message ? e.message.slice(0, ERR_CLAMP) : "syntax"));
373
+ return;
374
+ }
375
+ // Literal-opener: stash + emit continuation. Zero-length literals
376
+ // (`{0}`) are legal per RFC 9051 §6.3.12 (e.g. APPEND of an empty
377
+ // message body — rare but spec-compliant; refusing them would
378
+ // diverge from the wire-protocol).
379
+ if (parsed.literalSize !== null) {
380
+ if (parsed.literalSize > maxLiteralBytes) {
381
+ _emit("mail.server.imap.literal_overflow_refused",
382
+ { connectionId: state.id, attempted: parsed.literalSize, cap: maxLiteralBytes },
383
+ "denied");
384
+ _writeTagged(socket, parsed.tag,
385
+ "NO Literal " + parsed.literalSize + " bytes exceeds cap " + maxLiteralBytes);
386
+ return;
387
+ }
388
+ // Zero-byte literal: no continuation, no read — synthesize the
389
+ // pending-literal with an empty body and complete immediately on
390
+ // the next loop tick.
391
+ if (parsed.literalSize === 0) {
392
+ state.pendingLiteral = {
393
+ tag: parsed.tag,
394
+ verb: parsed.verb,
395
+ line: line,
396
+ size: 0,
397
+ body: Buffer.alloc(0),
398
+ synchronizing: !parsed.literalNonSync,
399
+ };
400
+ _completeLiteralCommand(state, socket);
401
+ return;
402
+ }
403
+ state.pendingLiteral = {
404
+ tag: parsed.tag,
405
+ verb: parsed.verb,
406
+ line: line,
407
+ size: parsed.literalSize,
408
+ body: Buffer.alloc(0),
409
+ synchronizing: !parsed.literalNonSync,
410
+ };
411
+ if (!parsed.literalNonSync) {
412
+ _writeUntagged(socket, "+ Ready for literal data");
413
+ }
414
+ return;
415
+ }
416
+ _dispatch(state, socket, parsed, line);
417
+ }
418
+
419
+ function _completeLiteralCommand(state, socket) {
420
+ var pending = state.pendingLiteral;
421
+ state.pendingLiteral = null;
422
+ // Strip the trailing literal opener `{N}` (or `{N+}`) from the line
423
+ var lineNoLit = pending.line.replace(/\{[0-9]+\+?\}$/, "").trim(); // allow:regex-no-length-cap — line length already capped upstream
424
+ var parsed;
425
+ try { parsed = guardImapCommand.validate(lineNoLit, { profile: profile, authenticated: state.actor !== null }); }
426
+ catch (e) {
427
+ _writeTagged(socket, pending.tag, "BAD " + (e && e.message ? e.message.slice(0, ERR_CLAMP) : "syntax"));
428
+ return;
429
+ }
430
+ _dispatch(state, socket, parsed, lineNoLit, pending.body);
431
+ }
432
+
433
+ function _dispatch(state, socket, parsed, _rawLine, literalBody) {
434
+ var tag = parsed.tag;
435
+ var verb = parsed.verb;
436
+ var args = parsed.args;
437
+
438
+ switch (verb) {
439
+ case "CAPABILITY": return _handleCapability(state, socket, tag);
440
+ case "NOOP": return _writeTagged(socket, tag, "OK NOOP completed");
441
+ case "LOGOUT": return _handleLogout(state, socket, tag);
442
+ case "ID": return _handleId(state, socket, tag, args);
443
+ case "STARTTLS": return _handleStartTls(state, socket, tag);
444
+ case "AUTHENTICATE": return _handleAuthenticate(state, socket, tag, args);
445
+ case "LOGIN": return _handleLogin(state, socket, tag, args);
446
+ case "ENABLE": return _writeTagged(socket, tag, "OK ENABLED");
447
+ case "SELECT":
448
+ case "EXAMINE": return _handleSelect(state, socket, tag, args, verb === "EXAMINE");
449
+ case "LIST": return _handleList(state, socket, tag, args);
450
+ case "STATUS": return _handleStatus(state, socket, tag, args);
451
+ case "NAMESPACE": return _writeUntagged(socket, "NAMESPACE ((\"\" \"/\")) NIL NIL"), _writeTagged(socket, tag, "OK NAMESPACE completed");
452
+ case "APPEND": return _handleAppend(state, socket, tag, args, literalBody);
453
+ case "CHECK": return _writeTagged(socket, tag, "OK CHECK completed");
454
+ case "CLOSE":
455
+ case "UNSELECT": return _handleClose(state, socket, tag);
456
+ case "EXPUNGE": return _handleExpunge(state, socket, tag);
457
+ case "FETCH": return _handleFetch(state, socket, tag, args);
458
+ case "STORE": return _handleStore(state, socket, tag, args);
459
+ case "UID": return _handleUid(state, socket, tag, args);
460
+ case "IDLE": return _handleIdle(state, socket, tag);
461
+ case "DONE": return _writeTagged(socket, tag, "BAD DONE outside IDLE");
462
+ default: return _writeTagged(socket, tag, "BAD Verb '" + verb + "' not implemented in v1");
463
+ }
464
+ }
465
+
466
+ function _capabilityLine(state) {
467
+ var caps = ["IMAP4rev2"];
468
+ if (!state.tls) caps.push("STARTTLS");
469
+ // Advertise AUTH=<mech> ONLY for mechanisms the operator wired
470
+ // in opts.auth.mechanisms. RFC 9051 §7.2 — clients pick from the
471
+ // advertised list; advertising AUTH=PLAIN when authConfig is null
472
+ // or doesn't include PLAIN sets clients up for AUTHENTICATE
473
+ // requests that the listener refuses with "no AUTHENTICATE
474
+ // configured" / "mechanism not advertised".
475
+ if (authConfig && Array.isArray(authConfig.mechanisms)) {
476
+ for (var i = 0; i < authConfig.mechanisms.length; i += 1) {
477
+ var m = String(authConfig.mechanisms[i]).toUpperCase();
478
+ if (caps.indexOf("AUTH=" + m) === -1) caps.push("AUTH=" + m);
479
+ }
480
+ }
481
+ return caps.join(" ");
482
+ }
483
+
484
+ function _handleCapability(state, socket, tag) {
485
+ _writeUntagged(socket, "CAPABILITY " + _capabilityLine(state));
486
+ _writeTagged(socket, tag, "OK CAPABILITY completed");
487
+ }
488
+
489
+ function _handleId(state, socket, tag, args) {
490
+ // RFC 2971 — clients send a key/value list, server replies with
491
+ // its own. We accept anything (validator caps line size) and reply
492
+ // with a minimal identifier.
493
+ void args;
494
+ _writeUntagged(socket, "ID (\"name\" \"blamejs\" \"version\" \"" + pkgVersion + "\")");
495
+ _writeTagged(socket, tag, "OK ID completed");
496
+ }
497
+
498
+ function _handleLogout(state, socket, tag) {
499
+ _writeUntagged(socket, "BYE Logging out");
500
+ _writeTagged(socket, tag, "OK LOGOUT completed");
501
+ _close(socket);
502
+ }
503
+
504
+ function _handleStartTls(state, socket, tag) {
505
+ if (state.tls) {
506
+ _writeTagged(socket, tag, "BAD TLS already negotiated");
507
+ return;
508
+ }
509
+ _writeTagged(socket, tag, "OK Begin TLS negotiation now");
510
+ // Drain pre-handshake buffer (RFC 9051 §11.1 / CVE-2021-33515
511
+ // class STARTTLS-injection defense). Any commands a client
512
+ // pipelined before the handshake are discarded — the post-TLS
513
+ // socket reads fresh.
514
+ state.lineBuffer = Buffer.alloc(0);
515
+ var tlsSocket = new nodeTls.TLSSocket(socket, {
516
+ isServer: true,
517
+ secureContext: opts.tlsContext,
518
+ });
519
+ tlsSocket.on("secure", function () {
520
+ state.tls = true;
521
+ tlsSocket.setTimeout(idleTimeoutMs);
522
+ tlsSocket.on("data", function (chunk) {
523
+ state.lineBuffer = Buffer.concat([state.lineBuffer, chunk]);
524
+ _drainBuffer(state, tlsSocket);
525
+ });
526
+ });
527
+ tlsSocket.on("error", function (err) {
528
+ _emit("mail.server.imap.tls_handshake_failed",
529
+ { connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
530
+ _close(socket);
531
+ });
532
+ }
533
+
534
+ function _handleAuthenticate(state, socket, tag, args) {
535
+ if (state.actor) {
536
+ _writeTagged(socket, tag, "BAD Already authenticated");
537
+ return;
538
+ }
539
+ if (!state.tls && profile !== "permissive") {
540
+ _writeTagged(socket, tag, "BAD AUTHENTICATE requires TLS (use STARTTLS first)");
541
+ return;
542
+ }
543
+ if (!authConfig || typeof authConfig.verify !== "function") {
544
+ _writeTagged(socket, tag, "NO AUTHENTICATE not configured on this listener");
545
+ return;
546
+ }
547
+ var authAdmit = rateLimit.checkAuthAdmit(state.remoteAddress);
548
+ if (!authAdmit.ok) {
549
+ _emit("mail.server.imap.auth_rate_limit_refused",
550
+ { connectionId: state.id, remoteAddress: state.remoteAddress, reason: authAdmit.reason },
551
+ "denied");
552
+ _writeTagged(socket, tag, "NO [ALERT] Too many AUTH failures from your IP");
553
+ _close(socket);
554
+ return;
555
+ }
556
+ var mechName = args.split(" ")[0].toUpperCase();
557
+ var initialResp = args.indexOf(" ") === -1 ? null : args.slice(args.indexOf(" ") + 1).trim();
558
+ var mechanisms = (authConfig.mechanisms || ["PLAIN", "LOGIN"]).map(function (m) {
559
+ return String(m).toUpperCase();
560
+ });
561
+ if (mechanisms.indexOf(mechName) === -1) {
562
+ _writeTagged(socket, tag, "NO Mechanism '" + mechName + "' not advertised");
563
+ return;
564
+ }
565
+ _emit("mail.server.imap.auth_attempt",
566
+ { connectionId: state.id, mechanism: mechName, remoteAddress: state.remoteAddress });
567
+ state.authPending = { mechanism: mechName, tag: tag, step: 0 };
568
+ _runAuthStep(state, socket, initialResp);
569
+ }
570
+
571
+ function _runAuthStep(state, socket, clientResp) {
572
+ var pending = state.authPending;
573
+ Promise.resolve()
574
+ .then(function () {
575
+ return authConfig.verify(pending.mechanism, {
576
+ step: pending.step,
577
+ clientResponse: clientResp,
578
+ tls: state.tls,
579
+ remoteAddress: state.remoteAddress,
580
+ });
581
+ })
582
+ .then(function (result) {
583
+ pending.step += 1;
584
+ if (result && result.pending && typeof result.challenge === "string") {
585
+ // Server-side challenge — `+ <base64>` per RFC 9051 §6.2.2.
586
+ _writeContinuation(socket, result.challenge);
587
+ return;
588
+ }
589
+ if (result && result.ok === true && result.actor) {
590
+ state.actor = result.actor;
591
+ state.stage = "authenticated";
592
+ var savedTag = pending.tag;
593
+ state.authPending = null;
594
+ _emit("mail.server.imap.auth_success",
595
+ { connectionId: state.id, mechanism: pending.mechanism,
596
+ tenantId: result.actor.tenantId || null });
597
+ _writeTagged(socket, savedTag, "OK [CAPABILITY " + _capabilityLine(state) + "] AUTHENTICATE completed");
598
+ return;
599
+ }
600
+ var failTag = pending.tag;
601
+ state.authPending = null;
602
+ rateLimit.noteAuthFailure(state.remoteAddress);
603
+ _emit("mail.server.imap.auth_failed",
604
+ { connectionId: state.id, mechanism: pending.mechanism,
605
+ reason: (result && result.reason) || "verify-returned-fail" }, "denied");
606
+ _writeTagged(socket, failTag, "NO Authentication credentials invalid");
607
+ })
608
+ .catch(function (err) {
609
+ var failTag = pending.tag;
610
+ state.authPending = null;
611
+ rateLimit.noteAuthFailure(state.remoteAddress);
612
+ _emit("mail.server.imap.auth_failed",
613
+ { connectionId: state.id, mechanism: pending.mechanism,
614
+ reason: (err && err.message) || String(err) }, "failure");
615
+ _writeTagged(socket, failTag, "NO Authentication failed");
616
+ });
617
+ }
618
+
619
+ function _handleLogin(state, socket, tag, args) {
620
+ // RFC 9051 §6.3.4 — LOGIN is deprecated; new MUAs use AUTHENTICATE.
621
+ if (state.actor) {
622
+ _writeTagged(socket, tag, "BAD Already authenticated");
623
+ return;
624
+ }
625
+ if (profile === "strict") {
626
+ _writeTagged(socket, tag, "BAD LOGIN deprecated under strict profile; use AUTHENTICATE");
627
+ return;
628
+ }
629
+ if (!state.tls && profile !== "permissive") {
630
+ _writeTagged(socket, tag, "BAD LOGIN requires TLS (use STARTTLS first)");
631
+ return;
632
+ }
633
+ if (!authConfig || typeof authConfig.verify !== "function") {
634
+ _writeTagged(socket, tag, "NO AUTH not configured");
635
+ return;
636
+ }
637
+ var authAdmit = rateLimit.checkAuthAdmit(state.remoteAddress);
638
+ if (!authAdmit.ok) {
639
+ _writeTagged(socket, tag, "NO [ALERT] Too many AUTH failures from your IP");
640
+ _close(socket);
641
+ return;
642
+ }
643
+ // LOGIN args: `user pass` (quoted or atom).
644
+ var parts = _parseLoginArgs(args);
645
+ if (!parts) {
646
+ _writeTagged(socket, tag, "BAD LOGIN expects user + pass");
647
+ return;
648
+ }
649
+ Promise.resolve()
650
+ .then(function () {
651
+ return authConfig.verify("LOGIN", {
652
+ step: 0,
653
+ username: parts[0],
654
+ password: parts[1],
655
+ tls: state.tls,
656
+ remoteAddress: state.remoteAddress,
657
+ });
658
+ })
659
+ .then(function (result) {
660
+ if (result && result.ok && result.actor) {
661
+ state.actor = result.actor;
662
+ state.stage = "authenticated";
663
+ _emit("mail.server.imap.auth_success",
664
+ { connectionId: state.id, mechanism: "LOGIN", tenantId: result.actor.tenantId || null });
665
+ _writeTagged(socket, tag, "OK [CAPABILITY " + _capabilityLine(state) + "] LOGIN completed");
666
+ return;
667
+ }
668
+ rateLimit.noteAuthFailure(state.remoteAddress);
669
+ _emit("mail.server.imap.auth_failed",
670
+ { connectionId: state.id, mechanism: "LOGIN", reason: "verify-returned-fail" }, "denied");
671
+ _writeTagged(socket, tag, "NO LOGIN credentials invalid");
672
+ })
673
+ .catch(function () {
674
+ rateLimit.noteAuthFailure(state.remoteAddress);
675
+ _writeTagged(socket, tag, "NO LOGIN failed");
676
+ });
677
+ }
678
+
679
+ function _parseLoginArgs(args) {
680
+ if (typeof args !== "string") return null;
681
+ // Quoted or atom — simple parser sufficient for happy path.
682
+ var rest = args.trim();
683
+ function _take() {
684
+ if (rest[0] === "\"") {
685
+ var end = rest.indexOf("\"", 1);
686
+ if (end === -1) return null;
687
+ var v = rest.slice(1, end);
688
+ rest = rest.slice(end + 1).trim();
689
+ return v;
690
+ }
691
+ var sp = rest.indexOf(" ");
692
+ var v2 = sp === -1 ? rest : rest.slice(0, sp);
693
+ rest = sp === -1 ? "" : rest.slice(sp + 1).trim();
694
+ return v2;
695
+ }
696
+ var user = _take(); if (user === null) return null;
697
+ var pass = _take(); if (pass === null) return null;
698
+ return [user, pass];
699
+ }
700
+
701
+ function _requireAuth(state, socket, tag) {
702
+ if (!state.actor) {
703
+ _writeTagged(socket, tag, "NO Login first");
704
+ return false;
705
+ }
706
+ return true;
707
+ }
708
+
709
+ function _handleSelect(state, socket, tag, args, examine) {
710
+ if (!_requireAuth(state, socket, tag)) return;
711
+ var name = _unquote(args.trim());
712
+ if (!_validateMailboxName(name, { allowLegacyMUtf7: allowLegacyMUtf7 })) {
713
+ _writeTagged(socket, tag, "BAD Mailbox name refused");
714
+ return;
715
+ }
716
+ Promise.resolve()
717
+ .then(function () {
718
+ if (typeof mailStore.selectFolder === "function") {
719
+ return mailStore.selectFolder(state.actor, name, { readOnly: examine });
720
+ }
721
+ // Fallback shape — operators without selectFolder get a minimal
722
+ // OK with sentinel UIDVALIDITY 1.
723
+ return { exists: 0, recent: 0, uidvalidity: 1, uidnext: 1, modseq: 0, flags: [] };
724
+ })
725
+ .then(function (info) {
726
+ state.selectedMailbox = name;
727
+ state.selectedReadOnly = !!examine;
728
+ state.stage = "selected";
729
+ var flagsStr = (info.flags && info.flags.length) ? info.flags.join(" ") : "\\Seen \\Answered \\Flagged \\Deleted \\Draft";
730
+ _writeUntagged(socket, info.exists + " EXISTS");
731
+ _writeUntagged(socket, info.recent + " RECENT");
732
+ _writeUntagged(socket, "FLAGS (" + flagsStr + ")");
733
+ _writeUntagged(socket, "OK [UIDVALIDITY " + info.uidvalidity + "] UIDs valid");
734
+ _writeUntagged(socket, "OK [UIDNEXT " + info.uidnext + "] Predicted next UID");
735
+ if (info.modseq !== undefined) {
736
+ _writeUntagged(socket, "OK [HIGHESTMODSEQ " + info.modseq + "]");
737
+ }
738
+ _emit("mail.server.imap.select", {
739
+ connectionId: state.id, mailbox: name,
740
+ modseq: info.modseq || 0, exists: info.exists,
741
+ });
742
+ _writeTagged(socket, tag, "OK [" + (examine ? "READ-ONLY" : "READ-WRITE") + "] " +
743
+ (examine ? "EXAMINE" : "SELECT") + " completed");
744
+ })
745
+ .catch(function (err) {
746
+ _writeTagged(socket, tag, "NO " + ((err && err.message) || "Select failed").slice(0, ERR_CLAMP));
747
+ });
748
+ }
749
+
750
+ function _handleList(state, socket, tag, args) {
751
+ if (!_requireAuth(state, socket, tag)) return;
752
+ // RFC 9051 §6.3.9 — LIST reference mailbox-pattern. Minimal
753
+ // implementation delegates to mailStore.listFolders if present.
754
+ void args;
755
+ Promise.resolve()
756
+ .then(function () {
757
+ if (typeof mailStore.listFolders === "function") {
758
+ return mailStore.listFolders(state.actor);
759
+ }
760
+ return [{ name: "INBOX", attributes: [] }];
761
+ })
762
+ .then(function (folders) {
763
+ for (var i = 0; i < folders.length; i += 1) {
764
+ var f = folders[i];
765
+ var attrs = (f.attributes || []).map(function (a) { return "\\" + a; }).join(" ");
766
+ _writeUntagged(socket, "LIST (" + attrs + ") \"/\" " + _quote(f.name));
767
+ }
768
+ _writeTagged(socket, tag, "OK LIST completed");
769
+ })
770
+ .catch(function (err) {
771
+ _writeTagged(socket, tag, "NO " + ((err && err.message) || "List failed").slice(0, ERR_CLAMP));
772
+ });
773
+ }
774
+
775
+ function _handleStatus(state, socket, tag, args) {
776
+ if (!_requireAuth(state, socket, tag)) return;
777
+ var match = args.match(/^(\S+|"[^"]+")\s+\(([^)]+)\)$/); // allow:regex-no-length-cap — args length already capped upstream
778
+ if (!match) {
779
+ _writeTagged(socket, tag, "BAD STATUS expects mailbox + paren-list of items");
780
+ return;
781
+ }
782
+ var name = _unquote(match[1]);
783
+ var items = match[2].split(/\s+/);
784
+ if (!_validateMailboxName(name, { allowLegacyMUtf7: allowLegacyMUtf7 })) {
785
+ _writeTagged(socket, tag, "BAD Mailbox name refused");
786
+ return;
787
+ }
788
+ Promise.resolve()
789
+ .then(function () {
790
+ if (typeof mailStore.statusFolder === "function") {
791
+ return mailStore.statusFolder(state.actor, name, items);
792
+ }
793
+ return { MESSAGES: 0, UIDNEXT: 1, UIDVALIDITY: 1, UNSEEN: 0 };
794
+ })
795
+ .then(function (info) {
796
+ var parts = [];
797
+ for (var k = 0; k < items.length; k += 1) {
798
+ var key = items[k].toUpperCase();
799
+ if (info[key] !== undefined) parts.push(key + " " + info[key]);
800
+ }
801
+ _writeUntagged(socket, "STATUS " + _quote(name) + " (" + parts.join(" ") + ")");
802
+ _writeTagged(socket, tag, "OK STATUS completed");
803
+ })
804
+ .catch(function (err) {
805
+ _writeTagged(socket, tag, "NO " + ((err && err.message) || "Status failed").slice(0, ERR_CLAMP));
806
+ });
807
+ }
808
+
809
+ function _handleAppend(state, socket, tag, args, literalBody) {
810
+ if (!_requireAuth(state, socket, tag)) return;
811
+ if (!literalBody) {
812
+ _writeTagged(socket, tag, "BAD APPEND requires a literal {N} message");
813
+ return;
814
+ }
815
+ // RFC 9051 §6.3.12 — APPEND mailbox [(flags)] [date-time] literal
816
+ var match = args.match(/^(\S+|"[^"]+")(?:\s+\(([^)]*)\))?(?:\s+("[^"]+"))?$/); // allow:regex-no-length-cap — args length already capped upstream
817
+ if (!match) {
818
+ _writeTagged(socket, tag, "BAD APPEND syntax");
819
+ return;
820
+ }
821
+ var name = _unquote(match[1]);
822
+ var flags = match[2] ? match[2].split(/\s+/).filter(Boolean) : [];
823
+ if (!_validateMailboxName(name, { allowLegacyMUtf7: allowLegacyMUtf7 })) {
824
+ _writeTagged(socket, tag, "BAD Mailbox name refused");
825
+ return;
826
+ }
827
+ Promise.resolve()
828
+ .then(function () {
829
+ return mailStore.appendMessage(name, literalBody, { actor: state.actor, flags: flags });
830
+ })
831
+ .then(function (info) {
832
+ _emit("mail.server.imap.append",
833
+ { connectionId: state.id, mailbox: name, size: literalBody.length, flags: flags });
834
+ var token = info && info.uid ? "[APPENDUID " + (info.uidvalidity || 0) + " " + info.uid + "] " : "";
835
+ _writeTagged(socket, tag, "OK " + token + "APPEND completed");
836
+ })
837
+ .catch(function (err) {
838
+ _writeTagged(socket, tag, "NO " + ((err && err.message) || "Append failed").slice(0, ERR_CLAMP));
839
+ });
840
+ }
841
+
842
+ function _handleClose(state, socket, tag) {
843
+ state.selectedMailbox = null;
844
+ state.selectedReadOnly = false;
845
+ state.stage = "authenticated";
846
+ _writeTagged(socket, tag, "OK CLOSE completed");
847
+ }
848
+
849
+ function _handleExpunge(state, socket, tag) {
850
+ if (!state.selectedMailbox) {
851
+ _writeTagged(socket, tag, "NO No mailbox selected");
852
+ return;
853
+ }
854
+ Promise.resolve()
855
+ .then(function () {
856
+ if (typeof mailStore.expungeFolder === "function") {
857
+ return mailStore.expungeFolder(state.actor, state.selectedMailbox);
858
+ }
859
+ return { expunged: [], modseq: 0 };
860
+ })
861
+ .then(function (info) {
862
+ var ex = info.expunged || [];
863
+ for (var i = 0; i < ex.length; i += 1) {
864
+ _writeUntagged(socket, ex[i] + " EXPUNGE");
865
+ }
866
+ _emit("mail.server.imap.expunge",
867
+ { connectionId: state.id, mailbox: state.selectedMailbox,
868
+ count: ex.length, modseq: info.modseq || 0 });
869
+ _writeTagged(socket, tag, "OK EXPUNGE completed");
870
+ })
871
+ .catch(function (err) {
872
+ _writeTagged(socket, tag, "NO " + ((err && err.message) || "Expunge failed").slice(0, ERR_CLAMP));
873
+ });
874
+ }
875
+
876
+ function _handleFetch(state, socket, tag, args, useUid) {
877
+ if (!state.selectedMailbox) {
878
+ _writeTagged(socket, tag, "NO No mailbox selected");
879
+ return;
880
+ }
881
+ if (typeof mailStore.fetchRange !== "function") {
882
+ _writeTagged(socket, tag, "BAD FETCH backend not configured");
883
+ return;
884
+ }
885
+ var match = args.match(/^(\S+)\s+(.+)$/); // allow:regex-no-length-cap — args length already capped upstream
886
+ if (!match) {
887
+ _writeTagged(socket, tag, "BAD FETCH expects sequence-set + parts");
888
+ return;
889
+ }
890
+ var seqSet = match[1];
891
+ var partsSpec = match[2];
892
+ Promise.resolve()
893
+ .then(function () {
894
+ // useUid: true tells the backend to interpret seqSet as UIDs
895
+ // per RFC 9051 §6.4.9 — distinct from message-sequence-numbers
896
+ // under the SELECT context. UID FETCH responses MUST include
897
+ // the UID in the parts list per §6.4.9 ("the server SHOULD also
898
+ // include UID information in its response").
899
+ return mailStore.fetchRange(state.actor, state.selectedMailbox, seqSet, partsSpec,
900
+ { useUid: useUid === true });
901
+ })
902
+ .then(function (rows) {
903
+ var rs = rows || [];
904
+ _emit("mail.server.imap.fetch_bulk",
905
+ { connectionId: state.id, mailbox: state.selectedMailbox, count: rs.length });
906
+ for (var i = 0; i < rs.length; i += 1) {
907
+ var r = rs[i];
908
+ _writeUntagged(socket, r.seq + " FETCH (" + (r.payload || "") + ")");
909
+ }
910
+ _writeTagged(socket, tag, "OK FETCH completed");
911
+ })
912
+ .catch(function (err) {
913
+ _writeTagged(socket, tag, "NO " + ((err && err.message) || "Fetch failed").slice(0, ERR_CLAMP));
914
+ });
915
+ }
916
+
917
+ function _handleStore(state, socket, tag, args, useUid) {
918
+ if (!state.selectedMailbox) {
919
+ _writeTagged(socket, tag, "NO No mailbox selected");
920
+ return;
921
+ }
922
+ if (state.selectedReadOnly) {
923
+ _writeTagged(socket, tag, "NO Mailbox is read-only");
924
+ return;
925
+ }
926
+ if (typeof mailStore.storeFlags !== "function") {
927
+ _writeTagged(socket, tag, "BAD STORE backend not configured");
928
+ return;
929
+ }
930
+ var match = args.match(/^(\S+)\s+([+-]?FLAGS(?:\.SILENT)?)\s+\(([^)]*)\)$/i); // allow:regex-no-length-cap — args length already capped upstream
931
+ if (!match) {
932
+ _writeTagged(socket, tag, "BAD STORE expects seq-set FLAGS (...)");
933
+ return;
934
+ }
935
+ var seqSet = match[1];
936
+ var op = match[2].toUpperCase();
937
+ var flagsArr = match[3].split(/\s+/).filter(Boolean);
938
+ var silent = /\.SILENT$/i.test(op);
939
+ var mode = op[0] === "+" ? "add" : op[0] === "-" ? "remove" : "replace";
940
+ Promise.resolve()
941
+ .then(function () {
942
+ return mailStore.storeFlags(state.actor, state.selectedMailbox, seqSet, mode, flagsArr,
943
+ { useUid: useUid === true });
944
+ })
945
+ .then(function (rows) {
946
+ if (!silent) {
947
+ var rs = rows || [];
948
+ for (var i = 0; i < rs.length; i += 1) {
949
+ var r = rs[i];
950
+ _writeUntagged(socket, r.seq + " FETCH (FLAGS (" + (r.flags || []).join(" ") + "))");
951
+ }
952
+ }
953
+ _writeTagged(socket, tag, "OK STORE completed");
954
+ })
955
+ .catch(function (err) {
956
+ _writeTagged(socket, tag, "NO " + ((err && err.message) || "Store failed").slice(0, ERR_CLAMP));
957
+ });
958
+ }
959
+
960
+ function _handleUid(state, socket, tag, args) {
961
+ // UID FETCH / UID STORE / UID SEARCH / UID COPY / UID MOVE per
962
+ // RFC 9051 §6.4.9. The sub-command's sequence-set is interpreted
963
+ // as UIDs (not message-sequence-numbers); we pass `useUid: true`
964
+ // to the sub-handler which threads it through to the backend's
965
+ // mailStore.fetchRange / storeFlags via opts. Without this, the
966
+ // backend treats the seq-set as msg-numbers and a client's
967
+ // `UID FETCH 12345 (BODY[])` returns the WRONG message.
968
+ var sub = args.match(/^(\S+)\s+(.+)$/); // allow:regex-no-length-cap — args length already capped upstream
969
+ if (!sub) {
970
+ _writeTagged(socket, tag, "BAD UID expects a sub-command");
971
+ return;
972
+ }
973
+ var subVerb = sub[1].toUpperCase();
974
+ var subArgs = sub[2];
975
+ if (subVerb === "FETCH") return _handleFetch(state, socket, tag, subArgs, true);
976
+ if (subVerb === "STORE") return _handleStore(state, socket, tag, subArgs, true);
977
+ _writeTagged(socket, tag, "BAD UID " + subVerb + " not implemented in v1");
978
+ }
979
+
980
+ function _handleIdle(state, socket, tag) {
981
+ if (!_requireAuth(state, socket, tag)) return;
982
+ _writeContinuation(socket, "idling");
983
+ // RFC 2177 §3 — IDLE must be terminated with DONE before
984
+ // bandwidth-timeout. We schedule a soft cutoff 1 min before the
985
+ // hard 30-min cutoff to force client re-issue.
986
+ var timer = setTimeout(function () {
987
+ if (state.idle) {
988
+ _writeUntagged(socket, "BYE IDLE timed out — re-issue");
989
+ state.idle = null;
990
+ _close(socket);
991
+ }
992
+ }, IDLE_BANDWIDTH_TIMEOUT_MS);
993
+ state.idle = { tag: tag, timer: timer };
994
+ }
995
+
996
+ function _writeTagged(socket, tag, msg) {
997
+ try { socket.write(tag + " " + msg + "\r\n"); }
998
+ catch (_e) { /* socket may be down */ }
999
+ }
1000
+ function _writeUntagged(socket, msg) {
1001
+ try { socket.write("* " + msg + "\r\n"); }
1002
+ catch (_e) { /* socket may be down */ }
1003
+ }
1004
+ function _writeContinuation(socket, msg) {
1005
+ try { socket.write("+ " + msg + "\r\n"); }
1006
+ catch (_e) { /* socket may be down */ }
1007
+ }
1008
+ function _close(socket) {
1009
+ try { socket.end(); } catch (_e) { /* idempotent */ }
1010
+ try { socket.destroy(); } catch (_e2) { /* idempotent */ }
1011
+ connections.delete(socket);
1012
+ }
1013
+ function _quote(s) { return '"' + String(s).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + '"'; }
1014
+ function _unquote(s) {
1015
+ if (typeof s !== "string") return "";
1016
+ if (s[0] === "\"" && s[s.length - 1] === "\"") return s.slice(1, -1);
1017
+ return s;
1018
+ }
1019
+
1020
+ // ---- Lifecycle ----------------------------------------------------------
1021
+ async function listen(listenOpts) {
1022
+ listenOpts = listenOpts || {};
1023
+ if (listening) {
1024
+ throw new MailServerImapError("mail-server-imap/already-listening",
1025
+ "listen: already listening");
1026
+ }
1027
+ var port = listenOpts.port === undefined ? 143 : listenOpts.port; // allow:raw-byte-literal — RFC 9051 IMAP port (IANA)
1028
+ var address = listenOpts.address || "0.0.0.0";
1029
+ tcpServer = net.createServer(function (socket) { _handleConnection(socket); });
1030
+ return new Promise(function (resolve, reject) {
1031
+ tcpServer.once("error", reject);
1032
+ tcpServer.listen(port, address, function () {
1033
+ listening = true;
1034
+ tcpServer.removeListener("error", reject);
1035
+ _emit("mail.server.imap.listening",
1036
+ { port: port, address: address });
1037
+ resolve({ port: tcpServer.address().port, address: address });
1038
+ });
1039
+ });
1040
+ }
1041
+
1042
+ async function close() {
1043
+ if (!listening) return;
1044
+ listening = false;
1045
+ for (var s of connections) { try { s.destroy(); } catch (_e) { /* idempotent */ } }
1046
+ connections.clear();
1047
+ return new Promise(function (resolve) {
1048
+ tcpServer.close(function () {
1049
+ _emit("mail.server.imap.closed", {});
1050
+ resolve();
1051
+ });
1052
+ });
1053
+ }
1054
+
1055
+ return {
1056
+ listen: listen,
1057
+ close: close,
1058
+ };
1059
+ }
1060
+
1061
+ module.exports = {
1062
+ create: create,
1063
+ MailServerImapError: MailServerImapError,
1064
+ };