@hogsend/engine 0.7.0 → 0.9.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.
Files changed (41) hide show
  1. package/package.json +7 -6
  2. package/src/app.ts +36 -1
  3. package/src/container.ts +80 -8
  4. package/src/destinations/define-destination.ts +104 -0
  5. package/src/destinations/presets/index.ts +94 -0
  6. package/src/destinations/presets/posthog.ts +71 -0
  7. package/src/destinations/presets/segment.ts +75 -0
  8. package/src/destinations/presets/slack.ts +66 -0
  9. package/src/destinations/presets/webhook.ts +37 -0
  10. package/src/destinations/registry-singleton.ts +78 -0
  11. package/src/env.ts +40 -0
  12. package/src/index.ts +59 -1
  13. package/src/journeys/define-journey.ts +26 -3
  14. package/src/journeys/journey-context.ts +1 -17
  15. package/src/lib/analytics-singleton.ts +7 -0
  16. package/src/lib/bucket-emit.ts +45 -0
  17. package/src/lib/contacts.ts +28 -6
  18. package/src/lib/mailer.ts +102 -0
  19. package/src/lib/outbound.ts +223 -0
  20. package/src/lib/preferences.ts +31 -0
  21. package/src/lib/seed-posthog-destination.ts +93 -0
  22. package/src/lib/tracked.ts +45 -3
  23. package/src/lib/tracking-events.ts +77 -10
  24. package/src/lib/webhook-signing.ts +152 -0
  25. package/src/routes/admin/contacts.ts +43 -3
  26. package/src/routes/admin/index.ts +2 -0
  27. package/src/routes/admin/webhooks.ts +557 -0
  28. package/src/routes/contacts/index.ts +48 -5
  29. package/src/routes/lists/index.ts +41 -5
  30. package/src/routes/tracking/click.ts +58 -22
  31. package/src/routes/tracking/open.ts +53 -22
  32. package/src/routes/webhooks/sources.ts +69 -10
  33. package/src/webhook-sources/define-webhook-source.ts +57 -5
  34. package/src/webhook-sources/presets/clerk.ts +185 -0
  35. package/src/webhook-sources/presets/index.ts +80 -0
  36. package/src/webhook-sources/presets/segment.ts +120 -0
  37. package/src/webhook-sources/presets/stripe.ts +147 -0
  38. package/src/webhook-sources/presets/supabase.ts +131 -0
  39. package/src/webhook-sources/verify.ts +172 -0
  40. package/src/worker.ts +6 -0
  41. package/src/workflows/deliver-webhook.ts +484 -0
