@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.
- package/package.json +9 -6
- package/src/container.ts +156 -29
- package/src/destinations/define-destination.ts +104 -0
- package/src/destinations/presets/index.ts +94 -0
- package/src/destinations/presets/posthog.ts +71 -0
- package/src/destinations/presets/segment.ts +75 -0
- package/src/destinations/presets/slack.ts +66 -0
- package/src/destinations/presets/webhook.ts +37 -0
- package/src/destinations/registry-singleton.ts +78 -0
- package/src/env.ts +38 -1
- package/src/index.ts +46 -6
- package/src/journeys/define-journey.ts +0 -1
- package/src/journeys/journey-context.ts +1 -17
- package/src/lib/analytics-singleton.ts +7 -0
- package/src/lib/email-provider-registry.ts +45 -0
- package/src/lib/email-providers-from-env.ts +94 -0
- package/src/lib/email-service-types.ts +40 -4
- package/src/lib/headers.ts +13 -0
- package/src/lib/mailer.ts +137 -72
- package/src/lib/outbound.ts +18 -2
- package/src/lib/seed-posthog-destination.ts +93 -0
- package/src/lib/tracked.ts +34 -29
- package/src/lib/tracking-events.ts +37 -20
- package/src/lib/webhook-signing.ts +2 -1
- package/src/routes/admin/emails.ts +5 -1
- package/src/routes/admin/webhooks.ts +100 -9
- package/src/routes/tracking/click.ts +20 -25
- package/src/routes/tracking/open.ts +20 -27
- package/src/routes/webhooks/email-provider.ts +124 -0
- package/src/routes/webhooks/index.ts +7 -0
- package/src/routes/webhooks/resend.ts +14 -29
- package/src/routes/webhooks/sources.ts +15 -4
- package/src/workflows/deliver-webhook.ts +137 -52
- 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:
|
|
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
|
-
|
|
41
|
-
|
|
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
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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)
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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)
|
|
202
|
-
|
|
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
|
|
205
|
-
|
|
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)
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
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 (!
|
|
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
|
|
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:
|
|
401
|
+
fastFail: permanentFail,
|
|
402
|
+
adapterFailed,
|
|
318
403
|
error: lastError,
|
|
319
404
|
});
|
|
320
|
-
return { status: "failed" as const, attemptCount, fastFail:
|
|
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,
|