@pilely/simple-email 0.4.5 → 0.5.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 +102 -67
- package/dist/api.d.ts +52 -91
- package/dist/api.js +38 -85
- package/dist/hooks.d.ts +34 -27
- package/dist/hooks.js +15 -40
- package/dist/index.d.ts +7 -4
- package/dist/index.js +7 -2
- package/dist/mock.d.ts +7 -0
- package/dist/mock.js +165 -201
- package/dist/send.d.ts +75 -0
- package/dist/send.js +94 -0
- package/dist/types.d.ts +20 -47
- package/package.json +6 -2
package/dist/mock.js
CHANGED
|
@@ -1,35 +1,57 @@
|
|
|
1
1
|
// The in-browser fake of the simple_email service that `@pilely/core`'s
|
|
2
2
|
// mock runtime routes to in mock mode. It answers every route this
|
|
3
|
-
// package's
|
|
4
|
-
// is ever sent:
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
3
|
+
// package's page-side functions call with the JSON the real service
|
|
4
|
+
// returns. Nothing is ever sent: a template send appends to an in-memory
|
|
5
|
+
// outbox, carrying the template's stored recipient, and logs the message to
|
|
6
|
+
// the console; its answer is `{ ok, send_id }` and never the address.
|
|
7
|
+
// Accounts and templates live in memory and persist through the core store.
|
|
8
|
+
//
|
|
9
|
+
// The template send admits callers by the template's groups the way the
|
|
10
|
+
// simple-db fake admits by policies: an empty `send_group` admits nobody;
|
|
11
|
+
// the two platform-wide groups hold callers by type (every signed-out
|
|
12
|
+
// visitor is in the anonymous group, every signed-in person — the owner
|
|
13
|
+
// included — in the authenticated group), named by the same fixed test ids
|
|
14
|
+
// the simple-group fake answers; the fake has no group membership data, so
|
|
15
|
+
// ANY OTHER GROUP HOLDS NOBODY — an owner group as `send_group` admits no
|
|
16
|
+
// one and a `deny_group` denies no one. Every refused caller gets the
|
|
17
|
+
// uniform bare 404. Each placeholder of the template is read from the body
|
|
18
|
+
// field of the same name; a missing or non-string one is `400 bad_request`
|
|
19
|
+
// and nothing lands in the outbox; every other field is ignored.
|
|
20
|
+
//
|
|
21
|
+
// The freestyle send (`accounts/{app_id}/send`) answers the uniform bare
|
|
22
|
+
// 404 to every caller: the service admits only the app's backend there, and
|
|
23
|
+
// no caller in a browser is one. `send()` itself never reaches this fake —
|
|
24
|
+
// it is a server function that calls `fetch` directly.
|
|
25
|
+
//
|
|
26
|
+
// Owner writes need a signed-in user; a signed-out write and a missing
|
|
27
|
+
// account or template answer the uniform bare 404. Accounts carry
|
|
28
|
+
// `reply_to`, set on create and by `set_reply_to` (key required, a string
|
|
29
|
+
// or `null`); templates carry `recipient` (required on create), `send_group`
|
|
30
|
+
// and `deny_group` (a platform-wide group as `deny_group` is the service's
|
|
31
|
+
// 400). The fake checks the fields' shapes, not the address rules, refused
|
|
32
|
+
// domains or group ownership. It does not model the daily caps, rate
|
|
33
|
+
// limits or credit checks. Reached only from the `isMockMode()` branch in
|
|
34
|
+
// api.ts, so a production build carries none of it.
|
|
22
35
|
const MARKER = "pilely-mock-fake:simple-email";
|
|
23
36
|
const DEFAULT_PAGE_SIZE = 50;
|
|
24
37
|
const MAX_PAGE_SIZE = 100;
|
|
25
|
-
const MAX_RECIPIENTS = 50;
|
|
26
38
|
const DAILY_CAP = 200;
|
|
27
39
|
const SENDING_DOMAIN = "mail.pilely.invalid";
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
|
|
40
|
+
const VARIABLES_MAX_BYTES = 4096;
|
|
41
|
+
const GROUP_ID = /^[A-Za-z0-9]{1,64}$/;
|
|
42
|
+
const ADDRESS = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
43
|
+
const PLACEHOLDER = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g;
|
|
44
|
+
/** The fake's ids for the two platform-wide groups: fixed test values, not
|
|
45
|
+
* the production ids. `@pilely/simple-group`'s fake answers the same two
|
|
46
|
+
* from `groups/anonymous` and `groups/authenticated`, so in mock mode an id
|
|
47
|
+
* read from `anonymousGroupId()` / `authenticatedGroupId()` works as a
|
|
48
|
+
* template's `send_group` here. Not part of the package's public surface. */
|
|
49
|
+
export const MOCK_ANON_GROUP = "mockAnonymousGroup0001";
|
|
50
|
+
export const MOCK_AUTH_GROUP = "mockAuthenticatedGrp01";
|
|
51
|
+
/** A template persisted before the template-send fields existed reads this
|
|
52
|
+
* recipient, the stand-in for the owner's account email the service's
|
|
53
|
+
* migration stored. */
|
|
54
|
+
const LEGACY_RECIPIENT = "owner@account.pilely.invalid";
|
|
33
55
|
function isObject(value) {
|
|
34
56
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
57
|
}
|
|
@@ -40,34 +62,36 @@ function pageSize(body) {
|
|
|
40
62
|
const raw = body.limit === undefined || body.limit === null ? DEFAULT_PAGE_SIZE : Number(body.limit);
|
|
41
63
|
return Math.min(MAX_PAGE_SIZE, Math.max(1, Number.isFinite(raw) ? Math.trunc(raw) : DEFAULT_PAGE_SIZE));
|
|
42
64
|
}
|
|
43
|
-
|
|
44
|
-
|
|
65
|
+
/** Every placeholder name in `text`, in order of first use. */
|
|
66
|
+
function placeholders(text) {
|
|
67
|
+
return [...new Set([...text.matchAll(PLACEHOLDER)].map((m) => m[1]))];
|
|
68
|
+
}
|
|
69
|
+
function render(text, values) {
|
|
70
|
+
return text.replace(PLACEHOLDER, (whole, name) => (name in values ? values[name] : whole));
|
|
45
71
|
}
|
|
46
72
|
function accountPayload(account) {
|
|
73
|
+
// Built field by field, so state persisted with fields the service no
|
|
74
|
+
// longer answers never reaches the wire.
|
|
47
75
|
return {
|
|
48
76
|
app_id: account.app_id,
|
|
49
77
|
address: account.address,
|
|
50
78
|
display_name: account.display_name,
|
|
51
|
-
send_group: account.send_group,
|
|
52
|
-
// State persisted before account_senders existed reads the default.
|
|
53
|
-
account_senders: account.account_senders ?? "send_group",
|
|
54
79
|
// State persisted before reply_to existed reads none.
|
|
55
80
|
reply_to: account.reply_to ?? null,
|
|
56
81
|
created_time_stamp: account.created_time_stamp,
|
|
57
82
|
};
|
|
58
83
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return ACCOUNT_SENDERS.includes(value) ? value : null;
|
|
84
|
+
function templateMeta(template) {
|
|
85
|
+
return {
|
|
86
|
+
template_id: template.template_id,
|
|
87
|
+
name: template.name,
|
|
88
|
+
subject: template.subject,
|
|
89
|
+
recipient: template.recipient ?? LEGACY_RECIPIENT,
|
|
90
|
+
send_group: template.send_group ?? null,
|
|
91
|
+
deny_group: template.deny_group ?? null,
|
|
92
|
+
created_time_stamp: template.created_time_stamp,
|
|
93
|
+
updated_time_stamp: template.updated_time_stamp,
|
|
94
|
+
};
|
|
71
95
|
}
|
|
72
96
|
/** `undefined` when the key is absent; the address, or `null` (none). A value
|
|
73
97
|
* of any other type is the server's 400, answered as `false`. */
|
|
@@ -79,34 +103,27 @@ function parseReplyTo(body) {
|
|
|
79
103
|
return value;
|
|
80
104
|
return false;
|
|
81
105
|
}
|
|
82
|
-
/**
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
body.app_id,
|
|
94
|
-
to === null ? null : [...new Set(to.map((r) => r.toLowerCase()))].sort(),
|
|
95
|
-
body.to_account ?? null,
|
|
96
|
-
body.template_id ?? null,
|
|
97
|
-
variables,
|
|
98
|
-
body.subject ?? null,
|
|
99
|
-
body.html ?? null,
|
|
100
|
-
]);
|
|
106
|
+
/** `undefined` when the key is absent; the group id, or `null` (empty). A
|
|
107
|
+
* value that is not a group id is the server's 400, answered as `false`. */
|
|
108
|
+
function parseGroup(body, key) {
|
|
109
|
+
if (!(key in body))
|
|
110
|
+
return undefined;
|
|
111
|
+
const value = body[key];
|
|
112
|
+
if (value === null)
|
|
113
|
+
return null;
|
|
114
|
+
if (typeof value === "string" && GROUP_ID.test(value))
|
|
115
|
+
return value;
|
|
116
|
+
return false;
|
|
101
117
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
118
|
+
/** Whether `group` holds the caller: the anonymous group every signed-out
|
|
119
|
+
* visitor, the authenticated group every signed-in person; the fake has no
|
|
120
|
+
* membership data, so any other group holds nobody. */
|
|
121
|
+
function groupHolds(group, signedIn) {
|
|
122
|
+
if (group === MOCK_ANON_GROUP)
|
|
123
|
+
return !signedIn;
|
|
124
|
+
if (group === MOCK_AUTH_GROUP)
|
|
125
|
+
return signedIn;
|
|
126
|
+
return false;
|
|
110
127
|
}
|
|
111
128
|
/**
|
|
112
129
|
* Newest-first keyset paging over rows already sorted, with the cursor
|
|
@@ -143,138 +160,69 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
143
160
|
function findAccount(appId) {
|
|
144
161
|
return state.accounts.find((a) => a.app_id === appId);
|
|
145
162
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
reply_to: replyTo,
|
|
154
|
-
created_time_stamp: ctx.now(),
|
|
155
|
-
templates: [],
|
|
156
|
-
};
|
|
157
|
-
state.accounts.push(account);
|
|
158
|
-
return account;
|
|
159
|
-
}
|
|
160
|
-
function send(body) {
|
|
161
|
-
if (typeof body.app_id !== "string" || body.app_id === "") {
|
|
162
|
-
return ctx.refuse(400, "bad_request", "app_id is required");
|
|
163
|
+
/** The two groups of a create or update body, or the server's 400. Each
|
|
164
|
+
* is `undefined` when its key is absent. */
|
|
165
|
+
function parseGroups(body) {
|
|
166
|
+
const sendGroup = parseGroup(body, "send_group");
|
|
167
|
+
const denyGroup = parseGroup(body, "deny_group");
|
|
168
|
+
if (sendGroup === false || denyGroup === false) {
|
|
169
|
+
return ctx.refuse(400, "bad_request", "send_group and deny_group must be a group id (1..=64 of [A-Za-z0-9]) or null");
|
|
163
170
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (hasTo === hasToAccount) {
|
|
167
|
-
return ctx.refuse(400, "bad_request", "send either to or to_account");
|
|
171
|
+
if (denyGroup === MOCK_ANON_GROUP || denyGroup === MOCK_AUTH_GROUP) {
|
|
172
|
+
return ctx.refuse(400, "bad_request", "deny_group must be an ACTIVE simple_group owned by you, or null");
|
|
168
173
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
const hasTemplate = body.template_id !== undefined && body.template_id !== null;
|
|
185
|
-
const hasInline = body.subject !== undefined || body.html !== undefined;
|
|
186
|
-
if (toAccount !== null && (hasInline || !hasTemplate)) {
|
|
187
|
-
return ctx.refuse(400, "bad_request", "an account send takes template_id, never subject or html");
|
|
188
|
-
}
|
|
189
|
-
if (hasTemplate === hasInline) {
|
|
190
|
-
return ctx.refuse(400, "bad_request", "send either template_id or subject + html");
|
|
191
|
-
}
|
|
192
|
-
const key = body.idempotency_key;
|
|
193
|
-
if (key !== undefined && (typeof key !== "string" || !IDEMPOTENCY_KEY.test(key))) {
|
|
194
|
-
return ctx.refuse(400, "bad_request", "idempotency_key must be 1..=128 characters of [A-Za-z0-9._:-]");
|
|
195
|
-
}
|
|
196
|
-
const account = findAccount(body.app_id) ?? createAccount(body.app_id, null, "send_group", null);
|
|
197
|
-
const user = ctx.user();
|
|
198
|
-
const caller = user?.id ?? "mock-user";
|
|
199
|
-
const print = fingerprint(caller, body, to);
|
|
200
|
-
const now = ctx.now();
|
|
201
|
-
if (typeof key === "string") {
|
|
202
|
-
const first = state.sends.find((s) => s.app_id === account.app_id && s.idempotency_key === key && now - s.fired_at < IDEMPOTENCY_WINDOW_MS);
|
|
203
|
-
if (first) {
|
|
204
|
-
if (first.fingerprint !== print) {
|
|
205
|
-
return ctx.refuse(409, "idempotency_key_reused", "this idempotency_key belongs to a different request");
|
|
206
|
-
}
|
|
207
|
-
return ctx.ok({ ok: true, send_id: first.send_id, recipient_count: first.recipient_count, replayed: true });
|
|
208
|
-
}
|
|
174
|
+
return { send_group: sendGroup, deny_group: denyGroup };
|
|
175
|
+
}
|
|
176
|
+
/** The template send: the caller and the template's groups, then the
|
|
177
|
+
* body, then the outbox. */
|
|
178
|
+
function sendTemplate(account, template, rawBody) {
|
|
179
|
+
const signedIn = ctx.user() !== null;
|
|
180
|
+
if (!account || !template)
|
|
181
|
+
return ctx.notFound();
|
|
182
|
+
const sendGroup = template.send_group ?? null;
|
|
183
|
+
if (!groupHolds(sendGroup, signedIn))
|
|
184
|
+
return ctx.notFound();
|
|
185
|
+
// A deny_group is an owner group, and an owner group holds nobody here.
|
|
186
|
+
if (rawBody !== undefined && rawBody !== null && !isObject(rawBody)) {
|
|
187
|
+
return ctx.refuse(400, "bad_request", "the body must be a JSON object of the template's variables");
|
|
209
188
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return ctx.refuse(400, "bad_request", "template_id names no template of this account");
|
|
189
|
+
const body = isObject(rawBody) ? rawBody : {};
|
|
190
|
+
const values = {};
|
|
191
|
+
for (const name of placeholders(`${template.subject}\n${template.html}`)) {
|
|
192
|
+
const value = body[name];
|
|
193
|
+
if (typeof value !== "string") {
|
|
194
|
+
return ctx.refuse(400, "bad_request", `template variable ${name} is missing or not a string`);
|
|
217
195
|
}
|
|
218
|
-
|
|
219
|
-
subject = render(template.subject, variables);
|
|
220
|
-
html = render(template.html, variables);
|
|
221
|
-
templateId = template.template_id;
|
|
196
|
+
values[name] = value;
|
|
222
197
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return ctx.refuse(400, "bad_request", "subject and html are required together");
|
|
226
|
-
}
|
|
227
|
-
subject = body.subject;
|
|
228
|
-
html = body.html;
|
|
198
|
+
if (new TextEncoder().encode(Object.values(values).join("")).length > VARIABLES_MAX_BYTES) {
|
|
199
|
+
return ctx.refuse(400, "bad_request", `the template's values exceed ${VARIABLES_MAX_BYTES} bytes`);
|
|
229
200
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const recipients = to !== null ? [...to] : toAccount === "owner" ? [`${user?.handle ?? "owner"}@${ACCOUNT_EMAIL_DOMAIN}`] : [];
|
|
201
|
+
const recipient = template.recipient ?? LEGACY_RECIPIENT;
|
|
202
|
+
const subject = render(template.subject, values);
|
|
203
|
+
const html = render(template.html, values);
|
|
234
204
|
const row = {
|
|
235
205
|
app_id: account.app_id,
|
|
236
|
-
fingerprint: print,
|
|
237
206
|
send_id: ctx.nanoid(),
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
kind: hasTemplate ? "template" : "inline",
|
|
241
|
-
template_id: templateId,
|
|
207
|
+
template_id: template.template_id,
|
|
208
|
+
recipient,
|
|
242
209
|
subject,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
outcome: "sent",
|
|
246
|
-
outcome_reason: null,
|
|
247
|
-
to_account: toAccount,
|
|
248
|
-
idempotency_key: typeof key === "string" ? key : null,
|
|
249
|
-
fired_at: now,
|
|
210
|
+
recipient_count: 1,
|
|
211
|
+
fired_at: ctx.now(),
|
|
250
212
|
};
|
|
251
213
|
state.sends.push(row);
|
|
252
214
|
save();
|
|
253
215
|
console.log("[pilely mock] simple-email outbox: nothing was sent", {
|
|
254
216
|
from: account.address,
|
|
255
|
-
to:
|
|
217
|
+
to: recipient,
|
|
256
218
|
subject,
|
|
257
219
|
html,
|
|
258
220
|
});
|
|
259
|
-
return ctx.ok({ ok: true, send_id: row.send_id
|
|
221
|
+
return ctx.ok({ ok: true, send_id: row.send_id });
|
|
260
222
|
}
|
|
261
223
|
function handle(path, rawBody) {
|
|
262
224
|
const body = isObject(rawBody) ? rawBody : {};
|
|
263
225
|
const signedIn = ctx.user() !== null;
|
|
264
|
-
if (path === "/send") {
|
|
265
|
-
if (!signedIn)
|
|
266
|
-
return ctx.notFound();
|
|
267
|
-
return send(body);
|
|
268
|
-
}
|
|
269
|
-
if (path === "/sends/list") {
|
|
270
|
-
const appId = typeof body.app_id === "string" ? body.app_id : "";
|
|
271
|
-
const rows = state.sends.filter((s) => s.app_id === appId);
|
|
272
|
-
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 }));
|
|
273
|
-
if (!result)
|
|
274
|
-
return cursorRefusal();
|
|
275
|
-
const sends = result.page.map(sendPayload);
|
|
276
|
-
return ctx.ok({ ok: true, sends, next_cursor: result.next });
|
|
277
|
-
}
|
|
278
226
|
if (path === "/accounts/create") {
|
|
279
227
|
if (!signedIn)
|
|
280
228
|
return ctx.notFound();
|
|
@@ -284,16 +232,19 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
284
232
|
if (findAccount(body.app_id)) {
|
|
285
233
|
return ctx.refuse(409, "account_exists", "this app already has an email account");
|
|
286
234
|
}
|
|
287
|
-
const accountSenders = parseAccountSenders(body);
|
|
288
|
-
if (accountSenders === null) {
|
|
289
|
-
return ctx.refuse(400, "bad_request", 'account_senders must be "send_group" or "app_users"');
|
|
290
|
-
}
|
|
291
235
|
const replyTo = parseReplyTo(body);
|
|
292
236
|
if (replyTo === false) {
|
|
293
237
|
return ctx.refuse(400, "bad_request", "reply_to must be an email address or null");
|
|
294
238
|
}
|
|
295
|
-
const
|
|
296
|
-
|
|
239
|
+
const account = {
|
|
240
|
+
app_id: body.app_id,
|
|
241
|
+
address: `${body.app_id}@${SENDING_DOMAIN}`,
|
|
242
|
+
display_name: null,
|
|
243
|
+
reply_to: replyTo ?? null,
|
|
244
|
+
created_time_stamp: ctx.now(),
|
|
245
|
+
templates: [],
|
|
246
|
+
};
|
|
247
|
+
state.accounts.push(account);
|
|
297
248
|
save();
|
|
298
249
|
return ctx.ok({ ok: true, account: accountPayload(account) });
|
|
299
250
|
}
|
|
@@ -309,6 +260,10 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
309
260
|
const account = findAccount(parts[1]);
|
|
310
261
|
const rest = parts.slice(2);
|
|
311
262
|
const route = rest.join("/");
|
|
263
|
+
if (route === "send") {
|
|
264
|
+
// Only the app's backend may make a freestyle send; no browser caller is one.
|
|
265
|
+
return ctx.notFound();
|
|
266
|
+
}
|
|
312
267
|
if (route === "info") {
|
|
313
268
|
if (!account)
|
|
314
269
|
return ctx.notFound();
|
|
@@ -331,22 +286,6 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
331
286
|
save();
|
|
332
287
|
return ctx.ok({ ok: true });
|
|
333
288
|
}
|
|
334
|
-
if (route === "access/set") {
|
|
335
|
-
if (!signedIn || !account)
|
|
336
|
-
return ctx.notFound();
|
|
337
|
-
if (!("send_group" in body))
|
|
338
|
-
return ctx.refuse(400, "bad_request", "send_group is required");
|
|
339
|
-
const accountSenders = parseAccountSenders(body);
|
|
340
|
-
if (accountSenders === null) {
|
|
341
|
-
return ctx.refuse(400, "bad_request", 'account_senders must be "send_group" or "app_users"');
|
|
342
|
-
}
|
|
343
|
-
account.send_group = typeof body.send_group === "string" ? body.send_group : null;
|
|
344
|
-
// Omitted keeps the current value.
|
|
345
|
-
if (accountSenders !== undefined)
|
|
346
|
-
account.account_senders = accountSenders;
|
|
347
|
-
save();
|
|
348
|
-
return ctx.ok({ ok: true, account: accountPayload(account) });
|
|
349
|
-
}
|
|
350
289
|
if (route === "set_reply_to") {
|
|
351
290
|
if (!signedIn || !account)
|
|
352
291
|
return ctx.notFound();
|
|
@@ -367,12 +306,21 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
367
306
|
if (typeof body.subject !== "string" || typeof body.html !== "string") {
|
|
368
307
|
return ctx.refuse(400, "bad_request", "subject and html are required");
|
|
369
308
|
}
|
|
309
|
+
if (typeof body.recipient !== "string" || !ADDRESS.test(body.recipient)) {
|
|
310
|
+
return ctx.refuse(400, "bad_request", "recipient is required: one email address");
|
|
311
|
+
}
|
|
312
|
+
const groups = parseGroups(body);
|
|
313
|
+
if ("status" in groups)
|
|
314
|
+
return groups;
|
|
370
315
|
const now = ctx.now();
|
|
371
316
|
const template = {
|
|
372
317
|
template_id: ctx.nanoid(),
|
|
373
318
|
name: typeof body.name === "string" ? body.name : null,
|
|
374
319
|
subject: body.subject,
|
|
375
320
|
html: body.html,
|
|
321
|
+
recipient: body.recipient,
|
|
322
|
+
send_group: groups.send_group ?? null,
|
|
323
|
+
deny_group: groups.deny_group ?? null,
|
|
376
324
|
created_time_stamp: now,
|
|
377
325
|
updated_time_stamp: now,
|
|
378
326
|
};
|
|
@@ -392,6 +340,9 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
392
340
|
return null;
|
|
393
341
|
const template = account?.templates.find((t) => t.template_id === rest[1]);
|
|
394
342
|
const action = rest[2];
|
|
343
|
+
if (action === "send") {
|
|
344
|
+
return sendTemplate(account, template, rawBody);
|
|
345
|
+
}
|
|
395
346
|
if (action === "info") {
|
|
396
347
|
if (!template)
|
|
397
348
|
return ctx.notFound();
|
|
@@ -403,8 +354,21 @@ export const createSimpleEmailFake = (ctx) => {
|
|
|
403
354
|
if (typeof body.subject !== "string" || typeof body.html !== "string") {
|
|
404
355
|
return ctx.refuse(400, "bad_request", "subject and html are both required");
|
|
405
356
|
}
|
|
357
|
+
if ("recipient" in body && (typeof body.recipient !== "string" || !ADDRESS.test(body.recipient))) {
|
|
358
|
+
return ctx.refuse(400, "bad_request", "recipient must be one email address; it cannot be emptied");
|
|
359
|
+
}
|
|
360
|
+
const groups = parseGroups(body);
|
|
361
|
+
if ("status" in groups)
|
|
362
|
+
return groups;
|
|
406
363
|
template.subject = body.subject;
|
|
407
364
|
template.html = body.html;
|
|
365
|
+
// An omitted key keeps the stored value; null empties a group.
|
|
366
|
+
if (typeof body.recipient === "string")
|
|
367
|
+
template.recipient = body.recipient;
|
|
368
|
+
if (groups.send_group !== undefined)
|
|
369
|
+
template.send_group = groups.send_group;
|
|
370
|
+
if (groups.deny_group !== undefined)
|
|
371
|
+
template.deny_group = groups.deny_group;
|
|
408
372
|
template.updated_time_stamp = ctx.now();
|
|
409
373
|
save();
|
|
410
374
|
return ctx.ok({ ok: true, template: templateMeta(template) });
|
package/dist/send.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SimpleEmailErrorCode } from "./types.js";
|
|
2
|
+
/** Where the service answers. A testing-tier lane passes its own origin. */
|
|
3
|
+
export declare const SIMPLE_EMAIL_ORIGIN = "https://simple-email.pilely.app";
|
|
4
|
+
/** `to` holds 1 to 50 addresses. */
|
|
5
|
+
export type SendContent = {
|
|
6
|
+
template_id: string;
|
|
7
|
+
variables?: Record<string, string>;
|
|
8
|
+
} | {
|
|
9
|
+
subject: string;
|
|
10
|
+
html: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Optional on both shapes: 1–128 characters of `[A-Za-z0-9._:-]`, held per
|
|
14
|
+
* app for 24 hours from its first use. A repeat with the same key and the
|
|
15
|
+
* same body answers the first send's `send_id` with `replayed: true` and
|
|
16
|
+
* sends and bills nothing; the same key with another body is
|
|
17
|
+
* `409 idempotency_key_reused`. Pick keys stable per logical email
|
|
18
|
+
* (`digest:<user id>:<YYYY-MM-DD>`, `confirm:<row id>`) — a new key per
|
|
19
|
+
* attempt defeats the protection.
|
|
20
|
+
*/
|
|
21
|
+
export interface SendKey {
|
|
22
|
+
idempotency_key?: string;
|
|
23
|
+
}
|
|
24
|
+
/** A freestyle send: to the addresses in `to`, template or inline content. */
|
|
25
|
+
export type SendInput = {
|
|
26
|
+
to: string[];
|
|
27
|
+
} & SendContent & SendKey;
|
|
28
|
+
/** `replayed` is `true` when the answer is an earlier keyed send's (nothing
|
|
29
|
+
* new was sent), `false` for a new send — and always `false` without an
|
|
30
|
+
* `idempotency_key`. Duplicate addresses collapse, so `recipient_count`
|
|
31
|
+
* can be lower than `to.length`. */
|
|
32
|
+
export interface SendResult {
|
|
33
|
+
send_id: string;
|
|
34
|
+
recipient_count: number;
|
|
35
|
+
replayed: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface SendOptions {
|
|
38
|
+
/** The account's app — the app whose backend this is. */
|
|
39
|
+
appId: string;
|
|
40
|
+
/** The backend's own `PILELY_BACKEND_TOKEN`. Sent only as the bearer,
|
|
41
|
+
* only to `origin`. */
|
|
42
|
+
backendToken: string;
|
|
43
|
+
/** Defaults to `https://simple-email.pilely.app`. */
|
|
44
|
+
origin?: string;
|
|
45
|
+
}
|
|
46
|
+
/** A refused `send`. `code` is `null` for a bodiless answer — an oversize
|
|
47
|
+
* body refused before any handler runs, an unknown path — and on a missing
|
|
48
|
+
* `appId` or `backendToken` (`status` 0, nothing was sent). Never carries
|
|
49
|
+
* the token. */
|
|
50
|
+
export declare class SendError extends Error {
|
|
51
|
+
readonly status: number;
|
|
52
|
+
readonly code: SimpleEmailErrorCode | null;
|
|
53
|
+
readonly reason: string;
|
|
54
|
+
name: string;
|
|
55
|
+
constructor(status: number, code: SimpleEmailErrorCode | null, reason: string);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Sends one message from the app's account to every address in `to`:
|
|
59
|
+
* `POST <origin>/accounts/<appId>/send` with the backend token as bearer.
|
|
60
|
+
* Resolves to the service's answer; throws `SendError` on any refusal — a
|
|
61
|
+
* caller that is not this app's backend, an unknown account or template, or
|
|
62
|
+
* the owner out of balance (all one 404), a bad body (400), an
|
|
63
|
+
* `idempotency_key` conflict (409), the app's 500-recipient UTC-day
|
|
64
|
+
* freestyle cap (`429 daily_cap_exceeded`).
|
|
65
|
+
*
|
|
66
|
+
* ```ts
|
|
67
|
+
* import { send } from "@pilely/simple-email/send";
|
|
68
|
+
*
|
|
69
|
+
* await send(
|
|
70
|
+
* { to: [row.email], template_id: WELCOME, variables: { name: row.name }, idempotency_key: `welcome:${row.id}` },
|
|
71
|
+
* { appId: process.env.APP_ID!, backendToken: process.env.PILELY_BACKEND_TOKEN! },
|
|
72
|
+
* );
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export declare function send(input: SendInput, options: SendOptions): Promise<SendResult>;
|