@hogsend/engine 0.8.0 → 0.10.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 (34) hide show
  1. package/package.json +9 -6
  2. package/src/container.ts +156 -29
  3. package/src/destinations/define-destination.ts +104 -0
  4. package/src/destinations/presets/index.ts +94 -0
  5. package/src/destinations/presets/posthog.ts +71 -0
  6. package/src/destinations/presets/segment.ts +75 -0
  7. package/src/destinations/presets/slack.ts +66 -0
  8. package/src/destinations/presets/webhook.ts +37 -0
  9. package/src/destinations/registry-singleton.ts +78 -0
  10. package/src/env.ts +38 -1
  11. package/src/index.ts +46 -6
  12. package/src/journeys/define-journey.ts +0 -1
  13. package/src/journeys/journey-context.ts +1 -17
  14. package/src/lib/analytics-singleton.ts +7 -0
  15. package/src/lib/email-provider-registry.ts +45 -0
  16. package/src/lib/email-providers-from-env.ts +94 -0
  17. package/src/lib/email-service-types.ts +40 -4
  18. package/src/lib/headers.ts +13 -0
  19. package/src/lib/mailer.ts +137 -72
  20. package/src/lib/outbound.ts +18 -2
  21. package/src/lib/seed-posthog-destination.ts +93 -0
  22. package/src/lib/tracked.ts +34 -29
  23. package/src/lib/tracking-events.ts +37 -20
  24. package/src/lib/webhook-signing.ts +2 -1
  25. package/src/routes/admin/emails.ts +5 -1
  26. package/src/routes/admin/webhooks.ts +100 -9
  27. package/src/routes/tracking/click.ts +20 -25
  28. package/src/routes/tracking/open.ts +20 -27
  29. package/src/routes/webhooks/email-provider.ts +124 -0
  30. package/src/routes/webhooks/index.ts +7 -0
  31. package/src/routes/webhooks/resend.ts +14 -29
  32. package/src/routes/webhooks/sources.ts +15 -4
  33. package/src/workflows/deliver-webhook.ts +137 -52
  34. package/src/workflows/send-email.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,11 +38,14 @@
38
38
  "svix": "^1.95.1",
39
39
  "winston": "^3.19.0",
40
40
  "zod": "^4.4.3",
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"
41
+ "@hogsend/core": "^0.10.0",
42
+ "@hogsend/db": "^0.10.0",
43
+ "@hogsend/email": "^0.10.0",
44
+ "@hogsend/plugin-posthog": "^0.10.0",
45
+ "@hogsend/plugin-resend": "^0.10.0"
46
+ },
47
+ "optionalDependencies": {
48
+ "@hogsend/plugin-postmark": "^0.10.0"
46
49
  },
