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

@@ -9,6 +9,7 @@ import { user } from "@kk/db/schema/auth";
import { ORPCError } from "@orpc/server";
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
import { z } from "zod";
import type { EmailMessage } from "../email-queue";
import { adminProcedure, protectedProcedure } from "../index";
import { sendDeductionEmail } from "../lib/drinkkaart-email";
import {
@@ -339,7 +340,7 @@ export const drinkkaartRouter = {
createdAt: now,
});
// Fire-and-forget deduction email
// Fire-and-forget deduction email (via queue when available)
const cardUser = await db
.select({ email: user.email, name: user.name })
.from(user)
@@ -348,14 +349,21 @@ export const drinkkaartRouter = {
.then((r) => r[0]);
if (cardUser) {
await sendDeductionEmail({
const deductionMsg: EmailMessage = {
type: "deduction",
to: cardUser.email,
firstName: cardUser.name.split(" ")[0] ?? cardUser.name,
amountCents: input.amountCents,
newBalanceCents: balanceAfter,
}).catch((err) =>
console.error("Failed to send deduction email:", err),
);
};
if (context.emailQueue) {
await context.emailQueue.send(deductionMsg);
} else {
await sendDeductionEmail(deductionMsg).catch((err) =>
console.error("Failed to send deduction email:", err),
);
}
}
return {