@pilely/simple-email 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,104 +1,135 @@
1
1
  # `@pilely/simple-email`
2
2
 
3
- React hooks for the `simple_email` service — the platform's managed transactional email. A query hook for every read, a mutation hook for every write. Render them inside `@pilely/core`'s `<PilelyProvider>`.
3
+ The client for the `simple_email` service — the platform's managed transactional email. An email account has two ways to send, and this package has one entry for each:
4
4
 
5
- ## Hooks
5
+ - **`sendTemplate(templateId, variables)`**, and its hook `useSendTemplate()`, for a **page** of the app: the **template send**. It sends one of the account's templates, filled from the variables, to the ONE address the owner stored on that template. The caller supplies only the variables, so it can never point the mail at anyone else or change what it says. Any caller the template's groups admit can make it — signed-out visitors included.
6
+ - **`send({ to, ... })`** for the app's **backend only**: the **freestyle send**, to any addresses, from a template or inline. Only the account's app's own backend, calling with its backend token, is admitted; a page, a signed-in user and you, the owner, get the uniform 404. It needs no browser runtime: a server imports it from `@pilely/simple-email/send`.
6
7
 
7
- | Hook | Wraps | Kind |
8
+ Beside them, a query hook for every read and a mutation hook for every write of the account and its templates, for owner tooling. Render the hooks inside `@pilely/core`'s `<PilelyProvider>`.
9
+
10
+ **Moving from `0.4.x`:** `0.5.0` removes `useSend()`, `useSends()` and `useSetEmailAccountAccess()` — the service no longer has the routes they call, so a `0.4.x` client's sends and outbox fail — and accounts carry no send access of their own any more: who may send is each template's groups, and your app's backend. Templates gain `recipient`, `send_group` and `deny_group`. `create-pilely-app` `0.4.3` still scaffolds `"@pilely/simple-email": "^0.4.0"`, whose `useSend` / `useSends` call those removed routes: in a scaffolded app, move the range to `^0.5.0`.
11
+
12
+ ## Hooks and functions
13
+
14
+ | Export | Wraps | Kind |
8
15
  | --- | --- | --- |
9
- | `useSend()` | `/send` | mutation, `{ to, subject, html }` or `{ to, template_id, variables? }` (a raw send), or `{ to_account, template_id, variables? }` (an account send); each takes an optional `idempotency_key`. Answers `{ send_id, recipient_count, replayed }` |
10
- | `useSends({ limit? })` | `/sends/list`, paged | infinite query; this app's outbox, `SendRow[]` |
16
+ | `useSendTemplate()` | `accounts/{app}/templates/{t}/send` | mutation, `{ templateId, variables? }`; answers `{ send_id }`, never the address |
17
+ | `sendTemplate(templateId, variables?, { challenge }?)` | `accounts/{app}/templates/{t}/send` | the same template send as a plain function |
18
+ | `send(input, { appId, backendToken, origin? })` | `accounts/{app}/send` | the freestyle send, backend only; `input` is `{ to, subject, html }` or `{ to, template_id, variables? }`, each with an optional `idempotency_key`. Answers `{ send_id, recipient_count, replayed }` |
11
19
  | `useEmailAccount()` | `accounts/{app}/info` | query; this app's account with its daily cap, sent today and credit state |
12
20
  | `useEmailAccounts({ limit? })` | `accounts/list`, paged | infinite query; every account the caller owns |
13
- | `useCreateEmailAccount()` | `accounts/create` | mutation, the optional send group, or `{ send_group?, account_senders?, reply_to? }` |
21
+ | `useCreateEmailAccount()` | `accounts/create` | mutation, no variables, or `{ reply_to? }` |
14
22
  | `useDeleteEmailAccount()` | `accounts/{app}/delete` | mutation, no variables |
15
- | `useSetEmailAccountAccess()` | `accounts/{app}/access/set` | mutation, the send group (`null` = owner-only), or `{ send_group, account_senders? }` (an omitted `account_senders` keeps the current value) |
16
23
  | `useSetEmailReplyTo()` | `accounts/{app}/set_reply_to` | mutation, the reply-to address, or `null` to clear it; always sent, never "keep" |
17
24
  | `useTemplates({ limit? })` | `templates/list`, paged | infinite query; metadata only |
18
25
  | `useTemplate(templateId)` | `templates/{t}/info` | query; the template with its `html`; an `undefined` id disables it |
19
- | `useCreateTemplate()` | `templates/create` | mutation, `{ name, subject, html }` (`name` may be `null`) |
20
- | `useUpdateTemplate()` | `templates/{t}/update` | mutation, `{ templateId, subject, html }` — a full replace |
26
+ | `useCreateTemplate()` | `templates/create` | mutation, `{ name, subject, html, recipient, send_group?, deny_group? }` (`name` may be `null`; `recipient` is required) |
27
+ | `useUpdateTemplate()` | `templates/{t}/update` | mutation, `{ templateId, subject, html, recipient?, send_group?, deny_group? }` — `subject` and `html` a full replace; an omitted `recipient` or group keeps the stored value, `null` empties a group |
21
28
  | `useDeleteTemplate()` | `templates/{t}/delete` | mutation, the template id |
22
29
 
23
- The app id is added from `@pilely/core` on every route; it is never a variable. Every write waits for the server: an email "sent" before the server accepted it would be a lie.
30
+ On a page the app id is added from `@pilely/core` on every route; it is never a variable. `send()` runs where there is no page, so it takes the app id and the backend token explicitly. Every write waits for the server: an email "sent" before the server accepted it would be a lie.
24
31
 
25
- ## Bot checks: `{ challenge }`
32
+ Every template answer (`TemplateMeta`, `Template`) carries `recipient` (the stored address), `send_group` and `deny_group` (group ids, or `null` for an empty group). An `Account` carries `app_id`, `address`, `display_name`, `reply_to` and `created_time_stamp`.
26
33
 
