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.
- package/CLAUDE.md +65 -20
- package/README.md +6 -6
- package/package.json +5 -3
- package/packages/cli/src/bin.ts +7 -0
- package/packages/cli/src/commands/init.ts +1 -0
- package/packages/cli/src/commands/metrics.ts +154 -0
- package/packages/cli/src/commands/routes.ts +3 -3
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +66 -44
- package/packages/core/src/devMailbox.ts +4 -0
- package/packages/core/src/dotenv.ts +13 -4
- package/packages/core/src/events.ts +86 -4
- package/packages/core/src/graphql.ts +182 -128
- package/packages/core/src/htmlElement.ts +62 -3
- package/packages/core/src/index.ts +21 -10
- package/packages/core/src/logger.ts +85 -28
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/mcp.ts +25 -8
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +557 -98
- package/packages/core/src/middleware.ts +130 -40
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
- package/packages/core/src/rateLimiter.ts +1 -1
- package/packages/core/src/response.ts +90 -6
- package/packages/core/src/router.ts +56 -8
- package/packages/core/src/server.ts +138 -23
- package/packages/core/src/session.ts +130 -18
- package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
- package/packages/core/src/testClient.ts +1 -1
- package/packages/core/src/types.ts +17 -2
- package/packages/core/src/websocket.ts +666 -42
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/core/src/wsdl.ts +55 -21
- package/packages/orm/src/adapters/pg-types.d.ts +60 -0
- package/packages/orm/src/adapters/postgres.ts +26 -4
- package/packages/orm/src/adapters/sqlite.ts +112 -13
- package/packages/orm/src/baseModel.ts +175 -25
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +6 -1
- package/packages/orm/src/migration.ts +151 -24
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- 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 = /\
|
|
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
|
-
*
|
|
140
|
-
*
|
|
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: "
|
|
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
|
-
|
|
179
|
+
fileEnabled: boolean;
|
|
175
180
|
} {
|
|
176
181
|
const logDir = process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
|
|
177
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
261
|
-
*
|
|
262
|
-
*
|
|
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 = (
|
|
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
|
|
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
|
|
417
|
-
//
|
|
418
|
-
//
|
|
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
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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.
|
|
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";
|
package/packages/core/src/mcp.ts
CHANGED
|
@@ -202,18 +202,35 @@ function envTruthy(val: string | undefined): boolean {
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
/**
|
|
205
|
-
* Whether the built-in MCP
|
|
205
|
+
* Whether the built-in MCP dev tools / `/__dev/mcp` endpoint should be enabled.
|
|
206
206
|
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
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
|
|
213
|
-
if (
|
|
214
|
-
return envTruthy(
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
844
|
-
if (buffer.includes(`${tag} OK`)
|
|
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]*)/);
|