@pilely/simple-email 0.1.0 → 0.3.0
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 +62 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +37 -14
- package/dist/mock.d.ts +2 -0
- package/dist/mock.js +289 -0
- package/dist/types.d.ts +23 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -30,7 +30,69 @@ There is no top-level `templates/...` route. Every template call is
|
|
|
30
30
|
`createTemplate`/`listTemplates`/`templateInfo`/`updateTemplate`/`deleteTemplate`
|
|
31
31
|
all reach it that way.
|
|
32
32
|
|
|
33
|
+
## Mock mode
|
|
34
|
+
|
|
35
|
+
Importing this package in a build made with `VITE_PILELY_MOCK=1` registers
|
|
36
|
+
an in-browser fake of `simple_email` behind `@pilely/core`'s mock runtime —
|
|
37
|
+
nothing else to call. Every method here then answers from memory with the
|
|
38
|
+
JSON the real service returns.
|
|
39
|
+
|
|
40
|
+
**Nothing is ever sent.** `send()` appends the message to an in-memory
|
|
41
|
+
outbox — the rows `listSends()` answers — and logs it to the console.
|
|
42
|
+
Accounts and templates are kept in memory and persisted to `localStorage`
|
|
43
|
+
across reloads; an app's account is created on its first send if it does
|
|
44
|
+
not exist yet. A missing account or template answers the bare 404, and a
|
|
45
|
+
second account for the same app answers `account_exists` (409). The
|
|
46
|
+
50-recipient bound on a send is kept, since it is the request's shape.
|
|
47
|
+
|
|
48
|
+
What it does not: send groups, the daily cap, the phone gate, rate limits
|
|
49
|
+
and credit checks.
|
|
50
|
+
|
|
51
|
+
Writes need a signed-in user: a signed-out write answers the uniform bare
|
|
52
|
+
404, which reaches the app as `PilelyError(404, null)`, exactly where the
|
|
53
|
+
real service would hide it. Mock mode is on only in a Vite build with
|
|
54
|
+
`VITE_PILELY_MOCK=1`; see [`@pilely/core`](../core#mock-mode) for the
|
|
55
|
+
switch, `seedMock()` and `resetMock()`.
|
|
56
|
+
|
|
33
57
|
## Status
|
|
34
58
|
|
|
35
59
|
This is a pre-1.0 package and moves with the platform. Nothing here is
|
|
36
60
|
published to the registry yet. Zero runtime dependencies.
|
|
61
|
+
|
|
62
|
+
## Templates can be nameless, and can lose their body
|
|
63
|
+
|
|
64
|
+
Two degraded shapes the service keeps deliberately visible, both reflected
|
|
65
|
+
in the types:
|
|
66
|
+
|
|
67
|
+
- `name` is `string | null`. A nameless template is legal — `createTemplate`
|
|
68
|
+
takes `string | null` and hands the `null` straight back.
|
|
69
|
+
- `html` is **optional, not nullable**. When the stored object behind a
|
|
70
|
+
template is missing, `templateInfo` omits the key rather than sending
|
|
71
|
+
`null`. Discriminate with `"html" in template`, and never assume a body.
|
|
72
|
+
|
|
73
|
+
## Error codes
|
|
74
|
+
|
|
75
|
+
Refusals arrive as `PilelyError` with a `code` drawn from
|
|
76
|
+
`SimpleEmailErrorCode`. Here matching on the code is not optional:
|
|
77
|
+
|
|
78
|
+
**`daily_cap_exceeded` and `rate_limited` both answer 429.** One means the
|
|
79
|
+
app has spent its UTC-day send allowance (retry tomorrow, or not at all);
|
|
80
|
+
the other is an ordinary burst window (retry shortly). The status cannot
|
|
81
|
+
tell them apart — only the code can.
|
|
82
|
+
|
|
83
|
+
`send` can also answer `phone_verification_required` (403) and
|
|
84
|
+
`phone_verification_unavailable` (503) from the phone gate, and
|
|
85
|
+
`out_of_traffic_credits` (503). Provisioning adds `account_exists`,
|
|
86
|
+
`account_limit_reached` and `template_limit_reached` (all 409). The
|
|
87
|
+
remainder: `not_found` (404, the uniform hide), `bad_request` (400),
|
|
88
|
+
`unauthenticated` (401), `bad_forwarded_identity` (401), `internal` (500).
|
|
89
|
+
|
|
90
|
+
A member-role caller sees a uniform 404 where the owner would see a 503
|
|
91
|
+
`out_of_traffic_credits` — worth knowing before debugging a member's send.
|
|
92
|
+
|
|
93
|
+
## Limits worth knowing before you call `send`
|
|
94
|
+
|
|
95
|
+
- Billed **per recipient**, not per call.
|
|
96
|
+
- At most 50 recipients per send.
|
|
97
|
+
- 200 sends per app per UTC day — the `daily_cap_exceeded` wall above.
|
|
98
|
+
- `send_id` is the idempotency key.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Account, AccountCursor, AccountInfoEnvelope, SendCursor, SendRow, Template, TemplateCursor, TemplateMeta } from "./types.js";
|
|
2
|
-
export 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
3
|
/** `to` is capped at the service's `max_recipients_per_send` (50 by default). */
|
|
4
4
|
export type SendContent = {
|
|
5
5
|
template_id: string;
|
|
@@ -72,8 +72,12 @@ export declare function deleteAccount(): Promise<void>;
|
|
|
72
72
|
* means owner-only. */
|
|
73
73
|
export declare function setAccountAccess(sendGroup: string | null): Promise<Account>;
|
|
74
74
|
/** Returns metadata only — no `html`. Use `templateInfo` to read the body
|
|
75
|
-
* back.
|
|
76
|
-
|
|
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>;
|
|
77
81
|
/** The paged primitive; use `listAllTemplates` to walk to the end. Rows
|
|
78
82
|
* carry metadata only — no `html`. */
|
|
79
83
|
export declare function listTemplates(options?: ListTemplatesOptions): Promise<{
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
// @pilely/simple-email — a typed wrapper over the simple_email service's 12
|
|
2
2
|
// POST routes. See README.md for where app_id lives on each route: this is
|
|
3
3
|
// the one service where it moves between the body and the path.
|
|
4
|
-
import { appId, call, collectPages } from "@pilely/core";
|
|
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
|
+
}
|
|
5
24
|
// ── Sending ──────────────────────────────────────────────────────────────
|
|
6
25
|
/**
|
|
7
26
|
* `app_id` is added from core's `appId()` — it is not a caller parameter.
|
|
@@ -12,8 +31,8 @@ import { appId, call, collectPages } from "@pilely/core";
|
|
|
12
31
|
*/
|
|
13
32
|
export async function send(input) {
|
|
14
33
|
const body = "template_id" in input
|
|
15
|
-
? { app_id:
|
|
16
|
-
: { app_id:
|
|
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 };
|
|
17
36
|
return call({
|
|
18
37
|
service: "simple-email",
|
|
19
38
|
path: "/send",
|
|
@@ -27,7 +46,7 @@ export async function listSends(options = {}) {
|
|
|
27
46
|
service: "simple-email",
|
|
28
47
|
path: "/sends/list",
|
|
29
48
|
body: {
|
|
30
|
-
app_id:
|
|
49
|
+
app_id: requireAppId(),
|
|
31
50
|
limit: options.limit,
|
|
32
51
|
after_created_time_stamp: options.after_created_time_stamp,
|
|
33
52
|
after_send_id: options.after_send_id,
|
|
@@ -46,7 +65,7 @@ export async function listAllSends() {
|
|
|
46
65
|
* key is left off the wire, meaning owner-only. `setAccountAccess` below
|
|
47
66
|
* is the opposite: its key is always required. */
|
|
48
67
|
export async function createAccount(sendGroup) {
|
|
49
|
-
const body = { app_id:
|
|
68
|
+
const body = { app_id: requireAppId() };
|
|
50
69
|
if (sendGroup !== undefined) {
|
|
51
70
|
body.send_group = sendGroup;
|
|
52
71
|
}
|
|
@@ -83,7 +102,7 @@ export async function listAllAccounts() {
|
|
|
83
102
|
export async function accountInfo() {
|
|
84
103
|
const json = await call({
|
|
85
104
|
service: "simple-email",
|
|
86
|
-
path: `/accounts/${
|
|
105
|
+
path: `/accounts/${requireAppId()}/info`,
|
|
87
106
|
});
|
|
88
107
|
return {
|
|
89
108
|
account: json.account,
|
|
@@ -94,7 +113,7 @@ export async function accountInfo() {
|
|
|
94
113
|
};
|
|
95
114
|
}
|
|
96
115
|
export async function deleteAccount() {
|
|
97
|
-
await call({ service: "simple-email", path: `/accounts/${
|
|
116
|
+
await call({ service: "simple-email", path: `/accounts/${requireAppId()}/delete` });
|
|
98
117
|
}
|
|
99
118
|
/** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
|
|
100
119
|
* the opposite of `createAccount`, where the key is optional. `null`
|
|
@@ -102,18 +121,22 @@ export async function deleteAccount() {
|
|
|
102
121
|
export async function setAccountAccess(sendGroup) {
|
|
103
122
|
const json = await call({
|
|
104
123
|
service: "simple-email",
|
|
105
|
-
path: `/accounts/${
|
|
124
|
+
path: `/accounts/${requireAppId()}/access/set`,
|
|
106
125
|
body: { send_group: sendGroup },
|
|
107
126
|
});
|
|
108
127
|
return json.account;
|
|
109
128
|
}
|
|
110
129
|
// ── Templates (nested under the account) ────────────────────────────────
|
|
111
130
|
/** Returns metadata only — no `html`. Use `templateInfo` to read the body
|
|
112
|
-
* back.
|
|
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`. */
|
|
113
136
|
export async function createTemplate(name, subject, html) {
|
|
114
137
|
const json = await call({
|
|
115
138
|
service: "simple-email",
|
|
116
|
-
path: `/accounts/${
|
|
139
|
+
path: `/accounts/${requireAppId()}/templates/create`,
|
|
117
140
|
body: { name, subject, html },
|
|
118
141
|
});
|
|
119
142
|
return json.template;
|
|
@@ -123,7 +146,7 @@ export async function createTemplate(name, subject, html) {
|
|
|
123
146
|
export async function listTemplates(options = {}) {
|
|
124
147
|
const json = await call({
|
|
125
148
|
service: "simple-email",
|
|
126
|
-
path: `/accounts/${
|
|
149
|
+
path: `/accounts/${requireAppId()}/templates/list`,
|
|
127
150
|
body: {
|
|
128
151
|
limit: options.limit,
|
|
129
152
|
after_created_time_stamp: options.after_created_time_stamp,
|
|
@@ -142,7 +165,7 @@ export async function listAllTemplates() {
|
|
|
142
165
|
export async function templateInfo(templateNanoid) {
|
|
143
166
|
const json = await call({
|
|
144
167
|
service: "simple-email",
|
|
145
|
-
path: `/accounts/${
|
|
168
|
+
path: `/accounts/${requireAppId()}/templates/${templateNanoid}/info`,
|
|
146
169
|
});
|
|
147
170
|
return json.template;
|
|
148
171
|
}
|
|
@@ -154,7 +177,7 @@ export async function templateInfo(templateNanoid) {
|
|
|
154
177
|
export async function updateTemplate(templateNanoid, body) {
|
|
155
178
|
const json = await call({
|
|
156
179
|
service: "simple-email",
|
|
157
|
-
path: `/accounts/${
|
|
180
|
+
path: `/accounts/${requireAppId()}/templates/${templateNanoid}/update`,
|
|
158
181
|
body,
|
|
159
182
|
});
|
|
160
183
|
return json.template;
|
|
@@ -162,6 +185,6 @@ export async function updateTemplate(templateNanoid, body) {
|
|
|
162
185
|
export async function deleteTemplate(templateNanoid) {
|
|
163
186
|
await call({
|
|
164
187
|
service: "simple-email",
|
|
165
|
-
path: `/accounts/${
|
|
188
|
+
path: `/accounts/${requireAppId()}/templates/${templateNanoid}/delete`,
|
|
166
189
|
});
|
|
167
190
|
}
|
package/dist/mock.d.ts
ADDED
package/dist/mock.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// The in-browser fake of the simple_email service that `@pilely/core`'s
|
|
2
|
+
// mock runtime routes to in mock mode. It answers every route this
|
|
3
|
+
// package's wrapper calls with the JSON the real service returns. Nothing
|
|
4
|
+
// is ever sent: `send` appends to an in-memory outbox (the rows `listSends`
|
|
5
|
+
// answers) and logs the message to the console. Accounts and templates
|
|
6
|
+
// live in memory and persist through the core store; an app's account is
|
|
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
|
|
9
|
+
// 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
|
|
11
|
+
// `isMockMode()` branch in index.ts, so a production build carries none of
|
|
12
|
+
// it.
|
|
13
|
+
const MARKER = "pilely-mock-fake:simple-email";
|
|
14
|
+
const DEFAULT_PAGE_SIZE = 50;
|
|
15
|
+
const MAX_PAGE_SIZE = 100;
|
|
16
|
+
const MAX_RECIPIENTS = 50;
|
|
17
|
+
const DAILY_CAP = 200;
|
|
18
|
+
const SENDING_DOMAIN = "mail.pilely.invalid";
|
|
19
|
+
function isObject(value) {
|
|
20
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
function compare(a, b) {
|
|
23
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
24
|
+
}
|
|
25
|
+
function pageSize(body) {
|
|
26
|
+
const raw = body.limit === undefined || body.limit === null ? DEFAULT_PAGE_SIZE : Number(body.limit);
|
|
27
|
+
return Math.min(MAX_PAGE_SIZE, Math.max(1, Number.isFinite(raw) ? Math.trunc(raw) : DEFAULT_PAGE_SIZE));
|
|
28
|
+
}
|
|
29
|
+
function render(text, variables) {
|
|
30
|
+
return text.replace(/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (whole, name) => name in variables ? String(variables[name]) : whole);
|
|
31
|
+
}
|
|
32
|
+
function accountPayload(account) {
|
|
33
|
+
return {
|
|
34
|
+
app_id: account.app_id,
|
|
35
|
+
address: account.address,
|
|
36
|
+
display_name: account.display_name,
|
|
37
|
+
send_group: account.send_group,
|
|
38
|
+
created_time_stamp: account.created_time_stamp,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function templateMeta(template) {
|
|
42
|
+
return {
|
|
43
|
+
template_id: template.template_id,
|
|
44
|
+
name: template.name,
|
|
45
|
+
subject: template.subject,
|
|
46
|
+
created_time_stamp: template.created_time_stamp,
|
|
47
|
+
updated_time_stamp: template.updated_time_stamp,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Newest-first keyset paging over rows already sorted, with the cursor
|
|
52
|
+
* taken from the last row whenever the page is full — the service's rule.
|
|
53
|
+
*/
|
|
54
|
+
function keysetPage(rows, body, keys, key, cursor) {
|
|
55
|
+
const [msKey, idKey] = keys;
|
|
56
|
+
const hasMs = body[msKey] !== undefined && body[msKey] !== null;
|
|
57
|
+
const hasId = body[idKey] !== undefined && body[idKey] !== null;
|
|
58
|
+
if (hasMs !== hasId)
|
|
59
|
+
return null;
|
|
60
|
+
const limit = pageSize(body);
|
|
61
|
+
let sorted = [...rows].sort((a, b) => {
|
|
62
|
+
const [am, ai] = key(a);
|
|
63
|
+
const [bm, bi] = key(b);
|
|
64
|
+
return compare(bm, am) || compare(bi, ai);
|
|
65
|
+
});
|
|
66
|
+
if (hasMs) {
|
|
67
|
+
const afterMs = Number(body[msKey]);
|
|
68
|
+
const afterId = String(body[idKey]);
|
|
69
|
+
sorted = sorted.filter((row) => {
|
|
70
|
+
const [ms, id] = key(row);
|
|
71
|
+
return ms < afterMs || (ms === afterMs && id < afterId);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const page = sorted.slice(0, limit);
|
|
75
|
+
const last = page[page.length - 1];
|
|
76
|
+
return { page, next: page.length === limit && last ? cursor(last) : null };
|
|
77
|
+
}
|
|
78
|
+
export const createSimpleEmailFake = (ctx) => {
|
|
79
|
+
const state = ctx.load() ?? { accounts: [], sends: [] };
|
|
80
|
+
const save = () => ctx.save(state);
|
|
81
|
+
const cursorRefusal = () => ctx.refuse(400, "bad_request", "cursor needs BOTH keys or neither");
|
|
82
|
+
function findAccount(appId) {
|
|
83
|
+
return state.accounts.find((a) => a.app_id === appId);
|
|
84
|
+
}
|
|
85
|
+
function createAccount(appId, sendGroup) {
|
|
86
|
+
const account = {
|
|
87
|
+
app_id: appId,
|
|
88
|
+
address: `${appId}@${SENDING_DOMAIN}`,
|
|
89
|
+
display_name: null,
|
|
90
|
+
send_group: sendGroup,
|
|
91
|
+
created_time_stamp: ctx.now(),
|
|
92
|
+
templates: [],
|
|
93
|
+
};
|
|
94
|
+
state.accounts.push(account);
|
|
95
|
+
return account;
|
|
96
|
+
}
|
|
97
|
+
function send(body) {
|
|
98
|
+
if (typeof body.app_id !== "string" || body.app_id === "") {
|
|
99
|
+
return ctx.refuse(400, "bad_request", "app_id is required");
|
|
100
|
+
}
|
|
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`);
|
|
104
|
+
}
|
|
105
|
+
const hasTemplate = body.template_id !== undefined && body.template_id !== null;
|
|
106
|
+
const hasInline = body.subject !== undefined || body.html !== undefined;
|
|
107
|
+
if (hasTemplate === hasInline) {
|
|
108
|
+
return ctx.refuse(400, "bad_request", "send either template_id or subject + html");
|
|
109
|
+
}
|
|
110
|
+
const account = findAccount(body.app_id) ?? createAccount(body.app_id, null);
|
|
111
|
+
let subject;
|
|
112
|
+
let html;
|
|
113
|
+
let templateId = null;
|
|
114
|
+
if (hasTemplate) {
|
|
115
|
+
const template = account.templates.find((t) => t.template_id === body.template_id);
|
|
116
|
+
if (!template) {
|
|
117
|
+
return ctx.refuse(400, "bad_request", "template_id names no template of this account");
|
|
118
|
+
}
|
|
119
|
+
const variables = isObject(body.variables) ? body.variables : {};
|
|
120
|
+
subject = render(template.subject, variables);
|
|
121
|
+
html = render(template.html, variables);
|
|
122
|
+
templateId = template.template_id;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
if (typeof body.subject !== "string" || typeof body.html !== "string") {
|
|
126
|
+
return ctx.refuse(400, "bad_request", "subject and html are required together");
|
|
127
|
+
}
|
|
128
|
+
subject = body.subject;
|
|
129
|
+
html = body.html;
|
|
130
|
+
}
|
|
131
|
+
const now = ctx.now();
|
|
132
|
+
const row = {
|
|
133
|
+
app_id: account.app_id,
|
|
134
|
+
send_id: ctx.nanoid(),
|
|
135
|
+
requester_user_id: ctx.user()?.id ?? "mock-user",
|
|
136
|
+
via_app: true,
|
|
137
|
+
kind: hasTemplate ? "template" : "inline",
|
|
138
|
+
template_id: templateId,
|
|
139
|
+
subject,
|
|
140
|
+
recipients: [...to],
|
|
141
|
+
recipient_count: to.length,
|
|
142
|
+
outcome: "sent",
|
|
143
|
+
outcome_reason: null,
|
|
144
|
+
fired_at: now,
|
|
145
|
+
};
|
|
146
|
+
state.sends.push(row);
|
|
147
|
+
save();
|
|
148
|
+
console.log("[pilely mock] simple-email outbox: nothing was sent", {
|
|
149
|
+
from: account.address,
|
|
150
|
+
to: row.recipients,
|
|
151
|
+
subject,
|
|
152
|
+
html,
|
|
153
|
+
});
|
|
154
|
+
return ctx.ok({ ok: true, send_id: row.send_id, recipient_count: row.recipient_count });
|
|
155
|
+
}
|
|
156
|
+
function handle(path, rawBody) {
|
|
157
|
+
const body = isObject(rawBody) ? rawBody : {};
|
|
158
|
+
const signedIn = ctx.user() !== null;
|
|
159
|
+
if (path === "/send") {
|
|
160
|
+
if (!signedIn)
|
|
161
|
+
return ctx.notFound();
|
|
162
|
+
return send(body);
|
|
163
|
+
}
|
|
164
|
+
if (path === "/sends/list") {
|
|
165
|
+
const appId = typeof body.app_id === "string" ? body.app_id : "";
|
|
166
|
+
const rows = state.sends.filter((s) => s.app_id === appId);
|
|
167
|
+
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
|
+
if (!result)
|
|
169
|
+
return cursorRefusal();
|
|
170
|
+
const sends = result.page.map(({ app_id: _appId, ...row }) => row);
|
|
171
|
+
return ctx.ok({ ok: true, sends, next_cursor: result.next });
|
|
172
|
+
}
|
|
173
|
+
if (path === "/accounts/create") {
|
|
174
|
+
if (!signedIn)
|
|
175
|
+
return ctx.notFound();
|
|
176
|
+
if (typeof body.app_id !== "string" || body.app_id === "") {
|
|
177
|
+
return ctx.refuse(400, "bad_request", "app_id is required");
|
|
178
|
+
}
|
|
179
|
+
if (findAccount(body.app_id)) {
|
|
180
|
+
return ctx.refuse(409, "account_exists", "this app already has an email account");
|
|
181
|
+
}
|
|
182
|
+
const sendGroup = typeof body.send_group === "string" ? body.send_group : null;
|
|
183
|
+
const account = createAccount(body.app_id, sendGroup);
|
|
184
|
+
save();
|
|
185
|
+
return ctx.ok({ ok: true, account: accountPayload(account) });
|
|
186
|
+
}
|
|
187
|
+
if (path === "/accounts/list") {
|
|
188
|
+
const result = keysetPage(state.accounts, body, ["after_created_time_stamp", "after_app_id"], (a) => [a.created_time_stamp, a.app_id], (a) => ({ after_created_time_stamp: a.created_time_stamp, after_app_id: a.app_id }));
|
|
189
|
+
if (!result)
|
|
190
|
+
return cursorRefusal();
|
|
191
|
+
return ctx.ok({ ok: true, accounts: result.page.map(accountPayload), next_cursor: result.next });
|
|
192
|
+
}
|
|
193
|
+
const parts = path.split("/").filter((p) => p !== "").map(decodeURIComponent);
|
|
194
|
+
if (parts[0] !== "accounts" || parts[1] === undefined)
|
|
195
|
+
return null;
|
|
196
|
+
const account = findAccount(parts[1]);
|
|
197
|
+
const rest = parts.slice(2);
|
|
198
|
+
const route = rest.join("/");
|
|
199
|
+
if (route === "info") {
|
|
200
|
+
if (!account)
|
|
201
|
+
return ctx.notFound();
|
|
202
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
203
|
+
const sentToday = state.sends
|
|
204
|
+
.filter((s) => s.app_id === account.app_id && new Date(s.fired_at).toISOString().slice(0, 10) === today)
|
|
205
|
+
.reduce((sum, s) => sum + s.recipient_count, 0);
|
|
206
|
+
return ctx.ok({
|
|
207
|
+
ok: true,
|
|
208
|
+
account: accountPayload(account),
|
|
209
|
+
daily_cap: DAILY_CAP,
|
|
210
|
+
sent_today: sentToday,
|
|
211
|
+
credit_blocked: false,
|
|
212
|
+
phone_verified: true,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (route === "delete") {
|
|
216
|
+
if (!signedIn || !account)
|
|
217
|
+
return ctx.notFound();
|
|
218
|
+
state.accounts = state.accounts.filter((a) => a !== account);
|
|
219
|
+
save();
|
|
220
|
+
return ctx.ok({ ok: true });
|
|
221
|
+
}
|
|
222
|
+
if (route === "access/set") {
|
|
223
|
+
if (!signedIn || !account)
|
|
224
|
+
return ctx.notFound();
|
|
225
|
+
if (!("send_group" in body))
|
|
226
|
+
return ctx.refuse(400, "bad_request", "send_group is required");
|
|
227
|
+
account.send_group = typeof body.send_group === "string" ? body.send_group : null;
|
|
228
|
+
save();
|
|
229
|
+
return ctx.ok({ ok: true, account: accountPayload(account) });
|
|
230
|
+
}
|
|
231
|
+
if (route === "templates/create") {
|
|
232
|
+
if (!signedIn || !account)
|
|
233
|
+
return ctx.notFound();
|
|
234
|
+
if (typeof body.subject !== "string" || typeof body.html !== "string") {
|
|
235
|
+
return ctx.refuse(400, "bad_request", "subject and html are required");
|
|
236
|
+
}
|
|
237
|
+
const now = ctx.now();
|
|
238
|
+
const template = {
|
|
239
|
+
template_id: ctx.nanoid(),
|
|
240
|
+
name: typeof body.name === "string" ? body.name : null,
|
|
241
|
+
subject: body.subject,
|
|
242
|
+
html: body.html,
|
|
243
|
+
created_time_stamp: now,
|
|
244
|
+
updated_time_stamp: now,
|
|
245
|
+
};
|
|
246
|
+
account.templates.push(template);
|
|
247
|
+
save();
|
|
248
|
+
return ctx.ok({ ok: true, template: templateMeta(template) });
|
|
249
|
+
}
|
|
250
|
+
if (route === "templates/list") {
|
|
251
|
+
if (!account)
|
|
252
|
+
return ctx.notFound();
|
|
253
|
+
const result = keysetPage(account.templates, body, ["after_created_time_stamp", "after_template_id"], (t) => [t.created_time_stamp, t.template_id], (t) => ({ after_created_time_stamp: t.created_time_stamp, after_template_id: t.template_id }));
|
|
254
|
+
if (!result)
|
|
255
|
+
return cursorRefusal();
|
|
256
|
+
return ctx.ok({ ok: true, templates: result.page.map(templateMeta), next_cursor: result.next });
|
|
257
|
+
}
|
|
258
|
+
if (rest[0] !== "templates" || rest[1] === undefined || rest.length !== 3)
|
|
259
|
+
return null;
|
|
260
|
+
const template = account?.templates.find((t) => t.template_id === rest[1]);
|
|
261
|
+
const action = rest[2];
|
|
262
|
+
if (action === "info") {
|
|
263
|
+
if (!template)
|
|
264
|
+
return ctx.notFound();
|
|
265
|
+
return ctx.ok({ ok: true, template: { ...templateMeta(template), html: template.html } });
|
|
266
|
+
}
|
|
267
|
+
if (action === "update") {
|
|
268
|
+
if (!signedIn || !template)
|
|
269
|
+
return ctx.notFound();
|
|
270
|
+
if (typeof body.subject !== "string" || typeof body.html !== "string") {
|
|
271
|
+
return ctx.refuse(400, "bad_request", "subject and html are both required");
|
|
272
|
+
}
|
|
273
|
+
template.subject = body.subject;
|
|
274
|
+
template.html = body.html;
|
|
275
|
+
template.updated_time_stamp = ctx.now();
|
|
276
|
+
save();
|
|
277
|
+
return ctx.ok({ ok: true, template: templateMeta(template) });
|
|
278
|
+
}
|
|
279
|
+
if (action === "delete") {
|
|
280
|
+
if (!signedIn || !account || !template)
|
|
281
|
+
return ctx.notFound();
|
|
282
|
+
account.templates = account.templates.filter((t) => t !== template);
|
|
283
|
+
save();
|
|
284
|
+
return ctx.ok({ ok: true });
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
return { marker: MARKER, handle: (request) => handle(request.path, request.body) };
|
|
289
|
+
};
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
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`.
|
|
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. */
|
|
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";
|
|
1
12
|
/** `address` is derived at read time, never stored, and `null` when the
|
|
2
13
|
* app it names is gone. */
|
|
3
14
|
export interface Account {
|
|
@@ -21,15 +32,23 @@ export interface AccountInfoEnvelope {
|
|
|
21
32
|
/** What `templates/create` and `templates/update` answer with — no `html`. */
|
|
22
33
|
export interface TemplateMeta {
|
|
23
34
|
template_id: string;
|
|
24
|
-
/** Not updatable through `updateTemplate` — only `subject` and `html` are.
|
|
25
|
-
|
|
35
|
+
/** Not updatable through `updateTemplate` — only `subject` and `html` are.
|
|
36
|
+
* `null` when the template was created without one: the service accepts a
|
|
37
|
+
* nameless template and returns the name back as `null`. */
|
|
38
|
+
name: string | null;
|
|
26
39
|
subject: string;
|
|
27
40
|
created_time_stamp: number;
|
|
28
41
|
updated_time_stamp: number;
|
|
29
42
|
}
|
|
30
|
-
/** What `templates/{t}/info` answers with — `TemplateMeta` plus the body.
|
|
43
|
+
/** What `templates/{t}/info` answers with — `TemplateMeta` plus the body.
|
|
44
|
+
*
|
|
45
|
+
* `html` is OPTIONAL, not nullable: when the R2 object behind the template is
|
|
46
|
+
* missing (the compensated-create residue the service keeps visible so an
|
|
47
|
+
* owner can spot it) or its body is not valid UTF-8, the service omits the key
|
|
48
|
+
* entirely rather than sending `null`. Test with `"html" in template` or a
|
|
49
|
+
* plain falsy check — never assume the body is there. */
|
|
31
50
|
export interface Template extends TemplateMeta {
|
|
32
|
-
html
|
|
51
|
+
html?: string;
|
|
33
52
|
}
|
|
34
53
|
export interface SendRow {
|
|
35
54
|
send_id: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pilely/simple-email",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A typed client for the simple_email service.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
"directory": "npm/simple-email"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
|
-
"@pilely/core": "^0.
|
|
24
|
+
"@pilely/core": "^0.3.0"
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"build": "tsc -p tsconfig.json",
|
|
28
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
28
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
|
|
29
29
|
"test": "vitest run"
|
|
30
30
|
}
|
|
31
31
|
}
|