From 75088b1fc264d280b34117b6cdc6424cc686785c Mon Sep 17 00:00:00 2001 From: Albert Dong Date: Wed, 29 Jul 2026 16:43:51 -0700 Subject: [PATCH] Replace Postmark with AWS SES for email sending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all outbound mail to SES, called as a signed REST request via aws4fetch rather than @aws-sdk/client-sesv2, which is far too large for a Worker bundle. Emails are authored once as a block list and rendered twice, to HTML and to plain text, so the text/plain alternative can't drift from the HTML. HTML is built from @react-email/components for its email-client workarounds (Outlook conditional tables, MSO button padding, preheader whitespace trick) but rendered with react-dom/server rather than @react-email/render — that package pulls in html-to-text and js-beautify for features this app doesn't use, at 132 KB gzipped versus 4.8 KB for the whole module as written. Also send a confirmation email for student mid-/end-of-quarter feedback submissions, calling out whether the submitter included a UID, since that's the only way a submission can be credited. The send happens in ctx.waitUntil so a slow SES call can't fail an accepted submission. The commented-out Postmark batch in process-withdraws is rewritten against the new API and its template ported, so uncommenting it works. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 18 +- laprogram/.env.example | 9 +- .../app/api/cron/process-withdraws/route.ts | 32 +- laprogram/app/api/feedback/route.ts | 22 +- laprogram/lib/email.ts | 48 -- laprogram/lib/email/index.ts | 64 ++ laprogram/lib/email/render.tsx | 545 +++++++++++++++++ laprogram/lib/email/ses.ts | 116 ++++ laprogram/lib/email/templates.ts | 151 +++++ laprogram/package-lock.json | 548 +++++++++++++++++- laprogram/package.json | 2 + 11 files changed, 1481 insertions(+), 74 deletions(-) delete mode 100644 laprogram/lib/email.ts create mode 100644 laprogram/lib/email/index.ts create mode 100644 laprogram/lib/email/render.tsx create mode 100644 laprogram/lib/email/ses.ts create mode 100644 laprogram/lib/email/templates.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9ffa7cc..c968b8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ npx wrangler d1 execute data --local --file scripts/testing.sql - `/login` — email-based magic link login via BetterAuth - `/settings` — user settings: avatar upload, course info display (auth required) - `/api/auth/[...all]` — BetterAuth catch-all API route - - `/api/feedback` — POST (public): submit feedback; GET (auth): retrieve feedback for current user + - `/api/feedback` — POST (public): submit feedback, and for student mid-/end-of-quarter submissions email the submitter a confirmation; GET (auth): retrieve feedback for current user - `/api/la` — GET (public): list all LAs with name, course, position, image - `/api/la/self` — GET (auth): get current user's course positions - `/api/settings/avatar` — POST (auth): upload and transform avatar image @@ -60,9 +60,19 @@ npx wrangler d1 execute data --local --file scripts/testing.sql - Uses a single Cloudflare D1 database (`data` binding) for all storage — auth tables (`user`, `session`, `account`, `verification`) and app tables (`course`, `feedback`) share one DB. - The `user` table includes BetterAuth admin fields (`role`, `banned`, `banReason`, `banExpires`) and an `impersonatedBy` field on `session`. - The `auth.ts` module calls `getCloudflareContext()` to access the D1 binding at runtime — this is async, so a singleton pattern wraps the auth instance. -- Magic links are sent via AWS SES (`lib/email.ts`). Before sending, the `user` table is checked — if no account exists for the email, a "no account found" email is sent instead of the magic link. +- Magic links are sent via AWS SES (`lib/email/`). Before sending, the `user` table is checked — if no account exists for the email, nothing is sent (the attempt is logged instead), so the login form can't be used to probe for accounts. - Pages requiring auth (`/settings`, `/feedback/view`) wrap their client component in a server component that checks the session via `getAuth()` and redirects to `/login`. The client component does not handle auth checks. +#### Email (`lib/email/`) + +All outbound mail goes through **AWS SES**, called as a signed REST request rather than through the AWS SDK — `@aws-sdk/client-sesv2` is far too large for a Worker bundle. `aws4fetch` does SigV4 signing with WebCrypto in a few KB. + +- `ses.ts` — the transport. `sendEmail()` POSTs to the SES v2 `outbound-emails` endpoint with both an HTML and a text part, so SES builds a `multipart/alternative` message. It returns a boolean instead of throwing: email is never load-bearing for a request. In `NODE_ENV=development` it logs the message to the console instead of sending, so local dev needs no AWS credentials. +- `render.tsx` — an email is authored **once** as a list of blocks (`heading`, `text`, `button`, `note`, `facts`, `bullets`, …) and `renderEmail()` renders it twice: to HTML and to plain text. Single-sourcing means the text fallback can't drift from the HTML. Mark a block `htmlOnly: true` for HTML-only affordances (e.g. a copy-paste link fallback) so it's dropped from the text version. +- HTML is built from `@react-email/components`, which carries the email-client workarounds worth having (Outlook conditional wrapper tables, the MSO padding hack on buttons, the preheader whitespace trick). Rendering uses `renderToStaticMarkup` from `react-dom/server` — **do not** import `@react-email/render`: it pulls in `html-to-text` and `js-beautify` (~130 KB gzipped) for features this app doesn't use. Components alone cost under 5 KB gzipped. +- `templates.ts` — one function per email returning blocks. Add new emails here, not in route handlers. +- Callers should send inside `ctx.waitUntil()` so a slow SES call doesn't delay the user's response. + #### Feedback form (`app/feedback/`) The feedback form is the most complex part of the frontend. It conditionally renders different sections based on role (`student`, `la`, `ta`) and feedback type. @@ -150,6 +160,10 @@ Wrangler supports all resources used in this project (Workers, D1, KV, R2, secre - `BETTER_AUTH_URL` — base URL of the app. **Must match the port you're running on:** `http://localhost:3000` for `npm run dev`, `http://localhost:8787` for `npm run preview`. Update this when switching between the two. Use the production domain for prod. - `NEXT_PUBLIC_BUCKET_URL` — public URL of the R2 bucket for avatar images. - `NEXTJS_ENV` — set in `.dev.vars` for local dev (`development`). +- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — IAM credentials for SES email sending. The IAM user only needs `ses:SendEmail`. +- `AWS_REGION` — SES region (defaults to `us-east-1`). Must be the region where the sending domain is verified. +- `SES_FROM_ADDRESS` — verified sender address (defaults to `admin@laprogramucla.com`). +- `SES_CONFIGURATION_SET` — optional SES configuration set for bounce/complaint tracking. - Copy `.env.example` to `.env` and fill in values for local development. ### Database Schema diff --git a/laprogram/.env.example b/laprogram/.env.example index d6496fc..366d8b8 100644 --- a/laprogram/.env.example +++ b/laprogram/.env.example @@ -6,8 +6,13 @@ BETTER_AUTH_SECRET= TURNSTILE_SECRET_KEY= -# secret for postmark email service -POSTMARK_SERVER_TOKEN= +# aws ses (email sending) +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=us-east-1 +SES_FROM_ADDRESS=admin@laprogramucla.com +# optional: SES configuration set for open/click/bounce tracking +SES_CONFIGURATION_SET= # airtable AIRTABLE_API_KEY= diff --git a/laprogram/app/api/cron/process-withdraws/route.ts b/laprogram/app/api/cron/process-withdraws/route.ts index 452d875..bf89731 100644 --- a/laprogram/app/api/cron/process-withdraws/route.ts +++ b/laprogram/app/api/cron/process-withdraws/route.ts @@ -194,26 +194,18 @@ export async function POST(request: Request) { } /* - if (affectedObservers.size > 0 && process.env.POSTMARK_SERVER_TOKEN) { - await fetch("https://api.postmarkapp.com/email/batchWithTemplates", { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "X-Postmark-Server-Token": process.env.POSTMARK_SERVER_TOKEN, - }, - body: JSON.stringify({ - Messages: [...affectedObservers].map(([email, withdrawnNames]) => ({ - From: "admin@laprogramucla.com", - To: email, - TemplateId: 44230508, - TemplateModel: { - name: observerNames.get(email), - la_name: withdrawnNames.join(", "), - }, - })), - }), - }); + if (affectedObservers.size > 0) { + await sendEmails( + [...affectedObservers].map(([email, withdrawnNames]) => ({ + to: email, + ...renderEmail( + observationCancelledEmail( + observerNames.get(email) ?? "there", + withdrawnNames, + ), + ), + })), + ); } */ diff --git a/laprogram/app/api/feedback/route.ts b/laprogram/app/api/feedback/route.ts index 4751acb..78011f7 100644 --- a/laprogram/app/api/feedback/route.ts +++ b/laprogram/app/api/feedback/route.ts @@ -6,6 +6,7 @@ import { Id } from "@/types/db"; import { headers } from "next/headers"; import { anonFeedbackSchema } from "@/app/feedback/view/columns"; import { sortBy } from "lodash"; +import { isQuarterFeedbackType, sendFeedbackConfirmation } from "@/lib/email"; export async function POST(request: Request) { const request_json = await request.json(); @@ -17,8 +18,8 @@ export async function POST(request: Request) { } const feedback = parsed.data; + const { env, ctx } = getCloudflareContext(); try { - const { env } = getCloudflareContext(); const recipient = await env.data ?.prepare( `SELECT user.id AS id @@ -40,6 +41,25 @@ export async function POST(request: Request) { return new Response("Encountered database error.", { status: 500 }); } + // Confirmation only goes out for the two student feedback types that carry a + // UID. Sent after the response so a slow or failing SES call can't turn a + // successful submission into an error for the student. + if ( + feedback.role === "student" && + isQuarterFeedbackType(feedback.feedback_type) + ) { + ctx.waitUntil( + sendFeedbackConfirmation({ + email: feedback.email, + name: feedback.name, + feedbackType: feedback.feedback_type, + course: feedback.course, + la: feedback.la, + uid: feedback.uid, + }), + ); + } + return new Response(null, { status: 200 }); } diff --git a/laprogram/lib/email.ts b/laprogram/lib/email.ts deleted file mode 100644 index e1acf90..0000000 --- a/laprogram/lib/email.ts +++ /dev/null @@ -1,48 +0,0 @@ -import "server-only"; - -import { getCloudflareContext } from "@opennextjs/cloudflare"; - -export async function sendMagicLink(email: string, url: string) { - const { env } = await getCloudflareContext({ async: true }); - - const name = await env.data - ?.prepare("SELECT id, name FROM user WHERE email = ?") - .bind(email) - .first("name"); - - if (!name) { - console.log( - `User with email ${email} attempted to log in; no such user found`, - ); - return; - } - - // just use console for development so we don't hit Postmark API - if (process.env.NODE_ENV === "development") { - console.log(url); - return; - } - - if (!process.env.POSTMARK_SERVER_TOKEN) { - console.log("Could not load process.env.POSTMARK_SERVER_TOKEN"); - return; - } - - await fetch("https://api.postmarkapp.com/email/withTemplate", { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "X-Postmark-Server-Token": process.env.POSTMARK_SERVER_TOKEN, - }, - body: JSON.stringify({ - From: "admin@laprogramucla.com", - To: email, - TemplateId: 44160184, - TemplateModel: { - name: name, - action_url: url, - }, - }), - }); -} diff --git a/laprogram/lib/email/index.ts b/laprogram/lib/email/index.ts new file mode 100644 index 0000000..bb17bbc --- /dev/null +++ b/laprogram/lib/email/index.ts @@ -0,0 +1,64 @@ +import "server-only"; + +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { renderEmail } from "./render"; +import { sendEmail } from "./ses"; +import { + feedbackConfirmationEmail, + magicLinkEmail, + type QuarterFeedbackType, +} from "./templates"; + +export { sendEmail, sendEmails } from "./ses"; +export { renderEmail } from "./render"; +export { + isQuarterFeedbackType, + observationCancelledEmail, + type QuarterFeedbackType, +} from "./templates"; + +export async function sendMagicLink(email: string, url: string) { + const { env } = await getCloudflareContext({ async: true }); + + const name = await env.data + ?.prepare("SELECT id, name FROM user WHERE email = ?") + .bind(email) + .first("name"); + + if (!name) { + console.log( + `User with email ${email} attempted to log in; no such user found`, + ); + return; + } + + await sendEmail({ + to: email, + ...renderEmail(magicLinkEmail(String(name), url)), + }); +} + +/** + * Confirms a mid-/end-of-quarter submission back to the student, calling out + * whether they included a UID (the only way a submission can be credited). + */ +export async function sendFeedbackConfirmation(params: { + email: string; + name: string; + feedbackType: QuarterFeedbackType; + course: string; + la: string; + uid?: string; +}) { + const { email, ...content } = params; + + await sendEmail({ + to: email, + ...renderEmail( + feedbackConfirmationEmail({ + ...content, + uid: content.uid?.trim() ? content.uid.trim() : undefined, + }), + ), + }); +} diff --git a/laprogram/lib/email/render.tsx b/laprogram/lib/email/render.tsx new file mode 100644 index 0000000..b920734 --- /dev/null +++ b/laprogram/lib/email/render.tsx @@ -0,0 +1,545 @@ +import "server-only"; + +import { + Body, + Button, + Column, + Container, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Row, + Section, + Text, +} from "@react-email/components"; +import { renderToStaticMarkup } from "react-dom/server"; + +// --------------------------------------------------------------------------- +// Email renderer +// +// An email is authored once as a list of blocks, then rendered twice: to HTML +// for `text/html` and to plain text for the `text/plain` alternative. A single +// source means the text fallback can't drift out of sync with the HTML. +// +// The HTML comes out of @react-email/components rather than hand-written +// tables — its primitives carry the client workarounds that matter (Outlook +// conditional wrapper tables around Container, the MSO letter-spacing hack in +// Button, the whitespace padding trick in Preview). Only the components are +// imported, not @react-email/render: that package pulls in html-to-text and +// js-beautify (~130 KB gzipped) for its plain-text and pretty modes, which is +// far too much for a Worker bundle. Rendering with react-dom/server directly +// costs nothing extra since Next.js already bundles it. +// --------------------------------------------------------------------------- + +/** + * `htmlOnly` marks affordances that only exist in the HTML rendering — a + * "click the button below" line, or a copy-paste fallback for a link the text + * version already prints inline. They are dropped from the text alternative so + * it doesn't read as duplicated. + */ +type Common = { htmlOnly?: boolean }; + +export type EmailBlock = Common & + ( + | { type: "heading"; text: string } + | { type: "text"; text: string; muted?: boolean } + | { type: "button"; label: string; url: string } + | { type: "url"; url: string } + | { + type: "note"; + tone: "info" | "success" | "warning"; + title?: string; + text: string; + } + | { type: "facts"; items: { label: string; value: string }[] } + | { type: "bullets"; items: string[] } + | { type: "rule" } + ); + +export type EmailContent = { + subject: string; + /** Snippet shown after the subject in most inbox previews. */ + preheader: string; + blocks: EmailBlock[]; +}; + +export type RenderedEmail = { + subject: string; + html: string; + text: string; +}; + +// --------------------------------------------------------------------------- +// Design tokens +// --------------------------------------------------------------------------- + +const FONT = + "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"; + +const C = { + page: "#f1f4f8", + card: "#ffffff", + text: "#1f2933", + muted: "#5b6875", + border: "#e2e8f0", + brand: "#2774ae", + brandDark: "#005587", +}; + +const TONES = { + info: { bg: "#eef4fb", border: "#2774ae", text: "#1c4f7c" }, + success: { bg: "#eaf7ef", border: "#2f855a", text: "#1f5c3f" }, + warning: { bg: "#fdf5e3", border: "#b7791f", text: "#7a5410" }, +} as const; + +const SITE_URL = + process.env.NEXT_PUBLIC_BETTER_AUTH_URL ?? "https://www.laprogramucla.com"; + +const FOOTER_TEXT = + "You received this email because of your involvement with the UCLA Learning Assistant Program."; + +/** + * Dark-mode and small-screen rules. Every block also carries inline + * light-mode styles, so clients that strip