@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 +6 -6
- package/src/container.ts +80 -8
- package/src/destinations/define-destination.ts +104 -0
- package/src/destinations/presets/index.ts +94 -0
- package/src/destinations/presets/posthog.ts +71 -0
- package/src/destinations/presets/segment.ts +75 -0
- package/src/destinations/presets/slack.ts +66 -0
- package/src/destinations/presets/webhook.ts +37 -0
- package/src/destinations/registry-singleton.ts +78 -0
- package/src/env.ts +15 -0
- package/src/index.ts +25 -0
- package/src/journeys/define-journey.ts +0 -1
- package/src/journeys/journey-context.ts +1 -17
- package/src/lib/analytics-singleton.ts +7 -0
- package/src/lib/mailer.ts +21 -6
- package/src/lib/outbound.ts +7 -0
- package/src/lib/seed-posthog-destination.ts +93 -0
- package/src/lib/tracking-events.ts +11 -9
- package/src/lib/webhook-signing.ts +2 -1
- package/src/routes/admin/webhooks.ts +100 -9
- package/src/routes/tracking/click.ts +20 -25
- package/src/routes/tracking/open.ts +20 -27
- package/src/workflows/deliver-webhook.ts +137 -52
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "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.
|
|
42
|
-
"@hogsend/
|
|
43
|
-
"@hogsend/
|
|
44
|
-
"@hogsend/plugin-
|
|
45
|
-
"@hogsend/
|
|
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
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
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 mirror — NOT 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
|
+
});
|