@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.7.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.7.0",
41
- "@hogsend/db": "^0.7.0",
42
- "@hogsend/email": "^0.7.0",
43
- "@hogsend/plugin-posthog": "^0.7.0",
44
- "@hogsend/plugin-resend": "^0.7.0"
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
- registerRoutes(app, { webhookSources: opts.webhookSources ?? [] });
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: new Date(),
198
- updatedAt: new Date(),
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
@@ -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,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,