settld 0.2.5 → 0.2.7

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.
@@ -0,0 +1,89 @@
1
+ function normalizeBaseUrl(raw) {
2
+ const text = String(raw ?? "").trim();
3
+ if (!text) return null;
4
+ try {
5
+ const parsed = new URL(text);
6
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
7
+ return parsed.toString().replace(/\/+$/, "");
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+
13
+ function errorMessageOrFallback(err, fallback = "resend request failed") {
14
+ const direct = typeof err?.message === "string" ? err.message.trim() : "";
15
+ if (direct) return direct;
16
+ const asText = String(err ?? "").trim();
17
+ if (asText && asText !== "[object Object]") return asText;
18
+ return fallback;
19
+ }
20
+
21
+ export async function sendResendMail({
22
+ apiKey,
23
+ from,
24
+ to,
25
+ subject,
26
+ text,
27
+ baseUrl = "https://api.resend.com",
28
+ timeoutMs = 10_000,
29
+ fetchImpl = fetch
30
+ } = {}) {
31
+ const key = String(apiKey ?? "").trim();
32
+ if (!key) throw new Error("resend api key required");
33
+ const sender = String(from ?? "").trim();
34
+ if (!sender) throw new Error("resend from is required");
35
+ const recipient = String(to ?? "").trim();
36
+ if (!recipient) throw new Error("resend to is required");
37
+ const sub = String(subject ?? "").trim();
38
+ if (!sub) throw new Error("resend subject is required");
39
+ const bodyText = String(text ?? "");
40
+ const urlBase = normalizeBaseUrl(baseUrl);
41
+ if (!urlBase) throw new Error("resend base URL must be a valid http(s) URL");
42
+ if (typeof fetchImpl !== "function") throw new Error("resend fetch implementation unavailable");
43
+
44
+ const controller = new AbortController();
45
+ const t = setTimeout(() => controller.abort(), Math.max(100, Number(timeoutMs) || 10_000));
46
+ t.unref?.();
47
+
48
+ let res;
49
+ try {
50
+ res = await fetchImpl(`${urlBase}/emails`, {
51
+ method: "POST",
52
+ headers: {
53
+ authorization: `Bearer ${key}`,
54
+ "content-type": "application/json"
55
+ },
56
+ body: JSON.stringify({
57
+ from: sender,
58
+ to: [recipient],
59
+ subject: sub,
60
+ text: bodyText
61
+ }),
62
+ signal: controller.signal
63
+ });
64
+ } catch (err) {
65
+ throw new Error(errorMessageOrFallback(err, "resend transport failed"));
66
+ } finally {
67
+ clearTimeout(t);
68
+ }
69
+
70
+ const raw = await res.text();
71
+ let json = null;
72
+ try {
73
+ json = raw ? JSON.parse(raw) : null;
74
+ } catch {
75
+ json = null;
76
+ }
77
+ if (!res.ok) {
78
+ const message =
79
+ (json && typeof json === "object" && (json?.message || json?.error?.message || json?.error)) ||
80
+ raw ||
81
+ `HTTP ${res.status}`;
82
+ throw new Error(`resend send failed (${res.status}): ${String(message)}`);
83
+ }
84
+ return {
85
+ ok: true,
86
+ id: json && typeof json === "object" && typeof json.id === "string" ? json.id : null
87
+ };
88
+ }
89
+
@@ -11,11 +11,21 @@ function nowIso() {
11
11
  return new Date().toISOString();
12
12
  }
13
13
 
14
- const dataDir = process.env.MAGIC_LINK_DATA_DIR ? path.resolve(process.env.MAGIC_LINK_DATA_DIR) : path.join(os.tmpdir(), "settld-magic-link");
14
+ const dataDirRaw = process.env.MAGIC_LINK_DATA_DIR ? String(process.env.MAGIC_LINK_DATA_DIR).trim() : "";
15
+ const dataDir = dataDirRaw ? path.resolve(dataDirRaw) : path.join(os.tmpdir(), "settld-magic-link");
16
+ const dataDirLikelyEphemeral =
17
+ dataDir === "/tmp" ||
18
+ dataDir.startsWith("/tmp/") ||
19
+ dataDir === os.tmpdir() ||
20
+ dataDir.startsWith(`${os.tmpdir()}${path.sep}`);
21
+ const requireDurableDataDir = String(process.env.MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR ?? "0").trim() === "1";
15
22
  const migrateOnStartup = String(process.env.MAGIC_LINK_MIGRATE_ON_STARTUP ?? "1").trim() !== "0";
16
23
  const intervalSeconds = Number.parseInt(String(process.env.MAGIC_LINK_MAINTENANCE_INTERVAL_SECONDS ?? "86400"), 10);
17
24
 
18
25
  if (!Number.isInteger(intervalSeconds) || intervalSeconds < 5) throw new Error("MAGIC_LINK_MAINTENANCE_INTERVAL_SECONDS must be an integer >= 5");
26
+ if (requireDurableDataDir && dataDirLikelyEphemeral) {
27
+ throw new Error("MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR=1 but MAGIC_LINK_DATA_DIR resolves to an ephemeral path (/tmp)");
28
+ }
19
29
 
20
30
  await fs.mkdir(dataDir, { recursive: true });
21
31
  const fmt = await checkAndMigrateDataDir({ dataDir, migrateOnStartup });
@@ -35,6 +45,21 @@ process.on("SIGTERM", () => shutdown("SIGTERM"));
35
45
 
36
46
  // eslint-disable-next-line no-console
37
47
  console.log(JSON.stringify({ at: nowIso(), event: "magic_link_maintenance.start", dataDir, intervalSeconds }, null, 2));
48
+ if (dataDirLikelyEphemeral) {
49
+ // eslint-disable-next-line no-console
50
+ console.warn(
51
+ JSON.stringify(
52
+ {
53
+ at: nowIso(),
54
+ event: "magic_link_maintenance.ephemeral_data_dir_warning",
55
+ dataDir,
56
+ message: "data dir looks ephemeral; use persistent volume + MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR=1 in production"
57
+ },
58
+ null,
59
+ 2
60
+ )
61
+ );
62
+ }
38
63
 
39
64
  while (!stopped) {
40
65
  const loopStartMs = Date.now();
@@ -713,11 +713,24 @@ async function readJsonIfExists(fp) {
713
713
  }
714
714
  }
715
715
 
716
+ function isLikelyEphemeralDataDir(dirPath) {
717
+ const resolved = path.resolve(String(dirPath ?? ""));
718
+ return (
719
+ resolved === "/tmp" ||
720
+ resolved.startsWith("/tmp/") ||
721
+ resolved === os.tmpdir() ||
722
+ resolved.startsWith(`${os.tmpdir()}${path.sep}`)
723
+ );
724
+ }
725
+
716
726
  const port = Number(process.env.MAGIC_LINK_PORT ?? "8787");
717
727
  const host = process.env.MAGIC_LINK_HOST ? String(process.env.MAGIC_LINK_HOST) : "127.0.0.1";
718
728
  const socketPath = process.env.MAGIC_LINK_SOCKET_PATH ? path.resolve(String(process.env.MAGIC_LINK_SOCKET_PATH)) : null;
719
729
  const apiKey = process.env.MAGIC_LINK_API_KEY ?? null;
720
- const dataDir = process.env.MAGIC_LINK_DATA_DIR ? path.resolve(process.env.MAGIC_LINK_DATA_DIR) : path.join(os.tmpdir(), "settld-magic-link");
730
+ const dataDirRaw = process.env.MAGIC_LINK_DATA_DIR ? String(process.env.MAGIC_LINK_DATA_DIR).trim() : "";
731
+ const dataDir = dataDirRaw ? path.resolve(dataDirRaw) : path.join(os.tmpdir(), "settld-magic-link");
732
+ const dataDirLikelyEphemeral = isLikelyEphemeralDataDir(dataDir);
733
+ const requireDurableDataDir = String(process.env.MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR ?? "0").trim() === "1";
721
734
  const maxUploadBytes = Number(process.env.MAGIC_LINK_MAX_UPLOAD_BYTES ?? String(50 * 1024 * 1024));
722
735
  const tokenTtlSeconds = Number(process.env.MAGIC_LINK_TOKEN_TTL_SECONDS ?? String(7 * 24 * 3600));
723
736
  const verifyTimeoutMs = Number(process.env.MAGIC_LINK_VERIFY_TIMEOUT_MS ?? String(60_000));
@@ -897,6 +910,10 @@ const smtpUser = process.env.MAGIC_LINK_SMTP_USER ? String(process.env.MAGIC_LIN
897
910
  const smtpPass = process.env.MAGIC_LINK_SMTP_PASS ? String(process.env.MAGIC_LINK_SMTP_PASS) : "";
898
911
  const smtpFrom = process.env.MAGIC_LINK_SMTP_FROM ? String(process.env.MAGIC_LINK_SMTP_FROM).trim() : "";
899
912
  const smtpConfig = smtpHost && smtpFrom ? { host: smtpHost, port: smtpPort, secure: smtpSecure, starttls: smtpStarttls, user: smtpUser, pass: smtpPass, from: smtpFrom } : null;
913
+ const resendApiKey = process.env.MAGIC_LINK_RESEND_API_KEY ? String(process.env.MAGIC_LINK_RESEND_API_KEY).trim() : "";
914
+ const resendFrom = process.env.MAGIC_LINK_RESEND_FROM ? String(process.env.MAGIC_LINK_RESEND_FROM).trim() : "";
915
+ const resendBaseUrl = process.env.MAGIC_LINK_RESEND_BASE_URL ? String(process.env.MAGIC_LINK_RESEND_BASE_URL).trim() : "https://api.resend.com";
916
+ const resendConfig = resendApiKey && resendFrom ? { apiKey: resendApiKey, from: resendFrom, baseUrl: resendBaseUrl } : null;
900
917
  const onboardingEmailSequenceDeliveryMode =
901
918
  onboardingEmailSequenceDeliveryModeRaw || (smtpConfig ? "smtp" : "record");
902
919
 
@@ -985,10 +1002,14 @@ if (billingProvider === "stripe" && stripeWebhookSecret && !stripeWebhookSecret.
985
1002
  }
986
1003
  if (!Number.isInteger(decisionOtpTtlSeconds) || decisionOtpTtlSeconds <= 0) throw new Error("MAGIC_LINK_DECISION_OTP_TTL_SECONDS must be a positive integer");
987
1004
  if (!Number.isInteger(decisionOtpMaxAttempts) || decisionOtpMaxAttempts < 1) throw new Error("MAGIC_LINK_DECISION_OTP_MAX_ATTEMPTS must be an integer >= 1");
988
- if (decisionOtpDeliveryMode !== "record" && decisionOtpDeliveryMode !== "log" && decisionOtpDeliveryMode !== "smtp") throw new Error("MAGIC_LINK_DECISION_OTP_DELIVERY_MODE must be record|log|smtp");
1005
+ if (decisionOtpDeliveryMode !== "record" && decisionOtpDeliveryMode !== "log" && decisionOtpDeliveryMode !== "smtp" && decisionOtpDeliveryMode !== "resend") {
1006
+ throw new Error("MAGIC_LINK_DECISION_OTP_DELIVERY_MODE must be record|log|smtp|resend");
1007
+ }
989
1008
  if (!Number.isInteger(buyerOtpTtlSeconds) || buyerOtpTtlSeconds <= 0) throw new Error("MAGIC_LINK_BUYER_OTP_TTL_SECONDS must be a positive integer");
990
1009
  if (!Number.isInteger(buyerOtpMaxAttempts) || buyerOtpMaxAttempts < 1) throw new Error("MAGIC_LINK_BUYER_OTP_MAX_ATTEMPTS must be an integer >= 1");
991
- if (buyerOtpDeliveryMode !== "record" && buyerOtpDeliveryMode !== "log" && buyerOtpDeliveryMode !== "smtp") throw new Error("MAGIC_LINK_BUYER_OTP_DELIVERY_MODE must be record|log|smtp");
1010
+ if (buyerOtpDeliveryMode !== "record" && buyerOtpDeliveryMode !== "log" && buyerOtpDeliveryMode !== "smtp" && buyerOtpDeliveryMode !== "resend") {
1011
+ throw new Error("MAGIC_LINK_BUYER_OTP_DELIVERY_MODE must be record|log|smtp|resend");
1012
+ }
992
1013
  if (!Number.isInteger(buyerSessionTtlSeconds) || buyerSessionTtlSeconds <= 0) throw new Error("MAGIC_LINK_BUYER_SESSION_TTL_SECONDS must be a positive integer");
993
1014
  if (
994
1015
  onboardingEmailSequenceDeliveryMode !== "record" &&
@@ -1001,10 +1022,18 @@ if (!Number.isInteger(paymentTriggerRetryIntervalMs) || paymentTriggerRetryInter
1001
1022
  if (!Number.isInteger(paymentTriggerMaxAttempts) || paymentTriggerMaxAttempts < 1) throw new Error("MAGIC_LINK_PAYMENT_TRIGGER_MAX_ATTEMPTS must be an integer >= 1");
1002
1023
  if (!Number.isInteger(paymentTriggerRetryBackoffMs) || paymentTriggerRetryBackoffMs < 0) throw new Error("MAGIC_LINK_PAYMENT_TRIGGER_RETRY_BACKOFF_MS must be an integer >= 0");
1003
1024
  if (typeof migrateOnStartup !== "boolean") throw new Error("MAGIC_LINK_MIGRATE_ON_STARTUP must be 1|0");
1025
+ if (requireDurableDataDir && dataDirLikelyEphemeral) {
1026
+ throw new Error("MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR=1 but MAGIC_LINK_DATA_DIR resolves to an ephemeral path (/tmp)");
1027
+ }
1004
1028
  if (smtpHost) {
1005
1029
  if (!Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535) throw new Error("MAGIC_LINK_SMTP_PORT must be 1..65535");
1006
1030
  if (!smtpFrom) throw new Error("MAGIC_LINK_SMTP_FROM is required when MAGIC_LINK_SMTP_HOST is set");
1007
1031
  }
1032
+ if (buyerOtpDeliveryMode === "resend" || decisionOtpDeliveryMode === "resend") {
1033
+ if (!resendApiKey || !resendFrom) {
1034
+ throw new Error("MAGIC_LINK_RESEND_API_KEY and MAGIC_LINK_RESEND_FROM are required when OTP delivery mode is resend");
1035
+ }
1036
+ }
1008
1037
  if (publicBaseUrl !== null && publicBaseUrl !== "") {
1009
1038
  // Minimal validation; URL constructor will throw on invalid.
1010
1039
  // eslint-disable-next-line no-new
@@ -1062,6 +1091,12 @@ const verifyDurationsWindow = 500;
1062
1091
  await fs.mkdir(dataDir, { recursive: true });
1063
1092
  const dataDirFormat = await checkAndMigrateDataDir({ dataDir, migrateOnStartup });
1064
1093
  if (!dataDirFormat.ok) throw new Error(`magic-link data dir check failed: ${dataDirFormat.code ?? "UNKNOWN"}`);
1094
+ if (dataDirLikelyEphemeral) {
1095
+ // eslint-disable-next-line no-console
1096
+ console.warn(
1097
+ `magic-link warning: MAGIC_LINK_DATA_DIR (${dataDir}) is likely ephemeral; use a persistent volume and set MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR=1 in production`
1098
+ );
1099
+ }
1065
1100
 
1066
1101
  function recordVerifyDurationMs(ms) {
1067
1102
  const n = Math.max(0, Number(ms));
@@ -1093,6 +1128,8 @@ async function readinessSignals() {
1093
1128
  metrics.setGauge("magic_link_data_dir_writable_gauge", null, dataDirWritable ? 1 : 0);
1094
1129
  return {
1095
1130
  dataDir,
1131
+ dataDirLikelyEphemeral,
1132
+ requireDurableDataDir,
1096
1133
  dataDirWritable,
1097
1134
  dataFormatVersion: MAGIC_LINK_DATA_FORMAT_VERSION_CURRENT,
1098
1135
  migrateOnStartup,
@@ -10123,10 +10160,18 @@ async function handleBuyerLoginOtpRequest(req, res, tenantId) {
10123
10160
  return sendJson(res, 400, { ok: false, code: "BUYER_EMAIL_DOMAIN_FORBIDDEN", message: "email domain is not allowed" });
10124
10161
  }
10125
10162
 
10126
- const issued = await issueBuyerOtp({ dataDir, tenantId, email, ttlSeconds: buyerOtpTtlSeconds, deliveryMode: buyerOtpDeliveryMode, smtp: smtpConfig });
10163
+ const issued = await issueBuyerOtp({
10164
+ dataDir,
10165
+ tenantId,
10166
+ email,
10167
+ ttlSeconds: buyerOtpTtlSeconds,
10168
+ deliveryMode: buyerOtpDeliveryMode,
10169
+ smtp: smtpConfig,
10170
+ resend: resendConfig
10171
+ });
10127
10172
  if (!issued.ok) {
10128
10173
  metrics.incCounter("login_otp_requests_total", { tenantId, ok: "false", code: String(issued.error ?? "OTP_FAILED") }, 1);
10129
- return sendJson(res, 400, { ok: false, code: issued.error ?? "OTP_FAILED", message: issued.message ?? "otp failed" });
10174
+ return sendJson(res, 400, { ok: false, code: issued.error ?? "OTP_FAILED", message: issued.message || "otp failed" });
10130
10175
  }
10131
10176
  metrics.incCounter("login_otp_requests_total", { tenantId, ok: "true" }, 1);
10132
10177
  try {
@@ -10160,7 +10205,7 @@ async function handleBuyerLogin(req, res, tenantId) {
10160
10205
  if (!isEmailAllowedByDomains({ email, allowedDomains })) return sendJson(res, 400, { ok: false, code: "BUYER_EMAIL_DOMAIN_FORBIDDEN", message: "email domain is not allowed" });
10161
10206
 
10162
10207
  const verified = await verifyAndConsumeBuyerOtp({ dataDir, tenantId, email, code, maxAttempts: buyerOtpMaxAttempts });
10163
- if (!verified.ok) return sendJson(res, 400, { ok: false, code: verified.error ?? "OTP_FAILED", message: verified.message ?? "otp failed" });
10208
+ if (!verified.ok) return sendJson(res, 400, { ok: false, code: verified.error ?? "OTP_FAILED", message: verified.message || "otp failed" });
10164
10209
 
10165
10210
  const session = createBuyerSessionToken({ sessionKey, tenantId, email, ttlSeconds: buyerSessionTtlSeconds });
10166
10211
  if (!session.ok) return sendJson(res, 500, { ok: false, code: session.error ?? "SESSION_FAILED", message: "failed to create buyer session" });
@@ -10336,7 +10381,15 @@ async function handlePublicSignup(req, res) {
10336
10381
  status: "active"
10337
10382
  });
10338
10383
 
10339
- const issued = await issueBuyerOtp({ dataDir, tenantId, email, ttlSeconds: buyerOtpTtlSeconds, deliveryMode: buyerOtpDeliveryMode, smtp: smtpConfig });
10384
+ const issued = await issueBuyerOtp({
10385
+ dataDir,
10386
+ tenantId,
10387
+ email,
10388
+ ttlSeconds: buyerOtpTtlSeconds,
10389
+ deliveryMode: buyerOtpDeliveryMode,
10390
+ smtp: smtpConfig,
10391
+ resend: resendConfig
10392
+ });
10340
10393
  if (!issued.ok) {
10341
10394
  return sendJson(res, 202, {
10342
10395
  ok: true,
@@ -10344,7 +10397,7 @@ async function handlePublicSignup(req, res) {
10344
10397
  email,
10345
10398
  otpIssued: false,
10346
10399
  warning: issued.error ?? "OTP_FAILED",
10347
- message: issued.message ?? "tenant created, otp issue failed"
10400
+ message: issued.message || "tenant created, otp issue failed"
10348
10401
  });
10349
10402
  }
10350
10403
 
@@ -14515,10 +14568,18 @@ async function handleDecisionOtpRequest(req, res, token) {
14515
14568
  return sendJson(res, 400, { ok: false, code: "OTP_EMAIL_DOMAIN_FORBIDDEN", message: "email domain is not allowed" });
14516
14569
  }
14517
14570
 
14518
- const issued = await issueDecisionOtp({ dataDir, token, email, ttlSeconds: decisionOtpTtlSeconds, deliveryMode: decisionOtpDeliveryMode, smtp: smtpConfig });
14571
+ const issued = await issueDecisionOtp({
14572
+ dataDir,
14573
+ token,
14574
+ email,
14575
+ ttlSeconds: decisionOtpTtlSeconds,
14576
+ deliveryMode: decisionOtpDeliveryMode,
14577
+ smtp: smtpConfig,
14578
+ resend: resendConfig
14579
+ });
14519
14580
  if (!issued.ok) {
14520
14581
  metrics.incCounter("decision_otp_requests_total", { tenantId: String(tenantId ?? "default"), ok: "false", code: String(issued.error ?? "OTP_FAILED") }, 1);
14521
- return sendJson(res, 400, { ok: false, code: issued.error ?? "OTP_FAILED", message: issued.message ?? "otp failed" });
14582
+ return sendJson(res, 400, { ok: false, code: issued.error ?? "OTP_FAILED", message: issued.message || "otp failed" });
14522
14583
  }
14523
14584
  metrics.incCounter("decision_otp_requests_total", { tenantId: String(tenantId ?? "default"), ok: "true" }, 1);
14524
14585
 
@@ -14724,7 +14785,7 @@ async function handleDecision(req, res, token, { internalAutoDecision = false }
14724
14785
  return sendJson(res, 400, { ok: false, code: "OTP_REQUIRED", message: "otp code is required" });
14725
14786
  }
14726
14787
  const verified = await verifyAndConsumeDecisionOtp({ dataDir, token, email: actorEmailNorm, code: otp, maxAttempts: decisionOtpMaxAttempts });
14727
- if (!verified.ok) return sendJson(res, 400, { ok: false, code: verified.error ?? "OTP_FAILED", message: verified.message ?? "otp failed" });
14788
+ if (!verified.ok) return sendJson(res, 400, { ok: false, code: verified.error ?? "OTP_FAILED", message: verified.message || "otp failed" });
14728
14789
  }
14729
14790
 
14730
14791
  let cliOut = null;
@@ -21,6 +21,19 @@ function clampText(v, { max }) {
21
21
  return s.slice(0, Math.max(0, max - 1)) + "…";
22
22
  }
23
23
 
24
+ const SIMPLE_EMAIL_RE = /^[^\s@<>]+@[^\s@<>]+$/;
25
+
26
+ export function extractSmtpEnvelopeAddress(value, { fieldName = "address" } = {}) {
27
+ const raw = String(value ?? "").trim();
28
+ if (!raw) throw new Error(`smtp ${fieldName} required`);
29
+ const bracketed = /<\s*([^<>\s@]+@[^\s@<>]+)\s*>/.exec(raw);
30
+ const candidate = (bracketed ? bracketed[1] : raw).trim();
31
+ if (!SIMPLE_EMAIL_RE.test(candidate)) {
32
+ throw new Error(`smtp ${fieldName} must be an email address`);
33
+ }
34
+ return candidate;
35
+ }
36
+
24
37
  export function formatSmtpMessage({ from, to, subject, text, messageIdDomain }) {
25
38
  const subj = clampText(subject, { max: 200 });
26
39
  const body = String(text ?? "");
@@ -133,6 +146,8 @@ export async function sendSmtpMail({
133
146
  if (!h) throw new Error("smtp host required");
134
147
  if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("smtp port invalid");
135
148
  if (!from || !to) throw new Error("smtp from/to required");
149
+ const envelopeFrom = extractSmtpEnvelopeAddress(from, { fieldName: "from" });
150
+ const envelopeTo = extractSmtpEnvelopeAddress(to, { fieldName: "to" });
136
151
 
137
152
  const connect = secure
138
153
  ? () => tls.connect({ host: h, port: p, servername: h })
@@ -178,13 +193,13 @@ export async function sendSmtpMail({
178
193
  }
179
194
  }
180
195
 
181
- await sendCmd(socket, reader, `MAIL FROM:<${from}>`, 250);
182
- await sendCmd(socket, reader, `RCPT TO:<${to}>`, [250, 251]);
196
+ await sendCmd(socket, reader, `MAIL FROM:<${envelopeFrom}>`, 250);
197
+ await sendCmd(socket, reader, `RCPT TO:<${envelopeTo}>`, [250, 251]);
183
198
  await sendCmd(socket, reader, "DATA", 354);
184
199
 
185
200
  const domain = (() => {
186
- const i = String(from).indexOf("@");
187
- return i !== -1 ? String(from).slice(i + 1) : "localhost";
201
+ const i = envelopeFrom.indexOf("@");
202
+ return i !== -1 ? envelopeFrom.slice(i + 1) : "localhost";
188
203
  })();
189
204
  const msg = formatSmtpMessage({ from, to, subject, text, messageIdDomain: domain });
190
205
 
package/src/api/app.js CHANGED
@@ -7588,7 +7588,12 @@ export function createApi({
7588
7588
  const out = [];
7589
7589
  const seen = new Set();
7590
7590
  for (const entry of parsed) {
7591
- const id = normalizeOptionalX402RefInput(entry, fieldPath, { allowNull: true, max });
7591
+ if (entry === null || entry === undefined || String(entry).trim() === "") continue;
7592
+ if (typeof entry !== "string") throw new TypeError(`${fieldPath} must contain only strings`);
7593
+ const id = String(entry).trim();
7594
+ if (!id) continue;
7595
+ if (id.length > max) throw new TypeError(`${fieldPath} must be <= ${max} chars`);
7596
+ if (!/^[A-Za-z0-9:_.-]+$/.test(id)) throw new TypeError(`${fieldPath} must match ^[A-Za-z0-9:_.-]+$`);
7592
7597
  if (!id) continue;
7593
7598
  if (seen.has(id)) continue;
7594
7599
  seen.add(id);