diff --git a/.gitignore b/.gitignore index f8da59d..00258f1 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ dist/ .dev.vars.staging .dev.vars.production worker-configuration.d.ts +workers-email-configuration.d.ts # Test coverage coverage/ diff --git a/CONSUMING.md b/CONSUMING.md new file mode 100644 index 0000000..10d1111 --- /dev/null +++ b/CONSUMING.md @@ -0,0 +1,520 @@ +# Consuming `@ampl/kit` + +This guide covers everything a tool on `ampl.tools` needs to wire +`@ampl/kit` correctly — the four integration points that require explicit +care, and the recipe for pinning and updating the git dependency. + +--- + +## Integration requirements + +### 1. Apex login/logout targeting + +All tools on `ampl.tools` share the same authentication service at +`ampl.tools/auth`. Login and logout must always target the **apex paths** +(`/auth/login`, `/auth/logout`) — never a tool-local path. + +**Server-side redirects (loaders)** + +Use `buildLoginRedirect` and `buildLogoutHref` (from `@ampl/kit/auth`) +when building an absolute URL inside a server-side `redirect()` call. +These helpers encode `return_to` correctly and produce absolute URLs that +bypass React Router v7's basename re-prepend (which would otherwise +produce a double-prefix like `/palaeography/auth/login`). + +```typescript +import { buildLoginRedirect, buildLogoutHref } from "@ampl/kit/auth"; + +// Redirect an unauthenticated visitor to the login page +const loginUrl = buildLoginRedirect( + safeReturnTo(returnTo), // full apex path, e.g. "/palaeography/manuscript/123" + new URL(request.url).origin, +); +return redirect(loginUrl); + +// Build an absolute logout URL for a server action +const logoutUrl = buildLogoutHref( + safeReturnTo(returnTo), + new URL(request.url).origin, +); +return redirect(logoutUrl); +``` + +Both helpers share the same signature: +`(returnTo: string, origin: string, authBasename?: string) → string` + +The `authBasename` parameter defaults to `"/auth"` — only override it if +your tool uses a non-standard auth path. + +**AccountWidget (client-side form)** + +The `AccountWidget` component already uses a `
` whose +default action targets `/auth/logout`. No `signOutHref` prop is required +for standard deployments — the widget POSTs to the apex logout endpoint +automatically. Pass `signOutHref` only if you need to override the target +(e.g. in a local dev environment with a different auth origin). + +```tsx +import { AccountWidget } from "@ampl/kit/ui"; + +// Standard: default signOutHref="/auth/logout" — no prop needed + + +// Override for non-standard auth paths + +``` + +--- + +### 2. CSP avatar host + +The `AccountWidget` renders the user's GitHub avatar as a plain `` +tag. Because each tool controls its own Content Security Policy, tools +that render the account widget must explicitly allow the GitHub avatar +host in their `img-src` directive. + +Add `avatars.githubusercontent.com` to `img-src`: + +``` +Content-Security-Policy: img-src 'self' avatars.githubusercontent.com; ... +``` + +Without this, the avatar image is blocked silently and the widget renders +a broken image placeholder. + +--- + +### 3. Read-only `AUTH_DB` binding + +Each tool validates user sessions with a **read-only** binding to the +shared `ampl-auth-db` database. Bind it in your `wrangler.jsonc` as a +D1 database named `AUTH_DB`. + +**The session-validation contract is read-only by design.** The +`validateSession` helper issues a single `SELECT` per gated request and +never writes to the database. Binding the database read-only (using +Cloudflare's `prevent_writes` flag) enforces this at the infrastructure +level — the tool can validate sessions but cannot corrupt shared auth +state. + +```jsonc +// wrangler.jsonc +{ + "d1_databases": [ + { + "binding": "AUTH_DB", + "database_name": "ampl-auth-db", + "database_id": "", + "prevent_writes": true // read-only — session validation never writes + } + ] +} +``` + +Session lifetime: 30-day absolute maximum. The `validateSession` helper +returns `null` for expired or invalid tokens — gate your routes on this +return value. + +```typescript +import { redirect } from "react-router"; +import { + validateSession, + buildLoginRedirect, + safeReturnTo, + users, + sessions, +} from "@ampl/kit/auth"; +import { drizzle } from "drizzle-orm/d1"; + +export async function loader({ request, context }: Route.LoaderArgs) { + const db = drizzle(context.cloudflare.env.AUTH_DB, { + schema: { users, sessions }, + }); + const user = await validateSession(db, request); + if (!user) { + // Derive the path and origin from the request. Do not pass the full + // request.url to safeReturnTo — it only accepts root-relative paths and + // collapses an absolute URL to "/", and buildLoginRedirect needs a real + // origin to emit an absolute, basename-safe URL. + const url = new URL(request.url); + const safeTarget = safeReturnTo(url.pathname + url.search); + return redirect(buildLoginRedirect(safeTarget, url.origin)); + } + // user is AuthenticatedUser — { id, name, handle, email, avatarUrl } +} +``` + +--- + +### 4. `return_to` basename handling + +When redirecting a visitor to the login page, pass the **full apex path** +as `return_to` — do not strip the tool's basename prefix. + +``` +Correct: /palaeography/manuscript/123 +Incorrect: /manuscript/123 (basename stripped — broken after login) +``` + +Always guard `return_to` values with `safeReturnTo` before using them: + +```typescript +import { safeReturnTo, buildLoginRedirect } from "@ampl/kit/auth"; + +const returnTo = new URL(request.url).pathname; +const safeTarget = safeReturnTo(returnTo); // validates; returns "/" on invalid input +const loginUrl = buildLoginRedirect(safeTarget, origin); +``` + +`safeReturnTo` rejects values that contain `://` (absolute URLs), start +with `//` (protocol-relative), or include backslashes — returning `"/"` +as a safe fallback. Use it on every `return_to` value from a query +parameter or redirect chain before passing it to `buildLoginRedirect` or +`buildLogoutHref`. + +The `ampl-auth` service accepts `return_to` as a full apex path and +redirects there directly after login — the absolute-URL redirect avoids +the double-prefix issue (`/auth/auth/...`) that arises when a relative +`redirect()` path collides with React Router's basename prepend. + +--- + +## Git dependency — pinning and updating + +`@ampl/kit` ships as a tag-pinned git dependency from the public +`UCSB-AMPLab/ampl-kit` repository. + +### Initial setup + +Add the dependency to your `package.json`: + +```json +{ + "dependencies": { + "@ampl/kit": "github:UCSB-AMPLab/ampl-kit#v0.2.0" + } +} +``` + +Then configure Vite and your app CSS: + +**`vite.config.ts`** — add `ssr.noExternal: ["@ampl/kit"]` to tell the +SSR bundler to include `@ampl/kit` in the bundle (do not externalize it): + +```typescript +export default defineConfig({ + ssr: { + noExternal: ["@ampl/kit"], + }, + // ... rest of config +}); +``` + +**`app/app.css`** — import the design tokens and add the kit source to +Tailwind's scanner: + +```css +@import "@ampl/kit/theme.css"; +@source "../node_modules/@ampl/kit"; +``` + +**`app/root.tsx`** — spread the kit's font `` tags into your route's +`links` export: + +```typescript +import { kitFontLinks } from "@ampl/kit/ui"; + +export const links: Route.LinksFunction = () => [ + ...kitFontLinks(), + // ... your other links +]; +``` + +### Bumping the pinned version + +1. Check for a new release tag at `github.com/UCSB-AMPLab/ampl-kit`. +2. Update the `#vX.Y.Z` ref in `package.json`: + + ```json + "@ampl/kit": "github:UCSB-AMPLab/ampl-kit#v0.2.0" + ``` + +3. Run `npm install` to fetch the new ref and update `package-lock.json`. +4. Run your test suite and typecheck to confirm compatibility. +5. Commit `package.json` and `package-lock.json` together. + +There is no separate release branch to track — pin to the exact tag and +bump deliberately. + +--- + +## `@ampl/kit/email` — bilingual shell + `.ics` builder + +The `@ampl/kit/email` subpath ships two pure TypeScript functions and their +types: `renderEmailShell` (the branded HTML + plain-text email shell) and +`buildIcs` (a pure RFC 5545 `.ics` calendar attachment builder). These are +the only surfaces from `@ampl/kit/email`; the underlying modules are not +part of the public contract. + +```typescript +import { + renderEmailShell, + buildIcs, + type EmailShellInput, + type EmailBlock, + type IcsEvent, +} from "@ampl/kit/email"; +``` + +The `send()` call itself is made via the `EMAIL` service binding on each +tool's Worker environment (`env.EMAIL.send(msg)`) — the service binding is +not part of this subpath. + +--- + +### Recipe 1: Calamus invitation shape + +Use this shape for a bilingual invitation email with a CTA button and an +expiry note (no `.ics` attachment). + +```typescript +import { renderEmailShell, type EmailShellInput } from "@ampl/kit/email"; +import type { SendMessage } from "../app/email/types"; // the Worker RPC shape + +function buildInvitationMessage(locale: "en" | "es"): SendMessage { + const input: EmailShellInput = + locale === "en" + ? { + locale: "en", + preheader: "Your palaeography practice invitation from AMPL", + heading: "You're invited to Calamus", + blocks: [ + { + kind: "text", + content: + "You have been invited to join a palaeography practice group on Calamus.", + }, + { + kind: "button", + label: "Accept invitation", + url: "https://ampl.tools/palaeography/invite/", + }, + { kind: "note", content: "This invitation expires in 14 days." }, + ], + } + : { + locale: "es", + preheader: "Tu invitación a la práctica de paleografía de AMPL", + heading: "Estás invitado a Calamus", + blocks: [ + { + kind: "text", + content: + "Has sido invitado a unirte a un grupo de práctica paleográfica en Calamus.", + }, + { + kind: "button", + label: "Aceptar invitación", + url: "https://ampl.tools/palaeography/invite/", + }, + { kind: "note", content: "Esta invitación expira en 14 días." }, + ], + }; + + const { html, text } = renderEmailShell(input); + + return { + to: "user@example.com", + subject: "[Calamus] Invitation to practice group", + html, + text, + tool: "calamus", + locale, + idempotencyKey: `calamus-invite--${locale}`, + }; +} + +// In your Worker or action: +const msg = buildInvitationMessage("en"); +const result = await env.EMAIL.send(msg); +``` + +--- + +### Recipe 2: Scheduling event-email shape (with `.ics` attachment) + +Use this shape for appointment confirmation, cancellation, poll-finalisation, +and reminder emails that include a calendar attachment. + +```typescript +import { renderEmailShell, buildIcs, type EmailShellInput, type IcsEvent } from "@ampl/kit/email"; +import type { SendMessage } from "../app/email/types"; + +function buildSchedulingMessage( + subject: string, + method: "REQUEST" | "CANCEL", + event: IcsEvent, +): SendMessage { + // method = "REQUEST" for confirmation / poll-finalisation / reminder + // method = "CANCEL" for cancellation (event.sequence MUST be > original) + + const shellInput: EmailShellInput = { + locale: "en", + heading: method === "REQUEST" ? "Appointment confirmed" : "Appointment cancelled", + blocks: [ + { + kind: "text", + content: + method === "REQUEST" + ? "Your appointment has been confirmed." + : "Your appointment has been cancelled.", + }, + { + kind: "details", + rows: [ + { label: "Date", value: "June 15, 2026" }, + { label: "Time", value: "10:00 AM" }, + ], + }, + ], + }; + + const { html, text } = renderEmailShell(shellInput); + const icsContent = buildIcs(event); + + return { + to: "user@example.com", + subject: `[Scheduling] ${subject}`, + html, + text, + tool: "scheduling", + attachments: [ + { + content: icsContent, // raw .ics string — the Worker base64-encodes it + filename: "event.ics", + // RULE 1: the method= parameter MUST match IcsEvent.method — see note below + type: `text/calendar; charset=utf-8; method=${event.method}`, + }, + ], + }; +} + +// Build the event — caller supplies the uid from the booking record: +const event: IcsEvent = { + uid: "booking-123@ampl.tools", // stable per booking — SAME across REQUEST and CANCEL + sequence: 0, // 0 on first REQUEST; MUST be incremented for CANCEL + method: "REQUEST", + summary: "Lab consultation", + dtstart: new Date("2026-06-15T10:00:00Z"), + dtend: new Date("2026-06-15T11:00:00Z"), + organizer: { email: "noreply@ampl.tools", name: "AMPL" }, + attendees: [{ email: "user@example.com", name: "User" }], +}; + +const msg = buildSchedulingMessage("Appointment confirmed", "REQUEST", event); +const result = await env.EMAIL.send(msg); +``` + +--- + +### Non-obvious contract rules + +Three correctness requirements that are not enforced by types — document +them here because a mistake is silent (the email delivers, but the calendar +behaves incorrectly): + +**Rule 1 — The content-type `method=` parameter must match `IcsEvent.method`.** +The MIME content-type `text/calendar; charset=utf-8; method=REQUEST` (or +`method=CANCEL`) must exactly match the `METHOD:` property inside the `.ics` +file. RFC 6047 (iMIP) requires this consistency. Mismatching the two causes +Outlook to treat the calendar attachment as a generic file rather than a +calendar update — it will not prompt the user to add or remove the event. +Derive the parameter directly from the event: `` `method=${event.method}` ``. + +**Rule 2 — For CANCEL: increment SEQUENCE and reuse the same UID.** +When cancelling an appointment, the CANCEL `.ics` must carry the **same +`uid`** as the original REQUEST **and** a `sequence` value that is **strictly +greater** than the REQUEST's sequence. Calendar clients use SEQUENCE to +determine which send supersedes which. If the CANCEL has the same SEQUENCE +as the original REQUEST, the client sees them as identical revisions and may +leave the event on the recipient's calendar unchanged. + +Practical implication for Scheduling: store the event's `sequence` in your D1 +database at confirmation time (it starts at 0). When the appointment is +cancelled, read that stored value and pass `sequence + 1` to `buildIcs`. The +stable `uid` (e.g. `booking-@ampl.tools`) ties the two sends together. + +```typescript +// On confirmation: +const event: IcsEvent = { uid: "booking-123@ampl.tools", sequence: 0, method: "REQUEST", ... }; +// Store sequence: 0 in your DB alongside the booking record. + +// On cancellation (read sequence from DB, increment it): +const cancelEvent: IcsEvent = { + uid: "booking-123@ampl.tools", // SAME uid as the REQUEST + sequence: storedSequence + 1, // MUST be > the original sequence + method: "CANCEL", + ... // same summary, dtstart, dtend, organizer, attendees +}; +``` + +**Rule 3 — The AMPL logo: hosted HTTPS only.** +The shell renders the AMPL logo via ``. By default it uses +the kit-level constant `DEFAULT_AMPL_LOGO_URL` exported from +`@ampl/kit/email/shell`. You may override it per-send via the optional +`EmailShellInput.logoUrl` field. + +The logo URL must be a real hosted HTTPS URL — data URIs, inline SVG, and +CID (Content-ID) attachments are stripped by Gmail and Outlook and will not +render for most recipients. + +**Deployment follow-up:** the default `DEFAULT_AMPL_LOGO_URL` points to +`https://ampl.clair.ucsb.edu/assets/ampl-logo.png`. The actual PNG asset +must be uploaded and served at that URL before the logo appears in live +emails. This upload is a deployment step outside the kit itself; it is not +gating the kit surface. Until the asset is live, pass your own `logoUrl` +in `EmailShellInput` to use a URL you control. + +```typescript +// Override the logo per-send: +const input: EmailShellInput = { + locale: "en", + heading: "...", + blocks: [...], + logoUrl: "https://your-host.example.com/ampl-logo.png", +}; +``` + +--- + +### Versioning and breaking changes + +The git tag is the contract for `@ampl/kit`. Consumers pin to an exact tag: + +```json +"@ampl/kit": "github:UCSB-AMPLab/ampl-kit#v0.2.0" +``` + +**This release:** `v0.2.0` adds the `./email` subpath (`renderEmailShell`, +`buildIcs`, `EmailShellInput`, `EmailBlock`, `IcsEvent`). Consumers on +`v0.1.0` are unaffected — the `./auth` and `./ui` subpaths are unchanged. + +**Policy:** + +| Change type | Version impact | Examples | +|-------------|---------------|---------| +| New optional field on an existing type | **minor bump** | Adding `logoUrl` to `EmailShellInput` | +| New `EmailBlock` kind | **minor bump** | A future `{kind:"image"}` block | +| Rename or remove a field | **major bump** | Removing `IcsEvent.sequence`, renaming `method` | +| Make an optional field required | **major bump** | Making `logoUrl` required | +| Remove an exported function or type | **major bump** | Removing `buildIcs` | + +To upgrade, follow the recipe in "Bumping the pinned version" above: update +the `#vX.Y.Z` ref, run `npm install`, run the test suite, and commit. diff --git a/app/email/db/client.email.ts b/app/email/db/client.email.ts new file mode 100644 index 0000000..f4b6680 --- /dev/null +++ b/app/email/db/client.email.ts @@ -0,0 +1,23 @@ +/** + * Email database client factory + * + * This file hands back a Drizzle query client bound to the email Worker's D1 + * database, the `EMAIL_DB` binding. The email Worker owns this database + * read-write — it is the single source of truth for the send log and + * suppression list. No other Worker reads or writes this database directly; the + * `send()` RPC is the only external interface. Unlike `app/db/client.server.ts`, + * the `.server.ts` suffix convention does not apply here because the email + * Worker has no browser bundle. + * + * @version v0.1.0 + */ + +import { drizzle } from "drizzle-orm/d1"; +import * as schema from "./schema.email"; + +export function getEmailDb(env: Env) { + return drizzle(env.EMAIL_DB, { schema }); +} + +export type EmailDB = ReturnType; +export { schema }; diff --git a/app/email/db/schema.email.ts b/app/email/db/schema.email.ts new file mode 100644 index 0000000..48855a7 --- /dev/null +++ b/app/email/db/schema.email.ts @@ -0,0 +1,71 @@ +/** + * Drizzle schema — ampl-email Worker + * + * Defines the two tables that back the email service: `sends` (the send log, + * recording every delivery attempt with its status, tool, and optional + * idempotency key) and `suppressions` (the global suppression list, keyed by + * recipient address). Both are retained indefinitely — no prune column or + * cron job. The UNIQUE constraints on `idempotency_key` and `address` are the + * schema-level gates for deduplication and global suppression. + * + * @version v0.1.0 + */ + +import { + sqliteTable, + integer, + text, + index, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; + +/** + * `sends` — the email send log. + * + * One row per `send()` call. `tool` records the originating tool for per-tool + * analytics (no schema change needed to add per-tool quota caps later). + * `idempotency_key` is UNIQUE and nullable — when present, the UNIQUE + * constraint blocks a duplicate delivery; when absent (null), SQLite treats + * NULLs as distinct so each non-keyed call is its own row. All timestamps are + * milliseconds since epoch (integer), never text. + */ +export const sends = sqliteTable( + "sends", + { + id: integer("id").primaryKey({ autoIncrement: true }), + tool: text("tool").notNull(), // "calamus" | "scheduling" + recipient: text("recipient").notNull(), + subject: text("subject").notNull(), + status: text("status").notNull(), // "sent" | "suppressed" | "quota_exceeded" | "duplicate" + resendId: text("resend_id"), // Resend message id, null on non-delivery + idempotencyKey: text("idempotency_key"), // caller-supplied, nullable + sentAt: integer("sent_at").notNull(), + createdAt: integer("created_at").notNull(), + }, + (t) => [ + uniqueIndex("sends_idempotency_key_unique").on(t.idempotencyKey), + index("sends_recipient_idx").on(t.recipient), + index("sends_tool_idx").on(t.tool), + index("sends_sent_at_idx").on(t.sentAt), + ], +); + +/** + * `suppressions` — the global suppression list. + * + * Keyed by recipient address (UNIQUE). Any suppression — bounce, complaint, or + * explicit unsubscribe — blocks all AMPL mail to that address. The `reason` + * and `source` columns record why and how the address was suppressed, + * enabling future auditing without a schema change. Retained indefinitely. + */ +export const suppressions = sqliteTable( + "suppressions", + { + id: integer("id").primaryKey({ autoIncrement: true }), + address: text("address").notNull().unique(), + reason: text("reason").notNull(), // "bounce" | "complaint" | "unsubscribe" + source: text("source").notNull(), // "resend_webhook" | "user_request" + createdAt: integer("created_at").notNull(), + }, + (t) => [uniqueIndex("suppressions_address_unique").on(t.address)], +); diff --git a/app/email/lib/footer.ts b/app/email/lib/footer.ts new file mode 100644 index 0000000..f495ea2 --- /dev/null +++ b/app/email/lib/footer.ts @@ -0,0 +1,78 @@ +/** + * Compliance footer builder + * + * This file builds the bilingual compliance footer appended to every outbound + * email inside `send()`. The footer always contains: + * - A stable HTML comment marker (``) so tests can assert + * footer presence via substring search + * - The unsubscribe URL (signed token, built by the caller) + * + * The bilingual EN/ES copy is sourced from the `email.footer.*` i18n keys and + * inlined here so `buildFooter` has no runtime dependency on the i18next bundle + * (email is sent server-side without the browser i18n stack). + * + * The footer is stamped INSIDE `send()` — the caller cannot opt out. This + * ensures every delivered email complies with RFC 8058 and CAN-SPAM hygiene + * requirements. + * + * @version v0.2.0 + */ + +/** + * Bilingual compliance-footer copy. + * + * These strings are sourced from kit/locales/en.ts and kit/locales/es.ts under + * the `email.footer.*` keys. They are inlined here so `buildFooter` has no + * runtime dependency on the i18next bundle (email is sent server-side without + * the browser i18n stack). Any change to these strings must be made in both + * places to keep kit/locales and this copy in lockstep. + */ +const FOOTER_COPY = { + en: { + transactional: "This is an automated transactional message from AMPL.", + tagline: "Archives, Memory, and Preservation Lab · UC Santa Barbara", + unsubscribeLabel: "Unsubscribe", + }, + es: { + transactional: "Este es un mensaje transaccional automático de AMPL.", + tagline: "Archives, Memory, and Preservation Lab · UC Santa Barbara", + unsubscribeLabel: "Darte de baja", + }, +} as const; + +/** + * Build the compliance footer for a given locale and unsubscribe URL. + * + * Returns both an HTML variant (for the `html` body) and a plain-text variant + * (for the `text` body). Both contain the stable `` marker + * and the unsubscribe URL. EN/ES keys live in kit/locales under + * `email.footer.*`. + * + * @param locale - "en" or "es" (defaults to "en" in `send()` when absent) + * @param unsubUrl - the full unsubscribe URL including signed token + * @returns `{ html: string, text: string }` footer fragments + */ +export function buildFooter( + locale: "en" | "es", + unsubUrl: string, +): { html: string; text: string } { + const copy = FOOTER_COPY[locale]; + + const html = [ + "", + `
`, + `