@@ -0,0 +1,223 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
3
+ import {
4
+ type Database,
5
+ webhookDeliveries,
6
+ webhookEndpoints,
7
+ } from "@hogsend/db";
8
+ import { and, eq, isNull, sql } from "drizzle-orm";
9
+ import { deliverWebhookTask } from "../workflows/deliver-webhook.js";
10
+ import type { SerializedContact } from "./contacts.js";
11
+ import type { Logger } from "./logger.js";
12
+ import {
13
+ WEBHOOK_EVENT_TYPES,
14
+ type WebhookEventType,
15
+ } from "./webhook-signing.js";
16
+
17
+ export {
18
+ type ResendEmailSendContext,
19
+ resolveEmailSendContextByResendId,
20
+ } from "./tracking-events.js";
21
+
22
+ /**
23
+ * The outbound event catalog re-exported for the emit spine. Identical to the
24
+ * signing lib's {@link WEBHOOK_EVENT_TYPES} — the single source of truth.
25
+ */
26
+ export const OUTBOUND_EVENTS = WEBHOOK_EVENT_TYPES;
27
+ export type OutboundEventName = WebhookEventType;
28
+
29
+ interface EmailEventPayload {
30
+ emailSendId: string;
31
+ resendId: string | null;
32
+ templateKey: string | null;
33
+ userId: string | null;
34
+ to: string;
35
+ at: string;
36
+ // Optional enrichment (additive — older subscribers ignore absent keys).
37
+ category?: string;
38
+ subject?: string;
39
+ }
40
+
41
+ interface BucketEventPayload {
42
+ bucketId: string;
43
+ bucketName: string;
44
+ userId: string;
45
+ userEmail: string | null;
46
+ transition: "entered" | "left";
47
+ entryCount: number;
48
+ source: string;
49
+ }
50
+
51
+ /**
52
+ * The typed per-event payload map. `data` in each delivered envelope is exactly
53
+ * `OutboundPayloads[E]` for the emitted event `E`. Producers (the 12 hook
54
+ * points) construct these; subscribers receive them under `envelope.data`.
55
+ */
56
+ export interface OutboundPayloads {
57
+ "contact.created": SerializedContact;
58
+ "contact.updated": SerializedContact;
59
+ "contact.deleted": {
60
+ id: string;
61
+ externalId: string | null;
62
+ email: string | null;
63
+ };
64
+ "contact.unsubscribed": {
65
+ externalId: string | null;
66
+ email: string | null;
67
+ category: string | null;
68
+ scope: "all" | "category";
69
+ };
70
+ "email.sent": {
71
+ emailSendId: string;
72
+ resendId: string;
73
+ templateKey: string | null;
74
+ to: string;
75
+ userId: string | null;
76
+ category: string | null;
77
+ journeyStateId: string | null;
78
+ subject: string;
79
+ sentAt: string;
80
+ };
81
+ "email.delivered": EmailEventPayload;
82
+ "email.opened": EmailEventPayload;
83
+ "email.clicked": EmailEventPayload & { linkUrl?: string; linkId?: string };
84
+ "email.bounced": EmailEventPayload & {
85
+ bounceType?: string;
86
+ bounceReason?: string;
87
+ };
88
+ "email.complained": EmailEventPayload & {
89
+ complaintType?: string;
90
+ reason?: string;
91
+ };
92
+ "journey.completed": {
93
+ journeyId: string;
94
+ journeyName: string;
95
+ stateId: string;
96
+ userId: string;
97
+ userEmail: string;
98
+ completedAt: string;
99
+ };
100
+ "bucket.entered": BucketEventPayload;
101
+ "bucket.left": BucketEventPayload & { reason?: string };
102
+ }
103
+
104
+ /**
105
+ * The signed envelope shape written to `webhook_deliveries.payload` and sent
106
+ * verbatim to subscribers. `id` is the shared `Webhook-Id`; `timestamp` is the
107
+ * logical-event time (ISO); `data` is the typed per-event payload.
108
+ */
109
+ interface OutboundEnvelope<E extends OutboundEventName> {
110
+ id: string;
111
+ type: E;
112
+ timestamp: string;
113
+ data: OutboundPayloads[E];
114
+ }
115
+
116
+ /**
117
+ * THE fire-and-forget emit spine. It does NOT deliver — it selects the active,
118
+ * subscribed endpoints for `event`, inserts one `webhook_deliveries` row per
119
+ * endpoint (all sharing ONE `webhookId` = the `Webhook-Id` header), and enqueues
120
+ * the durable {@link deliverWebhookTask} per inserted row.
121
+ *
122
+ * Idempotency: when `dedupeKey` is provided, the unique
123
+ * `(endpointId, dedupeKey)` index makes a re-emit (e.g. a Hatchet retry of the
124
+ * producing task) a no-op via `onConflictDoNothing` — no duplicate row, no
125
+ * second enqueue. Events without a `dedupeKey` are never deduped (NULL keys are
126
+ * distinct in Postgres), which is correct for non-retryable emit points.
127
+ *
128
+ * NEVER throws to callers. Internal failures (endpoint select, insert, enqueue)
129
+ * are logged via `logger.warn` and swallowed so a transient outbound error can
130
+ * never fail a contact upsert / email send / journey step. Callers MUST STILL
131
+ * wrap the call as `void emitOutbound(...).catch(logger.warn)` — the `.catch` is
132
+ * defence-in-depth against a programming error that escapes this guard.
133
+ *
134
+ * Single-tenant: only endpoints with `organizationId IS NULL` are selected and
135
+ * the delivery rows are written with `organizationId ?? null`. Multi-tenant
136
+ * scoping is a later non-breaking change.
137
+ */
138
+ export async function emitOutbound<E extends OutboundEventName>(opts: {
139
+ db: Database;
140
+ hatchet: HatchetClient;
141
+ logger: Logger;
142
+ event: E;
143
+ payload: OutboundPayloads[E];
144
+ dedupeKey?: string;
145
+ organizationId?: string | null;
146
+ }): Promise<void> {
147
+ const { db, logger, event, payload, dedupeKey } = opts;
148
+ const organizationId = opts.organizationId ?? null;
149
+
150
+ try {
151
+ const webhookId = `msg_${randomUUID()}`;
152
+ const timestamp = new Date();
153
+
154
+ // (2) Active, subscribed endpoints. `event_types @> '["<event>"]'` matches
155
+ // the jsonb array containing this event. Single-tenant: organizationId IS
156
+ // NULL (NOT a hardcoded tenant — keeps the MT wiring non-breaking).
157
+ const endpoints = await db
158
+ .select({ id: webhookEndpoints.id })
159
+ .from(webhookEndpoints)
160
+ .where(
161
+ and(
162
+ eq(webhookEndpoints.disabled, false),
163
+ isNull(webhookEndpoints.organizationId),
164
+ sql`${webhookEndpoints.eventTypes} @> ${JSON.stringify([event])}::jsonb`,
165
+ ),
166
+ );
167
+
168
+ if (endpoints.length === 0) return;
169
+
170
+ // (3) The frozen envelope — signed + sent verbatim by the delivery task.
171
+ const envelope: OutboundEnvelope<E> = {
172
+ id: webhookId,
173
+ type: event,
174
+ timestamp: timestamp.toISOString(),
175
+ data: payload,
176
+ };
177
+
178
+ // (4) One delivery row per endpoint, sharing the webhookId. onConflictDoNothing
179
+ // on (endpointId, dedupeKey) is the producer-side fan-out idempotency guard.
180
+ const inserted = await db
181
+ .insert(webhookDeliveries)
182
+ .values(
183
+ endpoints.map((endpoint) => ({
184
+ endpointId: endpoint.id,
185
+ organizationId,
186
+ webhookId,
187
+ eventType: event,
188
+ dedupeKey: dedupeKey ?? null,
189
+ payload: envelope as unknown as Record<string, unknown>,
190
+ status: "pending" as const,
191
+ attemptCount: 0,
192
+ nextRetryAt: timestamp,
193
+ })),
194
+ )
195
+ .onConflictDoNothing({
196
+ target: [webhookDeliveries.endpointId, webhookDeliveries.dedupeKey],
197
+ })
198
+ .returning({ id: webhookDeliveries.id });
199
+
200
+ // (5) Enqueue the durable delivery task per freshly-inserted row,
201
+ // fire-and-forget. A failed enqueue is recovered by the reaper (the row is
202
+ // already `pending` with `nextRetryAt <= now`), so a broker hiccup here only
203
+ // delays — never drops — a delivery.
204
+ for (const row of inserted) {
205
+ void deliverWebhookTask
206
+ .runNoWait({ deliveryId: row.id })
207
+ .catch((error: unknown) => {
208
+ logger.warn("emitOutbound: deliverWebhookTask enqueue failed", {
209
+ deliveryId: row.id,
210
+ event,
211
+ error: error instanceof Error ? error.message : String(error),
212
+ });
213
+ });
214
+ }
215
+ } catch (error) {
216
+ // FAIL-SAFE: never propagate an outbound error onto the producer's hot path.
217
+ logger.warn("emitOutbound failed", {
218
+ event,
219
+ dedupeKey,
220
+ error: error instanceof Error ? error.message : String(error),
221
+ });
222
+ }
223
+ }
@@ -2,6 +2,11 @@ import type { Database } from "@hogsend/db";
2
2
  import { emailPreferences } from "@hogsend/db";
