@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
|
@@ -53,6 +53,12 @@ export interface SendTrackedEmailOptions<
|
|
|
53
53
|
replyTo?: string | string[];
|
|
54
54
|
skipPreferenceCheck?: boolean;
|
|
55
55
|
baseUrl?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Caller-supplied idempotency key (POST /v1/emails). A retry with the same key
|
|
58
|
+
* short-circuits to the prior `email_sends` row instead of dispatching a
|
|
59
|
+
* duplicate provider send.
|
|
60
|
+
*/
|
|
61
|
+
idempotencyKey?: string;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
export interface TrackedSendResult {
|
|
@@ -128,6 +134,8 @@ export interface EmailServiceSendOptions<
|
|
|
128
134
|
headers?: Record<string, string>;
|
|
129
135
|
replyTo?: string | string[];
|
|
130
136
|
skipPreferenceCheck?: boolean;
|
|
137
|
+
/** Caller-supplied idempotency key (POST /v1/emails) — dedups duplicate sends. */
|
|
138
|
+
idempotencyKey?: string;
|
|
131
139
|
}
|
|
132
140
|
|
|
133
141
|
export interface EmailServiceWebhookOptions {
|
package/src/lib/ingestion.ts
CHANGED
|
@@ -4,15 +4,27 @@ import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
|
4
4
|
import { type Database, journeyStates, userEvents } from "@hogsend/db";
|
|
5
5
|
import { and, eq, inArray, isNull } from "drizzle-orm";
|
|
6
6
|
import { checkBucketMembership } from "../buckets/check-membership.js";
|
|
7
|
-
import {
|
|
7
|
+
import { resolveOrCreateContact } from "./contacts.js";
|
|
8
8
|
import type { Logger } from "./logger.js";
|
|
9
9
|
|
|
10
10
|
export interface IngestEvent {
|
|
11
11
|
event: string;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/** D1: optional — email-only / anonymous events resolve a key downstream. */
|
|
13
|
+
userId?: string;
|
|
14
|
+
userEmail?: string;
|
|
15
|
+
/** D1: future anonymous→identified path. Threaded into the resolver. */
|
|
16
|
+
anonymousId?: string;
|
|
17
|
+
/** D2: → `user_events` + Hatchet `trigger.where`/`exitOn` ONLY. */
|
|
18
|
+
eventProperties: Record<string, unknown>;
|
|
19
|
+
/** D2: → `contacts.properties` merge ONLY. */
|
|
20
|
+
contactProperties?: Record<string, unknown>;
|
|
15
21
|
idempotencyKey?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Caller-supplied event time (§2.5 `timestamp`). When set, `user_events`
|
|
24
|
+
* `occurred_at` is stamped from it (backfill/replay) instead of defaulting to
|
|
25
|
+
* the ingest instant. Accepts a `Date` or an ISO-8601 string.
|
|
26
|
+
*/
|
|
27
|
+
occurredAt?: Date | string;
|
|
16
28
|
}
|
|
17
29
|
|
|
18
30
|
export interface ExitResult {
|
|
@@ -35,14 +47,36 @@ export async function ingestEvent(opts: {
|
|
|
35
47
|
}): Promise<IngestResult> {
|
|
36
48
|
const { db, registry, hatchet, logger, event } = opts;
|
|
37
49
|
|
|
50
|
+
// (1) Resolve identity FIRST (awaited — no longer fire-and-forget). The
|
|
51
|
+
// contact-referencing tables join on a NOT NULL text key, so an email-only /
|
|
52
|
+
// anonymous event (D1 optional userId) needs a canonical key resolved before
|
|
53
|
+
// any insert (risk 2). The resolver applies ONLY contactProperties to
|
|
54
|
+
// `contacts.properties` (D2 split) and returns BOTH the canonical contact id
|
|
55
|
+
// AND its resolved string key (external_id ?? anonymous_id ?? contact.id —
|
|
56
|
+
// risk 1/6), so no second read-back of the contact row is needed.
|
|
57
|
+
const { resolvedKey } = await resolveOrCreateContact({
|
|
58
|
+
db,
|
|
59
|
+
userId: event.userId,
|
|
60
|
+
email: event.userEmail || undefined,
|
|
61
|
+
anonymousId: event.anonymousId,
|
|
62
|
+
contactProperties: event.contactProperties,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Caller-supplied event time (backfill/replay). Coerced to a Date; undefined
|
|
66
|
+
// falls back to the `occurred_at` DB default (ingest instant).
|
|
67
|
+
const occurredAt = event.occurredAt ? new Date(event.occurredAt) : undefined;
|
|
68
|
+
|
|
69
|
+
// (2) Idempotency dedup + `user_events` insert keyed on the resolved key, with
|
|
70
|
+
// ONLY eventProperties in the properties bag (D2).
|
|
38
71
|
if (event.idempotencyKey) {
|
|
39
72
|
const result = await db
|
|
40
73
|
.insert(userEvents)
|
|
41
74
|
.values({
|
|
42
|
-
userId:
|
|
75
|
+
userId: resolvedKey,
|
|
43
76
|
event: event.event,
|
|
44
|
-
properties: event.
|
|
77
|
+
properties: event.eventProperties,
|
|
45
78
|
idempotencyKey: event.idempotencyKey,
|
|
79
|
+
...(occurredAt ? { occurredAt } : {}),
|
|
46
80
|
})
|
|
47
81
|
.onConflictDoNothing({
|
|
48
82
|
target: userEvents.idempotencyKey,
|
|
@@ -54,14 +88,17 @@ export async function ingestEvent(opts: {
|
|
|
54
88
|
}
|
|
55
89
|
} else {
|
|
56
90
|
await db.insert(userEvents).values({
|
|
57
|
-
userId:
|
|
91
|
+
userId: resolvedKey,
|
|
58
92
|
event: event.event,
|
|
59
|
-
properties: event.
|
|
93
|
+
properties: event.eventProperties,
|
|
94
|
+
...(occurredAt ? { occurredAt } : {}),
|
|
60
95
|
});
|
|
61
96
|
}
|
|
62
97
|
|
|
98
|
+
// (3) Build the JSON-serializable subset of eventProperties for the Hatchet
|
|
99
|
+
// push payload (scalars only — the SDK serializes the envelope).
|
|
63
100
|
const serializableProperties = Object.fromEntries(
|
|
64
|
-
Object.entries(event.
|
|
101
|
+
Object.entries(event.eventProperties).filter(
|
|
65
102
|
([, v]) =>
|
|
66
103
|
typeof v === "string" ||
|
|
67
104
|
typeof v === "number" ||
|
|
@@ -70,57 +107,50 @@ export async function ingestEvent(opts: {
|
|
|
70
107
|
),
|
|
71
108
|
) as Record<string, string | number | boolean | null>;
|
|
72
109
|
|
|
110
|
+
// (4) Hatchet push + (5) checkExits, both keyed on the resolved key. The push
|
|
111
|
+
// payload wire key STAYS `properties` (bucket tests assert on it — risk 9).
|
|
73
112
|
const [, exits] = await Promise.all([
|
|
74
113
|
hatchet.events.push(event.event, {
|
|
75
|
-
userId:
|
|
76
|
-
userEmail: event.userEmail,
|
|
114
|
+
userId: resolvedKey,
|
|
115
|
+
userEmail: event.userEmail ?? "",
|
|
77
116
|
properties: serializableProperties,
|
|
78
117
|
}),
|
|
79
118
|
checkExits(db, registry, hatchet, logger, {
|
|
80
|
-
userId:
|
|
119
|
+
userId: resolvedKey,
|
|
81
120
|
eventName: event.event,
|
|
82
|
-
properties: event.
|
|
83
|
-
}),
|
|
84
|
-
upsertContact({
|
|
85
|
-
db,
|
|
86
|
-
externalId: event.userId,
|
|
87
|
-
email: event.userEmail || undefined,
|
|
88
|
-
properties: event.properties,
|
|
89
|
-
}).catch((err) => {
|
|
90
|
-
logger.warn("Contact upsert failed", {
|
|
91
|
-
userId: event.userId,
|
|
92
|
-
error: err instanceof Error ? err.message : String(err),
|
|
93
|
-
});
|
|
121
|
+
properties: event.eventProperties,
|
|
94
122
|
}),
|
|
95
123
|
]);
|
|
96
124
|
|
|
97
|
-
// Real-time bucket membership re-evaluation (Section 6.1). NOT part of the
|
|
98
|
-
// Promise.all above: its property eval reads
|
|
99
|
-
// bucket:entered/left emissions recurse back
|
|
100
|
-
// guard in checkBucketMembership bounds them).
|
|
101
|
-
// must not fail the ingest of the originating
|
|
125
|
+
// (6) Real-time bucket membership re-evaluation (Section 6.1). NOT part of the
|
|
126
|
+
// Promise.all above: its property eval reads contact state ⊕ this-ingest
|
|
127
|
+
// contactProperties patch, and its bucket:entered/left emissions recurse back
|
|
128
|
+
// into ingestEvent (the recursion guard in checkBucketMembership bounds them).
|
|
129
|
+
// Best-effort: a bucket failure must not fail the ingest of the originating
|
|
130
|
+
// event.
|
|
102
131
|
try {
|
|
103
132
|
await checkBucketMembership({
|
|
104
133
|
db,
|
|
105
134
|
registry,
|
|
106
135
|
hatchet,
|
|
107
136
|
logger,
|
|
108
|
-
userId:
|
|
137
|
+
userId: resolvedKey,
|
|
109
138
|
userEmail: event.userEmail || null,
|
|
110
139
|
event: event.event,
|
|
111
|
-
|
|
140
|
+
eventProperties: event.eventProperties,
|
|
141
|
+
contactProperties: event.contactProperties ?? {},
|
|
112
142
|
});
|
|
113
143
|
} catch (err) {
|
|
114
144
|
logger.warn("Bucket membership check failed", {
|
|
115
145
|
event: event.event,
|
|
116
|
-
userId:
|
|
146
|
+
userId: resolvedKey,
|
|
117
147
|
error: err instanceof Error ? err.message : String(err),
|
|
118
148
|
});
|
|
119
149
|
}
|
|
120
150
|
|
|
121
151
|
logger.info("Event ingested", {
|
|
122
152
|
event: event.event,
|
|
123
|
-
userId:
|
|
153
|
+
userId: resolvedKey,
|
|
124
154
|
exits: exits.filter((e) => e.exited).length,
|
|
125
155
|
});
|
|
126
156
|
|
package/src/lib/mailer.ts
CHANGED
|
@@ -24,8 +24,17 @@ import type {
|
|
|
24
24
|
SendResult,
|
|
25
25
|
TrackedSendResult,
|
|
26
26
|
} from "./email-service-types.js";
|
|
27
|
+
import { hatchet } from "./hatchet.js";
|
|
28
|
+
import { createLogger } from "./logger.js";
|
|
29
|
+
import { emitOutbound } from "./outbound.js";
|
|
27
30
|
import type { PrepareTrackedHtmlFn } from "./tracked.js";
|
|
28
31
|
import { sendTrackedEmail } from "./tracked.js";
|
|
32
|
+
import { resolveEmailSendContextByResendId } from "./tracking-events.js";
|
|
33
|
+
|
|
34
|
+
// Fallback logger for the provider-webhook outbound emit — `config.logger` is
|
|
35
|
+
// optional, but `emitOutbound` requires one. Mirrors the engine-lib singleton
|
|
36
|
+
// pattern (define-journey, preferences, tracked).
|
|
37
|
+
const emitLogger = createLogger(process.env.LOG_LEVEL);
|
|
29
38
|
|
|
30
39
|
const WEBHOOK_TO_STATUS_FIELD: Partial<
|
|
31
40
|
Record<WebhookEventType, keyof typeof emailSends.$inferSelect>
|
|
@@ -98,6 +107,7 @@ export function createTrackedMailer(
|
|
|
98
107
|
headers: options.headers,
|
|
99
108
|
replyTo: options.replyTo,
|
|
100
109
|
skipPreferenceCheck: options.skipPreferenceCheck,
|
|
110
|
+
idempotencyKey: options.idempotencyKey,
|
|
101
111
|
baseUrl: config.baseUrl,
|
|
102
112
|
},
|
|
103
113
|
});
|
|
@@ -186,9 +196,23 @@ export function createTrackedMailer(
|
|
|
186
196
|
): Promise<boolean> {
|
|
187
197
|
switch (event.type) {
|
|
188
198
|
case "email.sent":
|
|
199
|
+
// `email.sent` is emitted FIRST-PARTY from the tracked mailer's
|
|
200
|
+
// provider-accepted branch (lib/tracked.ts) with the rich payload — the
|
|
201
|
+
// provider-webhook echo only updates the DB status, it does NOT emit.
|
|
202
|
+
await updateEmailStatus(event.type, event.data.email_id);
|
|
203
|
+
break;
|
|
189
204
|
case "email.delivered":
|
|
205
|
+
await updateEmailStatus(event.type, event.data.email_id);
|
|
206
|
+
// OUTBOUND `email.delivered` — the provider webhook is the SINGLE source
|
|
207
|
+
// for delivered/bounced (these have no first-party signal).
|
|
208
|
+
await emitProviderEmailEvent("email.delivered", event.data.email_id);
|
|
209
|
+
break;
|
|
190
210
|
case "email.opened":
|
|
191
211
|
case "email.clicked":
|
|
212
|
+
// First-party pixel/redirect is the SINGLE outbound emitter for
|
|
213
|
+
// open/click (gated on the first-touch null→set UPDATE in the tracking
|
|
214
|
+
// routes — risk 4). The provider-webhook echo is SUPPRESSED here: it only
|
|
215
|
+
// updates the DB status, it does NOT emit outbound (no double-source).
|
|
192
216
|
await updateEmailStatus(event.type, event.data.email_id);
|
|
193
217
|
break;
|
|
194
218
|
case "email.bounced":
|
|
@@ -196,6 +220,11 @@ export function createTrackedMailer(
|
|
|
196
220
|
bounceType: event.data.bounce?.type,
|
|
197
221
|
bounceReason: event.data.bounce?.message,
|
|
198
222
|
});
|
|
223
|
+
// OUTBOUND `email.bounced` with the bounce detail.
|
|
224
|
+
await emitProviderEmailEvent("email.bounced", event.data.email_id, {
|
|
225
|
+
bounceType: event.data.bounce?.type,
|
|
226
|
+
bounceReason: event.data.bounce?.message,
|
|
227
|
+
});
|
|
199
228
|
await handleBounce(event.data.to);
|
|
200
229
|
break;
|
|
201
230
|
case "email.complained":
|
|
@@ -249,6 +278,65 @@ export function createTrackedMailer(
|
|
|
249
278
|
.where(eq(emailPreferences.email, email));
|
|
250
279
|
}
|
|
251
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Emit the provider-funnel outbound event (`email.delivered` / `email.bounced`)
|
|
283
|
+
* for a Resend `email_id`. Enriches via {@link resolveEmailSendContextByResendId}
|
|
284
|
+
* (the only handle a provider webhook holds is the Resend id). Fire-and-forget:
|
|
285
|
+
* a missing context (webhook racing the send-row commit) or a transient outbound
|
|
286
|
+
* error is logged and swallowed — never failing the webhook handler. No
|
|
287
|
+
* `dedupeKey`: the provider path is not a Hatchet-retryable producer, and the
|
|
288
|
+
* shared `Webhook-Id` is the subscriber-side dedup for any provider redelivery.
|
|
289
|
+
*/
|
|
290
|
+
function emitProviderEmailEvent(
|
|
291
|
+
event: "email.delivered" | "email.bounced",
|
|
292
|
+
resendId: string,
|
|
293
|
+
bounce?: { bounceType?: string; bounceReason?: string },
|
|
294
|
+
): void {
|
|
295
|
+
if (!db) return;
|
|
296
|
+
const log = config.logger ?? emitLogger;
|
|
297
|
+
const database = db;
|
|
298
|
+
void resolveEmailSendContextByResendId(database, resendId)
|
|
299
|
+
.then((ctx) => {
|
|
300
|
+
if (!ctx) return;
|
|
301
|
+
const base = {
|
|
302
|
+
emailSendId: ctx.emailSendId,
|
|
303
|
+
resendId,
|
|
304
|
+
templateKey: ctx.templateKey,
|
|
305
|
+
userId: ctx.userId,
|
|
306
|
+
to: ctx.to,
|
|
307
|
+
at: new Date().toISOString(),
|
|
308
|
+
};
|
|
309
|
+
if (event === "email.bounced") {
|
|
310
|
+
return emitOutbound({
|
|
311
|
+
db: database,
|
|
312
|
+
hatchet,
|
|
313
|
+
logger: log,
|
|
314
|
+
event: "email.bounced",
|
|
315
|
+
payload: {
|
|
316
|
+
...base,
|
|
317
|
+
...(bounce?.bounceType ? { bounceType: bounce.bounceType } : {}),
|
|
318
|
+
...(bounce?.bounceReason
|
|
319
|
+
? { bounceReason: bounce.bounceReason }
|
|
320
|
+
: {}),
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return emitOutbound({
|
|
325
|
+
db: database,
|
|
326
|
+
hatchet,
|
|
327
|
+
logger: log,
|
|
328
|
+
event: "email.delivered",
|
|
329
|
+
payload: base,
|
|
330
|
+
});
|
|
331
|
+
})
|
|
332
|
+
.catch((err: unknown) => {
|
|
333
|
+
log.warn(`emitOutbound ${event} failed`, {
|
|
334
|
+
resendId,
|
|
335
|
+
error: err instanceof Error ? err.message : String(err),
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
252
340
|
async function updateEmailStatus(
|
|
253
341
|
eventType: WebhookEventType,
|
|
254
342
|
resendId: string,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Database } from "@hogsend/db";
|
|
2
|
+
import { emailPreferences } from "@hogsend/db";
|
|
3
|
+
import { sql } from "drizzle-orm";
|
|
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);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Single source of truth for an `email_preferences` upsert: the `(user_id, email)`
|
|
13
|
+
* onConflict + the jsonb category-flip. Extracted from the private
|
|
14
|
+
* `upsertPreference` that used to live in `routes/email/unsubscribe.ts` (decision
|
|
15
|
+
* #9) so subscribe/unsubscribe routes, the preference center, list membership, and
|
|
16
|
+
* the unsubscribe-token flow all share ONE write.
|
|
17
|
+
*
|
|
18
|
+
* `externalId` is the `user_id` column value: the contact's `external_id` when it
|
|
19
|
+
* has one, else the contact `id` (uuid) fallback for an email-only contact (risk
|
|
20
|
+
* 10). `email` is REQUIRED — both columns are NOT NULL and form the PK.
|
|
21
|
+
*/
|
|
22
|
+
export async function upsertEmailPreference(opts: {
|
|
23
|
+
db: Database;
|
|
24
|
+
externalId: string;
|
|
25
|
+
email: string;
|
|
26
|
+
update: {
|
|
27
|
+
unsubscribedAll?: boolean;
|
|
28
|
+
suppressed?: boolean;
|
|
29
|
+
categoryKey?: string;
|
|
30
|
+
categoryValue?: boolean;
|
|
31
|
+
};
|
|
32
|
+
}): Promise<void> {
|
|
33
|
+
const { db, externalId, email, update } = opts;
|
|
34
|
+
|
|
35
|
+
const setClause: Record<string, unknown> = { updatedAt: new Date() };
|
|
36
|
+
|
|
37
|
+
if (update.unsubscribedAll !== undefined) {
|
|
38
|
+
setClause.unsubscribedAll = update.unsubscribedAll;
|
|
39
|
+
}
|
|
40
|
+
if (update.suppressed !== undefined) {
|
|
41
|
+
setClause.suppressed = update.suppressed;
|
|
42
|
+
}
|
|
43
|
+
if (update.categoryKey !== undefined) {
|
|
44
|
+
const jsonValue = update.categoryValue ? "true" : "false";
|
|
45
|
+
setClause.categories = sql`jsonb_set(COALESCE(${emailPreferences.categories}, '{}'::jsonb), ${`{${update.categoryKey}}`}, ${jsonValue}::jsonb)`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await db
|
|
49
|
+
.insert(emailPreferences)
|
|
50
|
+
.values({
|
|
51
|
+
userId: externalId,
|
|
52
|
+
email,
|
|
53
|
+
...(update.unsubscribedAll !== undefined
|
|
54
|
+
? { unsubscribedAll: update.unsubscribedAll }
|
|
55
|
+
: {}),
|
|
56
|
+
...(update.suppressed !== undefined
|
|
57
|
+
? { suppressed: update.suppressed }
|
|
58
|
+
: {}),
|
|
59
|
+
...(update.categoryKey !== undefined
|
|
60
|
+
? {
|
|
61
|
+
categories: { [update.categoryKey]: update.categoryValue ?? false },
|
|
62
|
+
}
|
|
63
|
+
: {}),
|
|
64
|
+
})
|
|
65
|
+
.onConflictDoUpdate({
|
|
66
|
+
target: [emailPreferences.userId, emailPreferences.email],
|
|
67
|
+
set: setClause,
|
|
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
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* D3 list-membership write. Resolves the caller's identity to the deterministic
|
|
99
|
+
* `(externalId | contactId fallback, email)` pair via `resolveRecipient`, then
|
|
100
|
+
* writes one category flip per list key through `upsertEmailPreference`.
|
|
101
|
+
*
|
|
102
|
+
* Requires a resolvable email — `email_preferences.email` is NOT NULL and the
|
|
103
|
+
* preference center / unsubscribe-token flow key on it (risk 10). The caller is
|
|
104
|
+
* expected to have already run `resolveOrCreateContact` (so the contact exists);
|
|
105
|
+
* this reads identity back. Throws if no email can be resolved — the route maps
|
|
106
|
+
* that to a 400 ("Contact has no email; cannot manage list membership").
|
|
107
|
+
*/
|
|
108
|
+
export async function applyListMembership(opts: {
|
|
109
|
+
db: Database;
|
|
110
|
+
userId?: string;
|
|
111
|
+
email?: string;
|
|
112
|
+
lists: Record<string, boolean>;
|
|
113
|
+
}): Promise<void> {
|
|
114
|
+
const { db, userId, email, lists } = opts;
|
|
115
|
+
|
|
116
|
+
const entries = Object.entries(lists);
|
|
117
|
+
if (entries.length === 0) return;
|
|
118
|
+
|
|
119
|
+
const recipient = await resolveRecipient({ db, userId, email });
|
|
120
|
+
if (!recipient) {
|
|
121
|
+
throw new Error("Contact has no email; cannot manage list membership");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `user_id` column = external_id when present, else the contact id (uuid)
|
|
125
|
+
// fallback — the SAME deterministic key used by subscribe writes,
|
|
126
|
+
// preference-center reads, and unsubscribe-token issuance (risk 10).
|
|
127
|
+
const externalId = recipient.externalId ?? recipient.contactId;
|
|
128
|
+
|
|
129
|
+
for (const [categoryKey, categoryValue] of entries) {
|
|
130
|
+
await upsertEmailPreference({
|
|
131
|
+
db,
|
|
132
|
+
externalId,
|
|
133
|
+
email: recipient.email,
|
|
134
|
+
update: { categoryKey, categoryValue },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|