Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 24 additions & 17 deletions CONSUMING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
```
Expand Down Expand Up @@ -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`.
Expand All @@ -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 {
Expand All @@ -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<SendResult>`) — 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`.)

---

Expand All @@ -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 =
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:**

Expand Down
11 changes: 10 additions & 1 deletion app/email/routes/unsubscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
11 changes: 10 additions & 1 deletion app/email/routes/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
98 changes: 10 additions & 88 deletions app/email/types.ts
Original file line number Diff line number Diff line change
@@ -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<SendResult>` 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";
10 changes: 8 additions & 2 deletions kit/email/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
94 changes: 88 additions & 6 deletions kit/email/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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;
};
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
Loading
Loading