@pilely/simple-email 0.4.2 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -6
- package/dist/api.d.ts +24 -16
- package/dist/api.js +53 -23
- package/dist/hooks.d.ts +22 -15
- package/dist/hooks.js +52 -30
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mock.js +40 -7
- package/dist/types.d.ts +19 -9
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,11 +8,12 @@ React hooks for the `simple_email` service — the platform's managed transactio
|
|
|
8
8
|
| --- | --- | --- |
|
|
9
9
|
| `useSend()` | `/send` | mutation, `{ to, subject, html }` or `{ to, template_id, variables? }` (a raw send), or `{ to_account, template_id, variables? }` (an account send); each takes an optional `idempotency_key`. Answers `{ send_id, recipient_count, replayed }` |
|
|
10
10
|
| `useSends({ limit? })` | `/sends/list`, paged | infinite query; this app's outbox, `SendRow[]` |
|
|
11
|
-
| `useEmailAccount()` | `accounts/{app}/info` | query; this app's account with its daily cap, sent today
|
|
11
|
+
| `useEmailAccount()` | `accounts/{app}/info` | query; this app's account with its daily cap, sent today and credit state |
|
|
12
12
|
| `useEmailAccounts({ limit? })` | `accounts/list`, paged | infinite query; every account the caller owns |
|
|
13
|
-
| `useCreateEmailAccount()` | `accounts/create` | mutation, the optional send group, or `{ send_group?, account_senders? }` |
|
|
13
|
+
| `useCreateEmailAccount()` | `accounts/create` | mutation, the optional send group, or `{ send_group?, account_senders?, reply_to? }` |
|
|
14
14
|
| `useDeleteEmailAccount()` | `accounts/{app}/delete` | mutation, no variables |
|
|
15
15
|
| `useSetEmailAccountAccess()` | `accounts/{app}/access/set` | mutation, the send group (`null` = owner-only), or `{ send_group, account_senders? }` (an omitted `account_senders` keeps the current value) |
|
|
16
|
+
| `useSetEmailReplyTo()` | `accounts/{app}/set_reply_to` | mutation, the reply-to address, or `null` to clear it; always sent, never "keep" |
|
|
16
17
|
| `useTemplates({ limit? })` | `templates/list`, paged | infinite query; metadata only |
|
|
17
18
|
| `useTemplate(templateId)` | `templates/{t}/info` | query; the template with its `html`; an `undefined` id disables it |
|
|
18
19
|
| `useCreateTemplate()` | `templates/create` | mutation, `{ name, subject, html }` (`name` may be `null`) |
|
|
@@ -21,12 +22,29 @@ React hooks for the `simple_email` service — the platform's managed transactio
|
|
|
21
22
|
|
|
22
23
|
The app id is added from `@pilely/core` on every route; it is never a variable. Every write waits for the server: an email "sent" before the server accepted it would be a lie.
|
|
23
24
|
|
|
25
|
+
## Bot checks: `{ challenge }`
|
|
26
|
+
|
|
27
|
+
The app's owner can put a **challenge policy** on any route of `simple-email.pilely.app` for the app, typically `POST /send` (`https://simple-limiter.pilely.app/README.md`); root then refuses every send through the app that carries no solved bot check with `403 challenge_required`, before the service sees it — the owner's and the app's server's sends included. Every hook takes a `challenge` option for that, in its options object or as its last argument where it has none (`useSend({ challenge })`, `useTemplate(templateId, { challenge })`):
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
import { getChallenge, solveChallenge } from "@pilely/simple-limiter";
|
|
31
|
+
|
|
32
|
+
const send = useSend({ challenge: async () => solveChallenge(await getChallenge()) });
|
|
33
|
+
await send.mutateAsync({ to_account: "owner", template_id, variables }); // a fresh answer per send
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
A string, or a function answering one (it may be async, and may answer `undefined`) asked once for each request the hook sends; every request carries it as the `X-Pilely-Challenge` header, and root uses it up only on a call a policy matches. One answer works for one call, within 5 minutes of `getChallenge()`, so give a function to a hook that sends more than once. A refusal is a `PilelyError` with `challenge_required` or `challenge_failed` (both 403) and is never retried; get and solve a new one to start over.
|
|
37
|
+
|
|
24
38
|
## Raw sends and account sends
|
|
25
39
|
|
|
26
40
|
A **raw send** (`to`) goes to the addresses you name, with template or inline content. An **account send** (`to_account`) sends a template to one address the service looks up itself: the caller's own Pilely account email (`"self"`) or the app owner's (`"owner"`). The caller never supplies or sees that address — not in the answer, and not on the `"self"` row of the owner's `useSends()` (its `recipients` is `[]`). An account send is template-only: `to` with `to_account`, or `subject` / `html` on an account send, does not typecheck. It counts 1 recipient toward the daily cap and is billed to the owner like any other send.
|
|
27
41
|
|
|
28
42
|
Who may make account sends is the account's `account_senders`: `"send_group"` (the default — the owner and the send group's members) or `"app_users"` (also every signed-in user of the app, through the app; raw sends stay closed to them). When you choose `"app_users"`, add a per-user `@simple_limiter` policy on `simple-email.<endpoint>` `/send` — without it one user can use up the app's daily cap and spend your balance. The service README's `### Who can send from an account` has the full table.
|
|
29
43
|
|
|
44
|
+
## Replies: `reply_to`
|
|
45
|
+
|
|
46
|
+
An account's `reply_to` is the address recipients' replies go to: every send from the account — raw, template and account sends — carries it as its `Reply-To`. `null` (the default) means none, and replies go nowhere. Set it on create (`useCreateEmailAccount()`'s `{ reply_to }`) or later with `useSetEmailReplyTo()`, which takes the address or `null` to clear it. It must be one bare address (no display name, no list) and not on `pilely-app-email.com`, `pilely.ai` or `pilely.app` nor a subdomain of one; the server answers anything else `bad_request` (400) with a reason naming the rule. Every `Account` carries it.
|
|
47
|
+
|
|
30
48
|
## Retries: `idempotency_key`
|
|
31
49
|
|
|
32
50
|
Give a send an `idempotency_key` (1–128 characters of `[A-Za-z0-9._:-]`, held per app for 24 hours) and a repeat with the same key, the same body and the same caller answers the FIRST send's `send_id` with `replayed: true` — nothing is sent, billed or counted again. The same key with another body or from another caller is `idempotency_key_reused` (409); a repeat while the first is still being handed to the provider is `idempotency_key_in_progress` (409, retry in a few seconds). Pick keys stable per logical email — `confirm:<row id>`, `digest:<user id>:<YYYY-MM-DD>` — so a retry after a crash or a timeout repeats the same key. Without a key every request is a new send, and `replayed` is `false`.
|
|
@@ -36,7 +54,7 @@ Give a send an `idempotency_key` (1–128 characters of `[A-Za-z0-9._:-]`, held
|
|
|
36
54
|
| Write | Invalidates |
|
|
37
55
|
| --- | --- |
|
|
38
56
|
| send | `useSends()` |
|
|
39
|
-
| account create / set access | `useEmailAccounts()`, `useEmailAccount()` |
|
|
57
|
+
| account create / set access / set reply-to | `useEmailAccounts()`, `useEmailAccount()` |
|
|
40
58
|
| account delete | `useEmailAccounts()`, `useEmailAccount()`, and the templates under it |
|
|
41
59
|
| template create | `useTemplates()` |
|
|
42
60
|
| template update / delete on `t` | `useTemplates()`, `useTemplate(t)` |
|
|
@@ -96,15 +114,17 @@ Importing this package in a build made with `VITE_PILELY_MOCK=1` registers an in
|
|
|
96
114
|
|
|
97
115
|
**Nothing is ever sent.** A send is appended to an in-memory outbox — the rows `useSends` answers — and logged to the console. Accounts and templates persist to `localStorage` across reloads; the app's account is created on its first send. A missing account or template answers the bare 404, and a second account for the same app answers `account_exists` (409). The 50-recipient bound is kept.
|
|
98
116
|
|
|
99
|
-
It models the send shapes and `idempotency_key`: an account send's row carries `to_account` and one recipient (`recipients: []` for `"self"`, a stand-in owner address for `"owner"` — the fake has no real account emails); a body with both `to` and `to_account`, or an account send with inline content, is `bad_request`; a repeat with the same key, body and caller answers `replayed: true` without a new row, and the same key with another body is `idempotency_key_reused`. Accounts carry `account_senders`, settable on create and access/set. It does not model send groups, `account_senders` gating, the daily cap,
|
|
117
|
+
It models the send shapes and `idempotency_key`: an account send's row carries `to_account` and one recipient (`recipients: []` for `"self"`, a stand-in owner address for `"owner"` — the fake has no real account emails); a body with both `to` and `to_account`, or an account send with inline content, is `bad_request`; a repeat with the same key, body and caller answers `replayed: true` without a new row, and the same key with another body is `idempotency_key_reused`. Accounts carry `account_senders`, settable on create and access/set, and `reply_to`, settable on create and set_reply_to (a missing key or a non-string value on set_reply_to is `bad_request`; the address rules and refused domains are not checked). It does not model send groups, `account_senders` gating, the daily cap, rate limits, credit checks or `idempotency_key_in_progress`.
|
|
100
118
|
|
|
101
119
|
Writes need a signed-in user: a signed-out write answers the uniform bare 404, which reaches the hook's `error` as `PilelyError(404, null)`. See [`@pilely/core`](../core#mock-mode) for the switch, `seedMock()` and `resetMock()`.
|
|
102
120
|
|
|
103
121
|
## Error codes
|
|
104
122
|
|
|
105
|
-
Refusals arrive as `PilelyError` in the hook's `error`, with a `code` drawn from `SimpleEmailErrorCode`. Match on the code, not the status: **`
|
|
123
|
+
Refusals arrive as `PilelyError` in the hook's `error`, with a `code` drawn from `SimpleEmailErrorCode`. Match on the code, not the status: **`daily_cap_exceeded` and `rate_limited` both answer 429**. `daily_cap_exceeded` means the app spent its UTC-day allowance (retry tomorrow). `rate_limited` means the caller is over a `@simple_limiter` policy the app's owner set on this app's sends; root answers it with a `Retry-After` header (retry after that many seconds). `idempotency_key_reused` (409) means the key already belongs to a different request on this app in the last 24 hours — nothing was sent, use a new key for a different email; `idempotency_key_in_progress` (409) means the first request with that key is still being handed to the provider — retry in a few seconds with the same key.
|
|
124
|
+
|
|
125
|
+
A send can also answer `out_of_traffic_credits` (503): the owner's traffic balance is used up, and the owner must top up. Provisioning adds `account_exists`, `account_limit_reached` and `template_limit_reached` (all 409). The rest: `not_found` (404, the uniform hide), `bad_request` (400), `unauthenticated` (401), `bad_forwarded_identity` (401), `internal` (500).
|
|
106
126
|
|
|
107
|
-
A
|
|
127
|
+
A route the app's owner put a challenge policy on (`## Bot checks: { challenge }`) answers two more, from root before the service: `challenge_required` (403, the call carried no `challenge`; nothing was sent) and `challenge_failed` (403, the `challenge` was malformed, wrong, expired, started through another app, or already used — get and solve a new one). An oversize send or template body is refused before any handler runs, so its `code` is `null`.
|
|
108
128
|
|
|
109
129
|
Limits: billed per recipient, at most 50 recipients per send (an account send has exactly 1), 200 recipients per app per UTC day by default. `useEmailAccount()`'s `daily_cap` shows the cap in force for this app; for a higher one, open a support ticket on `@customer` naming the app, the cap you want and what the mail is.
|
|
110
130
|
|
package/dist/api.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ChallengeOption } from "@pilely/core";
|
|
1
2
|
import type { Account, AccountCursor, AccountInfoEnvelope, AccountSenders, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta, ToAccount } from "./types.js";
|
|
2
3
|
/** `to` is capped at the service's `max_recipients_per_send` (50 by default). */
|
|
3
4
|
export type SendContent = {
|
|
@@ -77,57 +78,64 @@ export interface UpdateTemplateBody {
|
|
|
77
78
|
* the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
|
|
78
79
|
* carries a null `code`.
|
|
79
80
|
*/
|
|
80
|
-
export declare function send(input: SendInput): Promise<SendResult>;
|
|
81
|
+
export declare function send(input: SendInput, { challenge }?: ChallengeOption): Promise<SendResult>;
|
|
81
82
|
/** `app_id` lives in the body here, like `send` — not in the path. The
|
|
82
83
|
* paged primitive; use `listAllSends` to walk to the end. */
|
|
83
|
-
export declare function listSends(options?: ListSendsOptions): Promise<{
|
|
84
|
+
export declare function listSends(options?: ListSendsOptions, { challenge }?: ChallengeOption): Promise<{
|
|
84
85
|
sends: SendRow[];
|
|
85
86
|
next_cursor: SendCursor | null;
|
|
86
87
|
}>;
|
|
87
|
-
export declare function listAllSends(): Promise<SendRow[]>;
|
|
88
|
+
export declare function listAllSends(request?: ChallengeOption): Promise<SendRow[]>;
|
|
88
89
|
/** `sendGroup` is optional here — omit it (or pass `undefined`) and the
|
|
89
90
|
* key is left off the wire, meaning owner-only. `setAccountAccess` below
|
|
90
91
|
* is the opposite: its key is always required. `accountSenders` is
|
|
91
92
|
* optional too, and left off the wire when `undefined` (the server then
|
|
92
|
-
* uses `"send_group"`; it refuses an explicit `null` with a 400).
|
|
93
|
-
|
|
93
|
+
* uses `"send_group"`; it refuses an explicit `null` with a 400).
|
|
94
|
+
* `replyTo` is optional as well: `undefined` leaves the key off the wire,
|
|
95
|
+
* and omitted or `null` both mean no reply-to. */
|
|
96
|
+
export declare function createAccount(sendGroup?: string | null, accountSenders?: AccountSenders, replyTo?: string | null, { challenge }?: ChallengeOption): Promise<Account>;
|
|
94
97
|
/** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
|
|
95
98
|
* not a selector. The paged primitive; use `listAllAccounts` to walk to
|
|
96
99
|
* the end. */
|
|
97
|
-
export declare function listAccounts(options?: ListAccountsOptions): Promise<{
|
|
100
|
+
export declare function listAccounts(options?: ListAccountsOptions, { challenge }?: ChallengeOption): Promise<{
|
|
98
101
|
accounts: Account[];
|
|
99
102
|
next_cursor: AccountCursor | null;
|
|
100
103
|
}>;
|
|
101
|
-
export declare function listAllAccounts(): Promise<Account[]>;
|
|
104
|
+
export declare function listAllAccounts(request?: ChallengeOption): Promise<Account[]>;
|
|
102
105
|
/** Returns the account plus its live operational state — the four extra
|
|
103
106
|
* fields are nullable siblings of `account`, not nested inside it. */
|
|
104
|
-
export declare function accountInfo(): Promise<AccountInfoEnvelope>;
|
|
105
|
-
export declare function deleteAccount(): Promise<void>;
|
|
107
|
+
export declare function accountInfo({ challenge }?: ChallengeOption): Promise<AccountInfoEnvelope>;
|
|
108
|
+
export declare function deleteAccount({ challenge }?: ChallengeOption): Promise<void>;
|
|
106
109
|
/** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
|
|
107
110
|
* the opposite of `createAccount`, where the key is optional. `null`
|
|
108
111
|
* means owner-only. `accountSenders` is optional: `undefined` leaves the
|
|
109
112
|
* key off the wire, which KEEPS the account's current value. */
|
|
110
|
-
export declare function setAccountAccess(sendGroup: string | null, accountSenders?: AccountSenders): Promise<Account>;
|
|
113
|
+
export declare function setAccountAccess(sendGroup: string | null, accountSenders?: AccountSenders, { challenge }?: ChallengeOption): Promise<Account>;
|
|
114
|
+
/** `replyTo` is REQUIRED and its key is always sent, even `null` — the
|
|
115
|
+
* server refuses a missing key with a 400, so a typo can never silently
|
|
116
|
+
* clear it. An address sets where recipients' replies go (every send from
|
|
117
|
+
* the account carries it as its `Reply-To`); `null` clears it. */
|
|
118
|
+
export declare function setReplyTo(replyTo: string | null, { challenge }?: ChallengeOption): Promise<Account>;
|
|
111
119
|
/** Returns metadata only — no `html`. Use `templateInfo` to read the body
|
|
112
120
|
* back.
|
|
113
121
|
*
|
|
114
122
|
* `name` is nullable because the service accepts a nameless template. It
|
|
115
123
|
* stays a required POSITIONAL parameter — pass an explicit `null`, not
|
|
116
124
|
* nothing — and `TemplateMeta.name` then comes back `null`. */
|
|
117
|
-
export declare function createTemplate(name: string | null, subject: string, html: string): Promise<TemplateMeta>;
|
|
125
|
+
export declare function createTemplate(name: string | null, subject: string, html: string, { challenge }?: ChallengeOption): Promise<TemplateMeta>;
|
|
118
126
|
/** The paged primitive; use `listAllTemplates` to walk to the end. Rows
|
|
119
127
|
* carry metadata only — no `html`. */
|
|
120
|
-
export declare function listTemplates(options?: ListTemplatesOptions): Promise<{
|
|
128
|
+
export declare function listTemplates(options?: ListTemplatesOptions, { challenge }?: ChallengeOption): Promise<{
|
|
121
129
|
templates: TemplateMeta[];
|
|
122
130
|
next_cursor: TemplateCursor | null;
|
|
123
131
|
}>;
|
|
124
|
-
export declare function listAllTemplates(): Promise<TemplateMeta[]>;
|
|
132
|
+
export declare function listAllTemplates(request?: ChallengeOption): Promise<TemplateMeta[]>;
|
|
125
133
|
/** The only template route whose answer carries `html`. */
|
|
126
|
-
export declare function templateInfo(templateNanoid: string): Promise<Template>;
|
|
134
|
+
export declare function templateInfo(templateNanoid: string, { challenge }?: ChallengeOption): Promise<Template>;
|
|
127
135
|
/**
|
|
128
136
|
* A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
|
|
129
137
|
* is not updatable at all. Returns metadata only, like `createTemplate`.
|
|
130
138
|
* Subject to the same oversize-body caveat as `send`/`createTemplate`.
|
|
131
139
|
*/
|
|
132
|
-
export declare function updateTemplate(templateNanoid: string, body: UpdateTemplateBody): Promise<TemplateMeta>;
|
|
133
|
-
export declare function deleteTemplate(templateNanoid: string): Promise<void>;
|
|
140
|
+
export declare function updateTemplate(templateNanoid: string, body: UpdateTemplateBody, { challenge }?: ChallengeOption): Promise<TemplateMeta>;
|
|
141
|
+
export declare function deleteTemplate(templateNanoid: string, { challenge }?: ChallengeOption): Promise<void>;
|
package/dist/api.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// The simple_email service's
|
|
1
|
+
// The simple_email service's 13 POST routes, one function each. This is the
|
|
2
2
|
// internal layer the hooks in hooks.ts call; the package exports the hooks,
|
|
3
3
|
// not these. app_id moves between the body and the path from route to
|
|
4
|
-
// route on this service.
|
|
4
|
+
// route on this service. Every function takes `{ challenge }` — a bot-check
|
|
5
|
+
// answer, or a function asked once per request — and every request it sends
|
|
6
|
+
// carries it.
|
|
5
7
|
import { appId, call, collectPages, isMockMode, registerMockService } from "@pilely/core";
|
|
6
8
|
import { createSimpleEmailFake } from "./mock.js";
|
|
7
9
|
// Importing this package (its hooks import this module) is all an app does
|
|
@@ -31,7 +33,7 @@ function requireAppId() {
|
|
|
31
33
|
* the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
|
|
32
34
|
* carries a null `code`.
|
|
33
35
|
*/
|
|
34
|
-
export async function send(input) {
|
|
36
|
+
export async function send(input, { challenge } = {}) {
|
|
35
37
|
const app_id = requireAppId();
|
|
36
38
|
const idempotency_key = input.idempotency_key;
|
|
37
39
|
const body = input.to_account !== undefined
|
|
@@ -43,11 +45,12 @@ export async function send(input) {
|
|
|
43
45
|
service: "simple-email",
|
|
44
46
|
path: "/send",
|
|
45
47
|
body,
|
|
48
|
+
challenge,
|
|
46
49
|
});
|
|
47
50
|
}
|
|
48
51
|
/** `app_id` lives in the body here, like `send` — not in the path. The
|
|
49
52
|
* paged primitive; use `listAllSends` to walk to the end. */
|
|
50
|
-
export async function listSends(options = {}) {
|
|
53
|
+
export async function listSends(options = {}, { challenge } = {}) {
|
|
51
54
|
const json = await call({
|
|
52
55
|
service: "simple-email",
|
|
53
56
|
path: "/sends/list",
|
|
@@ -57,12 +60,13 @@ export async function listSends(options = {}) {
|
|
|
57
60
|
after_created_time_stamp: options.after_created_time_stamp,
|
|
58
61
|
after_send_id: options.after_send_id,
|
|
59
62
|
},
|
|
63
|
+
challenge,
|
|
60
64
|
});
|
|
61
65
|
return { sends: json.sends, next_cursor: json.next_cursor };
|
|
62
66
|
}
|
|
63
|
-
export async function listAllSends() {
|
|
67
|
+
export async function listAllSends(request = {}) {
|
|
64
68
|
return collectPages(async (cursor) => {
|
|
65
|
-
const page = await listSends({ limit: 100, ...(cursor ?? {}) });
|
|
69
|
+
const page = await listSends({ limit: 100, ...(cursor ?? {}) }, request);
|
|
66
70
|
return { rows: page.sends, nextCursor: page.next_cursor };
|
|
67
71
|
});
|
|
68
72
|
}
|
|
@@ -71,8 +75,10 @@ export async function listAllSends() {
|
|
|
71
75
|
* key is left off the wire, meaning owner-only. `setAccountAccess` below
|
|
72
76
|
* is the opposite: its key is always required. `accountSenders` is
|
|
73
77
|
* optional too, and left off the wire when `undefined` (the server then
|
|
74
|
-
* uses `"send_group"`; it refuses an explicit `null` with a 400).
|
|
75
|
-
|
|
78
|
+
* uses `"send_group"`; it refuses an explicit `null` with a 400).
|
|
79
|
+
* `replyTo` is optional as well: `undefined` leaves the key off the wire,
|
|
80
|
+
* and omitted or `null` both mean no reply-to. */
|
|
81
|
+
export async function createAccount(sendGroup, accountSenders, replyTo, { challenge } = {}) {
|
|
76
82
|
const body = {
|
|
77
83
|
app_id: requireAppId(),
|
|
78
84
|
};
|
|
@@ -82,17 +88,21 @@ export async function createAccount(sendGroup, accountSenders) {
|
|
|
82
88
|
if (accountSenders !== undefined) {
|
|
83
89
|
body.account_senders = accountSenders;
|
|
84
90
|
}
|
|
91
|
+
if (replyTo !== undefined) {
|
|
92
|
+
body.reply_to = replyTo;
|
|
93
|
+
}
|
|
85
94
|
const json = await call({
|
|
86
95
|
service: "simple-email",
|
|
87
96
|
path: "/accounts/create",
|
|
88
97
|
body,
|
|
98
|
+
challenge,
|
|
89
99
|
});
|
|
90
100
|
return json.account;
|
|
91
101
|
}
|
|
92
102
|
/** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
|
|
93
103
|
* not a selector. The paged primitive; use `listAllAccounts` to walk to
|
|
94
104
|
* the end. */
|
|
95
|
-
export async function listAccounts(options = {}) {
|
|
105
|
+
export async function listAccounts(options = {}, { challenge } = {}) {
|
|
96
106
|
const json = await call({
|
|
97
107
|
service: "simple-email",
|
|
98
108
|
path: "/accounts/list",
|
|
@@ -101,38 +111,39 @@ export async function listAccounts(options = {}) {
|
|
|
101
111
|
after_created_time_stamp: options.after_created_time_stamp,
|
|
102
112
|
after_app_id: options.after_app_id,
|
|
103
113
|
},
|
|
114
|
+
challenge,
|
|
104
115
|
});
|
|
105
116
|
return { accounts: json.accounts, next_cursor: json.next_cursor };
|
|
106
117
|
}
|
|
107
|
-
export async function listAllAccounts() {
|
|
118
|
+
export async function listAllAccounts(request = {}) {
|
|
108
119
|
return collectPages(async (cursor) => {
|
|
109
|
-
const page = await listAccounts({ limit: 100, ...(cursor ?? {}) });
|
|
120
|
+
const page = await listAccounts({ limit: 100, ...(cursor ?? {}) }, request);
|
|
110
121
|
return { rows: page.accounts, nextCursor: page.next_cursor };
|
|
111
122
|
});
|
|
112
123
|
}
|
|
113
124
|
/** Returns the account plus its live operational state — the four extra
|
|
114
125
|
* fields are nullable siblings of `account`, not nested inside it. */
|
|
115
|
-
export async function accountInfo() {
|
|
126
|
+
export async function accountInfo({ challenge } = {}) {
|
|
116
127
|
const json = await call({
|
|
117
128
|
service: "simple-email",
|
|
118
129
|
path: `/accounts/${requireAppId()}/info`,
|
|
130
|
+
challenge,
|
|
119
131
|
});
|
|
120
132
|
return {
|
|
121
133
|
account: json.account,
|
|
122
134
|
daily_cap: json.daily_cap,
|
|
123
135
|
sent_today: json.sent_today,
|
|
124
136
|
credit_blocked: json.credit_blocked,
|
|
125
|
-
phone_verified: json.phone_verified,
|
|
126
137
|
};
|
|
127
138
|
}
|
|
128
|
-
export async function deleteAccount() {
|
|
129
|
-
await call({ service: "simple-email", path: `/accounts/${requireAppId()}/delete
|
|
139
|
+
export async function deleteAccount({ challenge } = {}) {
|
|
140
|
+
await call({ service: "simple-email", path: `/accounts/${requireAppId()}/delete`, challenge });
|
|
130
141
|
}
|
|
131
142
|
/** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
|
|
132
143
|
* the opposite of `createAccount`, where the key is optional. `null`
|
|
133
144
|
* means owner-only. `accountSenders` is optional: `undefined` leaves the
|
|
134
145
|
* key off the wire, which KEEPS the account's current value. */
|
|
135
|
-
export async function setAccountAccess(sendGroup, accountSenders) {
|
|
146
|
+
export async function setAccountAccess(sendGroup, accountSenders, { challenge } = {}) {
|
|
136
147
|
const body = { send_group: sendGroup };
|
|
137
148
|
if (accountSenders !== undefined) {
|
|
138
149
|
body.account_senders = accountSenders;
|
|
@@ -141,6 +152,20 @@ export async function setAccountAccess(sendGroup, accountSenders) {
|
|
|
141
152
|
service: "simple-email",
|
|
142
153
|
path: `/accounts/${requireAppId()}/access/set`,
|
|
143
154
|
body,
|
|
155
|
+
challenge,
|
|
156
|
+
});
|
|
157
|
+
return json.account;
|
|
158
|
+
}
|
|
159
|
+
/** `replyTo` is REQUIRED and its key is always sent, even `null` — the
|
|
160
|
+
* server refuses a missing key with a 400, so a typo can never silently
|
|
161
|
+
* clear it. An address sets where recipients' replies go (every send from
|
|
162
|
+
* the account carries it as its `Reply-To`); `null` clears it. */
|
|
163
|
+
export async function setReplyTo(replyTo, { challenge } = {}) {
|
|
164
|
+
const json = await call({
|
|
165
|
+
service: "simple-email",
|
|
166
|
+
path: `/accounts/${requireAppId()}/set_reply_to`,
|
|
167
|
+
body: { reply_to: replyTo },
|
|
168
|
+
challenge,
|
|
144
169
|
});
|
|
145
170
|
return json.account;
|
|
146
171
|
}
|
|
@@ -151,17 +176,18 @@ export async function setAccountAccess(sendGroup, accountSenders) {
|
|
|
151
176
|
* `name` is nullable because the service accepts a nameless template. It
|
|
152
177
|
* stays a required POSITIONAL parameter — pass an explicit `null`, not
|
|
153
178
|
* nothing — and `TemplateMeta.name` then comes back `null`. */
|
|
154
|
-
export async function createTemplate(name, subject, html) {
|
|
179
|
+
export async function createTemplate(name, subject, html, { challenge } = {}) {
|
|
155
180
|
const json = await call({
|
|
156
181
|
service: "simple-email",
|
|
157
182
|
path: `/accounts/${requireAppId()}/templates/create`,
|
|
158
183
|
body: { name, subject, html },
|
|
184
|
+
challenge,
|
|
159
185
|
});
|
|
160
186
|
return json.template;
|
|
161
187
|
}
|
|
162
188
|
/** The paged primitive; use `listAllTemplates` to walk to the end. Rows
|
|
163
189
|
* carry metadata only — no `html`. */
|
|
164
|
-
export async function listTemplates(options = {}) {
|
|
190
|
+
export async function listTemplates(options = {}, { challenge } = {}) {
|
|
165
191
|
const json = await call({
|
|
166
192
|
service: "simple-email",
|
|
167
193
|
path: `/accounts/${requireAppId()}/templates/list`,
|
|
@@ -170,20 +196,22 @@ export async function listTemplates(options = {}) {
|
|
|
170
196
|
after_created_time_stamp: options.after_created_time_stamp,
|
|
171
197
|
after_template_id: options.after_template_id,
|
|
172
198
|
},
|
|
199
|
+
challenge,
|
|
173
200
|
});
|
|
174
201
|
return { templates: json.templates, next_cursor: json.next_cursor };
|
|
175
202
|
}
|
|
176
|
-
export async function listAllTemplates() {
|
|
203
|
+
export async function listAllTemplates(request = {}) {
|
|
177
204
|
return collectPages(async (cursor) => {
|
|
178
|
-
const page = await listTemplates({ limit: 100, ...(cursor ?? {}) });
|
|
205
|
+
const page = await listTemplates({ limit: 100, ...(cursor ?? {}) }, request);
|
|
179
206
|
return { rows: page.templates, nextCursor: page.next_cursor };
|
|
180
207
|
});
|
|
181
208
|
}
|
|
182
209
|
/** The only template route whose answer carries `html`. */
|
|
183
|
-
export async function templateInfo(templateNanoid) {
|
|
210
|
+
export async function templateInfo(templateNanoid, { challenge } = {}) {
|
|
184
211
|
const json = await call({
|
|
185
212
|
service: "simple-email",
|
|
186
213
|
path: `/accounts/${requireAppId()}/templates/${templateNanoid}/info`,
|
|
214
|
+
challenge,
|
|
187
215
|
});
|
|
188
216
|
return json.template;
|
|
189
217
|
}
|
|
@@ -192,17 +220,19 @@ export async function templateInfo(templateNanoid) {
|
|
|
192
220
|
* is not updatable at all. Returns metadata only, like `createTemplate`.
|
|
193
221
|
* Subject to the same oversize-body caveat as `send`/`createTemplate`.
|
|
194
222
|
*/
|
|
195
|
-
export async function updateTemplate(templateNanoid, body) {
|
|
223
|
+
export async function updateTemplate(templateNanoid, body, { challenge } = {}) {
|
|
196
224
|
const json = await call({
|
|
197
225
|
service: "simple-email",
|
|
198
226
|
path: `/accounts/${requireAppId()}/templates/${templateNanoid}/update`,
|
|
199
227
|
body,
|
|
228
|
+
challenge,
|
|
200
229
|
});
|
|
201
230
|
return json.template;
|
|
202
231
|
}
|
|
203
|
-
export async function deleteTemplate(templateNanoid) {
|
|
232
|
+
export async function deleteTemplate(templateNanoid, { challenge } = {}) {
|
|
204
233
|
await call({
|
|
205
234
|
service: "simple-email",
|
|
206
235
|
path: `/accounts/${requireAppId()}/templates/${templateNanoid}/delete`,
|
|
236
|
+
challenge,
|
|
207
237
|
});
|
|
208
238
|
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import type { ChallengeOption } from "@pilely/core";
|
|
1
2
|
import type { UseInfiniteQueryResult, UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
|
2
3
|
import type { SendInput, SendResult } from "./api.js";
|
|
3
4
|
import type { Account, AccountInfoEnvelope, AccountSenders, SendRow, Template, TemplateMeta } from "./types.js";
|
|
4
|
-
export interface PageOptions {
|
|
5
|
+
export interface PageOptions extends ChallengeOption {
|
|
5
6
|
/** Rows per page; the server defaults to 50 and caps at 100. */
|
|
6
7
|
limit?: number;
|
|
7
8
|
}
|
|
@@ -11,12 +12,14 @@ export interface CreateTemplateInput {
|
|
|
11
12
|
subject: string;
|
|
12
13
|
html: string;
|
|
13
14
|
}
|
|
14
|
-
/** `useCreateEmailAccount`'s object form.
|
|
15
|
+
/** `useCreateEmailAccount`'s object form. Every key is optional: an omitted
|
|
15
16
|
* `send_group` means owner-only, an omitted `account_senders` means
|
|
16
|
-
* `"send_group"
|
|
17
|
+
* `"send_group"`, and an omitted (or `null`) `reply_to` means no reply-to. */
|
|
17
18
|
export interface CreateEmailAccountInput {
|
|
18
19
|
send_group?: string | null;
|
|
19
20
|
account_senders?: AccountSenders;
|
|
21
|
+
/** The address recipients' replies go to. */
|
|
22
|
+
reply_to?: string | null;
|
|
20
23
|
}
|
|
21
24
|
/** `useSetEmailAccountAccess`'s object form. `send_group` is required (`null`
|
|
22
25
|
* = owner-only); an omitted `account_senders` keeps the current value. */
|
|
@@ -34,26 +37,30 @@ export interface UpdateTemplateInput {
|
|
|
34
37
|
* send (`to_account: "self" | "owner"`). Not optimistic: a send counts only
|
|
35
38
|
* once the server accepted it. A repeat carrying the same `idempotency_key`
|
|
36
39
|
* answers the first send with `replayed: true`. */
|
|
37
|
-
export declare function useSend(): UseMutationResult<SendResult, Error, SendInput>;
|
|
40
|
+
export declare function useSend(options?: ChallengeOption): UseMutationResult<SendResult, Error, SendInput>;
|
|
38
41
|
/** This app's outbox, newest first, as an infinite query. */
|
|
39
42
|
export declare function useSends(options?: PageOptions): UseInfiniteQueryResult<SendRow[], Error>;
|
|
40
43
|
/** The caller's email accounts across apps, as an infinite query. */
|
|
41
44
|
export declare function useEmailAccounts(options?: PageOptions): UseInfiniteQueryResult<Account[], Error>;
|
|
42
|
-
/** This app's account with its live state: daily cap, sent today
|
|
43
|
-
*
|
|
44
|
-
export declare function useEmailAccount(): UseQueryResult<AccountInfoEnvelope, Error>;
|
|
45
|
+
/** This app's account with its live state: daily cap, sent today and
|
|
46
|
+
* credit. */
|
|
47
|
+
export declare function useEmailAccount(options?: ChallengeOption): UseQueryResult<AccountInfoEnvelope, Error>;
|
|
45
48
|
/** Creates this app's account; the variable is its optional send group
|
|
46
|
-
* (`null` or omitted: owner-only), or `{ send_group?, account_senders? }`. */
|
|
47
|
-
export declare function useCreateEmailAccount(): UseMutationResult<Account, Error, string | null | void | CreateEmailAccountInput>;
|
|
49
|
+
* (`null` or omitted: owner-only), or `{ send_group?, account_senders?, reply_to? }`. */
|
|
50
|
+
export declare function useCreateEmailAccount(options?: ChallengeOption): UseMutationResult<Account, Error, string | null | void | CreateEmailAccountInput>;
|
|
48
51
|
/** Deletes this app's account and the templates under it. */
|
|
49
|
-
export declare function useDeleteEmailAccount(): UseMutationResult<void, Error, void>;
|
|
52
|
+
export declare function useDeleteEmailAccount(options?: ChallengeOption): UseMutationResult<void, Error, void>;
|
|
50
53
|
/** Replaces this app's send group (`null` means owner-only); the object form
|
|
51
54
|
* `{ send_group, account_senders? }` also sets who may make account sends. */
|
|
52
|
-
export declare function useSetEmailAccountAccess(): UseMutationResult<Account, Error, string | null | SetEmailAccountAccessInput>;
|
|
55
|
+
export declare function useSetEmailAccountAccess(options?: ChallengeOption): UseMutationResult<Account, Error, string | null | SetEmailAccountAccessInput>;
|
|
56
|
+
/** Sets this app's reply-to address — where recipients' replies go; every
|
|
57
|
+
* send from the account carries it as its `Reply-To` — or clears it with
|
|
58
|
+
* `null`. The variable is required: there is no "keep" form. */
|
|
59
|
+
export declare function useSetEmailReplyTo(options?: ChallengeOption): UseMutationResult<Account, Error, string | null>;
|
|
53
60
|
/** This app's templates (metadata only), as an infinite query. */
|
|
54
61
|
export declare function useTemplates(options?: PageOptions): UseInfiniteQueryResult<TemplateMeta[], Error>;
|
|
55
62
|
/** One template with its `html`. An `undefined` id disables the query. */
|
|
56
|
-
export declare function useTemplate(templateId: string | undefined): UseQueryResult<Template, Error>;
|
|
57
|
-
export declare function useCreateTemplate(): UseMutationResult<TemplateMeta, Error, CreateTemplateInput>;
|
|
58
|
-
export declare function useUpdateTemplate(): UseMutationResult<TemplateMeta, Error, UpdateTemplateInput>;
|
|
59
|
-
export declare function useDeleteTemplate(): UseMutationResult<void, Error, string>;
|
|
63
|
+
export declare function useTemplate(templateId: string | undefined, options?: ChallengeOption): UseQueryResult<Template, Error>;
|
|
64
|
+
export declare function useCreateTemplate(options?: ChallengeOption): UseMutationResult<TemplateMeta, Error, CreateTemplateInput>;
|
|
65
|
+
export declare function useUpdateTemplate(options?: ChallengeOption): UseMutationResult<TemplateMeta, Error, UpdateTemplateInput>;
|
|
66
|
+
export declare function useDeleteTemplate(options?: ChallengeOption): UseMutationResult<void, Error, string>;
|
package/dist/hooks.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { serviceQueryKey, useServiceInfiniteQuery, useServiceMutation, useServiceQuery } from "@pilely/core";
|
|
2
|
-
import { accountInfo, createAccount, createTemplate, deleteAccount, deleteTemplate, listAccounts, listSends, listTemplates, send, setAccountAccess, templateInfo, updateTemplate, } from "./api.js";
|
|
2
|
+
import { accountInfo, createAccount, createTemplate, deleteAccount, deleteTemplate, listAccounts, listSends, listTemplates, send, setAccountAccess, setReplyTo, templateInfo, updateTemplate, } from "./api.js";
|
|
3
3
|
/** `["pilely", "simple-email", "sends", options]` */
|
|
4
4
|
const sendsKey = () => serviceQueryKey("simple-email", "sends");
|
|
5
5
|
/** `["pilely", "simple-email", "accounts", options]` */
|
|
@@ -15,19 +15,20 @@ const templateKey = (templateId) => serviceQueryKey("simple-email", "template",
|
|
|
15
15
|
* send (`to_account: "self" | "owner"`). Not optimistic: a send counts only
|
|
16
16
|
* once the server accepted it. A repeat carrying the same `idempotency_key`
|
|
17
17
|
* answers the first send with `replayed: true`. */
|
|
18
|
-
export function useSend() {
|
|
18
|
+
export function useSend(options = {}) {
|
|
19
|
+
const { challenge } = options;
|
|
19
20
|
return useServiceMutation("useSend", {
|
|
20
|
-
mutationFn: send,
|
|
21
|
+
mutationFn: (input) => send(input, { challenge }),
|
|
21
22
|
invalidates: () => [sendsKey()],
|
|
22
23
|
});
|
|
23
24
|
}
|
|
24
25
|
/** This app's outbox, newest first, as an infinite query. */
|
|
25
26
|
export function useSends(options = {}) {
|
|
26
|
-
const { limit } = options;
|
|
27
|
+
const { limit, challenge } = options;
|
|
27
28
|
return useServiceInfiniteQuery("useSends", {
|
|
28
29
|
queryKey: [...sendsKey(), { limit }],
|
|
29
30
|
fetchPage: async (cursor) => {
|
|
30
|
-
const page = await listSends({ limit, ...(cursor ?? {}) });
|
|
31
|
+
const page = await listSends({ limit, ...(cursor ?? {}) }, { challenge });
|
|
31
32
|
return { rows: page.sends, nextCursor: page.next_cursor };
|
|
32
33
|
},
|
|
33
34
|
});
|
|
@@ -35,82 +36,103 @@ export function useSends(options = {}) {
|
|
|
35
36
|
// ── Accounts ─────────────────────────────────────────────────────────────
|
|
36
37
|
/** The caller's email accounts across apps, as an infinite query. */
|
|
37
38
|
export function useEmailAccounts(options = {}) {
|
|
38
|
-
const { limit } = options;
|
|
39
|
+
const { limit, challenge } = options;
|
|
39
40
|
return useServiceInfiniteQuery("useEmailAccounts", {
|
|
40
41
|
queryKey: [...accountsKey(), { limit }],
|
|
41
42
|
fetchPage: async (cursor) => {
|
|
42
|
-
const page = await listAccounts({ limit, ...(cursor ?? {}) });
|
|
43
|
+
const page = await listAccounts({ limit, ...(cursor ?? {}) }, { challenge });
|
|
43
44
|
return { rows: page.accounts, nextCursor: page.next_cursor };
|
|
44
45
|
},
|
|
45
46
|
});
|
|
46
47
|
}
|
|
47
|
-
/** This app's account with its live state: daily cap, sent today
|
|
48
|
-
*
|
|
49
|
-
export function useEmailAccount() {
|
|
50
|
-
|
|
48
|
+
/** This app's account with its live state: daily cap, sent today and
|
|
49
|
+
* credit. */
|
|
50
|
+
export function useEmailAccount(options = {}) {
|
|
51
|
+
const { challenge } = options;
|
|
52
|
+
return useServiceQuery("useEmailAccount", {
|
|
53
|
+
queryKey: accountKey(),
|
|
54
|
+
queryFn: () => accountInfo({ challenge }),
|
|
55
|
+
});
|
|
51
56
|
}
|
|
52
57
|
/** Creates this app's account; the variable is its optional send group
|
|
53
|
-
* (`null` or omitted: owner-only), or `{ send_group?, account_senders? }`. */
|
|
54
|
-
export function useCreateEmailAccount() {
|
|
58
|
+
* (`null` or omitted: owner-only), or `{ send_group?, account_senders?, reply_to? }`. */
|
|
59
|
+
export function useCreateEmailAccount(options = {}) {
|
|
60
|
+
const { challenge } = options;
|
|
55
61
|
return useServiceMutation("useCreateEmailAccount", {
|
|
56
62
|
mutationFn: (input) => typeof input === "object" && input !== null
|
|
57
|
-
? createAccount(input.send_group, input.account_senders)
|
|
58
|
-
: createAccount(input ?? undefined),
|
|
63
|
+
? createAccount(input.send_group, input.account_senders, input.reply_to, { challenge })
|
|
64
|
+
: createAccount(input ?? undefined, undefined, undefined, { challenge }),
|
|
59
65
|
invalidates: () => [accountsKey(), accountKey()],
|
|
60
66
|
});
|
|
61
67
|
}
|
|
62
68
|
/** Deletes this app's account and the templates under it. */
|
|
63
|
-
export function useDeleteEmailAccount() {
|
|
69
|
+
export function useDeleteEmailAccount(options = {}) {
|
|
70
|
+
const { challenge } = options;
|
|
64
71
|
return useServiceMutation("useDeleteEmailAccount", {
|
|
65
|
-
mutationFn: () => deleteAccount(),
|
|
72
|
+
mutationFn: () => deleteAccount({ challenge }),
|
|
66
73
|
invalidates: () => [accountsKey(), accountKey(), templatesKey(), serviceQueryKey("simple-email", "template")],
|
|
67
74
|
});
|
|
68
75
|
}
|
|
69
76
|
/** Replaces this app's send group (`null` means owner-only); the object form
|
|
70
77
|
* `{ send_group, account_senders? }` also sets who may make account sends. */
|
|
71
|
-
export function useSetEmailAccountAccess() {
|
|
78
|
+
export function useSetEmailAccountAccess(options = {}) {
|
|
79
|
+
const { challenge } = options;
|
|
72
80
|
return useServiceMutation("useSetEmailAccountAccess", {
|
|
73
81
|
mutationFn: (input) => typeof input === "object" && input !== null
|
|
74
|
-
? setAccountAccess(input.send_group, input.account_senders)
|
|
75
|
-
: setAccountAccess(input),
|
|
82
|
+
? setAccountAccess(input.send_group, input.account_senders, { challenge })
|
|
83
|
+
: setAccountAccess(input, undefined, { challenge }),
|
|
84
|
+
invalidates: () => [accountsKey(), accountKey()],
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** Sets this app's reply-to address — where recipients' replies go; every
|
|
88
|
+
* send from the account carries it as its `Reply-To` — or clears it with
|
|
89
|
+
* `null`. The variable is required: there is no "keep" form. */
|
|
90
|
+
export function useSetEmailReplyTo(options = {}) {
|
|
91
|
+
const { challenge } = options;
|
|
92
|
+
return useServiceMutation("useSetEmailReplyTo", {
|
|
93
|
+
mutationFn: (replyTo) => setReplyTo(replyTo, { challenge }),
|
|
76
94
|
invalidates: () => [accountsKey(), accountKey()],
|
|
77
95
|
});
|
|
78
96
|
}
|
|
79
97
|
// ── Templates ────────────────────────────────────────────────────────────
|
|
80
98
|
/** This app's templates (metadata only), as an infinite query. */
|
|
81
99
|
export function useTemplates(options = {}) {
|
|
82
|
-
const { limit } = options;
|
|
100
|
+
const { limit, challenge } = options;
|
|
83
101
|
return useServiceInfiniteQuery("useTemplates", {
|
|
84
102
|
queryKey: [...templatesKey(), { limit }],
|
|
85
103
|
fetchPage: async (cursor) => {
|
|
86
|
-
const page = await listTemplates({ limit, ...(cursor ?? {}) });
|
|
104
|
+
const page = await listTemplates({ limit, ...(cursor ?? {}) }, { challenge });
|
|
87
105
|
return { rows: page.templates, nextCursor: page.next_cursor };
|
|
88
106
|
},
|
|
89
107
|
});
|
|
90
108
|
}
|
|
91
109
|
/** One template with its `html`. An `undefined` id disables the query. */
|
|
92
|
-
export function useTemplate(templateId) {
|
|
110
|
+
export function useTemplate(templateId, options = {}) {
|
|
111
|
+
const { challenge } = options;
|
|
93
112
|
return useServiceQuery("useTemplate", {
|
|
94
113
|
queryKey: templateKey(templateId),
|
|
95
|
-
queryFn: () => templateInfo(templateId),
|
|
114
|
+
queryFn: () => templateInfo(templateId, { challenge }),
|
|
96
115
|
enabled: templateId !== undefined,
|
|
97
116
|
});
|
|
98
117
|
}
|
|
99
|
-
export function useCreateTemplate() {
|
|
118
|
+
export function useCreateTemplate(options = {}) {
|
|
119
|
+
const { challenge } = options;
|
|
100
120
|
return useServiceMutation("useCreateTemplate", {
|
|
101
|
-
mutationFn: ({ name, subject, html }) => createTemplate(name, subject, html),
|
|
121
|
+
mutationFn: ({ name, subject, html }) => createTemplate(name, subject, html, { challenge }),
|
|
102
122
|
invalidates: () => [templatesKey()],
|
|
103
123
|
});
|
|
104
124
|
}
|
|
105
|
-
export function useUpdateTemplate() {
|
|
125
|
+
export function useUpdateTemplate(options = {}) {
|
|
126
|
+
const { challenge } = options;
|
|
106
127
|
return useServiceMutation("useUpdateTemplate", {
|
|
107
|
-
mutationFn: ({ templateId, subject, html }) => updateTemplate(templateId, { subject, html }),
|
|
128
|
+
mutationFn: ({ templateId, subject, html }) => updateTemplate(templateId, { subject, html }, { challenge }),
|
|
108
129
|
invalidates: (_template, { templateId }) => [templatesKey(), templateKey(templateId)],
|
|
109
130
|
});
|
|
110
131
|
}
|
|
111
|
-
export function useDeleteTemplate() {
|
|
132
|
+
export function useDeleteTemplate(options = {}) {
|
|
133
|
+
const { challenge } = options;
|
|
112
134
|
return useServiceMutation("useDeleteTemplate", {
|
|
113
|
-
mutationFn: deleteTemplate,
|
|
135
|
+
mutationFn: (templateId) => deleteTemplate(templateId, { challenge }),
|
|
114
136
|
invalidates: (_none, templateId) => [templatesKey(), templateKey(templateId)],
|
|
115
137
|
});
|
|
116
138
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.js";
|
|
1
|
+
export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useSetEmailReplyTo, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.js";
|
|
2
2
|
export type { CreateEmailAccountInput, CreateTemplateInput, PageOptions, SetEmailAccountAccessInput, UpdateTemplateInput, } from "./hooks.js";
|
|
3
3
|
export type { AccountSendInput, RawSendInput, SendContent, SendInput, SendKey, SendResult } from "./api.js";
|
|
4
4
|
export type { Account, AccountSenders, SimpleEmailErrorCode, AccountCursor, AccountInfoEnvelope, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta, ToAccount, } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// @pilely/simple-email — React hooks over the simple_email service: a query
|
|
2
2
|
// hook for every read, a mutation hook for every write. Render them inside
|
|
3
3
|
// @pilely/core's <PilelyProvider>.
|
|
4
|
-
export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.js";
|
|
4
|
+
export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useSetEmailReplyTo, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.js";
|
package/dist/mock.js
CHANGED
|
@@ -5,15 +5,18 @@
|
|
|
5
5
|
// answers) and logs the message to the console. Accounts and templates
|
|
6
6
|
// live in memory and persist through the core store; an app's account is
|
|
7
7
|
// created on its first send if it does not exist yet. It models shape, not
|
|
8
|
-
// policy: no send groups, `account_senders` gating, daily caps
|
|
9
|
-
//
|
|
8
|
+
// policy: no send groups, `account_senders` gating, daily caps or credit
|
|
9
|
+
// checks. It does model the send shapes (raw and account sends)
|
|
10
10
|
// and `idempotency_key`: a repeat with the same key, body and caller within
|
|
11
11
|
// 24 hours answers the first send with `replayed: true` and adds no row; the
|
|
12
12
|
// same key with another body or caller is `409 idempotency_key_reused`. An
|
|
13
13
|
// account send to `"self"` logs `recipients: []`, one to `"owner"` logs a
|
|
14
14
|
// stand-in owner address — the fake has no real account emails. Writes
|
|
15
15
|
// need a signed-in user; a signed-out write and a missing account or
|
|
16
|
-
// template answer the uniform bare 404.
|
|
16
|
+
// template answer the uniform bare 404. Accounts carry `reply_to`, set on
|
|
17
|
+
// create and by `set_reply_to` (key required, a string or `null`); the fake
|
|
18
|
+
// checks only its type, not the address rules or refused domains. Reached
|
|
19
|
+
// only from the
|
|
17
20
|
// `isMockMode()` branch in index.ts, so a production build carries none of
|
|
18
21
|
// it.
|
|
19
22
|
const MARKER = "pilely-mock-fake:simple-email";
|
|
@@ -48,6 +51,8 @@ function accountPayload(account) {
|
|
|
48
51
|
send_group: account.send_group,
|
|
49
52
|
// State persisted before account_senders existed reads the default.
|
|
50
53
|
account_senders: account.account_senders ?? "send_group",
|
|
54
|
+
// State persisted before reply_to existed reads none.
|
|
55
|
+
reply_to: account.reply_to ?? null,
|
|
51
56
|
created_time_stamp: account.created_time_stamp,
|
|
52
57
|
};
|
|
53
58
|
}
|
|
@@ -64,6 +69,16 @@ function parseAccountSenders(body) {
|
|
|
64
69
|
const value = body.account_senders;
|
|
65
70
|
return ACCOUNT_SENDERS.includes(value) ? value : null;
|
|
66
71
|
}
|
|
72
|
+
/** `undefined` when the key is absent; the address, or `null` (none). A value
|
|
73
|
+
* of any other type is the server's 400, answered as `false`. */
|
|
74
|
+
function parseReplyTo(body) {
|
|
75
|
+
if (!("reply_to" in body))
|
|
76
|
+
return undefined;
|
|
77
|
+
const value = body.reply_to;
|
|
78
|
+
if (value === null || typeof value === "string")
|
|
79
|
+
return value;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
67
82
|
/** The service's "same body": the caller, the mode, `to` as a
|
|
68
83
|
* case-insensitive set, `to_account`, the template and its variables, and
|
|
69
84
|
* inline content. */
|
|
@@ -128,13 +143,14 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
128
143
|
function findAccount(appId) {
|
|
129
144
|
return state.accounts.find((a) => a.app_id === appId);
|
|
130
145
|
}
|
|
131
|
-
function createAccount(appId, sendGroup, accountSenders) {
|
|
146
|
+
function createAccount(appId, sendGroup, accountSenders, replyTo) {
|
|
132
147
|
const account = {
|
|
133
148
|
app_id: appId,
|
|
134
149
|
address: `${appId}@${SENDING_DOMAIN}`,
|
|
135
150
|
display_name: null,
|
|
136
151
|
send_group: sendGroup,
|
|
137
152
|
account_senders: accountSenders,
|
|
153
|
+
reply_to: replyTo,
|
|
138
154
|
created_time_stamp: ctx.now(),
|
|
139
155
|
templates: [],
|
|
140
156
|
};
|
|
@@ -177,7 +193,7 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
177
193
|
if (key !== undefined && (typeof key !== "string" || !IDEMPOTENCY_KEY.test(key))) {
|
|
178
194
|
return ctx.refuse(400, "bad_request", "idempotency_key must be 1..=128 characters of [A-Za-z0-9._:-]");
|
|
179
195
|
}
|
|
180
|
-
const account = findAccount(body.app_id) ?? createAccount(body.app_id, null, "send_group");
|
|
196
|
+
const account = findAccount(body.app_id) ?? createAccount(body.app_id, null, "send_group", null);
|
|
181
197
|
const user = ctx.user();
|
|
182
198
|
const caller = user?.id ?? "mock-user";
|
|
183
199
|
const print = fingerprint(caller, body, to);
|
|
@@ -272,8 +288,12 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
272
288
|
if (accountSenders === null) {
|
|
273
289
|
return ctx.refuse(400, "bad_request", 'account_senders must be "send_group" or "app_users"');
|
|
274
290
|
}
|
|
291
|
+
const replyTo = parseReplyTo(body);
|
|
292
|
+
if (replyTo === false) {
|
|
293
|
+
return ctx.refuse(400, "bad_request", "reply_to must be an email address or null");
|
|
294
|
+
}
|
|
275
295
|
const sendGroup = typeof body.send_group === "string" ? body.send_group : null;
|
|
276
|
-
const account = createAccount(body.app_id, sendGroup, accountSenders ?? "send_group");
|
|
296
|
+
const account = createAccount(body.app_id, sendGroup, accountSenders ?? "send_group", replyTo ?? null);
|
|
277
297
|
save();
|
|
278
298
|
return ctx.ok({ ok: true, account: accountPayload(account) });
|
|
279
299
|
}
|
|
@@ -302,7 +322,6 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
302
322
|
daily_cap: DAILY_CAP,
|
|
303
323
|
sent_today: sentToday,
|
|
304
324
|
credit_blocked: false,
|
|
305
|
-
phone_verified: true,
|
|
306
325
|
});
|
|
307
326
|
}
|
|
308
327
|
if (route === "delete") {
|
|
@@ -328,6 +347,20 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
328
347
|
save();
|
|
329
348
|
return ctx.ok({ ok: true, account: accountPayload(account) });
|
|
330
349
|
}
|
|
350
|
+
if (route === "set_reply_to") {
|
|
351
|
+
if (!signedIn || !account)
|
|
352
|
+
return ctx.notFound();
|
|
353
|
+
const replyTo = parseReplyTo(body);
|
|
354
|
+
if (replyTo === undefined) {
|
|
355
|
+
return ctx.refuse(400, "bad_request", "reply_to is required (an email address, or null to clear it)");
|
|
356
|
+
}
|
|
357
|
+
if (replyTo === false) {
|
|
358
|
+
return ctx.refuse(400, "bad_request", "reply_to must be an email address or null");
|
|
359
|
+
}
|
|
360
|
+
account.reply_to = replyTo;
|
|
361
|
+
save();
|
|
362
|
+
return ctx.ok({ ok: true, account: accountPayload(account) });
|
|
363
|
+
}
|
|
331
364
|
if (route === "templates/create") {
|
|
332
365
|
if (!signedIn || !account)
|
|
333
366
|
return ctx.notFound();
|
package/dist/types.d.ts
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* refusal, including the two `idempotency_key_*` conflicts, checked
|
|
3
3
|
* against the service's `response_json.rs` and `render.rs`.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Status alone does not say what to do: `daily_cap_exceeded` and
|
|
6
|
+
* `rate_limited` BOTH answer 429, so a consumer cannot tell a spent UTC-day
|
|
7
|
+
* allowance (retry tomorrow) from a limiter policy (retry after
|
|
8
|
+
* `Retry-After`) by status alone — only by matching this code. */
|
|
9
9
|
export type SimpleEmailErrorCode = "unauthenticated" | "bad_forwarded_identity" | "bad_request" | "not_found" | "account_exists" | "account_limit_reached" | "template_limit_reached"
|
|
10
10
|
/** 409 — this `idempotency_key` already belongs to a different request
|
|
11
11
|
* (another body or another caller) on this app in the last 24 hours;
|
|
@@ -15,10 +15,18 @@ export type SimpleEmailErrorCode = "unauthenticated" | "bad_forwarded_identity"
|
|
|
15
15
|
* handed to the provider; retry in a few seconds. */
|
|
16
16
|
| "idempotency_key_in_progress"
|
|
17
17
|
/** 429 — the per-app UTC-day send cap. */
|
|
18
|
-
| "daily_cap_exceeded" | "
|
|
18
|
+
| "daily_cap_exceeded" | "out_of_traffic_credits"
|
|
19
19
|
/** 429 — over a `@simple_limiter` policy the app's owner set on this
|
|
20
20
|
* app's sends; root answers it with a `Retry-After` header. */
|
|
21
|
-
| "rate_limited"
|
|
21
|
+
| "rate_limited"
|
|
22
|
+
/** 403 — root's challenge check: a challenge policy the app's owner set
|
|
23
|
+
* on this route (for example `POST /send`) matches the call, and it
|
|
24
|
+
* carried no `challenge`. Nothing was sent. */
|
|
25
|
+
| "challenge_required"
|
|
26
|
+
/** 403 — root's challenge check refused the `challenge` sent: malformed,
|
|
27
|
+
* wrong, expired, started through another app, or already used. Get and
|
|
28
|
+
* solve a new one (`@pilely/simple-limiter`) and send the call again. */
|
|
29
|
+
| "challenge_failed" | "internal";
|
|
22
30
|
/** Who besides the owner may make account sends (`to_account`):
|
|
23
31
|
* `"send_group"` (the default) keeps them to the owner and the send group's
|
|
24
32
|
* members; `"app_users"` also lets every signed-in user arriving through the
|
|
@@ -35,10 +43,13 @@ export interface Account {
|
|
|
35
43
|
app_id: string;
|
|
36
44
|
address: string | null;
|
|
37
45
|
display_name: string | null;
|
|
38
|
-
/** `null` means owner-only
|
|
39
|
-
*
|
|
46
|
+
/** `null` means owner-only. Unlike a simple_db policy's `group`, it
|
|
47
|
+
* cannot name the platform-wide anonymous or authenticated group. */
|
|
40
48
|
send_group: string | null;
|
|
41
49
|
account_senders: AccountSenders;
|
|
50
|
+
/** The address recipients' replies go to: every send from the account
|
|
51
|
+
* carries it as its `Reply-To`. `null` means none — replies go nowhere. */
|
|
52
|
+
reply_to: string | null;
|
|
42
53
|
created_time_stamp: number;
|
|
43
54
|
}
|
|
44
55
|
/** `daily_cap`'s three siblings are nullable and sit BESIDE `account`, not
|
|
@@ -48,7 +59,6 @@ export interface AccountInfoEnvelope {
|
|
|
48
59
|
daily_cap: number;
|
|
49
60
|
sent_today: number | null;
|
|
50
61
|
credit_blocked: boolean | null;
|
|
51
|
-
phone_verified: boolean | null;
|
|
52
62
|
}
|
|
53
63
|
/** What `templates/create` and `templates/update` answer with — no `html`. */
|
|
54
64
|
export interface TemplateMeta {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pilely/simple-email",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "React hooks for the simple_email service.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"directory": "npm/simple-email"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
|
-
"@pilely/core": "^0.4.
|
|
24
|
+
"@pilely/core": "^0.4.4",
|
|
25
25
|
"@tanstack/react-query": "^5.0.0",
|
|
26
26
|
"react": "^18.3.0 || ^19.0.0"
|
|
27
27
|
},
|