@hogsend/engine 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +7 -6
  2. package/src/app.ts +36 -1
  3. package/src/container.ts +80 -8
  4. package/src/destinations/define-destination.ts +104 -0
  5. package/src/destinations/presets/index.ts +94 -0
  6. package/src/destinations/presets/posthog.ts +71 -0
  7. package/src/destinations/presets/segment.ts +75 -0
  8. package/src/destinations/presets/slack.ts +66 -0
  9. package/src/destinations/presets/webhook.ts +37 -0
  10. package/src/destinations/registry-singleton.ts +78 -0
  11. package/src/env.ts +40 -0
  12. package/src/index.ts +59 -1
  13. package/src/journeys/define-journey.ts +26 -3
  14. package/src/journeys/journey-context.ts +1 -17
  15. package/src/lib/analytics-singleton.ts +7 -0
  16. package/src/lib/bucket-emit.ts +45 -0
  17. package/src/lib/contacts.ts +28 -6
  18. package/src/lib/mailer.ts +102 -0
  19. package/src/lib/outbound.ts +223 -0
  20. package/src/lib/preferences.ts +31 -0
  21. package/src/lib/seed-posthog-destination.ts +93 -0
  22. package/src/lib/tracked.ts +45 -3
  23. package/src/lib/tracking-events.ts +77 -10
  24. package/src/lib/webhook-signing.ts +152 -0
  25. package/src/routes/admin/contacts.ts +43 -3
  26. package/src/routes/admin/index.ts +2 -0
  27. package/src/routes/admin/webhooks.ts +557 -0
  28. package/src/routes/contacts/index.ts +48 -5
  29. package/src/routes/lists/index.ts +41 -5
  30. package/src/routes/tracking/click.ts +58 -22
  31. package/src/routes/tracking/open.ts +53 -22
  32. package/src/routes/webhooks/sources.ts +69 -10
  33. package/src/webhook-sources/define-webhook-source.ts +57 -5
  34. package/src/webhook-sources/presets/clerk.ts +185 -0
  35. package/src/webhook-sources/presets/index.ts +80 -0
  36. package/src/webhook-sources/presets/segment.ts +120 -0
  37. package/src/webhook-sources/presets/stripe.ts +147 -0
  38. package/src/webhook-sources/presets/supabase.ts +131 -0
  39. package/src/webhook-sources/verify.ts +172 -0
  40. package/src/worker.ts +6 -0
  41. package/src/workflows/deliver-webhook.ts +484 -0
