@pilely/simple-email 0.4.3 → 0.4.5

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
@@ -8,7 +8,7 @@ React hooks for the `simple_email` service — the platform's managed transactio
8
8
  | --- | --- | --- |
9
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
10
  | `useSends({ limit? })` | `/sends/list`, paged | infinite query; this app's outbox, `SendRow[]` |
11
- | `useEmailAccount()` | `accounts/{app}/info` | query; this app's account with its daily cap, sent today, credit and phone state |
11
+ | `useEmailAccount()` | `accounts/{app}/info` | query; this app's account with its daily cap, sent today and credit state |
12
12
  | `useEmailAccounts({ limit? })` | `accounts/list`, paged | infinite query; every account the caller owns |
13
13
  | `useCreateEmailAccount()` | `accounts/create` | mutation, the optional send group, or `{ send_group?, account_senders?, reply_to? }` |
14
14
  | `useDeleteEmailAccount()` | `accounts/{app}/delete` | mutation, no variables |
@@ -22,6 +22,19 @@ React hooks for the `simple_email` service — the platform's managed transactio
22
22
 
23
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.
24
24
 
25
+ ## Bot checks: `{ challenge }`
26
+
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 })`):
28
+
29
+ ```tsx
30
+ import { getChallenge, solveChallenge } from "@pilely/simple-limiter";
31
+
32
+ const send = useSend({ challenge: async () => solveChallenge(await getChallenge()) });
33
+ await send.mutateAsync({ to_account: "owner", template_id, variables }); // a fresh answer per send
34
+ ```
35
+
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.
37
+
25
38
  ## Raw sends and account sends
26
39
 
27
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.
@@ -101,15 +114,17 @@ Importing this package in a build made with `VITE_PILELY_MOCK=1` registers an in
101
114
 
102
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.
103
116
 
104
- 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, the phone gate, rate limits, credit checks or `idempotency_key_in_progress`.
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`.
105
118
 
106
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()`.
107
120
 
108
121
  ## Error codes
109
122
 
110
- Refusals arrive as `PilelyError` in the hook's `error`, with a `code` drawn from `SimpleEmailErrorCode`. Match on the code, not the status: **`out_of_traffic_credits` and `phone_verification_unavailable` both answer 503** — one means the owner's traffic balance is used up (the owner must top up), the other is transient (retry shortly). `daily_cap_exceeded` (429) means the app spent its UTC-day allowance (retry tomorrow). `rate_limited` (429) 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.
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.
124
+
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).
111
126
 
112
- A send can also answer `phone_verification_required` (403), `phone_verification_unavailable` (503) and `out_of_traffic_credits` (503). 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). An oversize send or template body is refused before any handler runs, so its `code` is `null`.
127
+ 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`.
113
128
 
114
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.
115
130
 
package/dist/api.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ChallengeOption } from "@pilely/core";
1
2
  import type { Account, AccountCursor, AccountInfoEnvelope, AccountSenders, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta, ToAccount } from "./types.js";
2
3
  /** `to` is capped at the service's `max_recipients_per_send` (50 by default). */
