@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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { DefinedDestination } from "./define-destination.js";
|
|
2
|
+
import { PRESET_DESTINATIONS } from "./presets/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The process-wide destination registry, set once by `createHogsendClient` at
|
|
6
|
+
* startup and read by the delivery task (`workflows/deliver-webhook.ts`) to
|
|
7
|
+
* resolve a transform by `endpoint.kind`.
|
|
8
|
+
*
|
|
9
|
+
* Why a singleton (mirrors `lib/analytics-singleton.ts`): the durable
|
|
10
|
+
* `deliverWebhookTask` SELF-BOOTS — it opens its own `getDb()` from
|
|
11
|
+
* `process.env` and has NO client/container reference. So the registered
|
|
12
|
+
* transforms (presets + the consumer's `defineDestination()` destinations) MUST
|
|
13
|
+
* be reachable via a process singleton, exactly as analytics / the journey +
|
|
14
|
+
* bucket registries are. `createHogsendClient` runs in BOTH the API and worker,
|
|
15
|
+
* so by the time any worker task executes the registry has been installed.
|
|
16
|
+
*
|
|
17
|
+
* Resilient default: if a delivery task somehow runs in a process that never
|
|
18
|
+
* called `createHogsendClient` (a bare reaper re-drive in a test harness), the
|
|
19
|
+
* getter lazily falls back to the shipped {@link PRESET_DESTINATIONS} so the
|
|
20
|
+
* no-regression `webhook` + the `posthog` presets still resolve. Installing a
|
|
21
|
+
* registry via {@link setDestinationRegistry} replaces this fallback.
|
|
22
|
+
*/
|
|
23
|
+
export class DestinationRegistry {
|
|
24
|
+
private readonly byKind = new Map<string, DefinedDestination>();
|
|
25
|
+
|
|
26
|
+
constructor(destinations: DefinedDestination[] = []) {
|
|
27
|
+
for (const destination of destinations) {
|
|
28
|
+
// Last-writer-wins on id collision — the caller (container) orders the
|
|
29
|
+
// array so the consumer's destination wins over a preset of the same id.
|
|
30
|
+
this.byKind.set(destination.meta.id, destination);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolve a destination by its `kind` id, or `undefined` when unregistered. */
|
|
35
|
+
get(kind: string): DefinedDestination | undefined {
|
|
36
|
+
return this.byKind.get(kind);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every registered destination (for diagnostics / catalog enumeration). */
|
|
40
|
+
getAll(): DefinedDestination[] {
|
|
41
|
+
return [...this.byKind.values()];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Number of registered destinations. */
|
|
45
|
+
count(): number {
|
|
46
|
+
return this.byKind.size;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The lazily-built fallback registry of just the shipped presets. */
|
|
51
|
+
let fallback: DestinationRegistry | undefined;
|
|
52
|
+
let installed: DestinationRegistry | undefined;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Install the resolved destination registry. Called by `createHogsendClient`
|
|
56
|
+
* after merging the env presets with the consumer's `opts.destinations`.
|
|
57
|
+
*/
|
|
58
|
+
export function setDestinationRegistry(registry: DestinationRegistry): void {
|
|
59
|
+
installed = registry;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read the destination registry. Returns the installed registry, or a lazily
|
|
64
|
+
* built preset-only fallback so a self-booting task always resolves the
|
|
65
|
+
* always-on `webhook` + `posthog` presets even before any container ran.
|
|
66
|
+
*/
|
|
67
|
+
export function getDestinationRegistry(): DestinationRegistry {
|
|
68
|
+
if (installed) return installed;
|
|
69
|
+
if (!fallback) {
|
|
70
|
+
fallback = new DestinationRegistry(Object.values(PRESET_DESTINATIONS));
|
|
71
|
+
}
|
|
72
|
+
return fallback;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Reset the installed registry — only for test cleanup. */
|
|
76
|
+
export function resetDestinationRegistry(): void {
|
|
77
|
+
installed = undefined;
|
|
78
|
+
}
|
package/src/env.ts
CHANGED
|
@@ -27,8 +27,30 @@ export const env = createEnv({
|
|
|
27
27
|
// comma-separated. Needed when the Studio is served from a different origin
|
|
28
28
|
// than the API — e.g. the `hogsend studio` CLI pointing at a remote instance.
|
|
29
29
|
BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(),
|
|
30
|
-
|
|
30
|
+
// Optional: a deploy may run a non-Resend provider (Postmark, SES…) and set
|
|
31
|
+
// no Resend key at all. Read directly ONLY in the lazy-resend default branch
|
|
32
|
+
// (container.ts) and the future `emailProvidersFromEnv` preset. With this
|
|
33
|
+
// optional, a Postmark-only deploy boots without a Resend key.
|
|
34
|
+
RESEND_API_KEY: z.string().min(1).optional(),
|
|
31
35
|
RESEND_FROM_EMAIL: z.string().email().default("noreply@hogsend.com"),
|
|
36
|
+
// --- Provider-neutral email config (BYO email provider) ---
|
|
37
|
+
// The active email provider id the container resolves from the
|
|
38
|
+
// EmailProviderRegistry. Absent → "resend" (today's byte-for-byte default).
|
|
39
|
+
EMAIL_PROVIDER: z.string().optional(),
|
|
40
|
+
// Neutral default-from address. The mailer's `defaultFrom` is
|
|
41
|
+
// `EMAIL_FROM ?? RESEND_FROM_EMAIL`, so an unset EMAIL_FROM keeps today's
|
|
42
|
+
// Resend-named default.
|
|
43
|
+
EMAIL_FROM: z.string().email().optional(),
|
|
44
|
+
// --- Postmark (opt-in BYO provider) ---
|
|
45
|
+
// Postmark stays OPT-IN: a preset is built only when POSTMARK_SERVER_TOKEN
|
|
46
|
+
// is present, and it NEVER changes the default active provider — set
|
|
47
|
+
// EMAIL_PROVIDER=postmark to activate it. Postmark has no HMAC, so webhook
|
|
48
|
+
// authenticity is HTTP Basic creds in the webhook URL — fail-closed when
|
|
49
|
+
// unset (status updates rejected).
|
|
50
|
+
POSTMARK_SERVER_TOKEN: z.string().min(1).optional(),
|
|
51
|
+
POSTMARK_MESSAGE_STREAM: z.string().min(1).optional(),
|
|
52
|
+
POSTMARK_WEBHOOK_USER: z.string().min(1).optional(),
|
|
53
|
+
POSTMARK_WEBHOOK_PASS: z.string().min(1).optional(),
|
|
32
54
|
// Hatchet connection contract. The @hatchet-dev SDK also reads these straight
|
|
33
55
|
// from process.env via its own config-loader, so this schema is a presence /
|
|
34
56
|
// shape check that keeps the contract in one place — the values still flow to
|
|
@@ -57,6 +79,12 @@ export const env = createEnv({
|
|
|
57
79
|
POSTHOG_API_KEY: z.string().min(1).optional(),
|
|
58
80
|
POSTHOG_HOST: z.string().url().optional(),
|
|
59
81
|
POSTHOG_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
82
|
+
// When true AND POSTHOG_API_KEY is set, the engine idempotently auto-seeds
|
|
83
|
+
// ONE kind="posthog" webhook endpoint subscribed to the email funnel so the
|
|
84
|
+
// full email lifecycle fans out to PostHog DURABLY (on the delivery spine).
|
|
85
|
+
// Default OFF to avoid a surprise double-emit alongside the existing
|
|
86
|
+
// fire-and-forget PostHog capture path.
|
|
87
|
+
ENABLE_POSTHOG_DESTINATION: z.coerce.boolean().default(false),
|
|
60
88
|
RESEND_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
61
89
|
ADMIN_API_KEY: z.string().min(1).optional(),
|
|
62
90
|
API_PUBLIC_URL: z.string().url().default("http://localhost:3002"),
|
|
@@ -96,6 +124,15 @@ export const env = createEnv({
|
|
|
96
124
|
// Preset enablement override: csv of preset ids, `"*"` (all with a secret),
|
|
97
125
|
// or `"none"`. Absent → auto-enable any preset whose secret is set.
|
|
98
126
|
ENABLED_WEBHOOK_PRESETS: z.string().optional(),
|
|
127
|
+
// --- Outbound destination presets (Phase 3) ---
|
|
128
|
+
// Which `defineDestination()` PRESETS are registered into the process
|
|
129
|
+
// destination registry the delivery task resolves by `endpoint.kind`. csv of
|
|
130
|
+
// ids (e.g. "segment,slack"), `"*"` (all presets), or `"none"`. Absent → the
|
|
131
|
+
// DEFAULT set (webhook + posthog). The `webhook` and `posthog` presets are
|
|
132
|
+
// ALWAYS registered regardless of this value, so the no-regression delivery
|
|
133
|
+
// path can never be turned off by misconfiguration. Set this to add the
|
|
134
|
+
// segment/slack presets (credentials still live per-endpoint in `config`).
|
|
135
|
+
ENABLED_DESTINATION_PRESETS: z.string().optional(),
|
|
99
136
|
},
|
|
100
137
|
runtimeEnv: process.env,
|
|
101
138
|
emptyStringAsUndefined: true,
|
package/src/index.ts
CHANGED
|
@@ -3,24 +3,32 @@
|
|
|
3
3
|
// Content (journeys, webhook sources, workflows) is injected into these
|
|
4
4
|
// factories by client app code; the engine never imports content.
|
|
5
5
|
|
|
6
|
-
// --- Capability-provider contracts (canonical origin: @hogsend/core) ---
|
|
7
|
-
// Email provider contract + analytics contract, re-exported so consumers can
|
|
8
|
-
// import them from `@hogsend/engine`. (`SendEmailOptions` is intentionally
|
|
9
|
-
// omitted here: the engine's public `SendEmailOptions` is the high-level
|
|
10
|
-
// journey-facing send options from `./lib/email.js`; the provider-contract
|
|
11
|
-
// `SendEmailOptions` remains available via `@hogsend/core`.)
|
|
12
6
|
export type {
|
|
13
7
|
BatchEmailItem,
|
|
14
8
|
CaptureOptions,
|
|
9
|
+
EmailEvent,
|
|
10
|
+
EmailEventType,
|
|
15
11
|
EmailProvider,
|
|
12
|
+
EmailProviderCapabilities,
|
|
13
|
+
EmailProviderMeta,
|
|
14
|
+
/** @deprecated Use {@link EmailEvent}. Frozen `event.raw` cast target. */
|
|
15
|
+
LegacyResendWebhookEvent,
|
|
16
16
|
PostHogService,
|
|
17
17
|
SendResult,
|
|
18
|
+
/** @deprecated Use {@link EmailEvent}. Kept for one minor. */
|
|
18
19
|
WebhookEvent,
|
|
19
20
|
WebhookHandlerMap,
|
|
20
21
|
} from "@hogsend/core";
|
|
21
22
|
// Core helpers used by content journeys (days/hours/minutes, condition + journey
|
|
22
23
|
// types) so content can import everything from `@hogsend/engine`.
|
|
23
24
|
export * from "@hogsend/core";
|
|
25
|
+
// --- Capability-provider contracts (canonical origin: @hogsend/core) ---
|
|
26
|
+
// Email provider contract + analytics contract, re-exported so consumers can
|
|
27
|
+
// import them from `@hogsend/engine`. (`SendEmailOptions` is intentionally
|
|
28
|
+
// omitted here: the engine's public `SendEmailOptions` is the high-level
|
|
29
|
+
// journey-facing send options from `./lib/email.js`; the provider-contract
|
|
30
|
+
// `SendEmailOptions` remains available via `@hogsend/core`.)
|
|
31
|
+
export { defineEmailProvider, WebhookHandshakeSignal } from "@hogsend/core";
|
|
24
32
|
export {
|
|
25
33
|
BucketRegistry,
|
|
26
34
|
JourneyRegistry,
|
|
@@ -76,6 +84,31 @@ export {
|
|
|
76
84
|
type HogsendClientOptions,
|
|
77
85
|
type HogsendDefaults,
|
|
78
86
|
} from "./container.js";
|
|
87
|
+
// --- Outbound destinations: public authoring layer (Phase 3) ---
|
|
88
|
+
export {
|
|
89
|
+
type DefinedDestination,
|
|
90
|
+
type DestinationCtx,
|
|
91
|
+
type DestinationEnvelope,
|
|
92
|
+
type DestinationMeta,
|
|
93
|
+
type DestinationTransformResult,
|
|
94
|
+
defineDestination,
|
|
95
|
+
type WebhookEndpointRow,
|
|
96
|
+
} from "./destinations/define-destination.js";
|
|
97
|
+
export {
|
|
98
|
+
type DestinationPresetId,
|
|
99
|
+
destinationsFromEnv,
|
|
100
|
+
PRESET_DESTINATIONS,
|
|
101
|
+
posthogDestination,
|
|
102
|
+
segmentDestination,
|
|
103
|
+
slackDestination,
|
|
104
|
+
webhookDestination,
|
|
105
|
+
} from "./destinations/presets/index.js";
|
|
106
|
+
export {
|
|
107
|
+
DestinationRegistry,
|
|
108
|
+
getDestinationRegistry,
|
|
109
|
+
resetDestinationRegistry,
|
|
110
|
+
setDestinationRegistry,
|
|
111
|
+
} from "./destinations/registry-singleton.js";
|
|
79
112
|
// --- Env ---
|
|
80
113
|
export { API_VERSION, env } from "./env.js";
|
|
81
114
|
// --- Journeys ---
|
|
@@ -124,6 +157,8 @@ export {
|
|
|
124
157
|
sendEmail,
|
|
125
158
|
setEmailService,
|
|
126
159
|
} from "./lib/email.js";
|
|
160
|
+
// --- Email provider registry (container-held, keyed by meta.id) ---
|
|
161
|
+
export { EmailProviderRegistry } from "./lib/email-provider-registry.js";
|
|
127
162
|
// --- Email service (engine-owned tracked mailer) ---
|
|
128
163
|
export type {
|
|
129
164
|
EmailService,
|
|
@@ -180,6 +215,11 @@ export {
|
|
|
180
215
|
export {
|
|
181
216
|
pushTrackingEvent,
|
|
182
217
|
resolveEmailSendContext,
|
|
218
|
+
resolveEmailSendContextByMessageId,
|
|
219
|
+
/**
|
|
220
|
+
* @deprecated Kept for one minor; use
|
|
221
|
+
* {@link resolveEmailSendContextByMessageId}.
|
|
222
|
+
*/
|
|
183
223
|
resolveEmailSendContextByResendId,
|
|
184
224
|
} from "./lib/tracking-events.js";
|
|
185
225
|
// --- Outbound webhooks: signing core (Section 1.2) ---
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
SleepCondition,
|
|
8
8
|
UserEventCondition,
|
|
9
9
|
} from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
10
|
-
import type { DurationObject
|
|
10
|
+
import type { DurationObject } from "@hogsend/core";
|
|
11
11
|
import { durationToMs, evaluateEventCondition } from "@hogsend/core";
|
|
12
12
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
13
13
|
import {
|
|
@@ -69,7 +69,6 @@ interface JourneyContextConfig {
|
|
|
69
69
|
};
|
|
70
70
|
registry: JourneyRegistry;
|
|
71
71
|
logger: Logger;
|
|
72
|
-
posthog?: PostHogService;
|
|
73
72
|
stateId: string;
|
|
74
73
|
userId: string;
|
|
75
74
|
userEmail: string;
|
|
@@ -140,7 +139,6 @@ export function createJourneyContext(
|
|
|
140
139
|
hatchetCtx,
|
|
141
140
|
registry,
|
|
142
141
|
logger,
|
|
143
|
-
posthog,
|
|
144
142
|
stateId,
|
|
145
143
|
userId,
|
|
146
144
|
userEmail,
|
|
@@ -316,10 +314,6 @@ export function createJourneyContext(
|
|
|
316
314
|
});
|
|
317
315
|
},
|
|
318
316
|
|
|
319
|
-
identify(properties) {
|
|
320
|
-
posthog?.identify(userId, properties);
|
|
321
|
-
},
|
|
322
|
-
|
|
323
317
|
guard: {
|
|
324
318
|
async isSubscribed() {
|
|
325
319
|
const prefs = await checkEmailPreferences({ db, userId });
|
|
@@ -390,15 +384,5 @@ export function createJourneyContext(
|
|
|
390
384
|
};
|
|
391
385
|
},
|
|
392
386
|
},
|
|
393
|
-
|
|
394
|
-
posthog: {
|
|
395
|
-
capture({ event, properties }) {
|
|
396
|
-
posthog?.captureEvent({
|
|
397
|
-
distinctId: userId,
|
|
398
|
-
event,
|
|
399
|
-
properties,
|
|
400
|
-
});
|
|
401
|
-
},
|
|
402
|
-
},
|
|
403
387
|
};
|
|
404
388
|
}
|
|
@@ -6,6 +6,13 @@ import { createOptionalSingleton } from "./singleton.js";
|
|
|
6
6
|
* the module-level task-execution sites that have no client reference of their
|
|
7
7
|
* own (the journey durable task in `define-journey`, the bucket PostHog sync).
|
|
8
8
|
*
|
|
9
|
+
* Its role is deliberately NARROW (see the `analytics?` option doc on
|
|
10
|
+
* {@link createHogsendClient}): the identity PULL (`getPersonProperties` for
|
|
11
|
+
* per-user timezone resolution) and the opt-in `bucket.syncToPostHog`
|
|
12
|
+
* person-property mirror. It is explicitly NOT the outbound-catalog firing
|
|
13
|
+
* path — the email/contact/journey/bucket lifecycle fans out durably via
|
|
14
|
+
* DESTINATIONS on the webhook spine, not through this singleton.
|
|
15
|
+
*
|
|
9
16
|
* Mirrors the journey/bucket-registry + client-schedule-defaults singletons:
|
|
10
17
|
* `createHogsendClient` runs in BOTH the API and worker processes, so by the
|
|
11
18
|
* time any worker task executes, the container has already installed the
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { EmailProvider } from "@hogsend/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Container-held registry of email providers, keyed by `provider.meta.id`. The
|
|
5
|
+
* webhook route (`POST /v1/webhooks/email/:providerId`) resolves the verifying
|
|
6
|
+
* provider out of this registry via `c.get("container")`, and the container
|
|
7
|
+
* also picks ONE `active` provider out of it for the mailer.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately NOT a process singleton: unlike the `DestinationRegistry`
|
|
10
|
+
* singleton — which exists only because the self-booting `deliverWebhookTask`
|
|
11
|
+
* has no container — both readers of this registry (the mailer the container
|
|
12
|
+
* constructs, and the webhook route which has the container) have a container
|
|
13
|
+
* reference, so the singleton + lazy-preset fallback would be dead weight.
|
|
14
|
+
*
|
|
15
|
+
* Keyed by `meta.id` with last-writer-wins, so a consumer-supplied provider of
|
|
16
|
+
* the same id overrides an env preset of that id.
|
|
17
|
+
*/
|
|
18
|
+
export class EmailProviderRegistry {
|
|
19
|
+
private byId = new Map<string, EmailProvider>();
|
|
20
|
+
|
|
21
|
+
constructor(providers: EmailProvider[] = []) {
|
|
22
|
+
for (const provider of providers) this.register(provider);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register (or replace) a provider. Falls back to `"resend"` for a provider
|
|
27
|
+
* built before `meta` existed (the contract keeps `meta` optional for
|
|
28
|
+
* back-compat). Last-writer-wins.
|
|
29
|
+
*/
|
|
30
|
+
register(provider: EmailProvider): void {
|
|
31
|
+
this.byId.set(provider.meta?.id ?? "resend", provider);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get(id: string): EmailProvider | undefined {
|
|
35
|
+
return this.byId.get(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getAll(): EmailProvider[] {
|
|
39
|
+
return [...this.byId.values()];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
count(): number {
|
|
43
|
+
return this.byId.size;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { EmailProvider } from "@hogsend/core";
|
|
2
|
+
import { createResendProvider } from "@hogsend/plugin-resend";
|
|
3
|
+
import type { env as envSchema } from "../env.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `@hogsend/plugin-postmark` is an OPT-IN, deferred-publish package: it is an
|
|
7
|
+
* engine `optionalDependency`, NOT a hard one, and it is not on the npm registry
|
|
8
|
+
* yet. So we MUST NOT statically import it — a static `import` would make the
|
|
9
|
+
* package mandatory at engine load, and `npm install @hogsend/engine` would fail
|
|
10
|
+
* with E404 on plugin-postmark for every consumer that doesn't have it.
|
|
11
|
+
*
|
|
12
|
+
* Instead we load it lazily, ONCE, behind a top-level guarded dynamic import:
|
|
13
|
+
* the `import()` only fires when `POSTMARK_SERVER_TOKEN` is present (the same
|
|
14
|
+
* gate the preset below uses), so a deploy that never sets that var never
|
|
15
|
+
* touches the package and degrades gracefully when it isn't installed. ESM
|
|
16
|
+
* top-level await keeps `emailProvidersFromEnv` itself synchronous (it reads the
|
|
17
|
+
* already-resolved factory), so `createHogsendClient` stays synchronous.
|
|
18
|
+
*
|
|
19
|
+
* The specifier is assembled at runtime (not a string literal) ON PURPOSE: a
|
|
20
|
+
* literal `import("@hogsend/plugin-postmark")` makes `tsc` resolve the module's
|
|
21
|
+
* types, which fails with TS2307 for any consumer that doesn't have the opt-in
|
|
22
|
+
* package installed (e.g. a fresh `create-hogsend` app). A computed specifier is
|
|
23
|
+
* opaque to the type-checker — resolved only at runtime — so the engine
|
|
24
|
+
* type-checks identically with or without the package present.
|
|
25
|
+
*/
|
|
26
|
+
type CreatePostmarkProvider = (cfg: {
|
|
27
|
+
serverToken: string;
|
|
28
|
+
messageStream?: string;
|
|
29
|
+
webhookBasicAuth?: { user: string; pass: string };
|
|
30
|
+
}) => EmailProvider;
|
|
31
|
+
|
|
32
|
+
const POSTMARK_PACKAGE = ["@hogsend", "plugin-postmark"].join("/");
|
|
33
|
+
|
|
34
|
+
let createPostmarkProvider: CreatePostmarkProvider | null = null;
|
|
35
|
+
if (process.env.POSTMARK_SERVER_TOKEN) {
|
|
36
|
+
try {
|
|
37
|
+
({ createPostmarkProvider } = (await import(POSTMARK_PACKAGE)) as {
|
|
38
|
+
createPostmarkProvider: CreatePostmarkProvider;
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
// The token is set but the opt-in package isn't installed. Leave the factory
|
|
42
|
+
// null — `emailProvidersFromEnv` skips the preset, and if Postmark was the
|
|
43
|
+
// resolved active provider the container throws a clear "not registered"
|
|
44
|
+
// error directing the operator to install `@hogsend/plugin-postmark`.
|
|
45
|
+
createPostmarkProvider = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the env-enabled email-provider presets. Mirrors `destinationsFromEnv`:
|
|
51
|
+
* a preset is constructed ONLY when its credential is present, so a
|
|
52
|
+
* Postmark-only deploy (no `RESEND_API_KEY`) contributes no Resend provider.
|
|
53
|
+
*
|
|
54
|
+
* These presets come FIRST in the container's merge — a consumer-supplied
|
|
55
|
+
* provider of the same id wins (last-writer-wins on the registry).
|
|
56
|
+
*/
|
|
57
|
+
export function emailProvidersFromEnv(env: typeof envSchema): EmailProvider[] {
|
|
58
|
+
const providers: EmailProvider[] = [];
|
|
59
|
+
|
|
60
|
+
if (env.RESEND_API_KEY) {
|
|
61
|
+
providers.push(
|
|
62
|
+
createResendProvider({
|
|
63
|
+
apiKey: env.RESEND_API_KEY,
|
|
64
|
+
webhookSecret: env.RESEND_WEBHOOK_SECRET,
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Postmark is OPT-IN: built only when its token is present AND the opt-in
|
|
70
|
+
// package resolved (see the guarded dynamic import above), and it never
|
|
71
|
+
// changes the default active provider — set EMAIL_PROVIDER=postmark to
|
|
72
|
+
// activate it. Postmark has no HMAC, so webhook auth is HTTP Basic creds (the
|
|
73
|
+
// provider fails closed when they're unset).
|
|
74
|
+
if (env.POSTMARK_SERVER_TOKEN && createPostmarkProvider) {
|
|
75
|
+
providers.push(
|
|
76
|
+
createPostmarkProvider({
|
|
77
|
+
serverToken: env.POSTMARK_SERVER_TOKEN,
|
|
78
|
+
...(env.POSTMARK_MESSAGE_STREAM
|
|
79
|
+
? { messageStream: env.POSTMARK_MESSAGE_STREAM }
|
|
80
|
+
: {}),
|
|
81
|
+
...(env.POSTMARK_WEBHOOK_USER && env.POSTMARK_WEBHOOK_PASS
|
|
82
|
+
? {
|
|
83
|
+
webhookBasicAuth: {
|
|
84
|
+
user: env.POSTMARK_WEBHOOK_USER,
|
|
85
|
+
pass: env.POSTMARK_WEBHOOK_PASS,
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
: {}),
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return providers;
|
|
94
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
BatchEmailItem,
|
|
3
3
|
DurationObject,
|
|
4
|
+
EmailEvent,
|
|
5
|
+
EmailEventType,
|
|
4
6
|
SendEmailOptions,
|
|
5
7
|
SendResult,
|
|
6
|
-
WebhookEventType,
|
|
7
8
|
WebhookHandlerMap,
|
|
8
9
|
} from "@hogsend/core";
|
|
9
10
|
import type {
|
|
@@ -63,12 +64,36 @@ export interface SendTrackedEmailOptions<
|
|
|
63
64
|
|
|
64
65
|
export interface TrackedSendResult {
|
|
65
66
|
emailSendId: string;
|
|
67
|
+
/** The provider's neutral message id (Resend email_id / Postmark MessageID). */
|
|
68
|
+
messageId: string;
|
|
69
|
+
/**
|
|
70
|
+
* @deprecated Renamed to {@link TrackedSendResult.messageId}. This read-alias
|
|
71
|
+
* always mirrors `messageId`; kept for one minor and removed the following
|
|
72
|
+
* minor. Build results via {@link trackedSendResult} so the alias stays live.
|
|
73
|
+
*/
|
|
66
74
|
resendId: string;
|
|
67
75
|
status: "sent" | "suppressed" | "unsubscribed" | "skipped";
|
|
68
76
|
/** Present only when `status === "skipped"` by the frequency cap. */
|
|
69
77
|
reason?: "frequency_capped";
|
|
70
78
|
}
|
|
71
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Build a {@link TrackedSendResult}, attaching a live `@deprecated` `resendId`
|
|
82
|
+
* read-alias getter that mirrors `messageId`. Lets every send path return a
|
|
83
|
+
* single canonical `messageId` while public consumers reading the old `resendId`
|
|
84
|
+
* field keep working for one minor.
|
|
85
|
+
*/
|
|
86
|
+
export function trackedSendResult(
|
|
87
|
+
result: Omit<TrackedSendResult, "resendId">,
|
|
88
|
+
): TrackedSendResult {
|
|
89
|
+
return Object.defineProperty({ ...result }, "resendId", {
|
|
90
|
+
get(this: { messageId: string }) {
|
|
91
|
+
return this.messageId;
|
|
92
|
+
},
|
|
93
|
+
enumerable: true,
|
|
94
|
+
}) as TrackedSendResult;
|
|
95
|
+
}
|
|
96
|
+
|
|
72
97
|
// ---------------------------------------------------------------------------
|
|
73
98
|
// Frequency capping (client default config)
|
|
74
99
|
// ---------------------------------------------------------------------------
|
|
@@ -102,7 +127,6 @@ export interface EmailServiceConfig {
|
|
|
102
127
|
*/
|
|
103
128
|
templates: TemplateRegistry;
|
|
104
129
|
db?: unknown;
|
|
105
|
-
webhookSecret?: string;
|
|
106
130
|
webhookHandlers?: WebhookHandlerMap;
|
|
107
131
|
retryOptions?: RetryOptions;
|
|
108
132
|
bounceThreshold?: number;
|
|
@@ -138,13 +162,18 @@ export interface EmailServiceSendOptions<
|
|
|
138
162
|
idempotencyKey?: string;
|
|
139
163
|
}
|
|
140
164
|
|
|
165
|
+
/**
|
|
166
|
+
* @deprecated The route now verifies the provider webhook and hands
|
|
167
|
+
* {@link EmailService.handleWebhook} an already-parsed {@link EmailEvent}. This
|
|
168
|
+
* raw `{ payload, headers }` shape is no longer the handler input.
|
|
169
|
+
*/
|
|
141
170
|
export interface EmailServiceWebhookOptions {
|
|
142
171
|
payload: string;
|
|
143
172
|
headers: Record<string, string>;
|
|
144
173
|
}
|
|
145
174
|
|
|
146
175
|
export interface EmailServiceWebhookResult {
|
|
147
|
-
type:
|
|
176
|
+
type: EmailEventType;
|
|
148
177
|
handled: boolean;
|
|
149
178
|
}
|
|
150
179
|
|
|
@@ -163,7 +192,14 @@ export interface EmailService {
|
|
|
163
192
|
options: EmailServiceRenderOptions<K>,
|
|
164
193
|
): Promise<EmailServiceRenderResult>;
|
|
165
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Dispatch an already-verified, provider-neutral {@link EmailEvent} into the
|
|
197
|
+
* status/suppression/outbound pipeline. The webhook route owns provider
|
|
198
|
+
* resolution + signature verification and passes the parsed event + the
|
|
199
|
+
* resolving `providerId` (the latter is informational for now).
|
|
200
|
+
*/
|
|
166
201
|
handleWebhook(
|
|
167
|
-
|
|
202
|
+
event: EmailEvent,
|
|
203
|
+
providerId?: string,
|
|
168
204
|
): Promise<EmailServiceWebhookResult>;
|
|
169
205
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flatten a `Headers` instance into a plain lowercased `Record<string, string>`.
|
|
3
|
+
* Webhook routes verify signatures over the EXACT received bytes, so they need a
|
|
4
|
+
* case-insensitive header lookup — this is the single place that lowercasing
|
|
5
|
+
* lives. Pass `c.req.raw.headers`.
|
|
6
|
+
*/
|
|
7
|
+
export function headersToRecord(headers: Headers): Record<string, string> {
|
|
8
|
+
const record: Record<string, string> = {};
|
|
9
|
+
for (const [key, value] of headers.entries()) {
|
|
10
|
+
record[key.toLowerCase()] = value;
|
|
11
|
+
}
|
|
12
|
+
return record;
|
|
13
|
+
}
|