tina4-nodejs 3.13.37 → 3.13.39

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.
Files changed (56) hide show
  1. package/CLAUDE.md +65 -20
  2. package/README.md +6 -6
  3. package/package.json +5 -3
  4. package/packages/cli/src/bin.ts +7 -0
  5. package/packages/cli/src/commands/init.ts +1 -0
  6. package/packages/cli/src/commands/metrics.ts +154 -0
  7. package/packages/cli/src/commands/routes.ts +3 -3
  8. package/packages/core/src/api.ts +64 -1
  9. package/packages/core/src/auth.ts +112 -2
  10. package/packages/core/src/cache.ts +2 -2
  11. package/packages/core/src/devAdmin.ts +66 -44
  12. package/packages/core/src/devMailbox.ts +4 -0
  13. package/packages/core/src/dotenv.ts +13 -4
  14. package/packages/core/src/events.ts +86 -4
  15. package/packages/core/src/graphql.ts +182 -128
  16. package/packages/core/src/htmlElement.ts +62 -3
  17. package/packages/core/src/index.ts +21 -10
  18. package/packages/core/src/logger.ts +85 -28
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/mcp.ts +25 -8
  21. package/packages/core/src/messenger.ts +111 -11
  22. package/packages/core/src/metrics.ts +557 -98
  23. package/packages/core/src/middleware.ts +130 -40
  24. package/packages/core/src/plan.ts +1 -1
  25. package/packages/core/src/queue.ts +1 -1
  26. package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
  27. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  28. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  29. package/packages/core/src/rateLimiter.ts +1 -1
  30. package/packages/core/src/response.ts +90 -6
  31. package/packages/core/src/router.ts +56 -8
  32. package/packages/core/src/server.ts +138 -23
  33. package/packages/core/src/session.ts +130 -18
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  35. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  36. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  37. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  38. package/packages/core/src/testClient.ts +1 -1
  39. package/packages/core/src/types.ts +17 -2
  40. package/packages/core/src/websocket.ts +666 -42
  41. package/packages/core/src/websocketBackplane.ts +210 -10
  42. package/packages/core/src/websocketConnection.ts +6 -0
  43. package/packages/core/src/wsdl.ts +55 -21
  44. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  45. package/packages/orm/src/adapters/postgres.ts +26 -4
  46. package/packages/orm/src/adapters/sqlite.ts +112 -13
  47. package/packages/orm/src/baseModel.ts +175 -25
  48. package/packages/orm/src/cachedDatabase.ts +15 -6
  49. package/packages/orm/src/database.ts +257 -55
  50. package/packages/orm/src/index.ts +6 -1
  51. package/packages/orm/src/migration.ts +151 -24
  52. package/packages/orm/src/queryBuilder.ts +14 -2
  53. package/packages/orm/src/seeder.ts +443 -65
  54. package/packages/orm/src/types.ts +7 -0
  55. package/packages/orm/src/validation.ts +14 -0
  56. package/packages/swagger/src/ui.ts +1 -1
