@pilely/simple-email 0.1.0 → 0.2.1

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
@@ -34,3 +34,41 @@ all reach it that way.
34
34
 
35
35
  This is a pre-1.0 package and moves with the platform. Nothing here is
36
36
  published to the registry yet. Zero runtime dependencies.
37
+
38
+ ## Templates can be nameless, and can lose their body
39
+
40
+ Two degraded shapes the service keeps deliberately visible, both reflected
41
+ in the types:
42
+
43
+ - `name` is `string | null`. A nameless template is legal — `createTemplate`
44
+ takes `string | null` and hands the `null` straight back.
45
+ - `html` is **optional, not nullable**. When the stored object behind a
46
+ template is missing, `templateInfo` omits the key rather than sending
47
+ `null`. Discriminate with `"html" in template`, and never assume a body.
48
+
49
+ ## Error codes
50
+
51
+ Refusals arrive as `PilelyError` with a `code` drawn from
52
+ `SimpleEmailErrorCode`. Here matching on the code is not optional:
53
+
54
+ **`daily_cap_exceeded` and `rate_limited` both answer 429.** One means the
55
+ app has spent its UTC-day send allowance (retry tomorrow, or not at all);
56
+ the other is an ordinary burst window (retry shortly). The status cannot
57
+ tell them apart — only the code can.
58
+
59
+ `send` can also answer `phone_verification_required` (403) and
60
+ `phone_verification_unavailable` (503) from the phone gate, and
61
+ `out_of_traffic_credits` (503). Provisioning adds `account_exists`,
62
+ `account_limit_reached` and `template_limit_reached` (all 409). The
63
+ remainder: `not_found` (404, the uniform hide), `bad_request` (400),
64
+ `unauthenticated` (401), `bad_forwarded_identity` (401), `internal` (500).
65
+
66
+ A member-role caller sees a uniform 404 where the owner would see a 503
67
+ `out_of_traffic_credits` — worth knowing before debugging a member's send.
68
+
69
+ ## Limits worth knowing before you call `send`
70
+
71
+ - Billed **per recipient**, not per call.
72
+ - At most 50 recipients per send.
73
+ - 200 sends per app per UTC day — the `daily_cap_exceeded` wall above.
74
+ - `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
@@ -2,6 +2,19 @@
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
4
  import { appId, call, collectPages } from "@pilely/core";
5
+ /**
6
+ * Every route on this service needs an app id on the wire (body or path).
7
+ * `@pilely/core`'s `appId()` can return `null` (no tag, `ready()` not yet
8
+ * resolved) — this service cannot send that: a `null` in a path template
9
+ * would silently become the literal string `"null"`. Fail loudly instead.
10
+ */
11
+ function requireAppId() {
12
+ const id = appId();
13
+ if (!id) {
14
+ throw new Error('pilely client not loaded, <meta name="pilely-app"> missing, and no token pile_id claim available yet — call after ready()');
15
+ }
16
+ return id;
17
+ }
5
18
  // ── Sending ──────────────────────────────────────────────────────────────
6
19
  /**
7
20
  * `app_id` is added from core's `appId()` — it is not a caller parameter.
@@ -12,8 +25,8 @@ import { appId, call, collectPages } from "@pilely/core";
12
25
  */
13
26
  export async function send(input) {
14
27
  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 };
28
+ ? { app_id: requireAppId(), to: input.to, template_id: input.template_id, variables: input.variables }
29
+ : { app_id: requireAppId(), to: input.to, subject: input.subject, html: input.html };
17
30
  return call({
18
31
  service: "simple-email",
19
32
  path: "/send",
@@ -27,7 +40,7 @@ export async function listSends(options = {}) {
27
40
  service: "simple-email",
28
41
  path: "/sends/list",
29
42
  body: {
30
- app_id: appId(),
43
+ app_id: requireAppId(),
31
44
  limit: options.limit,
32
45
  after_created_time_stamp: options.after_created_time_stamp,
33
46
  after_send_id: options.after_send_id,
@@ -46,7 +59,7 @@ export async function listAllSends() {
46
59
  * key is left off the wire, meaning owner-only. `setAccountAccess` below
47
60
  * is the opposite: its key is always required. */
48
61
  export async function createAccount(sendGroup) {
49
- const body = { app_id: appId() };
62
+ const body = { app_id: requireAppId() };
50
63
  if (sendGroup !== undefined) {
51
64
  body.send_group = sendGroup;
52
65
  }
@@ -83,7 +96,7 @@ export async function listAllAccounts() {
83
96
  export async function accountInfo() {
84
97
  const json = await call({
85
98
  service: "simple-email",
86
- path: `/accounts/${appId()}/info`,
99
+ path: `/accounts/${requireAppId()}/info`,
87
100
  });
88
101
  return {
89
102
  account: json.account,
@@ -94,7 +107,7 @@ export async function accountInfo() {
94
107
  };
95
108
  }
96
109
  export async function deleteAccount() {
97
- await call({ service: "simple-email", path: `/accounts/${appId()}/delete` });
110
+ await call({ service: "simple-email", path: `/accounts/${requireAppId()}/delete` });
98
111
  }
99
112
  /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
100
113
  * the opposite of `createAccount`, where the key is optional. `null`
@@ -102,18 +115,22 @@ export async function deleteAccount() {
102
115
  export async function setAccountAccess(sendGroup) {
103
116
  const json = await call({
104
117
  service: "simple-email",
105
- path: `/accounts/${appId()}/access/set`,
118
+ path: `/accounts/${requireAppId()}/access/set`,
106
119
  body: { send_group: sendGroup },
107
120
  });
108
121
  return json.account;
109
122
  }
110
123
  // ── Templates (nested under the account) ────────────────────────────────
111
124
  /** Returns metadata only — no `html`. Use `templateInfo` to read the body
112
- * back. */
125
+ * back.
126
+ *
127
+ * `name` is nullable because the service accepts a nameless template. It
128
+ * stays a required POSITIONAL parameter — pass an explicit `null`, not
129
+ * nothing — and `TemplateMeta.name` then comes back `null`. */
113
130
  export async function createTemplate(name, subject, html) {
114
131
  const json = await call({
115
132
  service: "simple-email",
116
- path: `/accounts/${appId()}/templates/create`,
133
+ path: `/accounts/${requireAppId()}/templates/create`,
117
134
  body: { name, subject, html },
118
135
  });
119
136
  return json.template;
@@ -123,7 +140,7 @@ export async function createTemplate(name, subject, html) {
123
140
  export async function listTemplates(options = {}) {
124
141
  const json = await call({
125
142
  service: "simple-email",
126
- path: `/accounts/${appId()}/templates/list`,
143
+ path: `/accounts/${requireAppId()}/templates/list`,
127
144
  body: {
128
145
  limit: options.limit,
129
146
  after_created_time_stamp: options.after_created_time_stamp,
@@ -142,7 +159,7 @@ export async function listAllTemplates() {
142
159
  export async function templateInfo(templateNanoid) {
143
160
  const json = await call({
144
161
  service: "simple-email",
145
- path: `/accounts/${appId()}/templates/${templateNanoid}/info`,
162
+ path: `/accounts/${requireAppId()}/templates/${templateNanoid}/info`,
146
163
  });
147
164
  return json.template;
148
165
  }
@@ -154,7 +171,7 @@ export async function templateInfo(templateNanoid) {
154
171
  export async function updateTemplate(templateNanoid, body) {
155
172
  const json = await call({
156
173
  service: "simple-email",
157
- path: `/accounts/${appId()}/templates/${templateNanoid}/update`,
174
+ path: `/accounts/${requireAppId()}/templates/${templateNanoid}/update`,
158
175
  body,
159
176
  });
160
177
  return json.template;
@@ -162,6 +179,6 @@ export async function updateTemplate(templateNanoid, body) {
162
179
  export async function deleteTemplate(templateNanoid) {
163
180
  await call({
164
181
  service: "simple-email",
165
- path: `/accounts/${appId()}/templates/${templateNanoid}/delete`,
182
+ path: `/accounts/${requireAppId()}/templates/${templateNanoid}/delete`,
166
183
  });
167
184
  }
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.2.1",
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.2.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
  }