settld 0.2.4 → 0.2.5
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/docs/CONFIG.md +12 -0
- package/docs/README.md +3 -0
- package/docs/ops/HOSTED_BASELINE_R2.md +4 -2
- package/docs/ops/MINIMUM_PRODUCTION_TOPOLOGY.md +19 -7
- package/docs/ops/PRODUCTION_DEPLOYMENT_CHECKLIST.md +8 -3
- package/package.json +3 -1
- package/scripts/ci/run-public-onboarding-gate.mjs +136 -0
- package/scripts/setup/login.mjs +67 -1
- package/scripts/setup/onboard.mjs +159 -28
- package/scripts/setup/onboarding-failure-taxonomy.mjs +96 -0
- package/scripts/setup/onboarding-state-machine.mjs +102 -0
- package/services/magic-link/README.md +343 -0
- package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_criteria.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/evidence/evidence_index.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/metering/metering_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_definition.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_criteria.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/evidence/evidence_index.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/metering/metering_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/sla/sla_definition.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/sla/sla_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/trust.json +11 -0
- package/services/magic-link/src/audit-log.js +24 -0
- package/services/magic-link/src/buyer-auth.js +220 -0
- package/services/magic-link/src/buyer-notifications.js +402 -0
- package/services/magic-link/src/buyer-users.js +129 -0
- package/services/magic-link/src/decision-otp.js +156 -0
- package/services/magic-link/src/decisions.js +92 -0
- package/services/magic-link/src/ingest-keys.js +137 -0
- package/services/magic-link/src/maintenance.js +70 -0
- package/services/magic-link/src/onboarding-email-sequence.js +331 -0
- package/services/magic-link/src/payment-triggers.js +733 -0
- package/services/magic-link/src/pdf.js +149 -0
- package/services/magic-link/src/policy.js +69 -0
- package/services/magic-link/src/redaction.js +6 -0
- package/services/magic-link/src/render-model.js +70 -0
- package/services/magic-link/src/retention-gc.js +158 -0
- package/services/magic-link/src/run-records.js +496 -0
- package/services/magic-link/src/s3.js +171 -0
- package/services/magic-link/src/server.js +15788 -0
- package/services/magic-link/src/settlement-decisions.js +84 -0
- package/services/magic-link/src/smtp.js +202 -0
- package/services/magic-link/src/storage-cli.js +88 -0
- package/services/magic-link/src/storage-format.js +59 -0
- package/services/magic-link/src/tenant-billing.js +115 -0
- package/services/magic-link/src/tenant-onboarding.js +467 -0
- package/services/magic-link/src/tenant-settings.js +1140 -0
- package/services/magic-link/src/usage.js +80 -0
- package/services/magic-link/src/verify-queue.js +179 -0
- package/services/magic-link/src/verify-worker.js +157 -0
- package/services/magic-link/src/webhook-retries.js +542 -0
- package/services/magic-link/src/webhooks.js +218 -0
- package/src/api/app.js +129 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function isPlainObject(v) {
|
|
5
|
+
return Boolean(v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function decisionPath({ dataDir, token }) {
|
|
9
|
+
return path.join(dataDir, "decisions", `${token}.json`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function loadDecisionRecord({ dataDir, token }) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = await fs.readFile(decisionPath({ dataDir, token }), "utf8");
|
|
15
|
+
const j = JSON.parse(raw);
|
|
16
|
+
if (!isPlainObject(j) || j.schemaVersion !== "DecisionRecord.v0") return null;
|
|
17
|
+
return j;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function clampText(v, { max }) {
|
|
24
|
+
const s = String(v ?? "").trim();
|
|
25
|
+
if (!s) return null;
|
|
26
|
+
return s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeDecision(v) {
|
|
30
|
+
const s = String(v ?? "").trim().toLowerCase();
|
|
31
|
+
if (s === "approve" || s === "hold") return s;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function appendDecision({
|
|
36
|
+
dataDir,
|
|
37
|
+
token,
|
|
38
|
+
tenantId,
|
|
39
|
+
zipSha256,
|
|
40
|
+
verifyJsonSha256,
|
|
41
|
+
decision,
|
|
42
|
+
actorName,
|
|
43
|
+
actorEmail,
|
|
44
|
+
authMethod,
|
|
45
|
+
actorIp,
|
|
46
|
+
actorUserAgent,
|
|
47
|
+
note
|
|
48
|
+
}) {
|
|
49
|
+
const d = normalizeDecision(decision);
|
|
50
|
+
if (!d) return { ok: false, error: "INVALID_DECISION", message: "decision must be approve|hold" };
|
|
51
|
+
const name = clampText(actorName, { max: 200 });
|
|
52
|
+
const email = clampText(actorEmail, { max: 320 });
|
|
53
|
+
if (!email) return { ok: false, error: "INVALID_ACTOR", message: "email is required" };
|
|
54
|
+
const auth = clampText(authMethod, { max: 32 }) ?? "none";
|
|
55
|
+
const ip = clampText(actorIp, { max: 128 });
|
|
56
|
+
const userAgent = clampText(actorUserAgent, { max: 400 });
|
|
57
|
+
const noteValue = clampText(note, { max: 2000 });
|
|
58
|
+
|
|
59
|
+
const fp = decisionPath({ dataDir, token });
|
|
60
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
61
|
+
|
|
62
|
+
let record = null;
|
|
63
|
+
try {
|
|
64
|
+
record = JSON.parse(await fs.readFile(fp, "utf8"));
|
|
65
|
+
} catch {
|
|
66
|
+
record = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const decidedAt = new Date().toISOString();
|
|
70
|
+
const entry = {
|
|
71
|
+
decision: d,
|
|
72
|
+
decidedAt,
|
|
73
|
+
actor: { name, email },
|
|
74
|
+
auth: { method: auth },
|
|
75
|
+
client: { ip, userAgent },
|
|
76
|
+
note: noteValue
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const next = isPlainObject(record) && record.schemaVersion === "DecisionRecord.v0"
|
|
80
|
+
? { ...record }
|
|
81
|
+
: { schemaVersion: "DecisionRecord.v0", token, tenantId, zipSha256, verifyJsonSha256, decisions: [] };
|
|
82
|
+
|
|
83
|
+
next.tenantId = tenantId;
|
|
84
|
+
next.zipSha256 = zipSha256;
|
|
85
|
+
next.verifyJsonSha256 = verifyJsonSha256;
|
|
86
|
+
next.decisions = Array.isArray(next.decisions) ? next.decisions : [];
|
|
87
|
+
next.decisions.push(entry);
|
|
88
|
+
next.updatedAt = decidedAt;
|
|
89
|
+
|
|
90
|
+
await fs.writeFile(fp, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
91
|
+
return { ok: true, record: next };
|
|
92
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function isPlainObject(v) {
|
|
6
|
+
return Boolean(v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function assertId(value, { name }) {
|
|
10
|
+
const v = String(value ?? "").trim();
|
|
11
|
+
if (!v) throw new TypeError(`${name} is required`);
|
|
12
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(v)) throw new TypeError(`${name} invalid (allowed: [A-Za-z0-9_-]{1,64})`);
|
|
13
|
+
return v;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function nowIso() {
|
|
17
|
+
return new Date().toISOString();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sha256Hex(s) {
|
|
21
|
+
return crypto.createHash("sha256").update(String(s ?? ""), "utf8").digest("hex");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ingestKeyPath({ dataDir, tenantId, keyHash }) {
|
|
25
|
+
return path.join(dataDir, "ingest-keys", tenantId, `${keyHash}.json`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function generateIngestKey() {
|
|
29
|
+
return "igk_" + crypto.randomBytes(32).toString("hex");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function createIngestKey({ dataDir, tenantId, vendorId, vendorName = null, expiresAt = null } = {}) {
|
|
33
|
+
const t = assertId(tenantId, { name: "tenantId" });
|
|
34
|
+
const v = assertId(vendorId, { name: "vendorId" });
|
|
35
|
+
const name = vendorName === null || vendorName === undefined ? null : String(vendorName).trim() || null;
|
|
36
|
+
|
|
37
|
+
let exp = null;
|
|
38
|
+
if (expiresAt !== null && expiresAt !== undefined) {
|
|
39
|
+
const raw = String(expiresAt ?? "").trim();
|
|
40
|
+
if (!raw) exp = null;
|
|
41
|
+
else {
|
|
42
|
+
const ms = Date.parse(raw);
|
|
43
|
+
if (!Number.isFinite(ms)) throw new TypeError("expiresAt must be an ISO date string or null");
|
|
44
|
+
exp = new Date(ms).toISOString();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await fs.mkdir(path.join(dataDir, "ingest-keys", t), { recursive: true });
|
|
49
|
+
|
|
50
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
51
|
+
const ingestKey = generateIngestKey();
|
|
52
|
+
const keyHash = sha256Hex(ingestKey);
|
|
53
|
+
const fp = ingestKeyPath({ dataDir, tenantId: t, keyHash });
|
|
54
|
+
try {
|
|
55
|
+
await fs.writeFile(
|
|
56
|
+
fp,
|
|
57
|
+
JSON.stringify(
|
|
58
|
+
{
|
|
59
|
+
schemaVersion: "MagicLinkIngestKey.v1",
|
|
60
|
+
tenantId: t,
|
|
61
|
+
vendorId: v,
|
|
62
|
+
vendorName: name,
|
|
63
|
+
keyHash,
|
|
64
|
+
createdAt: nowIso(),
|
|
65
|
+
expiresAt: exp,
|
|
66
|
+
revokedAt: null,
|
|
67
|
+
revokedReason: null,
|
|
68
|
+
permissions: ["upload_only"]
|
|
69
|
+
},
|
|
70
|
+
null,
|
|
71
|
+
2
|
|
72
|
+
) + "\n",
|
|
73
|
+
{ encoding: "utf8", flag: "wx" }
|
|
74
|
+
);
|
|
75
|
+
return { ok: true, ingestKey, keyHash };
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err?.code === "EEXIST") continue;
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, error: "KEYGEN_FAILED", message: "failed to generate unique ingest key" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function loadIngestKeyRecordByHash({ dataDir, tenantId, keyHash } = {}) {
|
|
85
|
+
const t = assertId(tenantId, { name: "tenantId" });
|
|
86
|
+
const h = String(keyHash ?? "").trim().toLowerCase();
|
|
87
|
+
if (!/^[0-9a-f]{64}$/.test(h)) return null;
|
|
88
|
+
const fp = ingestKeyPath({ dataDir, tenantId: t, keyHash: h });
|
|
89
|
+
try {
|
|
90
|
+
const raw = await fs.readFile(fp, "utf8");
|
|
91
|
+
const j = JSON.parse(raw);
|
|
92
|
+
if (!isPlainObject(j) || j.schemaVersion !== "MagicLinkIngestKey.v1") return null;
|
|
93
|
+
if (j.tenantId !== t) return null;
|
|
94
|
+
if (typeof j.vendorId !== "string" || !j.vendorId.trim()) return null;
|
|
95
|
+
if (j.keyHash !== h) return null;
|
|
96
|
+
return j;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function authenticateIngestKey({ dataDir, tenantId, ingestKey } = {}) {
|
|
103
|
+
const key = typeof ingestKey === "string" ? ingestKey.trim() : "";
|
|
104
|
+
if (!key) return { ok: false, error: "MISSING" };
|
|
105
|
+
if (!key.startsWith("igk_")) return { ok: false, error: "INVALID" };
|
|
106
|
+
const keyHash = sha256Hex(key);
|
|
107
|
+
const rec = await loadIngestKeyRecordByHash({ dataDir, tenantId, keyHash });
|
|
108
|
+
if (!rec) return { ok: false, error: "NOT_FOUND" };
|
|
109
|
+
if (rec.revokedAt) return { ok: false, error: "REVOKED" };
|
|
110
|
+
if (rec.expiresAt) {
|
|
111
|
+
const ms = Date.parse(String(rec.expiresAt));
|
|
112
|
+
if (Number.isFinite(ms) && Date.now() > ms) return { ok: false, error: "EXPIRED" };
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, record: rec };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function revokeIngestKey({ dataDir, tenantId, keyHash, reason = null } = {}) {
|
|
118
|
+
const t = assertId(tenantId, { name: "tenantId" });
|
|
119
|
+
const h = String(keyHash ?? "").trim().toLowerCase();
|
|
120
|
+
if (!/^[0-9a-f]{64}$/.test(h)) return { ok: false, error: "INVALID_KEY_HASH" };
|
|
121
|
+
const fp = ingestKeyPath({ dataDir, tenantId: t, keyHash: h });
|
|
122
|
+
|
|
123
|
+
let rec = null;
|
|
124
|
+
try {
|
|
125
|
+
rec = JSON.parse(await fs.readFile(fp, "utf8"));
|
|
126
|
+
} catch {
|
|
127
|
+
return { ok: false, error: "NOT_FOUND" };
|
|
128
|
+
}
|
|
129
|
+
if (!isPlainObject(rec) || rec.schemaVersion !== "MagicLinkIngestKey.v1") return { ok: false, error: "NOT_FOUND" };
|
|
130
|
+
if (rec.revokedAt) return { ok: true, alreadyRevoked: true, revokedAt: rec.revokedAt };
|
|
131
|
+
|
|
132
|
+
rec.revokedAt = nowIso();
|
|
133
|
+
rec.revokedReason = typeof reason === "string" && reason.trim() ? reason.trim() : null;
|
|
134
|
+
await fs.writeFile(fp, JSON.stringify(rec, null, 2) + "\n", "utf8");
|
|
135
|
+
return { ok: true, revokedAt: rec.revokedAt };
|
|
136
|
+
}
|
|
137
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
|
|
6
|
+
import { checkAndMigrateDataDir } from "./storage-format.js";
|
|
7
|
+
import { loadTenantSettings } from "./tenant-settings.js";
|
|
8
|
+
import { garbageCollectTenantByRetention, listTenantIdsWithIndex } from "./retention-gc.js";
|
|
9
|
+
|
|
10
|
+
function nowIso() {
|
|
11
|
+
return new Date().toISOString();
|
|
12
|
+
}
|
|
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");
|
|
15
|
+
const migrateOnStartup = String(process.env.MAGIC_LINK_MIGRATE_ON_STARTUP ?? "1").trim() !== "0";
|
|
16
|
+
const intervalSeconds = Number.parseInt(String(process.env.MAGIC_LINK_MAINTENANCE_INTERVAL_SECONDS ?? "86400"), 10);
|
|
17
|
+
|
|
18
|
+
if (!Number.isInteger(intervalSeconds) || intervalSeconds < 5) throw new Error("MAGIC_LINK_MAINTENANCE_INTERVAL_SECONDS must be an integer >= 5");
|
|
19
|
+
|
|
20
|
+
await fs.mkdir(dataDir, { recursive: true });
|
|
21
|
+
const fmt = await checkAndMigrateDataDir({ dataDir, migrateOnStartup });
|
|
22
|
+
if (!fmt.ok) throw new Error(`magic-link data dir check failed: ${fmt.code ?? "UNKNOWN"}`);
|
|
23
|
+
|
|
24
|
+
let stopped = false;
|
|
25
|
+
async function shutdown(signal) {
|
|
26
|
+
if (stopped) return;
|
|
27
|
+
stopped = true;
|
|
28
|
+
// eslint-disable-next-line no-console
|
|
29
|
+
console.log(JSON.stringify({ at: nowIso(), event: "magic_link_maintenance.shutdown", signal }, null, 2));
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
34
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
35
|
+
|
|
36
|
+
// eslint-disable-next-line no-console
|
|
37
|
+
console.log(JSON.stringify({ at: nowIso(), event: "magic_link_maintenance.start", dataDir, intervalSeconds }, null, 2));
|
|
38
|
+
|
|
39
|
+
while (!stopped) {
|
|
40
|
+
const loopStartMs = Date.now();
|
|
41
|
+
let tenants = [];
|
|
42
|
+
try {
|
|
43
|
+
tenants = await listTenantIdsWithIndex({ dataDir });
|
|
44
|
+
} catch {
|
|
45
|
+
tenants = [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let deleted = 0;
|
|
49
|
+
let kept = 0;
|
|
50
|
+
for (const tenantId of tenants) {
|
|
51
|
+
try {
|
|
52
|
+
// eslint-disable-next-line no-await-in-loop
|
|
53
|
+
const tenantSettings = await loadTenantSettings({ dataDir, tenantId });
|
|
54
|
+
// eslint-disable-next-line no-await-in-loop
|
|
55
|
+
const res = await garbageCollectTenantByRetention({ dataDir, tenantId, tenantSettings });
|
|
56
|
+
deleted += Number(res?.deleted ?? 0);
|
|
57
|
+
kept += Number(res?.kept ?? 0);
|
|
58
|
+
} catch {
|
|
59
|
+
// ignore per-tenant failures
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// eslint-disable-next-line no-console
|
|
64
|
+
console.log(JSON.stringify({ at: nowIso(), event: "magic_link_maintenance.retention_sweep", tenants: tenants.length, deleted, kept }, null, 2));
|
|
65
|
+
|
|
66
|
+
const elapsedMs = Date.now() - loopStartMs;
|
|
67
|
+
const sleepMs = Math.max(0, intervalSeconds * 1000 - elapsedMs);
|
|
68
|
+
// eslint-disable-next-line no-await-in-loop
|
|
69
|
+
await new Promise((r) => setTimeout(r, sleepMs));
|
|
70
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { sendSmtpMail } from "./smtp.js";
|
|
6
|
+
|
|
7
|
+
function nowIso() {
|
|
8
|
+
return new Date().toISOString();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isPlainObject(v) {
|
|
12
|
+
return Boolean(v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeEmailLower(value) {
|
|
16
|
+
const raw = String(value ?? "").trim().toLowerCase();
|
|
17
|
+
if (!raw || raw.length > 320 || /\s/.test(raw)) return null;
|
|
18
|
+
const parts = raw.split("@");
|
|
19
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
|
|
20
|
+
return raw;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function onboardingEmailSequencePath({ dataDir, tenantId }) {
|
|
24
|
+
return path.join(dataDir, "tenants", tenantId, "onboarding_email_sequence.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function onboardingEmailOutboxPath({ dataDir, tenantId, stepKey, recipient, sentAt }) {
|
|
28
|
+
const hash = crypto.createHash("sha256").update(`${tenantId}\n${stepKey}\n${recipient}\n${sentAt}`, "utf8").digest("hex").slice(0, 16);
|
|
29
|
+
return path.join(dataDir, "onboarding-email-outbox", tenantId, stepKey, `${sentAt.replaceAll(":", "-")}_${hash}.json`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ONBOARDING_EMAIL_SEQUENCE_VERSION = "MagicLinkOnboardingEmailSequence.v1";
|
|
33
|
+
|
|
34
|
+
const ONBOARDING_EMAIL_STEPS = Object.freeze([
|
|
35
|
+
Object.freeze({
|
|
36
|
+
stepKey: "welcome",
|
|
37
|
+
label: "Welcome + launch checklist",
|
|
38
|
+
trigger: (profile) => (typeof profile?.createdAt === "string" && profile.createdAt ? profile.createdAt : null),
|
|
39
|
+
subject: (ctx) => `Welcome to Settld, ${ctx.tenantName}`,
|
|
40
|
+
text: (ctx) =>
|
|
41
|
+
[
|
|
42
|
+
`Welcome to Settld, ${ctx.tenantName}.`,
|
|
43
|
+
"",
|
|
44
|
+
"Your onboarding workspace is ready.",
|
|
45
|
+
`Open: ${ctx.onboardingUrl}`,
|
|
46
|
+
"",
|
|
47
|
+
"Goal for today: complete your first verified live settlement in under 10 minutes.",
|
|
48
|
+
"1) Upload a sample bundle",
|
|
49
|
+
"2) Validate policy",
|
|
50
|
+
"3) Run first live settlement",
|
|
51
|
+
"",
|
|
52
|
+
"If you need examples, use the Python quickstart in docs/QUICKSTART_SDK_PYTHON.md.",
|
|
53
|
+
"",
|
|
54
|
+
"— Settld"
|
|
55
|
+
].join("\n")
|
|
56
|
+
}),
|
|
57
|
+
Object.freeze({
|
|
58
|
+
stepKey: "sample_verified_nudge",
|
|
59
|
+
label: "Sample verified -> go live nudge",
|
|
60
|
+
trigger: (profile) =>
|
|
61
|
+
profile?.firstSampleVerifiedAt && !profile?.firstUploadAt
|
|
62
|
+
? String(profile.firstSampleVerifiedAt)
|
|
63
|
+
: null,
|
|
64
|
+
subject: () => "Sample verified. Push your first live settlement now.",
|
|
65
|
+
text: (ctx) =>
|
|
66
|
+
[
|
|
67
|
+
"Your sample verification passed.",
|
|
68
|
+
"",
|
|
69
|
+
"Next step: run your first live settlement now.",
|
|
70
|
+
`Open onboarding: ${ctx.onboardingUrl}`,
|
|
71
|
+
`Pricing reference: ${ctx.pricingUrl}`,
|
|
72
|
+
"",
|
|
73
|
+
"Keep momentum: teams that run live within the same session activate fastest.",
|
|
74
|
+
"",
|
|
75
|
+
"— Settld"
|
|
76
|
+
].join("\n")
|
|
77
|
+
}),
|
|
78
|
+
Object.freeze({
|
|
79
|
+
stepKey: "first_settlement_completed",
|
|
80
|
+
label: "First settlement completed + referral invite",
|
|
81
|
+
trigger: (profile) => (typeof profile?.firstVerifiedAt === "string" && profile.firstVerifiedAt ? profile.firstVerifiedAt : null),
|
|
82
|
+
subject: () => "First settlement complete. Invite another team.",
|
|
83
|
+
text: (ctx) =>
|
|
84
|
+
[
|
|
85
|
+
"Your first verified settlement is complete.",
|
|
86
|
+
"",
|
|
87
|
+
"Next steps:",
|
|
88
|
+
"1) Add your second endpoint/workflow",
|
|
89
|
+
"2) Enable policy presets for default guardrails",
|
|
90
|
+
"3) Invite one peer team and track referral conversion",
|
|
91
|
+
"",
|
|
92
|
+
`Track referral events via onboarding API: POST /v1/tenants/${ctx.tenantId}/onboarding/events`,
|
|
93
|
+
"eventType values: referral_link_shared, referral_signup",
|
|
94
|
+
"",
|
|
95
|
+
"— Settld"
|
|
96
|
+
].join("\n")
|
|
97
|
+
})
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
function defaultSequenceState({ tenantId }) {
|
|
101
|
+
return {
|
|
102
|
+
schemaVersion: ONBOARDING_EMAIL_SEQUENCE_VERSION,
|
|
103
|
+
tenantId,
|
|
104
|
+
steps: {},
|
|
105
|
+
updatedAt: nowIso()
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function loadOnboardingEmailSequenceStateBestEffort({ dataDir, tenantId }) {
|
|
110
|
+
const fp = onboardingEmailSequencePath({ dataDir, tenantId });
|
|
111
|
+
try {
|
|
112
|
+
const raw = JSON.parse(await fs.readFile(fp, "utf8"));
|
|
113
|
+
if (!isPlainObject(raw)) return defaultSequenceState({ tenantId });
|
|
114
|
+
const stepsIn = isPlainObject(raw.steps) ? raw.steps : {};
|
|
115
|
+
const steps = {};
|
|
116
|
+
for (const row of ONBOARDING_EMAIL_STEPS) {
|
|
117
|
+
const step = isPlainObject(stepsIn[row.stepKey]) ? stepsIn[row.stepKey] : null;
|
|
118
|
+
if (!step || typeof step.sentAt !== "string" || step.sentAt.trim() === "") continue;
|
|
119
|
+
steps[row.stepKey] = {
|
|
120
|
+
sentAt: step.sentAt,
|
|
121
|
+
triggerAt: typeof step.triggerAt === "string" ? step.triggerAt : null,
|
|
122
|
+
deliveryMode: typeof step.deliveryMode === "string" ? step.deliveryMode : null,
|
|
123
|
+
recipients: Array.isArray(step.recipients) ? step.recipients.map((x) => String(x ?? "").trim().toLowerCase()).filter(Boolean) : []
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
schemaVersion: ONBOARDING_EMAIL_SEQUENCE_VERSION,
|
|
128
|
+
tenantId,
|
|
129
|
+
steps,
|
|
130
|
+
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null
|
|
131
|
+
};
|
|
132
|
+
} catch {
|
|
133
|
+
return defaultSequenceState({ tenantId });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function saveOnboardingEmailSequenceState({ dataDir, tenantId, state }) {
|
|
138
|
+
const fp = onboardingEmailSequencePath({ dataDir, tenantId });
|
|
139
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
140
|
+
const next = {
|
|
141
|
+
...defaultSequenceState({ tenantId }),
|
|
142
|
+
...(isPlainObject(state) ? state : {}),
|
|
143
|
+
tenantId,
|
|
144
|
+
updatedAt: nowIso()
|
|
145
|
+
};
|
|
146
|
+
await fs.writeFile(fp, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeDeliveryMode(rawMode, { smtpConfigured }) {
|
|
151
|
+
const raw = String(rawMode ?? "").trim().toLowerCase();
|
|
152
|
+
if (!raw) return smtpConfigured ? "smtp" : "record";
|
|
153
|
+
if (raw === "record" || raw === "log" || raw === "smtp") return raw;
|
|
154
|
+
throw new Error("MAGIC_LINK_ONBOARDING_EMAIL_DELIVERY_MODE must be record|log|smtp");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function recipientsFromProfile(profile) {
|
|
158
|
+
const out = [];
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
const add = (emailRaw) => {
|
|
161
|
+
const email = normalizeEmailLower(emailRaw);
|
|
162
|
+
if (!email) return;
|
|
163
|
+
if (seen.has(email)) return;
|
|
164
|
+
seen.add(email);
|
|
165
|
+
out.push(email);
|
|
166
|
+
};
|
|
167
|
+
add(profile?.contactEmail ?? null);
|
|
168
|
+
add(profile?.billingEmail ?? null);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function evaluateSteps({ profile }) {
|
|
173
|
+
const list = [];
|
|
174
|
+
for (const step of ONBOARDING_EMAIL_STEPS) {
|
|
175
|
+
const triggerAt = step.trigger(profile);
|
|
176
|
+
list.push({
|
|
177
|
+
stepKey: step.stepKey,
|
|
178
|
+
label: step.label,
|
|
179
|
+
triggerAt: triggerAt && String(triggerAt).trim() !== "" ? String(triggerAt).trim() : null
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return list;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function buildOnboardingEmailSequenceStatus({ tenantId, profile, state, enabled, deliveryMode }) {
|
|
186
|
+
const evaluated = evaluateSteps({ profile });
|
|
187
|
+
const stepsState = isPlainObject(state?.steps) ? state.steps : {};
|
|
188
|
+
const steps = evaluated.map((row) => {
|
|
189
|
+
const sent = isPlainObject(stepsState[row.stepKey]) ? stepsState[row.stepKey] : null;
|
|
190
|
+
return {
|
|
191
|
+
stepKey: row.stepKey,
|
|
192
|
+
label: row.label,
|
|
193
|
+
eligible: Boolean(row.triggerAt),
|
|
194
|
+
triggerAt: row.triggerAt,
|
|
195
|
+
sentAt: sent && typeof sent.sentAt === "string" ? sent.sentAt : null
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
const totalSteps = steps.length;
|
|
199
|
+
const sentSteps = steps.filter((row) => typeof row.sentAt === "string" && row.sentAt).length;
|
|
200
|
+
const nextStep = steps.find((row) => row.eligible && !row.sentAt) ?? null;
|
|
201
|
+
return {
|
|
202
|
+
schemaVersion: "MagicLinkOnboardingEmailSequenceStatus.v1",
|
|
203
|
+
tenantId,
|
|
204
|
+
enabled: Boolean(enabled),
|
|
205
|
+
deliveryMode: deliveryMode ?? null,
|
|
206
|
+
totalSteps,
|
|
207
|
+
sentSteps,
|
|
208
|
+
completionPct: totalSteps > 0 ? Math.round((sentSteps / totalSteps) * 10000) / 100 : 0,
|
|
209
|
+
nextStepKey: nextStep ? nextStep.stepKey : null,
|
|
210
|
+
steps
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function dispatchOnboardingEmailSequenceBestEffort({
|
|
215
|
+
dataDir,
|
|
216
|
+
tenantId,
|
|
217
|
+
profile,
|
|
218
|
+
enabled = true,
|
|
219
|
+
deliveryMode = null,
|
|
220
|
+
smtpConfig = null,
|
|
221
|
+
publicBaseUrl = null
|
|
222
|
+
} = {}) {
|
|
223
|
+
if (!enabled) return { ok: true, skipped: true, reason: "disabled" };
|
|
224
|
+
if (!isPlainObject(profile)) return { ok: true, skipped: true, reason: "missing_profile" };
|
|
225
|
+
const t = String(tenantId ?? profile.tenantId ?? "").trim();
|
|
226
|
+
if (!t) return { ok: true, skipped: true, reason: "missing_tenant" };
|
|
227
|
+
|
|
228
|
+
const resolvedMode = normalizeDeliveryMode(deliveryMode, { smtpConfigured: Boolean(smtpConfig?.host && smtpConfig?.from) });
|
|
229
|
+
const recipients = recipientsFromProfile(profile);
|
|
230
|
+
if (recipients.length === 0) return { ok: true, skipped: true, reason: "no_recipients" };
|
|
231
|
+
|
|
232
|
+
const onboardingUrl = publicBaseUrl
|
|
233
|
+
? `${String(publicBaseUrl).replace(/\/+$/, "")}/v1/tenants/${encodeURIComponent(t)}/onboarding`
|
|
234
|
+
: `/v1/tenants/${encodeURIComponent(t)}/onboarding`;
|
|
235
|
+
const pricingUrl = publicBaseUrl ? `${String(publicBaseUrl).replace(/\/+$/, "")}/pricing` : "/pricing";
|
|
236
|
+
const tenantName = typeof profile?.name === "string" && profile.name.trim() ? profile.name.trim() : t;
|
|
237
|
+
|
|
238
|
+
const state = await loadOnboardingEmailSequenceStateBestEffort({ dataDir, tenantId: t });
|
|
239
|
+
const nextState = {
|
|
240
|
+
schemaVersion: ONBOARDING_EMAIL_SEQUENCE_VERSION,
|
|
241
|
+
tenantId: t,
|
|
242
|
+
steps: isPlainObject(state.steps) ? { ...state.steps } : {},
|
|
243
|
+
updatedAt: nowIso()
|
|
244
|
+
};
|
|
245
|
+
const dispatched = [];
|
|
246
|
+
|
|
247
|
+
for (const step of ONBOARDING_EMAIL_STEPS) {
|
|
248
|
+
const triggerAt = step.trigger(profile);
|
|
249
|
+
if (!triggerAt) continue;
|
|
250
|
+
const existing = isPlainObject(nextState.steps[step.stepKey]) ? nextState.steps[step.stepKey] : null;
|
|
251
|
+
if (existing && typeof existing.sentAt === "string" && existing.sentAt.trim() !== "") continue;
|
|
252
|
+
|
|
253
|
+
const sentAt = nowIso();
|
|
254
|
+
const subject = step.subject({ tenantId: t, tenantName, onboardingUrl, pricingUrl });
|
|
255
|
+
const text = step.text({ tenantId: t, tenantName, onboardingUrl, pricingUrl });
|
|
256
|
+
const deliveries = [];
|
|
257
|
+
|
|
258
|
+
for (const recipient of recipients) {
|
|
259
|
+
if (resolvedMode === "record") {
|
|
260
|
+
const fp = onboardingEmailOutboxPath({ dataDir, tenantId: t, stepKey: step.stepKey, recipient, sentAt });
|
|
261
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
262
|
+
await fs.writeFile(
|
|
263
|
+
fp,
|
|
264
|
+
JSON.stringify(
|
|
265
|
+
{
|
|
266
|
+
schemaVersion: "MagicLinkOnboardingEmailOutbox.v1",
|
|
267
|
+
tenantId: t,
|
|
268
|
+
stepKey: step.stepKey,
|
|
269
|
+
label: step.label,
|
|
270
|
+
triggerAt,
|
|
271
|
+
sentAt,
|
|
272
|
+
recipient,
|
|
273
|
+
subject,
|
|
274
|
+
text
|
|
275
|
+
},
|
|
276
|
+
null,
|
|
277
|
+
2
|
|
278
|
+
) + "\n",
|
|
279
|
+
"utf8"
|
|
280
|
+
);
|
|
281
|
+
deliveries.push({ ok: true, recipient, mode: "record", outboxPath: fp });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (resolvedMode === "log") {
|
|
285
|
+
// eslint-disable-next-line no-console
|
|
286
|
+
console.log(`onboarding-sequence tenant=${t} step=${step.stepKey} recipient=${recipient} subject=${subject}`);
|
|
287
|
+
deliveries.push({ ok: true, recipient, mode: "log" });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
await sendSmtpMail({
|
|
292
|
+
host: smtpConfig?.host,
|
|
293
|
+
port: smtpConfig?.port,
|
|
294
|
+
secure: Boolean(smtpConfig?.secure),
|
|
295
|
+
starttls: smtpConfig?.starttls === undefined ? true : Boolean(smtpConfig?.starttls),
|
|
296
|
+
auth: smtpConfig?.user && smtpConfig?.pass ? { user: smtpConfig.user, pass: smtpConfig.pass } : null,
|
|
297
|
+
from: smtpConfig?.from,
|
|
298
|
+
to: recipient,
|
|
299
|
+
subject,
|
|
300
|
+
text
|
|
301
|
+
});
|
|
302
|
+
deliveries.push({ ok: true, recipient, mode: "smtp" });
|
|
303
|
+
} catch (err) {
|
|
304
|
+
deliveries.push({ ok: false, recipient, mode: "smtp", error: err?.message ?? String(err ?? "smtp failed") });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!deliveries.some((row) => row.ok === true)) continue;
|
|
309
|
+
nextState.steps[step.stepKey] = {
|
|
310
|
+
sentAt,
|
|
311
|
+
triggerAt,
|
|
312
|
+
deliveryMode: resolvedMode,
|
|
313
|
+
recipients: deliveries.filter((row) => row.ok).map((row) => row.recipient)
|
|
314
|
+
};
|
|
315
|
+
dispatched.push({
|
|
316
|
+
stepKey: step.stepKey,
|
|
317
|
+
triggerAt,
|
|
318
|
+
sentAt,
|
|
319
|
+
deliveries
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const saved = await saveOnboardingEmailSequenceState({ dataDir, tenantId: t, state: nextState });
|
|
324
|
+
return {
|
|
325
|
+
ok: true,
|
|
326
|
+
tenantId: t,
|
|
327
|
+
deliveryMode: resolvedMode,
|
|
328
|
+
dispatched,
|
|
329
|
+
state: saved
|
|
330
|
+
};
|
|
331
|
+
}
|