@hogsend/engine 0.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.9.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.9.0",
42
- "@hogsend/db": "^0.9.0",
43
- "@hogsend/email": "^0.9.0",
44
- "@hogsend/plugin-posthog": "^0.9.0",
45
- "@hogsend/plugin-resend": "^0.9.0"
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,11 +9,6 @@ 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 {
@@ -33,6 +28,8 @@ import { buildJourneyRegistry } from "./journeys/registry.js";
33
28
  import { setAnalytics } from "./lib/analytics-singleton.js";
34
29
  import { type Auth, createAuth } from "./lib/auth.js";
35
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";
36
33
  import type {
37
34
  EmailService,
38
35
  FrequencyCapConfig,
@@ -61,8 +58,19 @@ export interface HogsendClient {
61
58
  db: Database;
62
59
  dbClient: DatabaseClient;
63
60
  auth: Auth;
64
- email: Resend;
65
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;
66
74
  /**
67
75
  * The app's template registry (key → component + subject + category +
68
76
  * optional preview/examples). Same object threaded into the engine mailer;
@@ -121,10 +129,18 @@ export interface HogsendClientOptions {
121
129
  * (templates → render → preference checks → tracking → `email_sends` write),
122
130
  * and the {@link EmailProvider} is only the swappable wire under it.
123
131
  *
124
- * - `provider` — the swappable email provider (Resend, Postmark, SES…).
125
- * Defaults to a Resend provider built from env (`RESEND_API_KEY` /
126
- * `RESEND_WEBHOOK_SECRET`). Tracking/rendering/preferences come along for
127
- * 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.
128
144
  * - `templates` — the app's template registry (key → component + subject +
129
145
  * category), threaded into the engine mailer and onward to
130
146
  * `getTemplate(..., { registry })`. The engine bakes in no business
@@ -136,6 +152,8 @@ export interface HogsendClientOptions {
136
152
  */
137
153
  email?: {
138
154
  provider?: EmailProvider;
155
+ providers?: EmailProvider[];
156
+ defaultProvider?: string;
139
157
  templates?: TemplateRegistry;
140
158
  };
141
159
  /**
@@ -249,8 +267,6 @@ export function createHogsendClient(
249
267
  ),
250
268
  });
251
269
 
252
- const email = createResendClient({ apiKey: env.RESEND_API_KEY });
253
-
254
270
  const registry = buildJourneyRegistry(
255
271
  opts.journeys ?? [],
256
272
  opts.enabledJourneys ?? env.ENABLED_JOURNEYS,
@@ -301,12 +317,51 @@ export function createHogsendClient(
301
317
  opts.enabledLists ?? env.ENABLED_LISTS,
302
318
  );
303
319
 
304
- const provider =
305
- opts.email?.provider ??
306
- createResendProvider({
307
- apiKey: env.RESEND_API_KEY,
308
- webhookSecret: env.RESEND_WEBHOOK_SECRET,
309
- });
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
+ }
310
365
 
311
366
  const defaults: HogsendDefaults = {
312
367
  timezone: opts.defaults?.timezone ?? "UTC",
@@ -327,10 +382,9 @@ export function createHogsendClient(
327
382
  opts.overrides?.mailer ??
328
383
  createTrackedMailer(
329
384
  {
330
- defaultFrom: env.RESEND_FROM_EMAIL,
385
+ defaultFrom: env.EMAIL_FROM ?? env.RESEND_FROM_EMAIL,
331
386
  templates,
332
387
  db,
333
- webhookSecret: env.RESEND_WEBHOOK_SECRET,
334
388
  bounceThreshold: 3,
335
389
  baseUrl: env.API_PUBLIC_URL,
336
390
  frequencyCap: defaults.frequencyCap,
@@ -404,8 +458,9 @@ export function createHogsendClient(
404
458
  db,
405
459
  dbClient: created.client,
406
460
  auth,
407
- email,
408
461
  emailService,
462
+ emailProviders,
463
+ emailProvider: provider,
409
464
  templates,
410
465
  analytics,
411
466
  registry,
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
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,
@@ -149,6 +157,8 @@ export {
149
157
  sendEmail,
150
158
  setEmailService,
151
159
  } from "./lib/email.js";
160
+ // --- Email provider registry (container-held, keyed by meta.id) ---
161
+ export { EmailProviderRegistry } from "./lib/email-provider-registry.js";
152
162
  // --- Email service (engine-owned tracked mailer) ---
153
163
  export type {
154
164
  EmailService,
@@ -205,6 +215,11 @@ export {
205
215
  export {
206
216
  pushTrackingEvent,
207
217
  resolveEmailSendContext,
218
+ resolveEmailSendContextByMessageId,
219
+ /**
220
+ * @deprecated Kept for one minor; use
221
+ * {@link resolveEmailSendContextByMessageId}.
222
+ */
208
223
  resolveEmailSendContextByResendId,
209
224
  } from "./lib/tracking-events.js";
210
225
  // --- Outbound webhooks: signing core (Section 1.2) ---
@@ -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
+ }