@pilely/simple-email 0.2.1 → 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,74 +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.
16
-
17
- ## Where the app id lives
18
-
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.
54
+ ## Query keys
25
55
 
26
- ## Templates live under the account
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` |
27
63
 
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.
64
+ ## Templates can be nameless, and can lose their body
32
65
 
33
- ## Status
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.
34
67
 
35
- This is a pre-1.0 package and moves with the platform. Nothing here is
36
- published to the registry yet. Zero runtime dependencies.
68
+ ## Mock mode
37
69
 
38
- ## Templates can be nameless, and can lose their body
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.
39
71
 
40
- Two degraded shapes the service keeps deliberately visible, both reflected
41
- in the types:
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.
42
73
 
43
- - `name` is `string | null`. A nameless template is legal — `createTemplate`
44
- takes `string | null` and hands the `null` straight back.
45
- - `html` is **optional, not nullable**. When the stored object behind a
46
- template is missing, `templateInfo` omits the key rather than sending
47
- `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()`.
48
75
 
49
76
  ## Error codes
50
77
 
51
- Refusals arrive as `PilelyError` with a `code` drawn from
52
- `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).
53
79
 
54
- **`daily_cap_exceeded` and `rate_limited` both answer 429.** One means the
55
- app has spent its UTC-day send allowance (retry tomorrow, or not at all);
56
- the other is an ordinary burst window (retry shortly). The status cannot
57
- 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`.
58
81
 
59
- `send` can also answer `phone_verification_required` (403) and
60
- `phone_verification_unavailable` (503) from the phone gate, and
61
- `out_of_traffic_credits` (503). Provisioning adds `account_exists`,
62
- `account_limit_reached` and `template_limit_reached` (all 409). The
63
- remainder: `not_found` (404, the uniform hide), `bad_request` (400),
64
- `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.
65
83
 
66
- A member-role caller sees a uniform 404 where the owner would see a 503
67
- `out_of_traffic_credits` — worth knowing before debugging a member's send.
84
+ ## Install
68
85
 
69
- ## Limits worth knowing before you call `send`
86
+ ```sh
87
+ npm install @pilely/simple-email @pilely/core @tanstack/react-query
88
+ ```
70
89
 
