@pilely/simple-email 0.3.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/dist/hooks.js ADDED
@@ -0,0 +1,116 @@
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";
3
+ /** `["pilely", "simple-email", "sends", options]` */
4
+ const sendsKey = () => serviceQueryKey("simple-email", "sends");
5
+ /** `["pilely", "simple-email", "accounts", options]` */
6
+ const accountsKey = () => serviceQueryKey("simple-email", "accounts");
7
+ /** `["pilely", "simple-email", "account"]` — this app's account info. */
8
+ const accountKey = () => serviceQueryKey("simple-email", "account");
9
+ /** `["pilely", "simple-email", "templates", options]` */
10
+ const templatesKey = () => serviceQueryKey("simple-email", "templates");
11
+ /** `["pilely", "simple-email", "template", templateId]` */
12
+ const templateKey = (templateId) => serviceQueryKey("simple-email", "template", templateId);
13
+ // ── Sending ──────────────────────────────────────────────────────────────
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`. */
18
+ export function useSend() {
19
+ return useServiceMutation("useSend", {
20
+ mutationFn: send,
21
+ invalidates: () => [sendsKey()],
22
+ });
23
+ }
24
+ /** This app's outbox, newest first, as an infinite query. */
25
+ export function useSends(options = {}) {
26
+ const { limit } = options;
27
+ return useServiceInfiniteQuery("useSends", {
28
+ queryKey: [...sendsKey(), { limit }],
29
+ fetchPage: async (cursor) => {
30
+ const page = await listSends({ limit, ...(cursor ?? {}) });
31
+ return { rows: page.sends, nextCursor: page.next_cursor };
32
+ },
33
+ });
34
+ }
35
+ // ── Accounts ─────────────────────────────────────────────────────────────
36
+ /** The caller's email accounts across apps, as an infinite query. */
37
+ export function useEmailAccounts(options = {}) {
38
+ const { limit } = options;
39
+ return useServiceInfiniteQuery("useEmailAccounts", {
40
+ queryKey: [...accountsKey(), { limit }],
41
+ fetchPage: async (cursor) => {
42
+ const page = await listAccounts({ limit, ...(cursor ?? {}) });
43
+ return { rows: page.accounts, nextCursor: page.next_cursor };
44
+ },
45
+ });
46
+ }
47
+ /** This app's account with its live state: daily cap, sent today, credit
48
+ * and phone verification. */
49
+ export function useEmailAccount() {
50
+ return useServiceQuery("useEmailAccount", { queryKey: accountKey(), queryFn: accountInfo });
51
+ }
52
+ /** Creates this app's account; the variable is its optional send group
53
+ * (`null` or omitted: owner-only), or `{ send_group?, account_senders? }`. */
54
+ export function useCreateEmailAccount() {
55
+ return useServiceMutation("useCreateEmailAccount", {
56
+ mutationFn: (input) => typeof input === "object" && input !== null
57
+ ? createAccount(input.send_group, input.account_senders)
58
+ : createAccount(input ?? undefined),
59
+ invalidates: () => [accountsKey(), accountKey()],
60
+ });
61
+ }
62
+ /** Deletes this app's account and the templates under it. */
63
+ export function useDeleteEmailAccount() {
64
+ return useServiceMutation("useDeleteEmailAccount", {
65
+ mutationFn: () => deleteAccount(),
66
+ invalidates: () => [accountsKey(), accountKey(), templatesKey(), serviceQueryKey("simple-email", "template")],
67
+ });
68
+ }
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. */
71
+ export function useSetEmailAccountAccess() {
72
+ return useServiceMutation("useSetEmailAccountAccess", {
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
+ // ── Templates ────────────────────────────────────────────────────────────
80
+ /** This app's templates (metadata only), as an infinite query. */
81
+ export function useTemplates(options = {}) {
82
+ const { limit } = options;
83
+ return useServiceInfiniteQuery("useTemplates", {
84
+ queryKey: [...templatesKey(), { limit }],
85
+ fetchPage: async (cursor) => {
86
+ const page = await listTemplates({ limit, ...(cursor ?? {}) });
87
+ return { rows: page.templates, nextCursor: page.next_cursor };
88
+ },
89
+ });
90
+ }
91
+ /** One template with its `html`. An `undefined` id disables the query. */
92
+ export function useTemplate(templateId) {
93
+ return useServiceQuery("useTemplate", {
94
+ queryKey: templateKey(templateId),
95
+ queryFn: () => templateInfo(templateId),
96
+ enabled: templateId !== undefined,
97
+ });
98
+ }
99
+ export function useCreateTemplate() {
100
+ return useServiceMutation("useCreateTemplate", {
101
+ mutationFn: ({ name, subject, html }) => createTemplate(name, subject, html),
102
+ invalidates: () => [templatesKey()],
103
+ });
104
+ }
105
+ export function useUpdateTemplate() {
106
+ return useServiceMutation("useUpdateTemplate", {
107
+ mutationFn: ({ templateId, subject, html }) => updateTemplate(templateId, { subject, html }),
108
+ invalidates: (_template, { templateId }) => [templatesKey(), templateKey(templateId)],
109
+ });
110
+ }
111
+ export function useDeleteTemplate() {
112
+ return useServiceMutation("useDeleteTemplate", {
113
+ mutationFn: deleteTemplate,
114
+ invalidates: (_none, templateId) => [templatesKey(), templateKey(templateId)],
115
+ });
116
+ }
package/dist/index.d.ts CHANGED
@@ -1,96 +1,4 @@
1
- import type { Account, AccountCursor, AccountInfoEnvelope, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta } from "./types.js";
2
- export type { Account, SimpleEmailErrorCode, AccountCursor, AccountInfoEnvelope, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta, } from "./types.js";
3
- /** `to` is capped at the service's `max_recipients_per_send` (50 by default). */
4
- export type SendContent = {
5
- template_id: string;
6
- variables?: Record<string, string>;
7
- } | {
8
- subject: string;
9
- html: string;
10
- };
11
- export type SendInput = {
12
- to: string[];
13
- } & SendContent;
14
- export interface ListSendsOptions {
15
- limit?: number;
16
- after_created_time_stamp?: number;
17
- after_send_id?: string;
18
- }
19
- export interface ListAccountsOptions {
20
- limit?: number;
21
- after_created_time_stamp?: number;
22
- after_app_id?: string;
23
- }
24
- export interface ListTemplatesOptions {
25
- limit?: number;
26
- after_created_time_stamp?: number;
27
- after_template_id?: string;
28
- }
29
- /** Both keys are REQUIRED — the server rejects either one missing with a
30
- * 400 (`ContentField::validate`), matching `updateTemplate`'s full-replace
31
- * contract: there is no patch form on this route. */
32
- export interface UpdateTemplateBody {
33
- subject: string;
34
- html: string;
35
- }
36
- /**
37
- * `app_id` is added from core's `appId()` — it is not a caller parameter.
38
- * Body size is capped on this route (like `createTemplate`/`updateTemplate`):
39
- * an oversize body is rejected by the server before any handler runs, so
40
- * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
41
- * carries a null `code`.
42
- */
43
- export declare function send(input: SendInput): Promise<{
44
- send_id: string;
45
- recipient_count: number;
46
- }>;
47
- /** `app_id` lives in the body here, like `send` — not in the path. The
48
- * paged primitive; use `listAllSends` to walk to the end. */
49
- export declare function listSends(options?: ListSendsOptions): Promise<{
50
- sends: SendRow[];
51
- next_cursor: SendCursor | null;
52
- }>;
53
- export declare function listAllSends(): Promise<SendRow[]>;
54
- /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
55
- * key is left off the wire, meaning owner-only. `setAccountAccess` below
56
- * is the opposite: its key is always required. */
57
- export declare function createAccount(sendGroup?: string | null): Promise<Account>;
58
- /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
59
- * not a selector. The paged primitive; use `listAllAccounts` to walk to
60
- * the end. */
61
- export declare function listAccounts(options?: ListAccountsOptions): Promise<{
62
- accounts: Account[];
63
- next_cursor: AccountCursor | null;
64
- }>;
65
- export declare function listAllAccounts(): Promise<Account[]>;
66
- /** Returns the account plus its live operational state — the four extra
67
- * fields are nullable siblings of `account`, not nested inside it. */
68
- export declare function accountInfo(): Promise<AccountInfoEnvelope>;
69
- export declare function deleteAccount(): Promise<void>;
70
- /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
71
- * the opposite of `createAccount`, where the key is optional. `null`
72
- * means owner-only. */
73
- export declare function setAccountAccess(sendGroup: string | null): Promise<Account>;
74
- /** Returns metadata only — no `html`. Use `templateInfo` to read the body
75
- * back.
76
- *
77
- * `name` is nullable because the service accepts a nameless template. It
78
- * stays a required POSITIONAL parameter — pass an explicit `null`, not
79
- * nothing — and `TemplateMeta.name` then comes back `null`. */
80
- export declare function createTemplate(name: string | null, subject: string, html: string): Promise<TemplateMeta>;
81
- /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
82
- * carry metadata only — no `html`. */
83
- export declare function listTemplates(options?: ListTemplatesOptions): Promise<{
84
- templates: TemplateMeta[];
85
- next_cursor: TemplateCursor | null;
86
- }>;
87
- export declare function listAllTemplates(): Promise<TemplateMeta[]>;
88
- /** The only template route whose answer carries `html`. */
89
- export declare function templateInfo(templateNanoid: string): Promise<Template>;
90
- /**
91
- * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
92
- * is not updatable at all. Returns metadata only, like `createTemplate`.
93
- * Subject to the same oversize-body caveat as `send`/`createTemplate`.
94
- */
95
- export declare function updateTemplate(templateNanoid: string, body: UpdateTemplateBody): Promise<TemplateMeta>;
96
- export declare function deleteTemplate(templateNanoid: string): Promise<void>;
1
+ export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, 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,190 +1,4 @@
1
- // @pilely/simple-email — a typed wrapper over the simple_email service's 12
2
- // POST routes. See README.md for where app_id lives on each route: this is
3
- // the one service where it moves between the body and the path.
4
- import { appId, call, collectPages, isMockMode, registerMockService } from "@pilely/core";
5
- import { createSimpleEmailFake } from "./mock.js";
6
- // Importing this package is all an app does to get its fake in mock mode.
7
- // Dead code in a build without `VITE_PILELY_MOCK=1`, fake included.
8
- if (isMockMode()) {
9
- registerMockService("simple-email", createSimpleEmailFake);
10
- }
11
- /**
12
- * Every route on this service needs an app id on the wire (body or path).
13
- * `@pilely/core`'s `appId()` can return `null` (no tag, `ready()` not yet
14
- * resolved) — this service cannot send that: a `null` in a path template
15
- * would silently become the literal string `"null"`. Fail loudly instead.
16
- */
17
- function requireAppId() {
18
- const id = appId();
19
- if (!id) {
20
- throw new Error('pilely client not loaded, <meta name="pilely-app"> missing, and no token pile_id claim available yet — call after ready()');
21
- }
22
- return id;
23
- }
24
- // ── Sending ──────────────────────────────────────────────────────────────
25
- /**
26
- * `app_id` is added from core's `appId()` — it is not a caller parameter.
27
- * Body size is capped on this route (like `createTemplate`/`updateTemplate`):
28
- * an oversize body is rejected by the server before any handler runs, so
29
- * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
30
- * carries a null `code`.
31
- */
32
- export async function send(input) {
33
- const body = "template_id" in input
34
- ? { app_id: requireAppId(), to: input.to, template_id: input.template_id, variables: input.variables }
35
- : { app_id: requireAppId(), to: input.to, subject: input.subject, html: input.html };
36
- return call({
37
- service: "simple-email",
38
- path: "/send",
39
- body,
40
- });
41
- }
42
- /** `app_id` lives in the body here, like `send` — not in the path. The
43
- * paged primitive; use `listAllSends` to walk to the end. */
44
- export async function listSends(options = {}) {
45
- const json = await call({
46
- service: "simple-email",
47
- path: "/sends/list",
48
- body: {
49
- app_id: requireAppId(),
50
- limit: options.limit,
51
- after_created_time_stamp: options.after_created_time_stamp,
52
- after_send_id: options.after_send_id,
53
- },
54
- });
55
- return { sends: json.sends, next_cursor: json.next_cursor };
56
- }
57
- export async function listAllSends() {
58
- return collectPages(async (cursor) => {
59
- const page = await listSends({ limit: 100, ...(cursor ?? {}) });
60
- return { rows: page.sends, nextCursor: page.next_cursor };
61
- });
62
- }
63
- // ── Accounts ─────────────────────────────────────────────────────────────
64
- /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
65
- * key is left off the wire, meaning owner-only. `setAccountAccess` below
66
- * is the opposite: its key is always required. */
67
- export async function createAccount(sendGroup) {
68
- const body = { app_id: requireAppId() };
69
- if (sendGroup !== undefined) {
70
- body.send_group = sendGroup;
71
- }
72
- const json = await call({
73
- service: "simple-email",
74
- path: "/accounts/create",
75
- body,
76
- });
77
- return json.account;
78
- }
79
- /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
80
- * not a selector. The paged primitive; use `listAllAccounts` to walk to
81
- * the end. */
82
- export async function listAccounts(options = {}) {
83
- const json = await call({
84
- service: "simple-email",
85
- path: "/accounts/list",
86
- body: {
87
- limit: options.limit,
88
- after_created_time_stamp: options.after_created_time_stamp,
89
- after_app_id: options.after_app_id,
90
- },
91
- });
92
- return { accounts: json.accounts, next_cursor: json.next_cursor };
93
- }
94
- export async function listAllAccounts() {
95
- return collectPages(async (cursor) => {
96
- const page = await listAccounts({ limit: 100, ...(cursor ?? {}) });
97
- return { rows: page.accounts, nextCursor: page.next_cursor };
98
- });
99
- }
100
- /** Returns the account plus its live operational state — the four extra
101
- * fields are nullable siblings of `account`, not nested inside it. */
102
- export async function accountInfo() {
103
- const json = await call({
104
- service: "simple-email",
105
- path: `/accounts/${requireAppId()}/info`,
106
- });
107
- return {
108
- account: json.account,
109
- daily_cap: json.daily_cap,
110
- sent_today: json.sent_today,
111
- credit_blocked: json.credit_blocked,
112
- phone_verified: json.phone_verified,
113
- };
114
- }
115
- export async function deleteAccount() {
116
- await call({ service: "simple-email", path: `/accounts/${requireAppId()}/delete` });
117
- }
118
- /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
119
- * the opposite of `createAccount`, where the key is optional. `null`
120
- * means owner-only. */
121
- export async function setAccountAccess(sendGroup) {
122
- const json = await call({
123
- service: "simple-email",
124
- path: `/accounts/${requireAppId()}/access/set`,
125
- body: { send_group: sendGroup },
126
- });
127
- return json.account;
128
- }
129
- // ── Templates (nested under the account) ────────────────────────────────
130
- /** Returns metadata only — no `html`. Use `templateInfo` to read the body
131
- * back.
132
- *
133
- * `name` is nullable because the service accepts a nameless template. It
134
- * stays a required POSITIONAL parameter — pass an explicit `null`, not
135
- * nothing — and `TemplateMeta.name` then comes back `null`. */
136
- export async function createTemplate(name, subject, html) {
137
- const json = await call({
138
- service: "simple-email",
139
- path: `/accounts/${requireAppId()}/templates/create`,
140
- body: { name, subject, html },
141
- });
142
- return json.template;
143
- }
144
- /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
145
- * carry metadata only — no `html`. */
146
- export async function listTemplates(options = {}) {
147
- const json = await call({
148
- service: "simple-email",
149
- path: `/accounts/${requireAppId()}/templates/list`,
150
- body: {
151
- limit: options.limit,
152
- after_created_time_stamp: options.after_created_time_stamp,
153
- after_template_id: options.after_template_id,
154
- },
155
- });
156
- return { templates: json.templates, next_cursor: json.next_cursor };
157
- }
158
- export async function listAllTemplates() {
159
- return collectPages(async (cursor) => {
160
- const page = await listTemplates({ limit: 100, ...(cursor ?? {}) });
161
- return { rows: page.templates, nextCursor: page.next_cursor };
162
- });
163
- }
164
- /** The only template route whose answer carries `html`. */
165
- export async function templateInfo(templateNanoid) {
166
- const json = await call({
167
- service: "simple-email",
168
- path: `/accounts/${requireAppId()}/templates/${templateNanoid}/info`,
169
- });
170
- return json.template;
171
- }
172
- /**
173
- * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
174
- * is not updatable at all. Returns metadata only, like `createTemplate`.
175
- * Subject to the same oversize-body caveat as `send`/`createTemplate`.
176
- */
177
- export async function updateTemplate(templateNanoid, body) {
178
- const json = await call({
179
- service: "simple-email",
180
- path: `/accounts/${requireAppId()}/templates/${templateNanoid}/update`,
181
- body,
182
- });
183
- return json.template;
184
- }
185
- export async function deleteTemplate(templateNanoid) {
186
- await call({
187
- service: "simple-email",
188
- path: `/accounts/${requireAppId()}/templates/${templateNanoid}/delete`,
189
- });
190
- }
1
+ // @pilely/simple-email — React hooks over the simple_email service: a query
2
+ // hook for every read, a mutation hook for every write. Render them inside
3
+ // @pilely/core's <PilelyProvider>.
4
+ export { useCreateEmailAccount, useCreateTemplate, useDeleteEmailAccount, useDeleteTemplate, useEmailAccount, useEmailAccounts, useSend, useSends, useSetEmailAccountAccess, useTemplate, useTemplates, useUpdateTemplate, } from "./hooks.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
  }