@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.
- package/package.json +9 -6
- package/src/container.ts +156 -29
- 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 +38 -1
- package/src/index.ts +46 -6
- 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/email-provider-registry.ts +45 -0
- package/src/lib/email-providers-from-env.ts +94 -0
- package/src/lib/email-service-types.ts +40 -4
- package/src/lib/headers.ts +13 -0
- package/src/lib/mailer.ts +137 -72
- package/src/lib/outbound.ts +18 -2
- package/src/lib/seed-posthog-destination.ts +93 -0
- package/src/lib/tracked.ts +34 -29
- package/src/lib/tracking-events.ts +37 -20
- package/src/lib/webhook-signing.ts +2 -1
- package/src/routes/admin/emails.ts +5 -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/routes/webhooks/email-provider.ts +124 -0
- package/src/routes/webhooks/index.ts +7 -0
- package/src/routes/webhooks/resend.ts +14 -29
- package/src/routes/webhooks/sources.ts +15 -4
- package/src/workflows/deliver-webhook.ts +137 -52
- 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.
|
|
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.
|
|
42
|
-
"@hogsend/
|
|
43
|
-
"@hogsend/
|
|
44
|
-
"@hogsend/plugin-
|
|
45
|
-
"@hogsend/
|
|
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` —
|
|
118
|
-
*
|
|
119
|
-
* `
|
|
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
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
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 mirror — NOT 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
|
+
});
|