tina4-nodejs 3.13.91 → 3.13.94

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 (135) hide show
  1. package/CLAUDE.md +16 -3
  2. package/README.md +1 -1
  3. package/package.json +12 -9
  4. package/packages/cli/dist/bin.js +1312 -987
  5. package/packages/core/dist/index.js +1312 -987
  6. package/packages/core/src/auth.ts +141 -21
  7. package/packages/core/src/devMailbox.ts +20 -44
  8. package/packages/core/src/index.ts +2 -2
  9. package/packages/core/src/messenger.ts +72 -0
  10. package/packages/core/src/queueBackends/kafkaBackend.ts +108 -12
  11. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  12. package/packages/core/src/sessionHandlers/mongoClient.ts +9 -3
  13. package/packages/core/src/sessionHandlers/redisHandler.ts +18 -5
  14. package/packages/core/src/sessionHandlers/respClient.ts +5 -1
  15. package/packages/frond/dist/index.js +74 -31
  16. package/packages/frond/src/engine.ts +99 -33
  17. package/packages/orm/dist/index.js +3102 -2777
  18. package/packages/orm/src/adapters/sqlite.ts +4 -1
  19. package/packages/orm/src/database.ts +108 -8
  20. package/types/cli/src/bin.d.ts +92 -0
  21. package/types/cli/src/commands/build.d.ts +2 -0
  22. package/types/cli/src/commands/generate.d.ts +47 -0
  23. package/types/cli/src/commands/init.d.ts +1 -0
  24. package/types/cli/src/commands/metrics.d.ts +6 -0
  25. package/types/cli/src/commands/migrate.d.ts +1 -0
  26. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  27. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  28. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  29. package/types/cli/src/commands/queue.d.ts +20 -0
  30. package/types/cli/src/commands/routes.d.ts +1 -0
  31. package/types/cli/src/commands/seed.d.ts +1 -0
  32. package/types/cli/src/commands/serve.d.ts +6 -0
  33. package/types/cli/src/commands/test.d.ts +1 -0
  34. package/types/core/src/ai.d.ts +64 -0
  35. package/types/core/src/api.d.ts +262 -0
  36. package/types/core/src/auth.d.ts +154 -0
  37. package/types/core/src/authGate.d.ts +20 -0
  38. package/types/core/src/background.d.ts +34 -0
  39. package/types/core/src/cache.d.ts +160 -0
  40. package/types/core/src/constants.d.ts +38 -0
  41. package/types/core/src/container.d.ts +44 -0
  42. package/types/core/src/context/chunker.d.ts +31 -0
  43. package/types/core/src/context/index.d.ts +93 -0
  44. package/types/core/src/devAdmin.d.ts +179 -0
  45. package/types/core/src/devMailbox.d.ts +54 -0
  46. package/types/core/src/docs.d.ts +141 -0
  47. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  48. package/types/core/src/dotenv.d.ts +65 -0
  49. package/types/core/src/env.d.ts +28 -0
  50. package/types/core/src/errorOverlay.d.ts +36 -0
  51. package/types/core/src/events.d.ts +75 -0
  52. package/types/core/src/fakeData.d.ts +55 -0
  53. package/types/core/src/feedback.d.ts +90 -0
  54. package/types/core/src/graphql.d.ts +207 -0
  55. package/types/core/src/health.d.ts +22 -0
  56. package/types/core/src/htmlElement.d.ts +75 -0
  57. package/types/core/src/i18n.d.ts +37 -0
  58. package/types/core/src/index.d.ts +93 -0
  59. package/types/core/src/job.d.ts +39 -0
  60. package/types/core/src/logger.d.ts +123 -0
  61. package/types/core/src/mcp.d.ts +248 -0
  62. package/types/core/src/messenger.d.ts +191 -0
  63. package/types/core/src/metrics.d.ts +77 -0
  64. package/types/core/src/middleware.d.ts +207 -0
  65. package/types/core/src/mqtt.d.ts +257 -0
  66. package/types/core/src/mqttMessage.d.ts +67 -0
  67. package/types/core/src/plan.d.ts +96 -0
  68. package/types/core/src/projectIndex.d.ts +56 -0
  69. package/types/core/src/queue.d.ts +219 -0
  70. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  71. package/types/core/src/queueBackends/liteBackend.d.ts +119 -0
  72. package/types/core/src/queueBackends/mongoBackend.d.ts +97 -0
  73. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  74. package/types/core/src/rateLimiter.d.ts +49 -0
  75. package/types/core/src/request.d.ts +25 -0
  76. package/types/core/src/response.d.ts +28 -0
  77. package/types/core/src/routeDiscovery.d.ts +12 -0
  78. package/types/core/src/router.d.ts +355 -0
  79. package/types/core/src/scss.d.ts +19 -0
  80. package/types/core/src/server.d.ts +131 -0
  81. package/types/core/src/service.d.ts +115 -0
  82. package/types/core/src/session.d.ts +256 -0
  83. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  84. package/types/core/src/sessionHandlers/databaseHandler.d.ts +42 -0
  85. package/types/core/src/sessionHandlers/mongoClient.d.ts +24 -0
  86. package/types/core/src/sessionHandlers/mongoHandler.d.ts +61 -0
  87. package/types/core/src/sessionHandlers/redisHandler.d.ts +60 -0
  88. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  89. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  90. package/types/core/src/static.d.ts +2 -0
  91. package/types/core/src/test.d.ts +94 -0
  92. package/types/core/src/testClient.d.ts +36 -0
  93. package/types/core/src/testing.d.ts +58 -0
  94. package/types/core/src/types.d.ts +219 -0
  95. package/types/core/src/validator.d.ts +52 -0
  96. package/types/core/src/websocket.d.ts +376 -0
  97. package/types/core/src/websocketBackplane.d.ts +166 -0
  98. package/types/core/src/websocketConnection.d.ts +54 -0
  99. package/types/core/src/wsdl.d.ts +101 -0
  100. package/types/frond/src/engine.d.ts +263 -0
  101. package/types/frond/src/index.d.ts +2 -0
  102. package/types/orm/src/adapters/firebird.d.ts +138 -0
  103. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  104. package/types/orm/src/adapters/mssql.d.ts +70 -0
  105. package/types/orm/src/adapters/mysql.d.ts +66 -0
  106. package/types/orm/src/adapters/odbc.d.ts +97 -0
  107. package/types/orm/src/adapters/postgres.d.ts +85 -0
  108. package/types/orm/src/adapters/sqlite.d.ts +56 -0
  109. package/types/orm/src/autoCrud.d.ts +73 -0
  110. package/types/orm/src/baseModel.d.ts +391 -0
  111. package/types/orm/src/cachedDatabase.d.ts +177 -0
  112. package/types/orm/src/database.d.ts +609 -0
  113. package/types/orm/src/databaseResult.d.ts +85 -0
  114. package/types/orm/src/docstore.d.ts +182 -0
  115. package/types/orm/src/fakeData.d.ts +22 -0
  116. package/types/orm/src/index.d.ts +40 -0
  117. package/types/orm/src/migration.d.ts +275 -0
  118. package/types/orm/src/model.d.ts +7 -0
  119. package/types/orm/src/query.d.ts +14 -0
  120. package/types/orm/src/queryBuilder.d.ts +173 -0
  121. package/types/orm/src/realtime/index.d.ts +7 -0
  122. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  123. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  124. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  125. package/types/orm/src/realtime/models/message.d.ts +36 -0
  126. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  127. package/types/orm/src/realtime/realtime.d.ts +24 -0
  128. package/types/orm/src/realtime/storage.d.ts +61 -0
  129. package/types/orm/src/seeder.d.ts +118 -0
  130. package/types/orm/src/sqlTranslator.d.ts +134 -0
  131. package/types/orm/src/types.d.ts +138 -0
  132. package/types/orm/src/validation.d.ts +6 -0
  133. package/types/swagger/src/generator.d.ts +46 -0
  134. package/types/swagger/src/index.d.ts +2 -0
  135. package/types/swagger/src/ui.d.ts +11 -0
@@ -1297,11 +1297,14 @@ var init_sqlite = __esm({
1297
1297
  const pragma = schema && isIdentifier(schema) && isIdentifier(tbl) ? `PRAGMA ${schema}.table_info("${tbl}")` : `PRAGMA table_info("${table2}")`;
1298
1298
  const rows = this.db.prepare(pragma).all();
1299
1299
  return rows.map((r) => ({
1300
+ // PRAGMA table_info reports `pk` as the 1-BASED POSITION within the primary
1301
+ // key, not a boolean: a composite key gives pk=1, pk=2, ... Testing `=== 1`
1302
+ // reported only the first column of a composite key.
1300
1303
  name: r.name,
1301
1304
  type: r.type,
1302
1305
  nullable: r.notnull === 0,
1303
1306
  default: r.dflt_value,
1304
- primaryKey: r.pk === 1
1307
+ primaryKey: Number(r.pk) > 0
1305
1308
  }));
1306
1309
  }
1307
1310
  lastInsertId() {
@@ -4297,6 +4300,8 @@ var init_database = __esm({
4297
4300
  poolIndex = 0;
4298
4301
  /** Factory for creating new adapters (used by pool) */
4299
4302
  adapterFactory = null;
4303
+ /** table -> primary-key column name (or null), introspected once */
4304
+ _pkCache = /* @__PURE__ */ new Map();
4300
4305
  /**
4301
4306
  * Whether a standalone write auto-commits. ON by default — a write made
4302
4307
  * outside an explicit transaction commits on its own connection before
@@ -4555,29 +4560,112 @@ var init_database = __esm({
4555
4560
  }
4556
4561
  return result;
4557
4562
  }
4558
- /** Update rows in a table matching filter. */
4563
+ /**
4564
+ * The table's primary-key column, introspected once and cached.
4565
+ *
4566
+ * Uses the cross-engine getColumns() contract (v3.13.14, #48), which reports
4567
+ * primaryKey per column on every adapter. Resolves to null when the table has
4568
+ * no primary key or cannot be introspected.
4569
+ */
4570
+ async primaryKey(table2) {
4571
+ if (!this._pkCache.has(table2)) {
4572
+ let pk = [];
4573
+ try {
4574
+ const columns = await this.getColumns(table2);
4575
+ pk = columns.filter((c) => c.primaryKey).map((c) => c.name);
4576
+ } catch {
4577
+ pk = [];
4578
+ }
4579
+ this._pkCache.set(table2, pk);
4580
+ }
4581
+ return this._pkCache.get(table2) ?? [];
4582
+ }
4583
+ /**
4584
+ * A failed write must be loud.
4585
+ *
4586
+ * The adapters catch a SQL error and return { success: false, affectedRows: 0 },
4587
+ * so a filterless update produced invalid SQL ("... WHERE ") and reported
4588
+ * nothing rather than raising. A caller who does not inspect the result
4589
+ * believes the write landed (audit feature 4, P1).
4590
+ */
4591
+ static assertWrote(result, verb, table2) {
4592
+ if (result && result.success === false) {
4593
+ throw new Error(
4594
+ `${verb} failed on ${table2}: ${result.error ?? "unknown error"}`
4595
+ );
4596
+ }
4597
+ return result;
4598
+ }
4599
+ /**
4600
+ * Update rows. A write with no filter is an error, not a full-table write.
4601
+ *
4602
+ * With no explicit filter the primary key is taken out of `data` and used as
4603
+ * the WHERE clause. With neither a filter nor a primary key in `data` this
4604
+ * throws rather than silently changing nothing (audit feature 4, P1).
4605
+ */
4559
4606
  async update(table2, data, filter, params) {
4607
+ let effectiveFilter = filter ?? {};
4608
+ let effectiveData = data;
4609
+ if (Object.keys(effectiveFilter).length === 0) {
4610
+ const pkColumns = await this.primaryKey(table2);
4611
+ const missing = pkColumns.filter((c) => !(c in data));
4612
+ if (pkColumns.length === 0 || missing.length > 0) {
4613
+ throw new Error(
4614
+ `update requires a filter or the complete primary key in the data; pass filter explicitly to update multiple rows (table=${table2}, primary key=[${pkColumns.join(", ")}], missing from data=[${missing.join(", ")}]). To empty a table use truncate(${table2}).`
4615
+ );
4616
+ }
4617
+ effectiveData = { ...data };
4618
+ const keyed = {};
4619
+ for (const col of pkColumns) {
4620
+ keyed[col] = effectiveData[col];
4621
+ delete effectiveData[col];
4622
+ }
4623
+ if (Object.keys(effectiveData).length === 0) {
4624
+ throw new Error(
4625
+ `update was given only the primary key [${pkColumns.join(", ")}] and no columns to set (table=${table2})`
4626
+ );
4627
+ }
4628
+ effectiveFilter = keyed;
4629
+ }
4560
4630
  const adapter = this.getNextAdapter();
4561
- const result = adapter.updateAsync ? await adapter.updateAsync(table2, data, filter ?? {}, params) : adapter.update(table2, data, filter ?? {}, params);
4631
+ const result = adapter.updateAsync ? await adapter.updateAsync(table2, effectiveData, effectiveFilter, params) : adapter.update(table2, effectiveData, effectiveFilter, params);
4562
4632
  if (this.autoCommit && !this.inExplicitTransaction()) {
4563
4633
  try {
4564
4634
  await adapterCommit(adapter);
4565
4635
  } catch {
4566
4636
  }
4567
4637
  }
4568
- return result;
4638
+ return _Database.assertWrote(result, "update", table2);
4569
4639
  }
4570
- /** Delete rows from a table matching filter. */
4640
+ /** Delete rows. A filterless delete throws; use truncate() to empty a table. */
4571
4641
  async delete(table2, filter, params) {
4642
+ const effectiveFilter = filter ?? {};
4643
+ if (!Array.isArray(effectiveFilter) && typeof effectiveFilter !== "string" && Object.keys(effectiveFilter).length === 0) {
4644
+ throw new Error(
4645
+ `delete requires a filter (table=${table2}). To remove every row use truncate(${table2}).`
4646
+ );
4647
+ }
4572
4648
  const adapter = this.getNextAdapter();
4573
- const result = adapter.deleteAsync ? await adapter.deleteAsync(table2, filter ?? {}, params) : adapter.delete(table2, filter ?? {}, params);
4649
+ const result = adapter.deleteAsync ? await adapter.deleteAsync(table2, effectiveFilter, params) : adapter.delete(table2, effectiveFilter, params);
4574
4650
  if (this.autoCommit && !this.inExplicitTransaction()) {
4575
4651
  try {
4576
4652
  await adapterCommit(adapter);
4577
4653
  } catch {
4578
4654
  }
4579
4655
  }
4580
- return result;
4656
+ return _Database.assertWrote(result, "delete", table2);
4657
+ }
4658
+ /** Remove every row. The explicit spelling of a whole-table delete. */
4659
+ async truncate(table2) {
4660
+ const adapter = this.getNextAdapter();
4661
+ const result = adapter.deleteAsync ? await adapter.deleteAsync(table2, "1 = 1", []) : adapter.delete(table2, "1 = 1", []);
4662
+ if (this.autoCommit && !this.inExplicitTransaction()) {
4663
+ try {
4664
+ await adapterCommit(adapter);
4665
+ } catch {
4666
+ }
4667
+ }
4668
+ return _Database.assertWrote(result, "truncate", table2);
4581
4669
  }
4582
4670
  /** Close all database connections (pool or single). */
4583
4671
  close() {
@@ -12121,6 +12209,7 @@ var init_logger = __esm({
12121
12209
  var auth_exports = {};
12122
12210
  __export(auth_exports, {
12123
12211
  Auth: () => Auth,
12212
+ JWT_LEEWAY_SECONDS: () => JWT_LEEWAY_SECONDS,
12124
12213
  authMiddleware: () => authMiddleware,
12125
12214
  authenticateRequest: () => authenticateRequest,
12126
12215
  checkPassword: () => checkPassword,
@@ -12129,6 +12218,7 @@ __export(auth_exports, {
12129
12218
  getToken: () => getToken,
12130
12219
  hashPassword: () => hashPassword,
12131
12220
  refreshToken: () => refreshToken,
12221
+ resolveAlgorithm: () => resolveAlgorithm,
12132
12222
  validToken: () => validToken,
12133
12223
  validateApiKey: () => validateApiKey
12134
12224
  });
@@ -12189,6 +12279,18 @@ function ensureDevSecret(cwd) {
12189
12279
  }
12190
12280
  return newSecret;
12191
12281
  }
12282
+ function unsupportedAlgorithmError(algorithm) {
12283
+ return new Error(
12284
+ `Unsupported JWT algorithm "${algorithm}". Tina4 signs with ${SUPPORTED_ALGORITHMS.join(", ")} (HMAC via node:crypto; RS256 needs a PEM key pair). Set TINA4_JWT_ALGORITHM to one of those.`
12285
+ );
12286
+ }
12287
+ function resolveAlgorithm(algorithm) {
12288
+ const chosen = (algorithm || process.env.TINA4_JWT_ALGORITHM || "HS256").trim();
12289
+ if (!HMAC_DIGESTS.has(chosen) && !RSA_SIGN_ALGORITHMS.has(chosen)) {
12290
+ throw unsupportedAlgorithmError(chosen);
12291
+ }
12292
+ return chosen;
12293
+ }
12192
12294
  function base64urlEncode(data) {
12193
12295
  return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
12194
12296
  }
@@ -12211,7 +12313,7 @@ function getToken(payload, secretOrExpiresIn, expiresIn = 60, algorithm) {
12211
12313
  if (!resolvedSecret) {
12212
12314
  _warnBlankSecret();
12213
12315
  }
12214
- const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
12316
+ const resolvedAlgorithm = resolveAlgorithm(algorithm);
12215
12317
  const header = { alg: resolvedAlgorithm, typ: "JWT" };
12216
12318
  const now = Math.floor(Date.now() / 1e3);
12217
12319
  const claims = { ...payload, iat: now };
@@ -12229,19 +12331,27 @@ function validToken(token, secret, algorithm) {
12229
12331
  if (!resolvedSecret) {
12230
12332
  _warnBlankSecret();
12231
12333
  }
12232
- const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
12334
+ const resolvedAlgorithm = resolveAlgorithm(algorithm);
12233
12335
  try {
12234
12336
  const parts = token.split(".");
12235
12337
  if (parts.length !== 3) return null;
12236
12338
  const [h, p, sig] = parts;
12339
+ const header = JSON.parse(base64urlDecode(h).toString());
12340
+ if (header.alg !== resolvedAlgorithm) return null;
12237
12341
  const signingInput = `${h}.${p}`;
12238
12342
  if (!verifySignature(signingInput, sig, resolvedSecret, resolvedAlgorithm)) {
12239
12343
  return null;
12240
12344
  }
12241
12345
  const payload = JSON.parse(base64urlDecode(p).toString());
12242
- if (typeof payload.exp === "number" && Date.now() / 1e3 > payload.exp) {
12346
+ const now = Date.now() / 1e3;
12347
+ if (typeof payload.exp === "number" && now > payload.exp) {
12243
12348
  return null;
12244
12349
  }
12350
+ if (Object.hasOwn(payload, "nbf")) {
12351
+ const notBefore = payload.nbf;
12352
+ if (typeof notBefore !== "number" || !Number.isFinite(notBefore)) return null;
12353
+ if (now + JWT_LEEWAY_SECONDS < notBefore) return null;
12354
+ }
12245
12355
  return payload;
12246
12356
  } catch {
12247
12357
  return null;
@@ -12257,32 +12367,33 @@ function getPayload(token) {
12257
12367
  }
12258
12368
  }
12259
12369
  function sign(input, secret, algorithm) {
12260
- if (algorithm === "HS256") {
12261
- const sig = createHmac2("sha256", secret).update(input).digest();
12262
- return base64urlEncode(sig);
12370
+ const digest = HMAC_DIGESTS.get(algorithm);
12371
+ if (digest) {
12372
+ return base64urlEncode(createHmac2(digest, secret).update(input).digest());
12263
12373
  }
12264
- if (algorithm === "RS256") {
12265
- const signer = createSign("RSA-SHA256");
12374
+ const rsaAlgorithm = RSA_SIGN_ALGORITHMS.get(algorithm);
12375
+ if (rsaAlgorithm) {
12376
+ const signer = createSign(rsaAlgorithm);
12266
12377
  signer.update(input);
12267
- const sig = signer.sign(secret);
12268
- return base64urlEncode(sig);
12378
+ return base64urlEncode(signer.sign(secret));
12269
12379
  }
12270
- throw new Error(`Unsupported algorithm: ${algorithm}`);
12380
+ throw unsupportedAlgorithmError(algorithm);
12271
12381
  }
12272
12382
  function verifySignature(input, sig, secret, algorithm) {
12273
- if (algorithm === "HS256") {
12383
+ if (HMAC_DIGESTS.has(algorithm)) {
12274
12384
  const expected = sign(input, secret, algorithm);
12275
12385
  const a = Buffer.from(sig);
12276
12386
  const b = Buffer.from(expected);
12277
12387
  if (a.length !== b.length) return false;
12278
12388
  return timingSafeEqual(a, b);
12279
12389
  }
12280
- if (algorithm === "RS256") {
12281
- const verifier = createVerify("RSA-SHA256");
12390
+ const rsaAlgorithm = RSA_SIGN_ALGORITHMS.get(algorithm);
12391
+ if (rsaAlgorithm) {
12392
+ const verifier = createVerify(rsaAlgorithm);
12282
12393
  verifier.update(input);
12283
12394
  return verifier.verify(secret, base64urlDecode(sig));
12284
12395
  }
12285
- throw new Error(`Unsupported algorithm: ${algorithm}`);
12396
+ throw unsupportedAlgorithmError(algorithm);
12286
12397
  }
12287
12398
  function hashPassword(password, salt, iterations = 26e4) {
12288
12399
  const actualSalt = salt ?? randomBytes3(16).toString("hex");
@@ -12307,7 +12418,7 @@ function checkPassword(password, hash) {
12307
12418
  return false;
12308
12419
  }
12309
12420
  }
12310
- function authMiddleware(secret, algorithm = "HS256") {
12421
+ function authMiddleware(secret, algorithm) {
12311
12422
  return (req2, res, next) => {
12312
12423
  const authHeader = req2.headers.authorization ?? "";
12313
12424
  if (!authHeader.startsWith("Bearer ")) {
@@ -12330,11 +12441,11 @@ function refreshToken(token, expiresIn = 60) {
12330
12441
  const { iat: _iat, exp: _exp, ...claims } = payload;
12331
12442
  return getToken(claims, expiresIn);
12332
12443
  }
12333
- function authenticateRequest(headers, secret, algorithm = "HS256") {
12444
+ function authenticateRequest(headers, secret, algorithm) {
12334
12445
  const authHeader = headers.authorization ?? headers.Authorization ?? "";
12335
12446
  if (!authHeader.startsWith("Bearer ")) return null;
12336
12447
  const token = authHeader.slice(7);
12337
- if (validToken(token)) return getPayload(token);
12448
+ if (validToken(token, secret, algorithm)) return getPayload(token);
12338
12449
  if (validateApiKey(token)) {
12339
12450
  return { _auth: "api_key" };
12340
12451
  }
@@ -12348,12 +12459,23 @@ function validateApiKey(provided, expected) {
12348
12459
  if (a.length !== b.length) return false;
12349
12460
  return timingSafeEqual(a, b);
12350
12461
  }
12351
- var BLANK_SECRET_WARNING, Auth;
12462
+ var BLANK_SECRET_WARNING, HMAC_DIGESTS, RSA_SIGN_ALGORITHMS, SUPPORTED_ALGORITHMS, JWT_LEEWAY_SECONDS, Auth;
12352
12463
  var init_auth = __esm({
12353
12464
  "src/auth.ts"() {
12354
12465
  "use strict";
12355
12466
  init_dotenv();
12356
12467
  BLANK_SECRET_WARNING = "Auth: TINA4_SECRET is not set \u2014 JWT signing is insecure. Set TINA4_SECRET to a random value (e.g. `openssl rand -hex 32`) in your environment or .env before serving traffic. For LOCAL DEV, set TINA4_DEBUG=true and a per-machine secret is generated automatically into .env.local (gitignored). Seeing this warning means the run was NOT detected as dev \u2014 typically a container or CI without TINA4_DEBUG set, or TINA4_ENV=production.";
12468
+ HMAC_DIGESTS = /* @__PURE__ */ new Map([
12469
+ ["HS256", "sha256"],
12470
+ ["HS384", "sha384"],
12471
+ ["HS512", "sha512"]
12472
+ ]);
12473
+ RSA_SIGN_ALGORITHMS = /* @__PURE__ */ new Map([["RS256", "RSA-SHA256"]]);
12474
+ SUPPORTED_ALGORITHMS = [
12475
+ ...HMAC_DIGESTS.keys(),
12476
+ ...RSA_SIGN_ALGORITHMS.keys()
12477
+ ];
12478
+ JWT_LEEWAY_SECONDS = 60;
12357
12479
  Auth = class {
12358
12480
  static getToken = getToken;
12359
12481
  static validToken = validToken;
@@ -13792,7 +13914,7 @@ function _generateFormToken(descriptor = "") {
13792
13914
  function _generateFormTokenValue(descriptor = "") {
13793
13915
  return new SafeString(_buildFormTokenJwt(descriptor));
13794
13916
  }
13795
- var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
13917
+ var SafeString, KNOWN_TAGS, TERMINATOR_TAGS, GATEABLE_TAGS, BLOCK_TAG_ENDS, JSON_UNSAFE_RE, JSON_UNSAFE_MAP, NUMERIC_RE, METHOD_CALL_RE, FN_CALL_RE, IS_NOT_RE, IS_RE, NOT_IN_RE, IN_RE, DIVISIBLE_BY_RE, FILTER_WITH_ARGS_RE, FILTER_COMPARISON_RE, TITLE_WORD_RE, STRIP_TAGS_RE, FORMAT_RE, LEADING_WS_RE, TRAILING_WS_RE, THOUSANDS_RE, LIVE_RE, LIVE_WS_RE, LIVE_SRC_RE, filterChainCache, pathParseCache, TEMPLATE_CACHE_MAX, TOKEN_RE, RAW_BLOCK_RE, VarRef, BUILTIN_FILTERS, _formTokenSessionId, Frond;
13796
13918
  var init_engine = __esm({
13797
13919
  "../frond/src/engine.ts"() {
13798
13920
  "use strict";
@@ -13835,6 +13957,29 @@ var init_engine = __esm({
13835
13957
  "endset",
13836
13958
  "endspaceless"
13837
13959
  ]);
13960
+ GATEABLE_TAGS = /* @__PURE__ */ new Set([
13961
+ "autoescape",
13962
+ "cache",
13963
+ "for",
13964
+ "from",
13965
+ "if",
13966
+ "import",
13967
+ "include",
13968
+ "live",
13969
+ "macro",
13970
+ "set",
13971
+ "spaceless"
13972
+ ]);
13973
+ BLOCK_TAG_ENDS = {
13974
+ autoescape: "endautoescape",
13975
+ cache: "endcache",
13976
+ for: "endfor",
13977
+ if: "endif",
13978
+ live: "endlive",
13979
+ macro: "endmacro",
13980
+ set: "endset",
13981
+ spaceless: "endspaceless"
13982
+ };
13838
13983
  JSON_UNSAFE_RE = /[<>&'\u2028\u2029]/g;
13839
13984
  JSON_UNSAFE_MAP = {
13840
13985
  "<": "\\u003c",
@@ -14470,42 +14615,27 @@ var init_engine = __esm({
14470
14615
  if (stripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
14471
14616
  tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
14472
14617
  }
14473
- if (tag === "if") {
14474
- if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("if")) {
14475
- const skip = this.skipBlock(tokens, i, "if", "endif");
14476
- i = skip;
14477
- } else {
14478
- const [result, skip] = this.handleIf(tokens, i, context);
14479
- output.push(result);
14480
- i = skip;
14481
- }
14618
+ if (!this.tagPermitted(tag)) {
14619
+ i = this.skipDeniedTag(tokens, i, tag, content);
14620
+ } else if (tag === "if") {
14621
+ const [result, skip] = this.handleIf(tokens, i, context);
14622
+ output.push(result);
14623
+ i = skip;
14482
14624
  } else if (tag === "for") {
14483
- if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("for")) {
14484
- const skip = this.skipBlock(tokens, i, "for", "endfor");
14485
- i = skip;
14486
- } else {
14487
- const [result, skip] = this.handleFor(tokens, i, context);
14488
- output.push(result);
14489
- i = skip;
14490
- }
14625
+ const [result, skip] = this.handleFor(tokens, i, context);
14626
+ output.push(result);
14627
+ i = skip;
14491
14628
  } else if (tag === "set") {
14492
- const isBlockSet = !content.includes("=");
14493
- if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
14494
- i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
14495
- } else if (isBlockSet) {
14629
+ if (!content.includes("=")) {
14496
14630
  i = this.handleSetBlock(tokens, i, context);
14497
14631
  } else {
14498
14632
  this.handleSet(content, context);
14499
14633
  i++;
14500
14634
  }
14501
14635
  } else if (tag === "include") {
14502
- if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("include")) {
14503
- i++;
14504
- } else {
14505
- const result = this.handleInclude(content, context);
14506
- output.push(result);
14507
- i++;
14508
- }
14636
+ const result = this.handleInclude(content, context);
14637
+ output.push(result);
14638
+ i++;
14509
14639
  } else if (tag === "macro") {
14510
14640
  const skip = this.handleMacro(tokens, i, context);
14511
14641
  i = skip;
@@ -14550,6 +14680,41 @@ var init_engine = __esm({
14550
14680
  }
14551
14681
  return output.join("");
14552
14682
  }
14683
+ /**
14684
+ * May this filter RUN under the current sandbox?
14685
+ *
14686
+ * The escaping decision has to ask this rather than read the filter name out of
14687
+ * the source. Node carries safety as a FLAG rather than as a value-level marker
14688
+ * (Python and Ruby return a SafeString, PHP prepends a RAW_MARKER -- all three
14689
+ * produced only by actually running the filter), so here the name alone was
14690
+ * enough to suppress auto-escaping even when the filter was denied and skipped.
14691
+ */
14692
+ filterPermitted(name) {
14693
+ if (!this._sandbox || this._allowedFilters === null) return true;
14694
+ return this._allowedFilters.has(name);
14695
+ }
14696
+ /**
14697
+ * May this tag run under the current sandbox?
14698
+ *
14699
+ * One gate for every tag, so the allow-list governs the whole tag vocabulary
14700
+ * instead of the four names that happened to be checked individually.
14701
+ */
14702
+ tagPermitted(tag) {
14703
+ if (!this._sandbox || this._allowedTags === null) return true;
14704
+ if (!GATEABLE_TAGS.has(tag)) return true;
14705
+ return this._allowedTags.has(tag);
14706
+ }
14707
+ /**
14708
+ * Consume a denied tag WITHOUT running it, returning the index past its body.
14709
+ *
14710
+ * Advancing a single token past a body-owning tag would leave the body's tokens
14711
+ * to render at the TOP level, leaking exactly the content the sandbox denied.
14712
+ */
14713
+ skipDeniedTag(tokens, start2, tag, content) {
14714
+ const closeTag = BLOCK_TAG_ENDS[tag];
14715
+ if (closeTag === void 0 || tag === "set" && content.includes("=")) return start2 + 1;
14716
+ return this.skipBlock(tokens, start2, tag, closeTag);
14717
+ }
14553
14718
  skipBlock(tokens, start2, openTag, closeTag) {
14554
14719
  let depth = 0;
14555
14720
  let i = start2 + 1;
@@ -14557,7 +14722,7 @@ var init_engine = __esm({
14557
14722
  if (tokens[i][0] === "BLOCK") {
14558
14723
  const [content] = stripTag(tokens[i][1]);
14559
14724
  const tag = content.split(/\s+/)[0] || "";
14560
- if (tag === openTag) depth++;
14725
+ if (tag === openTag && !(openTag === "set" && content.includes("="))) depth++;
14561
14726
  else if (tag === closeTag) {
14562
14727
  if (depth === 0) return i + 1;
14563
14728
  depth--;
@@ -14742,11 +14907,11 @@ var init_engine = __esm({
14742
14907
  for (const [fname, rawArgs] of filters) {
14743
14908
  const args = rawArgs.map((a) => a instanceof VarRef ? evalExpr(a.name, context) : a);
14744
14909
  if (fname === "raw" || fname === "safe") {
14745
- isSafe = true;
14910
+ if (this.filterPermitted(fname)) isSafe = true;
14746
14911
  continue;
14747
14912
  }
14748
14913
  if (fname === "escape" || fname === "e") {
14749
- isSafe = true;
14914
+ if (this.filterPermitted(fname)) isSafe = true;
14750
14915
  }
14751
14916
  if (this._sandbox && this._allowedFilters !== null) {
14752
14917
  if (!this._allowedFilters.has(fname)) {
@@ -16557,858 +16722,128 @@ var init_rateLimiter = __esm({
16557
16722
  }
16558
16723
  });
16559
16724
 
16560
- // src/messenger.ts
16561
- import net2 from "node:net";
16562
- import tls from "node:tls";
16563
- import { readFileSync as readFileSync8 } from "node:fs";
16564
- import { basename as basename2 } from "node:path";
16725
+ // src/devMailbox.ts
16726
+ import { mkdirSync as mkdirSync8, readdirSync as readdirSync6, readFileSync as readFileSync8, writeFileSync as writeFileSync6, unlinkSync as unlinkSync4, existsSync as existsSync10 } from "node:fs";
16727
+ import { join as join12 } from "node:path";
16565
16728
  import { randomUUID as randomUUID2 } from "node:crypto";
16566
- function tlsRejectUnauthorized() {
16567
- return !isTruthy(process.env.TINA4_MAIL_TLS_INSECURE);
16568
- }
16569
- function readResponse(socket) {
16570
- return new Promise((resolve21, reject) => {
16571
- let buffer = "";
16572
- const onData = (chunk) => {
16573
- buffer += chunk.toString("utf-8");
16574
- const lines = buffer.split("\r\n");
16575
- for (let i = 0; i < lines.length; i++) {
16576
- const line = lines[i];
16577
- if (line.length < 3) continue;
16578
- const code = parseInt(line.substring(0, 3), 10);
16579
- if (line.length >= 4 && line[3] === " ") {
16580
- socket.removeListener("data", onData);
16581
- socket.removeListener("error", onError);
16582
- resolve21({ code, text: buffer.trim() });
16583
- return;
16584
- }
16585
- }
16586
- };
16587
- const onError = (err) => {
16588
- socket.removeListener("data", onData);
16589
- reject(err);
16590
- };
16591
- socket.on("data", onData);
16592
- socket.on("error", onError);
16593
- });
16594
- }
16595
- function sendCommand(socket, command) {
16596
- return new Promise((resolve21, reject) => {
16597
- socket.write(command + "\r\n", "utf-8", (err) => {
16598
- if (err) return reject(err);
16599
- readResponse(socket).then(resolve21, reject);
16600
- });
16601
- });
16602
- }
16603
- function buildMimeMessage(options) {
16604
- const boundary = `----=_Tina4_${Date.now()}_${Math.random().toString(36).substring(2)}`;
16605
- const altBoundary = `----=_Tina4Alt_${Date.now()}_${Math.random().toString(36).substring(2)}`;
16606
- const hasAttachments = options.attachments && options.attachments.length > 0;
16607
- const hasTextAlt = options.text !== void 0 && options.html;
16608
- const lines = [];
16609
- const fromHeader = options.fromName ? `"${options.fromName}" <${options.from}>` : options.from;
16610
- lines.push(`From: ${fromHeader}`);
16611
- lines.push(`To: ${options.to.join(", ")}`);
16612
- if (options.cc.length > 0) {
16613
- lines.push(`Cc: ${options.cc.join(", ")}`);
16614
- }
16615
- lines.push(`Subject: ${options.subject}`);
16616
- lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
16617
- lines.push(`Message-ID: <${options.messageId}>`);
16618
- lines.push("MIME-Version: 1.0");
16619
- if (options.replyTo) {
16620
- lines.push(`Reply-To: ${options.replyTo}`);
16621
- }
16622
- if (options.headers) {
16623
- for (const [key, value] of Object.entries(options.headers)) {
16624
- lines.push(`${key}: ${value}`);
16625
- }
16626
- }
16627
- if (hasAttachments) {
16628
- lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
16629
- lines.push("");
16630
- lines.push(`--${boundary}`);
16631
- if (hasTextAlt) {
16632
- lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
16633
- lines.push("");
16634
- lines.push(`--${altBoundary}`);
16635
- lines.push("Content-Type: text/plain; charset=UTF-8");
16636
- lines.push("Content-Transfer-Encoding: 7bit");
16637
- lines.push("");
16638
- lines.push(options.text);
16639
- lines.push("");
16640
- lines.push(`--${altBoundary}`);
16641
- lines.push("Content-Type: text/html; charset=UTF-8");
16642
- lines.push("Content-Transfer-Encoding: 7bit");
16643
- lines.push("");
16644
- lines.push(options.body);
16645
- lines.push("");
16646
- lines.push(`--${altBoundary}--`);
16647
- } else {
16648
- const contentType = options.html ? "text/html" : "text/plain";
16649
- lines.push(`Content-Type: ${contentType}; charset=UTF-8`);
16650
- lines.push("Content-Transfer-Encoding: 7bit");
16651
- lines.push("");
16652
- lines.push(options.body);
16653
- }
16654
- for (const filePath of options.attachments) {
16655
- const fileName = basename2(filePath);
16656
- const fileData = readFileSync8(filePath);
16657
- const base64Data = fileData.toString("base64");
16658
- lines.push("");
16659
- lines.push(`--${boundary}`);
16660
- lines.push(`Content-Type: application/octet-stream; name="${fileName}"`);
16661
- lines.push("Content-Transfer-Encoding: base64");
16662
- lines.push(`Content-Disposition: attachment; filename="${fileName}"`);
16663
- lines.push("");
16664
- for (let i = 0; i < base64Data.length; i += 76) {
16665
- lines.push(base64Data.substring(i, i + 76));
16666
- }
16667
- }
16668
- lines.push("");
16669
- lines.push(`--${boundary}--`);
16670
- } else if (hasTextAlt) {
16671
- lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
16672
- lines.push("");
16673
- lines.push(`--${altBoundary}`);
16674
- lines.push("Content-Type: text/plain; charset=UTF-8");
16675
- lines.push("Content-Transfer-Encoding: 7bit");
16676
- lines.push("");
16677
- lines.push(options.text);
16678
- lines.push("");
16679
- lines.push(`--${altBoundary}`);
16680
- lines.push("Content-Type: text/html; charset=UTF-8");
16681
- lines.push("Content-Transfer-Encoding: 7bit");
16682
- lines.push("");
16683
- lines.push(options.body);
16684
- lines.push("");
16685
- lines.push(`--${altBoundary}--`);
16686
- } else {
16687
- const contentType = options.html ? "text/html" : "text/plain";
16688
- lines.push(`Content-Type: ${contentType}; charset=UTF-8`);
16689
- lines.push("");
16690
- lines.push(options.body);
16691
- }
16692
- return lines.join("\r\n");
16693
- }
16694
- function imapQuote(s) {
16695
- if (/^[a-zA-Z0-9_./-]+$/.test(s)) return s;
16696
- return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
16697
- }
16698
- function imapReadLine(socket) {
16699
- return new Promise((resolve21, reject) => {
16700
- let buffer = "";
16701
- const onData = (chunk) => {
16702
- buffer += chunk.toString("utf-8");
16703
- const nlIndex = buffer.indexOf("\r\n");
16704
- if (nlIndex !== -1) {
16705
- socket.removeListener("data", onData);
16706
- socket.removeListener("error", onError);
16707
- resolve21(buffer);
16729
+ var DevMailbox;
16730
+ var init_devMailbox = __esm({
16731
+ "src/devMailbox.ts"() {
16732
+ "use strict";
16733
+ DevMailbox = class {
16734
+ mailboxDir;
16735
+ constructor(mailboxDir) {
16736
+ this.mailboxDir = mailboxDir ?? process.env.TINA4_MAILBOX_DIR ?? "data/mailbox";
16708
16737
  }
16709
- };
16710
- const onError = (err) => {
16711
- socket.removeListener("data", onData);
16712
- reject(err);
16713
- };
16714
- socket.on("data", onData);
16715
- socket.on("error", onError);
16716
- });
16717
- }
16718
- function imapCommand(socket, command) {
16719
- return new Promise((resolve21, reject) => {
16720
- imapTagCounter++;
16721
- const tag = `T${imapTagCounter}`;
16722
- const fullCommand = `${tag} ${command}\r
16723
- `;
16724
- let buffer = "";
16725
- const onData = (chunk) => {
16726
- buffer += chunk.toString("utf-8");
16727
- if (buffer.includes(`${tag} OK`)) {
16728
- socket.removeListener("data", onData);
16729
- socket.removeListener("error", onError);
16730
- resolve21(buffer);
16731
- return;
16738
+ /**
16739
+ * Ensure a folder directory exists.
16740
+ */
16741
+ ensureFolder(folder) {
16742
+ const dir = join12(this.mailboxDir, folder);
16743
+ mkdirSync8(dir, { recursive: true });
16744
+ return dir;
16732
16745
  }
16733
- if (buffer.includes(`${tag} NO`) || buffer.includes(`${tag} BAD`)) {
16734
- socket.removeListener("data", onData);
16735
- socket.removeListener("error", onError);
16736
- reject(new MessengerConnectionError(`IMAP command failed: ${command.split(" ")[0]} \u2192 ${buffer.trim()}`));
16746
+ /**
16747
+ * Capture an email to the dev mailbox instead of sending it.
16748
+ *
16749
+ * The parameter order MATCHES Messenger.send() on purpose. It did not before:
16750
+ * send()'s 5th positional was `text` and capture()'s was `cc`, so the same call
16751
+ * meant different things depending on which door it came through -- that mismatch
16752
+ * IS nodejs#42.
16753
+ *
16754
+ * BREAKING: `text` is now the 5th positional. A caller passing cc positionally
16755
+ * must move it. Aligning the two signatures is the fix; leaving them apart would
16756
+ * preserve the bug.
16757
+ */
16758
+ capture(to, subject, body, html = false, text, cc = [], bcc = [], replyTo, attachments = [], from) {
16759
+ const id = randomUUID2();
16760
+ const toList = Array.isArray(to) ? to : [to];
16761
+ const ccList = Array.isArray(cc) ? cc : cc ? [cc] : [];
16762
+ const bccList = Array.isArray(bcc) ? bcc : bcc ? [bcc] : [];
16763
+ const now = (/* @__PURE__ */ new Date()).toISOString();
16764
+ const message = {
16765
+ id,
16766
+ type: "outbox",
16767
+ from: from ?? process.env.TINA4_MAIL_FROM ?? "dev@localhost",
16768
+ to: toList,
16769
+ cc: ccList,
16770
+ bcc: bccList,
16771
+ reply_to: replyTo,
16772
+ subject,
16773
+ body,
16774
+ text,
16775
+ html,
16776
+ attachments,
16777
+ date: now,
16778
+ read: false
16779
+ };
16780
+ const outboxDir = this.ensureFolder("outbox");
16781
+ writeFileSync6(join12(outboxDir, `${id}.json`), JSON.stringify(message, null, 2));
16782
+ const inboxDir = this.ensureFolder("inbox");
16783
+ const inboxMessage = { ...message, type: "inbox" };
16784
+ writeFileSync6(join12(inboxDir, `${id}.json`), JSON.stringify(inboxMessage, null, 2));
16785
+ return { success: true, message: "Email captured to dev mailbox", id };
16737
16786
  }
16738
- };
16739
- const onError = (err) => {
16740
- socket.removeListener("data", onData);
16741
- reject(err);
16742
- };
16743
- socket.on("data", onData);
16744
- socket.on("error", onError);
16745
- socket.write(fullCommand, "utf-8");
16746
- });
16747
- }
16748
- function imapFail(method, err) {
16749
- const e = err instanceof Error ? err : new Error(String(err));
16750
- Log.error(`Messenger IMAP ${method}() failed: ${e.name}: ${e.message}`);
16751
- if (e instanceof MessengerConnectionError) return e;
16752
- return new MessengerConnectionError(`IMAP ${method} failed: ${e.message}`);
16753
- }
16754
- function parseSearchResponse(response) {
16755
- const match = response.match(/\* SEARCH (.+)/);
16756
- if (!match) return [];
16757
- return match[1].trim().split(/\s+/).filter((s) => /^\d+$/.test(s));
16758
- }
16759
- function parseHeaderResponse(uid, response) {
16760
- const headers = {};
16761
- const headerBlock = response.match(/\r\n([\s\S]*?)\r\n\)/);
16762
- if (headerBlock) {
16763
- const lines = headerBlock[1].split(/\r\n/);
16764
- let currentKey = "";
16765
- for (const line of lines) {
16766
- if (/^\s/.test(line) && currentKey) {
16767
- headers[currentKey] += " " + line.trim();
16768
- } else {
16769
- const colonIdx = line.indexOf(":");
16770
- if (colonIdx > 0) {
16771
- currentKey = line.substring(0, colonIdx).trim().toLowerCase();
16772
- headers[currentKey] = line.substring(colonIdx + 1).trim();
16787
+ /**
16788
+ * List messages from a folder (default: inbox).
16789
+ */
16790
+ inbox(limit = 50, offset = 0, folder = "inbox") {
16791
+ const dir = this.ensureFolder(folder);
16792
+ const results = [];
16793
+ let files;
16794
+ try {
16795
+ files = readdirSync6(dir).filter((f) => f.endsWith(".json")).sort().reverse();
16796
+ } catch {
16797
+ return [];
16773
16798
  }
16774
- }
16775
- }
16776
- }
16777
- const seen = /\\Seen/i.test(response);
16778
- return {
16779
- uid,
16780
- subject: headers["subject"] ?? "",
16781
- from: headers["from"] ?? "",
16782
- to: headers["to"] ?? "",
16783
- date: headers["date"] ?? "",
16784
- snippet: "",
16785
- seen
16786
- };
16787
- }
16788
- function emptyFullMessage(uid) {
16789
- return { uid, subject: "", from: "", to: "", cc: "", date: "", bodyText: "", bodyHtml: "", headers: {} };
16790
- }
16791
- function parseFullMessage(uid, response) {
16792
- const bodyMatch = response.match(/\{(\d+)\}\r\n([\s\S]*)/);
16793
- const rawMessage = bodyMatch ? bodyMatch[2] : response;
16794
- const headerEnd = rawMessage.indexOf("\r\n\r\n");
16795
- const headerSection = headerEnd > 0 ? rawMessage.substring(0, headerEnd) : rawMessage;
16796
- const bodySection = headerEnd > 0 ? rawMessage.substring(headerEnd + 4) : "";
16797
- const headers = {};
16798
- const headerLines = headerSection.split(/\r\n/);
16799
- let currentKey = "";
16800
- for (const line of headerLines) {
16801
- if (/^\s/.test(line) && currentKey) {
16802
- headers[currentKey] += " " + line.trim();
16803
- } else {
16804
- const colonIdx = line.indexOf(":");
16805
- if (colonIdx > 0) {
16806
- currentKey = line.substring(0, colonIdx).trim().toLowerCase();
16807
- headers[currentKey] = line.substring(colonIdx + 1).trim();
16808
- }
16809
- }
16810
- }
16811
- const contentType = headers["content-type"] ?? "text/plain";
16812
- let bodyText = "";
16813
- let bodyHtml = "";
16814
- if (contentType.includes("multipart")) {
16815
- const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/);
16816
- if (boundaryMatch) {
16817
- const boundary = boundaryMatch[1];
16818
- const parts = bodySection.split("--" + boundary);
16819
- for (const part of parts) {
16820
- if (part.trim() === "" || part.trim() === "--") continue;
16821
- const partHeaderEnd = part.indexOf("\r\n\r\n");
16822
- const partHeaders = partHeaderEnd > 0 ? part.substring(0, partHeaderEnd).toLowerCase() : "";
16823
- const partBody = partHeaderEnd > 0 ? part.substring(partHeaderEnd + 4).trim() : "";
16824
- if (partHeaders.includes("text/html")) {
16825
- bodyHtml = partBody;
16826
- } else if (partHeaders.includes("text/plain")) {
16827
- bodyText = partBody;
16799
+ const sliced = files.slice(offset, offset + limit);
16800
+ for (const file of sliced) {
16801
+ try {
16802
+ const msg = JSON.parse(readFileSync8(join12(dir, file), "utf-8"));
16803
+ results.push(msg);
16804
+ } catch {
16805
+ }
16828
16806
  }
16807
+ return results;
16829
16808
  }
16830
- }
16831
- } else if (contentType.includes("text/html")) {
16832
- bodyHtml = bodySection;
16833
- } else {
16834
- bodyText = bodySection;
16835
- }
16836
- bodyText = bodyText.replace(/\)\r\n[A-Z]\d+ OK.*$/s, "").trim();
16837
- bodyHtml = bodyHtml.replace(/\)\r\n[A-Z]\d+ OK.*$/s, "").trim();
16838
- return {
16839
- uid,
16840
- subject: headers["subject"] ?? "",
16841
- from: headers["from"] ?? "",
16842
- to: headers["to"] ?? "",
16843
- cc: headers["cc"] ?? "",
16844
- date: headers["date"] ?? "",
16845
- bodyText,
16846
- bodyHtml,
16847
- headers
16848
- };
16849
- }
16850
- var MessengerConnectionError, Messenger, imapTagCounter;
16851
- var init_messenger = __esm({
16852
- "src/messenger.ts"() {
16853
- "use strict";
16854
- init_dotenv();
16855
- init_logger();
16856
- MessengerConnectionError = class extends Error {
16857
- constructor(message) {
16858
- super(message);
16859
- this.name = "MessengerConnectionError";
16860
- }
16861
- };
16862
- Messenger = class {
16863
- host;
16864
- port;
16865
- username;
16866
- password;
16867
- fromAddress;
16868
- fromName;
16869
- encryption;
16870
- useTls;
16871
- imapHost;
16872
- imapPort;
16873
- imapUser;
16874
- imapPass;
16875
- imapEncryption;
16876
- constructor(options) {
16877
- this.host = options?.host ?? process.env.TINA4_MAIL_HOST ?? "localhost";
16878
- this.port = options?.port ?? parseInt(process.env.TINA4_MAIL_PORT ?? "587", 10);
16879
- this.username = options?.username ?? process.env.TINA4_MAIL_USERNAME ?? "";
16880
- this.password = options?.password ?? process.env.TINA4_MAIL_PASSWORD ?? "";
16881
- this.fromAddress = options?.fromAddress ?? process.env.TINA4_MAIL_FROM ?? (this.username || "noreply@localhost");
16882
- this.fromName = options?.fromName ?? process.env.TINA4_MAIL_FROM_NAME ?? "";
16883
- const envEncryption = options?.encryption ?? process.env.TINA4_MAIL_ENCRYPTION;
16884
- if (envEncryption) {
16885
- this.encryption = envEncryption.toLowerCase();
16886
- } else if (options?.useTls !== void 0) {
16887
- this.encryption = options.useTls ? "tls" : "none";
16888
- } else {
16889
- this.encryption = "tls";
16809
+ /**
16810
+ * Read a single message by ID. Searches all folders.
16811
+ */
16812
+ read(msgId) {
16813
+ const folders = ["inbox", "outbox"];
16814
+ for (const folder of folders) {
16815
+ const filePath = join12(this.mailboxDir, folder, `${msgId}.json`);
16816
+ if (existsSync10(filePath)) {
16817
+ try {
16818
+ const msg = JSON.parse(readFileSync8(filePath, "utf-8"));
16819
+ msg.read = true;
16820
+ writeFileSync6(filePath, JSON.stringify(msg, null, 2));
16821
+ return msg;
16822
+ } catch {
16823
+ return null;
16824
+ }
16825
+ }
16890
16826
  }
16891
- this.useTls = ["tls", "starttls"].includes(this.encryption);
16892
- this.imapHost = options?.imapHost ?? process.env.TINA4_MAIL_IMAP_HOST ?? "";
16893
- this.imapPort = options?.imapPort ?? parseInt(process.env.TINA4_MAIL_IMAP_PORT ?? "993", 10);
16894
- this.imapUser = options?.imapUser ?? process.env.TINA4_MAIL_IMAP_USERNAME ?? this.username;
16895
- this.imapPass = options?.imapPass ?? process.env.TINA4_MAIL_IMAP_PASSWORD ?? this.password;
16896
- this.imapEncryption = (options?.imapEncryption ?? process.env.TINA4_MAIL_IMAP_ENCRYPTION ?? "tls").toLowerCase();
16827
+ return null;
16897
16828
  }
16898
16829
  /**
16899
- * Read-only IMAP encryption mode for inspection / tests.
16900
- * Returns one of "tls", "starttls", "none", "ssl".
16830
+ * Count unread messages in the inbox.
16901
16831
  */
16902
- getImapEncryption() {
16903
- return this.imapEncryption;
16904
- }
16905
- /**
16906
- * Send an email via SMTP.
16907
- */
16908
- async send(to, subject, body, html = false, text, cc, bcc, replyTo, attachments, headers) {
16909
- const options = { to, subject, body, html, text, cc, bcc, replyTo, attachments, headers };
16910
- const toList = Array.isArray(options.to) ? options.to : [options.to];
16911
- const ccList = Array.isArray(options.cc) ? options.cc : options.cc ? [options.cc] : [];
16912
- const bccList = Array.isArray(options.bcc) ? options.bcc : options.bcc ? [options.bcc] : [];
16913
- const allRecipients = [...toList, ...ccList, ...bccList];
16914
- const messageId = `${randomUUID2()}@${this.host}`;
16915
- if (allRecipients.length === 0) {
16916
- return { success: false, message: "No recipients specified" };
16917
- }
16918
- if (!this.fromAddress) {
16919
- return { success: false, message: "No from address configured" };
16920
- }
16921
- try {
16922
- let socket;
16923
- if (this.port === 465) {
16924
- socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: tlsRejectUnauthorized() });
16925
- await new Promise((resolve21, reject) => {
16926
- socket.once("secureConnect", resolve21);
16927
- socket.once("error", reject);
16928
- });
16929
- } else {
16930
- socket = net2.createConnection({ host: this.host, port: this.port });
16931
- await new Promise((resolve21, reject) => {
16932
- socket.once("connect", resolve21);
16933
- socket.once("error", reject);
16934
- });
16935
- }
16936
- const greeting = await readResponse(socket);
16937
- if (greeting.code !== 220) {
16938
- socket.destroy();
16939
- return { success: false, message: `SMTP greeting failed: ${greeting.text}` };
16940
- }
16941
- const ehlo = await sendCommand(socket, `EHLO ${this.host}`);
16942
- if (ehlo.code !== 250) {
16943
- socket.destroy();
16944
- return { success: false, message: `EHLO failed: ${ehlo.text}` };
16945
- }
16946
- if (this.useTls && this.port !== 465 && ehlo.text.includes("STARTTLS")) {
16947
- const starttls = await sendCommand(socket, "STARTTLS");
16948
- if (starttls.code !== 220) {
16949
- socket.destroy();
16950
- return { success: false, message: `STARTTLS failed: ${starttls.text}` };
16951
- }
16952
- const plainSocket = socket;
16953
- socket = tls.connect(
16954
- { socket: plainSocket, host: this.host, rejectUnauthorized: tlsRejectUnauthorized() }
16955
- );
16956
- await new Promise((resolve21, reject) => {
16957
- socket.once("secureConnect", resolve21);
16958
- socket.once("error", reject);
16959
- });
16960
- const ehlo2 = await sendCommand(socket, `EHLO ${this.host}`);
16961
- if (ehlo2.code !== 250) {
16962
- socket.destroy();
16963
- return { success: false, message: `EHLO after STARTTLS failed: ${ehlo2.text}` };
16964
- }
16965
- }
16966
- if (this.username && this.password) {
16967
- const auth = await sendCommand(socket, "AUTH LOGIN");
16968
- if (auth.code !== 334) {
16969
- socket.destroy();
16970
- return { success: false, message: `AUTH LOGIN failed: ${auth.text}` };
16971
- }
16972
- const userResp = await sendCommand(socket, Buffer.from(this.username).toString("base64"));
16973
- if (userResp.code !== 334) {
16974
- socket.destroy();
16975
- return { success: false, message: `AUTH username failed: ${userResp.text}` };
16976
- }
16977
- const passResp = await sendCommand(socket, Buffer.from(this.password).toString("base64"));
16978
- if (passResp.code !== 235) {
16979
- socket.destroy();
16980
- return { success: false, message: `AUTH password failed: ${passResp.text}` };
16981
- }
16982
- }
16983
- const mailFrom = await sendCommand(socket, `MAIL FROM:<${this.fromAddress}>`);
16984
- if (mailFrom.code !== 250) {
16985
- socket.destroy();
16986
- return { success: false, message: `MAIL FROM failed: ${mailFrom.text}` };
16987
- }
16988
- for (const recipient of allRecipients) {
16989
- const rcpt = await sendCommand(socket, `RCPT TO:<${recipient}>`);
16990
- if (rcpt.code !== 250 && rcpt.code !== 251) {
16991
- socket.destroy();
16992
- return { success: false, message: `RCPT TO <${recipient}> failed: ${rcpt.text}` };
16993
- }
16994
- }
16995
- const dataCmd = await sendCommand(socket, "DATA");
16996
- if (dataCmd.code !== 354) {
16997
- socket.destroy();
16998
- return { success: false, message: `DATA failed: ${dataCmd.text}` };
16999
- }
17000
- const mimeMessage = buildMimeMessage({
17001
- from: this.fromAddress,
17002
- fromName: this.fromName,
17003
- to: toList,
17004
- cc: ccList,
17005
- subject: options.subject,
17006
- body: options.body,
17007
- html: options.html ?? false,
17008
- text: options.text,
17009
- replyTo: options.replyTo,
17010
- attachments: options.attachments,
17011
- headers: options.headers,
17012
- messageId
17013
- });
17014
- const endData = await sendCommand(socket, mimeMessage + "\r\n.");
17015
- if (endData.code !== 250) {
17016
- socket.destroy();
17017
- return { success: false, message: `Message delivery failed: ${endData.text}` };
17018
- }
17019
- await sendCommand(socket, "QUIT");
17020
- socket.destroy();
17021
- return { success: true, message: "Email sent successfully", id: messageId };
17022
- } catch (err) {
17023
- const errMsg = err instanceof Error ? err.message : String(err);
17024
- return { success: false, message: `SMTP error: ${errMsg}` };
17025
- }
17026
- }
17027
- /**
17028
- * Test the SMTP connection without sending an email.
17029
- */
17030
- async testConnection() {
17031
- try {
17032
- let socket;
17033
- if (this.port === 465) {
17034
- socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: tlsRejectUnauthorized() });
17035
- await new Promise((resolve21, reject) => {
17036
- socket.once("secureConnect", resolve21);
17037
- socket.once("error", reject);
17038
- });
17039
- } else {
17040
- socket = net2.createConnection({ host: this.host, port: this.port });
17041
- await new Promise((resolve21, reject) => {
17042
- socket.once("connect", resolve21);
17043
- socket.once("error", reject);
17044
- });
17045
- }
17046
- const greeting = await readResponse(socket);
17047
- if (greeting.code !== 220) {
17048
- socket.destroy();
17049
- return { success: false, message: `SMTP greeting failed: ${greeting.text}` };
17050
- }
17051
- const ehlo = await sendCommand(socket, `EHLO ${this.host}`);
17052
- if (ehlo.code !== 250) {
17053
- socket.destroy();
17054
- return { success: false, message: `EHLO failed: ${ehlo.text}` };
17055
- }
17056
- await sendCommand(socket, "QUIT");
17057
- socket.destroy();
17058
- return { success: true, message: `Connected to ${this.host}:${this.port}` };
17059
- } catch (err) {
17060
- const errMsg = err instanceof Error ? err.message : String(err);
17061
- return { success: false, message: `Connection failed: ${errMsg}` };
17062
- }
17063
- }
17064
- // ── IMAP (Read) ────────────────────────────────────────────
17065
- /**
17066
- * Connect to the IMAP server via raw TCP/TLS.
17067
- * Returns the socket and reads the greeting.
17068
- */
17069
- async imapConnect() {
17070
- if (!this.imapHost) {
17071
- throw new Error("IMAP host not configured (set imapHost or IMAP_HOST env)");
17072
- }
17073
- let socket;
17074
- const useTls = this.imapEncryption === "tls" || this.imapEncryption === "ssl" || this.imapEncryption === "" && this.imapPort === 993;
17075
- if (useTls) {
17076
- socket = tls.connect({ host: this.imapHost, port: this.imapPort, rejectUnauthorized: tlsRejectUnauthorized() });
17077
- await new Promise((resolve21, reject) => {
17078
- socket.once("secureConnect", resolve21);
17079
- socket.once("error", reject);
17080
- });
17081
- } else {
17082
- socket = net2.createConnection({ host: this.imapHost, port: this.imapPort });
17083
- await new Promise((resolve21, reject) => {
17084
- socket.once("connect", resolve21);
17085
- socket.once("error", reject);
17086
- });
17087
- }
17088
- await imapReadLine(socket);
17089
- if (this.imapUser && this.imapPass) {
17090
- const loginResp = await imapCommand(socket, `LOGIN ${imapQuote(this.imapUser)} ${imapQuote(this.imapPass)}`);
17091
- if (!loginResp.includes("OK")) {
17092
- socket.destroy();
17093
- throw new Error(`IMAP login failed: ${loginResp}`);
17094
- }
17095
- }
17096
- return socket;
17097
- }
17098
- /**
17099
- * Disconnect from IMAP cleanly.
17100
- */
17101
- async imapDisconnect(socket) {
17102
- try {
17103
- await imapCommand(socket, "LOGOUT");
17104
- } catch {
17105
- }
17106
- socket.destroy();
17107
- }
17108
- /**
17109
- * Fetch latest messages from a folder.
17110
- * Returns list of message summaries.
17111
- */
17112
- async inbox(limit = 20, offset = 0, folder = "INBOX") {
17113
- let socket;
17114
- try {
17115
- socket = await this.imapConnect();
17116
- } catch (err) {
17117
- throw imapFail("inbox", err);
17118
- }
17119
- try {
17120
- await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
17121
- const searchResp = await imapCommand(socket, "SEARCH ALL");
17122
- const uids = parseSearchResponse(searchResp);
17123
- if (uids.length === 0) return [];
17124
- uids.reverse();
17125
- const selected = uids.slice(offset, offset + limit);
17126
- if (selected.length === 0) return [];
17127
- const messages = [];
17128
- for (const uid of selected) {
17129
- const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])`);
17130
- messages.push(parseHeaderResponse(uid, fetchResp));
17131
- }
17132
- return messages;
17133
- } catch (err) {
17134
- throw imapFail("inbox", err);
17135
- } finally {
17136
- await this.imapDisconnect(socket);
17137
- }
17138
- }
17139
- /**
17140
- * Read a single message by sequence number or UID.
17141
- */
17142
- async read(uid, folder = "INBOX") {
17143
- let socket;
17144
- try {
17145
- socket = await this.imapConnect();
17146
- } catch (err) {
17147
- throw imapFail("read", err);
17148
- }
17149
- try {
17150
- await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
17151
- const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY[])`);
17152
- if (!/\{\d+\}/.test(fetchResp)) {
17153
- return emptyFullMessage(uid);
17154
- }
17155
- await imapCommand(socket, `STORE ${uid} +FLAGS (\\Seen)`);
17156
- return parseFullMessage(uid, fetchResp);
17157
- } catch (err) {
17158
- throw imapFail("read", err);
17159
- } finally {
17160
- await this.imapDisconnect(socket);
17161
- }
17162
- }
17163
- /**
17164
- * Search messages using IMAP search criteria.
17165
- */
17166
- async search(folder = "INBOX", subject, sender, since, before, unseenOnly = false, limit = 50) {
17167
- const criteria = ["ALL"];
17168
- if (subject) criteria.push(`SUBJECT "${subject}"`);
17169
- if (sender) criteria.push(`FROM "${sender}"`);
17170
- if (since) criteria.push(`SINCE ${since}`);
17171
- if (before) criteria.push(`BEFORE ${before}`);
17172
- if (unseenOnly) criteria.push("UNSEEN");
17173
- const query = criteria.join(" ");
17174
- let socket;
17175
- try {
17176
- socket = await this.imapConnect();
17177
- } catch (err) {
17178
- throw imapFail("search", err);
17179
- }
17180
- try {
17181
- await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
17182
- const searchResp = await imapCommand(socket, `SEARCH ${query}`);
17183
- const uids = parseSearchResponse(searchResp);
17184
- if (uids.length === 0) return [];
17185
- uids.reverse();
17186
- const messages = [];
17187
- for (const uid of uids.slice(0, limit)) {
17188
- const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])`);
17189
- messages.push(parseHeaderResponse(uid, fetchResp));
17190
- }
17191
- return messages;
17192
- } catch (err) {
17193
- throw imapFail("search", err);
17194
- } finally {
17195
- await this.imapDisconnect(socket);
17196
- }
17197
- }
17198
- /**
17199
- * Delete a message by UID.
17200
- */
17201
- async deleteMessage(uid, folder = "INBOX") {
17202
- const socket = await this.imapConnect();
17203
- try {
17204
- await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
17205
- await imapCommand(socket, `STORE ${uid} +FLAGS (\\Deleted)`);
17206
- await imapCommand(socket, "EXPUNGE");
17207
- } finally {
17208
- await this.imapDisconnect(socket);
17209
- }
17210
- }
17211
- /**
17212
- * Mark a message as read.
17213
- */
17214
- async markRead(uid, folder = "INBOX") {
17215
- const socket = await this.imapConnect();
17216
- try {
17217
- await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
17218
- await imapCommand(socket, `STORE ${uid} +FLAGS (\\Seen)`);
17219
- } finally {
17220
- await this.imapDisconnect(socket);
17221
- }
17222
- }
17223
- /**
17224
- * Count unseen messages in a folder.
17225
- */
17226
- async unread(folder = "INBOX") {
17227
- let socket;
17228
- try {
17229
- socket = await this.imapConnect();
17230
- } catch (err) {
17231
- throw imapFail("unread", err);
17232
- }
17233
- try {
17234
- await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
17235
- const searchResp = await imapCommand(socket, "SEARCH UNSEEN");
17236
- return parseSearchResponse(searchResp).length;
17237
- } catch (err) {
17238
- throw imapFail("unread", err);
17239
- } finally {
17240
- await this.imapDisconnect(socket);
17241
- }
17242
- }
17243
- /**
17244
- * List available IMAP folders/mailboxes.
17245
- */
17246
- async folders() {
17247
- let socket;
17248
- try {
17249
- socket = await this.imapConnect();
17250
- } catch (err) {
17251
- throw imapFail("folders", err);
17252
- }
17253
- try {
17254
- const resp = await imapCommand(socket, 'LIST "" "*"');
17255
- const result = [];
17256
- for (const line of resp.split("\r\n")) {
17257
- const m = line.match(/\* LIST \([^)]*\) "[^"]*" "?([^"\r\n]+)"?/i);
17258
- if (m) result.push(m[1]);
17259
- }
17260
- return result;
17261
- } catch (err) {
17262
- throw imapFail("folders", err);
17263
- } finally {
17264
- await this.imapDisconnect(socket);
17265
- }
17266
- }
17267
- /**
17268
- * Test IMAP connectivity without reading.
17269
- */
17270
- async testImapConnection() {
17271
- try {
17272
- const socket = await this.imapConnect();
17273
- await this.imapDisconnect(socket);
17274
- return { success: true, message: `Connected to ${this.imapHost}:${this.imapPort}` };
17275
- } catch (err) {
17276
- const errMsg = err instanceof Error ? err.message : String(err);
17277
- return { success: false, message: `IMAP connection failed: ${errMsg}` };
17278
- }
17279
- }
17280
- };
17281
- imapTagCounter = 0;
17282
- }
17283
- });
17284
-
17285
- // src/devMailbox.ts
17286
- import { mkdirSync as mkdirSync8, readdirSync as readdirSync6, readFileSync as readFileSync9, writeFileSync as writeFileSync6, unlinkSync as unlinkSync4, existsSync as existsSync10 } from "node:fs";
17287
- import { join as join12 } from "node:path";
17288
- import { randomUUID as randomUUID3 } from "node:crypto";
17289
- function createMessenger() {
17290
- const debug = process.env.TINA4_DEBUG;
17291
- const smtpHost = process.env.TINA4_MAIL_HOST;
17292
- const isProd = !isTruthy(debug) && process.env.NODE_ENV === "production";
17293
- if (isTruthy(debug)) {
17294
- return new DevMailbox();
17295
- }
17296
- if (!smtpHost) {
17297
- return new DevMailbox();
17298
- }
17299
- if (!isProd) {
17300
- return new DevMailbox();
17301
- }
17302
- return new Messenger();
17303
- }
17304
- var DevMailbox;
17305
- var init_devMailbox = __esm({
17306
- "src/devMailbox.ts"() {
17307
- "use strict";
17308
- init_messenger();
17309
- init_dotenv();
17310
- DevMailbox = class {
17311
- mailboxDir;
17312
- constructor(mailboxDir) {
17313
- this.mailboxDir = mailboxDir ?? process.env.TINA4_MAILBOX_DIR ?? "data/mailbox";
17314
- }
17315
- /**
17316
- * Ensure a folder directory exists.
17317
- */
17318
- ensureFolder(folder) {
17319
- const dir = join12(this.mailboxDir, folder);
17320
- mkdirSync8(dir, { recursive: true });
17321
- return dir;
17322
- }
17323
- /**
17324
- * Capture an email to the dev mailbox instead of sending it.
17325
- */
17326
- capture(to, subject, body, html = false, cc = [], bcc = [], replyTo, attachments = [], from) {
17327
- const id = randomUUID3();
17328
- const toList = Array.isArray(to) ? to : [to];
17329
- const now = (/* @__PURE__ */ new Date()).toISOString();
17330
- const message = {
17331
- id,
17332
- type: "outbox",
17333
- from: from ?? process.env.TINA4_MAIL_FROM ?? "dev@localhost",
17334
- to: toList,
17335
- cc,
17336
- bcc,
17337
- reply_to: replyTo,
17338
- subject,
17339
- body,
17340
- html,
17341
- attachments,
17342
- date: now,
17343
- read: false
17344
- };
17345
- const outboxDir = this.ensureFolder("outbox");
17346
- writeFileSync6(join12(outboxDir, `${id}.json`), JSON.stringify(message, null, 2));
17347
- const inboxDir = this.ensureFolder("inbox");
17348
- const inboxMessage = { ...message, type: "inbox" };
17349
- writeFileSync6(join12(inboxDir, `${id}.json`), JSON.stringify(inboxMessage, null, 2));
17350
- return { success: true, message: "Email captured to dev mailbox", id };
17351
- }
17352
- /**
17353
- * List messages from a folder (default: inbox).
17354
- */
17355
- inbox(limit = 50, offset = 0, folder = "inbox") {
17356
- const dir = this.ensureFolder(folder);
17357
- const results = [];
17358
- let files;
17359
- try {
17360
- files = readdirSync6(dir).filter((f) => f.endsWith(".json")).sort().reverse();
17361
- } catch {
17362
- return [];
17363
- }
17364
- const sliced = files.slice(offset, offset + limit);
17365
- for (const file of sliced) {
17366
- try {
17367
- const msg = JSON.parse(readFileSync9(join12(dir, file), "utf-8"));
17368
- results.push(msg);
17369
- } catch {
17370
- }
17371
- }
17372
- return results;
17373
- }
17374
- /**
17375
- * Read a single message by ID. Searches all folders.
17376
- */
17377
- read(msgId) {
17378
- const folders = ["inbox", "outbox"];
17379
- for (const folder of folders) {
17380
- const filePath = join12(this.mailboxDir, folder, `${msgId}.json`);
17381
- if (existsSync10(filePath)) {
17382
- try {
17383
- const msg = JSON.parse(readFileSync9(filePath, "utf-8"));
17384
- msg.read = true;
17385
- writeFileSync6(filePath, JSON.stringify(msg, null, 2));
17386
- return msg;
17387
- } catch {
17388
- return null;
17389
- }
17390
- }
17391
- }
17392
- return null;
17393
- }
17394
- /**
17395
- * Count unread messages in the inbox.
17396
- */
17397
- unreadCount() {
17398
- const dir = this.ensureFolder("inbox");
17399
- let count = 0;
17400
- try {
17401
- const files = readdirSync6(dir).filter((f) => f.endsWith(".json"));
17402
- for (const file of files) {
17403
- try {
17404
- const msg = JSON.parse(readFileSync9(join12(dir, file), "utf-8"));
17405
- if (!msg.read) count++;
17406
- } catch {
17407
- }
17408
- }
17409
- } catch {
17410
- }
17411
- return count;
16832
+ unreadCount() {
16833
+ const dir = this.ensureFolder("inbox");
16834
+ let count = 0;
16835
+ try {
16836
+ const files = readdirSync6(dir).filter((f) => f.endsWith(".json"));
16837
+ for (const file of files) {
16838
+ try {
16839
+ const msg = JSON.parse(readFileSync8(join12(dir, file), "utf-8"));
16840
+ if (!msg.read) count++;
16841
+ } catch {
16842
+ }
16843
+ }
16844
+ } catch {
16845
+ }
16846
+ return count;
17412
16847
  }
17413
16848
  /**
17414
16849
  * Delete a message by ID. Removes from all folders.
@@ -17473,7 +16908,7 @@ var init_devMailbox = __esm({
17473
16908
  const subject = subjects[i % subjects.length];
17474
16909
  const from = senders[i % senders.length];
17475
16910
  const date = new Date(Date.now() - i * 36e5).toISOString();
17476
- const id = randomUUID3();
16911
+ const id = randomUUID2();
17477
16912
  const message = {
17478
16913
  id,
17479
16914
  type: "inbox",
@@ -18512,7 +17947,7 @@ var init_metrics = __esm({
18512
17947
  });
18513
17948
 
18514
17949
  // src/feedback.ts
18515
- import { readFileSync as readFileSync11, existsSync as existsSync12 } from "node:fs";
17950
+ import { readFileSync as readFileSync10, existsSync as existsSync12 } from "node:fs";
18516
17951
  import { dirname as dirname5, join as join14, resolve as resolve8 } from "node:path";
18517
17952
  import { fileURLToPath } from "node:url";
18518
17953
  function feedbackEnabled() {
@@ -18653,7 +18088,7 @@ var init_feedback = __esm({
18653
18088
  handleFeedbackWidgetJs = (_req, res) => {
18654
18089
  let body;
18655
18090
  if (existsSync12(WIDGET_BUNDLE_PATH)) {
18656
- body = readFileSync11(WIDGET_BUNDLE_PATH);
18091
+ body = readFileSync10(WIDGET_BUNDLE_PATH);
18657
18092
  } else {
18658
18093
  body = "console.warn('tina4-feedback-widget bundle not built yet');";
18659
18094
  }
@@ -20361,7 +19796,7 @@ __export(errorOverlay_exports, {
20361
19796
  renderErrorOverlay: () => renderErrorOverlay,
20362
19797
  renderProductionError: () => renderProductionError
20363
19798
  });
20364
- import { readFileSync as readFileSync13, statSync as statSync10 } from "node:fs";
19799
+ import { readFileSync as readFileSync12, statSync as statSync10 } from "node:fs";
20365
19800
  import { resolve as resolve10 } from "node:path";
20366
19801
  function esc(text) {
20367
19802
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -20385,7 +19820,7 @@ function parseStack(stack) {
20385
19820
  function readSourceLines(filename, lineno) {
20386
19821
  try {
20387
19822
  const absPath = resolve10(filename);
20388
- const content = readFileSync13(absPath, "utf-8");
19823
+ const content = readFileSync12(absPath, "utf-8");
20389
19824
  const allLines = content.split("\n");
20390
19825
  const start2 = Math.max(0, lineno - CONTEXT_LINES - 1);
20391
19826
  const end = Math.min(allLines.length, lineno + CONTEXT_LINES);
@@ -21649,8 +21084,8 @@ __export(context_exports, {
21649
21084
  fts5Supported: () => fts5Supported
21650
21085
  });
21651
21086
  import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
21652
- import { existsSync as existsSync14, mkdirSync as mkdirSync10, readFileSync as readFileSync14, readdirSync as readdirSync9, realpathSync as realpathSync2 } from "node:fs";
21653
- import { basename as basename4, dirname as dirname7, extname as extname6, isAbsolute as isAbsolute5, join as join16, relative as relative4, resolve as resolve11 } from "node:path";
21087
+ import { existsSync as existsSync14, mkdirSync as mkdirSync10, readFileSync as readFileSync13, readdirSync as readdirSync9, realpathSync as realpathSync2 } from "node:fs";
21088
+ import { basename as basename3, dirname as dirname7, extname as extname6, isAbsolute as isAbsolute5, join as join16, relative as relative4, resolve as resolve11 } from "node:path";
21654
21089
  function fts5Supported() {
21655
21090
  try {
21656
21091
  const conn = new DatabaseSync3(":memory:");
@@ -21673,7 +21108,7 @@ function realResolve(abs) {
21673
21108
  } catch {
21674
21109
  }
21675
21110
  try {
21676
- return join16(realpathSync2(dirname7(abs)), basename4(abs));
21111
+ return join16(realpathSync2(dirname7(abs)), basename3(abs));
21677
21112
  } catch {
21678
21113
  return abs;
21679
21114
  }
@@ -21786,7 +21221,7 @@ var init_context = __esm({
21786
21221
  // ── indexing ───────────────────────────────────────────────
21787
21222
  static chunksFor(label, text) {
21788
21223
  const ext = extname6(label).toLowerCase();
21789
- const special = SPECIAL_FILES.has(basename4(label).toLowerCase());
21224
+ const special = SPECIAL_FILES.has(basename3(label).toLowerCase());
21790
21225
  if (CODE_EXTS.has(ext) || CONFIG_EXTS.has(ext) || special) {
21791
21226
  return chunkCode(text, label);
21792
21227
  }
@@ -21803,7 +21238,7 @@ var init_context = __esm({
21803
21238
  const stored = label != null ? String(label) : String(file);
21804
21239
  let text;
21805
21240
  try {
21806
- text = readFileSync14(file, "utf-8");
21241
+ text = readFileSync13(file, "utf-8");
21807
21242
  } catch {
21808
21243
  return 0;
21809
21244
  }
@@ -21883,7 +21318,7 @@ var init_context = __esm({
21883
21318
  if (parts.some((seg) => SKIP_DIRS.has(seg)) || dirParts.some((seg) => seg.startsWith("."))) {
21884
21319
  return -1;
21885
21320
  }
21886
- if (!_Context.eligible(basename4(rel))) return -1;
21321
+ if (!_Context.eligible(basename3(rel))) return -1;
21887
21322
  const stored = rel;
21888
21323
  if (!existsSync14(abs)) {
21889
21324
  this.conn.prepare("DELETE FROM chunks WHERE path = ?").run(stored);
@@ -21975,7 +21410,7 @@ var init_context = __esm({
21975
21410
  });
21976
21411
 
21977
21412
  // src/websocketBackplane.ts
21978
- import { randomUUID as randomUUID4 } from "node:crypto";
21413
+ import { randomUUID as randomUUID3 } from "node:crypto";
21979
21414
  function createBackplane(url) {
21980
21415
  const backend = (process.env.TINA4_WS_BACKPLANE ?? "").trim().toLowerCase();
21981
21416
  switch (backend) {
@@ -22005,7 +21440,7 @@ function buildEnvelope(src, kind, message, opts = {}) {
22005
21440
  return envelope;
22006
21441
  }
22007
21442
  function randomInstanceId() {
22008
- return randomUUID4().replace(/-/g, "").slice(0, 16);
21443
+ return randomUUID3().replace(/-/g, "").slice(0, 16);
22009
21444
  }
22010
21445
  var RedisBackplane, NATSBackplane, WS_BACKPLANE_CHANNEL, WsBackplaneManager;
22011
21446
  var init_websocketBackplane = __esm({
@@ -22233,7 +21668,7 @@ __export(websocket_exports, {
22233
21668
  });
22234
21669
  import { createServer } from "node:http";
22235
21670
  import { createHash as createHash4 } from "node:crypto";
22236
- import { randomUUID as randomUUID5 } from "node:crypto";
21671
+ import { randomUUID as randomUUID4 } from "node:crypto";
22237
21672
  function computeAcceptKey(key) {
22238
21673
  return createHash4("sha1").update(key + MAGIC_STRING).digest("base64");
22239
21674
  }
@@ -22361,7 +21796,7 @@ function parseFrame(data) {
22361
21796
  return { fin: !!fin, opcode, payload: Buffer.from(payload), bytesConsumed: offset + payloadLen };
22362
21797
  }
22363
21798
  function createRouteConnection(socket, path8, headers, params, auth) {
22364
- const id = randomUUID5().slice(0, 8);
21799
+ const id = randomUUID4().slice(0, 8);
22365
21800
  const send = (message) => {
22366
21801
  try {
22367
21802
  socket.write(buildFrame(OP_TEXT, Buffer.from(message, "utf-8")));
@@ -22918,7 +22353,7 @@ var init_websocket = __esm({
22918
22353
  }
22919
22354
  responseLines.push("", "");
22920
22355
  socket.write(responseLines.join("\r\n"));
22921
- const clientId = randomUUID5().slice(0, 8);
22356
+ const clientId = randomUUID4().slice(0, 8);
22922
22357
  const client = {
22923
22358
  id: clientId,
22924
22359
  socket,
@@ -23206,7 +22641,7 @@ var init_websocket = __esm({
23206
22641
 
23207
22642
  // src/queueBackends/rabbitmqBackend.ts
23208
22643
  import { execFileSync } from "node:child_process";
23209
- import { randomUUID as randomUUID6 } from "node:crypto";
22644
+ import { randomUUID as randomUUID5 } from "node:crypto";
23210
22645
  function parseAmqpUrl(url) {
23211
22646
  const config = {};
23212
22647
  let rest = url.replace(/^amqps:\/\//, "").replace(/^amqp:\/\//, "");
@@ -23650,7 +23085,7 @@ var init_rabbitmqBackend = __esm({
23650
23085
  }
23651
23086
  }
23652
23087
  push(queue, payload, _delay) {
23653
- const id = randomUUID6();
23088
+ const id = randomUUID5();
23654
23089
  const now = (/* @__PURE__ */ new Date()).toISOString();
23655
23090
  const job = {
23656
23091
  id,
@@ -23689,7 +23124,7 @@ var init_rabbitmqBackend = __esm({
23689
23124
 
23690
23125
  // src/queueBackends/kafkaBackend.ts
23691
23126
  import { execFileSync as execFileSync2 } from "node:child_process";
23692
- import { randomUUID as randomUUID7 } from "node:crypto";
23127
+ import { randomUUID as randomUUID6 } from "node:crypto";
23693
23128
  function kafkaSecurityConfig(env = process.env) {
23694
23129
  const mapping = [
23695
23130
  ["security.protocol", "SECURITY_PROTOCOL"],
@@ -23713,7 +23148,7 @@ var init_kafkaBackend = __esm({
23713
23148
  "use strict";
23714
23149
  API_PRODUCE = 0;
23715
23150
  API_FETCH = 1;
23716
- KafkaBackend = class {
23151
+ KafkaBackend = class _KafkaBackend {
23717
23152
  brokers;
23718
23153
  groupId;
23719
23154
  constructor(config) {
@@ -24075,12 +23510,15 @@ var init_kafkaBackend = __esm({
24075
23510
  if (errCode === 0) {
24076
23511
  finish("__PUBLISHED__", 0);
24077
23512
  } else {
23513
+ // Report the CODE, not just "it failed" \u2014 the caller decides
23514
+ // whether it is retriable (3/5, the async topic-creation race)
23515
+ // or fatal (e.g. 29 TOPIC_AUTHORIZATION_FAILED).
24078
23516
  process.stderr.write("Produce error code " + errCode);
24079
- finish("__ERROR__" + errCode, 0);
23517
+ finish("__PRODUCEERROR__" + errCode, 0);
24080
23518
  }
24081
23519
  } catch (e) {
24082
23520
  process.stderr.write("produce parse: " + e.message);
24083
- finish("__ERROR__", 0);
23521
+ finish("__PARSEERROR__produce: " + e.message, 0);
24084
23522
  }
24085
23523
  return;
24086
23524
  } else if (operation === "get") {
@@ -24094,6 +23532,7 @@ var init_kafkaBackend = __esm({
24094
23532
  pos += 4; // throttleTimeMs (v1+)
24095
23533
  const topicCount = buffer.readInt32BE(pos); pos += 4;
24096
23534
  let out = "__EMPTY__";
23535
+ let fatalCode = 0;
24097
23536
  for (let t = 0; t < topicCount; t++) {
24098
23537
  const tl = buffer.readInt16BE(pos); pos += 2 + tl;
24099
23538
  const pc = buffer.readInt32BE(pos); pos += 4;
@@ -24105,6 +23544,14 @@ var init_kafkaBackend = __esm({
24105
23544
  const abortedCount = buffer.readInt32BE(pos); pos += 4;
24106
23545
  if (abortedCount > 0) pos += abortedCount * 16; // (-1 => none, skip)
24107
23546
  const recSetSize = buffer.readInt32BE(pos); pos += 4;
23547
+ // 3 = UNKNOWN_TOPIC_OR_PARTITION, 5 = LEADER_NOT_AVAILABLE:
23548
+ // "nothing to read here yet", which a consumer that starts
23549
+ // before its producer hits on every cold start. Any OTHER code
23550
+ // (29 TOPIC_AUTHORIZATION_FAILED, 13 STALE_CONTROLLER_EPOCH, \u2026)
23551
+ // is a real failure and must NOT be reported as an empty queue.
23552
+ if (errCode !== 0 && errCode !== 3 && errCode !== 5) {
23553
+ fatalCode = errCode;
23554
+ }
24108
23555
  if (errCode === 0 && recSetSize > 0) {
24109
23556
  const val = firstRecordValue(buffer, pos, pos + recSetSize);
24110
23557
  if (val !== null) out = val;
@@ -24112,21 +23559,33 @@ var init_kafkaBackend = __esm({
24112
23559
  pos += recSetSize > 0 ? recSetSize : 0;
24113
23560
  }
24114
23561
  }
23562
+ if (fatalCode !== 0) {
23563
+ process.stderr.write("Fetch error code " + fatalCode);
23564
+ finish("__FETCHERROR__" + fatalCode, 0);
23565
+ return;
23566
+ }
24115
23567
  finish(out, 0);
24116
23568
  } catch (e) {
23569
+ // A parse failure is NOT an empty queue either \u2014 say so.
24117
23570
  process.stderr.write("fetch parse: " + e.message);
24118
- finish("__EMPTY__", 0);
23571
+ finish("__PARSEERROR__fetch: " + e.message, 0);
24119
23572
  }
24120
23573
  return;
24121
23574
  }
24122
23575
  });
24123
23576
 
23577
+ // Report the reason on STDOUT and exit 0. Writing it to stderr and
23578
+ // exiting non-zero LOST it: stderr to a pipe is an async write and
23579
+ // process.exit() truncates it, so the parent saw an empty stderr and fell
23580
+ // back to execFileSync's message -- which embeds this entire script.
23581
+ // stdout is flushed by finish()'s write callback, so it survives.
24124
23582
  sock.on("error", (err) => {
24125
- process.stderr.write(err.message);
24126
- finish("", 1);
23583
+ finish("__TRANSPORTERROR__" + err.message, 0);
24127
23584
  });
24128
23585
 
24129
- var timer = setTimeout(() => { finish("", 1); }, 10000);
23586
+ var timer = setTimeout(() => {
23587
+ finish("__TRANSPORTERROR__timed out after 10s talking to " + host + ":" + port, 0);
23588
+ }, 10000);
24130
23589
  `;
24131
23590
  try {
24132
23591
  const result = execFileSync2(process.execPath, ["-e", script], {
@@ -24135,12 +23594,51 @@ var init_kafkaBackend = __esm({
24135
23594
  stdio: ["pipe", "pipe", "pipe"]
24136
23595
  });
24137
23596
  return result;
24138
- } catch {
24139
- return "";
23597
+ } catch (err) {
23598
+ const e = err;
23599
+ const reason = String(e.stderr ?? "").trim() || e.message || "unknown error";
23600
+ const firstLine2 = reason.split("\n", 1)[0].slice(0, 200);
23601
+ return "__TRANSPORTERROR__" + firstLine2;
23602
+ }
23603
+ }
23604
+ /**
23605
+ * Sleep synchronously between produce retries.
23606
+ *
23607
+ * `push()` is synchronous (the whole backend drives its socket through a child
23608
+ * process), so there is no event loop to await on. `Atomics.wait` on a
23609
+ * SharedArrayBuffer is the stdlib way to block a thread for a fixed time --
23610
+ * no dependency, no busy-wait burning CPU.
23611
+ */
23612
+ static sleepSync(ms) {
23613
+ const shared = new Int32Array(new SharedArrayBuffer(4));
23614
+ Atomics.wait(shared, 0, 0, ms);
23615
+ }
23616
+ /**
23617
+ * Turn a sentinel from the protocol child into a thrown error, or return.
23618
+ *
23619
+ * The wording matches the Python and PHP backends exactly -- the parity rule
23620
+ * covers user-visible error messages, not just behaviour.
23621
+ */
23622
+ static assertNoError(result, operation, topic) {
23623
+ const fatal = /^__(PRODUCEERROR|FETCHERROR)__(\d+)/.exec(result);
23624
+ if (fatal) {
23625
+ throw new Error(
23626
+ `Kafka rejected the ${operation} for topic ${topic}: error code ${fatal[2]}`
23627
+ );
23628
+ }
23629
+ if (result.startsWith("__TRANSPORTERROR__")) {
23630
+ throw new Error(
23631
+ `Kafka ${operation} for topic ${topic} failed: ` + result.slice("__TRANSPORTERROR__".length)
23632
+ );
23633
+ }
23634
+ if (result.startsWith("__PARSEERROR__")) {
23635
+ throw new Error(
23636
+ `Kafka ${operation} for topic ${topic} returned an unreadable response: ` + result.slice("__PARSEERROR__".length)
23637
+ );
24140
23638
  }
24141
23639
  }
24142
23640
  push(queue, payload, _delay) {
24143
- const id = randomUUID7();
23641
+ const id = randomUUID6();
24144
23642
  const now = (/* @__PURE__ */ new Date()).toISOString();
24145
23643
  const job = {
24146
23644
  id,
@@ -24150,14 +23648,25 @@ var init_kafkaBackend = __esm({
24150
23648
  attempts: 0,
24151
23649
  delayUntil: null
24152
23650
  };
24153
- const result = this.execSync("publish", queue, JSON.stringify(job));
24154
- if (!result.includes("__PUBLISHED__")) {
24155
- throw new Error("Kafka publish failed");
23651
+ const body = JSON.stringify(job);
23652
+ let result = "";
23653
+ for (let attempt = 1; attempt <= 10; attempt++) {
23654
+ result = this.execSync("publish", queue, body);
23655
+ if (result.includes("__PUBLISHED__")) {
23656
+ return id;
23657
+ }
23658
+ const retriable = /^__PRODUCEERROR__(3|5)\b/.test(result);
23659
+ if (!retriable || attempt === 10) {
23660
+ break;
23661
+ }
23662
+ _KafkaBackend.sleepSync(200);
24156
23663
  }
24157
- return id;
23664
+ _KafkaBackend.assertNoError(result, "produce", queue);
23665
+ throw new Error(`Kafka publish failed for topic ${queue}: ${result || "no response"}`);
24158
23666
  }
24159
23667
  pop(queue) {
24160
23668
  const result = this.execSync("get", queue);
23669
+ _KafkaBackend.assertNoError(result, "fetch", queue);
24161
23670
  if (!result || result === "__EMPTY__" || result === "__UNSUPPORTED__") return null;
24162
23671
  try {
24163
23672
  return JSON.parse(result);
@@ -24175,7 +23684,7 @@ var init_kafkaBackend = __esm({
24175
23684
  });
24176
23685
 
24177
23686
  // src/queueBackends/mongoBackend.ts
24178
- import { randomUUID as randomUUID8 } from "node:crypto";
23687
+ import { randomUUID as randomUUID7 } from "node:crypto";
24179
23688
  import { execFileSync as execFileSync3 } from "node:child_process";
24180
23689
  var MongoBackend2;
24181
23690
  var init_mongoBackend = __esm({
@@ -24488,7 +23997,7 @@ var init_mongoBackend = __esm({
24488
23997
  }
24489
23998
  }
24490
23999
  push(queue, payload, delay) {
24491
- const id = randomUUID8();
24000
+ const id = randomUUID7();
24492
24001
  const now = (/* @__PURE__ */ new Date()).toISOString();
24493
24002
  const job = {
24494
24003
  id,
@@ -24623,9 +24132,9 @@ var init_job = __esm({
24623
24132
  });
24624
24133
 
24625
24134
  // src/queueBackends/liteBackend.ts
24626
- import { mkdirSync as mkdirSync11, readdirSync as readdirSync10, readFileSync as readFileSync15, writeFileSync as writeFileSync8, unlinkSync as unlinkSync5, existsSync as existsSync15 } from "node:fs";
24135
+ import { mkdirSync as mkdirSync11, readdirSync as readdirSync10, readFileSync as readFileSync14, writeFileSync as writeFileSync8, unlinkSync as unlinkSync5, existsSync as existsSync15 } from "node:fs";
24627
24136
  import { join as join17 } from "node:path";
24628
- import { randomUUID as randomUUID9 } from "node:crypto";
24137
+ import { randomUUID as randomUUID8 } from "node:crypto";
24629
24138
  var LiteBackend;
24630
24139
  var init_liteBackend = __esm({
24631
24140
  "src/queueBackends/liteBackend.ts"() {
@@ -24677,7 +24186,7 @@ var init_liteBackend = __esm({
24677
24186
  }
24678
24187
  push(queue, payload, delay, priority) {
24679
24188
  const dir = this.ensureDir(queue);
24680
- const id = randomUUID9();
24189
+ const id = randomUUID8();
24681
24190
  const now = (/* @__PURE__ */ new Date()).toISOString();
24682
24191
  const job = {
24683
24192
  id,
@@ -24713,7 +24222,7 @@ var init_liteBackend = __esm({
24713
24222
  const filePath = join17(dir, filename);
24714
24223
  let job;
24715
24224
  try {
24716
- job = JSON.parse(readFileSync15(filePath, "utf-8"));
24225
+ job = JSON.parse(readFileSync14(filePath, "utf-8"));
24717
24226
  } catch {
24718
24227
  continue;
24719
24228
  }
@@ -24777,7 +24286,7 @@ var init_liteBackend = __esm({
24777
24286
  const filePath = join17(reservedDir, filename);
24778
24287
  let record;
24779
24288
  try {
24780
- record = JSON.parse(readFileSync15(filePath, "utf-8"));
24289
+ record = JSON.parse(readFileSync14(filePath, "utf-8"));
24781
24290
  } catch {
24782
24291
  continue;
24783
24292
  }
@@ -24890,7 +24399,7 @@ var init_liteBackend = __esm({
24890
24399
  let count = 0;
24891
24400
  for (const file of files) {
24892
24401
  try {
24893
- const job = JSON.parse(readFileSync15(join17(scanDir, file), "utf-8"));
24402
+ const job = JSON.parse(readFileSync14(join17(scanDir, file), "utf-8"));
24894
24403
  if (job.status === status2) count++;
24895
24404
  } catch {
24896
24405
  }
@@ -24947,7 +24456,7 @@ var init_liteBackend = __esm({
24947
24456
  const files = readdirSync10(dir).filter((f) => f.endsWith(".queue-data")).sort();
24948
24457
  for (const file of files) {
24949
24458
  try {
24950
- const job = JSON.parse(readFileSync15(join17(dir, file), "utf-8"));
24459
+ const job = JSON.parse(readFileSync14(join17(dir, file), "utf-8"));
24951
24460
  const attempts = job.attempts || 0;
24952
24461
  if (attempts > 0 && attempts < maxRetries) {
24953
24462
  results.push(job);
@@ -24973,7 +24482,7 @@ var init_liteBackend = __esm({
24973
24482
  const failedDir = join17(this.basePath, q, "failed");
24974
24483
  const filePath = join17(failedDir, `${jobId}.queue-data`);
24975
24484
  if (existsSync15(filePath)) {
24976
- const job = JSON.parse(readFileSync15(filePath, "utf-8"));
24485
+ const job = JSON.parse(readFileSync14(filePath, "utf-8"));
24977
24486
  job.status = "pending";
24978
24487
  job.attempts = (job.attempts || 0) + 1;
24979
24488
  job.error = void 0;
@@ -24997,7 +24506,7 @@ var init_liteBackend = __esm({
24997
24506
  const files = readdirSync10(failedDir).filter((f) => f.endsWith(".queue-data")).sort();
24998
24507
  for (const file of files) {
24999
24508
  try {
25000
- const job = JSON.parse(readFileSync15(join17(failedDir, file), "utf-8"));
24509
+ const job = JSON.parse(readFileSync14(join17(failedDir, file), "utf-8"));
25001
24510
  if ((job.attempts || 0) >= maxRetries) {
25002
24511
  job.status = "dead";
25003
24512
  results.push(job);
@@ -25031,7 +24540,7 @@ var init_liteBackend = __esm({
25031
24540
  const files = readdirSync10(dir).filter((f) => f.endsWith(".queue-data"));
25032
24541
  for (const file of files) {
25033
24542
  try {
25034
- const job = JSON.parse(readFileSync15(join17(dir, file), "utf-8"));
24543
+ const job = JSON.parse(readFileSync14(join17(dir, file), "utf-8"));
25035
24544
  if (job.status === status2) {
25036
24545
  unlinkSync5(join17(dir, file));
25037
24546
  count++;
@@ -25058,7 +24567,7 @@ var init_liteBackend = __esm({
25058
24567
  for (const file of files) {
25059
24568
  try {
25060
24569
  const filePath = join17(failedDir, file);
25061
- const job = JSON.parse(readFileSync15(filePath, "utf-8"));
24570
+ const job = JSON.parse(readFileSync14(filePath, "utf-8"));
25062
24571
  if ((job.attempts || 0) >= maxRetries) {
25063
24572
  continue;
25064
24573
  }
@@ -25090,7 +24599,7 @@ var init_liteBackend = __esm({
25090
24599
  const filePath = join17(dir, file);
25091
24600
  let job;
25092
24601
  try {
25093
- job = JSON.parse(readFileSync15(filePath, "utf-8"));
24602
+ job = JSON.parse(readFileSync14(filePath, "utf-8"));
25094
24603
  } catch {
25095
24604
  continue;
25096
24605
  }
@@ -27580,7 +27089,7 @@ ${end}
27580
27089
 
27581
27090
  // src/devAdmin.ts
27582
27091
  import { cpus as osCpus } from "node:os";
27583
- import { readFileSync as readFileSync19, writeFileSync as writeFileSync12, existsSync as existsSync19, readdirSync as readdirSync14, mkdirSync as mkdirSync14, copyFileSync, statSync as statSync15 } from "node:fs";
27092
+ import { readFileSync as readFileSync18, writeFileSync as writeFileSync12, existsSync as existsSync19, readdirSync as readdirSync14, mkdirSync as mkdirSync14, copyFileSync, statSync as statSync15 } from "node:fs";
27584
27093
  import { join as join21, dirname as dirname9, resolve as resolve15, relative as relative8 } from "node:path";
27585
27094
  import { fileURLToPath as fileURLToPath3 } from "node:url";
27586
27095
  import { timingSafeEqual as timingSafeEqual2 } from "node:crypto";
@@ -27766,7 +27275,7 @@ function resolveDevEnvVar(key) {
27766
27275
  if (live !== void 0 && live !== "") return live;
27767
27276
  const envPath = join21(process.cwd(), ".env");
27768
27277
  if (!existsSync19(envPath)) return "";
27769
- for (const line of readFileSync19(envPath, "utf-8").split("\n")) {
27278
+ for (const line of readFileSync18(envPath, "utf-8").split("\n")) {
27770
27279
  const t = line.trim();
27771
27280
  if (!t || t.startsWith("#") || !t.includes("=")) continue;
27772
27281
  const eq = t.indexOf("=");
@@ -27776,7 +27285,7 @@ function resolveDevEnvVar(key) {
27776
27285
  }
27777
27286
  function upsertDevEnvVar(key, value) {
27778
27287
  const envPath = join21(process.cwd(), ".env");
27779
- const lines = existsSync19(envPath) ? readFileSync19(envPath, "utf-8").split("\n") : [];
27288
+ const lines = existsSync19(envPath) ? readFileSync18(envPath, "utf-8").split("\n") : [];
27780
27289
  let found = false;
27781
27290
  const out = [];
27782
27291
  for (const line of lines) {
@@ -27809,7 +27318,7 @@ function parseEnvFile() {
27809
27318
  const envPath = join21(process.cwd(), ".env");
27810
27319
  const result = {};
27811
27320
  if (!existsSync19(envPath)) return result;
27812
- const lines = readFileSync19(envPath, "utf-8").split("\n");
27321
+ const lines = readFileSync18(envPath, "utf-8").split("\n");
27813
27322
  for (const line of lines) {
27814
27323
  const trimmed = line.trim();
27815
27324
  if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
@@ -28166,7 +27675,7 @@ var init_devAdmin = __esm({
28166
27675
  for (const rel of ["../../../package.json", "../../package.json"]) {
28167
27676
  const p = resolve15(__dirname2, rel);
28168
27677
  if (existsSync19(p)) {
28169
- const pkg = JSON.parse(readFileSync19(p, "utf-8"));
27678
+ const pkg = JSON.parse(readFileSync18(p, "utf-8"));
28170
27679
  if (pkg.version) return pkg.version;
28171
27680
  }
28172
27681
  }
@@ -28824,7 +28333,7 @@ var init_devAdmin = __esm({
28824
28333
  for (const filename of readdirSync14(queueDir).sort()) {
28825
28334
  if (!filename.endsWith(".queue-data")) continue;
28826
28335
  try {
28827
- const job = JSON.parse(readFileSync19(join21(queueDir, filename), "utf-8"));
28336
+ const job = JSON.parse(readFileSync18(join21(queueDir, filename), "utf-8"));
28828
28337
  jobs.push(mapQueueJob(job, topic, "pending"));
28829
28338
  } catch {
28830
28339
  }
@@ -29310,7 +28819,7 @@ var init_devAdmin = __esm({
29310
28819
  }
29311
28820
  try {
29312
28821
  const envPath = join21(process.cwd(), ".env");
29313
- const lines = existsSync19(envPath) ? readFileSync19(envPath, "utf-8").split("\n") : [];
28822
+ const lines = existsSync19(envPath) ? readFileSync18(envPath, "utf-8").split("\n") : [];
29314
28823
  const keysFound = { TINA4_DATABASE_URL: false, TINA4_DATABASE_USERNAME: false, TINA4_DATABASE_PASSWORD: false };
29315
28824
  const newLines = [];
29316
28825
  for (const line of lines) {
@@ -29356,7 +28865,7 @@ var init_devAdmin = __esm({
29356
28865
  const metaFile = join21(entryPath, "meta.json");
29357
28866
  if (statSync15(entryPath).isDirectory() && existsSync19(metaFile)) {
29358
28867
  try {
29359
- const meta = JSON.parse(readFileSync19(metaFile, "utf-8"));
28868
+ const meta = JSON.parse(readFileSync18(metaFile, "utf-8"));
29360
28869
  meta.id = entry;
29361
28870
  const srcDir = join21(entryPath, "src");
29362
28871
  if (existsSync19(srcDir)) {
@@ -29571,7 +29080,7 @@ var init_devAdmin = __esm({
29571
29080
  return;
29572
29081
  }
29573
29082
  try {
29574
- const content = readFileSync19(target, "utf-8");
29083
+ const content = readFileSync18(target, "utf-8");
29575
29084
  const path8 = relative8(root, target);
29576
29085
  res.json({ path: path8, content, language: devAdminLanguage(path8), bytes: Buffer.byteLength(content, "utf-8") });
29577
29086
  } catch (e) {
@@ -29613,7 +29122,7 @@ var init_devAdmin = __esm({
29613
29122
  return;
29614
29123
  }
29615
29124
  try {
29616
- const buf = readFileSync19(target);
29125
+ const buf = readFileSync18(target);
29617
29126
  const ext = target.slice(target.lastIndexOf(".") + 1).toLowerCase();
29618
29127
  const mime = {
29619
29128
  js: "application/javascript",
@@ -30041,7 +29550,7 @@ var init_devAdmin = __esm({
30041
29550
  });
30042
29551
 
30043
29552
  // src/i18n.ts
30044
- import { readFileSync as readFileSync20, readdirSync as readdirSync15, existsSync as existsSync20 } from "node:fs";
29553
+ import { readFileSync as readFileSync19, readdirSync as readdirSync15, existsSync as existsSync20 } from "node:fs";
30045
29554
  import { join as join22, resolve as resolve16 } from "node:path";
30046
29555
  var I18n;
30047
29556
  var init_i18n = __esm({
@@ -30138,7 +29647,7 @@ var init_i18n = __esm({
30138
29647
  const filePath = join22(this._localeDir, `${locale}.json`);
30139
29648
  if (existsSync20(filePath)) {
30140
29649
  try {
30141
- const raw = readFileSync20(filePath, "utf-8");
29650
+ const raw = readFileSync19(filePath, "utf-8");
30142
29651
  const data = JSON.parse(raw);
30143
29652
  this._translations.set(locale, _I18n._flatten(data));
30144
29653
  return;
@@ -30151,7 +29660,7 @@ var init_i18n = __esm({
30151
29660
  const yamlPath = join22(this._localeDir, `${locale}${ext}`);
30152
29661
  if (existsSync20(yamlPath)) {
30153
29662
  try {
30154
- const raw = readFileSync20(yamlPath, "utf-8");
29663
+ const raw = readFileSync19(yamlPath, "utf-8");
30155
29664
  const data = _I18n._parseSimpleYaml(raw);
30156
29665
  this._translations.set(locale, _I18n._flatten(data));
30157
29666
  return;
@@ -30819,6 +30328,36 @@ var init_docsAutoDiscovery = __esm({
30819
30328
  }
30820
30329
  });
30821
30330
 
30331
+ // src/sessionHandlers/childError.ts
30332
+ function childFailureReason(err) {
30333
+ const e = err ?? {};
30334
+ const stderr = String(e.stderr ?? "").trim();
30335
+ if (stderr !== "") {
30336
+ return firstLine(stderr);
30337
+ }
30338
+ if (e.code === "ETIMEDOUT" || e.signal) {
30339
+ return `timed out or was killed (${e.code ?? e.signal})`;
30340
+ }
30341
+ if (typeof e.status === "number" && e.status !== 0) {
30342
+ return `child exited with code ${e.status} and no output`;
30343
+ }
30344
+ return firstLine(String(e.message ?? "unknown error"));
30345
+ }
30346
+ function firstLine(text) {
30347
+ const line = text.split("\n", 1)[0] ?? "";
30348
+ return line.length > MAX_FALLBACK ? `${line.slice(0, MAX_FALLBACK)}...` : line;
30349
+ }
30350
+ function childFailureError(label, err) {
30351
+ return new Error(`${label} command failed: ${childFailureReason(err)}`);
30352
+ }
30353
+ var MAX_FALLBACK;
30354
+ var init_childError = __esm({
30355
+ "src/sessionHandlers/childError.ts"() {
30356
+ "use strict";
30357
+ MAX_FALLBACK = 200;
30358
+ }
30359
+ });
30360
+
30822
30361
  // src/sessionHandlers/respClient.ts
30823
30362
  import { execFileSync as execFileSync4 } from "node:child_process";
30824
30363
  function respCommandSync(target, args, label = "Redis") {
@@ -30938,7 +30477,7 @@ function respCommandSync(target, args, label = "Redis") {
30938
30477
  stdio: ["pipe", "pipe", "pipe"]
30939
30478
  });
30940
30479
  } catch (err) {
30941
- throw new Error(`${label} command failed: ${err.message}`);
30480
+ throw childFailureError(label, err);
30942
30481
  }
30943
30482
  if (result === "__NULL__") return "";
30944
30483
  if (result.startsWith("__ERR__")) {
@@ -30949,6 +30488,7 @@ function respCommandSync(target, args, label = "Redis") {
30949
30488
  var init_respClient = __esm({
30950
30489
  "src/sessionHandlers/respClient.ts"() {
30951
30490
  "use strict";
30491
+ init_childError();
30952
30492
  }
30953
30493
  });
30954
30494
 
@@ -30967,6 +30507,7 @@ var moduleRequire, RedisNpmSessionHandler;
30967
30507
  var init_redisHandler = __esm({
30968
30508
  "src/sessionHandlers/redisHandler.ts"() {
30969
30509
  "use strict";
30510
+ init_childError();
30970
30511
  init_respClient();
30971
30512
  moduleRequire = createRequire8(import.meta.url);
30972
30513
  RedisNpmSessionHandler = class {
@@ -31027,9 +30568,17 @@ var init_redisHandler = __esm({
31027
30568
  (async () => {
31028
30569
  try {
31029
30570
  const redis = require("redis");
30571
+ // reconnectStrategy: false \u2014 this child runs ONE command and exits, so
30572
+ // retrying inside it is pointless: the handler is called again on the
30573
+ // next request anyway. With the driver's default strategy a refused
30574
+ // connection never rejects, the child hangs until execFileSync's 5s
30575
+ // timeout kills it, and the caller is told "timed out" when the truth
30576
+ // is "connection refused". Off, connect() rejects in ~5ms with the real
30577
+ // reason -- a better message AND no 5s stall per request when Redis is
30578
+ // down.
31030
30579
  const clientOpts = useUrl
31031
- ? { url }
31032
- : { socket: { host, port }, password: password || undefined, database: db };
30580
+ ? { url, socket: { reconnectStrategy: false } }
30581
+ : { socket: { host, port, reconnectStrategy: false }, password: password || undefined, database: db };
31033
30582
  const client = redis.createClient(clientOpts);
31034
30583
  client.on("error", () => {});
31035
30584
  await client.connect();
@@ -31043,8 +30592,10 @@ var init_redisHandler = __esm({
31043
30592
  const out = (result === null || result === undefined) ? "__NULL__" : String(result);
31044
30593
  process.stdout.write(out, () => process.exit(0));
31045
30594
  } catch (err) {
31046
- process.stderr.write(String((err && err.message) || err));
31047
- process.exit(1);
30595
+ // Exit from the write CALLBACK: stderr to a pipe is an async write and
30596
+ // a bare process.exit() truncates it, which left the parent with an
30597
+ // empty stderr and nothing but execFileSync's script-dump message.
30598
+ process.stderr.write(String((err && err.message) || err), () => process.exit(1));
31048
30599
  }
31049
30600
  })();
31050
30601
  `;
@@ -31056,7 +30607,7 @@ var init_redisHandler = __esm({
31056
30607
  stdio: ["pipe", "pipe", "pipe"]
31057
30608
  });
31058
30609
  } catch (err) {
31059
- throw new Error(`Redis command failed: ${err.message}`);
30610
+ throw childFailureError("Redis", err);
31060
30611
  }
31061
30612
  if (result === "__NULL__") return "";
31062
30613
  return result;
@@ -31188,8 +30739,10 @@ function mongoCommandSync(target, command, args, label = "MongoDB") {
31188
30739
  process.stdout.write(out, () => process.exit(0));
31189
30740
  } catch (err) {
31190
30741
  try { if (client) await client.close(); } catch (e) {}
31191
- process.stderr.write(String((err && err.message) || err));
31192
- process.exit(1);
30742
+ // Exit from the write CALLBACK: stderr to a pipe is an async write and
30743
+ // a bare process.exit() truncates it, which left the parent with an
30744
+ // empty stderr and nothing but execFileSync's script-dump message.
30745
+ process.stderr.write(String((err && err.message) || err), () => process.exit(1));
31193
30746
  }
31194
30747
  })();
31195
30748
  } else {
@@ -31330,12 +30883,13 @@ function mongoCommandSync(target, command, args, label = "MongoDB") {
31330
30883
  stdio: ["pipe", "pipe", "pipe"]
31331
30884
  });
31332
30885
  } catch (err) {
31333
- throw new Error(`${label} command failed: ${err.message}`);
30886
+ throw childFailureError(label, err);
31334
30887
  }
31335
30888
  }
31336
30889
  var init_mongoClient = __esm({
31337
30890
  "src/sessionHandlers/mongoClient.ts"() {
31338
30891
  "use strict";
30892
+ init_childError();
31339
30893
  }
31340
30894
  });
31341
30895
 
@@ -31488,7 +31042,7 @@ __export(session_exports, {
31488
31042
  sessionCookieName: () => sessionCookieName
31489
31043
  });
31490
31044
  import { randomBytes as randomBytes6 } from "node:crypto";
31491
- import { existsSync as existsSync22, mkdirSync as mkdirSync17, readFileSync as readFileSync22, writeFileSync as writeFileSync15, unlinkSync as unlinkSync7, readdirSync as readdirSync16 } from "node:fs";
31045
+ import { existsSync as existsSync22, mkdirSync as mkdirSync17, readFileSync as readFileSync21, writeFileSync as writeFileSync15, unlinkSync as unlinkSync7, readdirSync as readdirSync16 } from "node:fs";
31492
31046
  import { join as join24 } from "node:path";
31493
31047
  function isSecureScheme(forwardedProto, socketEncrypted) {
31494
31048
  const forwarded = (forwardedProto ?? "").trim();
@@ -31541,7 +31095,7 @@ var init_session = __esm({
31541
31095
  const filePath = this.filePath(sessionId);
31542
31096
  try {
31543
31097
  if (!existsSync22(filePath)) return null;
31544
- const raw = readFileSync22(filePath, "utf-8");
31098
+ const raw = readFileSync21(filePath, "utf-8");
31545
31099
  const wrapper = JSON.parse(raw);
31546
31100
  if (wrapper._expires && wrapper._expires > 0 && Date.now() / 1e3 > wrapper._expires) {
31547
31101
  try {
@@ -31577,7 +31131,7 @@ var init_session = __esm({
31577
31131
  if (!file.endsWith(".json")) continue;
31578
31132
  const fullPath = join24(this.storagePath, file);
31579
31133
  try {
31580
- const raw = readFileSync22(fullPath, "utf-8");
31134
+ const raw = readFileSync21(fullPath, "utf-8");
31581
31135
  const wrapper = JSON.parse(raw);
31582
31136
  if (wrapper._expires && wrapper._expires > 0 && now > wrapper._expires) {
31583
31137
  unlinkSync7(fullPath);
@@ -32094,7 +31648,7 @@ var init_events = __esm({
32094
31648
  // src/server.ts
32095
31649
  import { createServer as createServer2 } from "node:http";
32096
31650
  import { resolve as resolve18, dirname as dirname10, join as join25, relative as relative9 } from "node:path";
32097
- import { existsSync as existsSync23, readdirSync as readdirSync17, readFileSync as readFileSync23, statSync as statSync16 } from "node:fs";
31651
+ import { existsSync as existsSync23, readdirSync as readdirSync17, readFileSync as readFileSync22, statSync as statSync16 } from "node:fs";
32098
31652
  import { isatty } from "node:tty";
32099
31653
  import { fileURLToPath as fileURLToPath4 } from "node:url";
32100
31654
  import { execFileSync as execFileSync7, exec } from "node:child_process";
@@ -32156,7 +31710,7 @@ async function autoMigrateOnStartup(migrationDir = "migrations", base = process.
32156
31710
  function readPackageVersion() {
32157
31711
  try {
32158
31712
  const pkgPath = resolve18(dirname10(fileURLToPath4(import.meta.url)), "..", "..", "..", "package.json");
32159
- const pkg = JSON.parse(readFileSync23(pkgPath, "utf-8"));
31713
+ const pkg = JSON.parse(readFileSync22(pkgPath, "utf-8"));
32160
31714
  return pkg.version ?? "0.0.0";
32161
31715
  } catch {
32162
31716
  return "0.0.0";
@@ -32986,7 +32540,7 @@ ${reset2}
32986
32540
  res.raw.writeHead(200, void 0, { "Content-Type": "text/html; charset=utf-8" });
32987
32541
  res.raw.end(html);
32988
32542
  } else {
32989
- const html = readFileSync23(resolve18(templatesDir, tplFile), "utf-8");
32543
+ const html = readFileSync22(resolve18(templatesDir, tplFile), "utf-8");
32990
32544
  res.raw.writeHead(200, void 0, { "Content-Type": "text/html; charset=utf-8" });
32991
32545
  res.raw.end(html);
32992
32546
  }
@@ -33377,7 +32931,7 @@ var init_constants = __esm({
33377
32931
  });
33378
32932
 
33379
32933
  // src/scss.ts
33380
- import { readFileSync as readFileSync24, writeFileSync as writeFileSync16, existsSync as existsSync24, mkdirSync as mkdirSync18, readdirSync as readdirSync18 } from "node:fs";
32934
+ import { readFileSync as readFileSync23, writeFileSync as writeFileSync16, existsSync as existsSync24, mkdirSync as mkdirSync18, readdirSync as readdirSync18 } from "node:fs";
33381
32935
  import { join as join26, resolve as resolve19, dirname as dirname11 } from "node:path";
33382
32936
  function compileString(scss, importPaths, variables) {
33383
32937
  const imported = /* @__PURE__ */ new Set();
@@ -33408,7 +32962,7 @@ function resolveImports(content, paths, imported) {
33408
32962
  for (const candidate of candidates) {
33409
32963
  if (existsSync24(candidate) && !imported.has(candidate)) {
33410
32964
  imported.add(candidate);
33411
- const fileContent = readFileSync24(candidate, "utf-8");
32965
+ const fileContent = readFileSync23(candidate, "utf-8");
33412
32966
  return resolveImports(fileContent, [dirname11(candidate), ...paths], imported);
33413
32967
  }
33414
32968
  }
@@ -33749,7 +33303,7 @@ var init_scss = __esm({
33749
33303
  /** Compile an SCSS file to CSS. */
33750
33304
  compileFile(filePath) {
33751
33305
  const absPath = resolve19(filePath);
33752
- const content = readFileSync24(absPath, "utf-8");
33306
+ const content = readFileSync23(absPath, "utf-8");
33753
33307
  const paths = [dirname11(absPath), ...this._importPaths];
33754
33308
  return compileString(content, paths, { ...this._variables });
33755
33309
  }
@@ -33772,7 +33326,7 @@ var init_scss = __esm({
33772
33326
  const imported = /* @__PURE__ */ new Set();
33773
33327
  let merged = "";
33774
33328
  for (const file of files) {
33775
- const content = readFileSync24(file, "utf-8");
33329
+ const content = readFileSync23(file, "utf-8");
33776
33330
  imported.add(file);
33777
33331
  merged += resolveImports(content, paths, imported) + "\n";
33778
33332
  }
@@ -33789,7 +33343,7 @@ var init_scss = __esm({
33789
33343
  if (!existsSync24(outDir)) mkdirSync18(outDir, { recursive: true });
33790
33344
  let existing = null;
33791
33345
  try {
33792
- existing = existsSync24(absOutput) ? readFileSync24(absOutput, "utf-8") : null;
33346
+ existing = existsSync24(absOutput) ? readFileSync23(absOutput, "utf-8") : null;
33793
33347
  } catch {
33794
33348
  existing = null;
33795
33349
  }
@@ -33873,10 +33427,10 @@ var init_mqttMessage = __esm({
33873
33427
  });
33874
33428
 
33875
33429
  // src/mqtt.ts
33876
- import net3 from "node:net";
33877
- import tls2 from "node:tls";
33430
+ import net2 from "node:net";
33431
+ import tls from "node:tls";
33878
33432
  import { randomBytes as randomBytes7 } from "node:crypto";
33879
- import { existsSync as existsSync25, readFileSync as readFileSync25 } from "node:fs";
33433
+ import { existsSync as existsSync25, readFileSync as readFileSync24 } from "node:fs";
33880
33434
  var MqttError, MqttTimeoutError, CONNECT, CONNACK, PUBLISH, PUBACK, SUBSCRIBE, SUBACK, PINGREQ, PINGRESP, DISCONNECT, PROTOCOL_LEVEL, DEFAULT_PORT, DEFAULT_TLS_PORT, DEFAULT_URL, DEFAULT_KEEPALIVE, SUBSCRIPTION_REFUSED, MAX_REMAINING_LENGTH, QOS2_REFUSED_MESSAGE, CONNACK_RETURN_CODES, Mqtt;
33881
33435
  var init_mqtt = __esm({
33882
33436
  "src/mqtt.ts"() {
@@ -34341,10 +33895,10 @@ var init_mqtt = __esm({
34341
33895
  servername: this.host,
34342
33896
  rejectUnauthorized: this.tlsVerify
34343
33897
  };
34344
- if (this.tlsVerify && this.caFile) opts.ca = readFileSync25(this.caFile);
34345
- sock = tls2.connect(opts, () => settle(() => resolve21(sock)));
33898
+ if (this.tlsVerify && this.caFile) opts.ca = readFileSync24(this.caFile);
33899
+ sock = tls.connect(opts, () => settle(() => resolve21(sock)));
34346
33900
  } else {
34347
- sock = net3.createConnection({ host: this.host, port: this.port }, () => settle(() => resolve21(sock)));
33901
+ sock = net2.createConnection({ host: this.host, port: this.port }, () => settle(() => resolve21(sock)));
34348
33902
  }
34349
33903
  sock.once("error", (err) => {
34350
33904
  settle(() => {
@@ -34895,7 +34449,7 @@ import https from "node:https";
34895
34449
  import { URL as URL2 } from "node:url";
34896
34450
  import { randomBytes as randomBytes8 } from "node:crypto";
34897
34451
  import { promises as fsp, createWriteStream } from "node:fs";
34898
- import { basename as basename6 } from "node:path";
34452
+ import { basename as basename5 } from "node:path";
34899
34453
  import { pipeline } from "node:stream/promises";
34900
34454
  function sameOrigin(urlA, urlB) {
34901
34455
  try {
@@ -35185,7 +34739,7 @@ var init_api = __esm({
35185
34739
  error: err instanceof Error ? err.message : String(err)
35186
34740
  };
35187
34741
  }
35188
- uploadName = filename || basename6(filePath);
34742
+ uploadName = filename || basename5(filePath);
35189
34743
  } else {
35190
34744
  return { http_code: null, body: null, headers: {}, error: "upload requires filePath or fileBytes" };
35191
34745
  }
@@ -35541,6 +35095,777 @@ var init_api = __esm({
35541
35095
  }
35542
35096
  });
35543
35097
 
35098
+ // src/messenger.ts
35099
+ import net3 from "node:net";
35100
+ import tls2 from "node:tls";
35101
+ import { readFileSync as readFileSync25 } from "node:fs";
35102
+ import { basename as basename6 } from "node:path";
35103
+ import { randomUUID as randomUUID9 } from "node:crypto";
35104
+ function tlsRejectUnauthorized() {
35105
+ return !isTruthy(process.env.TINA4_MAIL_TLS_INSECURE);
35106
+ }
35107
+ function readResponse(socket) {
35108
+ return new Promise((resolve21, reject) => {
35109
+ let buffer = "";
35110
+ const onData = (chunk) => {
35111
+ buffer += chunk.toString("utf-8");
35112
+ const lines = buffer.split("\r\n");
35113
+ for (let i = 0; i < lines.length; i++) {
35114
+ const line = lines[i];
35115
+ if (line.length < 3) continue;
35116
+ const code = parseInt(line.substring(0, 3), 10);
35117
+ if (line.length >= 4 && line[3] === " ") {
35118
+ socket.removeListener("data", onData);
35119
+ socket.removeListener("error", onError);
35120
+ resolve21({ code, text: buffer.trim() });
35121
+ return;
35122
+ }
35123
+ }
35124
+ };
35125
+ const onError = (err) => {
35126
+ socket.removeListener("data", onData);
35127
+ reject(err);
35128
+ };
35129
+ socket.on("data", onData);
35130
+ socket.on("error", onError);
35131
+ });
35132
+ }
35133
+ function sendCommand(socket, command) {
35134
+ return new Promise((resolve21, reject) => {
35135
+ socket.write(command + "\r\n", "utf-8", (err) => {
35136
+ if (err) return reject(err);
35137
+ readResponse(socket).then(resolve21, reject);
35138
+ });
35139
+ });
35140
+ }
35141
+ function buildMimeMessage(options) {
35142
+ const boundary = `----=_Tina4_${Date.now()}_${Math.random().toString(36).substring(2)}`;
35143
+ const altBoundary = `----=_Tina4Alt_${Date.now()}_${Math.random().toString(36).substring(2)}`;
35144
+ const hasAttachments = options.attachments && options.attachments.length > 0;
35145
+ const hasTextAlt = options.text !== void 0 && options.html;
35146
+ const lines = [];
35147
+ const fromHeader = options.fromName ? `"${options.fromName}" <${options.from}>` : options.from;
35148
+ lines.push(`From: ${fromHeader}`);
35149
+ lines.push(`To: ${options.to.join(", ")}`);
35150
+ if (options.cc.length > 0) {
35151
+ lines.push(`Cc: ${options.cc.join(", ")}`);
35152
+ }
35153
+ lines.push(`Subject: ${options.subject}`);
35154
+ lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
35155
+ lines.push(`Message-ID: <${options.messageId}>`);
35156
+ lines.push("MIME-Version: 1.0");
35157
+ if (options.replyTo) {
35158
+ lines.push(`Reply-To: ${options.replyTo}`);
35159
+ }
35160
+ if (options.headers) {
35161
+ for (const [key, value] of Object.entries(options.headers)) {
35162
+ lines.push(`${key}: ${value}`);
35163
+ }
35164
+ }
35165
+ if (hasAttachments) {
35166
+ lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
35167
+ lines.push("");
35168
+ lines.push(`--${boundary}`);
35169
+ if (hasTextAlt) {
35170
+ lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
35171
+ lines.push("");
35172
+ lines.push(`--${altBoundary}`);
35173
+ lines.push("Content-Type: text/plain; charset=UTF-8");
35174
+ lines.push("Content-Transfer-Encoding: 7bit");
35175
+ lines.push("");
35176
+ lines.push(options.text);
35177
+ lines.push("");
35178
+ lines.push(`--${altBoundary}`);
35179
+ lines.push("Content-Type: text/html; charset=UTF-8");
35180
+ lines.push("Content-Transfer-Encoding: 7bit");
35181
+ lines.push("");
35182
+ lines.push(options.body);
35183
+ lines.push("");
35184
+ lines.push(`--${altBoundary}--`);
35185
+ } else {
35186
+ const contentType = options.html ? "text/html" : "text/plain";
35187
+ lines.push(`Content-Type: ${contentType}; charset=UTF-8`);
35188
+ lines.push("Content-Transfer-Encoding: 7bit");
35189
+ lines.push("");
35190
+ lines.push(options.body);
35191
+ }
35192
+ for (const filePath of options.attachments) {
35193
+ const fileName = basename6(filePath);
35194
+ const fileData = readFileSync25(filePath);
35195
+ const base64Data = fileData.toString("base64");
35196
+ lines.push("");
35197
+ lines.push(`--${boundary}`);
35198
+ lines.push(`Content-Type: application/octet-stream; name="${fileName}"`);
35199
+ lines.push("Content-Transfer-Encoding: base64");
35200
+ lines.push(`Content-Disposition: attachment; filename="${fileName}"`);
35201
+ lines.push("");
35202
+ for (let i = 0; i < base64Data.length; i += 76) {
35203
+ lines.push(base64Data.substring(i, i + 76));
35204
+ }
35205
+ }
35206
+ lines.push("");
35207
+ lines.push(`--${boundary}--`);
35208
+ } else if (hasTextAlt) {
35209
+ lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
35210
+ lines.push("");
35211
+ lines.push(`--${altBoundary}`);
35212
+ lines.push("Content-Type: text/plain; charset=UTF-8");
35213
+ lines.push("Content-Transfer-Encoding: 7bit");
35214
+ lines.push("");
35215
+ lines.push(options.text);
35216
+ lines.push("");
35217
+ lines.push(`--${altBoundary}`);
35218
+ lines.push("Content-Type: text/html; charset=UTF-8");
35219
+ lines.push("Content-Transfer-Encoding: 7bit");
35220
+ lines.push("");
35221
+ lines.push(options.body);
35222
+ lines.push("");
35223
+ lines.push(`--${altBoundary}--`);
35224
+ } else {
35225
+ const contentType = options.html ? "text/html" : "text/plain";
35226
+ lines.push(`Content-Type: ${contentType}; charset=UTF-8`);
35227
+ lines.push("");
35228
+ lines.push(options.body);
35229
+ }
35230
+ return lines.join("\r\n");
35231
+ }
35232
+ function imapQuote(s) {
35233
+ if (/^[a-zA-Z0-9_./-]+$/.test(s)) return s;
35234
+ return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
35235
+ }
35236
+ function imapReadLine(socket) {
35237
+ return new Promise((resolve21, reject) => {
35238
+ let buffer = "";
35239
+ const onData = (chunk) => {
35240
+ buffer += chunk.toString("utf-8");
35241
+ const nlIndex = buffer.indexOf("\r\n");
35242
+ if (nlIndex !== -1) {
35243
+ socket.removeListener("data", onData);
35244
+ socket.removeListener("error", onError);
35245
+ resolve21(buffer);
35246
+ }
35247
+ };
35248
+ const onError = (err) => {
35249
+ socket.removeListener("data", onData);
35250
+ reject(err);
35251
+ };
35252
+ socket.on("data", onData);
35253
+ socket.on("error", onError);
35254
+ });
35255
+ }
35256
+ function imapCommand(socket, command) {
35257
+ return new Promise((resolve21, reject) => {
35258
+ imapTagCounter++;
35259
+ const tag = `T${imapTagCounter}`;
35260
+ const fullCommand = `${tag} ${command}\r
35261
+ `;
35262
+ let buffer = "";
35263
+ const onData = (chunk) => {
35264
+ buffer += chunk.toString("utf-8");
35265
+ if (buffer.includes(`${tag} OK`)) {
35266
+ socket.removeListener("data", onData);
35267
+ socket.removeListener("error", onError);
35268
+ resolve21(buffer);
35269
+ return;
35270
+ }
35271
+ if (buffer.includes(`${tag} NO`) || buffer.includes(`${tag} BAD`)) {
35272
+ socket.removeListener("data", onData);
35273
+ socket.removeListener("error", onError);
35274
+ reject(new MessengerConnectionError(`IMAP command failed: ${command.split(" ")[0]} \u2192 ${buffer.trim()}`));
35275
+ }
35276
+ };
35277
+ const onError = (err) => {
35278
+ socket.removeListener("data", onData);
35279
+ reject(err);
35280
+ };
35281
+ socket.on("data", onData);
35282
+ socket.on("error", onError);
35283
+ socket.write(fullCommand, "utf-8");
35284
+ });
35285
+ }
35286
+ function imapFail(method, err) {
35287
+ const e = err instanceof Error ? err : new Error(String(err));
35288
+ Log.error(`Messenger IMAP ${method}() failed: ${e.name}: ${e.message}`);
35289
+ if (e instanceof MessengerConnectionError) return e;
35290
+ return new MessengerConnectionError(`IMAP ${method} failed: ${e.message}`);
35291
+ }
35292
+ function parseSearchResponse(response) {
35293
+ const match = response.match(/\* SEARCH (.+)/);
35294
+ if (!match) return [];
35295
+ return match[1].trim().split(/\s+/).filter((s) => /^\d+$/.test(s));
35296
+ }
35297
+ function parseHeaderResponse(uid, response) {
35298
+ const headers = {};
35299
+ const headerBlock = response.match(/\r\n([\s\S]*?)\r\n\)/);
35300
+ if (headerBlock) {
35301
+ const lines = headerBlock[1].split(/\r\n/);
35302
+ let currentKey = "";
35303
+ for (const line of lines) {
35304
+ if (/^\s/.test(line) && currentKey) {
35305
+ headers[currentKey] += " " + line.trim();
35306
+ } else {
35307
+ const colonIdx = line.indexOf(":");
35308
+ if (colonIdx > 0) {
35309
+ currentKey = line.substring(0, colonIdx).trim().toLowerCase();
35310
+ headers[currentKey] = line.substring(colonIdx + 1).trim();
35311
+ }
35312
+ }
35313
+ }
35314
+ }
35315
+ const seen = /\\Seen/i.test(response);
35316
+ return {
35317
+ uid,
35318
+ subject: headers["subject"] ?? "",
35319
+ from: headers["from"] ?? "",
35320
+ to: headers["to"] ?? "",
35321
+ date: headers["date"] ?? "",
35322
+ snippet: "",
35323
+ seen
35324
+ };
35325
+ }
35326
+ function emptyFullMessage(uid) {
35327
+ return { uid, subject: "", from: "", to: "", cc: "", date: "", bodyText: "", bodyHtml: "", headers: {} };
35328
+ }
35329
+ function parseFullMessage(uid, response) {
35330
+ const bodyMatch = response.match(/\{(\d+)\}\r\n([\s\S]*)/);
35331
+ const rawMessage = bodyMatch ? bodyMatch[2] : response;
35332
+ const headerEnd = rawMessage.indexOf("\r\n\r\n");
35333
+ const headerSection = headerEnd > 0 ? rawMessage.substring(0, headerEnd) : rawMessage;
35334
+ const bodySection = headerEnd > 0 ? rawMessage.substring(headerEnd + 4) : "";
35335
+ const headers = {};
35336
+ const headerLines = headerSection.split(/\r\n/);
35337
+ let currentKey = "";
35338
+ for (const line of headerLines) {
35339
+ if (/^\s/.test(line) && currentKey) {
35340
+ headers[currentKey] += " " + line.trim();
35341
+ } else {
35342
+ const colonIdx = line.indexOf(":");
35343
+ if (colonIdx > 0) {
35344
+ currentKey = line.substring(0, colonIdx).trim().toLowerCase();
35345
+ headers[currentKey] = line.substring(colonIdx + 1).trim();
35346
+ }
35347
+ }
35348
+ }
35349
+ const contentType = headers["content-type"] ?? "text/plain";
35350
+ let bodyText = "";
35351
+ let bodyHtml = "";
35352
+ if (contentType.includes("multipart")) {
35353
+ const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/);
35354
+ if (boundaryMatch) {
35355
+ const boundary = boundaryMatch[1];
35356
+ const parts = bodySection.split("--" + boundary);
35357
+ for (const part of parts) {
35358
+ if (part.trim() === "" || part.trim() === "--") continue;
35359
+ const partHeaderEnd = part.indexOf("\r\n\r\n");
35360
+ const partHeaders = partHeaderEnd > 0 ? part.substring(0, partHeaderEnd).toLowerCase() : "";
35361
+ const partBody = partHeaderEnd > 0 ? part.substring(partHeaderEnd + 4).trim() : "";
35362
+ if (partHeaders.includes("text/html")) {
35363
+ bodyHtml = partBody;
35364
+ } else if (partHeaders.includes("text/plain")) {
35365
+ bodyText = partBody;
35366
+ }
35367
+ }
35368
+ }
35369
+ } else if (contentType.includes("text/html")) {
35370
+ bodyHtml = bodySection;
35371
+ } else {
35372
+ bodyText = bodySection;
35373
+ }
35374
+ bodyText = bodyText.replace(/\)\r\n[A-Z]\d+ OK.*$/s, "").trim();
35375
+ bodyHtml = bodyHtml.replace(/\)\r\n[A-Z]\d+ OK.*$/s, "").trim();
35376
+ return {
35377
+ uid,
35378
+ subject: headers["subject"] ?? "",
35379
+ from: headers["from"] ?? "",
35380
+ to: headers["to"] ?? "",
35381
+ cc: headers["cc"] ?? "",
35382
+ date: headers["date"] ?? "",
35383
+ bodyText,
35384
+ bodyHtml,
35385
+ headers
35386
+ };
35387
+ }
35388
+ function createMessenger() {
35389
+ return new Messenger();
35390
+ }
35391
+ var MessengerConnectionError, Messenger, imapTagCounter;
35392
+ var init_messenger = __esm({
35393
+ "src/messenger.ts"() {
35394
+ "use strict";
35395
+ init_dotenv();
35396
+ init_devMailbox();
35397
+ init_logger();
35398
+ MessengerConnectionError = class extends Error {
35399
+ constructor(message) {
35400
+ super(message);
35401
+ this.name = "MessengerConnectionError";
35402
+ }
35403
+ };
35404
+ Messenger = class {
35405
+ host;
35406
+ port;
35407
+ username;
35408
+ password;
35409
+ fromAddress;
35410
+ fromName;
35411
+ encryption;
35412
+ useTls;
35413
+ /** Whether an SMTP host was actually configured (see the constructor). */
35414
+ smtpConfigured = false;
35415
+ /** The local mailbox, present only when this messenger captures. */
35416
+ devMailbox = null;
35417
+ imapHost;
35418
+ imapPort;
35419
+ imapUser;
35420
+ imapPass;
35421
+ imapEncryption;
35422
+ constructor(options) {
35423
+ this.smtpConfigured = Boolean(options?.host ?? process.env.TINA4_MAIL_HOST);
35424
+ this.host = options?.host ?? process.env.TINA4_MAIL_HOST ?? "localhost";
35425
+ this.port = options?.port ?? parseInt(process.env.TINA4_MAIL_PORT ?? "587", 10);
35426
+ this.username = options?.username ?? process.env.TINA4_MAIL_USERNAME ?? "";
35427
+ this.password = options?.password ?? process.env.TINA4_MAIL_PASSWORD ?? "";
35428
+ this.fromAddress = options?.fromAddress ?? process.env.TINA4_MAIL_FROM ?? (this.username || "noreply@localhost");
35429
+ this.fromName = options?.fromName ?? process.env.TINA4_MAIL_FROM_NAME ?? "";
35430
+ const envEncryption = options?.encryption ?? process.env.TINA4_MAIL_ENCRYPTION;
35431
+ if (envEncryption) {
35432
+ this.encryption = envEncryption.toLowerCase();
35433
+ } else if (options?.useTls !== void 0) {
35434
+ this.encryption = options.useTls ? "tls" : "none";
35435
+ } else {
35436
+ this.encryption = "tls";
35437
+ }
35438
+ this.useTls = ["tls", "starttls"].includes(this.encryption);
35439
+ this.imapHost = options?.imapHost ?? process.env.TINA4_MAIL_IMAP_HOST ?? "";
35440
+ this.imapPort = options?.imapPort ?? parseInt(process.env.TINA4_MAIL_IMAP_PORT ?? "993", 10);
35441
+ this.imapUser = options?.imapUser ?? process.env.TINA4_MAIL_IMAP_USERNAME ?? this.username;
35442
+ this.imapPass = options?.imapPass ?? process.env.TINA4_MAIL_IMAP_PASSWORD ?? this.password;
35443
+ this.imapEncryption = (options?.imapEncryption ?? process.env.TINA4_MAIL_IMAP_ENCRYPTION ?? "tls").toLowerCase();
35444
+ }
35445
+ /**
35446
+ * Read-only IMAP encryption mode for inspection / tests.
35447
+ * Returns one of "tls", "starttls", "none", "ssl".
35448
+ */
35449
+ getImapEncryption() {
35450
+ return this.imapEncryption;
35451
+ }
35452
+ /**
35453
+ * Send an email via SMTP.
35454
+ */
35455
+ /**
35456
+ * Should send() capture locally instead of talking to SMTP?
35457
+ *
35458
+ * Availability decides, not verbosity. With no SMTP host configured sending is
35459
+ * impossible, so simulate it into a folder rather than failing -- that is what
35460
+ * makes a laptop with no mail server usable. TINA4_MAIL_CAPTURE forces capture
35461
+ * even when a host IS configured.
35462
+ *
35463
+ * TINA4_DEBUG deliberately does NOT gate this, and neither does NODE_ENV. Debug
35464
+ * must still be able to send, and the old `NODE_ENV !== "production"` clause
35465
+ * silently swallowed every staging email.
35466
+ */
35467
+ shouldCapture() {
35468
+ if (isTruthy(process.env.TINA4_MAIL_CAPTURE)) return true;
35469
+ return !this.smtpConfigured;
35470
+ }
35471
+ /** The local mailbox, created on first capture and reused after. */
35472
+ getDevMailbox() {
35473
+ if (this.devMailbox === null) {
35474
+ this.devMailbox = new DevMailbox();
35475
+ }
35476
+ return this.devMailbox;
35477
+ }
35478
+ async send(to, subject, body, html = false, text, cc, bcc, replyTo, attachments, headers) {
35479
+ const options = { to, subject, body, html, text, cc, bcc, replyTo, attachments, headers };
35480
+ const toList = Array.isArray(options.to) ? options.to : [options.to];
35481
+ const ccList = Array.isArray(options.cc) ? options.cc : options.cc ? [options.cc] : [];
35482
+ const bccList = Array.isArray(options.bcc) ? options.bcc : options.bcc ? [options.bcc] : [];
35483
+ const allRecipients = [...toList, ...ccList, ...bccList];
35484
+ if (this.shouldCapture()) {
35485
+ return this.getDevMailbox().capture(
35486
+ to,
35487
+ subject,
35488
+ body,
35489
+ html,
35490
+ text,
35491
+ ccList,
35492
+ bccList,
35493
+ replyTo,
35494
+ attachments,
35495
+ this.fromAddress || void 0
35496
+ );
35497
+ }
35498
+ const messageId = `${randomUUID9()}@${this.host}`;
35499
+ if (allRecipients.length === 0) {
35500
+ return { success: false, message: "No recipients specified" };
35501
+ }
35502
+ if (!this.fromAddress) {
35503
+ return { success: false, message: "No from address configured" };
35504
+ }
35505
+ try {
35506
+ let socket;
35507
+ if (this.port === 465) {
35508
+ socket = tls2.connect({ host: this.host, port: this.port, rejectUnauthorized: tlsRejectUnauthorized() });
35509
+ await new Promise((resolve21, reject) => {
35510
+ socket.once("secureConnect", resolve21);
35511
+ socket.once("error", reject);
35512
+ });
35513
+ } else {
35514
+ socket = net3.createConnection({ host: this.host, port: this.port });
35515
+ await new Promise((resolve21, reject) => {
35516
+ socket.once("connect", resolve21);
35517
+ socket.once("error", reject);
35518
+ });
35519
+ }
35520
+ const greeting = await readResponse(socket);
35521
+ if (greeting.code !== 220) {
35522
+ socket.destroy();
35523
+ return { success: false, message: `SMTP greeting failed: ${greeting.text}` };
35524
+ }
35525
+ const ehlo = await sendCommand(socket, `EHLO ${this.host}`);
35526
+ if (ehlo.code !== 250) {
35527
+ socket.destroy();
35528
+ return { success: false, message: `EHLO failed: ${ehlo.text}` };
35529
+ }
35530
+ if (this.useTls && this.port !== 465 && ehlo.text.includes("STARTTLS")) {
35531
+ const starttls = await sendCommand(socket, "STARTTLS");
35532
+ if (starttls.code !== 220) {
35533
+ socket.destroy();
35534
+ return { success: false, message: `STARTTLS failed: ${starttls.text}` };
35535
+ }
35536
+ const plainSocket = socket;
35537
+ socket = tls2.connect(
35538
+ { socket: plainSocket, host: this.host, rejectUnauthorized: tlsRejectUnauthorized() }
35539
+ );
35540
+ await new Promise((resolve21, reject) => {
35541
+ socket.once("secureConnect", resolve21);
35542
+ socket.once("error", reject);
35543
+ });
35544
+ const ehlo2 = await sendCommand(socket, `EHLO ${this.host}`);
35545
+ if (ehlo2.code !== 250) {
35546
+ socket.destroy();
35547
+ return { success: false, message: `EHLO after STARTTLS failed: ${ehlo2.text}` };
35548
+ }
35549
+ }
35550
+ if (this.username && this.password) {
35551
+ const auth = await sendCommand(socket, "AUTH LOGIN");
35552
+ if (auth.code !== 334) {
35553
+ socket.destroy();
35554
+ return { success: false, message: `AUTH LOGIN failed: ${auth.text}` };
35555
+ }
35556
+ const userResp = await sendCommand(socket, Buffer.from(this.username).toString("base64"));
35557
+ if (userResp.code !== 334) {
35558
+ socket.destroy();
35559
+ return { success: false, message: `AUTH username failed: ${userResp.text}` };
35560
+ }
35561
+ const passResp = await sendCommand(socket, Buffer.from(this.password).toString("base64"));
35562
+ if (passResp.code !== 235) {
35563
+ socket.destroy();
35564
+ return { success: false, message: `AUTH password failed: ${passResp.text}` };
35565
+ }
35566
+ }
35567
+ const mailFrom = await sendCommand(socket, `MAIL FROM:<${this.fromAddress}>`);
35568
+ if (mailFrom.code !== 250) {
35569
+ socket.destroy();
35570
+ return { success: false, message: `MAIL FROM failed: ${mailFrom.text}` };
35571
+ }
35572
+ for (const recipient of allRecipients) {
35573
+ const rcpt = await sendCommand(socket, `RCPT TO:<${recipient}>`);
35574
+ if (rcpt.code !== 250 && rcpt.code !== 251) {
35575
+ socket.destroy();
35576
+ return { success: false, message: `RCPT TO <${recipient}> failed: ${rcpt.text}` };
35577
+ }
35578
+ }
35579
+ const dataCmd = await sendCommand(socket, "DATA");
35580
+ if (dataCmd.code !== 354) {
35581
+ socket.destroy();
35582
+ return { success: false, message: `DATA failed: ${dataCmd.text}` };
35583
+ }
35584
+ const mimeMessage = buildMimeMessage({
35585
+ from: this.fromAddress,
35586
+ fromName: this.fromName,
35587
+ to: toList,
35588
+ cc: ccList,
35589
+ subject: options.subject,
35590
+ body: options.body,
35591
+ html: options.html ?? false,
35592
+ text: options.text,
35593
+ replyTo: options.replyTo,
35594
+ attachments: options.attachments,
35595
+ headers: options.headers,
35596
+ messageId
35597
+ });
35598
+ const endData = await sendCommand(socket, mimeMessage + "\r\n.");
35599
+ if (endData.code !== 250) {
35600
+ socket.destroy();
35601
+ return { success: false, message: `Message delivery failed: ${endData.text}` };
35602
+ }
35603
+ await sendCommand(socket, "QUIT");
35604
+ socket.destroy();
35605
+ return { success: true, message: "Email sent successfully", id: messageId };
35606
+ } catch (err) {
35607
+ const errMsg = err instanceof Error ? err.message : String(err);
35608
+ return { success: false, message: `SMTP error: ${errMsg}` };
35609
+ }
35610
+ }
35611
+ /**
35612
+ * Test the SMTP connection without sending an email.
35613
+ */
35614
+ async testConnection() {
35615
+ try {
35616
+ let socket;
35617
+ if (this.port === 465) {
35618
+ socket = tls2.connect({ host: this.host, port: this.port, rejectUnauthorized: tlsRejectUnauthorized() });
35619
+ await new Promise((resolve21, reject) => {
35620
+ socket.once("secureConnect", resolve21);
35621
+ socket.once("error", reject);
35622
+ });
35623
+ } else {
35624
+ socket = net3.createConnection({ host: this.host, port: this.port });
35625
+ await new Promise((resolve21, reject) => {
35626
+ socket.once("connect", resolve21);
35627
+ socket.once("error", reject);
35628
+ });
35629
+ }
35630
+ const greeting = await readResponse(socket);
35631
+ if (greeting.code !== 220) {
35632
+ socket.destroy();
35633
+ return { success: false, message: `SMTP greeting failed: ${greeting.text}` };
35634
+ }
35635
+ const ehlo = await sendCommand(socket, `EHLO ${this.host}`);
35636
+ if (ehlo.code !== 250) {
35637
+ socket.destroy();
35638
+ return { success: false, message: `EHLO failed: ${ehlo.text}` };
35639
+ }
35640
+ await sendCommand(socket, "QUIT");
35641
+ socket.destroy();
35642
+ return { success: true, message: `Connected to ${this.host}:${this.port}` };
35643
+ } catch (err) {
35644
+ const errMsg = err instanceof Error ? err.message : String(err);
35645
+ return { success: false, message: `Connection failed: ${errMsg}` };
35646
+ }
35647
+ }
35648
+ // ── IMAP (Read) ────────────────────────────────────────────
35649
+ /**
35650
+ * Connect to the IMAP server via raw TCP/TLS.
35651
+ * Returns the socket and reads the greeting.
35652
+ */
35653
+ async imapConnect() {
35654
+ if (!this.imapHost) {
35655
+ throw new Error("IMAP host not configured (set imapHost or IMAP_HOST env)");
35656
+ }
35657
+ let socket;
35658
+ const useTls = this.imapEncryption === "tls" || this.imapEncryption === "ssl" || this.imapEncryption === "" && this.imapPort === 993;
35659
+ if (useTls) {
35660
+ socket = tls2.connect({ host: this.imapHost, port: this.imapPort, rejectUnauthorized: tlsRejectUnauthorized() });
35661
+ await new Promise((resolve21, reject) => {
35662
+ socket.once("secureConnect", resolve21);
35663
+ socket.once("error", reject);
35664
+ });
35665
+ } else {
35666
+ socket = net3.createConnection({ host: this.imapHost, port: this.imapPort });
35667
+ await new Promise((resolve21, reject) => {
35668
+ socket.once("connect", resolve21);
35669
+ socket.once("error", reject);
35670
+ });
35671
+ }
35672
+ await imapReadLine(socket);
35673
+ if (this.imapUser && this.imapPass) {
35674
+ const loginResp = await imapCommand(socket, `LOGIN ${imapQuote(this.imapUser)} ${imapQuote(this.imapPass)}`);
35675
+ if (!loginResp.includes("OK")) {
35676
+ socket.destroy();
35677
+ throw new Error(`IMAP login failed: ${loginResp}`);
35678
+ }
35679
+ }
35680
+ return socket;
35681
+ }
35682
+ /**
35683
+ * Disconnect from IMAP cleanly.
35684
+ */
35685
+ async imapDisconnect(socket) {
35686
+ try {
35687
+ await imapCommand(socket, "LOGOUT");
35688
+ } catch {
35689
+ }
35690
+ socket.destroy();
35691
+ }
35692
+ /**
35693
+ * Fetch latest messages from a folder.
35694
+ * Returns list of message summaries.
35695
+ */
35696
+ async inbox(limit = 20, offset = 0, folder = "INBOX") {
35697
+ let socket;
35698
+ try {
35699
+ socket = await this.imapConnect();
35700
+ } catch (err) {
35701
+ throw imapFail("inbox", err);
35702
+ }
35703
+ try {
35704
+ await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
35705
+ const searchResp = await imapCommand(socket, "SEARCH ALL");
35706
+ const uids = parseSearchResponse(searchResp);
35707
+ if (uids.length === 0) return [];
35708
+ uids.reverse();
35709
+ const selected = uids.slice(offset, offset + limit);
35710
+ if (selected.length === 0) return [];
35711
+ const messages = [];
35712
+ for (const uid of selected) {
35713
+ const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])`);
35714
+ messages.push(parseHeaderResponse(uid, fetchResp));
35715
+ }
35716
+ return messages;
35717
+ } catch (err) {
35718
+ throw imapFail("inbox", err);
35719
+ } finally {
35720
+ await this.imapDisconnect(socket);
35721
+ }
35722
+ }
35723
+ /**
35724
+ * Read a single message by sequence number or UID.
35725
+ */
35726
+ async read(uid, folder = "INBOX") {
35727
+ let socket;
35728
+ try {
35729
+ socket = await this.imapConnect();
35730
+ } catch (err) {
35731
+ throw imapFail("read", err);
35732
+ }
35733
+ try {
35734
+ await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
35735
+ const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY[])`);
35736
+ if (!/\{\d+\}/.test(fetchResp)) {
35737
+ return emptyFullMessage(uid);
35738
+ }
35739
+ await imapCommand(socket, `STORE ${uid} +FLAGS (\\Seen)`);
35740
+ return parseFullMessage(uid, fetchResp);
35741
+ } catch (err) {
35742
+ throw imapFail("read", err);
35743
+ } finally {
35744
+ await this.imapDisconnect(socket);
35745
+ }
35746
+ }
35747
+ /**
35748
+ * Search messages using IMAP search criteria.
35749
+ */
35750
+ async search(folder = "INBOX", subject, sender, since, before, unseenOnly = false, limit = 50) {
35751
+ const criteria = ["ALL"];
35752
+ if (subject) criteria.push(`SUBJECT "${subject}"`);
35753
+ if (sender) criteria.push(`FROM "${sender}"`);
35754
+ if (since) criteria.push(`SINCE ${since}`);
35755
+ if (before) criteria.push(`BEFORE ${before}`);
35756
+ if (unseenOnly) criteria.push("UNSEEN");
35757
+ const query = criteria.join(" ");
35758
+ let socket;
35759
+ try {
35760
+ socket = await this.imapConnect();
35761
+ } catch (err) {
35762
+ throw imapFail("search", err);
35763
+ }
35764
+ try {
35765
+ await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
35766
+ const searchResp = await imapCommand(socket, `SEARCH ${query}`);
35767
+ const uids = parseSearchResponse(searchResp);
35768
+ if (uids.length === 0) return [];
35769
+ uids.reverse();
35770
+ const messages = [];
35771
+ for (const uid of uids.slice(0, limit)) {
35772
+ const fetchResp = await imapCommand(socket, `FETCH ${uid} (FLAGS BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DATE)])`);
35773
+ messages.push(parseHeaderResponse(uid, fetchResp));
35774
+ }
35775
+ return messages;
35776
+ } catch (err) {
35777
+ throw imapFail("search", err);
35778
+ } finally {
35779
+ await this.imapDisconnect(socket);
35780
+ }
35781
+ }
35782
+ /**
35783
+ * Delete a message by UID.
35784
+ */
35785
+ async deleteMessage(uid, folder = "INBOX") {
35786
+ const socket = await this.imapConnect();
35787
+ try {
35788
+ await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
35789
+ await imapCommand(socket, `STORE ${uid} +FLAGS (\\Deleted)`);
35790
+ await imapCommand(socket, "EXPUNGE");
35791
+ } finally {
35792
+ await this.imapDisconnect(socket);
35793
+ }
35794
+ }
35795
+ /**
35796
+ * Mark a message as read.
35797
+ */
35798
+ async markRead(uid, folder = "INBOX") {
35799
+ const socket = await this.imapConnect();
35800
+ try {
35801
+ await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
35802
+ await imapCommand(socket, `STORE ${uid} +FLAGS (\\Seen)`);
35803
+ } finally {
35804
+ await this.imapDisconnect(socket);
35805
+ }
35806
+ }
35807
+ /**
35808
+ * Count unseen messages in a folder.
35809
+ */
35810
+ async unread(folder = "INBOX") {
35811
+ let socket;
35812
+ try {
35813
+ socket = await this.imapConnect();
35814
+ } catch (err) {
35815
+ throw imapFail("unread", err);
35816
+ }
35817
+ try {
35818
+ await imapCommand(socket, `SELECT ${imapQuote(folder)}`);
35819
+ const searchResp = await imapCommand(socket, "SEARCH UNSEEN");
35820
+ return parseSearchResponse(searchResp).length;
35821
+ } catch (err) {
35822
+ throw imapFail("unread", err);
35823
+ } finally {
35824
+ await this.imapDisconnect(socket);
35825
+ }
35826
+ }
35827
+ /**
35828
+ * List available IMAP folders/mailboxes.
35829
+ */
35830
+ async folders() {
35831
+ let socket;
35832
+ try {
35833
+ socket = await this.imapConnect();
35834
+ } catch (err) {
35835
+ throw imapFail("folders", err);
35836
+ }
35837
+ try {
35838
+ const resp = await imapCommand(socket, 'LIST "" "*"');
35839
+ const result = [];
35840
+ for (const line of resp.split("\r\n")) {
35841
+ const m = line.match(/\* LIST \([^)]*\) "[^"]*" "?([^"\r\n]+)"?/i);
35842
+ if (m) result.push(m[1]);
35843
+ }
35844
+ return result;
35845
+ } catch (err) {
35846
+ throw imapFail("folders", err);
35847
+ } finally {
35848
+ await this.imapDisconnect(socket);
35849
+ }
35850
+ }
35851
+ /**
35852
+ * Test IMAP connectivity without reading.
35853
+ */
35854
+ async testImapConnection() {
35855
+ try {
35856
+ const socket = await this.imapConnect();
35857
+ await this.imapDisconnect(socket);
35858
+ return { success: true, message: `Connected to ${this.imapHost}:${this.imapPort}` };
35859
+ } catch (err) {
35860
+ const errMsg = err instanceof Error ? err.message : String(err);
35861
+ return { success: false, message: `IMAP connection failed: ${errMsg}` };
35862
+ }
35863
+ }
35864
+ };
35865
+ imapTagCounter = 0;
35866
+ }
35867
+ });
35868
+
35544
35869
  // src/wsdl.ts
35545
35870
  function escapeXml(value) {
35546
35871
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");