@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.
@@ -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,