@hogsend/engine 0.8.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.
@@ -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
+ resendId: ctx.resendId ?? 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
+ resendId: ctx.resendId ?? 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
  });
@@ -4,10 +4,15 @@ import {
4
4
  webhookEndpoints,
5
5
  } from "@hogsend/db";
6
6
  import { and, eq, lt, or, sql } from "drizzle-orm";
7
+ import type {
8
+ DestinationEnvelope,
9
+ DestinationTransformResult,
10
+ } from "../destinations/define-destination.js";
11
+ import { webhookDestination } from "../destinations/presets/webhook.js";
12
+ import { getDestinationRegistry } from "../destinations/registry-singleton.js";
7
13
  import { getDb } from "../lib/db.js";
8
14
  import { hatchet } from "../lib/hatchet.js";
9
15
  import { createLogger } from "../lib/logger.js";
10
- import { signWebhook } from "../lib/webhook-signing.js";
11
16
 
12
17
  /**
13
18
  * Outbound webhook delivery — the durable per-(event × endpoint) POST attempt
@@ -20,11 +25,17 @@ import { signWebhook } from "../lib/webhook-signing.js";
20
25
  * recovery — mirroring `reapStuckCampaignsTask`. Hatchet's own retry is OFF
21
26
  * (`retries: 0`); `nextRetryAt` is the single retry clock.
22
27
  *
23
- * The task signs from the FROZEN `payload` envelope on the row + the LIVE
24
- * endpoint secret read at delivery time, so a rotate-secret invalidates
25
- * in-flight deliveries to a compromised secret (acceptable under at-least-once).
26
- * The `body` that `signWebhook` produces is the EXACT bytes that are POSTed —
27
- * the payload is never re-serialized between sign and send (Open Risk 8).
28
+ * The task resolves a delivery-time DESTINATION TRANSFORM by `endpoint.kind`
29
+ * (default "webhook") from the process destination registry, applied to the
30
+ * FROZEN `payload` envelope on the row + the LIVE endpoint read at delivery
31
+ * time. For "webhook" the transform signs with the live secret, so a
32
+ * rotate-secret invalidates in-flight deliveries to a compromised secret
33
+ * (acceptable under at-least-once) and the `body` is the EXACT bytes POSTed —
34
+ * never re-serialized between sign and send (Open Risk 8). A keyed destination
35
+ * (e.g. "posthog") rewrites url/headers/body; a `null` result skips delivery
36
+ * (successful no-op); a transform throw (bad config) is a NON-retryable
37
+ * permanent failure (straight to DLQ, like a persistent 4xx). All
38
+ * retry/backoff/DLQ/reaper/CAS logic operates on the ROW, not the wire.
28
39
  */
29
40
 
30
41
  /** Statuses that are TERMINAL — a duplicate/late enqueue must not re-deliver. */
