@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,13 +35,14 @@
|
|
|
35
35
|
"papaparse": "^5.5.3",
|
|
36
36
|
"picocolors": "^1.1.1",
|
|
37
37
|
"resend": "^6.12.3",
|
|
38
|
+
"svix": "^1.95.1",
|
|
38
39
|
"winston": "^3.19.0",
|
|
39
40
|
"zod": "^4.4.3",
|
|
40
|
-
"@hogsend/core": "^0.
|
|
41
|
-
"@hogsend/
|
|
42
|
-
"@hogsend/
|
|
43
|
-
"@hogsend/plugin-
|
|
44
|
-
"@hogsend/
|
|
41
|
+
"@hogsend/core": "^0.8.0",
|
|
42
|
+
"@hogsend/email": "^0.8.0",
|
|
43
|
+
"@hogsend/plugin-posthog": "^0.8.0",
|
|
44
|
+
"@hogsend/plugin-resend": "^0.8.0",
|
|
45
|
+
"@hogsend/db": "^0.8.0"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"@types/node": "^22.15.3",
|
package/src/app.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { errorHandler } from "./middleware/error-handler.js";
|
|
|
15
15
|
import { requestLogger } from "./middleware/request-logger.js";
|
|
16
16
|
import { registerRoutes } from "./routes/index.js";
|
|
17
17
|
import type { DefinedWebhookSource } from "./webhook-sources/define-webhook-source.js";
|
|
18
|
+
import { presetsFromEnv } from "./webhook-sources/presets/index.js";
|
|
18
19
|
|
|
19
20
|
type AuthSession = Awaited<ReturnType<Auth["api"]["getSession"]>>;
|
|
20
21
|
|
|
@@ -35,10 +36,32 @@ export interface CreateAppOptions {
|
|
|
35
36
|
middleware?: MiddlewareHandler[];
|
|
36
37
|
/** Webhook sources served at `/v1/webhooks/:sourceId`. */
|
|
37
38
|
webhookSources?: DefinedWebhookSource[];
|
|
39
|
+
/**
|
|
40
|
+
* Auto-enable the shipped integration presets (Clerk, Supabase, Stripe,
|
|
41
|
+
* Segment) for every preset whose env secret is configured (gated further by
|
|
42
|
+
* `ENABLED_WEBHOOK_PRESETS`). Consumer-supplied `webhookSources` always win on
|
|
43
|
+
* an id collision. Set `false` to opt out entirely. Default `true`.
|
|
44
|
+
*/
|
|
45
|
+
enablePresets?: boolean;
|
|
38
46
|
/** Override the default error handler. */
|
|
39
47
|
onError?: ErrorHandler<AppEnv>;
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Merge env-enabled presets with the consumer's explicit sources. The
|
|
52
|
+
* consumer-supplied source WINS on an id collision (so a hand-tuned override of
|
|
53
|
+
* a preset replaces the shipped one rather than registering a duplicate route).
|
|
54
|
+
*/
|
|
55
|
+
function dedupeById(sources: DefinedWebhookSource[]): DefinedWebhookSource[] {
|
|
56
|
+
const byId = new Map<string, DefinedWebhookSource>();
|
|
57
|
+
for (const source of sources) {
|
|
58
|
+
// Last write wins; callers order presets BEFORE consumer sources so the
|
|
59
|
+
// consumer override lands last.
|
|
60
|
+
byId.set(source.meta.id, source);
|
|
61
|
+
}
|
|
62
|
+
return [...byId.values()];
|
|
63
|
+
}
|
|
64
|
+
|
|
42
65
|
export function createApp(
|
|
43
66
|
container: HogsendClient,
|
|
44
67
|
opts: CreateAppOptions = {},
|
|
@@ -96,7 +119,19 @@ export function createApp(
|
|
|
96
119
|
return c.json({ needsSetup: existing.length === 0 });
|
|
97
120
|
});
|
|
98
121
|
|
|
99
|
-
|
|
122
|
+
// Merge env-enabled presets ahead of the consumer's explicit sources so a
|
|
123
|
+
// consumer override of a preset id wins (decision #13). `enablePresets`
|
|
124
|
+
// defaults true; setting only `STRIPE_WEBHOOK_SECRET` auto-mounts Stripe at
|
|
125
|
+
// `POST /v1/webhooks/stripe` and nothing else.
|
|
126
|
+
const enablePresets = opts.enablePresets ?? true;
|
|
127
|
+
const webhookSources = enablePresets
|
|
128
|
+
? dedupeById([
|
|
129
|
+
...presetsFromEnv(container.env),
|
|
130
|
+
...(opts.webhookSources ?? []),
|
|
131
|
+
])
|
|
132
|
+
: (opts.webhookSources ?? []);
|
|
133
|
+
|
|
134
|
+
registerRoutes(app, { webhookSources });
|
|
100
135
|
|
|
101
136
|
// Serve the Studio SPA at /studio/* (static layer, no auth — the SPA gates
|
|
102
137
|
// itself via /v1/auth/status + login; data endpoints stay behind requireAdmin).
|
package/src/env.ts
CHANGED
|
@@ -71,6 +71,31 @@ export const env = createEnv({
|
|
|
71
71
|
ENABLED_LISTS: z.string().default("*"),
|
|
72
72
|
// Cadence for the engine-owned bucket reconcile cron (time-based leaves).
|
|
73
73
|
BUCKET_RECONCILE_CRON: z.string().default("*/5 * * * *"),
|
|
74
|
+
// --- Outbound webhooks (Section 1.5/1.8) ---
|
|
75
|
+
// Cadence for the engine-owned outbound-delivery reaper cron (the retry
|
|
76
|
+
// scheduler + orphan-`sending` recovery). Declared for parity with
|
|
77
|
+
// BUCKET_RECONCILE_CRON; the delivery task also reads it raw off process.env.
|
|
78
|
+
OUTBOUND_WEBHOOK_REAPER_CRON: z.string().optional(),
|
|
79
|
+
// Delivery tunables — read raw off process.env inside the durable task;
|
|
80
|
+
// declared here so they are part of the validated env contract. All optional
|
|
81
|
+
// with task-internal defaults (MAX_ATTEMPTS 8, TIMEOUT 15s, BASE 5s,
|
|
82
|
+
// MAX_DELAY 6h, STUCK_AFTER 5min).
|
|
83
|
+
OUTBOUND_WEBHOOK_MAX_ATTEMPTS: z.coerce.number().optional(),
|
|
84
|
+
OUTBOUND_WEBHOOK_TIMEOUT_MS: z.coerce.number().optional(),
|
|
85
|
+
OUTBOUND_WEBHOOK_BASE_DELAY_MS: z.coerce.number().optional(),
|
|
86
|
+
OUTBOUND_WEBHOOK_MAX_DELAY_MS: z.coerce.number().optional(),
|
|
87
|
+
OUTBOUND_WEBHOOK_STUCK_AFTER_MS: z.coerce.number().optional(),
|
|
88
|
+
// --- Integration presets (Section 2.2) ---
|
|
89
|
+
// Signature-source secrets. The webhook route resolves a preset's secret via
|
|
90
|
+
// env[source.auth.envKey]; a signature source FAILS CLOSED when its secret is
|
|
91
|
+
// unset. Setting one auto-enables that preset at POST /v1/webhooks/<id>.
|
|
92
|
+
CLERK_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
93
|
+
SUPABASE_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
94
|
+
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
95
|
+
SEGMENT_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
96
|
+
// Preset enablement override: csv of preset ids, `"*"` (all with a secret),
|
|
97
|
+
// or `"none"`. Absent → auto-enable any preset whose secret is set.
|
|
98
|
+
ENABLED_WEBHOOK_PRESETS: z.string().optional(),
|
|
74
99
|
},
|
|
75
100
|
runtimeEnv: process.env,
|
|
76
101
|
emptyStringAsUndefined: true,
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,6 @@ export type {
|
|
|
16
16
|
PostHogService,
|
|
17
17
|
SendResult,
|
|
18
18
|
WebhookEvent,
|
|
19
|
-
WebhookEventType,
|
|
20
19
|
WebhookHandlerMap,
|
|
21
20
|
} from "@hogsend/core";
|
|
22
21
|
// Core helpers used by content journeys (days/hours/minutes, condition + journey
|
|
@@ -150,6 +149,13 @@ export {
|
|
|
150
149
|
// --- Logging ---
|
|
151
150
|
export { createLogger, type Logger } from "./lib/logger.js";
|
|
152
151
|
export { createTrackedMailer } from "./lib/mailer.js";
|
|
152
|
+
// --- Outbound webhooks: emit spine (Section 1.4) ---
|
|
153
|
+
export {
|
|
154
|
+
emitOutbound,
|
|
155
|
+
OUTBOUND_EVENTS,
|
|
156
|
+
type OutboundEventName,
|
|
157
|
+
type OutboundPayloads,
|
|
158
|
+
} from "./lib/outbound.js";
|
|
153
159
|
export { getPostHog } from "./lib/posthog.js";
|
|
154
160
|
export { getRedisIfConnected } from "./lib/redis.js";
|
|
155
161
|
export { type MountStudioResult, mountStudio } from "./lib/studio.js";
|
|
@@ -174,7 +180,17 @@ export {
|
|
|
174
180
|
export {
|
|
175
181
|
pushTrackingEvent,
|
|
176
182
|
resolveEmailSendContext,
|
|
183
|
+
resolveEmailSendContextByResendId,
|
|
177
184
|
} from "./lib/tracking-events.js";
|
|
185
|
+
// --- Outbound webhooks: signing core (Section 1.2) ---
|
|
186
|
+
export {
|
|
187
|
+
generateWebhookSecret,
|
|
188
|
+
type SignedWebhook,
|
|
189
|
+
signWebhook,
|
|
190
|
+
verifyWebhookSignature,
|
|
191
|
+
WEBHOOK_EVENT_TYPES,
|
|
192
|
+
type WebhookEventType,
|
|
193
|
+
} from "./lib/webhook-signing.js";
|
|
178
194
|
// --- Lists (D3) ---
|
|
179
195
|
export {
|
|
180
196
|
type DefinedList,
|
|
@@ -191,9 +207,21 @@ export {
|
|
|
191
207
|
export {
|
|
192
208
|
type DefinedWebhookSource,
|
|
193
209
|
defineWebhookSource,
|
|
210
|
+
verifySignature,
|
|
211
|
+
type WebhookSourceAuth,
|
|
194
212
|
type WebhookSourceCtx,
|
|
195
213
|
type WebhookSourceMeta,
|
|
196
214
|
} from "./webhook-sources/define-webhook-source.js";
|
|
215
|
+
// --- Integration presets (Section 2.3/2.4) ---
|
|
216
|
+
export {
|
|
217
|
+
clerkSource,
|
|
218
|
+
PRESET_SOURCES,
|
|
219
|
+
type PresetId,
|
|
220
|
+
presetsFromEnv,
|
|
221
|
+
segmentSource,
|
|
222
|
+
stripeSource,
|
|
223
|
+
supabaseSource,
|
|
224
|
+
} from "./webhook-sources/presets/index.js";
|
|
197
225
|
export {
|
|
198
226
|
type CreateWorkerOptions,
|
|
199
227
|
createWorker,
|
|
@@ -211,6 +239,11 @@ export {
|
|
|
211
239
|
bucketReconcileTask,
|
|
212
240
|
} from "./workflows/bucket-reconcile.js";
|
|
213
241
|
export { checkAlertsTask } from "./workflows/check-alerts.js";
|
|
242
|
+
// --- Outbound webhooks: durable delivery task + reaper (Section 1.5) ---
|
|
243
|
+
export {
|
|
244
|
+
deliverWebhookTask,
|
|
245
|
+
reapDueWebhookDeliveriesTask,
|
|
246
|
+
} from "./workflows/deliver-webhook.js";
|
|
214
247
|
export { importContactsTask } from "./workflows/import-contacts.js";
|
|
215
248
|
export { sendCampaignTask } from "./workflows/send-campaign.js";
|
|
216
249
|
// --- Built-in Hatchet workflow tasks ---
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "../lib/enrollment-guards.js";
|
|
16
16
|
import { hatchet } from "../lib/hatchet.js";
|
|
17
17
|
import { createLogger } from "../lib/logger.js";
|
|
18
|
+
import { emitOutbound } from "../lib/outbound.js";
|
|
18
19
|
import { resolveTimezoneWithSource } from "../lib/timezone.js";
|
|
19
20
|
import { getClientScheduleDefaults } from "./client-defaults-singleton.js";
|
|
20
21
|
import { JOURNEY_EXECUTION_TIMEOUT } from "./constants.js";
|
|
@@ -190,12 +191,13 @@ export function defineJourney(options: {
|
|
|
190
191
|
try {
|
|
191
192
|
await options.run(user, ctx);
|
|
192
193
|
|
|
194
|
+
const completedAt = new Date();
|
|
193
195
|
await db
|
|
194
196
|
.update(journeyStates)
|
|
195
197
|
.set({
|
|
196
198
|
status: "completed",
|
|
197
|
-
completedAt
|
|
198
|
-
updatedAt:
|
|
199
|
+
completedAt,
|
|
200
|
+
updatedAt: completedAt,
|
|
199
201
|
})
|
|
200
202
|
.where(eq(journeyStates.id, stateId));
|
|
201
203
|
|
|
@@ -205,6 +207,28 @@ export function defineJourney(options: {
|
|
|
205
207
|
userId,
|
|
206
208
|
});
|
|
207
209
|
|
|
210
|
+
// OUTBOUND `journey.completed` — fired alongside the internal
|
|
211
|
+
// `journey:completed` push. Runs in the WORKER (this durable task), so it
|
|
212
|
+
// uses the engine `db`/`hatchet`/`logger` singletons. `dedupeKey` =
|
|
213
|
+
// `journey.completed:<stateId>`: a Hatchet re-execution recomputes the
|
|
214
|
+
// identical key and the unique `(endpointId, dedupeKey)` index absorbs the
|
|
215
|
+
// duplicate (risk 3). `journey:failed` is NOT in the catalog → no emit.
|
|
216
|
+
void emitOutbound({
|
|
217
|
+
db,
|
|
218
|
+
hatchet,
|
|
219
|
+
logger,
|
|
220
|
+
event: "journey.completed",
|
|
221
|
+
dedupeKey: `journey.completed:${stateId}`,
|
|
222
|
+
payload: {
|
|
223
|
+
journeyId: meta.id,
|
|
224
|
+
journeyName: meta.name,
|
|
225
|
+
stateId,
|
|
226
|
+
userId,
|
|
227
|
+
userEmail,
|
|
228
|
+
completedAt: completedAt.toISOString(),
|
|
229
|
+
},
|
|
230
|
+
}).catch(logger.warn);
|
|
231
|
+
|
|
208
232
|
return { stateId, status: "completed" };
|
|
209
233
|
} catch (err) {
|
|
210
234
|
// The journey reached a terminal state (exitOn / cancel) while suspended
|
package/src/lib/bucket-emit.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { BucketLeaveReason } from "../buckets/bucket-reactions.js";
|
|
|
6
6
|
import { syncBucketToPostHog } from "./bucket-posthog-sync.js";
|
|
7
7
|
import { ingestEvent } from "./ingestion.js";
|
|
8
8
|
import type { Logger } from "./logger.js";
|
|
9
|
+
import { emitOutbound } from "./outbound.js";
|
|
9
10
|
|
|
10
11
|
export type BucketTransitionKind = "entered" | "left" | "dwell";
|
|
11
12
|
|
|
@@ -146,4 +147,48 @@ export async function emitBucketTransition(opts: {
|
|
|
146
147
|
},
|
|
147
148
|
});
|
|
148
149
|
}
|
|
150
|
+
|
|
151
|
+
// OUTBOUND `bucket.entered` / `bucket.left` — this is the SINGLE producer for
|
|
152
|
+
// all three transition origins (real-time / reconcile / fast-expiry), so the
|
|
153
|
+
// emit lives here once. GATED to enter/leave (dwell is a recurring membership
|
|
154
|
+
// tick, not in the catalog — same gate as the PostHog mirror above). The
|
|
155
|
+
// `dedupeKey` is the SAME deterministic `idempotencyKey` all three producers
|
|
156
|
+
// compute, so a Hatchet re-execution converges to ONE emission via the unique
|
|
157
|
+
// `(endpointId, dedupeKey)` index (risk 3). Fire-and-forget.
|
|
158
|
+
if (kind === "entered") {
|
|
159
|
+
void emitOutbound({
|
|
160
|
+
db,
|
|
161
|
+
hatchet,
|
|
162
|
+
logger,
|
|
163
|
+
event: "bucket.entered",
|
|
164
|
+
dedupeKey: idempotencyKey,
|
|
165
|
+
payload: {
|
|
166
|
+
bucketId: bucket.id,
|
|
167
|
+
bucketName: bucket.name,
|
|
168
|
+
userId,
|
|
169
|
+
userEmail,
|
|
170
|
+
transition: "entered",
|
|
171
|
+
entryCount: epoch,
|
|
172
|
+
source,
|
|
173
|
+
},
|
|
174
|
+
}).catch(logger.warn);
|
|
175
|
+
} else if (kind === "left") {
|
|
176
|
+
void emitOutbound({
|
|
177
|
+
db,
|
|
178
|
+
hatchet,
|
|
179
|
+
logger,
|
|
180
|
+
event: "bucket.left",
|
|
181
|
+
dedupeKey: idempotencyKey,
|
|
182
|
+
payload: {
|
|
183
|
+
bucketId: bucket.id,
|
|
184
|
+
bucketName: bucket.name,
|
|
185
|
+
userId,
|
|
186
|
+
userEmail,
|
|
187
|
+
transition: "left",
|
|
188
|
+
entryCount: epoch,
|
|
189
|
+
source,
|
|
190
|
+
...(reason != null ? { reason } : {}),
|
|
191
|
+
},
|
|
192
|
+
}).catch(logger.warn);
|
|
193
|
+
}
|
|
149
194
|
}
|
package/src/lib/contacts.ts
CHANGED
|
@@ -38,7 +38,7 @@ export async function resolveContact(opts: { db: Database; id: string }) {
|
|
|
38
38
|
return rows[0] ?? null;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
interface SerializedContact {
|
|
41
|
+
export interface SerializedContact {
|
|
42
42
|
id: string;
|
|
43
43
|
externalId: string | null;
|
|
44
44
|
email: string | null;
|
|
@@ -1007,13 +1007,23 @@ export async function findContacts(opts: {
|
|
|
1007
1007
|
|
|
1008
1008
|
/**
|
|
1009
1009
|
* Soft-delete a contact resolved by email or external id (sets `deletedAt`).
|
|
1010
|
-
*
|
|
1010
|
+
*
|
|
1011
|
+
* Returns `{ deleted }` plus the soft-deleted row's identity (`id`,
|
|
1012
|
+
* `externalId`, `email`) so the delete route can both make its 404 decision
|
|
1013
|
+
* (`deleted`) AND emit the `contact.deleted` outbound webhook with the real
|
|
1014
|
+
* identity — without a second read-back. `deleted` is false (and the identity
|
|
1015
|
+
* fields absent) when no live row matched.
|
|
1011
1016
|
*/
|
|
1012
1017
|
export async function softDeleteContact(opts: {
|
|
1013
1018
|
db: Database;
|
|
1014
1019
|
email?: string;
|
|
1015
1020
|
userId?: string;
|
|
1016
|
-
}): Promise<
|
|
1021
|
+
}): Promise<{
|
|
1022
|
+
deleted: boolean;
|
|
1023
|
+
id?: string;
|
|
1024
|
+
externalId?: string | null;
|
|
1025
|
+
email?: string | null;
|
|
1026
|
+
}> {
|
|
1017
1027
|
const { db } = opts;
|
|
1018
1028
|
const email = opts.email ? normalizeEmail(opts.email) : undefined;
|
|
1019
1029
|
const userId = opts.userId?.trim() || undefined;
|
|
@@ -1021,15 +1031,27 @@ export async function softDeleteContact(opts: {
|
|
|
1021
1031
|
const clauses = [];
|
|
1022
1032
|
if (email) clauses.push(eq(contacts.email, email));
|
|
1023
1033
|
if (userId) clauses.push(eq(contacts.externalId, userId));
|
|
1024
|
-
if (clauses.length === 0) return false;
|
|
1034
|
+
if (clauses.length === 0) return { deleted: false };
|
|
1025
1035
|
|
|
1026
1036
|
const updated = await db
|
|
1027
1037
|
.update(contacts)
|
|
1028
1038
|
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
|
1029
1039
|
.where(and(or(...clauses), isNull(contacts.deletedAt)))
|
|
1030
|
-
.returning({
|
|
1040
|
+
.returning({
|
|
1041
|
+
id: contacts.id,
|
|
1042
|
+
externalId: contacts.externalId,
|
|
1043
|
+
email: contacts.email,
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
const row = updated[0];
|
|
1047
|
+
if (!row) return { deleted: false };
|
|
1031
1048
|
|
|
1032
|
-
return
|
|
1049
|
+
return {
|
|
1050
|
+
deleted: true,
|
|
1051
|
+
id: row.id,
|
|
1052
|
+
externalId: row.externalId,
|
|
1053
|
+
email: row.email,
|
|
1054
|
+
};
|
|
1033
1055
|
}
|
|
1034
1056
|
|
|
1035
1057
|
/**
|
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>
|
|
@@ -187,9 +196,23 @@ export function createTrackedMailer(
|
|
|
187
196
|
): Promise<boolean> {
|
|
188
197
|
switch (event.type) {
|
|
189
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;
|
|
190
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;
|
|
191
210
|
case "email.opened":
|
|
192
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).
|
|
193
216
|
await updateEmailStatus(event.type, event.data.email_id);
|
|
194
217
|
break;
|
|
195
218
|
case "email.bounced":
|
|
@@ -197,6 +220,11 @@ export function createTrackedMailer(
|
|
|
197
220
|
bounceType: event.data.bounce?.type,
|
|
198
221
|
bounceReason: event.data.bounce?.message,
|
|
199
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
|
+
});
|
|
200
228
|
await handleBounce(event.data.to);
|
|
201
229
|
break;
|
|
202
230
|
case "email.complained":
|
|
@@ -250,6 +278,65 @@ export function createTrackedMailer(
|
|
|
250
278
|
.where(eq(emailPreferences.email, email));
|
|
251
279
|
}
|
|
252
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
|
+
|
|
253
340
|
async function updateEmailStatus(
|
|
254
341
|
eventType: WebhookEventType,
|
|
255
342
|
resendId: string,
|