@hogsend/engine 0.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,11 +38,11 @@
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.9.0",
42
+ "@hogsend/db": "^0.9.0",
43
+ "@hogsend/email": "^0.9.0",
44
+ "@hogsend/plugin-posthog": "^0.9.0",
45
+ "@hogsend/plugin-resend": "^0.9.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^22.15.3",
package/src/container.ts CHANGED
@@ -20,6 +20,12 @@ import {
20
20
  buildBucketRegistry,
21
21
  collectBucketReactionJourneys,
22
22
  } from "./buckets/registry.js";
23
+ import type { DefinedDestination } from "./destinations/define-destination.js";
24
+ import { destinationsFromEnv } from "./destinations/presets/index.js";
25
+ import {
26
+ DestinationRegistry,
27
+ setDestinationRegistry,
28
+ } from "./destinations/registry-singleton.js";
23
29
  import { env } from "./env.js";
24
30
  import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
25
31
  import type { DefinedJourney } from "./journeys/define-journey.js";
@@ -35,6 +41,7 @@ import { hatchet } from "./lib/hatchet.js";
35
41
  import { createLogger, type Logger } from "./lib/logger.js";
36
42
  import { createTrackedMailer } from "./lib/mailer.js";
37
43
  import { getPostHog } from "./lib/posthog.js";
44
+ import { seedPostHogDestination } from "./lib/seed-posthog-destination.js";
38
45
  import { prepareTrackedHtml } from "./lib/tracking.js";
39
46
  import type { DefinedList } from "./lists/define-list.js";
40
47
  import { buildListRegistry, type ListRegistry } from "./lists/registry.js";
@@ -132,12 +139,38 @@ export interface HogsendClientOptions {
132
139
  templates?: TemplateRegistry;
133
140
  };
134
141
  /**
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).
142
+ * The PostHog-style analytics service. As of the destinations spine its role
143
+ * is deliberately NARROW it is NOT the outbound-catalog firing path (the
144
+ * email/contact/journey/bucket lifecycle now fans out durably via
145
+ * DESTINATIONS on the webhook spine, keyed by `webhook_endpoints.kind`). It
146
+ * remains for exactly two things:
147
+ *
148
+ * 1. The identity PULL — `getPersonProperties` for per-user timezone
149
+ * resolution at journey enrollment (`define-journey` / `lib/timezone.ts`).
150
+ * This read role is UNCHANGED and load-bearing.
151
+ * 2. The opt-in `bucket.syncToPostHog` person-property mirror — `$set`/`$unset`
152
+ * of a boolean cohort property on bucket transitions (`bucket-posthog-sync`).
153
+ * Off by default; PostHog `$set`/`$unset` identity semantics have no
154
+ * vendor-neutral envelope, so this stays a PostHog-direct write.
155
+ *
156
+ * Lives at the top level (not under `email`) because the engine itself uses
157
+ * it for the PULL. Defaults to {@link getPostHog} (a no-op when
158
+ * `POSTHOG_API_KEY` is unset).
139
159
  */
140
160
  analytics?: PostHogService;
