@hogsend/engine 0.8.0 → 0.10.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 (34) hide show
  1. package/package.json +9 -6
  2. package/src/container.ts +156 -29
  3. package/src/destinations/define-destination.ts +104 -0
  4. package/src/destinations/presets/index.ts +94 -0
  5. package/src/destinations/presets/posthog.ts +71 -0
  6. package/src/destinations/presets/segment.ts +75 -0
  7. package/src/destinations/presets/slack.ts +66 -0
  8. package/src/destinations/presets/webhook.ts +37 -0
  9. package/src/destinations/registry-singleton.ts +78 -0
  10. package/src/env.ts +38 -1
  11. package/src/index.ts +46 -6
  12. package/src/journeys/define-journey.ts +0 -1
  13. package/src/journeys/journey-context.ts +1 -17
  14. package/src/lib/analytics-singleton.ts +7 -0
  15. package/src/lib/email-provider-registry.ts +45 -0
  16. package/src/lib/email-providers-from-env.ts +94 -0
  17. package/src/lib/email-service-types.ts +40 -4
  18. package/src/lib/headers.ts +13 -0
  19. package/src/lib/mailer.ts +137 -72
  20. package/src/lib/outbound.ts +18 -2
  21. package/src/lib/seed-posthog-destination.ts +93 -0
  22. package/src/lib/tracked.ts +34 -29
  23. package/src/lib/tracking-events.ts +37 -20
  24. package/src/lib/webhook-signing.ts +2 -1
  25. package/src/routes/admin/emails.ts +5 -1
  26. package/src/routes/admin/webhooks.ts +100 -9
  27. package/src/routes/tracking/click.ts +20 -25
  28. package/src/routes/tracking/open.ts +20 -27
  29. package/src/routes/webhooks/email-provider.ts +124 -0
  30. package/src/routes/webhooks/index.ts +7 -0
  31. package/src/routes/webhooks/resend.ts +14 -29
  32. package/src/routes/webhooks/sources.ts +15 -4
  33. package/src/workflows/deliver-webhook.ts +137 -52
  34. package/src/workflows/send-email.ts +2 -1
@@ -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,7 +9,7 @@ interface EmailSendContext {
10
9
  userId: string;
11
10
  userEmail: string;
12
11
  templateKey: string | null;
13
- resendId: string | null;
12
+ messageId: string | null;
14
13
  to: string;
15
14
  }
16
15
 
@@ -22,7 +21,7 @@ export async function resolveEmailSendContext(
22
21
  .select({
23
22
  toEmail: emailSends.toEmail,
24
23
  templateKey: emailSends.templateKey,
25
- resendId: emailSends.resendId,
24
+ messageId: emailSends.messageId,
26
25
  userId: journeyStates.userId,
27
26
  userEmail: journeyStates.userEmail,
28
27
  })
@@ -38,12 +37,12 @@ export async function resolveEmailSendContext(
38
37
  userId: row.userId ?? row.toEmail,
39
38
  userEmail: row.userEmail ?? row.toEmail,
40
39
  templateKey: row.templateKey,
41
- resendId: row.resendId,
40
+ messageId: row.messageId,
42
41
  to: row.toEmail,
43
42
  };
44
43
  }
45
44
 
