@pilely/simple-email 0.4.3 → 0.5.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 +112 -62
- package/dist/api.d.ts +63 -101
- package/dist/api.js +61 -95
- package/dist/hooks.d.ts +45 -37
- package/dist/hooks.js +46 -58
- package/dist/index.d.ts +7 -4
- package/dist/index.js +7 -2
- package/dist/mock.d.ts +7 -0
- package/dist/mock.js +165 -202
- package/dist/send.d.ts +75 -0
- package/dist/send.js +94 -0
- package/dist/types.d.ts +32 -52
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -1,91 +1,135 @@
|
|
|
1
1
|
# `@pilely/simple-email`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The client for the `simple_email` service — the platform's managed transactional email. An email account has two ways to send, and this package has one entry for each:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- **`sendTemplate(templateId, variables)`**, and its hook `useSendTemplate()`, for a **page** of the app: the **template send**. It sends one of the account's templates, filled from the variables, to the ONE address the owner stored on that template. The caller supplies only the variables, so it can never point the mail at anyone else or change what it says. Any caller the template's groups admit can make it — signed-out visitors included.
|
|
6
|
+
- **`send({ to, ... })`** for the app's **backend only**: the **freestyle send**, to any addresses, from a template or inline. Only the account's app's own backend, calling with its backend token, is admitted; a page, a signed-in user and you, the owner, get the uniform 404. It needs no browser runtime: a server imports it from `@pilely/simple-email/send`.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
Beside them, a query hook for every read and a mutation hook for every write of the account and its templates, for owner tooling. Render the hooks inside `@pilely/core`'s `<PilelyProvider>`.
|
|
9
|
+
|
|
10
|
+
**Moving from `0.4.x`:** `0.5.0` removes `useSend()`, `useSends()` and `useSetEmailAccountAccess()` — the service no longer has the routes they call, so a `0.4.x` client's sends and outbox fail — and accounts carry no send access of their own any more: who may send is each template's groups, and your app's backend. Templates gain `recipient`, `send_group` and `deny_group`. `create-pilely-app` `0.4.3` still scaffolds `"@pilely/simple-email": "^0.4.0"`, whose `useSend` / `useSends` call those removed routes: in a scaffolded app, move the range to `^0.5.0`.
|
|
11
|
+
|
|
12
|
+
## Hooks and functions
|
|
13
|
+
|
|
14
|
+
| Export | Wraps | Kind |
|
|
8
15
|
| --- | --- | --- |
|
|
9
|
-
| `
|
|
10
|
-
| `
|
|
11
|
-
| `
|
|
16
|
+
| `useSendTemplate()` | `accounts/{app}/templates/{t}/send` | mutation, `{ templateId, variables? }`; answers `{ send_id }`, never the address |
|
|
17
|
+
| `sendTemplate(templateId, variables?, { challenge }?)` | `accounts/{app}/templates/{t}/send` | the same template send as a plain function |
|
|
18
|
+
| `send(input, { appId, backendToken, origin? })` | `accounts/{app}/send` | the freestyle send, backend only; `input` is `{ to, subject, html }` or `{ to, template_id, variables? }`, each with an optional `idempotency_key`. Answers `{ send_id, recipient_count, replayed }` |
|
|
19
|
+
| `useEmailAccount()` | `accounts/{app}/info` | query; this app's account with its daily cap, sent today and credit state |
|
|
12
20
|
| `useEmailAccounts({ limit? })` | `accounts/list`, paged | infinite query; every account the caller owns |
|
|
13
|
-
| `useCreateEmailAccount()` | `accounts/create` | mutation,
|
|
21
|
+
| `useCreateEmailAccount()` | `accounts/create` | mutation, no variables, or `{ reply_to? }` |
|
|
14
22
|
| `useDeleteEmailAccount()` | `accounts/{app}/delete` | mutation, no variables |
|
|
15
|
-
| `useSetEmailAccountAccess()` | `accounts/{app}/access/set` | mutation, the send group (`null` = owner-only), or `{ send_group, account_senders? }` (an omitted `account_senders` keeps the current value) |
|
|
16
23
|
| `useSetEmailReplyTo()` | `accounts/{app}/set_reply_to` | mutation, the reply-to address, or `null` to clear it; always sent, never "keep" |
|
|
17
24
|
| `useTemplates({ limit? })` | `templates/list`, paged | infinite query; metadata only |
|
|
18
25
|
| `useTemplate(templateId)` | `templates/{t}/info` | query; the template with its `html`; an `undefined` id disables it |
|
|
19
|
-
| `useCreateTemplate()` | `templates/create` | mutation, `{ name, subject, html }` (`name` may be `null`) |
|
|
20
|
-
| `useUpdateTemplate()` | `templates/{t}/update` | mutation, `{ templateId, subject, html }` — a full replace |
|
|
26
|
+
| `useCreateTemplate()` | `templates/create` | mutation, `{ name, subject, html, recipient, send_group?, deny_group? }` (`name` may be `null`; `recipient` is required) |
|
|
27
|
+
| `useUpdateTemplate()` | `templates/{t}/update` | mutation, `{ templateId, subject, html, recipient?, send_group?, deny_group? }` — `subject` and `html` a full replace; an omitted `recipient` or group keeps the stored value, `null` empties a group |
|
|
21
28
|
| `useDeleteTemplate()` | `templates/{t}/delete` | mutation, the template id |
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
On a page the app id is added from `@pilely/core` on every route; it is never a variable. `send()` runs where there is no page, so it takes the app id and the backend token explicitly. Every write waits for the server: an email "sent" before the server accepted it would be a lie.
|
|
24
31
|
|
|
25
|
-
|
|
32
|
+
Every template answer (`TemplateMeta`, `Template`) carries `recipient` (the stored address), `send_group` and `deny_group` (group ids, or `null` for an empty group). An `Account` carries `app_id`, `address`, `display_name`, `reply_to` and `created_time_stamp`.
|
|
26
33
|
|
|
27
|
-
|
|
34
|
+
## The template send, from a page
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
```tsx
|
|
37
|
+
import { useSendTemplate } from "@pilely/simple-email";
|
|
30
38
|
|
|
31
|
-
|
|
39
|
+
// CONTACT is a template of this app's account whose recipient is your own
|
|
40
|
+
// address and whose send_group admits the people who use this form.
|
|
41
|
+
export function ContactForm() {
|
|
42
|
+
const sendTemplate = useSendTemplate();
|
|
43
|
+
return (
|
|
44
|
+
<form
|
|
45
|
+
onSubmit={(e) => {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
const form = new FormData(e.currentTarget);
|
|
48
|
+
sendTemplate.mutate({
|
|
49
|
+
templateId: CONTACT,
|
|
50
|
+
variables: { name: String(form.get("name")), message: String(form.get("message")) },
|
|
51
|
+
});
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<input name="name" />
|
|
55
|
+
<textarea name="message" />
|
|
56
|
+
<button disabled={sendTemplate.isPending}>Send</button>
|
|
57
|
+
{sendTemplate.isSuccess && <p>Thanks — your message was sent.</p>}
|
|
58
|
+
{sendTemplate.error && <p role="alert">{sendTemplate.error.message}</p>}
|
|
59
|
+
</form>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`variables` holds one string per `{{placeholder}}` of the template's subject and html, and it is the whole request body: every other field the service ignores, so a caller can never add a `to`, a `subject` or an `html`. A missing (or non-string) variable is `bad_request` (400) and nothing is sent. The answer is `{ send_id }`; no answer of this route, success or error, carries the stored address. A template send counts 1 recipient toward the app's daily send cap.
|
|
65
|
+
|
|
66
|
+
**Who may send it is the template's two groups.** Through the app's own page, a caller is admitted exactly when it is a member of the template's `send_group` and not a member of its `deny_group`. A refused caller, an unknown template and an exhausted owner balance all get the same `not_found` (404). An empty `send_group` admits nobody: a template cannot be sent this way until you set one.
|
|
67
|
+
|
|
68
|
+
**Setting up the groups** with `@pilely/simple-group` (`https://simple-group.pilely.app/README.md`), then storing the ids on the template with `useCreateTemplate()` or `useUpdateTemplate()`:
|
|
69
|
+
|
|
70
|
+
- every signed-out visitor of a public app: `send_group` = the platform-wide anonymous group, from `anonymousGroupId()`;
|
|
71
|
+
- every signed-in user: `send_group` = the platform-wide authenticated group, from `authenticatedGroupId()`;
|
|
72
|
+
- chosen people, or your app's backend: an owner group of your own (`useCreateGroup()`), with them added as members;
|
|
73
|
+
- people to refuse: an owner group of your own as the `deny_group` (a platform-wide group is never a `deny_group`).
|
|
74
|
+
|
|
75
|
+
**Cap how often callers send it with a `@simple_limiter` policy** (`https://simple-limiter.pilely.app/README.md` → `### Limiting simple_email sends`): `host` `simple-email.pilely.app`, your app's id, `POST`, path `/accounts/<app id>/templates/*/send` (or one template's id in place of `*`), keyed by `ip` for signed-out visitors or `user` for signed-in users — for example a few sends per hour. Over it, root answers `rate_limited` (429) with a `Retry-After`. The service sets no per-caller limit of its own: without a policy, one caller can use up the app's daily cap and spend your balance. A template open to visitors is a form anyone can submit.
|
|
76
|
+
|
|
77
|
+
**You pay for every send.** Every send, template or freestyle, whoever caused it — a visitor, a signed-in user, your backend — is billed to the account's owner, never to the caller.
|
|
78
|
+
|
|
79
|
+
## The freestyle send, from your backend
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { send } from "@pilely/simple-email/send";
|
|
83
|
+
|
|
84
|
+
await send(
|
|
85
|
+
{ to: [row.email], template_id: WELCOME, variables: { name: row.name }, idempotency_key: `welcome:${row.id}` },
|
|
86
|
+
{ appId: APP_ID, backendToken: process.env.PILELY_BACKEND_TOKEN! },
|
|
87
|
+
);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`send()` is for a server that holds the app's backend token — **never call it from a page**: a page has no backend token, and the service answers every caller but the app's own backend with the uniform 404. It imports nothing at runtime (no `@pilely/core`, no React, no `window.pilely`) and makes one plain `fetch` to `https://simple-email.pilely.app/accounts/<appId>/send` with the token as `Authorization: Bearer`; pass `origin` for another host. It is also exported from the main entry. A `@simple_server` box runs Rust, not JavaScript: there, make the same `POST` with your HTTP client (`https://simple-email.pilely.app/README.md` → `### POST /accounts/{app_id}/send`).
|
|
91
|
+
|
|
92
|
+
`to` holds 1 to 50 addresses; the content is a template (`template_id`, with `variables` matching its placeholders exactly) or inline (`subject` + `html`), never both. A refusal throws `SendError` with `status`, `code` (a `SimpleEmailErrorCode`, or `null` for a bodiless answer) and `reason`; it never carries the token.
|
|
93
|
+
|
|
94
|
+
Who may cause a freestyle send is your backend's own code — no group is involved. So is how often it sends: no `@simple_limiter` policy counts your app's backend, and the service's only bound is the freestyle cap, 500 recipients per app per UTC day (`daily_cap_exceeded`, 429), counted apart from the daily send cap. Before mailing an address a visitor typed, send at most one fixed-template confirmation with an `idempotency_key` tied to that address's row.
|
|
32
95
|
|
|
33
|
-
|
|
96
|
+
### Retries: `idempotency_key`
|
|
34
97
|
|
|
35
|
-
|
|
98
|
+
Give a freestyle 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 and the same body answers the FIRST send's `send_id` with `replayed: true` — nothing is sent, billed or counted again. The same key with another body 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`. The template send takes no key: every accepted call is a new email.
|
|
36
99
|
|
|
37
|
-
|
|
100
|
+
## Bot checks: `{ challenge }`
|
|
101
|
+
|
|
102
|
+
The app's owner can put a **challenge policy** on any route of `simple-email.pilely.app` for the app, typically the template send's path `/accounts/<app id>/templates/*/send` (`https://simple-limiter.pilely.app/README.md`) — never the freestyle send's `/accounts/<app id>/send`, whose backend caller cannot solve one, so such a policy would refuse every backend send; root then refuses every matching call through the app that carries no solved bot check with `403 challenge_required`, before the service sees it. Every hook and `sendTemplate` take a `challenge` option for that, in its options object or as its last argument where it has none (`useSendTemplate({ challenge })`, `useTemplate(templateId, { challenge })`):
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
import { getChallenge, solveChallenge } from "@pilely/simple-limiter";
|
|
106
|
+
|
|
107
|
+
const sendTemplate = useSendTemplate({ challenge: async () => solveChallenge(await getChallenge()) });
|
|
108
|
+
await sendTemplate.mutateAsync({ templateId, variables }); // a fresh answer per send
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
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. `send()` takes no `challenge`: a challenge is solved in a browser.
|
|
112
|
+
|
|
113
|
+
## Replies: `reply_to`
|
|
114
|
+
|
|
115
|
+
An account's `reply_to` is the address recipients' replies go to: every send from the account — template and freestyle — 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.
|
|
38
116
|
|
|
39
117
|
## What each write refreshes
|
|
40
118
|
|
|
41
119
|
| Write | Invalidates |
|
|
42
120
|
| --- | --- |
|
|
43
|
-
| send | `
|
|
44
|
-
| account create / set
|
|
121
|
+
| template send | `useEmailAccount()` (its `sent_today`) |
|
|
122
|
+
| account create / set reply-to | `useEmailAccounts()`, `useEmailAccount()` |
|
|
45
123
|
| account delete | `useEmailAccounts()`, `useEmailAccount()`, and the templates under it |
|
|
46
124
|
| template create | `useTemplates()` |
|
|
47
125
|
| template update / delete on `t` | `useTemplates()`, `useTemplate(t)` |
|
|
48
126
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
```tsx
|
|
52
|
-
import { useSend, useSends } from "@pilely/simple-email";
|
|
53
|
-
|
|
54
|
-
export function Outbox() {
|
|
55
|
-
const { data: sends } = useSends();
|
|
56
|
-
const send = useSend();
|
|
57
|
-
return (
|
|
58
|
-
<>
|
|
59
|
-
<button onClick={() => send.mutate({ to: ["friend@example.com"], subject: "Hi", html: "<p>Hello</p>" })}>
|
|
60
|
-
Send
|
|
61
|
-
</button>
|
|
62
|
-
{send.error && <p role="alert">{send.error.message}</p>}
|
|
63
|
-
<ul>
|
|
64
|
-
{sends?.map((row) => (
|
|
65
|
-
<li key={row.send_id}>
|
|
66
|
-
{row.subject} — {row.outcome}
|
|
67
|
-
{row.to_account && ` (to the ${row.to_account} account email)`}
|
|
68
|
-
</li>
|
|
69
|
-
))}
|
|
70
|
-
</ul>
|
|
71
|
-
</>
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// After a row is stored: confirm it to the signed-in submitter, once, even if
|
|
76
|
-
// the page retries. `templateId` is a template of this app's account.
|
|
77
|
-
export function useConfirmSubmission(templateId: string) {
|
|
78
|
-
const send = useSend();
|
|
79
|
-
return (rowId: string) =>
|
|
80
|
-
send.mutateAsync({ to_account: "self", template_id: templateId, idempotency_key: `confirm:${rowId}` });
|
|
81
|
-
}
|
|
82
|
-
```
|
|
127
|
+
`send()` is not a hook and touches no cache.
|
|
83
128
|
|
|
84
129
|
## Query keys
|
|
85
130
|
|
|
86
131
|
| Key | Query |
|
|
87
132
|
| --- | --- |
|
|
88
|
-
| `["pilely", "simple-email", "sends", { limit }]` | `useSends` |
|
|
89
133
|
| `["pilely", "simple-email", "account"]` | `useEmailAccount` |
|
|
90
134
|
| `["pilely", "simple-email", "accounts", { limit }]` | `useEmailAccounts` |
|
|
91
135
|
| `["pilely", "simple-email", "templates", { limit }]` | `useTemplates` |
|
|
@@ -99,19 +143,25 @@ export function useConfirmSubmission(templateId: string) {
|
|
|
99
143
|
|
|
100
144
|
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.
|
|
101
145
|
|
|
102
|
-
**Nothing is ever sent.** A send is appended to an in-memory outbox
|
|
146
|
+
**Nothing is ever sent.** A template send is appended to an in-memory outbox, addressed to the template's stored recipient, and logged to the console; its answer is `{ send_id }`, never the address. `useEmailAccount()`'s `sent_today` counts those sends. Accounts and templates persist to `localStorage` across reloads. A missing account or template answers the bare 404, and a second account for the same app answers `account_exists` (409).
|
|
147
|
+
|
|
148
|
+
The template send admits callers by the template's groups as the `@pilely/simple-db` fake admits by policies: the two platform-wide groups hold callers by type — every signed-out visitor is in the anonymous group, every signed-in person in the authenticated group — under the same fixed test ids `@pilely/simple-group`'s fake answers from `anonymousGroupId()` / `authenticatedGroupId()`. The fake has no group membership data, so **any other group holds nobody**: an owner group as `send_group` admits no one and a `deny_group` denies no one — test those against the real service. An empty `send_group` admits nobody. Each placeholder is read from the variables; a missing or non-string one is `bad_request`, and every other field is ignored.
|
|
103
149
|
|
|
104
|
-
|
|
150
|
+
The freestyle route answers the bare 404 to every caller, since no caller in a browser is the app's backend. `send()` never reaches the fake: it is a server function that calls `fetch` directly.
|
|
105
151
|
|
|
106
|
-
|
|
152
|
+
Templates carry `recipient` (required on create; `null` on update is `bad_request`), `send_group` and `deny_group` (a platform-wide group as `deny_group` is `bad_request`); accounts carry `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 fake checks the fields' shapes, not the address rules, refused domains or group ownership. It does not model the daily caps, rate limits or credit checks.
|
|
153
|
+
|
|
154
|
+
Owner 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()`.
|
|
107
155
|
|
|
108
156
|
## Error codes
|
|
109
157
|
|
|
110
|
-
Refusals arrive as `PilelyError` in the hook's `error
|
|
158
|
+
Refusals arrive as `PilelyError` in the hook's `error` (as `SendError` from `send()`), 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 a UTC-day allowance (retry tomorrow): the daily send cap on a template send, the 500-recipient freestyle cap on `send()`. `rate_limited` means the caller is over a `@simple_limiter` policy the app's owner set on the template send; 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.
|
|
159
|
+
|
|
160
|
+
Both send routes answer an exhausted owner balance with the uniform `not_found` (404), so billing state never leaks to a caller; the owner routes answer it `out_of_traffic_credits` (503) — 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).
|
|
111
161
|
|
|
112
|
-
A
|
|
162
|
+
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`.
|
|
113
163
|
|
|
114
|
-
Limits: billed per recipient
|
|
164
|
+
Limits: billed per recipient. A template send has exactly 1 recipient and counts toward the daily send cap, 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. A freestyle send has 1 to 50 recipients and counts toward the freestyle cap, 500 recipients per app per UTC day, never the daily send cap.
|
|
115
165
|
|
|
116
166
|
## Install
|
|
117
167
|
|
|
@@ -119,4 +169,4 @@ Limits: billed per recipient, at most 50 recipients per send (an account send ha
|
|
|
119
169
|
npm install @pilely/simple-email @pilely/core @tanstack/react-query
|
|
120
170
|
```
|
|
121
171
|
|
|
122
|
-
`@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.
|
|
172
|
+
`@pilely/core`, `react` (`^18.3 || ^19`) and `@tanstack/react-query` (`^5`) are peer dependencies of the main entry; the `@pilely/simple-email/send` subpath loads none of them. 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
CHANGED
|
@@ -1,58 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
export type SendContent = {
|
|
4
|
-
template_id: string;
|
|
5
|
-
variables?: Record<string, string>;
|
|
6
|
-
} | {
|
|
7
|
-
subject: string;
|
|
8
|
-
html: string;
|
|
9
|
-
};
|
|
10
|
-
/**
|
|
11
|
-
* Optional on every send shape: 1–128 characters of `[A-Za-z0-9._:-]`, held
|
|
12
|
-
* per app for 24 hours from its first use. A repeat with the same key, the
|
|
13
|
-
* same body and the same caller answers the first send's `send_id` with
|
|
14
|
-
* `replayed: true` and sends and bills nothing; the same key with another
|
|
15
|
-
* body or caller is `409 idempotency_key_reused`. Pick keys stable per
|
|
16
|
-
* logical email (`digest:<user id>:<YYYY-MM-DD>`, `confirm:<row id>`) — a
|
|
17
|
-
* new key per attempt defeats the protection.
|
|
18
|
-
*/
|
|
19
|
-
export interface SendKey {
|
|
20
|
-
idempotency_key?: string;
|
|
21
|
-
}
|
|
22
|
-
/** A raw send: to the addresses in `to`, template or inline content. */
|
|
23
|
-
export type RawSendInput = {
|
|
24
|
-
to: string[];
|
|
25
|
-
to_account?: never;
|
|
26
|
-
} & SendContent & SendKey;
|
|
27
|
-
/**
|
|
28
|
-
* An account send: a template to ONE address the service looks up itself —
|
|
29
|
-
* the caller's own account email (`"self"`) or the account owner's
|
|
30
|
-
* (`"owner"`). No `to`, and template content only: `subject` / `html` are a
|
|
31
|
-
* 400 on the server, so they do not typecheck here.
|
|
32
|
-
*/
|
|
33
|
-
export type AccountSendInput = {
|
|
34
|
-
to_account: ToAccount;
|
|
35
|
-
to?: never;
|
|
36
|
-
template_id: string;
|
|
37
|
-
variables?: Record<string, string>;
|
|
38
|
-
subject?: never;
|
|
39
|
-
html?: never;
|
|
40
|
-
} & SendKey;
|
|
41
|
-
/** Exactly one of `to` (a raw send) or `to_account` (an account send). */
|
|
42
|
-
export type SendInput = RawSendInput | AccountSendInput;
|
|
43
|
-
/** `replayed` is `true` when the answer is an earlier keyed send's (nothing
|
|
44
|
-
* new was sent), `false` for a new send — and always `false` without an
|
|
45
|
-
* `idempotency_key`. An account send's answer never carries an address. */
|
|
46
|
-
export interface SendResult {
|
|
47
|
-
send_id: string;
|
|
48
|
-
recipient_count: number;
|
|
49
|
-
replayed: boolean;
|
|
50
|
-
}
|
|
51
|
-
export interface ListSendsOptions {
|
|
52
|
-
limit?: number;
|
|
53
|
-
after_created_time_stamp?: number;
|
|
54
|
-
after_send_id?: string;
|
|
55
|
-
}
|
|
1
|
+
import type { ChallengeOption } from "@pilely/core";
|
|
2
|
+
import type { Account, AccountCursor, AccountInfoEnvelope, Template, TemplateCursor, TemplateMeta } from "./types.js";
|
|
56
3
|
export interface ListAccountsOptions {
|
|
57
4
|
limit?: number;
|
|
58
5
|
after_created_time_stamp?: number;
|
|
@@ -63,78 +10,93 @@ export interface ListTemplatesOptions {
|
|
|
63
10
|
after_created_time_stamp?: number;
|
|
64
11
|
after_template_id?: string;
|
|
65
12
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
13
|
+
/** The template send's answer. Never the template's recipient. */
|
|
14
|
+
export interface TemplateSendResult {
|
|
15
|
+
send_id: string;
|
|
16
|
+
}
|
|
17
|
+
/** `recipient` is REQUIRED: the ONE address every template send of this
|
|
18
|
+
* template goes to — any address the owner likes, never verified. The two
|
|
19
|
+
* groups are optional; omitted or `null` is an empty group. `send_group`
|
|
20
|
+
* takes an owner group or one of the two platform-wide groups (anonymous,
|
|
21
|
+
* authenticated — `@pilely/simple-group`'s `anonymousGroupId()` /
|
|
22
|
+
* `authenticatedGroupId()`); `deny_group` takes an owner group only. */
|
|
23
|
+
export interface CreateTemplateBody {
|
|
24
|
+
/** `null` for a nameless template. */
|
|
25
|
+
name: string | null;
|
|
26
|
+
subject: string;
|
|
27
|
+
html: string;
|
|
28
|
+
recipient: string;
|
|
29
|
+
send_group?: string | null;
|
|
30
|
+
deny_group?: string | null;
|
|
31
|
+
}
|
|
32
|
+
/** `subject` and `html` are REQUIRED — the server rejects either one missing
|
|
33
|
+
* with a 400: the content is always a full replace. The other three are
|
|
34
|
+
* optional: an omitted key keeps the stored value, `null` empties a group,
|
|
35
|
+
* and `recipient` cannot be emptied (`null` is a 400, so it does not
|
|
36
|
+
* typecheck). */
|
|
69
37
|
export interface UpdateTemplateBody {
|
|
70
38
|
subject: string;
|
|
71
39
|
html: string;
|
|
40
|
+
recipient?: string;
|
|
41
|
+
send_group?: string | null;
|
|
42
|
+
deny_group?: string | null;
|
|
72
43
|
}
|
|
73
44
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
45
|
+
* The template send: `POST /accounts/{app_id}/templates/{template_id}/send`
|
|
46
|
+
* with the page's own app id. Sends that template, filled from `variables`,
|
|
47
|
+
* to the one recipient the owner stored on it — the caller can never point
|
|
48
|
+
* it anywhere else. `variables` is the body itself: each placeholder of the
|
|
49
|
+
* template's subject and html is read from the field of the same name, and
|
|
50
|
+
* every other field is ignored. A missing placeholder is a 400 and nothing
|
|
51
|
+
* is sent. The service admits the caller only when it is in the template's
|
|
52
|
+
* `send_group` and not in its `deny_group`; everyone else, an unknown
|
|
53
|
+
* template and an exhausted owner balance alike, gets the uniform 404.
|
|
79
54
|
*/
|
|
80
|
-
export declare function
|
|
81
|
-
/** `
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}>;
|
|
87
|
-
export declare function listAllSends(): Promise<SendRow[]>;
|
|
88
|
-
/** `sendGroup` is optional here — omit it (or pass `undefined`) and the
|
|
89
|
-
* key is left off the wire, meaning owner-only. `setAccountAccess` below
|
|
90
|
-
* is the opposite: its key is always required. `accountSenders` is
|
|
91
|
-
* optional too, and left off the wire when `undefined` (the server then
|
|
92
|
-
* uses `"send_group"`; it refuses an explicit `null` with a 400).
|
|
93
|
-
* `replyTo` is optional as well: `undefined` leaves the key off the wire,
|
|
94
|
-
* and omitted or `null` both mean no reply-to. */
|
|
95
|
-
export declare function createAccount(sendGroup?: string | null, accountSenders?: AccountSenders, replyTo?: string | null): Promise<Account>;
|
|
55
|
+
export declare function sendTemplate(templateId: string, variables?: Record<string, string>, { challenge }?: ChallengeOption): Promise<TemplateSendResult>;
|
|
56
|
+
/** `replyTo` is optional: `undefined` leaves the key off the wire, and
|
|
57
|
+
* omitted or `null` both mean no reply-to. The account carries no send
|
|
58
|
+
* access of its own — who may send is each template's groups, and the
|
|
59
|
+
* app's backend. */
|
|
60
|
+
export declare function createAccount(replyTo?: string | null, { challenge }?: ChallengeOption): Promise<Account>;
|
|
96
61
|
/** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
|
|
97
62
|
* not a selector. The paged primitive; use `listAllAccounts` to walk to
|
|
98
63
|
* the end. */
|
|
99
|
-
export declare function listAccounts(options?: ListAccountsOptions): Promise<{
|
|
64
|
+
export declare function listAccounts(options?: ListAccountsOptions, { challenge }?: ChallengeOption): Promise<{
|
|
100
65
|
accounts: Account[];
|
|
101
66
|
next_cursor: AccountCursor | null;
|
|
102
67
|
}>;
|
|
103
|
-
export declare function listAllAccounts(): Promise<Account[]>;
|
|
68
|
+
export declare function listAllAccounts(request?: ChallengeOption): Promise<Account[]>;
|
|
104
69
|
/** Returns the account plus its live operational state — the four extra
|
|
105
70
|
* fields are nullable siblings of `account`, not nested inside it. */
|
|
106
|
-
export declare function accountInfo(): Promise<AccountInfoEnvelope>;
|
|
107
|
-
export declare function deleteAccount(): Promise<void>;
|
|
108
|
-
/** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
|
|
109
|
-
* the opposite of `createAccount`, where the key is optional. `null`
|
|
110
|
-
* means owner-only. `accountSenders` is optional: `undefined` leaves the
|
|
111
|
-
* key off the wire, which KEEPS the account's current value. */
|
|
112
|
-
export declare function setAccountAccess(sendGroup: string | null, accountSenders?: AccountSenders): Promise<Account>;
|
|
71
|
+
export declare function accountInfo({ challenge }?: ChallengeOption): Promise<AccountInfoEnvelope>;
|
|
72
|
+
export declare function deleteAccount({ challenge }?: ChallengeOption): Promise<void>;
|
|
113
73
|
/** `replyTo` is REQUIRED and its key is always sent, even `null` — the
|
|
114
74
|
* server refuses a missing key with a 400, so a typo can never silently
|
|
115
75
|
* clear it. An address sets where recipients' replies go (every send from
|
|
116
76
|
* the account carries it as its `Reply-To`); `null` clears it. */
|
|
117
|
-
export declare function setReplyTo(replyTo: string | null): Promise<Account>;
|
|
77
|
+
export declare function setReplyTo(replyTo: string | null, { challenge }?: ChallengeOption): Promise<Account>;
|
|
118
78
|
/** Returns metadata only — no `html`. Use `templateInfo` to read the body
|
|
119
79
|
* back.
|
|
120
80
|
*
|
|
121
|
-
* `name` is nullable because the service accepts a nameless template
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
export declare function createTemplate(
|
|
81
|
+
* `name` is nullable because the service accepts a nameless template: pass
|
|
82
|
+
* an explicit `null`, and `TemplateMeta.name` comes back `null`. An
|
|
83
|
+
* omitted group is left off the wire (an empty group). */
|
|
84
|
+
export declare function createTemplate(body: CreateTemplateBody, { challenge }?: ChallengeOption): Promise<TemplateMeta>;
|
|
125
85
|
/** The paged primitive; use `listAllTemplates` to walk to the end. Rows
|
|
126
86
|
* carry metadata only — no `html`. */
|
|
127
|
-
export declare function listTemplates(options?: ListTemplatesOptions): Promise<{
|
|
87
|
+
export declare function listTemplates(options?: ListTemplatesOptions, { challenge }?: ChallengeOption): Promise<{
|
|
128
88
|
templates: TemplateMeta[];
|
|
129
89
|
next_cursor: TemplateCursor | null;
|
|
130
90
|
}>;
|
|
131
|
-
export declare function listAllTemplates(): Promise<TemplateMeta[]>;
|
|
91
|
+
export declare function listAllTemplates(request?: ChallengeOption): Promise<TemplateMeta[]>;
|
|
132
92
|
/** The only template route whose answer carries `html`. */
|
|
133
|
-
export declare function templateInfo(templateNanoid: string): Promise<Template>;
|
|
93
|
+
export declare function templateInfo(templateNanoid: string, { challenge }?: ChallengeOption): Promise<Template>;
|
|
134
94
|
/**
|
|
135
|
-
* A FULL REPLACE of both `subject` and `html`
|
|
136
|
-
*
|
|
137
|
-
*
|
|
95
|
+
* A FULL REPLACE of both `subject` and `html`; `recipient`, `send_group` and
|
|
96
|
+
* `deny_group` change only when their key is sent. `name` is not updatable
|
|
97
|
+
* at all. Returns metadata only, like `createTemplate`. An oversize body is
|
|
98
|
+
* rejected by the server before any handler runs, so the thrown
|
|
99
|
+
* `PilelyError` carries a null `code`.
|
|
138
100
|
*/
|
|
139
|
-
export declare function updateTemplate(templateNanoid: string, body: UpdateTemplateBody): Promise<TemplateMeta>;
|
|
140
|
-
export declare function deleteTemplate(templateNanoid: string): Promise<void>;
|
|
101
|
+
export declare function updateTemplate(templateNanoid: string, body: UpdateTemplateBody, { challenge }?: ChallengeOption): Promise<TemplateMeta>;
|
|
102
|
+
export declare function deleteTemplate(templateNanoid: string, { challenge }?: ChallengeOption): Promise<void>;
|