- Copyright © {year} The Regents of the University of California. All
- Rights Reserved.
+
+ © {year} Archives, Memory, and Preservation Lab
. */
+ /** Locale switcher slot — pass a pre-wired
. */
localeSwitcher?: React.ReactNode;
+ /** Optional nav slot. When present, renders above localeSwitcher in the right column. When absent, the header renders exactly as before (backward compatible). */
+ nav?: React.ReactNode;
};
-export function SiteHeader({ children, localeSwitcher }: SiteHeaderProps) {
+export function SiteHeader({ children, localeSwitcher, nav }: SiteHeaderProps) {
return (
{children}
-
{localeSwitcher}
+
+ {nav ? (
+
+ {nav}
+ {localeSwitcher}
+
+ ) : (
+ localeSwitcher
+ )}
+
);
diff --git a/package-lock.json b/package-lock.json
index eab33e4..a7ea88e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@ampl/kit",
- "version": "0.1.1",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ampl/kit",
- "version": "0.1.1",
+ "version": "0.2.0",
"hasInstallScript": true,
"dependencies": {
"@oslojs/crypto": "^1.0.1",
diff --git a/package.json b/package.json
index f974a28..5c0557a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@ampl/kit",
- "version": "0.1.1",
+ "version": "0.2.0",
"private": true,
"type": "module",
"description": "Shared foundation for the AMPL tools suite: the ampl-auth Worker (ampl.tools/auth) + the @ampl/kit design system, surfaces, and session-validation helper consumed by every tool.",
@@ -10,16 +10,25 @@
"./fonts": "./kit/fonts.ts",
"./locales/en": "./kit/locales/en.ts",
"./locales/es": "./kit/locales/es.ts",
- "./auth": "./kit/auth/index.ts"
+ "./auth": "./kit/auth/index.ts",
+ "./email": "./kit/email/index.ts"
},
- "comment:exports": "Consumed by other tools via git-dep. Ship source; consumers transpile via Vite (ssr.noExternal). The './auth' subpath provides the session-validation helper + the read-only AUTH_DB contract. The Worker app (app/, wrangler.jsonc name=ampl-auth) deploys independently.",
+ "comment:exports": "Consumed by other tools via git-dep. Ship source; consumers transpile via Vite (ssr.noExternal). The './auth' subpath provides the session-validation helper + the read-only AUTH_DB contract. The './email' subpath provides the bilingual branded shell (renderEmailShell) + the pure RFC 5545 .ics builder (buildIcs). The Worker app (app/, wrangler.jsonc name=ampl-auth) deploys independently.",
"scripts": {
"build": "react-router build && node scripts/rebase-client-assets.mjs",
"cf-typegen": "wrangler types",
"deploy": "npm run build && wrangler deploy",
"deploy:staging": "CLOUDFLARE_ENV=staging npm run build && wrangler deploy --env staging",
"dev": "react-router dev",
- "postinstall": "npm run cf-typegen",
+ "postinstall": "npm run cf-typegen && npm run cf-typegen:email",
+ "cf-typegen:email": "wrangler types workers-email-configuration.d.ts --config wrangler.email.jsonc --include-runtime=false",
+ "deploy:email": "wrangler deploy --config wrangler.email.jsonc",
+ "deploy:email:staging": "wrangler deploy --config wrangler.email.jsonc --env staging",
+ "db:generate:email": "drizzle-kit generate --config drizzle.email.config.ts",
+ "db:migrate:email": "wrangler d1 migrations apply EMAIL_DB --local --config wrangler.email.jsonc",
+ "db:migrate:email:prod": "wrangler d1 migrations apply EMAIL_DB --remote --config wrangler.email.jsonc",
+ "db:migrate:email:staging": "wrangler d1 migrations apply EMAIL_DB --remote --env staging --config wrangler.email.jsonc",
+ "test:email": "vitest --config vitest.email.config.ts",
"preview": "npm run build && vite preview",
"typecheck": "npm run cf-typegen && react-router typegen && tsc -b",
"db:generate": "drizzle-kit generate",
diff --git a/tests/apply-email-migrations.ts b/tests/apply-email-migrations.ts
new file mode 100644
index 0000000..33729a4
--- /dev/null
+++ b/tests/apply-email-migrations.ts
@@ -0,0 +1,19 @@
+/**
+ * Email test database setup
+ *
+ * This file runs once before any test in the email Worker suite. It applies the
+ * email D1 migrations to the in-memory miniflare database that the Cloudflare
+ * Workers test pool hands to every test — so each test starts against the
+ * `sends` and `suppressions` schema that matches production. The list of
+ * migrations is injected at runtime by the `bindings` block in
+ * `vitest.email.config.ts`.
+ *
+ * @version v0.1.0
+ */
+
+import { env, applyD1Migrations } from "cloudflare:test";
+import { beforeAll } from "vitest";
+
+beforeAll(async () => {
+ await applyD1Migrations(env.EMAIL_DB, env.TEST_EMAIL_MIGRATIONS);
+});
diff --git a/tests/email/contract.test.ts b/tests/email/contract.test.ts
new file mode 100644
index 0000000..ec9d03d
--- /dev/null
+++ b/tests/email/contract.test.ts
@@ -0,0 +1,456 @@
+/**
+ * send() consumer contract integration tests
+ *
+ * Tests the full send() pipeline for both AMPL email consumer shapes:
+ * Calamus (bilingual invitation) and Scheduling (confirmation / cancellation /
+ * poll-finalisation / reminder with .ics attachments). Uses the EMAIL_DB
+ * harness and a fake Resend transport — mirrors send.test.ts exactly.
+ *
+ * Each test case stubs the global `fetch` to intercept the POST to
+ * `https://api.resend.com/emails` so no real network call is made.
+ *
+ * Cases covered:
+ * 1. Calamus bilingual invitation — en locale: send() succeeds, Worker
+ * stamps in html.
+ * 2. Calamus bilingual invitation — es locale: distinct idempotencyKey
+ * prevents the idempotency gate from rejecting the second call.
+ * 3. Scheduling confirmation: attachments[0].content is valid base64 that
+ * atob-decodes to BEGIN:VCALENDAR / METHOD:REQUEST / STATUS:CONFIRMED.
+ * 4. Scheduling cancellation: attachment content_type carries method=CANCEL;
+ * decoded .ics contains STATUS:CANCELLED.
+ * 5. Poll-finalisation and reminder: both route through send() with a REQUEST
+ * .ics attachment present.
+ *
+ * Expected to run RED until kit/email barrel + deliver() attachment wiring
+ * (Waves 1/2) land.
+ *
+ * @version v0.2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { env } from "cloudflare:test";
+import { getEmailDb } from "../helpers/email-db";
+import { renderEmailShell, buildIcs } from "../../kit/email";
+import type { EmailShellInput } from "../../kit/email/types";
+import type { SendMessage } from "../../app/email/types";
+
+// ---------------------------------------------------------------------------
+// Resend fetch fake helpers (mirrors send.test.ts exactly)
+// ---------------------------------------------------------------------------
+
+type ResendPayload = {
+ from: string;
+ to: string | string[];
+ subject: string;
+ html: string;
+ text: string;
+ headers: Record
;
+ attachments?: Array<{
+ content: string;
+ filename: string;
+ content_type: string;
+ content_id?: string;
+ }>;
+};
+
+let resendCalls: ResendPayload[] = [];
+
+const FAKE_RESEND_ID = "resend-fake-id-contract-001";
+
+function installResendFake() {
+ resendCalls = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url === "https://api.resend.com/emails") {
+ const body = JSON.parse((init?.body as string) ?? "{}") as ResendPayload;
+ resendCalls.push(body);
+ return new Response(JSON.stringify({ id: FAKE_RESEND_ID }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ throw new Error(`Unexpected fetch to: ${url}`);
+ }),
+ );
+}
+
+function uninstallResendFake() {
+ vi.unstubAllGlobals();
+}
+
+// ---------------------------------------------------------------------------
+// Helpers — build fixtures using kit/email functions
+// ---------------------------------------------------------------------------
+
+function buildCalamusInviteMsg(locale: "en" | "es", idempotencyKey?: string): 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/fixture-abc",
+ },
+ { 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/fixture-abc",
+ },
+ { kind: "note", content: "Esta invitación expira en 14 días." },
+ ],
+ };
+
+ const { html, text } = renderEmailShell(input);
+ const msg: SendMessage = {
+ to: "user@example.com",
+ subject: "[Calamus] Invitation to practice group",
+ html,
+ text,
+ tool: "calamus",
+ locale,
+ ...(idempotencyKey ? { idempotencyKey } : {}),
+ };
+ return msg;
+}
+
+/** Build a Scheduling .ics attachment string using buildIcs. */
+function buildIcsAttachment(
+ method: "REQUEST" | "CANCEL",
+ sequence: number,
+): { content: string; filename: string; type: string } {
+ const content = buildIcs({
+ uid: "booking-fixture-001@ampl.tools",
+ sequence,
+ method,
+ 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" }],
+ });
+ return {
+ content,
+ filename: "event.ics",
+ type: `text/calendar; charset=utf-8; method=${method}`,
+ };
+}
+
+function buildSchedulingMsg(
+ subject: string,
+ method: "REQUEST" | "CANCEL",
+ sequence: number,
+): SendMessage {
+ 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 att = buildIcsAttachment(method, sequence);
+ return {
+ to: "user@example.com",
+ subject: `[Scheduling] ${subject}`,
+ html,
+ text,
+ tool: "scheduling",
+ attachments: [att],
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Test suite
+// ---------------------------------------------------------------------------
+
+describe("send() consumer contract", () => {
+ let db: ReturnType;
+
+ beforeEach(() => {
+ db = getEmailDb();
+ installResendFake();
+ });
+
+ afterEach(() => {
+ uninstallResendFake();
+ });
+
+ // -------------------------------------------------------------------------
+ // 1. Calamus bilingual invitation — EN
+ // -------------------------------------------------------------------------
+ it("Calamus en invitation: send() succeeds and Worker stamps in html", async () => {
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ const msg = buildCalamusInviteMsg("en", "calamus-invite-en-fixture");
+ const result = await worker.send(msg);
+
+ expect(result.ok).toBe(true);
+ expect(resendCalls).toHaveLength(1);
+ // Worker's deliver() appends buildFooter(locale) → is stamped
+ expect(resendCalls[0].html).toContain("");
+ });
+
+ // -------------------------------------------------------------------------
+ // 2. Calamus bilingual invitation — ES (distinct idempotencyKey)
+ // Note: distinct key avoids the idempotency gate rejecting this as a duplicate.
+ // -------------------------------------------------------------------------
+ it("Calamus es invitation: send() succeeds with distinct idempotencyKey", async () => {
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ // EN call first (different key)
+ await worker.send(buildCalamusInviteMsg("en", "calamus-invite-en-fixture-2"));
+
+ // ES call with a different key — must not be rejected as duplicate
+ const msg = buildCalamusInviteMsg("es", "calamus-invite-es-fixture");
+ const result = await worker.send(msg);
+
+ expect(result.ok).toBe(true);
+ expect(resendCalls).toHaveLength(2);
+ // ES footer copy present in the second call's html
+ expect(resendCalls[1].html).toContain("");
+ });
+
+ // -------------------------------------------------------------------------
+ // 3. Scheduling confirmation with REQUEST .ics attachment
+ // -------------------------------------------------------------------------
+ it("Scheduling confirmation: attachments[0].content base64-decodes to BEGIN:VCALENDAR / METHOD:REQUEST / STATUS:CONFIRMED", async () => {
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ const msg = buildSchedulingMsg("Appointment confirmed", "REQUEST", 0);
+ const result = await worker.send(msg);
+
+ expect(result.ok).toBe(true);
+ expect(resendCalls).toHaveLength(1);
+
+ const att = resendCalls[0].attachments;
+ expect(att).toHaveLength(1);
+ expect(att![0].filename).toBe("event.ics");
+ expect(att![0].content_type).toBe("text/calendar; charset=utf-8; method=REQUEST");
+
+ // Decode base64 and assert valid VCALENDAR content
+ const decoded = atob(att![0].content);
+ expect(decoded).toContain("BEGIN:VCALENDAR");
+ expect(decoded).toContain("METHOD:REQUEST");
+ expect(decoded).toContain("STATUS:CONFIRMED");
+ });
+
+ // -------------------------------------------------------------------------
+ // 4. Scheduling cancellation with CANCEL .ics attachment
+ // -------------------------------------------------------------------------
+ it("Scheduling cancellation: content_type method=CANCEL and decoded .ics contains STATUS:CANCELLED", async () => {
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ const msg = buildSchedulingMsg("Appointment cancelled", "CANCEL", 1);
+ const result = await worker.send(msg);
+
+ expect(result.ok).toBe(true);
+ expect(resendCalls).toHaveLength(1);
+
+ const att = resendCalls[0].attachments;
+ expect(att).toHaveLength(1);
+ // content_type must carry method=CANCEL
+ expect(att![0].content_type).toBe("text/calendar; charset=utf-8; method=CANCEL");
+
+ const decoded = atob(att![0].content);
+ expect(decoded).toContain("BEGIN:VCALENDAR");
+ expect(decoded).toContain("METHOD:CANCEL");
+ expect(decoded).toContain("STATUS:CANCELLED");
+ });
+
+ // -------------------------------------------------------------------------
+ // 5a. Poll-finalisation fixture — routes with a REQUEST .ics attachment
+ // -------------------------------------------------------------------------
+ it("Scheduling poll-finalisation: routes through send() with a REQUEST .ics attachment", async () => {
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ const shellInput: EmailShellInput = {
+ locale: "en",
+ heading: "Your poll result is in — appointment confirmed",
+ blocks: [
+ { kind: "text", content: "The poll for your group meeting has closed. Your appointment is confirmed." },
+ {
+ kind: "details",
+ rows: [
+ { label: "Date", value: "June 15, 2026" },
+ { label: "Time", value: "10:00 AM" },
+ ],
+ },
+ ],
+ };
+ const { html, text } = renderEmailShell(shellInput);
+ const att = buildIcsAttachment("REQUEST", 0);
+ const msg: SendMessage = {
+ to: "user@example.com",
+ subject: "[Scheduling] Poll result",
+ html,
+ text,
+ tool: "scheduling",
+ attachments: [att],
+ };
+
+ const result = await worker.send(msg);
+ expect(result.ok).toBe(true);
+ expect(resendCalls).toHaveLength(1);
+
+ const sentAtt = resendCalls[0].attachments;
+ expect(sentAtt).toHaveLength(1);
+ const decoded = atob(sentAtt![0].content);
+ expect(decoded).toContain("BEGIN:VCALENDAR");
+ expect(decoded).toContain("METHOD:REQUEST");
+ });
+
+ // -------------------------------------------------------------------------
+ // 5b. Reminder fixture — routes with a REQUEST .ics attachment
+ // -------------------------------------------------------------------------
+ it("Scheduling reminder: routes through send() with a REQUEST .ics attachment", async () => {
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ const shellInput: EmailShellInput = {
+ locale: "en",
+ heading: "Reminder: your appointment is coming up",
+ blocks: [
+ { kind: "text", content: "This is a reminder that your appointment is tomorrow." },
+ {
+ kind: "details",
+ rows: [
+ { label: "Date", value: "June 15, 2026" },
+ { label: "Time", value: "10:00 AM" },
+ ],
+ },
+ { kind: "note", content: "The .ics file is attached for your calendar." },
+ ],
+ };
+ const { html, text } = renderEmailShell(shellInput);
+ const att = buildIcsAttachment("REQUEST", 0);
+ const msg: SendMessage = {
+ to: "user@example.com",
+ subject: "[Scheduling] Appointment reminder",
+ html,
+ text,
+ tool: "scheduling",
+ attachments: [att],
+ };
+
+ const result = await worker.send(msg);
+ expect(result.ok).toBe(true);
+ expect(resendCalls).toHaveLength(1);
+
+ const sentAtt = resendCalls[0].attachments;
+ expect(sentAtt).toHaveLength(1);
+ const decoded = atob(sentAtt![0].content);
+ expect(decoded).toContain("BEGIN:VCALENDAR");
+ expect(decoded).toContain("METHOD:REQUEST");
+ });
+
+ // -------------------------------------------------------------------------
+ // 6. ArrayBuffer attachment content — encodeBase64 ArrayBuffer branch
+ // Exercises the `new Uint8Array(content)` path (workers/email.ts line 67)
+ // which no existing test reaches (all prior cases pass string content).
+ // -------------------------------------------------------------------------
+ it("ArrayBuffer attachment content round-trips through encodeBase64 correctly", async () => {
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ // Build a known byte sequence via TextEncoder and extract its ArrayBuffer.
+ // This simulates a binary attachment (e.g. a PDF or compiled binary blob)
+ // passed as raw bytes rather than a string.
+ const knownText = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR";
+ const originalBytes = new TextEncoder().encode(knownText);
+ const arrayBufferContent: ArrayBuffer = originalBytes.buffer;
+
+ const shellInput: EmailShellInput = {
+ locale: "en",
+ heading: "Binary attachment test",
+ blocks: [{ kind: "text", content: "This message carries an ArrayBuffer attachment." }],
+ };
+ const { html, text } = renderEmailShell(shellInput);
+
+ // Use a distinct idempotencyKey so the idempotency gate does not
+ // reject this as a duplicate of another test in this suite (mirrors case 2).
+ const msg: SendMessage = {
+ to: "user@example.com",
+ subject: "[Scheduling] ArrayBuffer attachment fixture",
+ html,
+ text,
+ tool: "scheduling",
+ idempotencyKey: "email-03-arraybuffer-fixture",
+ attachments: [
+ {
+ content: arrayBufferContent,
+ filename: "event.ics",
+ type: "text/calendar; charset=utf-8; method=REQUEST",
+ },
+ ],
+ };
+
+ const result = await worker.send(msg);
+
+ expect(result.ok).toBe(true);
+ expect(resendCalls).toHaveLength(1);
+
+ const sentAtt = resendCalls[0].attachments;
+ expect(sentAtt).toHaveLength(1);
+ expect(sentAtt![0].filename).toBe("event.ics");
+
+ // Round-trip: atob() the base64 the Worker produced and verify byte-for-byte
+ // equality against the original ArrayBuffer content.
+ const base64Sent = sentAtt![0].content;
+ const decodedString = atob(base64Sent);
+ const decodedBytes = new Uint8Array(decodedString.length);
+ for (let i = 0; i < decodedString.length; i++) {
+ decodedBytes[i] = decodedString.charCodeAt(i);
+ }
+
+ expect(decodedBytes).toEqual(originalBytes);
+ });
+});
diff --git a/tests/email/harness.test.ts b/tests/email/harness.test.ts
new file mode 100644
index 0000000..dca441d
--- /dev/null
+++ b/tests/email/harness.test.ts
@@ -0,0 +1,130 @@
+/**
+ * Email harness smoke test
+ *
+ * This is the Wave 0 proof that the email test harness is correctly wired:
+ * migrations applied, `EMAIL_DB` mounted, and the Drizzle client can round-trip
+ * a row through the `sends` table. If this test passes, Plans 02 and 03 have a
+ * working, migrated foundation to build their full integration tests on.
+ *
+ * Pattern: insert a `sends` row directly via the email-db helper, select it
+ * back by id, and assert on both the returned value and the DB row — the same
+ * dual-assertion pattern as `tests/sessions.test.ts`.
+ *
+ * @version v0.1.0
+ */
+
+import { describe, it, expect } from "vitest";
+import { eq } from "drizzle-orm";
+import { getEmailDb, schema } from "../helpers/email-db";
+
+describe("email harness — sends round-trip", () => {
+ it("inserts a sends row and reads it back by id", async () => {
+ const db = getEmailDb();
+ const now = Date.now();
+
+ const [inserted] = await db
+ .insert(schema.sends)
+ .values({
+ tool: "calamus",
+ recipient: "test@ampl.tools",
+ subject: "[Calamus] Harness test",
+ status: "sent",
+ resendId: "test-resend-id-001",
+ idempotencyKey: "harness-test-key-001",
+ sentAt: now,
+ createdAt: now,
+ })
+ .returning({ id: schema.sends.id });
+
+ expect(inserted).toBeDefined();
+ expect(typeof inserted.id).toBe("number");
+
+ const row = await db
+ .select()
+ .from(schema.sends)
+ .where(eq(schema.sends.id, inserted.id))
+ .get();
+
+ expect(row).toBeDefined();
+ expect(row?.tool).toBe("calamus");
+ expect(row?.recipient).toBe("test@ampl.tools");
+ expect(row?.idempotencyKey).toBe("harness-test-key-001");
+ expect(row?.status).toBe("sent");
+ });
+
+ it("enforces UNIQUE constraint on idempotency_key — second insert with same key is rejected", async () => {
+ const db = getEmailDb();
+ const now = Date.now();
+
+ await db.insert(schema.sends).values({
+ tool: "scheduling",
+ recipient: "user@ampl.tools",
+ subject: "[Scheduling] Confirmation",
+ status: "sent",
+ idempotencyKey: "harness-dedup-key-001",
+ sentAt: now,
+ createdAt: now,
+ });
+
+ await expect(
+ db.insert(schema.sends).values({
+ tool: "scheduling",
+ recipient: "user@ampl.tools",
+ subject: "[Scheduling] Confirmation",
+ status: "sent",
+ idempotencyKey: "harness-dedup-key-001",
+ sentAt: now,
+ createdAt: now,
+ }),
+ ).rejects.toThrow();
+ });
+
+ it("allows multiple rows with null idempotency_key (non-idempotent sends)", async () => {
+ const db = getEmailDb();
+ const now = Date.now();
+
+ await db.insert(schema.sends).values({
+ tool: "calamus",
+ recipient: "multi@ampl.tools",
+ subject: "[Calamus] No key 1",
+ status: "sent",
+ idempotencyKey: null,
+ sentAt: now,
+ createdAt: now,
+ });
+
+ await expect(
+ db.insert(schema.sends).values({
+ tool: "calamus",
+ recipient: "multi@ampl.tools",
+ subject: "[Calamus] No key 2",
+ status: "sent",
+ idempotencyKey: null,
+ sentAt: now,
+ createdAt: now,
+ }),
+ ).resolves.toBeDefined();
+ });
+
+ it("inserts a suppressions row and reads it back by address", async () => {
+ const db = getEmailDb();
+ const now = Date.now();
+
+ await db.insert(schema.suppressions).values({
+ address: "suppressed@ampl.tools",
+ reason: "bounce",
+ source: "resend_webhook",
+ createdAt: now,
+ });
+
+ const row = await db
+ .select()
+ .from(schema.suppressions)
+ .where(eq(schema.suppressions.address, "suppressed@ampl.tools"))
+ .get();
+
+ expect(row).toBeDefined();
+ expect(row?.reason).toBe("bounce");
+ expect(row?.source).toBe("resend_webhook");
+ });
+});
diff --git a/tests/email/ics.test.ts b/tests/email/ics.test.ts
new file mode 100644
index 0000000..60bf4b8
--- /dev/null
+++ b/tests/email/ics.test.ts
@@ -0,0 +1,427 @@
+/**
+ * buildIcs unit tests — RFC 5545 correctness
+ *
+ * Pure-function tests for `buildIcs`. No DB harness, no fetch stub, no
+ * `env`, no `beforeEach`/`afterEach` — `buildIcs` is a synchronous pure
+ * function and can be called directly.
+ *
+ * Cases covered:
+ * 1. VCALENDAR wrapper: BEGIN/END, VERSION:2.0, PRODID, CALSCALE.
+ * 2. CRLF line endings: every line ends in \r\n; no bare \n present.
+ * 3. METHOD:REQUEST → STATUS:CONFIRMED; DTSTART serialized as UTC "Z" form.
+ * 4. TEXT escaping: comma → \,, semicolon → \;, backslash → \\, newline → \n.
+ * 5. Line folding: lines longer than 75 octets are folded with CRLF + leading space.
+ * 6. METHOD:CANCEL → STATUS:CANCELLED, SEQUENCE:1, same UID as REQUEST.
+ *
+ * @version v0.2.0
+ */
+
+import { describe, it, expect } from "vitest";
+import { buildIcs } from "../../kit/email/ics";
+import type { IcsEvent } from "../../kit/email/types";
+
+// ---------------------------------------------------------------------------
+// Base fixture
+// ---------------------------------------------------------------------------
+
+const BASE_EVENT: IcsEvent = {
+ uid: "booking-abc@ampl.tools",
+ sequence: 0,
+ 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" }],
+};
+
+// ---------------------------------------------------------------------------
+// 1. VCALENDAR wrapper
+// ---------------------------------------------------------------------------
+
+describe("buildIcs — VCALENDAR wrapper", () => {
+ it("output contains BEGIN:VCALENDAR and END:VCALENDAR", () => {
+ const output = buildIcs(BASE_EVENT);
+ expect(output).toContain("BEGIN:VCALENDAR");
+ expect(output).toContain("END:VCALENDAR");
+ });
+
+ it("output contains VERSION:2.0", () => {
+ const output = buildIcs(BASE_EVENT);
+ expect(output).toContain("VERSION:2.0");
+ });
+
+ it("output contains a PRODID property", () => {
+ const output = buildIcs(BASE_EVENT);
+ expect(output).toContain("PRODID:");
+ });
+
+ it("output contains BEGIN:VEVENT and END:VEVENT", () => {
+ const output = buildIcs(BASE_EVENT);
+ expect(output).toContain("BEGIN:VEVENT");
+ expect(output).toContain("END:VEVENT");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 2. CRLF line endings
+// ---------------------------------------------------------------------------
+
+describe("buildIcs — CRLF line endings", () => {
+ it("output contains \\r\\n line endings", () => {
+ const output = buildIcs(BASE_EVENT);
+ expect(output).toContain("\r\n");
+ });
+
+ it("output does not contain bare \\n without preceding \\r", () => {
+ const output = buildIcs(BASE_EVENT);
+ // Replace all \r\n first, then check no \n remains
+ const withoutCRLF = output.replace(/\r\n/g, "");
+ expect(withoutCRLF).not.toContain("\n");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 3. METHOD:REQUEST semantics
+// ---------------------------------------------------------------------------
+
+describe("buildIcs — METHOD:REQUEST", () => {
+ it("emits METHOD:REQUEST", () => {
+ const output = buildIcs(BASE_EVENT);
+ expect(output).toContain("METHOD:REQUEST");
+ });
+
+ it("emits STATUS:CONFIRMED for REQUEST", () => {
+ const output = buildIcs(BASE_EVENT);
+ expect(output).toContain("STATUS:CONFIRMED");
+ });
+
+ it("serializes DTSTART in UTC Z form", () => {
+ const output = buildIcs(BASE_EVENT);
+ // 2026-06-15T10:00:00Z → 20260615T100000Z
+ expect(output).toContain("DTSTART:20260615T100000Z");
+ });
+
+ it("serializes DTEND in UTC Z form", () => {
+ const output = buildIcs(BASE_EVENT);
+ expect(output).toContain("DTEND:20260615T110000Z");
+ });
+
+ it("emits UID and SEQUENCE:0", () => {
+ const output = buildIcs(BASE_EVENT);
+ expect(output).toContain("UID:booking-abc@ampl.tools");
+ expect(output).toContain("SEQUENCE:0");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 4. TEXT escaping
+// ---------------------------------------------------------------------------
+
+describe("buildIcs — TEXT escaping", () => {
+ it("escapes comma in SUMMARY → \\,", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ summary: "Coffee, tea, or consultation",
+ };
+ const output = buildIcs(event);
+ expect(output).toContain("Coffee\\, tea\\, or consultation");
+ });
+
+ it("escapes semicolon in SUMMARY → \\;", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ summary: "Health; safety; compliance",
+ };
+ const output = buildIcs(event);
+ expect(output).toContain("Health\\; safety\\; compliance");
+ });
+
+ it("escapes backslash in SUMMARY → \\\\", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ summary: "Folder\\Subfolder",
+ };
+ const output = buildIcs(event);
+ expect(output).toContain("Folder\\\\Subfolder");
+ });
+
+ it("does not leave raw comma, semicolon, or backslash in SUMMARY", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ summary: "a,b;c\\d",
+ };
+ const output = buildIcs(event);
+ // Extract the SUMMARY line (may be folded, but the escaped forms must appear)
+ expect(output).toContain("\\,");
+ expect(output).toContain("\\;");
+ expect(output).toContain("\\\\");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 5. Line folding at 75 octets
+// ---------------------------------------------------------------------------
+
+describe("buildIcs — line folding", () => {
+ it("folds a long SUMMARY at 75 octets with CRLF + leading space", () => {
+ // Create a summary long enough to exceed the 75-octet RFC 5545 limit
+ // "SUMMARY:" is 8 chars, so the value needs to push past 67 chars to trigger folding
+ const longSummary =
+ "A very long summary that exceeds the RFC 5545 seventy-five octet line limit for property values";
+ const event: IcsEvent = { ...BASE_EVENT, summary: longSummary };
+ const output = buildIcs(event);
+ // RFC 5545 fold continuation is CRLF followed by a single space
+ expect(output).toContain("\r\n ");
+ });
+
+ it("each physical line in the output is no longer than 75 octets", () => {
+ const longSummary =
+ "A very long summary that definitely exceeds the seventy-five octet per line limit imposed by RFC 5545 section 3.1";
+ const event: IcsEvent = { ...BASE_EVENT, summary: longSummary };
+ const output = buildIcs(event);
+ const encoder = new TextEncoder();
+ const lines = output.split("\r\n");
+ for (const line of lines) {
+ if (line === "") continue; // trailing empty after final CRLF
+ const octets = encoder.encode(line).length;
+ expect(octets).toBeLessThanOrEqual(75);
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 6. METHOD:CANCEL semantics
+// ---------------------------------------------------------------------------
+
+describe("buildIcs — METHOD:CANCEL", () => {
+ it("emits METHOD:CANCEL", () => {
+ const cancelEvent: IcsEvent = {
+ ...BASE_EVENT,
+ method: "CANCEL",
+ sequence: 1,
+ };
+ const output = buildIcs(cancelEvent);
+ expect(output).toContain("METHOD:CANCEL");
+ });
+
+ it("emits STATUS:CANCELLED for CANCEL", () => {
+ const cancelEvent: IcsEvent = {
+ ...BASE_EVENT,
+ method: "CANCEL",
+ sequence: 1,
+ };
+ const output = buildIcs(cancelEvent);
+ expect(output).toContain("STATUS:CANCELLED");
+ });
+
+ it("emits SEQUENCE:1 for a CANCEL with sequence 1", () => {
+ const cancelEvent: IcsEvent = {
+ ...BASE_EVENT,
+ method: "CANCEL",
+ sequence: 1,
+ };
+ const output = buildIcs(cancelEvent);
+ expect(output).toContain("SEQUENCE:1");
+ });
+
+ it("preserves the same UID as the original REQUEST", () => {
+ const cancelEvent: IcsEvent = {
+ ...BASE_EVENT,
+ method: "CANCEL",
+ sequence: 1,
+ };
+ const output = buildIcs(cancelEvent);
+ expect(output).toContain("UID:booking-abc@ampl.tools");
+ });
+
+ it("does NOT emit STATUS:CONFIRMED for a CANCEL event", () => {
+ const cancelEvent: IcsEvent = {
+ ...BASE_EVENT,
+ method: "CANCEL",
+ sequence: 1,
+ };
+ const output = buildIcs(cancelEvent);
+ expect(output).not.toContain("STATUS:CONFIRMED");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 7. ICS injection prevention
+// ---------------------------------------------------------------------------
+
+describe("buildIcs — ICS injection prevention", () => {
+ it("uid with \\n does not produce extra property lines", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ uid: "abc\nATTENDEE:mailto:victim@example.com",
+ };
+ const output = buildIcs(event);
+ // The newline must NOT appear as a real line break in the UID property
+ // (it would be stripped). The injected ATTENDEE must not appear as a new property.
+ const lines = output.split("\r\n").filter((l) => l !== "");
+ const uidLine = lines.find((l) => l.startsWith("UID:"));
+ expect(uidLine).toBeDefined();
+ // Stripped: no newline in the uid value
+ expect(uidLine).not.toContain("\n");
+ // The injected attendee does not appear as an extra ATTENDEE line caused by the uid
+ const attendeeLines = lines.filter((l) => l.startsWith("ATTENDEE:mailto:victim@example.com"));
+ expect(attendeeLines).toHaveLength(0);
+ });
+
+ it("url with \\r\\n does not produce extra property lines", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ url: "https://ampl.tools/booking\r\nX-INJECTED:evil",
+ };
+ const output = buildIcs(event);
+ // The CR/LF are stripped, so no extra line beginning with "X-INJECTED:" is emitted
+ const lines = output.split("\r\n").filter((l) => l !== "");
+ const injectedLine = lines.find((l) => l.startsWith("X-INJECTED:"));
+ expect(injectedLine).toBeUndefined();
+ });
+
+ it("organizer name containing : is DQUOTE-wrapped", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ organizer: { name: "Org:Admin", email: "org@ampl.tools" },
+ };
+ const output = buildIcs(event);
+ // CN value must be quoted so the colon doesn't corrupt the property grammar
+ expect(output).toContain('CN="Org:Admin"');
+ });
+
+ it("organizer name containing ; is DQUOTE-wrapped", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ organizer: { name: "Doe; ROLE=CHAIR", email: "doe@ampl.tools" },
+ };
+ const output = buildIcs(event);
+ expect(output).toContain('CN="Doe; ROLE=CHAIR"');
+ });
+
+ it("attendee name containing : is DQUOTE-wrapped", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ attendees: [{ name: "User:Test", email: "user@example.com" }],
+ };
+ const output = buildIcs(event);
+ expect(output).toContain('CN="User:Test"');
+ });
+
+ it("CR/LF in organizer email is stripped — no injected property line", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ organizer: { name: "AMPL", email: "noreply@ampl.tools\nX-INJECT:evil" },
+ };
+ const output = buildIcs(event);
+ // CR/LF stripped: no line starting with "X-INJECT:" is emitted
+ const lines = output.split("\r\n").filter((l) => l !== "");
+ const injectedLine = lines.find((l) => l.startsWith("X-INJECT:"));
+ expect(injectedLine).toBeUndefined();
+ });
+
+ it("CR/LF in attendee email is stripped — no injected property line", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ attendees: [{ email: "user@example.com\r\nX-INJECT:evil" }],
+ };
+ const output = buildIcs(event);
+ // CR/LF stripped: no line starting with "X-INJECT:" is emitted
+ const lines = output.split("\r\n").filter((l) => l !== "");
+ const injectedLine = lines.find((l) => l.startsWith("X-INJECT:"));
+ expect(injectedLine).toBeUndefined();
+ });
+
+ it("organizer name with \\n in CN is stripped — no injected property line", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ organizer: { name: "AMPL\nX-INJECT:evil", email: "noreply@ampl.tools" },
+ };
+ const output = buildIcs(event);
+ // CR/LF stripped from CN: no line starting with "X-INJECT:" is emitted
+ const lines = output.split("\r\n").filter((l) => l !== "");
+ const injectedLine = lines.find((l) => l.startsWith("X-INJECT:"));
+ expect(injectedLine).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 8. Input validation
+// ---------------------------------------------------------------------------
+
+describe("buildIcs — input validation", () => {
+ // invalid dates
+ it("throws on invalid dtstart", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ dtstart: new Date("not a date"),
+ };
+ expect(() => buildIcs(event)).toThrow("buildIcs: invalid date");
+ });
+
+ it("throws on invalid dtend", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ dtend: new Date("not a date"),
+ };
+ expect(() => buildIcs(event)).toThrow("buildIcs: invalid date");
+ });
+
+ it("throws on invalid dtstamp", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ dtstamp: new Date("not a date"),
+ };
+ expect(() => buildIcs(event)).toThrow("buildIcs: invalid date");
+ });
+
+ // empty attendees
+ it("throws when attendees array is empty", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ attendees: [],
+ };
+ expect(() => buildIcs(event)).toThrow("buildIcs: at least one attendee required");
+ });
+
+ // dtend before dtstart
+ it("throws when dtend is before dtstart", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ dtstart: new Date("2026-06-15T11:00:00Z"),
+ dtend: new Date("2026-06-15T10:00:00Z"),
+ };
+ expect(() => buildIcs(event)).toThrow("buildIcs: dtend must be >= dtstart");
+ });
+
+ it("accepts dtend equal to dtstart (zero-duration event)", () => {
+ const event: IcsEvent = {
+ ...BASE_EVENT,
+ dtstart: new Date("2026-06-15T10:00:00Z"),
+ dtend: new Date("2026-06-15T10:00:00Z"),
+ };
+ expect(() => buildIcs(event)).not.toThrow();
+ });
+
+ // sequence validation
+ it("throws on non-integer sequence (float)", () => {
+ const event: IcsEvent = { ...BASE_EVENT, sequence: 0.5 };
+ expect(() => buildIcs(event)).toThrow("buildIcs: sequence must be a non-negative integer");
+ });
+
+ it("throws on NaN sequence", () => {
+ const event: IcsEvent = { ...BASE_EVENT, sequence: NaN };
+ expect(() => buildIcs(event)).toThrow("buildIcs: sequence must be a non-negative integer");
+ });
+
+ it("throws on negative sequence", () => {
+ const event: IcsEvent = { ...BASE_EVENT, sequence: -1 };
+ expect(() => buildIcs(event)).toThrow("buildIcs: sequence must be a non-negative integer");
+ });
+
+ it("accepts sequence 0", () => {
+ const event: IcsEvent = { ...BASE_EVENT, sequence: 0 };
+ expect(() => buildIcs(event)).not.toThrow();
+ });
+});
diff --git a/tests/email/idempotency.test.ts b/tests/email/idempotency.test.ts
new file mode 100644
index 0000000..393635b
--- /dev/null
+++ b/tests/email/idempotency.test.ts
@@ -0,0 +1,92 @@
+/**
+ * Idempotency tests — ampl-email Worker
+ *
+ * Tests the insert-or-dedup helper against the EMAIL_DB harness. Covers:
+ * - New idempotency key: inserts and returns { inserted: true, id }
+ * - Duplicate idempotency key: returns { inserted: false, id } of first row
+ * with no second row created
+ * - Null idempotency key: always inserts (SQLite NULLs are distinct in UNIQUE)
+ *
+ * @version v0.1.0
+ */
+
+import { describe, it, expect, beforeEach } from "vitest";
+import { eq } from "drizzle-orm";
+import { getEmailDb, schema } from "../helpers/email-db";
+import { insertSendOrDedup } from "../../app/email/lib/idempotency";
+
+describe("insertSendOrDedup", () => {
+ let db: ReturnType;
+ const now = Date.now();
+
+ const baseRow = {
+ tool: "calamus" as const,
+ recipient: "test@ampl.tools",
+ subject: "[Calamus] Idempotency test",
+ status: "sent",
+ sentAt: now,
+ createdAt: now,
+ };
+
+ beforeEach(() => {
+ db = getEmailDb();
+ });
+
+ it("inserts a new row and returns { inserted: true, id }", async () => {
+ const result = await insertSendOrDedup(db, {
+ ...baseRow,
+ idempotencyKey: "new-key-001",
+ });
+
+ expect(result.inserted).toBe(true);
+ expect(typeof result.id).toBe("number");
+
+ // Verify the row exists
+ const row = await db
+ .select()
+ .from(schema.sends)
+ .where(eq(schema.sends.id, result.id))
+ .get();
+ expect(row).toBeDefined();
+ expect(row?.idempotencyKey).toBe("new-key-001");
+ });
+
+ it("returns { inserted: false, id } of first row when key is duplicated", async () => {
+ const first = await insertSendOrDedup(db, {
+ ...baseRow,
+ idempotencyKey: "dedup-key-001",
+ });
+ expect(first.inserted).toBe(true);
+
+ const second = await insertSendOrDedup(db, {
+ ...baseRow,
+ idempotencyKey: "dedup-key-001",
+ });
+ expect(second.inserted).toBe(false);
+ // Must return the first row's id
+ expect(second.id).toBe(first.id);
+
+ // Only one row should exist with this key
+ const rows = await db
+ .select()
+ .from(schema.sends)
+ .where(eq(schema.sends.idempotencyKey, "dedup-key-001"));
+ expect(rows).toHaveLength(1);
+ });
+
+ it("allows multiple rows with null idempotency key (non-idempotent sends)", async () => {
+ const first = await insertSendOrDedup(db, {
+ ...baseRow,
+ idempotencyKey: null,
+ });
+ expect(first.inserted).toBe(true);
+
+ const second = await insertSendOrDedup(db, {
+ ...baseRow,
+ idempotencyKey: null,
+ });
+ expect(second.inserted).toBe(true);
+ // Each null-key send gets its own unique id
+ expect(second.id).not.toBe(first.id);
+ });
+});
diff --git a/tests/email/quota.test.ts b/tests/email/quota.test.ts
new file mode 100644
index 0000000..9f2adda
--- /dev/null
+++ b/tests/email/quota.test.ts
@@ -0,0 +1,83 @@
+/**
+ * Quota enforcement tests — ampl-email Worker
+ *
+ * Tests the `checkQuota` helper against the EMAIL_DB harness. Covers:
+ * - Under quota: returns "ok"
+ * - Monthly ceiling reached: returns "monthly_exceeded"
+ * - Daily ceiling reached: returns "daily_exceeded"
+ *
+ * Rows are seeded directly into `sends` so we can deterministically control
+ * the quota state without going through the full `send()` pipeline.
+ *
+ * @version v0.1.0
+ */
+
+import { describe, it, expect, beforeEach } from "vitest";
+import { getEmailDb, schema } from "../helpers/email-db";
+import { checkQuota } from "../../app/email/lib/quota";
+
+/** Insert N `sends` rows with status "sent" at the given timestamp. */
+async function seedSends(
+ db: ReturnType,
+ count: number,
+ sentAt: number,
+) {
+ const rows = Array.from({ length: count }, (_, i) => ({
+ tool: "calamus" as const,
+ recipient: `user${i}@ampl.tools`,
+ subject: `[Calamus] Test ${i}`,
+ status: "sent",
+ sentAt,
+ createdAt: sentAt,
+ }));
+ for (const row of rows) {
+ await db.insert(schema.sends).values(row);
+ }
+}
+
+describe("checkQuota", () => {
+ let db: ReturnType;
+ const now = Date.now();
+ // A timestamp guaranteed to be within the current calendar month and day
+ const thisMonthTs = now;
+
+ beforeEach(() => {
+ db = getEmailDb();
+ });
+
+ it("returns 'ok' when no sends have been made", async () => {
+ const result = await checkQuota(db, { monthly: 2500, daily: 90 });
+ expect(result).toBe("ok");
+ });
+
+ it("returns 'monthly_exceeded' when monthly ceiling is reached", async () => {
+ // Seed exactly 2500 sent rows within this calendar month
+ await seedSends(db, 2500, thisMonthTs);
+ const result = await checkQuota(db, { monthly: 2500, daily: 90 });
+ expect(result).toBe("monthly_exceeded");
+ });
+
+ it("returns 'daily_exceeded' when daily guard is reached (under monthly ceiling)", async () => {
+ // Seed exactly 90 sent rows today (well under monthly ceiling)
+ await seedSends(db, 90, thisMonthTs);
+ const result = await checkQuota(db, { monthly: 2500, daily: 90 });
+ expect(result).toBe("daily_exceeded");
+ });
+
+ it("counts only 'sent' status rows for quota", async () => {
+ // Seed 2500 rows with status "suppressed" — should NOT count toward quota
+ const suppressedRows = Array.from({ length: 2500 }, (_, i) => ({
+ tool: "calamus" as const,
+ recipient: `user${i}@ampl.tools`,
+ subject: `[Calamus] Suppressed ${i}`,
+ status: "suppressed",
+ sentAt: thisMonthTs,
+ createdAt: thisMonthTs,
+ }));
+ for (const row of suppressedRows) {
+ await db.insert(schema.sends).values(row);
+ }
+ const result = await checkQuota(db, { monthly: 2500, daily: 90 });
+ expect(result).toBe("ok");
+ });
+});
diff --git a/tests/email/send.test.ts b/tests/email/send.test.ts
new file mode 100644
index 0000000..5def173
--- /dev/null
+++ b/tests/email/send.test.ts
@@ -0,0 +1,450 @@
+/**
+ * EmailWorker send() integration tests
+ *
+ * Tests the full `send()` pipeline against the EMAIL_DB harness and a fake
+ * Resend transport. Each test case stubs the global `fetch` to intercept the
+ * POST to `https://api.resend.com/emails` so no real network call is made.
+ *
+ * Cases covered:
+ * 1. Happy path: non-suppressed address, under quota → sends row inserted,
+ * Resend fake called exactly once, { ok: true, id } returned.
+ * 2. Identity: the `from` field sent to Resend is always
+ * "AMPL "; the send-log row records the tool.
+ * 3. Headers + footer: the Resend payload headers contain List-Unsubscribe
+ * and List-Unsubscribe-Post; html and text both contain .
+ * 4. Suppression: send() to a seeded-suppressed address returns
+ * { ok: false, reason: "suppressed" } with zero Resend calls.
+ * 5. Idempotency: two send() calls with the same idempotencyKey → one Resend
+ * call, second returns the first id.
+ * 6. Quota: with the daily guard exceeded, send() returns
+ * { ok: false, reason: "quota_exceeded" } with zero Resend calls.
+ *
+ * @version v0.1.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { env } from "cloudflare:test";
+import { eq } from "drizzle-orm";
+import { getEmailDb, schema } from "../helpers/email-db";
+import { checkQuota } from "../../app/email/lib/quota";
+import type { SendMessage } from "../../app/email/types";
+
+// ---------------------------------------------------------------------------
+// Resend fetch fake helpers
+// ---------------------------------------------------------------------------
+
+type ResendPayload = {
+ from: string;
+ to: string | string[];
+ subject: string;
+ html: string;
+ text: string;
+ headers: Record;
+};
+
+/** Captured Resend calls — populated by the fetch fake. */
+let resendCalls: ResendPayload[] = [];
+
+/** A fake Resend response id to return from the mock. */
+const FAKE_RESEND_ID = "resend-fake-id-001";
+
+/**
+ * Install a global fetch fake that intercepts POST to api.resend.com.
+ * Non-Resend requests pass through (or would in a real env; here they just
+ * use the same mock since we have no real network).
+ */
+function installResendFake() {
+ resendCalls = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url === "https://api.resend.com/emails") {
+ const body = JSON.parse((init?.body as string) ?? "{}") as ResendPayload;
+ resendCalls.push(body);
+ return new Response(JSON.stringify({ id: FAKE_RESEND_ID }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ throw new Error(`Unexpected fetch to: ${url}`);
+ }),
+ );
+}
+
+function uninstallResendFake() {
+ vi.unstubAllGlobals();
+}
+
+/**
+ * Install a fetch fake whose Resend call fails (HTTP 500), so callResend
+ * throws. Used by the transport-failure regression test.
+ */
+function installResendFailFake() {
+ resendCalls = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url === "https://api.resend.com/emails") {
+ const body = JSON.parse((init?.body as string) ?? "{}") as ResendPayload;
+ resendCalls.push(body);
+ return new Response("upstream boom", { status: 500 });
+ }
+ throw new Error(`Unexpected fetch to: ${url}`);
+ }),
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Shared message fixture
+// ---------------------------------------------------------------------------
+
+const BASE_MSG: SendMessage = {
+ to: "recipient@example.com",
+ subject: "[Calamus] Test invitation",
+ html: "Hello
",
+ text: "Hello",
+ tool: "calamus",
+};
+
+// ---------------------------------------------------------------------------
+// Test suite
+// ---------------------------------------------------------------------------
+
+describe("EmailWorker send()", () => {
+ let db: ReturnType;
+
+ beforeEach(() => {
+ db = getEmailDb();
+ installResendFake();
+ });
+
+ afterEach(() => {
+ uninstallResendFake();
+ });
+
+ // -------------------------------------------------------------------------
+ // 1. Happy path
+ // -------------------------------------------------------------------------
+ it("happy path: sends row inserted, Resend called once, returns { ok: true, id }", async () => {
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ const result = await worker.send({ ...BASE_MSG });
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("result.ok should be true");
+ expect(result.id).toBe(FAKE_RESEND_ID);
+ expect(resendCalls).toHaveLength(1);
+
+ // The sends row should exist with status "sent"
+ const row = await db
+ .select()
+ .from(schema.sends)
+ .where(eq(schema.sends.resendId, FAKE_RESEND_ID))
+ .get();
+ expect(row).toBeDefined();
+ expect(row?.status).toBe("sent");
+ });
+
+ // -------------------------------------------------------------------------
+ // 2. Identity
+ // -------------------------------------------------------------------------
+ it("identity: from is always 'AMPL '; send-log records tool", async () => {
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ await worker.send({ ...BASE_MSG, tool: "scheduling" });
+
+ expect(resendCalls).toHaveLength(1);
+ expect(resendCalls[0].from).toBe("AMPL ");
+
+ const row = await db
+ .select()
+ .from(schema.sends)
+ .where(eq(schema.sends.resendId, FAKE_RESEND_ID))
+ .get();
+ expect(row?.tool).toBe("scheduling");
+ });
+
+ // -------------------------------------------------------------------------
+ // 3. Headers + footer
+ // -------------------------------------------------------------------------
+ it("stamps List-Unsubscribe headers and in html and text", async () => {
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ await worker.send({ ...BASE_MSG });
+
+ expect(resendCalls).toHaveLength(1);
+ const payload = resendCalls[0];
+
+ // Headers
+ expect(payload.headers["List-Unsubscribe"]).toMatch(
+ /^");
+ expect(payload.text).toContain("---");
+ });
+
+ // -------------------------------------------------------------------------
+ // 4. Suppression
+ // -------------------------------------------------------------------------
+ it("suppressed address: returns { ok:false, reason:'suppressed' }, zero Resend calls", async () => {
+ const now = Date.now();
+ await db.insert(schema.suppressions).values({
+ address: "suppressed@example.com",
+ reason: "bounce",
+ source: "resend_webhook",
+ createdAt: now,
+ });
+
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ const result = await worker.send({
+ ...BASE_MSG,
+ to: "suppressed@example.com",
+ });
+
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("result.ok should be false");
+ expect(result.reason).toBe("suppressed");
+ expect(resendCalls).toHaveLength(0);
+ });
+
+ // -------------------------------------------------------------------------
+ // 5. Idempotency
+ // -------------------------------------------------------------------------
+ it("duplicate idempotency key: second call returns first id, one Resend call total", async () => {
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ const first = await worker.send({
+ ...BASE_MSG,
+ idempotencyKey: "test-idem-key-001",
+ });
+ const second = await worker.send({
+ ...BASE_MSG,
+ idempotencyKey: "test-idem-key-001",
+ });
+
+ expect(first.ok).toBe(true);
+ expect(second.ok).toBe(true);
+ if (!first.ok || !second.ok) throw new Error("both sends should succeed");
+
+ // Same id returned
+ expect(second.id).toBe(first.id);
+ // Only one Resend call made
+ expect(resendCalls).toHaveLength(1);
+ });
+
+ // -------------------------------------------------------------------------
+ // 6. Quota
+ // -------------------------------------------------------------------------
+ it("daily quota exceeded: returns { ok:false, reason:'quota_exceeded' }, zero Resend calls", async () => {
+ // Seed 90 sent rows today to trip the daily guard
+ const now = Date.now();
+ const rows = Array.from({ length: 90 }, (_, i) => ({
+ tool: "calamus" as const,
+ recipient: `user${i}@ampl.tools`,
+ subject: `[Calamus] Quota seed ${i}`,
+ status: "sent",
+ sentAt: now,
+ createdAt: now,
+ }));
+ for (const row of rows) {
+ await db.insert(schema.sends).values(row);
+ }
+
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ const result = await worker.send({ ...BASE_MSG });
+
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("result.ok should be false");
+ expect(result.reason).toBe("quota_exceeded");
+ expect(resendCalls).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Regression tests
+// ---------------------------------------------------------------------------
+
+describe("EmailWorker send() — regression tests", () => {
+ let db: ReturnType;
+
+ beforeEach(() => {
+ db = getEmailDb();
+ installResendFake();
+ });
+
+ afterEach(() => {
+ uninstallResendFake();
+ });
+
+ // A Resend failure must NOT leave a phantom "sent" row. The row is
+ // marked "failed" (excluded from quota), and a retry with the same
+ // idempotency key re-attempts delivery and can succeed.
+ it("a Resend failure marks the row 'failed', preserves the key for retry, and never reports a false success", async () => {
+ uninstallResendFake();
+ installResendFailFake();
+
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ const failed = await worker.send({
+ ...BASE_MSG,
+ to: "cr01@example.com",
+ idempotencyKey: "cr01-retry-key",
+ });
+
+ // The send reports failure, NOT { ok: true }
+ expect(failed.ok).toBe(false);
+ if (failed.ok) throw new Error("failed send should not be ok");
+ expect(failed.reason).toBe("error");
+ expect(resendCalls).toHaveLength(1);
+
+ // The persisted row is "failed" with no resend id — not a phantom "sent".
+ const afterFail = await db
+ .select()
+ .from(schema.sends)
+ .where(eq(schema.sends.idempotencyKey, "cr01-retry-key"));
+ expect(afterFail).toHaveLength(1);
+ expect(afterFail[0].status).toBe("failed");
+ expect(afterFail[0].resendId).toBeNull();
+
+ // A failed delivery does not count toward the quota.
+ expect(await checkQuota(db, { monthly: 2500, daily: 90 })).toBe("ok");
+
+ // Retry with the same key now re-drives delivery and succeeds.
+ uninstallResendFake();
+ installResendFake();
+ const retried = await worker.send({
+ ...BASE_MSG,
+ to: "cr01@example.com",
+ idempotencyKey: "cr01-retry-key",
+ });
+ expect(retried.ok).toBe(true);
+ if (!retried.ok) throw new Error("retry should succeed");
+ expect(retried.id).toBe(FAKE_RESEND_ID);
+ expect(resendCalls).toHaveLength(1);
+
+ // Still exactly one row for the key, now "sent".
+ const afterRetry = await db
+ .select()
+ .from(schema.sends)
+ .where(eq(schema.sends.idempotencyKey, "cr01-retry-key"));
+ expect(afterRetry).toHaveLength(1);
+ expect(afterRetry[0].status).toBe("sent");
+ expect(afterRetry[0].resendId).toBe(FAKE_RESEND_ID);
+ });
+
+ // A multi-recipient send is rejected before any Resend call, since
+ // the single unsubscribe token cannot bind to more than one recipient.
+ it("rejects a multi-recipient send without contacting Resend", async () => {
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ const result = await worker.send({
+ ...BASE_MSG,
+ to: ["one@example.com", "two@example.com"],
+ });
+
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("multi-recipient send should be rejected");
+ expect(result.reason).toBe("error");
+ expect(result.detail).toBe("multi_recipient_unsupported");
+ expect(resendCalls).toHaveLength(0);
+ });
+
+ // A missing/malformed quota ceiling must fail closed, not silently
+ // disable the quota (Number("x") === NaN; count >= NaN is always false).
+ it("a malformed quota ceiling fails closed with a config error and sends nothing", async () => {
+ const badEnv = {
+ ...env,
+ MONTHLY_QUOTA_CEILING: "not-a-number",
+ } as unknown as Env;
+
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, badEnv);
+
+ const result = await worker.send({ ...BASE_MSG, to: "wr06@example.com" });
+
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("malformed ceiling should fail closed");
+ expect(result.reason).toBe("error");
+ expect(result.detail).toBe("configuration_error");
+ expect(resendCalls).toHaveLength(0);
+ });
+
+ // If Resend returns 2xx with no `id` field, deliver() must throw
+ // (not silently write { resendId: undefined } and return { ok: true, id: undefined }).
+ it("a 2xx Resend response with no id field is treated as an error", async () => {
+ uninstallResendFake();
+ // Fake that returns 2xx but with an empty body (no id)
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url === "https://api.resend.com/emails") {
+ return new Response(JSON.stringify({}), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ throw new Error(`Unexpected fetch to: ${url}`);
+ }),
+ );
+
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ const result = await worker.send({ ...BASE_MSG, to: "wr05@example.com" });
+
+ // Must NOT return { ok: true } with an undefined id
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("missing Resend id should not be a success");
+ expect(result.reason).toBe("error");
+ });
+
+ // Suppression matching is case-insensitive: a send to a
+ // differently-cased suppressed address is blocked.
+ it("a send to a differently-cased suppressed address is blocked", async () => {
+ await db.insert(schema.suppressions).values({
+ address: "case@example.com",
+ reason: "bounce",
+ source: "resend_webhook",
+ createdAt: Date.now(),
+ });
+
+ const worker = new (
+ await import("../../workers/email")
+ ).default({} as unknown as ExecutionContext, env);
+
+ const result = await worker.send({ ...BASE_MSG, to: "CASE@Example.COM" });
+
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("differently-cased address should be suppressed");
+ expect(result.reason).toBe("suppressed");
+ expect(resendCalls).toHaveLength(0);
+ });
+});
diff --git a/tests/email/shell.test.ts b/tests/email/shell.test.ts
new file mode 100644
index 0000000..1d435a5
--- /dev/null
+++ b/tests/email/shell.test.ts
@@ -0,0 +1,393 @@
+/**
+ * renderEmailShell unit tests — bilingual shell output correctness
+ *
+ * Pure-function tests for `renderEmailShell`. No DB harness, no fetch stub,
+ * no `env`, no `beforeEach`/`afterEach` — `renderEmailShell` is a synchronous
+ * pure function returning `{ html, text }`.
+ *
+ * Cases covered:
+ * 1. Non-empty output: both html and text are non-empty strings.
+ * 2. Logo: html contains an
(shell is a fragment).
+ * 4. No compliance footer: html does NOT contain .
+ * 5. Text content: text contains the heading and at least one block's content.
+ * 6. Preheader presence: when preheader is supplied, a preheader {
+ it("returns a non-empty html string", () => {
+ const result = renderEmailShell(BASE_EN);
+ expect(result.html).toBeTruthy();
+ });
+
+ it("returns a non-empty text string", () => {
+ const result = renderEmailShell(BASE_EN);
+ expect(result.text).toBeTruthy();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 2. Logo
+// ---------------------------------------------------------------------------
+
+describe("renderEmailShell — logo", () => {
+ it("html contains an
![]()
{
+ const result = renderEmailShell(BASE_EN);
+ expect(result.html).toContain("
![]()
{
+ const withLogo: EmailShellInput = {
+ ...BASE_EN,
+ logoUrl: "https://example.com/test-logo.png",
+ };
+ const result = renderEmailShell(withLogo);
+ expect(result.html).toContain("https://example.com/test-logo.png");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 3. Fragment — no
+// ---------------------------------------------------------------------------
+
+describe("renderEmailShell — HTML fragment (no document wrapper)", () => {
+ it("html does NOT contain (shell is a fragment, not a full document)", () => {
+ const result = renderEmailShell(BASE_EN);
+ expect(result.html).not.toContain("