@hogsend/engine 0.6.0 → 0.8.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 (60) hide show
  1. package/package.json +7 -6
  2. package/src/app.ts +36 -1
  3. package/src/buckets/check-membership.ts +34 -15
  4. package/src/container.ts +33 -0
  5. package/src/env.ts +29 -0
  6. package/src/index.ts +47 -1
  7. package/src/journeys/define-journey.ts +26 -2
  8. package/src/journeys/journey-context.ts +5 -1
  9. package/src/lib/boot.ts +1 -1
  10. package/src/lib/bucket-emit.ts +47 -2
  11. package/src/lib/contacts.ts +1105 -18
  12. package/src/lib/email-service-types.ts +8 -0
  13. package/src/lib/ingestion.ts +63 -33
  14. package/src/lib/mailer.ts +88 -0
  15. package/src/lib/outbound.ts +216 -0
  16. package/src/lib/preferences.ts +137 -0
  17. package/src/lib/tracked.ts +204 -37
  18. package/src/lib/tracking-events.ts +67 -2
  19. package/src/lib/webhook-signing.ts +151 -0
  20. package/src/lists/define-list.ts +81 -0
  21. package/src/lists/registry-singleton.ts +39 -0
  22. package/src/lists/registry.ts +95 -0
  23. package/src/middleware/api-key.ts +33 -7
  24. package/src/middleware/rate-limit.ts +73 -49
  25. package/src/routes/_shared.ts +30 -0
  26. package/src/routes/admin/api-keys.ts +1 -1
  27. package/src/routes/admin/bulk.ts +7 -3
  28. package/src/routes/admin/contacts.ts +108 -59
  29. package/src/routes/admin/events.ts +65 -0
  30. package/src/routes/admin/index.ts +2 -0
  31. package/src/routes/admin/journeys.ts +3 -1
  32. package/src/routes/admin/preferences.ts +2 -2
  33. package/src/routes/admin/reporting.ts +3 -3
  34. package/src/routes/admin/timeline.ts +5 -2
  35. package/src/routes/admin/webhooks.ts +466 -0
  36. package/src/routes/campaigns/index.ts +252 -0
  37. package/src/routes/contacts/index.ts +231 -0
  38. package/src/routes/email/preferences.ts +27 -3
  39. package/src/routes/email/unsubscribe.ts +7 -49
  40. package/src/routes/emails/index.ts +133 -0
  41. package/src/routes/events/index.ts +119 -0
  42. package/src/routes/index.ts +52 -2
  43. package/src/routes/lists/index.ts +258 -0
  44. package/src/routes/tracking/click.ts +59 -18
  45. package/src/routes/tracking/open.ts +62 -24
  46. package/src/routes/webhooks/sources.ts +69 -10
  47. package/src/webhook-sources/define-webhook-source.ts +57 -5
  48. package/src/webhook-sources/presets/clerk.ts +185 -0
  49. package/src/webhook-sources/presets/index.ts +80 -0
  50. package/src/webhook-sources/presets/segment.ts +120 -0
  51. package/src/webhook-sources/presets/stripe.ts +147 -0
  52. package/src/webhook-sources/presets/supabase.ts +131 -0
  53. package/src/webhook-sources/verify.ts +172 -0
  54. package/src/worker.ts +12 -0
  55. package/src/workflows/bucket-backfill.ts +32 -21
  56. package/src/workflows/bucket-reconcile.ts +20 -5
  57. package/src/workflows/deliver-webhook.ts +399 -0
  58. package/src/workflows/import-contacts.ts +28 -20
  59. package/src/workflows/send-campaign.ts +589 -0
  60. package/src/routes/ingest.ts +0 -71
@@ -7,15 +7,28 @@ import type {
7
7
  TemplateName,
8
8
  TemplateRegistry,
9
9
  } from "@hogsend/email";
10
- import { getTemplate, renderToHtml } from "@hogsend/email";
10
+ import {
11
+ generateUnsubscribeUrl,
12
+ getTemplate,
13
+ renderToHtml,
14
+ } from "@hogsend/email";
11
15
  import { eq } from "drizzle-orm";