161
+ /**
162
+ * Code-defined outbound DESTINATIONS (Phase 3). Each is a
163
+ * `defineDestination()` delivery-time transform keyed by its `meta.id`, which
164
+ * the delivery task resolves by `webhook_endpoints.kind`. They are MERGED with
165
+ * the env-enabled presets ({@link destinationsFromEnv}): a consumer
166
+ * destination WINS over a preset of the same id (so you can override the
167
+ * shipped `posthog`/`segment`/`slack` shapes). The `webhook` + `posthog`
168
+ * presets are always present, so the no-regression signed-POST path can never
169
+ * be turned off here. Installed as the process registry the self-booting
170
+ * delivery task reads — and `createHogsendClient` runs in BOTH the API and
171
+ * worker, so it is wired in both. Defaults to none (presets only).
172
+ */
173
+ destinations?: DefinedDestination[];
141
174
  /**
142
175
  * Comma-separated ids (or `*`) controlling which journeys load. Defaults to
143
176
  * `env.ENABLED_JOURNEYS`.
@@ -314,17 +347,56 @@ export function createHogsendClient(
314
347
  const analytics = opts.analytics ?? getPostHog();
315
348
 
316
349
  // 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.
350
+ // sites that have no client reference. Its role is NARROW (see the
351
+ // `analytics?` option doc): the identity PULL (`getPersonProperties` for tz
352
+ // resolution in the journey durable task) plus the opt-in
353
+ // `bucket.syncToPostHog` person-property mirrorNOT the outbound catalog
354
+ // firing path (that is the destinations spine). `createHogsendClient` runs in
355
+ // both the API and worker, so this is installed before any worker task runs.
356
+ // May be undefined (no POSTHOG_API_KEY) — the reads stay no-ops.
321
357
  setAnalytics(analytics);
322
358
 
359
+ // Build + install the outbound DESTINATION registry (Phase 3) the
360
+ // self-booting delivery task resolves by `webhook_endpoints.kind`. Order is
361
+ // load-bearing: the env-enabled presets come FIRST and the consumer's
362
+ // `opts.destinations` LAST, so the DestinationRegistry's last-writer-wins map
363
+ // lets a consumer destination override a shipped preset of the same id. Runs
364
+ // in BOTH the API and worker (both call createHogsendClient), so the registry
365
+ // is present before any worker delivery task executes.
366
+ const destinations = [
367
+ ...destinationsFromEnv(env),
368
+ ...(opts.destinations ?? []),
369
+ ];
370
+ const destinationRegistry = new DestinationRegistry(destinations);
371
+ setDestinationRegistry(destinationRegistry);
372
+
373
+ // Optional: auto-seed a PostHog DESTINATION on the outbound spine so the email
374
+ // lifecycle fans out to PostHog durably. Default OFF (ENABLE_POSTHOG_DESTINATION)
375
+ // to avoid double-emit alongside the fire-and-forget capture path. Idempotent +
376
+ // fire-and-forget — a seed failure must never block boot. Runs in BOTH the API
377
+ // and worker (both call createHogsendClient); the dup guard makes the second a
378
+ // no-op.
379
+ if (env.ENABLE_POSTHOG_DESTINATION && env.POSTHOG_API_KEY) {
380
+ void seedPostHogDestination({
381
+ db,
382
+ logger,
383
+ apiKey: env.POSTHOG_API_KEY,
384
+ host: env.POSTHOG_HOST,
385
+ }).catch((error: unknown) => {
386
+ logger.warn("seedPostHogDestination failed", {
387
+ error: error instanceof Error ? error.message : String(error),
388
+ });
389
+ });
390
+ }
391
+
323
392
  // Counts are surfaced by the boot banner / structured ready log (lib/boot.ts);
324
393
  // keep these at debug for non-boot contexts (tests, REPL, library use).
325
394
  logger.debug(`Journey registry loaded: ${registry.count()} journeys`);
326
395
  logger.debug(`Bucket registry loaded: ${bucketRegistry.count()} buckets`);
327
396
  logger.debug(`List registry loaded: ${listRegistry.count()} lists`);
397
+ logger.debug(
398
+ `Destination registry loaded: ${destinationRegistry.count()} destinations`,
399
+ );
328
400
 
329
401
  return {
330
402
  env,
@@ -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
+ });
@@ -0,0 +1,66 @@
1
+ import { WEBHOOK_EVENT_TYPES } from "../../lib/webhook-signing.js";
2
+ import { defineDestination } from "../define-destination.js";
3
+
4
+ /** Slack destination config read off `endpoint.config`. */
5
+ interface SlackConfig {
6
+ /**
7
+ * The Slack INCOMING WEBHOOK url (`https://hooks.slack.com/services/…`). When
8
+ * set it overrides `endpoint.url`; either may carry it (the column `url` is the
9
+ * natural home, `config.url` the explicit one).
10
+ */
11
+ url?: string;
12
+ /** Optional username override for the posted message. */
13
+ username?: string;
14
+ /** Optional emoji icon (e.g. `:email:`) for the posted message. */
15
+ iconEmoji?: string;
16
+ }
17
+
18
+ /** A compact, human-readable line per catalog event for the Slack text block. */
19
+ function formatLine(type: string, data: Record<string, unknown>): string {
20
+ const to = typeof data.to === "string" ? data.to : undefined;
21
+ const email = typeof data.userEmail === "string" ? data.userEmail : undefined;
22
+ const template =
23
+ typeof data.templateKey === "string" ? data.templateKey : undefined;
24
+ const who = to ?? email;
25
+ const parts = [`*${type}*`];
26
+ if (who) parts.push(`for \`${who}\``);
27
+ if (template) parts.push(`(template \`${template}\`)`);
28
+ return parts.join(" ");
29
+ }
30
+
31
+ /**
32
+ * Slack incoming-webhook destination — posts a formatted text block per catalog
33
+ * event to a Slack channel. The webhook url comes from `config.url` (preferred)
34
+ * or the endpoint `url`; a missing url is a CONFIG error (thrown → DLQ).
35
+ *
36
+ * Slack returns `200` with a plain `ok` body on success and a non-2xx on a bad
37
+ * payload, so the default 2xx success rule is correct (no `isSuccess` override).
38
+ */
39
+ export const slackDestination = defineDestination({
40
+ meta: {
41
+ id: "slack",
42
+ name: "Slack",
43
+ description:
44
+ "Post a formatted message per email-lifecycle event to a Slack channel.",
45
+ },
46
+ events: [...WEBHOOK_EVENT_TYPES],
47
+ transform(envelope, ctx) {
48
+ const config = (ctx.endpoint.config ?? {}) as SlackConfig;
49
+ const url = config.url ?? ctx.endpoint.url;
50
+ if (!url || url.length === 0) {
51
+ throw new Error(
52
+ "slack destination is missing config.url / endpoint.url (non-retryable config error)",
53
+ );
54
+ }
55
+ return {
56
+ url,
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify({
60
+ text: formatLine(envelope.type, envelope.data),
61
+ ...(config.username ? { username: config.username } : {}),
62
+ ...(config.iconEmoji ? { icon_emoji: config.iconEmoji } : {}),
63
+ }),
64
+ };
65
+ },
66
+ });
@@ -0,0 +1,37 @@
1
+ import { signWebhook, WEBHOOK_EVENT_TYPES } from "../../lib/webhook-signing.js";
2
+ import { defineDestination } from "../define-destination.js";
3
+
4
+ /**
5
+ * The DEFAULT destination — the signed Standard-Webhooks POST every existing
6
+ * subscriber receives. BYTE-IDENTICAL to the pre-destination delivery: the same
7
+ * `signWebhook` arguments (id = the envelope id, timestamp = current epoch
8
+ * seconds, payload = the frozen envelope, secret = the live endpoint secret) and
9
+ * the exact signed bytes + headers, POSTed to the raw `endpoint.url`.
10
+ *
11
+ * Its `meta.id` is `"webhook"`, so an endpoint with `kind = "webhook"` (the
12
+ * column default) resolves here. This preset is ALWAYS registered, so the
13
+ * critical no-regression invariant holds even when a consumer wires no
14
+ * destinations of their own.
15
+ */
16
+ export const webhookDestination = defineDestination({
17
+ meta: {
18
+ id: "webhook",
19
+ name: "Signed webhook",
20
+ description:
21
+ "The default Standard-Webhooks signed POST to a subscriber URL (Svix HMAC).",
22
+ },
23
+ // The default signed webhook fans out the WHOLE outbound catalog — it is the
24
+ // generic subscriber transport, not a per-vendor projection.
25
+ events: [...WEBHOOK_EVENT_TYPES],
26
+ transform(envelope, ctx) {
27
+ const { headers, body } = signWebhook({
28
+ id: envelope.id,
29
+ // Same expression the pre-destination delivery task used, so the signature
30
+ // matches the body the task sends.
31
+ timestamp: Math.floor(Date.now() / 1000),
32
+ payload: envelope,
33
+ secret: ctx.endpoint.secret ?? "",
34
+ });
35
+ return { url: ctx.endpoint.url, headers, body };
36
+ },
37
+ });