# simple-email.pilely.app — outbound email for your registered Pilely apps

Base URL: `https://simple-email.pilely.app` (the `@simple_email` service). Every operation is a `POST` to a route below. This README is the only manual: `GET https://simple-email.pilely.app/README.md`, public, no credential needed. The platform directory of every service README is `https://pilely.app/README.md`.

## When to use

- The user's app needs to send email — sign-up confirmations, notifications, receipts, digests — from its own address.
- Send-only, per-app email accounts. An app's account sends from `<its-subdomain-label>@pilely-app-email.com`, with the app's display name as the friendly From name. Mail sent through this service comes from the builder's app on `pilely-app-email.com`, not from Pilely: Pilely's own mail comes only from `pilely.ai`, and no route, request field or label can make an account send from `pilely.ai`. No inbound: this service never receives mail. Replies go to the account's `reply_to`, an address the owner chooses (`POST /accounts/create`, `POST /accounts/{app_id}/set_reply_to`) and every `/send` carries as its `Reply-To`; without one, replies go nowhere.
- Templates live HERE, not in your bundle: a shipped SPA references a `template_id` plus variables, and you iterate the content anytime without redeploying the app.
- Email is notification, never workflow state — the db row is the truth, the email points at it.

## Auth

- Every route needs `Authorization: Bearer <token>` — your own token for this service, or the token an app holds for the caller. Every caller a route does not serve — a signed-out visitor, an agent, a person through an app the route does not admit — gets the uniform `404 not_found`, the same answer as an unknown account. How to sign in and get a token: `https://pilely.app/README.md`; the sign-in page is `https://pilely.app/~/login`.
- Which app a call comes through decides what it may reach. With your own token for this service you act on every account you own. Through an app's page (a token for app A), you reach only app A's own account: `accounts/create` and `sends/list` for app A, `accounts/list` showing only app A's account, and every `accounts/{app_id}/*` route only when `{app_id}` is A. Through any other app, the owner too gets the `404`. `send` is the one route a non-owner reaches: a `send_group` member, and — for account sends only, when the account's `account_senders` is `"app_users"` — any signed-in user of the app, both only through the account's own app.
- A backend app (a `@simple_server` box of app A, calling with its `PILELY_BACKEND_TOKEN`) is app A acting as itself, never its owner: it may `send` from app A's account only when app A (`subject_type: "app"`, the app's uuid) is a member of the account's `send_group`, and it reaches no other route.
- Owner surfaces: `accounts/*`, `accounts/{app_id}/templates/*`, `sends/list`. The send surface: `send`.
- Internal only: `~/internal/new_row_email/check` and `~/internal/new_row_email/send` answer only `@simple_db_v2`, which calls this service directly (not through root), as the db owner through `@simple_db_v2`'s own app, to email an app owner about new rows in their tables (see those routes). Every other caller — a person on any other app, a backend app, an agent, an anonymous visitor — gets the empty-body `404` an unknown path gets.

### Who can send from an account

`send` has two modes. A **raw send** (`to`) goes to the addresses the caller names. An **account send** (`to_account`) sends a template to one address the service looks up itself: the caller's own Pilely account email (`"self"`) or the account owner's (`"owner"`). The caller never supplies or sees that address, and account emails are proven at sign-in (the login code is mailed to them), so an account send never reaches an unchecked or third-party address.

Three tiers may send, and only through the right app:

- **The owner**: raw sends and account sends (`self` and `owner` are then both the owner's address), with their own token for this service or through the account's own app — never through some *other* app: an app the owner happens to be signed into cannot send as their app.
- **`send_group` members**, through the account's own app: a `user` member, and the account's app's own backend when the app is an `app` member. Full raw rights (any recipients, template or inline content) and account sends. `send_group` is a `@simple_group` group **the owner owns**, resolved per request; `null` means **owner-only** — NOT "everyone". The service never checks where a raw recipient address came from, so the group IS the safety boundary; curate it accordingly. Groups: `https://simple-group.pilely.app/README.md`.
- **Every signed-in user of the app**, through the account's own app, for **account sends only** — when the owner sets the account's `account_senders` to `"app_users"` (the default `"send_group"` keeps account sends to the owner and the group). It never opens raw sends: such a user sending with `to` gets the `404`.

| Caller | Raw send (`to`) | Account send (`to_account`) | Replay of own keyed send | `access/set` (incl. `account_senders`) · `set_reply_to` · `info` · `sends/list` |
|---|---|---|---|---|
| Account owner, with their own token for this service or through the account's own app | yes | yes (`self` and `owner` are both the owner's address) | yes | yes |
| The account's app's own backend, the app an `app` member of `send_group` | yes | yes (`owner` only) | yes | `404` |
| The account's app's own backend, not a member | `404` | `404` | `404` | `404` |
| Owner calling through another app's page or backend | `404` | `404` | `404` | `404` |
| `send_group` member, through the account's own app | yes | yes | yes | `404` |
| Signed-in non-member, through the account's own app, `account_senders: "send_group"` | `404` | `404` | `404` | `404` |
| Signed-in non-member, through the account's own app, `account_senders: "app_users"` | `404` | yes | yes (account sends only) | `404` |
| Any signed-in user through another app | `404` | `404` | `404` | `404` |
| An agent | `404` | `404` | `404` | `404` |
| Anonymous visitor, or signed out | `404` | `404` | `404` | `404` |

A replay of a keyed send first passes the same check as a new send: a caller who has lost access since gets the `404`. Everything else on this service (accounts, templates, the send log) is owner-only.

**When you set `"app_users"`, add a per-user `@simple_limiter` policy on `/send`**: `host: "simple-email.pilely.app"`, `app_id` your app's id, `path: "/send"`, `key: "user"` — for example a few sends per user per hour (`https://simple-limiter.pilely.app/README.md` → `### Limiting simple_email sends`). This service sets no per-caller limit of its own; without the policy one signed-in user can use up the app's daily cap and spend your balance. The policy never counts you, the owner.

A denied caller and a non-existent account get the same `404` — this service never reveals whether an account exists.

## Limits

| Limit | Value |
|---|---|
| Daily send cap | per app: 200 recipients per UTC day by default (`429 daily_cap_exceeded`); resets when the UTC day rolls over. `POST /accounts/{app_id}/info` shows your app's cap as `daily_cap`. For a higher cap, open a support ticket on `@customer` (`https://customer.pilely.app/README.md`) naming the app, the cap you want and what the mail is (for example, a weekly digest to opted-in subscribers) |
| Recipients per send | 1 to 50 addresses in `to`; an account send (`to_account`) has exactly 1 recipient and counts 1 toward the daily cap |
| `idempotency_key` | 1–128 characters of `[A-Za-z0-9._:-]`; a key is held per app for 24 hours from the first request that used it |
| Email accounts | one per app; 100 per owner (`409 account_limit_reached`) |
| Templates | 50 per account (`409 template_limit_reached`) |
| `subject` | one non-blank line, ≤200 characters; line breaks are rejected |
| `html` | non-blank, ≤1 MB (1,048,576 bytes) — template, inline and the substituted template body alike |
| Template `name` | ≤120 characters |
| `variables` | at most 50 entries; names 1–64 characters of `[A-Za-z0-9_]`; string values only; the whole map ≤4096 bytes |
| Group nanoid | non-empty, at most 64 characters |
| `list` page size | 1–100 rows, default 50 (an out-of-range `limit` is clamped, not rejected) |
| Send-log retention | 30 days; older entries are pruned as new sends land |