3
3
  import { sql } from "drizzle-orm";
4
4
  import { resolveRecipient } from "./contacts.js";
5
+ import { hatchet } from "./hatchet.js";
6
+ import { createLogger } from "./logger.js";
7
+ import { emitOutbound } from "./outbound.js";
8
+
9
+ const logger = createLogger(process.env.LOG_LEVEL);
5
10
 
6
11
  /**
7
12
  * Single source of truth for an `email_preferences` upsert: the `(user_id, email)`
@@ -61,6 +66,32 @@ export async function upsertEmailPreference(opts: {
61
66
  target: [emailPreferences.userId, emailPreferences.email],
62
67
  set: setClause,
63
68
  });
69
+
70
+ // OUTBOUND `contact.unsubscribed` — this is the SINGLE choke for ALL preference
71
+ // writes (token unsub, preference center, list-membership flips), so the emit
72
+ // lives here once. GATED to a genuine opt-OUT only: a full unsubscribe
73
+ // (`unsubscribedAll === true`) or a category flip to false. A resubscribe
74
+ // (`unsubscribedAll === false` / `categoryValue === true`) does NOT emit. Uses
75
+ // the engine `hatchet`/`logger` singletons (this lib has no request container);
76
+ // fire-and-forget so a transient outbound error never fails the pref write.
77
+ const isUnsubscribe =
78
+ update.unsubscribedAll === true || update.categoryValue === false;
79
+ if (isUnsubscribe) {
80
+ const scope: "all" | "category" =
81
+ update.unsubscribedAll === true ? "all" : "category";
82
+ void emitOutbound({
83
+ db,
84
+ hatchet,
85
+ logger,
86
+ event: "contact.unsubscribed",
87
+ payload: {
88
+ externalId,
89
+ email,
90
+ category: update.categoryKey ?? null,
91
+ scope,
92
+ },
93
+ }).catch(logger.warn);
94
+ }
64
95
  }
65
96
 
66
97
  /**
@@ -0,0 +1,93 @@
1
+ import { type Database, webhookEndpoints } from "@hogsend/db";
2
+ import { and, eq, isNull, sql } from "drizzle-orm";
3
+ import type { Logger } from "./logger.js";
4
+
5
+ /**
6
+ * Transaction-scoped advisory-lock key serializing the single-tenant PostHog
7
+ * seed across concurrent API + worker boots. An arbitrary fixed constant within
8
+ * int4 range (the single-arg `pg_advisory_xact_lock` overload casts to bigint).
9
+ */
10
+ const SEED_ADVISORY_LOCK_KEY = 1426198835;
11
+
12
+ /**
13
+ * The email funnel a seeded PostHog destination subscribes to — the full
14
+ * lifecycle that reaches PostHog on NO path before this destination existed.
15
+ */
16
+ const POSTHOG_FUNNEL_EVENTS = [
17
+ "email.sent",
18
+ "email.delivered",
19
+ "email.opened",
20
+ "email.clicked",
21
+ "email.bounced",
22
+ "email.complained",
23
+ ] as const;
24
+
25
+ /**
26
+ * Idempotently seed ONE `kind="posthog"` webhook endpoint subscribed to the
27
+ * email funnel, so the full email lifecycle fans out to PostHog DURABLY on the
28
+ * delivery spine.
29
+ *
30
+ * Guarded against duplicates: it inserts only when no single-tenant
31
+ * (`organization_id IS NULL`) `kind="posthog"` endpoint already exists. Safe to
32
+ * call from BOTH the API and worker boots (both build the client) — the second
33
+ * caller finds the row and no-ops. Fire-and-forget at the call site: a transient
34
+ * seed failure must never block boot.
35
+ */
36
+ export async function seedPostHogDestination(opts: {
37
+ db: Database;
38
+ logger: Logger;
39
+ apiKey: string;
40
+ host?: string;
41
+ }): Promise<{ seeded: boolean }> {
42
+ const { db, logger, apiKey, host } = opts;
43
+
44
+ // Serialize the check-then-insert across concurrent API + worker boots (both
45
+ // build the client) with a transaction-scoped advisory lock, so the race can
46
+ // never double-seed. A per-endpoint unique constraint is intentionally avoided
47
+ // — operators may legitimately create multiple PostHog endpoints by hand.
48
+ return db.transaction(async (tx) => {
49
+ await tx.execute(
50
+ sql`SELECT pg_advisory_xact_lock(${SEED_ADVISORY_LOCK_KEY})`,
51
+ );
52
+
53
+ const existing = await tx
54
+ .select({ id: webhookEndpoints.id })
55
+ .from(webhookEndpoints)
56
+ .where(
57
+ and(
58
+ isNull(webhookEndpoints.organizationId),
59
+ eq(webhookEndpoints.kind, "posthog"),
60
+ ),
61
+ )
62
+ .limit(1);
63
+
64
+ if (existing.length > 0) {
65
+ return { seeded: false };
66
+ }
67
+
68
+ await tx.insert(webhookEndpoints).values({
69
+ url: "posthog://capture",
70
+ description:
71
+ "Auto-seeded PostHog destination (ENABLE_POSTHOG_DESTINATION)",
72
+ kind: "posthog",
73
+ config: {
74
+ apiKey,
75
+ ...(host ? { host } : {}),
76
+ // Preserve continuity with the legacy fire-and-forget PostHog path,
77
+ // which captured clicks as "email.link_clicked"; the posthog transform
78
+ // remaps the canonical "email.clicked" back so existing PostHog funnels
79
+ // keep working after the cutover.
80
+ eventNames: { "email.clicked": "email.link_clicked" },
81
+ },
82
+ eventTypes: [...POSTHOG_FUNNEL_EVENTS],
83
+ secret: null,
84
+ secretPrefix: null,
85
+ disabled: false,
86
+ });
87
+
88
+ logger.info("Seeded PostHog destination on the outbound spine", {
89
+ events: POSTHOG_FUNNEL_EVENTS.length,
90
+ });
91
+ return { seeded: true };
92
+ });
93
+ }
@@ -20,7 +20,15 @@ import type {
20
20
  TrackedSendResult,
21
21
  } from "./email-service-types.js";
