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:
147
packages/api/src/email-queue.ts
Normal file
147
packages/api/src/email-queue.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Email queue types and dispatcher.
|
||||
*
|
||||
* All email sends are modelled as a discriminated union so the Cloudflare Queue
|
||||
* consumer can pattern-match on `msg.type` and call the right send*Email().
|
||||
*
|
||||
* The `Queue<EmailMessage>` type used in context.ts / server.ts refers to the
|
||||
* CF runtime binding type (`import type { Queue } from "@cloudflare/workers-types"`).
|
||||
*/
|
||||
|
||||
import {
|
||||
sendCancellationEmail,
|
||||
sendConfirmationEmail,
|
||||
sendPaymentConfirmationEmail,
|
||||
sendPaymentReminderEmail,
|
||||
sendReminder24hEmail,
|
||||
sendReminderEmail,
|
||||
sendSubscriptionConfirmationEmail,
|
||||
sendUpdateEmail,
|
||||
} from "./email";
|
||||
import { sendDeductionEmail } from "./lib/drinkkaart-email";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message types — one variant per send*Email function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConfirmationMessage = {
|
||||
type: "registrationConfirmation";
|
||||
to: string;
|
||||
firstName: string;
|
||||
managementToken: string;
|
||||
wantsToPerform: boolean;
|
||||
artForm?: string | null;
|
||||
giftAmount?: number;
|
||||
drinkCardValue?: number;
|
||||
signupUrl?: string;
|
||||
};
|
||||
|
||||
export type UpdateMessage = {
|
||||
type: "updateConfirmation";
|
||||
to: string;
|
||||
firstName: string;
|
||||
managementToken: string;
|
||||
wantsToPerform: boolean;
|
||||
artForm?: string | null;
|
||||
giftAmount?: number;
|
||||
drinkCardValue?: number;
|
||||
};
|
||||
|
||||
export type CancellationMessage = {
|
||||
type: "cancellation";
|
||||
to: string;
|
||||
firstName: string;
|
||||
};
|
||||
|
||||
export type SubscriptionConfirmationMessage = {
|
||||
type: "subscriptionConfirmation";
|
||||
to: string;
|
||||
};
|
||||
|
||||
export type Reminder24hMessage = {
|
||||
type: "reminder24h";
|
||||
to: string;
|
||||
firstName?: string | null;
|
||||
};
|
||||
|
||||
export type Reminder1hMessage = {
|
||||
type: "reminder1h";
|
||||
to: string;
|
||||
firstName?: string | null;
|
||||
};
|
||||
|
||||
export type PaymentReminderMessage = {
|
||||
type: "paymentReminder";
|
||||
to: string;
|
||||
firstName: string;
|
||||
managementToken: string;
|
||||
drinkCardValue?: number;
|
||||
giftAmount?: number;
|
||||
};
|
||||
|
||||
export type PaymentConfirmationMessage = {
|
||||
type: "paymentConfirmation";
|
||||
to: string;
|
||||
firstName: string;
|
||||
managementToken: string;
|
||||
drinkCardValue?: number;
|
||||
giftAmount?: number;
|
||||
};
|
||||
|
||||
export type DeductionMessage = {
|
||||
type: "deduction";
|
||||
to: string;
|
||||
firstName: string;
|
||||
amountCents: number;
|
||||
newBalanceCents: number;
|
||||
};
|
||||
|
||||
export type EmailMessage =
|
||||
| ConfirmationMessage
|
||||
| UpdateMessage
|
||||
| CancellationMessage
|
||||
| SubscriptionConfirmationMessage
|
||||
| Reminder24hMessage
|
||||
| Reminder1hMessage
|
||||
| PaymentReminderMessage
|
||||
| PaymentConfirmationMessage
|
||||
| DeductionMessage;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Consumer-side dispatcher — called once per queue message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function dispatchEmailMessage(msg: EmailMessage): Promise<void> {
|
||||
switch (msg.type) {
|
||||
case "registrationConfirmation":
|
||||
await sendConfirmationEmail(msg);
|
||||
break;
|
||||
case "updateConfirmation":
|
||||
await sendUpdateEmail(msg);
|
||||
break;
|
||||
case "cancellation":
|
||||
await sendCancellationEmail(msg);
|
||||
break;
|
||||
case "subscriptionConfirmation":
|
||||
await sendSubscriptionConfirmationEmail({ to: msg.to });
|
||||
break;
|
||||
case "reminder24h":
|
||||
await sendReminder24hEmail(msg);
|
||||
break;
|
||||
case "reminder1h":
|
||||
await sendReminderEmail(msg);
|
||||
break;
|
||||
case "paymentReminder":
|
||||
await sendPaymentReminderEmail(msg);
|
||||
break;
|
||||
case "paymentConfirmation":
|
||||
await sendPaymentConfirmationEmail(msg);
|
||||
break;
|
||||
case "deduction":
|
||||
await sendDeductionEmail(msg);
|
||||
break;
|
||||
default:
|
||||
// Exhaustiveness check — TypeScript will catch unhandled variants at compile time
|
||||
msg satisfies never;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user