@pilely/simple-email 0.4.2 → 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,11 +8,12 @@ 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
- | `useCreateEmailAccount()` | `accounts/create` | mutation, the optional send group, or `{ send_group?, account_senders? }` |
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 |
15
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
+ | `useSetEmailReplyTo()` | `accounts/{app}/set_reply_to` | mutation, the reply-to address, or `null` to clear it; always sent, never "keep" |
16
17
  | `useTemplates({ limit? })` | `templates/list`, paged | infinite query; metadata only |
17
18
  | `useTemplate(templateId)` | `templates/{t}/info` | query; the template with its `html`; an `undefined` id disables it |
18
19
  | `useCreateTemplate()` | `templates/create` | mutation, `{ name, subject, html }` (`name` may be `null`) |
@@ -21,12 +22,29 @@ React hooks for the `simple_email` service — the platform's managed transactio
21
22
 
22
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.
23
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
+
24
38
  ## Raw sends and account sends
25
39
 
26
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.
27
41
 
28
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.
29
43
 
44
+ ## Replies: `reply_to`
45
+
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.
47
+
30
48
  ## Retries: `idempotency_key`
31
49
 
32
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`.
@@ -36,7 +54,7 @@ Give a send an `idempotency_key` (1–128 characters of `[A-Za-z0-9._:-]`, held
36
54
  | Write | Invalidates |
37
55
  | --- | --- |
38
56
  | send | `useSends()` |
39
- | account create / set access | `useEmailAccounts()`, `useEmailAccount()` |
57
+ | account create / set access / set reply-to | `useEmailAccounts()`, `useEmailAccount()` |
40
58
  | account delete | `useEmailAccounts()`, `useEmailAccount()`, and the templates under it |
41
59
  | template create | `useTemplates()` |
42
60
  | template update / delete on `t` | `useTemplates()`, `useTemplate(t)` |
@@ -96,15 +114,17 @@ Importing this package in a build made with `VITE_PILELY_MOCK=1` registers an in
96
114
 
97
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.
98
116
 
99
- 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. 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`.
100
118
 
101
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()`.
102
120
 
103
121
  ## Error codes
104
122
 
105
- 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).
106
126
 
107
- 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`.
108
128
 
109
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.
110
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,57 +78,64 @@ 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
91
92
  * optional too, and left off the wire when `undefined` (the server then
92
- * uses `"send_group"`; it refuses an explicit `null` with a 400). */
93
- export declare function createAccount(sendGroup?: string | null, accountSenders?: AccountSenders): Promise<Account>;
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>;
94
97
  /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
95
98
  * not a selector. The paged primitive; use `listAllAccounts` to walk to
96
99
  * the end. */