@@ -100,7 +100,7 @@ const COLORS: Record<LogLevel, string> = {
100
100
  const RESET = "\x1b[0m";
101
101
 
102
102
  /** Regex to strip ANSI escape codes */
103
- const ANSI_RE = /\033\[[0-9;]*m/g;
103
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
104
104
 
105
105
  /** Default log directory */
106
106
  const DEFAULT_LOG_DIR = "logs";
@@ -136,18 +136,23 @@ function resolveLogFilePath(logDir: string, logFile: string): string {
136
136
  /**
137
137
  * Structured logger for Tina4.
138
138
  *
139
- * Production (TINA4_DEBUG not truthy): JSON or text lines to logs/tina4.log
140
- * Development (TINA4_DEBUG=true): Colorized human-readable to stdout + file
139
+ * Development (TINA4_DEBUG=true): colorized human-readable to stdout + file.
140
+ * Production (TINA4_DEBUG not truthy): clean structured JSON to stdout ONLY
141
+ * no log file by default (writing logs/tina4.log inside a container bloats the
142
+ * writable layer + disk; 12-factor wants logs on stdout). stdout is ALWAYS on.
143
+ *
144
+ * Default file-output rule (TINA4_LOG_OUTPUT unset): the log FILE is written
145
+ * only in development. An explicit TINA4_LOG_OUTPUT=file/both, OR an explicit
146
+ * TINA4_LOG_FILE path, always forces a file (explicit wins).
141
147
  *
142
148
  * Env vars:
143
- * TINA4_LOG_FILE — explicit log file (absolute or relative). Empty = use TINA4_LOG_DIR + tina4.log
149
+ * TINA4_LOG_FILE — explicit log file (absolute or relative). Setting it forces a file even in production. Empty = use TINA4_LOG_DIR + tina4.log
144
150
  * TINA4_LOG_DIR — directory for log files (default: "logs")
145
151
  * TINA4_LOG_FORMAT — "text" | "json" (default: "text")
146
- * TINA4_LOG_OUTPUT — "stdout" | "file" | "both" (default: "stdout")
147
- * TINA4_LOG_CRITICAL — "true" to enable CRITICAL level shortcut (default: "false")
152
+ * TINA4_LOG_OUTPUT — "stdout" | "file" | "both" (default: "stdout" → file only in dev)
148
153
  * TINA4_LOG_ROTATE_SIZE — bytes; 0 disables rotation (default: 10485760 = 10MB)
149
154
  * TINA4_LOG_ROTATE_KEEP — number of historical files to keep (default: 5)
150
- * TINA4_LOG_LEVEL — minimum console level (default: "DEBUG")
155
+ * TINA4_LOG_LEVEL — minimum console level: DEBUG | INFO | WARNING | ERROR | CRITICAL (default: "INFO")
151
156
  *
152
157
  * Rotation is stdlib roll-your-own:
153
158
  * - On each write, statSync the file. If size >= TINA4_LOG_ROTATE_SIZE, rotate.
@@ -171,10 +176,11 @@ export class Log {
171
176
  minLevel: number;
172
177
  format: "text" | "json";
173
178
  output: "stdout" | "file" | "both";
174
- criticalEnabled: boolean;
179
+ fileEnabled: boolean;
175
180
  } {
176
181
  const logDir = process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
177
- const logFile = (process.env.TINA4_LOG_FILE ?? "").trim() || DEFAULT_LOG_FILE;
182
+ const explicitFile = (process.env.TINA4_LOG_FILE ?? "").trim();
183
+ const logFile = explicitFile || DEFAULT_LOG_FILE;
178
184
 
179
185
  const rawSize = process.env.TINA4_LOG_ROTATE_SIZE;
180
186
  let rotateSize = DEFAULT_ROTATE_SIZE;
@@ -203,9 +209,57 @@ export class Log {
203
209
  if (out === "file") output = "file";
204
210
  else if (out === "both") output = "both";
205
211
 
206
- const criticalEnabled = isTruthy(process.env.TINA4_LOG_CRITICAL);
212
+ // v3.13.39: dev/prod-aware default file output (Python master, 4c6d881).
213
+ // When TINA4_LOG_OUTPUT is unset (default "stdout"), the log FILE is written
214
+ // only in development (TINA4_DEBUG truthy). In production / containers the
215
+ // logger is stdout-only — writing logs/tina4.log inside a container just
216
+ // bloats the writable layer + disk, and 12-factor wants logs on stdout for
217
+ // the platform to capture. Explicit TINA4_LOG_OUTPUT=file/both OR an explicit
218
+ // TINA4_LOG_FILE path always wins (explicit forces a file regardless of env).
219
+ let fileEnabled: boolean;
220
+ if (output === "file" || output === "both") {
221
+ fileEnabled = true;
222
+ } else if (explicitFile !== "") {
223
+ fileEnabled = true;
224
+ } else {
225
+ fileEnabled = !Log.isProduction();
226
+ }
227
+
228
+ return { logDir, logFile, rotateSize, rotateKeep, minLevel, format, output, fileEnabled };
229
+ }
230
+
231
+ /**
232
+ * The single console-threshold predicate: does a message at `level` clear
233
+ * the configured minimum console level? This is the ONE place level
234
+ * comparison lives — both the live log() gate and the public isEnabled()
235
+ * predicate call it, so they can never disagree about what actually prints.
236
+ */
237
+ private static passesThreshold(level: LogLevel, minLevel: number): boolean {
238
+ return (LEVEL_PRIORITY[level] ?? 0) >= minLevel;
239
+ }
207
240
 
208
- return { logDir, logFile, rotateSize, rotateKeep, minLevel, format, output, criticalEnabled };
241
+ /**
242
+ * Return true if a message at `level` would pass the configured minimum
243
+ * console level (TINA4_LOG_LEVEL) — the same threshold that gates stdout.
244
+ *
245
+ * This reflects CONSOLE (stdout) visibility only. The log file always
246
+ * records every level regardless of this threshold, so don't use it to
247
+ * decide whether something gets persisted — use it to skip building an
248
+ * expensive payload that would not be shown:
249
+ *
250
+ * if (Log.isEnabled("debug")) {
251
+ * Log.debug("state", expensiveSnapshot());
252
+ * }
253
+ *
254
+ * `level` is case-insensitive. "critical" is the highest severity (priority
255
+ * 4 > error 3) and flows through the ordinary threshold check like every
256
+ * other level — there is no toggle. It reuses the same passesThreshold()
257
+ * check the logger itself uses, so it never drifts from what print does.
258
+ */
259
+ static isEnabled(level: string): boolean {
260
+ const cfg = Log.readEnv();
261
+ const lvl = (level ?? "").toUpperCase() as LogLevel;
262
+ return Log.passesThreshold(lvl, cfg.minLevel);
209
263
  }
210
264
 
211
265
  /**
@@ -257,9 +311,12 @@ export class Log {
257
311
  }
258
312
 
259
313
  /**
260
- * Log a critical message. Only emitted when TINA4_LOG_CRITICAL=true,
261
- * otherwise this is a no-op (matches Python paritycritical is the
262
- * highest-severity bucket and is opt-in to avoid drowning noisy apps).
314
+ * Log a critical message. CRITICAL is the highest severity (priority 4 >
315
+ * error 3) and ALWAYS emits like every other level subject only to the
316
+ * console threshold, which it always passes at normal levels — and is always
317
+ * persisted to the log file (Node tees every level to a single tina4.log;
318
+ * critical 4 >= warning 2 so it would be in error.log on a split-file model).
319
+ * Matches Python master parity — there is no enable toggle.
263
320
  */
264
321
  static critical(message: string, data?: unknown): void {
265
322
  Log.log("CRITICAL", message, data);
@@ -355,9 +412,6 @@ export class Log {
355
412
  private static log(level: LogLevel, message: string, data?: unknown): void {
356
413
  const cfg = Log.readEnv();
357
414
 
358
- // Critical level is opt-in; treat as no-op when disabled.
359
- if (level === "CRITICAL" && !cfg.criticalEnabled) return;
360
-
361
415
  const entry: LogEntry = {
362
416
  timestamp: Log.timestamp(),
363
417
  level,
@@ -393,7 +447,7 @@ export class Log {
393
447
  const fileLine =
394
448
  cfg.format === "json" || Log.isProduction() ? JSON.stringify(entry) : humanLine;
395
449
 
396
- const shouldLog = (LEVEL_PRIORITY[level] ?? 0) >= cfg.minLevel;
450
+ const shouldLog = Log.passesThreshold(level, cfg.minLevel);
397
451
 
398
452
  // Console output. v3.13.14: stdout is NOT suppressed in production —
399
453
  // containers read PID 1 stdout (docker logs / k8s) and the old
@@ -410,17 +464,20 @@ export class Log {
410
464
  }
411
465
  }
412
466
 
413
- // File output: always teed for dev (legacy behaviour), and either always
414
- // (production default) or honoured per output mode.
467
+ // File output (v3.13.39 Python master, 4c6d881): gated on cfg.fileEnabled.
415
468
  //
416
- // output=stdout (default): file in dev + prod (legacy parity)
417
- // output=file file only — no console
418
- // output=both file + console (already handled above)
469
+ // output=stdout (default): file ONLY in dev (TINA4_DEBUG truthy);
470
+ // production / containers are stdout-only — no
471
+ // file to bloat the writable layer / disk.
472
+ // output=file file only — no console (always writes a file)
473
+ // output=both file + console (always writes a file)
474
+ // explicit TINA4_LOG_FILE always writes a file (explicit wins)
419
475
  //
420
- // The "stdout-only without file" mode that some Python deployments want
421
- // is gated on TINA4_LOG_OUTPUT=stdout combined with TINA4_LOG_FILE set
422
- // explicitly to an empty string in env — we treat empty file as default.
423
- const filePath = resolveLogFilePath(cfg.logDir, cfg.logFile);
424
- Log.writeToFile(filePath, fileLine, cfg.rotateSize, cfg.rotateKeep);
476
+ // readEnv() resolves all of the above into cfg.fileEnabled, so flipping
477
+ // that one flag gates the whole file writer (the only persisted sink).
478
+ if (cfg.fileEnabled) {
479
+ const filePath = resolveLogFilePath(cfg.logDir, cfg.logFile);
480
+ Log.writeToFile(filePath, fileLine, cfg.rotateSize, cfg.rotateKeep);
481
+ }
425
482
  }
426
483
  }
@@ -6,7 +6,7 @@ import {
6
6
  encodeResponse, encodeError, encodeNotification, decodeRequest,
7
7
  McpServer, isLocalhost, schemaFromParams, registerDevTools,
8
8
  PARSE_ERROR, METHOD_NOT_FOUND, INTERNAL_ERROR,
9
- } from "./mcp.ts";
9
+ } from "./mcp.js";
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import * as os from "node:os";
@@ -202,18 +202,35 @@ function envTruthy(val: string | undefined): boolean {
202
202
  }
203
203
 
204
204
  /**
205
- * Whether the built-in MCP server should auto-start.
205
+ * Whether the built-in MCP dev tools / `/__dev/mcp` endpoint should be enabled.
206
206
  *
207
- * Default: `true` when `TINA4_DEBUG=true`, `false` otherwise. The `TINA4_MCP`
208
- * env var can force either state explicitly. Matches the Python framework
209
- * which only exposes MCP endpoints in dev mode by default.
207
+ * Resolution order (highest priority first), matching Python master
208
+ * `tina4_python.mcp.is_enabled()`:
209
+ * 1. `TINA4_MCP` explicit on/off override, honoured on ANY host. An
210
+ * explicit truthy value opts a remote / debug-disabled deployment in
211
+ * (e.g. for a remote AI assistant); an explicit falsy value force-disables
212
+ * it everywhere.
213
+ * 2. `TINA4_DEBUG=true` — implicit on for dev, but LOCALHOST-ONLY unless
214
+ * `TINA4_MCP_REMOTE=true`. The MCP dev tools expose powerful operations
215
+ * (DB query, file read/WRITE, route listing), so they never auto-expose on
216
+ * a non-localhost host without an explicit opt-in.
217
+ * 3. Otherwise off.
218
+ *
219
+ * Wired in v3.13.39: previously this was `TINA4_MCP` else `TINA4_DEBUG`, with
220
+ * `isLocalhost()` unused for the gate and `TINA4_MCP_REMOTE` read by zero code
221
+ * — so the documented localhost guard was not actually enforced and a
222
+ * non-localhost `TINA4_DEBUG=true` deployment auto-exposed the dev tools.
210
223
  */
211
224
  export function mcpEnabled(): boolean {
212
- const raw = process.env.TINA4_MCP;
213
- if (raw === undefined || raw.trim() === "") {
214
- return envTruthy(process.env.TINA4_DEBUG);
225
+ const explicit = process.env.TINA4_MCP;
226
+ if (explicit !== undefined && explicit.trim() !== "") {
227
+ return envTruthy(explicit);
228
+ }
229
+ if (!envTruthy(process.env.TINA4_DEBUG)) {
230
+ return false;
215
231
  }
216
- return envTruthy(raw);
232
+ // Dev auto-enable: localhost only, unless explicitly opted into remote.
233
+ return isLocalhost() || envTruthy(process.env.TINA4_MCP_REMOTE);
217
234
  }
218
235
 
219
236
  /**
@@ -28,6 +28,19 @@ import tls from "node:tls";
28
28
  import { readFileSync } from "node:fs";
29
29
  import { basename } from "node:path";
30
30
  import { randomUUID } from "node:crypto";
31
+ import { isTruthy } from "./dotenv.js";
32
+ import { Log } from "./logger.js";
33
+
34
+ /**
35
+ * TLS certificate validation defaults to SECURE (rejectUnauthorized: true).
36
+ * Set TINA4_MAIL_TLS_INSECURE=true to disable validation for dev / self-signed
37
+ * certificates ONLY — never in production. Previously this was hard-coded to
38
+ * `rejectUnauthorized: false`, silently disabling certificate validation for
39
+ * every TLS connection (a man-in-the-middle risk).
40
+ */
41
+ function tlsRejectUnauthorized(): boolean {
42
+ return !isTruthy(process.env.TINA4_MAIL_TLS_INSECURE);
43
+ }
31
44
 
32
45
  // ── Types ────────────────────────────────────────────────────
33
46
 
@@ -37,6 +50,23 @@ export interface SendResult {
37
50
  id?: string;
38
51
  }
39
52
 
53
+ /**
54
+ * Raised when an IMAP read fails to connect, authenticate, or speak the
55
+ * protocol (a `NO`/`BAD` tagged response, a refused/reset socket, a TLS or
56
+ * DNS failure). Distinct from a SUCCESSFUL fetch that simply has no messages —
57
+ * that still returns an empty result ([] / 0 / {}), NOT an error.
58
+ *
59
+ * inbox()/read()/unread()/search()/folders() LOG and then RAISE this on a
60
+ * connection/protocol failure so a dead mailbox is never silently mistaken for
61
+ * an empty one. send() is unchanged — it keeps returning { success, error }.
62
+ */
63
+ export class MessengerConnectionError extends Error {
64
+ constructor(message: string) {
65
+ super(message);
66
+ this.name = "MessengerConnectionError";
67
+ }
68
+ }
69
+
40
70
  export interface EmailMessage {
41
71
  id: string;
42
72
  type: "inbox" | "outbox";
@@ -402,7 +432,7 @@ export class Messenger {
402
432
 
403
433
  if (this.port === 465) {
404
434
  // Implicit TLS (SMTPS)
405
- socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: false });
435
+ socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: tlsRejectUnauthorized() });
406
436
  await new Promise<void>((resolve, reject) => {
407
437
  socket.once("secureConnect", resolve);
408
438
  socket.once("error", reject);
@@ -440,7 +470,7 @@ export class Messenger {
440
470
  // Upgrade to TLS
441
471
  const plainSocket = socket as net.Socket;
442
472
  socket = tls.connect(
443
- { socket: plainSocket, host: this.host, rejectUnauthorized: false },
473
+ { socket: plainSocket, host: this.host, rejectUnauthorized: tlsRejectUnauthorized() },
444
474
  );
445
475
  await new Promise<void>((resolve, reject) => {
446
476
  (socket as tls.TLSSocket).once("secureConnect", resolve);
@@ -541,7 +571,7 @@ export class Messenger {
541
571
  let socket: net.Socket | tls.TLSSocket;
542
572
 
543
573
  if (this.port === 465) {
544
- socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: false });
574
+ socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: tlsRejectUnauthorized() });
545
575
  await new Promise<void>((resolve, reject) => {
546
576
  socket.once("secureConnect", resolve);
547
577
  socket.once("error", reject);
@@ -595,7 +625,7 @@ export class Messenger {
595
625
  || (this.imapEncryption === "" && this.imapPort === 993);
596
626
 
597
627
  if (useTls) {
598
- socket = tls.connect({ host: this.imapHost, port: this.imapPort, rejectUnauthorized: false });
628
+ socket = tls.connect({ host: this.imapHost, port: this.imapPort, rejectUnauthorized: tlsRejectUnauthorized() });
599
629
  await new Promise<void>((resolve, reject) => {
600
630
  socket.once("secureConnect", resolve);
601
631
  socket.once("error", reject);
@@ -638,7 +668,12 @@ export class Messenger {
638
668
  * Returns list of message summaries.
639
669
  */
640
670
  async inbox(limit: number = 20, offset: number = 0, folder: string = "INBOX"): Promise<ImapMessage[]> {
641
- const socket = await this.imapConnect();
671
+ let socket: net.Socket | tls.TLSSocket;
672
+ try {
673
+ socket = await this.imapConnect();
674
+ } catch (err) {
675
+ throw imapFail("inbox", err);
676
+ }
642
677
  try {
643
678
  // Select folder
644
679
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
@@ -660,6 +695,8 @@ export class Messenger {
660
695
  }
661
696
 
662
697
  return messages;
698
+ } catch (err) {
699
+ throw imapFail("inbox", err);
663
700
  } finally {
664
701
  await this.imapDisconnect(socket);
665
702
  }
@@ -669,15 +706,28 @@ export class Messenger {
669
706
  * Read a single message by sequence number or UID.
670
707
  */
671
708
  async read(uid: string, folder: string = "INBOX"): Promise<ImapFullMessage> {
672
- const socket = await this.imapConnect();
709
+ let socket: net.Socket | tls.TLSSocket;
710
+ try {
711
+ socket = await this.imapConnect();
712
+ } catch (err) {
713
+ throw imapFail("read", err);
714
+ }
673
715
  try {
674
716
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
675
717
  const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY[])`);
676
718
 
719
+ // A genuinely missing UID is a tagged OK with no message body literal —
720
+ // that is NOT an error: return an empty message (parity with Python's {}).
721
+ if (!/\{\d+\}/.test(fetchResp)) {
722
+ return emptyFullMessage(uid);
723
+ }
724
+
677
725
  // Mark as seen
678
726
  await imapCommand(socket, `STORE ${uid} +FLAGS (\\Seen)`);
679
727
 
680
728
  return parseFullMessage(uid, fetchResp);
729
+ } catch (err) {
730
+ throw imapFail("read", err);
681
731
  } finally {
682
732
  await this.imapDisconnect(socket);
683
733
  }
@@ -704,7 +754,12 @@ export class Messenger {
704
754
  if (unseenOnly) criteria.push("UNSEEN");
705
755
 
706
756
  const query = criteria.join(" ");
707
- const socket = await this.imapConnect();
757
+ let socket: net.Socket | tls.TLSSocket;
758
+ try {
759
+ socket = await this.imapConnect();
760
+ } catch (err) {
761
+ throw imapFail("search", err);
762
+ }
708
763
  try {
709
764
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
710
765
  const searchResp = await imapCommand(socket, `SEARCH ${query}`);
@@ -718,6 +773,8 @@ export class Messenger {
718
773
  messages.push(parseHeaderResponse(uid, fetchResp));
719
774
  }
720
775
  return messages;
776
+ } catch (err) {
777
+ throw imapFail("search", err);
721
778
  } finally {
722
779
  await this.imapDisconnect(socket);
723
780
  }
@@ -754,11 +811,18 @@ export class Messenger {
754
811
  * Count unseen messages in a folder.
755
812
  */
756
813
  async unread(folder: string = "INBOX"): Promise<number> {
757
- const socket = await this.imapConnect();
814
+ let socket: net.Socket | tls.TLSSocket;
815
+ try {
816
+ socket = await this.imapConnect();
817
+ } catch (err) {
818
+ throw imapFail("unread", err);
819
+ }
758
820
  try {
759
821
  await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
760
822
  const searchResp = await imapCommand(socket, "SEARCH UNSEEN");
761
823
  return parseSearchResponse(searchResp).length;
824
+ } catch (err) {
825
+ throw imapFail("unread", err);
762
826
  } finally {
763
827
  await this.imapDisconnect(socket);
764
828
  }
@@ -768,7 +832,12 @@ export class Messenger {
768
832
  * List available IMAP folders/mailboxes.
769
833
  */
770
834
  async folders(): Promise<string[]> {
771
- const socket = await this.imapConnect();
835
+ let socket: net.Socket | tls.TLSSocket;
836
+ try {
837
+ socket = await this.imapConnect();
838
+ } catch (err) {
839
+ throw imapFail("folders", err);
840
+ }
772
841
  try {
773
842
  const resp = await imapCommand(socket, 'LIST "" "*"');
774
843
  const result: string[] = [];
@@ -778,6 +847,8 @@ export class Messenger {
778
847
  if (m) result.push(m[1]);
779
848
  }
780
849
  return result;
850
+ } catch (err) {
851
+ throw imapFail("folders", err);
781
852
  } finally {
782
853
  await this.imapDisconnect(socket);
783
854
  }
@@ -840,11 +911,20 @@ function imapCommand(socket: net.Socket | tls.TLSSocket, command: string): Promi
840
911
  let buffer = "";
841
912
  const onData = (chunk: Buffer) => {
842
913
  buffer += chunk.toString("utf-8");
843
- // Look for tagged response line indicating completion
844
- if (buffer.includes(`${tag} OK`) || buffer.includes(`${tag} NO`) || buffer.includes(`${tag} BAD`)) {
914
+ // Tagged OK = success.
915
+ if (buffer.includes(`${tag} OK`)) {
845
916
  socket.removeListener("data", onData);
846
917
  socket.removeListener("error", onError);
847
918
  resolve(buffer);
919
+ return;
920
+ }
921
+ // Tagged NO / BAD = protocol failure — fail loud (mirrors Python's
922
+ // non-OK status raise). A genuinely empty mailbox is a tagged OK with an
923
+ // empty SEARCH result, which still resolves above.
924
+ if (buffer.includes(`${tag} NO`) || buffer.includes(`${tag} BAD`)) {
925
+ socket.removeListener("data", onData);
926
+ socket.removeListener("error", onError);
927
+ reject(new MessengerConnectionError(`IMAP command failed: ${command.split(" ")[0]} → ${buffer.trim()}`));
848
928
  }
849
929
  };
850
930
 
@@ -859,6 +939,17 @@ function imapCommand(socket: net.Socket | tls.TLSSocket, command: string): Promi
859
939
  });
860
940
  }
861
941
 
942
+ /**
943
+ * Log an IMAP connection/protocol failure and return the error to throw.
944
+ * A genuinely empty mailbox is NOT an error and never reaches here.
945
+ */
946
+ function imapFail(method: string, err: unknown): MessengerConnectionError {
947
+ const e = err instanceof Error ? err : new Error(String(err));
948
+ Log.error(`Messenger IMAP ${method}() failed: ${e.name}: ${e.message}`);
949
+ if (e instanceof MessengerConnectionError) return e;
950
+ return new MessengerConnectionError(`IMAP ${method} failed: ${e.message}`);
951
+ }
952
+
862
953
  function parseSearchResponse(response: string): string[] {
863
954
  // SEARCH response: * SEARCH 1 2 3 4 5
864
955
  const match = response.match(/\* SEARCH (.+)/);
@@ -898,6 +989,15 @@ function parseHeaderResponse(uid: string, response: string): ImapMessage {
898
989
  };
899
990
  }
900
991
 
992
+ /**
993
+ * An empty full message — returned when a FETCH succeeds (tagged OK) but the
994
+ * UID does not exist, so there is no message body. Mirrors Python's {} return:
995
+ * a missing UID is NOT an error.
996
+ */
997
+ function emptyFullMessage(uid: string): ImapFullMessage {
998
+ return { uid, subject: "", from: "", to: "", cc: "", date: "", bodyText: "", bodyHtml: "", headers: {} };
999
+ }
1000
+
901
1001
  function parseFullMessage(uid: string, response: string): ImapFullMessage {
902
1002
  // Extract the raw message body from FETCH response
903
1003
  const bodyMatch = response.match(/\{(\d+)\}\r\n([\s\S]*)/);