@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/README.md +96 -77
- package/dist/api.d.ts +133 -0
- package/dist/api.js +208 -0
- package/dist/hooks.d.ts +59 -0
- package/dist/hooks.js +116 -0
- package/dist/index.d.ts +4 -96
- package/dist/index.js +4 -190
- package/dist/mock.js +114 -14
- package/dist/types.d.ts +37 -8
- package/package.json +5 -3
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
|
-
|
|
2
|
-
export type {
|
|
3
|
-
|
|
4
|
-
export type
|
|
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 —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
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
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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(
|
|
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
|
}
|