@hogsend/engine 0.7.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/env.ts +25 -0
- package/src/index.ts +34 -1
- package/src/journeys/define-journey.ts +26 -2
- package/src/lib/bucket-emit.ts +45 -0
- package/src/lib/contacts.ts +28 -6
- package/src/lib/mailer.ts +87 -0
- package/src/lib/outbound.ts +216 -0
- package/src/lib/preferences.ts +31 -0
- package/src/lib/tracked.ts +45 -3
- package/src/lib/tracking-events.ts +66 -1
- package/src/lib/webhook-signing.ts +151 -0
- package/src/routes/admin/contacts.ts +43 -3
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/webhooks.ts +466 -0
- package/src/routes/contacts/index.ts +48 -5
- package/src/routes/lists/index.ts +41 -5
- 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 +6 -0
- package/src/workflows/deliver-webhook.ts +399 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
3
|
+
import {
|
|
4
|
+
type Database,
|
|
5
|
+
webhookDeliveries,
|
|
6
|
+
webhookEndpoints,
|
|
7
|
+
} from "@hogsend/db";
|
|
8
|
+
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
9
|
+
import { deliverWebhookTask } from "../workflows/deliver-webhook.js";
|
|
10
|
+
import type { SerializedContact } from "./contacts.js";
|
|
11
|
+
import type { Logger } from "./logger.js";
|
|
12
|
+
import {
|
|
13
|
+
WEBHOOK_EVENT_TYPES,
|
|
14
|
+
type WebhookEventType,
|
|
15
|
+
} from "./webhook-signing.js";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
type ResendEmailSendContext,
|
|
19
|
+
resolveEmailSendContextByResendId,
|
|
20
|
+
} from "./tracking-events.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The outbound event catalog re-exported for the emit spine. Identical to the
|
|
24
|
+
* signing lib's {@link WEBHOOK_EVENT_TYPES} — the single source of truth.
|
|
25
|
+
*/
|
|
26
|
+
export const OUTBOUND_EVENTS = WEBHOOK_EVENT_TYPES;
|
|
27
|
+
export type OutboundEventName = WebhookEventType;
|
|
28
|
+
|
|
29
|
+
interface EmailEventPayload {
|
|
30
|
+
emailSendId: string;
|
|
31
|
+
resendId: string | null;
|
|
32
|
+
templateKey: string | null;
|
|
33
|
+
userId: string | null;
|
|
34
|
+
to: string;
|
|
35
|
+
at: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface BucketEventPayload {
|
|
39
|
+
bucketId: string;
|
|
40
|
+
bucketName: string;
|
|
41
|
+
userId: string;
|
|
42
|
+
userEmail: string | null;
|
|
43
|
+
transition: "entered" | "left";
|
|
44
|
+
entryCount: number;
|
|
45
|
+
source: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The typed per-event payload map. `data` in each delivered envelope is exactly
|
|
50
|
+
* `OutboundPayloads[E]` for the emitted event `E`. Producers (the 12 hook
|
|
51
|
+
* points) construct these; subscribers receive them under `envelope.data`.
|
|
52
|
+
*/
|
|
53
|
+
export interface OutboundPayloads {
|
|
54
|
+
"contact.created": SerializedContact;
|
|
55
|
+
"contact.updated": SerializedContact;
|
|
56
|
+
"contact.deleted": {
|
|
57
|
+
id: string;
|
|
58
|
+
externalId: string | null;
|
|
59
|
+
email: string | null;
|
|
60
|
+
};
|
|
61
|
+
"contact.unsubscribed": {
|
|
62
|
+
externalId: string | null;
|
|
63
|
+
email: string | null;
|
|
64
|
+
category: string | null;
|
|
65
|
+
scope: "all" | "category";
|
|
66
|
+
};
|
|
67
|
+
"email.sent": {
|
|
68
|
+
emailSendId: string;
|
|
69
|
+
resendId: string;
|
|
70
|
+
templateKey: string | null;
|
|
71
|
+
to: string;
|
|
72
|
+
userId: string | null;
|
|
73
|
+
category: string | null;
|
|
74
|
+
journeyStateId: string | null;
|
|
75
|
+
subject: string;
|
|
76
|
+
sentAt: string;
|
|
77
|
+
};
|
|
78
|
+
"email.delivered": EmailEventPayload;
|
|
79
|
+
"email.opened": EmailEventPayload;
|
|
80
|
+
"email.clicked": EmailEventPayload & { linkUrl?: string; linkId?: string };
|
|
81
|
+
"email.bounced": EmailEventPayload & {
|
|
82
|
+
bounceType?: string;
|
|
83
|
+
bounceReason?: string;
|
|
84
|
+
};
|
|
85
|
+
"journey.completed": {
|
|
86
|
+
journeyId: string;
|
|
87
|
+
journeyName: string;
|
|
88
|
+
stateId: string;
|
|
89
|
+
userId: string;
|
|
90
|
+
userEmail: string;
|
|
91
|
+
completedAt: string;
|
|
92
|
+
};
|
|
93
|
+
"bucket.entered": BucketEventPayload;
|
|
94
|
+
"bucket.left": BucketEventPayload & { reason?: string };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The signed envelope shape written to `webhook_deliveries.payload` and sent
|
|
99
|
+
* verbatim to subscribers. `id` is the shared `Webhook-Id`; `timestamp` is the
|
|
100
|
+
* logical-event time (ISO); `data` is the typed per-event payload.
|
|
101
|
+
*/
|
|
102
|
+
interface OutboundEnvelope<E extends OutboundEventName> {
|
|
103
|
+
id: string;
|
|
104
|
+
type: E;
|
|
105
|
+
timestamp: string;
|
|
106
|
+
data: OutboundPayloads[E];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* THE fire-and-forget emit spine. It does NOT deliver — it selects the active,
|
|
111
|
+
* subscribed endpoints for `event`, inserts one `webhook_deliveries` row per
|
|
112
|
+
* endpoint (all sharing ONE `webhookId` = the `Webhook-Id` header), and enqueues
|
|
113
|
+
* the durable {@link deliverWebhookTask} per inserted row.
|
|
114
|
+
*
|
|
115
|
+
* Idempotency: when `dedupeKey` is provided, the unique
|
|
116
|
+
* `(endpointId, dedupeKey)` index makes a re-emit (e.g. a Hatchet retry of the
|
|
117
|
+
* producing task) a no-op via `onConflictDoNothing` — no duplicate row, no
|
|
118
|
+
* second enqueue. Events without a `dedupeKey` are never deduped (NULL keys are
|
|
119
|
+
* distinct in Postgres), which is correct for non-retryable emit points.
|
|
120
|
+
*
|
|
121
|
+
* NEVER throws to callers. Internal failures (endpoint select, insert, enqueue)
|
|
122
|
+
* are logged via `logger.warn` and swallowed so a transient outbound error can
|
|
123
|
+
* never fail a contact upsert / email send / journey step. Callers MUST STILL
|
|
124
|
+
* wrap the call as `void emitOutbound(...).catch(logger.warn)` — the `.catch` is
|
|
125
|
+
* defence-in-depth against a programming error that escapes this guard.
|
|
126
|
+
*
|
|
127
|
+
* Single-tenant: only endpoints with `organizationId IS NULL` are selected and
|
|
128
|
+
* the delivery rows are written with `organizationId ?? null`. Multi-tenant
|
|
129
|
+
* scoping is a later non-breaking change.
|
|
130
|
+
*/
|
|
131
|
+
export async function emitOutbound<E extends OutboundEventName>(opts: {
|
|
132
|
+
db: Database;
|
|
133
|
+
hatchet: HatchetClient;
|
|
134
|
+
logger: Logger;
|
|
135
|
+
event: E;
|
|
136
|
+
payload: OutboundPayloads[E];
|
|
137
|
+
dedupeKey?: string;
|
|
138
|
+
organizationId?: string | null;
|
|
139
|
+
}): Promise<void> {
|
|
140
|
+
const { db, logger, event, payload, dedupeKey } = opts;
|
|
141
|
+
const organizationId = opts.organizationId ?? null;
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const webhookId = `msg_${randomUUID()}`;
|
|
145
|
+
const timestamp = new Date();
|
|
146
|
+
|
|
147
|
+
// (2) Active, subscribed endpoints. `event_types @> '["<event>"]'` matches
|
|
148
|
+
// the jsonb array containing this event. Single-tenant: organizationId IS
|
|
149
|
+
// NULL (NOT a hardcoded tenant — keeps the MT wiring non-breaking).
|
|
150
|
+
const endpoints = await db
|
|
151
|
+
.select({ id: webhookEndpoints.id })
|
|
152
|
+
.from(webhookEndpoints)
|
|
153
|
+
.where(
|
|
154
|
+
and(
|
|
155
|
+
eq(webhookEndpoints.disabled, false),
|
|
156
|
+
isNull(webhookEndpoints.organizationId),
|
|
157
|
+
sql`${webhookEndpoints.eventTypes} @> ${JSON.stringify([event])}::jsonb`,
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
if (endpoints.length === 0) return;
|
|
162
|
+
|
|
163
|
+
// (3) The frozen envelope — signed + sent verbatim by the delivery task.
|
|
164
|
+
const envelope: OutboundEnvelope<E> = {
|
|
165
|
+
id: webhookId,
|
|
166
|
+
type: event,
|
|
167
|
+
timestamp: timestamp.toISOString(),
|
|
168
|
+
data: payload,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// (4) One delivery row per endpoint, sharing the webhookId. onConflictDoNothing
|
|
172
|
+
// on (endpointId, dedupeKey) is the producer-side fan-out idempotency guard.
|
|
173
|
+
const inserted = await db
|
|
174
|
+
.insert(webhookDeliveries)
|
|
175
|
+
.values(
|
|
176
|
+
endpoints.map((endpoint) => ({
|
|
177
|
+
endpointId: endpoint.id,
|
|
178
|
+
organizationId,
|
|
179
|
+
webhookId,
|
|
180
|
+
eventType: event,
|
|
181
|
+
dedupeKey: dedupeKey ?? null,
|
|
182
|
+
payload: envelope as unknown as Record<string, unknown>,
|
|
183
|
+
status: "pending" as const,
|
|
184
|
+
attemptCount: 0,
|
|
185
|
+
nextRetryAt: timestamp,
|
|
186
|
+
})),
|
|
187
|
+
)
|
|
188
|
+
.onConflictDoNothing({
|
|
189
|
+
target: [webhookDeliveries.endpointId, webhookDeliveries.dedupeKey],
|
|
190
|
+
})
|
|
191
|
+
.returning({ id: webhookDeliveries.id });
|
|
192
|
+
|
|
193
|
+
// (5) Enqueue the durable delivery task per freshly-inserted row,
|
|
194
|
+
// fire-and-forget. A failed enqueue is recovered by the reaper (the row is
|
|
195
|
+
// already `pending` with `nextRetryAt <= now`), so a broker hiccup here only
|
|
196
|
+
// delays — never drops — a delivery.
|
|
197
|
+
for (const row of inserted) {
|
|
198
|
+
void deliverWebhookTask
|
|
199
|
+
.runNoWait({ deliveryId: row.id })
|
|
200
|
+
.catch((error: unknown) => {
|
|
201
|
+
logger.warn("emitOutbound: deliverWebhookTask enqueue failed", {
|
|
202
|
+
deliveryId: row.id,
|
|
203
|
+
event,
|
|
204
|
+
error: error instanceof Error ? error.message : String(error),
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// FAIL-SAFE: never propagate an outbound error onto the producer's hot path.
|
|
210
|
+
logger.warn("emitOutbound failed", {
|
|
211
|
+
event,
|
|
212
|
+
dedupeKey,
|
|
213
|
+
error: error instanceof Error ? error.message : String(error),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/lib/preferences.ts
CHANGED
|
@@ -2,6 +2,11 @@ import type { Database } from "@hogsend/db";
|
|
|
2
2
|
import { emailPreferences } from "@hogsend/db";
|
|
3
3
|
import { sql } from "drizzle-orm";
|
|
4
4
|
import { resolveRecipient } from "./contacts.js";
|
|
5
|
+
import { hatchet } from "./hatchet.js";
|
|
6
|
+
import { createLogger } from "./logger.js";
|
|
7
|
+
import { emitOutbound } from "./outbound.js";
|
|
8
|
+
|
|
9
|
+
const logger = createLogger(process.env.LOG_LEVEL);
|
|
5
10
|
|
|
6
11
|
/**
|
|
7
12
|
* Single source of truth for an `email_preferences` upsert: the `(user_id, email)`
|
|
@@ -61,6 +66,32 @@ export async function upsertEmailPreference(opts: {
|
|
|
61
66
|
target: [emailPreferences.userId, emailPreferences.email],
|
|
62
67
|
set: setClause,
|
|
63
68
|
});
|
|
69
|
+
|
|
70
|
+
// OUTBOUND `contact.unsubscribed` — this is the SINGLE choke for ALL preference
|
|
71
|
+
// writes (token unsub, preference center, list-membership flips), so the emit
|
|
72
|
+
// lives here once. GATED to a genuine opt-OUT only: a full unsubscribe
|
|
73
|
+
// (`unsubscribedAll === true`) or a category flip to false. A resubscribe
|
|
74
|
+
// (`unsubscribedAll === false` / `categoryValue === true`) does NOT emit. Uses
|
|
75
|
+
// the engine `hatchet`/`logger` singletons (this lib has no request container);
|
|
76
|
+
// fire-and-forget so a transient outbound error never fails the pref write.
|
|
77
|
+
const isUnsubscribe =
|
|
78
|
+
update.unsubscribedAll === true || update.categoryValue === false;
|
|
79
|
+
if (isUnsubscribe) {
|
|
80
|
+
const scope: "all" | "category" =
|
|
81
|
+
update.unsubscribedAll === true ? "all" : "category";
|
|
82
|
+
void emitOutbound({
|
|
83
|
+
db,
|
|
84
|
+
hatchet,
|
|
85
|
+
logger,
|
|
86
|
+
event: "contact.unsubscribed",
|
|
87
|
+
payload: {
|
|
88
|
+
externalId,
|
|
89
|
+
email,
|
|
90
|
+
category: update.categoryKey ?? null,
|
|
91
|
+
scope,
|
|
92
|
+
},
|
|
93
|
+
}).catch(logger.warn);
|
|
94
|
+
}
|
|
64
95
|
}
|
|
65
96
|
|
|
66
97
|
/**
|
package/src/lib/tracked.ts
CHANGED
|
@@ -20,7 +20,15 @@ import type {
|
|
|
20
20
|
TrackedSendResult,
|
|
21
21
|
} from "./email-service-types.js";
|
|
22
22
|
import { isFrequencyCapped } from "./frequency-cap.js";
|
|
23
|
-
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);
|
|
24
32
|
|
|
25
33
|
export type PrepareTrackedHtmlFn = (opts: {
|
|
26
34
|
html: string;
|
|
@@ -270,16 +278,50 @@ export async function sendTrackedEmail<K extends TemplateName>(
|
|
|
270
278
|
replyTo: options.replyTo,
|
|
271
279
|
});
|
|
272
280
|
|
|
281
|
+
const sentAt = new Date();
|
|
273
282
|
await db
|
|
274
283
|
.update(emailSends)
|
|
275
284
|
.set({
|
|
276
285
|
resendId: result.id,
|
|
277
286
|
status: "sent",
|
|
278
|
-
sentAt
|
|
279
|
-
updatedAt:
|
|
287
|
+
sentAt,
|
|
288
|
+
updatedAt: sentAt,
|
|
280
289
|
})
|
|
281
290
|
.where(eq(emailSends.id, emailSendId));
|
|
282
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
|
+
|
|
283
325
|
return {
|
|
284
326
|
emailSendId,
|
|
285
327
|
resendId: result.id,
|
|
@@ -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> = {
|
|
@@ -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
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
serializeContact as serializeContactRow,
|
|
10
10
|
serializePrefs,
|
|
11
11
|
} from "../../lib/contacts.js";
|
|
12
|
+
import { emitOutbound } from "../../lib/outbound.js";
|
|
12
13
|
|
|
13
14
|
const contactSchema = z.object({
|
|
14
15
|
id: z.string(),
|
|
@@ -262,13 +263,18 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
|
|
|
262
263
|
);
|
|
263
264
|
})
|
|
264
265
|
.openapi(createRoute_, async (c) => {
|
|
265
|
-
const { db } = c.get("container");
|
|
266
|
+
const { db, hatchet, logger } = c.get("container");
|
|
266
267
|
const body = c.req.valid("json");
|
|
267
268
|
|
|
268
269
|
// Delegate to the identity resolver (D1): it upserts/merges on the provided
|
|
269
270
|
// identity keys (externalId and/or email), so the hand-rolled existence
|
|
270
271
|
// check + raw insert + 409 are gone (§5). Read the row back to serialize.
|
|
271
|
-
const {
|
|
272
|
+
const {
|
|
273
|
+
id,
|
|
274
|
+
created: wasCreated,
|
|
275
|
+
linked,
|
|
276
|
+
merged,
|
|
277
|
+
} = await resolveOrCreateContact({
|
|
272
278
|
db,
|
|
273
279
|
userId: body.externalId,
|
|
274
280
|
email: body.email,
|
|
@@ -280,10 +286,26 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
|
|
|
280
286
|
throw new Error("Failed to create contact");
|
|
281
287
|
}
|
|
282
288
|
|
|
289
|
+
// INTENT-LAYER outbound emit (decision #3): admin upsert mirrors the public
|
|
290
|
+
// route — `contact.created` on a real creation, `contact.updated` when an
|
|
291
|
+
// existing contact was linked/merged with a non-empty property delta.
|
|
292
|
+
const hadPropertyDelta = Boolean(
|
|
293
|
+
body.properties && Object.keys(body.properties).length > 0,
|
|
294
|
+
);
|
|
295
|
+
if (wasCreated || (linked || merged ? hadPropertyDelta : false)) {
|
|
296
|
+
void emitOutbound({
|
|
297
|
+
db,
|
|
298
|
+
hatchet,
|
|
299
|
+
logger,
|
|
300
|
+
event: wasCreated ? "contact.created" : "contact.updated",
|
|
301
|
+
payload: serializeContactRow(created),
|
|
302
|
+
}).catch(logger.warn);
|
|
303
|
+
}
|
|
304
|
+
|
|
283
305
|
return c.json({ contact: serializeContact(created) }, 201);
|
|
284
306
|
})
|
|
285
307
|
.openapi(updateRoute, async (c) => {
|
|
286
|
-
const { db } = c.get("container");
|
|
308
|
+
const { db, hatchet, logger } = c.get("container");
|
|
287
309
|
const { id } = c.req.valid("param");
|
|
288
310
|
const body = c.req.valid("json");
|
|
289
311
|
|
|
@@ -331,6 +353,24 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
|
|
|
331
353
|
throw new Error("Failed to update contact");
|
|
332
354
|
}
|
|
333
355
|
|
|
356
|
+
// INTENT-LAYER outbound emit (decision #3): the admin update is an explicit
|
|
357
|
+
// edit — emit `contact.updated` on a non-empty property delta or a filled
|
|
358
|
+
// email (a newly-attached identity). Fire-and-forget; the serialized updated
|
|
359
|
+
// row is the catalog payload.
|
|
360
|
+
const hadPropertyDelta = Boolean(
|
|
361
|
+
body.properties && Object.keys(body.properties).length > 0,
|
|
362
|
+
);
|
|
363
|
+
const filledEmail = Boolean(body.email && body.email !== current.email);
|
|
364
|
+
if (hadPropertyDelta || filledEmail) {
|
|
365
|
+
void emitOutbound({
|
|
366
|
+
db,
|
|
367
|
+
hatchet,
|
|
368
|
+
logger,
|
|
369
|
+
event: "contact.updated",
|
|
370
|
+
payload: serializeContactRow(updated),
|
|
371
|
+
}).catch(logger.warn);
|
|
372
|
+
}
|
|
373
|
+
|
|
334
374
|
return c.json({ contact: serializeContact(updated) }, 200);
|
|
335
375
|
})
|
|
336
376
|
.openapi(deleteRoute, async (c) => {
|
|
@@ -20,6 +20,7 @@ import { reportingRouter } from "./reporting.js";
|
|
|
20
20
|
import { suppressionsRouter } from "./suppressions.js";
|
|
21
21
|
import { templatesRouter } from "./templates.js";
|
|
22
22
|
import { timelineRouter } from "./timeline.js";
|
|
23
|
+
import { webhooksRouter } from "./webhooks.js";
|
|
23
24
|
|
|
24
25
|
export const adminRouter = new OpenAPIHono<AppEnv>();
|
|
25
26
|
adminRouter.use("*", requireAdmin);
|
|
@@ -39,6 +40,7 @@ adminRouter.route("/reporting", reportingRouter);
|
|
|
39
40
|
adminRouter.route("/templates", templatesRouter);
|
|
40
41
|
adminRouter.route("/suppressions", suppressionsRouter);
|
|
41
42
|
adminRouter.route("/api-keys", apiKeysRouter);
|
|
43
|
+
adminRouter.route("/webhooks", webhooksRouter);
|
|
42
44
|
adminRouter.route("/audit-logs", auditLogsRouter);
|
|
43
45
|
adminRouter.route("/alerts", alertsRouter);
|
|
44
46
|
adminRouter.route("/dlq", dlqRouter);
|