@pilely/simple-email 0.3.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,98 +1,117 @@
1
1
  # `@pilely/simple-email`
2
2
 
3
- A typed client for the `simple_email` service — the platform's managed
4
- transactional email. Wraps all 12 of its POST routes: sending, account
5
- provisioning, and templates.
6
-
7
- ## Install
8
-
9
- ```sh
10
- npm install @pilely/simple-email @pilely/core
3
+ React hooks for the `simple_email` service — the platform's managed transactional email. A query hook for every read, a mutation hook for every write. Render them inside `@pilely/core`'s `<PilelyProvider>`.
4
+
5
+ ## Hooks
6
+
7
+ | Hook | Wraps | Kind |
8
+ | --- | --- | --- |
9
+ | `useSend()` | `/send` | mutation, `{ to, subject, html }` or `{ to, template_id, variables? }` (a raw send), or `{ to_account, template_id, variables? }` (an account send); each takes an optional `idempotency_key`. Answers `{ send_id, recipient_count, replayed }` |
10
+ | `useSends({ limit? })` | `/sends/list`, paged | infinite query; this app's outbox, `SendRow[]` |
11
+ | `useEmailAccount()` | `accounts/{app}/info` | query; this app's account with its daily cap, sent today, credit and phone state |
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? }` |
14
+ | `useDeleteEmailAccount()` | `accounts/{app}/delete` | mutation, no variables |
15
+ | `useSetEmailAccountAccess()` | `accounts/{app}/access/set` | mutation, the send group (`null` = owner-only), or `{ send_group, account_senders? }` (an omitted `account_senders` keeps the current value) |
16
+ | `useTemplates({ limit? })` | `templates/list`, paged | infinite query; metadata only |
17
+ | `useTemplate(templateId)` | `templates/{t}/info` | query; the template with its `html`; an `undefined` id disables it |
18
+ | `useCreateTemplate()` | `templates/create` | mutation, `{ name, subject, html }` (`name` may be `null`) |
19
+ | `useUpdateTemplate()` | `templates/{t}/update` | mutation, `{ templateId, subject, html }` — a full replace |
20
+ | `useDeleteTemplate()` | `templates/{t}/delete` | mutation, the template id |
21
+
22
+ 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
+ ## Raw sends and account sends
25
+
26
+ 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
+
28
+ 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
+
30
+ ## Retries: `idempotency_key`
31
+
32
+ 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`.
33
+
34
+ ## What each write refreshes
35
+
36
+ | Write | Invalidates |
37
+ | --- | --- |
38
+ | send | `useSends()` |
39
+ | account create / set access | `useEmailAccounts()`, `useEmailAccount()` |
40
+ | account delete | `useEmailAccounts()`, `useEmailAccount()`, and the templates under it |
41
+ | template create | `useTemplates()` |
42
+ | template update / delete on `t` | `useTemplates()`, `useTemplate(t)` |
43
+
44
+ ## Example
45
+
46
+ ```tsx
47
+ import { useSend, useSends } from "@pilely/simple-email";
48
+
49
+ export function Outbox() {
50
+ const { data: sends } = useSends();
51
+ const send = useSend();
52
+ return (
53
+ <>
54
+ <button onClick={() => send.mutate({ to: ["friend@example.com"], subject: "Hi", html: "<p>Hello</p>" })}>
55
+ Send
56
+ </button>
57
+ {send.error && <p role="alert">{send.error.message}</p>}
58
+ <ul>
59
+ {sends?.map((row) => (
60
+ <li key={row.send_id}>
61
+ {row.subject} — {row.outcome}
62
+ {row.to_account && ` (to the ${row.to_account} account email)`}
63
+ </li>
64
+ ))}
65
+ </ul>
66
+ </>
67
+ );
68
+ }
69
+
70
+ // After a row is stored: confirm it to the signed-in submitter, once, even if
71
+ // the page retries. `templateId` is a template of this app's account.
72
+ export function useConfirmSubmission(templateId: string) {
73
+ const send = useSend();
74
+ return (rowId: string) =>
75
+ send.mutateAsync({ to_account: "self", template_id: templateId, idempotency_key: `confirm:${rowId}` });
76
+ }
11
77
  ```
12
78
 
13
- Always install with the scope — `pilely-email` and similar unscoped names
14
- are not owned by this project and are squattable. `@pilely/core` is a peer
15
- dependency; an app has exactly one facade, never two.
79
+ ## Query keys
16
80
 
17
- ## Where the app id lives
81
+ | Key | Query |
82
+ | --- | --- |
83
+ | `["pilely", "simple-email", "sends", { limit }]` | `useSends` |
84
+ | `["pilely", "simple-email", "account"]` | `useEmailAccount` |
85
+ | `["pilely", "simple-email", "accounts", { limit }]` | `useEmailAccounts` |
86
+ | `["pilely", "simple-email", "templates", { limit }]` | `useTemplates` |
87
+ | `["pilely", "simple-email", "template", templateId]` | `useTemplate` |
18
88
 
19
- Unlike the other three service packages, this one splits: `send()` and
20
- `listSends()` put the app id in the request **body**; every account and
21
- template method puts it in the request **path**. `listAccounts()` takes no
22
- app id at all — it lists every account the caller owns. Every method adds
23
- the id itself from `@pilely/core`'s `appId()`; it is never a parameter you
24
- pass.
25
-
26
- ## Templates live under the account
89
+ ## Templates can be nameless, and can lose their body
27
90
 
28
- There is no top-level `templates/...` route. Every template call is
29
- `accounts/{app}/templates/...` — a template belongs to one account, and
30
- `createTemplate`/`listTemplates`/`templateInfo`/`updateTemplate`/`deleteTemplate`
31
- all reach it that way.
91
+ `name` is `string | null`: a nameless template is legal. `html` on `useTemplate`'s data is optional, not nullable — when the stored body is missing the service omits the key. Check `"html" in template` before rendering it.
32
92
 
33
93
  ## Mock mode
34
94
 
35
- Importing this package in a build made with `VITE_PILELY_MOCK=1` registers
36
- an in-browser fake of `simple_email` behind `@pilely/core`'s mock runtime —
37
- nothing else to call. Every method here then answers from memory with the
38
- JSON the real service returns.
39
-
40
- **Nothing is ever sent.** `send()` appends the message to an in-memory
41
- outbox — the rows `listSends()` answers — and logs it to the console.
42
- Accounts and templates are kept in memory and persisted to `localStorage`
43
- across reloads; an app's account is created on its first send if it does
44
- not exist yet. A missing account or template answers the bare 404, and a
45
- second account for the same app answers `account_exists` (409). The
46
- 50-recipient bound on a send is kept, since it is the request's shape.
47
-
48
- What it does not: send groups, the daily cap, the phone gate, rate limits
49
- and credit checks.
95
+ Importing this package in a build made with `VITE_PILELY_MOCK=1` registers an in-browser fake of `simple_email` behind `@pilely/core`'s mock runtime — nothing else to call.
50
96
 
51
- Writes need a signed-in user: a signed-out write answers the uniform bare
52
- 404, which reaches the app as `PilelyError(404, null)`, exactly where the
53
- real service would hide it. Mock mode is on only in a Vite build with
54
- `VITE_PILELY_MOCK=1`; see [`@pilely/core`](../core#mock-mode) for the
55
- switch, `seedMock()` and `resetMock()`.
97
+ **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.
56
98
 
57
- ## Status
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`.
58
100
 