@@ -155,55 +166,119 @@ export const deliverWebhookTask = hatchet.task({
155
166
  return { status: "skipped", reason: "lost_cas" as const };
156
167
  }
157
168
 
158
- // (4) Sign from the FROZEN row payload + the LIVE endpoint secret. `body` is
159
- // the EXACT bytes signed AND sent never re-serialize between sign and send
160
- // (Open Risk 8).
161
- const { headers, body } = signWebhook({
162
- id: row.webhookId,
163
- timestamp: Math.floor(Date.now() / 1000),
164
- payload: row.payload,
165
- secret: endpoint.secret,
166
- });
167
-
168
- // (5) POST with an AbortController timeout. A network error / timeout leaves
169
- // `responseStatus` null (a retryable failure, handled below).
170
- const controller = new AbortController();
171
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
169
+ // (4) Resolve the delivery-time DESTINATION TRANSFORM by the endpoint's
170
+ // `kind` (default "webhook"byte-identical to the pre-destination signed
171
+ // POST) from the process destination registry. The transform turns the
172
+ // FROZEN row envelope + LIVE endpoint into the concrete HTTP request. For
173
+ // "webhook" it signs from the row payload + live secret; for a keyed
174
+ // destination it rewrites url/headers/body. An UNKNOWN kind (no registered
175
+ // transform) falls back to the always-on `webhook` preset, preserving the
176
+ // pre-registry `ADAPTERS[kind] ?? webhookAdapter` behaviour. A `null` result
177
+ // SKIPS delivery for this event (a successful no-op — marked delivered, no
178
+ // POST). A THROW (bad/missing config) is NOT transient — it routes straight
179
+ // to the failed+DLQ branch like a persistent 4xx (`adapterFailed`).
180
+ const destination =
181
+ getDestinationRegistry().get(endpoint.kind ?? "webhook") ??
182
+ webhookDestination;
183
+ let req: DestinationTransformResult | null = null;
184
+ let transformSkipped = false;
185
+ let adapterFailed = false;
186
+ let adapterUrl = endpoint.url;
172
187
  let responseStatus: number | null = null;
173
188
  let responseBodySnippet: string | null = null;
174
189
  let lastError: string | null = null;
175
190
  try {
176
- const res = await fetch(endpoint.url, {
177
- method: "POST",
178
- headers,
179
- body,
180
- signal: controller.signal,
181
- });
182
- responseStatus = res.status;
183
- const text = await res.text().catch(() => "");
184
- responseBodySnippet = text ? text.slice(0, SNIPPET_MAX) : null;
185
- if (responseStatus < 200 || responseStatus >= 300) {
186
- lastError = `HTTP ${responseStatus}`;
191
+ req = destination.transform(
192
+ row.payload as unknown as DestinationEnvelope,
193
+ {
194
+ endpoint,
195
+ logger,
196
+ },
197
+ );
198
+ if (req === null) {
199
+ transformSkipped = true;
200
+ } else {
201
+ adapterUrl = req.url;
187
202
  }
188
203
  } catch (err) {
189
- lastError =
190
- err instanceof Error
191
- ? controller.signal.aborted
192
- ? `Timeout after ${TIMEOUT_MS}ms`
193
- : err.message
194
- : String(err);
195
- } finally {
196
- clearTimeout(timer);
204
+ adapterFailed = true;
205
+ lastError = err instanceof Error ? err.message : String(err);
206
+ }
207
+
208
+ // (4a) Transform returned null → SKIP: mark the row delivered without a POST
209
+ // (a successful no-op for an event this destination chose not to forward).
210
+ // The `delivered` status keeps the row terminal so the reaper never
211
+ // re-drives it. No endpoint `lastDeliveryAt` bump — nothing was sent.
212
+ if (transformSkipped) {
213
+ const skippedAt = new Date();
214
+ await db
215
+ .update(webhookDeliveries)
216
+ .set({
217
+ status: "delivered",
218
+ attemptCount: row.attemptCount + 1,
219
+ responseStatus: null,
220
+ responseBodySnippet: null,
221
+ deliveredAt: skippedAt,
222
+ nextRetryAt: null,
223
+ lastError: null,
224
+ lastAttemptAt: skippedAt,
225
+ updatedAt: skippedAt,
226
+ })
227
+ .where(eq(webhookDeliveries.id, row.id));
228
+ logger.info("deliver-webhook: skipped by destination transform", {
229
+ deliveryId: row.id,
230
+ endpointId: endpoint.id,
231
+ kind: endpoint.kind ?? "webhook",
232
+ eventType: row.eventType,
233
+ });
234
+ return { status: "delivered" as const, skipped: true };
235
+ }
236
+
237
+ // (5) POST with an AbortController timeout. A network error / timeout leaves
238
+ // `responseStatus` null (a retryable failure, handled below). Skipped when
239
+ // the transform threw (permanent config failure → straight to DLQ).
240
+ if (req) {
241
+ const controller = new AbortController();
242
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
243
+ try {
244
+ const res = await fetch(req.url, {
245
+ method: req.method ?? "POST",
246
+ headers: req.headers,
247
+ body: req.body,
248
+ signal: controller.signal,
249
+ });
250
+ responseStatus = res.status;
251
+ const text = await res.text().catch(() => "");
252
+ responseBodySnippet = text ? text.slice(0, SNIPPET_MAX) : null;
253
+ const ok =
254
+ req.isSuccess?.(responseStatus, responseBodySnippet ?? "") ??
255
+ (responseStatus >= 200 && responseStatus < 300);
256
+ if (!ok) {
257
+ lastError = `HTTP ${responseStatus}`;
258
+ }
259
+ } catch (err) {
260
+ lastError =
261
+ err instanceof Error
262
+ ? controller.signal.aborted
263
+ ? `Timeout after ${TIMEOUT_MS}ms`
264
+ : err.message
265
+ : String(err);
266
+ } finally {
267
+ clearTimeout(timer);
268
+ }
197
269
  }
198
270
 
199
271
  const now = new Date();
200
272
 
201
- // (6) 2xx → delivered (TERMINAL). Also bump the endpoint's lastDeliveryAt.
202
- if (
273
+ // (6) success → delivered (TERMINAL). Also bump the endpoint's
274
+ // lastDeliveryAt. The transform's `isSuccess` (or the default 2xx rule) is
275
+ // the authority — re-checked here against the resolved request.
276
+ const delivered =
277
+ req !== null &&
203
278
  responseStatus !== null &&
204
- responseStatus >= 200 &&
205
- responseStatus < 300
206
- ) {
279
+ (req.isSuccess?.(responseStatus, responseBodySnippet ?? "") ??
280
+ (responseStatus >= 200 && responseStatus < 300));
281
+ if (delivered) {
207
282
  await db
208
283
  .update(webhookDeliveries)
209
284
  .set({
@@ -233,18 +308,22 @@ export const deliverWebhookTask = hatchet.task({
233
308
 
234
309
  const attemptCount = row.attemptCount + 1;
235
310
 
236
- // (7) Persistent-4xx fast-fail: a non-retryable client error (anything 4xx
237
- // except 408/429) is permanent after attempt >= 2 — a `410 Gone` must not
238
- // burn 8 attempts. The `>= 2` guard tolerates a single transient 4xx blip
239
- // before declaring the endpoint mis-configured.
311
+ // (7) Permanent (non-retryable) failures fast-fail, skipping the remaining
312
+ // retry budget:
313
+ // - a transform THROW (bad/missing destination config) config is not
314
+ // transient, so a single attempt is enough to declare it dead.
315
+ // - a persistent-4xx: a non-retryable client error (anything 4xx except
316
+ // 408/429) after attempt >= 2 — a `410 Gone` must not burn 8 attempts.
317
+ // The `>= 2` guard tolerates a single transient 4xx blip first.
240
318
  const httpFastFail =
241
319
  responseStatus !== null &&
242
320
  !isRetryableStatus(responseStatus) &&
243
321
  attemptCount >= 2;
322
+ const permanentFail = adapterFailed || httpFastFail;
244
323
 
245
324
  // (8) Retryable failure with attempts remaining → back to `pending` with the
246
325
  // next backoff deadline; the reaper re-drives it once `nextRetryAt` passes.
247
- if (!httpFastFail && attemptCount < MAX_ATTEMPTS) {
326
+ if (!permanentFail && attemptCount < MAX_ATTEMPTS) {
248
327
  const nextRetryAt = new Date(now.getTime() + backoffMs(attemptCount));
249
328
  await db
250
329
  .update(webhookDeliveries)
@@ -298,7 +377,11 @@ export const deliverWebhookTask = hatchet.task({
298
377
  sourceId: row.id,
299
378
  payload: {
300
379
  endpointId: endpoint.id,
301
- url: endpoint.url,
380
+ // The adapter-RESOLVED url + the endpoint kind, so a failed keyed
381
+ // delivery is debuggable (NOT the raw endpoint.url, which for a keyed
382
+ // destination is not the URL actually POSTed to).
383
+ url: adapterUrl,
384
+ kind: endpoint.kind ?? "webhook",
302
385
  eventType: row.eventType,
303
386
  webhookId: row.webhookId,
304
387
  body: row.payload,
@@ -311,13 +394,15 @@ export const deliverWebhookTask = hatchet.task({
311
394
  logger.error("deliver-webhook: failed (dead-lettered)", {
312
395
  deliveryId: row.id,
313
396
  endpointId: endpoint.id,
397
+ kind: endpoint.kind ?? "webhook",
314
398
  eventType: row.eventType,
315
399
  attemptCount,
316
400
  responseStatus,
317
- fastFail: httpFastFail,
401
+ fastFail: permanentFail,
402
+ adapterFailed,
318
403
  error: lastError,
319
404
  });
320
- return { status: "failed" as const, attemptCount, fastFail: httpFastFail };
405
+ return { status: "failed" as const, attemptCount, fastFail: permanentFail };
321
406
  },
322
407
  });
323
408