27
- The app's owner can put a **challenge policy** on any route of `simple-email.pilely.app` for the app, typically `POST /send` (`https://simple-limiter.pilely.app/README.md`); root then refuses every send through the app that carries no solved bot check with `403 challenge_required`, before the service sees it — the owner's and the app's server's sends included. Every hook takes a `challenge` option for that, in its options object or as its last argument where it has none (`useSend({ challenge })`, `useTemplate(templateId, { challenge })`):
34
+ ## The template send, from a page
28
35
 
29
36
  ```tsx
30
- import { getChallenge, solveChallenge } from "@pilely/simple-limiter";
37
+ import { useSendTemplate } from "@pilely/simple-email";
31
38
 
32
- const send = useSend({ challenge: async () => solveChallenge(await getChallenge()) });
33
- await send.mutateAsync({ to_account: "owner", template_id, variables }); // a fresh answer per send
39
+ // CONTACT is a template of this app's account whose recipient is your own
40
+ // address and whose send_group admits the people who use this form.
41
+ export function ContactForm() {
42
+ const sendTemplate = useSendTemplate();
43
+ return (
44
+ <form
45
+ onSubmit={(e) => {
46
+ e.preventDefault();
47
+ const form = new FormData(e.currentTarget);
48
+ sendTemplate.mutate({
49
+ templateId: CONTACT,
50
+ variables: { name: String(form.get("name")), message: String(form.get("message")) },
51
+ });
52
+ }}
53
+ >
54
+ <input name="name" />
55
+ <textarea name="message" />
56
+ <button disabled={sendTemplate.isPending}>Send</button>
57
+ {sendTemplate.isSuccess && <p>Thanks — your message was sent.</p>}
58
+ {sendTemplate.error && <p role="alert">{sendTemplate.error.message}</p>}
59
+ </form>
60
+ );
61
+ }
34
62
  ```
35
63
 
36
- A string, or a function answering one (it may be async, and may answer `undefined`) asked once for each request the hook sends; every request carries it as the `X-Pilely-Challenge` header, and root uses it up only on a call a policy matches. One answer works for one call, within 5 minutes of `getChallenge()`, so give a function to a hook that sends more than once. A refusal is a `PilelyError` with `challenge_required` or `challenge_failed` (both 403) and is never retried; get and solve a new one to start over.
64
+ `variables` holds one string per `{{placeholder}}` of the template's subject and html, and it is the whole request body: every other field the service ignores, so a caller can never add a `to`, a `subject` or an `html`. A missing (or non-string) variable is `bad_request` (400) and nothing is sent. The answer is `{ send_id }`; no answer of this route, success or error, carries the stored address. A template send counts 1 recipient toward the app's daily send cap.
37
65
 
38
- ## Raw sends and account sends
66
+ **Who may send it is the template's two groups.** Through the app's own page, a caller is admitted exactly when it is a member of the template's `send_group` and not a member of its `deny_group`. A refused caller, an unknown template and an exhausted owner balance all get the same `not_found` (404). An empty `send_group` admits nobody: a template cannot be sent this way until you set one.
39
67
 
40
- A **raw send** (`to`) goes to the addresses you name, with template or inline content. 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 app owner's (`"owner"`). The caller never supplies or sees that address — not in the answer, and not on the `"self"` row of the owner's `useSends()` (its `recipients` is `[]`). An account send is template-only: `to` with `to_account`, or `subject` / `html` on an account send, does not typecheck. It counts 1 recipient toward the daily cap and is billed to the owner like any other send.
68
+ **Setting up the groups** with `@pilely/simple-group` (`https://simple-group.pilely.app/README.md`), then storing the ids on the template with `useCreateTemplate()` or `useUpdateTemplate()`:
41
69
 
42
- Who may make account sends is the account's `account_senders`: `"send_group"` (the default — the owner and the send group's members) or `"app_users"` (also every signed-in user of the app, through the app; raw sends stay closed to them). When you choose `"app_users"`, add a per-user `@simple_limiter` policy on `simple-email.<endpoint>` `/send` — without it one user can use up the app's daily cap and spend your balance. The service README's `### Who can send from an account` has the full table.
70
+ - every signed-out visitor of a public app: `send_group` = the platform-wide anonymous group, from `anonymousGroupId()`;
71
+ - every signed-in user: `send_group` = the platform-wide authenticated group, from `authenticatedGroupId()`;
72
+ - chosen people, or your app's backend: an owner group of your own (`useCreateGroup()`), with them added as members;
73
+ - people to refuse: an owner group of your own as the `deny_group` (a platform-wide group is never a `deny_group`).
43
74
 
44
- ## Replies: `reply_to`
75
+ **Cap how often callers send it with a `@simple_limiter` policy** (`https://simple-limiter.pilely.app/README.md` → `### Limiting simple_email sends`): `host` `simple-email.pilely.app`, your app's id, `POST`, path `/accounts/<app id>/templates/*/send` (or one template's id in place of `*`), keyed by `ip` for signed-out visitors or `user` for signed-in users — for example a few sends per hour. Over it, root answers `rate_limited` (429) with a `Retry-After`. The service sets no per-caller limit of its own: without a policy, one caller can use up the app's daily cap and spend your balance. A template open to visitors is a form anyone can submit.
76
+
77
+ **You pay for every send.** Every send, template or freestyle, whoever caused it — a visitor, a signed-in user, your backend — is billed to the account's owner, never to the caller.
45
78
 
