Skip to content

Add the bool.email battery - #23

Closed
max-bader wants to merge 1 commit into
mainfrom
claude/bool-email-battery
Closed

Add the bool.email battery#23
max-bader wants to merge 1 commit into
mainfrom
claude/bool-email-battery

Conversation

@max-bader

@max-bader max-bader commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Adds the second SDK battery: a deployed app can send email with no SMTP config, no
provider account, and no API key in the bundle.

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
});

Calls route through the gateway's email plane (/_bool/v1/email/send), which
renders the message, meters the owner's app-credit pool, and queues it in a
durable outbox — so once send resolves, the platform keeps retrying delivery
server-side. Platform side: codehs/bool#574.

Recipients are restricted, and that's the feature

Only two kinds of recipient work, enforced server-side:

  1. the app's owner — passed as the literal 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 with
recipient_not_allowed. An app that could email anyone would make Bool's sending
domain a spam relay, and the cost of that lands on every other app's
deliverability — including platform password-reset mail.

The "owner" sentinel exists because the client has no way to know the owner's
address, and the alternative — a builder pasting their own email into app source —
publishes it in every visitor's browser bundle. It resolves server-side.

There's also 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.

Notes on the implementation

  • Lives in client.ts next to bool.ai, sharing its header helper (renamed
    to cover both batteries) — same transport, same credentials: "include" +
    viewer/eu-session identity replay as the db, users and ai planes.
  • No client-side validation, deliberately. The gateway is the authority on the
    recipient rule, body limits and https-only button URLs. A second copy of those
    rules here would drift from it and, worse, would reject things a newer gateway
    allows. What the client owes the caller is a clear error instead.
  • BoolEmailError carries detail — the gateway's human-readable
    explanation — on top of status/code. recipient_not_allowed is the one 4xx
    a builder will hit constantly while building, and swallowing the explanation
    would turn it into a support ticket.
  • { duplicate: true } (an idempotencyKey matched an existing send) and
    { suppressed: true } (the address hard-bounced or reported spam, so the
    platform's do-not-mail list dropped it) resolve normally rather than throwing —
    neither is the app's bug, and throwing would crash an app over one dead address.

Drive-by fix

BoolAiError's docs still promised out_of_ai_credits, which the platform's
rename of the battery pool to app credits ("juice") broke — its own comment noted
the SDK needed a matching release. Now documented as out_of_app_credits, with a
note to branch on status === 402 if you need to cover older gateways too.

Testing

bun test 161 pass, tsc --noEmit clean, bun run build clean. 9 new cases
covering the transport, verbatim field pass-through, the owner sentinel never
being resolved client-side, typed errors (including a non-JSON error body), the
duplicate/suppressed results, and viewer-token replay.

Ships as 0.2.0-next.26 on the canary channel. Not publishable until the
platform PR merges
— the plane 404s without it, and the bool-email workspace
flag is off by default.

🤖 Generated with Claude Code

@HomemadeToast57

Copy link
Copy Markdown
Collaborator

shelving for now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants