diff --git a/CHANGELOG.md b/CHANGELOG.md index 038c55e..cc67ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 0.2.0-next.26 + +- **Adds the email battery: `bool.email.send({ to, subject, body })`.** A deployed + app can now send email with no SMTP config, no provider account and no API key + — the gateway's email plane (`/_bool/v1/email`) renders the message, meters the + owner's app-credit pool (2 per send, the same pool `bool.ai` spends), and queues + it in a durable outbox, so a resolved `send` will keep retrying delivery + server-side. Requires the `bool-email` server flag. + + **Recipients are restricted, by design.** Only the app's **owner** — passed as + the literal `to: "owner"`, resolved server-side so the address never ships in + the bundle — or a signed-in **end user of this app whose email is verified**. + An address a visitor typed into a form is refused with `recipient_not_allowed`. + An app that could email anyone would make Bool's sending domain a spam relay, + and the cost of that would land on every other app's deliverability. + + There is no HTML body, no attachments and no custom `from`: every message + renders into one fixed Bool template. `body` is plain text (blank lines become + paragraphs) plus at most one https `button`. + + New exports: `BoolEmailError` (`status`, `code`, `detail`), and the types + `BoolEmail`, `BoolEmailMessage`, `BoolEmailResult`. `{ duplicate: true }` (an + `idempotencyKey` matched) and `{ suppressed: true }` (the address hard-bounced + or reported spam) resolve normally rather than throwing. + +- **`BoolAiError`'s 402 code is now `out_of_app_credits`.** The platform renamed + the battery pool from "AI credits" to app credits ("juice" in the UI), so the + wire code names the shared POOL rather than one plane — the same string now + means the same thing for every battery. Docs/comments only; no code change. + Branch on `status === 402` if you need to also handle older gateways, which sent + `out_of_ai_credits`. + ## 0.2.0-next.25 - **Fixes live updates being silently swallowed on the public fallback channel.** diff --git a/README.md b/README.md index f2152e0..803caa9 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ tested, and upgradable independently of any one app. - **AI battery.** `client.ai` gives a deployed app server-side AI with **no API key in the bundle** — calls route through the gateway's AI plane (`/_bool/v1/ai`), which runs the prompt against Bool's provider credential and - meters one AI credit against the app owner. Returns results directly and throws - a `BoolAiError` (with `status` + `code`, e.g. `"out_of_ai_credits"`) on failure: + meters the app owner's app-credit pool. Returns results directly and throws + a `BoolAiError` (with `status` + `code`, e.g. `"out_of_app_credits"`) on failure: ```ts const text = await bool.ai.generate("Summarize this review: " + review); @@ -64,6 +64,38 @@ tested, and upgradable independently of any one app. for await (const chunk of bool.ai.stream("Write a haiku")) setText((t) => t + chunk); ``` Requires the workspace to be opted into the `bool-ai` server flag. +- **Email battery.** `client.email` lets a deployed app send email with **no SMTP + config and no API key** — calls route through the gateway's email plane + (`/_bool/v1/email`), which renders the message, meters the same app-credit pool + as `bool.ai`, and queues it in a durable outbox (so a resolved `send` will keep + retrying delivery server-side). + ```ts + await bool.email.send({ + to: "owner", // the person who built this app + subject: "New contact form submission", + body: `From: ${name}\n\n${message}`, // plain text; blank lines = paragraphs + button: { label: "Open the app", url: appUrl }, // optional, https only + replyTo: "owner", // optional + }); + ``` + **Who you can email is enforced server-side**, and it's a short list: the app's + **owner** (pass the literal `"owner"`, so their address never ships in the + bundle) or a signed-in **end user of this app who has verified their address**. + An address a visitor merely typed into a form is refused with + `recipient_not_allowed`. That's deliberate — an app that could email anyone + would turn Bool's sending domain into a spam relay, and every other app's mail + would pay for it. + + There is no HTML body, no attachments and no custom `from`: app-authored markup + sent from a Bool-signed domain would be a ready-made phishing kit, so every + message renders into one fixed template carrying the app's name. + + Throws a `BoolEmailError` (`status`, `code`, and `detail` — the gateway's + human-readable explanation) on failure. Two non-failures resolve normally: + `{ duplicate: true }` when an `idempotencyKey` matched an existing send, and + `{ suppressed: true }` when the address is on the platform's do-not-mail list + (it previously hard-bounced or reported spam). Requires the workspace to be + opted into the `bool-email` server flag. - **React auth layer** (`bool-sdk/react`): ``, `useBoolAuth()`, ``, and the headless `useSignInForm()` state machine that login forms bind to. diff --git a/package.json b/package.json index 00862b2..5693c56 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bool-sdk", - "version": "0.2.0-next.25", - "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).", + "version": "0.2.0-next.26", + "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, the AI and email batteries, the React auth layer, and the local-dev CLI (link, types, deploy).", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/src/client.test.ts b/src/client.test.ts index d69433b..54e6202 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -5,6 +5,7 @@ import { hasDefaultBoolClient, isDeploymentSubdomain, BoolAiError, + BoolEmailError, type BoolClientConfig, } from "./client"; @@ -415,6 +416,127 @@ describe("bool.ai battery", () => { }); }); +describe("bool.email battery", () => { + const OK = () => + new Response(JSON.stringify({ id: "email_1" }), { + status: 202, + headers: { "content-type": "application/json" }, + }); + const MSG = { to: "owner", subject: "New submission", body: "Someone wrote in." }; + + test("send POSTs to the email plane and returns the queued id", async () => { + respond = OK; + const client = createBoolClient(CONFIG); + const out = await client.email.send(MSG); + expect(out).toEqual({ id: "email_1" }); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://bool.test/served/my-app/_bool/v1/email/send"); + expect(calls[0]!.init?.method).toBe("POST"); + expect(calls[0]!.init?.credentials).toBe("include"); + }); + + test("passes every field through verbatim (the gateway is the authority)", async () => { + respond = OK; + const client = createBoolClient(CONFIG); + await client.email.send({ + ...MSG, + button: { label: "Open", url: "https://app.test/x" }, + replyTo: "owner", + idempotencyKey: "order-42", + }); + expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ + to: "owner", + subject: "New submission", + body: "Someone wrote in.", + button: { label: "Open", url: "https://app.test/x" }, + replyTo: "owner", + idempotencyKey: "order-42", + }); + }); + + test("the owner sentinel is sent as-is — the address is never in the bundle", async () => { + // The whole point of `to: "owner"`: the client cannot know the owner's + // address, and resolving it here would mean shipping it to every visitor. + respond = OK; + const client = createBoolClient(CONFIG); + await client.email.send(MSG); + expect(JSON.parse(String(calls[0]!.init?.body)).to).toBe("owner"); + }); + + test("throws BoolEmailError carrying status, code and the explanation", async () => { + respond = () => + new Response( + JSON.stringify({ + error: "recipient_not_allowed", + message: "This app may only email its owner or a verified user.", + }), + { status: 403, headers: { "content-type": "application/json" } }, + ); + const client = createBoolClient(CONFIG); + const err = (await client.email + .send({ ...MSG, to: "stranger@example.com" }) + .catch((e) => e)) as BoolEmailError; + expect(err).toBeInstanceOf(BoolEmailError); + expect(err.status).toBe(403); + expect(err.code).toBe("recipient_not_allowed"); + // The gateway's explanation is what makes this actionable while building — + // it must reach the app author, not be swallowed. + expect(err.detail).toContain("owner"); + expect(err.message).toContain("recipient_not_allowed"); + }); + + test("out-of-credits names the shared pool, not this battery", async () => { + respond = () => + new Response(JSON.stringify({ error: "out_of_app_credits" }), { + status: 402, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const err = (await client.email.send(MSG).catch((e) => e)) as BoolEmailError; + expect(err.code).toBe("out_of_app_credits"); + expect(err.status).toBe(402); + }); + + test("a non-JSON error body still throws a typed error", async () => { + respond = () => new Response("502", { status: 502 }); + const client = createBoolClient(CONFIG); + const err = (await client.email.send(MSG).catch((e) => e)) as BoolEmailError; + expect(err).toBeInstanceOf(BoolEmailError); + expect(err.code).toBe("email_failed"); + expect(err.detail).toBeNull(); + }); + + test("a deduped send resolves as a duplicate, not an error", async () => { + respond = () => + new Response(JSON.stringify({ id: null, duplicate: true }), { + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + expect(await client.email.send({ ...MSG, idempotencyKey: "k" })).toEqual({ + id: null, + duplicate: true, + }); + }); + + test("a suppressed recipient resolves, and says so", async () => { + // The do-not-mail list doing its job is not the app's bug — throwing here + // would crash an app over one dead address. + respond = () => + new Response(JSON.stringify({ id: null, suppressed: true }), { + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + expect(await client.email.send(MSG)).toEqual({ id: null, suppressed: true }); + }); + + test("replays the preview viewer token as x-bool-viewer", async () => { + respond = OK; + const client = createBoolClient({ ...CONFIG, viewerToken: "viewer-123" }); + await client.email.send(MSG); + expect(headersOf(calls[0]!).get("x-bool-viewer")).toBe("viewer-123"); + }); +}); + // The registry has to be a singleton across module INSTANCES, not just within // one. Regression coverage for a real break: a generated app imported // `createBoolClient` from "bool-sdk" and `useEntity` from "bool-sdk/react", diff --git a/src/client.ts b/src/client.ts index 2cc6989..4a7f8e8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -151,8 +151,13 @@ export type BoolChangePayload = { export type BoolAiSchema = Record; /** Thrown when a bool.ai call fails. `status` is the gateway HTTP status and - * `code` its machine-readable error (e.g. "out_of_ai_credits" on a 402, - * "rate_limited" on a 429) so app code can branch without string-matching. */ + * `code` its machine-readable error (e.g. "out_of_app_credits" on a 402, + * "rate_limited" on a 429) so app code can branch without string-matching. + * + * The 402 code names the shared app-credit POOL every battery draws on, not this + * plane, so the same string means the same thing for `bool.email` and whatever + * ships next. (Gateways predating that rename sent "out_of_ai_credits"; branch on + * `status === 402` if you need to cover both.) */ export class BoolAiError extends Error { readonly status: number; readonly code: string; @@ -180,6 +185,86 @@ export type BoolAi = { stream(prompt: string): AsyncIterable; }; +/** Thrown when a `bool.email` call fails. Same shape as {@link BoolAiError}. + * + * The codes worth branching on: + * - `recipient_not_allowed` (403) — the address is neither the app owner nor a + * verified user of this app. See {@link BoolEmail} for the rule; `detail` + * carries a human-readable explanation worth showing while building. + * - `reply_to_not_allowed` (403) — `replyTo` may only be the owner. + * - `out_of_app_credits` (402) — the owner's shared battery pool is empty. + * - `rate_limited` (429) — a per-caller, per-app or per-recipient cap. + * - `invalid_email` / `missing_subject` / `invalid_button` / … (400) — bad input. + */ +export class BoolEmailError extends Error { + readonly status: number; + readonly code: string; + /** The gateway's human-readable explanation, when it sent one. */ + readonly detail: string | null; + constructor(code: string, status: number, detail: string | null = null) { + super( + detail + ? `bool.email failed: ${code} (${status}) — ${detail}` + : `bool.email failed: ${code} (${status})`, + ); + this.name = "BoolEmailError"; + this.code = code; + this.status = status; + this.detail = detail; + } +} + +/** One message for {@link BoolEmail.send}. */ +export type BoolEmailMessage = { + /** Who to email. Either the literal `"owner"` — the person who built this app, + * resolved server-side so their address never ships in the bundle — or the + * address of a signed-in end user of this app who has verified their email. + * Anything else is refused with `recipient_not_allowed`. */ + to: string; + subject: string; + /** PLAIN TEXT; blank lines become paragraphs. There is no HTML field on + * purpose — app-authored markup sent from a Bool-signed domain would be a + * ready-made phishing kit, so every message renders into one fixed template. */ + body: string; + /** An optional single call to action. `url` must be https. */ + button?: { label: string; url: string }; + /** Where replies go. Only the owner's address, or the literal `"owner"`. */ + replyTo?: string; + /** Send-once key. A repeat with the same key returns `{ id: null, duplicate: + * true }` instead of sending again — use it when a retry, or a double-clicked + * button, must not produce two emails. */ + idempotencyKey?: string; +}; + +/** The result of a send. The message is queued durably: once this resolves, the + * platform keeps retrying delivery on its own. */ +export type BoolEmailResult = { + /** The queued message's id, or null when nothing new was queued. */ + id: string | null; + /** True when `idempotencyKey` matched a message already queued. */ + duplicate?: boolean; + /** True when the address is on the platform's do-not-mail list (it previously + * hard-bounced or reported spam). NOT an error — the send is dropped, because + * retrying it would damage deliverability for every Bool app. */ + suppressed?: boolean; +}; + +/** The email battery — send email with NO SMTP setup and NO API key in the app + * bundle. Each call routes through the Bool gateway (/_bool/v1/email), which + * renders the message, meters the app owner's shared battery credits, and hands + * it to a durable outbox. Throws a {@link BoolEmailError} on failure. + * + * **Who you can email.** Only two kinds of recipient, enforced server-side: + * 1. the app's **owner** — pass `to: "owner"`; + * 2. a signed-in **end user of this app whose email is verified**. + * + * An address a visitor merely typed into a form is refused. That's deliberate: an + * app that could email anyone would make Bool's sending domain a spam relay, and + * the cost of that would land on every other app's mail. */ +export type BoolEmail = { + send(msg: BoolEmailMessage): Promise; +}; + /** The gateway-routed supabase-js client. Loosely typed on the schema-name * generic because each Bool runs in its own non-"public" schema. */ export type BoolDb = SupabaseClient; @@ -197,6 +282,10 @@ export type BoolClient = { /** The AI battery: `ai.generate(prompt)` / `ai.generate({prompt, schema})` / * `ai.stream(prompt)`. Server-side AI with no API key in the bundle. */ ai: BoolAi; + /** The email battery: `email.send({ to, subject, body })`. No SMTP, no API + * key. Can email the app's owner (`to: "owner"`) or a verified user of the + * app — see {@link BoolEmail} for why nobody else. */ + email: BoolEmail; /** This app's private Postgres schema name. */ schema: string; /** Subscribe to the app's realtime "doorbell": fires whenever any row in the @@ -561,9 +650,9 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { }, }; - // bool.ai battery — POST the prompt to the gateway AI plane - // (/_bool/v1/ai/*), which runs it against Bool's provider credential and - // meters one AI credit against the app owner. credentials:include + the + // Battery planes (bool.ai, bool.email) — POST to /_bool/v1//*, which + // runs the call against Bool's own server-side credential and meters it + // against the app owner's shared battery pool. credentials:include + the // viewer/eu-session identity headers mirror the db and users planes so the // same live-gate identity flows (same-origin cookie deployed, viewer token // cross-origin in preview). @@ -630,6 +719,55 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { }, }; + // bool.email battery — POST the message to the gateway email plane + // (/_bool/v1/email/send), which renders it into Bool's app-mail template, + // meters the owner's battery credits, and queues it in a durable outbox. The + // plane answers 202 once the message is committed; delivery (and any retries) + // happen server-side after that. + // + // Fields are passed through verbatim rather than validated here: the gateway is + // the authority on what's allowed (recipient rule, body limits, https-only + // button URL), and a second copy of those rules in the client would inevitably + // drift from it — worse, it would reject things a newer gateway allows. What the + // client owes the caller is a clear error, which is what BoolEmailError carries. + const email: BoolEmail = { + async send(msg: BoolEmailMessage): Promise { + const res = await fetch(`${GATEWAY}/_bool/${GATEWAY_API}/email/send`, { + method: "POST", + headers: aiHeaders(), + credentials: "include", + body: JSON.stringify({ + to: msg.to, + subject: msg.subject, + body: msg.body, + button: msg.button, + replyTo: msg.replyTo, + idempotencyKey: msg.idempotencyKey, + }), + }); + let body: any = null; + try { + body = await res.json(); + } catch (_) {} + if (!res.ok) { + throw new BoolEmailError( + body?.error ?? "email_failed", + res.status, + typeof body?.message === "string" ? body.message : null, + ); + } + return { + id: body?.id ?? null, + ...(body?.duplicate ? { duplicate: true as const } : {}), + // A suppressed address is a 200, not a throw — it's the platform's + // do-not-mail list doing its job, not the app's bug to handle. Surfaced + // on the result for apps that want to say "we couldn't reach that + // address" rather than silently succeeding. + ...(body?.suppressed ? { suppressed: true as const } : {}), + }; + }, + }; + // Realtime doorbell — the private-channel design. Change payloads (with the // ROW on them for tables whose audience is nameable) arrive on PRIVATE // topics; the gateway mints the short-TTL wristband that lets this socket @@ -698,6 +836,7 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { entities: createEntitiesModule(db, subscribeToChanges), auth, ai, + email, schema, subscribeToChanges, }; diff --git a/src/index.ts b/src/index.ts index bc06389..4972384 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,10 @@ export { type BoolAi, type BoolAiSchema, BoolAiError, + type BoolEmail, + type BoolEmailMessage, + type BoolEmailResult, + BoolEmailError, type BoolUser, type BoolChangePayload, type AuthEvent,