This service sets no request-rate limit of its own on any route; the daily send cap above still applies to `send`. To cap how often each user sends through your app, add a `@simple_limiter` policy with `host: "simple-email.pilely.app"`, `app_id` set to your app's id, `path: "/send"` and `key: "user"` (`https://simple-limiter.pilely.app/README.md`); root refuses a send over it with `429 rate_limited` and a `Retry-After` header before the send reaches this service. Such a policy counts every send made through that app, whatever account the body names, and never you, the app's owner, whatever credential you use.

## Errors

JSON errors are `{ "ok": false, "code": "...", "reason": "..." }` with the HTTP status; markdown errors are a `Request failed (<status>)` page with a short reason, carrying the same status. Codes shared by every route:

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | a malformed body; the route lists its own field rules |
| 401 | `bad_forwarded_identity` | the forwarded identity headers reached this service missing or malformed (always JSON) |
| 404 | `not_found` | the uniform 404: no such account (or template), you are not its owner, the request came through another app, or a caller the route does not serve (see `## Auth`) — one answer for all |
| 429 | `rate_limited` | over a `@simple_limiter` policy the app's owner set on this host; answered by root with a `Retry-After` header (see `## Limits`). Not the same as `send`'s own `429 daily_cap_exceeded`, the per-app daily cap (`## Limits` says how to ask for a higher one) |
| 503 | `out_of_traffic_credits` | the billing account has no traffic balance left — top up and retry (each route says whether it is gated) |
| 500 | `internal` | retry in a few seconds |

Route-specific codes: `account_exists` and `account_limit_reached` (`409`) on `accounts/create`; `template_limit_reached` (`409`) on `templates/create`; `idempotency_key_reused` and `idempotency_key_in_progress` (`409`) and `daily_cap_exceeded` (`429`) on `send`; `unavailable` (`503`) on the two internal routes.

Unknown accounts and denied callers are the same `404`, everywhere. A `GET` (or any method other than `POST`) on a route below, and any unknown path, answers an empty-body `404`.

## Response format

