@pilely/simple-email 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pilely
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # `@pilely/simple-email`
2
+
3
+ A typed client for the `simple_email` service — the platform's managed
4
+ transactional email. Wraps all 12 of its POST routes: sending, account
5
+ provisioning, and templates.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @pilely/simple-email @pilely/core
11
+ ```
12
+
13
+ Always install with the scope — `pilely-email` and similar unscoped names
14
+ are not owned by this project and are squattable. `@pilely/core` is a peer
15
+ dependency; an app has exactly one facade, never two.
16
+
17
+ ## Where the app id lives
18
+
19
+ Unlike the other three service packages, this one splits: `send()` and
20
+ `listSends()` put the app id in the request **body**; every account and
21
+ template method puts it in the request **path**. `listAccounts()` takes no
22
+ app id at all — it lists every account the caller owns. Every method adds
23
+ the id itself from `@pilely/core`'s `appId()`; it is never a parameter you
24
+ pass.
25
+
26
+ ## Templates live under the account
27
+
28
+ There is no top-level `templates/...` route. Every template call is
29
+ `accounts/{app}/templates/...` — a template belongs to one account, and
30
+ `createTemplate`/`listTemplates`/`templateInfo`/`updateTemplate`/`deleteTemplate`
31
+ all reach it that way.
32
+
33
+ ## Status
34
+
35
+ This is a pre-1.0 package and moves with the platform. Nothing here is
36
+ published to the registry yet. Zero runtime dependencies.
@@ -0,0 +1,92 @@
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";
3
+ /** `to` is capped at the service's `max_recipients_per_send` (50 by default). */
4
+ export type SendContent = {
5
+ template_id: string;
6
+ variables?: Record<string, string>;
7
+ } | {
8
+ subject: string;
9
+ html: string;
10
+ };
11
+ export type SendInput = {
12
+ to: string[];
13
+ } & SendContent;
14
+ export interface ListSendsOptions {
15
+ limit?: number;
16
+ after_created_time_stamp?: number;
17
+ after_send_id?: string;
18
+ }
19
+ export interface ListAccountsOptions {
20
+ limit?: number;
21
+ after_created_time_stamp?: number;
22
+ after_app_id?: string;
23
+ }
24
+ export interface ListTemplatesOptions {
25
+ limit?: number;
26
+ after_created_time_stamp?: number;
27
+ after_template_id?: string;
28
+ }
29
+ /** Both keys are REQUIRED — the server rejects either one missing with a
30
+ * 400 (`ContentField::validate`), matching `updateTemplate`'s full-replace
31
+ * contract: there is no patch form on this route. */
32
+ export interface UpdateTemplateBody {
33
+ subject: string;
34
+ html: string;
35
+ }
36
+ /**
37
+ * `app_id` is added from core's `appId()` — it is not a caller parameter.
38
+ * Body size is capped on this route (like `createTemplate`/`updateTemplate`):
39
+ * an oversize body is rejected by the server before any handler runs, so
40
+ * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
41
+ * carries a null `code`.
42
+ */
43
+ export declare function send(input: SendInput): Promise<{
44
+ send_id: string;
45
+ recipient_count: number;
46
+ }>;
47
+ /** `app_id` lives in the body here, like `send` — not in the path. The
48
+ * paged primitive; use `listAllSends` to walk to the end. */
49
+ export declare function listSends(options?: ListSendsOptions): Promise<{
50
+ sends: SendRow[];
51
+ next_cursor: SendCursor | null;
52
+ }>;
53
+ export declare function listAllSends(): Promise<SendRow[]>;
54
+ /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
55
+ * key is left off the wire, meaning owner-only. `setAccountAccess` below
56
+ * is the opposite: its key is always required. */
57
+ export declare function createAccount(sendGroup?: string | null): Promise<Account>;
58
+ /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
59
+ * not a selector. The paged primitive; use `listAllAccounts` to walk to
60
+ * the end. */
61
+ export declare function listAccounts(options?: ListAccountsOptions): Promise<{
62
+ accounts: Account[];
63
+ next_cursor: AccountCursor | null;
64
+ }>;
65
+ export declare function listAllAccounts(): Promise<Account[]>;
66
+ /** Returns the account plus its live operational state — the four extra
67
+ * fields are nullable siblings of `account`, not nested inside it. */
68
+ export declare function accountInfo(): Promise<AccountInfoEnvelope>;
69
+ export declare function deleteAccount(): Promise<void>;
70
+ /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
71
+ * the opposite of `createAccount`, where the key is optional. `null`
72
+ * means owner-only. */
73
+ export declare function setAccountAccess(sendGroup: string | null): Promise<Account>;
74
+ /** Returns metadata only — no `html`. Use `templateInfo` to read the body
75
+ * back. */
76
+ export declare function createTemplate(name: string, subject: string, html: string): Promise<TemplateMeta>;
77
+ /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
78
+ * carry metadata only — no `html`. */
79
+ export declare function listTemplates(options?: ListTemplatesOptions): Promise<{
80
+ templates: TemplateMeta[];
81
+ next_cursor: TemplateCursor | null;
82
+ }>;
83
+ export declare function listAllTemplates(): Promise<TemplateMeta[]>;
84
+ /** The only template route whose answer carries `html`. */
85
+ export declare function templateInfo(templateNanoid: string): Promise<Template>;
86
+ /**
87
+ * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
88
+ * is not updatable at all. Returns metadata only, like `createTemplate`.
89
+ * Subject to the same oversize-body caveat as `send`/`createTemplate`.
90
+ */
91
+ export declare function updateTemplate(templateNanoid: string, body: UpdateTemplateBody): Promise<TemplateMeta>;
92
+ export declare function deleteTemplate(templateNanoid: string): Promise<void>;
package/dist/index.js ADDED
@@ -0,0 +1,167 @@
1
+ // @pilely/simple-email — a typed wrapper over the simple_email service's 12
2
+ // POST routes. See README.md for where app_id lives on each route: this is
3
+ // the one service where it moves between the body and the path.
4
+ import { appId, call, collectPages } from "@pilely/core";
5
+ // ── Sending ──────────────────────────────────────────────────────────────
6
+ /**
7
+ * `app_id` is added from core's `appId()` — it is not a caller parameter.
8
+ * Body size is capped on this route (like `createTemplate`/`updateTemplate`):
9
+ * an oversize body is rejected by the server before any handler runs, so
10
+ * the failure is not an `{ok:false}` envelope and the thrown `PilelyError`
11
+ * carries a null `code`.
12
+ */
13
+ export async function send(input) {
14
+ 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 };
17
+ return call({
18
+ service: "simple-email",
19
+ path: "/send",
20
+ body,
21
+ });
22
+ }
23
+ /** `app_id` lives in the body here, like `send` — not in the path. The
24
+ * paged primitive; use `listAllSends` to walk to the end. */
25
+ export async function listSends(options = {}) {
26
+ const json = await call({
27
+ service: "simple-email",
28
+ path: "/sends/list",
29
+ body: {
30
+ app_id: appId(),
31
+ limit: options.limit,
32
+ after_created_time_stamp: options.after_created_time_stamp,
33
+ after_send_id: options.after_send_id,
34
+ },
35
+ });
36
+ return { sends: json.sends, next_cursor: json.next_cursor };
37
+ }
38
+ export async function listAllSends() {
39
+ return collectPages(async (cursor) => {
40
+ const page = await listSends({ limit: 100, ...(cursor ?? {}) });
41
+ return { rows: page.sends, nextCursor: page.next_cursor };
42
+ });
43
+ }
44
+ // ── Accounts ─────────────────────────────────────────────────────────────
45
+ /** `sendGroup` is optional here — omit it (or pass `undefined`) and the
46
+ * key is left off the wire, meaning owner-only. `setAccountAccess` below
47
+ * is the opposite: its key is always required. */
48
+ export async function createAccount(sendGroup) {
49
+ const body = { app_id: appId() };
50
+ if (sendGroup !== undefined) {
51
+ body.send_group = sendGroup;
52
+ }
53
+ const json = await call({
54
+ service: "simple-email",
55
+ path: "/accounts/create",
56
+ body,
57
+ });
58
+ return json.account;
59
+ }
60
+ /** No `app_id` anywhere on this route — `after_app_id` is a cursor field,
61
+ * not a selector. The paged primitive; use `listAllAccounts` to walk to
62
+ * the end. */
63
+ export async function listAccounts(options = {}) {
64
+ const json = await call({
65
+ service: "simple-email",
66
+ path: "/accounts/list",
67
+ body: {
68
+ limit: options.limit,
69
+ after_created_time_stamp: options.after_created_time_stamp,
70
+ after_app_id: options.after_app_id,
71
+ },
72
+ });
73
+ return { accounts: json.accounts, next_cursor: json.next_cursor };
74
+ }
75
+ export async function listAllAccounts() {
76
+ return collectPages(async (cursor) => {
77
+ const page = await listAccounts({ limit: 100, ...(cursor ?? {}) });
78
+ return { rows: page.accounts, nextCursor: page.next_cursor };
79
+ });
80
+ }
81
+ /** Returns the account plus its live operational state — the four extra
82
+ * fields are nullable siblings of `account`, not nested inside it. */
83
+ export async function accountInfo() {
84
+ const json = await call({
85
+ service: "simple-email",
86
+ path: `/accounts/${appId()}/info`,
87
+ });
88
+ return {
89
+ account: json.account,
90
+ daily_cap: json.daily_cap,
91
+ sent_today: json.sent_today,
92
+ credit_blocked: json.credit_blocked,
93
+ phone_verified: json.phone_verified,
94
+ };
95
+ }
96
+ export async function deleteAccount() {
97
+ await call({ service: "simple-email", path: `/accounts/${appId()}/delete` });
98
+ }
99
+ /** `sendGroup` is REQUIRED here and its key is always sent, even `null` —
100
+ * the opposite of `createAccount`, where the key is optional. `null`
101
+ * means owner-only. */
102
+ export async function setAccountAccess(sendGroup) {
103
+ const json = await call({
104
+ service: "simple-email",
105
+ path: `/accounts/${appId()}/access/set`,
106
+ body: { send_group: sendGroup },
107
+ });
108
+ return json.account;
109
+ }
110
+ // ── Templates (nested under the account) ────────────────────────────────
111
+ /** Returns metadata only — no `html`. Use `templateInfo` to read the body
112
+ * back. */
113
+ export async function createTemplate(name, subject, html) {
114
+ const json = await call({
115
+ service: "simple-email",
116
+ path: `/accounts/${appId()}/templates/create`,
117
+ body: { name, subject, html },
118
+ });
119
+ return json.template;
120
+ }
121
+ /** The paged primitive; use `listAllTemplates` to walk to the end. Rows
122
+ * carry metadata only — no `html`. */
123
+ export async function listTemplates(options = {}) {
124
+ const json = await call({
125
+ service: "simple-email",
126
+ path: `/accounts/${appId()}/templates/list`,
127
+ body: {
128
+ limit: options.limit,
129
+ after_created_time_stamp: options.after_created_time_stamp,
130
+ after_template_id: options.after_template_id,
131
+ },
132
+ });
133
+ return { templates: json.templates, next_cursor: json.next_cursor };
134
+ }
135
+ export async function listAllTemplates() {
136
+ return collectPages(async (cursor) => {
137
+ const page = await listTemplates({ limit: 100, ...(cursor ?? {}) });
138
+ return { rows: page.templates, nextCursor: page.next_cursor };
139
+ });
140
+ }
141
+ /** The only template route whose answer carries `html`. */
142
+ export async function templateInfo(templateNanoid) {
143
+ const json = await call({
144
+ service: "simple-email",
145
+ path: `/accounts/${appId()}/templates/${templateNanoid}/info`,
146
+ });
147
+ return json.template;
148
+ }
149
+ /**
150
+ * A FULL REPLACE of both `subject` and `html` — never a patch — and `name`
151
+ * is not updatable at all. Returns metadata only, like `createTemplate`.
152
+ * Subject to the same oversize-body caveat as `send`/`createTemplate`.
153
+ */
154
+ export async function updateTemplate(templateNanoid, body) {
155
+ const json = await call({
156
+ service: "simple-email",
157
+ path: `/accounts/${appId()}/templates/${templateNanoid}/update`,
158
+ body,
159
+ });
160
+ return json.template;
161
+ }
162
+ export async function deleteTemplate(templateNanoid) {
163
+ await call({
164
+ service: "simple-email",
165
+ path: `/accounts/${appId()}/templates/${templateNanoid}/delete`,
166
+ });
167
+ }
@@ -0,0 +1,58 @@
1
+ /** `address` is derived at read time, never stored, and `null` when the
2
+ * app it names is gone. */
3
+ export interface Account {
4
+ app_id: string;
5
+ address: string | null;
6
+ display_name: string | null;
7
+ /** `null` means owner-only — the opposite polarity from simple_db's
8
+ * `read_group`/`write_group`, where `null` means everyone. */
9
+ send_group: string | null;
10
+ created_time_stamp: number;
11
+ }
12
+ /** `daily_cap`'s three siblings are nullable and sit BESIDE `account`, not
13
+ * inside it — the whole reason an app calls `accountInfo`. */
14
+ export interface AccountInfoEnvelope {
15
+ account: Account;
16
+ daily_cap: number;
17
+ sent_today: number | null;
18
+ credit_blocked: boolean | null;
19
+ phone_verified: boolean | null;
20
+ }
21
+ /** What `templates/create` and `templates/update` answer with — no `html`. */
22
+ export interface TemplateMeta {
23
+ template_id: string;
24
+ /** Not updatable through `updateTemplate` — only `subject` and `html` are. */
25
+ name: string;
26
+ subject: string;
27
+ created_time_stamp: number;
28
+ updated_time_stamp: number;
29
+ }
30
+ /** What `templates/{t}/info` answers with — `TemplateMeta` plus the body. */
31
+ export interface Template extends TemplateMeta {
32
+ html: string;
33
+ }
34
+ export interface SendRow {
35
+ send_id: string;
36
+ requester_user_id: string;
37
+ via_app: boolean;
38
+ kind: string;
39
+ template_id: string | null;
40
+ subject: string;
41
+ recipients: unknown;
42
+ recipient_count: number;
43
+ outcome: string;
44
+ outcome_reason: string | null;
45
+ fired_at: number;
46
+ }
47
+ export interface SendCursor {
48
+ after_created_time_stamp: number;
49
+ after_send_id: string;
50
+ }
51
+ export interface AccountCursor {
52
+ after_created_time_stamp: number;
53
+ after_app_id: string;
54
+ }
55
+ export interface TemplateCursor {
56
+ after_created_time_stamp: number;
57
+ after_template_id: string;
58
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@pilely/simple-email",
3
+ "version": "0.1.0",
4
+ "description": "A typed client for the simple_email service.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "sideEffects": false,
16
+ "files": ["dist", "README.md", "LICENSE"],
17
+ "publishConfig": { "access": "public" },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/Pile-ly/reference-code.git",
21
+ "directory": "npm/simple-email"
22
+ },
23
+ "peerDependencies": {
24
+ "@pilely/core": "^0.1.0"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.json",
28
+ "typecheck": "tsc -p tsconfig.json --noEmit",
29
+ "test": "vitest run"
30
+ }
31
+ }