59
- This is a pre-1.0 package and moves with the platform. Nothing here is
60
- published to the registry yet. Zero runtime dependencies.
61
-
62
- ## Templates can be nameless, and can lose their body
63
-
64
- Two degraded shapes the service keeps deliberately visible, both reflected
65
- in the types:
66
-
67
- - `name` is `string | null`. A nameless template is legal — `createTemplate`
68
- takes `string | null` and hands the `null` straight back.
69
- - `html` is **optional, not nullable**. When the stored object behind a
70
- template is missing, `templateInfo` omits the key rather than sending
71
- `null`. Discriminate with `"html" in template`, and never assume a body.
101
+ 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()`.
72
102
 
73
103
  ## Error codes
74
104
 
75
- Refusals arrive as `PilelyError` with a `code` drawn from
76
- `SimpleEmailErrorCode`. Here matching on the code is not optional:
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.
77
106
 
78
- **`daily_cap_exceeded` and `rate_limited` both answer 429.** One means the
79
- app has spent its UTC-day send allowance (retry tomorrow, or not at all);
80
- the other is an ordinary burst window (retry shortly). The status cannot
81
- tell them apart — only the code can.
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`.
82
108
 
83
- `send` can also answer `phone_verification_required` (403) and
84
- `phone_verification_unavailable` (503) from the phone gate, and
85
- `out_of_traffic_credits` (503). Provisioning adds `account_exists`,
86
- `account_limit_reached` and `template_limit_reached` (all 409). The
87
- remainder: `not_found` (404, the uniform hide), `bad_request` (400),
88
- `unauthenticated` (401), `bad_forwarded_identity` (401), `internal` (500).
109
+ 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.
89
110
 
