tina4-nodejs 3.13.133 → 3.13.134

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 (50) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/packages/cli/dist/bin.js +3181 -3051
  5. package/packages/cli/src/commands/generate.ts +33 -22
  6. package/packages/cli/src/commands/lint.ts +77 -111
  7. package/packages/core/dist/index.js +3090 -2952
  8. package/packages/core/src/.tina4-metrics.json +15004 -0
  9. package/packages/core/src/aiClient.ts +199 -161
  10. package/packages/core/src/dispatchPipeline.ts +65 -67
  11. package/packages/core/src/docs.ts +52 -544
  12. package/packages/core/src/docsParser.ts +270 -0
  13. package/packages/core/src/docsScanner.ts +121 -0
  14. package/packages/core/src/docsSignatures.ts +165 -0
  15. package/packages/core/src/index.ts +2 -0
  16. package/packages/core/src/logger.ts +68 -82
  17. package/packages/core/src/mcp.ts +32 -60
  18. package/packages/core/src/messenger.ts +136 -157
  19. package/packages/core/src/middleware.ts +56 -60
  20. package/packages/core/src/plan.ts +78 -70
  21. package/packages/core/src/projectIndex.ts +15 -288
  22. package/packages/core/src/projectIndexExtractors.ts +126 -0
  23. package/packages/core/src/projectIndexStorage.ts +122 -0
  24. package/packages/core/src/push.ts +281 -0
  25. package/packages/core/src/server.ts +182 -183
  26. package/packages/frond/dist/index.js +607 -770
  27. package/packages/frond/src/engine.ts +670 -818
  28. package/packages/orm/dist/index.js +3100 -2965
  29. package/packages/orm/src/adapters/mongodb.ts +99 -144
  30. package/packages/orm/src/baseModel.ts +429 -515
  31. package/packages/orm/src/fakeData.ts +73 -61
  32. package/packages/orm/src/migration.ts +96 -126
  33. package/packages/orm/src/seeder.ts +6 -238
  34. package/packages/orm/src/seederTable.ts +101 -0
  35. package/packages/orm/src/seederTypes.ts +14 -0
  36. package/packages/orm/src/validation.ts +97 -80
  37. package/types/core/src/aiClient.d.ts +5 -0
  38. package/types/core/src/docsParser.d.ts +28 -0
  39. package/types/core/src/docsScanner.d.ts +1 -0
  40. package/types/core/src/docsSignatures.d.ts +11 -0
  41. package/types/core/src/index.d.ts +2 -0
  42. package/types/core/src/messenger.d.ts +8 -0
  43. package/types/core/src/projectIndexExtractors.d.ts +3 -0
  44. package/types/core/src/projectIndexStorage.d.ts +13 -0
  45. package/types/core/src/push.d.ts +45 -0
  46. package/types/frond/src/engine.d.ts +25 -0
  47. package/types/orm/src/fakeData.d.ts +3 -0
  48. package/types/orm/src/seeder.d.ts +3 -89
  49. package/types/orm/src/seederTable.d.ts +9 -0
  50. package/types/orm/src/seederTypes.d.ts +16 -0
@@ -676,89 +676,58 @@ function isProduction(): boolean {
676
676
  return !isTruthy(process.env.TINA4_DEBUG);
677
677
  }
678
678
 