Make these calls with an HTTP client (`curl` or your runtime's equivalent), never a browser. Every route answers `POST` only. Markdown comes back by default; send `Accept: application/json` for the JSON envelope `{ "ok": true, ... }`. Both forms carry the same status code.

In markdown answers an account's send group reads `none (owner-only)` when it is `null`, its address reads `none (the app no longer exists)` once the app's subdomain label is gone (`none (the app's subdomain label is a reserved name)` when the label is reserved), its account-send line reads `send group only` (`account_senders: "send_group"`) or `every signed-in user of the app` (`"app_users"`), and its reply-to line reads the `reply_to` address, or `none` when it has none.

## Routes

| Route | Purpose | Who may call |
|---|---|---|
| `POST /accounts/create` | give one of your apps an outbound email account | a person who owns the named app, with their own token for this service or through the named app; the caller becomes the owner |
| `POST /accounts/list` | your email accounts, newest first (keyset-paginated) | a person: every account they own with their own token for this service, only that app's account through an app |
| `POST /accounts/{app_id}/info` | address, send group, today's usage vs the daily cap and traffic balance | the owner, with their own token for this service or through the account's app |
| `POST /accounts/{app_id}/access/set` | replace the account's `send_group`, and optionally its `account_senders` | the owner, with their own token for this service or through the account's app |
| `POST /accounts/{app_id}/set_reply_to` | set or clear the account's `reply_to`, the address recipients' replies go to | the owner, with their own token for this service or through the account's app |
| `POST /accounts/{app_id}/delete` | hard delete: account, its templates, its send log | the owner, with their own token for this service or through the account's app |
| `POST /accounts/{app_id}/templates/create` | register a subject + HTML body; returns the stable `template_id` | the owner, with their own token for this service or through the account's app |
| `POST /accounts/{app_id}/templates/list` | the account's templates, newest first (meta only) | the owner, with their own token for this service or through the account's app |
| `POST /accounts/{app_id}/templates/{template_nanoid}/info` | one template's meta and full HTML body | the owner, with their own token for this service or through the account's app |
| `POST /accounts/{app_id}/templates/{template_nanoid}/update` | full replace of `subject` + `html`, in place under the same id | the owner, with their own token for this service or through the account's app |
| `POST /accounts/{app_id}/templates/{template_nanoid}/delete` | hard delete of one template | the owner, with their own token for this service or through the account's app |
| `POST /send` | send one message to up to 50 recipients (a raw send), or a template to the caller's or the owner's own account email (an account send) | the owner, with their own token for this service or through the account's app — and, for the `@customer` app's account only, through `@customer_staff`; a `send_group` member (a person, or the account's app's own backend) through the account's own app; for account sends under `account_senders: "app_users"`, any signed-in person through the account's own app |
| `POST /sends/list` | an account's send history, newest first (bounded window) | the owner, with their own token for this service or through the account's app |
| `POST /~/internal/new_row_email/check` | can the app's account send its owner a new-row notification (sends nothing) | the account's owner, through `@simple_db_v2`'s app only |
| `POST /~/internal/new_row_email/send` | send the owner one new-row notification from the app's account | the account's owner, through `@simple_db_v2`'s app only |

`{template_nanoid}` is the template's `template_id`.

### POST /accounts/create

Give one of your apps an outbound email account.

#### Auth

A person who owns the app named by `app_id`, with their own token for this service or through that app itself. Through any other app — even one you own — the call answers `404 not_found` and nothing is created; so does an app you do not own, a backend app, an agent and an anonymous visitor. You become the account's owner.

#### Request

A JSON body is required (`Content-Type: application/json`):

```json
{ "app_id": "<your app's pile uuid>", "send_group": null, "account_senders": "send_group", "reply_to": "support@myshop.example" }
```

- `app_id` — **required** uuid of an app you own. One account per app at a time; a second create answers `account_exists`.
- `send_group` — optional `@simple_group` nanoid naming who besides you may send through the app. **Omitted or `null` means owner-only** (the safe default). The group must be ACTIVE and owned by you.
- `account_senders` — optional, `"send_group"` or `"app_users"`: who besides you may make account sends (`to_account`). Omitted means `"send_group"` (you and the group's members). `"app_users"` also lets every signed-in user of the app make account sends — add the per-user `@simple_limiter` policy in `### Who can send from an account` when you choose it. Any other value, `null` included, is a `400`.
- `reply_to` — optional email address, or `null`: where recipients' replies go. Every `/send` from the account carries it as the `Reply-To`. **Omitted or `null` means none**: the mail has no `Reply-To` and replies go nowhere. One bare address under the rule a `/send` `to` address follows — ASCII, at most 254 characters, exactly one `@`, a non-empty local part and a dotted domain, no whitespace or control characters — with no display name, no angle brackets and no second address. Its domain must not be `pilely-app-email.com`, `pilely.ai` or `pilely.app`, nor a subdomain of one (compared case-insensitively). Stored as given. Change or clear it later with `POST /accounts/{app_id}/set_reply_to`.

The account's sending address is derived from the app's subdomain label: `<label>@pilely-app-email.com`, with the app's display name as the friendly From name. That address is the builder's app, not Pilely. It follows the label — relabel the app and the address moves with it. An app served at the apex (no label) cannot have an email account, and an app whose label is a reserved name (such as `no-reply`) has no address and cannot send (`address` is `null`, `/send` answers `404`). The one exception is Pilely's own ticket account, which Pilely configures by hand to send from `ticket-no-reply@pilely.ai`; no route and no request field sets a fixed local part or the `pilely.ai` domain, and such an account still stops sending when its app loses its label.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/accounts/create \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"app_id": "<your app uuid>", "send_group": null, "reply_to": "support@myshop.example"}'
```

#### Response

`200`. JSON:

```json
{ "ok": true, "account": { "app_id": "<uuid>", "address": "myapp@pilely-app-email.com",
  "display_name": "My App", "send_group": null, "account_senders": "send_group",
  "reply_to": "support@myshop.example", "created_time_stamp": 1750000000000 } }
```

`address` is a string, or `null` once the app's label is gone or when the label is a reserved name; `display_name` is a string or `null`; `send_group` is a nanoid string or `null` (= owner-only); `account_senders` is `"send_group"` or `"app_users"`; `reply_to` is the address replies go to, or `null` when none; `created_time_stamp` is epoch ms. The markdown form is an `Email account` card with the app, the address, the send group, who may make account sends and the reply-to (`none` when unset), followed by the `### Who can send from an account` rules.

Next: templates at `POST https://simple-email.pilely.app/accounts/<app_id>/templates/create`, sending at `POST https://simple-email.pilely.app/send`.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, `app_id` is missing, `send_group` is not a valid group nanoid, the group is not an ACTIVE group you own, `account_senders` is not `"send_group"` or `"app_users"`, `reply_to` is not one valid address or is on a refused domain (the reason names the rule; no account is created), or the app has no subdomain label to derive an address from |
| 404 | `not_found` | no such app, or you do not own it — one answer for both (also the answer to an anonymous visitor arriving through an app) |
| 409 | `account_exists` | this app already has an email account |
| 409 | `account_limit_reached` | you have reached the email-account limit (100) |
| 503 | `out_of_traffic_credits` | your account has no traffic balance left — top up and retry |
| 500 | `internal` | the create failed; retry in a few seconds |

#### When to use

Once per app, before its first send: the app needs an address to send from.

### POST /accounts/list

List **your own email accounts**, newest first.

#### Auth

A person. The listing is always accounts YOU own: every one with your own token for this service; through an app's page, only that app's account (or none), with `next_cursor` always `null`. A backend app, an agent and an anonymous visitor get `404 not_found`.

#### Request

All fields are optional; sending no body at all is the first page at the default size.

```json
{ "limit": 50, "after_created_time_stamp": 0, "after_app_id": "<uuid>" }
```

- `limit` — optional integer, default 50, clamped to 1–100.
- `after_created_time_stamp` — optional integer, the last row's `created_time_stamp` (epoch ms).
- `after_app_id` — optional uuid, the last row's `app_id`.

The two `after_*` fields are the keyset cursor: send both or neither — one alone is a `400`.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/accounts/list \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -d '{"limit": 50}'
```

#### Response

`200`. JSON:

```json
{ "ok": true, "accounts": [ { "app_id": "<uuid>", "address": "myapp@pilely-app-email.com",
  "display_name": "My App", "send_group": null, "account_senders": "send_group",
  "reply_to": null, "created_time_stamp": 1750000000000 } ],
  "next_cursor": { "after_created_time_stamp": 1750000000000,
                   "after_app_id": "<uuid>" } }
```

Each row is `app_id` (uuid), `address` (string or `null`), `display_name` (string or `null`), `send_group` (nanoid or `null` = owner-only), `account_senders` (`"send_group"` or `"app_users"`), `reply_to` (the address replies go to, or `null`) and `created_time_stamp` (epoch ms). `next_cursor` carries `after_created_time_stamp` + `after_app_id`, and is `null` once the listing is exhausted. The markdown form is one line per account (with `none (owner-only)` for a null group, who may make account sends, and the reply-to or `none`), followed by the same two cursor values.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, or only one of the two cursor fields was sent |
| 404 | `not_found` | an anonymous visitor arriving through an app |
| 503 | `out_of_traffic_credits` | your account has no traffic balance left — top up and retry |
| 500 | `internal` | the listing failed; retry in a few seconds |

#### When to use

You need the `app_id` of an account you already created, or want to see which of your apps can send.

### POST /accounts/{app_id}/info

The account's address, send group and live operational state.

#### Auth

Owner-only: a person, the account's owner, with their own token for this service or through the account's own app — never through another app's page or backend, and never a backend app, an agent or an anonymous visitor. Every other caller, every unknown account and an app's anonymous visitor get the same `404 not_found`.

#### Request

No fields; send no body. `{app_id}` in the URL is the account's app uuid — an unparseable one answers `404` like an unknown one.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/accounts/<app_id>/info \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

`200`. JSON:

```json
{ "ok": true, "account": { "app_id": "<uuid>", "address": "myapp@pilely-app-email.com",
  "display_name": "My App", "send_group": null, "account_senders": "send_group",
  "reply_to": "support@myshop.example", "created_time_stamp": 1750000000000 },
  "daily_cap": 200, "sent_today": 0, "credit_blocked": false }
```

- `account` — `app_id`, `address` (`null` once the app's subdomain label is gone, or when it is a reserved name), `display_name` (the friendly From name), `send_group` (nanoid or `null` = owner-only), `account_senders` (`"send_group"` or `"app_users"`), `reply_to` (the address replies go to, or `null`), `created_time_stamp`.
- `daily_cap` — recipients this app may send per UTC day: the cap in force for this app (200 unless a different cap was set for it on request; see `## Limits`).
- `sent_today` — recipients already sent today, or `null` while the counter is briefly unreadable.
- `credit_blocked` — boolean: true when the billing user's traffic balance is exhausted.

The markdown form is the account card — the address (`none (the app no longer exists)` when the label is gone), the send group (`none (owner-only)` when null), who may make account sends, the reply-to (`none` when unset) — plus one line reading `Sent today: N of <cap> (UTC day) · traffic balance exhausted: yes/no`, where `<cap>` is the same cap in force as `daily_cap`, with `?` for an unavailable check.

A `null`/`?` here is a display gap, never a verdict — `send` is the path that fails closed. This route is never blocked by the traffic balance: it is where a blocked owner comes to see `credit_blocked: true`.

#### Errors

| Status | `code` | When |
|---|---|---|
| 404 | `not_found` | no such account, you are not its owner, the request came through another app, or an anonymous visitor arrived through an app — one answer for all |
| 500 | `internal` | the account read failed; retry in a few seconds |

#### When to use

Before wiring or debugging `send`: check the address, the daily budget left and the traffic balance in one call.

### POST /accounts/{app_id}/access/set

Replace the account's **send group**, and optionally who may make **account sends**.

#### Auth

Owner-only: a person, the account's owner, with their own token for this service or through the account's own app — never through another app's page or backend, and never a backend app, an agent or an anonymous visitor. Every other caller, every unknown account and an app's anonymous visitor get the same `404 not_found`.

#### Request

A JSON body is required, and the `send_group` key with it — full-set semantics, so a typo'd body can never silently change access:

```json
{ "send_group": "<group nanoid>" }
```

or, to close the account back to owner-only:

```json
{ "send_group": null }
```

or, to also open account sends to every signed-in user of the app:

```json
{ "send_group": null, "account_senders": "app_users" }
```

- `send_group` — **required** key (missing ⇒ `400`), value a `@simple_group` nanoid or `null`. A nanoid must be non-empty, at most 64 characters, and name an ACTIVE group YOU own. `null` means owner-only.
- `account_senders` — optional, `"send_group"` or `"app_users"` (see `### Who can send from an account`). **Omitted keeps the current value.** Any other value, `null` included, is a `400`. When you set `"app_users"`, add the per-user `@simple_limiter` policy on `/send` described there.

The group's `user`-type members gain FULL send rights through the app (any recipients, template or inline) — the group IS the safety boundary. Archiving the group later drops the account back to owner-only. The change takes effect on the very next request.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/accounts/<app_id>/access/set \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -d '{"send_group": "<group nanoid>"}'
```

#### Response

`200`. JSON:

```json
{ "ok": true, "account": { "app_id": "<uuid>", "address": "myapp@pilely-app-email.com",
  "display_name": "My App", "send_group": "<group nanoid>", "account_senders": "send_group",
  "reply_to": null, "created_time_stamp": 1750000000000 } }
```

`send_group` is the new value, or `null`; `account_senders` is the value now in force. The markdown form is the `Email account` card with the app, the address, the new send group and who may make account sends, followed by the `### Who can send from an account` rules.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, the `send_group` key is missing, its value is not a valid group nanoid, it does not name an ACTIVE group you own, or `account_senders` is not `"send_group"` or `"app_users"` |
| 404 | `not_found` | no such account, you are not its owner, the request came through another app, or an anonymous visitor arrived through an app — one answer for all |
| 503 | `out_of_traffic_credits` | the billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the update failed; retry in a few seconds |

#### When to use

The app's users (not just you) must be able to send through it: point the account at a group you curate. Set `null` to close it back to owner-only. Set `account_senders: "app_users"` when every signed-in user should be able to email themselves (or you) a confirmation, without joining the group.

### POST /accounts/{app_id}/set_reply_to

Set or clear the account's **reply-to**: the address recipients' replies go to.

#### Auth

Owner-only: a person, the account's owner, with their own token for this service or through the account's own app — never through another app's page or backend, and never a `send_group` member, an app user, a backend app, an agent or an anonymous visitor. Every other caller, every unknown account and an app's anonymous visitor get the same `404 not_found`. It sends no mail and is never blocked by the traffic balance.

#### Request

A JSON body is required, and the `reply_to` key with it — full-set semantics, so a typo'd body can never silently clear it:

```json
{ "reply_to": "support@myshop.example" }
```

or, to clear it:

```json
{ "reply_to": null }
```

- `reply_to` — **required** key (missing ⇒ `400`), value an email address or `null`. An address follows the rules of `reply_to` on `POST /accounts/create`: one bare address (ASCII, at most 254 characters, exactly one `@`, a non-empty local part and a dotted domain, no whitespace or control characters, no display name, no angle brackets, no second address), not on `pilely-app-email.com`, `pilely.ai` or `pilely.app` nor a subdomain of one (compared case-insensitively). Stored as given. `null` clears it: the account's mail then carries no `Reply-To` and replies go nowhere.

The change takes effect on the very next request: every `/send` from the account (template, inline and account sends) carries the account's `reply_to` as it is at send time as its `Reply-To`, on every recipient's copy. The owner's new-row notification (`POST /~/internal/new_row_email/send`) never carries one. A keyed `/send` whose first attempt's outcome is unknown and is retried after a `reply_to` change sends another email under the same id, which the provider refuses: the retry answers `409 idempotency_key_in_progress` until the key's 24 hours end, as after a template change.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/accounts/<app_id>/set_reply_to \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -d '{"reply_to": "support@myshop.example"}'
```

#### Response

`200`. JSON, the `account` object `POST /accounts/{app_id}/info` returns:

```json
{ "ok": true, "account": { "app_id": "<uuid>", "address": "myapp@pilely-app-email.com",
  "display_name": "My App", "send_group": null, "account_senders": "send_group",
  "reply_to": "support@myshop.example", "created_time_stamp": 1750000000000 } }
```

`reply_to` is the new address, or `null` once cleared. The markdown form is the `Email account` card with the app, the address, the send group, who may make account sends and the new reply-to (`none` when cleared), followed by the `### Who can send from an account` rules.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, the `reply_to` key is missing, or its value is not one valid address or is on a refused domain — the reason names the rule; nothing changes |
| 404 | `not_found` | no such account, you are not its owner, the request came through another app, or an anonymous visitor arrived through an app — one answer for all |
| 500 | `internal` | the update failed; retry in a few seconds |

#### When to use

Recipients should be able to answer the app's mail: point replies at an address you read (a support inbox, your own address). Set `null` to go back to mail no one can reply to.

### POST /accounts/{app_id}/delete

**Hard-delete** the email account, with no undo:

- the account — every future send answers `404`, whatever it references;
- every template — the ids shipped SPAs reference go with them;
- the send log.

Recreating the account later mints nothing back (templates get new ids). The app itself is untouched — only its email capability goes.

#### Auth

Owner-only: a person, the account's owner, with their own token for this service or through the account's own app — never through another app's page or backend, and never a backend app, an agent or an anonymous visitor. Every other caller, every unknown account and an app's anonymous visitor get the same `404 not_found`. When an account is deleted, Pilely's own clean-up deletes its email accounts as that person.

#### Request

No fields; send no body. `{app_id}` in the URL is the account's app uuid — an unparseable one answers `404` like an unknown one.

Deleting is never blocked by the traffic balance.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/accounts/<app_id>/delete \
  -H "Authorization: Bearer <your-token>"
```

#### Response

`200`. JSON:

```json
{ "ok": true }
```

Nothing else. The markdown form is `# OK` with `The email account, its templates and its send log are deleted. Future sends answer 404.`

#### Errors

| Status | `code` | When |
|---|---|---|
| 404 | `not_found` | no such account, you are not its owner, the request came through another app, or an anonymous visitor arrived through an app — one answer for all |
| 500 | `internal` | the delete failed; retry in a few seconds |

#### When to use

The app should stop sending email for good, together with its templates and history.

### POST /accounts/{app_id}/templates/create

Register a **subject + HTML body** the account can send.

#### Auth

Owner-only: a person, the account's owner, with their own token for this service or through the account's own app — never through another app's page or backend, and never a backend app, an agent or an anonymous visitor. Every other caller, every unknown account and an app's anonymous visitor get the same `404 not_found`.

#### Request

A JSON body is required:

```json
{ "name": "welcome", "subject": "Hi {{name}}", "html": "<p>…</p>" }
```

- `subject` — **required** string, one line, non-blank, ≤200 characters; line breaks are rejected. `{{variable}}` placeholders OK.
- `html` — **required** string, non-blank, ≤1 MB (1,048,576 bytes), placeholders OK. A template is ONE HTML file, nothing else — no attachments, no alternatives; keep big assets out of the template and store them in a `file` column of a `@simple_db` table (`https://simple-db.pilely.app/README.md`) instead.
- `name` — optional label for your own lists, ≤120 characters.

At most 50 templates per account.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/accounts/<app_id>/templates/create \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"name": "welcome", "subject": "Hi {{name}}", "html": "<p>Welcome, {{name}}!</p>"}'
```

#### Response

`200`. JSON:

```json
{ "ok": true, "template": { "template_id": "<id>", "name": "welcome",
  "subject": "Hi {{name}}", "created_time_stamp": 1750000000000,
  "updated_time_stamp": 1750000000000 } }
```

`template_id` is the stable id; `name` is a string or `null`; the time stamps are epoch ms. The HTML body is not echoed back — read it with the template's `info` route. The markdown form is a `Template <template_id>` card with the name and the subject.

Ship the `template_id` in your SPA and iterate the content here with `update` anytime — the id never changes, so deployed apps pick the new content up on their next send. `send` must supply exactly the variables the template names (both directions checked — a missing one and an unknown extra are each a `400`).

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON (or is too large to read), `subject`/`html` is missing or blank, `subject` is over 200 characters or contains a line break, `html` is over 1 MB, or `name` is over 120 characters |
| 404 | `not_found` | no such account, you are not its owner, the request came through another app, or an anonymous visitor arrived through an app — one answer for all |
| 409 | `template_limit_reached` | this account already holds 50 templates |
| 503 | `out_of_traffic_credits` | the billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the template could not be stored; retry in a few seconds |

#### When to use

Before shipping an app that sends: put the content here and ship only its `template_id`.

### POST /accounts/{app_id}/templates/list

The account's **templates**, newest first.

#### Auth

Owner-only: a person, the account's owner, with their own token for this service or through the account's own app — never through another app's page or backend, and never a backend app, an agent or an anonymous visitor. Every other caller, every unknown account and an app's anonymous visitor get the same `404 not_found`.

#### Request

All fields are optional; sending no body at all is the first page at the default size.

```json
{ "limit": 50, "after_created_time_stamp": 0, "after_template_id": "<id>" }
```

- `limit` — optional integer, default 50, clamped to 1–100.
- `after_created_time_stamp` — optional integer, the last row's `created_time_stamp` (epoch ms).
- `after_template_id` — optional string, the last row's `template_id`.

Newest first; the two `after_*` fields are the keyset cursor — send both or neither, one alone is a `400`.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/accounts/<app_id>/templates/list \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -d '{"limit": 50}'
```

#### Response

`200`. JSON:

```json
{ "ok": true, "templates": [ { "template_id": "<id>", "name": "welcome",
  "subject": "Hi {{name}}", "created_time_stamp": 1750000000000,
  "updated_time_stamp": 1750000000000 } ],
  "next_cursor": { "after_created_time_stamp": 1750000000000,
                   "after_template_id": "<id>" } }
```

Each row is `template_id`, `name` (string or `null`), `subject`, `created_time_stamp` and `updated_time_stamp` — meta only; fetch one template's full HTML with its `info` route. `next_cursor` carries `after_created_time_stamp` + `after_template_id`, and is `null` once the listing is exhausted. The markdown form is one line per template, followed by the same two cursor values.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, or only one of the two cursor fields was sent |
| 404 | `not_found` | no such account, you are not its owner, the request came through another app, or an anonymous visitor arrived through an app — one answer for all |
| 503 | `out_of_traffic_credits` | the billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the listing failed; retry in a few seconds |

#### When to use

You need a `template_id` to ship or to update.

### POST /accounts/{app_id}/templates/{template_nanoid}/info

One template's **meta and full HTML body**.

#### Auth

Owner-only: a person, the account's owner, with their own token for this service or through the account's own app — never through another app's page or backend, and never a backend app, an agent or an anonymous visitor. Every other caller, every unknown account, every template id that is not this account's and an app's anonymous visitor get the same `404 not_found`.

#### Request

No fields; send no body. Both ids come from the URL.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/accounts/<app_id>/templates/<template_id>/info \
  -H "Authorization: Bearer <your-token>" \
  -H "Accept: application/json"
```

#### Response

`200`. JSON:

```json
{ "ok": true, "template": { "template_id": "<id>", "name": "welcome",
  "subject": "Hi {{name}}", "created_time_stamp": 1750000000000,
  "updated_time_stamp": 1750000000000, "html": "<p>Welcome, {{name}}!</p>" } }
```

`html` is the full body. The markdown form is the same meta plus an `## HTML body` fenced block.

A template whose content is missing (a crash-interrupted create) answers with meta and NO `html` — `update` it with fresh content or `delete` it; until then it answers `400` at send.

#### Errors

| Status | `code` | When |
|---|---|---|
| 404 | `not_found` | no such template on this account, no such account, you are not its owner, the request came through another app, or an anonymous visitor arrived through an app — one answer for all |
| 503 | `out_of_traffic_credits` | the billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the read failed; retry in a few seconds |

#### When to use

You are about to edit a template and need its current HTML.

### POST /accounts/{app_id}/templates/{template_nanoid}/update

Replace the template's **subject + HTML**, in place. The `template_id` NEVER changes: shipped SPAs referencing it pick the new content up on their very next send, no redeploy.

#### Auth

Owner-only: a person, the account's owner, with their own token for this service or through the account's own app — never through another app's page or backend, and never a backend app, an agent or an anonymous visitor. Every other caller, every unknown account, every template id that is not this account's and an app's anonymous visitor get the same `404 not_found`.

#### Request

A JSON body is required; this is a FULL replace of both fields:

```json
{ "subject": "…", "html": "<p>…</p>" }
```

- `subject` — **required** string, one line, non-blank, ≤200 characters; line breaks are rejected. `{{variable}}` placeholders OK.
- `html` — **required** string, non-blank, ≤1 MB (1,048,576 bytes), placeholders OK.
- `name` is set at create and is not touched here.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/accounts/<app_id>/templates/<template_id>/update \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -d '{"subject": "Welcome aboard, {{name}}", "html": "<p>Hi {{name}}.</p>"}'
```

#### Response

`200`. JSON:

```json
{ "ok": true, "template": { "template_id": "<id>", "name": "welcome",
  "subject": "Welcome aboard, {{name}}", "created_time_stamp": 1750000000000,
  "updated_time_stamp": 1750000300000 } }
```

`subject` is the new one and `updated_time_stamp` is bumped (epoch ms). The HTML body is not echoed back — read it with the `info` route. The markdown form is the id, the name and the new subject.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON (or is too large to read), `subject`/`html` is missing or blank, `subject` is over 200 characters or contains a line break, or `html` is over 1 MB |
| 404 | `not_found` | no such template on this account, no such account, you are not its owner, the request came through another app, or an anonymous visitor arrived through an app — one answer for all |
| 503 | `out_of_traffic_credits` | the billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the update failed; retry in a few seconds |

#### When to use

Change what a shipped app sends without redeploying it.

### POST /accounts/{app_id}/templates/{template_nanoid}/delete

**Hard-delete** one template: a shipped SPA still referencing this id gets `400` at send from then on. If the SPA must keep working, `update` the content instead of deleting — the id is the stable half of the contract.

#### Auth

Owner-only: a person, the account's owner, with their own token for this service or through the account's own app — never through another app's page or backend, and never a backend app, an agent or an anonymous visitor. Every other caller, every unknown account, every template id that is not this account's and an app's anonymous visitor get the same `404 not_found`.

#### Request

No fields; send no body. Both ids come from the URL.

Deleting is never blocked by the traffic balance.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/accounts/<app_id>/templates/<template_id>/delete \
  -H "Authorization: Bearer <your-token>"
```

#### Response

`200`. JSON:

```json
{ "ok": true }
```

Nothing else. The markdown form is `# OK` with `Template deleted. A shipped SPA still referencing it gets 400 at send.`

#### Errors

| Status | `code` | When |
|---|---|---|
| 404 | `not_found` | no such template on this account, no such account, you are not its owner, the request came through another app, or an anonymous visitor arrived through an app — one answer for all |
| 500 | `internal` | the delete failed; retry in a few seconds |

#### When to use

A template no shipped app references any more.

### POST /send

Send one message: to up to 50 recipients you name (a **raw send**), or a template to one account email the service looks up (an **account send**). **The one route a non-owner can reach.**

#### Auth

The account's OWNER (with their own token for this service, or through the account's own app — never through some other app; for the `@customer` app's account only, also through `@customer_staff`, which sends its ticket replies from that account as its owner), or a member of the account's `send_group` calling through the account's own app: a `user` member, or the account's app's own backend when the app is an `app` member. For account sends only, when the account's `account_senders` is `"app_users"`, any signed-in person calling through the account's own app. Everyone else — an agent, an anonymous visitor, any caller through another app — and every unknown account get the same `404 not_found`. See `### Who can send from an account`.

What `/send` enforces, in order: a caller this route serves (a person or a backend app; an agent or an anonymous visitor gets the `404`) · a valid body (`#### Request`) · the send gate above, for the body's mode · with an `idempotency_key`, the key rules (the idempotency rules under `#### Request`) · for an account send, the recipient's account email, read at send time · the owner's traffic balance (the account's billing user, not the caller) · the template or inline content · the app's daily cap (recipients per UTC day). Nothing else is checked: in a raw send the service never checks where an address came from, so any well-formed address passes, and a `send_group` member can send to any address.

