tina4-nodejs 3.13.133 → 3.13.135
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +3213 -3062
- package/packages/cli/src/commands/generate.ts +33 -22
- package/packages/cli/src/commands/lint.ts +77 -111
- package/packages/core/dist/index.js +3122 -2963
- package/packages/core/src/.tina4-metrics.json +15004 -0
- package/packages/core/src/aiClient.ts +199 -161
- package/packages/core/src/devAdmin.ts +46 -14
- package/packages/core/src/dispatchPipeline.ts +65 -67
- package/packages/core/src/docs.ts +52 -544
- package/packages/core/src/docsParser.ts +270 -0
- package/packages/core/src/docsScanner.ts +121 -0
- package/packages/core/src/docsSignatures.ts +165 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/logger.ts +68 -82
- package/packages/core/src/mcp.ts +32 -60
- package/packages/core/src/messenger.ts +136 -157
- package/packages/core/src/middleware.ts +56 -60
- package/packages/core/src/plan.ts +78 -70
- package/packages/core/src/projectIndex.ts +15 -288
- package/packages/core/src/projectIndexExtractors.ts +126 -0
- package/packages/core/src/projectIndexStorage.ts +122 -0
- package/packages/core/src/push.ts +293 -0
- package/packages/core/src/server.ts +187 -183
- package/packages/frond/dist/index.js +607 -770
- package/packages/frond/src/engine.ts +670 -818
- package/packages/orm/dist/index.js +3132 -2976
- package/packages/orm/src/adapters/mongodb.ts +99 -144
- package/packages/orm/src/baseModel.ts +429 -515
- package/packages/orm/src/fakeData.ts +73 -61
- package/packages/orm/src/migration.ts +96 -126
- package/packages/orm/src/seeder.ts +6 -238
- package/packages/orm/src/seederTable.ts +101 -0
- package/packages/orm/src/seederTypes.ts +14 -0
- package/packages/orm/src/validation.ts +97 -80
- package/types/core/src/aiClient.d.ts +5 -0
- package/types/core/src/devAdmin.d.ts +23 -1
- package/types/core/src/docsParser.d.ts +28 -0
- package/types/core/src/docsScanner.d.ts +1 -0
- package/types/core/src/docsSignatures.d.ts +11 -0
- package/types/core/src/index.d.ts +2 -0
- package/types/core/src/messenger.d.ts +8 -0
- package/types/core/src/projectIndexExtractors.d.ts +3 -0
- package/types/core/src/projectIndexStorage.d.ts +13 -0
- package/types/core/src/push.d.ts +45 -0
- package/types/frond/src/engine.d.ts +25 -0
- package/types/orm/src/fakeData.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +3 -89
- package/types/orm/src/seederTable.d.ts +9 -0
- 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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
:
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
const
|
|
723
|
-
if (
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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:
|
|
789
|
-
logFile:
|
|
790
|
-
layout,
|
|
774
|
+
logDir: paths.logDir,
|
|
775
|
+
logFile: paths.logFile,
|
|
776
|
+
layout: paths.layout,
|
|
791
777
|
rotateSize,
|
|
792
778
|
rotateKeep,
|
|
793
779
|
strict,
|
package/packages/core/src/mcp.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
999
|
-
}
|
|
1000
|
-
|
|
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
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
cmd
|
|
1009
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
-
|
|
533
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
579
|
-
|
|
543
|
+
secureSocket.once("secureConnect", resolve);
|
|
544
|
+
secureSocket.once("error", reject);
|
|
580
545
|
});
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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,
|