71
- - Billed **per recipient**, not per call.
72
- - At most 50 recipients per send.
73
- - 200 sends per app per UTC day — the `daily_cap_exceeded` wall above.
74
- - `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,184 +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 } from "@pilely/core";
5
- /**
6
- * Every route on this service needs an app id on the wire (body or path).
7
- * `@pilely/core`'s `appId()` can return `null` (no tag, `ready()` not yet
8
- * resolved) — this service cannot send that: a `null` in a path template
9
- * would silently become the literal string `"null"`. Fail loudly instead.
10
- */
11
- function requireAppId() {
12
- const id = appId();
13
- if (!id) {
14
- throw new Error('pilely client not loaded, <meta name="pilely-app"> missing, and no token pile_id claim available yet — call after ready()');
15
- }
16
- return id;
17
- }
18
- // ── Sending ──────────────────────────────────────────────────────────────
19
- /**
20
- * `app_id` is added from core's `appId()` — it is not a caller parameter.
21
- * Body size is capped on this route (like `createTemplate`/`updateTemplate`):
22
- * an oversize body is rejected by the server before any handler runs, so
23
- * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
24
- * carries a null `code`.
25
- */
26
- export async function send(input) {
27
- const body = "template_id" in input
28
- ? { app_id: requireAppId(), to: input.to, template_id: input.template_id, variables: input.variables }
29
- : { app_id: requireAppId(), to: input.to, subject: input.subject, html: input.html };
30
- return call({
31
- service: "simple-email",
32
- path: "/send",
33
- body,
34
- });
35
- }
36
- /** `app_id` lives in the body here, like `send` — not in the path. The
37
- * paged primitive; use `listAllSends` to walk to the end. */
38
- export async function listSends(options = {}) {
39
- const json = await call({
40
- service: "simple-email",
41
- path: "/sends/list",
42
- body: {
43
- app_id: requireAppId(),
44
- limit: options.limit,
45
- after_created_time_stamp: options.after_created_time_stamp,
46
- after_send_id: options.after_send_id,
47
- },
48
- });
49
- return { sends: json.sends, next_cursor: json.next_cursor };
50
- }
51
- export async function listAllSends() {
52
- return collectPages(async (cursor) => {
53
- const page = await listSends({ limit: 100, ...(cursor ?? {}) });
54
- return { rows: page.sends, nextCursor: page.next_cursor };
55
- });
56
- }
57
- // ── Accounts ─────────────────────────────────────────────────────────────
58
- /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
59
- * key is left off the wire, meaning owner-only. `setAccountAccess` below
60
- * is the opposite: its key is always required. */
61
- export async function createAccount(sendGroup) {
62
- const body = { app_id: requireAppId() };
63
- if (sendGroup !== undefined) {
64
- body.send_group = sendGroup;
65
- }
66
- const json = await call({
67
- service: "simple-email",
68
- path: "/accounts/create",
69
- body,
70
- });
71
- return json.account;
72
- }
73
- /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
74
- * not a selector. The paged primitive; use `listAllAccounts` to walk to
75
- * the end. */
76
- export async function listAccounts(options = {}) {
77
- const json = await call({
78
- service: "simple-email",
79
- path: "/accounts/list",
80
- body: {
81
- limit: options.limit,
82
- after_created_time_stamp: options.after_created_time_stamp,
83
- after_app_id: options.after_app_id,
84
- },
85
- });
86
- return { accounts: json.accounts, next_cursor: json.next_cursor };
87
- }
88
- export async function listAllAccounts() {
89
- return collectPages(async (cursor) => {
90
- const page = await listAccounts({ limit: 100, ...(cursor ?? {}) });
91
- return { rows: page.accounts, nextCursor: page.next_cursor };
92
- });
93
- }
94
- /** Returns the account plus its live operational state — the four extra
95
- * fields are nullable siblings of `account`, not nested inside it. */
96
- export async function accountInfo() {
97
- const json = await call({
98
- service: "simple-email",
99
- path: `/accounts/${requireAppId()}/info`,
100
- });
101
- return {
102
- account: json.account,
103
- daily_cap: json.daily_cap,
104
- sent_today: json.sent_today,
105
- credit_blocked: json.credit_blocked,
106
- phone_verified: json.phone_verified,
107
- };
108
- }
109
- export async function deleteAccount() {
110
- await call({ service: "simple-email", path: `/accounts/${requireAppId()}/delete` });
111
- }
112
- /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
113
- * the opposite of `createAccount`, where the key is optional. `null`
114
- * means owner-only. */
115
- export async function setAccountAccess(sendGroup) {
116
- const json = await call({
117
- service: "simple-email",
118
- path: `/accounts/${requireAppId()}/access/set`,
119
- body: { send_group: sendGroup },
120
- });
121
- return json.account;
122
- }
123
- // ── Templates (nested under the account) ────────────────────────────────
124
- /** Returns metadata only — no `html`. Use `templateInfo` to read the body
125
- * back.
126
- *
127
- * `name` is nullable because the service accepts a nameless template. It
128
- * stays a required POSITIONAL parameter — pass an explicit `null`, not
129
- * nothing — and `TemplateMeta.name` then comes back `null`. */
130
- export async function createTemplate(name, subject, html) {
131
- const json = await call({
132
- service: "simple-email",
133
- path: `/accounts/${requireAppId()}/templates/create`,
134
- body: { name, subject, html },
135
- });
136
- return json.template;
137
- }
138
- /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
139
- * carry metadata only — no `html`. */
140
- export async function listTemplates(options = {}) {
141
- const json = await call({
142
- service: "simple-email",
143
- path: `/accounts/${requireAppId()}/templates/list`,
144
- body: {
145
- limit: options.limit,
146
- after_created_time_stamp: options.after_created_time_stamp,
147
- after_template_id: options.after_template_id,
148
- },
149
- });
150
- return { templates: json.templates, next_cursor: json.next_cursor };
151
- }
152
- export async function listAllTemplates() {
153
- return collectPages(async (cursor) => {
154
- const page = await listTemplates({ limit: 100, ...(cursor ?? {}) });
155
- return { rows: page.templates, nextCursor: page.next_cursor };
156
- });
157
- }
158
- /** The only template route whose answer carries `html`. */
159
- export async function templateInfo(templateNanoid) {
160
- const json = await call({
161
- service: "simple-email",
162
- path: `/accounts/${requireAppId()}/templates/${templateNanoid}/info`,
163
- });
164
- return json.template;
165
- }
166
- /**
167
- * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
168
- * is not updatable at all. Returns metadata only, like `createTemplate`.
169
- * Subject to the same oversize-body caveat as `send`/`createTemplate`.
170
- */
171
- export async function updateTemplate(templateNanoid, body) {
172
- const json = await call({
173
- service: "simple-email",
174
- path: `/accounts/${requireAppId()}/templates/${templateNanoid}/update`,
175
- body,
176
- });
177
- return json.template;
178
- }
179
- export async function deleteTemplate(templateNanoid) {
180
- await call({
181
- service: "simple-email",
182
- path: `/accounts/${requireAppId()}/templates/${templateNanoid}/delete`,
183
- });
184
- }
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/dist/mock.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { MockServiceFactory } from "@pilely/core";
2
+ export declare const createSimpleEmailFake: MockServiceFactory;
package/dist/mock.js ADDED
@@ -0,0 +1,289 @@
1
+ // The in-browser fake of the simple_email service that `@pilely/core`'s
2
+ // mock runtime routes to in mock mode. It answers every route this
3
+ // package's wrapper calls with the JSON the real service returns. Nothing
4
+ // is ever sent: `send` appends to an in-memory outbox (the rows `listSends`
5
+ // answers) and logs the message to the console. Accounts and templates
6
+ // live in memory and persist through the core store; an app's account is
7
+ // created on its first send if it does not exist yet. It models shape, not
8
+ // policy: no send groups, daily caps, phone gates or credit checks. Writes
9
+ // need a signed-in user; a signed-out write and a missing account or
10
+ // template answer the uniform bare 404. Reached only from the
11
+ // `isMockMode()` branch in index.ts, so a production build carries none of
12
+ // it.
13
+ const MARKER = "pilely-mock-fake:simple-email";
14
+ const DEFAULT_PAGE_SIZE = 50;
15
+ const MAX_PAGE_SIZE = 100;
16
+ const MAX_RECIPIENTS = 50;
17
+ const DAILY_CAP = 200;
18
+ const SENDING_DOMAIN = "mail.pilely.invalid";
19
+ function isObject(value) {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ }
22
+ function compare(a, b) {
23
+ return a < b ? -1 : a > b ? 1 : 0;
24
+ }
25
+ function pageSize(body) {
26
+ const raw = body.limit === undefined || body.limit === null ? DEFAULT_PAGE_SIZE : Number(body.limit);
27
+ return Math.min(MAX_PAGE_SIZE, Math.max(1, Number.isFinite(raw) ? Math.trunc(raw) : DEFAULT_PAGE_SIZE));
28
+ }
29
+ function render(text, variables) {
30
+ return text.replace(/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (whole, name) => name in variables ? String(variables[name]) : whole);
31
+ }
32
+ function accountPayload(account) {
33
+ return {
34
+ app_id: account.app_id,
35
+ address: account.address,
36
+ display_name: account.display_name,
37
+ send_group: account.send_group,
38
+ created_time_stamp: account.created_time_stamp,
39
+ };
40
+ }
41
+ function templateMeta(template) {
42
+ return {
43
+ template_id: template.template_id,
44
+ name: template.name,
45
+ subject: template.subject,
46
+ created_time_stamp: template.created_time_stamp,
47
+ updated_time_stamp: template.updated_time_stamp,
48
+ };
49
+ }
50
+ /**
51
+ * Newest-first keyset paging over rows already sorted, with the cursor
52
+ * taken from the last row whenever the page is full — the service's rule.
53
+ */
54
+ function keysetPage(rows, body, keys, key, cursor) {
55
+ const [msKey, idKey] = keys;
56
+ const hasMs = body[msKey] !== undefined && body[msKey] !== null;
57
+ const hasId = body[idKey] !== undefined && body[idKey] !== null;
58
+ if (hasMs !== hasId)
59
+ return null;
60
+ const limit = pageSize(body);
61
+ let sorted = [...rows].sort((a, b) => {
62
+ const [am, ai] = key(a);
63
+ const [bm, bi] = key(b);
64
+ return compare(bm, am) || compare(bi, ai);
65
+ });
66
+ if (hasMs) {
67
+ const afterMs = Number(body[msKey]);
68
+ const afterId = String(body[idKey]);
69
+ sorted = sorted.filter((row) => {
70
+ const [ms, id] = key(row);
71
+ return ms < afterMs || (ms === afterMs && id < afterId);
72
+ });
73
+ }
74
+ const page = sorted.slice(0, limit);
75
+ const last = page[page.length - 1];
76
+ return { page, next: page.length === limit && last ? cursor(last) : null };
77
+ }
78
+ export const createSimpleEmailFake = (ctx) => {
79
+ const state = ctx.load() ?? { accounts: [], sends: [] };
80
+ const save = () => ctx.save(state);
81
+ const cursorRefusal = () => ctx.refuse(400, "bad_request", "cursor needs BOTH keys or neither");
82
+ function findAccount(appId) {
83
+ return state.accounts.find((a) => a.app_id === appId);
84
+ }
85
+ function createAccount(appId, sendGroup) {
86
+ const account = {
87
+ app_id: appId,
88
+ address: `${appId}@${SENDING_DOMAIN}`,
89
+ display_name: null,
90
+ send_group: sendGroup,
91
+ created_time_stamp: ctx.now(),
92
+ templates: [],
93
+ };
94
+ state.accounts.push(account);
95
+ return account;
96
+ }
97
+ function send(body) {
98
+ if (typeof body.app_id !== "string" || body.app_id === "") {
99
+ return ctx.refuse(400, "bad_request", "app_id is required");
100
+ }
101
+ const to = body.to;
102
+ if (!Array.isArray(to) || to.length < 1 || to.length > MAX_RECIPIENTS || !to.every((r) => typeof r === "string")) {
103
+ return ctx.refuse(400, "bad_request", `to must hold 1..=${MAX_RECIPIENTS} addresses`);
104
+ }
105
+ const hasTemplate = body.template_id !== undefined && body.template_id !== null;
106
+ const hasInline = body.subject !== undefined || body.html !== undefined;
107
+ if (hasTemplate === hasInline) {
108
+ return ctx.refuse(400, "bad_request", "send either template_id or subject + html");
109
+ }
110
+ const account = findAccount(body.app_id) ?? createAccount(body.app_id, null);
111
+ let subject;
112
+ let html;
113
+ let templateId = null;
114
+ if (hasTemplate) {
115
+ const template = account.templates.find((t) => t.template_id === body.template_id);
116
+ if (!template) {
117
+ return ctx.refuse(400, "bad_request", "template_id names no template of this account");
118
+ }
119
+ const variables = isObject(body.variables) ? body.variables : {};
120
+ subject = render(template.subject, variables);
121
+ html = render(template.html, variables);
122
+ templateId = template.template_id;
123
+ }
124
+ else {
125
+ if (typeof body.subject !== "string" || typeof body.html !== "string") {
126
+ return ctx.refuse(400, "bad_request", "subject and html are required together");
127
+ }
128
+ subject = body.subject;
129
+ html = body.html;
130
+ }
131
+ const now = ctx.now();
132
+ const row = {
133
+ app_id: account.app_id,
134
+ send_id: ctx.nanoid(),
135
+ requester_user_id: ctx.user()?.id ?? "mock-user",
136
+ via_app: true,
137
+ kind: hasTemplate ? "template" : "inline",
138
+ template_id: templateId,
139
+ subject,
140
+ recipients: [...to],
141
+ recipient_count: to.length,
142
+ outcome: "sent",
143
+ outcome_reason: null,
144
+ fired_at: now,
145
+ };
146
+ state.sends.push(row);
147
+ save();
148
+ console.log("[pilely mock] simple-email outbox: nothing was sent", {
149
+ from: account.address,
150
+ to: row.recipients,
151
+ subject,
152
+ html,
153
+ });
154
+ return ctx.ok({ ok: true, send_id: row.send_id, recipient_count: row.recipient_count });
155
+ }
156
+ function handle(path, rawBody) {
157
+ const body = isObject(rawBody) ? rawBody : {};
158
+ const signedIn = ctx.user() !== null;
159
+ if (path === "/send") {
160
+ if (!signedIn)
161
+ return ctx.notFound();
162
+ return send(body);
163
+ }
164
+ if (path === "/sends/list") {
165
+ const appId = typeof body.app_id === "string" ? body.app_id : "";
166
+ const rows = state.sends.filter((s) => s.app_id === appId);
167
+ const result = keysetPage(rows, body, ["after_created_time_stamp", "after_send_id"], (s) => [s.fired_at, s.send_id], (s) => ({ after_created_time_stamp: s.fired_at, after_send_id: s.send_id }));
168
+ if (!result)
169
+ return cursorRefusal();
170
+ const sends = result.page.map(({ app_id: _appId, ...row }) => row);
171
+ return ctx.ok({ ok: true, sends, next_cursor: result.next });
172
+ }
173
+ if (path === "/accounts/create") {
174
+ if (!signedIn)
175
+ return ctx.notFound();
176
+ if (typeof body.app_id !== "string" || body.app_id === "") {
177
+ return ctx.refuse(400, "bad_request", "app_id is required");
178
+ }
179
+ if (findAccount(body.app_id)) {
180
+ return ctx.refuse(409, "account_exists", "this app already has an email account");
181
+ }
182
+ const sendGroup = typeof body.send_group === "string" ? body.send_group : null;
183
+ const account = createAccount(body.app_id, sendGroup);
184
+ save();
185
+ return ctx.ok({ ok: true, account: accountPayload(account) });
186
+ }
187
+ if (path === "/accounts/list") {
188
+ const result = keysetPage(state.accounts, body, ["after_created_time_stamp", "after_app_id"], (a) => [a.created_time_stamp, a.app_id], (a) => ({ after_created_time_stamp: a.created_time_stamp, after_app_id: a.app_id }));
189
+ if (!result)
190
+ return cursorRefusal();
191
+ return ctx.ok({ ok: true, accounts: result.page.map(accountPayload), next_cursor: result.next });
192
+ }
193
+ const parts = path.split("/").filter((p) => p !== "").map(decodeURIComponent);
194
+ if (parts[0] !== "accounts" || parts[1] === undefined)
195
+ return null;
196
+ const account = findAccount(parts[1]);
197
+ const rest = parts.slice(2);
198
+ const route = rest.join("/");
199
+ if (route === "info") {
200
+ if (!account)
201
+ return ctx.notFound();
202
+ const today = new Date().toISOString().slice(0, 10);
203
+ const sentToday = state.sends
204
+ .filter((s) => s.app_id === account.app_id && new Date(s.fired_at).toISOString().slice(0, 10) === today)
205
+ .reduce((sum, s) => sum + s.recipient_count, 0);
206
+ return ctx.ok({
207
+ ok: true,
208
+ account: accountPayload(account),
209
+ daily_cap: DAILY_CAP,
210
+ sent_today: sentToday,
211
+ credit_blocked: false,
212
+ phone_verified: true,
213
+ });
214
+ }
215
+ if (route === "delete") {
216
+ if (!signedIn || !account)
217
+ return ctx.notFound();
218
+ state.accounts = state.accounts.filter((a) => a !== account);
219
+ save();
220
+ return ctx.ok({ ok: true });
221
+ }
222
+ if (route === "access/set") {
223
+ if (!signedIn || !account)
224
+ return ctx.notFound();
225
+ if (!("send_group" in body))
226
+ return ctx.refuse(400, "bad_request", "send_group is required");
227
+ account.send_group = typeof body.send_group === "string" ? body.send_group : null;
228
+ save();
229
+ return ctx.ok({ ok: true, account: accountPayload(account) });
230
+ }
231
+ if (route === "templates/create") {
232
+ if (!signedIn || !account)
233
+ return ctx.notFound();
234
+ if (typeof body.subject !== "string" || typeof body.html !== "string") {
235
+ return ctx.refuse(400, "bad_request", "subject and html are required");
236
+ }
237
+ const now = ctx.now();
238
+ const template = {
239
+ template_id: ctx.nanoid(),
240
+ name: typeof body.name === "string" ? body.name : null,
241
+ subject: body.subject,
242
+ html: body.html,
243
+ created_time_stamp: now,
244
+ updated_time_stamp: now,
245
+ };
246
+ account.templates.push(template);
247
+ save();
248
+ return ctx.ok({ ok: true, template: templateMeta(template) });
249
+ }
250
+ if (route === "templates/list") {
251
+ if (!account)
252
+ return ctx.notFound();
253
+ const result = keysetPage(account.templates, body, ["after_created_time_stamp", "after_template_id"], (t) => [t.created_time_stamp, t.template_id], (t) => ({ after_created_time_stamp: t.created_time_stamp, after_template_id: t.template_id }));
254
+ if (!result)
255
+ return cursorRefusal();
256
+ return ctx.ok({ ok: true, templates: result.page.map(templateMeta), next_cursor: result.next });
257
+ }
258
+ if (rest[0] !== "templates" || rest[1] === undefined || rest.length !== 3)
259
+ return null;
260
+ const template = account?.templates.find((t) => t.template_id === rest[1]);
261
+ const action = rest[2];
262
+ if (action === "info") {
263
+ if (!template)
264
+ return ctx.notFound();
265
+ return ctx.ok({ ok: true, template: { ...templateMeta(template), html: template.html } });
266
+ }
267
+ if (action === "update") {
268
+ if (!signedIn || !template)
269
+ return ctx.notFound();
270
+ if (typeof body.subject !== "string" || typeof body.html !== "string") {
271
+ return ctx.refuse(400, "bad_request", "subject and html are both required");
272
+ }
273
+ template.subject = body.subject;
274
+ template.html = body.html;
275
+ template.updated_time_stamp = ctx.now();
276
+ save();
277
+ return ctx.ok({ ok: true, template: templateMeta(template) });
278
+ }
279
+ if (action === "delete") {
280
+ if (!signedIn || !account || !template)
281
+ return ctx.notFound();
282
+ account.templates = account.templates.filter((t) => t !== template);
283
+ save();
284
+ return ctx.ok({ ok: true });
285
+ }
286
+ return null;
287
+ }
288
+ return { marker: MARKER, handle: (request) => handle(request.path, request.body) };
289
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pilely/simple-email",
3
- "version": "0.2.1",
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.2.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",