@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.
- package/package.json +11 -6
- package/src/app.ts +28 -17
- package/src/container.ts +133 -41
- package/src/env.ts +35 -1
- package/src/index.ts +40 -8
- package/src/lib/auth.ts +78 -1
- package/src/lib/bootstrap-admin.ts +90 -0
- package/src/lib/create-admin.ts +104 -0
- 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/redis.ts +68 -0
- package/src/lib/reset-email.ts +139 -0
- package/src/lib/tracked.ts +34 -29
- package/src/lib/tracking-events.ts +26 -11
- package/src/middleware/rate-limit.ts +38 -3
- 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
package/src/lib/tracked.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
77
|
+
messageId: "",
|
|
77
78
|
status: prior.status === "sent" ? "sent" : "skipped",
|
|
78
79
|
...(prior.status === "sent" ? {} : { reason: "frequency_capped" }),
|
|
79
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
+
messageId: row.messageId,
|
|
41
41
|
to: row.toEmail,
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export interface
|
|
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
|
-
* `
|
|
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
|
|
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 `
|
|
69
|
+
* that `messageId` yet (e.g. a webhook arriving before the send row is committed).
|
|
63
70
|
*/
|
|
64
|
-
export async function
|
|
71
|
+
export async function resolveEmailSendContextByMessageId(
|
|
65
72
|
db: Database,
|
|
66
|
-
|
|
67
|
-
): Promise<
|
|
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.
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|