@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 +24 -0
- package/dist/index.js +7 -1
- package/dist/mock.d.ts +2 -0
- package/dist/mock.js +289 -0
- package/package.json +2 -2
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
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.
|
|
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.
|
|
24
|
+
"@pilely/core": "^0.3.0"
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"build": "tsc -p tsconfig.json",
|