#### Request

A JSON body is required, in one of three shapes.

**Template send** (raw — the shipped-SPA shape; content lives server-side):

```json
{
  "app_id": "<the app's pile uuid>",
  "to": ["subscriber@example.com"],
  "template_id": "<template id>",
  "variables": { "name": "Ada" },
  "idempotency_key": "welcome:<subscriber row id>"
}
```

**Inline send** (raw — the owner/agent one-off shape):

```json
{ "app_id": "…", "to": ["…"], "subject": "…", "html": "<p>…</p>" }
```

**Account send** (a template to the caller's own account email, or the owner's):

```json
{
  "app_id": "<the app's pile uuid>",
  "to_account": "self",
  "template_id": "<template id>",
  "variables": { "item": "your order" },
  "idempotency_key": "<the new row's id>"
}
```

| Shape | Must carry | Must not carry |
|---|---|---|
| Template send (raw) | `app_id`, `to`, `template_id`, optional `variables` | `to_account`, `subject`, `html` |
| Inline send (raw) | `app_id`, `to`, `subject`, `html` | `to_account`, `template_id`, `variables` |
| Account send | `app_id`, `to_account`, `template_id`, optional `variables` | `to`, `subject`, `html` |

- `app_id` — **required** uuid: the app whose email account sends.
- EITHER `to` OR `to_account` — a body with both, or neither, is a `400`:
  - `to` — array of 1 to 50 addresses. Each must be ASCII, ≤254 characters, with exactly one `@`, a non-empty local part and a dotted domain; whitespace and control characters are rejected. Duplicates collapse case-insensitively before anything is billed, so the `recipient_count` you get back can be lower than the list you sent. Each recipient is one email at the provider and one metered unit, and they never see each other (separate emails, never a shared To:).
  - `to_account` — `"self"` (the caller's own Pilely account email at send time) or `"owner"` (the account owner's Pilely account email at send time); any other value is a `400`. Exactly one recipient. The service resolves the address; it never appears in the answer, and the caller never supplies it. An account send carrying `subject` or `html`, or no `template_id`, is a `400`.
