@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 +9 -6
- package/src/container.ts +76 -21
- package/src/env.ts +23 -1
- package/src/index.ts +21 -6
- 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 +120 -70
- package/src/lib/outbound.ts +11 -2
- package/src/lib/tracked.ts +34 -29
- package/src/lib/tracking-events.ts +26 -11
- package/src/routes/admin/emails.ts +5 -1
- package/src/routes/tracking/click.ts +1 -1
- package/src/routes/tracking/open.ts +1 -1
- 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/send-email.ts +2 -1
|
@@ -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:
|
|
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
|
-
|
|
41
|
-
|
|
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
|
|
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,
|