22
22
  import { isFrequencyCapped } from "./frequency-cap.js";
23
- import type { Logger } from "./logger.js";
23
+ import { hatchet } from "./hatchet.js";
24
+ import { createLogger, type Logger } from "./logger.js";
25
+ import { emitOutbound } from "./outbound.js";
26
+
27
+ // Module-level fallback logger for the outbound emit — the tracked-mailer's
28
+ // `logger` dep is optional, but `emitOutbound` requires one. Mirrors the
29
+ // `createLogger(process.env.LOG_LEVEL)` singleton pattern used elsewhere in the
30
+ // engine libs (define-journey, preferences).
31
+ const emitLogger = createLogger(process.env.LOG_LEVEL);
24
32
 
25
33
  export type PrepareTrackedHtmlFn = (opts: {
26
34
  html: string;
@@ -270,16 +278,50 @@ export async function sendTrackedEmail<K extends TemplateName>(
270
278
  replyTo: options.replyTo,
271
279
  });
272
280
 
281
+ const sentAt = new Date();
273
282
  await db
274
283
  .update(emailSends)
275
284
  .set({
276
285
  resendId: result.id,
277
286
  status: "sent",
278
- sentAt: new Date(),
279
- updatedAt: new Date(),
287
+ sentAt,
288
+ updatedAt: sentAt,
280
289
  })
281
290
  .where(eq(emailSends.id, emailSendId));
282
291
 
292
+ // OUTBOUND `email.sent` — fired ONLY on a real provider-accepted send (this
293
+ // success branch). Suppressed/frequency-capped/failed branches and the
294
+ // `db === undefined` mailer fallback do NOT reach here, so they never emit.
295
+ // `dedupeKey` = `email.sent:<emailSendId>`: this runs inside the tracked
296
+ // mailer which a journey (a Hatchet-retryable durable task) invokes, so a
297
+ // re-execution recomputes the identical key and the unique
298
+ // `(endpointId, dedupeKey)` index absorbs the duplicate. STRICTLY
299
+ // fire-and-forget: an un-caught reject here would bubble into the catch below
300
+ // and wrongly re-mark the (already sent) row `failed` (risk 2).
301
+ void emitOutbound({
302
+ db,
303
+ hatchet,
304
+ logger: logger ?? emitLogger,
305
+ event: "email.sent",
306
+ dedupeKey: `email.sent:${emailSendId}`,
307
+ payload: {
308
+ emailSendId,
309
+ resendId: result.id,
310
+ templateKey: options.templateKey,
311
+ to: options.to,
312
+ userId: options.userId ?? null,
313
+ category: effectiveCategory ?? null,
314
+ journeyStateId: options.journeyStateId ?? null,
315
+ subject,
316
+ sentAt: sentAt.toISOString(),
317
+ },
318
+ }).catch((err: unknown) => {
319
+ (logger ?? emitLogger).warn("emitOutbound email.sent failed", {
320
+ emailSendId,
321
+ error: err instanceof Error ? err.message : String(err),
322
+ });
323
+ });
324
+
283
325
  return {
284
326
  emailSendId,
285
327
  resendId: result.id,
@@ -1,5 +1,4 @@
1
1
  import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
- import type { PostHogService } from "@hogsend/core";
3
2
  import type { JourneyRegistry } from "@hogsend/core/registry";
4
3
  import { type Database, emailSends, journeyStates } from "@hogsend/db";
5
4
  import { eq } from "drizzle-orm";
@@ -10,6 +9,8 @@ interface EmailSendContext {
10
9
  userId: string;
11
10
  userEmail: string;
12
11
  templateKey: string | null;
12
+ resendId: string | null;
13
+ to: string;
13
14
  }
14
15
 
15
16
  export async function resolveEmailSendContext(
@@ -20,6 +21,7 @@ export async function resolveEmailSendContext(
20
21
  .select({
21
22
  toEmail: emailSends.toEmail,
22
23
  templateKey: emailSends.templateKey,
24
+ resendId: emailSends.resendId,
23
25
  userId: journeyStates.userId,
24
26
  userEmail: journeyStates.userEmail,
25
27
  })
@@ -35,6 +37,58 @@ export async function resolveEmailSendContext(
35
37
  userId: row.userId ?? row.toEmail,
36
38
  userEmail: row.userEmail ?? row.toEmail,
37
39
  templateKey: row.templateKey,
40
+ resendId: row.resendId,
41
+ to: row.toEmail,
42
+ };
43
+ }
44
+
45
+ export interface ResendEmailSendContext {
46
+ emailSendId: string;
47
+ userId: string;
48
+ userEmail: string;
49
+ templateKey: string | null;
50
+ to: string;
51
+ }
52
+
53
+ /**
54
+ * Mirror of {@link resolveEmailSendContext} that resolves by the provider's
55
+ * `resendId` instead of the internal `email_sends.id`. Used by the
56
+ * provider-webhook enrichment path (`email.delivered`/`email.bounced`) where the
57
+ * only handle we hold is the Resend `email_id`.
58
+ *
59
+ * Returns the internal `emailSendId` plus the same denormalized identity
60
+ * (`userId`/`userEmail` fall back to the recipient address, exactly like the
61
+ * id-keyed resolver) and `to` recipient. Returns null when no send row carries
62
+ * that `resendId` yet (e.g. a webhook arriving before the send row is committed).
63
+ */
64
+ export async function resolveEmailSendContextByResendId(
65
+ db: Database,
66
+ resendId: string,
67
+ ): Promise<ResendEmailSendContext | null> {
68
+ const rows = await db
69
+ .select({
70
+ emailSendId: emailSends.id,
71
+ toEmail: emailSends.toEmail,
72
+ templateKey: emailSends.templateKey,
73
+ userId: journeyStates.userId,
74
+ userEmail: journeyStates.userEmail,
75
+ sendUserId: emailSends.userId,
76
+ sendUserEmail: emailSends.userEmail,
77
+ })
78
+ .from(emailSends)
79
+ .leftJoin(journeyStates, eq(emailSends.journeyStateId, journeyStates.id))
80
+ .where(eq(emailSends.resendId, resendId))
81
+ .limit(1);
82
+
83
+ const row = rows[0];
84
+ if (!row) return null;
85
+
86
+ return {
87
+ emailSendId: row.emailSendId,
88
+ userId: row.userId ?? row.sendUserId ?? row.toEmail,
89
+ userEmail: row.userEmail ?? row.sendUserEmail ?? row.toEmail,
90
+ templateKey: row.templateKey,
91
+ to: row.toEmail,
38
92
  };
39
93
  }
40
94
 
@@ -43,18 +97,37 @@ export interface PushTrackingEventOpts {
43
97
  hatchet: HatchetClient;
44
98
  registry: JourneyRegistry;
45
99
  logger: Logger;
46
- posthog?: PostHogService;
47
100
  event: string;
48
101
  emailSendId: string;
49
102
  properties?: Record<string, unknown>;
103
+ /**
104
+ * Pre-resolved send context. When provided (including `null`), the duplicate
105
+ * `resolveEmailSendContext` read is skipped — the tracking routes resolve once
106
+ * and share the result with the outbound emit on the hot path. Omit to resolve
107
+ * lazily.
108
+ */
109
+ resolvedContext?: EmailSendContext | null;
50
110
  }
51
111
 
112
+ /**
113
+ * Re-push a first-party tracking event (open/click) back onto the INTERNAL bus
114
+ * (`ingestEvent`) for journey routing + `userEvents` persistence.
115
+ *
116
+ * NOTE (Phase 2): this NO LONGER fires a fire-and-forget PostHog `captureEvent`.
117
+ * PostHog now receives opens/clicks PER-HIT via the durable outbound spine — a
118
+ * `kind="posthog"` destination subscribed to `email.opened`/`email.clicked` (the
119
+ * tracking routes call `emitOutbound` alongside this). The legacy double-emit was
120
+ * removed so PostHog gets exactly one, durable copy of each hit.
121
+ */
52
122
  export async function pushTrackingEvent(
53
123
  opts: PushTrackingEventOpts,
54
124
  ): Promise<void> {
55
- const { db, hatchet, registry, logger, posthog, event, emailSendId } = opts;
125
+ const { db, hatchet, registry, logger, event, emailSendId } = opts;
56
126
 
57
- const ctx = await resolveEmailSendContext(db, emailSendId);
127
+ const ctx =
128
+ opts.resolvedContext !== undefined
129
+ ? opts.resolvedContext
130
+ : await resolveEmailSendContext(db, emailSendId);
58
131
  if (!ctx) return;
59
132
 
60
133
  const properties: Record<string, unknown> = {
@@ -63,12 +136,6 @@ export async function pushTrackingEvent(
63
136
  ...opts.properties,
64
137
  };
65
138
 
66
- posthog?.captureEvent({
67
- distinctId: ctx.userId,
68
- event,
69
- properties,
70
- });
71
-
72
139
  await ingestEvent({
73
140
  db,
74
141
  registry,