16
+ import { getListRegistry } from "../lists/registry-singleton.js";
12
17
  import type {
13
18
  FrequencyCapConfig,
14
19
  SendTrackedEmailOptions,
15
20
  TrackedSendResult,
16
21
  } from "./email-service-types.js";
17
22
  import { isFrequencyCapped } from "./frequency-cap.js";
18
- 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);
19
32
 
20
33
  export type PrepareTrackedHtmlFn = (opts: {
21
34
  html: string;
@@ -50,11 +63,55 @@ export async function sendTrackedEmail<K extends TemplateName>(
50
63
  options,
51
64
  } = opts;
52
65
 
66
+ // The idempotency-collision result, built identically whether the prior row is
67
+ // found by the up-front short-circuit select OR the concurrent-insert loser
68
+ // path below: surface the winner's send id, mapping "sent" → sent and anything
69
+ // else → a skipped/"frequency_capped" placeholder.
70
+ const idempotentResult = (prior: {
71
+ id: string;
72
+ status: string;
73
+ }): TrackedSendResult =>
74
+ ({
75
+ emailSendId: prior.id,
76
+ resendId: "",
77
+ status: prior.status === "sent" ? "sent" : "skipped",
78
+ ...(prior.status === "sent" ? {} : { reason: "frequency_capped" }),
79
+ }) as TrackedSendResult;
80
+
81
+ // Idempotency short-circuit (POST /v1/emails): a retry with the same key
82
+ // returns the prior send instead of dispatching a duplicate provider call /
83
+ // tracking artifacts (mirrors the user_events idempotency pattern).
84
+ if (options.idempotencyKey) {
85
+ const existing = await db
86
+ .select({ id: emailSends.id, status: emailSends.status })
87
+ .from(emailSends)
88
+ .where(eq(emailSends.idempotencyKey, options.idempotencyKey))
89
+ .limit(1);
90
+ const prior = existing[0];
91
+ if (prior) {
92
+ return idempotentResult(prior);
93
+ }
94
+ }
95
+
96
+ // Resolve the template ONCE up front so its default category is available to
97
+ // the suppression check (a public /v1/emails send may omit `category`, in
98
+ // which case the per-category suppression must still consult the template's
99
+ // own category — otherwise an unsubscribed recipient leaks the mail while the
100
+ // row is stamped with that very category — risk 6 / §2.6).
101
+ const {
102
+ element,
103
+ subject: defaultSubject,
104
+ category: templateCategory,
105
+ } = getTemplate({ key: options.templateKey, props: options.props, registry });
106
+
107
+ const effectiveCategory = options.category ?? templateCategory;
108
+ const subject = options.subject ?? defaultSubject;
109
+
53
110
  if (!options.skipPreferenceCheck) {
54
111
  const suppression = await checkSuppression(
55
112
  db,
56
113
  options.to,
57
- options.category,
114
+ effectiveCategory,
58
115
  );
59
116
  if (suppression) {
60
117
  const rows = await db
@@ -64,11 +121,14 @@ export async function sendTrackedEmail<K extends TemplateName>(
64
121
  fromEmail: options.from,
65
122
  toEmail: options.to,
66
123
  subject: options.subject ?? "",
67
- category: options.category,
124
+ category: effectiveCategory,
68
125
  journeyStateId: options.journeyStateId,
69
126
  userId: options.userId,
70
127
  userEmail: options.userEmail ?? options.to,
71
128
  status: "failed",
129
+ // A suppressed send does NOT consume the idempotency key — leaving it
130
+ // unset lets a later retry (e.g. after the recipient re-subscribes)
131
+ // actually attempt the send rather than dedup to the suppressed row.
72
132
  })
73
133
  .returning({ id: emailSends.id });
74
134
 
@@ -89,6 +149,10 @@ export async function sendTrackedEmail<K extends TemplateName>(
89
149
  // Frequency cap — consulted only for non-system sends (system mail sets
90
150
  // skipPreferenceCheck and bypasses both suppression and the cap). On a cap
91
151
  // hit: no provider call, no row inserted, no throw — the journey continues.
152
+ // Keyed on the caller-supplied `options.category` (NOT the template default)
153
+ // so the cap's byCategory/exempt rules apply exactly to what the caller
154
+ // asked to cap — distinct from suppression, which needs the template default
155
+ // to honor a per-category unsubscribe even when the caller omits `category`.
92
156
  if (frequencyCap) {
93
157
  const capped = await isFrequencyCapped({
94
158
  db,
@@ -111,37 +175,91 @@ export async function sendTrackedEmail<K extends TemplateName>(
111
175
  }
112
176
  }
113
177
 
114
- const {
115
- element,
116
- subject: defaultSubject,
117
- category,
118
- } = getTemplate({ key: options.templateKey, props: options.props, registry });
178
+ // Unsubscribe surface (RFC 8058 / CAN-SPAM): generate the per-recipient
179
+ // unsubscribe URL ONCE and inject it both as the in-body template prop AND the
180
+ // List-Unsubscribe / List-Unsubscribe-Post: One-Click headers, so EVERY send
181
+ // through the tracked mailer — journey AND public /v1/emails — carries it
182
+ // uniformly. Suppressed only for true system mail (skipPreferenceCheck). Built
183
+ // from the SAME user_id fallback (externalId ?? contactId) the email_sends row
184
+ // uses, keeping the token externalId consistent with the preference-center key.
185
+ const secret = process.env.BETTER_AUTH_SECRET;
186
+ let unsubscribeUrl: string | undefined;
187
+ if (!options.skipPreferenceCheck && options.baseUrl && secret) {
188
+ unsubscribeUrl = generateUnsubscribeUrl({
189
+ baseUrl: options.baseUrl,
190
+ secret,
191
+ externalId: options.userId ?? options.to,
192
+ email: options.to,
193
+ category: effectiveCategory,
194
+ });
195
+ }
119
196
 
120
- const subject = options.subject ?? defaultSubject;
197
+ const sendHeaders: Record<string, string> = { ...(options.headers ?? {}) };
198
+ if (unsubscribeUrl && !("List-Unsubscribe" in sendHeaders)) {
199
+ sendHeaders["List-Unsubscribe"] = `<${unsubscribeUrl}>`;
200
+ sendHeaders["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
201
+ }
121
202
 
122
- const insertRows = await db
123
- .insert(emailSends)
124
- .values({
125
- templateKey: options.templateKey,
126
- fromEmail: options.from,
127
- toEmail: options.to,
128
- subject,
129
- category: options.category ?? category,
130
- journeyStateId: options.journeyStateId,
131
- userId: options.userId,
132
- userEmail: options.userEmail ?? options.to,
133
- status: "queued",
134
- })
135
- .returning({ id: emailSends.id });
203
+ // Re-render the template element with the unsubscribe URL merged into props so
204
+ // the in-body footer link renders for journeyless public sends too. Journey
205
+ // sends already pass `unsubscribeUrl` in props (lib/email.ts); only set it when
206
+ // the caller didn't, so we never clobber an explicitly-passed value.
207
+ const propsRecord = options.props as unknown as
208
+ | Record<string, unknown>
209
+ | undefined;
210
+ const sendElement =
211
+ unsubscribeUrl && propsRecord?.unsubscribeUrl == null
212
+ ? getTemplate({
213
+ key: options.templateKey,
214
+ props: {
215
+ ...(propsRecord ?? {}),
216
+ unsubscribeUrl,
217
+ } as unknown as typeof options.props,
218
+ registry,
219
+ }).element
220
+ : element;
221
+
222
+ const baseInsert = db.insert(emailSends).values({
223
+ templateKey: options.templateKey,
224
+ fromEmail: options.from,
225
+ toEmail: options.to,
226
+ subject,
227
+ category: effectiveCategory,
228
+ journeyStateId: options.journeyStateId,
229
+ userId: options.userId,
230
+ userEmail: options.userEmail ?? options.to,
231
+ status: "queued",
232
+ idempotencyKey: options.idempotencyKey,
233
+ });
234
+
235
+ // With an idempotency key, swallow a concurrent-insert collision on the unique
236
+ // index (the select-then-insert above is not atomic) and return the winner.
237
+ const insertRows = options.idempotencyKey
238
+ ? await baseInsert
239
+ .onConflictDoNothing({ target: emailSends.idempotencyKey })
240
+ .returning({ id: emailSends.id })
241
+ : await baseInsert.returning({ id: emailSends.id });
136
242
 
137
243
  const insertedRow = insertRows[0];
244
+ if (!insertedRow && options.idempotencyKey) {
245
+ // A concurrent send claimed the key first — return its row.
246
+ const winner = await db
247
+ .select({ id: emailSends.id, status: emailSends.status })
248
+ .from(emailSends)
249
+ .where(eq(emailSends.idempotencyKey, options.idempotencyKey))
250
+ .limit(1);
251
+ const won = winner[0];
252
+ if (won) {
253
+ return idempotentResult(won);
254
+ }
255
+ }
138
256
  if (!insertedRow) throw new Error("Failed to insert email_sends row");
139
257
  const emailSendId = insertedRow.id;
140
258
 
141
259
  try {
142
260
  let html: string | undefined;
143
261
  if (options.baseUrl && prepareTrackedHtml) {
144
- const rawHtml = await renderToHtml(element);
262
+ const rawHtml = await renderToHtml(sendElement);
145
263
  html = await prepareTrackedHtml({
146
264
  html: rawHtml,
147
265
  emailSendId,
@@ -154,32 +272,74 @@ export async function sendTrackedEmail<K extends TemplateName>(
154
272
  from: options.from,
155
273
  to: options.to,
156
274
  subject,
157
- ...(html ? { html } : { react: element }),
275
+ ...(html ? { html } : { react: sendElement }),
158
276
  tags: options.tags,
159
- headers: options.headers,
277
+ headers: sendHeaders,
160
278
  replyTo: options.replyTo,
161
279
  });
162
280
 
281
+ const sentAt = new Date();
163
282
  await db
164
283
  .update(emailSends)
165
284
  .set({
166
285
  resendId: result.id,
167
286
  status: "sent",
168
- sentAt: new Date(),
169
- updatedAt: new Date(),
287
+ sentAt,
288
+ updatedAt: sentAt,
170
289
  })
171
290
  .where(eq(emailSends.id, emailSendId));
172
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
+
173
325
  return {
174
326
  emailSendId,
175
327
  resendId: result.id,
176
328
  status: "sent",
177
329
  };
178
330
  } catch (error) {
331
+ // A provider send failed (transient SMTP/network/429). Stamp `failed` AND
332
+ // RELEASE the idempotency key (set it null), exactly like the suppression
333
+ // path deliberately never consumes it: this lets a retry genuinely
334
+ // RE-ATTEMPT the send rather than dedup to this failed row. Without the
335
+ // release, the up-front short-circuit would return this `failed` row mapped
336
+ // to `skipped`, so a real delivery failure would (a) never be re-sent and
337
+ // (b) silently vanish from the campaign's failedCount into skippedCount.
179
338
  await db
180
339
  .update(emailSends)
181
340
  .set({
182
341
  status: "failed",
342
+ idempotencyKey: null,
183
343
  updatedAt: new Date(),
184
344
  })
185
345
  .where(eq(emailSends.id, emailSendId));
@@ -201,17 +361,24 @@ async function checkSuppression(
201
361
  .where(eq(emailPreferences.email, email))
202
362
  .limit(1);
203
363
 
204
- if (rows.length === 0) return null;
205
-
206
364
  const prefs = rows[0];
207
- if (!prefs) return null;
208
365
 
209
- if (prefs.suppressed) return "suppressed";
210
- if (prefs.unsubscribedAll) return "unsubscribed";
366
+ if (prefs?.suppressed) return "suppressed";
367
+ if (prefs?.unsubscribedAll) return "unsubscribed";
211
368
 
212
- if (category && prefs.categories) {
213
- const categories = prefs.categories as Record<string, boolean>;
214
- if (categories[category] === false) return "category_unsubscribed";
369
+ // Registry-aware polarity (§2.6, D3) — applied through the SINGLE source of
370
+ // truth `ListRegistry.isSubscribed` so it matches the preference center EXACTLY
371
+ // (categories default to `{}` when there is NO prefs row or NO categories map).
372
+ // This MUST run even when the row is absent/empty: an opt-out list
373
+ // (`defaultOptIn:false`) requires `categories[id] === true` to be subscribed,
374
+ // so absence-of-true (the common "never opted in" case) MUST block — otherwise
375
+ // a contact the preference center shows as "Unsubscribed" would still receive
376
+ // the mail (the two surfaces would disagree, which §2.6 forbids).
377
+ if (category) {
378
+ const categories = (prefs?.categories ?? {}) as Record<string, boolean>;
379
+ if (!getListRegistry().isSubscribed(categories, category)) {
380
+ return "category_unsubscribed";
381
+ }
215
382
  }
216
383
 
217
384
  return null;
@@ -10,6 +10,8 @@ interface EmailSendContext {
10
10
  userId: string;
11
11
  userEmail: string;
12
12
  templateKey: string | null;
13
+ resendId: string | null;
14
+ to: string;
13
15
  }
14
16
 
15
17
  export async function resolveEmailSendContext(
@@ -20,6 +22,7 @@ export async function resolveEmailSendContext(
20
22
  .select({
21
23
  toEmail: emailSends.toEmail,
22
24
  templateKey: emailSends.templateKey,
25
+ resendId: emailSends.resendId,
23
26
  userId: journeyStates.userId,
24
27
  userEmail: journeyStates.userEmail,
25
28
  })
@@ -35,6 +38,58 @@ export async function resolveEmailSendContext(
35
38
  userId: row.userId ?? row.toEmail,
36
39
  userEmail: row.userEmail ?? row.toEmail,
37
40
  templateKey: row.templateKey,
41
+ resendId: row.resendId,
42
+ to: row.toEmail,
43
+ };
44
+ }
45
+
46
+ export interface ResendEmailSendContext {
47
+ emailSendId: string;
48
+ userId: string;
49
+ userEmail: string;
50
+ templateKey: string | null;
51
+ to: string;
52
+ }
53
+
54
+ /**
55
+ * Mirror of {@link resolveEmailSendContext} that resolves by the provider's
56
+ * `resendId` instead of the internal `email_sends.id`. Used by the
57
+ * provider-webhook enrichment path (`email.delivered`/`email.bounced`) where the
58
+ * only handle we hold is the Resend `email_id`.
59
+ *
60
+ * Returns the internal `emailSendId` plus the same denormalized identity
61
+ * (`userId`/`userEmail` fall back to the recipient address, exactly like the
62
+ * 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).
64
+ */
65
+ export async function resolveEmailSendContextByResendId(
66
+ db: Database,
67
+ resendId: string,
68
+ ): Promise<ResendEmailSendContext | null> {
69
+ const rows = await db
70
+ .select({
71
+ emailSendId: emailSends.id,
72
+ toEmail: emailSends.toEmail,
73
+ templateKey: emailSends.templateKey,
74
+ userId: journeyStates.userId,
75
+ userEmail: journeyStates.userEmail,
76
+ sendUserId: emailSends.userId,
77
+ sendUserEmail: emailSends.userEmail,
78
+ })
79
+ .from(emailSends)
80
+ .leftJoin(journeyStates, eq(emailSends.journeyStateId, journeyStates.id))
81
+ .where(eq(emailSends.resendId, resendId))
82
+ .limit(1);
83
+
84
+ const row = rows[0];
85
+ if (!row) return null;
86
+
87
+ return {
88
+ emailSendId: row.emailSendId,
89
+ userId: row.userId ?? row.sendUserId ?? row.toEmail,
90
+ userEmail: row.userEmail ?? row.sendUserEmail ?? row.toEmail,
91
+ templateKey: row.templateKey,
92
+ to: row.toEmail,
38
93
  };
39
94
  }
40
95
 
@@ -47,6 +102,13 @@ export interface PushTrackingEventOpts {
47
102
  event: string;
48
103
  emailSendId: string;
49
104
  properties?: Record<string, unknown>;
105
+ /**
106
+ * Pre-resolved send context. When provided (including `null`), the duplicate
107
+ * `resolveEmailSendContext` read is skipped — the tracking routes resolve once
108
+ * and share the result with the outbound emit on the hot path. Omit to resolve
109
+ * lazily.
110
+ */
111
+ resolvedContext?: EmailSendContext | null;
50
112
  }
51
113
 
52
114
  export async function pushTrackingEvent(
@@ -54,7 +116,10 @@ export async function pushTrackingEvent(
54
116
  ): Promise<void> {
55
117
  const { db, hatchet, registry, logger, posthog, event, emailSendId } = opts;
56
118
 
57
- const ctx = await resolveEmailSendContext(db, emailSendId);
119
+ const ctx =
120
+ opts.resolvedContext !== undefined
121
+ ? opts.resolvedContext
122
+ : await resolveEmailSendContext(db, emailSendId);
58
123
  if (!ctx) return;
59
124
 
60
125
  const properties: Record<string, unknown> = {
@@ -78,7 +143,7 @@ export async function pushTrackingEvent(
78
143
  event,
79
144
  userId: ctx.userId,
80
145
  userEmail: ctx.userEmail,
81
- properties,
146
+ eventProperties: properties,
82
147
  },
83
148
  });
84
149
  }
@@ -0,0 +1,151 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { Webhook } from "svix";
3
+
4
+ /**
5
+ * Outbound webhook signing core.
6
+ *
7
+ * Hogsend emits a Svix-style HMAC-SHA256 signed event stream. The signing
8
+ * scheme is the Standard Webhooks spec (the same one `svix` implements and that
9
+ * `plugin-resend` consumes for inbound Resend webhooks):
10
+ *
11
+ * signedContent = `${id}.${timestampSeconds}.${body}`
12
+ * signature = base64( HMAC_SHA256( base64decode(secret without `whsec_`), signedContent ) )
13
+ * header value = `v1,${signature}`
14
+ *
15
+ * Pure `node:crypto` equivalent (documented for the SDK / spec consumers and
16
+ * subscriber-side verification without a `svix` dependency):
17
+ *
18
+ * import { createHmac, timingSafeEqual } from "node:crypto";
19
+ * const key = Buffer.from(secret.slice(6), "base64"); // drop the `whsec_` prefix
20
+ * const sig = createHmac("sha256", key)
21
+ * .update(`${id}.${ts}.${body}`)
22
+ * .digest("base64");
23
+ * const header = `v1,${sig}`;
24
+ * // compare each space-delimited `v1,<sig>` candidate with timingSafeEqual.
25
+ */
26
+
27
+ /**
28
+ * The 12-event catalog — the SINGLE source of truth (schema, routes, client,
29
+ * CLI all derive from this). The `webhook.test` sentinel is intentionally NOT a
30
+ * member (it is delivered out-of-band regardless of an endpoint's `eventTypes`).
31
+ */
32
+ export const WEBHOOK_EVENT_TYPES = [
33
+ "contact.created",
34
+ "contact.updated",
35
+ "contact.deleted",
36
+ "contact.unsubscribed",
37
+ "email.sent",
38
+ "email.delivered",
39
+ "email.opened",
40
+ "email.clicked",
41
+ "email.bounced",
42
+ "journey.completed",
43
+ "bucket.entered",
44
+ "bucket.left",
45
+ ] as const;
46
+
47
+ export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number];
48
+
49
+ /**
50
+ * Generate a new `whsec_<base64(32 bytes)>` signing secret plus its display
51
+ * prefix (safe to surface on list/get responses).
52
+ *
53
+ * NOTE: the secret body is STANDARD base64, not base64url. svix (via
54
+ * standardwebhooks → @stablelib/base64) strips the `whsec_` prefix and decodes
55
+ * the remainder with a STRICT standard-base64 decoder that rejects the `-`/`_`
56
+ * characters base64url emits (~74% of base64url secrets contain one and fail
57
+ * `new Webhook(secret)`). Standard base64 round-trips cleanly through both svix
58
+ * and the `node:crypto` fallback (`Buffer.from(secret.slice(6), "base64")`).
59
+ */
60
+ export function generateWebhookSecret(): {
61
+ secret: string;
62
+ secretPrefix: string;
63
+ } {
64
+ const secret = `whsec_${randomBytes(32).toString("base64")}`;
65
+ return { secret, secretPrefix: secret.slice(0, 12) };
66
+ }
67
+
68
+ export interface SignedWebhook {
69
+ headers: {
70
+ "Webhook-Id": string;
71
+ "Webhook-Timestamp": string;
72
+ "Webhook-Signature": string;
73
+ "Content-Type": "application/json";
74
+ };
75
+ /**
76
+ * The EXACT bytes that were signed AND must be sent. Never re-stringify the
77
+ * payload between signing and sending — the signature covers these bytes.
78
+ */
79
+ body: string;
80
+ }
81
+
82
+ /**
83
+ * Sign an outbound webhook payload, producing the Standard Webhooks header set
84
+ * (`Webhook-Id` / `Webhook-Timestamp` / `Webhook-Signature`) plus the exact
85
+ * `body` bytes that were signed.
86
+ *
87
+ * `timestamp` is unix SECONDS — the caller passes `Math.floor(Date.now()/1000)`.
88
+ * `payload` is JSON-stringified when an object; a string is used verbatim.
89
+ */
90
+ export function signWebhook(opts: {
91
+ id: string;
92
+ timestamp: number;
93
+ payload: unknown;
94
+ secret: string;
95
+ }): SignedWebhook {
96
+ const body =
97
+ typeof opts.payload === "string"
98
+ ? opts.payload
99
+ : JSON.stringify(opts.payload);
100
+
101
+ const wh = new Webhook(opts.secret);
102
+ // svix's `sign` takes the timestamp as a Date and floors it to seconds
103
+ // internally; pass the canonical seconds back through a Date to keep the exact
104
+ // value the caller intended.
105
+ const signature = wh.sign(opts.id, new Date(opts.timestamp * 1000), body);
106
+
107
+ return {
108
+ headers: {
109
+ "Webhook-Id": opts.id,
110
+ "Webhook-Timestamp": String(opts.timestamp),
111
+ "Webhook-Signature": signature,
112
+ "Content-Type": "application/json",
113
+ },
114
+ body,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Consumer/test-facing verification of an inbound Hogsend webhook. Enforces the
120
+ * 5-minute timestamp tolerance and uses a constant-time signature compare (both
121
+ * inside svix). Throws on a bad signature or stale timestamp.
122
+ *
123
+ * Accepts either Title-Case (`Webhook-Id`) or lowercase (`webhook-id`) header
124
+ * keys — and the `svix-*` aliases — by normalizing the header map first.
125
+ */
126
+ export function verifyWebhookSignature(opts: {
127
+ payload: string;
128
+ headers: Record<string, string>;
129
+ secret: string;
130
+ }): unknown {
131
+ const lowered: Record<string, string> = {};
132
+ for (const [key, value] of Object.entries(opts.headers)) {
133
+ lowered[key.toLowerCase()] = value;
134
+ }
135
+
136
+ // Coalesce to "" so a genuinely-absent header reaches svix as an empty
137
+ // string — svix then throws its own clear "Missing required header" rather
138
+ // than a type error here.
139
+ const id = lowered["webhook-id"] ?? lowered["svix-id"] ?? "";
140
+ const timestamp =
141
+ lowered["webhook-timestamp"] ?? lowered["svix-timestamp"] ?? "";
142
+ const signature =
143
+ lowered["webhook-signature"] ?? lowered["svix-signature"] ?? "";
144
+
145
+ const wh = new Webhook(opts.secret);
146
+ return wh.verify(opts.payload, {
147
+ "webhook-id": id,
148
+ "webhook-timestamp": timestamp,
149
+ "webhook-signature": signature,
150
+ });
151
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Email lists (D3) — code-defined subscription categories layered on top of the
3
+ * existing `email_preferences.categories` JSONB. A list is just a named category
4
+ * with a declared default polarity (`defaultOptIn`); there is NO new table.
5
+ *
6
+ * `defineList()` is the authoring entry point, mirroring `defineBucket()` /
7
+ * `defineJourney()`: a synchronous, definition-time call that validates the id
8
+ * and returns a `DefinedList`. The id shares the `email_preferences.categories`
9
+ * key namespace, so two ids are RESERVED for the engine's own non-list
10
+ * categories (`transactional`, `journey`) and rejected here (open risk #11).
11
+ */
12
+
13
+ /** A list id can collide with these built-in non-list categories — reject. */
14
+ const RESERVED_LIST_IDS = new Set(["transactional", "journey"]);
15
+
16
+ /** Allowed list-id shape: alphanumerics, dash, underscore (case-insensitive). */
17
+ const LIST_ID_PATTERN = /^[a-z0-9_-]+$/i;
18
+
19
+ /**
20
+ * The validated, fully-defaulted list metadata. `enabled` is always present
21
+ * after `defineList` (defaults to `true`); authoring input leaves it optional.
22
+ */
23
+ export interface ListMeta<Id extends string = string> {
24
+ id: Id;
25
+ name: string;
26
+ description?: string;
27
+ defaultOptIn: boolean;
28
+ enabled: boolean;
29
+ }
30
+
31
+ /**
32
+ * A defined list. `meta` carries the canonical, defaulted metadata; `id` is
33
+ * surfaced directly for literal-typed consumption (mirrors `DefinedBucket`).
34
+ */
35
+ export interface DefinedList<Id extends string = string> {
36
+ readonly meta: ListMeta<Id>;
37
+ readonly id: Id;
38
+ }
39
+
40
+ /**
41
+ * Define an email list. Validates the id against {@link LIST_ID_PATTERN} and the
42
+ * {@link RESERVED_LIST_IDS} blocklist (since list ids share the
43
+ * `email_preferences.categories` namespace), then returns a `DefinedList` with
44
+ * `enabled` defaulted to `true`.
45
+ *
46
+ * @throws if `id` is empty, malformed, or a reserved category id.
47
+ */
48
+ export function defineList<const Id extends string>(meta: {
49
+ id: Id;
50
+ name: string;
51
+ description?: string;
52
+ defaultOptIn: boolean;
53
+ enabled?: boolean;
54
+ }): DefinedList<Id> {
55
+ const { id, name, description, defaultOptIn, enabled } = meta;
56
+
57
+ if (!id || !LIST_ID_PATTERN.test(id)) {
58
+ throw new Error(
59
+ `Invalid list id "${id}": must match /^[a-z0-9_-]+$/i (letters, digits, "-", "_").`,
60
+ );
61
+ }
62
+
63
+ if (RESERVED_LIST_IDS.has(id.toLowerCase())) {
64
+ throw new Error(
65
+ `Reserved list id "${id}": "transactional" and "journey" are built-in email-preference categories and cannot be used as list ids.`,
66
+ );
67
+ }
68
+
69
+ const resolvedMeta: ListMeta<Id> = {
70
+ id,
71
+ name,
72
+ ...(description !== undefined ? { description } : {}),
73
+ defaultOptIn,
74
+ enabled: enabled ?? true,
75
+ };
76
+
77
+ return {
78
+ meta: resolvedMeta,
79
+ id,
80
+ };
81
+ }