- Then the content: the template arm OR the inline arm (raw sends) — never both, never neither; an account send takes the template arm only:
  - `template_id` — a template of THIS account; a blank string is a `400`.
  - `variables` — optional object riding template sends only. Values must be STRINGS (a number or a boolean is a `400`); at most 50 entries; names are 1–64 characters of `[A-Za-z0-9_]`; the whole map is capped at 4096 bytes. The set must match the template's placeholders EXACTLY — a missing one and an unknown extra are each a `400`. Values are HTML-escaped in the body and stripped of line breaks in the subject.
  - `subject` + `html` — the inline pair, taking the caps a template takes: `subject` one non-blank line of ≤200 characters (line breaks rejected), `html` non-blank and ≤1 MB. Sending `variables` with this arm is a `400` — inline html goes out verbatim.
- `idempotency_key` — optional string, 1–128 characters of `[A-Za-z0-9._:-]`, any other value is a `400`. Allowed in every shape. See the idempotency rules below.

A template whose SUBSTITUTED body crosses 1 MB is a `400` too. The mail leaves from the app's derived address. Every recipient's copy carries the account's `reply_to` as it is at send time as its `Reply-To` (set on `POST /accounts/create` or `POST /accounts/{app_id}/set_reply_to`); an account without one sends no `Reply-To`, and replies go nowhere.