46
- An account's `reply_to` is the address recipients' replies go to: every send from the account — raw, template and account sends — carries it as its `Reply-To`. `null` (the default) means none, and replies go nowhere. Set it on create (`useCreateEmailAccount()`'s `{ reply_to }`) or later with `useSetEmailReplyTo()`, which takes the address or `null` to clear it. It must be one bare address (no display name, no list) and not on `pilely-app-email.com`, `pilely.ai` or `pilely.app` nor a subdomain of one; the server answers anything else `bad_request` (400) with a reason naming the rule. Every `Account` carries it.
79
+ ## The freestyle send, from your backend
47
80
 
48
- ## Retries: `idempotency_key`
81
+ ```ts
82
+ import { send } from "@pilely/simple-email/send";
49
83
 
50
- Give a send an `idempotency_key` (1–128 characters of `[A-Za-z0-9._:-]`, held per app for 24 hours) and a repeat with the same key, the same body and the same caller answers the FIRST send's `send_id` with `replayed: true` — nothing is sent, billed or counted again. The same key with another body or from another caller is `idempotency_key_reused` (409); a repeat while the first is still being handed to the provider is `idempotency_key_in_progress` (409, retry in a few seconds). Pick keys stable per logical email — `confirm:<row id>`, `digest:<user id>:<YYYY-MM-DD>` — so a retry after a crash or a timeout repeats the same key. Without a key every request is a new send, and `replayed` is `false`.
84
+ await send(
85
+ { to: [row.email], template_id: WELCOME, variables: { name: row.name }, idempotency_key: `welcome:${row.id}` },
86
+ { appId: APP_ID, backendToken: process.env.PILELY_BACKEND_TOKEN! },
87
+ );
88
+ ```
89
+
90
+ `send()` is for a server that holds the app's backend token — **never call it from a page**: a page has no backend token, and the service answers every caller but the app's own backend with the uniform 404. It imports nothing at runtime (no `@pilely/core`, no React, no `window.pilely`) and makes one plain `fetch` to `https://simple-email.pilely.app/accounts/<appId>/send` with the token as `Authorization: Bearer`; pass `origin` for another host. It is also exported from the main entry. A `@simple_server` box runs Rust, not JavaScript: there, make the same `POST` with your HTTP client (`https://simple-email.pilely.app/README.md` → `### POST /accounts/{app_id}/send`).
91
+
92
+ `to` holds 1 to 50 addresses; the content is a template (`template_id`, with `variables` matching its placeholders exactly) or inline (`subject` + `html`), never both. A refusal throws `SendError` with `status`, `code` (a `SimpleEmailErrorCode`, or `null` for a bodiless answer) and `reason`; it never carries the token.
93
+
94
+ Who may cause a freestyle send is your backend's own code — no group is involved. So is how often it sends: no `@simple_limiter` policy counts your app's backend, and the service's only bound is the freestyle cap, 500 recipients per app per UTC day (`daily_cap_exceeded`, 429), counted apart from the daily send cap. Before mailing an address a visitor typed, send at most one fixed-template confirmation with an `idempotency_key` tied to that address's row.
95
+
96
+ ### Retries: `idempotency_key`
97
+
98
+ Give a freestyle send an `idempotency_key` (1–128 characters of `[A-Za-z0-9._:-]`, held per app for 24 hours) and a repeat with the same key and the same body answers the FIRST send's `send_id` with `replayed: true` — nothing is sent, billed or counted again. The same key with another body is `idempotency_key_reused` (409); a repeat while the first is still being handed to the provider is `idempotency_key_in_progress` (409, retry in a few seconds). Pick keys stable per logical email — `confirm:<row id>`, `digest:<user id>:<YYYY-MM-DD>` — so a retry after a crash or a timeout repeats the same key. Without a key every request is a new send, and `replayed` is `false`. The template send takes no key: every accepted call is a new email.
99
+
100
+ ## Bot checks: `{ challenge }`
101
+
102
+ The app's owner can put a **challenge policy** on any route of `simple-email.pilely.app` for the app, typically the template send's path `/accounts/<app id>/templates/*/send` (`https://simple-limiter.pilely.app/README.md`) — never the freestyle send's `/accounts/<app id>/send`, whose backend caller cannot solve one, so such a policy would refuse every backend send; root then refuses every matching call through the app that carries no solved bot check with `403 challenge_required`, before the service sees it. Every hook and `sendTemplate` take a `challenge` option for that, in its options object or as its last argument where it has none (`useSendTemplate({ challenge })`, `useTemplate(templateId, { challenge })`):
103
+
104
+ ```tsx
105
+ import { getChallenge, solveChallenge } from "@pilely/simple-limiter";
106
+
107
+ const sendTemplate = useSendTemplate({ challenge: async () => solveChallenge(await getChallenge()) });
108
+ await sendTemplate.mutateAsync({ templateId, variables }); // a fresh answer per send
109
+ ```
110
+
111
+ A string, or a function answering one (it may be async, and may answer `undefined`) asked once for each request the hook sends; every request carries it as the `X-Pilely-Challenge` header, and root uses it up only on a call a policy matches. One answer works for one call, within 5 minutes of `getChallenge()`, so give a function to a hook that sends more than once. A refusal is a `PilelyError` with `challenge_required` or `challenge_failed` (both 403) and is never retried; get and solve a new one to start over. `send()` takes no `challenge`: a challenge is solved in a browser.
112
+
113
+ ## Replies: `reply_to`
114
+
115
+ An account's `reply_to` is the address recipients' replies go to: every send from the account — template and freestyle — carries it as its `Reply-To`. `null` (the default) means none, and replies go nowhere. Set it on create (`useCreateEmailAccount()`'s `{ reply_to }`) or later with `useSetEmailReplyTo()`, which takes the address or `null` to clear it. It must be one bare address (no display name, no list) and not on `pilely-app-email.com`, `pilely.ai` or `pilely.app` nor a subdomain of one; the server answers anything else `bad_request` (400) with a reason naming the rule. Every `Account` carries it.
51
116
 
52
117
  ## What each write refreshes
53
118
 
54
119
  | Write | Invalidates |
55
120
  | --- | --- |
56
- | send | `useSends()` |
57
- | account create / set access / set reply-to | `useEmailAccounts()`, `useEmailAccount()` |
121
+ | template send | `useEmailAccount()` (its `sent_today`) |
122
+ | account create / set reply-to | `useEmailAccounts()`, `useEmailAccount()` |
58
123
  | account delete | `useEmailAccounts()`, `useEmailAccount()`, and the templates under it |
59
124
  | template create | `useTemplates()` |
60
125
  | template update / delete on `t` | `useTemplates()`, `useTemplate(t)` |
61
126
 
62
- ## Example
63
-
64
- ```tsx
65
- import { useSend, useSends } from "@pilely/simple-email";
66
-
67
- export function Outbox() {
68
- const { data: sends } = useSends();
69
- const send = useSend();
70
- return (
71
- <>
72
- <button onClick={() => send.mutate({ to: ["friend@example.com"], subject: "Hi", html: "<p>Hello</p>" })}>
73
- Send
74
- </button>
75
- {send.error && <p role="alert">{send.error.message}</p>}
76
- <ul>
77
- {sends?.map((row) => (
78
- <li key={row.send_id}>
79
- {row.subject} — {row.outcome}
80
- {row.to_account && ` (to the ${row.to_account} account email)`}
81
- </li>
82
- ))}
83
- </ul>
84
- </>
85
- );
86
- }
87
-
88
- // After a row is stored: confirm it to the signed-in submitter, once, even if
89
- // the page retries. `templateId` is a template of this app's account.
90
- export function useConfirmSubmission(templateId: string) {
91
- const send = useSend();
92
- return (rowId: string) =>
93
- send.mutateAsync({ to_account: "self", template_id: templateId, idempotency_key: `confirm:${rowId}` });
94
- }
95
- ```
127
+ `send()` is not a hook and touches no cache.
96
128
 
97
129
  ## Query keys
98
130
 
99
131
  | Key | Query |
100
132
  | --- | --- |
101
- | `["pilely", "simple-email", "sends", { limit }]` | `useSends` |
102
133
  | `["pilely", "simple-email", "account"]` | `useEmailAccount` |
103
134
  | `["pilely", "simple-email", "accounts", { limit }]` | `useEmailAccounts` |
104
135
  | `["pilely", "simple-email", "templates", { limit }]` | `useTemplates` |
@@ -112,21 +143,25 @@ export function useConfirmSubmission(templateId: string) {
112
143
 
113
144
  Importing this package in a build made with `VITE_PILELY_MOCK=1` registers an in-browser fake of `simple_email` behind `@pilely/core`'s mock runtime — nothing else to call.
114
145
 
115
- **Nothing is ever sent.** A send is appended to an in-memory outbox — the rows `useSends` answers — and logged to the console. Accounts and templates persist to `localStorage` across reloads; the app's account is created on its first send. A missing account or template answers the bare 404, and a second account for the same app answers `account_exists` (409). The 50-recipient bound is kept.
146
+ **Nothing is ever sent.** A template send is appended to an in-memory outbox, addressed to the template's stored recipient, and logged to the console; its answer is `{ send_id }`, never the address. `useEmailAccount()`'s `sent_today` counts those sends. Accounts and templates persist to `localStorage` across reloads. A missing account or template answers the bare 404, and a second account for the same app answers `account_exists` (409).
147
+
148
+ The template send admits callers by the template's groups as the `@pilely/simple-db` fake admits by policies: the two platform-wide groups hold callers by type — every signed-out visitor is in the anonymous group, every signed-in person in the authenticated group — under the same fixed test ids `@pilely/simple-group`'s fake answers from `anonymousGroupId()` / `authenticatedGroupId()`. The fake has no group membership data, so **any other group holds nobody**: an owner group as `send_group` admits no one and a `deny_group` denies no one — test those against the real service. An empty `send_group` admits nobody. Each placeholder is read from the variables; a missing or non-string one is `bad_request`, and every other field is ignored.
149
+
150
+ The freestyle route answers the bare 404 to every caller, since no caller in a browser is the app's backend. `send()` never reaches the fake: it is a server function that calls `fetch` directly.
116
151
 
117
- It models the send shapes and `idempotency_key`: an account send's row carries `to_account` and one recipient (`recipients: []` for `"self"`, a stand-in owner address for `"owner"` — the fake has no real account emails); a body with both `to` and `to_account`, or an account send with inline content, is `bad_request`; a repeat with the same key, body and caller answers `replayed: true` without a new row, and the same key with another body is `idempotency_key_reused`. Accounts carry `account_senders`, settable on create and access/set, and `reply_to`, settable on create and set_reply_to (a missing key or a non-string value on set_reply_to is `bad_request`; the address rules and refused domains are not checked). It does not model send groups, `account_senders` gating, the daily cap, rate limits, credit checks or `idempotency_key_in_progress`.
152
+ Templates carry `recipient` (required on create; `null` on update is `bad_request`), `send_group` and `deny_group` (a platform-wide group as `deny_group` is `bad_request`); accounts carry `reply_to`, settable on create and set_reply_to (a missing key or a non-string value on set_reply_to is `bad_request`). The fake checks the fields' shapes, not the address rules, refused domains or group ownership. It does not model the daily caps, rate limits or credit checks.
118
153
 
119
- Writes need a signed-in user: a signed-out write answers the uniform bare 404, which reaches the hook's `error` as `PilelyError(404, null)`. See [`@pilely/core`](../core#mock-mode) for the switch, `seedMock()` and `resetMock()`.
154
+ Owner writes need a signed-in user: a signed-out write answers the uniform bare 404, which reaches the hook's `error` as `PilelyError(404, null)`. See [`@pilely/core`](../core#mock-mode) for the switch, `seedMock()` and `resetMock()`.
120
155
 
121
156
  ## Error codes
122
157
 
123
- Refusals arrive as `PilelyError` in the hook's `error`, with a `code` drawn from `SimpleEmailErrorCode`. Match on the code, not the status: **`daily_cap_exceeded` and `rate_limited` both answer 429**. `daily_cap_exceeded` means the app spent its UTC-day allowance (retry tomorrow). `rate_limited` means the caller is over a `@simple_limiter` policy the app's owner set on this app's sends; root answers it with a `Retry-After` header (retry after that many seconds). `idempotency_key_reused` (409) means the key already belongs to a different request on this app in the last 24 hours — nothing was sent, use a new key for a different email; `idempotency_key_in_progress` (409) means the first request with that key is still being handed to the provider — retry in a few seconds with the same key.
158
+ Refusals arrive as `PilelyError` in the hook's `error` (as `SendError` from `send()`), with a `code` drawn from `SimpleEmailErrorCode`. Match on the code, not the status: **`daily_cap_exceeded` and `rate_limited` both answer 429**. `daily_cap_exceeded` means the app spent a UTC-day allowance (retry tomorrow): the daily send cap on a template send, the 500-recipient freestyle cap on `send()`. `rate_limited` means the caller is over a `@simple_limiter` policy the app's owner set on the template send; root answers it with a `Retry-After` header (retry after that many seconds). `idempotency_key_reused` (409) means the key already belongs to a different request on this app in the last 24 hours — nothing was sent, use a new key for a different email; `idempotency_key_in_progress` (409) means the first request with that key is still being handed to the provider — retry in a few seconds with the same key.
124
159
 
125
- A send can also answer `out_of_traffic_credits` (503): the owner's traffic balance is used up, and the owner must top up. Provisioning adds `account_exists`, `account_limit_reached` and `template_limit_reached` (all 409). The rest: `not_found` (404, the uniform hide), `bad_request` (400), `unauthenticated` (401), `bad_forwarded_identity` (401), `internal` (500).
160
+ Both send routes answer an exhausted owner balance with the uniform `not_found` (404), so billing state never leaks to a caller; the owner routes answer it `out_of_traffic_credits` (503) — top up. Provisioning adds `account_exists`, `account_limit_reached` and `template_limit_reached` (all 409). The rest: `not_found` (404, the uniform hide), `bad_request` (400), `unauthenticated` (401), `bad_forwarded_identity` (401), `internal` (500).
126
161
 
127
162
  A route the app's owner put a challenge policy on (`## Bot checks: { challenge }`) answers two more, from root before the service: `challenge_required` (403, the call carried no `challenge`; nothing was sent) and `challenge_failed` (403, the `challenge` was malformed, wrong, expired, started through another app, or already used — get and solve a new one). An oversize send or template body is refused before any handler runs, so its `code` is `null`.
128
163
 
129
- Limits: billed per recipient, at most 50 recipients per send (an account send has exactly 1), 200 recipients per app per UTC day by default. `useEmailAccount()`'s `daily_cap` shows the cap in force for this app; for a higher one, open a support ticket on `@customer` naming the app, the cap you want and what the mail is.
164
+ Limits: billed per recipient. A template send has exactly 1 recipient and counts toward the daily send cap, 200 recipients per app per UTC day by default; `useEmailAccount()`'s `daily_cap` shows the cap in force for this app — for a higher one, open a support ticket on `@customer` naming the app, the cap you want and what the mail is. A freestyle send has 1 to 50 recipients and counts toward the freestyle cap, 500 recipients per app per UTC day, never the daily send cap.
130
165
 
131
166
  ## Install
132
167
 
@@ -134,4 +169,4 @@ Limits: billed per recipient, at most 50 recipients per send (an account send ha
134
169
  npm install @pilely/simple-email @pilely/core @tanstack/react-query
135
170
  ```
136
171
 
137
- `@pilely/core`, `react` (`^18.3 || ^19`) and `@tanstack/react-query` (`^5`) are peer dependencies. Always install with the scope — unscoped names like `pilely-email` are not owned by this project. This is a pre-1.0 package and moves with the platform.
172
+ `@pilely/core`, `react` (`^18.3 || ^19`) and `@tanstack/react-query` (`^5`) are peer dependencies of the main entry; the `@pilely/simple-email/send` subpath loads none of them. Always install with the scope — unscoped names like `pilely-email` are not owned by this project. This is a pre-1.0 package and moves with the platform.
package/dist/api.d.ts CHANGED
@@ -1,59 +1,5 @@
1
1
  import type { ChallengeOption } from "@pilely/core";
2
- import type { Account, AccountCursor, AccountInfoEnvelope, AccountSenders, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta, ToAccount } from "./types.js";
3
- /** `to` is capped at the service's `max_recipients_per_send` (50 by default). */
4
- export type SendContent = {
5
- template_id: string;
6
- variables?: Record<string, string>;
7
- } | {
8
- subject: string;
9
- html: string;
10
- };
11
- /**
12
- * Optional on every send shape: 1–128 characters of `[A-Za-z0-9._:-]`, held
13
- * per app for 24 hours from its first use. A repeat with the same key, the
14
- * same body and the same caller answers the first send's `send_id` with
15
- * `replayed: true` and sends and bills nothing; the same key with another
16
- * body or caller is `409 idempotency_key_reused`. Pick keys stable per
17
- * logical email (`digest:<user id>:<YYYY-MM-DD>`, `confirm:<row id>`) — a
18
- * new key per attempt defeats the protection.
19
- */
20
- export interface SendKey {
21
- idempotency_key?: string;
22
- }
23
- /** A raw send: to the addresses in `to`, template or inline content. */
24
- export type RawSendInput = {
25
- to: string[];
26
- to_account?: never;
27
- } & SendContent & SendKey;
28
- /**
29
- * An account send: a template to ONE address the service looks up itself —
30
- * the caller's own account email (`"self"`) or the account owner's
31
- * (`"owner"`). No `to`, and template content only: `subject` / `html` are a
32
- * 400 on the server, so they do not typecheck here.
33
- */
34
- export type AccountSendInput = {
35
- to_account: ToAccount;
36
- to?: never;
37
- template_id: string;
38
- variables?: Record<string, string>;
39
- subject?: never;
40
- html?: never;
41
- } & SendKey;
42
- /** Exactly one of `to` (a raw send) or `to_account` (an account send). */
43
- export type SendInput = RawSendInput | AccountSendInput;
44
- /** `replayed` is `true` when the answer is an earlier keyed send's (nothing
45
- * new was sent), `false` for a new send — and always `false` without an
46
- * `idempotency_key`. An account send's answer never carries an address. */
47
- export interface SendResult {
48
- send_id: string;
49
- recipient_count: number;
50
- replayed: boolean;
51
- }
52
- export interface ListSendsOptions {
53
- limit?: number;
54
- after_created_time_stamp?: number;
55
- after_send_id?: string;
56
- }
2
+ import type { Account, AccountCursor, AccountInfoEnvelope, Template, TemplateCursor, TemplateMeta } from "./types.js";
57
3
  export interface ListAccountsOptions {
58
4
  limit?: number;
59
5
  after_created_time_stamp?: number;
@@ -64,36 +10,54 @@ export interface ListTemplatesOptions {
64
10
  after_created_time_stamp?: number;
65
11
  after_template_id?: string;
66
12
  }
67
- /** Both keys are REQUIRED — the server rejects either one missing with a
68
- * 400 (`ContentField::validate`), matching `updateTemplate`'s full-replace
69
- * contract: there is no patch form on this route. */
13
+ /** The template send's answer. Never the template's recipient. */
14
+ export interface TemplateSendResult {
15
+ send_id: string;
16
+ }
17
+ /** `recipient` is REQUIRED: the ONE address every template send of this
18
+ * template goes to — any address the owner likes, never verified. The two
19
+ * groups are optional; omitted or `null` is an empty group. `send_group`
20
+ * takes an owner group or one of the two platform-wide groups (anonymous,
21
+ * authenticated — `@pilely/simple-group`'s `anonymousGroupId()` /
22
+ * `authenticatedGroupId()`); `deny_group` takes an owner group only. */
23
+ export interface CreateTemplateBody {
24
+ /** `null` for a nameless template. */
25
+ name: string | null;
26
+ subject: string;
27
+ html: string;
28
+ recipient: string;
29
+ send_group?: string | null;
30
+ deny_group?: string | null;
31
+ }
32
+ /** `subject` and `html` are REQUIRED — the server rejects either one missing
33
+ * with a 400: the content is always a full replace. The other three are
34
+ * optional: an omitted key keeps the stored value, `null` empties a group,
35
+ * and `recipient` cannot be emptied (`null` is a 400, so it does not
36
+ * typecheck). */
70
37
  export interface UpdateTemplateBody {
71
38
  subject: string;
72
39
  html: string;
40
+ recipient?: string;
41
+ send_group?: string | null;
42
+ deny_group?: string | null;
73
43
  }
74
44
  /**
75
- * `app_id` is added from core's `appId()` — it is not a caller parameter.
76
- * Body size is capped on this route (like `createTemplate`/`updateTemplate`):
77
- * an oversize body is rejected by the server before any handler runs, so
78
- * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
79
- * carries a null `code`.
45
+ * The template send: `POST /accounts/{app_id}/templates/{template_id}/send`
46
+ * with the page's own app id. Sends that template, filled from `variables`,
47
+ * to the one recipient the owner stored on it — the caller can never point
48
+ * it anywhere else. `variables` is the body itself: each placeholder of the
49
+ * template's subject and html is read from the field of the same name, and
50
+ * every other field is ignored. A missing placeholder is a 400 and nothing
51
+ * is sent. The service admits the caller only when it is in the template's
52
+ * `send_group` and not in its `deny_group`; everyone else, an unknown
53
+ * template and an exhausted owner balance alike, gets the uniform 404.
80
54
  */
81
- export declare function send(input: SendInput, { challenge }?: ChallengeOption): Promise<SendResult>;
82
- /** `app_id` lives in the body here, like `send` — not in the path. The
83
- * paged primitive; use `listAllSends` to walk to the end. */
84
- export declare function listSends(options?: ListSendsOptions, { challenge }?: ChallengeOption): Promise<{
85
- sends: SendRow[];
86
- next_cursor: SendCursor | null;
87
- }>;
88
- export declare function listAllSends(request?: ChallengeOption): Promise<SendRow[]>;
89
- /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
90
- * key is left off the wire, meaning owner-only. `setAccountAccess` below
91
- * is the opposite: its key is always required. `accountSenders` is
92
- * optional too, and left off the wire when `undefined` (the server then
93
- * uses `"send_group"`; it refuses an explicit `null` with a 400).
94
- * `replyTo` is optional as well: `undefined` leaves the key off the wire,
95
- * and omitted or `null` both mean no reply-to. */
96
- export declare function createAccount(sendGroup?: string | null, accountSenders?: AccountSenders, replyTo?: string | null, { challenge }?: ChallengeOption): Promise<Account>;
55
+ export declare function sendTemplate(templateId: string, variables?: Record<string, string>, { challenge }?: ChallengeOption): Promise<TemplateSendResult>;
56
+ /** `replyTo` is optional: `undefined` leaves the key off the wire, and
57
+ * omitted or `null` both mean no reply-to. The account carries no send
58
+ * access of its own — who may send is each template's groups, and the
59
+ * app's backend. */
60
+ export declare function createAccount(replyTo?: string | null, { challenge }?: ChallengeOption): Promise<Account>;
97
61
  /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
98
62
  * not a selector. The paged primitive; use `listAllAccounts` to walk to
99
63
  * the end. */
@@ -106,11 +70,6 @@ export declare function listAllAccounts(request?: ChallengeOption): Promise<Acco
106
70
  * fields are nullable siblings of `account`, not nested inside it. */
107
71
  export declare function accountInfo({ challenge }?: ChallengeOption): Promise<AccountInfoEnvelope>;
108
72
  export declare function deleteAccount({ challenge }?: ChallengeOption): Promise<void>;
109
- /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
110
- * the opposite of `createAccount`, where the key is optional. `null`
111
- * means owner-only. `accountSenders` is optional: `undefined` leaves the
112
- * key off the wire, which KEEPS the account's current value. */
113
- export declare function setAccountAccess(sendGroup: string | null, accountSenders?: AccountSenders, { challenge }?: ChallengeOption): Promise<Account>;
114
73
  /** `replyTo` is REQUIRED and its key is always sent, even `null` — the
115
74
  * server refuses a missing key with a 400, so a typo can never silently
116
75
  * clear it. An address sets where recipients' replies go (every send from
@@ -119,10 +78,10 @@ export declare function setReplyTo(replyTo: string | null, { challenge }?: Chall
119
78
  /** Returns metadata only — no `html`. Use `templateInfo` to read the body
120
79
  * back.
121
80
  *
122
- * `name` is nullable because the service accepts a nameless template. It
123
- * stays a required POSITIONAL parameter — pass an explicit `null`, not
124
- * nothing — and `TemplateMeta.name` then comes back `null`. */
125
- export declare function createTemplate(name: string | null, subject: string, html: string, { challenge }?: ChallengeOption): Promise<TemplateMeta>;
81
+ * `name` is nullable because the service accepts a nameless template: pass
82
+ * an explicit `null`, and `TemplateMeta.name` comes back `null`. An
83
+ * omitted group is left off the wire (an empty group). */
84
+ export declare function createTemplate(body: CreateTemplateBody, { challenge }?: ChallengeOption): Promise<TemplateMeta>;
126
85
  /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
127
86
  * carry metadata only — no `html`. */
128
87
  export declare function listTemplates(options?: ListTemplatesOptions, { challenge }?: ChallengeOption): Promise<{
@@ -133,9 +92,11 @@ export declare function listAllTemplates(request?: ChallengeOption): Promise<Tem
133
92
  /** The only template route whose answer carries `html`. */
134
93
  export declare function templateInfo(templateNanoid: string, { challenge }?: ChallengeOption): Promise<Template>;
135
94
  /**
136
- * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
137
- * is not updatable at all. Returns metadata only, like `createTemplate`.
138
- * Subject to the same oversize-body caveat as `send`/`createTemplate`.
95
+ * A FULL REPLACE of both `subject` and `html`; `recipient`, `send_group` and
96
+ * `deny_group` change only when their key is sent. `name` is not updatable
97
+ * at all. Returns metadata only, like `createTemplate`. An oversize body is
98
+ * rejected by the server before any handler runs, so the thrown
99
+ * `PilelyError` carries a null `code`.
139
100
  */
140
101
  export declare function updateTemplate(templateNanoid: string, body: UpdateTemplateBody, { challenge }?: ChallengeOption): Promise<TemplateMeta>;
141
102
  export declare function deleteTemplate(templateNanoid: string, { challenge }?: ChallengeOption): Promise<void>;
package/dist/api.js CHANGED
@@ -1,9 +1,12 @@
1
- // The simple_email service's 13 POST routes, one function each. This is the
2
- // internal layer the hooks in hooks.ts call; the package exports the hooks,
3
- // not these. app_id moves between the body and the path from route to
4
- // route on this service. Every function takes `{ challenge }` — a bot-check
5
- // answer, or a function asked once per request — and every request it sends
6
- // carries it.
1
+ // The simple_email service's page-side POST routes, one function each: the
2
+ // template send and the owner's account and template management. This is
3
+ // the layer the hooks in hooks.ts call; the package exports the hooks and
4
+ // `sendTemplate`, not the rest. app_id is in the path on every route but
5
+ // `accounts/create` (body) and `accounts/list` (none). Every function takes
6
+ // `{ challenge }` — a bot-check answer, or a function asked once per
7
+ // request — and every request it sends carries it. The freestyle send is
8
+ // not here: only an app's backend may make it, so it lives in send.ts,
9
+ // which needs no browser runtime.
7
10
  import { appId, call, collectPages, isMockMode, registerMockService } from "@pilely/core";
8
11
  import { createSimpleEmailFake } from "./mock.js";
9
12
  // Importing this package (its hooks import this module) is all an app does
@@ -27,67 +30,32 @@ function requireAppId() {
27
30
  }
28
31
  // ── Sending ──────────────────────────────────────────────────────────────
29
32
  /**
30
- * `app_id` is added from core's `appId()` — it is not a caller parameter.
31
- * Body size is capped on this route (like `createTemplate`/`updateTemplate`):
32
- * an oversize body is rejected by the server before any handler runs, so
33
- * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
34
- * carries a null `code`.
33
+ * The template send: `POST /accounts/{app_id}/templates/{template_id}/send`
34
+ * with the page's own app id. Sends that template, filled from `variables`,
35
+ * to the one recipient the owner stored on it — the caller can never point
36
+ * it anywhere else. `variables` is the body itself: each placeholder of the
37
+ * template's subject and html is read from the field of the same name, and
38
+ * every other field is ignored. A missing placeholder is a 400 and nothing
39
+ * is sent. The service admits the caller only when it is in the template's
40
+ * `send_group` and not in its `deny_group`; everyone else, an unknown
41
+ * template and an exhausted owner balance alike, gets the uniform 404.
35
42
  */
36
- export async function send(input, { challenge } = {}) {
37
- const app_id = requireAppId();
38
- const idempotency_key = input.idempotency_key;
39
- const body = input.to_account !== undefined
40
- ? { app_id, to_account: input.to_account, template_id: input.template_id, variables: input.variables, idempotency_key }
41
- : "template_id" in input
42
- ? { app_id, to: input.to, template_id: input.template_id, variables: input.variables, idempotency_key }
43
- : { app_id, to: input.to, subject: input.subject, html: input.html, idempotency_key };
44
- return call({
45
- service: "simple-email",
46
- path: "/send",
47
- body,
48
- challenge,
49
- });
50
- }
51
- /** `app_id` lives in the body here, like `send` — not in the path. The
52
- * paged primitive; use `listAllSends` to walk to the end. */
53
- export async function listSends(options = {}, { challenge } = {}) {
43
+ export async function sendTemplate(templateId, variables = {}, { challenge } = {}) {
54
44
  const json = await call({
55
45
  service: "simple-email",
56
- path: "/sends/list",
57
- body: {
58
- app_id: requireAppId(),
59
- limit: options.limit,
60
- after_created_time_stamp: options.after_created_time_stamp,
61
- after_send_id: options.after_send_id,
62
- },
46
+ path: `/accounts/${requireAppId()}/templates/${templateId}/send`,
47
+ body: variables,
63
48
  challenge,
64
49
  });
65
- return { sends: json.sends, next_cursor: json.next_cursor };
66
- }
67
- export async function listAllSends(request = {}) {
68
- return collectPages(async (cursor) => {
69
- const page = await listSends({ limit: 100, ...(cursor ?? {}) }, request);
70
- return { rows: page.sends, nextCursor: page.next_cursor };
71
- });
50
+ return { send_id: json.send_id };
72
51
  }
73
52
  // ── Accounts ─────────────────────────────────────────────────────────────
74
- /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
75
- * key is left off the wire, meaning owner-only. `setAccountAccess` below
76
- * is the opposite: its key is always required. `accountSenders` is
77
- * optional too, and left off the wire when `undefined` (the server then
78
- * uses `"send_group"`; it refuses an explicit `null` with a 400).
79
- * `replyTo` is optional as well: `undefined` leaves the key off the wire,
80
- * and omitted or `null` both mean no reply-to. */
81
- export async function createAccount(sendGroup, accountSenders, replyTo, { challenge } = {}) {
82
- const body = {
83
- app_id: requireAppId(),
84
- };
85
- if (sendGroup !== undefined) {
86
- body.send_group = sendGroup;
87
- }
88
- if (accountSenders !== undefined) {
89
- body.account_senders = accountSenders;
90
- }
53
+ /** `replyTo` is optional: `undefined` leaves the key off the wire, and
54
+ * omitted or `null` both mean no reply-to. The account carries no send
55
+ * access of its own — who may send is each template's groups, and the
56
+ * app's backend. */
57
+ export async function createAccount(replyTo, { challenge } = {}) {
58
+ const body = { app_id: requireAppId() };
91
59
  if (replyTo !== undefined) {
92
60
  body.reply_to = replyTo;
93
61
  }
@@ -139,23 +107,6 @@ export async function accountInfo({ challenge } = {}) {
139
107
  export async function deleteAccount({ challenge } = {}) {
140
108
  await call({ service: "simple-email", path: `/accounts/${requireAppId()}/delete`, challenge });
141
109
  }
142
- /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
143
- * the opposite of `createAccount`, where the key is optional. `null`
144
- * means owner-only. `accountSenders` is optional: `undefined` leaves the
145
- * key off the wire, which KEEPS the account's current value. */
146
- export async function setAccountAccess(sendGroup, accountSenders, { challenge } = {}) {
147
- const body = { send_group: sendGroup };
148
- if (accountSenders !== undefined) {
149
- body.account_senders = accountSenders;
150
- }
151
- const json = await call({
152
- service: "simple-email",
153
- path: `/accounts/${requireAppId()}/access/set`,
154
- body,
155
- challenge,
156
- });
157
- return json.account;
158
- }
159
110
  /** `replyTo` is REQUIRED and its key is always sent, even `null` — the
160
111
  * server refuses a missing key with a 400, so a typo can never silently
161
112
  * clear it. An address sets where recipients' replies go (every send from
@@ -173,14 +124,14 @@ export async function setReplyTo(replyTo, { challenge } = {}) {
173
124
  /** Returns metadata only — no `html`. Use `templateInfo` to read the body
174
125
  * back.
175
126
  *
176
- * `name` is nullable because the service accepts a nameless template. It
177
- * stays a required POSITIONAL parameter — pass an explicit `null`, not
178
- * nothing — and `TemplateMeta.name` then comes back `null`. */
179
- export async function createTemplate(name, subject, html, { challenge } = {}) {
127
+ * `name` is nullable because the service accepts a nameless template: pass
128
+ * an explicit `null`, and `TemplateMeta.name` comes back `null`. An
129
+ * omitted group is left off the wire (an empty group). */
130
+ export async function createTemplate(body, { challenge } = {}) {
180
131
  const json = await call({
181
132
  service: "simple-email",
182
133
  path: `/accounts/${requireAppId()}/templates/create`,
183
- body: { name, subject, html },
134
+ body,
184
135
  challenge,
185
136
  });
186
137
  return json.template;
@@ -216,9 +167,11 @@ export async function templateInfo(templateNanoid, { challenge } = {}) {
216
167
  return json.template;
217
168
  }
218
169
  /**
219
- * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
220
- * is not updatable at all. Returns metadata only, like `createTemplate`.
221
- * Subject to the same oversize-body caveat as `send`/`createTemplate`.
170
+ * A FULL REPLACE of both `subject` and `html`; `recipient`, `send_group` and
171
+ * `deny_group` change only when their key is sent. `name` is not updatable
172
+ * at all. Returns metadata only, like `createTemplate`. An oversize body is
173
+ * rejected by the server before any handler runs, so the thrown
174
+ * `PilelyError` carries a null `code`.
222
175
  */
223
176
  export async function updateTemplate(templateNanoid, body, { challenge } = {}) {
224
177
  const json = await call({