From 1b56c8983fbb33eb521d9a76fd2023cc162c5a91 Mon Sep 17 00:00:00 2001 From: Juan Cobo Betancourt Date: Fri, 29 May 2026 16:18:32 -0700 Subject: [PATCH 1/3] Fail closed when email Worker secrets are unset Reject unsubscribe, webhook, and send requests with a server error when their HMAC, webhook, or Resend secrets are missing, instead of operating under an empty key. --- app/email/routes/unsubscribe.ts | 11 ++++- app/email/routes/webhook.ts | 11 ++++- tests/email/send.test.ts | 56 +++++++++++++++++++++++++ tests/email/unsubscribe.test.ts | 74 +++++++++++++++++++++++++++++++++ tests/email/webhook.test.ts | 38 +++++++++++++++++ workers/email.ts | 19 +++++++++ 6 files changed, 207 insertions(+), 2 deletions(-) diff --git a/app/email/routes/unsubscribe.ts b/app/email/routes/unsubscribe.ts index 5553fd1..cafc621 100644 --- a/app/email/routes/unsubscribe.ts +++ b/app/email/routes/unsubscribe.ts @@ -278,8 +278,17 @@ export async function handleUnsubscribe( // 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; + UNSUB_HMAC_SECRET?: string; }; + // Fail closed on a misconfigured environment: an unset secret must reject + // the request (server error) rather than verify the token under an empty + // HMAC key. + if (!UNSUB_HMAC_SECRET) { + logError(new Error("UNSUB_HMAC_SECRET missing"), { + action: "email.unsubscribe.secret", + }); + return new Response("Internal Server Error", { status: 500 }); + } const address = await verifyUnsubToken(token, UNSUB_HMAC_SECRET); if (!address) { return new Response("Forbidden: invalid token", { status: 403 }); diff --git a/app/email/routes/webhook.ts b/app/email/routes/webhook.ts index 18d6dd6..f37b43d 100644 --- a/app/email/routes/webhook.ts +++ b/app/email/routes/webhook.ts @@ -60,8 +60,17 @@ export async function handleWebhook( // 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; + RESEND_WEBHOOK_SECRET?: string; }; + // Fail closed on a misconfigured environment: an unset secret must reject + // the event (server error) rather than reach signature verification with an + // empty key. Never accept an unverified webhook. + if (!RESEND_WEBHOOK_SECRET) { + logError(new Error("RESEND_WEBHOOK_SECRET missing"), { + action: "email.webhook.secret", + }); + return new Response("Internal Server Error", { status: 500 }); + } const valid = await verifySvixSignature( rawBody, request.headers, diff --git a/tests/email/send.test.ts b/tests/email/send.test.ts index 5def173..cf365bc 100644 --- a/tests/email/send.test.ts +++ b/tests/email/send.test.ts @@ -448,3 +448,59 @@ describe("EmailWorker send() — regression tests", () => { expect(resendCalls).toHaveLength(0); }); }); + +// --------------------------------------------------------------------------- +// Missing-secret fail-closed guards (parity with the auth Worker). +// +// send() must refuse to deliver if any required secret is unset/empty rather +// than signing the unsubscribe token with a guessable key or calling Resend +// with an "undefined" Bearer token. A misconfigured deployment fails closed +// with detail "configuration_error" and makes ZERO Resend calls. +// --------------------------------------------------------------------------- + +describe("EmailWorker send() — missing-secret guards", () => { + beforeEach(() => installResendFake()); + afterEach(() => uninstallResendFake()); + + it("returns configuration_error and makes zero Resend calls when UNSUB_HMAC_SECRET is unset", async () => { + const noSecretEnv = { + ...env, + UNSUB_HMAC_SECRET: undefined, + } as unknown as Env; + const worker = new ( + await import("../../workers/email") + ).default({} as unknown as ExecutionContext, noSecretEnv); + + const result = await worker.send({ + ...BASE_MSG, + to: `cfgerr-unsub-${Date.now()}@example.com`, + }); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("send should fail closed on a missing secret"); + expect(result.reason).toBe("error"); + expect(result.detail).toBe("configuration_error"); + expect(resendCalls).toHaveLength(0); + }); + + it("returns configuration_error and makes zero Resend calls when RESEND_API_KEY is unset", async () => { + const noSecretEnv = { + ...env, + RESEND_API_KEY: "", + } as unknown as Env; + const worker = new ( + await import("../../workers/email") + ).default({} as unknown as ExecutionContext, noSecretEnv); + + const result = await worker.send({ + ...BASE_MSG, + to: `cfgerr-apikey-${Date.now()}@example.com`, + }); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("send should fail closed on a missing secret"); + expect(result.reason).toBe("error"); + expect(result.detail).toBe("configuration_error"); + expect(resendCalls).toHaveLength(0); + }); +}); diff --git a/tests/email/unsubscribe.test.ts b/tests/email/unsubscribe.test.ts index 11163f9..85dcf9d 100644 --- a/tests/email/unsubscribe.test.ts +++ b/tests/email/unsubscribe.test.ts @@ -205,3 +205,77 @@ describe("handleUnsubscribe — POST", () => { expect(res.status).toBe(429); }); }); + +// --------------------------------------------------------------------------- +// Missing-secret fail-closed guard +// +// If UNSUB_HMAC_SECRET is unset, the runtime would coerce `undefined` to the +// guessable string "undefined" as the HMAC key — an attacker could then forge +// a token with that known key and suppress an arbitrary address. The handler +// must refuse to process the request (500, no DB write) rather than verify a +// token under a guessable key. +// --------------------------------------------------------------------------- + +describe("handleUnsubscribe — missing UNSUB_HMAC_SECRET", () => { + it("returns 500 and writes no suppressions row when the secret is unset", async () => { + const address = `missing-unsub-secret-${Date.now()}@example.com`; + // A token that WOULD verify under the real secret; the guard must fire first. + const token = await signUnsubToken(address, env.UNSUB_HMAC_SECRET); + const noSecretEnv = { + ...env, + UNSUB_HMAC_SECRET: undefined, + } as unknown as Env; + + const req = new Request("https://ampl.tools/email/unsubscribe", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "CF-Connecting-IP": `10.9.1.${Date.now() % 200}`, + }, + body: new URLSearchParams({ token }).toString(), + }); + const res = await handleUnsubscribe(req, noSecretEnv); + + expect(res.status).toBe(500); + + const db = getEmailDb(); + const row = await db + .select() + .from(schema.suppressions) + .where(eq(schema.suppressions.address, address)) + .get(); + expect(row).toBeUndefined(); + }); + + it("rejects a token forged with the guessable 'undefined' key when the secret is unset", async () => { + const address = `weak-key-forge-${Date.now()}@example.com`; + // The attack: with the secret unset, an unguarded handler would HMAC with + // the literal "undefined". The attacker forges a token using that key. + const forged = await signUnsubToken(address, "undefined"); + const noSecretEnv = { + ...env, + UNSUB_HMAC_SECRET: undefined, + } as unknown as Env; + + const req = new Request("https://ampl.tools/email/unsubscribe", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "CF-Connecting-IP": `10.9.2.${Date.now() % 200}`, + }, + body: new URLSearchParams({ token: forged }).toString(), + }); + const res = await handleUnsubscribe(req, noSecretEnv); + + // Must NOT be a 200 suppression — the guard fails closed before verifying. + expect(res.status).toBe(500); + + const db = getEmailDb(); + const row = await db + .select() + .from(schema.suppressions) + .where(eq(schema.suppressions.address, address)) + .get(); + expect(row).toBeUndefined(); + }); +}); diff --git a/tests/email/webhook.test.ts b/tests/email/webhook.test.ts index 4f3988f..47003b4 100644 --- a/tests/email/webhook.test.ts +++ b/tests/email/webhook.test.ts @@ -248,3 +248,41 @@ describe("handleWebhook — idempotency", () => { expect(res2.status).toBe(200); }); }); + +// --------------------------------------------------------------------------- +// Missing-secret fail-closed guard +// +// If RESEND_WEBHOOK_SECRET is unset the handler must reject the event with no +// DB write, never silently accept an unverified webhook. Returns 5xx rather +// than 200/403 because this is a server misconfiguration, not a bad signature. +// --------------------------------------------------------------------------- + +describe("handleWebhook — missing RESEND_WEBHOOK_SECRET", () => { + it("rejects with no suppressions row when the secret is unset", async () => { + const address = `missing-wh-secret-${Date.now()}@example.com`; + const body = makeBouncePayload(address); + // Sign with the real fixture secret so only the missing-secret guard differs. + const req = await makeSignedRequest( + body, + env.RESEND_WEBHOOK_SECRET, + undefined, + `10.8.1.${Date.now() % 200}`, + ); + const noSecretEnv = { + ...env, + RESEND_WEBHOOK_SECRET: undefined, + } as unknown as Env; + + const res = await handleWebhook(req, noSecretEnv); + + expect(res.status).toBe(500); + + const db = getEmailDb(); + const row = await db + .select() + .from(schema.suppressions) + .where(eq(schema.suppressions.address, address)) + .get(); + expect(row).toBeUndefined(); + }); +}); diff --git a/workers/email.ts b/workers/email.ts index 61ecbf8..701fd64 100644 --- a/workers/email.ts +++ b/workers/email.ts @@ -128,6 +128,25 @@ export default class EmailWorker extends WorkerEntrypoint { const db = getEmailDb(this.env); const now = Date.now(); + // (1a) Fail closed on a misconfigured environment. RESEND_API_KEY and + // UNSUB_HMAC_SECRET are provisioned via `wrangler secret put` and are not + // typed on Env. If either is missing we refuse to deliver — rather than + // sign the unsubscribe token with an empty HMAC key or call Resend with an + // empty Bearer — and surface a clean "configuration_error" before any DB + // write. The underlying crypto already throws on an empty key; this makes + // the fail-closed behaviour explicit and avoids a wasted Resend call. + // Mirrors the auth Worker's missing-secret guard. + const secrets = this.env as unknown as { + UNSUB_HMAC_SECRET?: string; + RESEND_API_KEY?: string; + }; + if (!secrets.UNSUB_HMAC_SECRET || !secrets.RESEND_API_KEY) { + logError(new Error("email send secrets missing"), { + action: "email.send.secrets", + }); + return { ok: false, reason: "error", detail: "configuration_error" }; + } + // The worker currently delivers to a single recipient per call. The // compliance footer and List-Unsubscribe token are bound to one address; // a multi-recipient send (msg.to as an array of length > 1) would embed From 2f0af9157cccd3fe8dba5d15ef1344b9ccf1a93b Mon Sep 17 00:00:00 2001 From: Juan Cobo Betancourt Date: Fri, 29 May 2026 16:18:44 -0700 Subject: [PATCH 2/3] Export the send() RPC contract from @ampl/kit/email Move SendMessage and SendResult into the shared kit/email types and re-export them so consumers type their EMAIL service binding against the published contract; the email Worker re-exports them for its own use. --- CONSUMING.md | 41 +++++++++++-------- app/email/types.ts | 98 +++++----------------------------------------- kit/email/index.ts | 10 ++++- kit/email/types.ts | 94 +++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 130 insertions(+), 113 deletions(-) diff --git a/CONSUMING.md b/CONSUMING.md index 10d1111..f262f95 100644 --- a/CONSUMING.md +++ b/CONSUMING.md @@ -198,7 +198,7 @@ Add the dependency to your `package.json`: ```json { "dependencies": { - "@ampl/kit": "github:UCSB-AMPLab/ampl-kit#v0.2.0" + "@ampl/kit": "github:UCSB-AMPLab/ampl-kit#v0.2.1" } } ``` @@ -243,7 +243,7 @@ export const links: Route.LinksFunction = () => [ 2. Update the `#vX.Y.Z` ref in `package.json`: ```json - "@ampl/kit": "github:UCSB-AMPLab/ampl-kit#v0.2.0" + "@ampl/kit": "github:UCSB-AMPLab/ampl-kit#v0.2.1" ``` 3. Run `npm install` to fetch the new ref and update `package-lock.json`. @@ -257,11 +257,12 @@ 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. +The `@ampl/kit/email` subpath ships two pure TypeScript functions — +`renderEmailShell` (the branded HTML + plain-text email shell) and `buildIcs` +(a pure RFC 5545 `.ics` calendar attachment builder) — plus the type contracts +for both the shell/`.ics` inputs and the `send()` RPC. These are the only +surfaces from `@ampl/kit/email`; the underlying modules are not part of the +public contract. ```typescript import { @@ -270,12 +271,17 @@ import { type EmailShellInput, type EmailBlock, type IcsEvent, + type SendMessage, // send() RPC contract — exported as of v0.2.1 + type SendResult, // send() RPC contract — exported as of v0.2.1 } 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. +tool's Worker environment (`env.EMAIL.send(msg): Promise`) — the +service binding is configured in your `wrangler.jsonc`, not imported from this +subpath, but its `SendMessage` / `SendResult` contract is. (On `v0.2.0` these +two types were not exported; if you are still pinned there, vendor them from +the email Worker's `app/email/types.ts`.) --- @@ -285,8 +291,7 @@ 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 +import { renderEmailShell, type EmailShellInput, type SendMessage } from "@ampl/kit/email"; function buildInvitationMessage(locale: "en" | "es"): SendMessage { const input: EmailShellInput = @@ -354,8 +359,7 @@ 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"; +import { renderEmailShell, buildIcs, type EmailShellInput, type IcsEvent, type SendMessage } from "@ampl/kit/email"; function buildSchedulingMessage( subject: string, @@ -499,12 +503,15 @@ const input: EmailShellInput = { 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" +"@ampl/kit": "github:UCSB-AMPLab/ampl-kit#v0.2.1" ``` -**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. +**This release:** `v0.2.1` exports the `send()` RPC contract (`SendMessage`, +`SendResult`) from `./email` so consumers type their `EMAIL` service binding +against the published contract instead of vendoring it — additive, no breaking +change. (`v0.2.0` added the `./email` subpath itself: `renderEmailShell`, +`buildIcs`, `EmailShellInput`, `EmailBlock`, `IcsEvent`.) Consumers on `v0.1.0` +are unaffected — the `./auth` and `./ui` subpaths are unchanged. **Policy:** diff --git a/app/email/types.ts b/app/email/types.ts index 0233a89..fb0cd1b 100644 --- a/app/email/types.ts +++ b/app/email/types.ts @@ -1,94 +1,16 @@ /** - * Email service public contract + * Email service public contract — Worker-side re-export * - * 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. + * The canonical `SendMessage` / `SendResult` definitions moved to the shared + * library at `kit/email/types.ts` in v0.2.1, so consumers can type their + * `EMAIL` service binding against the published, tag-versioned + * `@ampl/kit/email` contract instead of vendoring a hand-copied interface. * - * Consumers call `env.EMAIL.send(msg: SendMessage): Promise` via a - * Cloudflare service binding — the Resend API key never leaves the email Worker. + * This file re-exports them for the email Worker's own internal imports + * (`workers/email.ts`, the email tests), keeping those import paths stable. + * The Worker thus implements the library contract rather than owning it. * - * @version v0.2.0 + * @version v0.2.1 */ -/** - * 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; - }; +export type { SendMessage, SendResult } from "../../kit/email/types"; diff --git a/kit/email/index.ts b/kit/email/index.ts index 1d128ed..e34d696 100644 --- a/kit/email/index.ts +++ b/kit/email/index.ts @@ -11,9 +11,15 @@ * (one version number for the whole subpath); see CONSUMING.md for the * breaking-change policy and copy-paste integration recipes. * - * @version v0.2.0 + * @version v0.2.1 */ export { renderEmailShell } from "./shell"; export { buildIcs } from "./ics"; -export type { EmailShellInput, EmailBlock, IcsEvent } from "./types"; +export type { + EmailShellInput, + EmailBlock, + IcsEvent, + SendMessage, + SendResult, +} from "./types"; diff --git a/kit/email/types.ts b/kit/email/types.ts index 861a4ae..e3baacd 100644 --- a/kit/email/types.ts +++ b/kit/email/types.ts @@ -2,16 +2,20 @@ * 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. + * contract: `SendMessage`, `SendResult`, `EmailShellInput`, `EmailBlock`, and + * `IcsEvent`. Every tool that renders an email shell, builds a calendar + * attachment, or calls the `EMAIL.send()` RPC imports from here. + * + * `SendMessage` / `SendResult` are the canonical `send()` RPC contract as of + * v0.2.1 — they live here (the shared library) rather than in the email + * Worker's `app/email/types.ts`, so consumers type their `EMAIL` service + * binding against the published, tag-versioned contract instead of vendoring a + * hand-copied interface. The Worker imports them back from here. * * Named exports only. No default export. No runtime values — type declarations * are compile-time only. * - * @version v0.2.0 + * @version v0.2.1 */ // ───────────────────────────────────────────────────────────────────────────── @@ -132,3 +136,81 @@ export interface IcsEvent { attendees: { name?: string; email: string }[]; url?: string; } + +// ───────────────────────────────────────────────────────────────────────────── +// send() RPC contract — SendMessage / SendResult +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The message shape passed to `env.EMAIL.send(msg)` via the Cloudflare service + * binding to the `ampl-email` Worker. The Resend API key never leaves that + * Worker — consumers only ever hold this contract. + * + * Required fields: + * - `to`, `subject`, `html`, `text` — core email content. Callers include the + * `"[ToolName] "` subject prefix; the Worker prepends nothing. NOTE: the + * Worker delivers to a single recipient per call — passing more than one + * address is rejected (`reason:"error"`, `detail:"multi_recipient_unsupported"`). + * - `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"); defaults + * to "en". + * + * Optional fields typed ahead of implementation: + * - `attachments` — for `.ics` attachments (Scheduling) and similar. Pass raw + * `string` (e.g. `.ics` text) or `ArrayBuffer` (binary); the Worker re-encodes + * `content` to base64 for the Resend REST API. + * - `replyTo` — per-tool reply-to address (not yet implemented by the Worker). + */ +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. Pass raw `string` (e.g. `.ics` text) or `ArrayBuffer` (binary); + * the Worker re-encodes to base64 for the Resend REST API. + */ + attachments?: Array<{ + content: string | ArrayBuffer; + filename: string; + type: string; + disposition?: "attachment" | "inline"; + contentId?: string; + }>; + + /** Reply-To address (per-tool reply-to). Not yet implemented by the Worker. */ + replyTo?: string; +} + +/** + * The result returned by `env.EMAIL.send(msg)`. + * + * On success: `{ ok: true, id }` — the Resend message ID. + * On failure: `{ ok: false, reason, detail? }` — the Worker rejected the send + * before (or instead of) calling Resend. Possible reasons: + * - `"suppressed"` — the recipient 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/transient (Resend failure, multi-recipient, + * `configuration_error`); `detail` carries a safe-to-log message. + */ +export type SendResult = + | { ok: true; id: string } + | { + ok: false; + reason: "suppressed" | "quota_exceeded" | "duplicate" | "error"; + detail?: string; + }; From dde459c3bd95d290dcfa925a5522fe35be3c064c Mon Sep 17 00:00:00 2001 From: Juan Cobo Betancourt Date: Fri, 29 May 2026 16:19:37 -0700 Subject: [PATCH 3/3] Bump @ampl/kit to 0.2.1 Patch release: send() RPC contract export and email secret fail-closed guards. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a7ea88e..884c264 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ampl/kit", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ampl/kit", - "version": "0.2.0", + "version": "0.2.1", "hasInstallScript": true, "dependencies": { "@oslojs/crypto": "^1.0.1", diff --git a/package.json b/package.json index 5c0557a..8f1e4d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ampl/kit", - "version": "0.2.0", + "version": "0.2.1", "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.",