@pilely/simple-email 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,98 +1,90 @@
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? }` |
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 |
14
+ | `useDeleteEmailAccount()` | `accounts/{app}/delete` | mutation, no variables |
15
+ | `useSetEmailAccountAccess()` | `accounts/{app}/access/set` | mutation, the send group (`null` = owner-only) |
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
+ ## What each write refreshes
25
+
26
+ | Write | Invalidates |
27
+ | --- | --- |
28
+ | send | `useSends()` |
29
+ | account create / set access | `useEmailAccounts()`, `useEmailAccount()` |
30
+ | account delete | `useEmailAccounts()`, `useEmailAccount()`, and the templates under it |
31
+ | template create | `useTemplates()` |
32
+ | template update / delete on `t` | `useTemplates()`, `useTemplate(t)` |
33
+
34
+ ## Example
35
+
36
+ ```tsx
37
+ import { useSend, useSends } from "@pilely/simple-email";
38
+
39
+ export function Outbox() {
40
+ const { data: sends } = useSends();
41
+ const send = useSend();
42
+ return (
43
+ <>
44
+ <button onClick={() => send.mutate({ to: ["friend@example.com"], subject: "Hi", html: "<p>Hello</p>" })}>
45
+ Send
46
+ </button>
47
+ {send.error && <p role="alert">{send.error.message}</p>}
48
+ <ul>{sends?.map((row) => <li key={row.send_id}>{row.subject} — {row.outcome}</li>)}</ul>
49
+ </>
50
+ );
51
+ }
11
52
  ```
12
53
 
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.
54
+ ## Query keys
16
55
 
17
- ## Where the app id lives
56
+ | Key | Query |
57
+ | --- | --- |
58
+ | `["pilely", "simple-email", "sends", { limit }]` | `useSends` |
59
+ | `["pilely", "simple-email", "account"]` | `useEmailAccount` |
60
+ | `["pilely", "simple-email", "accounts", { limit }]` | `useEmailAccounts` |
61
+ | `["pilely", "simple-email", "templates", { limit }]` | `useTemplates` |
62
+ | `["pilely", "simple-email", "template", templateId]` | `useTemplate` |
18
63
 
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
64
+ ## Templates can be nameless, and can lose their body
27
65
 
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.
66
+ `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
67
 
33
68
  ## Mock mode
