@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 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>{sends?.map((row) => <li key={row.send_id}>{row.subject} — {row.outcome}</li>)}</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. It does not model send groups, the daily cap, the phone gate, rate limits or credit checks.
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: **`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).
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 sends per app per UTC day.
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
- 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,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
- 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
+ * `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
- export declare function setAccountAccess(sendGroup: string | null): Promise<Account>;
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 12 POST routes, one function each. This is the
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 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,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
- 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
+ * `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
- export async function setAccountAccess(sendGroup) {
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: { send_group: sendGroup },
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. 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>;
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; `null` means owner-only. */
39
- export declare function useSetEmailAccountAccess(): UseMutationResult<Account, Error, string | null>;
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. 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?, reply_to? }`. */
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, 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; `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),
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 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
- // template answer the uniform bare 404. Reached only from the
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 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`);
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 account = findAccount(body.app_id) ?? createAccount(body.app_id, null);
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
- const now = ctx.now();
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: ctx.user()?.id ?? "mock-user",
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: [...to],
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(({ app_id: _appId, ...row }) => row);
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. 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,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 {
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.3",
4
4
  "description": "React hooks for the simple_email service.",
5
5
  "license": "MIT",
6
6
  "type": "module",