Skip to content
Closed
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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.**
Expand Down
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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`): `<BoolAuthProvider>`,
`useBoolAuth()`, `<AuthGate>`, and the headless `useSignInForm()` state
machine that login forms bind to.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
122 changes: 122 additions & 0 deletions src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
hasDefaultBoolClient,
isDeploymentSubdomain,
BoolAiError,
BoolEmailError,
type BoolClientConfig,
} from "./client";

Expand Down Expand Up @@ -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("<html>502</html>", { 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",
Expand Down
Loading
Loading