46
- export interface ResendEmailSendContext {
45
+ export interface EmailSendContextByMessageId {
47
46
  emailSendId: string;
48
47
  userId: string;
49
48
  userEmail: string;
@@ -51,21 +50,28 @@ export interface ResendEmailSendContext {
51
50
  to: string;
52
51
  }
53
52
 
53
+ /**
54
+ * @deprecated Renamed to {@link EmailSendContextByMessageId} as part of the
55
+ * provider-neutral `resendId` → `messageId` rename. Kept as an alias for one
56
+ * minor; removed the following minor.
57
+ */
58
+ export type ResendEmailSendContext = EmailSendContextByMessageId;
59
+
54
60
  /**
55
61
  * Mirror of {@link resolveEmailSendContext} that resolves by the provider's
56
- * `resendId` instead of the internal `email_sends.id`. Used by the
62
+ * `messageId` instead of the internal `email_sends.id`. Used by the
57
63
  * provider-webhook enrichment path (`email.delivered`/`email.bounced`) where the
58
- * only handle we hold is the Resend `email_id`.
64
+ * only handle we hold is the provider message id.
59
65
  *
60
66
  * Returns the internal `emailSendId` plus the same denormalized identity
61
67
  * (`userId`/`userEmail` fall back to the recipient address, exactly like the
62
68
  * id-keyed resolver) and `to` recipient. Returns null when no send row carries
63
- * that `resendId` yet (e.g. a webhook arriving before the send row is committed).
69
+ * that `messageId` yet (e.g. a webhook arriving before the send row is committed).
64
70
  */
65
- export async function resolveEmailSendContextByResendId(
71
+ export async function resolveEmailSendContextByMessageId(
66
72
  db: Database,
67
- resendId: string,
68
- ): Promise<ResendEmailSendContext | null> {
73
+ messageId: string,
74
+ ): Promise<EmailSendContextByMessageId | null> {
69
75
  const rows = await db
70
76
  .select({
71
77
  emailSendId: emailSends.id,
@@ -78,7 +84,7 @@ export async function resolveEmailSendContextByResendId(
78
84
  })
79
85
  .from(emailSends)
80
86
  .leftJoin(journeyStates, eq(emailSends.journeyStateId, journeyStates.id))
81
- .where(eq(emailSends.resendId, resendId))
87
+ .where(eq(emailSends.messageId, messageId))
82
88
  .limit(1);
83
89
 
84
90
  const row = rows[0];
@@ -93,12 +99,19 @@ export async function resolveEmailSendContextByResendId(
93
99
  };
94
100
  }
95
101
 
102
+ /**
103
+ * @deprecated Renamed to {@link resolveEmailSendContextByMessageId} as part of
104
+ * the provider-neutral `resendId` → `messageId` rename. Kept as an alias for one
105
+ * minor; removed the following minor.
106
+ */
107
+ export const resolveEmailSendContextByResendId =
108
+ resolveEmailSendContextByMessageId;
109
+
96
110
  export interface PushTrackingEventOpts {
97
111
  db: Database;
98
112
  hatchet: HatchetClient;
99
113
  registry: JourneyRegistry;
100
114
  logger: Logger;
101
- posthog?: PostHogService;
102
115
  event: string;
103
116
  emailSendId: string;
104
117
  properties?: Record<string, unknown>;
@@ -111,10 +124,20 @@ export interface PushTrackingEventOpts {
111
124
  resolvedContext?: EmailSendContext | null;
112
125
  }
113
126
 
127
+ /**
128
+ * Re-push a first-party tracking event (open/click) back onto the INTERNAL bus
129
+ * (`ingestEvent`) for journey routing + `userEvents` persistence.
130
+ *
131
+ * NOTE (Phase 2): this NO LONGER fires a fire-and-forget PostHog `captureEvent`.
132
+ * PostHog now receives opens/clicks PER-HIT via the durable outbound spine — a
133
+ * `kind="posthog"` destination subscribed to `email.opened`/`email.clicked` (the
134
+ * tracking routes call `emitOutbound` alongside this). The legacy double-emit was
135
+ * removed so PostHog gets exactly one, durable copy of each hit.
136
+ */
114
137
  export async function pushTrackingEvent(
115
138
  opts: PushTrackingEventOpts,
116
139
  ): Promise<void> {
117
- const { db, hatchet, registry, logger, posthog, event, emailSendId } = opts;
140
+ const { db, hatchet, registry, logger, event, emailSendId } = opts;
118
141
 
119
142
  const ctx =
120
143
  opts.resolvedContext !== undefined
@@ -128,12 +151,6 @@ export async function pushTrackingEvent(
128
151
  ...opts.properties,
129
152
  };
130
153
 
131
- posthog?.captureEvent({
132
- distinctId: ctx.userId,
133
- event,
134
- properties,
135
- });
136
-
137
154
  await ingestEvent({
138
155
  db,
139
156
  registry,
@@ -25,7 +25,7 @@ import { Webhook } from "svix";
25
25
  */
26
26
 
27
27
  /**
28
- * The 12-event catalog — the SINGLE source of truth (schema, routes, client,
28
+ * The 13-event catalog — the SINGLE source of truth (schema, routes, client,
29
29
  * CLI all derive from this). The `webhook.test` sentinel is intentionally NOT a
30
30
  * member (it is delivered out-of-band regardless of an endpoint's `eventTypes`).
31
31
  */
@@ -39,6 +39,7 @@ export const WEBHOOK_EVENT_TYPES = [
39
39
  "email.opened",
40
40
  "email.clicked",
41
41
  "email.bounced",
42
+ "email.complained",
42
43
  "journey.completed",
43
44
  "bucket.entered",
44
45
  "bucket.left",
@@ -26,6 +26,8 @@ const emailSchema = z.object({
26
26
  id: z.string(),
27
27
  journeyStateId: z.string().nullable(),
28
28
  templateKey: z.string().nullable(),
29
+ messageId: z.string().nullable(),
30
+ /** @deprecated Mirrors `messageId`; kept for one minor, removed thereafter. */
29
31
  resendId: z.string().nullable(),
30
32
  fromEmail: z.string(),
31
33
  toEmail: z.string(),
@@ -88,7 +90,9 @@ function serializeEmail(
88
90
  id: row.id,
89
91
  journeyStateId: row.journeyStateId,
90
92
  templateKey: row.templateKey,
91
- resendId: row.resendId,
93
+ messageId: row.messageId,
94
+ // @deprecated Mirror of `messageId` for one minor (back-compat).
95
+ resendId: row.messageId,
92
96
  fromEmail: row.fromEmail,
93
97
  toEmail: row.toEmail,
94
98
  subject: row.subject,
@@ -3,6 +3,7 @@ import { webhookDeliveries, webhookEndpoints } from "@hogsend/db";
3
3
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
4
4
  import { count, desc, eq } from "drizzle-orm";
5
5
  import type { AppEnv } from "../../app.js";
6
+ import { PRESET_DESTINATIONS } from "../../destinations/presets/index.js";
6
7
  import { errorSchema } from "../../lib/schemas.js";
7
8
  import {
8
9
  generateWebhookSecret,
@@ -24,17 +25,34 @@ import { deliverWebhookTask } from "../../workflows/deliver-webhook.js";
24
25
 
25
26
  // The catalog enum for request validation — derived from the SINGLE source of
26
27
  // truth in `webhook-signing.ts` (Section 1.3). `z.enum` needs a non-empty tuple,
27
- // which `WEBHOOK_EVENT_TYPES` (12 strings, `as const`) satisfies.
28
+ // which `WEBHOOK_EVENT_TYPES` (13 strings, `as const`) satisfies.
28
29
  const eventTypeEnum = z.enum(
29
30
  WEBHOOK_EVENT_TYPES as unknown as [WebhookEventType, ...WebhookEventType[]],
30
31
  );
31
32
 
33
+ // The destination `kind` enum. "webhook" (default) is the signed POST; any
34
+ // other value selects a delivery-time transform adapter keyed by this column.
35
+ // This MUST cover every shipped preset (the skills + env.example tell operators
36
+ // to create segment/slack endpoints via this admin API / the `hs.webhooks` SDK),
37
+ // so it is derived from `PRESET_DESTINATIONS` — the single source of truth for
38
+ // the shipped `kind`s — rather than a hand-maintained literal list. A `kind`
39
+ // whose transform is not REGISTERED at delivery time still fails its delivery as
40
+ // a config error (DLQ); the registry, not the env, decides resolvability — so we
41
+ // accept any shipped preset id here regardless of `ENABLED_DESTINATION_PRESETS`.
42
+ const kindEnum = z.enum(
43
+ Object.keys(PRESET_DESTINATIONS) as unknown as [string, ...string[]],
44
+ );
45
+
32
46
  const webhookEndpointSchema = z.object({
33
47
  id: z.string(),
34
48
  url: z.string(),
35
49
  description: z.string().nullable(),
36
50
  eventTypes: z.array(z.string()),
37
- secretPrefix: z.string(),
51
+ // Keyed destinations have no signing secret (null secretPrefix).
52
+ secretPrefix: z.string().nullable(),
53
+ kind: z.string(),
54
+ // REDACTED config view — credentials (e.g. config.apiKey) are masked.
55
+ config: z.record(z.string(), z.unknown()).nullable(),
38
56
  status: z.enum(["enabled", "disabled"]),
39
57
  organizationId: z.string().nullable(),
40
58
  lastDeliveryAt: z.string().nullable(),
@@ -85,6 +103,11 @@ const createEndpointRoute = createRoute({
85
103
  eventTypes: z.array(eventTypeEnum).min(1),
86
104
  description: z.string().max(500).optional(),
87
105
  disabled: z.boolean().optional(),
106
+ // Destination selector. Defaults to "webhook" (signed POST).
107
+ kind: kindEnum.optional(),
108
+ // Per-destination config for keyed adapters (e.g. PostHog's
109
+ // `{ apiKey, host }`). Ignored for kind="webhook".
110
+ config: z.record(z.string(), z.unknown()).optional(),
88
111
  }),
89
112
  },
90
113
  },
@@ -94,7 +117,11 @@ const createEndpointRoute = createRoute({
94
117
  201: {
95
118
  content: {
96
119
  "application/json": {
97
- schema: webhookEndpointSchema.extend({ secret: z.string() }),
120
+ // `secret` is present ONLY for kind="webhook" (the one-time signing
121
+ // secret). Keyed destinations carry no secret.
122
+ schema: webhookEndpointSchema.extend({
123
+ secret: z.string().optional(),
124
+ }),
98
125
  },
99
126
  },
100
127
  description: "Endpoint created — signing secret shown only once",
@@ -139,6 +166,8 @@ const updateEndpointRoute = createRoute({
139
166
  eventTypes: z.array(eventTypeEnum).min(1).optional(),
140
167
  description: z.string().max(500).nullable().optional(),
141
168
  disabled: z.boolean().optional(),
169
+ kind: kindEnum.optional(),
170
+ config: z.record(z.string(), z.unknown()).nullable().optional(),
142
171
  }),
143
172
  },
144
173
  },
@@ -237,10 +266,30 @@ const testRoute = createRoute({
237
266
  },
238
267
  });
239
268
 
269
+ /** Config keys whose values are credentials and must be masked in responses. */
270
+ const REDACTED_CONFIG_KEYS = new Set(["apiKey", "apikey", "api_key"]);
271
+
272
+ /**
273
+ * Mask credential values in a keyed destination's `config` for API responses —
274
+ * `config.apiKey` becomes `"***"` so a list/get never leaks the secret. Returns
275
+ * null when there is no config (kind="webhook").
276
+ */
277
+ function redactConfig(
278
+ config: Record<string, unknown> | null,
279
+ ): Record<string, unknown> | null {
280
+ if (!config) return null;
281
+ const out: Record<string, unknown> = {};
282
+ for (const [key, value] of Object.entries(config)) {
283
+ out[key] = REDACTED_CONFIG_KEYS.has(key) ? "***" : value;
284
+ }
285
+ return out;
286
+ }
287
+
240
288
  /**
241
289
  * Serialize an endpoint row for an API response. NEVER includes `secret` — the
242
290
  * full `whsec_…` is surfaced only on create + rotate-secret via the dedicated
243
- * response shapes. `status` is derived from the `disabled` boolean.
291
+ * response shapes. `config` is REDACTED (credentials masked). `status` is
292
+ * derived from the `disabled` boolean.
244
293
  */
245
294
  function serializeEndpoint(row: typeof webhookEndpoints.$inferSelect) {
246
295
  return {
@@ -249,6 +298,8 @@ function serializeEndpoint(row: typeof webhookEndpoints.$inferSelect) {
249
298
  description: row.description,
250
299
  eventTypes: row.eventTypes as string[],
251
300
  secretPrefix: row.secretPrefix,
301
+ kind: row.kind,
302
+ config: redactConfig(row.config),
252
303
  status: (row.disabled ? "disabled" : "enabled") as "enabled" | "disabled",
253
304
  organizationId: row.organizationId,
254
305
  lastDeliveryAt: row.lastDeliveryAt?.toISOString() ?? null,
@@ -292,7 +343,13 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
292
343
  const { db } = c.get("container");
293
344
  const body = c.req.valid("json");
294
345
 
295
- const { secret, secretPrefix } = generateWebhookSecret();
346
+ const kind = body.kind ?? "webhook";
347
+ // Only the signed "webhook" kind gets a signing secret; keyed destinations
348
+ // authenticate via `config` (their secret/secretPrefix stay null).
349
+ const credentials =
350
+ kind === "webhook"
351
+ ? generateWebhookSecret()
352
+ : { secret: null, secretPrefix: null };
296
353
 
297
354
  const [created] = await db
298
355
  .insert(webhookEndpoints)
@@ -301,15 +358,24 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
301
358
  eventTypes: body.eventTypes,
302
359
  description: body.description ?? null,
303
360
  disabled: body.disabled ?? false,
304
- secret,
305
- secretPrefix,
361
+ kind,
362
+ config: body.config ?? null,
363
+ secret: credentials.secret,
364
+ secretPrefix: credentials.secretPrefix,
306
365
  })
307
366
  .returning();
308
367
 
309
368
  if (!created) throw new Error("Failed to create webhook endpoint");
310
369
 
311
- // The ONLY list/get-shaped response that also carries the full secret.
312
- return c.json({ ...serializeEndpoint(created), secret }, 201);
370
+ // The ONLY list/get-shaped response that also carries the full secret — and
371
+ // ONLY for kind="webhook" (keyed destinations have no secret to surface).
372
+ if (kind === "webhook" && credentials.secret) {
373
+ return c.json(
374
+ { ...serializeEndpoint(created), secret: credentials.secret },
375
+ 201,
376
+ );
377
+ }
378
+ return c.json(serializeEndpoint(created), 201);
313
379
  })
314
380
  .openapi(getEndpointRoute, async (c) => {
315
381
  const { db } = c.get("container");
@@ -349,6 +415,19 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
349
415
  if (body.eventTypes !== undefined) patch.eventTypes = body.eventTypes;
350
416
  if (body.description !== undefined) patch.description = body.description;
351
417
  if (body.disabled !== undefined) patch.disabled = body.disabled;
418
+ if (body.kind !== undefined) patch.kind = body.kind;
419
+ if (body.config !== undefined) patch.config = body.config;
420
+
421
+ // Foot-gun guard: an endpoint that ends up as a signed "webhook" with no
422
+ // secret would dead-letter every delivery (the webhook transform signs with
423
+ // the live secret). Mint one when switching to (or back to) kind="webhook"
424
+ // without an existing secret. Keyed kinds keep their null secret — harmless.
425
+ const effectiveKind = body.kind ?? existing.kind;
426
+ if (effectiveKind === "webhook" && !existing.secret) {
427
+ const { secret, secretPrefix } = generateWebhookSecret();
428
+ patch.secret = secret;
429
+ patch.secretPrefix = secretPrefix;
430
+ }
352
431
 
353
432
  const [updated] = await db
354
433
  .update(webhookEndpoints)
@@ -418,6 +497,14 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
418
497
  // endpoint's `eventTypes`. Build a synthetic delivery row directly — it does
419
498
  // NOT go through `emitOutbound` (which filters by subscription) — then enqueue
420
499
  // the same durable delivery task the live emit path uses.
500
+ //
501
+ // The synthetic envelope routes THROUGH the per-kind delivery adapter (the
502
+ // delivery task resolves it by `endpoint.kind`), so the `data` must carry the
503
+ // fields every adapter needs to build a VALID request. For `kind="webhook"`
504
+ // the adapter only signs the frozen bytes, so the extra fields are harmless;
505
+ // for `kind="posthog"` the capture adapter coalesces `distinct_id` from
506
+ // `userId`/`to`/`userEmail`, so a synthetic recipient (`to`) is required or
507
+ // the test would POST a malformed capture with no distinct_id.
421
508
  const webhookId = `msg_${randomUUID()}`;
422
509
  const timestamp = new Date();
423
510
  const envelope = {
@@ -428,6 +515,10 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
428
515
  message: "Hogsend test event",
429
516
  endpointId: endpoint.id,
430
517
  sentAt: timestamp.toISOString(),
518
+ // Adapter-friendly synthetic identity so a keyed destination (e.g.
519
+ // posthog) builds a valid request from the test envelope.
520
+ userId: `test_${endpoint.id}`,
521
+ to: "test@hogsend.com",
431
522
  },
432
523
  };
433
524
 
@@ -52,10 +52,11 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
52
52
  null;
53
53
  const userAgent = c.req.header("user-agent") ?? null;
54
54
 
55
- // The `clickedAt` first-touch UPDATE is split OUT of the Promise.all so it can
56
- // `.returning({ id })` the `WHERE clickedAt IS NULL` makes a row come back
57
- // ONLY on the first click, which gates the outbound `email.clicked` emit.
58
- const [, , clicked] = await Promise.all([
55
+ // First-touch state UPDATE: the `WHERE clickedAt IS NULL` sets `clickedAt`
56
+ // exactly once (the first click), which is the row-level state we keep. The
57
+ // outbound emit is NO LONGER gated on this every destination must receive
58
+ // EVERY click (owner decision 1), so the emit below fires per-hit.
59
+ await Promise.all([
59
60
  db.insert(linkClicks).values({
60
61
  trackedLinkId: link.id,
61
62
  ipAddress: ip,
@@ -79,25 +80,17 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
79
80
  eq(emailSends.id, link.emailSendId),
80
81
  isNull(emailSends.clickedAt),
81
82
  ),
82
- )
83
- .returning({ id: emailSends.id }),
83
+ ),
84
84
  ]);
85
85
 
86
- const {
87
- hatchet,
88
- registry,
89
- logger,
90
- analytics: posthog,
91
- } = c.get("container");
86
+ const { hatchet, registry, logger } = c.get("container");
92
87
 
93
88
  // Resolve the send context ONCE (off the response path) and feed both the
94
- // re-ingest (every click) and the first-touch outbound emit (first click
95
- // only) — avoiding a duplicate `resolveEmailSendContext` read on the click
96
- // hot path. `dedupeKey` = `email.clicked:<emailSendId>` is defence-in-depth
97
- // alongside the first-touch gate (`clicked.length > 0`); first-party is the
98
- // SINGLE emitter for `email.clicked` (the provider-webhook echo is suppressed).
89
+ // re-ingest and the PER-HIT outbound emit avoiding a duplicate
90
+ // `resolveEmailSendContext` read on the click hot path. NO `dedupeKey`: a
91
+ // NULL dedupe key is distinct in Postgres, so every click creates a fresh
92
+ // delivery to every subscribed destination (per-hit, not first-touch).
99
93
  const emailSendId = link.emailSendId;
100
- const isFirstClick = clicked.length > 0;
101
94
  void resolveEmailSendContext(db, emailSendId)
102
95
  .then(async (ctx) => {
103
96
  await pushTrackingEvent({
@@ -105,7 +98,6 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
105
98
  hatchet,
106
99
  registry,
107
100
  logger,
108
- posthog,
109
101
  event: EMAIL_LINK_CLICKED,
110
102
  emailSendId,
111
103
  properties: { linkUrl: link.originalUrl, linkId: link.id },
@@ -117,19 +109,22 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
117
109
  });
118
110
  });
119
111
 
120
- if (isFirstClick) {
112
+ // Only emit when the send-context resolved. A missing emailSends row
113
+ // (orphaned tracked link / deleted send) has no userId or recipient to
114
+ // attribute, and a keyed destination (PostHog) would otherwise receive
115
+ // an empty distinct_id. A normal click always resolves a non-null userId.
116
+ if (ctx) {
121
117
  await emitOutbound({
122
118
  db,
123
119
  hatchet,
124
120
  logger,
125
121
  event: "email.clicked",
126
- dedupeKey: `email.clicked:${emailSendId}`,
127
122
  payload: {
128
123
  emailSendId,
129
- resendId: ctx?.resendId ?? null,
130
- templateKey: ctx?.templateKey ?? null,
131
- userId: ctx?.userId ?? null,
132
- to: ctx?.to ?? ctx?.userEmail ?? "",
124
+ messageId: ctx.messageId ?? null,
125
+ templateKey: ctx.templateKey ?? null,
126
+ userId: ctx.userId ?? null,
127
+ to: ctx.to ?? ctx.userEmail ?? "",
133
128
  at: new Date().toISOString(),
134
129
  linkUrl: link.originalUrl,
135
130
  linkId: link.id,
@@ -33,34 +33,25 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
33
33
  openRoute,
34
34
  async (c) => {
35
35
  const { id } = c.req.valid("param");
36
- const {
37
- db,
38
- hatchet,
39
- registry,
40
- logger,
41
- analytics: posthog,
42
- } = c.get("container");
36
+ const { db, hatchet, registry, logger } = c.get("container");
43
37
 
44
- // First-touch gate: the `WHERE openedAt IS NULL` makes this UPDATE return a
45
- // row ONLY on the FIRST open. `.returning({ id })` lets the outbound emit fire
46
- // exactly once first-party is the SINGLE emitter for `email.opened` (the
47
- // provider-webhook echo in the mailer is suppressed risk 4).
48
- const opened = await db
38
+ // First-touch state UPDATE: the `WHERE openedAt IS NULL` sets `openedAt`
39
+ // exactly once (the first open), which is the row-level state we keep. The
40
+ // outbound emit is NO LONGER gated on this every destination must receive
41
+ // EVERY open (owner decision 1), so the emit below fires per-hit.
42
+ await db
49
43
  .update(emailSends)
50
44
  .set({
51
45
  openedAt: new Date(),
52
46
  updatedAt: new Date(),
53
47
  })
54
- .where(and(eq(emailSends.id, id), isNull(emailSends.openedAt)))
55
- .returning({ id: emailSends.id });
48
+ .where(and(eq(emailSends.id, id), isNull(emailSends.openedAt)));
56
49
 
57
50
  // Resolve the send context ONCE (off the response path) and feed both the
58
- // re-ingest (every open) and the first-touch outbound emit (first open
59
- // only) — avoiding a duplicate `resolveEmailSendContext` read on the pixel
60
- // hot path. `dedupeKey` = `email.opened:<id>` is defence-in-depth alongside
61
- // the first-touch gate (`opened.length > 0`); first-party is the SINGLE
62
- // emitter for `email.opened` (the provider-webhook echo is suppressed).
63
- const isFirstOpen = opened.length > 0;
51
+ // re-ingest and the PER-HIT outbound emit avoiding a duplicate
52
+ // `resolveEmailSendContext` read on the pixel hot path. NO `dedupeKey`: a
53
+ // NULL dedupe key is distinct in Postgres, so every open creates a fresh
54
+ // delivery to every subscribed destination (per-hit, not first-touch).
64
55
  void resolveEmailSendContext(db, id)
65
56
  .then(async (ctx) => {
66
57
  await pushTrackingEvent({
@@ -68,7 +59,6 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
68
59
  hatchet,
69
60
  registry,
70
61
  logger,
71
- posthog,
72
62
  event: EMAIL_OPENED,
73
63
  emailSendId: id,
74
64
  resolvedContext: ctx,
@@ -79,19 +69,22 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
79
69
  });
80
70
  });
81
71
 
82
- if (isFirstOpen) {
72
+ // Only emit when the send-context resolved. A missing emailSends row
73
+ // (orphaned tracked pixel / deleted send) has no userId or recipient to
74
+ // attribute, and a keyed destination (PostHog) would otherwise receive
75
+ // an empty distinct_id. A normal open always resolves a non-null userId.
76
+ if (ctx) {
83
77
  await emitOutbound({
84
78
  db,
85
79
  hatchet,
86
80
  logger,
87
81
  event: "email.opened",
88
- dedupeKey: `email.opened:${id}`,
89
82
  payload: {
90
83
  emailSendId: id,
91
- resendId: ctx?.resendId ?? null,
92
- templateKey: ctx?.templateKey ?? null,
93
- userId: ctx?.userId ?? null,
94
- to: ctx?.to ?? ctx?.userEmail ?? "",
84
+ messageId: ctx.messageId ?? null,
85
+ templateKey: ctx.templateKey ?? null,
86
+ userId: ctx.userId ?? null,
87
+ to: ctx.to ?? ctx.userEmail ?? "",
95
88
  at: new Date().toISOString(),
96
89
  },
97
90
  });
@@ -0,0 +1,124 @@
1
+ import { WebhookHandshakeSignal } from "@hogsend/core";
2
+ import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import type { Context } from "hono";
4
+ import type { AppEnv } from "../../app.js";
5
+ import { headersToRecord } from "../../lib/headers.js";
6
+
7
+ /**
8
+ * Shared email-provider webhook dispatch used by BOTH the id-dispatched
9
+ * `POST /v1/webhooks/email/:providerId` route and the thin
10
+ * `POST /v1/webhooks/resend` alias. Resolves the provider from the container's
11
+ * {@link EmailProviderRegistry} (404 on unknown id), reads the raw body as the
12
+ * EXACT received bytes (signature schemes verify over these), verifies +
13
+ * dispatches the normalized {@link EmailEvent}, 200s a
14
+ * {@link WebhookHandshakeSignal}, and 401s a verification error.
15
+ */
16
+ export async function dispatchProviderWebhook(
17
+ c: Context<AppEnv>,
18
+ providerId: string,
19
+ ) {
20
+ const { emailProviders, emailService, logger } = c.get("container");
21
+
22
+ const provider = emailProviders.get(providerId);
23
+ if (!provider) {
24
+ return c.json({ error: "Unknown email provider" }, 404);
25
+ }
26
+
27
+ // Read the body ONCE as the EXACT received bytes — signature schemes verify
28
+ // over these bytes, so we must not re-stringify.
29
+ const payload = await c.req.text();
30
+ const headers = headersToRecord(c.req.raw.headers);
31
+
32
+ try {
33
+ const event = await provider.verifyWebhook({ payload, headers });
34
+ const result = await emailService.handleWebhook(event, providerId);
35
+
36
+ logger.info("Email provider webhook processed", {
37
+ providerId,
38
+ type: event.type,
39
+ handled: result.handled,
40
+ });
41
+
42
+ return c.json({ ok: true }, 200);
43
+ } catch (err) {
44
+ if (err instanceof WebhookHandshakeSignal) {
45
+ // A non-delivery-status handshake (SNS confirm, Postmark subscription
46
+ // change) the provider already handled — ack with 200.
47
+ logger.info("Email webhook handshake", {
48
+ providerId,
49
+ action: err.action,
50
+ });
51
+ return c.json({ ok: true }, 200);
52
+ }
53
+ logger.warn("Email provider webhook failed", {
54
+ providerId,
55
+ error: err instanceof Error ? err.message : String(err),
56
+ });
57
+ return c.json({ error: "Webhook verification failed" }, 401);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Id-dispatched email-provider webhook receiver:
63
+ * `POST /v1/webhooks/email/:providerId`.
64
+ *
65
+ * Resolves the provider from the container's {@link EmailProviderRegistry} (so
66
+ * an unknown id is a clean 404), verifies the webhook via that provider (which
67
+ * owns its OWN secrets), and dispatches the normalized {@link EmailEvent} into
68
+ * `emailService.handleWebhook`. Registered BEFORE the `:sourceId` catch-all so
69
+ * Hono matches the static `email/` prefix first.
70
+ *
71
+ * The provider's `verifyWebhook` is the ONLY place body-shape knowledge lives —
72
+ * the route never sniffs the payload. It returns a normalized event, OR throws
73
+ * {@link WebhookHandshakeSignal} for non-status handshakes (route 200s), OR
74
+ * throws a verification error (route 401s).
75
+ */
76
+ const emailProviderWebhookRoute = createRoute({
77
+ method: "post",
78
+ path: "/v1/webhooks/email/{providerId}",
79
+ tags: ["Webhooks"],
80
+ summary: "Email provider webhook receiver",
81
+ request: {
82
+ params: z.object({ providerId: z.string() }),
83
+ body: {
84
+ content: {
85
+ "application/json": {
86
+ schema: z.record(z.string(), z.unknown()),
87
+ },
88
+ },
89
+ },
90
+ },
91
+ responses: {
92
+ 200: {
93
+ content: {
94
+ "application/json": {
95
+ schema: z.object({ ok: z.boolean() }),
96
+ },
97
+ },
98
+ description: "Webhook processed",
99
+ },
100
+ 401: {
101
+ content: {
102
+ "application/json": {
103
+ schema: z.object({ error: z.string() }),
104
+ },
105
+ },
106
+ description: "Missing or invalid webhook signature",
107
+ },
108
+ 404: {
109
+ content: {
110
+ "application/json": {
111
+ schema: z.object({ error: z.string() }),
112
+ },
113
+ },
114
+ description: "Unknown email provider",
115
+ },
116
+ },
117
+ });
118
+
119
+ export function registerEmailProviderRoutes(app: OpenAPIHono<AppEnv>) {
120
+ app.openapi(emailProviderWebhookRoute, (c) => {
121
+ const { providerId } = c.req.valid("param");
122
+ return dispatchProviderWebhook(c, providerId);
123
+ });
124
+ }