@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 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
- export declare function createTemplate(name: string, subject: string, html: string): Promise<TemplateMeta>;
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: appId(), to: input.to, template_id: input.template_id, variables: input.variables }
16
- : { app_id: appId(), to: input.to, subject: input.subject, html: input.html };
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: appId(),
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: appId() };
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/${appId()}/info`,
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/${appId()}/delete` });
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/${appId()}/access/set`,
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/${appId()}/templates/create`,
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/${appId()}/templates/list`,
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/${appId()}/templates/${templateNanoid}/info`,
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/${appId()}/templates/${templateNanoid}/update`,
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/${appId()}/templates/${templateNanoid}/delete`,
188
+ path: `/accounts/${requireAppId()}/templates/${templateNanoid}/delete`,
166
189
  });
167
190
  }
package/dist/mock.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { MockServiceFactory } from "@pilely/core";
2
+ export declare const createSimpleEmailFake: MockServiceFactory;
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
- name: string;
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: string;
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.1.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.1.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
  }