@pilely/simple-email 0.4.2 → 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
@@ -10,9 +10,10 @@ React hooks for the `simple_email` service — the platform's managed transactio
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, or `{ send_group?, account_senders? }` |
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
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`) |
@@ -27,6 +28,10 @@ A **raw send** (`to`) goes to the addresses you name, with template or inline co
27
28
 
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.
29
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
+
30
35
  ## Retries: `idempotency_key`
31
36
 
32
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`.
@@ -36,7 +41,7 @@ Give a send an `idempotency_key` (1–128 characters of `[A-Za-z0-9._:-]`, held
36
41
  | Write | Invalidates |
37
42
  | --- | --- |
38
43
  | send | `useSends()` |
39
- | account create / set access | `useEmailAccounts()`, `useEmailAccount()` |
44
+ | account create / set access / set reply-to | `useEmailAccounts()`, `useEmailAccount()` |
40
45
  | account delete | `useEmailAccounts()`, `useEmailAccount()`, and the templates under it |
41
46
  | template create | `useTemplates()` |
42
47
  | template update / delete on `t` | `useTemplates()`, `useTemplate(t)` |
@@ -96,7 +101,7 @@ Importing this package in a build made with `VITE_PILELY_MOCK=1` registers an in
96
101
 
97
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.
98
103
 
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`.
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`.
100
105
 
101
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()`.
102
107
 
package/dist/api.d.ts CHANGED
@@ -89,8 +89,10 @@ export declare function listAllSends(): Promise<SendRow[]>;
89
89
  * key is left off the wire, meaning owner-only. `setAccountAccess` below
90
90
  * is the opposite: its key is always required. `accountSenders` is
91
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>;
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>;
94
96
  /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
95
97
  * not a selector. The paged primitive; use `listAllAccounts` to walk to
96
98
  * the end. */
@@ -108,6 +110,11 @@ export declare function deleteAccount(): Promise<void>;
108
110
  * means owner-only. `accountSenders` is optional: `undefined` leaves the
109
111
  * key off the wire, which KEEPS the account's current value. */
110
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>;
111
118
  /** Returns metadata only — no `html`. Use `templateInfo` to read the body
112
119
  * back.
113
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.
@@ -71,8 +71,10 @@ export async function listAllSends() {
71
71
  * key is left off the wire, meaning owner-only. `setAccountAccess` below
72
72
  * is the opposite: its key is always required. `accountSenders` is
73
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) {
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) {
76
78
  const body = {
77
79
  app_id: requireAppId(),
78
80
  };
@@ -82,6 +84,9 @@ export async function createAccount(sendGroup, accountSenders) {
82
84
  if (accountSenders !== undefined) {
83
85
  body.account_senders = accountSenders;
84
86
  }
87
+ if (replyTo !== undefined) {
88
+ body.reply_to = replyTo;
89
+ }
85
90
  const json = await call({
86
91
  service: "simple-email",
87
92
  path: "/accounts/create",
@@ -144,6 +149,18 @@ export async function setAccountAccess(sendGroup, accountSenders) {
144
149
  });
145
150
  return json.account;
146
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 },
161
+ });
162
+ return json.account;
163
+ }
147
164
  // ── Templates (nested under the account) ────────────────────────────────
148
165
  /** Returns metadata only — no `html`. Use `templateInfo` to read the body
149
166
  * back.
package/dist/hooks.d.ts CHANGED
@@ -11,12 +11,14 @@ export interface CreateTemplateInput {
11
11
  subject: string;
12
12
  html: string;
13
13
  }
14
- /** `useCreateEmailAccount`'s object form. Both keys are optional: an omitted
14
+ /** `useCreateEmailAccount`'s object form. Every key is optional: an omitted
15
15
  * `send_group` means owner-only, an omitted `account_senders` means
16
- * `"send_group"`. */
16
+ * `"send_group"`, and an omitted (or `null`) `reply_to` means no reply-to. */
17
17
  export interface CreateEmailAccountInput {
18
18
  send_group?: string | null;
19
19
  account_senders?: AccountSenders;
20
+ /** The address recipients' replies go to. */
21
+ reply_to?: string | null;
20
22
  }
21
23
  /** `useSetEmailAccountAccess`'s object form. `send_group` is required (`null`
22
24
  * = owner-only); an omitted `account_senders` keeps the current value. */
@@ -43,13 +45,17 @@ export declare function useEmailAccounts(options?: PageOptions): UseInfiniteQuer
43
45
  * and phone verification. */
44
46
  export declare function useEmailAccount(): UseQueryResult<AccountInfoEnvelope, Error>;
45
47
  /** Creates this app's account; the variable is its optional send group
46
- * (`null` or omitted: owner-only), or `{ send_group?, account_senders? }`. */
48
+ * (`null` or omitted: owner-only), or `{ send_group?, account_senders?, reply_to? }`. */
47
49
  export declare function useCreateEmailAccount(): UseMutationResult<Account, Error, string | null | void | CreateEmailAccountInput>;
48
50
  /** Deletes this app's account and the templates under it. */
49
51
  export declare function useDeleteEmailAccount(): UseMutationResult<void, Error, void>;
50
52
  /** Replaces this app's send group (`null` means owner-only); the object form
51
53
  * `{ send_group, account_senders? }` also sets who may make account sends. */
52
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>;
53
59
  /** This app's templates (metadata only), as an infinite query. */
54
60
  export declare function useTemplates(options?: PageOptions): UseInfiniteQueryResult<TemplateMeta[], Error>;
55
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]` */
@@ -50,11 +50,11 @@ export function useEmailAccount() {
50
50
  return useServiceQuery("useEmailAccount", { queryKey: accountKey(), queryFn: accountInfo });
51
51
  }
52
52
  /** Creates this app's account; the variable is its optional send group
53
- * (`null` or omitted: owner-only), or `{ send_group?, account_senders? }`. */
53
+ * (`null` or omitted: owner-only), or `{ send_group?, account_senders?, reply_to? }`. */
54
54
  export function useCreateEmailAccount() {
55
55
  return useServiceMutation("useCreateEmailAccount", {
56
56
  mutationFn: (input) => typeof input === "object" && input !== null
57
- ? createAccount(input.send_group, input.account_senders)
57
+ ? createAccount(input.send_group, input.account_senders, input.reply_to)
58
58
  : createAccount(input ?? undefined),
59
59
  invalidates: () => [accountsKey(), accountKey()],
60
60
  });
@@ -76,6 +76,15 @@ export function useSetEmailAccountAccess() {
76
76
  invalidates: () => [accountsKey(), accountKey()],
77
77
  });
78
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),
85
+ invalidates: () => [accountsKey(), accountKey()],
86
+ });
87
+ }
79
88
  // ── Templates ────────────────────────────────────────────────────────────