679
- /**
680
- * Resolve one fully-validated configuration snapshot from explicit options,
681
- * then environment, then default (ADR-0041) — WITHOUT touching the
682
- * filesystem. Every invalid setting throws LogConfigurationError before this
683
- * function returns, so a caller (configure() or the lazy first-use path)
684
- * commits nothing on a validation failure (LOG-C07, LOG-V01..V05).
685
- */
686
- function resolveSnapshot(options: ConfigureOptions): Omit<Snapshot, "mainSink" | "errorSink"> {
687
- checkRemovedSettings();
688
-
689
- const level = options.level !== undefined
690
- ? parseLevel(options.level, "level")
691
- : process.env.TINA4_LOG_LEVEL !== undefined
692
- ? parseLevel(process.env.TINA4_LOG_LEVEL, "TINA4_LOG_LEVEL")
693
- : DEFAULT_LEVEL;
694
-
695
- const fileLevel = options.fileLevel !== undefined
696
- ? parseLevel(options.fileLevel, "fileLevel")
697
- : process.env.TINA4_LOG_FILE_LEVEL !== undefined
698
- ? parseLevel(process.env.TINA4_LOG_FILE_LEVEL, "TINA4_LOG_FILE_LEVEL")
699
- : DEFAULT_FILE_LEVEL;
700
-
701
- // Format is DEBUG-DERIVED (Decision 3): explicit TINA4_LOG_FORMAT wins;
702
- // otherwise truthy TINA4_DEBUG selects text, else json.
703
- let format: "text" | "json";
704
- const explicitFormat = options.format ?? process.env.TINA4_LOG_FORMAT;
705
- if (explicitFormat !== undefined) {
706
- const f = explicitFormat.trim().toLowerCase();
707
- if (f !== "text" && f !== "json") {
708
- throw new LogConfigurationError(`TINA4_LOG_FORMAT=${JSON.stringify(explicitFormat)} is not valid`, {
709
- setting: "TINA4_LOG_FORMAT",
710
- value: explicitFormat,
711
- accepted: ["text", "json"],
712
- });
713
- }
714
- format = f;
715
- } else {
716
- format = isTruthy(process.env.TINA4_DEBUG) ? "text" : "json";
717
- }
679
+ function resolveConfiguredLevel(
680
+ explicit: string | undefined,
681
+ envName: string,
682
+ explicitSetting: string,
683
+ fallback: LogLevel,
684
+ ): LogLevel {
685
+ const raw = explicit ?? process.env[envName];
686
+ return raw === undefined ? fallback : parseLevel(raw, explicit === undefined ? envName : explicitSetting);
687
+ }
688
+
689
+ function resolveFormat(explicit: string | undefined): "text" | "json" {
690
+ const raw = explicit ?? process.env.TINA4_LOG_FORMAT;
691
+ if (raw === undefined) return isTruthy(process.env.TINA4_DEBUG) ? "text" : "json";
692
+ const format = raw.trim().toLowerCase();
693
+ if (format === "text" || format === "json") return format;
694
+ throw new LogConfigurationError(`TINA4_LOG_FORMAT=${JSON.stringify(raw)} is not valid`, {
695
+ setting: "TINA4_LOG_FORMAT",
696
+ value: raw,
697
+ accepted: ["text", "json"],
698
+ });
699
+ }
718
700
 
719
- // Output: an explicit value (stdout/file/both) ALWAYS wins, full stop
720
- // naming a file (below) never itself enables the file sink (LOG-C08).
721
- let outputSelector: "stdout" | "file" | "both";
722
- const explicitOutput = options.output ?? process.env.TINA4_LOG_OUTPUT;
723
- if (explicitOutput !== undefined) {
724
- const o = explicitOutput.trim().toLowerCase();
725
- if (o !== "stdout" && o !== "file" && o !== "both") {
726
- throw new LogConfigurationError(`TINA4_LOG_OUTPUT=${JSON.stringify(explicitOutput)} is not valid`, {
727
- setting: "TINA4_LOG_OUTPUT",
728
- value: explicitOutput,
729
- accepted: ["stdout", "file", "both"],
730
- });
731
- }
732
- outputSelector = o;
733
- } else {
734
- // Unset: dev/prod-aware default — file only in development.
735
- outputSelector = isProduction() ? "stdout" : "both";
736
- }
737
- const stdoutEnabled = outputSelector !== "file";
738
- const fileEnabled = outputSelector !== "stdout";
701
+ function resolveOutput(explicit: string | undefined): "stdout" | "file" | "both" {
702
+ const raw = explicit ?? process.env.TINA4_LOG_OUTPUT;
703
+ if (raw === undefined) return isProduction() ? "stdout" : "both";
704
+ const output = raw.trim().toLowerCase();
705
+ if (output === "stdout" || output === "file" || output === "both") return output;
706
+ throw new LogConfigurationError(`TINA4_LOG_OUTPUT=${JSON.stringify(raw)} is not valid`, {
707
+ setting: "TINA4_LOG_OUTPUT",
708
+ value: raw,
709
+ accepted: ["stdout", "file", "both"],
710
+ });
711
+ }
739
712
 
740
- const rotateSize = resolveInt(options.rotateSize, "TINA4_LOG_ROTATE_SIZE", DEFAULT_ROTATE_SIZE, MIN_ROTATE_SIZE);
741
- const rotateKeep = resolveRotateKeep(options.rotateKeep, DEFAULT_ROTATE_KEEP);
742
- const strict = resolveBool(options.strict, "TINA4_LOG_STRICT", false);
743
- const callerCapture = resolveBool(options.caller, "TINA4_LOG_FUNC", false);
713
+ interface ResolvedLogPaths {
714
+ logDir: string;
715
+ logFile: string | null;
716
+ layout: "directory" | "single";
717
+ }
744
718
 
719
+ function resolveLogPaths(options: ConfigureOptions): ResolvedLogPaths {
745
720
  const dirRaw = options.logDir ?? process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
746
721
  if (dirRaw === "") {
747
722
  throw new LogConfigurationError("logDir must not be empty", { setting: "TINA4_LOG_DIR", value: dirRaw });
748
723
  }
749
724
  const fileRaw = options.logFile ?? process.env.TINA4_LOG_FILE ?? "";
750
- // A NUL byte can never be part of a real path (the underlying syscalls
751
- // reject it), but that would otherwise only surface at OPEN time -- and
752
- // only when the file sink ends up enabled. Reject it here, unconditionally,
753
- // so a malformed path fails CONFIGURATION regardless of whether output
754
- // happens to resolve to a sink that would ever touch the filesystem.
755
725
  if (dirRaw.includes("\0") || fileRaw.includes("\0")) {
756
726
  throw new LogConfigurationError("log path must not contain a NUL byte", {
757
727
  setting: dirRaw.includes("\0") ? "TINA4_LOG_DIR" : "TINA4_LOG_FILE",
758
728
  });
759
729
  }
760
730
 
761
- const projectRoot = process.cwd();
762
731
  let dirCandidate = dirRaw;
763
732
  let fileCandidate = fileRaw;
764
733
  if (!fileCandidate && targetIsFile(dirCandidate)) {
@@ -766,17 +735,34 @@ function resolveSnapshot(options: ConfigureOptions): Omit<Snapshot, "mainSink" |
766
735
  dirCandidate = dirname(dirCandidate);
767
736
  }
768
737
 
769
- const resolvedLogDir = (isAbsolute(dirCandidate) ? dirCandidate : join(projectRoot, dirCandidate)).replace(/\/$/, "");
738
+ const logDir = (isAbsolute(dirCandidate) ? dirCandidate : join(process.cwd(), dirCandidate)).replace(/\/$/, "");
739
+ if (!fileCandidate) return { logDir, logFile: null, layout: "directory" };
740
+ const logFile = isAbsolute(fileCandidate) ? fileCandidate : join(logDir, fileCandidate);
741
+ return { logDir, logFile, layout: "single" };
742
+ }
770
743
 
771
- let resolvedLogFile: string | null;
772
- let layout: "directory" | "single";
773
- if (fileCandidate) {
774
- resolvedLogFile = isAbsolute(fileCandidate) ? fileCandidate : join(resolvedLogDir, fileCandidate);
775
- layout = "single";
776
- } else {
777
- resolvedLogFile = null;
778
- layout = "directory";
779
- }
744
+ /**
745
+ * Resolve one fully-validated configuration snapshot from explicit options,
746
+ * then environment, then default (ADR-0041) — WITHOUT touching the
747
+ * filesystem. Every invalid setting throws LogConfigurationError before this
748
+ * function returns, so a caller (configure() or the lazy first-use path)
749
+ * commits nothing on a validation failure (LOG-C07, LOG-V01..V05).
750
+ */
751
+ function resolveSnapshot(options: ConfigureOptions): Omit<Snapshot, "mainSink" | "errorSink"> {
752
+ checkRemovedSettings();
753
+
754
+ const level = resolveConfiguredLevel(options.level, "TINA4_LOG_LEVEL", "level", DEFAULT_LEVEL);
755
+ const fileLevel = resolveConfiguredLevel(options.fileLevel, "TINA4_LOG_FILE_LEVEL", "fileLevel", DEFAULT_FILE_LEVEL);
756
+ const format = resolveFormat(options.format);
757
+ const outputSelector = resolveOutput(options.output);
758
+ const stdoutEnabled = outputSelector !== "file";
759
+ const fileEnabled = outputSelector !== "stdout";
760
+
761
+ const rotateSize = resolveInt(options.rotateSize, "TINA4_LOG_ROTATE_SIZE", DEFAULT_ROTATE_SIZE, MIN_ROTATE_SIZE);
762
+ const rotateKeep = resolveRotateKeep(options.rotateKeep, DEFAULT_ROTATE_KEEP);
763
+ const strict = resolveBool(options.strict, "TINA4_LOG_STRICT", false);
764
+ const callerCapture = resolveBool(options.caller, "TINA4_LOG_FUNC", false);
765
+ const paths = resolveLogPaths(options);
780
766
 
781
767
  return {
782
768
  level,
@@ -785,9 +771,9 @@ function resolveSnapshot(options: ConfigureOptions): Omit<Snapshot, "mainSink" |
785
771
  stdoutEnabled,
786
772
  fileEnabled,
787
773
  outputSelector,
788
- logDir: resolvedLogDir,
789
- logFile: resolvedLogFile,
790
- layout,
774
+ logDir: paths.logDir,
775
+ logFile: paths.logFile,
776
+ layout: paths.layout,
791
777
  rotateSize,
792
778
  rotateKeep,
793
779
  strict,
@@ -989,77 +989,49 @@ function agentBackup(projectRoot: string, target: string): string | null {
989
989
  * - Uses spawnSync with 5-second timeout so a hung subprocess never
990
990
  * blocks the MCP server.
991
991
  */
992
- function verifyNodeSyntax(absPath: string, relPath: string): string | null {
993
- if (!relPath.startsWith("src/")) {
994
- return null;
995
- }
992
+ function syntaxCheckTarget(relPath: string): { ext: string } | null {
993
+ if (!relPath.startsWith("src/")) return null;
996
994
  const ext = path.extname(relPath).toLowerCase();
997
- if (![".js", ".ts", ".mjs", ".cjs"].includes(ext)) {
998
- return null;
999
- }
1000
- const base = path.basename(relPath);
1001
- if (/\.(test|spec)\.(ts|js|mjs|cjs)$/.test(base)) {
1002
- return null;
1003
- }
995
+ if (![".js", ".ts", ".mjs", ".cjs"].includes(ext)) return null;
996
+ if (/\.(test|spec)\.(ts|js|mjs|cjs)$/.test(path.basename(relPath))) return null;
997
+ return { ext };
998
+ }
1004
999
 
1005
- let cmd: string;
1006
- let args: string[];
1007
- if (ext === ".ts") {
1008
- cmd = "npx";
1009
- args = ["--no-install", "tsc", "--noEmit", "--allowJs", "--skipLibCheck", absPath];
1010
- } else {
1011
- cmd = "node";
1012
- args = ["--check", absPath];
1013
- }
1000
+ function syntaxCheckCommand(absPath: string, ext: string): { cmd: string; args: string[] } {
1001
+ return ext === ".ts"
1002
+ ? { cmd: "npx", args: ["--no-install", "tsc", "--noEmit", "--allowJs", "--skipLibCheck", absPath] }
1003
+ : { cmd: "node", args: ["--check", absPath] };
1004
+ }
1014
1005
 
1015
- let proc;
1006
+ function syntaxCheckOutput(absPath: string, relPath: string, ext: string, proc: any): string | null {
1007
+ if (ext === ".ts" && (proc.error || proc.status === null || proc.status === 127)) return null;
1008
+ if (proc.error) return `verification subprocess failed: ${proc.error.message}`;
1009
+ if (proc.status === 0) return null;
1010
+ const raw = (ext === ".ts"
1011
+ ? (proc.stdout || "") + (proc.stderr || "")
1012
+ : (proc.stderr || "") + (proc.stdout || "")).trim();
1013
+ if (ext === ".ts" && raw.includes("This is not the tsc command")) return null;
1014
+ if (!raw) return `syntax check failed (exit ${proc.status}, no output)`;
1015
+ const lines = raw.split(/\r?\n/).map((line: string) => line.trim()).filter(Boolean);
1016
+ if (lines.length === 0) return `syntax check failed (exit ${proc.status})`;
1017
+ const stripPath = (line: string): string => line.replace(absPath, relPath);
1018
+ return stripPath(lines.find((line: string) => /error|SyntaxError|TS\d+/i.test(line)) || lines[0]);
1019
+ }
1020
+
1021
+ function verifyNodeSyntax(absPath: string, relPath: string): string | null {
1022
+ const target = syntaxCheckTarget(relPath);
1023
+ if (!target) return null;
1024
+ const { cmd, args } = syntaxCheckCommand(absPath, target.ext);
1016
1025
  try {
1017
- proc = spawnSync(cmd, args, {
1026
+ const proc = spawnSync(cmd, args, {
1018
1027
  encoding: "utf-8",
1019
1028
  timeout: 5000,
1020
1029
  cwd: path.dirname(path.dirname(absPath)),
1021
1030
  });
1031
+ return syntaxCheckOutput(absPath, relPath, target.ext, proc);
1022
1032
  } catch (e) {
1023
1033
  return `verification subprocess failed: ${(e as Error).message}`;
1024
1034
  }
1025
-
1026
- // npx may fail to find tsc — gracefully return null instead of blocking.
1027
- if (ext === ".ts" && (proc.error || proc.status === null || proc.status === 127)) {
1028
- return null;
1029
- }
1030
- if (proc.error) {
1031
- return `verification subprocess failed: ${proc.error.message}`;
1032
- }
1033
- if (proc.status === 0) {
1034
- return null;
1035
- }
1036
-
1037
- // Errors land on stderr for `node --check`, stdout for tsc.
1038
- const raw = (ext === ".ts" ? (proc.stdout || "") + (proc.stderr || "") : (proc.stderr || "") + (proc.stdout || "")).trim();
1039
- // npx placeholder when tsc isn't installed locally — bail silently
1040
- // rather than block the write with a meaningless banner.
1041
- if (ext === ".ts" && raw.includes("This is not the tsc command")) {
1042
- return null;
1043
- }
1044
- if (!raw) {
1045
- return `syntax check failed (exit ${proc.status}, no output)`;
1046
- }
1047
- const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
1048
- if (lines.length === 0) {
1049
- return `syntax check failed (exit ${proc.status})`;
1050
- }
1051
- // Strip the absolute path prefix from the first meaningful line so the
1052
- // LLM sees a stable, project-relative error message.
1053
- const stripPath = (line: string): string => line.replace(absPath, relPath);
1054
- // For tsc: the first line is usually "src/foo.ts(3,5): error TS1109: ...".
1055
- // For node --check: the first line is the file path; the actual error is
1056
- // a later "SyntaxError: ..." line. Pick the most informative line.
1057
- for (const line of lines) {
1058
- if (/error|SyntaxError|TS\d+/i.test(line)) {
1059
- return stripPath(line);
1060
- }
1061
- }
1062
- return stripPath(lines[0]);
1063
1035
  }
1064
1036
 
1065
1037
  /** Latest resolved KV cache stats snapshot (the async API resolves into this). */
@@ -177,6 +177,22 @@ interface SendOptions {
177
177
  headers?: Record<string, string>;
178
178
  }
179
179
 
180
+ type SmtpSocket = net.Socket | tls.TLSSocket;
181
+
182
+ interface MailRecipients {
183
+ toList: string[];
184
+ ccList: string[];
185
+ bccList: string[];
186
+ allRecipients: string[];
187
+ }
188
+
189
+ class SmtpCommandError extends Error {
190
+ constructor(message: string) {
191
+ super(message);
192
+ this.name = "SmtpCommandError";
193
+ }
194
+ }
195
+
180
196
  // ── SMTP helpers ─────────────────────────────────────────────
181
197
 
182
198
  /**
@@ -474,41 +490,12 @@ export class Messenger {
474
490
  return this.devMailbox;
475
491
  }
476
492
 
477
- async send(
478
- to: string | string[],
479
- subject: string,
480
- body: string,
481
- html: boolean = false,
482
- text?: string,
483
- cc?: string | string[],
484
- bcc?: string | string[],
485
- replyTo?: string,
486
- attachments?: string[],
487
- headers?: Record<string, string>,
488
- ): Promise<SendResult> {
489
- const options: SendOptions = { to, subject, body, html, text, cc, bcc, replyTo, attachments, headers };
493
+ private prepareRecipients(options: SendOptions, redirect = false): MailRecipients {
490
494
  let toList = Array.isArray(options.to) ? options.to : [options.to];
491
495
  let ccList = Array.isArray(options.cc) ? options.cc : (options.cc ? [options.cc] : []);
492
496
  let bccList = Array.isArray(options.bcc) ? options.bcc : (options.bcc ? [options.bcc] : []);
493
497
  let allRecipients = [...toList, ...ccList, ...bccList];
494
-
495
- // Dev capture is a BRANCH here, not a different object returned by the factory.
496
- // createMessenger() used to hand back a DevMailbox, which has capture() and no
497
- // send(), so the documented call threw TypeError (nodejs#41).
498
- if (this.shouldCapture()) {
499
- return this.getDevMailbox().capture(
500
- to, subject, body, html, text, ccList, bccList, replyTo,
501
- attachments, this.fromAddress || undefined,
502
- );
503
- }
504
-
505
- // TINA4_MAIL_REDIRECT_TO (MAIL-DEC-01): on the REAL-SEND path only — capture
506
- // already returned above, so this never touches the capture branch. When the
507
- // list is non-empty, replace every recipient with the redirect list (so ONLY
508
- // the dev list receives the mail, never the real recipients) and preserve the
509
- // original recipients in X-Tina4-Original-To. Subject/body/attachments are
510
- // untouched, and send()'s return shape is unchanged.
511
- const redirectTo = parseMailRedirectList(process.env.TINA4_MAIL_REDIRECT_TO);
498
+ const redirectTo = redirect ? parseMailRedirectList(process.env.TINA4_MAIL_REDIRECT_TO) : [];
512
499
  if (redirectTo.length > 0) {
513
500
  const originalTo = allRecipients.join(", ");
514
501
  toList = redirectTo;
@@ -517,154 +504,146 @@ export class Messenger {
517
504
  allRecipients = [...toList];
518
505
  options.headers = { ...(options.headers ?? {}), "X-Tina4-Original-To": originalTo };
519
506
  }
507
+ return { toList, ccList, bccList, allRecipients };
508
+ }
520
509
 
521
- const messageId = `${randomUUID()}@${this.host}`;
522
-
523
- if (allRecipients.length === 0) {
524
- return { success: false, message: "No recipients specified", id: null };
510
+ private async connectSmtpSocket(): Promise<SmtpSocket> {
511
+ if (this.port === 465) {
512
+ const socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: tlsRejectUnauthorized() });
513
+ await new Promise<void>((resolve, reject) => {
514
+ socket.once("secureConnect", resolve);
515
+ socket.once("error", reject);
516
+ });
517
+ return socket;
525
518
  }
519
+ const socket = net.createConnection({ host: this.host, port: this.port });
520
+ await new Promise<void>((resolve, reject) => {
521
+ socket.once("connect", resolve);
522
+ socket.once("error", reject);
523
+ });
524
+ return socket;
525
+ }
526
526
 
527
- if (!this.fromAddress) {
528
- return { success: false, message: "No from address configured", id: null };
529
- }
527
+ private async requireSmtpResponse(socket: SmtpSocket, command: string, expected: number, failure: string): Promise<{ code: number; text: string }> {
528
+ const response = command === "" ? await readResponse(socket) : await sendCommand(socket, command);
529
+ if (response.code !== expected) throw new SmtpCommandError(`${failure}: ${response.text}`);
530
+ return response;
531
+ }
530
532
 
533
+ private async openSmtpSession(): Promise<SmtpSocket> {
534
+ let socket = await this.connectSmtpSocket();
531
535
  try {
532
- // Connect
533
- let socket: net.Socket | tls.TLSSocket;
534
-
535
- if (this.port === 465) {
536
- // Implicit TLS (SMTPS)
537
- socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: tlsRejectUnauthorized() });
538
- await new Promise<void>((resolve, reject) => {
539
- socket.once("secureConnect", resolve);
540
- socket.once("error", reject);
541
- });
542
- } else {
543
- socket = net.createConnection({ host: this.host, port: this.port });
544
- await new Promise<void>((resolve, reject) => {
545
- socket.once("connect", resolve);
546
- socket.once("error", reject);
547
- });
548
- }
549
-
550
- // Read greeting
551
- const greeting = await readResponse(socket);
552
- if (greeting.code !== 220) {
553
- socket.destroy();
554
- return { success: false, message: `SMTP greeting failed: ${greeting.text}`, id: null };
555
- }
556
-
557
- // EHLO
558
- const ehlo = await sendCommand(socket, `EHLO ${this.host}`);
559
- if (ehlo.code !== 250) {
560
- socket.destroy();
561
- return { success: false, message: `EHLO failed: ${ehlo.text}`, id: null };
562
- }
563
-
564
- // STARTTLS upgrade (for port 587 or when useTls is true and not already TLS)
536
+ await this.requireSmtpResponse(socket, "", 220, "SMTP greeting failed");
537
+ const ehlo = await this.requireSmtpResponse(socket, `EHLO ${this.host}`, 250, "EHLO failed");
565
538
  if (this.useTls && this.port !== 465 && ehlo.text.includes("STARTTLS")) {
566
- const starttls = await sendCommand(socket, "STARTTLS");
567
- if (starttls.code !== 220) {
568
- socket.destroy();
569
- return { success: false, message: `STARTTLS failed: ${starttls.text}`, id: null };
570
- }
571
-
572
- // Upgrade to TLS
539
+ await this.requireSmtpResponse(socket, "STARTTLS", 220, "STARTTLS failed");
573
540
  const plainSocket = socket as net.Socket;
574
- socket = tls.connect(
575
- { socket: plainSocket, host: this.host, rejectUnauthorized: tlsRejectUnauthorized() },
576
- );
541
+ const secureSocket = tls.connect({ socket: plainSocket, host: this.host, rejectUnauthorized: tlsRejectUnauthorized() });
577
542
  await new Promise<void>((resolve, reject) => {
578
- (socket as tls.TLSSocket).once("secureConnect", resolve);
579
- (socket as tls.TLSSocket).once("error", reject);
543
+ secureSocket.once("secureConnect", resolve);
544
+ secureSocket.once("error", reject);
580
545
  });
581
-
582
- // Re-EHLO after TLS upgrade
583
- const ehlo2 = await sendCommand(socket, `EHLO ${this.host}`);
584
- if (ehlo2.code !== 250) {
585
- socket.destroy();
586
- return { success: false, message: `EHLO after STARTTLS failed: ${ehlo2.text}`, id: null };
587
- }
588
- }
589
-
590
- // AUTH LOGIN
591
- if (this.username && this.password) {
592
- const auth = await sendCommand(socket, "AUTH LOGIN");
593
- if (auth.code !== 334) {
594
- socket.destroy();
595
- return { success: false, message: `AUTH LOGIN failed: ${auth.text}`, id: null };
596
- }
597
-
598
- const userResp = await sendCommand(socket, Buffer.from(this.username).toString("base64"));
599
- if (userResp.code !== 334) {
600
- socket.destroy();
601
- return { success: false, message: `AUTH username failed: ${userResp.text}`, id: null };
602
- }
603
-
604
- const passResp = await sendCommand(socket, Buffer.from(this.password).toString("base64"));
605
- if (passResp.code !== 235) {
606
- socket.destroy();
607
- return { success: false, message: `AUTH password failed: ${passResp.text}`, id: null };
608
- }
609
- }
610
-
611
- // MAIL FROM
612
- const mailFrom = await sendCommand(socket, `MAIL FROM:<${this.fromAddress}>`);
613
- if (mailFrom.code !== 250) {
614
- socket.destroy();
615
- return { success: false, message: `MAIL FROM failed: ${mailFrom.text}`, id: null };
616
- }
617
-
618
- // RCPT TO for all recipients
619
- for (const recipient of allRecipients) {
620
- const rcpt = await sendCommand(socket, `RCPT TO:<${recipient}>`);
621
- if (rcpt.code !== 250 && rcpt.code !== 251) {
622
- socket.destroy();
623
- return { success: false, message: `RCPT TO <${recipient}> failed: ${rcpt.text}`, id: null };
624
- }
546
+ socket = secureSocket;
547
+ await this.requireSmtpResponse(socket, `EHLO ${this.host}`, 250, "EHLO after STARTTLS failed");
625
548
  }
549
+ return socket;
550
+ } catch (error) {
551
+ socket.destroy();
552
+ throw error;
553
+ }
554
+ }
626
555
 
627
- // DATA
628
- const dataCmd = await sendCommand(socket, "DATA");
629
- if (dataCmd.code !== 354) {
630
- socket.destroy();
631
- return { success: false, message: `DATA failed: ${dataCmd.text}`, id: null };
632
- }
556
+ private async authenticateSmtp(socket: SmtpSocket): Promise<void> {
557
+ if (!this.username || !this.password) return;
558
+ await this.requireSmtpResponse(socket, "AUTH LOGIN", 334, "AUTH LOGIN failed");
559
+ await this.requireSmtpResponse(socket, Buffer.from(this.username).toString("base64"), 334, "AUTH username failed");
560
+ await this.requireSmtpResponse(socket, Buffer.from(this.password).toString("base64"), 235, "AUTH password failed");
561
+ }
633
562
 
634
- // Build and send the MIME message
635
- const mimeMessage = buildMimeMessage({
636
- from: this.fromAddress,
637
- fromName: this.fromName,
638
- to: toList,
639
- cc: ccList,
640
- subject: options.subject,
641
- body: options.body,
642
- html: options.html ?? false,
643
- text: options.text,
644
- replyTo: options.replyTo,
645
- attachments: options.attachments,
646
- headers: options.headers,
647
- messageId,
648
- });
563
+ private async sendSmtpEnvelope(socket: SmtpSocket, recipients: string[]): Promise<void> {
564
+ await this.requireSmtpResponse(socket, `MAIL FROM:<${this.fromAddress}>`, 250, "MAIL FROM failed");
565
+ for (const recipient of recipients) {
566
+ const response = await sendCommand(socket, `RCPT TO:<${recipient}>`);
567
+ if (response.code !== 250 && response.code !== 251) throw new SmtpCommandError(`RCPT TO <${recipient}> failed: ${response.text}`);
568
+ }
569
+ }
649
570
 
650
- // Send message body, terminate with <CRLF>.<CRLF>
651
- const endData = await sendCommand(socket, mimeMessage + "\r\n.");
652
- if (endData.code !== 250) {
653
- socket.destroy();
654
- return { success: false, message: `Message delivery failed: ${endData.text}`, id: null };
655
- }
571
+ private async sendSmtpMessage(socket: SmtpSocket, options: SendOptions, recipients: MailRecipients, messageId: string): Promise<void> {
572
+ await this.requireSmtpResponse(socket, "DATA", 354, "DATA failed");
573
+ const mimeMessage = buildMimeMessage({
574
+ from: this.fromAddress,
575
+ fromName: this.fromName,
576
+ to: recipients.toList,
577
+ cc: recipients.ccList,
578
+ subject: options.subject,
579
+ body: options.body,
580
+ html: options.html ?? false,
581
+ text: options.text,
582
+ replyTo: options.replyTo,
583
+ attachments: options.attachments,
584
+ headers: options.headers,
585
+ messageId,
586
+ });
587
+ await this.requireSmtpResponse(socket, mimeMessage + "\r\n.", 250, "Message delivery failed");
588
+ await sendCommand(socket, "QUIT");
589
+ }
656
590
 
657
- // QUIT
658
- await sendCommand(socket, "QUIT");
591
+ private async sendSmtp(options: SendOptions, recipients: MailRecipients, messageId: string): Promise<SendResult> {
592
+ let socket: SmtpSocket | null = null;
593
+ try {
594
+ socket = await this.openSmtpSession();
595
+ await this.authenticateSmtp(socket);
596
+ await this.sendSmtpEnvelope(socket, recipients.allRecipients);
597
+ await this.sendSmtpMessage(socket, options, recipients, messageId);
659
598
  socket.destroy();
660
-
661
599
  return { success: true, message: "Email sent successfully", id: messageId };
662
600
  } catch (err) {
601
+ socket?.destroy();
602
+ if (err instanceof SmtpCommandError) return { success: false, message: err.message, id: null };
663
603
  const errMsg = err instanceof Error ? err.message : String(err);
664
604
  return { success: false, message: `SMTP error: ${errMsg}`, id: null };
665
605
  }
666
606
  }
667
607
 
608
+ async send(
609
+ to: string | string[],
610
+ subject: string,
611
+ body: string,
612
+ html: boolean = false,
613
+ text?: string,
614
+ cc?: string | string[],
615
+ bcc?: string | string[],
616
+ replyTo?: string,
617
+ attachments?: string[],
618
+ headers?: Record<string, string>,
619
+ ): Promise<SendResult> {
620
+ const options: SendOptions = { to, subject, body, html, text, cc, bcc, replyTo, attachments, headers };
621
+ const capturedRecipients = this.prepareRecipients(options);
622
+
623
+ // Dev capture is a BRANCH here, not a different object returned by the factory.
624
+ // createMessenger() used to hand back a DevMailbox, which has capture() and no
625
+ // send(), so the documented call threw TypeError (nodejs#41).
626
+ if (this.shouldCapture()) {
627
+ return this.getDevMailbox().capture(
628
+ to, subject, body, html, text, capturedRecipients.ccList, capturedRecipients.bccList, replyTo,
629
+ attachments, this.fromAddress || undefined,
630
+ );
631
+ }
632
+ const recipients = this.prepareRecipients(options, true);
633
+
634
+ const messageId = `${randomUUID()}@${this.host}`;
635
+
636
+ if (recipients.allRecipients.length === 0) {
637
+ return { success: false, message: "No recipients specified", id: null };
638
+ }
639
+
640
+ if (!this.fromAddress) {
641
+ return { success: false, message: "No from address configured", id: null };
642
+ }
643
+
644
+ return this.sendSmtp(options, recipients, messageId);
645
+ }
646
+
668
647
  /**
669
648
  * Render a Frond template STRING and send it as an HTML email (G7, parity with
670
649
  * Python's send_template). Extra send() options (cc, bcc, replyTo, attachments,