@pilely/simple-email 0.2.1 → 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,6 +30,30 @@ 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
package/dist/index.js CHANGED
@@ -1,7 +1,13 @@
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
+ }
5
11
  /**
6
12
  * Every route on this service needs an app id on the wire (body or path).
7
13
  * `@pilely/core`'s `appId()` can return `null` (no tag, `ready()` not yet
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilely/simple-email",
3
- "version": "0.2.1",
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,7 +21,7 @@
21
21
  "directory": "npm/simple-email"
22
22
  },
23
23
  "peerDependencies": {
24
- "@pilely/core": "^0.2.0"
24
+ "@pilely/core": "^0.3.0"
25
25
  },
26
26
  "scripts": {
27
27
  "build": "tsc -p tsconfig.json",