@hogsend/engine 0.13.2 → 0.16.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.13.2",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,14 +40,14 @@
40
40
  "svix": "^1.95.1",
41
41
  "winston": "^3.19.0",
42
42
  "zod": "^4.4.3",
43
- "@hogsend/core": "^0.13.2",
44
- "@hogsend/plugin-posthog": "^0.13.2",
45
- "@hogsend/db": "^0.13.2",
46
- "@hogsend/plugin-resend": "^0.13.2",
47
- "@hogsend/email": "^0.13.2"
43
+ "@hogsend/core": "^0.16.0",
44
+ "@hogsend/db": "^0.16.0",
45
+ "@hogsend/email": "^0.16.0",
46
+ "@hogsend/plugin-posthog": "^0.16.0",
47
+ "@hogsend/plugin-resend": "^0.16.0"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@hogsend/plugin-postmark": "^0.13.2"
50
+ "@hogsend/plugin-postmark": "^0.16.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.15.3",
@@ -53,6 +53,41 @@ export const posthogDestination = defineDestination({
53
53
  userEmail?: string | null;
54
54
  };
55
55
  const distinctId = data.userId ?? data.to ?? data.userEmail ?? undefined;
56
+ // `email.action` is the semantic-link envelope: the CONSUMER's event name
57
+ // (data.event, e.g. "nps.submitted") is what PostHog should capture, with
58
+ // the author's properties flattened to the top level. Other catalog events
59
+ // capture under their spine name (with the optional remap).
60
+ if (envelope.type === "email.action") {
61
+ const action = envelope.data as {
62
+ event: string;
63
+ properties: Record<string, unknown> | null;
64
+ emailSendId: string;
65
+ templateKey: string | null;
66
+ linkId: string;
67
+ linkUrl: string;
68
+ to: string;
69
+ userId: string | null;
70
+ at: string;
71
+ };
72
+ return {
73
+ url: `${host}/capture/`,
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json" },
76
+ body: JSON.stringify({
77
+ api_key: config.apiKey,
78
+ event: action.event,
79
+ distinct_id: distinctId,
80
+ timestamp: envelope.timestamp,
81
+ properties: {
82
+ ...(action.properties ?? {}),
83
+ emailSendId: action.emailSendId,
84
+ templateKey: action.templateKey,
85
+ linkId: action.linkId,
86
+ $lib: "hogsend",
87
+ },
88
+ }),
89
+ };
90
+ }
56
91
  // Optional event-name remap (identity by default).
57
92
  const eventName = config.eventNames?.[envelope.type] ?? envelope.type;
58
93
  return {
package/src/env.ts CHANGED
@@ -141,6 +141,13 @@ export const env = createEnv({
141
141
  // Default OFF to avoid a surprise double-emit alongside the existing
142
142
  // fire-and-forget PostHog capture path.
143
143
  ENABLE_POSTHOG_DESTINATION: z.coerce.boolean().default(false),
144
+ /**
145
+ * Append a short-lived signed identity token (`hs_t`) to tracked-link
146
+ * redirect destinations, so the landing site can stitch the email click
147
+ * to its web session (`POST /v1/t/identify` + posthog.identify). Opt-in:
148
+ * it changes outbound URLs, which can break pre-signed destinations.
149
+ */
150
+ TRACKING_IDENTITY_TOKEN: z.coerce.boolean().default(false),
144
151
  RESEND_WEBHOOK_SECRET: z.string().min(1).optional(),
145
152
  ADMIN_API_KEY: z.string().min(1).optional(),
146
153
  API_PUBLIC_URL: z.string().url().default("http://localhost:3002"),
package/src/index.ts CHANGED
@@ -205,6 +205,12 @@ export { checkEmailPreferences } from "./lib/enrollment-guards.js";
205
205
  export { isFrequencyCapped } from "./lib/frequency-cap.js";
206
206
  export { addrSpecOf, hostOfFromAddress } from "./lib/from-address.js";
207
207
  export { hatchet } from "./lib/hatchet.js";
208
+ export {
209
+ generateIdentityToken,
210
+ type IdentityTokenPayload,
211
+ InvalidIdentityTokenError,
212
+ validateIdentityToken,
213
+ } from "./lib/identity-token.js";
208
214
  // --- Ingestion pipeline ---
209
215
  export {
210
216
  type IngestEvent,
@@ -229,6 +235,13 @@ export {
229
235
  } from "./lib/redis.js";
230
236
  // --- Self-service password reset (engine-owned, self-contained email) ---
231
237
  export { sendResetPasswordEmail } from "./lib/reset-email.js";
238
+ export {
239
+ type ConfirmSemanticClickInput,
240
+ type ConfirmSemanticClickResult,
241
+ confirmSemanticClick,
242
+ SEMANTIC_BURST_DISTINCT_LINKS,
243
+ SEMANTIC_BURST_WINDOW_MS,
244
+ } from "./lib/semantic-click.js";
232
245
  export { type MountStudioResult, mountStudio } from "./lib/studio.js";
233
246
  export {
234
247
  type ResolveTimezoneInput,
@@ -1,9 +1,12 @@
1
1
  import type { JsonValue } from "@hatchet-dev/typescript-sdk/v1/types.js";
2
- import { evaluatePropertyConditions } from "@hogsend/core";
2
+ import { criteriaBuilder, evaluatePropertyConditions } from "@hogsend/core";
3
3
  import type {
4
4
  JourneyMeta,
5
+ JourneyMetaInput,
5
6
  JourneyRunFn,
6
7
  JourneyUser,
8
+ JourneyWhere,
9
+ PropertyCondition,
7
10
  } from "@hogsend/core/types";
8
11
  import { contacts, journeyConfigs, journeyStates } from "@hogsend/db";
9
12
  import { and, eq, inArray, notInArray } from "drizzle-orm";
@@ -37,11 +40,45 @@ export interface DefinedJourney {
37
40
  task: ReturnType<typeof hatchet.durableTask>;
38
41
  }
39
42
 
43
+ /**
44
+ * Resolve a builder-function `where` to its `PropertyCondition[]` — a
45
+ * one-shot, definition-time call mirroring `defineBucket`'s criteria
46
+ * normalization. A declarative array passes straight through, so existing
47
+ * journeys are unaffected; downstream (registry parse, checkExits, admin
48
+ * routes, Studio) only ever sees plain data.
49
+ */
50
+ function normalizeWhere(
51
+ where: JourneyWhere | undefined,
52
+ ): PropertyCondition[] | undefined {
53
+ if (typeof where !== "function") return where;
54
+ const resolved = where(criteriaBuilder);
55
+ return Array.isArray(resolved) ? resolved : [resolved];
56
+ }
57
+
40
58
  export function defineJourney(options: {
41
- meta: JourneyMeta;
59
+ meta: JourneyMetaInput;
42
60
  run: JourneyRunFn;
43
61
  }): DefinedJourney {
44
- const { meta } = options;
62
+ const { trigger, exitOn, ...rest } = options.meta;
63
+ const triggerWhere = normalizeWhere(trigger.where);
64
+ const meta: JourneyMeta = {
65
+ ...rest,
66
+ trigger: {
67
+ event: trigger.event,
68
+ ...(triggerWhere ? { where: triggerWhere } : {}),
69
+ },
70
+ ...(exitOn
71
+ ? {
72
+ exitOn: exitOn.map((exit) => {
73
+ const exitWhere = normalizeWhere(exit.where);
74
+ return {
75
+ event: exit.event,
76
+ ...(exitWhere ? { where: exitWhere } : {}),
77
+ };
78
+ }),
79
+ }
80
+ : {}),
81
+ };
45
82
 
46
83
  const task = hatchet.durableTask({
47
84
  name: `journey-${meta.id}`,
@@ -22,11 +22,17 @@ import type {
22
22
  IfPast,
23
23
  JourneyContext,
24
24
  TimeOfDayBuilder,
25
+ WaitForEventResult,
25
26
  Weekday,
26
27
  WhenBuilder,
27
28
  } from "@hogsend/core/types";
28
- import { type Database, emailSends, journeyStates } from "@hogsend/db";
29
- import { and, count, eq, max, notInArray } from "drizzle-orm";
29
+ import {
30
+ type Database,
31
+ emailSends,
32
+ journeyStates,
33
+ userEvents,
34
+ } from "@hogsend/db";
35
+ import { and, count, desc, eq, gte, max, notInArray } from "drizzle-orm";
30
36
  import { checkEmailPreferences } from "../lib/enrollment-guards.js";
31
37
  import { ingestEvent } from "../lib/ingestion.js";
32
38
  import type { Logger } from "../lib/logger.js";
@@ -206,7 +212,8 @@ export function createJourneyContext(
206
212
  event: string,
207
213
  timeout: DurationObject,
208
214
  nodeId: string,
209
- ): Promise<{ timedOut: boolean }> => {
215
+ lookback?: DurationObject,
216
+ ): Promise<WaitForEventResult> => {
210
217
  // Reject a timeout longer than the journey task's executionTimeout up front
211
218
  // so it fails fast at authoring time. (Eviction-capable engines may allow
212
219
  // longer wall-clock waits, but we cap to the configured ceiling — raise
@@ -217,6 +224,41 @@ export function createJourneyContext(
217
224
  );
218
225
  }
219
226
 
227
+ // Optional lookback: the durable wait only matches events pushed AFTER it
228
+ // is established, so an answer landing in the gap (between a send and its
229
+ // wait, or between two back-to-back waits) would otherwise be permanently
230
+ // unobservable — its first-answer idempotency key is already claimed and
231
+ // can never re-push. A recent matching user_events row resolves the wait
232
+ // immediately, payload included.
233
+ if (lookback) {
234
+ const since = new Date(Date.now() - durationToMs(lookback));
235
+ const recent = await db
236
+ .select({ properties: userEvents.properties })
237
+ .from(userEvents)
238
+ .where(
239
+ and(
240
+ eq(userEvents.userId, userId),
241
+ eq(userEvents.event, event),
242
+ gte(userEvents.occurredAt, since),
243
+ ),
244
+ )
245
+ .orderBy(desc(userEvents.occurredAt))
246
+ .limit(1);
247
+ const row = recent[0];
248
+ if (row) {
249
+ const scalars = Object.fromEntries(
250
+ Object.entries(row.properties ?? {}).filter(
251
+ ([, v]) =>
252
+ typeof v === "string" ||
253
+ typeof v === "number" ||
254
+ typeof v === "boolean" ||
255
+ v === null,
256
+ ),
257
+ ) as NonNullable<WaitForEventResult["properties"]>;
258
+ return { timedOut: false, properties: scalars };
259
+ }
260
+ }
261
+
220
262
  await enterWait(nodeId);
221
263
 
222
264
  // Wait for the user-scoped event or the timeout. The event branch filters on
@@ -241,9 +283,34 @@ export function createJourneyContext(
241
283
  {}) as Record<string, unknown>;
242
284
  const timedOut = !("event" in fired);
243
285
 
286
+ // Surface the matched event's payload (best-effort). The engine returns
287
+ // matches as `[{ id, data }]` where `data` is the pushed ingest payload
288
+ // ({ userId, userEmail, properties }); the pre-eviction path may hand the
289
+ // payload back un-wrapped — tolerate both, mirroring the CREATE-strip.
290
+ let properties: WaitForEventResult["properties"];
291
+ if (!timedOut) {
292
+ const matches = fired.event;
293
+ const first = Array.isArray(matches) ? matches[0] : matches;
294
+ const payload =
295
+ first && typeof first === "object" && "data" in first
296
+ ? (first as { data?: unknown }).data
297
+ : first;
298
+ const candidate =
299
+ payload && typeof payload === "object" && "properties" in payload
300
+ ? (payload as { properties?: unknown }).properties
301
+ : undefined;
302
+ if (
303
+ candidate &&
304
+ typeof candidate === "object" &&
305
+ !Array.isArray(candidate)
306
+ ) {
307
+ properties = candidate as NonNullable<WaitForEventResult["properties"]>;
308
+ }
309
+ }
310
+
244
311
  await resumeFromWait();
245
312
 
246
- return { timedOut };
313
+ return { timedOut, ...(properties ? { properties } : {}) };
247
314
  };
248
315
 
249
316
  return {
@@ -275,11 +342,12 @@ export function createJourneyContext(
275
342
  );
276
343
  },
277
344
 
278
- async waitForEvent({ event, timeout, label }) {
345
+ async waitForEvent({ event, timeout, label, lookback }) {
279
346
  return performWaitForEvent(
280
347
  event,
281
348
  timeout,
282
349
  label ?? `wait-event:${event}`,
350
+ lookback,
283
351
  );
284
352
  },
285
353
 
@@ -0,0 +1,112 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHash,
5
+ randomBytes,
6
+ } from "node:crypto";
7
+
8
+ /**
9
+ * Short-lived identity token appended to tracked-link redirects as `hs_t`
10
+ * (opt-in via TRACKING_IDENTITY_TOKEN). The landing site exchanges it at
11
+ * `POST /v1/t/identify` for the distinct id and calls `posthog.identify` —
12
+ * stitching the email click to the web session.
13
+ *
14
+ * ENCRYPTED (AES-256-GCM keyed off BETTER_AUTH_SECRET), not merely signed:
15
+ * the distinct id can fall back to an email address, and a signed-but-
16
+ * readable token would put a base64-decodable email in the URL — into
17
+ * browser history, referrers, and any script on the landing page. The GCM
18
+ * auth tag also covers integrity, so tampering fails decryption.
19
+ */
20
+
21
+ export interface IdentityTokenPayload {
22
+ /** The distinct id the landing site should identify as. */
23
+ distinctId: string;
24
+ emailSendId: string;
25
+ exp: number;
26
+ }
27
+
28
+ export class InvalidIdentityTokenError extends Error {
29
+ constructor(message: string) {
30
+ super(message);
31
+ this.name = "InvalidIdentityTokenError";
32
+ }
33
+ }
34
+
35
+ const DEFAULT_EXPIRY_SECONDS = 60 * 60; // 1 hour — a click-to-landing hop
36
+ const IV_LENGTH = 12;
37
+ const TAG_LENGTH = 16;
38
+
39
+ function deriveKey(secret: string): Buffer {
40
+ return createHash("sha256").update(secret).digest();
41
+ }
42
+
43
+ export function generateIdentityToken(opts: {
44
+ secret: string;
45
+ distinctId: string;
46
+ emailSendId: string;
47
+ expiresInSeconds?: number;
48
+ }): string {
49
+ const payload: IdentityTokenPayload = {
50
+ distinctId: opts.distinctId,
51
+ emailSendId: opts.emailSendId,
52
+ exp:
53
+ Math.floor(Date.now() / 1000) +
54
+ (opts.expiresInSeconds ?? DEFAULT_EXPIRY_SECONDS),
55
+ };
56
+ const iv = randomBytes(IV_LENGTH);
57
+ const cipher = createCipheriv("aes-256-gcm", deriveKey(opts.secret), iv);
58
+ const ciphertext = Buffer.concat([
59
+ cipher.update(JSON.stringify(payload), "utf-8"),
60
+ cipher.final(),
61
+ ]);
62
+ return Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString(
63
+ "base64url",
64
+ );
65
+ }
66
+
67
+ export function validateIdentityToken(opts: {
68
+ token: string;
69
+ secret: string;
70
+ }): IdentityTokenPayload {
71
+ let raw: Buffer;
72
+ try {
73
+ raw = Buffer.from(opts.token, "base64url");
74
+ } catch {
75
+ throw new InvalidIdentityTokenError("Malformed token");
76
+ }
77
+ if (raw.length <= IV_LENGTH + TAG_LENGTH) {
78
+ throw new InvalidIdentityTokenError("Malformed token");
79
+ }
80
+
81
+ const iv = raw.subarray(0, IV_LENGTH);
82
+ const ciphertext = raw.subarray(IV_LENGTH, raw.length - TAG_LENGTH);
83
+ const tag = raw.subarray(raw.length - TAG_LENGTH);
84
+
85
+ let payload: IdentityTokenPayload;
86
+ try {
87
+ const decipher = createDecipheriv(
88
+ "aes-256-gcm",
89
+ deriveKey(opts.secret),
90
+ iv,
91
+ );
92
+ decipher.setAuthTag(tag);
93
+ const plaintext = Buffer.concat([
94
+ decipher.update(ciphertext),
95
+ decipher.final(),
96
+ ]).toString("utf-8");
97
+ payload = JSON.parse(plaintext);
98
+ } catch {
99
+ throw new InvalidIdentityTokenError("Bad token");
100
+ }
101
+
102
+ if (
103
+ typeof payload.distinctId !== "string" ||
104
+ typeof payload.exp !== "number"
105
+ ) {
106
+ throw new InvalidIdentityTokenError("Invalid payload shape");
107
+ }
108
+ if (payload.exp < Math.floor(Date.now() / 1000)) {
109
+ throw new InvalidIdentityTokenError("Token expired");
110
+ }
111
+ return payload;
112
+ }
@@ -68,6 +68,7 @@ export async function ingestEvent(opts: {
68
68
 
69
69
  // (2) Idempotency dedup + `user_events` insert keyed on the resolved key, with
70
70
  // ONLY eventProperties in the properties bag (D2).
71
+ let idempotentInsertId: string | undefined;
71
72
  if (event.idempotencyKey) {
72
73
  const result = await db
73
74
  .insert(userEvents)
@@ -86,6 +87,7 @@ export async function ingestEvent(opts: {
86
87
  if (result.length === 0) {
87
88
  return { stored: false, exits: [] };
88
89
  }
90
+ idempotentInsertId = result[0]?.id;
89
91
  } else {
90
92
  await db.insert(userEvents).values({
91
93
  userId: resolvedKey,
@@ -109,7 +111,13 @@ export async function ingestEvent(opts: {
109
111
 
110
112
  // (4) Hatchet push + (5) checkExits, both keyed on the resolved key. The push
111
113
  // payload wire key STAYS `properties` (bucket tests assert on it — risk 9).
112
- const [, exits] = await Promise.all([
114
+ //
115
+ // An idempotency claim must not outlive a FAILED publish: journeys were never
116
+ // notified, and the consumed key would make every retry a silent no-op (the
117
+ // event becomes permanently invisible to journeys/destinations). So on a push
118
+ // failure the just-inserted row is compensating-deleted before rethrowing —
119
+ // the caller's retry (same key) can then re-claim and re-publish.
120
+ const [pushResult, exitsResult] = await Promise.allSettled([
113
121
  hatchet.events.push(event.event, {
114
122
  userId: resolvedKey,
115
123
  userEmail: event.userEmail ?? "",
@@ -121,6 +129,29 @@ export async function ingestEvent(opts: {
121
129
  properties: event.eventProperties,
122
130
  }),
123
131
  ]);
132
+ if (pushResult.status === "rejected") {
133
+ if (idempotentInsertId) {
134
+ try {
135
+ await db
136
+ .delete(userEvents)
137
+ .where(eq(userEvents.id, idempotentInsertId));
138
+ } catch (cleanupErr) {
139
+ logger.warn("ingestEvent: failed to roll back idempotency claim", {
140
+ event: event.event,
141
+ idempotencyKey: event.idempotencyKey,
142
+ error:
143
+ cleanupErr instanceof Error
144
+ ? cleanupErr.message
145
+ : String(cleanupErr),
146
+ });
147
+ }
148
+ }
149
+ throw pushResult.reason;
150
+ }
151
+ if (exitsResult.status === "rejected") {
152
+ throw exitsResult.reason;
153
+ }
154
+ const exits = exitsResult.value;
124
155
 
125
156
  // (6) Real-time bucket membership re-evaluation (Section 6.1). NOT part of the
126
157
  // Promise.all above: its property eval reads contact state ⊕ this-ingest
@@ -90,6 +90,23 @@ export interface OutboundPayloads {
90
90
  "email.delivered": EmailEventPayload;
91
91
  "email.opened": EmailEventPayload;
92
92
  "email.clicked": EmailEventPayload & { linkUrl?: string; linkId?: string };
93
+ /**
94
+ * A SEMANTIC link answered — the in-email action event (consumer-named, e.g.
95
+ * "nps.submitted"). Emitted at most once per (send, event name): first
96
+ * answer wins, scanner bursts are suppressed. `event`/`properties` carry the
97
+ * consumer semantics; the rest is send context.
98
+ */
99
+ "email.action": {
100
+ event: string;
101
+ properties: Record<string, unknown> | null;
102
+ emailSendId: string;
103
+ templateKey: string | null;
104
+ userId: string | null;
105
+ to: string;
106
+ at: string;
107
+ linkId: string;
108
+ linkUrl: string;
109
+ };
93
110
  "email.bounced": EmailEventPayload & {
94
111
  bounceType?: string;
95
112
  bounceReason?: string;
@@ -18,6 +18,7 @@ const POSTHOG_FUNNEL_EVENTS = [
18
18
  "email.delivered",
19
19
  "email.opened",
20
20
  "email.clicked",
21
+ "email.action",
21
22
  "email.bounced",
22
23
  "email.complained",
23
24
  ] as const;
@@ -51,7 +52,11 @@ export async function seedPostHogDestination(opts: {
51
52
  );
52
53
 
53
54
  const existing = await tx
54
- .select({ id: webhookEndpoints.id })
55
+ .select({
56
+ id: webhookEndpoints.id,
57
+ url: webhookEndpoints.url,
58
+ eventTypes: webhookEndpoints.eventTypes,
59
+ })
55
60
  .from(webhookEndpoints)
56
61
  .where(
57
62
  and(
@@ -61,7 +66,34 @@ export async function seedPostHogDestination(opts: {
61
66
  )
62
67
  .limit(1);
63
68
 
64
- if (existing.length > 0) {
69
+ const found = existing[0];
70
+ if (found) {
71
+ // Reconcile the ENGINE-seeded row (identified by its sentinel URL) when
72
+ // the funnel list has grown since it was inserted — its stored
73
+ // eventTypes are a snapshot, and emitOutbound matches by jsonb
74
+ // containment, so a pre-upgrade row would silently never receive newer
75
+ // events (e.g. email.action). Operator-created endpoints are left
76
+ // untouched: subscriber-chooses-events is the contract there.
77
+ if (found.url === "posthog://capture") {
78
+ const current = Array.isArray(found.eventTypes)
79
+ ? (found.eventTypes as string[])
80
+ : [];
81
+ const missing = POSTHOG_FUNNEL_EVENTS.filter(
82
+ (e) => !current.includes(e),
83
+ );
84
+ if (missing.length > 0) {
85
+ await tx
86
+ .update(webhookEndpoints)
87
+ .set({
88
+ eventTypes: [...current, ...missing],
89
+ updatedAt: new Date(),
90
+ })
91
+ .where(eq(webhookEndpoints.id, found.id));
92
+ logger.info("Reconciled seeded PostHog destination event types", {
93
+ added: missing,
94
+ });
95
+ }
96
+ }
65
97
  return { seeded: false };
66
98
  }
67
99