@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 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>{sends?.map((row) => <li key={row.send_id}>{row.subject} — {row.outcome}</li>)}</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. It does not model send groups, the daily cap, the phone gate, rate limits or credit checks.
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: **`daily_cap_exceeded` and `rate_limited` both answer 429** — one means the app spent its UTC-day allowance (retry tomorrow), the other is a burst window (retry shortly).
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 sends per app per UTC day.
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
- export type SendInput = {
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
- } & SendContent;
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
- export declare function createAccount(sendGroup?: string | null): Promise<Account>;
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
- export declare function setAccountAccess(sendGroup: string | null): Promise<Account>;
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 body = "template_id" in input
36
- ? { app_id: requireAppId(), to: input.to, template_id: input.template_id, variables: input.variables }
37
- : { app_id: requireAppId(), to: input.to, subject: input.subject, html: input.html };
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
- export async function createAccount(sendGroup) {
70
- const body = { app_id: requireAppId() };
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
- export async function setAccountAccess(sendGroup) {
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: { send_group: sendGroup },
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. Not optimistic: a send counts
21
- * only once the server accepted it. */
22
- export declare function useSend(): UseMutationResult<{
23
- send_id: string;
24
- recipient_count: number;
25
- }, Error, SendInput>;
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; `null` means owner-only. */
39
- export declare function useSetEmailAccountAccess(): UseMutationResult<Account, Error, string | null>;
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. Not optimistic: a send counts
15
- * only once the server accepted it. */
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: (sendGroup) => createAccount(sendGroup ?? undefined),
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; `null` means owner-only. */
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: setAccountAccess,
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 or credit checks. Writes
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 to = body.to;
102
- if (!Array.isArray(to) || to.length < 1 || to.length > MAX_RECIPIENTS || !to.every((r) => typeof r === "string")) {
103
- return ctx.refuse(400, "bad_request", `to must hold 1..=${MAX_RECIPIENTS} addresses`);
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 account = findAccount(body.app_id) ?? createAccount(body.app_id, null);
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
- const now = ctx.now();
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: ctx.user()?.id ?? "mock-user",
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: [...to],
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(({ app_id: _appId, ...row }) => row);
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. Exhaustive as of the 2026-09 review, checked against the service's
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: `daily_cap_exceeded` and
6
- * `rate_limited` BOTH answer 429, so a consumer cannot tell a burst limit
7
- * (retry shortly) from the per-app daily send cap (retry tomorrow) by status
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
- /** 429, same status as `rate_limited` — the per-app UTC-day send cap. */
11
- | "daily_cap_exceeded" | "phone_verification_required" | "phone_verification_unavailable" | "out_of_traffic_credits" | "rate_limited" | "internal";
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilely/simple-email",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "React hooks for the simple_email service.",
5
5
  "license": "MIT",
6
6
  "type": "module",