@pilely/simple-email 0.4.0 → 0.4.3
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 +40 -8
- package/dist/api.d.ts +56 -11
- package/dist/api.js +43 -10
- package/dist/hooks.d.ts +31 -12
- package/dist/hooks.js +23 -7
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/mock.js +149 -15
- package/dist/types.d.ts +40 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,13 +6,14 @@ 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?, reply_to? }` |
|
|
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
|
+
| `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,26 @@ 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
|
+
## Raw sends and account sends
|
|
26
|
+
|
|
27
|
+
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.
|
|
28
|
+
|
|
29
|
+
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.
|
|
30
|
+
|
|
31
|
+
## Replies: `reply_to`
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
35
|
+
## Retries: `idempotency_key`
|
|
36
|
+
|
|
37
|
+
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`.
|
|
38
|
+
|
|
24
39
|
## What each write refreshes
|
|
25
40
|
|
|
26
41
|
| Write | Invalidates |
|
|
27
42
|
| --- | --- |
|
|
28
43
|
| send | `useSends()` |
|
|
29
|
-
| account create / set access | `useEmailAccounts()`, `useEmailAccount()` |
|
|
44
|
+
| account create / set access / set reply-to | `useEmailAccounts()`, `useEmailAccount()` |
|
|
30
45
|
| account delete | `useEmailAccounts()`, `useEmailAccount()`, and the templates under it |
|
|
31
46
|
| template create | `useTemplates()` |
|
|
32
47
|
| template update / delete on `t` | `useTemplates()`, `useTemplate(t)` |
|
|
@@ -45,10 +60,25 @@ export function Outbox() {
|
|
|
45
60
|
Send
|
|
46
61
|
</button>
|
|
47
62
|
{send.error && <p role="alert">{send.error.message}</p>}
|
|
48
|
-
<ul>
|
|
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>
|
|
49
71
|
</>
|
|
50
72
|
);
|
|
51
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
|
+
}
|
|
52
82
|
```
|
|
53
83
|
|
|
54
84
|
## Query keys
|
|
@@ -69,17 +99,19 @@ export function Outbox() {
|
|
|
69
99
|
|
|
70
100
|
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
101
|
|
|
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.
|
|
102
|
+
**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.
|
|
103
|
+
|
|
104
|
+
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, the phone gate, rate limits, credit checks or `idempotency_key_in_progress`.
|
|
73
105
|
|
|
74
106
|
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
107
|
|
|
76
108
|
## Error codes
|
|
77
109
|
|
|
78
|
-
Refusals arrive as `PilelyError` in the hook's `error`, with a `code` drawn from `SimpleEmailErrorCode`. Match on the code: **`
|
|
110
|
+
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
111
|
|
|
80
112
|
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
113
|
|
|
82
|
-
Limits: billed per recipient, at most 50 recipients per send, 200
|
|
114
|
+
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
115
|
|
|
84
116
|
## Install
|
|
85
117
|
|
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,12 @@ 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
|
+
* `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>;
|
|
57
96
|
/** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
|
|
58
97
|
* not a selector. The paged primitive; use `listAllAccounts` to walk to
|
|
59
98
|
* the end. */
|
|
@@ -68,8 +107,14 @@ export declare function accountInfo(): Promise<AccountInfoEnvelope>;
|
|
|
68
107
|
export declare function deleteAccount(): Promise<void>;
|
|
69
108
|
/** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
|
|
70
109
|
* the opposite of `createAccount`, where the key is optional. `null`
|
|
71
|
-
* means owner-only.
|
|
72
|
-
|
|
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>;
|
|
113
|
+
/** `replyTo` is REQUIRED and its key is always sent, even `null` — the
|
|
114
|
+
* server refuses a missing key with a 400, so a typo can never silently
|
|
115
|
+
* clear it. An address sets where recipients' replies go (every send from
|
|
116
|
+
* the account carries it as its `Reply-To`); `null` clears it. */
|
|
117
|
+
export declare function setReplyTo(replyTo: string | null): Promise<Account>;
|
|
73
118
|
/** Returns metadata only — no `html`. Use `templateInfo` to read the body
|
|
74
119
|
* back.
|
|
75
120
|
*
|
package/dist/api.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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
4
|
// route on this service.
|
|
@@ -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,24 @@ 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
|
+
* `replyTo` is optional as well: `undefined` leaves the key off the wire,
|
|
76
|
+
* and omitted or `null` both mean no reply-to. */
|
|
77
|
+
export async function createAccount(sendGroup, accountSenders, replyTo) {
|
|
78
|
+
const body = {
|
|
79
|
+
app_id: requireAppId(),
|
|
80
|
+
};
|
|
71
81
|
if (sendGroup !== undefined) {
|
|
72
82
|
body.send_group = sendGroup;
|
|
73
83
|
}
|
|
84
|
+
if (accountSenders !== undefined) {
|
|
85
|
+
body.account_senders = accountSenders;
|
|
86
|
+
}
|
|
87
|
+
if (replyTo !== undefined) {
|
|
88
|
+
body.reply_to = replyTo;
|
|
89
|
+
}
|
|
74
90
|
const json = await call({
|
|
75
91
|
service: "simple-email",
|
|
76
92
|
path: "/accounts/create",
|
|
@@ -119,12 +135,29 @@ export async function deleteAccount() {
|
|
|
119
135
|
}
|
|
120
136
|
/** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
|
|
121
137
|
* the opposite of `createAccount`, where the key is optional. `null`
|
|
122
|
-
* means owner-only.
|
|
123
|
-
|
|
138
|
+
* means owner-only. `accountSenders` is optional: `undefined` leaves the
|
|
139
|
+
* key off the wire, which KEEPS the account's current value. */
|
|
140
|
+
export async function setAccountAccess(sendGroup, accountSenders) {
|
|
141
|
+
const body = { send_group: sendGroup };
|
|
142
|
+
if (accountSenders !== undefined) {
|
|
143
|
+
body.account_senders = accountSenders;
|
|
144
|
+
}
|
|
124
145
|
const json = await call({
|
|
125
146
|
service: "simple-email",
|
|
126
147
|
path: `/accounts/${requireAppId()}/access/set`,
|
|
127
|
-
body
|
|
148
|
+
body,
|
|
149
|
+
});
|
|
150
|
+
return json.account;
|
|
151
|
+
}
|
|
152
|
+
/** `replyTo` is REQUIRED and its key is always sent, even `null` — the
|
|
153
|
+
* server refuses a missing key with a 400, so a typo can never silently
|
|
154
|
+
* clear it. An address sets where recipients' replies go (every send from
|
|
155
|
+
* the account carries it as its `Reply-To`); `null` clears it. */
|
|
156
|
+
export async function setReplyTo(replyTo) {
|
|
157
|
+
const json = await call({
|
|
158
|
+
service: "simple-email",
|
|
159
|
+
path: `/accounts/${requireAppId()}/set_reply_to`,
|
|
160
|
+
body: { reply_to: replyTo },
|
|
128
161
|
});
|
|
129
162
|
return json.account;
|
|
130
163
|
}
|
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,32 @@ export interface CreateTemplateInput {
|
|
|
11
11
|
subject: string;
|
|
12
12
|
html: string;
|
|
13
13
|
}
|
|
14
|
+
/** `useCreateEmailAccount`'s object form. Every key is optional: an omitted
|
|
15
|
+
* `send_group` means owner-only, an omitted `account_senders` means
|
|
16
|
+
* `"send_group"`, and an omitted (or `null`) `reply_to` means no reply-to. */
|
|
17
|
+
export interface CreateEmailAccountInput {
|
|
18
|
+
send_group?: string | null;
|
|
19
|
+
account_senders?: AccountSenders;
|
|
20
|
+
/** The address recipients' replies go to. */
|
|
21
|
+
reply_to?: string | null;
|
|
22
|
+
}
|
|
23
|
+
/** `useSetEmailAccountAccess`'s object form. `send_group` is required (`null`
|
|
24
|
+
* = owner-only); an omitted `account_senders` keeps the current value. */
|
|
25
|
+
export interface SetEmailAccountAccessInput {
|
|
26
|
+
send_group: string | null;
|
|
27
|
+
account_senders?: AccountSenders;
|
|
28
|
+
}
|
|
14
29
|
/** A full replace of both `subject` and `html`; `name` is not updatable. */
|
|
15
30
|
export interface UpdateTemplateInput {
|
|
16
31
|
templateId: string;
|
|
17
32
|
subject: string;
|
|
18
33
|
html: string;
|
|
19
34
|
}
|
|
20
|
-
/** Sends one email from this app's account
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}, Error, SendInput>;
|
|
35
|
+
/** Sends one email from this app's account: a raw send (`to`) or an account
|
|
36
|
+
* send (`to_account: "self" | "owner"`). Not optimistic: a send counts only
|
|
37
|
+
* once the server accepted it. A repeat carrying the same `idempotency_key`
|
|
38
|
+
* answers the first send with `replayed: true`. */
|
|
39
|
+
export declare function useSend(): UseMutationResult<SendResult, Error, SendInput>;
|
|
26
40
|
/** This app's outbox, newest first, as an infinite query. */
|
|
27
41
|
export declare function useSends(options?: PageOptions): UseInfiniteQueryResult<SendRow[], Error>;
|
|
28
42
|
/** The caller's email accounts across apps, as an infinite query. */
|
|
@@ -31,12 +45,17 @@ export declare function useEmailAccounts(options?: PageOptions): UseInfiniteQuer
|
|
|
31
45
|
* and phone verification. */
|
|
32
46
|
export declare function useEmailAccount(): UseQueryResult<AccountInfoEnvelope, Error>;
|
|
33
47
|
/** 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>;
|
|
48
|
+
* (`null` or omitted: owner-only), or `{ send_group?, account_senders?, reply_to? }`. */
|
|
49
|
+
export declare function useCreateEmailAccount(): UseMutationResult<Account, Error, string | null | void | CreateEmailAccountInput>;
|
|
36
50
|
/** Deletes this app's account and the templates under it. */
|
|
37
51
|
export declare function useDeleteEmailAccount(): UseMutationResult<void, Error, void>;
|
|
38
|
-
/** Replaces this app's send group
|
|
39
|
-
|
|
52
|
+
/** Replaces this app's send group (`null` means owner-only); the object form
|
|
53
|
+
* `{ send_group, account_senders? }` also sets who may make account sends. */
|
|
54
|
+
export declare function useSetEmailAccountAccess(): UseMutationResult<Account, Error, string | null | SetEmailAccountAccessInput>;
|
|
55
|
+
/** Sets this app's reply-to address — where recipients' replies go; every
|
|
56
|
+
* send from the account carries it as its `Reply-To` — or clears it with
|
|
57
|
+
* `null`. The variable is required: there is no "keep" form. */
|
|
58
|
+
export declare function useSetEmailReplyTo(): UseMutationResult<Account, Error, string | null>;
|
|
40
59
|
/** This app's templates (metadata only), as an infinite query. */
|
|
41
60
|
export declare function useTemplates(options?: PageOptions): UseInfiniteQueryResult<TemplateMeta[], Error>;
|
|
42
61
|
/** One template with its `html`. An `undefined` id disables the query. */
|
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]` */
|
|
@@ -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?, reply_to? }`. */
|
|
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, input.reply_to)
|
|
58
|
+
: createAccount(input ?? undefined),
|
|
55
59
|
invalidates: () => [accountsKey(), accountKey()],
|
|
56
60
|
});
|
|
57
61
|
}
|
|
@@ -62,10 +66,22 @@ 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),
|
|
76
|
+
invalidates: () => [accountsKey(), accountKey()],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Sets this app's reply-to address — where recipients' replies go; every
|
|
80
|
+
* send from the account carries it as its `Reply-To` — or clears it with
|
|
81
|
+
* `null`. The variable is required: there is no "keep" form. */
|
|
82
|
+
export function useSetEmailReplyTo() {
|
|
83
|
+
return useServiceMutation("useSetEmailReplyTo", {
|
|
84
|
+
mutationFn: (replyTo) => setReplyTo(replyTo),
|
|
69
85
|
invalidates: () => [accountsKey(), accountKey()],
|
|
70
86
|
});
|
|
71
87
|
}
|
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";
|
|
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";
|
|
1
|
+
export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useSetEmailReplyTo, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.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/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,9 +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, 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
|
-
// 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
|
|
11
20
|
// `isMockMode()` branch in index.ts, so a production build carries none of
|
|
12
21
|
// it.
|
|
13
22
|
const MARKER = "pilely-mock-fake:simple-email";
|
|
@@ -16,6 +25,11 @@ const MAX_PAGE_SIZE = 100;
|
|
|
16
25
|
const MAX_RECIPIENTS = 50;
|
|
17
26
|
const DAILY_CAP = 200;
|
|
18
27
|
const SENDING_DOMAIN = "mail.pilely.invalid";
|
|
28
|
+
const ACCOUNT_EMAIL_DOMAIN = "account.pilely.invalid";
|
|
29
|
+
const IDEMPOTENCY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
const IDEMPOTENCY_KEY = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
31
|
+
const ACCOUNT_SENDERS = ["send_group", "app_users"];
|
|
32
|
+
const TO_ACCOUNT = ["self", "owner"];
|
|
19
33
|
function isObject(value) {
|
|
20
34
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
35
|
}
|
|
@@ -35,9 +49,56 @@ function accountPayload(account) {
|
|
|
35
49
|
address: account.address,
|
|
36
50
|
display_name: account.display_name,
|
|
37
51
|
send_group: account.send_group,
|
|
52
|
+
// State persisted before account_senders existed reads the default.
|
|
53
|
+
account_senders: account.account_senders ?? "send_group",
|
|
54
|
+
// State persisted before reply_to existed reads none.
|
|
55
|
+
reply_to: account.reply_to ?? null,
|
|
38
56
|
created_time_stamp: account.created_time_stamp,
|
|
39
57
|
};
|
|
40
58
|
}
|
|
59
|
+
/** The wire row: the fake's own fields stripped, and the two newer keys
|
|
60
|
+
* defaulted for rows persisted before they existed. */
|
|
61
|
+
function sendPayload({ app_id: _appId, fingerprint: _fingerprint, ...row }) {
|
|
62
|
+
return { ...row, to_account: row.to_account ?? null, idempotency_key: row.idempotency_key ?? null };
|
|
63
|
+
}
|
|
64
|
+
/** `undefined` when the key is absent; `null` when it is present but not
|
|
65
|
+
* one of the two values (an explicit `null` included — the server's 400). */
|
|
66
|
+
function parseAccountSenders(body) {
|
|
67
|
+
if (!("account_senders" in body))
|
|
68
|
+
return undefined;
|
|
69
|
+
const value = body.account_senders;
|
|
70
|
+
return ACCOUNT_SENDERS.includes(value) ? value : null;
|
|
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
|
+
}
|
|
82
|
+
/** The service's "same body": the caller, the mode, `to` as a
|
|
83
|
+
* case-insensitive set, `to_account`, the template and its variables, and
|
|
84
|
+
* inline content. */
|
|
85
|
+
function fingerprint(caller, body, to) {
|
|
86
|
+
const variables = isObject(body.variables)
|
|
87
|
+
? Object.keys(body.variables)
|
|
88
|
+
.sort()
|
|
89
|
+
.map((name) => [name, body.variables[name]])
|
|
90
|
+
: null;
|
|
91
|
+
return JSON.stringify([
|
|
92
|
+
caller,
|
|
93
|
+
body.app_id,
|
|
94
|
+
to === null ? null : [...new Set(to.map((r) => r.toLowerCase()))].sort(),
|
|
95
|
+
body.to_account ?? null,
|
|
96
|
+
body.template_id ?? null,
|
|
97
|
+
variables,
|
|
98
|
+
body.subject ?? null,
|
|
99
|
+
body.html ?? null,
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
41
102
|
function templateMeta(template) {
|
|
42
103
|
return {
|
|
43
104
|
template_id: template.template_id,
|
|
@@ -82,12 +143,14 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
82
143
|
function findAccount(appId) {
|
|
83
144
|
return state.accounts.find((a) => a.app_id === appId);
|
|
84
145
|
}
|
|
85
|
-
function createAccount(appId, sendGroup) {
|
|
146
|
+
function createAccount(appId, sendGroup, accountSenders, replyTo) {
|
|
86
147
|
const account = {
|
|
87
148
|
app_id: appId,
|
|
88
149
|
address: `${appId}@${SENDING_DOMAIN}`,
|
|
89
150
|
display_name: null,
|
|
90
151
|
send_group: sendGroup,
|
|
152
|
+
account_senders: accountSenders,
|
|
153
|
+
reply_to: replyTo,
|
|
91
154
|
created_time_stamp: ctx.now(),
|
|
92
155
|
templates: [],
|
|
93
156
|
};
|
|
@@ -98,16 +161,52 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
98
161
|
if (typeof body.app_id !== "string" || body.app_id === "") {
|
|
99
162
|
return ctx.refuse(400, "bad_request", "app_id is required");
|
|
100
163
|
}
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
164
|
+
const hasTo = body.to !== undefined && body.to !== null;
|
|
165
|
+
const hasToAccount = body.to_account !== undefined && body.to_account !== null;
|
|
166
|
+
if (hasTo === hasToAccount) {
|
|
167
|
+
return ctx.refuse(400, "bad_request", "send either to or to_account");
|
|
168
|
+
}
|
|
169
|
+
let to = null;
|
|
170
|
+
let toAccount = null;
|
|
171
|
+
if (hasTo) {
|
|
172
|
+
const raw = body.to;
|
|
173
|
+
if (!Array.isArray(raw) || raw.length < 1 || raw.length > MAX_RECIPIENTS || !raw.every((r) => typeof r === "string")) {
|
|
174
|
+
return ctx.refuse(400, "bad_request", `to must hold 1..=${MAX_RECIPIENTS} addresses`);
|
|
175
|
+
}
|
|
176
|
+
to = raw;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
if (!TO_ACCOUNT.includes(body.to_account)) {
|
|
180
|
+
return ctx.refuse(400, "bad_request", 'to_account must be "self" or "owner"');
|
|
181
|
+
}
|
|
182
|
+
toAccount = body.to_account;
|
|
104
183
|
}
|
|
105
184
|
const hasTemplate = body.template_id !== undefined && body.template_id !== null;
|
|
106
185
|
const hasInline = body.subject !== undefined || body.html !== undefined;
|
|
186
|
+
if (toAccount !== null && (hasInline || !hasTemplate)) {
|
|
187
|
+
return ctx.refuse(400, "bad_request", "an account send takes template_id, never subject or html");
|
|
188
|
+
}
|
|
107
189
|
if (hasTemplate === hasInline) {
|
|
108
190
|
return ctx.refuse(400, "bad_request", "send either template_id or subject + html");
|
|
109
191
|
}
|
|
110
|
-
const
|
|
192
|
+
const key = body.idempotency_key;
|
|
193
|
+
if (key !== undefined && (typeof key !== "string" || !IDEMPOTENCY_KEY.test(key))) {
|
|
194
|
+
return ctx.refuse(400, "bad_request", "idempotency_key must be 1..=128 characters of [A-Za-z0-9._:-]");
|
|
195
|
+
}
|
|
196
|
+
const account = findAccount(body.app_id) ?? createAccount(body.app_id, null, "send_group", null);
|
|
197
|
+
const user = ctx.user();
|
|
198
|
+
const caller = user?.id ?? "mock-user";
|
|
199
|
+
const print = fingerprint(caller, body, to);
|
|
200
|
+
const now = ctx.now();
|
|
201
|
+
if (typeof key === "string") {
|
|
202
|
+
const first = state.sends.find((s) => s.app_id === account.app_id && s.idempotency_key === key && now - s.fired_at < IDEMPOTENCY_WINDOW_MS);
|
|
203
|
+
if (first) {
|
|
204
|
+
if (first.fingerprint !== print) {
|
|
205
|
+
return ctx.refuse(409, "idempotency_key_reused", "this idempotency_key belongs to a different request");
|
|
206
|
+
}
|
|
207
|
+
return ctx.ok({ ok: true, send_id: first.send_id, recipient_count: first.recipient_count, replayed: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
111
210
|
let subject;
|
|
112
211
|
let html;
|
|
113
212
|
let templateId = null;
|
|
@@ -128,30 +227,36 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
128
227
|
subject = body.subject;
|
|
129
228
|
html = body.html;
|
|
130
229
|
}
|
|
131
|
-
|
|
230
|
+
// A "self" row never shows the caller's address; an "owner" row shows the
|
|
231
|
+
// owner's own. The fake has no account emails, so the owner's is a
|
|
232
|
+
// stand-in built from the signed-in mock user's handle.
|
|
233
|
+
const recipients = to !== null ? [...to] : toAccount === "owner" ? [`${user?.handle ?? "owner"}@${ACCOUNT_EMAIL_DOMAIN}`] : [];
|
|
132
234
|
const row = {
|
|
133
235
|
app_id: account.app_id,
|
|
236
|
+
fingerprint: print,
|
|
134
237
|
send_id: ctx.nanoid(),
|
|
135
|
-
requester_user_id:
|
|
238
|
+
requester_user_id: caller,
|
|
136
239
|
via_app: true,
|
|
137
240
|
kind: hasTemplate ? "template" : "inline",
|
|
138
241
|
template_id: templateId,
|
|
139
242
|
subject,
|
|
140
|
-
recipients
|
|
141
|
-
recipient_count: to.length,
|
|
243
|
+
recipients,
|
|
244
|
+
recipient_count: to !== null ? to.length : 1,
|
|
142
245
|
outcome: "sent",
|
|
143
246
|
outcome_reason: null,
|
|
247
|
+
to_account: toAccount,
|
|
248
|
+
idempotency_key: typeof key === "string" ? key : null,
|
|
144
249
|
fired_at: now,
|
|
145
250
|
};
|
|
146
251
|
state.sends.push(row);
|
|
147
252
|
save();
|
|
148
253
|
console.log("[pilely mock] simple-email outbox: nothing was sent", {
|
|
149
254
|
from: account.address,
|
|
150
|
-
to: row.recipients,
|
|
255
|
+
to: toAccount !== null ? `(the ${toAccount === "self" ? "caller's" : "owner's"} account email)` : row.recipients,
|
|
151
256
|
subject,
|
|
152
257
|
html,
|
|
153
258
|
});
|
|
154
|
-
return ctx.ok({ ok: true, send_id: row.send_id, recipient_count: row.recipient_count });
|
|
259
|
+
return ctx.ok({ ok: true, send_id: row.send_id, recipient_count: row.recipient_count, replayed: false });
|
|
155
260
|
}
|
|
156
261
|
function handle(path, rawBody) {
|
|
157
262
|
const body = isObject(rawBody) ? rawBody : {};
|
|
@@ -167,7 +272,7 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
167
272
|
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
273
|
if (!result)
|
|
169
274
|
return cursorRefusal();
|
|
170
|
-
const sends = result.page.map(
|
|
275
|
+
const sends = result.page.map(sendPayload);
|
|
171
276
|
return ctx.ok({ ok: true, sends, next_cursor: result.next });
|
|
172
277
|
}
|
|
173
278
|
if (path === "/accounts/create") {
|
|
@@ -179,8 +284,16 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
179
284
|
if (findAccount(body.app_id)) {
|
|
180
285
|
return ctx.refuse(409, "account_exists", "this app already has an email account");
|
|
181
286
|
}
|
|
287
|
+
const accountSenders = parseAccountSenders(body);
|
|
288
|
+
if (accountSenders === null) {
|
|
289
|
+
return ctx.refuse(400, "bad_request", 'account_senders must be "send_group" or "app_users"');
|
|
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
|
+
}
|
|
182
295
|
const sendGroup = typeof body.send_group === "string" ? body.send_group : null;
|
|
183
|
-
const account = createAccount(body.app_id, sendGroup);
|
|
296
|
+
const account = createAccount(body.app_id, sendGroup, accountSenders ?? "send_group", replyTo ?? null);
|
|
184
297
|
save();
|
|
185
298
|
return ctx.ok({ ok: true, account: accountPayload(account) });
|
|
186
299
|
}
|
|
@@ -224,7 +337,28 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
224
337
|
return ctx.notFound();
|
|
225
338
|
if (!("send_group" in body))
|
|
226
339
|
return ctx.refuse(400, "bad_request", "send_group is required");
|
|
340
|
+
const accountSenders = parseAccountSenders(body);
|
|
341
|
+
if (accountSenders === null) {
|
|
342
|
+
return ctx.refuse(400, "bad_request", 'account_senders must be "send_group" or "app_users"');
|
|
343
|
+
}
|
|
227
344
|
account.send_group = typeof body.send_group === "string" ? body.send_group : null;
|
|
345
|
+
// Omitted keeps the current value.
|
|
346
|
+
if (accountSenders !== undefined)
|
|
347
|
+
account.account_senders = accountSenders;
|
|
348
|
+
save();
|
|
349
|
+
return ctx.ok({ ok: true, account: accountPayload(account) });
|
|
350
|
+
}
|
|
351
|
+
if (route === "set_reply_to") {
|
|
352
|
+
if (!signedIn || !account)
|
|
353
|
+
return ctx.notFound();
|
|
354
|
+
const replyTo = parseReplyTo(body);
|
|
355
|
+
if (replyTo === undefined) {
|
|
356
|
+
return ctx.refuse(400, "bad_request", "reply_to is required (an email address, or null to clear it)");
|
|
357
|
+
}
|
|
358
|
+
if (replyTo === false) {
|
|
359
|
+
return ctx.refuse(400, "bad_request", "reply_to must be an email address or null");
|
|
360
|
+
}
|
|
361
|
+
account.reply_to = replyTo;
|
|
228
362
|
save();
|
|
229
363
|
return ctx.ok({ ok: true, account: accountPayload(account) });
|
|
230
364
|
}
|
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,10 @@ 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;
|
|
42
|
+
/** The address recipients' replies go to: every send from the account
|
|
43
|
+
* carries it as its `Reply-To`. `null` means none — replies go nowhere. */
|
|
44
|
+
reply_to: string | null;
|
|
21
45
|
created_time_stamp: number;
|
|
22
46
|
}
|
|
23
47
|
/** `daily_cap`'s three siblings are nullable and sit BESIDE `account`, not
|
|
@@ -50,6 +74,10 @@ export interface TemplateMeta {
|
|
|
50
74
|
export interface Template extends TemplateMeta {
|
|
51
75
|
html?: string;
|
|
52
76
|
}
|
|
77
|
+
/** One send-log row. An account send's row carries its `to_account`: for
|
|
78
|
+
* `"self"`, `recipients` is `[]` and `recipient_count` is `1` — the owner
|
|
79
|
+
* never sees a user's account email through this service; for `"owner"`,
|
|
80
|
+
* `recipients` holds the owner's own address. */
|
|
53
81
|
export interface SendRow {
|
|
54
82
|
send_id: string;
|
|
55
83
|
requester_user_id: string;
|
|
@@ -61,6 +89,10 @@ export interface SendRow {
|
|
|
61
89
|
recipient_count: number;
|
|
62
90
|
outcome: string;
|
|
63
91
|
outcome_reason: string | null;
|
|
92
|
+
/** `null` for a raw send. */
|
|
93
|
+
to_account: ToAccount | null;
|
|
94
|
+
/** The caller's `idempotency_key`, or `null` when the send had none. */
|
|
95
|
+
idempotency_key: string | null;
|
|
64
96
|
fired_at: number;
|
|
65
97
|
}
|
|
66
98
|
export interface SendCursor {
|