**Idempotency rules.** A key names one logical email, per app, for 24 hours from the first request that used it:

- **The same key, the same body, the same caller** within 24 hours → `200` with the FIRST send's `send_id` and `recipient_count` and `"replayed": true`. Nothing is sent, billed or counted toward the daily cap, and no new send-log row is written.
- **The same key with a different body, or from a different caller** on the same app → `409 idempotency_key_reused`. Nothing is sent or billed. "The same body" means the same `app_id`, the same mode, the same `to` addresses (as a set, case-insensitive, order ignored), `to_account`, `template_id`, `variables`, `subject` and `html`.
- **The same key while its first request is still being handed to the provider** → `409 idempotency_key_in_progress`. Retry in a few seconds.
- **A request refused before the provider** (`400`, `403`, `404`, `429`, `503`, or a `500` before the hand-over) does not use up its key: a later request with that key is judged as new. So does a send the provider definitively refused — a provider `4xx` other than `408`, `409` and `429`.
- **A send whose outcome is unknown** (the provider could not be reached or answered `408`, `429` or a `5xx`, or the send is still `pending` more than 2 minutes after it started): a repeat hands it to the provider again under the first `send_id` — the provider's own idempotency delivers it at most once — and on success answers like a replay. When the provider answers that repeat the same way (or not at all), the send stays as it was, the answer is `500` and the key stays in use: repeat again later. It never uses daily-cap budget again, and the email is billed once if it was delivered.
- **After 24 hours** the key is forgotten: the same key is a fresh send with a new `send_id`.

