@hogsend/engine 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +9 -6
  2. package/src/container.ts +156 -29
  3. package/src/destinations/define-destination.ts +104 -0
  4. package/src/destinations/presets/index.ts +94 -0
  5. package/src/destinations/presets/posthog.ts +71 -0
  6. package/src/destinations/presets/segment.ts +75 -0
  7. package/src/destinations/presets/slack.ts +66 -0
  8. package/src/destinations/presets/webhook.ts +37 -0
  9. package/src/destinations/registry-singleton.ts +78 -0
  10. package/src/env.ts +38 -1
  11. package/src/index.ts +46 -6
  12. package/src/journeys/define-journey.ts +0 -1
  13. package/src/journeys/journey-context.ts +1 -17
  14. package/src/lib/analytics-singleton.ts +7 -0
  15. package/src/lib/email-provider-registry.ts +45 -0
  16. package/src/lib/email-providers-from-env.ts +94 -0
  17. package/src/lib/email-service-types.ts +40 -4
  18. package/src/lib/headers.ts +13 -0
  19. package/src/lib/mailer.ts +137 -72
  20. package/src/lib/outbound.ts +18 -2
  21. package/src/lib/seed-posthog-destination.ts +93 -0
  22. package/src/lib/tracked.ts +34 -29
  23. package/src/lib/tracking-events.ts +37 -20
  24. package/src/lib/webhook-signing.ts +2 -1
  25. package/src/routes/admin/emails.ts +5 -1
  26. package/src/routes/admin/webhooks.ts +100 -9
  27. package/src/routes/tracking/click.ts +20 -25
  28. package/src/routes/tracking/open.ts +20 -27
  29. package/src/routes/webhooks/email-provider.ts +124 -0
  30. package/src/routes/webhooks/index.ts +7 -0
  31. package/src/routes/webhooks/resend.ts +14 -29
  32. package/src/routes/webhooks/sources.ts +15 -4
  33. package/src/workflows/deliver-webhook.ts +137 -52
  34. package/src/workflows/send-email.ts +2 -1
@@ -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
- RESEND_API_KEY: z.string().min(1),
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) ---
@@ -179,7 +179,6 @@ export function defineJourney(options: {
179
179
  hatchetCtx,
180
180
  registry: getJourneyRegistrySingleton(),
181
181
  logger,
182
- posthog,
183
182
  stateId,
184
183
  userId,
185
184
  userEmail,
@@ -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, PostHogService } from "@hogsend/core";
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: WebhookEventType;
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
- options: EmailServiceWebhookOptions,
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
+ }