90
- A member-role caller sees a uniform 404 where the owner would see a 503
91
- `out_of_traffic_credits` — worth knowing before debugging a member's send.
111
+ ## Install
92
112
 
93
- ## Limits worth knowing before you call `send`
113
+ ```sh
114
+ npm install @pilely/simple-email @pilely/core @tanstack/react-query
115
+ ```
94
116
 
95
- - Billed **per recipient**, not per call.
96
- - At most 50 recipients per send.
97
- - 200 sends per app per UTC day — the `daily_cap_exceeded` wall above.
98
- - `send_id` is the idempotency key.
117
+ `@pilely/core`, `react` (`^18.3 || ^19`) and `@tanstack/react-query` (`^5`) are peer dependencies. Always install with the scope — unscoped names like `pilely-email` are not owned by this project. This is a pre-1.0 package and moves with the platform.
package/dist/api.d.ts ADDED
@@ -0,0 +1,133 @@
1
+ import type { Account, AccountCursor, AccountInfoEnvelope, AccountSenders, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta, ToAccount } from "./types.js";
2
+ /** `to` is capped at the service's `max_recipients_per_send` (50 by default). */
3
+ export type SendContent = {
4
+ template_id: string;
5
+ variables?: Record<string, string>;
6
+ } | {
7
+ subject: string;
8
+ html: string;
9
+ };
10
+ /**
11
+ * Optional on every send shape: 1–128 characters of `[A-Za-z0-9._:-]`, held
12
+ * per app for 24 hours from its first use. A repeat with the same key, the
13
+ * same body and the same caller answers the first send's `send_id` with
14
+ * `replayed: true` and sends and bills nothing; the same key with another
15
+ * body or caller is `409 idempotency_key_reused`. Pick keys stable per
16
+ * logical email (`digest:<user id>:<YYYY-MM-DD>`, `confirm:<row id>`) — a
17
+ * new key per attempt defeats the protection.
18
+ */
19
+ export interface SendKey {
20
+ idempotency_key?: string;
21
+ }
22
+ /** A raw send: to the addresses in `to`, template or inline content. */
23
+ export type RawSendInput = {
24
+ to: string[];
25
+ to_account?: never;
26
+ } & SendContent & SendKey;
27
+ /**
28
+ * An account send: a template to ONE address the service looks up itself —
29
+ * the caller's own account email (`"self"`) or the account owner's
30
+ * (`"owner"`). No `to`, and template content only: `subject` / `html` are a
31
+ * 400 on the server, so they do not typecheck here.
32
+ */
33
+ export type AccountSendInput = {
34
+ to_account: ToAccount;
35
+ to?: never;
36
+ template_id: string;
37
+ variables?: Record<string, string>;
38
+ subject?: never;
39
+ html?: never;
40
+ } & SendKey;
41
+ /** Exactly one of `to` (a raw send) or `to_account` (an account send). */
42
+ export type SendInput = RawSendInput | AccountSendInput;
43
+ /** `replayed` is `true` when the answer is an earlier keyed send's (nothing
44
+ * new was sent), `false` for a new send — and always `false` without an
45
+ * `idempotency_key`. An account send's answer never carries an address. */
46
+ export interface SendResult {
47
+ send_id: string;
48
+ recipient_count: number;
49
+ replayed: boolean;
50
+ }
51
+ export interface ListSendsOptions {
52
+ limit?: number;
53
+ after_created_time_stamp?: number;
54
+ after_send_id?: string;
55
+ }
56
+ export interface ListAccountsOptions {
57
+ limit?: number;
58
+ after_created_time_stamp?: number;
59
+ after_app_id?: string;
60
+ }
61
+ export interface ListTemplatesOptions {
62
+ limit?: number;
63
+ after_created_time_stamp?: number;
64
+ after_template_id?: string;
65
+ }
66
+ /** Both keys are REQUIRED — the server rejects either one missing with a
67
+ * 400 (`ContentField::validate`), matching `updateTemplate`'s full-replace
68
+ * contract: there is no patch form on this route. */
69
+ export interface UpdateTemplateBody {
70
+ subject: string;
71
+ html: string;
72
+ }
73
+ /**
74
+ * `app_id` is added from core's `appId()` — it is not a caller parameter.
75
+ * Body size is capped on this route (like `createTemplate`/`updateTemplate`):
76
+ * an oversize body is rejected by the server before any handler runs, so
77
+ * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
78
+ * carries a null `code`.
79
+ */
80
+ export declare function send(input: SendInput): Promise<SendResult>;
81
+ /** `app_id` lives in the body here, like `send` — not in the path. The
82
+ * paged primitive; use `listAllSends` to walk to the end. */
83
+ export declare function listSends(options?: ListSendsOptions): Promise<{
84
+ sends: SendRow[];
85
+ next_cursor: SendCursor | null;
86
+ }>;
87
+ export declare function listAllSends(): Promise<SendRow[]>;
88
+ /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
89
+ * key is left off the wire, meaning owner-only. `setAccountAccess` below
90
+ * is the opposite: its key is always required. `accountSenders` is
91
+ * 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>;
94
+ /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
95
+ * not a selector. The paged primitive; use `listAllAccounts` to walk to
96
+ * the end. */
97
+ export declare function listAccounts(options?: ListAccountsOptions): Promise<{
98
+ accounts: Account[];
99
+ next_cursor: AccountCursor | null;
100
+ }>;
101
+ export declare function listAllAccounts(): Promise<Account[]>;
102
+ /** Returns the account plus its live operational state — the four extra
103
+ * fields are nullable siblings of `account`, not nested inside it. */
104
+ export declare function accountInfo(): Promise<AccountInfoEnvelope>;
105
+ export declare function deleteAccount(): Promise<void>;
106
+ /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
107
+ * the opposite of `createAccount`, where the key is optional. `null`
108
+ * means owner-only. `accountSenders` is optional: `undefined` leaves the
109
+ * key off the wire, which KEEPS the account's current value. */
110
+ export declare function setAccountAccess(sendGroup: string | null, accountSenders?: AccountSenders): Promise<Account>;
111
+ /** Returns metadata only — no `html`. Use `templateInfo` to read the body
112
+ * back.
113
+ *
114
+ * `name` is nullable because the service accepts a nameless template. It
115
+ * stays a required POSITIONAL parameter — pass an explicit `null`, not
116
+ * nothing — and `TemplateMeta.name` then comes back `null`. */
117
+ export declare function createTemplate(name: string | null, subject: string, html: string): Promise<TemplateMeta>;
118
+ /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
119
+ * carry metadata only — no `html`. */
120
+ export declare function listTemplates(options?: ListTemplatesOptions): Promise<{
121
+ templates: TemplateMeta[];
122
+ next_cursor: TemplateCursor | null;
123
+ }>;
124
+ export declare function listAllTemplates(): Promise<TemplateMeta[]>;
125
+ /** The only template route whose answer carries `html`. */
126
+ export declare function templateInfo(templateNanoid: string): Promise<Template>;
127
+ /**
128
+ * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
129
+ * is not updatable at all. Returns metadata only, like `createTemplate`.
130
+ * Subject to the same oversize-body caveat as `send`/`createTemplate`.
131
+ */
132
+ export declare function updateTemplate(templateNanoid: string, body: UpdateTemplateBody): Promise<TemplateMeta>;
133
+ export declare function deleteTemplate(templateNanoid: string): Promise<void>;
package/dist/api.js ADDED
@@ -0,0 +1,208 @@
1
+ // The simple_email service's 12 POST routes, one function each. This is the
2
+ // internal layer the hooks in hooks.ts call; the package exports the hooks,
3
+ // not these. app_id moves between the body and the path from route to
4
+ // route on this service.
5
+ import { appId, call, collectPages, isMockMode, registerMockService } from "@pilely/core";
6
+ import { createSimpleEmailFake } from "./mock.js";
7
+ // Importing this package (its hooks import this module) is all an app does
8
+ // to get its fake in mock mode. Dead code in a build without
9
+ // `VITE_PILELY_MOCK=1`, fake included.
10
+ if (isMockMode()) {
11
+ registerMockService("simple-email", createSimpleEmailFake);
12
+ }
13
+ /**
14
+ * Every route on this service needs an app id on the wire (body or path).
15
+ * `@pilely/core`'s `appId()` can return `null` (no tag, `ready()` not yet
16
+ * resolved) — this service cannot send that: a `null` in a path template
17
+ * would silently become the literal string `"null"`. Fail loudly instead.
18
+ */
19
+ function requireAppId() {
20
+ const id = appId();
21
+ if (!id) {
22
+ throw new Error('pilely client not loaded, <meta name="pilely-app"> missing, and no token pile_id claim available yet — call after ready()');
23
+ }
24
+ return id;
25
+ }
26
+ // ── Sending ──────────────────────────────────────────────────────────────
27
+ /**
28
+ * `app_id` is added from core's `appId()` — it is not a caller parameter.
29
+ * Body size is capped on this route (like `createTemplate`/`updateTemplate`):
30
+ * an oversize body is rejected by the server before any handler runs, so
31
+ * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
32
+ * carries a null `code`.
33
+ */
34
+ export async function send(input) {
35
+ const app_id = requireAppId();
36
+ const idempotency_key = input.idempotency_key;
37
+ const body = input.to_account !== undefined
38
+ ? { app_id, to_account: input.to_account, template_id: input.template_id, variables: input.variables, idempotency_key }
39
+ : "template_id" in input
40
+ ? { app_id, to: input.to, template_id: input.template_id, variables: input.variables, idempotency_key }
41
+ : { app_id, to: input.to, subject: input.subject, html: input.html, idempotency_key };
42
+ return call({
43
+ service: "simple-email",
44
+ path: "/send",
45
+ body,
46
+ });
47
+ }
48
+ /** `app_id` lives in the body here, like `send` — not in the path. The
49
+ * paged primitive; use `listAllSends` to walk to the end. */
50
+ export async function listSends(options = {}) {
51
+ const json = await call({
52
+ service: "simple-email",
53
+ path: "/sends/list",
54
+ body: {
55
+ app_id: requireAppId(),
56
+ limit: options.limit,
57
+ after_created_time_stamp: options.after_created_time_stamp,
58
+ after_send_id: options.after_send_id,
59
+ },
60
+ });
61
+ return { sends: json.sends, next_cursor: json.next_cursor };
62
+ }
63
+ export async function listAllSends() {
64
+ return collectPages(async (cursor) => {
65
+ const page = await listSends({ limit: 100, ...(cursor ?? {}) });
66
+ return { rows: page.sends, nextCursor: page.next_cursor };
67
+ });
68
+ }
69
+ // ── Accounts ─────────────────────────────────────────────────────────────
70
+ /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
71
+ * key is left off the wire, meaning owner-only. `setAccountAccess` below
72
+ * is the opposite: its key is always required. `accountSenders` is
73
+ * 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) {
76
+ const body = {
77
+ app_id: requireAppId(),
78
+ };
79
+ if (sendGroup !== undefined) {
80
+ body.send_group = sendGroup;
81
+ }
82
+ if (accountSenders !== undefined) {
83
+ body.account_senders = accountSenders;
84
+ }
85
+ const json = await call({
86
+ service: "simple-email",
87
+ path: "/accounts/create",
88
+ body,
89
+ });
90
+ return json.account;
91
+ }
92
+ /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
93
+ * not a selector. The paged primitive; use `listAllAccounts` to walk to
94
+ * the end. */
95
+ export async function listAccounts(options = {}) {
96
+ const json = await call({
97
+ service: "simple-email",
98
+ path: "/accounts/list",
99
+ body: {
100
+ limit: options.limit,
101
+ after_created_time_stamp: options.after_created_time_stamp,
102
+ after_app_id: options.after_app_id,
103
+ },
104
+ });
105
+ return { accounts: json.accounts, next_cursor: json.next_cursor };
106
+ }
107
+ export async function listAllAccounts() {
108
+ return collectPages(async (cursor) => {
109
+ const page = await listAccounts({ limit: 100, ...(cursor ?? {}) });
110
+ return { rows: page.accounts, nextCursor: page.next_cursor };
111
+ });
112
+ }
113
+ /** Returns the account plus its live operational state — the four extra
114
+ * fields are nullable siblings of `account`, not nested inside it. */
115
+ export async function accountInfo() {
116
+ const json = await call({
117
+ service: "simple-email",
118
+ path: `/accounts/${requireAppId()}/info`,
119
+ });
120
+ return {
121
+ account: json.account,
122
+ daily_cap: json.daily_cap,
123
+ sent_today: json.sent_today,
124
+ credit_blocked: json.credit_blocked,
125
+ phone_verified: json.phone_verified,
126
+ };
127
+ }
128
+ export async function deleteAccount() {
129
+ await call({ service: "simple-email", path: `/accounts/${requireAppId()}/delete` });
130
+ }
131
+ /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
132
+ * the opposite of `createAccount`, where the key is optional. `null`
133
+ * means owner-only. `accountSenders` is optional: `undefined` leaves the
134
+ * key off the wire, which KEEPS the account's current value. */
135
+ export async function setAccountAccess(sendGroup, accountSenders) {
136
+ const body = { send_group: sendGroup };
137
+ if (accountSenders !== undefined) {
138
+ body.account_senders = accountSenders;
139
+ }
140
+ const json = await call({
141
+ service: "simple-email",
142
+ path: `/accounts/${requireAppId()}/access/set`,
143
+ body,
144
+ });
145
+ return json.account;
146
+ }
147
+ // ── Templates (nested under the account) ────────────────────────────────
148
+ /** Returns metadata only — no `html`. Use `templateInfo` to read the body
149
+ * back.
150
+ *
151
+ * `name` is nullable because the service accepts a nameless template. It
152
+ * stays a required POSITIONAL parameter — pass an explicit `null`, not
153
+ * nothing — and `TemplateMeta.name` then comes back `null`. */
154
+ export async function createTemplate(name, subject, html) {
155
+ const json = await call({
156
+ service: "simple-email",
157
+ path: `/accounts/${requireAppId()}/templates/create`,
158
+ body: { name, subject, html },
159
+ });
160
+ return json.template;
161
+ }
162
+ /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
163
+ * carry metadata only — no `html`. */
164
+ export async function listTemplates(options = {}) {
165
+ const json = await call({
166
+ service: "simple-email",
167
+ path: `/accounts/${requireAppId()}/templates/list`,
168
+ body: {
169
+ limit: options.limit,
170
+ after_created_time_stamp: options.after_created_time_stamp,
171
+ after_template_id: options.after_template_id,
172
+ },
173
+ });
174
+ return { templates: json.templates, next_cursor: json.next_cursor };
175
+ }
176
+ export async function listAllTemplates() {
177
+ return collectPages(async (cursor) => {
178
+ const page = await listTemplates({ limit: 100, ...(cursor ?? {}) });
179
+ return { rows: page.templates, nextCursor: page.next_cursor };
180
+ });
181
+ }
182
+ /** The only template route whose answer carries `html`. */
183
+ export async function templateInfo(templateNanoid) {
184
+ const json = await call({
185
+ service: "simple-email",
186
+ path: `/accounts/${requireAppId()}/templates/${templateNanoid}/info`,
187
+ });
188
+ return json.template;
189
+ }
190
+ /**
191
+ * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
192
+ * is not updatable at all. Returns metadata only, like `createTemplate`.
193
+ * Subject to the same oversize-body caveat as `send`/`createTemplate`.
194
+ */
195
+ export async function updateTemplate(templateNanoid, body) {
196
+ const json = await call({
197
+ service: "simple-email",
198
+ path: `/accounts/${requireAppId()}/templates/${templateNanoid}/update`,
199
+ body,
200
+ });
201
+ return json.template;
202
+ }
203
+ export async function deleteTemplate(templateNanoid) {
204
+ await call({
205
+ service: "simple-email",
206
+ path: `/accounts/${requireAppId()}/templates/${templateNanoid}/delete`,
207
+ });
208
+ }
@@ -0,0 +1,59 @@
1
+ import type { UseInfiniteQueryResult, UseMutationResult, UseQueryResult } from "@tanstack/react-query";
2
+ import type { SendInput, SendResult } from "./api.js";
3
+ import type { Account, AccountInfoEnvelope, AccountSenders, SendRow, Template, TemplateMeta } from "./types.js";
4
+ export interface PageOptions {
5
+ /** Rows per page; the server defaults to 50 and caps at 100. */
6
+ limit?: number;
7
+ }
8
+ export interface CreateTemplateInput {
9
+ /** `null` for a nameless template. */
10
+ name: string | null;
11
+ subject: string;
12
+ html: string;
13
+ }
14
+ /** `useCreateEmailAccount`'s object form. Both keys are optional: an omitted
15
+ * `send_group` means owner-only, an omitted `account_senders` means
16
+ * `"send_group"`. */
17
+ export interface CreateEmailAccountInput {
18
+ send_group?: string | null;
19
+ account_senders?: AccountSenders;
20
+ }
21
+ /** `useSetEmailAccountAccess`'s object form. `send_group` is required (`null`
22
+ * = owner-only); an omitted `account_senders` keeps the current value. */
23
+ export interface SetEmailAccountAccessInput {
24
+ send_group: string | null;
25
+ account_senders?: AccountSenders;
26
+ }
27
+ /** A full replace of both `subject` and `html`; `name` is not updatable. */
28
+ export interface UpdateTemplateInput {
29
+ templateId: string;
30
+ subject: string;
31
+ html: string;
32
+ }
33
+ /** Sends one email from this app's account: a raw send (`to`) or an account
34
+ * send (`to_account: "self" | "owner"`). Not optimistic: a send counts only
35
+ * once the server accepted it. A repeat carrying the same `idempotency_key`
36
+ * answers the first send with `replayed: true`. */
37
+ export declare function useSend(): UseMutationResult<SendResult, Error, SendInput>;
38
+ /** This app's outbox, newest first, as an infinite query. */
39
+ export declare function useSends(options?: PageOptions): UseInfiniteQueryResult<SendRow[], Error>;
40
+ /** The caller's email accounts across apps, as an infinite query. */
41
+ 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
+ /** 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>;
48
+ /** Deletes this app's account and the templates under it. */
49
+ export declare function useDeleteEmailAccount(): UseMutationResult<void, Error, void>;
50
+ /** Replaces this app's send group (`null` means owner-only); the object form
51
+ * `{ send_group, account_senders? }` also sets who may make account sends. */
52
+ export declare function useSetEmailAccountAccess(): UseMutationResult<Account, Error, string | null | SetEmailAccountAccessInput>;
53
+ /** This app's templates (metadata only), as an infinite query. */
54
+ export declare function useTemplates(options?: PageOptions): UseInfiniteQueryResult<TemplateMeta[], Error>;
55
+ /** 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>;