80
89
  /** This app's templates (metadata only), as an infinite query. */
81
90
  export function useTemplates(options = {}) {
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";
1
+ export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useSetEmailReplyTo, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.js";
2
2
  export type { CreateEmailAccountInput, CreateTemplateInput, PageOptions, SetEmailAccountAccessInput, UpdateTemplateInput, } from "./hooks.js";
3
3
  export type { AccountSendInput, RawSendInput, SendContent, SendInput, SendKey, SendResult } from "./api.js";
4
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
@@ -13,7 +13,10 @@
13
13
  // account send to `"self"` logs `recipients: []`, one to `"owner"` logs a
14
14
  // stand-in owner address — the fake has no real account emails. Writes
15
15
  // need a signed-in user; a signed-out write and a missing account or
16
- // 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
17
20
  // `isMockMode()` branch in index.ts, so a production build carries none of
18
21
  // it.
19
22
  const MARKER = "pilely-mock-fake:simple-email";
@@ -48,6 +51,8 @@ function accountPayload(account) {
48
51
  send_group: account.send_group,
49
52
  // State persisted before account_senders existed reads the default.
50
53
  account_senders: account.account_senders ?? "send_group",
54
+ // State persisted before reply_to existed reads none.
55
+ reply_to: account.reply_to ?? null,
51
56
  created_time_stamp: account.created_time_stamp,
52
57
  };
53
58
  }
