@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.
- package/package.json +7 -6
- package/src/app.ts +36 -1
- package/src/buckets/check-membership.ts +34 -15
- package/src/container.ts +33 -0
- package/src/env.ts +29 -0
- package/src/index.ts +47 -1
- package/src/journeys/define-journey.ts +26 -2
- package/src/journeys/journey-context.ts +5 -1
- package/src/lib/boot.ts +1 -1
- package/src/lib/bucket-emit.ts +47 -2
- package/src/lib/contacts.ts +1105 -18
- package/src/lib/email-service-types.ts +8 -0
- package/src/lib/ingestion.ts +63 -33
- package/src/lib/mailer.ts +88 -0
- package/src/lib/outbound.ts +216 -0
- package/src/lib/preferences.ts +137 -0
- package/src/lib/tracked.ts +204 -37
- package/src/lib/tracking-events.ts +67 -2
- package/src/lib/webhook-signing.ts +151 -0
- package/src/lists/define-list.ts +81 -0
- package/src/lists/registry-singleton.ts +39 -0
- package/src/lists/registry.ts +95 -0
- package/src/middleware/api-key.ts +33 -7
- package/src/middleware/rate-limit.ts +73 -49
- package/src/routes/_shared.ts +30 -0
- package/src/routes/admin/api-keys.ts +1 -1
- package/src/routes/admin/bulk.ts +7 -3
- package/src/routes/admin/contacts.ts +108 -59
- package/src/routes/admin/events.ts +65 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/journeys.ts +3 -1
- package/src/routes/admin/preferences.ts +2 -2
- package/src/routes/admin/reporting.ts +3 -3
- package/src/routes/admin/timeline.ts +5 -2
- package/src/routes/admin/webhooks.ts +466 -0
- package/src/routes/campaigns/index.ts +252 -0
- package/src/routes/contacts/index.ts +231 -0
- package/src/routes/email/preferences.ts +27 -3
- package/src/routes/email/unsubscribe.ts +7 -49
- package/src/routes/emails/index.ts +133 -0
- package/src/routes/events/index.ts +119 -0
- package/src/routes/index.ts +52 -2
- package/src/routes/lists/index.ts +258 -0
- package/src/routes/tracking/click.ts +59 -18
- package/src/routes/tracking/open.ts +62 -24
- package/src/routes/webhooks/sources.ts +69 -10
- package/src/webhook-sources/define-webhook-source.ts +57 -5
- package/src/webhook-sources/presets/clerk.ts +185 -0
- package/src/webhook-sources/presets/index.ts +80 -0
- package/src/webhook-sources/presets/segment.ts +120 -0
- package/src/webhook-sources/presets/stripe.ts +147 -0
- package/src/webhook-sources/presets/supabase.ts +131 -0
- package/src/webhook-sources/verify.ts +172 -0
- package/src/worker.ts +12 -0
- package/src/workflows/bucket-backfill.ts +32 -21
- package/src/workflows/bucket-reconcile.ts +20 -5
- package/src/workflows/deliver-webhook.ts +399 -0
- package/src/workflows/import-contacts.ts +28 -20
- package/src/workflows/send-campaign.ts +589 -0
- package/src/routes/ingest.ts +0 -71
package/src/lib/tracked.ts
CHANGED
|
@@ -7,15 +7,28 @@ import type {
|
|
|
7
7
|
TemplateName,
|
|
8
8
|
TemplateRegistry,
|
|
9
9
|
} from "@hogsend/email";
|
|
10
|
-
import {
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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(
|
|
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:
|
|
275
|
+
...(html ? { html } : { react: sendElement }),
|
|
158
276
|
tags: options.tags,
|
|
159
|
-
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
|
|
169
|
-
updatedAt:
|
|
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
|
|
210
|
-
if (prefs
|
|
366
|
+
if (prefs?.suppressed) return "suppressed";
|
|
367
|
+
if (prefs?.unsubscribedAll) return "unsubscribed";
|
|
211
368
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 =
|
|
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
|
+
}
|