Pick keys that are stable per logical email, so a rerun after a crash or a timeout repeats the same key: `digest:<user id>:<YYYY-MM-DD>` for a daily digest, `confirm:<row id>` for the confirmation of a stored row. A new key for every attempt defeats the protection. Without `idempotency_key`, every request is a new send.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/send \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"app_id": "<app uuid>", "to": ["ada@example.com"], "template_id": "<template id>", "variables": {"name": "Ada"}, "idempotency_key": "welcome:ada"}'
```

#### Response

`200`. JSON:

```json
{ "ok": true, "send_id": "<id>", "recipient_count": 1, "replayed": false }
```

`replayed` is `false` for a new send and `true` when the answer is an earlier send's (the idempotency rules under `#### Request`); every answer without a key reads `false`. The markdown form is a `Sent` page with the recipient count and the same send id; on a replay it adds a line saying an earlier request with this key already sent the message and nothing new was sent. An account send's answer, in both forms, never contains an address.

`send_id` is the send's log entry. The delivery outcome lands in `POST https://simple-email.pilely.app/sends/list`.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON (or is too large to read); `app_id` missing; both `to` and `to_account`, or neither; a malformed address or more than 50 of them; `to_account` not `"self"` or `"owner"`; an account send with `subject` or `html`, or without `template_id`; both content arms or neither; `variables` on an inline send, mistyped, or not matching the template's placeholders; `subject`/`html` missing, blank or over their caps; `template_id` unknown for this account, or its content is missing (recreate or update the template); `idempotency_key` empty, over 128 characters or outside `[A-Za-z0-9._:-]` |
| 404 | `not_found` | no such account; the caller may not make this kind of send (see `### Who can send from an account`); the app has no subdomain label to send from; or an anonymous visitor arrived through an app — one answer for all. A non-owner also gets this instead of `out_of_traffic_credits`, so billing state never leaks past the owner |
| 409 | `idempotency_key_reused` | this `idempotency_key` already belongs to a different request (another body or another caller) on this app in the last 24 hours; nothing was sent. Use a new key for a different email |
| 409 | `idempotency_key_in_progress` | an earlier request with this `idempotency_key` is still being handed to the provider; retry in a few seconds |
| 429 | `daily_cap_exceeded` | this app's daily recipient cap (200 by default, or the cap set for this app) is used up; it resets when the UTC day rolls over. A cap of `0` refuses every send. The reason names how to ask for a higher cap: a support ticket on `@customer` (`https://customer.pilely.app/README.md`), see `## Limits` |
| 503 | `out_of_traffic_credits` | the billing account has no traffic balance left (owner callers only) — top up and retry |
| 500 | `internal` | the send could not be completed (for an account send, also when the account email could not be read — nothing is sent then); retry with the same `idempotency_key`, or check `sends/list` before retrying without one |

Refusals by the traffic-balance and daily-cap gates are unbilled and land in the send log as `suppressed` rows.

#### When to use

The app (or you) sends a notification. What `/send` enforces is under `#### Auth`; the rest is yours:

- Raw sends go to any address that passes the format check; the service does not check that the recipient asked for mail.
- To reach a signed-in user or the owner, use an account send (`to_account: "self"` or `"owner"`): the address is the account's verified email, and nobody can point it elsewhere.
- To an address a visitor typed, send at most one fixed-template confirmation (double opt-in), with an `idempotency_key` tied to that address's row and no visitor text in the mail. Later mail goes only to confirmed addresses.
- Give every send from a backend or a timer a stable `idempotency_key` per logical email, so a rerun cannot email anyone twice within 24 hours.
- Email is notification, never workflow state — the db row is the truth, the email points at it.

### POST /sends/list

An account's **send history**, newest first.

#### Auth

Owner-only: a person, the account's owner, with their own token for this service or through the account's own app — never through another app's page or backend, and never a backend app, an agent or an anonymous visitor. The log holds recipient addresses, so a `send_group` member can send but not read history. Every other caller, every unknown account and an app's anonymous visitor get the same `404 not_found`.

#### Request

A JSON body is required; only `app_id` is mandatory:

```json
{ "app_id": "<uuid>", "limit": 50 }
```

- `app_id` — **required** uuid of the account's app.
- `limit` — optional integer, default 50, clamped to 1–100.
- `after_created_time_stamp` — optional integer, the last row's `fired_at` (epoch ms).
- `after_send_id` — optional string, the last row's `send_id`.

Newest first; the two `after_*` fields are the keyset cursor — send both or neither, one alone is a `400`.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/sends/list \
  -H "Authorization: Bearer <your-token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"app_id": "<app uuid>", "limit": 50}'
```

#### Response

`200`. JSON:

```json
{ "ok": true, "sends": [ { "send_id": "<id>", "requester_user_id": "<uuid>",
  "via_app": true, "kind": "template", "template_id": "<id>",
  "subject": "Hi Ada", "recipients": ["ada@example.com"],
  "recipient_count": 1, "outcome": "sent", "outcome_reason": null,
  "to_account": null, "idempotency_key": "welcome:ada",
  "fired_at": 1750000000000 } ],
  "next_cursor": { "after_created_time_stamp": 1750000000000,
                   "after_send_id": "<id>" } }
```

Each row is `send_id`, `requester_user_id` (uuid — the sending person's, or a backend app's own app uuid), `via_app` (boolean — the call came through an app other than this service), `kind` (`template` or `inline`), `template_id` (string or `null`), `subject` (as rendered), `recipients` (the address list), `recipient_count`, `outcome`, `outcome_reason` (string or `null`), `to_account` (`"self"`, `"owner"`, or `null` for a raw send), `idempotency_key` (string or `null`) and `fired_at` (epoch ms). An account send's row: for `"self"`, `recipients` is `[]` and `recipient_count` is `1` — you never see a user's account email through this service; for `"owner"`, `recipients` holds your own address. Suppressed rows follow the same rule. `next_cursor` carries `after_created_time_stamp` + `after_send_id`, and is `null` once the listing is exhausted. The markdown form is one line per send — id, kind, subject, recipient count, outcome, `to_account` and key when set, and `fired_at` (no addresses) — followed by the same two cursor values.

`outcome` is one of:

- `sent` — the provider accepted it.
- `failed` — with `outcome_reason` `provider_rejected_<status>` (the provider refused it; for a send without `idempotency_key`, any non-2xx answer other than `409`) or `provider_unreachable` (the outcome is unknown: no answer, or for a keyed send a `408`, `429` or `5xx` answer).
- `suppressed` — a gate refused it, unbilled, with `outcome_reason` `out_of_credit` or `daily_cap`.
- `pending` — a send interrupted mid-flight (a repeat with the same `idempotency_key` resolves it, see `### POST /send`).

Retention is bounded: entries older than 30 days are pruned as new sends land. The log is an operational record, not an archive.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, `app_id` is missing, or only one of the two cursor fields was sent |
| 404 | `not_found` | no such account, you are not its owner, the request came through another app, or an anonymous visitor arrived through an app — one answer for all |
| 503 | `out_of_traffic_credits` | the billing account has no traffic balance left — top up and retry |
| 500 | `internal` | the listing failed; retry in a few seconds |

#### When to use

Check whether a send was delivered, or why it was suppressed, before retrying.

### POST /~/internal/new_row_email/check

Report whether the app's account can send its owner a new-row notification. Sends nothing, uses none of the daily cap and records no billing.

#### Auth

