@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,6 +1,7 @@
1
1
  import type { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { AppEnv } from "../../app.js";
3
3
  import type { DefinedWebhookSource } from "../../webhook-sources/define-webhook-source.js";
4
+ import { registerEmailProviderRoutes } from "./email-provider.js";
4
5
  import { resendWebhookRouter } from "./resend.js";
5
6
  import { registerWebhookSourceRoutes } from "./sources.js";
6
7
 
@@ -12,6 +13,12 @@ export function registerWebhookRoutes(
12
13
  app: OpenAPIHono<AppEnv>,
13
14
  opts: RegisterWebhookRoutesOptions,
14
15
  ) {
16
+ // Order is load-bearing for Hono path matching:
17
+ // 1. the thin `/v1/webhooks/resend` alias (static),
18
+ // 2. the `/v1/webhooks/email/:providerId` id-dispatched route (static
19
+ // `email/` prefix — MUST come before the catch-all),
20
+ // 3. the `/v1/webhooks/:sourceId` consumer-source catch-all (LAST).
15
21
  app.route("/v1/webhooks", resendWebhookRouter);
22
+ registerEmailProviderRoutes(app);
16
23
  registerWebhookSourceRoutes(app, opts.webhookSources);
17
24
  }
@@ -1,11 +1,13 @@
1
1
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2
2
  import type { AppEnv } from "../../app.js";
3
+ import { dispatchProviderWebhook } from "./email-provider.js";
3
4
 
4
5
  const resendWebhookRoute = createRoute({
5
6
  method: "post",
6
7
  path: "/resend",
7
8
  tags: ["Webhooks"],
8
- summary: "Resend webhook receiver",
9
+ summary:
10
+ "Resend webhook receiver (@deprecated — use /v1/webhooks/email/resend)",
9
11
  request: {
10
12
  body: {
11
13
  content: {
@@ -32,37 +34,20 @@ const resendWebhookRoute = createRoute({
32
34
  },
33
35
  description: "Missing or invalid webhook secret",
34
36
  },
37
+ 404: {
38
+ content: {
39
+ "application/json": {
40
+ schema: z.object({ error: z.string() }),
41
+ },
42
+ },
43
+ description: "Resend provider not registered",
44
+ },
35
45
  },
36
46
  });
37
47
 
38
48
  export const resendWebhookRouter = new OpenAPIHono<AppEnv>().openapi(
39
49
  resendWebhookRoute,
40
- async (c) => {
41
- const { emailService, logger } = c.get("container");
42
-
43
- const rawBody = await c.req.text();
44
- const headers: Record<string, string> = {};
45
- for (const [key, value] of c.req.raw.headers.entries()) {
46
- headers[key] = value;
47
- }
48
-
49
- try {
50
- const result = await emailService.handleWebhook({
51
- payload: rawBody,
52
- headers,
53
- });
54
-
55
- logger.info("Resend webhook processed", {
56
- type: result.type,
57
- handled: result.handled,
58
- });
59
-
60
- return c.json({ ok: true }, 200);
61
- } catch (err) {
62
- logger.warn("Resend webhook failed", {
63
- error: err instanceof Error ? err.message : String(err),
64
- });
65
- return c.json({ error: "Webhook verification failed" }, 401);
66
- }
67
- },
50
+ // Thin deprecated alias for `POST /v1/webhooks/email/resend` — identical
51
+ // behavior, just the `resend` provider id wired in.
52
+ (c) => dispatchProviderWebhook(c, "resend"),
68
53
  );
@@ -1,5 +1,6 @@
1
1
  import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
2
2
  import type { AppEnv } from "../../app.js";
3
+ import { headersToRecord } from "../../lib/headers.js";
3
4
  import { ingestEvent } from "../../lib/ingestion.js";
4
5
  import type { DefinedWebhookSource } from "../../webhook-sources/define-webhook-source.js";
5
6
  import { verifySignature } from "../../webhook-sources/verify.js";
@@ -8,6 +9,19 @@ export function registerWebhookSourceRoutes(
8
9
  app: OpenAPIHono<AppEnv>,
9
10
  sources: DefinedWebhookSource[],
10
11
  ) {
12
+ // Reserve `email` for the email-provider route
13
+ // (`POST /v1/webhooks/email/:providerId`). A source with `meta.id === "email"`
14
+ // would shadow that prefix, so fail loudly at registration rather than let it
15
+ // silently break provider webhooks.
16
+ for (const source of sources) {
17
+ if (source.meta.id === "email") {
18
+ throw new Error(
19
+ 'Webhook source id "email" is reserved for the email-provider route ' +
20
+ "(POST /v1/webhooks/email/:providerId). Rename the source.",
21
+ );
22
+ }
23
+ }
24
+
11
25
  const sourceMap = new Map(sources.map((s) => [s.meta.id, s]));
12
26
 
13
27
  const webhookRoute = createRoute({
@@ -56,10 +70,7 @@ export function registerWebhookSourceRoutes(
56
70
  // Read the body ONCE as the EXACT received bytes — signature schemes verify
57
71
  // over these bytes, so we must not re-stringify. JSON.parse only AFTER auth.
58
72
  const rawBody = await c.req.text();
59
- const headers: Record<string, string> = {};
60
- for (const [key, value] of c.req.raw.headers.entries()) {
61
- headers[key.toLowerCase()] = value;
62
- }
73
+ const headers = headersToRecord(c.req.raw.headers);
63
74
 
64
75
  const secret = env[source.auth.envKey as keyof typeof env] as
65
76
  | string
@@ -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
 
@@ -34,7 +34,8 @@ export const sendEmailTask = hatchet.task({
34
34
 
35
35
  try {
36
36
  // `from` is optional: when absent the mailer's `resolveFrom` falls back to
37
- // its configured defaultFrom (env.RESEND_FROM_EMAIL).
37
+ // its configured defaultFrom (env.RESEND_FROM_EMAIL). The neutral
38
+ // `tags: {name,value}[]` shape passes straight through to the provider wire.
38
39
  const result = await emailService.sendRaw({
39
40
  from: input.from,
40
41
  to: input.to,