@@ -64,6 +69,16 @@ function parseAccountSenders(body) {
64
69
  const value = body.account_senders;
65
70
  return ACCOUNT_SENDERS.includes(value) ? value : null;
66
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
+ }
67
82
  /** The service's "same body": the caller, the mode, `to` as a
68
83
  * case-insensitive set, `to_account`, the template and its variables, and
69
84
  * inline content. */
@@ -128,13 +143,14 @@ export const createSimpleEmailFake = (ctx) => {
128
143
  function findAccount(appId) {
129
144
  return state.accounts.find((a) => a.app_id === appId);
130
145
  }
131
- function createAccount(appId, sendGroup, accountSenders) {
146
+ function createAccount(appId, sendGroup, accountSenders, replyTo) {
132
147
  const account = {
133
148
  app_id: appId,
134
149
  address: `${appId}@${SENDING_DOMAIN}`,
135
150
  display_name: null,
136
151
  send_group: sendGroup,
137
152
  account_senders: accountSenders,
153
+ reply_to: replyTo,
138
154
  created_time_stamp: ctx.now(),
139
155
  templates: [],
140
156
  };
@@ -177,7 +193,7 @@ export const createSimpleEmailFake = (ctx) => {
177
193
  if (key !== undefined && (typeof key !== "string" || !IDEMPOTENCY_KEY.test(key))) {
178
194
  return ctx.refuse(400, "bad_request", "idempotency_key must be 1..=128 characters of [A-Za-z0-9._:-]");
179
195
  }
180
- const account = findAccount(body.app_id) ?? createAccount(body.app_id, null, "send_group");
196
+ const account = findAccount(body.app_id) ?? createAccount(body.app_id, null, "send_group", null);
181
197
  const user = ctx.user();
182
198
  const caller = user?.id ?? "mock-user";
183
199
  const print = fingerprint(caller, body, to);
@@ -272,8 +288,12 @@ export const createSimpleEmailFake = (ctx) => {
272
288
  if (accountSenders === null) {
273
289
  return ctx.refuse(400, "bad_request", 'account_senders must be "send_group" or "app_users"');
274
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
+ }
275
295
  const sendGroup = typeof body.send_group === "string" ? body.send_group : null;
276
- const account = createAccount(body.app_id, sendGroup, accountSenders ?? "send_group");
296
+ const account = createAccount(body.app_id, sendGroup, accountSenders ?? "send_group", replyTo ?? null);
277
297
  save();
278
298
  return ctx.ok({ ok: true, account: accountPayload(account) });
279
299
  }
@@ -328,6 +348,20 @@ export const createSimpleEmailFake = (ctx) => {
328
348
  save();
329
349
  return ctx.ok({ ok: true, account: accountPayload(account) });
330
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;
362
+ save();
363
+ return ctx.ok({ ok: true, account: accountPayload(account) });
364
+ }
331
365
  if (route === "templates/create") {
332
366
  if (!signedIn || !account)
333
367
  return ctx.notFound();
package/dist/types.d.ts CHANGED
@@ -39,6 +39,9 @@ export interface Account {
39
39
  * `read_group`/`write_group`, where `null` means everyone. */
40
40
  send_group: string | null;
41
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;
42
45
  created_time_stamp: number;
43
46
  }
44
47
  /** `daily_cap`'s three siblings are nullable and sit BESIDE `account`, not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilely/simple-email",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "React hooks for the simple_email service.",
5
5
  "license": "MIT",
6
6
  "type": "module",