Internal only. The caller is `@simple_db_v2`, calling this service directly on the private network as the app's owner through `@simple_db_v2`'s own app: `X-Pile-Requester-Entity: user:<owner uuid>` and `X-Pile-Requester-App: <@simple_db_v2's app uuid>`. The same pair arriving through root is that owner's own token for `@simple_db_v2`: the route only ever emails the account's owner, from their own account, billed to them. Any other caller — the owner on this service, through any other app or through root, a backend app (`@simple_db_v2`'s included), an agent, an anonymous visitor — gets the empty-body `404` an unknown path gets, under any `Accept`.

#### Request

A JSON body is required (`Content-Type: application/json`):

```json
{ "app_id": "<the app's pile uuid>" }
```

- `app_id` — **required** uuid of the app whose account would send.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/~/internal/new_row_email/check \
  -H "X-Pile-Requester-Entity: user:<owner uuid>" \
  -H "X-Pile-Requester-App: <@simple_db_v2 app uuid>" \
  -H "Content-Type: application/json" \
  -d '{"app_id": "<app uuid>"}'
```

#### Response

`200`, JSON only:

```json
{ "ok": true, "ready": true }
```

or, when a notification could not be sent:

```json
{ "ok": true, "ready": false, "reason": "no_account" }
```

`reason`, checked in this order:

- `no_account` — the app has no email account, the caller is not the account's owner, or the account's billing user is not its owner.
- `no_address` — the app has no subdomain label left to send from.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON, or `app_id` is missing or not a uuid |
| 503 | `unavailable` | the account could not be read; nothing changed, retry shortly |

#### When to use

`@simple_db_v2` asks before it turns a table's `new_row_email` on.

### POST /~/internal/new_row_email/send

Send the app's owner one notification that people added rows to a table: from the app's account address, to the owner's own account email, with fixed content. One attempt, never retried.

#### Auth

Internal only, as `POST /~/internal/new_row_email/check`: the account's owner through `@simple_db_v2`'s own app (`user:<owner uuid>` on `@simple_db_v2`'s app uuid). Every other caller gets the empty-body `404` an unknown path gets.

#### Request

A JSON body is required (`Content-Type: application/json`):

```json
{ "app_id": "<the app's pile uuid>", "table": "inbox", "count": 3, "period": { "since_ms": 1750000000000 } }
```

- `app_id` — **required** uuid of the app whose account sends.
- `table` — **required** table name, lowercase snake_case (`^[a-z][a-z0-9_]{0,63}$`).
- `count` — **required** integer, at least 1: how many new rows.
- `period` — **required** object with exactly one key: `since_ms` (epoch ms of the previous notification, or `null` for the first one since the setting was turned on) for a table set to `each`, or `day` (`"YYYY-MM-DD"`, the UTC day) for a table set to `daily`.

Nothing else in the body is read: the recipient, subject and body are the service's own.

The email:

- **From** the app's account address, with the app's display name as the From name, as on any send.
- **No `Reply-To`**, whatever the account's `reply_to`: it is the owner's own notification.
- **To** the account owner's Pilely account email, read at send time. One recipient.
- **Language**: the owner's saved email language (the one their login saved), in any of the 16 supported languages; English when none is saved.
- **Subject**: the count, the table and the app's name, e.g. `3 new rows in inbox · My App` in English (one line, at most 200 characters).
- **Body**: the Pilely email design — the Pilely logo on top, the subject as the heading, a `pilely.ai` link at the bottom; Arabic right to left. Between them: the app's name (its display name, or its subdomain label when it has none), the table's name, the count, the period (in English `since <UTC time>`, `since notifications were turned on`, or the UTC date), a link to the app at `https://<label>.pilely.app/`, and one line saying the table's `new_row_email` setting caused it and that setting it to `off` with `notify/set` stops it. The setting names and `notify/set` stay as written in every language. The app's name and the table's name are HTML-escaped. Nothing else is in it.

What it checks, in order: the account exists, is owned by the caller and billed to that owner, and has a sending address (otherwise the notification is dropped) · the owner's account email and saved language (no email: dropped) · the owner's traffic balance · the app's daily cap (one recipient). It is the owner's own send: it lands in `POST /sends/list` with `requester_user_id` the owner, `via_app: false`, `kind: "inline"`, `to_account: "owner"` and the owner's address in `recipients`; it is billed to the account's billing user and counts one recipient against the app's daily cap, like any send.

#### Example

```bash
curl -X POST https://simple-email.pilely.app/~/internal/new_row_email/send \
  -H "X-Pile-Requester-Entity: user:<owner uuid>" \
  -H "X-Pile-Requester-App: <@simple_db_v2 app uuid>" \
  -H "Content-Type: application/json" \
  -d '{"app_id": "<app uuid>", "table": "inbox", "count": 3, "period": {"since_ms": null}}'
```

#### Response

`200`, JSON only:

```json
{ "ok": true, "outcome": "sent" }
```

`outcome` is one of:

- `sent` — the provider accepted it; a `sent` row is in the send log.
- `suppressed` — the traffic-balance or daily-cap gate refused it; a `suppressed` row with `outcome_reason` `out_of_credit` or `daily_cap` is in the send log, unbilled.
- `failed` — the provider did not accept it; a `failed` row is in the send log.
- `dropped` — no account, no sending address, not the owner's account, an account billed to someone else, or no owner account email; nothing is written or sent.

The answer never contains the owner's address.

#### Errors

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | the body is not JSON; `app_id`, `table`, `count` or `period` missing or malformed |
| 503 | `unavailable` | the account, the owner's address, the daily-cap counter or the send log could not be read or written; nothing was sent |

#### When to use

`@simple_db_v2` sends it when a table's notification falls due. Any answer but `sent` drops that notification.

## Notes

### Other GETs on this host

- `GET /README.md` — this page; public, the same bytes for every caller and every `Accept`.
- `GET /` — `302` to `/README.md`.
- `GET /healthz` — liveness probe, plain `ok`.

### A backend's send is the app's own, never the owner's

A backend app's server calls with its backend token, which names the app itself (`app:<app id>` on that app), never its owner. It sends only from its own app's email account, and only when the owner made the app an `app` member of the account's `send_group`; an account with no `send_group`, or one of a different app, answers the uniform `404`. It is never the owner: the owner-only routes refuse it, and its sends are a member's sends, not the owner's.

### A table's new-row notifications are sends from the account

When an app's `@simple_db` table has `new_row_email` set to `each` or `daily` (`https://simple-db.pilely.app/README.md`), each notification is a send from that app's email account to the owner's own account email (`POST /~/internal/new_row_email/send`). It shows in `POST /sends/list` like any other send (`to_account: "owner"`), is billed to the account's billing user and counts one recipient against the app's daily cap. A gate refusal lands there as a `suppressed` row. Deleting the account makes each notification drop; the table's setting stays as it is.

### Template and send ids

A template id is a server-minted 22-character nanoid over `[0-9a-zA-Z]`; it is the stable `template_id` shipped in the app, and `update` changes subject and HTML in place under the same id. Send-log ids are 22-character nanoids too; the service hands each send to the provider under its `send_id`, so the unknown-outcome retry of a keyed send (the idempotency rules under `### POST /send`) is delivered at most once. Your own `idempotency_key` is what makes a retry safe: a retry without one is a new send with a new `send_id`. Caps: 50 templates per account, subject ≤200 characters, HTML ≤1 MB, 50 recipients per send.