${copy.transactional}

`, + `

${copy.tagline}

`, + `

${copy.unsubscribeLabel}

`, + `
`, + ].join("\n"); + + const text = [ + "---", + copy.transactional, + copy.tagline, + `${copy.unsubscribeLabel}: ${unsubUrl}`, + ].join("\n"); + + return { html, text }; +} diff --git a/app/email/lib/idempotency.ts b/app/email/lib/idempotency.ts new file mode 100644 index 0000000..39dea24 --- /dev/null +++ b/app/email/lib/idempotency.ts @@ -0,0 +1,65 @@ +/** + * Idempotency insert helper + * + * This file wraps the D1 insert into the `sends` table with a try/catch around + * the `UNIQUE constraint failed` error on `idempotency_key`. When the key is + * new, it inserts and returns `{ inserted: true, id }`. When the key already + * exists, it looks up the first row and returns `{ inserted: false, id }` — no + * second Resend call, no second row. + * + * SQLite (and D1) treats NULLs as distinct in UNIQUE indexes, so rows with a + * null `idempotency_key` are always inserted — each call is its own send. + * This is intentional. + * + * @version v0.1.0 + */ + +import { eq } from "drizzle-orm"; +import type { EmailDB } from "../db/client.email"; +import { schema } from "../db/client.email"; + +/** The shape of a `sends` insert row passed by the `send()` pipeline. */ +export type SendInsertRow = typeof schema.sends.$inferInsert; + +/** + * Insert a send row or deduplicate on idempotency key. + * + * @param db - Drizzle client bound to `EMAIL_DB` + * @param row - the sends row to insert (may include or omit `idempotencyKey`) + * @returns `{ inserted: true, id }` for a new send; + * `{ inserted: false, id }` for a duplicate key (existing row id returned) + */ +export async function insertSendOrDedup( + db: EmailDB, + row: SendInsertRow, +): Promise<{ inserted: boolean; id: number }> { + try { + const [result] = await db + .insert(schema.sends) + .values(row) + .returning({ id: schema.sends.id }); + return { inserted: true, id: result.id }; + } catch (err) { + // D1/SQLite UNIQUE constraint violation on idempotency_key. + // The error text varies by environment: + // Standard SQLite: "UNIQUE constraint failed: sends.idempotency_key" + // D1 miniflare: "D1_ERROR: UNIQUE constraint failed: sends.idempotency_key: SQLITE_CONSTRAINT" + // D1 production: wraps the same message in a "Failed query: …" outer message + // Check for the stable substring that appears in all forms. + const isConstraintError = + err instanceof Error && + (err.message.includes("UNIQUE constraint failed") || + (err.cause instanceof Error && + err.cause.message.includes("UNIQUE constraint failed"))); + + if (isConstraintError) { + const existing = await db + .select({ id: schema.sends.id }) + .from(schema.sends) + .where(eq(schema.sends.idempotencyKey, row.idempotencyKey!)) + .get(); + return { inserted: false, id: existing!.id }; + } + throw err; + } +} diff --git a/app/email/lib/quota.ts b/app/email/lib/quota.ts new file mode 100644 index 0000000..a1baf0d --- /dev/null +++ b/app/email/lib/quota.ts @@ -0,0 +1,94 @@ +/** + * Quota enforcement + * + * This file exposes `checkQuota(db, limits)` — a calendar-month and daily + * COUNT(*) over the `sends` table (status = "sent"). The Worker calls this + * before the idempotency insert so over-quota requests are rejected before any + * Resend interaction. Both ceilings are configurable from `wrangler.email.jsonc` + * `vars` (`MONTHLY_QUOTA_CEILING` / `DAILY_QUOTA_CEILING`) so they can be + * tightened or loosened without a code deploy. + * + * Calendar-month window: the quota window mirrors Resend's monthly reset, + * which is UTC-based. Using `Date.UTC(...)` avoids off-by-one errors around + * midnight in local timezones. + * + * Daily guard: a separate UTC day ceiling provides a second line of defence + * against burst overuse that stays within the monthly budget overall. + * + * @version v0.1.0 + */ + +import { sql, and, eq, gte } from "drizzle-orm"; +import type { EmailDB } from "../db/client.email"; +import { schema } from "../db/client.email"; +import { logError } from "../../lib/logging.server"; + +/** + * Return the start of the current calendar month in UTC milliseconds. + * Mirrors Resend's monthly reset boundary. + */ +function getMonthStartMs(): number { + const now = new Date(); + return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1); +} + +/** + * Return the start of the current UTC day in milliseconds. + */ +function getDayStartMs(): number { + const now = new Date(); + return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); +} + +/** + * Check whether the suite-wide quota allows another send. + * + * Checks monthly ceiling first (more expensive to exceed), then daily guard. + * Returns the first exceeded limit, or "ok" if both pass. + * + * @param db - Drizzle client bound to `EMAIL_DB` + * @param limits - `{ monthly, daily }` ceiling values (parse from env vars) + * @returns "ok" | "monthly_exceeded" | "daily_exceeded" + */ +export async function checkQuota( + db: EmailDB, + limits: { monthly: number; daily: number }, +): Promise<"ok" | "monthly_exceeded" | "daily_exceeded"> { + try { + const monthStart = getMonthStartMs(); + const dayStart = getDayStartMs(); + + const [monthRow] = await db + .select({ count: sql`count(*)` }) + .from(schema.sends) + .where( + and( + eq(schema.sends.status, "sent"), + gte(schema.sends.sentAt, monthStart), + ), + ); + + if ((monthRow?.count ?? 0) >= limits.monthly) { + return "monthly_exceeded"; + } + + const [dayRow] = await db + .select({ count: sql`count(*)` }) + .from(schema.sends) + .where( + and( + eq(schema.sends.status, "sent"), + gte(schema.sends.sentAt, dayStart), + ), + ); + + if ((dayRow?.count ?? 0) >= limits.daily) { + return "daily_exceeded"; + } + + return "ok"; + } catch (err) { + logError(err, { action: "email.quota" }); + throw err; + } +} diff --git a/app/email/lib/resend.ts b/app/email/lib/resend.ts new file mode 100644 index 0000000..8481459 --- /dev/null +++ b/app/email/lib/resend.ts @@ -0,0 +1,77 @@ +/** + * Resend transport + * + * This is the ONLY file in the codebase that reads `RESEND_API_KEY`. It wraps + * a single POST to the Resend REST API (`https://api.resend.com/emails`). The + * caller (the `send()` pipeline in `workers/email.ts`) passes the key in; it is + * never stored, logged, or returned to consumers. Non-2xx responses throw with + * the response body in the message so the caller can log a safe-to-surface + * detail string. + * + * `attachments` in `ResendPayload` maps `content` to a base64 string as the + * Resend REST API requires. Callers supply pre-encoded content (or empty + * attachments). + * + * @version v0.2.0 + */ + +/** + * The payload shape accepted by the Resend `/emails` endpoint. + * A subset of the full Resend API. + */ +export interface ResendPayload { + from: string; + to: string | string[]; + subject: string; + html: string; + text: string; + /** Custom headers — used for List-Unsubscribe pair (RFC 8058). */ + headers: { + "List-Unsubscribe": string; + "List-Unsubscribe-Post": string; + [key: string]: string; + }; + /** Optional attachments. */ + attachments?: Array<{ + content: string; // base64-encoded for the Resend REST API + filename: string; + content_type: string; + content_id?: string; + }>; +} + +/** + * Call the Resend REST API to send one email. + * + * This is the sole place `RESEND_API_KEY` is used. Throws on non-2xx with + * the response body in the message — callers should catch, log with + * `logError({ action: "email.send" })`, and surface a safe `detail` string. + * + * @param apiKey - `RESEND_API_KEY` from `this.env` (never stored or returned) + * @param payload - the structured email payload + * @returns Resend's `{ id }` object on success + */ +export async function callResend( + apiKey: string, + payload: ResendPayload, +): Promise<{ id: string }> { + const res = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`Resend error ${res.status}: ${body}`); + } + + const data = (await res.json()) as { id?: unknown }; + if (typeof data.id !== "string" || !data.id) { + throw new Error("Resend: missing id in response"); + } + return { id: data.id }; +} diff --git a/app/email/lib/suppression.ts b/app/email/lib/suppression.ts new file mode 100644 index 0000000..8743bca --- /dev/null +++ b/app/email/lib/suppression.ts @@ -0,0 +1,51 @@ +/** + * Suppression check + * + * This file exposes a single query: `isSuppressed(db, address)` — a SELECT + * against the `suppressions` table. It returns `true` if the address has been + * suppressed for any reason (bounce, complaint, or user unsubscribe) and + * `false` otherwise. The `send()` pipeline calls this before any Resend work, + * so a suppressed address never results in a delivery attempt. + * + * The suppression list is global — one suppressed address blocks all AMPL mail + * to that address, regardless of tool. Per-tool scoping is deferred until + * traffic data justifies it. + * + * @version v0.1.0 + */ + +import { eq } from "drizzle-orm"; +import type { EmailDB } from "../db/client.email"; +import { schema } from "../db/client.email"; + +/** + * Normalise an email address for suppression storage and lookup: trim + * surrounding whitespace and lower-case. Suppression matching is exact at the + * SQL level, so a bounce for `User@Example.com` must block a later send to + * `user@example.com`. Every write to and read from `suppressions` MUST + * pass the address through this function so stored and queried forms agree. + */ +export function normalizeEmail(address: string): string { + return address.trim().toLowerCase(); +} + +/** + * Check whether `address` is on the global suppression list. The address is + * normalised (trim + lower-case) before the lookup, matching the normalised + * form written by the webhook and unsubscribe handlers. + * + * @param db - Drizzle client bound to `EMAIL_DB` + * @param address - recipient email address (case/whitespace-insensitive) + * @returns `true` if a suppressions row exists for this address + */ +export async function isSuppressed( + db: EmailDB, + address: string, +): Promise { + const row = await db + .select({ address: schema.suppressions.address }) + .from(schema.suppressions) + .where(eq(schema.suppressions.address, normalizeEmail(address))) + .get(); + return !!row; +} diff --git a/app/email/lib/svix-verify.ts b/app/email/lib/svix-verify.ts new file mode 100644 index 0000000..5e58f60 --- /dev/null +++ b/app/email/lib/svix-verify.ts @@ -0,0 +1,115 @@ +/** + * Svix webhook signature verification (Web Crypto, no npm dependency) + * + * This file verifies the HMAC-SHA256 signature that Resend adds to every + * webhook delivery. The algorithm: signed content = + * `${svix-id}.${svix-timestamp}.${rawBody}`; key = base64-decoded portion of + * the `whsec_`-prefixed secret; expected signature = HMAC-SHA256 of the + * signed content; compare each space-delimited `v1,` token in the + * `svix-signature` header with `timingSafeEqual`. A 300-second timestamp + * window rejects replays. + * + * The `svix` npm package is deliberately NOT imported — manual Web Crypto + * produces identical output with zero bundle overhead. + * + * @version v0.1.0 + */ + +import { logError } from "../../lib/logging.server"; + +/** + * Constant-time string comparison (timing-safe). + * + * Compares two strings without short-circuiting on the first mismatch, so an + * attacker cannot infer signature length or content from response timing. + */ +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + const aBytes = new TextEncoder().encode(a); + const bBytes = new TextEncoder().encode(b); + let result = 0; + for (let i = 0; i < aBytes.length; i++) result |= aBytes[i] ^ bBytes[i]; + return result === 0; +} + +/** + * Verify a Svix webhook signature. + * + * Reads the `svix-id`, `svix-timestamp`, and `svix-signature` headers from the + * request, builds the signed content string, computes the expected HMAC-SHA256 + * using the provided secret, and does a timing-safe comparison against each + * `v1,...` signature in the header. + * + * Returns `false` (and logs nothing) for expected failures: bad signature, + * missing headers, replay outside the 300-second window. Logs and re-throws + * only for unexpected Web Crypto errors (broken runtime, wrong key format). + * + * @param rawBody - the raw request body as a string (MUST be `await req.text()` + * — never JSON.parse then re-serialize; the signature covers + * the exact bytes Resend sent) + * @param headers - the request headers object + * @param secret - `RESEND_WEBHOOK_SECRET` from env (a `whsec_...` value) + * @returns `true` if the signature is valid and the timestamp is within 300s + */ +export async function verifySvixSignature( + rawBody: string, + headers: Headers, + secret: string, +): Promise { + const id = headers.get("svix-id") ?? ""; + const timestamp = headers.get("svix-timestamp") ?? ""; + const sigHeader = headers.get("svix-signature") ?? ""; + + // Reject if any required header is missing + if (!id || !timestamp || !sigHeader) return false; + + // Reject replays outside the 5-minute window + const ts = parseInt(timestamp, 10); + if (isNaN(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false; + + try { + // Build the signed content string exactly as Resend does + const signedContent = `${id}.${timestamp}.${rawBody}`; + + // Decode the base64 key material (strip the "whsec_" prefix) + const keyBytes = Uint8Array.from( + atob(secret.replace("whsec_", "")), + (c) => c.charCodeAt(0), + ); + + const key = await crypto.subtle.importKey( + "raw", + keyBytes, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + + const sig = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(signedContent), + ); + + // Base64-encode the computed signature for comparison + const expected = btoa(String.fromCharCode(...new Uint8Array(sig))); + + // The svix-signature header may contain multiple space-delimited signatures + // (e.g. "v1,sig1 v1,sig2"). Accept if any matches. + for (const part of sigHeader.split(" ")) { + const commaIdx = part.indexOf(","); + if (commaIdx === -1) continue; + const version = part.slice(0, commaIdx); + const value = part.slice(commaIdx + 1); + if (version === "v1" && timingSafeEqual(value, expected)) return true; + } + + return false; + } catch (error) { + // Only reaches here for unexpected Web Crypto failures (wrong key format, + // runtime crypto unavailable). A bad signature never throws — it just + // returns false above. + logError(error, { action: "email.svix-verify" }); + throw error; + } +} diff --git a/app/email/lib/unsub-token.ts b/app/email/lib/unsub-token.ts new file mode 100644 index 0000000..9c90356 --- /dev/null +++ b/app/email/lib/unsub-token.ts @@ -0,0 +1,112 @@ +/** + * Unsubscribe token signing and verification + * + * This file provides HMAC-SHA256 signed tokens for the RFC 8058 one-click + * unsubscribe flow. Tokens are stateless — they embed the recipient address + * and a timestamp, signed with `UNSUB_HMAC_SECRET`. The Worker verifies the + * token on POST `/email/unsubscribe` before adding the address to the + * suppressions table. + * + * `signUnsubToken` is used inside `send()` to build the List-Unsubscribe URL; + * `verifyUnsubToken` is used by the `handleUnsubscribe` route handler. + * + * Token format: `{base64url(address:timestamp)}.{base64url(HMAC-SHA256)}` + * The separator `.` is safe in a query-string value without percent-encoding. + * + * @version v0.1.0 + */ + +const SEPARATOR = "."; + +/** Convert a Uint8Array to a base64url string (no padding). */ +function b64url(data: Uint8Array): string { + return btoa(String.fromCharCode(...data)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +/** Decode a base64url string to a Uint8Array. */ +function b64urlDecode(s: string): Uint8Array { + const base64 = s.replace(/-/g, "+").replace(/_/g, "/"); + return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); +} + +/** Import a raw HMAC-SHA256 key from a UTF-8 string secret. */ +async function importHmacKey(secret: string): Promise { + return crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); +} + +/** Constant-time string comparison (timing-safe). */ +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + const aBytes = new TextEncoder().encode(a); + const bBytes = new TextEncoder().encode(b); + let result = 0; + for (let i = 0; i < aBytes.length; i++) result |= aBytes[i] ^ bBytes[i]; + return result === 0; +} + +/** + * Sign an unsubscribe token for the given recipient address. + * + * @param address - the recipient email address to embed in the token + * @param secret - `UNSUB_HMAC_SECRET` from `this.env` + * @returns a URL-safe token string suitable for the `?token=` query parameter + */ +export async function signUnsubToken( + address: string, + secret: string, +): Promise { + const payload = b64url( + new TextEncoder().encode(`${address}:${Date.now()}`), + ); + const key = await importHmacKey(secret); + const sig = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(payload), + ); + return `${payload}${SEPARATOR}${b64url(new Uint8Array(sig))}`; +} + +/** + * Verify an unsubscribe token and return the embedded address. + * + * Returns `null` if the token is malformed or the signature does not match. + * Called inside `handleUnsubscribe` before suppressing the address. + * + * @param token - the `?token=` query value from the unsubscribe URL + * @param secret - `UNSUB_HMAC_SECRET` from `this.env` + * @returns the verified recipient address, or `null` on failure + */ +export async function verifyUnsubToken( + token: string, + secret: string, +): Promise { + const parts = token.split(SEPARATOR); + if (parts.length < 2) return null; + // payload is everything before the last separator; sig is the last part + const sig = parts[parts.length - 1]; + const payload = parts.slice(0, parts.length - 1).join(SEPARATOR); + if (!payload || !sig) return null; + + const key = await importHmacKey(secret); + const expected = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(payload), + ); + if (!timingSafeEqual(sig, b64url(new Uint8Array(expected)))) return null; + + const decoded = new TextDecoder().decode(b64urlDecode(payload)); + const colonIdx = decoded.indexOf(":"); + if (colonIdx === -1) return null; + return decoded.slice(0, colonIdx); +} diff --git a/app/email/routes/unsubscribe.ts b/app/email/routes/unsubscribe.ts new file mode 100644 index 0000000..5553fd1 --- /dev/null +++ b/app/email/routes/unsubscribe.ts @@ -0,0 +1,324 @@ +/** + * Unsubscribe handler — GET/POST /email/unsubscribe + * + * This file handles the RFC 8058 one-click unsubscribe flow: + * GET /email/unsubscribe?token=… — bilingual confirmation page + * POST /email/unsubscribe — HMAC-verified address suppression + * + * Both methods rate-limit by IP first (EMAIL_RATE_LIMITER). + * + * GET: Renders a plain bilingual HTML page (EN + ES) explaining that + * unsubscribing stops ALL AMPL transactional mail (global suppression). + * No scripts, no nonces — a static page with a POST form/button. Five + * security headers applied. + * + * POST: Reads the token from the form body (application/x-www-form-urlencoded) + * or from the query string (RFC 8058 one-click POST), calls verifyUnsubToken(), + * and on success inserts a suppressions row (reason "unsubscribe", source + * "user_request"). Uses insert-or-ignore semantics — a repeat POST for an + * already-suppressed address succeeds with 200. + * + * @version v0.1.0 + */ + +import { verifyUnsubToken } from "../lib/unsub-token"; +import { getEmailDb, schema } from "../db/client.email"; +import { normalizeEmail } from "../lib/suppression"; +import { logError } from "../../lib/logging.server"; + +// --------------------------------------------------------------------------- +// Bilingual copy +// +// These strings mirror the kit/locales `email.unsubscribe.*` keys. They are +// inlined here so the handler has no runtime dependency on the i18next bundle. +// Any change must be kept in lockstep with kit/locales. +// --------------------------------------------------------------------------- + +const COPY = { + en: { + title: "Unsubscribe from AMPL emails", + heading: "Unsubscribe", + explain: + "Confirming will remove this address from all AMPL transactional mail. This is a global action — you will no longer receive automated messages from any AMPL tool (Calamus, Scheduling, or any future tool).", + button: "Confirm unsubscribe", + confirmedHeading: "You have been unsubscribed", + confirmedBody: + "Your address has been removed from our list. You will no longer receive transactional emails from any AMPL tool.", + tagline: "Archives, Memory, and Preservation Lab · UC Santa Barbara", + }, + es: { + title: "Darte de baja de los correos de AMPL", + heading: "Darte de baja", + explain: + "Al confirmar, esta dirección quedará excluida de todos los mensajes automáticos de AMPL. La exclusión es global: cubre todas las herramientas —Calamus, Scheduling y las que vengan más adelante—, no solo la que te envió este mensaje.", + button: "Confirmar baja", + confirmedHeading: "Te has dado de baja", + confirmedBody: + "Hemos quitado tu dirección de nuestra lista. Ya no recibirás mensajes automáticos de ninguna herramienta de AMPL.", + tagline: "Archives, Memory, and Preservation Lab · UC Santa Barbara", + }, +} as const; + +// --------------------------------------------------------------------------- +// Security headers applied to every HTML response from this handler +// --------------------------------------------------------------------------- + +function applySecurityHeaders(headers: Headers): void { + headers.set("X-Frame-Options", "DENY"); + headers.set("Strict-Transport-Security", "max-age=31536000"); + headers.set("X-Content-Type-Options", "nosniff"); + headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + // CSP: no scripts, no inline styles loaded from external origins. + // No nonce needed — the page has no scripts and no dynamic styles. + headers.set( + "Content-Security-Policy", + "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + ); +} + +// --------------------------------------------------------------------------- +// HTML builders +// --------------------------------------------------------------------------- + +/** + * Build a bilingual (EN + ES) unsubscribe confirmation page. + * + * The page shows EN content followed by an ES section, so both language markers + * are always present in the HTML. + */ +function buildBilingualConfirmationPage(token: string): string { + const en = COPY.en; + const es = COPY.es; + const safeToken = token.replace(/"/g, """); + return ` + + + + + ${en.title} / ${es.title} + + + +
+

