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:
@@ -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",
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user