@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/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 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, `account_senders` gating, daily caps or credit
9
- // checks. It does model the send shapes (raw and account sends)
10
- // and `idempotency_key`: a repeat with the same key, body and caller within
11
- // 24 hours answers the first send with `replayed: true` and adds no row; the
12
- // same key with another body or caller is `409 idempotency_key_reused`. An
13
- // account send to `"self"` logs `recipients: []`, one to `"owner"` logs a
14
- // stand-in owner address — the fake has no real account emails. Writes
15
- // need a signed-in user; a signed-out write and a missing account or
16
- // template answer the uniform bare 404. Accounts carry `reply_to`, set on
17
- // create and by `set_reply_to` (key required, a string or `null`); the fake
18
- // checks only its type, not the address rules or refused domains. Reached
19
- // only from the
20
- // `isMockMode()` branch in index.ts, so a production build carries none of
21
- // it.
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 ACCOUNT_EMAIL_DOMAIN = "account.pilely.invalid";
29
- const IDEMPOTENCY_WINDOW_MS = 24 * 60 * 60 * 1000;
30
- const IDEMPOTENCY_KEY = /^[A-Za-z0-9._:-]{1,128}$/;
31
- const ACCOUNT_SENDERS = ["send_group", "app_users"];
32
- const TO_ACCOUNT = ["self", "owner"];
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
- function render(text, variables) {
44
- return text.replace(/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (whole, name) => name in variables ? String(variables[name]) : whole);
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
- /** The wire row: the fake's own fields stripped, and the two newer keys
60
- * defaulted for rows persisted before they existed. */
61
- function sendPayload({ app_id: _appId, fingerprint: _fingerprint, ...row }) {
62
- return { ...row, to_account: row.to_account ?? null, idempotency_key: row.idempotency_key ?? null };
63
- }
64
- /** `undefined` when the key is absent; `null` when it is present but not
65
- * one of the two values (an explicit `null` included — the server's 400). */
66
- function parseAccountSenders(body) {
67
- if (!("account_senders" in body))
68
- return undefined;
69
- const value = body.account_senders;
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
- /** The service's "same body": the caller, the mode, `to` as a
83
- * case-insensitive set, `to_account`, the template and its variables, and
84
- * inline content. */
85
- function fingerprint(caller, body, to) {
86
- const variables = isObject(body.variables)
87
- ? Object.keys(body.variables)
88
- .sort()
89
- .map((name) => [name, body.variables[name]])
90
- : null;
91
- return JSON.stringify([
92
- caller,
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
- function templateMeta(template) {
103
- return {
104
- template_id: template.template_id,
105
- name: template.name,
106
- subject: template.subject,
107
- created_time_stamp: template.created_time_stamp,
108
- updated_time_stamp: template.updated_time_stamp,
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
- function createAccount(appId, sendGroup, accountSenders, replyTo) {
147
- const account = {
148
- app_id: appId,
149
- address: `${appId}@${SENDING_DOMAIN}`,
150
- display_name: null,
151
- send_group: sendGroup,
152
- account_senders: accountSenders,
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
- const hasTo = body.to !== undefined && body.to !== null;
165
- const hasToAccount = body.to_account !== undefined && body.to_account !== null;
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
- let to = null;
170
- let toAccount = null;
171
- if (hasTo) {
172
- const raw = body.to;
173
- if (!Array.isArray(raw) || raw.length < 1 || raw.length > MAX_RECIPIENTS || !raw.every((r) => typeof r === "string")) {
174
- return ctx.refuse(400, "bad_request", `to must hold 1..=${MAX_RECIPIENTS} addresses`);
175
- }
176
- to = raw;
177
- }
178
- else {
179
- if (!TO_ACCOUNT.includes(body.to_account)) {
180
- return ctx.refuse(400, "bad_request", 'to_account must be "self" or "owner"');
181
- }
182
- toAccount = body.to_account;
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
- let subject;
211
- let html;
212
- let templateId = null;
213
- if (hasTemplate) {
214
- const template = account.templates.find((t) => t.template_id === body.template_id);
215
- if (!template) {
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
- const variables = isObject(body.variables) ? body.variables : {};
219
- subject = render(template.subject, variables);
220
- html = render(template.html, variables);
221
- templateId = template.template_id;
196
+ values[name] = value;
222
197
  }
223
- else {
224
- if (typeof body.subject !== "string" || typeof body.html !== "string") {
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
- // A "self" row never shows the caller's address; an "owner" row shows the
231
- // owner's own. The fake has no account emails, so the owner's is a
232
- // stand-in built from the signed-in mock user's handle.
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
- requester_user_id: caller,
239
- via_app: true,
240
- kind: hasTemplate ? "template" : "inline",
241
- template_id: templateId,
207
+ template_id: template.template_id,
208
+ recipient,
242
209
  subject,
243
- recipients,
244
- recipient_count: to !== null ? to.length : 1,
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: toAccount !== null ? `(the ${toAccount === "self" ? "caller's" : "owner's"} account email)` : row.recipients,
217
+ to: recipient,
256
218
  subject,
257
219
  html,
258
220
  });
259
- return ctx.ok({ ok: true, send_id: row.send_id, recipient_count: row.recipient_count, replayed: false });
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 sendGroup = typeof body.send_group === "string" ? body.send_group : null;
296
- const account = createAccount(body.app_id, sendGroup, accountSenders ?? "send_group", replyTo ?? null);
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>;