@pilely/simple-email 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -7
- package/dist/api.d.ts +49 -11
- package/dist/api.js +25 -9
- package/dist/hooks.d.ts +25 -12
- package/dist/hooks.js +13 -6
- package/dist/index.d.ts +3 -3
- package/dist/mock.js +114 -14
- package/dist/types.d.ts +37 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,13 +6,13 @@ React hooks for the `simple_email` service — the platform's managed transactio
|
|
|
6
6
|
|
|
7
7
|
| Hook | Wraps | Kind |
|
|
8
8
|
| --- | --- | --- |
|
|
9
|
-
| `useSend()` | `/send` | mutation, `{ to, subject, html }` or `{ to, template_id, variables? }` |
|
|
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
11
|
| `useEmailAccount()` | `accounts/{app}/info` | query; this app's account with its daily cap, sent today, credit and phone state |
|
|
12
12
|
| `useEmailAccounts({ limit? })` | `accounts/list`, paged | infinite query; every account the caller owns |
|
|
13
|
-
| `useCreateEmailAccount()` | `accounts/create` | mutation, the optional send group |
|
|
13
|
+
| `useCreateEmailAccount()` | `accounts/create` | mutation, the optional send group, or `{ send_group?, account_senders? }` |
|
|
14
14
|
| `useDeleteEmailAccount()` | `accounts/{app}/delete` | mutation, no variables |
|
|
15
|
-
| `useSetEmailAccountAccess()` | `accounts/{app}/access/set` | mutation, the send group (`null` = owner-only) |
|
|
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
16
|
| `useTemplates({ limit? })` | `templates/list`, paged | infinite query; metadata only |
|
|
17
17
|
| `useTemplate(templateId)` | `templates/{t}/info` | query; the template with its `html`; an `undefined` id disables it |
|
|
18
18
|
| `useCreateTemplate()` | `templates/create` | mutation, `{ name, subject, html }` (`name` may be `null`) |
|
|
@@ -21,6 +21,16 @@ React hooks for the `simple_email` service — the platform's managed transactio
|
|
|
21
21
|
|
|
22
22
|
The app id is added from `@pilely/core` on every route; it is never a variable. Every write waits for the server: an email "sent" before the server accepted it would be a lie.
|
|
23
23
|
|
|
24
|
+
## Raw sends and account sends
|
|
25
|
+
|
|
26
|
+
A **raw send** (`to`) goes to the addresses you name, with template or inline content. An **account send** (`to_account`) sends a template to one address the service looks up itself: the caller's own Pilely account email (`"self"`) or the app owner's (`"owner"`). The caller never supplies or sees that address — not in the answer, and not on the `"self"` row of the owner's `useSends()` (its `recipients` is `[]`). An account send is template-only: `to` with `to_account`, or `subject` / `html` on an account send, does not typecheck. It counts 1 recipient toward the daily cap and is billed to the owner like any other send.
|
|
27
|
+
|
|
28
|
+
Who may make account sends is the account's `account_senders`: `"send_group"` (the default — the owner and the send group's members) or `"app_users"` (also every signed-in user of the app, through the app; raw sends stay closed to them). When you choose `"app_users"`, add a per-user `@simple_limiter` policy on `simple-email.<endpoint>` `/send` — without it one user can use up the app's daily cap and spend your balance. The service README's `### Who can send from an account` has the full table.
|
|
29
|
+
|
|
30
|
+
## Retries: `idempotency_key`
|
|
31
|
+
|
|
32
|
+
Give a send an `idempotency_key` (1–128 characters of `[A-Za-z0-9._:-]`, held per app for 24 hours) and a repeat with the same key, the same body and the same caller answers the FIRST send's `send_id` with `replayed: true` — nothing is sent, billed or counted again. The same key with another body or from another caller is `idempotency_key_reused` (409); a repeat while the first is still being handed to the provider is `idempotency_key_in_progress` (409, retry in a few seconds). Pick keys stable per logical email — `confirm:<row id>`, `digest:<user id>:<YYYY-MM-DD>` — so a retry after a crash or a timeout repeats the same key. Without a key every request is a new send, and `replayed` is `false`.
|
|
33
|
+
|
|
24
34
|
## What each write refreshes
|
|
25
35
|
|
|
26
36
|
| Write | Invalidates |
|
|
@@ -45,10 +55,25 @@ export function Outbox() {
|
|
|
45
55
|
Send
|
|
46
56
|
</button>
|
|
47
57
|
{send.error && <p role="alert">{send.error.message}</p>}
|
|
48
|
-
<ul>
|
|
58
|
+
<ul>
|
|
59
|
+
{sends?.map((row) => (
|
|
60
|
+
<li key={row.send_id}>
|
|
61
|
+
{row.subject} — {row.outcome}
|
|
62
|
+
{row.to_account && ` (to the ${row.to_account} account email)`}
|
|
63
|
+
</li>
|
|
64
|
+
))}
|
|
65
|
+
</ul>
|
|
49
66
|
</>
|
|
50
67
|
);
|
|
51
68
|
}
|
|
69
|
+
|
|
70
|
+
// After a row is stored: confirm it to the signed-in submitter, once, even if
|
|
71
|
+
// the page retries. `templateId` is a template of this app's account.
|
|
72
|
+
export function useConfirmSubmission(templateId: string) {
|
|
73
|
+
const send = useSend();
|
|
74
|
+
return (rowId: string) =>
|
|
75
|
+
send.mutateAsync({ to_account: "self", template_id: templateId, idempotency_key: `confirm:${rowId}` });
|
|
76
|
+
}
|
|
52
77
|
```
|
|
53
78
|
|
|
54
79
|
## Query keys
|
|
@@ -69,17 +94,19 @@ export function Outbox() {
|
|
|
69
94
|
|
|
70
95
|
Importing this package in a build made with `VITE_PILELY_MOCK=1` registers an in-browser fake of `simple_email` behind `@pilely/core`'s mock runtime — nothing else to call.
|
|
71
96
|
|
|
72
|
-
**Nothing is ever sent.** A send is appended to an in-memory outbox — the rows `useSends` answers — and logged to the console. Accounts and templates persist to `localStorage` across reloads; the app's account is created on its first send. A missing account or template answers the bare 404, and a second account for the same app answers `account_exists` (409). The 50-recipient bound is kept.
|
|
97
|
+
**Nothing is ever sent.** A send is appended to an in-memory outbox — the rows `useSends` answers — and logged to the console. Accounts and templates persist to `localStorage` across reloads; the app's account is created on its first send. A missing account or template answers the bare 404, and a second account for the same app answers `account_exists` (409). The 50-recipient bound is kept.
|
|
98
|
+
|
|
99
|
+
It models the send shapes and `idempotency_key`: an account send's row carries `to_account` and one recipient (`recipients: []` for `"self"`, a stand-in owner address for `"owner"` — the fake has no real account emails); a body with both `to` and `to_account`, or an account send with inline content, is `bad_request`; a repeat with the same key, body and caller answers `replayed: true` without a new row, and the same key with another body is `idempotency_key_reused`. Accounts carry `account_senders`, settable on create and access/set. It does not model send groups, `account_senders` gating, the daily cap, the phone gate, rate limits, credit checks or `idempotency_key_in_progress`.
|
|
73
100
|
|
|
74
101
|
Writes need a signed-in user: a signed-out write answers the uniform bare 404, which reaches the hook's `error` as `PilelyError(404, null)`. See [`@pilely/core`](../core#mock-mode) for the switch, `seedMock()` and `resetMock()`.
|
|
75
102
|
|
|
76
103
|
## Error codes
|
|
77
104
|
|
|
78
|
-
Refusals arrive as `PilelyError` in the hook's `error`, with a `code` drawn from `SimpleEmailErrorCode`. Match on the code: **`
|
|
105
|
+
Refusals arrive as `PilelyError` in the hook's `error`, with a `code` drawn from `SimpleEmailErrorCode`. Match on the code, not the status: **`out_of_traffic_credits` and `phone_verification_unavailable` both answer 503** — one means the owner's traffic balance is used up (the owner must top up), the other is transient (retry shortly). `daily_cap_exceeded` (429) means the app spent its UTC-day allowance (retry tomorrow). `rate_limited` (429) means the caller is over a `@simple_limiter` policy the app's owner set on this app's sends; root answers it with a `Retry-After` header (retry after that many seconds). `idempotency_key_reused` (409) means the key already belongs to a different request on this app in the last 24 hours — nothing was sent, use a new key for a different email; `idempotency_key_in_progress` (409) means the first request with that key is still being handed to the provider — retry in a few seconds with the same key.
|
|
79
106
|
|
|
80
107
|
A send can also answer `phone_verification_required` (403), `phone_verification_unavailable` (503) and `out_of_traffic_credits` (503). Provisioning adds `account_exists`, `account_limit_reached` and `template_limit_reached` (all 409). The rest: `not_found` (404, the uniform hide), `bad_request` (400), `unauthenticated` (401), `bad_forwarded_identity` (401), `internal` (500). An oversize send or template body is refused before any handler runs, so its `code` is `null`.
|
|
81
108
|
|
|
82
|
-
Limits: billed per recipient, at most 50 recipients per send, 200
|
|
109
|
+
Limits: billed per recipient, at most 50 recipients per send (an account send has exactly 1), 200 recipients per app per UTC day by default. `useEmailAccount()`'s `daily_cap` shows the cap in force for this app; for a higher one, open a support ticket on `@customer` naming the app, the cap you want and what the mail is.
|
|
83
110
|
|
|
84
111
|
## Install
|
|
85
112
|
|
package/dist/api.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Account, AccountCursor, AccountInfoEnvelope, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta } from "./types.js";
|
|
1
|
+
import type { Account, AccountCursor, AccountInfoEnvelope, AccountSenders, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta, ToAccount } from "./types.js";
|
|
2
2
|
/** `to` is capped at the service's `max_recipients_per_send` (50 by default). */
|
|
3
3
|
export type SendContent = {
|
|
4
4
|
template_id: string;
|
|
@@ -7,9 +7,47 @@ export type SendContent = {
|
|
|
7
7
|
subject: string;
|
|
8
8
|
html: string;
|
|
9
9
|
};
|
|
10
|
-
|
|
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 = {
|
|
11
24
|
to: string[];
|
|
12
|
-
|
|
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
|
+
}
|
|
13
51
|
export interface ListSendsOptions {
|
|
14
52
|
limit?: number;
|
|
15
53
|
after_created_time_stamp?: number;
|
|
@@ -39,10 +77,7 @@ export interface UpdateTemplateBody {
|
|
|
39
77
|
* the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
|
|
40
78
|
* carries a null `code`.
|
|
41
79
|
*/
|
|
42
|
-
export declare function send(input: SendInput): Promise<
|
|
43
|
-
send_id: string;
|
|
44
|
-
recipient_count: number;
|
|
45
|
-
}>;
|
|
80
|
+
export declare function send(input: SendInput): Promise<SendResult>;
|
|
46
81
|
/** `app_id` lives in the body here, like `send` — not in the path. The
|
|
47
82
|
* paged primitive; use `listAllSends` to walk to the end. */
|
|
48
83
|
export declare function listSends(options?: ListSendsOptions): Promise<{
|
|
@@ -52,8 +87,10 @@ export declare function listSends(options?: ListSendsOptions): Promise<{
|
|
|
52
87
|
export declare function listAllSends(): Promise<SendRow[]>;
|
|
53
88
|
/** `sendGroup` is optional here — omit it (or pass `undefined`) and the
|
|
54
89
|
* key is left off the wire, meaning owner-only. `setAccountAccess` below
|
|
55
|
-
* is the opposite: its key is always required.
|
|
56
|
-
|
|
90
|
+
* is the opposite: its key is always required. `accountSenders` is
|
|
91
|
+
* optional too, and left off the wire when `undefined` (the server then
|
|
92
|
+
* uses `"send_group"`; it refuses an explicit `null` with a 400). */
|
|
93
|
+
export declare function createAccount(sendGroup?: string | null, accountSenders?: AccountSenders): Promise<Account>;
|
|
57
94
|
/** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
|
|
58
95
|
* not a selector. The paged primitive; use `listAllAccounts` to walk to
|
|
59
96
|
* the end. */
|
|
@@ -68,8 +105,9 @@ export declare function accountInfo(): Promise<AccountInfoEnvelope>;
|
|
|
68
105
|
export declare function deleteAccount(): Promise<void>;
|
|
69
106
|
/** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
|
|
70
107
|
* the opposite of `createAccount`, where the key is optional. `null`
|
|
71
|
-
* means owner-only.
|
|
72
|
-
|
|
108
|
+
* means owner-only. `accountSenders` is optional: `undefined` leaves the
|
|
109
|
+
* key off the wire, which KEEPS the account's current value. */
|
|
110
|
+
export declare function setAccountAccess(sendGroup: string | null, accountSenders?: AccountSenders): Promise<Account>;
|
|
73
111
|
/** Returns metadata only — no `html`. Use `templateInfo` to read the body
|
|
74
112
|
* back.
|
|
75
113
|
*
|
package/dist/api.js
CHANGED
|
@@ -32,9 +32,13 @@ function requireAppId() {
|
|
|
32
32
|
* carries a null `code`.
|
|
33
33
|
*/
|
|
34
34
|
export async function send(input) {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
const app_id = requireAppId();
|
|
36
|
+
const idempotency_key = input.idempotency_key;
|
|
37
|
+
const body = input.to_account !== undefined
|
|
38
|
+
? { app_id, to_account: input.to_account, template_id: input.template_id, variables: input.variables, idempotency_key }
|
|
39
|
+
: "template_id" in input
|
|
40
|
+
? { app_id, to: input.to, template_id: input.template_id, variables: input.variables, idempotency_key }
|
|
41
|
+
: { app_id, to: input.to, subject: input.subject, html: input.html, idempotency_key };
|
|
38
42
|
return call({
|
|
39
43
|
service: "simple-email",
|
|
40
44
|
path: "/send",
|
|
@@ -65,12 +69,19 @@ export async function listAllSends() {
|
|
|
65
69
|
// ── Accounts ─────────────────────────────────────────────────────────────
|
|
66
70
|
/** `sendGroup` is optional here — omit it (or pass `undefined`) and the
|
|
67
71
|
* key is left off the wire, meaning owner-only. `setAccountAccess` below
|
|
68
|
-
* is the opposite: its key is always required.
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
* is the opposite: its key is always required. `accountSenders` is
|
|
73
|
+
* optional too, and left off the wire when `undefined` (the server then
|
|
74
|
+
* uses `"send_group"`; it refuses an explicit `null` with a 400). */
|
|
75
|
+
export async function createAccount(sendGroup, accountSenders) {
|
|
76
|
+
const body = {
|
|
77
|
+
app_id: requireAppId(),
|
|
78
|
+
};
|
|
71
79
|
if (sendGroup !== undefined) {
|
|
72
80
|
body.send_group = sendGroup;
|
|
73
81
|
}
|
|
82
|
+
if (accountSenders !== undefined) {
|
|
83
|
+
body.account_senders = accountSenders;
|
|
84
|
+
}
|
|
74
85
|
const json = await call({
|
|
75
86
|
service: "simple-email",
|
|
76
87
|
path: "/accounts/create",
|
|
@@ -119,12 +130,17 @@ export async function deleteAccount() {
|
|
|
119
130
|
}
|
|
120
131
|
/** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
|
|
121
132
|
* the opposite of `createAccount`, where the key is optional. `null`
|
|
122
|
-
* means owner-only.
|
|
123
|
-
|
|
133
|
+
* means owner-only. `accountSenders` is optional: `undefined` leaves the
|
|
134
|
+
* key off the wire, which KEEPS the account's current value. */
|
|
135
|
+
export async function setAccountAccess(sendGroup, accountSenders) {
|
|
136
|
+
const body = { send_group: sendGroup };
|
|
137
|
+
if (accountSenders !== undefined) {
|
|
138
|
+
body.account_senders = accountSenders;
|
|
139
|
+
}
|
|
124
140
|
const json = await call({
|
|
125
141
|
service: "simple-email",
|
|
126
142
|
path: `/accounts/${requireAppId()}/access/set`,
|
|
127
|
-
body
|
|
143
|
+
body,
|
|
128
144
|
});
|
|
129
145
|
return json.account;
|
|
130
146
|
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { UseInfiniteQueryResult, UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
|
2
|
-
import type { SendInput } from "./api.js";
|
|
3
|
-
import type { Account, AccountInfoEnvelope, SendRow, Template, TemplateMeta } from "./types.js";
|
|
2
|
+
import type { SendInput, SendResult } from "./api.js";
|
|
3
|
+
import type { Account, AccountInfoEnvelope, AccountSenders, SendRow, Template, TemplateMeta } from "./types.js";
|
|
4
4
|
export interface PageOptions {
|
|
5
5
|
/** Rows per page; the server defaults to 50 and caps at 100. */
|
|
6
6
|
limit?: number;
|
|
@@ -11,18 +11,30 @@ export interface CreateTemplateInput {
|
|
|
11
11
|
subject: string;
|
|
12
12
|
html: string;
|
|
13
13
|
}
|
|
14
|
+
/** `useCreateEmailAccount`'s object form. Both keys are optional: an omitted
|
|
15
|
+
* `send_group` means owner-only, an omitted `account_senders` means
|
|
16
|
+
* `"send_group"`. */
|
|
17
|
+
export interface CreateEmailAccountInput {
|
|
18
|
+
send_group?: string | null;
|
|
19
|
+
account_senders?: AccountSenders;
|
|
20
|
+
}
|
|
21
|
+
/** `useSetEmailAccountAccess`'s object form. `send_group` is required (`null`
|
|
22
|
+
* = owner-only); an omitted `account_senders` keeps the current value. */
|
|
23
|
+
export interface SetEmailAccountAccessInput {
|
|
24
|
+
send_group: string | null;
|
|
25
|
+
account_senders?: AccountSenders;
|
|
26
|
+
}
|
|
14
27
|
/** A full replace of both `subject` and `html`; `name` is not updatable. */
|
|
15
28
|
export interface UpdateTemplateInput {
|
|
16
29
|
templateId: string;
|
|
17
30
|
subject: string;
|
|
18
31
|
html: string;
|
|
19
32
|
}
|
|
20
|
-
/** Sends one email from this app's account
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}, Error, SendInput>;
|
|
33
|
+
/** Sends one email from this app's account: a raw send (`to`) or an account
|
|
34
|
+
* send (`to_account: "self" | "owner"`). Not optimistic: a send counts only
|
|
35
|
+
* once the server accepted it. A repeat carrying the same `idempotency_key`
|
|
36
|
+
* answers the first send with `replayed: true`. */
|
|
37
|
+
export declare function useSend(): UseMutationResult<SendResult, Error, SendInput>;
|
|
26
38
|
/** This app's outbox, newest first, as an infinite query. */
|
|
27
39
|
export declare function useSends(options?: PageOptions): UseInfiniteQueryResult<SendRow[], Error>;
|
|
28
40
|
/** The caller's email accounts across apps, as an infinite query. */
|
|
@@ -31,12 +43,13 @@ export declare function useEmailAccounts(options?: PageOptions): UseInfiniteQuer
|
|
|
31
43
|
* and phone verification. */
|
|
32
44
|
export declare function useEmailAccount(): UseQueryResult<AccountInfoEnvelope, Error>;
|
|
33
45
|
/** Creates this app's account; the variable is its optional send group
|
|
34
|
-
* (`null` or omitted: owner-only)
|
|
35
|
-
export declare function useCreateEmailAccount(): UseMutationResult<Account, Error, string | null | void>;
|
|
46
|
+
* (`null` or omitted: owner-only), or `{ send_group?, account_senders? }`. */
|
|
47
|
+
export declare function useCreateEmailAccount(): UseMutationResult<Account, Error, string | null | void | CreateEmailAccountInput>;
|
|
36
48
|
/** Deletes this app's account and the templates under it. */
|
|
37
49
|
export declare function useDeleteEmailAccount(): UseMutationResult<void, Error, void>;
|
|
38
|
-
/** Replaces this app's send group
|
|
39
|
-
|
|
50
|
+
/** Replaces this app's send group (`null` means owner-only); the object form
|
|
51
|
+
* `{ send_group, account_senders? }` also sets who may make account sends. */
|
|
52
|
+
export declare function useSetEmailAccountAccess(): UseMutationResult<Account, Error, string | null | SetEmailAccountAccessInput>;
|
|
40
53
|
/** This app's templates (metadata only), as an infinite query. */
|
|
41
54
|
export declare function useTemplates(options?: PageOptions): UseInfiniteQueryResult<TemplateMeta[], Error>;
|
|
42
55
|
/** One template with its `html`. An `undefined` id disables the query. */
|
package/dist/hooks.js
CHANGED
|
@@ -11,8 +11,10 @@ const templatesKey = () => serviceQueryKey("simple-email", "templates");
|
|
|
11
11
|
/** `["pilely", "simple-email", "template", templateId]` */
|
|
12
12
|
const templateKey = (templateId) => serviceQueryKey("simple-email", "template", templateId);
|
|
13
13
|
// ── Sending ──────────────────────────────────────────────────────────────
|
|
14
|
-
/** Sends one email from this app's account
|
|
15
|
-
*
|
|
14
|
+
/** Sends one email from this app's account: a raw send (`to`) or an account
|
|
15
|
+
* send (`to_account: "self" | "owner"`). Not optimistic: a send counts only
|
|
16
|
+
* once the server accepted it. A repeat carrying the same `idempotency_key`
|
|
17
|
+
* answers the first send with `replayed: true`. */
|
|
16
18
|
export function useSend() {
|
|
17
19
|
return useServiceMutation("useSend", {
|
|
18
20
|
mutationFn: send,
|
|
@@ -48,10 +50,12 @@ export function useEmailAccount() {
|
|
|
48
50
|
return useServiceQuery("useEmailAccount", { queryKey: accountKey(), queryFn: accountInfo });
|
|
49
51
|
}
|
|
50
52
|
/** Creates this app's account; the variable is its optional send group
|
|
51
|
-
* (`null` or omitted: owner-only)
|
|
53
|
+
* (`null` or omitted: owner-only), or `{ send_group?, account_senders? }`. */
|
|
52
54
|
export function useCreateEmailAccount() {
|
|
53
55
|
return useServiceMutation("useCreateEmailAccount", {
|
|
54
|
-
mutationFn: (
|
|
56
|
+
mutationFn: (input) => typeof input === "object" && input !== null
|
|
57
|
+
? createAccount(input.send_group, input.account_senders)
|
|
58
|
+
: createAccount(input ?? undefined),
|
|
55
59
|
invalidates: () => [accountsKey(), accountKey()],
|
|
56
60
|
});
|
|
57
61
|
}
|
|
@@ -62,10 +66,13 @@ export function useDeleteEmailAccount() {
|
|
|
62
66
|
invalidates: () => [accountsKey(), accountKey(), templatesKey(), serviceQueryKey("simple-email", "template")],
|
|
63
67
|
});
|
|
64
68
|
}
|
|
65
|
-
/** Replaces this app's send group
|
|
69
|
+
/** Replaces this app's send group (`null` means owner-only); the object form
|
|
70
|
+
* `{ send_group, account_senders? }` also sets who may make account sends. */
|
|
66
71
|
export function useSetEmailAccountAccess() {
|
|
67
72
|
return useServiceMutation("useSetEmailAccountAccess", {
|
|
68
|
-
mutationFn:
|
|
73
|
+
mutationFn: (input) => typeof input === "object" && input !== null
|
|
74
|
+
? setAccountAccess(input.send_group, input.account_senders)
|
|
75
|
+
: setAccountAccess(input),
|
|
69
76
|
invalidates: () => [accountsKey(), accountKey()],
|
|
70
77
|
});
|
|
71
78
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.js";
|
|
2
|
-
export type { CreateTemplateInput, PageOptions, UpdateTemplateInput } from "./hooks.js";
|
|
3
|
-
export type { SendContent, SendInput } from "./api.js";
|
|
4
|
-
export type { Account, SimpleEmailErrorCode, AccountCursor, AccountInfoEnvelope, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta, } from "./types.js";
|
|
2
|
+
export type { CreateEmailAccountInput, CreateTemplateInput, PageOptions, SetEmailAccountAccessInput, UpdateTemplateInput, } from "./hooks.js";
|
|
3
|
+
export type { AccountSendInput, RawSendInput, SendContent, SendInput, SendKey, SendResult } from "./api.js";
|
|
4
|
+
export type { Account, AccountSenders, SimpleEmailErrorCode, AccountCursor, AccountInfoEnvelope, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta, ToAccount, } from "./types.js";
|
package/dist/mock.js
CHANGED
|
@@ -5,7 +5,13 @@
|
|
|
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, daily caps, phone gates
|
|
8
|
+
// policy: no send groups, `account_senders` gating, daily caps, phone gates
|
|
9
|
+
// or credit checks. It does model the send shapes (raw and account sends)
|
|
10
|
+
// and `idempotency_key`: a repeat with the same key, body and caller within
|
|
11
|
+
// 24 hours answers the first send with `replayed: true` and adds no row; the
|
|
12
|
+
// same key with another body or caller is `409 idempotency_key_reused`. An
|
|
13
|
+
// account send to `"self"` logs `recipients: []`, one to `"owner"` logs a
|
|
14
|
+
// stand-in owner address — the fake has no real account emails. Writes
|
|
9
15
|
// need a signed-in user; a signed-out write and a missing account or
|
|
10
16
|
// template answer the uniform bare 404. Reached only from the
|
|
11
17
|
// `isMockMode()` branch in index.ts, so a production build carries none of
|
|
@@ -16,6 +22,11 @@ const MAX_PAGE_SIZE = 100;
|
|
|
16
22
|
const MAX_RECIPIENTS = 50;
|
|
17
23
|
const DAILY_CAP = 200;
|
|
18
24
|
const SENDING_DOMAIN = "mail.pilely.invalid";
|
|
25
|
+
const ACCOUNT_EMAIL_DOMAIN = "account.pilely.invalid";
|
|
26
|
+
const IDEMPOTENCY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
const IDEMPOTENCY_KEY = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
28
|
+
const ACCOUNT_SENDERS = ["send_group", "app_users"];
|
|
29
|
+
const TO_ACCOUNT = ["self", "owner"];
|
|
19
30
|
function isObject(value) {
|
|
20
31
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
32
|
}
|
|
@@ -35,9 +46,44 @@ function accountPayload(account) {
|
|
|
35
46
|
address: account.address,
|
|
36
47
|
display_name: account.display_name,
|
|
37
48
|
send_group: account.send_group,
|
|
49
|
+
// State persisted before account_senders existed reads the default.
|
|
50
|
+
account_senders: account.account_senders ?? "send_group",
|
|
38
51
|
created_time_stamp: account.created_time_stamp,
|
|
39
52
|
};
|
|
40
53
|
}
|
|
54
|
+
/** The wire row: the fake's own fields stripped, and the two newer keys
|
|
55
|
+
* defaulted for rows persisted before they existed. */
|
|
56
|
+
function sendPayload({ app_id: _appId, fingerprint: _fingerprint, ...row }) {
|
|
57
|
+
return { ...row, to_account: row.to_account ?? null, idempotency_key: row.idempotency_key ?? null };
|
|
58
|
+
}
|
|
59
|
+
/** `undefined` when the key is absent; `null` when it is present but not
|
|
60
|
+
* one of the two values (an explicit `null` included — the server's 400). */
|
|
61
|
+
function parseAccountSenders(body) {
|
|
62
|
+
if (!("account_senders" in body))
|
|
63
|
+
return undefined;
|
|
64
|
+
const value = body.account_senders;
|
|
65
|
+
return ACCOUNT_SENDERS.includes(value) ? value : null;
|
|
66
|
+
}
|
|
67
|
+
/** The service's "same body": the caller, the mode, `to` as a
|
|
68
|
+
* case-insensitive set, `to_account`, the template and its variables, and
|
|
69
|
+
* inline content. */
|
|
70
|
+
function fingerprint(caller, body, to) {
|
|
71
|
+
const variables = isObject(body.variables)
|
|
72
|
+
? Object.keys(body.variables)
|
|
73
|
+
.sort()
|
|
74
|
+
.map((name) => [name, body.variables[name]])
|
|
75
|
+
: null;
|
|
76
|
+
return JSON.stringify([
|
|
77
|
+
caller,
|
|
78
|
+
body.app_id,
|
|
79
|
+
to === null ? null : [...new Set(to.map((r) => r.toLowerCase()))].sort(),
|
|
80
|
+
body.to_account ?? null,
|
|
81
|
+
body.template_id ?? null,
|
|
82
|
+
variables,
|
|
83
|
+
body.subject ?? null,
|
|
84
|
+
body.html ?? null,
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
41
87
|
function templateMeta(template) {
|
|
42
88
|
return {
|
|
43
89
|
template_id: template.template_id,
|
|
@@ -82,12 +128,13 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
82
128
|
function findAccount(appId) {
|
|
83
129
|
return state.accounts.find((a) => a.app_id === appId);
|
|
84
130
|
}
|
|
85
|
-
function createAccount(appId, sendGroup) {
|
|
131
|
+
function createAccount(appId, sendGroup, accountSenders) {
|
|
86
132
|
const account = {
|
|
87
133
|
app_id: appId,
|
|
88
134
|
address: `${appId}@${SENDING_DOMAIN}`,
|
|
89
135
|
display_name: null,
|
|
90
136
|
send_group: sendGroup,
|
|
137
|
+
account_senders: accountSenders,
|
|
91
138
|
created_time_stamp: ctx.now(),
|
|
92
139
|
templates: [],
|
|
93
140
|
};
|
|
@@ -98,16 +145,52 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
98
145
|
if (typeof body.app_id !== "string" || body.app_id === "") {
|
|
99
146
|
return ctx.refuse(400, "bad_request", "app_id is required");
|
|
100
147
|
}
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
148
|
+
const hasTo = body.to !== undefined && body.to !== null;
|
|
149
|
+
const hasToAccount = body.to_account !== undefined && body.to_account !== null;
|
|
150
|
+
if (hasTo === hasToAccount) {
|
|
151
|
+
return ctx.refuse(400, "bad_request", "send either to or to_account");
|
|
152
|
+
}
|
|
153
|
+
let to = null;
|
|
154
|
+
let toAccount = null;
|
|
155
|
+
if (hasTo) {
|
|
156
|
+
const raw = body.to;
|
|
157
|
+
if (!Array.isArray(raw) || raw.length < 1 || raw.length > MAX_RECIPIENTS || !raw.every((r) => typeof r === "string")) {
|
|
158
|
+
return ctx.refuse(400, "bad_request", `to must hold 1..=${MAX_RECIPIENTS} addresses`);
|
|
159
|
+
}
|
|
160
|
+
to = raw;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
if (!TO_ACCOUNT.includes(body.to_account)) {
|
|
164
|
+
return ctx.refuse(400, "bad_request", 'to_account must be "self" or "owner"');
|
|
165
|
+
}
|
|
166
|
+
toAccount = body.to_account;
|
|
104
167
|
}
|
|
105
168
|
const hasTemplate = body.template_id !== undefined && body.template_id !== null;
|
|
106
169
|
const hasInline = body.subject !== undefined || body.html !== undefined;
|
|
170
|
+
if (toAccount !== null && (hasInline || !hasTemplate)) {
|
|
171
|
+
return ctx.refuse(400, "bad_request", "an account send takes template_id, never subject or html");
|
|
172
|
+
}
|
|
107
173
|
if (hasTemplate === hasInline) {
|
|
108
174
|
return ctx.refuse(400, "bad_request", "send either template_id or subject + html");
|
|
109
175
|
}
|
|
110
|
-
const
|
|
176
|
+
const key = body.idempotency_key;
|
|
177
|
+
if (key !== undefined && (typeof key !== "string" || !IDEMPOTENCY_KEY.test(key))) {
|
|
178
|
+
return ctx.refuse(400, "bad_request", "idempotency_key must be 1..=128 characters of [A-Za-z0-9._:-]");
|
|
179
|
+
}
|
|
180
|
+
const account = findAccount(body.app_id) ?? createAccount(body.app_id, null, "send_group");
|
|
181
|
+
const user = ctx.user();
|
|
182
|
+
const caller = user?.id ?? "mock-user";
|
|
183
|
+
const print = fingerprint(caller, body, to);
|
|
184
|
+
const now = ctx.now();
|
|
185
|
+
if (typeof key === "string") {
|
|
186
|
+
const first = state.sends.find((s) => s.app_id === account.app_id && s.idempotency_key === key && now - s.fired_at < IDEMPOTENCY_WINDOW_MS);
|
|
187
|
+
if (first) {
|
|
188
|
+
if (first.fingerprint !== print) {
|
|
189
|
+
return ctx.refuse(409, "idempotency_key_reused", "this idempotency_key belongs to a different request");
|
|
190
|
+
}
|
|
191
|
+
return ctx.ok({ ok: true, send_id: first.send_id, recipient_count: first.recipient_count, replayed: true });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
111
194
|
let subject;
|
|
112
195
|
let html;
|
|
113
196
|
let templateId = null;
|
|
@@ -128,30 +211,36 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
128
211
|
subject = body.subject;
|
|
129
212
|
html = body.html;
|
|
130
213
|
}
|
|
131
|
-
|
|
214
|
+
// A "self" row never shows the caller's address; an "owner" row shows the
|
|
215
|
+
// owner's own. The fake has no account emails, so the owner's is a
|
|
216
|
+
// stand-in built from the signed-in mock user's handle.
|
|
217
|
+
const recipients = to !== null ? [...to] : toAccount === "owner" ? [`${user?.handle ?? "owner"}@${ACCOUNT_EMAIL_DOMAIN}`] : [];
|
|
132
218
|
const row = {
|
|
133
219
|
app_id: account.app_id,
|
|
220
|
+
fingerprint: print,
|
|
134
221
|
send_id: ctx.nanoid(),
|
|
135
|
-
requester_user_id:
|
|
222
|
+
requester_user_id: caller,
|
|
136
223
|
via_app: true,
|
|
137
224
|
kind: hasTemplate ? "template" : "inline",
|
|
138
225
|
template_id: templateId,
|
|
139
226
|
subject,
|
|
140
|
-
recipients
|
|
141
|
-
recipient_count: to.length,
|
|
227
|
+
recipients,
|
|
228
|
+
recipient_count: to !== null ? to.length : 1,
|
|
142
229
|
outcome: "sent",
|
|
143
230
|
outcome_reason: null,
|
|
231
|
+
to_account: toAccount,
|
|
232
|
+
idempotency_key: typeof key === "string" ? key : null,
|
|
144
233
|
fired_at: now,
|
|
145
234
|
};
|
|
146
235
|
state.sends.push(row);
|
|
147
236
|
save();
|
|
148
237
|
console.log("[pilely mock] simple-email outbox: nothing was sent", {
|
|
149
238
|
from: account.address,
|
|
150
|
-
to: row.recipients,
|
|
239
|
+
to: toAccount !== null ? `(the ${toAccount === "self" ? "caller's" : "owner's"} account email)` : row.recipients,
|
|
151
240
|
subject,
|
|
152
241
|
html,
|
|
153
242
|
});
|
|
154
|
-
return ctx.ok({ ok: true, send_id: row.send_id, recipient_count: row.recipient_count });
|
|
243
|
+
return ctx.ok({ ok: true, send_id: row.send_id, recipient_count: row.recipient_count, replayed: false });
|
|
155
244
|
}
|
|
156
245
|
function handle(path, rawBody) {
|
|
157
246
|
const body = isObject(rawBody) ? rawBody : {};
|
|
@@ -167,7 +256,7 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
167
256
|
const result = keysetPage(rows, body, ["after_created_time_stamp", "after_send_id"], (s) => [s.fired_at, s.send_id], (s) => ({ after_created_time_stamp: s.fired_at, after_send_id: s.send_id }));
|
|
168
257
|
if (!result)
|
|
169
258
|
return cursorRefusal();
|
|
170
|
-
const sends = result.page.map(
|
|
259
|
+
const sends = result.page.map(sendPayload);
|
|
171
260
|
return ctx.ok({ ok: true, sends, next_cursor: result.next });
|
|
172
261
|
}
|
|
173
262
|
if (path === "/accounts/create") {
|
|
@@ -179,8 +268,12 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
179
268
|
if (findAccount(body.app_id)) {
|
|
180
269
|
return ctx.refuse(409, "account_exists", "this app already has an email account");
|
|
181
270
|
}
|
|
271
|
+
const accountSenders = parseAccountSenders(body);
|
|
272
|
+
if (accountSenders === null) {
|
|
273
|
+
return ctx.refuse(400, "bad_request", 'account_senders must be "send_group" or "app_users"');
|
|
274
|
+
}
|
|
182
275
|
const sendGroup = typeof body.send_group === "string" ? body.send_group : null;
|
|
183
|
-
const account = createAccount(body.app_id, sendGroup);
|
|
276
|
+
const account = createAccount(body.app_id, sendGroup, accountSenders ?? "send_group");
|
|
184
277
|
save();
|
|
185
278
|
return ctx.ok({ ok: true, account: accountPayload(account) });
|
|
186
279
|
}
|
|
@@ -224,7 +317,14 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
224
317
|
return ctx.notFound();
|
|
225
318
|
if (!("send_group" in body))
|
|
226
319
|
return ctx.refuse(400, "bad_request", "send_group is required");
|
|
320
|
+
const accountSenders = parseAccountSenders(body);
|
|
321
|
+
if (accountSenders === null) {
|
|
322
|
+
return ctx.refuse(400, "bad_request", 'account_senders must be "send_group" or "app_users"');
|
|
323
|
+
}
|
|
227
324
|
account.send_group = typeof body.send_group === "string" ? body.send_group : null;
|
|
325
|
+
// Omitted keeps the current value.
|
|
326
|
+
if (accountSenders !== undefined)
|
|
327
|
+
account.account_senders = accountSenders;
|
|
228
328
|
save();
|
|
229
329
|
return ctx.ok({ ok: true, account: accountPayload(account) });
|
|
230
330
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
/** Every `code` the simple_email service can put in a `{ok:false, code, reason}`
|
|
2
|
-
* refusal
|
|
3
|
-
* `response_json.rs` and `render.rs`.
|
|
2
|
+
* refusal, including the two `idempotency_key_*` conflicts, checked
|
|
3
|
+
* against the service's `response_json.rs` and `render.rs`.
|
|
4
4
|
*
|
|
5
|
-
* This is the union that most earns its keep: `
|
|
6
|
-
* `
|
|
7
|
-
*
|
|
8
|
-
* alone — only by matching this code. */
|
|
5
|
+
* This is the union that most earns its keep: `out_of_traffic_credits` and
|
|
6
|
+
* `phone_verification_unavailable` BOTH answer 503, so a consumer cannot tell
|
|
7
|
+
* a used-up owner balance (the owner must top up) from a transient outage
|
|
8
|
+
* (retry shortly) 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
|
-
/**
|
|
11
|
-
|
|
10
|
+
/** 409 — this `idempotency_key` already belongs to a different request
|
|
11
|
+
* (another body or another caller) on this app in the last 24 hours;
|
|
12
|
+
* nothing was sent. Use a new key for a different email. */
|
|
13
|
+
| "idempotency_key_reused"
|
|
14
|
+
/** 409 — an earlier request with this `idempotency_key` is still being
|
|
15
|
+
* handed to the provider; retry in a few seconds. */
|
|
16
|
+
| "idempotency_key_in_progress"
|
|
17
|
+
/** 429 — the per-app UTC-day send cap. */
|
|
18
|
+
| "daily_cap_exceeded" | "phone_verification_required" | "phone_verification_unavailable" | "out_of_traffic_credits"
|
|
19
|
+
/** 429 — over a `@simple_limiter` policy the app's owner set on this
|
|
20
|
+
* app's sends; root answers it with a `Retry-After` header. */
|
|
21
|
+
| "rate_limited" | "internal";
|
|
22
|
+
/** Who besides the owner may make account sends (`to_account`):
|
|
23
|
+
* `"send_group"` (the default) keeps them to the owner and the send group's
|
|
24
|
+
* members; `"app_users"` also lets every signed-in user arriving through the
|
|
25
|
+
* account's own app send a template to their own or the owner's account
|
|
26
|
+
* email. It never opens raw sends (`to`). */
|
|
27
|
+
export type AccountSenders = "send_group" | "app_users";
|
|
28
|
+
/** The recipient of an account send, resolved by the service: the caller's
|
|
29
|
+
* own Pilely account email (`"self"`) or the account owner's (`"owner"`).
|
|
30
|
+
* The caller never supplies or sees the address. */
|
|
31
|
+
export type ToAccount = "self" | "owner";
|
|
12
32
|
/** `address` is derived at read time, never stored, and `null` when the
|
|
13
33
|
* app it names is gone. */
|
|
14
34
|
export interface Account {
|
|
@@ -18,6 +38,7 @@ export interface Account {
|
|
|
18
38
|
/** `null` means owner-only — the opposite polarity from simple_db's
|
|
19
39
|
* `read_group`/`write_group`, where `null` means everyone. */
|
|
20
40
|
send_group: string | null;
|
|
41
|
+
account_senders: AccountSenders;
|
|
21
42
|
created_time_stamp: number;
|
|
22
43
|
}
|
|
23
44
|
/** `daily_cap`'s three siblings are nullable and sit BESIDE `account`, not
|
|
@@ -50,6 +71,10 @@ export interface TemplateMeta {
|
|
|
50
71
|
export interface Template extends TemplateMeta {
|
|
51
72
|
html?: string;
|
|
52
73
|
}
|
|
74
|
+
/** One send-log row. An account send's row carries its `to_account`: for
|
|
75
|
+
* `"self"`, `recipients` is `[]` and `recipient_count` is `1` — the owner
|
|
76
|
+
* never sees a user's account email through this service; for `"owner"`,
|
|
77
|
+
* `recipients` holds the owner's own address. */
|
|
53
78
|
export interface SendRow {
|
|
54
79
|
send_id: string;
|
|
55
80
|
requester_user_id: string;
|
|
@@ -61,6 +86,10 @@ export interface SendRow {
|
|
|
61
86
|
recipient_count: number;
|
|
62
87
|
outcome: string;
|
|
63
88
|
outcome_reason: string | null;
|
|
89
|
+
/** `null` for a raw send. */
|
|
90
|
+
to_account: ToAccount | null;
|
|
91
|
+
/** The caller's `idempotency_key`, or `null` when the send had none. */
|
|
92
|
+
idempotency_key: string | null;
|
|
64
93
|
fired_at: number;
|
|
65
94
|
}
|
|
66
95
|
export interface SendCursor {
|