@hogsend/engine 0.9.0 → 0.11.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.
@@ -14,10 +14,11 @@ import {
14
14
  } from "@hogsend/email";
15
15
  import { eq } from "drizzle-orm";
16
16
  import { getListRegistry } from "../lists/registry-singleton.js";
17
- import type {
18
- FrequencyCapConfig,
19
- SendTrackedEmailOptions,
20
- TrackedSendResult,
17
+ import {
18
+ type FrequencyCapConfig,
19
+ type SendTrackedEmailOptions,
20
+ type TrackedSendResult,
21
+ trackedSendResult,
21
22
  } from "./email-service-types.js";
22
23
  import { isFrequencyCapped } from "./frequency-cap.js";
23
24
  import { hatchet } from "./hatchet.js";
@@ -71,12 +72,12 @@ export async function sendTrackedEmail<K extends TemplateName>(
71
72
  id: string;
72
73
  status: string;
73
74
  }): TrackedSendResult =>
74
- ({
75
+ trackedSendResult({
75
76
  emailSendId: prior.id,
76
- resendId: "",
77
+ messageId: "",
77
78
  status: prior.status === "sent" ? "sent" : "skipped",
78
79
  ...(prior.status === "sent" ? {} : { reason: "frequency_capped" }),
79
- }) as TrackedSendResult;
80
+ } as Omit<TrackedSendResult, "resendId">);
80
81
 
81
82
  // Idempotency short-circuit (POST /v1/emails): a retry with the same key
82
83
  // returns the prior send instead of dispatching a duplicate provider call /
@@ -135,15 +136,15 @@ export async function sendTrackedEmail<K extends TemplateName>(
135
136
  const suppressedRow = rows[0];
136
137
  if (!suppressedRow) throw new Error("Failed to insert email_sends row");
137
138
 
138
- return {
139
+ return trackedSendResult({
139
140
  emailSendId: suppressedRow.id,
140
- resendId: "",
141
+ messageId: "",
141
142
  status:
142
143
  suppression === "unsubscribed" ||
143
144
  suppression === "category_unsubscribed"
144
145
  ? "unsubscribed"
145
146
  : "suppressed",
146
- };
147
+ });
147
148
  }
148
149
 
149
150
  // Frequency cap — consulted only for non-system sends (system mail sets
@@ -165,12 +166,12 @@ export async function sendTrackedEmail<K extends TemplateName>(
165
166
  to: options.to,
166
167
  category: options.category,
167
168
  });
168
- return {
169
+ return trackedSendResult({
169
170
  emailSendId: "",
170
- resendId: "",
171
+ messageId: "",
171
172
  status: "skipped",
172
173
  reason: "frequency_capped",
173
- };
174
+ });
174
175
  }
175
176
  }
176
177
  }
