Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,62 @@
import { describe, it, expect } from "vitest";
import { evaluateAllConditions, evaluateCondition, resolvePath } from "./conditions.js";
describe("resolvePath", () => {
it("walks nested objects", () => {
expect(resolvePath({ a: { b: { c: 1 } } }, "a.b.c")).toBe(1);
});
it("walks array indices", () => {
expect(resolvePath({ items: [{ name: "x" }, { name: "y" }] }, "items.1.name")).toBe("y");
});
it("returns undefined for missing path", () => {
expect(resolvePath({ a: 1 }, "a.b.c")).toBeUndefined();
});
it("returns undefined on empty path", () => {
expect(resolvePath({}, "")).toBeUndefined();
});
});
describe("evaluateCondition", () => {
const payload = { lead: { status: "qualified", tags: ["alpha", "beta"] }, count: 5 };
it("supports eq / neq", () => {
expect(evaluateCondition(payload, { path: "lead.status", op: "eq", value: "qualified" })).toBe(true);
expect(evaluateCondition(payload, { path: "lead.status", op: "neq", value: "qualified" })).toBe(false);
});
it("supports in / not_in with arrays", () => {
expect(evaluateCondition(payload, { path: "lead.status", op: "in", value: ["new", "qualified"] })).toBe(true);
expect(evaluateCondition(payload, { path: "lead.status", op: "not_in", value: ["new"] })).toBe(true);
expect(evaluateCondition(payload, { path: "lead.status", op: "not_in", value: ["qualified"] })).toBe(false);
});
it("supports exists / not_exists", () => {
expect(evaluateCondition(payload, { path: "lead.status", op: "exists" })).toBe(true);
expect(evaluateCondition(payload, { path: "lead.missing", op: "not_exists" })).toBe(true);
expect(evaluateCondition(payload, { path: "lead.status", op: "not_exists" })).toBe(false);
});
it("returns false for unknown op", () => {
expect(
evaluateCondition(payload, { path: "lead.status", op: "unknown" as unknown as "eq", value: "x" })
).toBe(false);
});
});
describe("evaluateAllConditions", () => {
it("returns true for undefined / empty list", () => {
expect(evaluateAllConditions({}, undefined)).toBe(true);
expect(evaluateAllConditions({}, [])).toBe(true);
});
it("AND-combines multiple conditions", () => {
const payload = { status: "qualified", plan: "pro" };
expect(
evaluateAllConditions(payload, [
{ path: "status", op: "eq", value: "qualified" },
{ path: "plan", op: "eq", value: "pro" },
])
).toBe(true);
expect(
evaluateAllConditions(payload, [
{ path: "status", op: "eq", value: "qualified" },
{ path: "plan", op: "eq", value: "starter" },
])
).toBe(false);
});
});
@@ -0,0 +1,80 @@
/**
* Condition-evaluator for the sequence step DSL.
*
* Pure function — no DB, no IO. The trigger handler builds the
* `context` payload (typically a copy of the outbox event plus
* resolved lead/tenant fields) and asks the engine whether the step
* should run.
*/
import type { SequenceStepCondition } from "./types.js";
/**
* Read a dot-path out of a JSON-like payload. Returns `undefined` when
* any intermediate segment is missing. Array-index access uses
* numeric segments (`items.0.name`).
*/
export function resolvePath(payload: Readonly<Record<string, unknown>>, path: string): unknown {
if (path.length === 0) return undefined;
const segments = path.split(".");
let cur: unknown = payload;
for (const seg of segments) {
if (cur === null || cur === undefined) return undefined;
if (Array.isArray(cur)) {
const idx = Number(seg);
if (!Number.isInteger(idx)) return undefined;
cur = cur[idx];
continue;
}
if (typeof cur === "object") {
cur = (cur as Record<string, unknown>)[seg];
continue;
}
return undefined;
}
return cur;
}
function asArray(v: SequenceStepCondition["value"]): readonly (string | number)[] {
if (Array.isArray(v)) return v;
if (v === undefined || v === null) return [];
return [v as string | number];
}
export function evaluateCondition(
payload: Readonly<Record<string, unknown>>,
cond: SequenceStepCondition
): boolean {
const actual = resolvePath(payload, cond.path);
switch (cond.op) {
case "eq":
return actual === cond.value;
case "neq":
return actual !== cond.value;
case "in":
return asArray(cond.value).includes(actual as string | number);
case "not_in":
return !asArray(cond.value).includes(actual as string | number);
case "exists":
return actual !== undefined && actual !== null;
case "not_exists":
return actual === undefined || actual === null;
default:
return false;
}
}
/**
* AND-evaluate every condition. Empty array → always true (no
* conditions means "always run").
*/
export function evaluateAllConditions(
payload: Readonly<Record<string, unknown>>,
conditions: readonly SequenceStepCondition[] | undefined
): boolean {
if (!conditions || conditions.length === 0) return true;
for (const c of conditions) {
if (!evaluateCondition(payload, c)) return false;
}
return true;
}
@@ -0,0 +1,198 @@
import { describe, it, expect, vi } from "vitest";
import {
startSequenceRun,
advanceSequenceRun,
type SequenceEngineStore,
type SequenceEngineQueue,
type SequenceRunRow,
} from "./engine.js";
import type { SequenceDefinition } from "./types.js";
function makeStore(initial: SequenceRunRow | null = null): SequenceEngineStore & {
createdDeliveries: Array<{ runId: string; stepIndex: number; scheduledAt: Date }>;
createdRuns: Array<{ sequenceId: string }>;
statusUpdates: Array<{ runId: string; status: string }>;
} {
const createdDeliveries: Array<{ runId: string; stepIndex: number; scheduledAt: Date }> = [];
const createdRuns: Array<{ sequenceId: string }> = [];
const statusUpdates: Array<{ runId: string; status: string }> = [];
return {
createdDeliveries,
createdRuns,
statusUpdates,
createRun: vi.fn(async ({ sequenceId, leadId, tenantId, triggerEventId }) => {
createdRuns.push({ sequenceId });
return {
id: `run_${createdRuns.length}`,
sequenceId,
leadId,
tenantId,
triggerEventId,
status: "running" as const,
startedAt: new Date("2026-05-17T12:00:00Z"),
completedAt: null,
stepStateJson: null,
};
}),
findRunByTrigger: vi.fn(async () => initial),
updateRunStatus: vi.fn(async (runId, status) => {
statusUpdates.push({ runId, status });
}),
createDelivery: vi.fn(async ({ runId, stepIndex, scheduledAt }) => {
createdDeliveries.push({ runId, stepIndex, scheduledAt });
return {
id: `del_${createdDeliveries.length}`,
runId,
stepIndex,
status: "pending" as const,
scheduledAt,
};
}),
};
}
function makeQueue(): SequenceEngineQueue & { scheduled: Array<{ runId: string; stepIndex: number; runAt: Date }> } {
const scheduled: Array<{ runId: string; stepIndex: number; runAt: Date }> = [];
return {
scheduled,
scheduleStep: vi.fn(async (args) => {
scheduled.push(args);
}),
};
}
const BASE_SEQUENCE: SequenceDefinition = {
id: "seq_1",
tenantId: null,
name: "Welcome",
description: null,
triggerEvent: "lead.captured",
isEnabled: true,
steps: [
{ stepIndex: 0, delayHours: 0, template: "welcome", subject: "Welcome" },
{ stepIndex: 1, delayHours: 24, template: "generic-step", subject: "Day 1" },
{ stepIndex: 2, delayHours: 72, template: "generic-step", subject: "Day 3" },
],
};
const TRIGGER = {
triggerEventId: "evt_1",
leadId: "lead_1",
tenantId: null,
email: "alice@example.com",
recipientName: "Alice",
payload: { lead: { status: "new" } },
} as const;
const NOW = () => new Date("2026-05-17T12:00:00Z");
describe("startSequenceRun", () => {
it("creates a run and schedules step 0 immediately when delayHours=0", async () => {
const store = makeStore();
const queue = makeQueue();
const result = await startSequenceRun({ sequence: BASE_SEQUENCE, trigger: TRIGGER }, { store, queue, now: NOW });
expect(result.scheduled).toBe(true);
expect(result.alreadyExisted).toBe(false);
expect(store.createdRuns).toHaveLength(1);
expect(store.createdDeliveries).toHaveLength(1);
expect(store.createdDeliveries[0]?.scheduledAt.toISOString()).toBe("2026-05-17T12:00:00.000Z");
expect(queue.scheduled).toHaveLength(1);
});
it("returns the existing run when (sequence, trigger-event-id) already produced one", async () => {
const existing: SequenceRunRow = {
id: "run_existing",
sequenceId: BASE_SEQUENCE.id,
leadId: "lead_1",
tenantId: null,
triggerEventId: "evt_1",
status: "running",
startedAt: NOW(),
completedAt: null,
stepStateJson: null,
};
const store = makeStore(existing);
const queue = makeQueue();
const result = await startSequenceRun({ sequence: BASE_SEQUENCE, trigger: TRIGGER }, { store, queue, now: NOW });
expect(result.alreadyExisted).toBe(true);
expect(store.createdRuns).toHaveLength(0);
expect(queue.scheduled).toHaveLength(0);
});
it("skips disabled sequences", async () => {
const store = makeStore();
const queue = makeQueue();
const result = await startSequenceRun(
{ sequence: { ...BASE_SEQUENCE, isEnabled: false }, trigger: TRIGGER },
{ store, queue, now: NOW }
);
expect(result.scheduled).toBe(false);
expect(store.createdRuns).toHaveLength(0);
});
it("skips when sequence-level conditions are false", async () => {
const store = makeStore();
const queue = makeQueue();
const result = await startSequenceRun(
{
sequence: {
...BASE_SEQUENCE,
conditions: [{ path: "lead.status", op: "eq", value: "qualified" }],
},
trigger: TRIGGER,
},
{ store, queue, now: NOW }
);
expect(result.scheduled).toBe(false);
expect(store.createdRuns).toHaveLength(0);
});
});
describe("advanceSequenceRun", () => {
it("schedules the next step with its delay-from-now", async () => {
const store = makeStore();
const queue = makeQueue();
const run: SequenceRunRow = {
id: "run_1",
sequenceId: "seq_1",
leadId: "lead_1",
tenantId: null,
triggerEventId: "evt_1",
status: "running",
startedAt: NOW(),
completedAt: null,
stepStateJson: null,
};
const result = await advanceSequenceRun(
{ run, steps: BASE_SEQUENCE.steps, previousStepIndex: 0 },
{ store, queue, now: NOW }
);
expect(result.completed).toBe(false);
expect(result.scheduledAt?.toISOString()).toBe("2026-05-18T12:00:00.000Z");
expect(queue.scheduled).toHaveLength(1);
expect(queue.scheduled[0]?.stepIndex).toBe(1);
});
it("completes the run when no further steps exist", async () => {
const store = makeStore();
const queue = makeQueue();
const run: SequenceRunRow = {
id: "run_1",
sequenceId: "seq_1",
leadId: "lead_1",
tenantId: null,
triggerEventId: "evt_1",
status: "running",
startedAt: NOW(),
completedAt: null,
stepStateJson: null,
};
const result = await advanceSequenceRun(
{ run, steps: BASE_SEQUENCE.steps, previousStepIndex: 2 },
{ store, queue, now: NOW }
);
expect(result.completed).toBe(true);
expect(store.statusUpdates).toEqual([{ runId: "run_1", status: "completed" }]);
expect(queue.scheduled).toHaveLength(0);
});
});
@@ -0,0 +1,196 @@
/**
* Sequence-execution engine.
*
* Two entry points:
* - `startSequenceRun()` — called by the trigger worker. Materialises
* an EmailSequenceRun row + a pending Step-0 delivery + schedules
* the first step.
* - `advanceSequenceRun()` — called by the step-runner after a step
* completes. Materialises the next step's delivery row + schedules
* it; flips the run to `completed` when no more steps remain.
*
* Storage + queue are injected so the engine stays testable.
*/
import type { SequenceDefinition, SequenceStep, SequenceTriggerContext } from "./types.js";
import { parseSequenceSteps } from "./types.js";
import { evaluateAllConditions } from "./conditions.js";
export type SequenceRunStatus = "pending" | "running" | "completed" | "cancelled" | "unsubscribed" | "failed";
export type StepDeliveryStatus = "pending" | "sent" | "skipped" | "bounced" | "failed";
export interface SequenceRunRow {
readonly id: string;
readonly sequenceId: string;
readonly leadId: string | null;
readonly tenantId: string | null;
readonly triggerEventId: string | null;
readonly status: SequenceRunStatus;
readonly startedAt: Date;
readonly completedAt: Date | null;
readonly stepStateJson: Readonly<Record<string, unknown>> | null;
}
export interface StepDeliveryRow {
readonly id: string;
readonly runId: string;
readonly stepIndex: number;
readonly status: StepDeliveryStatus;
readonly scheduledAt: Date;
}
/**
* Storage surface required by the engine. Implementations wrap Prisma
* (in the worker) or in-memory fakes (in tests).
*/
export interface SequenceEngineStore {
createRun(input: {
sequenceId: string;
leadId: string | null;
tenantId: string | null;
triggerEventId: string | null;
}): Promise<SequenceRunRow>;
/** Idempotent — returns the existing run if `(sequenceId,triggerEventId)` already exists. */
findRunByTrigger(sequenceId: string, triggerEventId: string): Promise<SequenceRunRow | null>;
updateRunStatus(runId: string, status: SequenceRunStatus, completedAt?: Date | null): Promise<void>;
createDelivery(input: { runId: string; stepIndex: number; scheduledAt: Date }): Promise<StepDeliveryRow>;
}
/**
* Queue surface — `scheduleStep` enqueues a delayed job that wakes up
* at `runAt` and invokes the step-runner.
*/
export interface SequenceEngineQueue {
scheduleStep(args: { runId: string; stepIndex: number; runAt: Date }): Promise<void>;
}
export interface SequenceEngineLogger {
info?: (record: Record<string, unknown>, message?: string) => void;
warn?: (record: Record<string, unknown>, message?: string) => void;
error: (record: Record<string, unknown>, message?: string) => void;
}
export interface SequenceEngineDeps {
readonly store: SequenceEngineStore;
readonly queue: SequenceEngineQueue;
readonly logger?: SequenceEngineLogger;
readonly now?: () => Date;
}
export interface StartSequenceRunArgs {
readonly sequence: SequenceDefinition;
readonly trigger: SequenceTriggerContext;
}
export interface StartSequenceRunResult {
readonly run: SequenceRunRow;
readonly firstDelivery: StepDeliveryRow | null;
readonly scheduled: boolean;
/** True when an existing run was returned instead of inserting a duplicate. */
readonly alreadyExisted: boolean;
}
/**
* Resolve the steps array, applying sequence-level enable + condition
* gating before producing the first delivery row.
*/
export async function startSequenceRun(
args: StartSequenceRunArgs,
deps: SequenceEngineDeps
): Promise<StartSequenceRunResult> {
const { sequence, trigger } = args;
if (!sequence.isEnabled) {
return emptyResult();
}
if (!evaluateAllConditions(trigger.payload, sequence.conditions)) {
deps.logger?.info?.(
{ op: "sequence.skipped.sequence_conditions", sequenceId: sequence.id },
"sequence-level conditions not met"
);
return emptyResult();
}
const steps = parseSequenceSteps(sequence.steps as unknown);
if (steps.length === 0) {
return emptyResult();
}
if (trigger.triggerEventId) {
const existing = await deps.store.findRunByTrigger(sequence.id, trigger.triggerEventId);
if (existing) {
return { run: existing, firstDelivery: null, scheduled: false, alreadyExisted: true };
}
}
const run = await deps.store.createRun({
sequenceId: sequence.id,
leadId: trigger.leadId ?? null,
tenantId: trigger.tenantId ?? null,
triggerEventId: trigger.triggerEventId ?? null,
});
const firstStep = steps[0]!;
const now = (deps.now ?? (() => new Date()))();
const scheduledAt = new Date(now.getTime() + firstStep.delayHours * 60 * 60 * 1000);
const delivery = await deps.store.createDelivery({
runId: run.id,
stepIndex: firstStep.stepIndex,
scheduledAt,
});
await deps.queue.scheduleStep({ runId: run.id, stepIndex: firstStep.stepIndex, runAt: scheduledAt });
deps.logger?.info?.(
{ op: "sequence.run.started", sequenceId: sequence.id, runId: run.id, stepIndex: firstStep.stepIndex },
"sequence run started"
);
return { run, firstDelivery: delivery, scheduled: true, alreadyExisted: false };
}
function emptyResult(): StartSequenceRunResult {
const placeholder: SequenceRunRow = {
id: "",
sequenceId: "",
leadId: null,
tenantId: null,
triggerEventId: null,
status: "cancelled",
startedAt: new Date(0),
completedAt: null,
stepStateJson: null,
};
return { run: placeholder, firstDelivery: null, scheduled: false, alreadyExisted: false };
}
export interface AdvanceArgs {
readonly run: SequenceRunRow;
readonly steps: readonly SequenceStep[];
readonly previousStepIndex: number;
}
export interface AdvanceResult {
readonly nextDelivery: StepDeliveryRow | null;
readonly scheduledAt: Date | null;
readonly completed: boolean;
}
/**
* After a step finishes (sent, skipped or bounced) move to the next.
* Marks the run completed when no further step exists.
*/
export async function advanceSequenceRun(args: AdvanceArgs, deps: SequenceEngineDeps): Promise<AdvanceResult> {
const { run, steps, previousStepIndex } = args;
const sorted = [...steps].sort((a, b) => a.stepIndex - b.stepIndex);
const nextIdx = sorted.findIndex((s) => s.stepIndex > previousStepIndex);
if (nextIdx === -1) {
await deps.store.updateRunStatus(run.id, "completed", (deps.now ?? (() => new Date()))());
deps.logger?.info?.({ op: "sequence.run.completed", runId: run.id }, "sequence run completed");
return { nextDelivery: null, scheduledAt: null, completed: true };
}
const nextStep = sorted[nextIdx]!;
const now = (deps.now ?? (() => new Date()))();
const scheduledAt = new Date(now.getTime() + nextStep.delayHours * 60 * 60 * 1000);
const delivery = await deps.store.createDelivery({
runId: run.id,
stepIndex: nextStep.stepIndex,
scheduledAt,
});
await deps.queue.scheduleStep({ runId: run.id, stepIndex: nextStep.stepIndex, runAt: scheduledAt });
return { nextDelivery: delivery, scheduledAt, completed: false };
}
@@ -0,0 +1,46 @@
/**
* Marketing-Funnel Phase 3 — email-sequence engine (Phase 05 #80).
*
* Pure-logic package: definitions, condition DSL, step engine, step runner.
* Storage + queue + mail-provider surfaces are injected by the worker so
* this package has zero runtime dependencies on Prisma or BullMQ.
*/
export * from "./types.js";
export {
resolvePath,
evaluateCondition,
evaluateAllConditions,
} from "./conditions.js";
export {
startSequenceRun,
advanceSequenceRun,
type SequenceEngineStore,
type SequenceEngineQueue,
type SequenceEngineDeps,
type SequenceEngineLogger,
type SequenceRunRow,
type SequenceRunStatus,
type StepDeliveryRow,
type StepDeliveryStatus,
type StartSequenceRunArgs,
type StartSequenceRunResult,
type AdvanceArgs,
type AdvanceResult,
} from "./engine.js";
export {
runStep,
type StepRunnerStore,
type StepRunnerDeps,
type StepRunResult,
type RenderedMail,
type MailSendResult,
} from "./step-runner.js";
export {
buildUnsubscribeUrl,
signUnsubscribeToken,
UnsubscribeTokenSecretMissingError,
verifyUnsubscribeToken,
type SignUnsubscribeTokenInput,
type UnsubscribeTokenPayload,
} from "./unsubscribe-token.js";
@@ -0,0 +1,174 @@
import { describe, it, expect, vi } from "vitest";
import { runStep, type StepRunnerStore, type StepRunnerDeps } from "./step-runner.js";
import type { SequenceStep, SequenceTriggerContext } from "./types.js";
import type { SequenceRunRow } from "./engine.js";
const NOW = () => new Date("2026-05-17T12:00:00Z");
function buildRun(overrides: Partial<SequenceRunRow> = {}): SequenceRunRow {
return {
id: "run_1",
sequenceId: "seq_1",
leadId: "lead_1",
tenantId: null,
triggerEventId: "evt_1",
status: "running",
startedAt: NOW(),
completedAt: null,
stepStateJson: null,
...overrides,
};
}
function buildStore(opts: {
run?: SequenceRunRow | null;
steps?: readonly SequenceStep[];
trigger?: SequenceTriggerContext | null;
}): StepRunnerStore & { delivered: unknown[]; statusUpdates: Array<{ runId: string; status: string }> } {
const delivered: unknown[] = [];
const statusUpdates: Array<{ runId: string; status: string }> = [];
return {
delivered,
statusUpdates,
loadRun: vi.fn(async () => opts.run ?? null),
loadSteps: vi.fn(async () => opts.steps ?? []),
loadTriggerContext: vi.fn(async () => opts.trigger ?? null),
markDelivery: vi.fn(async (input) => {
delivered.push(input);
}),
createRun: vi.fn(),
findRunByTrigger: vi.fn(async () => null),
updateRunStatus: vi.fn(async (runId, status) => {
statusUpdates.push({ runId, status });
}),
createDelivery: vi.fn(async ({ runId, stepIndex, scheduledAt }) => ({
id: `del_${stepIndex}`,
runId,
stepIndex,
status: "pending" as const,
scheduledAt,
})),
};
}
function buildDeps(store: StepRunnerStore, overrides: Partial<StepRunnerDeps> = {}): StepRunnerDeps {
return {
store,
queue: { scheduleStep: vi.fn() },
now: NOW,
renderTemplate: () => ({ html: "<p>x</p>", text: "x", subject: "Hello" }),
sendMail: async () => ({ ok: true, messageId: "msg_1" }),
buildUnsubscribeUrl: () => "https://app.example.com/unsubscribe/tok",
...overrides,
};
}
const STEPS: readonly SequenceStep[] = [
{ stepIndex: 0, delayHours: 0, template: "welcome", subject: "S0" },
{ stepIndex: 1, delayHours: 24, template: "generic-step", subject: "S1" },
];
const TRIGGER: SequenceTriggerContext = {
triggerEventId: "evt_1",
leadId: "lead_1",
tenantId: null,
email: "alice@example.com",
recipientName: "Alice",
payload: { lead: { status: "new" } },
};
describe("runStep", () => {
it("cancels when the run is not found", async () => {
const store = buildStore({ run: null });
const deps = buildDeps(store);
const result = await runStep({ runId: "run_1", stepIndex: 0 }, deps);
expect(result.action).toBe("cancelled");
});
it("skips when run is unsubscribed mid-sequence", async () => {
const store = buildStore({ run: buildRun({ status: "unsubscribed" }), steps: STEPS, trigger: TRIGGER });
const deps = buildDeps(store);
const result = await runStep({ runId: "run_1", stepIndex: 1 }, deps);
expect(result.action).toBe("cancelled");
expect(store.delivered).toHaveLength(0);
});
it("skips when step conditions evaluate false and advances", async () => {
const conditionalSteps: readonly SequenceStep[] = [
{ stepIndex: 0, delayHours: 0, template: "welcome", subject: "S0", conditions: [{ path: "lead.status", op: "eq", value: "qualified" }] },
{ stepIndex: 1, delayHours: 24, template: "generic-step", subject: "S1" },
];
const store = buildStore({ run: buildRun(), steps: conditionalSteps, trigger: TRIGGER });
const queueSchedule = vi.fn();
const deps = buildDeps(store, { queue: { scheduleStep: queueSchedule } });
const result = await runStep({ runId: "run_1", stepIndex: 0 }, deps);
expect(result.action).toBe("skipped");
expect(store.delivered).toEqual([{ runId: "run_1", stepIndex: 0, status: "skipped" }]);
expect(queueSchedule).toHaveBeenCalledOnce();
});
it("skips and advances without sending when trigger context is missing", async () => {
const store = buildStore({ run: buildRun(), steps: STEPS, trigger: null });
const sendMail = vi.fn();
const queueSchedule = vi.fn();
const deps = buildDeps(store, { sendMail, queue: { scheduleStep: queueSchedule } });
const result = await runStep({ runId: "run_1", stepIndex: 0 }, deps);
expect(result.action).toBe("skipped");
expect(result.completed).toBe(false);
expect(sendMail).not.toHaveBeenCalled();
expect(store.delivered).toEqual([
{
runId: "run_1",
stepIndex: 0,
status: "skipped",
errorMessage: "trigger_context_missing",
},
]);
expect(queueSchedule).toHaveBeenCalledOnce();
expect(queueSchedule.mock.calls[0]?.[0]).toMatchObject({ runId: "run_1", stepIndex: 1 });
});
it("sends the step and advances to the next when conditions pass", async () => {
const store = buildStore({ run: buildRun(), steps: STEPS, trigger: TRIGGER });
const sendMail = vi.fn().mockResolvedValue({ ok: true, messageId: "msg_42" });
const queueSchedule = vi.fn();
const deps = buildDeps(store, { sendMail, queue: { scheduleStep: queueSchedule } });
const result = await runStep({ runId: "run_1", stepIndex: 0 }, deps);
expect(result.action).toBe("sent");
expect(result.completed).toBe(false);
expect(sendMail).toHaveBeenCalledOnce();
expect(store.delivered[0]).toMatchObject({ status: "sent", providerMessageId: "msg_42" });
expect(queueSchedule).toHaveBeenCalledOnce();
expect(queueSchedule.mock.calls[0]?.[0].stepIndex).toBe(1);
});
it("marks run completed after the last step sends", async () => {
const store = buildStore({ run: buildRun(), steps: STEPS, trigger: TRIGGER });
const deps = buildDeps(store);
const result = await runStep({ runId: "run_1", stepIndex: 1 }, deps);
expect(result.action).toBe("sent");
expect(result.completed).toBe(true);
expect(store.statusUpdates).toEqual([{ runId: "run_1", status: "completed" }]);
});
it("marks delivery bounced + run failed on hard bounce", async () => {
const store = buildStore({ run: buildRun(), steps: STEPS, trigger: TRIGGER });
const sendMail = vi.fn().mockResolvedValue({ ok: false, bounced: true, errorTag: "hard_bounce" });
const deps = buildDeps(store, { sendMail });
const result = await runStep({ runId: "run_1", stepIndex: 0 }, deps);
expect(result.action).toBe("failed");
expect(result.completed).toBe(true);
expect(store.delivered[0]).toMatchObject({ status: "bounced", errorMessage: "hard_bounce" });
expect(store.statusUpdates).toEqual([{ runId: "run_1", status: "failed" }]);
});
it("marks delivery failed without bounce status when sendMail fails transiently", async () => {
const store = buildStore({ run: buildRun(), steps: STEPS, trigger: TRIGGER });
const sendMail = vi.fn().mockResolvedValue({ ok: false, errorTag: "timeout" });
const deps = buildDeps(store, { sendMail, logger: { error: vi.fn() } });
const result = await runStep({ runId: "run_1", stepIndex: 0 }, deps);
expect(result.action).toBe("failed");
expect(result.completed).toBe(false);
expect(store.delivered[0]).toMatchObject({ status: "failed", errorMessage: "timeout" });
});
});
@@ -0,0 +1,198 @@
/**
* Step-runner — invoked by the worker scheduler when a delayed
* `email-sequence.step` job wakes up.
*
* Responsibilities:
* 1. Load run + delivery rows.
* 2. Skip / cancel based on run status (unsubscribe / cancellation
* mid-sequence).
* 3. Evaluate per-step conditions against the cached payload.
* 4. Render template via injected `templateRenderer` → { html, text }.
* 5. Call injected `mailSender` to send the email with the
* List-Unsubscribe header. Persist providerMessageId.
* 6. Advance the run (next step or completion) via {@link advanceSequenceRun}.
*
* Storage + queue surfaces are injected so the runner is fully unit-testable.
*/
import type { SequenceRunRow, SequenceEngineStore, SequenceEngineQueue, SequenceEngineLogger, StepDeliveryStatus } from "./engine.js";
import { advanceSequenceRun } from "./engine.js";
import type { SequenceStep, SequenceTriggerContext } from "./types.js";
import { evaluateAllConditions } from "./conditions.js";
export interface RenderedMail {
readonly html: string;
readonly text: string;
readonly subject: string;
}
export interface MailSendResult {
readonly ok: boolean;
readonly messageId?: string;
readonly errorTag?: string;
readonly bounced?: boolean;
}
export interface StepRunnerStore extends SequenceEngineStore {
loadRun(runId: string): Promise<SequenceRunRow | null>;
loadSteps(runId: string): Promise<readonly SequenceStep[]>;
loadTriggerContext(runId: string): Promise<SequenceTriggerContext | null>;
/** Atomically transition the delivery row to a terminal status. */
markDelivery(args: {
runId: string;
stepIndex: number;
status: StepDeliveryStatus;
sentAt?: Date;
bouncedAt?: Date;
errorMessage?: string;
providerMessageId?: string;
}): Promise<void>;
}
export interface StepRunnerDeps {
readonly store: StepRunnerStore;
readonly queue: SequenceEngineQueue;
readonly logger?: SequenceEngineLogger;
readonly now?: () => Date;
readonly renderTemplate: (
step: SequenceStep,
trigger: SequenceTriggerContext,
runId: string
) => Promise<RenderedMail> | RenderedMail;
readonly sendMail: (input: {
to: string;
subject: string;
html: string;
text: string;
unsubscribeUrl: string;
listUnsubscribeMailto?: string;
}) => Promise<MailSendResult>;
readonly buildUnsubscribeUrl: (trigger: SequenceTriggerContext, runId: string) => string;
readonly buildUnsubscribeMailto?: (trigger: SequenceTriggerContext, runId: string) => string | undefined;
}
export interface StepRunResult {
readonly action: "sent" | "skipped" | "failed" | "cancelled";
readonly completed: boolean;
}
export async function runStep(
args: { runId: string; stepIndex: number },
deps: StepRunnerDeps
): Promise<StepRunResult> {
const run = await deps.store.loadRun(args.runId);
if (!run) {
deps.logger?.warn?.({ op: "sequence.step.run_missing", runId: args.runId }, "sequence run not found");
return { action: "cancelled", completed: true };
}
if (run.status === "cancelled" || run.status === "unsubscribed" || run.status === "completed" || run.status === "failed") {
deps.logger?.info?.(
{ op: "sequence.step.run_inactive", runId: args.runId, status: run.status },
"sequence run inactive — skip"
);
return { action: "cancelled", completed: true };
}
const steps = await deps.store.loadSteps(args.runId);
const step = steps.find((s) => s.stepIndex === args.stepIndex);
if (!step) {
deps.logger?.warn?.(
{ op: "sequence.step.unknown_step", runId: args.runId, stepIndex: args.stepIndex },
"step index out of range"
);
return { action: "cancelled", completed: true };
}
const trigger = await deps.store.loadTriggerContext(args.runId);
if (!trigger) {
deps.logger?.warn?.(
{ op: "sequence.step.no_trigger", runId: args.runId },
"trigger context missing — skip"
);
await deps.store.markDelivery({
runId: args.runId,
stepIndex: args.stepIndex,
status: "skipped",
errorMessage: "trigger_context_missing",
});
const adv = await advanceSequenceRun(
{ run, steps, previousStepIndex: args.stepIndex },
{ store: deps.store, queue: deps.queue, logger: deps.logger, now: deps.now }
);
return { action: "skipped", completed: adv.completed };
}
if (!evaluateAllConditions(trigger.payload, step.conditions)) {
deps.logger?.info?.(
{ op: "sequence.step.conditions_false", runId: args.runId, stepIndex: args.stepIndex },
"step conditions not met — skip"
);
await deps.store.markDelivery({
runId: args.runId,
stepIndex: args.stepIndex,
status: "skipped",
});
const adv = await advanceSequenceRun(
{ run, steps, previousStepIndex: args.stepIndex },
{ store: deps.store, queue: deps.queue, logger: deps.logger, now: deps.now }
);
return { action: "skipped", completed: adv.completed };
}
const rendered = await deps.renderTemplate(step, trigger, args.runId);
const unsubscribeUrl = deps.buildUnsubscribeUrl(trigger, args.runId);
const listUnsubscribeMailto = deps.buildUnsubscribeMailto?.(trigger, args.runId);
const result = await deps.sendMail({
to: trigger.email,
subject: rendered.subject,
html: rendered.html,
text: rendered.text,
unsubscribeUrl,
listUnsubscribeMailto,
});
const sentAt = (deps.now ?? (() => new Date()))();
if (result.ok) {
await deps.store.markDelivery({
runId: args.runId,
stepIndex: args.stepIndex,
status: "sent",
sentAt,
providerMessageId: result.messageId,
});
deps.logger?.info?.(
{ op: "sequence.step.sent", runId: args.runId, stepIndex: args.stepIndex, providerMessageId: result.messageId },
"sequence step sent"
);
const adv = await advanceSequenceRun(
{ run, steps, previousStepIndex: args.stepIndex },
{ store: deps.store, queue: deps.queue, logger: deps.logger, now: deps.now }
);
return { action: "sent", completed: adv.completed };
}
if (result.bounced) {
await deps.store.markDelivery({
runId: args.runId,
stepIndex: args.stepIndex,
status: "bounced",
bouncedAt: sentAt,
errorMessage: result.errorTag,
});
await deps.store.updateRunStatus(args.runId, "failed", sentAt);
deps.logger?.warn?.(
{ op: "sequence.step.bounced", runId: args.runId, stepIndex: args.stepIndex, errorTag: result.errorTag },
"sequence step bounced — run failed"
);
return { action: "failed", completed: true };
}
await deps.store.markDelivery({
runId: args.runId,
stepIndex: args.stepIndex,
status: "failed",
errorMessage: result.errorTag,
});
deps.logger?.error(
{ op: "sequence.step.failed", runId: args.runId, stepIndex: args.stepIndex, errorTag: result.errorTag },
"sequence step send failed"
);
return { action: "failed", completed: false };
}
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { parseSequenceSteps, parseConditions, SequenceValidationError } from "./types.js";
describe("parseSequenceSteps", () => {
it("parses a well-formed steps array", () => {
const steps = parseSequenceSteps([
{ stepIndex: 0, delayHours: 0, template: "welcome", subject: "Welcome" },
{ stepIndex: 1, delayHours: 24, template: "generic-step", subject: "Day 1" },
]);
expect(steps).toHaveLength(2);
expect(steps[0]?.delayHours).toBe(0);
});
it("rejects non-array input", () => {
expect(() => parseSequenceSteps({} as unknown)).toThrow(SequenceValidationError);
});
it("rejects negative delayHours", () => {
expect(() => parseSequenceSteps([{ stepIndex: 0, delayHours: -1, template: "x", subject: "y" }])).toThrow(
SequenceValidationError
);
});
it("rejects missing template", () => {
expect(() => parseSequenceSteps([{ stepIndex: 0, delayHours: 0, subject: "y" }])).toThrow(SequenceValidationError);
});
it("rejects missing subject", () => {
expect(() => parseSequenceSteps([{ stepIndex: 0, delayHours: 0, template: "welcome" }])).toThrow(
SequenceValidationError
);
});
it("defaults stepIndex to array index when missing", () => {
const steps = parseSequenceSteps([
{ delayHours: 0, template: "welcome", subject: "a" },
{ delayHours: 1, template: "welcome", subject: "b" },
]);
expect(steps[0]?.stepIndex).toBe(0);
expect(steps[1]?.stepIndex).toBe(1);
});
});
describe("parseConditions", () => {
it("returns undefined for undefined input", () => {
expect(parseConditions(undefined, "test")).toBeUndefined();
});
it("rejects unknown op", () => {
expect(() => parseConditions([{ path: "x", op: "bogus" }], "test")).toThrow(SequenceValidationError);
});
it("parses valid op", () => {
const conds = parseConditions([{ path: "a", op: "eq", value: 1 }], "test");
expect(conds?.[0]?.op).toBe("eq");
});
});
@@ -0,0 +1,165 @@
/**
* Marketing-Funnel Phase 3 email-sequence engine (Phase 05 #80).
*
* Types for the sequence-definition DSL stored in
* `EmailSequence.stepsJson`. Sequences are linear V1: step 0, 1, 2, ...
* Each step carries a delay-from-trigger so a sequence is reusable for
* any new lead/tenant without absolute-date math.
*/
export type SequenceTriggerEvent =
| "lead.captured"
| "lead.qualified"
| "lead.stale_60d"
| "lead.converted_to_trial"
| "lead.converted_to_customer"
| "tenant.signup"
| "tenant.trial_expiring"
| "tenant.cancelled"
| "custom";
/**
* Built-in template identifiers shipped by `@toolctrl/email-templates`.
* Operators can also point at a `custom:<id>` value when their tenant
* has a registry-stored override.
*/
export type SequenceStepTemplateId =
| "welcome"
| "trial-reminder"
| "re-activation"
| "generic-step";
export type SequenceStepConditionOperator =
| "eq"
| "neq"
| "in"
| "not_in"
| "exists"
| "not_exists";
export interface SequenceStepCondition {
/** Dot-path into the trigger payload (e.g. `lead.status`). */
readonly path: string;
readonly op: SequenceStepConditionOperator;
/** Comparator value. Ignored for `exists` / `not_exists`. */
readonly value?: string | number | boolean | readonly (string | number)[];
}
export interface SequenceStep {
/** Zero-based ordinal — used as the `EmailSequenceStepDelivery.stepIndex`. */
readonly stepIndex: number;
/**
* Hours to wait between trigger-time (step 0) or the previous step's
* sentAt (step 1+). `0` means "send immediately".
*/
readonly delayHours: number;
/** Template-id from the registry. */
readonly template: SequenceStepTemplateId | string;
/** Subject-line for the outbound email. Variables are substituted. */
readonly subject: string;
/**
* Free-form variable bag forwarded to the template. Keys like
* `headline`, `bodyParagraphs`, `ctaUrl` map to the named props on
* the corresponding template component.
*/
readonly variables?: Readonly<Record<string, unknown>>;
/** Per-step conditions ANDed together. */
readonly conditions?: readonly SequenceStepCondition[];
}
export interface SequenceDefinition {
readonly id: string;
readonly tenantId?: string | null;
readonly name: string;
readonly description?: string | null;
readonly triggerEvent: SequenceTriggerEvent | string;
readonly steps: readonly SequenceStep[];
readonly conditions?: readonly SequenceStepCondition[];
readonly isEnabled: boolean;
}
/**
* Recipient context passed by the trigger handler to the engine. The
* concrete fields are merged into the template-variable bag — the
* engine itself does not interpret them.
*/
export interface SequenceTriggerContext {
/** Outbox event-id from the originating trigger. */
readonly triggerEventId?: string | null;
/** Stable target id — Lead.id or Tenant.id depending on event type. */
readonly leadId?: string | null;
readonly tenantId?: string | null;
readonly email: string;
readonly recipientName?: string | null;
/** Free-form payload for condition evaluation. */
readonly payload: Readonly<Record<string, unknown>>;
}
export class SequenceValidationError extends Error {
public override readonly name = "SequenceValidationError";
public readonly code: "INVALID_STEPS" | "INVALID_STEP" | "INVALID_CONDITION";
constructor(message: string, code: SequenceValidationError["code"]) {
super(message);
this.code = code;
}
}
/**
* Validate + parse a `stepsJson` blob coming out of the DB. Throws
* {@link SequenceValidationError} when the shape does not match.
*/
export function parseSequenceSteps(raw: unknown): SequenceStep[] {
if (!Array.isArray(raw)) {
throw new SequenceValidationError("stepsJson must be an array", "INVALID_STEPS");
}
return raw.map((entry, idx) => {
if (!entry || typeof entry !== "object") {
throw new SequenceValidationError(`step ${idx} is not an object`, "INVALID_STEP");
}
const e = entry as Record<string, unknown>;
const stepIndex = typeof e.stepIndex === "number" ? e.stepIndex : idx;
if (typeof e.delayHours !== "number" || e.delayHours < 0) {
throw new SequenceValidationError(`step ${idx} delayHours must be a non-negative number`, "INVALID_STEP");
}
if (typeof e.template !== "string" || e.template.length === 0) {
throw new SequenceValidationError(`step ${idx} template must be a non-empty string`, "INVALID_STEP");
}
if (typeof e.subject !== "string" || e.subject.length === 0) {
throw new SequenceValidationError(`step ${idx} subject must be a non-empty string`, "INVALID_STEP");
}
const variables =
e.variables && typeof e.variables === "object"
? (e.variables as Record<string, unknown>)
: undefined;
const conditions = parseConditions(e.conditions, `step ${idx}`);
return {
stepIndex,
delayHours: e.delayHours,
template: e.template,
subject: e.subject,
variables,
conditions,
};
});
}
export function parseConditions(raw: unknown, context: string): SequenceStepCondition[] | undefined {
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw)) {
throw new SequenceValidationError(`${context} conditions must be an array`, "INVALID_CONDITION");
}
return raw.map((c, idx) => {
if (!c || typeof c !== "object") {
throw new SequenceValidationError(`${context} condition ${idx} not an object`, "INVALID_CONDITION");
}
const o = c as Record<string, unknown>;
if (typeof o.path !== "string" || o.path.length === 0) {
throw new SequenceValidationError(`${context} condition ${idx} path must be a string`, "INVALID_CONDITION");
}
const op = o.op as SequenceStepConditionOperator;
if (!["eq", "neq", "in", "not_in", "exists", "not_exists"].includes(op)) {
throw new SequenceValidationError(`${context} condition ${idx} op unknown: ${String(op)}`, "INVALID_CONDITION");
}
return { path: o.path, op, value: o.value as SequenceStepCondition["value"] };
});
}
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
buildUnsubscribeUrl,
signUnsubscribeToken,
UnsubscribeTokenSecretMissingError,
verifyUnsubscribeToken,
} from "./unsubscribe-token.js";
const SECRET = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
describe("signUnsubscribeToken", () => {
it("throws when no secret is configured", () => {
expect(() => signUnsubscribeToken({ runId: "run_1" })).toThrow(UnsubscribeTokenSecretMissingError);
});
it("throws when secret is too short", () => {
expect(() => signUnsubscribeToken({ runId: "run_1", secret: "short" })).toThrow(UnsubscribeTokenSecretMissingError);
});
it("requires runId", () => {
expect(() => signUnsubscribeToken({ runId: "" as unknown as string, secret: SECRET })).toThrow(TypeError);
});
it("returns a u1 payload signature string", () => {
const token = signUnsubscribeToken({ runId: "run_1", secret: SECRET });
expect(token.split(".")).toHaveLength(3);
expect(token.startsWith("u1.")).toBe(true);
});
it("uses MARKETING_TOKEN_SECRET when an env record is injected", () => {
const env = { MARKETING_TOKEN_SECRET: SECRET };
const token = signUnsubscribeToken({ runId: "run_1", env });
expect(verifyUnsubscribeToken(token, { env })).toMatchObject({ runId: "run_1" });
});
it("falls back to LEAD_CONVERSION_SECRET when an env record is injected", () => {
const env = { LEAD_CONVERSION_SECRET: SECRET };
const token = signUnsubscribeToken({ runId: "run_1", env });
expect(verifyUnsubscribeToken(token, { env })).toMatchObject({ runId: "run_1" });
});
});
describe("verifyUnsubscribeToken", () => {
it("returns null for null or empty input", () => {
expect(verifyUnsubscribeToken(null, { secret: SECRET })).toBeNull();
expect(verifyUnsubscribeToken("", { secret: SECRET })).toBeNull();
});
it("returns null when secret is missing", () => {
const token = signUnsubscribeToken({ runId: "run_1", secret: SECRET });
expect(verifyUnsubscribeToken(token)).toBeNull();
});
it("returns null on tampered signature", () => {
const token = signUnsubscribeToken({ runId: "run_1", secret: SECRET });
const tampered = `${token.slice(0, -2)}XX`;
expect(verifyUnsubscribeToken(tampered, { secret: SECRET })).toBeNull();
});
it("returns null on tampered payload", () => {
const token = signUnsubscribeToken({ runId: "run_1", secret: SECRET });
const parts = token.split(".");
const tamperedPayload = Buffer.from(JSON.stringify({ runId: "run_2", iat: 0 }), "utf8").toString("base64url");
const tampered = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
expect(verifyUnsubscribeToken(tampered, { secret: SECRET })).toBeNull();
});
it("returns null on wrong version prefix", () => {
const token = signUnsubscribeToken({ runId: "run_1", secret: SECRET });
const tampered = token.replace("u1.", "u2.");
expect(verifyUnsubscribeToken(tampered, { secret: SECRET })).toBeNull();
});
it("returns the payload on a valid token", () => {
const token = signUnsubscribeToken({ runId: "run_1", leadId: "lead_1", tenantId: "tenant_1", secret: SECRET });
expect(verifyUnsubscribeToken(token, { secret: SECRET })).toMatchObject({
runId: "run_1",
leadId: "lead_1",
tenantId: "tenant_1",
});
});
it("returns null when verifying with a different secret", () => {
const token = signUnsubscribeToken({ runId: "run_1", secret: SECRET });
expect(verifyUnsubscribeToken(token, { secret: "f".repeat(64) })).toBeNull();
});
});
describe("buildUnsubscribeUrl", () => {
it("builds an absolute url with the signed token", () => {
const url = buildUnsubscribeUrl({
baseUrl: "https://app.example.com",
runId: "run_1",
secret: SECRET,
});
expect(url.startsWith("https://app.example.com/unsubscribe/u1.")).toBe(true);
});
it("strips trailing slashes from the base url", () => {
const url = buildUnsubscribeUrl({
baseUrl: "https://app.example.com/",
runId: "run_1",
secret: SECRET,
});
expect(url.startsWith("https://app.example.com/unsubscribe/u1.")).toBe(true);
});
});
@@ -0,0 +1,139 @@
/**
* Shared unsubscribe-token helper for marketing sequence mails.
*
* Sequence emails carry `/unsubscribe/<token>` links. The token is the
* authentication boundary for one-click unsubscribe, so web routes and worker
* mail producers must sign and verify the same payload shape.
*/
import { createHmac, timingSafeEqual } from "node:crypto";
type EnvRecord = Record<string, string | undefined>;
export interface UnsubscribeTokenPayload {
/** EmailSequenceRun.id this token unsubscribes from. */
runId: string;
/** Lead.id (may be null for tenant-scoped sequences). */
leadId: string | null;
/** Tenant.id (may be null for lead-scoped sequences). */
tenantId: string | null;
/** Issued-at unix seconds, used for debugging only. */
iat: number;
}
export interface SignUnsubscribeTokenInput {
runId: string;
leadId?: string | null;
tenantId?: string | null;
now?: () => Date;
secret?: string;
env?: EnvRecord;
}
export interface VerifyUnsubscribeTokenOptions {
secret?: string;
env?: EnvRecord;
}
export class UnsubscribeTokenSecretMissingError extends Error {
override readonly name = "UnsubscribeTokenSecretMissingError";
constructor() {
super("MARKETING_TOKEN_SECRET env var is not set; cannot sign or verify unsubscribe tokens.");
}
}
const TOKEN_VERSION = "u1";
const MIN_SECRET_BYTES = 32;
function getSecret(override?: string, env?: EnvRecord): Buffer {
const raw = override ?? env?.MARKETING_TOKEN_SECRET ?? env?.LEAD_CONVERSION_SECRET;
if (!raw || raw.length === 0) throw new UnsubscribeTokenSecretMissingError();
if (raw.length < MIN_SECRET_BYTES) throw new UnsubscribeTokenSecretMissingError();
return Buffer.from(raw, "utf8");
}
function b64uEncode(input: Buffer | string): string {
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input, "utf8");
return buf.toString("base64url");
}
function b64uDecode(input: string): Buffer | null {
if (!/^[A-Za-z0-9_-]+$/u.test(input)) return null;
try {
return Buffer.from(input, "base64url");
} catch {
return null;
}
}
function nowSec(now?: () => Date): number {
return Math.floor((now ? now() : new Date()).getTime() / 1000);
}
export function signUnsubscribeToken(input: SignUnsubscribeTokenInput): string {
if (!input.runId || typeof input.runId !== "string") {
throw new TypeError("signUnsubscribeToken: runId required");
}
const secret = getSecret(input.secret, input.env);
const payload: UnsubscribeTokenPayload = {
runId: input.runId,
leadId: input.leadId ?? null,
tenantId: input.tenantId ?? null,
iat: nowSec(input.now),
};
const payloadB64 = b64uEncode(JSON.stringify(payload));
const sig = createHmac("sha256", secret).update(`${TOKEN_VERSION}.${payloadB64}`).digest();
return `${TOKEN_VERSION}.${payloadB64}.${b64uEncode(sig)}`;
}
export function verifyUnsubscribeToken(
token: string | null | undefined,
options: VerifyUnsubscribeTokenOptions = {}
): UnsubscribeTokenPayload | null {
if (!token || typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length !== 3) return null;
const [version, payloadB64, sigB64] = parts;
if (version !== TOKEN_VERSION) return null;
let secret: Buffer;
try {
secret = getSecret(options.secret, options.env);
} catch {
return null;
}
const expected = createHmac("sha256", secret).update(`${version}.${payloadB64}`).digest();
const actual = b64uDecode(sigB64);
if (!actual || actual.length !== expected.length) return null;
if (!timingSafeEqual(actual, expected)) return null;
const payloadBuf = b64uDecode(payloadB64);
if (!payloadBuf) return null;
let parsed: unknown;
try {
parsed = JSON.parse(payloadBuf.toString("utf8"));
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const rec = parsed as Record<string, unknown>;
if (typeof rec.runId !== "string" || rec.runId.length === 0) return null;
return {
runId: rec.runId,
leadId: typeof rec.leadId === "string" ? rec.leadId : null,
tenantId: typeof rec.tenantId === "string" ? rec.tenantId : null,
iat: typeof rec.iat === "number" ? rec.iat : 0,
};
}
export function buildUnsubscribeUrl(args: {
baseUrl: string;
runId: string;
leadId?: string | null;
tenantId?: string | null;
secret?: string;
now?: () => Date;
}): string {
const token = signUnsubscribeToken(args);
const base = args.baseUrl.replace(/\/+$/, "");
return `${base}/unsubscribe/${token}`;
}