@@ -0,0 +1,78 @@
1
+ import type { DefinedDestination } from "./define-destination.js";
2
+ import { PRESET_DESTINATIONS } from "./presets/index.js";
3
+
4
+ /**
5
+ * The process-wide destination registry, set once by `createHogsendClient` at
6
+ * startup and read by the delivery task (`workflows/deliver-webhook.ts`) to
7
+ * resolve a transform by `endpoint.kind`.
8
+ *
9
+ * Why a singleton (mirrors `lib/analytics-singleton.ts`): the durable
10
+ * `deliverWebhookTask` SELF-BOOTS — it opens its own `getDb()` from
11
+ * `process.env` and has NO client/container reference. So the registered
12
+ * transforms (presets + the consumer's `defineDestination()` destinations) MUST
13
+ * be reachable via a process singleton, exactly as analytics / the journey +
14
+ * bucket registries are. `createHogsendClient` runs in BOTH the API and worker,
15
+ * so by the time any worker task executes the registry has been installed.
16
+ *
17
+ * Resilient default: if a delivery task somehow runs in a process that never
18
+ * called `createHogsendClient` (a bare reaper re-drive in a test harness), the
19
+ * getter lazily falls back to the shipped {@link PRESET_DESTINATIONS} so the
20
+ * no-regression `webhook` + the `posthog` presets still resolve. Installing a
21
+ * registry via {@link setDestinationRegistry} replaces this fallback.
22
+ */
23
+ export class DestinationRegistry {
24
+ private readonly byKind = new Map<string, DefinedDestination>();
25
+
26
+ constructor(destinations: DefinedDestination[] = []) {
27
+ for (const destination of destinations) {
28
+ // Last-writer-wins on id collision — the caller (container) orders the
29
+ // array so the consumer's destination wins over a preset of the same id.
30
+ this.byKind.set(destination.meta.id, destination);
31
+ }
32
+ }
33
+
34
+ /** Resolve a destination by its `kind` id, or `undefined` when unregistered. */
35
+ get(kind: string): DefinedDestination | undefined {
36
+ return this.byKind.get(kind);
37
+ }
38
+
39
+ /** Every registered destination (for diagnostics / catalog enumeration). */
40
+ getAll(): DefinedDestination[] {
41
+ return [...this.byKind.values()];
42
+ }
43
+
44
+ /** Number of registered destinations. */
45
+ count(): number {
46
+ return this.byKind.size;
47
+ }
48
+ }
49
+
50
+ /** The lazily-built fallback registry of just the shipped presets. */
51
+ let fallback: DestinationRegistry | undefined;
52
+ let installed: DestinationRegistry | undefined;
53
+
54
+ /**
55
+ * Install the resolved destination registry. Called by `createHogsendClient`
56
+ * after merging the env presets with the consumer's `opts.destinations`.
57
+ */
58
+ export function setDestinationRegistry(registry: DestinationRegistry): void {
59
+ installed = registry;
60
+ }
61
+
62
+ /**
63
+ * Read the destination registry. Returns the installed registry, or a lazily
64
+ * built preset-only fallback so a self-booting task always resolves the
65
+ * always-on `webhook` + `posthog` presets even before any container ran.
66
+ */
67
+ export function getDestinationRegistry(): DestinationRegistry {
68
+ if (installed) return installed;
69
+ if (!fallback) {
70
+ fallback = new DestinationRegistry(Object.values(PRESET_DESTINATIONS));
71
+ }
72
+ return fallback;
73
+ }
74
+
75
+ /** Reset the installed registry — only for test cleanup. */
76
+ export function resetDestinationRegistry(): void {
77
+ installed = undefined;
78
+ }
package/src/env.ts CHANGED
@@ -57,6 +57,12 @@ export const env = createEnv({
57
57
  POSTHOG_API_KEY: z.string().min(1).optional(),
58
58
  POSTHOG_HOST: z.string().url().optional(),
59
59
  POSTHOG_WEBHOOK_SECRET: z.string().min(1).optional(),
60
+ // When true AND POSTHOG_API_KEY is set, the engine idempotently auto-seeds
61
+ // ONE kind="posthog" webhook endpoint subscribed to the email funnel so the
62
+ // full email lifecycle fans out to PostHog DURABLY (on the delivery spine).
63
+ // Default OFF to avoid a surprise double-emit alongside the existing
64
+ // fire-and-forget PostHog capture path.
65
+ ENABLE_POSTHOG_DESTINATION: z.coerce.boolean().default(false),
60
66
  RESEND_WEBHOOK_SECRET: z.string().min(1).optional(),
61
67
  ADMIN_API_KEY: z.string().min(1).optional(),
62
68
  API_PUBLIC_URL: z.string().url().default("http://localhost:3002"),
@@ -71,6 +77,40 @@ export const env = createEnv({
71
77
  ENABLED_LISTS: z.string().default("*"),
72
78
  // Cadence for the engine-owned bucket reconcile cron (time-based leaves).
73
79
  BUCKET_RECONCILE_CRON: z.string().default("*/5 * * * *"),
80
+ // --- Outbound webhooks (Section 1.5/1.8) ---
81
+ // Cadence for the engine-owned outbound-delivery reaper cron (the retry
82
+ // scheduler + orphan-`sending` recovery). Declared for parity with
83
+ // BUCKET_RECONCILE_CRON; the delivery task also reads it raw off process.env.
84
+ OUTBOUND_WEBHOOK_REAPER_CRON: z.string().optional(),
85
+ // Delivery tunables — read raw off process.env inside the durable task;
86
+ // declared here so they are part of the validated env contract. All optional
87
+ // with task-internal defaults (MAX_ATTEMPTS 8, TIMEOUT 15s, BASE 5s,
88
+ // MAX_DELAY 6h, STUCK_AFTER 5min).
89
+ OUTBOUND_WEBHOOK_MAX_ATTEMPTS: z.coerce.number().optional(),
90
+ OUTBOUND_WEBHOOK_TIMEOUT_MS: z.coerce.number().optional(),
91
+ OUTBOUND_WEBHOOK_BASE_DELAY_MS: z.coerce.number().optional(),
92
+ OUTBOUND_WEBHOOK_MAX_DELAY_MS: z.coerce.number().optional(),
93
+ OUTBOUND_WEBHOOK_STUCK_AFTER_MS: z.coerce.number().optional(),
94
+ // --- Integration presets (Section 2.2) ---
95
+ // Signature-source secrets. The webhook route resolves a preset's secret via
96
+ // env[source.auth.envKey]; a signature source FAILS CLOSED when its secret is
97
+ // unset. Setting one auto-enables that preset at POST /v1/webhooks/<id>.
98
+ CLERK_WEBHOOK_SECRET: z.string().min(1).optional(),
99
+ SUPABASE_WEBHOOK_SECRET: z.string().min(1).optional(),
100
+ STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
101
+ SEGMENT_WEBHOOK_SECRET: z.string().min(1).optional(),
102
+ // Preset enablement override: csv of preset ids, `"*"` (all with a secret),
103
+ // or `"none"`. Absent → auto-enable any preset whose secret is set.
104
+ ENABLED_WEBHOOK_PRESETS: z.string().optional(),
105
+ // --- Outbound destination presets (Phase 3) ---
106
+ // Which `defineDestination()` PRESETS are registered into the process
107
+ // destination registry the delivery task resolves by `endpoint.kind`. csv of
108
+ // ids (e.g. "segment,slack"), `"*"` (all presets), or `"none"`. Absent → the
109
+ // DEFAULT set (webhook + posthog). The `webhook` and `posthog` presets are
110
+ // ALWAYS registered regardless of this value, so the no-regression delivery
111
+ // path can never be turned off by misconfiguration. Set this to add the
112
+ // segment/slack presets (credentials still live per-endpoint in `config`).
113
+ ENABLED_DESTINATION_PRESETS: z.string().optional(),
74
114
  },
75
115
  runtimeEnv: process.env,
76
116
  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
@@ -77,6 +76,31 @@ export {
77
76
  type HogsendClientOptions,
78
77
  type HogsendDefaults,
79
78
  } from "./container.js";
79
+ // --- Outbound destinations: public authoring layer (Phase 3) ---
80
+ export {
81
+ type DefinedDestination,
82
+ type DestinationCtx,
83
+ type DestinationEnvelope,
84
+ type DestinationMeta,
85
+ type DestinationTransformResult,
86
+ defineDestination,
87
+ type WebhookEndpointRow,
88
+ } from "./destinations/define-destination.js";
89
+ export {
90
+ type DestinationPresetId,
91
+ destinationsFromEnv,
92
+ PRESET_DESTINATIONS,
93
+ posthogDestination,
94
+ segmentDestination,
95
+ slackDestination,
96
+ webhookDestination,
97
+ } from "./destinations/presets/index.js";
98
+ export {
99
+ DestinationRegistry,
100
+ getDestinationRegistry,
101
+ resetDestinationRegistry,
102
+ setDestinationRegistry,
103
+ } from "./destinations/registry-singleton.js";
80
104
  // --- Env ---
81
105
  export { API_VERSION, env } from "./env.js";
82
106
  // --- Journeys ---
@@ -150,6 +174,13 @@ export {
150
174
  // --- Logging ---
151
175
  export { createLogger, type Logger } from "./lib/logger.js";
152
176
  export { createTrackedMailer } from "./lib/mailer.js";
177
+ // --- Outbound webhooks: emit spine (Section 1.4) ---
178
+ export {
179
+ emitOutbound,
180
+ OUTBOUND_EVENTS,
181
+ type OutboundEventName,
182
+ type OutboundPayloads,
183
+ } from "./lib/outbound.js";
153
184
  export { getPostHog } from "./lib/posthog.js";
154
185
  export { getRedisIfConnected } from "./lib/redis.js";
155
186
  export { type MountStudioResult, mountStudio } from "./lib/studio.js";
@@ -174,7 +205,17 @@ export {
174
205
  export {
175
206
  pushTrackingEvent,
176
207
  resolveEmailSendContext,
208
+ resolveEmailSendContextByResendId,
177
209
  } from "./lib/tracking-events.js";
210
+ // --- Outbound webhooks: signing core (Section 1.2) ---
211
+ export {
212
+ generateWebhookSecret,
213
+ type SignedWebhook,
214
+ signWebhook,
215
+ verifyWebhookSignature,
216
+ WEBHOOK_EVENT_TYPES,
217
+ type WebhookEventType,
218
+ } from "./lib/webhook-signing.js";
178
219
  // --- Lists (D3) ---
179
220
  export {
180
221
  type DefinedList,
@@ -191,9 +232,21 @@ export {
191
232
  export {
192
233
  type DefinedWebhookSource,
193
234
  defineWebhookSource,
235
+ verifySignature,
236
+ type WebhookSourceAuth,
194
237
  type WebhookSourceCtx,
195
238
  type WebhookSourceMeta,
196
239
  } from "./webhook-sources/define-webhook-source.js";
240
+ // --- Integration presets (Section 2.3/2.4) ---
241
+ export {
242
+ clerkSource,
243
+ PRESET_SOURCES,
244
+ type PresetId,
245
+ presetsFromEnv,
246
+ segmentSource,
247
+ stripeSource,
248
+ supabaseSource,
249
+ } from "./webhook-sources/presets/index.js";
197
250
  export {
198
251
  type CreateWorkerOptions,
199
252
  createWorker,
@@ -211,6 +264,11 @@ export {
211
264
  bucketReconcileTask,
212
265
  } from "./workflows/bucket-reconcile.js";
213
266
  export { checkAlertsTask } from "./workflows/check-alerts.js";
267
+ // --- Outbound webhooks: durable delivery task + reaper (Section 1.5) ---
268
+ export {
269
+ deliverWebhookTask,
270
+ reapDueWebhookDeliveriesTask,
271
+ } from "./workflows/deliver-webhook.js";
214
272
  export { importContactsTask } from "./workflows/import-contacts.js";
215
273
  export { sendCampaignTask } from "./workflows/send-campaign.js";
216
274
  // --- 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";
@@ -178,7 +179,6 @@ export function defineJourney(options: {
178
179
  hatchetCtx,
179
180
  registry: getJourneyRegistrySingleton(),
180
181
  logger,
181
- posthog,
182
182
  stateId,
183
183
  userId,
184
184
  userEmail,
@@ -190,12 +190,13 @@ export function defineJourney(options: {
190
190
  try {
191
191
  await options.run(user, ctx);
192
192
 
193
+ const completedAt = new Date();
193
194
  await db
194
195
  .update(journeyStates)
195
196
  .set({
196
197
  status: "completed",
197
- completedAt: new Date(),
198
- updatedAt: new Date(),
198
+ completedAt,
199
+ updatedAt: completedAt,
199
200
  })
200
201
  .where(eq(journeyStates.id, stateId));
201
202
 
@@ -205,6 +206,28 @@ export function defineJourney(options: {
205
206
  userId,
206
207
  });
207
208
 
209
+ // OUTBOUND `journey.completed` — fired alongside the internal
210
+ // `journey:completed` push. Runs in the WORKER (this durable task), so it
211
+ // uses the engine `db`/`hatchet`/`logger` singletons. `dedupeKey` =
212
+ // `journey.completed:<stateId>`: a Hatchet re-execution recomputes the
213
+ // identical key and the unique `(endpointId, dedupeKey)` index absorbs the
214
+ // duplicate (risk 3). `journey:failed` is NOT in the catalog → no emit.
215
+ void emitOutbound({
216
+ db,
217
+ hatchet,
218
+ logger,
219
+ event: "journey.completed",
220
+ dedupeKey: `journey.completed:${stateId}`,
221
+ payload: {
222
+ journeyId: meta.id,
223
+ journeyName: meta.name,
224
+ stateId,
225
+ userId,
226
+ userEmail,
227
+ completedAt: completedAt.toISOString(),
228
+ },
229
+ }).catch(logger.warn);
230
+
208
231
  return { stateId, status: "completed" };
209
232
  } catch (err) {
210
233
  // The journey reached a terminal state (exitOn / cancel) while suspended
@@ -7,7 +7,7 @@ import {
7
7
  SleepCondition,
8
8
  UserEventCondition,
9
9
  } from "@hatchet-dev/typescript-sdk/v1/index.js";
10
- import type { DurationObject, PostHogService } from "@hogsend/core";
10
+ import type { DurationObject } from "@hogsend/core";
11
11
  import { durationToMs, evaluateEventCondition } from "@hogsend/core";
12
12
  import type { JourneyRegistry } from "@hogsend/core/registry";
13
13
  import {
@@ -69,7 +69,6 @@ interface JourneyContextConfig {
69
69
  };
70
70
  registry: JourneyRegistry;
71
71
  logger: Logger;
72
- posthog?: PostHogService;
73
72
  stateId: string;
74
73
  userId: string;
75
74
  userEmail: string;
@@ -140,7 +139,6 @@ export function createJourneyContext(
140
139
  hatchetCtx,
141
140
  registry,
142
141
  logger,
143
- posthog,
144
142
  stateId,
145
143
  userId,
146
144
  userEmail,
@@ -316,10 +314,6 @@ export function createJourneyContext(
316
314
  });
317
315
  },
318
316
 
319
- identify(properties) {
320
- posthog?.identify(userId, properties);
321
- },
322
-
323
317
  guard: {
324
318
  async isSubscribed() {
325
319
  const prefs = await checkEmailPreferences({ db, userId });
@@ -390,15 +384,5 @@ export function createJourneyContext(
390
384
  };
391
385
  },
392
386
  },
393
-
394
- posthog: {
395
- capture({ event, properties }) {
396
- posthog?.captureEvent({
397
- distinctId: userId,
398
- event,
399
- properties,
400
- });
401
- },
402
- },
403
387
  };
404
388
  }
@@ -6,6 +6,13 @@ import { createOptionalSingleton } from "./singleton.js";
6
6
  * the module-level task-execution sites that have no client reference of their
7
7
  * own (the journey durable task in `define-journey`, the bucket PostHog sync).
8
8
  *
9
+ * Its role is deliberately NARROW (see the `analytics?` option doc on
10
+ * {@link createHogsendClient}): the identity PULL (`getPersonProperties` for
11
+ * per-user timezone resolution) and the opt-in `bucket.syncToPostHog`
12
+ * person-property mirror. It is explicitly NOT the outbound-catalog firing
13
+ * path — the email/contact/journey/bucket lifecycle fans out durably via
14
+ * DESTINATIONS on the webhook spine, not through this singleton.
15
+ *
9
16
  * Mirrors the journey/bucket-registry + client-schedule-defaults singletons:
10
17
  * `createHogsendClient` runs in BOTH the API and worker processes, so by the
11
18
  * time any worker task executes, the container has already installed the
@@ -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
  }
@@ -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
- * Returns true iff a live row was found and soft-deleted.
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<boolean> {
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({ id: contacts.id });
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 updated.length > 0;
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,24 @@ 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 — it now fires PER-HIT (every open/click → a delivery to
214
+ // every destination, owner decision 1). The provider-webhook echo is
215
+ // SUPPRESSED here: it only updates the DB status, it does NOT emit
216
+ // outbound (no double-source).
193
217
  await updateEmailStatus(event.type, event.data.email_id);
194
218
  break;
195
219
  case "email.bounced":
@@ -197,10 +221,18 @@ export function createTrackedMailer(
197
221
  bounceType: event.data.bounce?.type,
198
222
  bounceReason: event.data.bounce?.message,
199
223
  });
224
+ // OUTBOUND `email.bounced` with the bounce detail.
225
+ await emitProviderEmailEvent("email.bounced", event.data.email_id, {
226
+ bounceType: event.data.bounce?.type,
227
+ bounceReason: event.data.bounce?.message,
228
+ });
200
229
  await handleBounce(event.data.to);
201
230
  break;
202
231
  case "email.complained":
203
232
  await updateEmailStatus(event.type, event.data.email_id);
233
+ // OUTBOUND `email.complained` — the provider webhook is the SINGLE
234
+ // source for complaints (no first-party signal exists).
235
+ await emitProviderEmailEvent("email.complained", event.data.email_id);
204
236
  await handleComplaint(event.data.to);
205
237
  break;
206
238
  case "email.delivery_delayed":
@@ -250,6 +282,76 @@ export function createTrackedMailer(
250
282
  .where(eq(emailPreferences.email, email));
251
283
  }
252
284
 
285
+ /**
286
+ * Emit the provider-funnel outbound event (`email.delivered` /
287
+ * `email.bounced` / `email.complained`) for a Resend `email_id`. These three
288
+ * have no first-party signal — the provider webhook is their single source.
289
+ * Enriches via {@link resolveEmailSendContextByResendId}
290
+ * (the only handle a provider webhook holds is the Resend id). Fire-and-forget:
291
+ * a missing context (webhook racing the send-row commit) or a transient outbound
292
+ * error is logged and swallowed — never failing the webhook handler. No
293
+ * `dedupeKey`: the provider path is not a Hatchet-retryable producer, and the
294
+ * shared `Webhook-Id` is the subscriber-side dedup for any provider redelivery.
295
+ */
296
+ function emitProviderEmailEvent(
297
+ event: "email.delivered" | "email.bounced" | "email.complained",
298
+ resendId: string,
299
+ bounce?: { bounceType?: string; bounceReason?: string },
300
+ ): void {
301
+ if (!db) return;
302
+ const log = config.logger ?? emitLogger;
303
+ const database = db;
304
+ void resolveEmailSendContextByResendId(database, resendId)
305
+ .then((ctx) => {
306
+ if (!ctx) return;
307
+ const base = {
308
+ emailSendId: ctx.emailSendId,
309
+ resendId,
310
+ templateKey: ctx.templateKey,
311
+ userId: ctx.userId,
312
+ to: ctx.to,
313
+ at: new Date().toISOString(),
314
+ };
315
+ if (event === "email.bounced") {
316
+ return emitOutbound({
317
+ db: database,
318
+ hatchet,
319
+ logger: log,
320
+ event: "email.bounced",
321
+ payload: {
322
+ ...base,
323
+ ...(bounce?.bounceType ? { bounceType: bounce.bounceType } : {}),
324
+ ...(bounce?.bounceReason
325
+ ? { bounceReason: bounce.bounceReason }
326
+ : {}),
327
+ },
328
+ });
329
+ }
330
+ if (event === "email.complained") {
331
+ return emitOutbound({
332
+ db: database,
333
+ hatchet,
334
+ logger: log,
335
+ event: "email.complained",
336
+ payload: base,
337
+ });
338
+ }
339
+ return emitOutbound({
340
+ db: database,
341
+ hatchet,
342
+ logger: log,
343
+ event: "email.delivered",
344
+ payload: base,
345
+ });
346
+ })
347
+ .catch((err: unknown) => {
348
+ log.warn(`emitOutbound ${event} failed`, {
349
+ resendId,
350
+ error: err instanceof Error ? err.message : String(err),
351
+ });
352
+ });
353
+ }
354
+
253
355
  async function updateEmailStatus(
254
356
  eventType: WebhookEventType,
255
357
  resendId: string,