@@ -257,22 +258,26 @@ export async function sendTrackedEmail<K extends TemplateName>(
257
258
  const emailSendId = insertedRow.id;
258
259
 
259
260
  try {
260
- let html: string | undefined;
261
- if (options.baseUrl && prepareTrackedHtml) {
262
- const rawHtml = await renderToHtml(sendElement);
263
- html = await prepareTrackedHtml({
264
- html: rawHtml,
265
- emailSendId,
266
- baseUrl: options.baseUrl,
267
- db,
268
- });
269
- }
261
+ // HTML-ONLY wire the engine ALWAYS renders React → HTML itself. When
262
+ // tracking is on (baseUrl + prepareTrackedHtml) we render then rewrite
263
+ // links/inject the open pixel; otherwise we render plain HTML. React Email
264
+ // stays first-class for authoring/Studio; it never crosses the wire.
265
+ const rawHtml = await renderToHtml(sendElement);
266
+ const html =
267
+ options.baseUrl && prepareTrackedHtml
268
+ ? await prepareTrackedHtml({
269
+ html: rawHtml,
270
+ emailSendId,
271
+ baseUrl: options.baseUrl,
272
+ db,
273
+ })
274
+ : rawHtml;
270
275
 
271
276
  const result = await provider.send({
272
277
  from: options.from,
273
278
  to: options.to,
274
279
  subject,
275
- ...(html ? { html } : { react: sendElement }),
280
+ html,
276
281
  tags: options.tags,
277
282
  headers: sendHeaders,
278
283
  replyTo: options.replyTo,
@@ -282,7 +287,7 @@ export async function sendTrackedEmail<K extends TemplateName>(
282
287
  await db
283
288
  .update(emailSends)
284
289
  .set({
285
- resendId: result.id,
290
+ messageId: result.id,
286
291
  status: "sent",
287
292
  sentAt,
288
293
  updatedAt: sentAt,
@@ -306,7 +311,7 @@ export async function sendTrackedEmail<K extends TemplateName>(
306
311
  dedupeKey: `email.sent:${emailSendId}`,
307
312
  payload: {
308
313
  emailSendId,
309
- resendId: result.id,
314
+ messageId: result.id,
310
315
  templateKey: options.templateKey,
311
316
  to: options.to,
312
317
  userId: options.userId ?? null,
@@ -322,11 +327,11 @@ export async function sendTrackedEmail<K extends TemplateName>(
322
327
  });
323
328
  });
324
329
 
325
- return {
330
+ return trackedSendResult({
326
331
  emailSendId,
327
- resendId: result.id,
332
+ messageId: result.id,
328
333
  status: "sent",
329
- };
334
+ });
330
335
  } catch (error) {
331
336
  // A provider send failed (transient SMTP/network/429). Stamp `failed` AND
332
337
  // RELEASE the idempotency key (set it null), exactly like the suppression
@@ -9,7 +9,7 @@ interface EmailSendContext {
9
9
  userId: string;
10
10
  userEmail: string;
11
11
  templateKey: string | null;
12
- resendId: string | null;
12
+ messageId: string | null;
13
13
  to: string;
14
14
  }
15
15
 
@@ -21,7 +21,7 @@ export async function resolveEmailSendContext(
21
21
  .select({
22
22
  toEmail: emailSends.toEmail,
23
23
  templateKey: emailSends.templateKey,
24
- resendId: emailSends.resendId,
24
+ messageId: emailSends.messageId,
25
25
  userId: journeyStates.userId,
26
26
  userEmail: journeyStates.userEmail,
27
27
  })
@@ -37,12 +37,12 @@ export async function resolveEmailSendContext(
37
37
  userId: row.userId ?? row.toEmail,
38
38
  userEmail: row.userEmail ?? row.toEmail,
39
39
  templateKey: row.templateKey,
40
- resendId: row.resendId,
40
+ messageId: row.messageId,
41
41
  to: row.toEmail,
42
42
  };
43
43
  }
44
44
 
45
- export interface ResendEmailSendContext {
45
+ export interface EmailSendContextByMessageId {
46
46
  emailSendId: string;
47
47
  userId: string;
48
48
  userEmail: string;
@@ -50,21 +50,28 @@ export interface ResendEmailSendContext {
50
50
  to: string;
51
51
  }
52
52
 
53
+ /**
54
+ * @deprecated Renamed to {@link EmailSendContextByMessageId} as part of the
55
+ * provider-neutral `resendId` → `messageId` rename. Kept as an alias for one
56
+ * minor; removed the following minor.
57
+ */
58
+ export type ResendEmailSendContext = EmailSendContextByMessageId;
59
+
53
60
  /**
54
61
  * Mirror of {@link resolveEmailSendContext} that resolves by the provider's
55
- * `resendId` instead of the internal `email_sends.id`. Used by the
62
+ * `messageId` instead of the internal `email_sends.id`. Used by the
56
63
  * provider-webhook enrichment path (`email.delivered`/`email.bounced`) where the
57
- * only handle we hold is the Resend `email_id`.
64
+ * only handle we hold is the provider message id.
58
65
  *
59
66
  * Returns the internal `emailSendId` plus the same denormalized identity
60
67
  * (`userId`/`userEmail` fall back to the recipient address, exactly like the
61
68
  * id-keyed resolver) and `to` recipient. Returns null when no send row carries
62
- * that `resendId` yet (e.g. a webhook arriving before the send row is committed).
69
+ * that `messageId` yet (e.g. a webhook arriving before the send row is committed).
63
70
  */
64
- export async function resolveEmailSendContextByResendId(
71
+ export async function resolveEmailSendContextByMessageId(
65
72
  db: Database,
66
- resendId: string,
67
- ): Promise<ResendEmailSendContext | null> {
73
+ messageId: string,
74
+ ): Promise<EmailSendContextByMessageId | null> {
68
75
  const rows = await db
69
76
  .select({
70
77
  emailSendId: emailSends.id,
@@ -77,7 +84,7 @@ export async function resolveEmailSendContextByResendId(
77
84
  })
78
85
  .from(emailSends)
79
86
  .leftJoin(journeyStates, eq(emailSends.journeyStateId, journeyStates.id))
80
- .where(eq(emailSends.resendId, resendId))
87
+ .where(eq(emailSends.messageId, messageId))
81
88
  .limit(1);
82
89
 
83
90
  const row = rows[0];
@@ -92,6 +99,14 @@ export async function resolveEmailSendContextByResendId(
92
99
  };
93
100
  }
94
101
 
102
+ /**
103
+ * @deprecated Renamed to {@link resolveEmailSendContextByMessageId} as part of
104
+ * the provider-neutral `resendId` → `messageId` rename. Kept as an alias for one
105
+ * minor; removed the following minor.
106
+ */
107
+ export const resolveEmailSendContextByResendId =
108
+ resolveEmailSendContextByMessageId;
109
+
95
110
  export interface PushTrackingEventOpts {
96
111
  db: Database;
97
112
  hatchet: HatchetClient;
@@ -1,3 +1,4 @@
1
+ import type { Context } from "hono";
1
2
  import { createMiddleware } from "hono/factory";
2
3
  import type { AppEnv } from "../app.js";
3
4
  import { getRedis } from "../lib/redis.js";
@@ -11,6 +12,38 @@ export interface RateLimitOptions {
11
12
  windowMs?: number;
12
13
  max?: number;
13
14
  prefix?: string;
15
+ /**
16
+ * Derive the per-request bucket key. Default keys on the resolved api-key /
17
+ * user id (falling back to "anonymous") — correct for the authenticated data
18
+ * plane. Pass `clientIpKey` for UNAUTHENTICATED surfaces (e.g. the first-admin
19
+ * sign-up gate) where every request would otherwise collapse onto the single
20
+ * "anonymous" bucket and let one attacker exhaust the budget for everyone.
21
+ */
22
+ keyFn?: (c: Context<AppEnv>) => string;
23
+ /**
24
+ * The middleware no-ops under `NODE_ENV=test` by default so the suite isn't
25
+ * throttled. Set `false` to keep it ACTIVE in tests — required to assert the
26
+ * unauthenticated sign-up limiter actually returns 429 past the threshold.
27
+ */
28
+ disableInTest?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Best-effort client IP from the proxy headers Railway/Cloudflare set (same
33
+ * source as `audit.ts` / tracking). Falls back to "unknown" so a request with
34
+ * no forwarded IP still shares a single bounded bucket rather than bypassing.
35
+ */
36
+ function clientIp(c: Context<AppEnv>): string {
37
+ return (
38
+ c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
39
+ c.req.header("x-real-ip") ||
40
+ "unknown"
41
+ );
42
+ }
43
+
44
+ /** Bucket key for unauthenticated, IP-scoped surfaces (e.g. sign-up). */
45
+ export function clientIpKey(c: Context<AppEnv>): string {
46
+ return `ip:${clientIp(c)}`;
14
47
  }
15
48
 
16
49
  /**
@@ -25,6 +58,7 @@ export function createRateLimit(opts: RateLimitOptions = {}) {
25
58
  const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
26
59
  const max = opts.max ?? DEFAULT_MAX_REQUESTS;
27
60
  const prefix = opts.prefix ?? DEFAULT_PREFIX;
61
+ const disableInTest = opts.disableInTest ?? true;
28
62
 
29
63
  // Per-instance memory store so prefixes stay budget-isolated in the
30
64
  // Redis-less fallback path too.
@@ -32,10 +66,11 @@ export function createRateLimit(opts: RateLimitOptions = {}) {
32
66
  let cleanupCounter = 0;
33
67
 
34
68
  return createMiddleware<AppEnv>(async (c, next) => {
35
- if (process.env.NODE_ENV === "test") return next();
69
+ if (disableInTest && process.env.NODE_ENV === "test") return next();
36
70
 
37
- const apiKey = c.get("apiKey");
38
- const keyId = apiKey?.id ?? c.get("user")?.id ?? "anonymous";
71
+ const keyId = opts.keyFn
72
+ ? opts.keyFn(c)
73
+ : (c.get("apiKey")?.id ?? c.get("user")?.id ?? "anonymous");
39
74
  const now = Date.now();
40
75
 
41
76
  let count: number;
@@ -26,6 +26,8 @@ const emailSchema = z.object({
26
26
  id: z.string(),
27
27
  journeyStateId: z.string().nullable(),
28
28
  templateKey: z.string().nullable(),
29
+ messageId: z.string().nullable(),
30
+ /** @deprecated Mirrors `messageId`; kept for one minor, removed thereafter. */
29
31
  resendId: z.string().nullable(),
30
32
  fromEmail: z.string(),
31
33
  toEmail: z.string(),
@@ -88,7 +90,9 @@ function serializeEmail(
88
90
  id: row.id,
89
91
  journeyStateId: row.journeyStateId,
90
92
  templateKey: row.templateKey,
91
- resendId: row.resendId,
93
+ messageId: row.messageId,
94
+ // @deprecated Mirror of `messageId` for one minor (back-compat).
95
+ resendId: row.messageId,
92
96
  fromEmail: row.fromEmail,
93
97
  toEmail: row.toEmail,
94
98
  subject: row.subject,
@@ -121,7 +121,7 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
121
121
  event: "email.clicked",
122
122
  payload: {
123
123
  emailSendId,
124
- resendId: ctx.resendId ?? null,
124
+ messageId: ctx.messageId ?? null,
125
125
  templateKey: ctx.templateKey ?? null,
126
126
  userId: ctx.userId ?? null,
127
127
  to: ctx.to ?? ctx.userEmail ?? "",
@@ -81,7 +81,7 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
81
81
  event: "email.opened",
82
82
  payload: {
83
83
  emailSendId: id,
84
- resendId: ctx.resendId ?? null,
84
+ messageId: ctx.messageId ?? null,
85
85
  templateKey: ctx.templateKey ?? null,
86
86
  userId: ctx.userId ?? null,
87
87
  to: ctx.to ?? ctx.userEmail ?? "",
@@ -0,0 +1,124 @@
1
+ import { WebhookHandshakeSignal } from "@hogsend/core";
2
+ import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import type { Context } from "hono";
4
+ import type { AppEnv } from "../../app.js";
5
+ import { headersToRecord } from "../../lib/headers.js";
6
+
7
+ /**
8
+ * Shared email-provider webhook dispatch used by BOTH the id-dispatched
9
+ * `POST /v1/webhooks/email/:providerId` route and the thin
10
+ * `POST /v1/webhooks/resend` alias. Resolves the provider from the container's
11
+ * {@link EmailProviderRegistry} (404 on unknown id), reads the raw body as the
12
+ * EXACT received bytes (signature schemes verify over these), verifies +
13
+ * dispatches the normalized {@link EmailEvent}, 200s a
14
+ * {@link WebhookHandshakeSignal}, and 401s a verification error.
15
+ */
16
+ export async function dispatchProviderWebhook(
17
+ c: Context<AppEnv>,
18
+ providerId: string,
19
+ ) {
20
+ const { emailProviders, emailService, logger } = c.get("container");
21
+
22
+ const provider = emailProviders.get(providerId);
23
+ if (!provider) {
24
+ return c.json({ error: "Unknown email provider" }, 404);
25
+ }
26
+
27
+ // Read the body ONCE as the EXACT received bytes — signature schemes verify
28
+ // over these bytes, so we must not re-stringify.
29
+ const payload = await c.req.text();
30
+ const headers = headersToRecord(c.req.raw.headers);
31
+
32
+ try {
33
+ const event = await provider.verifyWebhook({ payload, headers });
34
+ const result = await emailService.handleWebhook(event, providerId);
35
+
36
+ logger.info("Email provider webhook processed", {
37
+ providerId,
38
+ type: event.type,
39
+ handled: result.handled,
40
+ });
41
+
42
+ return c.json({ ok: true }, 200);
43
+ } catch (err) {
44
+ if (err instanceof WebhookHandshakeSignal) {
45
+ // A non-delivery-status handshake (SNS confirm, Postmark subscription
46
+ // change) the provider already handled — ack with 200.
47
+ logger.info("Email webhook handshake", {
48
+ providerId,
49
+ action: err.action,
50
+ });
51
+ return c.json({ ok: true }, 200);
52
+ }
53
+ logger.warn("Email provider webhook failed", {
54
+ providerId,
55
+ error: err instanceof Error ? err.message : String(err),
56
+ });
57
+ return c.json({ error: "Webhook verification failed" }, 401);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Id-dispatched email-provider webhook receiver:
63
+ * `POST /v1/webhooks/email/:providerId`.
64
+ *
65
+ * Resolves the provider from the container's {@link EmailProviderRegistry} (so
66
+ * an unknown id is a clean 404), verifies the webhook via that provider (which
67
+ * owns its OWN secrets), and dispatches the normalized {@link EmailEvent} into
68
+ * `emailService.handleWebhook`. Registered BEFORE the `:sourceId` catch-all so
69
+ * Hono matches the static `email/` prefix first.
70
+ *
71
+ * The provider's `verifyWebhook` is the ONLY place body-shape knowledge lives —
72
+ * the route never sniffs the payload. It returns a normalized event, OR throws
73
+ * {@link WebhookHandshakeSignal} for non-status handshakes (route 200s), OR
74
+ * throws a verification error (route 401s).
75
+ */
76
+ const emailProviderWebhookRoute = createRoute({
77
+ method: "post",
78
+ path: "/v1/webhooks/email/{providerId}",
79
+ tags: ["Webhooks"],
80
+ summary: "Email provider webhook receiver",
81
+ request: {
82
+ params: z.object({ providerId: z.string() }),
83
+ body: {
84
+ content: {
85
+ "application/json": {
86
+ schema: z.record(z.string(), z.unknown()),
87
+ },
88
+ },
89
+ },
90
+ },
91
+ responses: {
92
+ 200: {
93
+ content: {
94
+ "application/json": {
95
+ schema: z.object({ ok: z.boolean() }),
96
+ },
97
+ },
98
+ description: "Webhook processed",
99
+ },
100
+ 401: {
101
+ content: {
102
+ "application/json": {
103
+ schema: z.object({ error: z.string() }),
104
+ },
105
+ },
106
+ description: "Missing or invalid webhook signature",
107
+ },
108
+ 404: {
109
+ content: {
110
+ "application/json": {
111
+ schema: z.object({ error: z.string() }),
112
+ },
113
+ },
114
+ description: "Unknown email provider",
115
+ },
116
+ },
117
+ });
118
+
119
+ export function registerEmailProviderRoutes(app: OpenAPIHono<AppEnv>) {
120
+ app.openapi(emailProviderWebhookRoute, (c) => {
121
+ const { providerId } = c.req.valid("param");
122
+ return dispatchProviderWebhook(c, providerId);
123
+ });
124
+ }
@@ -1,6 +1,7 @@
1
1
  import type { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { AppEnv } from "../../app.js";
3
3
  import type { DefinedWebhookSource } from "../../webhook-sources/define-webhook-source.js";
4
+ import { registerEmailProviderRoutes } from "./email-provider.js";
4
5
  import { resendWebhookRouter } from "./resend.js";
5
6
  import { registerWebhookSourceRoutes } from "./sources.js";
6
7
 
@@ -12,6 +13,12 @@ export function registerWebhookRoutes(
12
13
  app: OpenAPIHono<AppEnv>,
13
14
  opts: RegisterWebhookRoutesOptions,
14
15
  ) {
16
+ // Order is load-bearing for Hono path matching:
17
+ // 1. the thin `/v1/webhooks/resend` alias (static),
18
+ // 2. the `/v1/webhooks/email/:providerId` id-dispatched route (static
19
+ // `email/` prefix — MUST come before the catch-all),
20
+ // 3. the `/v1/webhooks/:sourceId` consumer-source catch-all (LAST).
15
21
  app.route("/v1/webhooks", resendWebhookRouter);
22
+ registerEmailProviderRoutes(app);
16
23
  registerWebhookSourceRoutes(app, opts.webhookSources);
17
24
  }
@@ -1,11 +1,13 @@
1
1
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2
2
  import type { AppEnv } from "../../app.js";
3
+ import { dispatchProviderWebhook } from "./email-provider.js";
3
4
 
4
5
  const resendWebhookRoute = createRoute({
5
6
  method: "post",
6
7
  path: "/resend",
7
8
  tags: ["Webhooks"],
8
- summary: "Resend webhook receiver",
9
+ summary:
10
+ "Resend webhook receiver (@deprecated — use /v1/webhooks/email/resend)",
9
11
  request: {
10
12
  body: {
11
13
  content: {
@@ -32,37 +34,20 @@ const resendWebhookRoute = createRoute({
32
34
  },
33
35
  description: "Missing or invalid webhook secret",
34
36
  },
37
+ 404: {
38
+ content: {
39
+ "application/json": {
40
+ schema: z.object({ error: z.string() }),
41
+ },
42
+ },
43
+ description: "Resend provider not registered",
44
+ },
35
45
  },
36
46
  });
37
47
 
38
48
  export const resendWebhookRouter = new OpenAPIHono<AppEnv>().openapi(
39
49
  resendWebhookRoute,
40
- async (c) => {
41
- const { emailService, logger } = c.get("container");
42
-
43
- const rawBody = await c.req.text();
44
- const headers: Record<string, string> = {};
45
- for (const [key, value] of c.req.raw.headers.entries()) {
46
- headers[key] = value;
47
- }
48
-
49
- try {
50
- const result = await emailService.handleWebhook({
51
- payload: rawBody,
52
- headers,
53
- });
54
-
55
- logger.info("Resend webhook processed", {
56
- type: result.type,
57
- handled: result.handled,
58
- });
59
-
60
- return c.json({ ok: true }, 200);
61
- } catch (err) {
62
- logger.warn("Resend webhook failed", {
63
- error: err instanceof Error ? err.message : String(err),
64
- });
65
- return c.json({ error: "Webhook verification failed" }, 401);
66
- }
67
- },
50
+ // Thin deprecated alias for `POST /v1/webhooks/email/resend` — identical
51
+ // behavior, just the `resend` provider id wired in.
52
+ (c) => dispatchProviderWebhook(c, "resend"),
68
53
  );
@@ -1,5 +1,6 @@
1
1
  import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
2
2
  import type { AppEnv } from "../../app.js";
3
+ import { headersToRecord } from "../../lib/headers.js";
3
4
  import { ingestEvent } from "../../lib/ingestion.js";
4
5
  import type { DefinedWebhookSource } from "../../webhook-sources/define-webhook-source.js";
5
6
  import { verifySignature } from "../../webhook-sources/verify.js";
@@ -8,6 +9,19 @@ export function registerWebhookSourceRoutes(
8
9
  app: OpenAPIHono<AppEnv>,
9
10
  sources: DefinedWebhookSource[],
10
11
  ) {
12
+ // Reserve `email` for the email-provider route
13
+ // (`POST /v1/webhooks/email/:providerId`). A source with `meta.id === "email"`
14
+ // would shadow that prefix, so fail loudly at registration rather than let it
15
+ // silently break provider webhooks.
16
+ for (const source of sources) {
17
+ if (source.meta.id === "email") {
18
+ throw new Error(
19
+ 'Webhook source id "email" is reserved for the email-provider route ' +
20
+ "(POST /v1/webhooks/email/:providerId). Rename the source.",
21
+ );
22
+ }
23
+ }
24
+
11
25
  const sourceMap = new Map(sources.map((s) => [s.meta.id, s]));
12
26
 
13
27
  const webhookRoute = createRoute({
@@ -56,10 +70,7 @@ export function registerWebhookSourceRoutes(
56
70
  // Read the body ONCE as the EXACT received bytes — signature schemes verify
57
71
  // over these bytes, so we must not re-stringify. JSON.parse only AFTER auth.
58
72
  const rawBody = await c.req.text();
59
- const headers: Record<string, string> = {};
60
- for (const [key, value] of c.req.raw.headers.entries()) {
61
- headers[key.toLowerCase()] = value;
62
- }
73
+ const headers = headersToRecord(c.req.raw.headers);
63
74
 
64
75
  const secret = env[source.auth.envKey as keyof typeof env] as
65
76
  | string
@@ -34,7 +34,8 @@ export const sendEmailTask = hatchet.task({
34
34
 
35
35
  try {
36
36
  // `from` is optional: when absent the mailer's `resolveFrom` falls back to
37
- // its configured defaultFrom (env.RESEND_FROM_EMAIL).
37
+ // its configured defaultFrom (env.RESEND_FROM_EMAIL). The neutral
38
+ // `tags: {name,value}[]` shape passes straight through to the provider wire.
38
39
  const result = await emailService.sendRaw({
39
40
  from: input.from,
40
41
  to: input.to,