97
- export declare function listAccounts(options?: ListAccountsOptions): Promise<{
100
+ export declare function listAccounts(options?: ListAccountsOptions, { challenge }?: ChallengeOption): Promise<{
98
101
  accounts: Account[];
99
102
  next_cursor: AccountCursor | null;
100
103
  }>;
101
- export declare function listAllAccounts(): Promise<Account[]>;
104
+ export declare function listAllAccounts(request?: ChallengeOption): Promise<Account[]>;
102
105
  /** Returns the account plus its live operational state — the four extra
103
106
  * fields are nullable siblings of `account`, not nested inside it. */
104
- export declare function accountInfo(): Promise<AccountInfoEnvelope>;
105
- export declare function deleteAccount(): Promise<void>;
107
+ export declare function accountInfo({ challenge }?: ChallengeOption): Promise<AccountInfoEnvelope>;
108
+ export declare function deleteAccount({ challenge }?: ChallengeOption): Promise<void>;
106
109
  /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
107
110
  * the opposite of `createAccount`, where the key is optional. `null`
108
111
  * means owner-only. `accountSenders` is optional: `undefined` leaves the
109
112
  * key off the wire, which KEEPS the account's current value. */
110
- 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>;
114
+ /** `replyTo` is REQUIRED and its key is always sent, even `null` — the
115
+ * server refuses a missing key with a 400, so a typo can never silently
116
+ * clear it. An address sets where recipients' replies go (every send from
117
+ * the account carries it as its `Reply-To`); `null` clears it. */
118
+ export declare function setReplyTo(replyTo: string | null, { challenge }?: ChallengeOption): Promise<Account>;
111
119
  /** Returns metadata only — no `html`. Use `templateInfo` to read the body
112
120
  * back.
113
121
  *
114
122
  * `name` is nullable because the service accepts a nameless template. It
115
123
  * stays a required POSITIONAL parameter — pass an explicit `null`, not
116
124
  * nothing — and `TemplateMeta.name` then comes back `null`. */
117
- 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>;
118
126
  /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
119
127
  * carry metadata only — no `html`. */
120
- export declare function listTemplates(options?: ListTemplatesOptions): Promise<{
128
+ export declare function listTemplates(options?: ListTemplatesOptions, { challenge }?: ChallengeOption): Promise<{
121
129
  templates: TemplateMeta[];
122
130
  next_cursor: TemplateCursor | null;
123
131
  }>;
124
- export declare function listAllTemplates(): Promise<TemplateMeta[]>;
132
+ export declare function listAllTemplates(request?: ChallengeOption): Promise<TemplateMeta[]>;
125
133
  /** The only template route whose answer carries `html`. */
126
- export declare function templateInfo(templateNanoid: string): Promise<Template>;
134
+ export declare function templateInfo(templateNanoid: string, { challenge }?: ChallengeOption): Promise<Template>;
127
135
  /**
128
136
  * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
129
137
  * is not updatable at all. Returns metadata only, like `createTemplate`.
130
138
  * Subject to the same oversize-body caveat as `send`/`createTemplate`.
131
139
  */
132
- export declare function updateTemplate(templateNanoid: string, body: UpdateTemplateBody): Promise<TemplateMeta>;
133
- 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
- // The simple_email service's 12 POST routes, one function each. This is the
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
  }
@@ -71,8 +75,10 @@ export async function listAllSends() {
71
75
  * key is left off the wire, meaning owner-only. `setAccountAccess` below
72
76
  * is the opposite: its key is always required. `accountSenders` is
73
77
  * optional too, and left off the wire when `undefined` (the server then
74
- * uses `"send_group"`; it refuses an explicit `null` with a 400). */
75
- export async function createAccount(sendGroup, accountSenders) {
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 } = {}) {
76
82
  const body = {
77
83
  app_id: requireAppId(),
78
84
  };
@@ -82,17 +88,21 @@ export async function createAccount(sendGroup, accountSenders) {
82
88
  if (accountSenders !== undefined) {
83
89
  body.account_senders = accountSenders;
84
90
  }
91
+ if (replyTo !== undefined) {
92
+ body.reply_to = replyTo;
93
+ }
85
94
  const json = await call({
86
95
  service: "simple-email",
87
96
  path: "/accounts/create",
88
97
  body,
98
+ challenge,
89
99
  });
90
100
  return json.account;
91
101
  }
92
102
  /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
93
103
  * not a selector. The paged primitive; use `listAllAccounts` to walk to
94
104
  * the end. */
95
- export async function listAccounts(options = {}) {
105
+ export async function listAccounts(options = {}, { challenge } = {}) {
96
106
  const json = await call({
97
107
  service: "simple-email",
98
108
  path: "/accounts/list",
@@ -101,38 +111,39 @@ export async function listAccounts(options = {}) {
101
111
  after_created_time_stamp: options.after_created_time_stamp,
102
112
  after_app_id: options.after_app_id,
103
113
  },
114
+ challenge,
104
115
  });
105
116
  return { accounts: json.accounts, next_cursor: json.next_cursor };
106
117
  }
107
- export async function listAllAccounts() {
118
+ export async function listAllAccounts(request = {}) {
108
119
  return collectPages(async (cursor) => {
109
- const page = await listAccounts({ limit: 100, ...(cursor ?? {}) });
120
+ const page = await listAccounts({ limit: 100, ...(cursor ?? {}) }, request);
110
121
  return { rows: page.accounts, nextCursor: page.next_cursor };
111
122
  });
112
123
  }
113
124
  /** Returns the account plus its live operational state — the four extra
114
125
  * fields are nullable siblings of `account`, not nested inside it. */
115
- export async function accountInfo() {
126
+ export async function accountInfo({ challenge } = {}) {
116
127
  const json = await call({
117
128
  service: "simple-email",
118
129
  path: `/accounts/${requireAppId()}/info`,
130
+ challenge,
119
131
  });
120
132
  return {
121
133
  account: json.account,
122
134
  daily_cap: json.daily_cap,
123
135
  sent_today: json.sent_today,
124
136
  credit_blocked: json.credit_blocked,
125
- phone_verified: json.phone_verified,
126
137
  };
127
138
  }
128
- export async function deleteAccount() {
129
- 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 });
130
141
  }
131
142
  /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
132
143
  * the opposite of `createAccount`, where the key is optional. `null`
133
144
  * means owner-only. `accountSenders` is optional: `undefined` leaves the
134
145
  * key off the wire, which KEEPS the account's current value. */
135
- export async function setAccountAccess(sendGroup, accountSenders) {
146
+ export async function setAccountAccess(sendGroup, accountSenders, { challenge } = {}) {
136
147
  const body = { send_group: sendGroup };
137
148
  if (accountSenders !== undefined) {
138
149
  body.account_senders = accountSenders;
@@ -141,6 +152,20 @@ export async function setAccountAccess(sendGroup, accountSenders) {
141
152
  service: "simple-email",
142
153
  path: `/accounts/${requireAppId()}/access/set`,
143
154
  body,
155
+ challenge,
156
+ });
157
+ return json.account;
158
+ }
159
+ /** `replyTo` is REQUIRED and its key is always sent, even `null` — the
160
+ * server refuses a missing key with a 400, so a typo can never silently
161
+ * clear it. An address sets where recipients' replies go (every send from
162
+ * the account carries it as its `Reply-To`); `null` clears it. */
163
+ export async function setReplyTo(replyTo, { challenge } = {}) {
164
+ const json = await call({
165
+ service: "simple-email",
166
+ path: `/accounts/${requireAppId()}/set_reply_to`,
167
+ body: { reply_to: replyTo },
168
+ challenge,
144
169
  });
145
170
  return json.account;
146
171
  }
@@ -151,17 +176,18 @@ export async function setAccountAccess(sendGroup, accountSenders) {
151
176
  * `name` is nullable because the service accepts a nameless template. It
152
177
  * stays a required POSITIONAL parameter — pass an explicit `null`, not
153
178
  * nothing — and `TemplateMeta.name` then comes back `null`. */
154
- export async function createTemplate(name, subject, html) {
179
+ export async function createTemplate(name, subject, html, { challenge } = {}) {
155
180
  const json = await call({
156
181
  service: "simple-email",
157
182
  path: `/accounts/${requireAppId()}/templates/create`,
158
183
  body: { name, subject, html },
184
+ challenge,
159
185
  });
160
186
  return json.template;
161
187
  }
162
188
  /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
163
189
  * carry metadata only — no `html`. */
164
- export async function listTemplates(options = {}) {
190
+ export async function listTemplates(options = {}, { challenge } = {}) {
165
191
  const json = await call({
166
192
  service: "simple-email",
167
193
  path: `/accounts/${requireAppId()}/templates/list`,
@@ -170,20 +196,22 @@ export async function listTemplates(options = {}) {
170
196
  after_created_time_stamp: options.after_created_time_stamp,
171
197
  after_template_id: options.after_template_id,
172
198
  },
199
+ challenge,
173
200
  });
174
201
  return { templates: json.templates, next_cursor: json.next_cursor };
175
202
  }
176
- export async function listAllTemplates() {
203
+ export async function listAllTemplates(request = {}) {
177
204
  return collectPages(async (cursor) => {
178
- const page = await listTemplates({ limit: 100, ...(cursor ?? {}) });
205
+ const page = await listTemplates({ limit: 100, ...(cursor ?? {}) }, request);
179
206
  return { rows: page.templates, nextCursor: page.next_cursor };
180
207
  });
181
208
  }
182
209
  /** The only template route whose answer carries `html`. */
183
- export async function templateInfo(templateNanoid) {
210
+ export async function templateInfo(templateNanoid, { challenge } = {}) {
184
211
  const json = await call({
185
212
  service: "simple-email",
186
213
  path: `/accounts/${requireAppId()}/templates/${templateNanoid}/info`,
214
+ challenge,
187
215
  });
188
216
  return json.template;
189
217
  }
@@ -192,17 +220,19 @@ export async function templateInfo(templateNanoid) {
192
220
  * is not updatable at all. Returns metadata only, like `createTemplate`.
193
221
  * Subject to the same oversize-body caveat as `send`/`createTemplate`.
194
222
  */
195
- export async function updateTemplate(templateNanoid, body) {
223
+ export async function updateTemplate(templateNanoid, body, { challenge } = {}) {
196
224
  const json = await call({
197
225
  service: "simple-email",
198
226
  path: `/accounts/${requireAppId()}/templates/${templateNanoid}/update`,
199
227
  body,
228
+ challenge,
200
229
  });
201
230
  return json.template;
202
231
  }
203
- export async function deleteTemplate(templateNanoid) {
232
+ export async function deleteTemplate(templateNanoid, { challenge } = {}) {
204
233
  await call({
205
234
  service: "simple-email",
206
235
  path: `/accounts/${requireAppId()}/templates/${templateNanoid}/delete`,
236
+ challenge,
207
237
  });
208
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
  }
@@ -11,12 +12,14 @@ export interface CreateTemplateInput {
11
12
  subject: string;
12
13
  html: string;
13
14
  }
14
- /** `useCreateEmailAccount`'s object form. Both keys are optional: an omitted
15
+ /** `useCreateEmailAccount`'s object form. Every key is optional: an omitted
15
16
  * `send_group` means owner-only, an omitted `account_senders` means
16
- * `"send_group"`. */
17
+ * `"send_group"`, and an omitted (or `null`) `reply_to` means no reply-to. */
17
18
  export interface CreateEmailAccountInput {
18
19
  send_group?: string | null;
19
20
  account_senders?: AccountSenders;
21
+ /** The address recipients' replies go to. */
22
+ reply_to?: string | null;
20
23
  }
21
24
  /** `useSetEmailAccountAccess`'s object form. `send_group` is required (`null`
22
25
  * = owner-only); an omitted `account_senders` keeps the current value. */
@@ -34,26 +37,30 @@ export interface UpdateTemplateInput {
34
37
  * send (`to_account: "self" | "owner"`). Not optimistic: a send counts only
35
38
  * once the server accepted it. A repeat carrying the same `idempotency_key`
36
39
  * answers the first send with `replayed: true`. */
37
- export declare function useSend(): UseMutationResult<SendResult, Error, SendInput>;
40
+ export declare function useSend(options?: ChallengeOption): UseMutationResult<SendResult, Error, SendInput>;
38
41
  /** This app's outbox, newest first, as an infinite query. */
39
42
  export declare function useSends(options?: PageOptions): UseInfiniteQueryResult<SendRow[], Error>;
40
43
  /** The caller's email accounts across apps, as an infinite query. */
41
44
  export declare function useEmailAccounts(options?: PageOptions): UseInfiniteQueryResult<Account[], Error>;
42
- /** This app's account with its live state: daily cap, sent today, credit
43
- * and phone verification. */
44
- 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>;
45
48
  /** Creates this app's account; the variable is its optional send group
46
- * (`null` or omitted: owner-only), or `{ send_group?, account_senders? }`. */
47
- export declare function useCreateEmailAccount(): UseMutationResult<Account, Error, string | null | void | CreateEmailAccountInput>;
49
+ * (`null` or omitted: owner-only), or `{ send_group?, account_senders?, reply_to? }`. */
50
+ export declare function useCreateEmailAccount(options?: ChallengeOption): UseMutationResult<Account, Error, string | null | void | CreateEmailAccountInput>;
48
51
  /** Deletes this app's account and the templates under it. */
49
- export declare function useDeleteEmailAccount(): UseMutationResult<void, Error, void>;
52
+ export declare function useDeleteEmailAccount(options?: ChallengeOption): UseMutationResult<void, Error, void>;
50
53
  /** Replaces this app's send group (`null` means owner-only); the object form
51
54
  * `{ send_group, account_senders? }` also sets who may make account sends. */
52
- export declare function useSetEmailAccountAccess(): UseMutationResult<Account, Error, string | null | SetEmailAccountAccessInput>;
55
+ export declare function useSetEmailAccountAccess(options?: ChallengeOption): UseMutationResult<Account, Error, string | null | SetEmailAccountAccessInput>;
56
+ /** Sets this app's reply-to address — where recipients' replies go; every
57
+ * send from the account carries it as its `Reply-To` — or clears it with
58
+ * `null`. The variable is required: there is no "keep" form. */
59
+ export declare function useSetEmailReplyTo(options?: ChallengeOption): UseMutationResult<Account, Error, string | null>;
53
60
  /** This app's templates (metadata only), as an infinite query. */
54
61
  export declare function useTemplates(options?: PageOptions): UseInfiniteQueryResult<TemplateMeta[], Error>;
55
62
  /** One template with its `html`. An `undefined` id disables the query. */
56
- export declare function useTemplate(templateId: string | undefined): UseQueryResult<Template, Error>;
57
- export declare function useCreateTemplate(): UseMutationResult<TemplateMeta, Error, CreateTemplateInput>;
58
- export declare function useUpdateTemplate(): UseMutationResult<TemplateMeta, Error, UpdateTemplateInput>;
59
- 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
@@ -1,5 +1,5 @@
1
1
  import { serviceQueryKey, useServiceInfiniteQuery, useServiceMutation, useServiceQuery } from "@pilely/core";
2
- import { accountInfo, createAccount, createTemplate, deleteAccount, deleteTemplate, listAccounts, listSends, listTemplates, send, setAccountAccess, templateInfo, updateTemplate, } from "./api.js";
2
+ import { accountInfo, createAccount, createTemplate, deleteAccount, deleteTemplate, listAccounts, listSends, listTemplates, send, setAccountAccess, setReplyTo, templateInfo, updateTemplate, } from "./api.js";
3
3
  /** `["pilely", "simple-email", "sends", options]` */
4
4
  const sendsKey = () => serviceQueryKey("simple-email", "sends");
5
5
  /** `["pilely", "simple-email", "accounts", options]` */
@@ -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,82 +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
- * (`null` or omitted: owner-only), or `{ send_group?, account_senders? }`. */
54
- export function useCreateEmailAccount() {
58
+ * (`null` or omitted: owner-only), or `{ send_group?, account_senders?, reply_to? }`. */
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)
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 }),
84
+ invalidates: () => [accountsKey(), accountKey()],
85
+ });
86
+ }
87
+ /** Sets this app's reply-to address — where recipients' replies go; every
88
+ * send from the account carries it as its `Reply-To` — or clears it with
89
+ * `null`. The variable is required: there is no "keep" form. */
90
+ export function useSetEmailReplyTo(options = {}) {
91
+ const { challenge } = options;
92
+ return useServiceMutation("useSetEmailReplyTo", {
93
+ mutationFn: (replyTo) => setReplyTo(replyTo, { challenge }),
76
94
  invalidates: () => [accountsKey(), accountKey()],
77
95
  });
78
96
  }
79
97
  // ── Templates ────────────────────────────────────────────────────────────
80
98
  /** This app's templates (metadata only), as an infinite query. */
81
99
  export function useTemplates(options = {}) {
82
- const { limit } = options;
100
+ const { limit, challenge } = options;
83
101
  return useServiceInfiniteQuery("useTemplates", {
84
102
  queryKey: [...templatesKey(), { limit }],
85
103
  fetchPage: async (cursor) => {
86
- const page = await listTemplates({ limit, ...(cursor ?? {}) });
104
+ const page = await listTemplates({ limit, ...(cursor ?? {}) }, { challenge });
87
105
  return { rows: page.templates, nextCursor: page.next_cursor };
88
106
  },
89
107
  });
90
108
  }
91
109
  /** One template with its `html`. An `undefined` id disables the query. */
92
- export function useTemplate(templateId) {
110
+ export function useTemplate(templateId, options = {}) {
111
+ const { challenge } = options;
93
112
  return useServiceQuery("useTemplate", {
94
113
  queryKey: templateKey(templateId),
95
- queryFn: () => templateInfo(templateId),
114
+ queryFn: () => templateInfo(templateId, { challenge }),
96
115
  enabled: templateId !== undefined,
97
116
  });
98
117
  }
99
- export function useCreateTemplate() {
118
+ export function useCreateTemplate(options = {}) {
119
+ const { challenge } = options;
100
120
  return useServiceMutation("useCreateTemplate", {
101
- mutationFn: ({ name, subject, html }) => createTemplate(name, subject, html),
121
+ mutationFn: ({ name, subject, html }) => createTemplate(name, subject, html, { challenge }),
102
122
  invalidates: () => [templatesKey()],
103
123
  });
104
124
  }
105
- export function useUpdateTemplate() {
125
+ export function useUpdateTemplate(options = {}) {
126
+ const { challenge } = options;
106
127
  return useServiceMutation("useUpdateTemplate", {
107
- mutationFn: ({ templateId, subject, html }) => updateTemplate(templateId, { subject, html }),
128
+ mutationFn: ({ templateId, subject, html }) => updateTemplate(templateId, { subject, html }, { challenge }),
108
129
  invalidates: (_template, { templateId }) => [templatesKey(), templateKey(templateId)],
109
130
  });
110
131
  }
111
- export function useDeleteTemplate() {
132
+ export function useDeleteTemplate(options = {}) {
133
+ const { challenge } = options;
112
134
  return useServiceMutation("useDeleteTemplate", {
113
- mutationFn: deleteTemplate,
135
+ mutationFn: (templateId) => deleteTemplate(templateId, { challenge }),
114
136
  invalidates: (_none, templateId) => [templatesKey(), templateKey(templateId)],
115
137
  });
116
138
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.js";
1
+ export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useSetEmailReplyTo, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.js";
2
2
  export type { CreateEmailAccountInput, CreateTemplateInput, PageOptions, SetEmailAccountAccessInput, UpdateTemplateInput, } from "./hooks.js";
3
3
  export type { AccountSendInput, RawSendInput, SendContent, SendInput, SendKey, SendResult } from "./api.js";
4
4
  export type { Account, AccountSenders, SimpleEmailErrorCode, AccountCursor, AccountInfoEnvelope, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta, ToAccount, } from "./types.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // @pilely/simple-email — React hooks over the simple_email service: a query
2
2
  // hook for every read, a mutation hook for every write. Render them inside
3
3
  // @pilely/core's <PilelyProvider>.
4
- export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.js";
4
+ export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useSetEmailReplyTo, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.js";
package/dist/mock.js CHANGED
@@ -5,15 +5,18 @@
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
13
13
  // account send to `"self"` logs `recipients: []`, one to `"owner"` logs a
14
14
  // stand-in owner address — the fake has no real account emails. Writes
15
15
  // need a signed-in user; a signed-out write and a missing account or
16
- // template answer the uniform bare 404. Reached only from the
16
+ // template answer the uniform bare 404. Accounts carry `reply_to`, set on
17
+ // create and by `set_reply_to` (key required, a string or `null`); the fake
18
+ // checks only its type, not the address rules or refused domains. Reached
19
+ // only from the
17
20
  // `isMockMode()` branch in index.ts, so a production build carries none of
18
21
  // it.
19
22
  const MARKER = "pilely-mock-fake:simple-email";
@@ -48,6 +51,8 @@ function accountPayload(account) {
48
51
  send_group: account.send_group,
49
52
  // State persisted before account_senders existed reads the default.
50
53
  account_senders: account.account_senders ?? "send_group",
54
+ // State persisted before reply_to existed reads none.
55
+ reply_to: account.reply_to ?? null,
51
56
  created_time_stamp: account.created_time_stamp,
52
57
  };
53
58
  }
@@ -64,6 +69,16 @@ function parseAccountSenders(body) {
64
69
  const value = body.account_senders;
65
70
  return ACCOUNT_SENDERS.includes(value) ? value : null;
66
71
  }
72
+ /** `undefined` when the key is absent; the address, or `null` (none). A value
73
+ * of any other type is the server's 400, answered as `false`. */
74
+ function parseReplyTo(body) {
75
+ if (!("reply_to" in body))
76
+ return undefined;
77
+ const value = body.reply_to;
78
+ if (value === null || typeof value === "string")
79
+ return value;
80
+ return false;
81
+ }
67
82
  /** The service's "same body": the caller, the mode, `to` as a
68
83
  * case-insensitive set, `to_account`, the template and its variables, and
69
84
  * inline content. */
@@ -128,13 +143,14 @@ export const createSimpleEmailFake = (ctx) => {
128
143
  function findAccount(appId) {
129
144
  return state.accounts.find((a) => a.app_id === appId);
130
145
  }
131
- function createAccount(appId, sendGroup, accountSenders) {
146
+ function createAccount(appId, sendGroup, accountSenders, replyTo) {
132
147
  const account = {
133
148
  app_id: appId,
134
149
  address: `${appId}@${SENDING_DOMAIN}`,
135
150
  display_name: null,
136
151
  send_group: sendGroup,
137
152
  account_senders: accountSenders,
153
+ reply_to: replyTo,
138
154
  created_time_stamp: ctx.now(),
139
155
  templates: [],
140
156
  };
@@ -177,7 +193,7 @@ export const createSimpleEmailFake = (ctx) => {
177
193
  if (key !== undefined && (typeof key !== "string" || !IDEMPOTENCY_KEY.test(key))) {
178
194
  return ctx.refuse(400, "bad_request", "idempotency_key must be 1..=128 characters of [A-Za-z0-9._:-]");
179
195
  }
180
- const account = findAccount(body.app_id) ?? createAccount(body.app_id, null, "send_group");
196
+ const account = findAccount(body.app_id) ?? createAccount(body.app_id, null, "send_group", null);
181
197
  const user = ctx.user();
182
198
  const caller = user?.id ?? "mock-user";
183
199
  const print = fingerprint(caller, body, to);
@@ -272,8 +288,12 @@ export const createSimpleEmailFake = (ctx) => {
272
288
  if (accountSenders === null) {
273
289
  return ctx.refuse(400, "bad_request", 'account_senders must be "send_group" or "app_users"');
274
290
  }
291
+ const replyTo = parseReplyTo(body);
292
+ if (replyTo === false) {
293
+ return ctx.refuse(400, "bad_request", "reply_to must be an email address or null");
294
+ }
275
295
  const sendGroup = typeof body.send_group === "string" ? body.send_group : null;
276
- const account = createAccount(body.app_id, sendGroup, accountSenders ?? "send_group");
296
+ const account = createAccount(body.app_id, sendGroup, accountSenders ?? "send_group", replyTo ?? null);
277
297
  save();
278
298
  return ctx.ok({ ok: true, account: accountPayload(account) });
279
299
  }
@@ -302,7 +322,6 @@ export const createSimpleEmailFake = (ctx) => {
302
322
  daily_cap: DAILY_CAP,
303
323
  sent_today: sentToday,
304
324
  credit_blocked: false,
305
- phone_verified: true,
306
325
  });
307
326
  }
308
327
  if (route === "delete") {
@@ -328,6 +347,20 @@ export const createSimpleEmailFake = (ctx) => {
328
347
  save();
329
348
  return ctx.ok({ ok: true, account: accountPayload(account) });
330
349
  }
350
+ if (route === "set_reply_to") {
351
+ if (!signedIn || !account)
352
+ return ctx.notFound();
353
+ const replyTo = parseReplyTo(body);
354
+ if (replyTo === undefined) {
355
+ return ctx.refuse(400, "bad_request", "reply_to is required (an email address, or null to clear it)");
356
+ }
357
+ if (replyTo === false) {
358
+ return ctx.refuse(400, "bad_request", "reply_to must be an email address or null");
359
+ }
360
+ account.reply_to = replyTo;
361
+ save();
362
+ return ctx.ok({ ok: true, account: accountPayload(account) });
363
+ }
331
364
  if (route === "templates/create") {
332
365
  if (!signedIn || !account)
333
366
  return ctx.notFound();
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,10 +43,13 @@ 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;
50
+ /** The address recipients' replies go to: every send from the account
51
+ * carries it as its `Reply-To`. `null` means none — replies go nowhere. */
52
+ reply_to: string | null;
42
53
  created_time_stamp: number;
43
54
  }
44
55
  /** `daily_cap`'s three siblings are nullable and sit BESIDE `account`, not
@@ -48,7 +59,6 @@ export interface AccountInfoEnvelope {
48
59
  daily_cap: number;
49
60
  sent_today: number | null;
50
61
  credit_blocked: boolean | null;
51
- phone_verified: boolean | null;
52
62
  }
53
63
  /** What `templates/create` and `templates/update` answer with — no `html`. */
54
64
  export interface TemplateMeta {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilely/simple-email",
3
- "version": "0.4.2",
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
  },