${en.heading}

+

${en.explain}

+ + + + +
+
+
+

${es.heading}

+

${es.explain}

+
+

${en.tagline}

+ +`; +} + +/** + * Build a bilingual (EN + ES) unsubscribe confirmed page (POST success state). + */ +function buildBilingualConfirmedPage(): string { + const en = COPY.en; + const es = COPY.es; + return ` + + + + + ${en.title} + + + +
+

${en.confirmedHeading}

+

${en.confirmedBody}

+
+
+
+

${es.confirmedHeading}

+

${es.confirmedBody}

+
+

${en.tagline}

+ +`; +} + +function buildConfirmationPage(token: string, locale: "en" | "es"): string { + const c = COPY[locale]; + // Escape the token for safe inline use in the form value + const safeToken = token.replace(/"/g, """); + return ` + + + + + ${c.title} + + + +

${c.heading}

+

${c.explain}

+
+ + +
+

${c.tagline}

+ +`; +} + +function buildConfirmedPage(locale: "en" | "es"): string { + const c = COPY[locale]; + return ` + + + + + ${c.title} + + + +

${c.confirmedHeading}

+

${c.confirmedBody}

+

${c.tagline}

+ +`; +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +/** + * Handle GET and POST /email/unsubscribe requests. + * + * @param request - the incoming Request + * @param env - the Worker's Env bindings + */ +export async function handleUnsubscribe( + request: Request, + env: Env, +): Promise { + // 1. Per-IP rate limit (both GET and POST) + const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"; + const limiter = await env.EMAIL_RATE_LIMITER.limit({ key: ip }); + if (!limiter.success) { + return new Response("Too Many Requests", { status: 429 }); + } + + const url = new URL(request.url); + // Detect locale from Accept-Language header; default to "en" + const acceptLang = request.headers.get("Accept-Language") ?? ""; + const locale: "en" | "es" = acceptLang.toLowerCase().startsWith("es") + ? "es" + : "en"; + + // ------------------------------------------------------------------------- + // GET — render bilingual confirmation page (always EN + ES) + // ------------------------------------------------------------------------- + if (request.method === "GET") { + const token = url.searchParams.get("token") ?? ""; + const html = buildBilingualConfirmationPage(token); + const headers = new Headers({ + "Content-Type": "text/html; charset=utf-8", + }); + applySecurityHeaders(headers); + return new Response(html, { status: 200, headers }); + } + + // ------------------------------------------------------------------------- + // POST — verify token and suppress address + // ------------------------------------------------------------------------- + if (request.method === "POST") { + try { + // Read token from form body or query string + let token = url.searchParams.get("token") ?? ""; + if (!token) { + const contentType = request.headers.get("Content-Type") ?? ""; + if (contentType.includes("application/x-www-form-urlencoded")) { + const body = await request.text(); + const params = new URLSearchParams(body); + token = params.get("token") ?? ""; + } + } + + if (!token) { + return new Response("Bad Request: missing token", { status: 400 }); + } + + // Verify the HMAC token — returns null if invalid. The secret is + // provisioned via `wrangler secret put`; not auto-typed on Env. + const { UNSUB_HMAC_SECRET } = env as unknown as { + UNSUB_HMAC_SECRET: string; + }; + const address = await verifyUnsubToken(token, UNSUB_HMAC_SECRET); + if (!address) { + return new Response("Forbidden: invalid token", { status: 403 }); + } + + // Insert suppression — insert-or-ignore for repeat POST + const db = getEmailDb(env); + try { + await db.insert(schema.suppressions).values({ + address: normalizeEmail(address), + reason: "unsubscribe", + source: "user_request", + createdAt: Date.now(), + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const causeMsg = + err instanceof Error && err.cause instanceof Error + ? err.cause.message + : ""; + const isUnique = + msg.includes("UNIQUE constraint failed") || + causeMsg.includes("UNIQUE constraint failed"); + if (!isUnique) throw err; + // Already suppressed — not an error + } + + const html = buildBilingualConfirmedPage(); + const headers = new Headers({ + "Content-Type": "text/html; charset=utf-8", + }); + applySecurityHeaders(headers); + return new Response(html, { status: 200, headers }); + } catch (error) { + logError(error, { action: "email.unsubscribe" }); + return new Response("Internal Server Error", { status: 500 }); + } + } + + // Method not allowed + return new Response("Method Not Allowed", { status: 405 }); +} diff --git a/app/email/routes/webhook.ts b/app/email/routes/webhook.ts new file mode 100644 index 0000000..18d6dd6 --- /dev/null +++ b/app/email/routes/webhook.ts @@ -0,0 +1,134 @@ +/** + * Resend webhook handler — POST /email/webhook + * + * This file handles incoming Resend bounce/complaint webhook events. For each + * event it: + * 1. Rate-limits by IP (EMAIL_RATE_LIMITER) — returns 429 if exceeded + * 2. Reads the raw request body as text (never request.json() — the Svix + * signature covers the exact original bytes) + * 3. Verifies the Svix HMAC-SHA256 signature — returns 403 BEFORE any DB write + * 4. Parses the JSON body and branches on event type + * 5. Inserts an address into suppressions with insert-or-ignore semantics + * (a repeat bounce for an already-suppressed address succeeds with 200) + * + * @version v0.1.0 + */ + +import { verifySvixSignature } from "../lib/svix-verify"; +import { getEmailDb } from "../db/client.email"; +import { schema } from "../db/client.email"; +import { normalizeEmail } from "../lib/suppression"; +import { logError } from "../../lib/logging.server"; + +/** + * Shape of Resend bounce/complaint webhook payloads. + * https://resend.com/docs/webhooks/emails/bounced + * https://resend.com/docs/webhooks/emails/complained + */ +interface ResendWebhookPayload { + type: string; + data: { + email_id?: string; + to?: string; + email?: string; + [key: string]: unknown; + }; +} + +/** + * Handle a POST /email/webhook request from Resend. + * + * @param request - the incoming Request (raw body read here) + * @param env - the Worker's Env bindings + */ +export async function handleWebhook( + request: Request, + env: Env, +): Promise { + // 1. Per-IP rate limit + const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"; + const limiter = await env.EMAIL_RATE_LIMITER.limit({ key: ip }); + if (!limiter.success) { + return new Response("Too Many Requests", { status: 429 }); + } + + try { + // 2. Read raw body — MUST be text(), never json() (the Svix signature + // covers the exact original bytes) + const rawBody = await request.text(); + + // 3. Verify Svix signature BEFORE any DB write. The secret is provisioned + // via `wrangler secret put`; not auto-typed on Env. + const { RESEND_WEBHOOK_SECRET } = env as unknown as { + RESEND_WEBHOOK_SECRET: string; + }; + const valid = await verifySvixSignature( + rawBody, + request.headers, + RESEND_WEBHOOK_SECRET, + ); + if (!valid) { + return new Response("Forbidden", { status: 403 }); + } + + // 4. Parse the event + let payload: ResendWebhookPayload; + try { + payload = JSON.parse(rawBody) as ResendWebhookPayload; + } catch { + return new Response("Bad Request", { status: 400 }); + } + + // 5. Determine suppression reason from event type + let reason: "bounce" | "complaint" | null = null; + if (payload.type === "email.bounced") { + reason = "bounce"; + } else if (payload.type === "email.complained") { + reason = "complaint"; + } + + if (!reason) { + // Unknown event type — acknowledge without suppressing + return new Response("OK", { status: 200 }); + } + + // Extract the recipient address (Resend webhook uses data.to) + const address = payload.data?.to ?? payload.data?.email ?? ""; + if (!address) { + return new Response("Bad Request: missing recipient address", { + status: 400, + }); + } + + // 6. Insert into suppressions — insert-or-ignore semantics: + // if the address is already suppressed, catch the UNIQUE violation + // and treat it as success (idempotent webhook delivery). + const db = getEmailDb(env); + try { + await db.insert(schema.suppressions).values({ + address: normalizeEmail(address), + reason, + source: "resend_webhook", + createdAt: Date.now(), + }); + } catch (err) { + // Swallow UNIQUE constraint violation — address already suppressed + const msg = + err instanceof Error ? err.message : String(err); + const causeMsg = + err instanceof Error && err.cause instanceof Error + ? err.cause.message + : ""; + const isUnique = + msg.includes("UNIQUE constraint failed") || + causeMsg.includes("UNIQUE constraint failed"); + if (!isUnique) throw err; + // Already suppressed — not an error + } + + return new Response("OK", { status: 200 }); + } catch (error) { + logError(error, { action: "email.webhook" }); + return new Response("Internal Server Error", { status: 500 }); + } +} diff --git a/app/email/types.ts b/app/email/types.ts new file mode 100644 index 0000000..0233a89 --- /dev/null +++ b/app/email/types.ts @@ -0,0 +1,94 @@ +/** + * Email service public contract + * + * This file defines the `SendMessage` and `SendResult` types that form the + * `send()` RPC surface between the `ampl-email` Worker and its consumers + * (Calamus, Scheduling, and future tools). The contract types `attachments` + * and `replyTo` up front so future consumers need zero breaking changes, even + * though the Worker does not yet implement rendering or attachment encoding + * for them. + * + * Consumers call `env.EMAIL.send(msg: SendMessage): Promise` via a + * Cloudflare service binding — the Resend API key never leaves the email Worker. + * + * @version v0.2.0 + */ + +/** + * The message shape passed to `env.EMAIL.send(msg)`. + * + * Required fields: + * - `to`, `subject`, `html`, `text` — core email content. Callers include the + * "[ToolName] " subject prefix; the Worker prepends nothing. + * - `tool` — identifies the originating tool for the send log; extend the union + * as new tools are added. + * + * Optional fields: + * - `idempotencyKey` — when present, the Worker deduplicates on this key via + * a D1 UNIQUE constraint; absent means non-idempotent (each call is a new + * send). + * - `locale` — selects the compliance footer language ("en" | "es"). + * + * Optional fields typed for the future (not yet implemented): + * - `attachments` — for `.ics` attachments (Scheduling) and similar. The Worker + * will re-encode `content` as base64 for the Resend REST API once implemented. + * Callers should pass raw binary (`ArrayBuffer`) or raw text (`string`), not + * pre-encoded base64. + * - `replyTo` — for per-tool reply-to addresses. + */ +export interface SendMessage { + to: string | string[]; + subject: string; // caller includes "[ToolName] " prefix + html: string; + text: string; + tool: "calamus" | "scheduling"; + + /** When present, deduplicates on this key. Absent = non-idempotent. */ + idempotencyKey?: string; + + /** Footer/compliance language. Defaults to "en" when absent. */ + locale?: "en" | "es"; + + /** + * Attachments — typed now so consumers need no breaking changes when + * rendering and encoding are implemented. The Worker currently ignores + * this field. Pass raw `string` (e.g. `.ics` text) or `ArrayBuffer` (binary); + * the Worker re-encodes to base64 for the Resend REST API. + * + * Not yet implemented. + */ + attachments?: Array<{ + content: string | ArrayBuffer; + filename: string; + type: string; + disposition?: "attachment" | "inline"; + contentId?: string; + }>; + + /** + * Reply-To address (per-tool reply-to). + * + * Not yet implemented. + */ + replyTo?: string; +} + +/** + * The result returned by `env.EMAIL.send(msg)`. + * + * On success: `{ ok: true, id: string }` — the Resend message ID. + * On failure: `{ ok: false, reason, detail? }` — the Worker rejected the send + * before calling Resend. Possible reasons: + * - `"suppressed"` — the recipient address is on the global suppression list. + * - `"quota_exceeded"` — the monthly or daily quota ceiling was reached. + * - `"duplicate"` — a send with this `idempotencyKey` was already delivered. + * - `"error"` — unexpected error (Resend API failure, etc.); `detail` carries + * a safe-to-log message. + */ +export type SendResult = + | { ok: true; id: string } + | { + ok: false; + reason: "suppressed" | "quota_exceeded" | "duplicate" | "error"; + detail?: string; + }; diff --git a/app/locales/en/common.json b/app/locales/en/common.json index 14c5886..e8abe35 100644 --- a/app/locales/en/common.json +++ b/app/locales/en/common.json @@ -31,5 +31,12 @@ "state-mismatch": "The sign-in session expired. Please try again.", "no-verified-email": "Your GitHub account must have a verified primary email address.", "rate-limited": "Too many requests. Please wait a moment and try again." + }, + "nav": { + "ariaLabel": "Lab site navigation", + "tools": "Tools", + "projects": "Projects", + "opportunities": "Opportunities", + "people": "People" } } diff --git a/app/locales/es/common.json b/app/locales/es/common.json index 3208286..b8e1249 100644 --- a/app/locales/es/common.json +++ b/app/locales/es/common.json @@ -31,5 +31,12 @@ "state-mismatch": "La sesión de inicio de sesión expiró. Por favor, inténtalo de nuevo.", "no-verified-email": "Tu cuenta de GitHub debe tener una dirección de correo primaria verificada.", "rate-limited": "Demasiadas solicitudes. Por favor, espera un momento antes de intentarlo de nuevo." + }, + "nav": { + "ariaLabel": "Navegación del sitio del laboratorio", + "tools": "Herramientas", + "projects": "Proyectos", + "opportunities": "Oportunidades", + "people": "Personas" } } diff --git a/app/root.tsx b/app/root.tsx index 07c0e61..bd9e8d9 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -36,6 +36,7 @@ import { securityMiddleware, nonceContext } from "~/middleware/security"; import { withBase, stripBasename } from "~/lib/paths"; import { kitFontLinks } from "@ampl/kit/fonts"; import { SiteHeader, SiteFooter, LocaleSwitcher } from "@ampl/kit/ui"; +import amplLogo from "@ampl/kit/assets/ampl-logo.svg"; import "./app.css"; export const middleware = [securityMiddleware, i18nextMiddleware]; @@ -54,6 +55,7 @@ export function Layout({ children }: { children: React.ReactNode }) { const locale = data?.locale ?? "en"; const nonce = data?.nonce ?? ""; const location = useLocation(); + const { t } = useTranslation("common"); // Build the locale-switch href: strip basename from current path so the // locale route can reconstruct it correctly, then prefix with withBase. @@ -81,11 +83,49 @@ export function Layout({ children }: { children: React.ReactNode }) { current={locale as "en" | "es"} /> } + nav={ + + } > - {/* Text lockup — no favicon asset in this repo (kit fonts only) */} - - AMPL Auth - + {/* AMPL logo — links to lab home; img is decorative (aria-label on anchor) */} + + +
{children}
diff --git a/app/routes/auth.callback.tsx b/app/routes/auth.callback.tsx index 27ebe86..4df2f64 100644 --- a/app/routes/auth.callback.tsx +++ b/app/routes/auth.callback.tsx @@ -175,7 +175,16 @@ export async function loader({ request, context }: Route.LoaderArgs) { } // ── Verified primary email gate ────────────────────────────────────────── - const primaryEmail = ghEmails!.find( + // Guard: a malformed or empty /user/emails body (non-array or zero-length) + // must not reach .find — an unguarded TypeError on {} would 500 an already- + // token-holding request. Reuse the existing no-verified-email code so the + // set of error codes stays fixed; no sixth code is introduced. The + // Array.isArray check also narrows the type so the ! non-null assertion + // below is no longer needed. + if (!Array.isArray(ghEmails) || ghEmails.length === 0) { + rejectWithError("no-verified-email", isSecure); + } + const primaryEmail = ghEmails.find( (e) => e.primary && e.verified, ); if (!primaryEmail) { diff --git a/app/routes/auth.login.tsx b/app/routes/auth.login.tsx index da21111..5a6582f 100644 --- a/app/routes/auth.login.tsx +++ b/app/routes/auth.login.tsx @@ -47,7 +47,22 @@ export default function LoginPage() { /> )} - diff --git a/drizzle-email/0000_nifty_boomerang.sql b/drizzle-email/0000_nifty_boomerang.sql new file mode 100644 index 0000000..08bd3cf --- /dev/null +++ b/drizzle-email/0000_nifty_boomerang.sql @@ -0,0 +1,25 @@ +CREATE TABLE `sends` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `tool` text NOT NULL, + `recipient` text NOT NULL, + `subject` text NOT NULL, + `status` text NOT NULL, + `resend_id` text, + `idempotency_key` text, + `sent_at` integer NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `sends_idempotency_key_unique` ON `sends` (`idempotency_key`);--> statement-breakpoint +CREATE INDEX `sends_recipient_idx` ON `sends` (`recipient`);--> statement-breakpoint +CREATE INDEX `sends_tool_idx` ON `sends` (`tool`);--> statement-breakpoint +CREATE INDEX `sends_sent_at_idx` ON `sends` (`sent_at`);--> statement-breakpoint +CREATE TABLE `suppressions` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `address` text NOT NULL, + `reason` text NOT NULL, + `source` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `suppressions_address_unique` ON `suppressions` (`address`); \ No newline at end of file diff --git a/drizzle-email/meta/0000_snapshot.json b/drizzle-email/meta/0000_snapshot.json new file mode 100644 index 0000000..e439c1b --- /dev/null +++ b/drizzle-email/meta/0000_snapshot.json @@ -0,0 +1,173 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2571adaf-da9a-4689-922c-19b70e443f04", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "sends": { + "name": "sends", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resend_id": { + "name": "resend_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sends_idempotency_key_unique": { + "name": "sends_idempotency_key_unique", + "columns": [ + "idempotency_key" + ], + "isUnique": true + }, + "sends_recipient_idx": { + "name": "sends_recipient_idx", + "columns": [ + "recipient" + ], + "isUnique": false + }, + "sends_tool_idx": { + "name": "sends_tool_idx", + "columns": [ + "tool" + ], + "isUnique": false + }, + "sends_sent_at_idx": { + "name": "sends_sent_at_idx", + "columns": [ + "sent_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suppressions": { + "name": "suppressions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "suppressions_address_unique": { + "name": "suppressions_address_unique", + "columns": [ + "address" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle-email/meta/_journal.json b/drizzle-email/meta/_journal.json new file mode 100644 index 0000000..5c85fbe --- /dev/null +++ b/drizzle-email/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1779924666243, + "tag": "0000_nifty_boomerang", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/drizzle.email.config.ts b/drizzle.email.config.ts new file mode 100644 index 0000000..530ef4a --- /dev/null +++ b/drizzle.email.config.ts @@ -0,0 +1,20 @@ +/** + * Drizzle Kit configuration (ampl-email) + * + * This file configures Drizzle Kit — the command-line tool that turns the + * TypeScript table definitions in `app/email/db/schema.email.ts` into SQL + * migration files. It targets SQLite (the dialect Cloudflare D1 speaks) and + * writes the generated migrations into the `drizzle-email/` directory, where + * Wrangler later applies them to the `ampl-email-db` database. This config is + * kept separate from `drizzle.config.ts` (auth schema) to avoid accidental + * merging. + * + * @version v0.1.0 + */ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + out: "./drizzle-email", + schema: "./app/email/db/schema.email.ts", + dialect: "sqlite", +}); diff --git a/kit/assets/ampl-logo.svg b/kit/assets/ampl-logo.svg new file mode 100644 index 0000000..47dc7a5 --- /dev/null +++ b/kit/assets/ampl-logo.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/kit/auth/index.ts b/kit/auth/index.ts index a973660..bfb7eb0 100644 --- a/kit/auth/index.ts +++ b/kit/auth/index.ts @@ -10,11 +10,12 @@ * and trust that calling it cannot change session state. The work of refreshing * or "rolling" a session is a write that lives elsewhere, with the app that * holds a write-capable connection. Alongside the validator, the module exposes - * two small URL helpers — one that sanitises a `return_to` value to block - * open-redirect attacks, and one that builds an absolute login-redirect URL — - * plus a re-export of the database contract types so a consumer gets everything - * from a single import. The cookie-reading helpers stay private because - * consumers only ever need to validate cookies, never construct them. + * three small URL helpers — one that sanitises a `return_to` value to block + * open-redirect attacks, one that builds an absolute login-redirect URL, and + * one that builds an absolute logout URL — plus a re-export of the database + * contract types so a consumer gets everything from a single import. The + * cookie-reading helpers stay private because consumers only ever need to + * validate cookies, never construct them. * * @version v0.1.0 */ @@ -97,33 +98,29 @@ export async function validateSession( db: DrizzleD1Database, request: Request, ): Promise { - try { - const isSecure = isSecureRequest(request); - const cookieName = getCookieName(isSecure); - const rawCookieValue = getCookieValue(request.headers.get("cookie"), cookieName); - if (!rawCookieValue || !isValidRawCookieValue(rawCookieValue)) { - return null; - } - const sessionId = hashCookieValue(rawCookieValue); - const row = await db - .select({ session: sessions, user: users }) - .from(sessions) - .innerJoin(users, eq(sessions.userId, users.id)) - .where(eq(sessions.id, sessionId)) - .get(); - if (!row) return null; - if (row.session.expiresAt <= Date.now()) return null; - return { - id: row.user.id, - githubId: row.user.githubId, - email: row.user.email, - handle: row.user.handle, - name: row.user.name, - avatarUrl: row.user.avatarUrl, - }; - } catch (error) { - throw error; + const isSecure = isSecureRequest(request); + const cookieName = getCookieName(isSecure); + const rawCookieValue = getCookieValue(request.headers.get("cookie"), cookieName); + if (!rawCookieValue || !isValidRawCookieValue(rawCookieValue)) { + return null; } + const sessionId = hashCookieValue(rawCookieValue); + const row = await db + .select({ session: sessions, user: users }) + .from(sessions) + .innerJoin(users, eq(sessions.userId, users.id)) + .where(eq(sessions.id, sessionId)) + .get(); + if (!row) return null; + if (row.session.expiresAt <= Date.now()) return null; + return { + id: row.user.id, + githubId: row.user.githubId, + email: row.user.email, + handle: row.user.handle, + name: row.user.name, + avatarUrl: row.user.avatarUrl, + }; } /** @@ -176,3 +173,29 @@ export function buildLoginRedirect( origin, ).toString(); } + +/** + * Builds an absolute logout URL that bypasses React Router v7's basename + * prepend. Consumer tools call this to redirect users to the apex logout + * endpoint, optionally with a `return_to` path so the user lands back on the + * right page after the session is cleared. + * + * Mirrors `buildLoginRedirect` exactly — same argument order, same default + * basename, same absolute URL output via `new URL(...)`. + * + * Example: + * buildLogoutHref("/palaeography", "https://ampl.tools") + * // → "https://ampl.tools/auth/logout?return_to=%2Fpalaeography" + * + * The `returnTo` value should already be validated by `safeReturnTo`. + */ +export function buildLogoutHref( + returnTo: string, + origin: string, + authBasename: string = "/auth", +): string { + return new URL( + authBasename + "/logout?return_to=" + encodeURIComponent(returnTo), + origin, + ).toString(); +} diff --git a/kit/email/ics.ts b/kit/email/ics.ts new file mode 100644 index 0000000..f58fb5f --- /dev/null +++ b/kit/email/ics.ts @@ -0,0 +1,245 @@ +/** + * RFC 5545 iCalendar (.ics) builder + * + * This module exports a single pure function `buildIcs(event: IcsEvent): string` + * that turns a typed `IcsEvent` into a conformant RFC 5545 VCALENDAR string + * suitable for use as a calendar attachment. + * + * Invariants: + * - `buildIcs` is a PURE function — no Worker, no network, no I/O. + * - All line endings are CRLF (\r\n) per RFC 5545 §3.1. + * - TEXT values (SUMMARY, DESCRIPTION, LOCATION) are RFC 5545–escaped: the + * backslash is escaped first, then comma, then semicolon, then newlines. + * - Lines are folded at 75 octets using a TextEncoder-aware split that never + * splits inside a UTF-8 multi-byte sequence (RFC 5545 §3.1). + * - DTSTART/DTEND are serialized as UTC (…Z form, no VTIMEZONE block). + * - METHOD:REQUEST → STATUS:CONFIRMED; METHOD:CANCEL → STATUS:CANCELLED. + * - No RRULE / recurrence; single events only. + * + * Named exports only. No default export. + * + * @version v0.2.0 + */ + +import type { IcsEvent } from "./types"; + +// ───────────────────────────────────────────────────────────────────────────── +// Private helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Escape a TEXT-type property value per RFC 5545 §3.3.11. + * + * The order matters: backslash must be escaped first to avoid double-escaping + * the backslashes that are introduced by the subsequent replacements. + * + * \ → \\ + * , → \, + * ; → \; + * \r?\n → \n (literal backslash + n, per RFC 5545; the physical line break + * is produced by foldLine, not by a raw newline in the value) + */ +function escapeText(value: string): string { + return value + .replace(/\\/g, "\\\\") // \ → \\ (MUST be first) + .replace(/,/g, "\\,") + .replace(/;/g, "\\;") + .replace(/\r?\n/g, "\\n"); +} + +/** + * Strip CR and LF characters from a raw field value that is not a TEXT + * property — e.g. UID, URL, and email addresses. + * + * RFC 5545 content lines are delimited by CRLF; an unescaped newline in any + * non-TEXT property terminates the current property line and injects new + * calendar content. Stripping CR/LF removes that injection vector. + */ +function stripNewlines(value: string): string { + return value.replace(/[\r\n]/g, ""); +} + +/** + * Wrap a CAL-ADDRESS parameter value (CN=) per RFC 5545 §3.2. + * + * Parameter values that contain `:`, `;`, `,`, or `"` MUST be DQUOTE-wrapped. + * Any embedded DQUOTEs are stripped (they would break the quoted-string syntax). + * CR/LF are stripped unconditionally to prevent property-line injection. + */ +function escapeParam(value: string): string { + const cleaned = value.replace(/[\r\n]/g, ""); + // Always DQUOTE-wrap CN values: simpler and always safe per RFC 5545 §3.2 + return `"${cleaned.replace(/"/g, "")}"`; +} + +/** + * Fold a content line at 75 octets, appending CRLF at the end of each chunk. + * + * RFC 5545 §3.1: "Lines of text SHOULD NOT be longer than 75 octets, excluding + * the line break. Long content lines SHOULD be split into a multiple line + * representations using a line 'folding' technique. That is, a long line can be + * split between any two characters by inserting a CRLF immediately followed by a + * single linear white-space character." + * + * The first line may be up to 75 bytes. Each continuation line has 1 byte taken + * by the leading space, so continuations carry at most 74 bytes of content. + * + * Multi-byte UTF-8 sequences are never split: the algorithm backs up past any + * continuation bytes ((byte & 0xc0) === 0x80) before marking the split point. + */ +function foldLine(line: string): string { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const bytes = encoder.encode(line); + + if (bytes.byteLength <= 75) { + return line + "\r\n"; + } + + const chunks: string[] = []; + let offset = 0; + let first = true; + + while (offset < bytes.byteLength) { + const limit = first ? 75 : 74; + let end = Math.min(offset + limit, bytes.byteLength); + + // Back up to avoid splitting inside a multi-byte UTF-8 sequence + while (end < bytes.byteLength && (bytes[end] & 0xc0) === 0x80) { + end--; + } + + chunks.push((first ? "" : " ") + decoder.decode(bytes.slice(offset, end))); + offset = end; + first = false; + } + + return chunks.join("\r\n") + "\r\n"; +} + +/** + * Serialize a Date to the RFC 5545 UTC date-time form: YYYYMMDDTHHmmssZ. + * + * Example: new Date("2026-06-15T10:00:00Z") → "20260615T100000Z" + */ +function formatDateTime(date: Date): string { + return date + .toISOString() + .replace(/[-:]/g, "") // "2026-06-15T10:00:00.000Z" → "20260615T100000.000Z" + .replace(/\.\d{3}/, ""); // remove milliseconds → "20260615T100000Z" +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public export +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Build a conformant RFC 5545 VCALENDAR string from the given `IcsEvent`. + * + * The returned string is ready to be base64-encoded and attached to an email + * with content-type `text/calendar; charset=utf-8; method=`. + * + * The function is pure: it reads only the supplied `event` object and + * `event.dtstamp ?? new Date()` for the generation timestamp. No I/O, no + * network, no Worker dependencies. + * + * @param event - The iCalendar event descriptor (see IcsEvent in types.ts) + * @returns A complete VCALENDAR string with CRLF line endings + */ +export function buildIcs(event: IcsEvent): string { + // ── Input validation ────────────────────────────────────────────────────── + + // Reject invalid Date objects before toISOString() can throw a cryptic + // RangeError — emit a clear contract-level error message instead. + if (Number.isNaN(event.dtstart.getTime())) { + throw new Error("buildIcs: invalid date — dtstart is not a valid Date"); + } + if (Number.isNaN(event.dtend.getTime())) { + throw new Error("buildIcs: invalid date — dtend is not a valid Date"); + } + if (event.dtstamp !== undefined && Number.isNaN(event.dtstamp.getTime())) { + throw new Error("buildIcs: invalid date — dtstamp is not a valid Date"); + } + + // At least one attendee is required for METHOD:REQUEST and METHOD:CANCEL + // (iTIP semantics; emitting zero ATTENDEE lines produces a malformed VEVENT). + if (event.attendees.length === 0) { + throw new Error("buildIcs: at least one attendee required"); + } + + // dtend must not precede dtstart — calendar clients silently misbehave + // when an event has negative duration. + if (event.dtend.getTime() < event.dtstart.getTime()) { + throw new Error("buildIcs: dtend must be >= dtstart"); + } + + // sequence must be a non-negative integer; floats and NaN corrupt iTIP + // ordering and render as literal "NaN" or "0.5" in the SEQUENCE property. + if (!Number.isInteger(event.sequence) || event.sequence < 0) { + throw new Error("buildIcs: sequence must be a non-negative integer"); + } + + const dtstamp = event.dtstamp ?? new Date(); + const status = event.method === "CANCEL" ? "CANCELLED" : "CONFIRMED"; + + // Helper: fold a "PROPERTY:value" pair and accumulate onto the lines array + const line = (prop: string): string => foldLine(prop); + + // Build VCALENDAR header lines + const vcalHeader: string[] = [ + line("BEGIN:VCALENDAR"), + line("VERSION:2.0"), + line("PRODID:-//AMPL//ampl.tools//EN"), + line("CALSCALE:GREGORIAN"), + line(`METHOD:${event.method}`), + ]; + + // Build VEVENT lines + const vevent: string[] = [line("BEGIN:VEVENT")]; + + vevent.push(line(`UID:${stripNewlines(event.uid)}`)); + vevent.push(line(`SEQUENCE:${event.sequence}`)); + vevent.push(line(`STATUS:${status}`)); + vevent.push(line(`DTSTAMP:${formatDateTime(dtstamp)}`)); + vevent.push(line(`DTSTART:${formatDateTime(event.dtstart)}`)); + vevent.push(line(`DTEND:${formatDateTime(event.dtend)}`)); + vevent.push(line(`SUMMARY:${escapeText(event.summary)}`)); + + if (event.description !== undefined) { + vevent.push(line(`DESCRIPTION:${escapeText(event.description)}`)); + } + + if (event.location !== undefined) { + vevent.push(line(`LOCATION:${escapeText(event.location)}`)); + } + + // ORGANIZER in CAL-ADDRESS form (RFC 5546 §3.2.2). + // CN parameter values are DQUOTE-wrapped per RFC 5545 §3.2 (escapeParam), + // and email addresses have CR/LF stripped to prevent property-line injection. + if (event.organizer.name !== undefined && event.organizer.name !== "") { + vevent.push(line(`ORGANIZER;CN=${escapeParam(event.organizer.name)}:mailto:${stripNewlines(event.organizer.email)}`)); + } else { + vevent.push(line(`ORGANIZER:mailto:${stripNewlines(event.organizer.email)}`)); + } + + // ATTENDEE lines in CAL-ADDRESS form + for (const attendee of event.attendees) { + if (attendee.name !== undefined && attendee.name !== "") { + vevent.push(line(`ATTENDEE;CN=${escapeParam(attendee.name)}:mailto:${stripNewlines(attendee.email)}`)); + } else { + vevent.push(line(`ATTENDEE:mailto:${stripNewlines(attendee.email)}`)); + } + } + + // Optional URL — strip CR/LF to prevent property-line injection + if (event.url !== undefined) { + vevent.push(line(`URL:${stripNewlines(event.url)}`)); + } + + vevent.push(line("END:VEVENT")); + + // Close VCALENDAR + const vcalFooter: string[] = [line("END:VCALENDAR")]; + + return [...vcalHeader, ...vevent, ...vcalFooter].join(""); +} diff --git a/kit/email/index.ts b/kit/email/index.ts new file mode 100644 index 0000000..1d128ed --- /dev/null +++ b/kit/email/index.ts @@ -0,0 +1,19 @@ +/** + * @ampl/kit/email — bilingual email shell + RFC 5545 .ics builder + * + * This barrel re-exports the three public surfaces of the `@ampl/kit/email` + * subpath: the branded email shell renderer, the pure iCalendar builder, and + * the contract types. Consumers import from `@ampl/kit/email`; the underlying + * modules (`./shell`, `./ics`, `./types`) are not part of the public contract + * — the barrel is the single stable entry point. + * + * Named exports only. No default export. The contract is pinned to the git tag + * (one version number for the whole subpath); see CONSUMING.md for the + * breaking-change policy and copy-paste integration recipes. + * + * @version v0.2.0 + */ + +export { renderEmailShell } from "./shell"; +export { buildIcs } from "./ics"; +export type { EmailShellInput, EmailBlock, IcsEvent } from "./types"; diff --git a/kit/email/shell.ts b/kit/email/shell.ts new file mode 100644 index 0000000..989c426 --- /dev/null +++ b/kit/email/shell.ts @@ -0,0 +1,270 @@ +/** + * renderEmailShell — generic bilingual branded email shell + * + * Maps `EmailShellInput` (a structured `EmailBlock[]`) to a cross-client + * HTML fragment plus a plain-text counterpart. Both bodies are derived from + * the same structured input in one call so they cannot drift out of sync. + * + * Invariants: + * - The shell renders branded CONTENT only. It never emits the compliance + * footer or an unsubscribe link. The Worker's `deliver()` function + * stamps the footer after this fragment. + * - The output is an HTML FRAGMENT — no DOCTYPE, no ``, no ``, + * no ``. The Worker concatenates this fragment + * with `"\n" + footer.html`. + * - The `EmailBlock` union is closed; no raw-HTML escape hatch. Every + * consumer-supplied string is HTML-escaped before interpolation — the closed + * union prevents kind-level injection, and escaping prevents value-level + * injection inside those blocks. + * - `DEFAULT_AMPL_LOGO_URL` is the only host coupling in kit. Consumers and + * tests may override it via `input.logoUrl`. The actual upload of the logo + * asset to this URL is a deployment follow-up documented in CONSUMING.md. + * + * @version v0.2.0 + */ + +import type { EmailShellInput, EmailBlock } from "./types"; + +// ───────────────────────────────────────────────────────────────────────────── +// HTML escaping — applied to every consumer-supplied string before interpolation +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Escape a string for safe interpolation into an HTML context. + * + * Covers the full set of characters that are significant in both element-content + * and attribute-value contexts (`&`, `<`, `>`, `"`, `'`). Applied to every + * consumer-supplied value before it is written into the HTML output — headings, + * preheaders, block content, button labels, button URLs, details rows, and the + * logo src — so that no consumer input, however it was derived, can inject + * markup, attributes, or script into the delivered email. + */ +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Validate and return a safe URL for use in an `href` or `src` attribute. + * + * Accepts `https:` and `mailto:` schemes only. Any other scheme (including + * `javascript:`, `data:`, `vbscript:`, etc.) is replaced with an empty string + * so the generated attribute is inert rather than exploitable. The validated + * value is then HTML-escaped for attribute context. + */ +function safeUrl(url: string): string { + const trimmed = url.trim(); + if (/^https:/i.test(trimmed) || /^mailto:/i.test(trimmed)) { + return escapeHtml(trimmed); + } + return ""; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Logo constant — the only host coupling in kit (overridable via input.logoUrl) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Default hosted HTTPS URL for the AMPL logo image. + * + * This is the only host-specific constant in kit. Consumers and tests may + * supply a different URL via `EmailShellInput.logoUrl`; when that field is + * omitted the shell falls back to this constant. The actual PNG asset must + * be uploaded to this URL as a deployment follow-up (not gating this surface). + * + * Using a hosted URL (not data-URI, inline SVG, or CID attachment) is + * required for cross-client rendering: Gmail blocks CID and strips inline + * SVG; Outlook requires an HTTP/S src for ``. + */ +export const DEFAULT_AMPL_LOGO_URL = + "https://ampl.clair.ucsb.edu/assets/ampl-logo.png"; + +// ───────────────────────────────────────────────────────────────────────────── +// Block renderers — HTML +// ───────────────────────────────────────────────────────────────────────────── + +function renderBlockHtml(block: EmailBlock): string { + switch (block.kind) { + case "text": + return [ + ``, + ` `, + ` ${escapeHtml(block.content)}`, + ` `, + ``, + ].join("\n"); + + case "button": { + const safeHref = safeUrl(block.url); + const safeLabel = escapeHtml(block.label); + return [ + ``, + ` `, + ` `, + ` `, + ` ${safeLabel}`, + ` `, + ` `, + ``, + ].join("\n"); + } + + case "details": { + const rows = block.rows + .map( + (row) => + [ + ` `, + ` ${escapeHtml(row.label)}`, + ` ${escapeHtml(row.value)}`, + ` `, + ].join("\n"), + ) + .join("\n"); + return [ + ``, + ` `, + ` `, + rows, + `
`, + ` `, + ``, + ].join("\n"); + } + + case "note": + return [ + ``, + ` `, + ` ${escapeHtml(block.content)}`, + ` `, + ``, + ].join("\n"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Block renderers — plain text +// ───────────────────────────────────────────────────────────────────────────── + +function renderBlockText(block: EmailBlock): string { + switch (block.kind) { + case "text": + return `${block.content}\n`; + + case "button": + return `${block.label}: ${block.url}\n`; + + case "details": + return block.rows.map((row) => `${row.label}: ${row.value}`).join("\n") + "\n"; + + case "note": + return `(${block.content})\n`; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// renderEmailShell — the only public export +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Render a bilingual branded email shell from structured `EmailBlock[]`. + * + * Returns both `html` (a table-based inline-styled HTML fragment) and `text` + * (a readable plain-text equivalent) derived from the same input in one call + * so they cannot drift out of sync. + * + * The `html` output is a FRAGMENT — it starts with a `` and ends + * with `
`. It contains no DOCTYPE, no ``, no ``, and no + * ``. The Worker's `deliver()` appends the compliance footer after this + * fragment. + * + * @param input - Shell input including locale, optional preheader, heading, + * content blocks, and an optional logo URL override. + * @returns `{ html: string, text: string }` — both bodies from one call. + */ +export function renderEmailShell(input: EmailShellInput): { + html: string; + text: string; +} { + const { locale, preheader, heading, blocks, logoUrl } = input; + const resolvedLogoUrl = logoUrl ?? DEFAULT_AMPL_LOGO_URL; + + // ── HTML fragment ────────────────────────────────────────────────────────── + + // Preheader is a hidden div; emitted only when `input.preheader` is set. + const preheaderHtml = preheader + ? `
${escapeHtml(preheader)}
\n` + : ""; + + const blockRows = blocks.map(renderBlockHtml).join("\n"); + + const html = [ + // Outer full-width background table — the fragment root + ``, + ``, + ` `, + ``, + `
`, + preheaderHtml, + // Inner content table — centered, max 600px wide + ` `, + // Header row — AMPL logo + ` `, + ` `, + ` `, + // Heading row + ` `, + ` `, + ` `, + // Content blocks + ` `, + ` `, + ` `, + // End content table — no trailing bottom border/margin (the footer adds its own border-top) + `
`, + ` AMPL`, + `
`, + ` ${escapeHtml(heading)}`, + `
`, + ` `, + blockRows, + `
`, + `
`, + `
`, + ] + .filter((line) => line !== "") + .join("\n"); + + // ── Plain-text counterpart ───────────────────────────────────────────────── + // + // Each `renderBlockText` call returns a string that ends with exactly one `\n`. + // We join with `\n` between blocks so each block is separated by one blank line + // (the trailing `\n` from the preceding block + the joining `\n` = one blank + // line). `preheader` is omitted from the text path — it is inbox-preview text + // only, meaningful in HTML clients but not in plain-text readers. + + const blockLines = blocks.map(renderBlockText).join("\n"); + + const text = [heading, "", blockLines].join("\n"); + + return { html, text }; +} diff --git a/kit/email/types.ts b/kit/email/types.ts new file mode 100644 index 0000000..861a4ae --- /dev/null +++ b/kit/email/types.ts @@ -0,0 +1,134 @@ +/** + * kit/email public contract types + * + * This file defines the client-surface types that form the `@ampl/kit/email` + * contract: `EmailShellInput`, `EmailBlock`, and `IcsEvent`. Every tool that + * renders an email shell or builds a calendar attachment imports from here. + * `SendMessage` (the Worker-side RPC shape) stays in `app/email/types.ts`; this + * file holds only the shapes that kit consumers need — the block DSL and the + * iCalendar event model. + * + * Named exports only. No default export. No runtime values — type declarations + * are compile-time only. + * + * @version v0.2.0 + */ + +// ───────────────────────────────────────────────────────────────────────────── +// EmailBlock — closed discriminated union +// ───────────────────────────────────────────────────────────────────────────── + +/** + * A single content block within an email shell. + * + * The union is deliberately closed — there is no `{kind:"html"}` escape hatch. + * Raw-HTML blocks are not supported; all consumer-supplied string values are + * HTML-escaped by `renderEmailShell` before interpolation, so this surface is + * safe for user-controlled data. Consumers compose their message from exactly + * these four block types. + * + * Members: + * - `text` — a paragraph of plain prose. The `content` string is + * HTML-escaped; no inline markup is rendered. + * - `button` — a CTA button (label + URL). The `url` must use an `https:` + * or `mailto:` scheme; other schemes are neutralized. Rendered + * as a bulletproof table-cell button in HTML; as a plain + * "label: url" line in text. + * - `details` — a key/value grid (e.g. date, time, location). Rendered as a + * table in HTML; as "Label: value" lines in text. + * - `note` — a muted helper text block (e.g. "This link expires in 14 days"). + * Visually distinguished from `text` blocks. + */ +export type EmailBlock = + | { kind: "text"; content: string } + | { kind: "button"; label: string; url: string } + | { kind: "details"; rows: { label: string; value: string }[] } + | { kind: "note"; content: string }; + +// ───────────────────────────────────────────────────────────────────────────── +// EmailShellInput — the branded shell input +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Input to `renderEmailShell()`. + * + * The shell owns the AMPL logo header, table-based layout, inline styles, and + * typography. Consumers supply the locale, an optional preheader, a heading, + * and an ordered list of content blocks. The compliance footer is NOT part of + * this input — it is stamped by the Worker's `deliver()` function after the + * shell is rendered. + * + * Fields: + * - `locale` — "en" or "es"; selects chrome strings (if any) in the shell. + * - `preheader` — optional inbox-preview text (hidden `
` at top of html). + * When omitted the shell renders no preheader element. + * - `heading` — visible heading rendered above the block content. + * - `blocks` — ordered content blocks (see `EmailBlock`). + * - `logoUrl` — optional override for the AMPL logo ``. When + * present the shell uses this URL as the logo source. When + * omitted the shell falls back to the module-level + * `DEFAULT_AMPL_LOGO_URL` constant defined in `shell.ts`. + * This is the only host coupling in kit, kept overridable by + * consumers and tests. + */ +export interface EmailShellInput { + locale: "en" | "es"; + preheader?: string; + heading: string; + blocks: EmailBlock[]; + /** Optional override for the AMPL logo source URL. Omit to use the default. */ + logoUrl?: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// IcsEvent — iCalendar event model +// ───────────────────────────────────────────────────────────────────────────── + +/** + * A single iCalendar event passed to `buildIcs()`. + * + * Represents one VEVENT inside a VCALENDAR wrapper. `buildIcs` is pure — no + * Worker, no network, no I/O. All date/time values are serialized to UTC form + * (`YYYYMMDDTHHmmssZ`); no VTIMEZONE blocks are emitted. + * + * Fields: + * - `uid` — stable identifier for this logical booking. Must be the + * same across a REQUEST and its subsequent CANCEL for + * calendar clients to correlate the two events and remove + * the cancelled entry rather than adding a duplicate. + * Recommended form: `@ampl.tools`. + * - `sequence` — integer that MUST be 0 on the initial REQUEST and + * strictly incremented (typically to 1) for each CANCEL or + * update. Calendar clients use SEQUENCE to resolve the + * ordering of multiple sends for the same UID. + * - `method` — iTIP method: `"REQUEST"` for confirmation / reminder / + * poll-finalisation (STATUS:CONFIRMED); `"CANCEL"` for + * cancellations (STATUS:CANCELLED). + * - `summary` — event title; TEXT-escaped per RFC 5545 §3.3.11. + * - `description` — optional long description; TEXT-escaped. + * - `location` — optional location string; TEXT-escaped. + * - `dtstart` — event start (UTC `Date`). + * - `dtend` — event end (UTC `Date`). + * - `dtstamp` — optional creation timestamp; defaults to `new Date()` in + * `buildIcs` when omitted. + * - `organizer` — event organizer. `name` is optional; `email` is required. + * - `attendees` — list of attendees (at least one expected). Same shape as + * `organizer`. + * - `url` — optional booking URL; emitted as a `URL:` property. + * + * No RRULE / recurrence. Single events only. + */ +export interface IcsEvent { + uid: string; + sequence: number; + method: "REQUEST" | "CANCEL"; + summary: string; + description?: string; + location?: string; + dtstart: Date; + dtend: Date; + dtstamp?: Date; + organizer: { name?: string; email: string }; + attendees: { name?: string; email: string }[]; + url?: string; +} diff --git a/kit/locales/en.ts b/kit/locales/en.ts index d55a0b5..cecc570 100644 --- a/kit/locales/en.ts +++ b/kit/locales/en.ts @@ -14,6 +14,23 @@ * @version v0.1.0 */ export default { + email: { + footer: { + transactional: "This is an automated transactional message from AMPL.", + tagline: "Archives, Memory, and Preservation Lab · UC Santa Barbara", + unsubscribeLabel: "Unsubscribe", + }, + unsubscribe: { + pageTitle: "Unsubscribe from AMPL emails", + heading: "Unsubscribe", + explain: + "Confirming will remove this address from all AMPL transactional mail. This is a global action — you will no longer receive automated messages from any AMPL tool (Calamus, Scheduling, or any future tool).", + button: "Confirm unsubscribe", + confirmedHeading: "You have been unsubscribed", + confirmedBody: + "Your address has been removed from our list. You will no longer receive transactional emails from any AMPL tool.", + }, + }, footer: { terms: "Terms of Use", accessibility: "Accessibility", diff --git a/kit/locales/es.ts b/kit/locales/es.ts index 713bec7..42a6b79 100644 --- a/kit/locales/es.ts +++ b/kit/locales/es.ts @@ -14,6 +14,23 @@ * @version v0.1.0 */ export default { + email: { + footer: { + transactional: "Este es un mensaje transaccional automático de AMPL.", + tagline: "Archives, Memory, and Preservation Lab · UC Santa Barbara", + unsubscribeLabel: "Darte de baja", + }, + unsubscribe: { + pageTitle: "Darte de baja de los correos de AMPL", + heading: "Darte de baja", + explain: + "Al confirmar, esta dirección quedará excluida de todos los mensajes automáticos de AMPL. La exclusión es global: cubre todas las herramientas —Calamus, Scheduling y las que vengan más adelante—, no solo la que te envió este mensaje.", + button: "Confirmar baja", + confirmedHeading: "Te has dado de baja", + confirmedBody: + "Hemos quitado tu dirección de nuestra lista. Ya no recibirás mensajes automáticos de ninguna herramienta de AMPL.", + }, + }, footer: { terms: "Términos de uso", accessibility: "Accesibilidad", diff --git a/kit/theme.css b/kit/theme.css index a1c2b46..ec6ccb4 100644 --- a/kit/theme.css +++ b/kit/theme.css @@ -49,6 +49,9 @@ --font-body: "Ubuntu Mono", ui-monospace, Menlo, Consolas, monospace; --font-title: "Roboto Slab", "Produkt", Georgia, serif; --font-display: "Silkscreen", ui-monospace, monospace; + /* Institutional sans — matches the lab site's footer body type (Avenir), + a system-font stack (no webfont load). Footer chrome only, not tool content. */ + --font-ui: "Avenir", "Century Gothic", sans-serif; /* ---------- Type ramp (size · line-height · tracking) ---------- */ --text-h1: 4rem; /* 64px */ diff --git a/kit/ui/AccountWidget.tsx b/kit/ui/AccountWidget.tsx index 7b87976..3dadd0c 100644 --- a/kit/ui/AccountWidget.tsx +++ b/kit/ui/AccountWidget.tsx @@ -18,23 +18,29 @@ import { useTranslation } from "react-i18next"; * * Props contract: * name — display name from users table - * handle — GitHub handle (without @) + * handle — (optional) GitHub handle (without @); when absent or empty + * the @handle line is not rendered at all * avatarUrl — GitHub avatar URL, or null (placeholder rendered) - * signOutHref — the POST logout endpoint, e.g. withBase("/logout") + * signOutHref — (optional) the POST logout endpoint; defaults to the + * root-relative literal "/auth/logout" so a consumer tool + * wired to the apex origin needs no href argument. + * Override for non-standard basenames or dev environments. + * A browser form action does not suffer RR v7 basename + * double-prefix, so a root-relative literal is correct here. * returnTo — optional post-logout destination; appended to the action as a * ?return_to= query param (the logout route reads it from the * query string and guards it with safeReturnTo). A POST form * preserves its action URL's query string, so this reaches the * route; a hidden input would land in the body and be ignored. * - * @version v0.1.1 + * @version v0.1.2 */ type AccountWidgetProps = { name: string; - handle: string; + handle?: string; avatarUrl: string | null; - signOutHref: string; + signOutHref?: string; returnTo?: string; }; @@ -55,7 +61,7 @@ export function AccountWidget({ name, handle, avatarUrl, - signOutHref, + signOutHref = "/auth/logout", returnTo, }: AccountWidgetProps) { const { t } = useTranslation("kit"); @@ -75,7 +81,9 @@ export function AccountWidget({ )}
{name} - @{handle} + {handle && ( + @{handle} + )}
)} ); diff --git a/kit/ui/SiteFooter.tsx b/kit/ui/SiteFooter.tsx index 7af3811..154fb9f 100644 --- a/kit/ui/SiteFooter.tsx +++ b/kit/ui/SiteFooter.tsx @@ -9,9 +9,9 @@ import { ReportProblem } from "./ReportProblem"; * This file renders the shared institutional footer that anchors the bottom of * every AMPL tool. It is two full-width plum bands: an upper band carrying the * UCSB wordmark, the lab's name and postal address, and the CLAIR logo; and a - * lower band carrying the Regents copyright line and the two statutory links + * lower band carrying the lab copyright line and the two statutory links * (terms of use and accessibility). Only those two link labels are translated — - * the lab name, address, and Regents wording stay in English in both languages + * the lab name, address, and copyright wording stay in English in both languages * — and the copyright year is computed at render time rather than hard-coded. * The "report a problem" trigger sits in the left column beneath the address. * The logos are loaded as image files rather than inline SVGs to fit the build @@ -28,7 +28,7 @@ export function SiteFooter() {