3
4
  export type SendContent = {
@@ -77,14 +78,14 @@ export interface UpdateTemplateBody {
77
78
  * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
78
79
  * carries a null `code`.
79
80
  */
80
- export declare function send(input: SendInput): Promise<SendResult>;
81
+ export declare function send(input: SendInput, { challenge }?: ChallengeOption): Promise<SendResult>;
81
82
  /** `app_id` lives in the body here, like `send` — not in the path. The
82
83
  * paged primitive; use `listAllSends` to walk to the end. */
83
- export declare function listSends(options?: ListSendsOptions): Promise<{
84
+ export declare function listSends(options?: ListSendsOptions, { challenge }?: ChallengeOption): Promise<{
84
85
  sends: SendRow[];
85
86
  next_cursor: SendCursor | null;
86
87
  }>;
87
- export declare function listAllSends(): Promise<SendRow[]>;
88
+ export declare function listAllSends(request?: ChallengeOption): Promise<SendRow[]>;
88
89
  /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
89
90
  * key is left off the wire, meaning owner-only. `setAccountAccess` below
90
91
  * is the opposite: its key is always required. `accountSenders` is
@@ -92,49 +93,49 @@ export declare function listAllSends(): Promise<SendRow[]>;
92
93
  * uses `"send_group"`; it refuses an explicit `null` with a 400).
93
94
  * `replyTo` is optional as well: `undefined` leaves the key off the wire,
94
95
  * and omitted or `null` both mean no reply-to. */
95
- export declare function createAccount(sendGroup?: string | null, accountSenders?: AccountSenders, replyTo?: string | null): Promise<Account>;
96
+ export declare function createAccount(sendGroup?: string | null, accountSenders?: AccountSenders, replyTo?: string | null, { challenge }?: ChallengeOption): Promise<Account>;
96
97
  /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
97
98
  * not a selector. The paged primitive; use `listAllAccounts` to walk to
98
99
  * the end. */
99
- export declare function listAccounts(options?: ListAccountsOptions): Promise<{
100
+ export declare function listAccounts(options?: ListAccountsOptions, { challenge }?: ChallengeOption): Promise<{
100
101
  accounts: Account[];
101
102
  next_cursor: AccountCursor | null;
102
103
  }>;
103
- export declare function listAllAccounts(): Promise<Account[]>;
104
+ export declare function listAllAccounts(request?: ChallengeOption): Promise<Account[]>;
104
105
  /** Returns the account plus its live operational state — the four extra
105
106
  * fields are nullable siblings of `account`, not nested inside it. */
106
- export declare function accountInfo(): Promise<AccountInfoEnvelope>;
107
- export declare function deleteAccount(): Promise<void>;
107
+ export declare function accountInfo({ challenge }?: ChallengeOption): Promise<AccountInfoEnvelope>;
108
+ export declare function deleteAccount({ challenge }?: ChallengeOption): Promise<void>;
108
109
  /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
109
110
  * the opposite of `createAccount`, where the key is optional. `null`
110
111
  * means owner-only. `accountSenders` is optional: `undefined` leaves the
111
112
  * key off the wire, which KEEPS the account's current value. */
112
- export declare function setAccountAccess(sendGroup: string | null, accountSenders?: AccountSenders): Promise<Account>;
113
+ export declare function setAccountAccess(sendGroup: string | null, accountSenders?: AccountSenders, { challenge }?: ChallengeOption): Promise<Account>;
113
114
  /** `replyTo` is REQUIRED and its key is always sent, even `null` — the
114
115
  * server refuses a missing key with a 400, so a typo can never silently
115
116
  * clear it. An address sets where recipients' replies go (every send from
116
117
  * the account carries it as its `Reply-To`); `null` clears it. */
117
- export declare function setReplyTo(replyTo: string | null): Promise<Account>;
118
+ export declare function setReplyTo(replyTo: string | null, { challenge }?: ChallengeOption): Promise<Account>;
118
119
  /** Returns metadata only — no `html`. Use `templateInfo` to read the body
119
120
  * back.
120
121
  *
121
122
  * `name` is nullable because the service accepts a nameless template. It
122
123
  * stays a required POSITIONAL parameter — pass an explicit `null`, not
123
124
  * nothing — and `TemplateMeta.name` then comes back `null`. */
124
- export declare function createTemplate(name: string | null, subject: string, html: string): Promise<TemplateMeta>;
125
+ export declare function createTemplate(name: string | null, subject: string, html: string, { challenge }?: ChallengeOption): Promise<TemplateMeta>;
125
126
  /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
126
127
  * carry metadata only — no `html`. */
127
- export declare function listTemplates(options?: ListTemplatesOptions): Promise<{
128
+ export declare function listTemplates(options?: ListTemplatesOptions, { challenge }?: ChallengeOption): Promise<{
128
129
  templates: TemplateMeta[];
129
130
  next_cursor: TemplateCursor | null;
130
131
  }>;
131
- export declare function listAllTemplates(): Promise<TemplateMeta[]>;
132
+ export declare function listAllTemplates(request?: ChallengeOption): Promise<TemplateMeta[]>;
132
133
  /** The only template route whose answer carries `html`. */
133
- export declare function templateInfo(templateNanoid: string): Promise<Template>;
134
+ export declare function templateInfo(templateNanoid: string, { challenge }?: ChallengeOption): Promise<Template>;
134
135
  /**
135
136
  * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
136
137
  * is not updatable at all. Returns metadata only, like `createTemplate`.
137
138
  * Subject to the same oversize-body caveat as `send`/`createTemplate`.
138
139
  */
139
- export declare function updateTemplate(templateNanoid: string, body: UpdateTemplateBody): Promise<TemplateMeta>;
140
- export declare function deleteTemplate(templateNanoid: string): Promise<void>;
140
+ export declare function updateTemplate(templateNanoid: string, body: UpdateTemplateBody, { challenge }?: ChallengeOption): Promise<TemplateMeta>;
141
+ export declare function deleteTemplate(templateNanoid: string, { challenge }?: ChallengeOption): Promise<void>;
package/dist/api.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // The simple_email service's 13 POST routes, one function each. This is the
2
2
  // internal layer the hooks in hooks.ts call; the package exports the hooks,
3
3
  // not these. app_id moves between the body and the path from route to
4
- // route on this service.
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.
5
7
  import { appId, call, collectPages, isMockMode, registerMockService } from "@pilely/core";
6
8
  import { createSimpleEmailFake } from "./mock.js";
7
9
  // Importing this package (its hooks import this module) is all an app does
@@ -31,7 +33,7 @@ function requireAppId() {
31
33
  * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
32
34
  * carries a null `code`.
33
35
  */
34
- export async function send(input) {
36
+ export async function send(input, { challenge } = {}) {
35
37
  const app_id = requireAppId();
36
38
  const idempotency_key = input.idempotency_key;
37
39
  const body = input.to_account !== undefined
@@ -43,11 +45,12 @@ export async function send(input) {
43
45
  service: "simple-email",
44
46
  path: "/send",
45
47
  body,
48
+ challenge,
46
49
  });
47
50
  }
48
51
  /** `app_id` lives in the body here, like `send` — not in the path. The
49
52
  * paged primitive; use `listAllSends` to walk to the end. */
50
- export async function listSends(options = {}) {
53
+ export async function listSends(options = {}, { challenge } = {}) {
51
54
  const json = await call({
52
55
  service: "simple-email",
53
56
  path: "/sends/list",
@@ -57,12 +60,13 @@ export async function listSends(options = {}) {
57
60
  after_created_time_stamp: options.after_created_time_stamp,
58
61
  after_send_id: options.after_send_id,
59
62
  },
63
+ challenge,
60
64
  });
61
65
  return { sends: json.sends, next_cursor: json.next_cursor };
62
66
  }
63
- export async function listAllSends() {
67
+ export async function listAllSends(request = {}) {
64
68
  return collectPages(async (cursor) => {
65
- const page = await listSends({ limit: 100, ...(cursor ?? {}) });
69
+ const page = await listSends({ limit: 100, ...(cursor ?? {}) }, request);
66
70
  return { rows: page.sends, nextCursor: page.next_cursor };
67
71
  });
68
72
  }
@@ -74,7 +78,7 @@ export async function listAllSends() {
74
78
  * uses `"send_group"`; it refuses an explicit `null` with a 400).
75
79
  * `replyTo` is optional as well: `undefined` leaves the key off the wire,
76
80
  * and omitted or `null` both mean no reply-to. */
77
- export async function createAccount(sendGroup, accountSenders, replyTo) {
81
+ export async function createAccount(sendGroup, accountSenders, replyTo, { challenge } = {}) {
78
82
  const body = {
79
83
  app_id: requireAppId(),
80
84
  };
@@ -91,13 +95,14 @@ export async function createAccount(sendGroup, accountSenders, replyTo) {
91
95
  service: "simple-email",
92
96
  path: "/accounts/create",
93
97
  body,
98
+ challenge,
94
99
  });
95
100
  return json.account;
96
101
  }
97
102
  /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
98
103
  * not a selector. The paged primitive; use `listAllAccounts` to walk to
99
104
  * the end. */
100
- export async function listAccounts(options = {}) {
105
+ export async function listAccounts(options = {}, { challenge } = {}) {
101
106
  const json = await call({
102
107
  service: "simple-email",
103
108
  path: "/accounts/list",
@@ -106,38 +111,39 @@ export async function listAccounts(options = {}) {
106
111
  after_created_time_stamp: options.after_created_time_stamp,
107
112
  after_app_id: options.after_app_id,
108
113
  },
114
+ challenge,
109
115
  });
110
116
  return { accounts: json.accounts, next_cursor: json.next_cursor };
111
117
  }
112
- export async function listAllAccounts() {
118
+ export async function listAllAccounts(request = {}) {
113
119
  return collectPages(async (cursor) => {
114
- const page = await listAccounts({ limit: 100, ...(cursor ?? {}) });
120
+ const page = await listAccounts({ limit: 100, ...(cursor ?? {}) }, request);
115
121
  return { rows: page.accounts, nextCursor: page.next_cursor };
116
122
  });
117
123
  }
118
124
  /** Returns the account plus its live operational state — the four extra
119
125
  * fields are nullable siblings of `account`, not nested inside it. */
120
- export async function accountInfo() {
126
+ export async function accountInfo({ challenge } = {}) {
121
127
  const json = await call({
122
128
  service: "simple-email",
123
129
  path: `/accounts/${requireAppId()}/info`,
130
+ challenge,
124
131
  });
125
132
  return {
126
133
  account: json.account,
127
134
  daily_cap: json.daily_cap,
128
135
  sent_today: json.sent_today,
129
136
  credit_blocked: json.credit_blocked,
130
- phone_verified: json.phone_verified,
131
137
  };
132
138
  }
133
- export async function deleteAccount() {
134
- await call({ service: "simple-email", path: `/accounts/${requireAppId()}/delete` });
139
+ export async function deleteAccount({ challenge } = {}) {
140
+ await call({ service: "simple-email", path: `/accounts/${requireAppId()}/delete`, challenge });
135
141
  }
136
142
  /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
137
143
  * the opposite of `createAccount`, where the key is optional. `null`
138
144
  * means owner-only. `accountSenders` is optional: `undefined` leaves the
139
145
  * key off the wire, which KEEPS the account's current value. */
140
- export async function setAccountAccess(sendGroup, accountSenders) {
146
+ export async function setAccountAccess(sendGroup, accountSenders, { challenge } = {}) {
141
147
  const body = { send_group: sendGroup };
142
148
  if (accountSenders !== undefined) {
143
149
  body.account_senders = accountSenders;
@@ -146,6 +152,7 @@ export async function setAccountAccess(sendGroup, accountSenders) {
146
152
  service: "simple-email",
147
153
  path: `/accounts/${requireAppId()}/access/set`,
148
154
  body,
155
+ challenge,
149
156
  });
150
157
  return json.account;
151
158
  }
@@ -153,11 +160,12 @@ export async function setAccountAccess(sendGroup, accountSenders) {
153
160
  * server refuses a missing key with a 400, so a typo can never silently
154
161
  * clear it. An address sets where recipients' replies go (every send from
155
162
  * the account carries it as its `Reply-To`); `null` clears it. */
156
- export async function setReplyTo(replyTo) {
163
+ export async function setReplyTo(replyTo, { challenge } = {}) {
157
164
  const json = await call({
158
165
  service: "simple-email",
159
166
  path: `/accounts/${requireAppId()}/set_reply_to`,
160
167
  body: { reply_to: replyTo },
168
+ challenge,
161
169
  });
162
170
  return json.account;
163
171
  }
@@ -168,17 +176,18 @@ export async function setReplyTo(replyTo) {
168
176
  * `name` is nullable because the service accepts a nameless template. It
169
177
  * stays a required POSITIONAL parameter — pass an explicit `null`, not
170
178
  * nothing — and `TemplateMeta.name` then comes back `null`. */
171
- export async function createTemplate(name, subject, html) {
179
+ export async function createTemplate(name, subject, html, { challenge } = {}) {
172
180
  const json = await call({
173
181
  service: "simple-email",
174
182
  path: `/accounts/${requireAppId()}/templates/create`,
175
183
  body: { name, subject, html },
184
+ challenge,
176
185
  });
177
186
  return json.template;
178
187
  }
179
188
  /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
180
189
  * carry metadata only — no `html`. */
181
- export async function listTemplates(options = {}) {
190
+ export async function listTemplates(options = {}, { challenge } = {}) {
182
191
  const json = await call({
183
192
  service: "simple-email",
184
193
  path: `/accounts/${requireAppId()}/templates/list`,
@@ -187,20 +196,22 @@ export async function listTemplates(options = {}) {
187
196
  after_created_time_stamp: options.after_created_time_stamp,
188
197
  after_template_id: options.after_template_id,
189
198
  },
199
+ challenge,
190
200
  });
191
201
  return { templates: json.templates, next_cursor: json.next_cursor };
192
202
  }
193
- export async function listAllTemplates() {
203
+ export async function listAllTemplates(request = {}) {
194
204
  return collectPages(async (cursor) => {
195
- const page = await listTemplates({ limit: 100, ...(cursor ?? {}) });
205
+ const page = await listTemplates({ limit: 100, ...(cursor ?? {}) }, request);
196
206
  return { rows: page.templates, nextCursor: page.next_cursor };
197
207
  });
198
208
  }
199
209
  /** The only template route whose answer carries `html`. */
200
- export async function templateInfo(templateNanoid) {
210
+ export async function templateInfo(templateNanoid, { challenge } = {}) {
201
211
  const json = await call({
202
212
  service: "simple-email",
203
213
  path: `/accounts/${requireAppId()}/templates/${templateNanoid}/info`,
214
+ challenge,
204
215
  });
205
216
  return json.template;
206
217
  }
@@ -209,17 +220,19 @@ export async function templateInfo(templateNanoid) {
209
220
  * is not updatable at all. Returns metadata only, like `createTemplate`.
210
221
  * Subject to the same oversize-body caveat as `send`/`createTemplate`.
211
222
  */
212
- export async function updateTemplate(templateNanoid, body) {
223
+ export async function updateTemplate(templateNanoid, body, { challenge } = {}) {
213
224
  const json = await call({
214
225
  service: "simple-email",
215
226
  path: `/accounts/${requireAppId()}/templates/${templateNanoid}/update`,
216
227
  body,
228
+ challenge,
217
229
  });
218
230
  return json.template;
219
231
  }
220
- export async function deleteTemplate(templateNanoid) {
232
+ export async function deleteTemplate(templateNanoid, { challenge } = {}) {
221
233
  await call({
222
234
  service: "simple-email",
223
235
  path: `/accounts/${requireAppId()}/templates/${templateNanoid}/delete`,
236
+ challenge,
224
237
  });
225
238
  }
package/dist/hooks.d.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import type { ChallengeOption } from "@pilely/core";
1
2
  import type { UseInfiniteQueryResult, UseMutationResult, UseQueryResult } from "@tanstack/react-query";
2
3
  import type { SendInput, SendResult } from "./api.js";
3
4
  import type { Account, AccountInfoEnvelope, AccountSenders, SendRow, Template, TemplateMeta } from "./types.js";
4
- export interface PageOptions {
5
+ export interface PageOptions extends ChallengeOption {
5
6
  /** Rows per page; the server defaults to 50 and caps at 100. */
6
7
  limit?: number;
7
8
  }
@@ -36,30 +37,30 @@ export interface UpdateTemplateInput {
36
37
  * send (`to_account: "self" | "owner"`). Not optimistic: a send counts only
37
38
  * once the server accepted it. A repeat carrying the same `idempotency_key`
38
39
  * answers the first send with `replayed: true`. */
39
- export declare function useSend(): UseMutationResult<SendResult, Error, SendInput>;
40
+ export declare function useSend(options?: ChallengeOption): UseMutationResult<SendResult, Error, SendInput>;
40
41
  /** This app's outbox, newest first, as an infinite query. */
41
42
  export declare function useSends(options?: PageOptions): UseInfiniteQueryResult<SendRow[], Error>;
42
43
  /** The caller's email accounts across apps, as an infinite query. */
43
44
  export declare function useEmailAccounts(options?: PageOptions): UseInfiniteQueryResult<Account[], Error>;
44
- /** This app's account with its live state: daily cap, sent today, credit
45
- * and phone verification. */
46
- export declare function useEmailAccount(): UseQueryResult<AccountInfoEnvelope, Error>;
45
+ /** This app's account with its live state: daily cap, sent today and
46
+ * credit. */
47
+ export declare function useEmailAccount(options?: ChallengeOption): UseQueryResult<AccountInfoEnvelope, Error>;
47
48
  /** Creates this app's account; the variable is its optional send group
48
49
  * (`null` or omitted: owner-only), or `{ send_group?, account_senders?, reply_to? }`. */
49
- export declare function useCreateEmailAccount(): UseMutationResult<Account, Error, string | null | void | CreateEmailAccountInput>;
50
+ export declare function useCreateEmailAccount(options?: ChallengeOption): UseMutationResult<Account, Error, string | null | void | CreateEmailAccountInput>;
50
51
  /** Deletes this app's account and the templates under it. */
51
- export declare function useDeleteEmailAccount(): UseMutationResult<void, Error, void>;
52
+ export declare function useDeleteEmailAccount(options?: ChallengeOption): UseMutationResult<void, Error, void>;
52
53
  /** Replaces this app's send group (`null` means owner-only); the object form
53
54
  * `{ send_group, account_senders? }` also sets who may make account sends. */
54
- export declare function useSetEmailAccountAccess(): UseMutationResult<Account, Error, string | null | SetEmailAccountAccessInput>;
55
+ export declare function useSetEmailAccountAccess(options?: ChallengeOption): UseMutationResult<Account, Error, string | null | SetEmailAccountAccessInput>;
55
56
  /** Sets this app's reply-to address — where recipients' replies go; every
56
57
  * send from the account carries it as its `Reply-To` — or clears it with
57
58
  * `null`. The variable is required: there is no "keep" form. */
58
- export declare function useSetEmailReplyTo(): UseMutationResult<Account, Error, string | null>;
59
+ export declare function useSetEmailReplyTo(options?: ChallengeOption): UseMutationResult<Account, Error, string | null>;
59
60
  /** This app's templates (metadata only), as an infinite query. */
60
61
  export declare function useTemplates(options?: PageOptions): UseInfiniteQueryResult<TemplateMeta[], Error>;
61
62
  /** One template with its `html`. An `undefined` id disables the query. */
62
- export declare function useTemplate(templateId: string | undefined): UseQueryResult<Template, Error>;
63
- export declare function useCreateTemplate(): UseMutationResult<TemplateMeta, Error, CreateTemplateInput>;
64
- export declare function useUpdateTemplate(): UseMutationResult<TemplateMeta, Error, UpdateTemplateInput>;
65
- export declare function useDeleteTemplate(): UseMutationResult<void, Error, string>;
63
+ export declare function useTemplate(templateId: string | undefined, options?: ChallengeOption): UseQueryResult<Template, Error>;
64
+ export declare function useCreateTemplate(options?: ChallengeOption): UseMutationResult<TemplateMeta, Error, CreateTemplateInput>;
65
+ export declare function useUpdateTemplate(options?: ChallengeOption): UseMutationResult<TemplateMeta, Error, UpdateTemplateInput>;
66
+ export declare function useDeleteTemplate(options?: ChallengeOption): UseMutationResult<void, Error, string>;
package/dist/hooks.js CHANGED
@@ -15,19 +15,20 @@ const templateKey = (templateId) => serviceQueryKey("simple-email", "template",
15
15
  * send (`to_account: "self" | "owner"`). Not optimistic: a send counts only
16
16
  * once the server accepted it. A repeat carrying the same `idempotency_key`
17
17
  * answers the first send with `replayed: true`. */
18
- export function useSend() {
18
+ export function useSend(options = {}) {
19
+ const { challenge } = options;
19
20
  return useServiceMutation("useSend", {
20
- mutationFn: send,
21
+ mutationFn: (input) => send(input, { challenge }),
21
22
  invalidates: () => [sendsKey()],
22
23
  });
23
24
  }
24
25
  /** This app's outbox, newest first, as an infinite query. */
25
26
  export function useSends(options = {}) {
26
- const { limit } = options;
27
+ const { limit, challenge } = options;
27
28
  return useServiceInfiniteQuery("useSends", {
28
29
  queryKey: [...sendsKey(), { limit }],
29
30
  fetchPage: async (cursor) => {
30
- const page = await listSends({ limit, ...(cursor ?? {}) });
31
+ const page = await listSends({ limit, ...(cursor ?? {}) }, { challenge });
31
32
  return { rows: page.sends, nextCursor: page.next_cursor };
32
33
  },
33
34
  });
@@ -35,91 +36,103 @@ export function useSends(options = {}) {
35
36
  // ── Accounts ─────────────────────────────────────────────────────────────
36
37
  /** The caller's email accounts across apps, as an infinite query. */
37
38
  export function useEmailAccounts(options = {}) {
38
- const { limit } = options;
39
+ const { limit, challenge } = options;
39
40
  return useServiceInfiniteQuery("useEmailAccounts", {
40
41
  queryKey: [...accountsKey(), { limit }],
41
42
  fetchPage: async (cursor) => {
42
- const page = await listAccounts({ limit, ...(cursor ?? {}) });
43
+ const page = await listAccounts({ limit, ...(cursor ?? {}) }, { challenge });
43
44
  return { rows: page.accounts, nextCursor: page.next_cursor };
44
45
  },
45
46
  });
46
47
  }
47
- /** This app's account with its live state: daily cap, sent today, credit
48
- * and phone verification. */
49
- export function useEmailAccount() {
50
- return useServiceQuery("useEmailAccount", { queryKey: accountKey(), queryFn: accountInfo });
48
+ /** This app's account with its live state: daily cap, sent today and
49
+ * credit. */
50
+ export function useEmailAccount(options = {}) {
51
+ const { challenge } = options;
52
+ return useServiceQuery("useEmailAccount", {
53
+ queryKey: accountKey(),
54
+ queryFn: () => accountInfo({ challenge }),
55
+ });
51
56
  }
52
57
  /** Creates this app's account; the variable is its optional send group
53
58
  * (`null` or omitted: owner-only), or `{ send_group?, account_senders?, reply_to? }`. */
54
- export function useCreateEmailAccount() {
59
+ export function useCreateEmailAccount(options = {}) {
60
+ const { challenge } = options;
55
61
  return useServiceMutation("useCreateEmailAccount", {
56
62
  mutationFn: (input) => typeof input === "object" && input !== null
57
- ? createAccount(input.send_group, input.account_senders, input.reply_to)
58
- : createAccount(input ?? undefined),
63
+ ? createAccount(input.send_group, input.account_senders, input.reply_to, { challenge })
64
+ : createAccount(input ?? undefined, undefined, undefined, { challenge }),
59
65
  invalidates: () => [accountsKey(), accountKey()],
60
66
  });
61
67
  }
62
68
  /** Deletes this app's account and the templates under it. */
63
- export function useDeleteEmailAccount() {
69
+ export function useDeleteEmailAccount(options = {}) {
70
+ const { challenge } = options;
64
71
  return useServiceMutation("useDeleteEmailAccount", {
65
- mutationFn: () => deleteAccount(),
72
+ mutationFn: () => deleteAccount({ challenge }),
66
73
  invalidates: () => [accountsKey(), accountKey(), templatesKey(), serviceQueryKey("simple-email", "template")],
67
74
  });
68
75
  }
69
76
  /** Replaces this app's send group (`null` means owner-only); the object form
70
77
  * `{ send_group, account_senders? }` also sets who may make account sends. */
71
- export function useSetEmailAccountAccess() {
78
+ export function useSetEmailAccountAccess(options = {}) {
79
+ const { challenge } = options;
72
80
  return useServiceMutation("useSetEmailAccountAccess", {
73
81
  mutationFn: (input) => typeof input === "object" && input !== null
74
- ? setAccountAccess(input.send_group, input.account_senders)
75
- : setAccountAccess(input),
82
+ ? setAccountAccess(input.send_group, input.account_senders, { challenge })
83
+ : setAccountAccess(input, undefined, { challenge }),
76
84
  invalidates: () => [accountsKey(), accountKey()],
77
85
  });
78
86
  }
79
87
  /** Sets this app's reply-to address — where recipients' replies go; every
80
88
  * send from the account carries it as its `Reply-To` — or clears it with
81
89
  * `null`. The variable is required: there is no "keep" form. */
82
- export function useSetEmailReplyTo() {
90
+ export function useSetEmailReplyTo(options = {}) {
91
+ const { challenge } = options;
83
92
  return useServiceMutation("useSetEmailReplyTo", {
84
- mutationFn: (replyTo) => setReplyTo(replyTo),
93
+ mutationFn: (replyTo) => setReplyTo(replyTo, { challenge }),
85
94
  invalidates: () => [accountsKey(), accountKey()],
86
95
  });
87
96
  }
88
97
  // ── Templates ────────────────────────────────────────────────────────────
89
98
  /** This app's templates (metadata only), as an infinite query. */
90
99
  export function useTemplates(options = {}) {
91
- const { limit } = options;
100
+ const { limit, challenge } = options;
92
101
  return useServiceInfiniteQuery("useTemplates", {
93
102
  queryKey: [...templatesKey(), { limit }],
94
103
  fetchPage: async (cursor) => {
95
- const page = await listTemplates({ limit, ...(cursor ?? {}) });
104
+ const page = await listTemplates({ limit, ...(cursor ?? {}) }, { challenge });
96
105
  return { rows: page.templates, nextCursor: page.next_cursor };
97
106
  },
98
107
  });
99
108
  }
100
109
  /** One template with its `html`. An `undefined` id disables the query. */
101
- export function useTemplate(templateId) {
110
+ export function useTemplate(templateId, options = {}) {
111
+ const { challenge } = options;
102
112
  return useServiceQuery("useTemplate", {
103
113
  queryKey: templateKey(templateId),
104
- queryFn: () => templateInfo(templateId),
114
+ queryFn: () => templateInfo(templateId, { challenge }),
105
115
  enabled: templateId !== undefined,
106
116
  });
107
117
  }
108
- export function useCreateTemplate() {
118
+ export function useCreateTemplate(options = {}) {
119
+ const { challenge } = options;
109
120
  return useServiceMutation("useCreateTemplate", {
110
- mutationFn: ({ name, subject, html }) => createTemplate(name, subject, html),
121
+ mutationFn: ({ name, subject, html }) => createTemplate(name, subject, html, { challenge }),
111
122
  invalidates: () => [templatesKey()],
112
123
  });
113
124
  }
114
- export function useUpdateTemplate() {
125
+ export function useUpdateTemplate(options = {}) {
126
+ const { challenge } = options;
115
127
  return useServiceMutation("useUpdateTemplate", {
116
- mutationFn: ({ templateId, subject, html }) => updateTemplate(templateId, { subject, html }),
128
+ mutationFn: ({ templateId, subject, html }) => updateTemplate(templateId, { subject, html }, { challenge }),
117
129
  invalidates: (_template, { templateId }) => [templatesKey(), templateKey(templateId)],
118
130
  });
119
131
  }
120
- export function useDeleteTemplate() {
132
+ export function useDeleteTemplate(options = {}) {
133
+ const { challenge } = options;
121
134
  return useServiceMutation("useDeleteTemplate", {
122
- mutationFn: deleteTemplate,
135
+ mutationFn: (templateId) => deleteTemplate(templateId, { challenge }),
123
136
  invalidates: (_none, templateId) => [templatesKey(), templateKey(templateId)],
124
137
  });
125
138
  }
package/dist/mock.js CHANGED
@@ -5,8 +5,8 @@
5
5
  // answers) and logs the message to the console. Accounts and templates
6
6
  // live in memory and persist through the core store; an app's account is
7
7
  // created on its first send if it does not exist yet. It models shape, not
8
- // policy: no send groups, `account_senders` gating, daily caps, phone gates
9
- // or credit checks. It does model the send shapes (raw and account sends)
8
+ // policy: no send groups, `account_senders` gating, daily caps or credit
9
+ // checks. It does model the send shapes (raw and account sends)
10
10
  // and `idempotency_key`: a repeat with the same key, body and caller within
11
11
  // 24 hours answers the first send with `replayed: true` and adds no row; the
12
12
  // same key with another body or caller is `409 idempotency_key_reused`. An
@@ -322,7 +322,6 @@ export const createSimpleEmailFake = (ctx) => {
322
322
  daily_cap: DAILY_CAP,
323
323
  sent_today: sentToday,
324
324
  credit_blocked: false,
325
- phone_verified: true,
326
325
  });
327
326
  }
328
327
  if (route === "delete") {
package/dist/types.d.ts CHANGED
@@ -2,10 +2,10 @@
2
2
  * refusal, including the two `idempotency_key_*` conflicts, checked
3
3
  * against the service's `response_json.rs` and `render.rs`.
4
4
  *
5
- * This is the union that most earns its keep: `out_of_traffic_credits` and
6
- * `phone_verification_unavailable` BOTH answer 503, so a consumer cannot tell
7
- * a used-up owner balance (the owner must top up) from a transient outage
8
- * (retry shortly) by status alone — only by matching this code. */
5
+ * Status alone does not say what to do: `daily_cap_exceeded` and
6
+ * `rate_limited` BOTH answer 429, so a consumer cannot tell a spent UTC-day
7
+ * allowance (retry tomorrow) from a limiter policy (retry after
8
+ * `Retry-After`) by status alone — only by matching this code. */
9
9
  export type SimpleEmailErrorCode = "unauthenticated" | "bad_forwarded_identity" | "bad_request" | "not_found" | "account_exists" | "account_limit_reached" | "template_limit_reached"
10
10
  /** 409 — this `idempotency_key` already belongs to a different request
11
11
  * (another body or another caller) on this app in the last 24 hours;
@@ -15,10 +15,18 @@ export type SimpleEmailErrorCode = "unauthenticated" | "bad_forwarded_identity"
15
15
  * handed to the provider; retry in a few seconds. */
16
16
  | "idempotency_key_in_progress"
17
17
  /** 429 — the per-app UTC-day send cap. */
18
- | "daily_cap_exceeded" | "phone_verification_required" | "phone_verification_unavailable" | "out_of_traffic_credits"
18
+ | "daily_cap_exceeded" | "out_of_traffic_credits"
19
19
  /** 429 — over a `@simple_limiter` policy the app's owner set on this
20
20
  * app's sends; root answers it with a `Retry-After` header. */
21
- | "rate_limited" | "internal";
21
+ | "rate_limited"
22
+ /** 403 — root's challenge check: a challenge policy the app's owner set
23
+ * on this route (for example `POST /send`) matches the call, and it
24
+ * carried no `challenge`. Nothing was sent. */
25
+ | "challenge_required"
26
+ /** 403 — root's challenge check refused the `challenge` sent: malformed,
27
+ * wrong, expired, started through another app, or already used. Get and
28
+ * solve a new one (`@pilely/simple-limiter`) and send the call again. */
29
+ | "challenge_failed" | "internal";
22
30
  /** Who besides the owner may make account sends (`to_account`):
23
31
  * `"send_group"` (the default) keeps them to the owner and the send group's
24
32
  * members; `"app_users"` also lets every signed-in user arriving through the
@@ -35,8 +43,8 @@ export interface Account {
35
43
  app_id: string;
36
44
  address: string | null;
37
45
  display_name: string | null;
38
- /** `null` means owner-only — the opposite polarity from simple_db's
39
- * `read_group`/`write_group`, where `null` means everyone. */
46
+ /** `null` means owner-only. Unlike a simple_db policy's `group`, it
47
+ * cannot name the platform-wide anonymous or authenticated group. */
40
48
  send_group: string | null;
41
49
  account_senders: AccountSenders;
42
50
  /** The address recipients' replies go to: every send from the account
@@ -51,7 +59,6 @@ export interface AccountInfoEnvelope {
51
59
  daily_cap: number;
52
60
  sent_today: number | null;
53
61
  credit_blocked: boolean | null;
54
- phone_verified: boolean | null;
55
62
  }
56
63
  /** What `templates/create` and `templates/update` answer with — no `html`. */
57
64
  export interface TemplateMeta {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilely/simple-email",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "React hooks for the simple_email service.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  "directory": "npm/simple-email"
22
22
  },
23
23
  "peerDependencies": {
24
- "@pilely/core": "^0.4.0",
24
+ "@pilely/core": "^0.4.4",
25
25
  "@tanstack/react-query": "^5.0.0",
26
26
  "react": "^18.3.0 || ^19.0.0"
27
27
  },