34
69
 
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.
70
+ 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
71
 
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()`.
72
+ **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. It does not model send groups, the daily cap, the phone gate, rate limits or credit checks.
56
73
 
57
- ## Status
58
-
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.
74
+ 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
75
 
73
76
  ## Error codes
74
77
 
75
- Refusals arrive as `PilelyError` with a `code` drawn from
76
- `SimpleEmailErrorCode`. Here matching on the code is not optional:
78
+ Refusals arrive as `PilelyError` in the hook's `error`, with a `code` drawn from `SimpleEmailErrorCode`. Match on the code: **`daily_cap_exceeded` and `rate_limited` both answer 429** — one means the app spent its UTC-day allowance (retry tomorrow), the other is a burst window (retry shortly).
77
79
 
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.
80
+ 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
81
 
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).
82
+ Limits: billed per recipient, at most 50 recipients per send, 200 sends per app per UTC day.
89
83
 
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.
84
+ ## Install
92
85
 
93
- ## Limits worth knowing before you call `send`
86
+ ```sh
87
+ npm install @pilely/simple-email @pilely/core @tanstack/react-query
88
+ ```
94
89
 
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.
90
+ `@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,95 @@
1
+ import type { Account, AccountCursor, AccountInfoEnvelope, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta } 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
+ export type SendInput = {
11
+ to: string[];
12
+ } & SendContent;
13
+ export interface ListSendsOptions {
14
+ limit?: number;
15
+ after_created_time_stamp?: number;
16
+ after_send_id?: string;
17
+ }
18
+ export interface ListAccountsOptions {
19
+ limit?: number;
20
+ after_created_time_stamp?: number;
21
+ after_app_id?: string;
22
+ }
23
+ export interface ListTemplatesOptions {
24
+ limit?: number;
25
+ after_created_time_stamp?: number;
26
+ after_template_id?: string;
27
+ }
28
+ /** Both keys are REQUIRED — the server rejects either one missing with a
29
+ * 400 (`ContentField::validate`), matching `updateTemplate`'s full-replace
30
+ * contract: there is no patch form on this route. */
31
+ export interface UpdateTemplateBody {
32
+ subject: string;
33
+ html: string;
34
+ }
35
+ /**
36
+ * `app_id` is added from core's `appId()` — it is not a caller parameter.
37
+ * Body size is capped on this route (like `createTemplate`/`updateTemplate`):
38
+ * an oversize body is rejected by the server before any handler runs, so
39
+ * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
40
+ * carries a null `code`.
41
+ */
42
+ export declare function send(input: SendInput): Promise<{
43
+ send_id: string;
44
+ recipient_count: number;
45
+ }>;
46
+ /** `app_id` lives in the body here, like `send` — not in the path. The
47
+ * paged primitive; use `listAllSends` to walk to the end. */
48
+ export declare function listSends(options?: ListSendsOptions): Promise<{
49
+ sends: SendRow[];
50
+ next_cursor: SendCursor | null;
51
+ }>;
52
+ export declare function listAllSends(): Promise<SendRow[]>;
53
+ /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
54
+ * key is left off the wire, meaning owner-only. `setAccountAccess` below
55
+ * is the opposite: its key is always required. */
56
+ export declare function createAccount(sendGroup?: string | null): Promise<Account>;
57
+ /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
58
+ * not a selector. The paged primitive; use `listAllAccounts` to walk to
59
+ * the end. */
60
+ export declare function listAccounts(options?: ListAccountsOptions): Promise<{
61
+ accounts: Account[];
62
+ next_cursor: AccountCursor | null;
63
+ }>;
64
+ export declare function listAllAccounts(): Promise<Account[]>;
65
+ /** Returns the account plus its live operational state — the four extra
66
+ * fields are nullable siblings of `account`, not nested inside it. */
67
+ export declare function accountInfo(): Promise<AccountInfoEnvelope>;
68
+ export declare function deleteAccount(): Promise<void>;
69
+ /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
70
+ * the opposite of `createAccount`, where the key is optional. `null`
71
+ * means owner-only. */
72
+ export declare function setAccountAccess(sendGroup: string | null): Promise<Account>;
73
+ /** Returns metadata only — no `html`. Use `templateInfo` to read the body
74
+ * back.
75
+ *
76
+ * `name` is nullable because the service accepts a nameless template. It
77
+ * stays a required POSITIONAL parameter — pass an explicit `null`, not
78
+ * nothing — and `TemplateMeta.name` then comes back `null`. */
79
+ export declare function createTemplate(name: string | null, subject: string, html: string): Promise<TemplateMeta>;
80
+ /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
81
+ * carry metadata only — no `html`. */
82
+ export declare function listTemplates(options?: ListTemplatesOptions): Promise<{
83
+ templates: TemplateMeta[];
84
+ next_cursor: TemplateCursor | null;
85
+ }>;
86
+ export declare function listAllTemplates(): Promise<TemplateMeta[]>;
87
+ /** The only template route whose answer carries `html`. */
88
+ export declare function templateInfo(templateNanoid: string): Promise<Template>;
89
+ /**
90
+ * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
91
+ * is not updatable at all. Returns metadata only, like `createTemplate`.
92
+ * Subject to the same oversize-body caveat as `send`/`createTemplate`.
93
+ */
94
+ export declare function updateTemplate(templateNanoid: string, body: UpdateTemplateBody): Promise<TemplateMeta>;
95
+ export declare function deleteTemplate(templateNanoid: string): Promise<void>;
package/dist/api.js ADDED
@@ -0,0 +1,192 @@
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 body = "template_id" in input
36
+ ? { app_id: requireAppId(), to: input.to, template_id: input.template_id, variables: input.variables }
37
+ : { app_id: requireAppId(), to: input.to, subject: input.subject, html: input.html };
38
+ return call({
39
+ service: "simple-email",
40
+ path: "/send",
41
+ body,
42
+ });
43
+ }
44
+ /** `app_id` lives in the body here, like `send` — not in the path. The
45
+ * paged primitive; use `listAllSends` to walk to the end. */
46
+ export async function listSends(options = {}) {
47
+ const json = await call({
48
+ service: "simple-email",
49
+ path: "/sends/list",
50
+ body: {
51
+ app_id: requireAppId(),
52
+ limit: options.limit,
53
+ after_created_time_stamp: options.after_created_time_stamp,
54
+ after_send_id: options.after_send_id,
55
+ },
56
+ });
57
+ return { sends: json.sends, next_cursor: json.next_cursor };
58
+ }
59
+ export async function listAllSends() {
60
+ return collectPages(async (cursor) => {
61
+ const page = await listSends({ limit: 100, ...(cursor ?? {}) });
62
+ return { rows: page.sends, nextCursor: page.next_cursor };
63
+ });
64
+ }
65
+ // ── Accounts ─────────────────────────────────────────────────────────────
66
+ /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
67
+ * key is left off the wire, meaning owner-only. `setAccountAccess` below
68
+ * is the opposite: its key is always required. */
69
+ export async function createAccount(sendGroup) {
70
+ const body = { app_id: requireAppId() };
71
+ if (sendGroup !== undefined) {
72
+ body.send_group = sendGroup;
73
+ }
74
+ const json = await call({
75
+ service: "simple-email",
76
+ path: "/accounts/create",
77
+ body,
78
+ });
79
+ return json.account;
80
+ }
81
+ /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
82
+ * not a selector. The paged primitive; use `listAllAccounts` to walk to
83
+ * the end. */
84
+ export async function listAccounts(options = {}) {
85
+ const json = await call({
86
+ service: "simple-email",
87
+ path: "/accounts/list",
88
+ body: {
89
+ limit: options.limit,
90
+ after_created_time_stamp: options.after_created_time_stamp,
91
+ after_app_id: options.after_app_id,
92
+ },
93
+ });
94
+ return { accounts: json.accounts, next_cursor: json.next_cursor };
95
+ }
96
+ export async function listAllAccounts() {
97
+ return collectPages(async (cursor) => {
98
+ const page = await listAccounts({ limit: 100, ...(cursor ?? {}) });
99
+ return { rows: page.accounts, nextCursor: page.next_cursor };
100
+ });
101
+ }
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 async function accountInfo() {
105
+ const json = await call({
106
+ service: "simple-email",
107
+ path: `/accounts/${requireAppId()}/info`,
108
+ });
109
+ return {
110
+ account: json.account,
111
+ daily_cap: json.daily_cap,
112
+ sent_today: json.sent_today,
113
+ credit_blocked: json.credit_blocked,
114
+ phone_verified: json.phone_verified,
115
+ };
116
+ }
117
+ export async function deleteAccount() {
118
+ await call({ service: "simple-email", path: `/accounts/${requireAppId()}/delete` });
119
+ }
120
+ /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
121
+ * the opposite of `createAccount`, where the key is optional. `null`
122
+ * means owner-only. */
123
+ export async function setAccountAccess(sendGroup) {
124
+ const json = await call({
125
+ service: "simple-email",
126
+ path: `/accounts/${requireAppId()}/access/set`,
127
+ body: { send_group: sendGroup },
128
+ });
129
+ return json.account;
130
+ }
131
+ // ── Templates (nested under the account) ────────────────────────────────
132
+ /** Returns metadata only — no `html`. Use `templateInfo` to read the body
133
+ * back.
134
+ *
135
+ * `name` is nullable because the service accepts a nameless template. It
136
+ * stays a required POSITIONAL parameter — pass an explicit `null`, not
137
+ * nothing — and `TemplateMeta.name` then comes back `null`. */
138
+ export async function createTemplate(name, subject, html) {
139
+ const json = await call({
140
+ service: "simple-email",
141
+ path: `/accounts/${requireAppId()}/templates/create`,
142
+ body: { name, subject, html },
143
+ });
144
+ return json.template;
145
+ }
146
+ /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
147
+ * carry metadata only — no `html`. */
148
+ export async function listTemplates(options = {}) {
149
+ const json = await call({
150
+ service: "simple-email",
151
+ path: `/accounts/${requireAppId()}/templates/list`,
152
+ body: {
153
+ limit: options.limit,
154
+ after_created_time_stamp: options.after_created_time_stamp,
155
+ after_template_id: options.after_template_id,
156
+ },
157
+ });
158
+ return { templates: json.templates, next_cursor: json.next_cursor };
159
+ }
160
+ export async function listAllTemplates() {
161
+ return collectPages(async (cursor) => {
162
+ const page = await listTemplates({ limit: 100, ...(cursor ?? {}) });
163
+ return { rows: page.templates, nextCursor: page.next_cursor };
164
+ });
165
+ }
166
+ /** The only template route whose answer carries `html`. */
167
+ export async function templateInfo(templateNanoid) {
168
+ const json = await call({
169
+ service: "simple-email",
170
+ path: `/accounts/${requireAppId()}/templates/${templateNanoid}/info`,
171
+ });
172
+ return json.template;
173
+ }
174
+ /**
175
+ * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
176
+ * is not updatable at all. Returns metadata only, like `createTemplate`.
177
+ * Subject to the same oversize-body caveat as `send`/`createTemplate`.
178
+ */
179
+ export async function updateTemplate(templateNanoid, body) {
180
+ const json = await call({
181
+ service: "simple-email",
182
+ path: `/accounts/${requireAppId()}/templates/${templateNanoid}/update`,
183
+ body,
184
+ });
185
+ return json.template;
186
+ }
187
+ export async function deleteTemplate(templateNanoid) {
188
+ await call({
189
+ service: "simple-email",
190
+ path: `/accounts/${requireAppId()}/templates/${templateNanoid}/delete`,
191
+ });
192
+ }
@@ -0,0 +1,46 @@
1
+ import type { UseInfiniteQueryResult, UseMutationResult, UseQueryResult } from "@tanstack/react-query";
2
+ import type { SendInput } from "./api.js";
3
+ import type { Account, AccountInfoEnvelope, 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
+ /** A full replace of both `subject` and `html`; `name` is not updatable. */
15
+ export interface UpdateTemplateInput {
16
+ templateId: string;
17
+ subject: string;
18
+ html: string;
19
+ }
20
+ /** Sends one email from this app's account. Not optimistic: a send counts
21
+ * only once the server accepted it. */
22
+ export declare function useSend(): UseMutationResult<{
23
+ send_id: string;
24
+ recipient_count: number;
25
+ }, Error, SendInput>;
26
+ /** This app's outbox, newest first, as an infinite query. */
27
+ export declare function useSends(options?: PageOptions): UseInfiniteQueryResult<SendRow[], Error>;
28
+ /** The caller's email accounts across apps, as an infinite query. */
29
+ export declare function useEmailAccounts(options?: PageOptions): UseInfiniteQueryResult<Account[], Error>;
30
+ /** This app's account with its live state: daily cap, sent today, credit
31
+ * and phone verification. */
32
+ export declare function useEmailAccount(): UseQueryResult<AccountInfoEnvelope, Error>;
33
+ /** Creates this app's account; the variable is its optional send group
34
+ * (`null` or omitted: owner-only). */
35
+ export declare function useCreateEmailAccount(): UseMutationResult<Account, Error, string | null | void>;
36
+ /** Deletes this app's account and the templates under it. */
37
+ export declare function useDeleteEmailAccount(): UseMutationResult<void, Error, void>;
38
+ /** Replaces this app's send group; `null` means owner-only. */
39
+ export declare function useSetEmailAccountAccess(): UseMutationResult<Account, Error, string | null>;
40
+ /** This app's templates (metadata only), as an infinite query. */
41
+ export declare function useTemplates(options?: PageOptions): UseInfiniteQueryResult<TemplateMeta[], Error>;
42
+ /** One template with its `html`. An `undefined` id disables the query. */
43
+ export declare function useTemplate(templateId: string | undefined): UseQueryResult<Template, Error>;
44
+ export declare function useCreateTemplate(): UseMutationResult<TemplateMeta, Error, CreateTemplateInput>;
45
+ export declare function useUpdateTemplate(): UseMutationResult<TemplateMeta, Error, UpdateTemplateInput>;
46
+ export declare function useDeleteTemplate(): UseMutationResult<void, Error, string>;
package/dist/hooks.js ADDED
@@ -0,0 +1,109 @@
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";
3
+ /** `["pilely", "simple-email", "sends", options]` */
4
+ const sendsKey = () => serviceQueryKey("simple-email", "sends");
5
+ /** `["pilely", "simple-email", "accounts", options]` */
6
+ const accountsKey = () => serviceQueryKey("simple-email", "accounts");
7
+ /** `["pilely", "simple-email", "account"]` — this app's account info. */
8
+ const accountKey = () => serviceQueryKey("simple-email", "account");
9
+ /** `["pilely", "simple-email", "templates", options]` */
10
+ const templatesKey = () => serviceQueryKey("simple-email", "templates");
11
+ /** `["pilely", "simple-email", "template", templateId]` */
12
+ const templateKey = (templateId) => serviceQueryKey("simple-email", "template", templateId);
13
+ // ── Sending ──────────────────────────────────────────────────────────────
14
+ /** Sends one email from this app's account. Not optimistic: a send counts
15
+ * only once the server accepted it. */
16
+ export function useSend() {
17
+ return useServiceMutation("useSend", {
18
+ mutationFn: send,
19
+ invalidates: () => [sendsKey()],
20
+ });
21
+ }
22
+ /** This app's outbox, newest first, as an infinite query. */
23
+ export function useSends(options = {}) {
24
+ const { limit } = options;
25
+ return useServiceInfiniteQuery("useSends", {
26
+ queryKey: [...sendsKey(), { limit }],
27
+ fetchPage: async (cursor) => {
28
+ const page = await listSends({ limit, ...(cursor ?? {}) });
29
+ return { rows: page.sends, nextCursor: page.next_cursor };
30
+ },
31
+ });
32
+ }
33
+ // ── Accounts ─────────────────────────────────────────────────────────────
34
+ /** The caller's email accounts across apps, as an infinite query. */
35
+ export function useEmailAccounts(options = {}) {
36
+ const { limit } = options;
37
+ return useServiceInfiniteQuery("useEmailAccounts", {
38
+ queryKey: [...accountsKey(), { limit }],
39
+ fetchPage: async (cursor) => {
40
+ const page = await listAccounts({ limit, ...(cursor ?? {}) });
41
+ return { rows: page.accounts, nextCursor: page.next_cursor };
42
+ },
43
+ });
44
+ }
45
+ /** This app's account with its live state: daily cap, sent today, credit
46
+ * and phone verification. */
47
+ export function useEmailAccount() {
48
+ return useServiceQuery("useEmailAccount", { queryKey: accountKey(), queryFn: accountInfo });
49
+ }
50
+ /** Creates this app's account; the variable is its optional send group
51
+ * (`null` or omitted: owner-only). */
52
+ export function useCreateEmailAccount() {
53
+ return useServiceMutation("useCreateEmailAccount", {
54
+ mutationFn: (sendGroup) => createAccount(sendGroup ?? undefined),
55
+ invalidates: () => [accountsKey(), accountKey()],
56
+ });
57
+ }
58
+ /** Deletes this app's account and the templates under it. */
59
+ export function useDeleteEmailAccount() {
60
+ return useServiceMutation("useDeleteEmailAccount", {
61
+ mutationFn: () => deleteAccount(),
62
+ invalidates: () => [accountsKey(), accountKey(), templatesKey(), serviceQueryKey("simple-email", "template")],
63
+ });
64
+ }
65
+ /** Replaces this app's send group; `null` means owner-only. */
66
+ export function useSetEmailAccountAccess() {
67
+ return useServiceMutation("useSetEmailAccountAccess", {
68
+ mutationFn: setAccountAccess,
69
+ invalidates: () => [accountsKey(), accountKey()],
70
+ });
71
+ }
72
+ // ── Templates ────────────────────────────────────────────────────────────
73
+ /** This app's templates (metadata only), as an infinite query. */
74
+ export function useTemplates(options = {}) {
75
+ const { limit } = options;
76
+ return useServiceInfiniteQuery("useTemplates", {
77
+ queryKey: [...templatesKey(), { limit }],
78
+ fetchPage: async (cursor) => {
79
+ const page = await listTemplates({ limit, ...(cursor ?? {}) });
80
+ return { rows: page.templates, nextCursor: page.next_cursor };
81
+ },
82
+ });
83
+ }
84
+ /** One template with its `html`. An `undefined` id disables the query. */
85
+ export function useTemplate(templateId) {
86
+ return useServiceQuery("useTemplate", {
87
+ queryKey: templateKey(templateId),
88
+ queryFn: () => templateInfo(templateId),
89
+ enabled: templateId !== undefined,
90
+ });
91
+ }
92
+ export function useCreateTemplate() {
93
+ return useServiceMutation("useCreateTemplate", {
94
+ mutationFn: ({ name, subject, html }) => createTemplate(name, subject, html),
95
+ invalidates: () => [templatesKey()],
96
+ });
97
+ }
98
+ export function useUpdateTemplate() {
99
+ return useServiceMutation("useUpdateTemplate", {
100
+ mutationFn: ({ templateId, subject, html }) => updateTemplate(templateId, { subject, html }),
101
+ invalidates: (_template, { templateId }) => [templatesKey(), templateKey(templateId)],
102
+ });
103
+ }
104
+ export function useDeleteTemplate() {
105
+ return useServiceMutation("useDeleteTemplate", {
106
+ mutationFn: deleteTemplate,
107
+ invalidates: (_none, templateId) => [templatesKey(), templateKey(templateId)],
108
+ });
109
+ }
package/dist/index.d.ts CHANGED
@@ -1,96 +1,4 @@
1
- import type { Account, AccountCursor, AccountInfoEnvelope, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta } from "./types.js";
1
+ export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.js";
2
+ export type { CreateTemplateInput, PageOptions, UpdateTemplateInput } from "./hooks.js";
3
+ export type { SendContent, SendInput } from "./api.js";
2
4
  export type { Account, SimpleEmailErrorCode, AccountCursor, AccountInfoEnvelope, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta, } from "./types.js";
3
- /** `to` is capped at the service's `max_recipients_per_send` (50 by default). */
4
- export type SendContent = {
5
- template_id: string;
6
- variables?: Record<string, string>;
7
- } | {
8
- subject: string;
9
- html: string;
10
- };
11
- export type SendInput = {
12
- to: string[];
13
- } & SendContent;
14
- export interface ListSendsOptions {
15
- limit?: number;
16
- after_created_time_stamp?: number;
17
- after_send_id?: string;
18
- }
19
- export interface ListAccountsOptions {
20
- limit?: number;
21
- after_created_time_stamp?: number;
22
- after_app_id?: string;
23
- }
24
- export interface ListTemplatesOptions {
25
- limit?: number;
26
- after_created_time_stamp?: number;
27
- after_template_id?: string;
28
- }
29
- /** Both keys are REQUIRED — the server rejects either one missing with a
30
- * 400 (`ContentField::validate`), matching `updateTemplate`'s full-replace
31
- * contract: there is no patch form on this route. */
32
- export interface UpdateTemplateBody {
33
- subject: string;
34
- html: string;
35
- }
36
- /**
37
- * `app_id` is added from core's `appId()` — it is not a caller parameter.
38
- * Body size is capped on this route (like `createTemplate`/`updateTemplate`):
39
- * an oversize body is rejected by the server before any handler runs, so
40
- * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
41
- * carries a null `code`.
42
- */
43
- export declare function send(input: SendInput): Promise<{
44
- send_id: string;
45
- recipient_count: number;
46
- }>;
47
- /** `app_id` lives in the body here, like `send` — not in the path. The
48
- * paged primitive; use `listAllSends` to walk to the end. */
49
- export declare function listSends(options?: ListSendsOptions): Promise<{
50
- sends: SendRow[];
51
- next_cursor: SendCursor | null;
52
- }>;
53
- export declare function listAllSends(): Promise<SendRow[]>;
54
- /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
55
- * key is left off the wire, meaning owner-only. `setAccountAccess` below
56
- * is the opposite: its key is always required. */
57
- export declare function createAccount(sendGroup?: string | null): Promise<Account>;
58
- /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
59
- * not a selector. The paged primitive; use `listAllAccounts` to walk to
60
- * the end. */
61
- export declare function listAccounts(options?: ListAccountsOptions): Promise<{
62
- accounts: Account[];
63
- next_cursor: AccountCursor | null;
64
- }>;
65
- export declare function listAllAccounts(): Promise<Account[]>;
66
- /** Returns the account plus its live operational state — the four extra
67
- * fields are nullable siblings of `account`, not nested inside it. */
68
- export declare function accountInfo(): Promise<AccountInfoEnvelope>;
69
- export declare function deleteAccount(): Promise<void>;
70
- /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
71
- * the opposite of `createAccount`, where the key is optional. `null`
72
- * means owner-only. */
73
- export declare function setAccountAccess(sendGroup: string | null): Promise<Account>;
74
- /** Returns metadata only — no `html`. Use `templateInfo` to read the body
75
- * back.
76
- *
77
- * `name` is nullable because the service accepts a nameless template. It
78
- * stays a required POSITIONAL parameter — pass an explicit `null`, not
79
- * nothing — and `TemplateMeta.name` then comes back `null`. */
80
- export declare function createTemplate(name: string | null, subject: string, html: string): Promise<TemplateMeta>;
81
- /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
82
- * carry metadata only — no `html`. */
83
- export declare function listTemplates(options?: ListTemplatesOptions): Promise<{
84
- templates: TemplateMeta[];
85
- next_cursor: TemplateCursor | null;
86
- }>;
87
- export declare function listAllTemplates(): Promise<TemplateMeta[]>;
88
- /** The only template route whose answer carries `html`. */
89
- export declare function templateInfo(templateNanoid: string): Promise<Template>;
90
- /**
91
- * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
92
- * is not updatable at all. Returns metadata only, like `createTemplate`.
93
- * Subject to the same oversize-body caveat as `send`/`createTemplate`.
94
- */
95
- export declare function updateTemplate(templateNanoid: string, body: UpdateTemplateBody): Promise<TemplateMeta>;
96
- export declare function deleteTemplate(templateNanoid: string): Promise<void>;
package/dist/index.js CHANGED
@@ -1,190 +1,4 @@
1
- // @pilely/simple-email — a typed wrapper over the simple_email service's 12
2
- // POST routes. See README.md for where app_id lives on each route: this is
3
- // the one service where it moves between the body and the path.
4
- import { appId, call, collectPages, isMockMode, registerMockService } from "@pilely/core";
5
- import { createSimpleEmailFake } from "./mock.js";
6
- // Importing this package is all an app does to get its fake in mock mode.
7
- // Dead code in a build without `VITE_PILELY_MOCK=1`, fake included.
8
- if (isMockMode()) {
9
- registerMockService("simple-email", createSimpleEmailFake);
10
- }
11
- /**
12
- * Every route on this service needs an app id on the wire (body or path).
13
- * `@pilely/core`'s `appId()` can return `null` (no tag, `ready()` not yet
14
- * resolved) — this service cannot send that: a `null` in a path template
15
- * would silently become the literal string `"null"`. Fail loudly instead.
16
- */
17
- function requireAppId() {
18
- const id = appId();
19
- if (!id) {
20
- throw new Error('pilely client not loaded, <meta name="pilely-app"> missing, and no token pile_id claim available yet — call after ready()');
21
- }
22
- return id;
23
- }
24
- // ── Sending ──────────────────────────────────────────────────────────────
25
- /**
26
- * `app_id` is added from core's `appId()` — it is not a caller parameter.
27
- * Body size is capped on this route (like `createTemplate`/`updateTemplate`):
28
- * an oversize body is rejected by the server before any handler runs, so
29
- * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
30
- * carries a null `code`.
31
- */
32
- export async function send(input) {
33
- const body = "template_id" in input
34
- ? { app_id: requireAppId(), to: input.to, template_id: input.template_id, variables: input.variables }
35
- : { app_id: requireAppId(), to: input.to, subject: input.subject, html: input.html };
36
- return call({
37
- service: "simple-email",
38
- path: "/send",
39
- body,
40
- });
41
- }
42
- /** `app_id` lives in the body here, like `send` — not in the path. The
43
- * paged primitive; use `listAllSends` to walk to the end. */
44
- export async function listSends(options = {}) {
45
- const json = await call({
46
- service: "simple-email",
47
- path: "/sends/list",
48
- body: {
49
- app_id: requireAppId(),
50
- limit: options.limit,
51
- after_created_time_stamp: options.after_created_time_stamp,
52
- after_send_id: options.after_send_id,
53
- },
54
- });
55
- return { sends: json.sends, next_cursor: json.next_cursor };
56
- }
57
- export async function listAllSends() {
58
- return collectPages(async (cursor) => {
59
- const page = await listSends({ limit: 100, ...(cursor ?? {}) });
60
- return { rows: page.sends, nextCursor: page.next_cursor };
61
- });
62
- }
63
- // ── Accounts ─────────────────────────────────────────────────────────────
64
- /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
65
- * key is left off the wire, meaning owner-only. `setAccountAccess` below
66
- * is the opposite: its key is always required. */
67
- export async function createAccount(sendGroup) {
68
- const body = { app_id: requireAppId() };
69
- if (sendGroup !== undefined) {
70
- body.send_group = sendGroup;
71
- }
72
- const json = await call({
73
- service: "simple-email",
74
- path: "/accounts/create",
75
- body,
76
- });
77
- return json.account;
78
- }
79
- /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
80
- * not a selector. The paged primitive; use `listAllAccounts` to walk to
81
- * the end. */
82
- export async function listAccounts(options = {}) {
83
- const json = await call({
84
- service: "simple-email",
85
- path: "/accounts/list",
86
- body: {
87
- limit: options.limit,
88
- after_created_time_stamp: options.after_created_time_stamp,
89
- after_app_id: options.after_app_id,
90
- },
91
- });
92
- return { accounts: json.accounts, next_cursor: json.next_cursor };
93
- }
94
- export async function listAllAccounts() {
95
- return collectPages(async (cursor) => {
96
- const page = await listAccounts({ limit: 100, ...(cursor ?? {}) });
97
- return { rows: page.accounts, nextCursor: page.next_cursor };
98
- });
99
- }
100
- /** Returns the account plus its live operational state — the four extra
101
- * fields are nullable siblings of `account`, not nested inside it. */
102
- export async function accountInfo() {
103
- const json = await call({
104
- service: "simple-email",
105
- path: `/accounts/${requireAppId()}/info`,
106
- });
107
- return {
108
- account: json.account,
109
- daily_cap: json.daily_cap,
110
- sent_today: json.sent_today,
111
- credit_blocked: json.credit_blocked,
112
- phone_verified: json.phone_verified,
113
- };
114
- }
115
- export async function deleteAccount() {
116
- await call({ service: "simple-email", path: `/accounts/${requireAppId()}/delete` });
117
- }
118
- /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
119
- * the opposite of `createAccount`, where the key is optional. `null`
120
- * means owner-only. */
121
- export async function setAccountAccess(sendGroup) {
122
- const json = await call({
123
- service: "simple-email",
124
- path: `/accounts/${requireAppId()}/access/set`,
125
- body: { send_group: sendGroup },
126
- });
127
- return json.account;
128
- }
129
- // ── Templates (nested under the account) ────────────────────────────────
130
- /** Returns metadata only — no `html`. Use `templateInfo` to read the body
131
- * back.
132
- *
133
- * `name` is nullable because the service accepts a nameless template. It
134
- * stays a required POSITIONAL parameter — pass an explicit `null`, not
135
- * nothing — and `TemplateMeta.name` then comes back `null`. */
136
- export async function createTemplate(name, subject, html) {
137
- const json = await call({
138
- service: "simple-email",
139
- path: `/accounts/${requireAppId()}/templates/create`,
140
- body: { name, subject, html },
141
- });
142
- return json.template;
143
- }
144
- /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
145
- * carry metadata only — no `html`. */
146
- export async function listTemplates(options = {}) {
147
- const json = await call({
148
- service: "simple-email",
149
- path: `/accounts/${requireAppId()}/templates/list`,
150
- body: {
151
- limit: options.limit,
152
- after_created_time_stamp: options.after_created_time_stamp,
153
- after_template_id: options.after_template_id,
154
- },
155
- });
156
- return { templates: json.templates, next_cursor: json.next_cursor };
157
- }
158
- export async function listAllTemplates() {
159
- return collectPages(async (cursor) => {
160
- const page = await listTemplates({ limit: 100, ...(cursor ?? {}) });
161
- return { rows: page.templates, nextCursor: page.next_cursor };
162
- });
163
- }
164
- /** The only template route whose answer carries `html`. */
165
- export async function templateInfo(templateNanoid) {
166
- const json = await call({
167
- service: "simple-email",
168
- path: `/accounts/${requireAppId()}/templates/${templateNanoid}/info`,
169
- });
170
- return json.template;
171
- }
172
- /**
173
- * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
174
- * is not updatable at all. Returns metadata only, like `createTemplate`.
175
- * Subject to the same oversize-body caveat as `send`/`createTemplate`.
176
- */
177
- export async function updateTemplate(templateNanoid, body) {
178
- const json = await call({
179
- service: "simple-email",
180
- path: `/accounts/${requireAppId()}/templates/${templateNanoid}/update`,
181
- body,
182
- });
183
- return json.template;
184
- }
185
- export async function deleteTemplate(templateNanoid) {
186
- await call({
187
- service: "simple-email",
188
- path: `/accounts/${requireAppId()}/templates/${templateNanoid}/delete`,
189
- });
190
- }
1
+ // @pilely/simple-email — React hooks over the simple_email service: a query
2
+ // hook for every read, a mutation hook for every write. Render them inside
3
+ // @pilely/core's <PilelyProvider>.
4
+ export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.js";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pilely/simple-email",
3
- "version": "0.3.0",
4
- "description": "A typed client for the simple_email service.",
3
+ "version": "0.4.0",
4
+ "description": "React hooks for the simple_email service.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -21,7 +21,9 @@
21
21
  "directory": "npm/simple-email"
22
22
  },
23
23
  "peerDependencies": {
24
- "@pilely/core": "^0.3.0"
24
+ "@pilely/core": "^0.4.0",
25
+ "@tanstack/react-query": "^5.0.0",
26
+ "react": "^18.3.0 || ^19.0.0"
25
27
  },
26
28
  "scripts": {
27
29
  "build": "tsc -p tsconfig.json",