feat(email): route all email sends through Cloudflare Queue

Introduces a CF Queue binding (kk-email-queue) to decouple email
delivery from request handlers, preventing slow responses and
providing automatic retries. All send*Email calls now go through
the queue when the binding is available, with direct-send fallbacks
for local dev. Reminder fan-outs mark DB rows optimistically before
enqueueing to prevent re-enqueue on subsequent cron ticks.
This commit is contained in:
2026-03-11 17:13:35 +01:00
parent 7f431c0091
commit e5d2b13b21
12 changed files with 524 additions and 106 deletions

View File

@@ -52,6 +52,7 @@
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.17.1",
"@tanstack/router-core": "^1.141.1",
"@kk/config": "workspace:*",
"@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-router-devtools": "^1.141.1",

View File

@@ -1,3 +1,4 @@
import type { EmailMessage } from "@kk/api/email-queue";
import { QueryClientProvider } from "@tanstack/react-query";
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
@@ -6,6 +7,18 @@ import Loader from "./components/loader";
import { routeTree } from "./routeTree.gen";
import { orpc, queryClient } from "./utils/orpc";
// Minimal CF Queue binding shape needed for type inference
type Queue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
sendBatch(
messages: Array<{ body: EmailMessage }>,
options?: { contentType?: string },
): Promise<void>;
};
export const getRouter = () => {
const router = createTanStackRouter({
routeTree,
@@ -26,3 +39,9 @@ declare module "@tanstack/react-router" {
router: ReturnType<typeof getRouter>;
}
}
declare module "@tanstack/router-core" {
interface Register {
server: { requestContext: { emailQueue?: Queue } };
}
}

View File

@@ -1,4 +1,5 @@
import { createContext } from "@kk/api/context";
import type { EmailMessage } from "@kk/api/email-queue";
import { appRouter } from "@kk/api/routers/index";
import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
@@ -7,6 +8,18 @@ import { RPCHandler } from "@orpc/server/fetch";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { createFileRoute } from "@tanstack/react-router";
// Minimal CF Queue binding shape — mirrors the declaration in router.tsx / context.ts
type Queue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
sendBatch(
messages: Array<{ body: EmailMessage }>,
options?: { contentType?: string },
): Promise<void>;
};
const rpcHandler = new RPCHandler(appRouter, {
interceptors: [
onError((error) => {
@@ -28,16 +41,21 @@ const apiHandler = new OpenAPIHandler(appRouter, {
],
});
async function handle({ request }: { request: Request }) {
const rpcResult = await rpcHandler.handle(request, {
async function handle(ctx: { request: Request; context?: unknown }) {
// emailQueue is threaded in via the fetch wrapper in server.ts
const emailQueue = (ctx.context as { emailQueue?: Queue } | undefined)
?.emailQueue;
const context = await createContext({ req: ctx.request, emailQueue });
const rpcResult = await rpcHandler.handle(ctx.request, {
prefix: "/api/rpc",
context: await createContext({ req: request }),
context,
});
if (rpcResult.response) return rpcResult.response;
const apiResult = await apiHandler.handle(request, {
const apiResult = await apiHandler.handle(ctx.request, {
prefix: "/api/rpc/api-reference",
context: await createContext({ req: request }),
context,
});
if (apiResult.response) return apiResult.response;

View File

@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { sendPaymentConfirmationEmail } from "@kk/api/email";
import type { EmailMessage } from "@kk/api/email-queue";
import { creditRegistrationToAccount } from "@kk/api/routers/index";
import { db } from "@kk/db";
import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema";
@@ -8,6 +9,14 @@ import { env } from "@kk/env/server";
import { createFileRoute } from "@tanstack/react-router";
import { and, eq } from "drizzle-orm";
// Minimal CF Queue binding shape used to enqueue emails
type EmailQueue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
};
// Mollie payment object (relevant fields only)
interface MolliePayment {
id: string;
@@ -41,7 +50,15 @@ async function fetchMolliePayment(paymentId: string): Promise<MolliePayment> {
return response.json() as Promise<MolliePayment>;
}
async function handleWebhook({ request }: { request: Request }) {
async function handleWebhook({
request,
context,
}: {
request: Request;
context?: unknown;
}) {
const emailQueue = (context as { emailQueue?: EmailQueue } | undefined)
?.emailQueue;
if (!env.MOLLIE_API_KEY) {
console.error("MOLLIE_API_KEY not configured");
return new Response("Payment provider not configured", { status: 500 });
@@ -207,19 +224,25 @@ async function handleWebhook({ request }: { request: Request }) {
`Payment successful for registration ${registrationToken}, payment ${payment.id}`,
);
// Send payment confirmation email (fire-and-forget; don't block webhook)
sendPaymentConfirmationEmail({
// Send payment confirmation email via queue when available, otherwise direct.
const confirmMsg: EmailMessage = {
type: "paymentConfirmation",
to: regRow.email,
firstName: regRow.firstName,
managementToken: regRow.managementToken ?? registrationToken,
drinkCardValue: regRow.drinkCardValue ?? undefined,
giftAmount: regRow.giftAmount ?? undefined,
}).catch((err) =>
console.error(
`Failed to send payment confirmation email for ${regRow.email}:`,
err,
),
);
};
if (emailQueue) {
await emailQueue.send(confirmMsg);
} else {
sendPaymentConfirmationEmail(confirmMsg).catch((err) =>
console.error(
`Failed to send payment confirmation email for ${regRow.email}:`,
err,
),
);
}
// If this is a watcher with a drink card value, try to credit their
// drinkkaart immediately — but only if they already have an account.

View File

@@ -1,18 +1,72 @@
import type { EmailMessage } from "@kk/api/email-queue";
import { dispatchEmailMessage } from "@kk/api/email-queue";
import { runSendReminders } from "@kk/api/routers/index";
import {
createStartHandler,
defaultStreamHandler,
} from "@tanstack/react-start/server";
const fetch = createStartHandler(defaultStreamHandler);
// ---------------------------------------------------------------------------
// CF Workers globals — not in DOM/ES2022 lib
// ---------------------------------------------------------------------------
type MessageItem<Body> = {
body: Body;
ack(): void;
retry(): void;
};
type MessageBatch<Body> = { messages: Array<MessageItem<Body>> };
type ExecutionContext = { waitUntil(promise: Promise<unknown>): void };
// ---------------------------------------------------------------------------
// Minimal CF Queue binding shape
// ---------------------------------------------------------------------------
type EmailQueue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
sendBatch(
messages: Array<{ body: EmailMessage }>,
options?: { contentType?: string },
): Promise<void>;
};
type Env = {
EMAIL_QUEUE?: EmailQueue;
};
const startHandler = createStartHandler(defaultStreamHandler);
export default {
fetch,
fetch(request: Request, env: Env) {
// Cast required: TanStack Start's BaseContext doesn't know about emailQueue,
// but it threads the value through to route handlers via requestContext.
return startHandler(request, {
context: { emailQueue: env.EMAIL_QUEUE } as Record<string, unknown>,
});
},
async queue(
batch: MessageBatch<EmailMessage>,
_env: Env,
_ctx: ExecutionContext,
) {
for (const msg of batch.messages) {
try {
await dispatchEmailMessage(msg.body);
msg.ack();
} catch (err) {
console.error("Queue dispatch failed:", err);
msg.retry();
}
}
},
async scheduled(
_event: { cron: string; scheduledTime: number },
_env: Record<string, unknown>,
ctx: { waitUntil: (promise: Promise<unknown>) => void },
env: Env,
ctx: ExecutionContext,
) {
ctx.waitUntil(runSendReminders());
ctx.waitUntil(runSendReminders(env.EMAIL_QUEUE));
},
};