47
50
  "devDependencies": {
48
51
  "@types/node": "^22.15.3",
package/src/container.ts CHANGED
@@ -9,17 +9,18 @@ import {
9
9
  type JournalShape,
10
10
  } from "@hogsend/db";
11
11
  import type { TemplateRegistry } from "@hogsend/email";
12
- import {
13
- createResendClient,
14
- createResendProvider,
15
- } from "@hogsend/plugin-resend";
16
- import type { Resend } from "resend";
17
12
  import { createBucketAccessor } from "./buckets/bucket-access.js";
18
13
  import type { DefinedBucket } from "./buckets/define-bucket.js";
19
14
  import {
20
15
  buildBucketRegistry,
21
16
  collectBucketReactionJourneys,
22
17
  } from "./buckets/registry.js";
18
+ import type { DefinedDestination } from "./destinations/define-destination.js";
19
+ import { destinationsFromEnv } from "./destinations/presets/index.js";
20
+ import {
21
+ DestinationRegistry,
22
+ setDestinationRegistry,
23
+ } from "./destinations/registry-singleton.js";
23
24
  import { env } from "./env.js";
24
25
  import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
25
26
  import type { DefinedJourney } from "./journeys/define-journey.js";
@@ -27,6 +28,8 @@ import { buildJourneyRegistry } from "./journeys/registry.js";
27
28
  import { setAnalytics } from "./lib/analytics-singleton.js";
28
29
  import { type Auth, createAuth } from "./lib/auth.js";
29
30
  import { setEmailService } from "./lib/email.js";
31
+ import { EmailProviderRegistry } from "./lib/email-provider-registry.js";
32
+ import { emailProvidersFromEnv } from "./lib/email-providers-from-env.js";
30
33
  import type {
31
34
  EmailService,
32
35
  FrequencyCapConfig,
@@ -35,6 +38,7 @@ import { hatchet } from "./lib/hatchet.js";
35
38
  import { createLogger, type Logger } from "./lib/logger.js";
36
39
  import { createTrackedMailer } from "./lib/mailer.js";
37
40
  import { getPostHog } from "./lib/posthog.js";
41
+ import { seedPostHogDestination } from "./lib/seed-posthog-destination.js";
38
42
  import { prepareTrackedHtml } from "./lib/tracking.js";
39
43
  import type { DefinedList } from "./lists/define-list.js";
40
44
  import { buildListRegistry, type ListRegistry } from "./lists/registry.js";
@@ -54,8 +58,19 @@ export interface HogsendClient {
54
58
  db: Database;
55
59
  dbClient: DatabaseClient;
56
60
  auth: Auth;
57
- email: Resend;
58
61
  emailService: EmailService;
62
+ /**
63
+ * The container-held registry of email providers, keyed by `meta.id`. The
64
+ * `POST /v1/webhooks/email/:providerId` route resolves the verifying provider
65
+ * out of this. Holds at least the resolved active provider.
66
+ */
67
+ emailProviders: EmailProviderRegistry;
68
+ /**
69
+ * The single resolved active email provider (the one the mailer sends
70
+ * through). Resolved from `opts.email.defaultProvider` / `EMAIL_PROVIDER`,
71
+ * defaulting to the env-built Resend provider for byte-for-byte parity.
72
+ */
73
+ emailProvider: EmailProvider;
59
74
  /**
60
75
  * The app's template registry (key → component + subject + category +
61
76
  * optional preview/examples). Same object threaded into the engine mailer;
@@ -114,10 +129,18 @@ export interface HogsendClientOptions {
114
129
  * (templates → render → preference checks → tracking → `email_sends` write),
115
130
  * and the {@link EmailProvider} is only the swappable wire under it.
116
131
  *
117
- * - `provider` — the swappable email provider (Resend, Postmark, SES…).
118
- * Defaults to a Resend provider built from env (`RESEND_API_KEY` /
119
- * `RESEND_WEBHOOK_SECRET`). Tracking/rendering/preferences come along for
120
- * free regardless of which provider you supply.
132
+ * - `provider` — a single swappable email provider (Resend, Postmark, SES…),
133
+ * the back-compat one-provider seam. MERGED LAST (after env presets and
134
+ * `providers`), so it wins on id collision. Tracking/rendering/preferences
135
+ * come along for free regardless of which provider you supply.
136
+ * - `providers` — register MANY providers into the {@link EmailProviderRegistry}
137
+ * (e.g. Resend + Postmark) so the `POST /v1/webhooks/email/:providerId`
138
+ * route can verify each one's webhooks. Merged AFTER the env presets and
139
+ * BEFORE `provider`.
140
+ * - `defaultProvider` — the active provider id the mailer sends through.
141
+ * Resolves as `defaultProvider ?? EMAIL_PROVIDER ?? "resend"`. If it names a
142
+ * provider that isn't registered, the container throws at boot with the list
143
+ * of registered ids.
121
144
  * - `templates` — the app's template registry (key → component + subject +
122
145
  * category), threaded into the engine mailer and onward to
123
146
  * `getTemplate(..., { registry })`. The engine bakes in no business
@@ -129,15 +152,43 @@ export interface HogsendClientOptions {
129
152
  */
130
153
  email?: {
131
154
  provider?: EmailProvider;
155
+ providers?: EmailProvider[];
156
+ defaultProvider?: string;
132
157
  templates?: TemplateRegistry;
133
158
  };
134
159
  /**
135
- * The PostHog-style analytics service used for person properties + event
136
- * capture. Lives at the top level (not under `email`) because the engine
137
- * itself uses it tracking routes and ingestion fire captures. Defaults to
138
- * {@link getPostHog} (a no-op when `POSTHOG_API_KEY` is unset).
160
+ * The PostHog-style analytics service. As of the destinations spine its role
161
+ * is deliberately NARROW it is NOT the outbound-catalog firing path (the
162
+ * email/contact/journey/bucket lifecycle now fans out durably via
163
+ * DESTINATIONS on the webhook spine, keyed by `webhook_endpoints.kind`). It
164
+ * remains for exactly two things:
165
+ *
166
+ * 1. The identity PULL — `getPersonProperties` for per-user timezone
167
+ * resolution at journey enrollment (`define-journey` / `lib/timezone.ts`).
168
+ * This read role is UNCHANGED and load-bearing.
169
+ * 2. The opt-in `bucket.syncToPostHog` person-property mirror — `$set`/`$unset`
170
+ * of a boolean cohort property on bucket transitions (`bucket-posthog-sync`).
171
+ * Off by default; PostHog `$set`/`$unset` identity semantics have no
172
+ * vendor-neutral envelope, so this stays a PostHog-direct write.
173
+ *
174
+ * Lives at the top level (not under `email`) because the engine itself uses
175
+ * it for the PULL. Defaults to {@link getPostHog} (a no-op when
176
+ * `POSTHOG_API_KEY` is unset).
139
177
  */
140
178
  analytics?: PostHogService;
179
+ /**
180
+ * Code-defined outbound DESTINATIONS (Phase 3). Each is a
181
+ * `defineDestination()` delivery-time transform keyed by its `meta.id`, which
182
+ * the delivery task resolves by `webhook_endpoints.kind`. They are MERGED with
183
+ * the env-enabled presets ({@link destinationsFromEnv}): a consumer
184
+ * destination WINS over a preset of the same id (so you can override the
185
+ * shipped `posthog`/`segment`/`slack` shapes). The `webhook` + `posthog`
186
+ * presets are always present, so the no-regression signed-POST path can never
187
+ * be turned off here. Installed as the process registry the self-booting
188
+ * delivery task reads — and `createHogsendClient` runs in BOTH the API and
189
+ * worker, so it is wired in both. Defaults to none (presets only).
190
+ */
191
+ destinations?: DefinedDestination[];
141
192
  /**
142
193
  * Comma-separated ids (or `*`) controlling which journeys load. Defaults to
143
194
  * `env.ENABLED_JOURNEYS`.
@@ -216,8 +267,6 @@ export function createHogsendClient(
216
267
  ),
217
268
  });
218
269
 
219
- const email = createResendClient({ apiKey: env.RESEND_API_KEY });
220
-
221
270
  const registry = buildJourneyRegistry(
222
271
  opts.journeys ?? [],
223
272
  opts.enabledJourneys ?? env.ENABLED_JOURNEYS,
@@ -268,12 +317,51 @@ export function createHogsendClient(
268
317
  opts.enabledLists ?? env.ENABLED_LISTS,
269
318
  );
270
319
 
271
- const provider =
272
- opts.email?.provider ??
273
- createResendProvider({
274
- apiKey: env.RESEND_API_KEY,
275
- webhookSecret: env.RESEND_WEBHOOK_SECRET,
276
- });
320
+ // Build the email provider registry, then resolve the single active provider
321
+ // the mailer sends through. Merge order is load-bearing (consumer last/wins,
322
+ // mirroring the destinations merge): env presets FIRST, then
323
+ // `opts.email.providers`, then the single back-compat `opts.email.provider`
324
+ // LAST — so a consumer-supplied provider overrides an env preset of the same
325
+ // id (last-writer-wins on the registry). The registry is what the
326
+ // `POST /v1/webhooks/email/:providerId` route dispatches by id.
327
+ const emailProviders = new EmailProviderRegistry([
328
+ ...emailProvidersFromEnv(env),
329
+ ...(opts.email?.providers ?? []),
330
+ ...(opts.email?.provider ? [opts.email.provider] : []),
331
+ ]);
332
+
333
+ // The active provider id the mailer sends through:
334
+ // `defaultProvider ?? EMAIL_PROVIDER ?? "resend"`. The default Resend provider
335
+ // is built (when RESEND_API_KEY is set) by `emailProvidersFromEnv` above — the
336
+ // SINGLE place Resend is constructed from env — so resolution is just a
337
+ // registry lookup that throws if the active id resolves to nothing. NEVER
338
+ // silently fall back for a non-resend id.
339
+ const activeId =
340
+ opts.email?.defaultProvider ?? env.EMAIL_PROVIDER ?? "resend";
341
+ const provider = emailProviders.get(activeId);
342
+
343
+ if (!provider) {
344
+ throw new Error(
345
+ `email provider "${activeId}" is not registered (registered: ${emailProviders
346
+ .getAll()
347
+ .map((p) => p.meta?.id ?? "resend")
348
+ .join(", ")})`,
349
+ );
350
+ }
351
+
352
+ // Tracking sovereignty: first-party open/click tracking is the single source
353
+ // of truth. A provider that can't force its OWN tracking off per-send (an
354
+ // account-level toggle — e.g. Resend) declares `nativeTracking: true`. We
355
+ // can't reach that toggle, so we WARN at boot. The outbound-echo suppression
356
+ // in `dispatchWebhook` is the defence: a native open/click webhook only
357
+ // touches DB status, never re-emits outbound.
358
+ if (provider.capabilities?.nativeTracking === true) {
359
+ logger.warn(
360
+ `provider ${
361
+ provider.meta?.id ?? "resend"
362
+ } reports account-level native tracking ON; disable it in the dashboard — first-party tracking is Hogsend's source of truth.`,
363
+ );
364
+ }
277
365
 
278
366
  const defaults: HogsendDefaults = {
279
367
  timezone: opts.defaults?.timezone ?? "UTC",
@@ -294,10 +382,9 @@ export function createHogsendClient(
294
382
  opts.overrides?.mailer ??
295
383
  createTrackedMailer(
296
384
  {
297
- defaultFrom: env.RESEND_FROM_EMAIL,
385
+ defaultFrom: env.EMAIL_FROM ?? env.RESEND_FROM_EMAIL,
298
386
  templates,
299
387
  db,
300
- webhookSecret: env.RESEND_WEBHOOK_SECRET,
301
388
  bounceThreshold: 3,
302
389
  baseUrl: env.API_PUBLIC_URL,
303
390
  frequencyCap: defaults.frequencyCap,
@@ -314,17 +401,56 @@ export function createHogsendClient(
314
401
  const analytics = opts.analytics ?? getPostHog();
315
402
 
316
403
  // Expose the resolved analytics instance to the module-level task-execution
317
- // sites that have no client reference (the journey durable task in
318
- // define-journey, the bucket PostHog sync). `createHogsendClient` runs in both
319
- // the API and worker, so this is installed before any worker task runs. May be
320
- // undefined (no POSTHOG_API_KEY) — the reads stay no-ops.
404
+ // sites that have no client reference. Its role is NARROW (see the
405
+ // `analytics?` option doc): the identity PULL (`getPersonProperties` for tz
406
+ // resolution in the journey durable task) plus the opt-in
407
+ // `bucket.syncToPostHog` person-property mirrorNOT the outbound catalog
408
+ // firing path (that is the destinations spine). `createHogsendClient` runs in
409
+ // both the API and worker, so this is installed before any worker task runs.
410
+ // May be undefined (no POSTHOG_API_KEY) — the reads stay no-ops.
321
411
  setAnalytics(analytics);
322
412
 
413
+ // Build + install the outbound DESTINATION registry (Phase 3) the
414
+ // self-booting delivery task resolves by `webhook_endpoints.kind`. Order is
415
+ // load-bearing: the env-enabled presets come FIRST and the consumer's
416
+ // `opts.destinations` LAST, so the DestinationRegistry's last-writer-wins map
417
+ // lets a consumer destination override a shipped preset of the same id. Runs
418
+ // in BOTH the API and worker (both call createHogsendClient), so the registry
419
+ // is present before any worker delivery task executes.
420
+ const destinations = [
421
+ ...destinationsFromEnv(env),
422
+ ...(opts.destinations ?? []),
423
+ ];
424
+ const destinationRegistry = new DestinationRegistry(destinations);
425
+ setDestinationRegistry(destinationRegistry);
426
+
427
+ // Optional: auto-seed a PostHog DESTINATION on the outbound spine so the email
428
+ // lifecycle fans out to PostHog durably. Default OFF (ENABLE_POSTHOG_DESTINATION)
429
+ // to avoid double-emit alongside the fire-and-forget capture path. Idempotent +
430
+ // fire-and-forget — a seed failure must never block boot. Runs in BOTH the API
431
+ // and worker (both call createHogsendClient); the dup guard makes the second a
432
+ // no-op.
433
+ if (env.ENABLE_POSTHOG_DESTINATION && env.POSTHOG_API_KEY) {
434
+ void seedPostHogDestination({
435
+ db,
436
+ logger,
437
+ apiKey: env.POSTHOG_API_KEY,
438
+ host: env.POSTHOG_HOST,
439
+ }).catch((error: unknown) => {
440
+ logger.warn("seedPostHogDestination failed", {
441
+ error: error instanceof Error ? error.message : String(error),
442
+ });
443
+ });
444
+ }
445
+
323
446
  // Counts are surfaced by the boot banner / structured ready log (lib/boot.ts);
324
447
  // keep these at debug for non-boot contexts (tests, REPL, library use).
325
448
  logger.debug(`Journey registry loaded: ${registry.count()} journeys`);
326
449
  logger.debug(`Bucket registry loaded: ${bucketRegistry.count()} buckets`);
327
450
  logger.debug(`List registry loaded: ${listRegistry.count()} lists`);
451
+ logger.debug(
452
+ `Destination registry loaded: ${destinationRegistry.count()} destinations`,
453
+ );
328
454
 
329
455
  return {
330
456
  env,
@@ -332,8 +458,9 @@ export function createHogsendClient(
332
458
  db,
333
459
  dbClient: created.client,
334
460
  auth,
335
- email,
336
461
  emailService,
462
+ emailProviders,
463
+ emailProvider: provider,
337
464
  templates,
338
465
  analytics,
339
466
  registry,
@@ -0,0 +1,104 @@
1
+ import type { webhookEndpoints } from "@hogsend/db";
2
+ import type { Logger } from "../lib/logger.js";
3
+ import type { OutboundEventName } from "../lib/outbound.js";
4
+
5
+ /**
6
+ * Public, code-first authoring layer for OUTBOUND destinations — the symmetric
7
+ * twin of {@link defineWebhookSource} on the inbound side.
8
+ *
9
+ * A destination is a delivery-time TRANSFORM keyed by `webhook_endpoints.kind`.
10
+ * It receives the FROZEN vendor-neutral envelope (`{ id, type, timestamp, data }`)
11
+ * `emitOutbound` wrote to `webhook_deliveries.payload`, plus the LIVE endpoint
12
+ * row read at delivery time, and returns the concrete HTTP request to make. All
13
+ * of the durable delivery machinery (retry / backoff / DLQ / reaper / CAS /
14
+ * idempotency) is unchanged — it operates on the delivery ROW, never on the wire
15
+ * — so a code-defined destination inherits every bit of it for free.
16
+ *
17
+ * Like `defineWebhookSource`, this is an identity / validating function: it
18
+ * returns its argument unchanged so the call site reads declaratively and a typo
19
+ * in the shape is a compile error. The real wiring happens when the destination
20
+ * is registered (via `createHogsendClient({ destinations })` or an env preset)
21
+ * into the process {@link getDestinationRegistry} the delivery task reads.
22
+ *
23
+ * NOTE: `defineDestination` is for event FAN-OUT to product/data tools
24
+ * (PostHog, Segment, Slack, a CRM, a warehouse). It is NOT the home for
25
+ * ad-platform conversion forwarding (CAPI) — that stays deferred to PostHog CDP;
26
+ * Hogsend just fires the events.
27
+ */
28
+
29
+ /** A live `webhook_endpoints` row, as read by the delivery task. */
30
+ export type WebhookEndpointRow = typeof webhookEndpoints.$inferSelect;
31
+
32
+ /**
33
+ * The frozen envelope stored on `webhook_deliveries.payload` and passed to a
34
+ * destination transform verbatim. Identical to the shape `emitOutbound` writes.
35
+ */
36
+ export interface DestinationEnvelope {
37
+ id: string;
38
+ type: string;
39
+ timestamp: string;
40
+ data: Record<string, unknown>;
41
+ }
42
+
43
+ /**
44
+ * Side context handed to a transform. Deliberately tiny — a transform derives
45
+ * its request from the envelope + the endpoint's `config`/`secret`. `logger` is
46
+ * provided for diagnostics; mutating external state from a transform is a
47
+ * mistake (it runs once per delivery attempt, including retries).
48
+ */
49
+ export interface DestinationCtx {
50
+ endpoint: WebhookEndpointRow;
51
+ logger: Logger;
52
+ }
53
+
54
+ /**
55
+ * The concrete HTTP request a transform resolves the envelope + endpoint into —
56
+ * the same contract the internal P1 adapters returned.
57
+ */
58
+ export interface DestinationTransformResult {
59
+ url: string;
60
+ method?: string;
61
+ headers: Record<string, string>;
62
+ /**
63
+ * EXACT bytes to send. For the `webhook` preset these are the SIGNED bytes —
64
+ * never re-stringify them between sign and send (the signature covers them).
65
+ */
66
+ body: string;
67
+ /**
68
+ * Optional success classifier. When absent, the delivery task uses the
69
+ * default 2xx rule (`status >= 200 && status < 300`). A destination whose 2xx
70
+ * body still encodes a logical error can override this.
71
+ */
72
+ isSuccess?: (status: number, bodySnippet: string) => boolean;
73
+ }
74
+
75
+ export interface DestinationMeta {
76
+ /**
77
+ * The stable id — also the value stored in `webhook_endpoints.kind`. An
78
+ * endpoint with `kind === meta.id` is delivered through this destination's
79
+ * transform. `"webhook"` and `"posthog"` are the shipped preset ids.
80
+ */
81
+ id: string;
82
+ name: string;
83
+ description?: string;
84
+ }
85
+
86
+ export interface DefinedDestination {
87
+ meta: DestinationMeta;
88
+ /** The outbound catalog events this destination accepts. */
89
+ events: OutboundEventName[];
90
+ /**
91
+ * Resolve the frozen envelope + live endpoint into a concrete HTTP request.
92
+ * Return `null` to SKIP delivery for that envelope (the delivery task treats a
93
+ * skip as a successful no-op — the row is marked delivered without a POST).
94
+ * A THROW is a non-retryable config error (straight to the DLQ).
95
+ */
96
+ transform(
97
+ envelope: DestinationEnvelope,
98
+ ctx: DestinationCtx,
99
+ ): DestinationTransformResult | null;
100
+ }
101
+
102
+ export function defineDestination(def: DefinedDestination): DefinedDestination {
103
+ return def;
104
+ }
@@ -0,0 +1,94 @@
1
+ import type { env as engineEnv } from "../../env.js";
2
+ import type { DefinedDestination } from "../define-destination.js";
3
+ import { posthogDestination } from "./posthog.js";
4
+ import { segmentDestination } from "./segment.js";
5
+ import { slackDestination } from "./slack.js";
6
+ import { webhookDestination } from "./webhook.js";
7
+
8
+ export { posthogDestination } from "./posthog.js";
9
+ export { segmentDestination } from "./segment.js";
10
+ export { slackDestination } from "./slack.js";
11
+ export { webhookDestination } from "./webhook.js";
12
+
13
+ /**
14
+ * All shipped destination presets, keyed by their `kind` id. The id is also the
15
+ * value stored in `webhook_endpoints.kind`: `PRESET_DESTINATIONS.posthog`
16
+ * delivers every endpoint with `kind = "posthog"`.
17
+ */
18
+ export const PRESET_DESTINATIONS = {
19
+ webhook: webhookDestination,
20
+ posthog: posthogDestination,
21
+ segment: segmentDestination,
22
+ slack: slackDestination,
23
+ } satisfies Record<string, DefinedDestination>;
24
+
25
+ /** The stable id of a shipped destination preset. */
26
+ export type DestinationPresetId = keyof typeof PRESET_DESTINATIONS;
27
+
28
+ /**
29
+ * The preset ids that are ALWAYS registered, regardless of
30
+ * `ENABLED_DESTINATION_PRESETS`:
31
+ * - `webhook` — the default signed POST every existing subscriber receives
32
+ * (turning it off would silently break all outbound webhooks).
33
+ * - `posthog` — the auto-seeded `ENABLE_POSTHOG_DESTINATION` endpoint resolves
34
+ * here; it must stay deliverable even when the env override names only other
35
+ * presets.
36
+ */
37
+ const ALWAYS_ON: readonly DestinationPresetId[] = ["webhook", "posthog"];
38
+
39
+ /** The slice of the validated env `destinationsFromEnv` reads. */
40
+ type DestinationPresetEnv = Pick<
41
+ typeof engineEnv,
42
+ "ENABLED_DESTINATION_PRESETS"
43
+ >;
44
+
45
+ /**
46
+ * Resolve which destination PRESETS to register into the process registry from
47
+ * the validated env (mirrors `presetsFromEnv` for inbound sources).
48
+ *
49
+ * Resolution order:
50
+ * 1. `ENABLED_DESTINATION_PRESETS === "none"` → ONLY the always-on set
51
+ * (`webhook` + `posthog`). "none" disables the OPTIONAL presets, never the
52
+ * no-regression ones.
53
+ * 2. `ENABLED_DESTINATION_PRESETS` is a csv of ids → exactly those (unknown ids
54
+ * ignored), UNIONED with the always-on set.
55
+ * 3. `ENABLED_DESTINATION_PRESETS === "*"` → every preset.
56
+ * 4. absent → the DEFAULT set (the always-on set only).
57
+ *
58
+ * Unlike inbound presets, destinations carry NO env secret to gate on — their
59
+ * credentials live per-endpoint in `webhook_endpoints.config`. The env only
60
+ * decides which transforms are RESOLVABLE; an endpoint with a `kind` whose
61
+ * transform is not registered fails its delivery as a config error (DLQ), which
62
+ * is the right signal that the preset was not enabled.
63
+ */
64
+ export function destinationsFromEnv(
65
+ env: DestinationPresetEnv,
66
+ ): DefinedDestination[] {
67
+ const override = env.ENABLED_DESTINATION_PRESETS?.trim();
68
+
69
+ const byId = (id: DestinationPresetId): DefinedDestination =>
70
+ PRESET_DESTINATIONS[id];
71
+
72
+ // (3) "*" — every preset.
73
+ if (override === "*") {
74
+ return Object.values(PRESET_DESTINATIONS);
75
+ }
76
+
77
+ // (2) explicit csv allow-list (anything other than "*"/"none"/empty), UNIONed
78
+ // with the always-on set so webhook/posthog can never be dropped.
79
+ if (override && override !== "none") {
80
+ const ids = new Set<string>([
81
+ ...ALWAYS_ON,
82
+ ...override
83
+ .split(",")
84
+ .map((id) => id.trim().toLowerCase())
85
+ .filter((id) => id.length > 0),
86
+ ]);
87
+ return (Object.keys(PRESET_DESTINATIONS) as DestinationPresetId[])
88
+ .filter((id) => ids.has(id))
89
+ .map(byId);
90
+ }
91
+
92
+ // (1) "none" and (4) absent → the always-on set only.
93
+ return ALWAYS_ON.map(byId);
94
+ }
@@ -0,0 +1,71 @@
1
+ import { WEBHOOK_EVENT_TYPES } from "../../lib/webhook-signing.js";
2
+ import { defineDestination } from "../define-destination.js";
3
+
4
+ /** PostHog destination config read off `endpoint.config`. */
5
+ interface PostHogConfig {
6
+ apiKey?: string;
7
+ host?: string;
8
+ /**
9
+ * OPTIONAL per-destination event-name remap, applied to `envelope.type` before
10
+ * building the capture body. Defaults to identity (no remap).
11
+ *
12
+ * `email.clicked` is the CANONICAL spine event name. The legacy fire-and-forget
13
+ * PostHog path captured clicks as `email.link_clicked`, so to preserve existing
14
+ * PostHog insights built on that name, set
15
+ * `eventNames: { "email.clicked": "email.link_clicked" }`. Any catalog event can
16
+ * be remapped this way; absent or unmapped keys pass through unchanged.
17
+ */
18
+ eventNames?: Record<string, string>;
19
+ }
20
+
21
+ /**
22
+ * PostHog capture destination. Credentials live in `endpoint.config`
23
+ * (`{ apiKey, host?, eventNames? }`), not a fake `whsec_`. A missing
24
+ * `config.apiKey` is a CONFIG error — the delivery task treats a thrown
25
+ * transform as a non-retryable permanent failure (straight to the DLQ).
26
+ *
27
+ * Byte-for-byte identical to the P1 internal `posthog` adapter: same capture
28
+ * URL, same `{ api_key, event, distinct_id, timestamp, properties }` body, same
29
+ * `$lib: "hogsend"` marker, same `userId ?? to ?? userEmail` distinct-id chain.
30
+ */
31
+ export const posthogDestination = defineDestination({
32
+ meta: {
33
+ id: "posthog",
34
+ name: "PostHog",
35
+ description:
36
+ "Fan email-lifecycle events out to a PostHog project (capture endpoint).",
37
+ },
38
+ // PostHog mirrors the whole catalog; the email funnel is the headline use but
39
+ // any catalog event can be captured. Subscription is still scoped per-endpoint
40
+ // via `event_types`, so an endpoint only receives what it subscribed to.
41
+ events: [...WEBHOOK_EVENT_TYPES],
42
+ transform(envelope, ctx) {
43
+ const config = (ctx.endpoint.config ?? {}) as PostHogConfig;
44
+ if (!config.apiKey) {
45
+ throw new Error(
46
+ "posthog destination is missing config.apiKey (non-retryable config error)",
47
+ );
48
+ }
49
+ const host = config.host ?? "https://us.i.posthog.com";
50
+ const data = envelope.data as {
51
+ userId?: string | null;
52
+ to?: string | null;
53
+ userEmail?: string | null;
54
+ };
55
+ const distinctId = data.userId ?? data.to ?? data.userEmail ?? undefined;
56
+ // Optional event-name remap (identity by default).
57
+ const eventName = config.eventNames?.[envelope.type] ?? envelope.type;
58
+ return {
59
+ url: `${host}/capture/`,
60
+ method: "POST",
61
+ headers: { "Content-Type": "application/json" },
62
+ body: JSON.stringify({
63
+ api_key: config.apiKey,
64
+ event: eventName,
65
+ distinct_id: distinctId,
66
+ timestamp: envelope.timestamp,
67
+ properties: { ...envelope.data, $lib: "hogsend" },
68
+ }),
69
+ };
70
+ },
71
+ });
@@ -0,0 +1,75 @@
1
+ import { WEBHOOK_EVENT_TYPES } from "../../lib/webhook-signing.js";
2
+ import { defineDestination } from "../define-destination.js";
3
+
4
+ /** Segment destination config read off `endpoint.config`. */
5
+ interface SegmentConfig {
6
+ /** The Segment source WRITE KEY — used as the HTTP Basic username. */
7
+ writeKey?: string;
8
+ /**
9
+ * Override the Segment HTTP Tracking API base (e.g. an EU region or a proxy).
10
+ * Defaults to `https://api.segment.io`. The `/v1/track` path is appended.
11
+ */
12
+ host?: string;
13
+ /**
14
+ * OPTIONAL per-destination event-name remap, applied to `envelope.type` before
15
+ * building the track body (identity by default). Lets a destination project a
16
+ * canonical spine name onto an existing Segment event name.
17
+ */
18
+ eventNames?: Record<string, string>;
19
+ }
20
+
21
+ /**
22
+ * Segment HTTP Tracking API destination — posts each catalog event to
23
+ * `POST /v1/track` as a Segment `track` call. Auth is HTTP Basic with the source
24
+ * write key as the username and an empty password (Segment's documented scheme).
25
+ *
26
+ * Credentials live in `endpoint.config` (`{ writeKey, host?, eventNames? }`). A
27
+ * missing `config.writeKey` is a CONFIG error — a thrown transform is a
28
+ * non-retryable permanent failure (straight to the DLQ).
29
+ *
30
+ * Identity: `userId` is taken from the envelope's `userId ?? to ?? userEmail`
31
+ * (the same chain the PostHog destination uses), so an open/click with a known
32
+ * user is attributed; an anonymous hit falls back to the email address.
33
+ */
34
+ export const segmentDestination = defineDestination({
35
+ meta: {
36
+ id: "segment",
37
+ name: "Segment",
38
+ description:
39
+ "Forward email-lifecycle events to a Segment source via the HTTP Tracking API.",
40
+ },
41
+ events: [...WEBHOOK_EVENT_TYPES],
42
+ transform(envelope, ctx) {
43
+ const config = (ctx.endpoint.config ?? {}) as SegmentConfig;
44
+ if (!config.writeKey) {
45
+ throw new Error(
46
+ "segment destination is missing config.writeKey (non-retryable config error)",
47
+ );
48
+ }
49
+ const host = config.host ?? "https://api.segment.io";
50
+ const data = envelope.data as {
51
+ userId?: string | null;
52
+ to?: string | null;
53
+ userEmail?: string | null;
54
+ };
55
+ const userId = data.userId ?? data.to ?? data.userEmail ?? undefined;
56
+ const eventName = config.eventNames?.[envelope.type] ?? envelope.type;
57
+ // HTTP Basic: base64("<writeKey>:"). Empty password per Segment's docs.
58
+ const basic = Buffer.from(`${config.writeKey}:`).toString("base64");
59
+ return {
60
+ url: `${host}/v1/track`,
61
+ method: "POST",
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ Authorization: `Basic ${basic}`,
65
+ },
66
+ body: JSON.stringify({
67
+ ...(userId ? { userId } : { anonymousId: envelope.id }),
68
+ event: eventName,
69
+ timestamp: envelope.timestamp,
70
+ messageId: envelope.id,
71
+ properties: { ...envelope.data, $lib: "hogsend" },
72
+ }),
73
+ };
74
+ },
75
+ });