@kurtbruns/kestrel 1.8.0 → 1.9.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/CHANGELOG.md +22 -1
- package/README.md +1 -1
- package/dist/public/dashboard/app.js +148 -148
- package/dist/public/dashboard/app.js.map +3 -3
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +23 -0
- package/dist/worker/index.js +2197 -480
- package/docs/README.md +1 -0
- package/docs/get-started/04-ses.md +1 -1
- package/docs/get-started/06-go-live.md +5 -2
- package/docs/reference/01-configuration.md +7 -1
- package/docs/reference/03-import-format.md +103 -0
- package/migrations/0005_imports.sql +74 -0
- package/migrations/0006_deliveries_fewer_indexes.sql +19 -0
- package/package.json +1 -1
package/dist/worker/index.js
CHANGED
|
@@ -1,4 +1,661 @@
|
|
|
1
|
-
// @kurtbruns/kestrel 1.
|
|
1
|
+
// @kurtbruns/kestrel 1.9.0: the Worker, built by scripts/build-package.mjs.
|
|
2
|
+
|
|
3
|
+
// shared/email.ts
|
|
4
|
+
function normalizeEmail(email2) {
|
|
5
|
+
return email2.trim().toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
function isValidEmail(email2) {
|
|
8
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email2);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// shared/imports.ts
|
|
12
|
+
var IMPORT_FORMAT = "kestrel-subscribers/1";
|
|
13
|
+
var IMPORT_FORMAT_VERSIONS = [IMPORT_FORMAT];
|
|
14
|
+
var IMPORT_STATUSES = ["subscribed", "pending", "unsubscribed", "none"];
|
|
15
|
+
var OPT_INS = ["double", "single", "unknown"];
|
|
16
|
+
var SUPPRESSION_CLAIMS = ["bounce", "complaint", "manual", "erased"];
|
|
17
|
+
var PLAIN_LIST_STATUSES = ["subscribed", "unsubscribed", "bounced"];
|
|
18
|
+
var IMPORT_LIMITS = {
|
|
19
|
+
maxRows: 1e4,
|
|
20
|
+
maxRowsFree: 1e3,
|
|
21
|
+
/** The request body, in bytes: 10,000 rows of about 700 bytes, evidence included. */
|
|
22
|
+
maxBodyBytes: 10 * 1024 * 1024,
|
|
23
|
+
/** One row's `evidence`, in bytes of its JSON. */
|
|
24
|
+
maxEvidenceBytes: 500
|
|
25
|
+
};
|
|
26
|
+
var DAILY_WRITES_IMPORT_CONFIRMED = 3e3;
|
|
27
|
+
function dailyWritesImportWarning(confirmed) {
|
|
28
|
+
return `On the Workers Free plan\u2019s limits, this import leaves ${confirmed.toLocaleString("en-US")} confirmed subscribers. Importing and sending to that many on the same day may use up the database\u2019s daily allowance of writes, which stops this newsletter until midnight UTC. Send your next post tomorrow (UTC), or move to Workers Paid.`;
|
|
29
|
+
}
|
|
30
|
+
var EXPORT_PAGE_ROWS = {
|
|
31
|
+
paid: 1e4,
|
|
32
|
+
free: 1e3
|
|
33
|
+
};
|
|
34
|
+
var ORIGIN = /^[a-z0-9][a-z0-9._-]{0,39}$/;
|
|
35
|
+
var INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
36
|
+
var IMPORT_COLUMNS = [
|
|
37
|
+
{
|
|
38
|
+
name: "email",
|
|
39
|
+
required: true,
|
|
40
|
+
values: null,
|
|
41
|
+
takes: "an email address; trimmed and lowercased before it is matched",
|
|
42
|
+
meaning: "The address. An invalid one is still a row, counted as invalid and reported, never dropped."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "status",
|
|
46
|
+
required: true,
|
|
47
|
+
values: IMPORT_STATUSES,
|
|
48
|
+
takes: "one of the values",
|
|
49
|
+
meaning: "The consent claim. `none` is a known address with no consent claim, present only to carry a suppression."
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "opt_in",
|
|
53
|
+
required: true,
|
|
54
|
+
values: OPT_INS,
|
|
55
|
+
takes: "one of the values",
|
|
56
|
+
meaning: "How consent was collected. `unknown` unless the source states it."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "origin",
|
|
60
|
+
required: true,
|
|
61
|
+
values: null,
|
|
62
|
+
takes: "a short lowercase name (letters, digits, `.`, `_`, `-`, up to 40): `sendy`, `mailchimp`, `kestrel`, `unknown`",
|
|
63
|
+
meaning: "Where consent was first collected, as the file claims it."
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "suppression",
|
|
67
|
+
required: false,
|
|
68
|
+
values: SUPPRESSION_CLAIMS,
|
|
69
|
+
takes: "one of the values, or empty",
|
|
70
|
+
meaning: "A suppression on top of `status`: the address bounced, complained, was suppressed by hand, or was erased."
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "subscribed_at",
|
|
74
|
+
required: false,
|
|
75
|
+
values: null,
|
|
76
|
+
takes: "a UTC ISO-8601 instant with its zone (`2024-03-01T09:30:00Z`), or empty",
|
|
77
|
+
meaning: "When the person subscribed, if the source states it."
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "confirmed_at",
|
|
81
|
+
required: false,
|
|
82
|
+
values: null,
|
|
83
|
+
takes: "an instant with its zone, or empty",
|
|
84
|
+
meaning: "When the person confirmed, if the source states it."
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "unsubscribed_at",
|
|
88
|
+
required: false,
|
|
89
|
+
values: null,
|
|
90
|
+
takes: "an instant with its zone, or empty",
|
|
91
|
+
meaning: "When the person left, if the source states it. A reader who confirmed here after it stays confirmed."
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "suppressed_at",
|
|
95
|
+
required: false,
|
|
96
|
+
values: null,
|
|
97
|
+
takes: "an instant with its zone, or empty",
|
|
98
|
+
meaning: "When the address was suppressed, if the source states it."
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "evidence",
|
|
102
|
+
required: false,
|
|
103
|
+
values: null,
|
|
104
|
+
takes: `a JSON object of at most ${IMPORT_LIMITS.maxEvidenceBytes} bytes, or empty`,
|
|
105
|
+
meaning: "The source\u2019s consent-relevant columns, verbatim (a signup IP, a form name, a confirmation time). Nothing reads it to decide anything: it is the proof of consent and the audit trail of a conversion."
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "format",
|
|
109
|
+
required: false,
|
|
110
|
+
values: IMPORT_FORMAT_VERSIONS,
|
|
111
|
+
takes: "the format\u2019s name and version, or empty",
|
|
112
|
+
meaning: "The version the file is written in. A file without it is read as version 1; a version this release doesn\u2019t know is refused by name."
|
|
113
|
+
}
|
|
114
|
+
];
|
|
115
|
+
var COLUMN_NAMES = new Set(IMPORT_COLUMNS.map((c) => c.name));
|
|
116
|
+
function fileKindOf(name) {
|
|
117
|
+
const lower = name.trim().toLowerCase();
|
|
118
|
+
return lower.endsWith(".csv") ? "csv" : lower.endsWith(".txt") ? "txt" : null;
|
|
119
|
+
}
|
|
120
|
+
function parseImportFile(file, plainListStatus = "subscribed") {
|
|
121
|
+
const kind = fileKindOf(file.name);
|
|
122
|
+
if (kind === null) {
|
|
123
|
+
return refuse(
|
|
124
|
+
"unrecognized_format",
|
|
125
|
+
`"${file.name}" is neither a .csv in the ${IMPORT_FORMAT} format nor a .txt plain list of addresses.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (/[�\0]/.test(file.text)) {
|
|
129
|
+
return refuse(
|
|
130
|
+
"unrecognized_format",
|
|
131
|
+
`"${file.name}" isn\u2019t UTF-8 text. Save it as UTF-8 (CSV UTF-8 in a spreadsheet) and choose it again.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const text = file.text.startsWith("\uFEFF") ? file.text.slice(1) : file.text;
|
|
135
|
+
return kind === "csv" ? parseCsvFile(text) : parsePlainList(text, plainListStatus);
|
|
136
|
+
}
|
|
137
|
+
function refuse(code, message2, at = {}) {
|
|
138
|
+
return { ok: false, error: { code, message: message2, ...at } };
|
|
139
|
+
}
|
|
140
|
+
function parsePlainList(text, declared) {
|
|
141
|
+
const lines = text.split(/\r\n|\n|\r/);
|
|
142
|
+
const rows = [];
|
|
143
|
+
let anyValid = false;
|
|
144
|
+
for (let i = 0; i < lines.length; i++) {
|
|
145
|
+
const line = (lines[i] ?? "").trim();
|
|
146
|
+
if (line === "") {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (/[,;\t]/.test(line)) {
|
|
150
|
+
return refuse(
|
|
151
|
+
"unrecognized_format",
|
|
152
|
+
`Line ${i + 1} has a comma, tab, or semicolon, so this is a table, not a plain list. Save it as a .csv in the ${IMPORT_FORMAT} format, or keep only the addresses, one per line.`,
|
|
153
|
+
{ row: i + 1 }
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const email2 = normalizeEmail(line);
|
|
157
|
+
const valid = isValidEmail(email2);
|
|
158
|
+
anyValid ||= valid;
|
|
159
|
+
rows.push({
|
|
160
|
+
row: i + 1,
|
|
161
|
+
email: valid ? email2 : line,
|
|
162
|
+
valid,
|
|
163
|
+
status: declared === "bounced" ? "none" : declared,
|
|
164
|
+
opt_in: "unknown",
|
|
165
|
+
origin: "unknown",
|
|
166
|
+
suppression: declared === "bounced" ? "bounce" : null,
|
|
167
|
+
subscribed_at: null,
|
|
168
|
+
confirmed_at: null,
|
|
169
|
+
unsubscribed_at: null,
|
|
170
|
+
suppressed_at: null,
|
|
171
|
+
evidence: null
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (!anyValid) {
|
|
175
|
+
return refuse(
|
|
176
|
+
"unrecognized_format",
|
|
177
|
+
"There isn\u2019t one valid email address in this file. A plain list is one address per line."
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return { ok: true, file: { kind: "txt", rows } };
|
|
181
|
+
}
|
|
182
|
+
function splitCsv(text) {
|
|
183
|
+
const records = [];
|
|
184
|
+
let record = [];
|
|
185
|
+
let i = 0;
|
|
186
|
+
const n = text.length;
|
|
187
|
+
while (i <= n) {
|
|
188
|
+
if (i === n) {
|
|
189
|
+
if (record.length > 0) {
|
|
190
|
+
records.push(record);
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
let field;
|
|
195
|
+
if (text.charCodeAt(i) === 34) {
|
|
196
|
+
let out = "";
|
|
197
|
+
let j = i + 1;
|
|
198
|
+
for (; ; ) {
|
|
199
|
+
const q = text.indexOf('"', j);
|
|
200
|
+
if (q === -1) {
|
|
201
|
+
return { unclosed: records.length };
|
|
202
|
+
}
|
|
203
|
+
out += text.slice(j, q);
|
|
204
|
+
if (text.charCodeAt(q + 1) === 34) {
|
|
205
|
+
out += '"';
|
|
206
|
+
j = q + 2;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
i = q + 1;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
field = out;
|
|
213
|
+
const end = nextDelimiter(text, i);
|
|
214
|
+
field += text.slice(i, end);
|
|
215
|
+
i = end;
|
|
216
|
+
} else {
|
|
217
|
+
const end = nextDelimiter(text, i);
|
|
218
|
+
field = text.slice(i, end);
|
|
219
|
+
i = end;
|
|
220
|
+
}
|
|
221
|
+
record.push(field);
|
|
222
|
+
const c = text.charCodeAt(i);
|
|
223
|
+
if (c === 44) {
|
|
224
|
+
i += 1;
|
|
225
|
+
if (i === n) {
|
|
226
|
+
record.push("");
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
records.push(record);
|
|
231
|
+
record = [];
|
|
232
|
+
if (c === 13 && text.charCodeAt(i + 1) === 10) {
|
|
233
|
+
i += 2;
|
|
234
|
+
} else {
|
|
235
|
+
i += 1;
|
|
236
|
+
}
|
|
237
|
+
if (i >= n) {
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { records };
|
|
242
|
+
}
|
|
243
|
+
function nextDelimiter(text, from) {
|
|
244
|
+
for (let k = from; k < text.length; k++) {
|
|
245
|
+
const c = text.charCodeAt(k);
|
|
246
|
+
if (c === 44 || c === 10 || c === 13) {
|
|
247
|
+
return k;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return text.length;
|
|
251
|
+
}
|
|
252
|
+
function parseInstant(value) {
|
|
253
|
+
if (!INSTANT.test(value)) {
|
|
254
|
+
return void 0;
|
|
255
|
+
}
|
|
256
|
+
const ms = Date.parse(value);
|
|
257
|
+
return Number.isNaN(ms) ? void 0 : ms;
|
|
258
|
+
}
|
|
259
|
+
function utf8Bytes(s) {
|
|
260
|
+
return new TextEncoder().encode(s).length;
|
|
261
|
+
}
|
|
262
|
+
function parseCsvFile(text) {
|
|
263
|
+
const split = splitCsv(text);
|
|
264
|
+
if ("unclosed" in split) {
|
|
265
|
+
return refuse(
|
|
266
|
+
"unrecognized_format",
|
|
267
|
+
`This isn\u2019t valid CSV: a quoted value that opens in row ${split.unclosed} is never closed.`,
|
|
268
|
+
{ row: split.unclosed }
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const records = split.records.filter((r) => !(r.length === 1 && r[0]?.trim() === ""));
|
|
272
|
+
const header = records[0];
|
|
273
|
+
if (!header?.includes("email")) {
|
|
274
|
+
return refuse(
|
|
275
|
+
"unrecognized_format",
|
|
276
|
+
`This isn\u2019t a ${IMPORT_FORMAT} file: its first row must name the columns, including email, status, opt_in, and origin. An export from another service is converted to this format first.`,
|
|
277
|
+
{ row: 0 }
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const seen = /* @__PURE__ */ new Set();
|
|
281
|
+
for (const name of header) {
|
|
282
|
+
if (!COLUMN_NAMES.has(name)) {
|
|
283
|
+
const lower = name.trim().toLowerCase();
|
|
284
|
+
const hint = COLUMN_NAMES.has(lower) ? ` Column names are written exactly, in lowercase: "${lower}".` : " A source\u2019s other columns belong in evidence, as JSON, not beside it.";
|
|
285
|
+
return refuse(
|
|
286
|
+
"invalid_field",
|
|
287
|
+
`The column "${name}" isn\u2019t part of ${IMPORT_FORMAT}.${hint}`,
|
|
288
|
+
{
|
|
289
|
+
row: 0,
|
|
290
|
+
column: name,
|
|
291
|
+
allowed: [...COLUMN_NAMES]
|
|
292
|
+
}
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (seen.has(name)) {
|
|
296
|
+
return refuse("invalid_field", `The column "${name}" appears twice in the header.`, {
|
|
297
|
+
row: 0,
|
|
298
|
+
column: name
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
seen.add(name);
|
|
302
|
+
}
|
|
303
|
+
for (const col of IMPORT_COLUMNS) {
|
|
304
|
+
if (col.required && !seen.has(col.name)) {
|
|
305
|
+
return refuse("invalid_field", `The required column "${col.name}" is missing.`, {
|
|
306
|
+
row: 0,
|
|
307
|
+
column: col.name
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const at = (name) => header.indexOf(name);
|
|
312
|
+
const idx = {
|
|
313
|
+
email: at("email"),
|
|
314
|
+
status: at("status"),
|
|
315
|
+
opt_in: at("opt_in"),
|
|
316
|
+
origin: at("origin"),
|
|
317
|
+
suppression: at("suppression"),
|
|
318
|
+
subscribed_at: at("subscribed_at"),
|
|
319
|
+
confirmed_at: at("confirmed_at"),
|
|
320
|
+
unsubscribed_at: at("unsubscribed_at"),
|
|
321
|
+
suppressed_at: at("suppressed_at"),
|
|
322
|
+
evidence: at("evidence"),
|
|
323
|
+
format: at("format")
|
|
324
|
+
};
|
|
325
|
+
const rows = [];
|
|
326
|
+
for (let r = 1; r < records.length; r++) {
|
|
327
|
+
const record = records[r] ?? [];
|
|
328
|
+
if (record.length !== header.length) {
|
|
329
|
+
return refuse(
|
|
330
|
+
"invalid_field",
|
|
331
|
+
`Row ${r} has ${record.length} values for the header\u2019s ${header.length} columns.`,
|
|
332
|
+
{ row: r }
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
const cell = (i) => i < 0 ? "" : unguardFormula((record[i] ?? "").trim());
|
|
336
|
+
const pick = (name, i, allowed) => {
|
|
337
|
+
const v2 = cell(i);
|
|
338
|
+
if (allowed.includes(v2)) {
|
|
339
|
+
return v2;
|
|
340
|
+
}
|
|
341
|
+
throw refuse(
|
|
342
|
+
"invalid_field",
|
|
343
|
+
`Row ${r}: ${name} is "${v2}", which isn\u2019t one of ${allowed.join(", ")}.`,
|
|
344
|
+
{ row: r, column: name, allowed }
|
|
345
|
+
).error;
|
|
346
|
+
};
|
|
347
|
+
const instant = (name, i) => {
|
|
348
|
+
const v2 = cell(i);
|
|
349
|
+
if (v2 === "") {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
const ms = parseInstant(v2);
|
|
353
|
+
if (ms === void 0) {
|
|
354
|
+
throw refuse(
|
|
355
|
+
"invalid_field",
|
|
356
|
+
`Row ${r}: ${name} is "${v2}", which isn\u2019t an instant with its zone, such as 2024-03-01T09:30:00Z. A time without a zone is never guessed at.`,
|
|
357
|
+
{ row: r, column: name }
|
|
358
|
+
).error;
|
|
359
|
+
}
|
|
360
|
+
return ms;
|
|
361
|
+
};
|
|
362
|
+
try {
|
|
363
|
+
const format2 = cell(idx.format);
|
|
364
|
+
if (format2 !== "" && !IMPORT_FORMAT_VERSIONS.includes(format2)) {
|
|
365
|
+
return refuse(
|
|
366
|
+
"unrecognized_format",
|
|
367
|
+
`Row ${r} is written in "${format2}", a version this release can\u2019t read. It reads ${IMPORT_FORMAT_VERSIONS.join(", ")}.`,
|
|
368
|
+
{ row: r, column: "format", allowed: IMPORT_FORMAT_VERSIONS }
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
const origin = cell(idx.origin);
|
|
372
|
+
if (!ORIGIN.test(origin)) {
|
|
373
|
+
return refuse(
|
|
374
|
+
"invalid_field",
|
|
375
|
+
`Row ${r}: origin is "${origin}", which isn\u2019t a short lowercase name such as sendy, mailchimp, or unknown.`,
|
|
376
|
+
{ row: r, column: "origin" }
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const suppression = cell(idx.suppression);
|
|
380
|
+
const evidenceText = cell(idx.evidence);
|
|
381
|
+
let evidence = null;
|
|
382
|
+
if (evidenceText !== "") {
|
|
383
|
+
let parsed;
|
|
384
|
+
try {
|
|
385
|
+
parsed = JSON.parse(evidenceText);
|
|
386
|
+
} catch {
|
|
387
|
+
parsed = void 0;
|
|
388
|
+
}
|
|
389
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
390
|
+
return refuse("invalid_field", `Row ${r}: evidence isn\u2019t a JSON object.`, {
|
|
391
|
+
row: r,
|
|
392
|
+
column: "evidence"
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
evidence = JSON.stringify(parsed);
|
|
396
|
+
if (utf8Bytes(evidence) > IMPORT_LIMITS.maxEvidenceBytes) {
|
|
397
|
+
return refuse(
|
|
398
|
+
"invalid_field",
|
|
399
|
+
`Row ${r}: evidence is over ${IMPORT_LIMITS.maxEvidenceBytes} bytes. Keep only the source\u2019s consent-relevant columns.`,
|
|
400
|
+
{ row: r, column: "evidence" }
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const raw = cell(idx.email);
|
|
405
|
+
const email2 = normalizeEmail(raw);
|
|
406
|
+
const valid = isValidEmail(email2);
|
|
407
|
+
rows.push({
|
|
408
|
+
row: r,
|
|
409
|
+
email: valid ? email2 : raw,
|
|
410
|
+
valid,
|
|
411
|
+
status: pick("status", idx.status, IMPORT_STATUSES),
|
|
412
|
+
opt_in: pick("opt_in", idx.opt_in, OPT_INS),
|
|
413
|
+
origin,
|
|
414
|
+
suppression: suppression === "" ? null : pick("suppression", idx.suppression, SUPPRESSION_CLAIMS),
|
|
415
|
+
subscribed_at: instant("subscribed_at", idx.subscribed_at),
|
|
416
|
+
confirmed_at: instant("confirmed_at", idx.confirmed_at),
|
|
417
|
+
unsubscribed_at: instant("unsubscribed_at", idx.unsubscribed_at),
|
|
418
|
+
suppressed_at: instant("suppressed_at", idx.suppressed_at),
|
|
419
|
+
evidence
|
|
420
|
+
});
|
|
421
|
+
} catch (err) {
|
|
422
|
+
return { ok: false, error: err };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return { ok: true, file: { kind: "csv", rows } };
|
|
426
|
+
}
|
|
427
|
+
var STATUS_RANK = {
|
|
428
|
+
none: 0,
|
|
429
|
+
pending: 1,
|
|
430
|
+
subscribed: 2,
|
|
431
|
+
unsubscribed: 3
|
|
432
|
+
};
|
|
433
|
+
var SUPPRESSION_RANK = {
|
|
434
|
+
manual: 0,
|
|
435
|
+
bounce: 1,
|
|
436
|
+
complaint: 2,
|
|
437
|
+
erased: 3
|
|
438
|
+
};
|
|
439
|
+
var OPT_IN_STRENGTH = { unknown: 0, single: 1, double: 2 };
|
|
440
|
+
function earliest(a, b) {
|
|
441
|
+
return a === null ? b : b === null ? a : Math.min(a, b);
|
|
442
|
+
}
|
|
443
|
+
function latest(a, b) {
|
|
444
|
+
return a === null ? b : b === null ? a : Math.max(a, b);
|
|
445
|
+
}
|
|
446
|
+
function mergeRows(rows) {
|
|
447
|
+
const byEmail2 = /* @__PURE__ */ new Map();
|
|
448
|
+
const out = { rows: [], repeats: [], invalid: [] };
|
|
449
|
+
for (const row of rows) {
|
|
450
|
+
if (!row.valid) {
|
|
451
|
+
out.invalid.push(row);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const held = byEmail2.get(row.email);
|
|
455
|
+
if (!held) {
|
|
456
|
+
const copy = { ...row };
|
|
457
|
+
byEmail2.set(row.email, copy);
|
|
458
|
+
out.rows.push(copy);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
out.repeats.push(row);
|
|
462
|
+
if (STATUS_RANK[row.status] > STATUS_RANK[held.status]) {
|
|
463
|
+
held.status = row.status;
|
|
464
|
+
held.origin = row.origin;
|
|
465
|
+
held.evidence = row.evidence;
|
|
466
|
+
}
|
|
467
|
+
if (row.suppression !== null && (held.suppression === null || SUPPRESSION_RANK[row.suppression] > SUPPRESSION_RANK[held.suppression])) {
|
|
468
|
+
held.suppression = row.suppression;
|
|
469
|
+
}
|
|
470
|
+
if (OPT_IN_STRENGTH[row.opt_in] < OPT_IN_STRENGTH[held.opt_in]) {
|
|
471
|
+
held.opt_in = row.opt_in;
|
|
472
|
+
}
|
|
473
|
+
held.subscribed_at = earliest(held.subscribed_at, row.subscribed_at);
|
|
474
|
+
held.confirmed_at = earliest(held.confirmed_at, row.confirmed_at);
|
|
475
|
+
held.unsubscribed_at = latest(held.unsubscribed_at, row.unsubscribed_at);
|
|
476
|
+
held.suppressed_at = latest(held.suppressed_at, row.suppressed_at);
|
|
477
|
+
}
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
var FORMULA_START = /^[=+\-@\t\r]/;
|
|
481
|
+
function guardFormula(value) {
|
|
482
|
+
return FORMULA_START.test(value) ? `'${value}` : value;
|
|
483
|
+
}
|
|
484
|
+
function unguardFormula(value) {
|
|
485
|
+
return value.startsWith("'") && FORMULA_START.test(value.slice(1)) ? value.slice(1) : value;
|
|
486
|
+
}
|
|
487
|
+
function csvField(raw) {
|
|
488
|
+
const value = guardFormula(raw);
|
|
489
|
+
return /[",\r\n]/.test(value) || value !== value.trim() ? `"${value.replace(/"/g, '""')}"` : value;
|
|
490
|
+
}
|
|
491
|
+
function toImportCsv(rows) {
|
|
492
|
+
const names = IMPORT_COLUMNS.map((c) => c.name);
|
|
493
|
+
const lines = [names.join(",")];
|
|
494
|
+
for (const row of rows) {
|
|
495
|
+
lines.push(names.map((name) => csvField(row[name] ?? "")).join(","));
|
|
496
|
+
}
|
|
497
|
+
return `${lines.join("\r\n")}\r
|
|
498
|
+
`;
|
|
499
|
+
}
|
|
500
|
+
var TEMPLATE_ROWS = [
|
|
501
|
+
{
|
|
502
|
+
email: "ada@example.com",
|
|
503
|
+
status: "subscribed",
|
|
504
|
+
opt_in: "double",
|
|
505
|
+
origin: "sendy",
|
|
506
|
+
suppression: null,
|
|
507
|
+
subscribed_at: "2024-03-01T09:30:00Z",
|
|
508
|
+
confirmed_at: "2024-03-01T09:42:00Z",
|
|
509
|
+
unsubscribed_at: null,
|
|
510
|
+
suppressed_at: null,
|
|
511
|
+
evidence: '{"signup_ip":"203.0.113.7","list":"Field Notes"}',
|
|
512
|
+
format: IMPORT_FORMAT
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
email: "grace@example.com",
|
|
516
|
+
status: "subscribed",
|
|
517
|
+
opt_in: "single",
|
|
518
|
+
origin: "mailchimp",
|
|
519
|
+
suppression: null,
|
|
520
|
+
subscribed_at: "2023-11-12T16:05:00Z",
|
|
521
|
+
confirmed_at: null,
|
|
522
|
+
unsubscribed_at: null,
|
|
523
|
+
suppressed_at: null,
|
|
524
|
+
evidence: null,
|
|
525
|
+
format: IMPORT_FORMAT
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
email: "edsger@example.com",
|
|
529
|
+
status: "unsubscribed",
|
|
530
|
+
opt_in: "unknown",
|
|
531
|
+
origin: "unknown",
|
|
532
|
+
suppression: null,
|
|
533
|
+
subscribed_at: null,
|
|
534
|
+
confirmed_at: null,
|
|
535
|
+
unsubscribed_at: "2025-01-20T08:00:00Z",
|
|
536
|
+
suppressed_at: null,
|
|
537
|
+
evidence: null,
|
|
538
|
+
format: IMPORT_FORMAT
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
email: "bounced@example.com",
|
|
542
|
+
status: "none",
|
|
543
|
+
opt_in: "unknown",
|
|
544
|
+
origin: "unknown",
|
|
545
|
+
suppression: "bounce",
|
|
546
|
+
subscribed_at: null,
|
|
547
|
+
confirmed_at: null,
|
|
548
|
+
unsubscribed_at: null,
|
|
549
|
+
suppressed_at: "2025-02-02T12:00:00Z",
|
|
550
|
+
evidence: null,
|
|
551
|
+
format: IMPORT_FORMAT
|
|
552
|
+
}
|
|
553
|
+
];
|
|
554
|
+
var TEMPLATE_CSV = toImportCsv(TEMPLATE_ROWS);
|
|
555
|
+
var TEMPLATE_TXT = "ada@example.com\r\ngrace@example.com\r\nlinus@example.com\r\n";
|
|
556
|
+
var IMPORT_OUTCOMES = [
|
|
557
|
+
"create_confirmed",
|
|
558
|
+
"confirm_pending",
|
|
559
|
+
"unchanged",
|
|
560
|
+
"mark_unsubscribed",
|
|
561
|
+
"create_unsubscribed",
|
|
562
|
+
"kept_confirmed_newer",
|
|
563
|
+
"kept_unsubscribed",
|
|
564
|
+
"blocked_by_suppression",
|
|
565
|
+
"suppression_added",
|
|
566
|
+
"left_out",
|
|
567
|
+
"invalid",
|
|
568
|
+
"merged"
|
|
569
|
+
];
|
|
570
|
+
var IMPORT_OUTCOME_MEANINGS = {
|
|
571
|
+
create_confirmed: "`subscribed`, with no subscriber and no suppression here: added as confirmed.",
|
|
572
|
+
confirm_pending: "`subscribed`, and pending here (never confirmed): confirmed, and its confirmation link no longer works.",
|
|
573
|
+
unchanged: "Already confirmed here, or already in the state the row claims. A subscriber who joined here keeps their own record.",
|
|
574
|
+
mark_unsubscribed: "`unsubscribed`, and confirmed or pending here, with no confirmation here newer than the row\u2019s `unsubscribed_at`: unsubscribed.",
|
|
575
|
+
create_unsubscribed: "`unsubscribed`, with no subscriber here: recorded as unsubscribed, so the list remembers they left.",
|
|
576
|
+
kept_confirmed_newer: "`unsubscribed`, but the reader confirmed here after the source\u2019s `unsubscribed_at`: their newer action stands.",
|
|
577
|
+
kept_unsubscribed: "`subscribed`, but unsubscribed here, or pending again after leaving until they confirm here themselves. An unsubscribe here is never reversed (I2).",
|
|
578
|
+
blocked_by_suppression: "`subscribed`, but the address carries a suppression here, a do-not-contact marker included: never added or confirmed.",
|
|
579
|
+
suppression_added: "The row carries a suppression and the address has none here (or only a do-not-contact marker, which a bounce, a complaint, or a manual suppression replaces), and nothing else changes. Applied whatever the row\u2019s `status`.",
|
|
580
|
+
left_out: "`pending` or `none` with nothing to apply: counted, never confirmed. An import sends no email.",
|
|
581
|
+
invalid: "The address isn\u2019t valid.",
|
|
582
|
+
merged: "A repeat of an address earlier in the file, folded into it."
|
|
583
|
+
};
|
|
584
|
+
var CONFIRMING_OUTCOMES = /* @__PURE__ */ new Set([
|
|
585
|
+
"create_confirmed",
|
|
586
|
+
"confirm_pending"
|
|
587
|
+
]);
|
|
588
|
+
var ATTESTATION = {
|
|
589
|
+
version: 1,
|
|
590
|
+
text: "Everyone I\u2019m importing as subscribed opted in to receive [publication]. This isn\u2019t a purchased, rented, or scraped list."
|
|
591
|
+
};
|
|
592
|
+
function attestationText(publication) {
|
|
593
|
+
return ATTESTATION.text.replace("[publication]", publication || "this newsletter");
|
|
594
|
+
}
|
|
595
|
+
var PLAIN_LIST_RULES = [
|
|
596
|
+
"One address per line. Each line is trimmed, blank lines are skipped, and CRLF line endings and a UTF-8 byte-order mark are accepted.",
|
|
597
|
+
"A line that isn\u2019t a single valid address (`Name <ada@example.com>`, a typo) is counted as invalid and listed among the rows left out, so one bad line doesn\u2019t sink a list.",
|
|
598
|
+
"The whole file is refused as `unrecognized_format` when it isn\u2019t a plain list at all: not UTF-8 text, a line with a comma, tab, or semicolon (it\u2019s a table, so a .csv), or not one valid address in it.",
|
|
599
|
+
"Its rows arrive with `origin: unknown` and `opt_in: unknown`."
|
|
600
|
+
];
|
|
601
|
+
function describeFormat(maxRows, maxRowsReason) {
|
|
602
|
+
return {
|
|
603
|
+
format: IMPORT_FORMAT,
|
|
604
|
+
versions: IMPORT_FORMAT_VERSIONS,
|
|
605
|
+
preferred: {
|
|
606
|
+
kind: "csv",
|
|
607
|
+
why: "The full CSV carries unsubscribes, bounces, and how each person joined; it is what the export writes; and you can open it in a spreadsheet to check it before importing."
|
|
608
|
+
},
|
|
609
|
+
files: [
|
|
610
|
+
{
|
|
611
|
+
kind: "csv",
|
|
612
|
+
name: `${IMPORT_FORMAT} (.csv)`,
|
|
613
|
+
when: "Any list with more than addresses: who unsubscribed or bounced, how and where each person joined, and the evidence. The format the export writes.",
|
|
614
|
+
loses: [],
|
|
615
|
+
template: "/api/imports/template.csv"
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
kind: "txt",
|
|
619
|
+
name: "Plain list (.txt)",
|
|
620
|
+
when: "A list that is only addresses, all of one kind, which you declare: subscribed, unsubscribed, or bounced.",
|
|
621
|
+
loses: [
|
|
622
|
+
"How each person joined: their opt-in method and origin are recorded as unknown.",
|
|
623
|
+
"Any times: when each person subscribed, confirmed, or left.",
|
|
624
|
+
"The evidence of consent.",
|
|
625
|
+
"Mixed states: every address in the file is the one kind you declare, so unsubscribes and bounces need a file of their own."
|
|
626
|
+
],
|
|
627
|
+
template: "/api/imports/template.txt"
|
|
628
|
+
}
|
|
629
|
+
],
|
|
630
|
+
columns: IMPORT_COLUMNS,
|
|
631
|
+
plain_list: {
|
|
632
|
+
statuses: [
|
|
633
|
+
{
|
|
634
|
+
value: "subscribed",
|
|
635
|
+
means: "They opted in: imported as confirmed, with your attestation."
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
value: "unsubscribed",
|
|
639
|
+
means: "They left: unsubscribed here, or recorded as unsubscribed."
|
|
640
|
+
},
|
|
641
|
+
{ value: "bounced", means: "Their mail bounced: suppressed, whatever their state here." }
|
|
642
|
+
],
|
|
643
|
+
default: "subscribed",
|
|
644
|
+
rules: [...PLAIN_LIST_RULES]
|
|
645
|
+
},
|
|
646
|
+
limits: {
|
|
647
|
+
max_rows: maxRows,
|
|
648
|
+
max_rows_reason: maxRowsReason,
|
|
649
|
+
max_body_bytes: IMPORT_LIMITS.maxBodyBytes,
|
|
650
|
+
max_evidence_bytes: IMPORT_LIMITS.maxEvidenceBytes
|
|
651
|
+
},
|
|
652
|
+
outcomes: IMPORT_OUTCOMES.map((outcome) => ({
|
|
653
|
+
outcome,
|
|
654
|
+
when: IMPORT_OUTCOME_MEANINGS[outcome]
|
|
655
|
+
})),
|
|
656
|
+
attestation: { version: ATTESTATION.version, text: ATTESTATION.text }
|
|
657
|
+
};
|
|
658
|
+
}
|
|
2
659
|
|
|
3
660
|
// shared/sends.ts
|
|
4
661
|
var NOT_CONNECTED_ADVICE = {
|
|
@@ -9,13 +666,15 @@ var NOT_CONNECTED_ADVICE = {
|
|
|
9
666
|
function refusalAdvice(cause) {
|
|
10
667
|
switch (cause) {
|
|
11
668
|
case "credentials":
|
|
12
|
-
return "Replace the SES access key in the deployment
|
|
669
|
+
return "Replace the SES access key in the deployment\u2019s secrets, or give it ses:SendEmail and ses:SendBulkEmail in its IAM policy.";
|
|
670
|
+
case "configuration":
|
|
671
|
+
return "Check that the SES_CONFIGURATION_SET secret names a configuration set in your SES region, or create the set in the SES console.";
|
|
13
672
|
case "sender":
|
|
14
673
|
return "Verify the sending domain or from-address with the provider.";
|
|
15
674
|
case "quota":
|
|
16
|
-
return "Wait for SES
|
|
675
|
+
return "Wait for SES\u2019s 24-hour quota to free up, or ask AWS to raise it in the Service Quotas console.";
|
|
17
676
|
case "suspended":
|
|
18
|
-
return "Settle the account
|
|
677
|
+
return "Settle the account\u2019s standing with the provider (for SES, in the SES console).";
|
|
19
678
|
case "not_connected":
|
|
20
679
|
return `${NOT_CONNECTED_ADVICE.step}${NOT_CONNECTED_ADVICE.guide}${NOT_CONNECTED_ADVICE.after}`;
|
|
21
680
|
default:
|
|
@@ -34,16 +693,23 @@ function formatLead(ms) {
|
|
|
34
693
|
}
|
|
35
694
|
return `${seconds} seconds`;
|
|
36
695
|
}
|
|
696
|
+
function tooSoonMessage(minLeadMs) {
|
|
697
|
+
return `Pick a time at least ${formatLead(minLeadMs)} from now, so there\u2019s time to cancel.`;
|
|
698
|
+
}
|
|
37
699
|
var BOUNCE_SPIKE_RATE = 0.05;
|
|
38
700
|
var BOUNCE_SPIKE_MIN = 3;
|
|
39
701
|
var BOUNCE_SPIKE_RECENT_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
702
|
+
var DAILY_WRITES_SEND_RECIPIENTS = 5e3;
|
|
703
|
+
function dailyWritesSendWarning(recipients) {
|
|
704
|
+
return `On the Workers Free plan\u2019s limits, a send to ${recipients.toLocaleString("en-US")} recipients may use up the database\u2019s daily allowance of writes. That stops this newsletter until midnight UTC, the send included. Workers Paid is recommended for a list of more than ${DAILY_WRITES_SEND_RECIPIENTS.toLocaleString("en-US")}.`;
|
|
705
|
+
}
|
|
40
706
|
|
|
41
707
|
// src/generated/version.ts
|
|
42
708
|
var BUILD_INFO = {
|
|
43
|
-
version: "1.
|
|
44
|
-
sha: "
|
|
45
|
-
tag: "v1.
|
|
46
|
-
buildTime: "2026-10-
|
|
709
|
+
version: "1.9.0",
|
|
710
|
+
sha: "7ad2b17",
|
|
711
|
+
tag: "v1.9.0",
|
|
712
|
+
buildTime: "2026-10-13T16:35:20.501Z",
|
|
47
713
|
repoUrl: "https://github.com/kurtbruns/kestrel"
|
|
48
714
|
};
|
|
49
715
|
|
|
@@ -127,7 +793,6 @@ var HttpError = class extends Error {
|
|
|
127
793
|
};
|
|
128
794
|
var badRequest = (message2, details) => new HttpError(400, "bad_request", message2, details);
|
|
129
795
|
var notFound = (message2) => new HttpError(404, "not_found", message2);
|
|
130
|
-
var conflict = (message2) => new HttpError(409, "conflict", message2);
|
|
131
796
|
var refusal = (status, code, message2, details) => new HttpError(status, code, message2, details);
|
|
132
797
|
var unsupportedMediaType = (message2) => new HttpError(415, "unsupported_media_type", message2);
|
|
133
798
|
function json(data, status = 200, headers2) {
|
|
@@ -205,6 +870,7 @@ var RESOURCE_TITLES = {
|
|
|
205
870
|
posts: "Posts",
|
|
206
871
|
sends: "Sends",
|
|
207
872
|
subscribers: "Subscribers",
|
|
873
|
+
imports: "Imports",
|
|
208
874
|
suppressions: "Suppressions",
|
|
209
875
|
subscriptions: "Subscriptions",
|
|
210
876
|
archive: "Archive",
|
|
@@ -348,17 +1014,20 @@ function isRealProvider(name) {
|
|
|
348
1014
|
var REQUIRED_VARS = {
|
|
349
1015
|
ses: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION", "SNS_TOPIC_ARN"]
|
|
350
1016
|
};
|
|
351
|
-
function isProviderName(
|
|
352
|
-
return PROVIDER_NAMES.includes(
|
|
1017
|
+
function isProviderName(v2) {
|
|
1018
|
+
return PROVIDER_NAMES.includes(v2);
|
|
353
1019
|
}
|
|
354
1020
|
|
|
355
1021
|
// src/env.ts
|
|
356
1022
|
var DEFAULT_SES_MAX_SEND_RATE = 14;
|
|
357
1023
|
var DEFAULT_SUBREQUEST_BUDGET = 50;
|
|
1024
|
+
function onFreePlanLimits(config) {
|
|
1025
|
+
return config.subrequestBudget <= DEFAULT_SUBREQUEST_BUDGET;
|
|
1026
|
+
}
|
|
358
1027
|
var MIN_SUBREQUEST_BUDGET = 30;
|
|
359
|
-
var orUndefined = (
|
|
360
|
-
function normalizeBasePath(
|
|
361
|
-
const raw = (orUndefined(
|
|
1028
|
+
var orUndefined = (v2) => v2 && v2.length > 0 ? v2 : void 0;
|
|
1029
|
+
function normalizeBasePath(v2) {
|
|
1030
|
+
const raw = (orUndefined(v2) ?? "/archive").trim();
|
|
362
1031
|
const withLead = raw.startsWith("/") ? raw : `/${raw}`;
|
|
363
1032
|
return withLead.length > 1 && withLead.endsWith("/") ? withLead.slice(0, -1) : withLead;
|
|
364
1033
|
}
|
|
@@ -457,8 +1126,8 @@ function readTeamDomain(raw) {
|
|
|
457
1126
|
}
|
|
458
1127
|
return host;
|
|
459
1128
|
}
|
|
460
|
-
function readProvider(
|
|
461
|
-
const raw = orUndefined(
|
|
1129
|
+
function readProvider(v2) {
|
|
1130
|
+
const raw = orUndefined(v2);
|
|
462
1131
|
if (raw === void 0) {
|
|
463
1132
|
return "none";
|
|
464
1133
|
}
|
|
@@ -551,8 +1220,8 @@ function parseHttpUrl(name, raw) {
|
|
|
551
1220
|
}
|
|
552
1221
|
return url;
|
|
553
1222
|
}
|
|
554
|
-
function readOrigin(name,
|
|
555
|
-
const raw = orUndefined(
|
|
1223
|
+
function readOrigin(name, v2) {
|
|
1224
|
+
const raw = orUndefined(v2?.trim());
|
|
556
1225
|
if (raw === void 0) {
|
|
557
1226
|
return void 0;
|
|
558
1227
|
}
|
|
@@ -565,16 +1234,16 @@ function readOrigin(name, v) {
|
|
|
565
1234
|
}
|
|
566
1235
|
return url.origin;
|
|
567
1236
|
}
|
|
568
|
-
function readBaseUrl(name,
|
|
569
|
-
const raw = orUndefined(
|
|
1237
|
+
function readBaseUrl(name, v2) {
|
|
1238
|
+
const raw = orUndefined(v2?.trim());
|
|
570
1239
|
if (raw === void 0) {
|
|
571
1240
|
return void 0;
|
|
572
1241
|
}
|
|
573
1242
|
const url = parseHttpUrl(name, raw);
|
|
574
1243
|
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
575
1244
|
}
|
|
576
|
-
function readPositiveInt(name,
|
|
577
|
-
const raw = orUndefined(
|
|
1245
|
+
function readPositiveInt(name, v2) {
|
|
1246
|
+
const raw = orUndefined(v2?.trim());
|
|
578
1247
|
if (raw === void 0) {
|
|
579
1248
|
return void 0;
|
|
580
1249
|
}
|
|
@@ -584,29 +1253,29 @@ function readPositiveInt(name, v) {
|
|
|
584
1253
|
}
|
|
585
1254
|
return n;
|
|
586
1255
|
}
|
|
587
|
-
function readMinLead(
|
|
588
|
-
const seconds = readPositiveInt("MIN_LEAD_SECONDS",
|
|
1256
|
+
function readMinLead(v2) {
|
|
1257
|
+
const seconds = readPositiveInt("MIN_LEAD_SECONDS", v2);
|
|
589
1258
|
if (seconds === void 0) {
|
|
590
1259
|
return DEFAULT_MIN_LEAD_MS;
|
|
591
1260
|
}
|
|
592
1261
|
if (seconds * 1e3 < MIN_LEAD_FLOOR_MS) {
|
|
593
1262
|
throw new ConfigError(
|
|
594
1263
|
"MIN_LEAD_SECONDS",
|
|
595
|
-
`must be at least ${MIN_LEAD_FLOOR_MS / 1e3}, not "${
|
|
1264
|
+
`must be at least ${MIN_LEAD_FLOOR_MS / 1e3}, not "${v2?.trim()}": the send sweep runs once a minute, so under ${formatLead(MIN_LEAD_FLOOR_MS)} the tick, not the lead, would decide when a send fires`
|
|
596
1265
|
);
|
|
597
1266
|
}
|
|
598
1267
|
if (seconds * 1e3 > MIN_LEAD_CEILING_MS) {
|
|
599
1268
|
throw new ConfigError(
|
|
600
1269
|
"MIN_LEAD_SECONDS",
|
|
601
|
-
`must be at most ${MIN_LEAD_CEILING_MS / 1e3} (one day), not "${
|
|
1270
|
+
`must be at most ${MIN_LEAD_CEILING_MS / 1e3} (one day), not "${v2?.trim()}": it is in seconds, and it is the least wait before every send, not a limit on how far out one can be scheduled`
|
|
602
1271
|
);
|
|
603
1272
|
}
|
|
604
1273
|
return seconds * 1e3;
|
|
605
1274
|
}
|
|
606
1275
|
var SIMULATION_OFF = /* @__PURE__ */ new Set(["", "0", "off", "false", "no"]);
|
|
607
1276
|
var SIMULATION_GENERIC = /* @__PURE__ */ new Set(["1", "on", "true", "yes"]);
|
|
608
|
-
function readSimulateSends(
|
|
609
|
-
const raw = (
|
|
1277
|
+
function readSimulateSends(v2) {
|
|
1278
|
+
const raw = (v2 ?? "").trim().toLowerCase();
|
|
610
1279
|
if (SIMULATION_OFF.has(raw)) {
|
|
611
1280
|
return null;
|
|
612
1281
|
}
|
|
@@ -618,16 +1287,16 @@ function readSimulateSends(v) {
|
|
|
618
1287
|
if (rest.length > 0 || !SIMULATION_PROFILES.includes(profile) || !SIMULATION_FAULTS.includes(faults)) {
|
|
619
1288
|
throw new ConfigError(
|
|
620
1289
|
"SIMULATE_SENDS",
|
|
621
|
-
`must be off, one of ${SIMULATION_PROFILES.join(", ")} (or 1) optionally followed by :${SIMULATION_FAULTS.join(" or :")}, or not-connected, not "${
|
|
1290
|
+
`must be off, one of ${SIMULATION_PROFILES.join(", ")} (or 1) optionally followed by :${SIMULATION_FAULTS.join(" or :")}, or not-connected, not "${v2?.trim()}"`
|
|
622
1291
|
);
|
|
623
1292
|
}
|
|
624
1293
|
return { profile, faults };
|
|
625
1294
|
}
|
|
626
|
-
function parseEmailList(
|
|
627
|
-
if (!
|
|
1295
|
+
function parseEmailList(v2) {
|
|
1296
|
+
if (!v2) {
|
|
628
1297
|
return void 0;
|
|
629
1298
|
}
|
|
630
|
-
const list5 =
|
|
1299
|
+
const list5 = v2.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean);
|
|
631
1300
|
return list5.length ? list5 : void 0;
|
|
632
1301
|
}
|
|
633
1302
|
|
|
@@ -635,8 +1304,8 @@ function parseEmailList(v) {
|
|
|
635
1304
|
function fieldError(field, message2) {
|
|
636
1305
|
return badRequest(message2, { field });
|
|
637
1306
|
}
|
|
638
|
-
function isObject(
|
|
639
|
-
return typeof
|
|
1307
|
+
function isObject(v2) {
|
|
1308
|
+
return typeof v2 === "object" && v2 !== null && !Array.isArray(v2);
|
|
640
1309
|
}
|
|
641
1310
|
function mediaTypeOf(req) {
|
|
642
1311
|
const declared = req.headers.get("content-type");
|
|
@@ -669,56 +1338,56 @@ function path(key, at) {
|
|
|
669
1338
|
return at ? `${at}.${key}` : key;
|
|
670
1339
|
}
|
|
671
1340
|
function optString(o, key, at) {
|
|
672
|
-
const
|
|
673
|
-
if (
|
|
1341
|
+
const v2 = o[key];
|
|
1342
|
+
if (v2 === void 0) {
|
|
674
1343
|
return void 0;
|
|
675
1344
|
}
|
|
676
|
-
if (typeof
|
|
1345
|
+
if (typeof v2 !== "string") {
|
|
677
1346
|
throw fieldError(path(key, at), `${path(key, at)} must be a string`);
|
|
678
1347
|
}
|
|
679
|
-
return
|
|
1348
|
+
return v2;
|
|
680
1349
|
}
|
|
681
1350
|
function optBoolean(o, key, at) {
|
|
682
|
-
const
|
|
683
|
-
if (
|
|
1351
|
+
const v2 = o[key];
|
|
1352
|
+
if (v2 === void 0) {
|
|
684
1353
|
return void 0;
|
|
685
1354
|
}
|
|
686
|
-
if (typeof
|
|
1355
|
+
if (typeof v2 !== "boolean") {
|
|
687
1356
|
throw fieldError(path(key, at), `${path(key, at)} must be true or false`);
|
|
688
1357
|
}
|
|
689
|
-
return
|
|
1358
|
+
return v2;
|
|
690
1359
|
}
|
|
691
1360
|
function optStringOrNull(o, key, at) {
|
|
692
|
-
const
|
|
693
|
-
if (
|
|
694
|
-
return
|
|
1361
|
+
const v2 = o[key];
|
|
1362
|
+
if (v2 === void 0 || v2 === null || typeof v2 === "string") {
|
|
1363
|
+
return v2;
|
|
695
1364
|
}
|
|
696
1365
|
throw fieldError(path(key, at), `${path(key, at)} must be a string or null`);
|
|
697
1366
|
}
|
|
698
1367
|
function optStringList(o, key, at) {
|
|
699
|
-
const
|
|
700
|
-
if (
|
|
1368
|
+
const v2 = o[key];
|
|
1369
|
+
if (v2 === void 0) {
|
|
701
1370
|
return void 0;
|
|
702
1371
|
}
|
|
703
|
-
if (!Array.isArray(
|
|
1372
|
+
if (!Array.isArray(v2) || !v2.every((item) => typeof item === "string")) {
|
|
704
1373
|
throw fieldError(path(key, at), `${path(key, at)} must be a list of strings`);
|
|
705
1374
|
}
|
|
706
|
-
return
|
|
1375
|
+
return v2;
|
|
707
1376
|
}
|
|
708
1377
|
function optObject(o, key, at) {
|
|
709
|
-
const
|
|
710
|
-
if (
|
|
1378
|
+
const v2 = o[key];
|
|
1379
|
+
if (v2 === void 0) {
|
|
711
1380
|
return void 0;
|
|
712
1381
|
}
|
|
713
|
-
if (!isObject(
|
|
1382
|
+
if (!isObject(v2)) {
|
|
714
1383
|
throw fieldError(path(key, at), `${path(key, at)} must be an object`);
|
|
715
1384
|
}
|
|
716
|
-
return
|
|
1385
|
+
return v2;
|
|
717
1386
|
}
|
|
718
1387
|
function oneOf(o, key, allowed, at) {
|
|
719
|
-
const
|
|
720
|
-
if (typeof
|
|
721
|
-
return
|
|
1388
|
+
const v2 = o[key];
|
|
1389
|
+
if (typeof v2 === "string" && allowed.includes(v2)) {
|
|
1390
|
+
return v2;
|
|
722
1391
|
}
|
|
723
1392
|
const list5 = allowed.map((a) => `'${a}'`).join(" or ");
|
|
724
1393
|
throw fieldError(path(key, at), `${path(key, at)} must be ${list5}`);
|
|
@@ -1017,20 +1686,20 @@ function archiveIndexPage(opts) {
|
|
|
1017
1686
|
|
|
1018
1687
|
// src/router.ts
|
|
1019
1688
|
function param(c, name) {
|
|
1020
|
-
const
|
|
1021
|
-
if (
|
|
1689
|
+
const v2 = c.params[name];
|
|
1690
|
+
if (v2 === void 0) {
|
|
1022
1691
|
throw badRequest(`missing path parameter: ${name}`);
|
|
1023
1692
|
}
|
|
1024
|
-
return
|
|
1693
|
+
return v2;
|
|
1025
1694
|
}
|
|
1026
1695
|
function decodePathParams(groups) {
|
|
1027
1696
|
const params = {};
|
|
1028
|
-
for (const [k,
|
|
1029
|
-
if (
|
|
1697
|
+
for (const [k, v2] of Object.entries(groups)) {
|
|
1698
|
+
if (v2 === void 0) {
|
|
1030
1699
|
continue;
|
|
1031
1700
|
}
|
|
1032
1701
|
try {
|
|
1033
|
-
params[k] = decodeURIComponent(
|
|
1702
|
+
params[k] = decodeURIComponent(v2);
|
|
1034
1703
|
} catch {
|
|
1035
1704
|
return { params, error: badRequest(`path parameter ${k} is not validly percent-encoded`) };
|
|
1036
1705
|
}
|
|
@@ -1146,12 +1815,28 @@ var EMPTY_SUBJECT_SLUG = "post";
|
|
|
1146
1815
|
|
|
1147
1816
|
// src/lib/ids.ts
|
|
1148
1817
|
var newId = () => crypto.randomUUID();
|
|
1818
|
+
var HEX = Array.from({ length: 256 }, (_, b) => b.toString(16).padStart(2, "0"));
|
|
1149
1819
|
function newToken(bytes = 32) {
|
|
1150
1820
|
const buf = new Uint8Array(bytes);
|
|
1151
1821
|
crypto.getRandomValues(buf);
|
|
1152
1822
|
let out = "";
|
|
1153
1823
|
for (const b of buf) {
|
|
1154
|
-
out += b
|
|
1824
|
+
out += HEX[b];
|
|
1825
|
+
}
|
|
1826
|
+
return out;
|
|
1827
|
+
}
|
|
1828
|
+
function newTokens(n, bytes = 32) {
|
|
1829
|
+
const buf = new Uint8Array(n * bytes);
|
|
1830
|
+
for (let at = 0; at < buf.length; at += 65536) {
|
|
1831
|
+
crypto.getRandomValues(buf.subarray(at, Math.min(at + 65536, buf.length)));
|
|
1832
|
+
}
|
|
1833
|
+
const out = new Array(n);
|
|
1834
|
+
for (let t = 0; t < n; t++) {
|
|
1835
|
+
let s = "";
|
|
1836
|
+
for (let k = t * bytes; k < (t + 1) * bytes; k++) {
|
|
1837
|
+
s += HEX[buf[k]];
|
|
1838
|
+
}
|
|
1839
|
+
out[t] = s;
|
|
1155
1840
|
}
|
|
1156
1841
|
return out;
|
|
1157
1842
|
}
|
|
@@ -1208,14 +1893,6 @@ function clampInt(field, raw, fallback, min, max) {
|
|
|
1208
1893
|
return Math.min(Math.max(n, min), max);
|
|
1209
1894
|
}
|
|
1210
1895
|
|
|
1211
|
-
// shared/email.ts
|
|
1212
|
-
function normalizeEmail(email2) {
|
|
1213
|
-
return email2.trim().toLowerCase();
|
|
1214
|
-
}
|
|
1215
|
-
function isValidEmail(email2) {
|
|
1216
|
-
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email2);
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
1896
|
// src/db/sends.ts
|
|
1220
1897
|
function countsOf(send) {
|
|
1221
1898
|
return {
|
|
@@ -1716,18 +2393,19 @@ async function dispatchFresh(db, sendId, lease, handOffs, now) {
|
|
|
1716
2393
|
const json2 = JSON.stringify(rows);
|
|
1717
2394
|
const guard2 = holdsLease(sendId, lease);
|
|
1718
2395
|
const [moved] = await db.batch([
|
|
1719
|
-
//
|
|
1720
|
-
//
|
|
1721
|
-
//
|
|
2396
|
+
// Driven from the batch: each row is looked up by primary key and takes its own
|
|
2397
|
+
// batch's key, so a row reads its one entry, not the whole batch. The unary `+` keeps
|
|
2398
|
+
// SQLite off the (send_id, status) index, which would walk every pending row of the
|
|
2399
|
+
// send. `json_each` has an `id` column of its own, so the row's is `deliveries.id`.
|
|
1722
2400
|
db.prepare(
|
|
1723
|
-
`UPDATE deliveries SET status = 'dispatched',
|
|
1724
|
-
dispatch_key = (SELECT json_extract(j.value, '$.k') FROM json_each(?) AS j
|
|
1725
|
-
WHERE json_extract(j.value, '$.id') = deliveries.id),
|
|
2401
|
+
`UPDATE deliveries SET status = 'dispatched', dispatch_key = json_extract(j.value, '$.k'),
|
|
1726
2402
|
keyed_at = ?, updated_at = ?
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
2403
|
+
FROM json_each(?) AS j
|
|
2404
|
+
WHERE deliveries.id = json_extract(j.value, '$.id')
|
|
2405
|
+
AND +deliveries.send_id = ? AND +deliveries.status = 'pending'
|
|
2406
|
+
AND deliveries.dispatch_key IS NULL AND ${guard2.sql}
|
|
2407
|
+
RETURNING deliveries.id AS id, deliveries.dispatch_key AS key`
|
|
2408
|
+
).bind(now, now, json2, sendId, ...guard2.binds),
|
|
1731
2409
|
// After the move, so it counts exactly the rows that took the (new) keys.
|
|
1732
2410
|
keyedCounterMove(
|
|
1733
2411
|
db,
|
|
@@ -1838,9 +2516,8 @@ async function settleDeliveries(db, sendId, lease, from, outcomes, now, answered
|
|
|
1838
2516
|
updated_at = ?
|
|
1839
2517
|
FROM json_each(?) AS j
|
|
1840
2518
|
WHERE deliveries.id = json_extract(j.value, '$.id')
|
|
1841
|
-
AND deliveries.id IN (SELECT json_extract(value, '$.id') FROM json_each(?))
|
|
1842
2519
|
AND +deliveries.send_id = ? AND +deliveries.status = ? AND ${guard2.sql}`
|
|
1843
|
-
).bind(now, json2,
|
|
2520
|
+
).bind(now, json2, sendId, from, ...guard2.binds),
|
|
1844
2521
|
// The column names come from the fixed `CounterCol` union, never user input.
|
|
1845
2522
|
db.prepare(
|
|
1846
2523
|
`UPDATE sends SET ${sets.join(", ")}, rev = ${NEXT_REV} WHERE id = ? AND lease_token = ?`
|
|
@@ -2172,11 +2849,15 @@ function markdownToHtml(md, ctx) {
|
|
|
2172
2849
|
const href = token2.href ?? "";
|
|
2173
2850
|
const alt = token2.text ?? "";
|
|
2174
2851
|
if (!alt.trim()) {
|
|
2175
|
-
ctx.warnings.push(
|
|
2852
|
+
ctx.warnings.push(
|
|
2853
|
+
`The image \u201C${href}\u201D has no alt text. Describe it between the square brackets.`
|
|
2854
|
+
);
|
|
2176
2855
|
}
|
|
2177
2856
|
const { url, image } = resolveImageSrc(href, ctx.images, ctx.mediaBase);
|
|
2178
2857
|
if (!image && !/^https?:\/\//i.test(url)) {
|
|
2179
|
-
ctx.warnings.push(
|
|
2858
|
+
ctx.warnings.push(
|
|
2859
|
+
`The image \u201C${href}\u201D isn\u2019t on this post. Upload it, or check the file name.`
|
|
2860
|
+
);
|
|
2180
2861
|
}
|
|
2181
2862
|
const size = sizeAttrs(image?.width ?? null, image?.height ?? null, ctx.maxWidth);
|
|
2182
2863
|
const title = token2.title ? ` title="${escapeHtmlAttr(token2.title)}"` : "";
|
|
@@ -2346,7 +3027,7 @@ var MAX_CE_BUTTON = 80;
|
|
|
2346
3027
|
var MAX_CE_REASSURANCE = 400;
|
|
2347
3028
|
function coercePublication(raw) {
|
|
2348
3029
|
const o = raw && typeof raw === "object" ? raw : {};
|
|
2349
|
-
const str = (
|
|
3030
|
+
const str = (v2) => typeof v2 === "string" ? v2 : "";
|
|
2350
3031
|
const lr = o.logo;
|
|
2351
3032
|
const key = lr && typeof lr.key === "string" && LOGO_KEY.test(lr.key);
|
|
2352
3033
|
const logo = lr && typeof lr === "object" && typeof lr.version === "number" ? {
|
|
@@ -2363,7 +3044,7 @@ function coercePublication(raw) {
|
|
|
2363
3044
|
}
|
|
2364
3045
|
function coerceConfirmation(raw) {
|
|
2365
3046
|
const o = raw && typeof raw === "object" ? raw : {};
|
|
2366
|
-
const str = (
|
|
3047
|
+
const str = (v2) => typeof v2 === "string" ? v2 : "";
|
|
2367
3048
|
return {
|
|
2368
3049
|
subject: str(o.subject).slice(0, MAX_CE_SUBJECT),
|
|
2369
3050
|
body: str(o.body).slice(0, MAX_CE_BODY),
|
|
@@ -2425,7 +3106,7 @@ function parseStored(data) {
|
|
|
2425
3106
|
throw new HttpError(
|
|
2426
3107
|
500,
|
|
2427
3108
|
"settings_corrupt",
|
|
2428
|
-
"The stored settings can
|
|
3109
|
+
"The stored settings can\u2019t be read, so they are left as they are and nothing will save over them. The server log (settings.corrupt) says what\u2019s wrong."
|
|
2429
3110
|
);
|
|
2430
3111
|
}
|
|
2431
3112
|
async function getSettings(db) {
|
|
@@ -2454,7 +3135,7 @@ function persistSettingsStmt(db, next, expected, guard2) {
|
|
|
2454
3135
|
var WRITE_RETRIES = 5;
|
|
2455
3136
|
var SettingsContention = class extends HttpError {
|
|
2456
3137
|
constructor() {
|
|
2457
|
-
super(409, "conflict", "settings changed
|
|
3138
|
+
super(409, "conflict", "The settings were changed at the same moment. Try again.");
|
|
2458
3139
|
}
|
|
2459
3140
|
};
|
|
2460
3141
|
async function updateSettingsWith(db, derive) {
|
|
@@ -2493,12 +3174,17 @@ function applyPatch(current2, patch) {
|
|
|
2493
3174
|
if (patch.publication !== void 0) {
|
|
2494
3175
|
const p = patch.publication;
|
|
2495
3176
|
if (p.name !== void 0) {
|
|
2496
|
-
next.publication.name = normalizeText(
|
|
3177
|
+
next.publication.name = normalizeText(
|
|
3178
|
+
p.name,
|
|
3179
|
+
"The publication name",
|
|
3180
|
+
MAX_NAME,
|
|
3181
|
+
"publication.name"
|
|
3182
|
+
);
|
|
2497
3183
|
}
|
|
2498
3184
|
if (p.tagline !== void 0) {
|
|
2499
3185
|
next.publication.tagline = normalizeText(
|
|
2500
3186
|
p.tagline,
|
|
2501
|
-
"tagline",
|
|
3187
|
+
"The tagline",
|
|
2502
3188
|
MAX_TAGLINE,
|
|
2503
3189
|
"publication.tagline"
|
|
2504
3190
|
);
|
|
@@ -2506,7 +3192,7 @@ function applyPatch(current2, patch) {
|
|
|
2506
3192
|
if (p.address !== void 0) {
|
|
2507
3193
|
next.publication.address = normalizeText(
|
|
2508
3194
|
p.address,
|
|
2509
|
-
"address",
|
|
3195
|
+
"The mailing address",
|
|
2510
3196
|
MAX_ADDRESS,
|
|
2511
3197
|
"publication.address"
|
|
2512
3198
|
);
|
|
@@ -2514,12 +3200,12 @@ function applyPatch(current2, patch) {
|
|
|
2514
3200
|
}
|
|
2515
3201
|
if (patch.emailTemplate !== void 0) {
|
|
2516
3202
|
if (typeof patch.emailTemplate !== "string") {
|
|
2517
|
-
throw new SettingsFieldError("emailTemplate", "
|
|
3203
|
+
throw new SettingsFieldError("emailTemplate", "The email template must be text.");
|
|
2518
3204
|
}
|
|
2519
3205
|
if (patch.emailTemplate.length > MAX_TEMPLATE) {
|
|
2520
3206
|
throw new SettingsFieldError(
|
|
2521
3207
|
"emailTemplate",
|
|
2522
|
-
`
|
|
3208
|
+
`The email template must be ${MAX_TEMPLATE.toLocaleString("en-US")} characters or fewer. Shorten it, then save.`
|
|
2523
3209
|
);
|
|
2524
3210
|
}
|
|
2525
3211
|
next.emailTemplate = patch.emailTemplate;
|
|
@@ -2529,7 +3215,7 @@ function applyPatch(current2, patch) {
|
|
|
2529
3215
|
if (c.subject !== void 0) {
|
|
2530
3216
|
next.confirmationEmail.subject = normalizeText(
|
|
2531
3217
|
c.subject,
|
|
2532
|
-
"subject",
|
|
3218
|
+
"The confirmation subject",
|
|
2533
3219
|
MAX_CE_SUBJECT,
|
|
2534
3220
|
"confirmationEmail.subject"
|
|
2535
3221
|
);
|
|
@@ -2537,7 +3223,7 @@ function applyPatch(current2, patch) {
|
|
|
2537
3223
|
if (c.body !== void 0) {
|
|
2538
3224
|
next.confirmationEmail.body = normalizeText(
|
|
2539
3225
|
c.body,
|
|
2540
|
-
"message",
|
|
3226
|
+
"The confirmation message",
|
|
2541
3227
|
MAX_CE_BODY,
|
|
2542
3228
|
"confirmationEmail.body"
|
|
2543
3229
|
);
|
|
@@ -2545,7 +3231,7 @@ function applyPatch(current2, patch) {
|
|
|
2545
3231
|
if (c.buttonLabel !== void 0) {
|
|
2546
3232
|
next.confirmationEmail.buttonLabel = normalizeText(
|
|
2547
3233
|
c.buttonLabel,
|
|
2548
|
-
"button label",
|
|
3234
|
+
"The button label",
|
|
2549
3235
|
MAX_CE_BUTTON,
|
|
2550
3236
|
"confirmationEmail.buttonLabel"
|
|
2551
3237
|
);
|
|
@@ -2553,7 +3239,7 @@ function applyPatch(current2, patch) {
|
|
|
2553
3239
|
if (c.reassurance !== void 0) {
|
|
2554
3240
|
next.confirmationEmail.reassurance = normalizeText(
|
|
2555
3241
|
c.reassurance,
|
|
2556
|
-
"reassurance line",
|
|
3242
|
+
"The reassurance line",
|
|
2557
3243
|
MAX_CE_REASSURANCE,
|
|
2558
3244
|
"confirmationEmail.reassurance"
|
|
2559
3245
|
);
|
|
@@ -2566,11 +3252,14 @@ function applyPatch(current2, patch) {
|
|
|
2566
3252
|
}
|
|
2567
3253
|
function normalizeNotifyTo(raw) {
|
|
2568
3254
|
if (typeof raw !== "string") {
|
|
2569
|
-
throw new SettingsFieldError("notifications.to", "notifications address must be
|
|
3255
|
+
throw new SettingsFieldError("notifications.to", "The notifications address must be text.");
|
|
2570
3256
|
}
|
|
2571
3257
|
const email2 = normalizeEmail(raw);
|
|
2572
3258
|
if (email2 && !isValidEmail(email2)) {
|
|
2573
|
-
throw new SettingsFieldError(
|
|
3259
|
+
throw new SettingsFieldError(
|
|
3260
|
+
"notifications.to",
|
|
3261
|
+
`\u201C${raw.trim()}\u201D isn\u2019t a valid email address. Fix it, or clear the field to turn notifications off.`
|
|
3262
|
+
);
|
|
2574
3263
|
}
|
|
2575
3264
|
return email2;
|
|
2576
3265
|
}
|
|
@@ -2580,31 +3269,37 @@ function withPublicationLogo(current2, logo) {
|
|
|
2580
3269
|
async function setPublicationLogo(db, logo) {
|
|
2581
3270
|
return updateSettingsWith(db, (current2) => withPublicationLogo(current2, logo));
|
|
2582
3271
|
}
|
|
2583
|
-
function normalizeText(
|
|
2584
|
-
if (typeof
|
|
2585
|
-
throw new SettingsFieldError(field, `${what} must be
|
|
3272
|
+
function normalizeText(v2, what, max, field) {
|
|
3273
|
+
if (typeof v2 !== "string") {
|
|
3274
|
+
throw new SettingsFieldError(field, `${what} must be text.`);
|
|
2586
3275
|
}
|
|
2587
|
-
return
|
|
3276
|
+
return v2.trim().slice(0, max);
|
|
2588
3277
|
}
|
|
2589
3278
|
function normalizeRecipients(input) {
|
|
2590
3279
|
if (!Array.isArray(input)) {
|
|
2591
3280
|
throw new SettingsFieldError(
|
|
2592
3281
|
"testRecipients",
|
|
2593
|
-
"
|
|
3282
|
+
"Test recipients must be a list of email addresses."
|
|
2594
3283
|
);
|
|
2595
3284
|
}
|
|
2596
3285
|
const seen = /* @__PURE__ */ new Set();
|
|
2597
3286
|
const out = [];
|
|
2598
3287
|
for (const raw of input) {
|
|
2599
3288
|
if (typeof raw !== "string") {
|
|
2600
|
-
throw new SettingsFieldError(
|
|
3289
|
+
throw new SettingsFieldError(
|
|
3290
|
+
"testRecipients",
|
|
3291
|
+
"Each test recipient must be an email address."
|
|
3292
|
+
);
|
|
2601
3293
|
}
|
|
2602
3294
|
const email2 = normalizeEmail(raw);
|
|
2603
3295
|
if (!email2) {
|
|
2604
3296
|
continue;
|
|
2605
3297
|
}
|
|
2606
3298
|
if (!isValidEmail(email2)) {
|
|
2607
|
-
throw new SettingsFieldError(
|
|
3299
|
+
throw new SettingsFieldError(
|
|
3300
|
+
"testRecipients",
|
|
3301
|
+
`\u201C${raw.trim()}\u201D isn\u2019t a valid email address. Fix or remove it, then save.`
|
|
3302
|
+
);
|
|
2608
3303
|
}
|
|
2609
3304
|
if (seen.has(email2)) {
|
|
2610
3305
|
continue;
|
|
@@ -2615,7 +3310,7 @@ function normalizeRecipients(input) {
|
|
|
2615
3310
|
if (out.length > MAX_TEST_RECIPIENTS) {
|
|
2616
3311
|
throw new SettingsFieldError(
|
|
2617
3312
|
"testRecipients",
|
|
2618
|
-
`at most ${MAX_TEST_RECIPIENTS} test recipients
|
|
3313
|
+
`There can be at most ${MAX_TEST_RECIPIENTS} test recipients. Remove some, then save.`
|
|
2619
3314
|
);
|
|
2620
3315
|
}
|
|
2621
3316
|
return out;
|
|
@@ -2728,7 +3423,7 @@ function regionErrors(html) {
|
|
|
2728
3423
|
errors.push("{{ end }} has no {{ if .IsEmail }} to close.");
|
|
2729
3424
|
} else if (POST_BODY.test(html.slice(openAt, at))) {
|
|
2730
3425
|
errors.push(
|
|
2731
|
-
"{{ .Post.Body }} can
|
|
3426
|
+
"{{ .Post.Body }} can\u2019t be inside {{ if .IsEmail }}: the archived post would have no content."
|
|
2732
3427
|
);
|
|
2733
3428
|
}
|
|
2734
3429
|
openAt = -1;
|
|
@@ -2736,10 +3431,10 @@ function regionErrors(html) {
|
|
|
2736
3431
|
}
|
|
2737
3432
|
if (cond !== IS_EMAIL) {
|
|
2738
3433
|
const tag = cond ? `{{ if ${cond} }}` : "{{ if }}";
|
|
2739
|
-
errors.push(`${tag} isn
|
|
3434
|
+
errors.push(`${tag} isn\u2019t supported. The one condition is {{ if .IsEmail }}.`);
|
|
2740
3435
|
}
|
|
2741
3436
|
if (openAt >= 0) {
|
|
2742
|
-
errors.push("An email-only region can
|
|
3437
|
+
errors.push("An email-only region can\u2019t contain another. Close the first with {{ end }}.");
|
|
2743
3438
|
}
|
|
2744
3439
|
openAt = at + m[0].length;
|
|
2745
3440
|
}
|
|
@@ -2748,7 +3443,7 @@ function regionErrors(html) {
|
|
|
2748
3443
|
}
|
|
2749
3444
|
if (/\{\{\s*else\s*\}\}/.test(html)) {
|
|
2750
3445
|
errors.push(
|
|
2751
|
-
"{{ else }} isn
|
|
3446
|
+
"{{ else }} isn\u2019t supported. A region is shown in the email and left out of the archived post; there is no alternative."
|
|
2752
3447
|
);
|
|
2753
3448
|
}
|
|
2754
3449
|
return errors;
|
|
@@ -2776,7 +3471,7 @@ function validateEmailTemplate(html) {
|
|
|
2776
3471
|
}
|
|
2777
3472
|
for (const m of html.replace(REGION_TAG, "").replace(TOKEN2, "").matchAll(/\{\{[^}]*\}\}/g)) {
|
|
2778
3473
|
if (!/^\{\{\s*else\s*\}\}$/.test(m[0])) {
|
|
2779
|
-
warnings.push(`${m[0]} isn
|
|
3474
|
+
warnings.push(`${m[0]} isn\u2019t something Kestrel fills, so it will show as written.`);
|
|
2780
3475
|
}
|
|
2781
3476
|
}
|
|
2782
3477
|
if (LOGO_URL_AS_IMG_SRC.test(html)) {
|
|
@@ -2785,7 +3480,7 @@ function validateEmailTemplate(html) {
|
|
|
2785
3480
|
);
|
|
2786
3481
|
}
|
|
2787
3482
|
if (/<script[\s/>]/i.test(html)) {
|
|
2788
|
-
warnings.push("A <script> tag won
|
|
3483
|
+
warnings.push("A <script> tag won\u2019t run in email and may get the message filtered.");
|
|
2789
3484
|
}
|
|
2790
3485
|
return { errors, warnings };
|
|
2791
3486
|
}
|
|
@@ -2969,14 +3664,18 @@ async function render(input, config, branding = defaultBranding()) {
|
|
|
2969
3664
|
const contentText = htmlToText(cleanHtml);
|
|
2970
3665
|
const hasSubject = meta.subject.trim() !== "";
|
|
2971
3666
|
if (!hasSubject) {
|
|
2972
|
-
warnings.push(
|
|
3667
|
+
warnings.push(
|
|
3668
|
+
"There\u2019s no subject, so the email will show \u201C(no subject)\u201D. Add one before you schedule."
|
|
3669
|
+
);
|
|
2973
3670
|
}
|
|
2974
3671
|
const subject = hasSubject ? meta.subject : "(no subject)";
|
|
2975
3672
|
const viewInBrowserUrl = archiveUrl(config, meta.slug);
|
|
2976
3673
|
const validation = validateEmailTemplate(branding.template);
|
|
2977
3674
|
let template = branding.template;
|
|
2978
3675
|
if (validation.errors.length > 0) {
|
|
2979
|
-
warnings.push(
|
|
3676
|
+
warnings.push(
|
|
3677
|
+
`The email template can\u2019t be used, so this shows the default template. ${validation.errors.join(" ")}`
|
|
3678
|
+
);
|
|
2980
3679
|
template = DEFAULT_EMAIL_TEMPLATE;
|
|
2981
3680
|
} else {
|
|
2982
3681
|
warnings.push(...validation.warnings);
|
|
@@ -2996,7 +3695,7 @@ async function render(input, config, branding = defaultBranding()) {
|
|
|
2996
3695
|
let html = await renderWith(template);
|
|
2997
3696
|
if (template !== DEFAULT_EMAIL_TEMPLATE && !emailOnlyRegionsWhole(html)) {
|
|
2998
3697
|
warnings.push(
|
|
2999
|
-
"email template
|
|
3698
|
+
"The email template\u2019s {{ if .IsEmail }} region doesn\u2019t wrap whole elements, so this shows the default template. Keep each region\u2019s {{ if .IsEmail }} and {{ end }} inside the same element, outside any attribute, and not directly inside a <table> or <tr>."
|
|
3000
3699
|
);
|
|
3001
3700
|
template = DEFAULT_EMAIL_TEMPLATE;
|
|
3002
3701
|
html = await renderWith(template);
|
|
@@ -3312,7 +4011,7 @@ async function claimConfirmation(db, id, now, quietSince) {
|
|
|
3312
4011
|
}
|
|
3313
4012
|
async function armConfirmation(db, id, token2, claimedAt, statusAtClaim) {
|
|
3314
4013
|
const res = await db.prepare(
|
|
3315
|
-
"UPDATE subscribers SET status = 'pending', confirm_token = ?, confirm_sent_at = ?, confirmed_at = NULL
|
|
4014
|
+
"UPDATE subscribers SET status = 'pending', confirm_token = ?, confirm_sent_at = ?, confirmed_at = NULL WHERE id = ? AND confirm_attempt_at = ? AND status = ?"
|
|
3316
4015
|
).bind(token2, claimedAt, id, claimedAt, statusAtClaim).run();
|
|
3317
4016
|
return (res.meta.changes ?? 0) > 0;
|
|
3318
4017
|
}
|
|
@@ -3326,7 +4025,7 @@ async function releaseConfirmation(db, id, claimedAt, previous, created) {
|
|
|
3326
4025
|
"UPDATE subscribers SET confirm_attempt_at = ? WHERE id = ? AND confirm_attempt_at = ?"
|
|
3327
4026
|
).bind(previous, id, claimedAt).run();
|
|
3328
4027
|
}
|
|
3329
|
-
async function confirm(db, token2, sentAfter) {
|
|
4028
|
+
async function confirm(db, token2, sentAfter, ip) {
|
|
3330
4029
|
const row = await getByConfirmToken(db, token2);
|
|
3331
4030
|
if (!row) {
|
|
3332
4031
|
return null;
|
|
@@ -3340,8 +4039,8 @@ async function confirm(db, token2, sentAfter) {
|
|
|
3340
4039
|
const confirmedAt = Date.now();
|
|
3341
4040
|
await db.batch([
|
|
3342
4041
|
db.prepare(
|
|
3343
|
-
"UPDATE subscribers SET status = 'confirmed', confirmed_at = ? WHERE id = ? AND status = 'pending' AND confirm_token = ?"
|
|
3344
|
-
).bind(confirmedAt, row.id, token2),
|
|
4042
|
+
"UPDATE subscribers SET status = 'confirmed', confirmed_at = ?, origin = 'kestrel', opt_in = 'double', import_id = NULL, consent_evidence = ? WHERE id = ? AND status = 'pending' AND confirm_token = ?"
|
|
4043
|
+
).bind(confirmedAt, ip ? JSON.stringify({ confirmed_ip: ip }) : null, row.id, token2),
|
|
3345
4044
|
db.prepare(
|
|
3346
4045
|
"DELETE FROM suppressions WHERE email = ? AND reason = ? AND EXISTS (SELECT 1 FROM subscribers WHERE id = ? AND status = 'confirmed' AND confirmed_at = ?)"
|
|
3347
4046
|
).bind(row.email, ERASED, row.id, confirmedAt)
|
|
@@ -3595,8 +4294,18 @@ var RANK = { action: 0, warn: 1, info: 2 };
|
|
|
3595
4294
|
function bySeverity(a, b) {
|
|
3596
4295
|
return RANK[a.severity] - RANK[b.severity] || (a.since ?? 0) - (b.since ?? 0);
|
|
3597
4296
|
}
|
|
3598
|
-
function sendConditions(send, now) {
|
|
4297
|
+
function sendConditions(send, now, freePlan) {
|
|
3599
4298
|
const out = [];
|
|
4299
|
+
if (freePlan && send.status === "scheduled" && send.recipient_count > DAILY_WRITES_SEND_RECIPIENTS) {
|
|
4300
|
+
out.push({
|
|
4301
|
+
kind: "daily_writes",
|
|
4302
|
+
severity: "warn",
|
|
4303
|
+
since: send.scheduled_at,
|
|
4304
|
+
message: dailyWritesSendWarning(send.recipient_count),
|
|
4305
|
+
action: null,
|
|
4306
|
+
recipients: send.recipient_count
|
|
4307
|
+
});
|
|
4308
|
+
}
|
|
3600
4309
|
if (send.status === "scheduled" && send.fire_at + MISSED_THRESHOLD_MS < now) {
|
|
3601
4310
|
out.push({
|
|
3602
4311
|
kind: "missed",
|
|
@@ -3862,7 +4571,7 @@ function buildSendView(send, config, hasRetries, now) {
|
|
|
3862
4571
|
delivery: { confirmed, percent_of_accepted: deliveryPercent },
|
|
3863
4572
|
provider: { name: config.provider, halt },
|
|
3864
4573
|
phase,
|
|
3865
|
-
conditions: sendConditions(send, now),
|
|
4574
|
+
conditions: sendConditions(send, now, onFreePlanLimits(config)),
|
|
3866
4575
|
actions: sendActions(send, now),
|
|
3867
4576
|
next_change_at: nextChangeAt(send, now),
|
|
3868
4577
|
links: {
|
|
@@ -3899,7 +4608,7 @@ async function viewWithCursor(env, id) {
|
|
|
3899
4608
|
async function renderPost(env, config, post, branding) {
|
|
3900
4609
|
const revision = await getCurrentRevision(env.DB, post);
|
|
3901
4610
|
if (!revision) {
|
|
3902
|
-
throw badRequest("post has no content
|
|
4611
|
+
throw badRequest("This post has no content yet. Write something, then schedule it.");
|
|
3903
4612
|
}
|
|
3904
4613
|
const images = await listImages(env.DB, post.id);
|
|
3905
4614
|
const rendered = await render({ post, revision, images }, config, branding);
|
|
@@ -3909,28 +4618,32 @@ var MINUTE_MS = 60 * 1e3;
|
|
|
3909
4618
|
function onTheMinute(fireAt) {
|
|
3910
4619
|
return Math.ceil(fireAt / MINUTE_MS) * MINUTE_MS;
|
|
3911
4620
|
}
|
|
3912
|
-
function acceptFireAt(requested, minLeadMs, { now = Date.now()
|
|
4621
|
+
function acceptFireAt(requested, minLeadMs, { now = Date.now() } = {}) {
|
|
3913
4622
|
if (requested < now + minLeadMs) {
|
|
3914
|
-
throw refusal(
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
`fire_at must be at least ${formatLead(minLeadMs)} in the future, this deployment's minimum lead${hint}`,
|
|
3918
|
-
{ field: "fire_at" }
|
|
3919
|
-
);
|
|
4623
|
+
throw refusal(400, "fire_at_too_soon", tooSoonMessage(minLeadMs), {
|
|
4624
|
+
field: "fire_at"
|
|
4625
|
+
});
|
|
3920
4626
|
}
|
|
3921
4627
|
return onTheMinute(requested);
|
|
3922
4628
|
}
|
|
3923
4629
|
var FREEZE_RETRIES = 3;
|
|
4630
|
+
var ACTIVE_SEND_EXISTS = "This post already has a send scheduled or going out. Reschedule or cancel that send instead.";
|
|
3924
4631
|
async function freeze(env, config, post, fireAt) {
|
|
3925
4632
|
const active = await getActiveSendForPost(env.DB, post.id);
|
|
3926
4633
|
if (active) {
|
|
3927
4634
|
throw await activeSendExists(env, active);
|
|
3928
4635
|
}
|
|
3929
4636
|
if (post.status !== "draft") {
|
|
3930
|
-
throw refusal(
|
|
4637
|
+
throw refusal(
|
|
4638
|
+
409,
|
|
4639
|
+
"post_not_draft",
|
|
4640
|
+
post.status === "sent" ? "This post has already been sent, so it can\u2019t be scheduled again." : "This post is already scheduled. Reschedule or cancel its send instead."
|
|
4641
|
+
);
|
|
3931
4642
|
}
|
|
3932
4643
|
if (!post.subject.trim()) {
|
|
3933
|
-
throw refusal(400, "subject_required", "
|
|
4644
|
+
throw refusal(400, "subject_required", "Add a subject before you schedule or send this post.", {
|
|
4645
|
+
field: "subject"
|
|
4646
|
+
});
|
|
3934
4647
|
}
|
|
3935
4648
|
for (let attempt = 0; attempt < FREEZE_RETRIES; attempt++) {
|
|
3936
4649
|
const { settings, version } = await readSettings(env.DB);
|
|
@@ -3960,7 +4673,7 @@ async function freeze(env, config, post, fireAt) {
|
|
|
3960
4673
|
} catch (err) {
|
|
3961
4674
|
if (isActiveSendConflict(err)) {
|
|
3962
4675
|
const winner = await getActiveSendForPost(env.DB, post.id);
|
|
3963
|
-
throw winner ? await activeSendExists(env, winner) : refusal(409, "active_send_exists",
|
|
4676
|
+
throw winner ? await activeSendExists(env, winner) : refusal(409, "active_send_exists", ACTIVE_SEND_EXISTS);
|
|
3964
4677
|
}
|
|
3965
4678
|
throw err;
|
|
3966
4679
|
}
|
|
@@ -3971,11 +4684,11 @@ async function freeze(env, config, post, fireAt) {
|
|
|
3971
4684
|
throw refusal(
|
|
3972
4685
|
409,
|
|
3973
4686
|
"settings_changed",
|
|
3974
|
-
"
|
|
4687
|
+
"The template or the publication\u2019s details changed while this was being scheduled. Try again."
|
|
3975
4688
|
);
|
|
3976
4689
|
}
|
|
3977
4690
|
async function activeSendExists(env, send) {
|
|
3978
|
-
return refusal(409, "active_send_exists",
|
|
4691
|
+
return refusal(409, "active_send_exists", ACTIVE_SEND_EXISTS, {
|
|
3979
4692
|
send: await viewSend(env, send.id)
|
|
3980
4693
|
});
|
|
3981
4694
|
}
|
|
@@ -3991,8 +4704,8 @@ async function readForAction(env, sendId) {
|
|
|
3991
4704
|
return send;
|
|
3992
4705
|
}
|
|
3993
4706
|
async function windowClosed(env, send, what) {
|
|
3994
|
-
const why = send.status === "scheduled" ? "its fire time has passed and it
|
|
3995
|
-
return refusal(409, "window_closed", `send can no longer be ${what}: ${why}
|
|
4707
|
+
const why = send.status === "scheduled" ? "its fire time has passed and it\u2019s about to send" : `it\u2019s ${send.status}`;
|
|
4708
|
+
return refusal(409, "window_closed", `This send can no longer be ${what}: ${why}.`, {
|
|
3996
4709
|
send: await viewSend(env, send.id)
|
|
3997
4710
|
});
|
|
3998
4711
|
}
|
|
@@ -4000,9 +4713,14 @@ async function reschedule(env, sendId, fireAt, minLeadMs) {
|
|
|
4000
4713
|
const send = await readForAction(env, sendId);
|
|
4001
4714
|
const now = Date.now();
|
|
4002
4715
|
if (send.status === "canceled") {
|
|
4003
|
-
throw refusal(
|
|
4004
|
-
|
|
4005
|
-
|
|
4716
|
+
throw refusal(
|
|
4717
|
+
409,
|
|
4718
|
+
"send_canceled",
|
|
4719
|
+
"This send was canceled. Schedule the post again instead.",
|
|
4720
|
+
{
|
|
4721
|
+
send: await viewSend(env, send.id)
|
|
4722
|
+
}
|
|
4723
|
+
);
|
|
4006
4724
|
}
|
|
4007
4725
|
if (send.status !== "scheduled" || send.fire_at <= now) {
|
|
4008
4726
|
throw await windowClosed(env, send, "rescheduled");
|
|
@@ -4382,9 +5100,9 @@ function scaledEmailFor(n) {
|
|
|
4382
5100
|
const pairIdx = Math.floor(s / (LOCAL_FORMATS.length * DOMAINS.length));
|
|
4383
5101
|
const given = unwrap(GIVEN_NAMES[pairIdx % GIVEN_NAMES.length], "given name");
|
|
4384
5102
|
const surname = unwrap(SURNAMES[Math.floor(pairIdx / GIVEN_NAMES.length)], "surname");
|
|
4385
|
-
const
|
|
5103
|
+
const format2 = unwrap(LOCAL_FORMATS[formatIdx], "local-part format");
|
|
4386
5104
|
const domain = unwrap(DOMAINS[domainIdx], "email domain");
|
|
4387
|
-
const local = cycle === 0 ?
|
|
5105
|
+
const local = cycle === 0 ? format2(given, surname) : `${format2(given, surname)}${cycle + 1}`;
|
|
4388
5106
|
return `${local}@${domain}`;
|
|
4389
5107
|
}
|
|
4390
5108
|
function isConfirmedAt(s, t) {
|
|
@@ -4921,13 +5639,13 @@ var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker,
|
|
|
4921
5639
|
var access_default = '# Lock the dashboard with Access\n\nCloudflare Access puts a login in front of the editor and the API. Kestrel checks every request against that login as well, so a path left out of Access stays locked. The public pages readers see stay public.\n\nIn this guide, you set up Cloudflare Zero Trust, create one Access application that covers the editor and the API, and allow yourself in. Then you give the app two values from that application, and check that the login works.\n\n## 1. Set up Zero Trust\n\nAccess is part of Cloudflare Zero Trust. Your account needs a Zero Trust organization before it can create an Access application. Skip this section if your account already has one.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), select **Zero Trust**.\n\n1. Choose a team name. It becomes your team domain, `YOUR_TEAM.cloudflareaccess.com`, where your login page lives.\n\n1. Choose a plan. The Free plan covers a newsletter\'s editors. Cloudflare asks for payment details even on the Free plan, and doesn\'t charge them.\n\n## 2. Create the Access application\n\nThe editor lives under `/dashboard`, and the API under `/api`. One application covers both, so they share one login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Applications**, and select **Create new application**.\n\n1. Select **Self-hosted and private**.\n\n1. Select **Add public hostname**, and enter your app\'s hostname with the first path:\n\n - **Subdomain:** `newsletter`\n - **Domain:** `example.com`\n - **Path:** `dashboard`\n\n1. Add a second public hostname with the same subdomain and domain, and the path `api`:\n\n | Path | What it covers |\n | --- | --- |\n | `dashboard` | the editor |\n | `api` | everything the editor and Claude read and change: posts, sends, subscribers, settings, and the in-app docs |\n\n Each path covers everything under it. Leave every other path out. The landing page, the archive, subscribing, unsubscribing, images, and your email provider\'s webhooks must stay public.\n\n## 3. Allow yourself in\n\nEvery Access application denies everyone until a policy allows them.\n\n1. Under **Access policies**, create a new policy:\n\n - **Policy name:** `Publishers`\n - **Action:** Allow\n - **Include:** the **Emails** selector, with your email address\n\n1. Keep the login method a new Zero Trust organization starts with: you sign in with your Cloudflare account. To sign in another way, such as a one-time code by email, add it under **Zero Trust \u2192 Integrations \u2192 Identity providers** first.\n\n1. Select **Create**.\n\n## 4. Give the app its two values\n\nThe app verifies each login against your team domain, and checks that it was issued for this application.\n\n1. Find your team domain under **Zero Trust \u2192 Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`.\n\n1. Find the application\'s audience tag. Under **Access controls \u2192 Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.\n\n1. Store the team domain as a secret. The command prompts you for the value. Enter the hostname alone, without `https://`. If you paste it with `https://` or a trailing slash, the app drops them. Anything else that isn\'t a hostname is refused, naming `ACCESS_TEAM_DOMAIN`:\n\n ```bash\n npx wrangler secret put ACCESS_TEAM_DOMAIN --env production\n ```\n\n1. Store the audience tag the same way:\n\n ```bash\n npx wrangler secret put ACCESS_AUD --env production\n ```\n\n A secret takes effect as soon as you store it, with no redeploy. The app needs both values. While only one is stored, it refuses its configuration and names the missing one: the editor says so, `/health` answers `500` with `"variable":"ACCESS_AUD"`, and the public pages say the newsletter is temporarily unavailable. Storing the second clears it.\n\n1. **(Optional, recommended)** Limit the editor to named people. The Access policy decides who can sign in. This list keeps that true if the policy is widened later, for another app or by mistake. Enter a comma-separated list of email addresses at the prompt:\n\n ```bash\n npx wrangler secret put ACCESS_ALLOWED_EMAILS --env production\n ```\n\n Without it, the app lets in anyone your Access policy allows.\n\n## Check it\n\n1. In a private browser window, open `https://newsletter.example.com/dashboard/`. Cloudflare asks you to sign in.\n\n1. Sign in. The editor loads, and its sidebar shows the email address you signed in with.\n\n1. In the same window, open `https://newsletter.example.com/api/whoami`. It shows you as a signed-in person, verified through Access:\n\n ```\n {"principal":{"kind":"human","email":"you@example.com"},"auth":{"mode":"access"}}\n ```\n\n1. The public pages still need no login:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\nIf the editor says the configuration needs a fix, store the secret it names. If Cloudflare lets you in but the editor answers `401`, check the two secrets for a typo, or `ACCESS_ALLOWED_EMAILS` may leave out your address.\n\nNext, [connect Amazon SES](04-ses.md).\n';
|
|
4922
5640
|
|
|
4923
5641
|
// docs/get-started/04-ses.md
|
|
4924
|
-
var ses_default = '# Connect Amazon SES\n\nKestrel sends your posts through Amazon SES, Amazon\'s email service. SES only delivers the mail: Kestrel keeps your list, each subscriber\'s consent, and the record of every send. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.\n\nIn this guide, you verify your sending hostname with SES, add a DMARC record, and ask AWS for production access. You also route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, set the provider, and deploy.\n\n## Before you begin\n\n- An [AWS account](https://portal.aws.amazon.com/billing/signup).\n\nTo move an instance that already sends through another provider or another SES account, read [Switch an instance that already sends](#switch-an-instance-that-already-sends) first.\n\n## 1. Verify your sending hostname\n\nSES sends only from an identity you\'ve verified. Verifying the whole hostname, rather than one address, lets any address on it send. It also sets up DKIM, which lets inboxes trust your mail.\n\n1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose the region you send from in the menu at the top right. Any region that offers SES works, so pick one near you. A few newer regions must be [enabled for your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html) before you can use them. SES keeps identities, quotas, and the sandbox separately in each region, so do every step in this one.\n\n1. Note the region\'s code. The menu shows a name, such as Europe (Ireland), but later steps need its code, such as `eu-west-1`. [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each region\'s code beside its name. The MAIL FROM record, the SNS access policy, and `AWS_REGION` all use this one code.\n\n1. Under **Configuration**, choose **Identities**, then **Create identity**.\n\n1. Select **Domain**, and enter `send.example.com`. Keep the default DKIM settings: **Easy DKIM**, with a 2048-bit key. Choose **Create identity**.\n\n1. On the identity\'s **Authentication** tab, expand **Publish DNS records**. It lists three CNAME records.\n\n1. In the Cloudflare dashboard, add each record to your domain\'s DNS records, exactly as SES lists it. Set each one\'s proxy status to **DNS only**. Cloudflare adds your domain to the name, so a name ends in `._domainkey.send`.\n\n1. Wait until SES shows **DKIM configuration** as **Successful** and **Identity status** as **Verified**.\n\n## 2. Add a DMARC record\n\nDMARC tells inboxes what to do with mail that fails its checks, and sends you reports about it. SES doesn\'t add this record, so you add it yourself. It starts in monitor mode, which reports without blocking anything.\n\n1. In the Cloudflare dashboard, open your domain\'s DNS records, and add a record:\n\n - **Type:** `TXT`\n - **Name:** `_dmarc.send`\n - **Content:** `v=DMARC1; p=none; rua=mailto:dmarc@example.com`\n\n1. Replace `dmarc@example.com` with an address that receives mail. Reports arrive there as attachments.\n\n[Sending-domain DNS](../reference/02-sending-domain-dns.md#dmarc-publish-a-policy-and-collect-reports) explains each record, and when to tighten DMARC.\n\n## 3. (Optional) Set a custom MAIL FROM domain\n\nSES sends with its own return path by default, so SPF passes for `amazonses.com` rather than for your hostname. DMARC still passes, through DKIM. A custom MAIL FROM domain makes SPF pass for your hostname too, a second check in your favor.\n\n1. In SES, open the `send.example.com` identity. Under **Custom MAIL FROM domain**, choose **Edit**.\n\n1. Select **Use a custom MAIL FROM domain**, and enter `bounce.send.example.com`. For **Behavior on MX failure**, keep **Use default MAIL FROM domain**. Choose **Save changes**.\n\n1. In Cloudflare, add the two records SES lists. Replace `us-east-1` with your region\'s code from section 1:\n\n - **Type:** `MX`, **Name:** `bounce.send`, **Mail server:** `feedback-smtp.us-east-1.amazonses.com`, **Priority:** `10`\n - **Type:** `TXT`, **Name:** `bounce.send`, **Content:** `v=spf1 include:amazonses.com ~all`\n\n1. Wait until SES shows the MAIL FROM domain as **Successful**.\n\n## 4. Request production access\n\nA new SES account starts in the sandbox. It sends only to addresses you\'ve verified in SES, up to 200 emails a day and one a second. Ask AWS to lift those limits now, and test while you wait.\n\n1. Name your publication first, since AWS may visit your website to review the request. In the editor, open **Settings \u2192 Publication identity**, and enter the name and tagline. Until you do, your public pages show the name in your From address. The subscribe form stays closed until section 7 sets the provider.\n\n1. In SES, open the **Account dashboard**, select **View Get set up page**, and then **Request production access**.\n\n1. Fill in the request:\n\n - **Mail type:** Marketing\n - **Website URL:** `https://newsletter.example.com/`\n\n1. Check the acknowledgement, and select **Submit request**. AWS Support answers by email, and may ask how you collect addresses and handle bounces.\n\n1. While you wait, verify the two addresses you test with in [Verify it works](05-verify.md#before-you-begin), so the sandbox lets you mail them. For each, under **Identities**, choose **Create identity**, select **Email address**, and follow the link in the email AWS sends.\n\nIf AWS asks for more detail, reply in the support case. If it denies the request, reply there, or open a new request from the **Account dashboard**. Either way, say how Kestrel handles your list:\n\n- Signups use double opt-in: each subscriber confirms by email before joining the list.\n- Every post offers one-click unsubscribe.\n- Bounces and complaints reach the app through SNS, and it stops mailing those addresses automatically.\n- How many subscribers you expect, and how often you send.\n\nYour account stays in the sandbox until AWS grants access, so don\'t go live before then. In the sandbox, a signup from any address you haven\'t verified fails, and a post reaches only your verified addresses.\n\nWhen AWS grants access, the **Account dashboard** shows your daily quota and your maximum send rate. You set the rate in the app in section 7. The rate decides how fast a send goes out, and [As your list grows](06-go-live.md#as-your-list-grows) says when to ask AWS to raise it.\n\n## 5. Send bounces and complaints to SNS\n\nSES reports what happens to each email through SNS, the AWS notification service. A configuration set says which events to report, and an SNS topic carries them to the app.\n\n1. In SES, under **Configuration**, choose **Configuration sets**, then **Create set**. Name it `kestrel-mail`, and choose **Create set**.\n\n1. On the set\'s **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, **Deliveries**, and **Rendering failures**, and choose **Next**. A rendering failure is an email SES took but couldn\'t build. It shouldn\'t happen, and the app logs it as an error if it does.\n\n1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled. Choose **Next**.\n\n1. For **SNS topic**, choose **Create SNS topic**. Make it a **Standard** topic named `kestrel-events`. Choose **Next**, then **Add destination**.\n\n1. Make the set your hostname\'s default, so SES reports on every email sent from it. Open the `send.example.com` identity, and choose the **Configuration set** tab. Under **Default configuration set**, choose **Edit**, select `kestrel-mail`, and save.\n\n1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), and copy its ARN. It looks like `arn:aws:sns:us-east-1:111122223333:kestrel-events`. After `sns` comes your region\'s code, then your 12-digit account ID. The account menu at the top right of the console also shows the account ID.\n\n1. Let SES publish to the topic. Choose **Edit**, expand **Access policy**, and add this statement to the `Statement` list:\n\n ```json\n {\n "Effect": "Allow",\n "Principal": { "Service": "ses.amazonaws.com" },\n "Action": "sns:Publish",\n "Resource": "REPLACE_WITH_TOPIC_ARN",\n "Condition": {\n "StringEquals": {\n "AWS:SourceAccount": "REPLACE_WITH_ACCOUNT_ID",\n "AWS:SourceArn": "arn:aws:ses:us-east-1:REPLACE_WITH_ACCOUNT_ID:configuration-set/kestrel-mail"\n }\n }\n }\n ```\n\n Replace the topic ARN, and both copies of the account ID, with the values from your ARN. Replace `us-east-1` with your region\'s code, the same one the ARN shows. Choose **Save changes**.\n\nYou subscribe the app to the topic in section 8, once it runs on SES.\n\n## 6. Create the app\'s AWS key\n\nThe app sends through an IAM user whose key can only send email. That limits the harm if the key ever leaks.\n\n1. In the [IAM console](https://console.aws.amazon.com/iam/), go to **Users**, and choose **Create user**. Name it `kestrel-sender`, and leave console access off.\n\n1. When asked for permissions, choose **Attach policies directly**, then **Create policy**. The policy editor opens in a new browser tab.\n\n1. In the new tab, choose the **JSON** editor, and replace its contents with this policy:\n\n ```json\n {\n "Version": "2012-10-17",\n "Statement": [\n {\n "Effect": "Allow",\n "Action": ["ses:SendEmail", "ses:SendBulkEmail"],\n "Resource": "*"\n }\n ]\n }\n ```\n\n Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. You can close this tab.\n\n1. Go back to the first tab, where you\'re creating the user. Refresh the policy list, search for `kestrel-send-email`, and select it.\n\n1. Choose **Next**, then **Create user**.\n\n1. Open the user, and on the **Security credentials** tab, choose **Create access key**. For the use case, choose **Application running outside AWS**.\n\n1. Copy the access key ID and the secret access key. AWS shows the secret only once.\n\n## 7. Set the provider and deploy\n\nUntil now the app has run with no email provider connected, refusing to send. Setting `PROVIDER` connects SES. Store the secrets before you set it. With `PROVIDER` set to `ses` and a required secret missing, the app refuses to run, and sends nothing, until you store it. `/health` and the editor\'s **A setting needs fixing** page name the missing secret, and reader pages say the newsletter is temporarily unavailable. `SES_CONFIGURATION_SET` is the one optional secret.\n\n1. Store the access key ID, and paste it at the prompt:\n\n ```bash\n npx wrangler secret put AWS_ACCESS_KEY_ID --env production\n ```\n\n1. Store the secret access key:\n\n ```bash\n npx wrangler secret put AWS_SECRET_ACCESS_KEY --env production\n ```\n\n1. Store the topic ARN from section 5. The app accepts events only from this topic:\n\n ```bash\n npx wrangler secret put SNS_TOPIC_ARN --env production\n ```\n\n1. Store the configuration set\'s name, `kestrel-mail`. It\'s optional but recommended: the app names it on every email, so SES reports on each one even without the default:\n\n ```bash\n npx wrangler secret put SES_CONFIGURATION_SET --env production\n ```\n\n1. In `wrangler.jsonc`, edit the `production` block\'s `vars`. Change `PROVIDER` to `ses` (it\'s `""` on a new instance; add it if your block has none), and add `SES_MAX_SEND_RATE`. Change `AWS_REGION` to your region\'s code from section 1. Your other values stay as they are. The block ends up like this:\n\n ```jsonc\n "vars": {\n "PROVIDER": "ses", // \u2190 set to ses\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1", // \u2190 change: your region\'s code\n "SES_MAX_SEND_RATE": "1" // \u2190 new: your maximum send rate; 1 in the sandbox\n }\n ```\n\n On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan\'s limit, so each minute sends more. Leave it out on Workers Free. [As your list grows](06-go-live.md#as-your-list-grows) says when the plan is worth it.\n\n When AWS grants production access, set `SES_MAX_SEND_RATE` to the maximum send rate on the **Account dashboard**, and deploy again. The app keeps to that rate, so SES doesn\'t throttle your send.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Send through Amazon SES"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## 8. Subscribe the app to the topic\n\nSNS delivers events only to a subscriber that has confirmed. The app confirms on its own, once it checks that the request came from your topic.\n\n1. In the SNS console, open your topic, and choose **Create subscription**.\n\n1. Set **Protocol** to **HTTPS**, and enter the endpoint:\n\n ```\n https://newsletter.example.com/webhooks/ses\n ```\n\n1. Leave raw message delivery off, and choose **Create subscription**.\n\n1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.\n\nIf it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:\n\n- **Access covers `/webhooks/ses`.** If your Access application covers more than `/dashboard` and `/api`, SNS gets the login page instead of the app. Remove that path from your Access application, so only `dashboard` and `api` remain.\n- **`SNS_TOPIC_ARN` matches the topic\'s ARN.**\n- **The deploy in section 7 finished.**\n\nOnce you\'ve fixed the cause, select the subscription, and choose **Request confirmation**. SNS sends the request again, and the app confirms it. SNS doesn\'t let you delete a subscription while it\'s pending, and removes it on its own after 48 hours.\n\n## Switch an instance that already sends\n\nChange `PROVIDER`, or move to another account with the same provider, only while no send is sending. The dashboard lists any send in progress.\n\nWhen a request to a provider gets no answer, Kestrel can\'t know whether its recipients were mailed. Moving to SES, from another provider or another SES account, mails none of them twice. Kestrel never repeats an unanswered request to SES, so those recipients wait for you to decide whether they were mailed. Use **Resolve** on the send\'s page.\n\nKestrel can\'t tell whether those emails went out. SES\'s console shows sent messages only if you\'ve turned on Virtual Deliverability Manager, which this guide doesn\'t set up. So **Assume not sent** is the safe default. Those readers may miss this post, but they get your next one. No one is mailed twice. Choose **Assume sent** only when you know otherwise, such as a reader who says they got it.\n\n## Check it\n\n1. The app answers on your hostname:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The webhook is public, and refuses a request that isn\'t from SNS:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" -X POST https://newsletter.example.com/webhooks/ses\n ```\n\n ```\n 400\n ```\n\n Any other answer, such as a redirect to your Cloudflare login, means Access covers the webhook. Remove that path from your Access application.\n\n1. The SNS subscription shows **Confirmed**.\n\n1. In the editor, **Settings \u2192 Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before.\n\n1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren\'t open yet.\n\nThe test sends in the next step prove the rest. In the sandbox, send them only to the addresses you verified in section 4, and to the SES test addresses that page lists.\n\nSES charges for each email it sends, test emails included. [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists the rates.\n\nNext, [verify it works](05-verify.md).\n';
|
|
5642
|
+
var ses_default = '# Connect Amazon SES\n\nKestrel sends your posts through Amazon SES, Amazon\'s email service. SES only delivers the mail: Kestrel keeps your list, each subscriber\'s consent, and the record of every send. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.\n\nIn this guide, you verify your sending hostname with SES, add a DMARC record, and ask AWS for production access. You also route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, set the provider, and deploy.\n\n## Before you begin\n\n- An [AWS account](https://portal.aws.amazon.com/billing/signup).\n\nTo move an instance that already sends through another provider or another SES account, read [Switch an instance that already sends](#switch-an-instance-that-already-sends) first.\n\n## 1. Verify your sending hostname\n\nSES sends only from an identity you\'ve verified. Verifying the whole hostname, rather than one address, lets any address on it send. It also sets up DKIM, which lets inboxes trust your mail.\n\n1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose the region you send from in the menu at the top right. Any region that offers SES works, so pick one near you. A few newer regions must be [enabled for your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html) before you can use them. SES keeps identities, quotas, and the sandbox separately in each region, so do every step in this one.\n\n1. Note the region\'s code. The menu shows a name, such as Europe (Ireland), but later steps need its code, such as `eu-west-1`. [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each region\'s code beside its name. The MAIL FROM record, the SNS access policy, and `AWS_REGION` all use this one code.\n\n1. Under **Configuration**, choose **Identities**, then **Create identity**.\n\n1. Select **Domain**, and enter `send.example.com`. Keep the default DKIM settings: **Easy DKIM**, with a 2048-bit key. Choose **Create identity**.\n\n1. On the identity\'s **Authentication** tab, expand **Publish DNS records**. It lists three CNAME records.\n\n1. In the Cloudflare dashboard, add each record to your domain\'s DNS records, exactly as SES lists it. Set each one\'s proxy status to **DNS only**. Cloudflare adds your domain to the name, so a name ends in `._domainkey.send`.\n\n1. Wait until SES shows **DKIM configuration** as **Successful** and **Identity status** as **Verified**.\n\n## 2. Add a DMARC record\n\nDMARC tells inboxes what to do with mail that fails its checks, and sends you reports about it. SES doesn\'t add this record, so you add it yourself. It starts in monitor mode, which reports without blocking anything.\n\n1. In the Cloudflare dashboard, open your domain\'s DNS records, and add a record:\n\n - **Type:** `TXT`\n - **Name:** `_dmarc.send`\n - **Content:** `v=DMARC1; p=none; rua=mailto:dmarc@example.com`\n\n1. Replace `dmarc@example.com` with an address that receives mail. Reports arrive there as attachments.\n\n[Sending-domain DNS](../reference/02-sending-domain-dns.md#dmarc-publish-a-policy-and-collect-reports) explains each record, and when to tighten DMARC.\n\n## 3. (Optional) Set a custom MAIL FROM domain\n\nSES sends with its own return path by default, so SPF passes for `amazonses.com` rather than for your hostname. DMARC still passes, through DKIM. A custom MAIL FROM domain makes SPF pass for your hostname too, a second check in your favor.\n\n1. In SES, open the `send.example.com` identity. Under **Custom MAIL FROM domain**, choose **Edit**.\n\n1. Select **Use a custom MAIL FROM domain**, and enter `bounce.send.example.com`. For **Behavior on MX failure**, keep **Use default MAIL FROM domain**. Choose **Save changes**.\n\n1. In Cloudflare, add the two records SES lists. Replace `us-east-1` with your region\'s code from section 1:\n\n - **Type:** `MX`, **Name:** `bounce.send`, **Mail server:** `feedback-smtp.us-east-1.amazonses.com`, **Priority:** `10`\n - **Type:** `TXT`, **Name:** `bounce.send`, **Content:** `v=spf1 include:amazonses.com ~all`\n\n1. Wait until SES shows the MAIL FROM domain as **Successful**.\n\n## 4. Request production access\n\nA new SES account starts in the sandbox. It sends only to addresses you\'ve verified in SES, up to 200 emails a day and one a second. Ask AWS to lift those limits now, and test while you wait.\n\n1. Name your publication first, since AWS may visit your website to review the request. In the editor, open **Settings \u2192 Publication identity**, and enter the name and tagline. Until you do, your public pages show the name in your From address. The subscribe form stays closed until section 7 sets the provider.\n\n1. In SES, open the **Account dashboard**, select **View Get set up page**, and then **Request production access**.\n\n1. Fill in the request:\n\n - **Mail type:** Marketing\n - **Website URL:** `https://newsletter.example.com/`\n\n1. Check the acknowledgement, and select **Submit request**. AWS Support answers by email, and may ask how you collect addresses and handle bounces.\n\n1. While you wait, verify the two addresses you test with in [Verify it works](05-verify.md#before-you-begin), so the sandbox lets you mail them. For each, under **Identities**, choose **Create identity**, select **Email address**, and follow the link in the email AWS sends.\n\nIf AWS asks for more detail, reply in the support case. If it denies the request, reply there, or open a new request from the **Account dashboard**. Either way, say how Kestrel handles your list:\n\n- Signups use double opt-in: each subscriber confirms by email before joining the list.\n- Every post offers one-click unsubscribe.\n- Bounces and complaints reach the app through SNS, and it stops mailing those addresses automatically.\n- How many subscribers you expect, and how often you send.\n\nYour account stays in the sandbox until AWS grants access, so don\'t go live before then. In the sandbox, a signup from any address you haven\'t verified fails, and a post reaches only your verified addresses.\n\nWhen AWS grants access, the **Account dashboard** shows your daily quota and your maximum send rate. You set the rate in the app in section 7. The rate decides how fast a send goes out, and [As your list grows](06-go-live.md#as-your-list-grows) says when to ask AWS to raise it.\n\n## 5. Send bounces and complaints to SNS\n\nSES reports what happens to each email through SNS, the AWS notification service. A configuration set says which events to report, and an SNS topic carries them to the app.\n\n1. In SES, under **Configuration**, choose **Configuration sets**, then **Create set**. Name it `kestrel-mail`, and choose **Create set**.\n\n1. On the set\'s **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, **Deliveries**, and **Rendering failures**, and choose **Next**. A rendering failure is an email SES took but couldn\'t build. It shouldn\'t happen, and the app logs it as an error if it does.\n\n1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled. Choose **Next**.\n\n1. For **SNS topic**, choose **Create SNS topic**. Make it a **Standard** topic named `kestrel-events`. Choose **Next**, then **Add destination**.\n\n1. Make the set your hostname\'s default, so SES reports on every email sent from it. Open the `send.example.com` identity, and choose the **Configuration set** tab. Under **Default configuration set**, choose **Edit**, select `kestrel-mail`, and save.\n\n1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), and copy its ARN. It looks like `arn:aws:sns:us-east-1:111122223333:kestrel-events`. After `sns` comes your region\'s code, then your 12-digit account ID. The account menu at the top right of the console also shows the account ID.\n\n1. Let SES publish to the topic. Choose **Edit**, expand **Access policy**, and add this statement to the `Statement` list:\n\n ```json\n {\n "Effect": "Allow",\n "Principal": { "Service": "ses.amazonaws.com" },\n "Action": "sns:Publish",\n "Resource": "REPLACE_WITH_TOPIC_ARN",\n "Condition": {\n "StringEquals": {\n "AWS:SourceAccount": "REPLACE_WITH_ACCOUNT_ID",\n "AWS:SourceArn": "arn:aws:ses:us-east-1:REPLACE_WITH_ACCOUNT_ID:configuration-set/kestrel-mail"\n }\n }\n }\n ```\n\n Replace the topic ARN, and both copies of the account ID, with the values from your ARN. Replace `us-east-1` with your region\'s code, the same one the ARN shows. Choose **Save changes**.\n\nYou subscribe the app to the topic in section 8, once it runs on SES.\n\n## 6. Create the app\'s AWS key\n\nThe app sends through an IAM user whose key can only send email. That limits the harm if the key ever leaks.\n\n1. In the [IAM console](https://console.aws.amazon.com/iam/), go to **Users**, and choose **Create user**. Name it `kestrel-sender`, and leave console access off.\n\n1. When asked for permissions, choose **Attach policies directly**, then **Create policy**. The policy editor opens in a new browser tab.\n\n1. In the new tab, choose the **JSON** editor, and replace its contents with this policy:\n\n ```json\n {\n "Version": "2012-10-17",\n "Statement": [\n {\n "Effect": "Allow",\n "Action": ["ses:SendEmail", "ses:SendBulkEmail"],\n "Resource": "*"\n }\n ]\n }\n ```\n\n Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. You can close this tab.\n\n1. Go back to the first tab, where you\'re creating the user. Refresh the policy list, search for `kestrel-send-email`, and select it.\n\n1. Choose **Next**, then **Create user**.\n\n1. Open the user, and on the **Security credentials** tab, choose **Create access key**. For the use case, choose **Application running outside AWS**.\n\n1. Copy the access key ID and the secret access key. AWS shows the secret only once.\n\n## 7. Set the provider and deploy\n\nUntil now the app has run with no email provider connected, refusing to send. Setting `PROVIDER` connects SES. Store the secrets before you set it. With `PROVIDER` set to `ses` and a required secret missing, the app refuses to run, and sends nothing, until you store it. `/health` and the editor\'s **A setting needs fixing** page name the missing secret, and reader pages say the newsletter is temporarily unavailable. `SES_CONFIGURATION_SET` is the one optional secret.\n\n1. Store the access key ID, and paste it at the prompt:\n\n ```bash\n npx wrangler secret put AWS_ACCESS_KEY_ID --env production\n ```\n\n1. Store the secret access key:\n\n ```bash\n npx wrangler secret put AWS_SECRET_ACCESS_KEY --env production\n ```\n\n1. Store the topic ARN from section 5. The app accepts events only from this topic:\n\n ```bash\n npx wrangler secret put SNS_TOPIC_ARN --env production\n ```\n\n1. Store the configuration set\'s name, `kestrel-mail`. It\'s optional but recommended: the app names it on every email, so SES reports on each one even without the default. Type it exactly as the set is named in this region: if SES can\'t find the set, it refuses every email, and a list send is held until the name is fixed or the set created:\n\n ```bash\n npx wrangler secret put SES_CONFIGURATION_SET --env production\n ```\n\n1. In `wrangler.jsonc`, edit the `production` block\'s `vars`. Change `PROVIDER` to `ses` (it\'s `""` on a new instance; add it if your block has none), and add `SES_MAX_SEND_RATE`. Change `AWS_REGION` to your region\'s code from section 1. Your other values stay as they are. The block ends up like this:\n\n ```jsonc\n "vars": {\n "PROVIDER": "ses", // \u2190 set to ses\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1", // \u2190 change: your region\'s code\n "SES_MAX_SEND_RATE": "1" // \u2190 new: your maximum send rate; 1 in the sandbox\n }\n ```\n\n On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan\'s limit, so each minute sends more. Leave it out on Workers Free. [As your list grows](06-go-live.md#as-your-list-grows) says when the plan is worth it.\n\n When AWS grants production access, set `SES_MAX_SEND_RATE` to the maximum send rate on the **Account dashboard**, and deploy again. The app keeps to that rate, so SES doesn\'t throttle your send.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Send through Amazon SES"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## 8. Subscribe the app to the topic\n\nSNS delivers events only to a subscriber that has confirmed. The app confirms on its own, once it checks that the request came from your topic.\n\n1. In the SNS console, open your topic, and choose **Create subscription**.\n\n1. Set **Protocol** to **HTTPS**, and enter the endpoint:\n\n ```\n https://newsletter.example.com/webhooks/ses\n ```\n\n1. Leave raw message delivery off, and choose **Create subscription**.\n\n1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.\n\nIf it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:\n\n- **Access covers `/webhooks/ses`.** If your Access application covers more than `/dashboard` and `/api`, SNS gets the login page instead of the app. Remove that path from your Access application, so only `dashboard` and `api` remain.\n- **`SNS_TOPIC_ARN` matches the topic\'s ARN.**\n- **The deploy in section 7 finished.**\n\nOnce you\'ve fixed the cause, select the subscription, and choose **Request confirmation**. SNS sends the request again, and the app confirms it. SNS doesn\'t let you delete a subscription while it\'s pending, and removes it on its own after 48 hours.\n\n## Switch an instance that already sends\n\nChange `PROVIDER`, or move to another account with the same provider, only while no send is sending. The dashboard lists any send in progress.\n\nWhen a request to a provider gets no answer, Kestrel can\'t know whether its recipients were mailed. Moving to SES, from another provider or another SES account, mails none of them twice. Kestrel never repeats an unanswered request to SES, so those recipients wait for you to decide whether they were mailed. Use **Resolve** on the send\'s page.\n\nKestrel can\'t tell whether those emails went out. SES\'s console shows sent messages only if you\'ve turned on Virtual Deliverability Manager, which this guide doesn\'t set up. So **Assume not sent** is the safe default. Those readers may miss this post, but they get your next one. No one is mailed twice. Choose **Assume sent** only when you know otherwise, such as a reader who says they got it.\n\n## Check it\n\n1. The app answers on your hostname:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The webhook is public, and refuses a request that isn\'t from SNS:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" -X POST https://newsletter.example.com/webhooks/ses\n ```\n\n ```\n 400\n ```\n\n Any other answer, such as a redirect to your Cloudflare login, means Access covers the webhook. Remove that path from your Access application.\n\n1. The SNS subscription shows **Confirmed**.\n\n1. In the editor, **Settings \u2192 Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before.\n\n1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren\'t open yet.\n\nThe test sends in the next step prove the rest. In the sandbox, send them only to the addresses you verified in section 4, and to the SES test addresses that page lists.\n\nSES charges for each email it sends, test emails included. [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists the rates.\n\nNext, [verify it works](05-verify.md).\n';
|
|
4925
5643
|
|
|
4926
5644
|
// docs/get-started/05-verify.md
|
|
4927
5645
|
var verify_default = "# Verify it works\n\nSome things only show up with real email: whether inboxes trust your mail, how it looks in a real mail client, and whether bounces and unsubscribes come back to the app. Check them now, while your list is empty and every email goes only to you.\n\nIn this guide, you send yourself a test email and check that it passes authentication. Then you set up your notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, and read the app's logs.\n\n## Before you begin\n\n- Two email addresses you own, such as a personal address and a work address. Some steps use both. While your SES account is in the sandbox, verify both in SES first, as [Connect Amazon SES](04-ses.md#4-request-production-access) describes.\n- A Gmail address, or another mail client that shows a message's original source and an **Unsubscribe** button.\n\n## 1. Send yourself a test email\n\nA test email goes through the same code as a real send. If the test looks right, the real send looks the same.\n\n1. In the editor at `https://newsletter.example.com/dashboard/`, create a post with a subject, a short body, and an image.\n\n1. Select **Send test email**, enter your address, and send it.\n\n1. Open the email. It should arrive in your inbox, not in spam. The image loads and the subject reads as you wrote it. Its **View in browser** link opens \"Nothing here yet\" until the post goes out, and its unsubscribe link opens the page a subscriber sees, marked as a test and showing a sample address. Its button walks through to the confirmation, but a test email unsubscribes no one. Section 5 unsubscribes for real.\n\n## 2. Check that it passes authentication\n\nInboxes trust mail that passes DKIM and DMARC for the domain it's from. Passing both is the best single sign that your mail reaches the inbox.\n\n1. Open the test email's original source. In Gmail, open the message's menu and select **Show original**.\n\n1. Find the `Authentication-Results` header. It shows:\n\n - `dkim=pass` for `send.example.com`\n - `dmarc=pass` for `send.example.com`\n - `spf=pass`, for a hostname under `send.example.com`\n\n With no custom MAIL FROM domain, SPF passes for `amazonses.com` instead. That's expected, and DMARC still passes.\n\nIf a check fails, compare your DNS records with the ones in [Connect Amazon SES](04-ses.md#1-verify-your-sending-hostname). [Sending-domain DNS](../reference/02-sending-domain-dns.md#verify-alignment) explains each result.\n\n## 3. Set up your notifications\n\nKestrel emails you when a send finishes, and right away when one runs into a problem. Notifications go through your email provider. To send them through Cloudflare instead, see [Notifications through Cloudflare's email](../guides/03-notifications.md).\n\n1. In the editor, open **Settings \u2192 Notifications**.\n\n1. Enter your address, and select **Save**.\n\n1. Select **Send a test notification**. It arrives at that address.\n\n## 4. Send a real post to yourself\n\nThis is a real send, to a list of one. It proves the whole path: subscribing, confirming, scheduling, and sending.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe with your second address.\n\n1. Open the confirmation email, follow its link, and select **Confirm**. In the editor, **Subscribers** shows the address as **Confirmed**.\n\n1. Open your post, select **Schedule**, and then **Send now**. Every send waits at least five minutes before it goes out, so you can cancel a mistake.\n\n1. Cancel the send, to see that canceling stops it. Then select **Send now** again, and let it go.\n\n1. When the send finishes, the post arrives at your second address. A notification arrives at your first, with the subject `Sent:` and the post's title.\n\n## 5. Unsubscribe\n\nEvery email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. Both work in one step, with no login.\n\n1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender.\n\n1. Select it. In the editor, **Subscribers** shows the address as **Unsubscribed**, and later sends leave it out.\n\n## 6. Check that bounces and complaints come back\n\nWhen an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:\n\n - `bounce@simulator.amazonses.com`, which bounces\n - `complaint@simulator.amazonses.com`, which marks your mail as spam\n\n Each one gets a confirmation email, which bounces or is marked as spam.\n\n1. In the editor, **Subscribers** lists both addresses as **Pending**, each with a **Suppressed** flag. The app never mails a suppressed address again.\n\nIf an address has no flag, SES's events aren't reaching the app. Check the configuration set and the SNS subscription in [Connect Amazon SES](04-ses.md#5-send-bounces-and-complaints-to-sns).\n\n## 7. Read the logs\n\nThe app writes a log line for each thing it does, and Cloudflare keeps them for a few days. Nothing else tells you if the once-a-minute schedule that sends your posts stops, so it's worth knowing where to look.\n\n1. In the Cloudflare dashboard, go to **Workers & Pages**, select `kestrel-production`, and select **Observability**.\n\n1. Filter on `event` equal to `sweep.tick`. A line arrives every minute.\n\n1. Filter on `event` equal to `send.completed`. Your send from section 4 is there.\n\n1. Filter on `level` equal to `error`. A healthy instance shows nothing.\n\nWhen every section gives the result it describes, your instance is ready for subscribers.\n\nNext, [go live](06-go-live.md).\n";
|
|
4928
5646
|
|
|
4929
5647
|
// docs/get-started/06-go-live.md
|
|
4930
|
-
var go_live_default = "# Go live\n\nThis last step makes the newsletter yours, and tells people where to subscribe.\n\nIn this guide, you check your publication's name, add a logo, and share your subscribe link. You also see what to change as your list grows.\n\n## Before you begin\n\n- SES production access, granted in the region the app sends from. [Connect Amazon SES](04-ses.md#4-request-production-access) asks for it.\n- `SES_MAX_SEND_RATE` set to the maximum send rate on the SES **Account dashboard**, and deployed. [Connect Amazon SES](04-ses.md#7-set-the-provider-and-deploy) sets it to `1` for the sandbox.\n\nIn the sandbox, SES refuses the confirmation email to any address you haven't verified, so those signups fail. A post reaches only the addresses you verified in SES.\n\n## 1. Name your publication\n\nYour publication's name, tagline, and logo appear on the public pages readers see, and in every email. You named it in [Connect Amazon SES](04-ses.md#4-request-production-access). Check those, and add a logo before anyone sees your pages.\n\n1. In the editor, open **Settings \u2192 Publication identity**.\n\n1. Check the name and tagline, and upload a logo.\n\n1. **(Optional)** Select **Add a mailing address**. US law asks for a postal address in promotional email, and a P.O. box works. The built-in email template prints it in every email's footer.\n\n## 2. Share your subscribe link\n\nReaders subscribe on a page the app hosts. Every signup confirms by email before it joins your list.\n\n1. In the editor, open **Settings \u2192 Ways to subscribe**.\n\n1. Copy your subscribe page's link, `https://newsletter.example.com/subscribe`, and share it.\n\n1. **(Optional)** To take signups on your own website, copy the embeddable subscribe form from the same page, and paste it into your site.\n\n1. **(Optional)** Cap how often one visitor can submit the form. The app already sends any one address at most one confirmation every 15 minutes. A Cloudflare rule can also limit each visitor, and the Free plan includes one. See [Rate-limit the subscribe form](../guides/05-rate-limit.md).\n\n## As your list grows\n\nA new instance runs on Cloudflare's free Workers plan. As your list grows,
|
|
5648
|
+
var go_live_default = "# Go live\n\nThis last step makes the newsletter yours, and tells people where to subscribe.\n\nIn this guide, you check your publication's name, add a logo, and share your subscribe link. You also see what to change as your list grows.\n\n## Before you begin\n\n- SES production access, granted in the region the app sends from. [Connect Amazon SES](04-ses.md#4-request-production-access) asks for it.\n- `SES_MAX_SEND_RATE` set to the maximum send rate on the SES **Account dashboard**, and deployed. [Connect Amazon SES](04-ses.md#7-set-the-provider-and-deploy) sets it to `1` for the sandbox.\n\nIn the sandbox, SES refuses the confirmation email to any address you haven't verified, so those signups fail. A post reaches only the addresses you verified in SES.\n\n## 1. Name your publication\n\nYour publication's name, tagline, and logo appear on the public pages readers see, and in every email. You named it in [Connect Amazon SES](04-ses.md#4-request-production-access). Check those, and add a logo before anyone sees your pages.\n\n1. In the editor, open **Settings \u2192 Publication identity**.\n\n1. Check the name and tagline, and upload a logo.\n\n1. **(Optional)** Select **Add a mailing address**. US law asks for a postal address in promotional email, and a P.O. box works. The built-in email template prints it in every email's footer.\n\n## 2. Share your subscribe link\n\nReaders subscribe on a page the app hosts. Every signup confirms by email before it joins your list.\n\n1. In the editor, open **Settings \u2192 Ways to subscribe**.\n\n1. Copy your subscribe page's link, `https://newsletter.example.com/subscribe`, and share it.\n\n1. **(Optional)** To take signups on your own website, copy the embeddable subscribe form from the same page, and paste it into your site.\n\n1. **(Optional)** Cap how often one visitor can submit the form. The app already sends any one address at most one confirmation every 15 minutes. A Cloudflare rule can also limit each visitor, and the Free plan includes one. See [Rate-limit the subscribe form](../guides/05-rate-limit.md).\n\n## As your list grows\n\nA new instance runs on Cloudflare's free Workers plan. As your list grows, three limits come into play.\n\n- **Your SES quota.** AWS caps how many emails your account sends a day. A send that hits the cap waits, and the app emails you. It resumes on its own once the quota frees up, or after AWS raises it.\n- **How fast a send goes out.** A send goes out a slice at a time, one slice a minute. SES takes one request a second, of up to 50 recipients, and no more recipients a second than your account's maximum send rate. So each request carries one second of that rate, up to 50.\n- **Your database's daily writes.** On the free plan, D1, the database the app runs on, allows 100,000 rows written a day. Past that, it refuses every query until midnight UTC, and the whole app stops: sends, signups, the public pages, and the editor.\n\nIt's recommended to move to the $5 Workers Paid plan from about 5,000 subscribers. A send writes about 14 database rows a recipient, delivery receipts included. So one send uses up the free plan's day at about 7,000 recipients, and sooner when an import or anything else writes that day. Workers Paid counts [D1's writes](https://developers.cloudflare.com/d1/platform/pricing/) by the month instead, with 50 million included, and never stops the database for them. On the free plan, the app warns you before a send to more than 5,000 recipients, in the Schedule dialog and on the scheduled post. It never stops you, since your account may already be on Workers Paid.\n\nThe free Workers plan sends four requests a minute. At 14 messages a second, a common starting rate, that's 56 recipients a minute, and 1,000 subscribers take about 18 minutes. At 50 a second or more, it's 200 a minute.\n\nTo send faster, first ask AWS to raise your maximum send rate to 50, which costs nothing, then set [`SES_MAX_SEND_RATE`](../reference/01-configuration.md#ses_max_send_rate) to match, and deploy again. A rate above 50 adds nothing on the free plan, where a rate of 50 sends 5,000 subscribers in about 25 minutes. A larger list belongs on Workers Paid for its database writes anyway. On Workers Paid, set [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) to `10000`. It sends about 50 requests a minute: about 700 recipients at 14 a second, and about 2,500 at 50 or more.\n\n## Check it\n\n1. The SES **Account dashboard**, in the region the app sends from, no longer says your account is in the sandbox.\n\n1. `https://newsletter.example.com/subscribe` shows your publication's name and logo.\n\n1. `https://newsletter.example.com/` shows your publication's name, and the post you sent yourself in [Verify it works](05-verify.md#4-send-a-real-post-to-yourself).\n\n## What's next\n\nEach of these guides stands on its own. Pick the ones you want.\n\n- [Connect Claude to the API](../guides/01-connect-claude.md), so Claude can draft, proofread, and schedule posts through the same API the editor uses.\n- [Notifications through Cloudflare's email](../guides/03-notifications.md), so you hear about problems even when your email provider has stopped sending.\n\nWhen a new release comes out, [Upgrade to a new release](../guides/07-upgrade.md) shows how to move to it.\n";
|
|
4931
5649
|
|
|
4932
5650
|
// docs/guides/01-connect-claude.md
|
|
4933
5651
|
var connect_claude_default = '# Connect Claude to the API\n\nClaude Code can draft, edit, proofread, and schedule your posts through the same API the editor uses. It has its own Cloudflare Access credential, so its changes show as Claude\'s, not yours. Anything it schedules waits out the review window, where you can still cancel it.\n\nIn this guide, you create a service token for Claude Code, and add a policy for it to your Access application. Then you store the token where Claude Code reads it, check that it works, and point Claude at the API reference.\n\n## Before you begin\n\nYou need:\n\n- The dashboard locked with Access, from [Lock the dashboard with Access](../get-started/03-access.md).\n- [Claude Code](https://code.claude.com/docs/en/overview), run from your instance\'s repository.\n\n## 1. Create a service token\n\nA service token is a Client ID and a Client Secret that a program sends with each request. It carries no email, so the app tells it apart from your own login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Service credentials \u2192 Service Tokens**, and select **Create Service Token**.\n\n1. Name it `kestrel-claude`, and choose a **Service Token Duration**. A year is a good default.\n\n1. Select **Generate token**.\n\n1. Copy the **Client ID** and the **Client Secret**. Cloudflare shows the secret only once.\n\n## 2. Add a policy for the token\n\nYour application lets in only the people its `Publishers` policy names. A token needs a policy of its own, on the same application, because the app accepts only logins issued for that application.\n\n1. Under **Access controls \u2192 Applications**, select **Configure** on the application from [Lock the dashboard with Access](../get-started/03-access.md#2-create-the-access-application).\n\n1. Add a policy:\n\n - **Policy name:** `Claude`\n - **Action:** Service Auth\n - **Include:** the **Service Token** selector, with `kestrel-claude`\n\n1. Save the application.\n\n`ACCESS_ALLOWED_EMAILS` doesn\'t apply to the token, since it has no email. This policy is the only thing that lets it in.\n\n## 3. Store the token for Claude Code\n\nClaude Code loads the `env` block of `.claude/settings.local.json` into every session. That file stays on your computer: your repository\'s `.gitignore` keeps it out of git.\n\n1. Open `.claude/settings.local.json` in your instance\'s repository. Create it if it doesn\'t exist.\n\n1. Add an `env` block, beside anything already in the file:\n\n ```json\n {\n "env": {\n "KESTREL_URL": "https://newsletter.example.com",\n "CF_ACCESS_CLIENT_ID": "REPLACE_WITH_CLIENT_ID",\n "CF_ACCESS_CLIENT_SECRET": "REPLACE_WITH_CLIENT_SECRET"\n }\n }\n ```\n\n `KESTREL_URL` is your app\'s address. The other two are the Client ID and Client Secret from section 1.\n\n1. Start a new Claude Code session, so it loads the values.\n\nNever put the secret in `.claude/settings.json`. That file is committed.\n\n## 4. Point Claude at the API\n\nKestrel has no plugin or connector for Claude. Claude calls the HTTP API directly, and the API reference describes every route: its method and path, the body it takes, and an example. The editor\'s **API** tab shows the same reference.\n\nYour instance\'s `.claude/CLAUDE.md` already tells Claude Code how to call the API, so a session in your instance\'s repository reads the reference before its first call. If it doesn\'t, a prompt like this one points it there:\n\n```\nKestrel\'s API is at $KESTREL_URL. Send the CF-Access-Client-Id and\nCF-Access-Client-Secret headers from your environment with every request.\nRead GET /api/reference first, and use it for every call.\n```\n\nClaude reads the reference, and from there can list your posts, write a draft, and schedule it. The reference lists the body types each route takes. Most take JSON, sent with `Content-Type: application/json`. An image upload takes a multipart form, or the image file itself.\n\n## Check it\n\n1. Ask Claude to call `GET /api/whoami`. The app answers with a program\'s identity, not yours:\n\n ```\n {"principal":{"kind":"service"},"auth":{"mode":"access"}}\n ```\n\n1. Ask Claude to list your posts. It answers with the posts the editor shows.\n\n1. Open a draft in the editor, and ask Claude to change a word in it. The editor shows that the draft was changed elsewhere, last edited by **Claude**.\n\nIf Claude gets a `401`, a `403`, or a login page, check the policy from section 2, and the token\'s two values in `.claude/settings.local.json`. Check that the token hasn\'t expired, too.\n';
|
|
@@ -4945,14 +5663,17 @@ var upgrade_default = "# Upgrade to a new release\n\nA new release of Kestrel is
|
|
|
4945
5663
|
var try_locally_default = "# Try Kestrel locally first\n\nYou can run Kestrel on your own computer to look around before you deploy it. It needs no Cloudflare account and no Git host. Email goes to a stand-in on your computer, so nothing reaches a real inbox.\n\nIn this guide, you write a new instance into an empty folder, start the app, and open the editor. Then you fill it with a demo publication, and empty it again. The folder can become your instance's repository later.\n\n## Before you begin\n\n- [Node.js](https://nodejs.org/) 22 or later\n\n## 1. Write a new instance\n\nAn instance is a small folder of your own. It holds the instance's settings and names the release of Kestrel it runs, which installs from npm as `@kurtbruns/kestrel`.\n\n1. Make an empty folder, and go into it:\n\n ```bash\n mkdir newsletter\n cd newsletter\n ```\n\n1. Write a new instance into it:\n\n ```bash\n npx @kurtbruns/kestrel init\n ```\n\n It writes the instance's files, and lists the commands to run next. The folder doesn't need to be a Git repository.\n\n1. Install the release and its tools:\n\n ```bash\n npm install\n ```\n\n## 2. Start the app\n\n1. Copy the local settings:\n\n ```bash\n cp .dev.vars.example .dev.vars\n ```\n\n They're for your computer only, and send email to the stand-in.\n\n1. Start the app:\n\n ```bash\n npm run dev\n ```\n\n The first time, it creates the local database and asks you to confirm its migrations. Answer yes. The server keeps running in this terminal, and its output ends with the address it's ready on.\n\n1. Open http://localhost:8787/dashboard/ to see the editor. There's nothing to sign in with on your computer. If another app already uses port 8787, Kestrel picks a free port instead, and the `Ready on` line shows it.\n\n## 3. Load the demo publication\n\nThe demo fills the editor with posts, subscribers, and sends that already went out, so you can see the app in use.\n\n1. In a second terminal, in the same folder, load the demo:\n\n ```bash\n npm run seed\n ```\n\n It replaces everything in the local database, and lists links to the editor and the demo's published posts.\n\n1. To empty the local database again, and see the app as a new instance starts:\n\n ```bash\n npm run reset\n ```\n\n1. To stop the server, press <kbd>Ctrl</kbd>+<kbd>C</kbd> in its terminal. Your local content stays for the next `npm run dev`.\n\n## Check it\n\n1. The app answers. If `npm run dev` picked another port, use the one on its `Ready on` line here and below:\n\n ```bash\n curl http://localhost:8787/health\n ```\n\n ```\n {\"status\":\"ok\",\"service\":\"kestrel\"}\n ```\n\n1. After `npm run seed`, http://localhost:8787/ shows the demo publication's public landing page, with its posts.\n\n## Keep the folder for your instance\n\nIf you came here from Deploy the app, your folder is already a repository: go back to [Sign in to Cloudflare](../get-started/02-deploy.md#2-sign-in-to-cloudflare).\n\nWhen you're ready to deploy, this folder can be your instance's repository. You need [Git](https://git-scm.com/) and a GitHub account for this part. The folder's `.gitignore` already keeps your local settings, the local database, and the installed packages out of it.\n\n1. On GitHub, [create a new repository](https://github.com/new) named `newsletter`. It can be private. Leave it empty, with no README, license, or `.gitignore`.\n\n1. Make the folder a repository, and commit the instance. Replace `YOUR_USERNAME` with your GitHub username:\n\n ```bash\n git init\n git add -A\n git commit -m \"Start a Kestrel instance\"\n git remote add origin https://github.com/YOUR_USERNAME/newsletter.git\n git push -u origin HEAD\n ```\n\nThen check [what deploying needs](../get-started/02-deploy.md#before-you-begin) and [choose your hostnames](../get-started/02-deploy.md#choose-your-hostnames). Then [deploy the app](../get-started/02-deploy.md#2-sign-in-to-cloudflare) from signing in to Cloudflare onward.\n";
|
|
4946
5664
|
|
|
4947
5665
|
// docs/README.md
|
|
4948
|
-
var README_default = "# Set up and run Kestrel\n\nThese guides deploy and run your own instance of Kestrel, from an empty repository to a newsletter on your own Cloudflare account.\n\n## Get started\n\nFollow these steps, from an empty repository to a live newsletter. Each one ends with a check, so you know it worked before you move on.\n\n1. [Deploy the app](get-started/02-deploy.md)\n1. [Lock the dashboard with Access](get-started/03-access.md)\n1. [Connect Amazon SES](get-started/04-ses.md)\n1. [Verify it works](get-started/05-verify.md)\n1. [Go live](get-started/06-go-live.md)\n\n## Guides\n\nEach one stands on its own. Pick the ones you need.\n\n- [Connect Claude to the API](guides/01-connect-claude.md)\n- [Try Kestrel locally first](guides/08-try-locally.md)\n- [Notifications through Cloudflare's email](guides/03-notifications.md)\n- [Rate-limit the subscribe form](guides/05-rate-limit.md)\n- [Upgrade to a new release](guides/07-upgrade.md)\n\n## Reference\n\nThe detail behind the steps, for when you need it.\n\n- [Configuration](reference/01-configuration.md)\n- [Sending-domain DNS](reference/02-sending-domain-dns.md)\n";
|
|
5666
|
+
var README_default = "# Set up and run Kestrel\n\nThese guides deploy and run your own instance of Kestrel, from an empty repository to a newsletter on your own Cloudflare account.\n\n## Get started\n\nFollow these steps, from an empty repository to a live newsletter. Each one ends with a check, so you know it worked before you move on.\n\n1. [Deploy the app](get-started/02-deploy.md)\n1. [Lock the dashboard with Access](get-started/03-access.md)\n1. [Connect Amazon SES](get-started/04-ses.md)\n1. [Verify it works](get-started/05-verify.md)\n1. [Go live](get-started/06-go-live.md)\n\n## Guides\n\nEach one stands on its own. Pick the ones you need.\n\n- [Connect Claude to the API](guides/01-connect-claude.md)\n- [Try Kestrel locally first](guides/08-try-locally.md)\n- [Notifications through Cloudflare's email](guides/03-notifications.md)\n- [Rate-limit the subscribe form](guides/05-rate-limit.md)\n- [Upgrade to a new release](guides/07-upgrade.md)\n\n## Reference\n\nThe detail behind the steps, for when you need it.\n\n- [Configuration](reference/01-configuration.md)\n- [Sending-domain DNS](reference/02-sending-domain-dns.md)\n- [Import format](reference/03-import-format.md)\n";
|
|
4949
5667
|
|
|
4950
5668
|
// docs/reference/01-configuration.md
|
|
4951
|
-
var configuration_default = "# Configuration\n\nEvery deploy-time setting: what it does, its default, and what the app refuses. Each environment's settings live in its block under `env` in `wrangler.jsonc`, and its secrets in Cloudflare.\n\n- **Vars** are public values in the block's `vars`, safe to commit. A change takes effect when you deploy.\n- **Secrets** are stored with `npx wrangler secret put NAME --env production`, and never committed. A secret takes effect as soon as you store it, with no redeploy.\n- **Bindings and triggers** connect the Worker to its database, its image bucket, and its schedule.\n\nIn local development, `npm run dev` reads the top level of `wrangler.jsonc` and `.dev.vars`, which you copy from `.dev.vars.example`. Restart it after you change either.\n\nNone of these can be read or changed from inside the app, by the editor or by Claude. Settings in the editor shows the public ones, read-only.\n\n## Vars\n\n| Var | What it is | Default |\n| --- | --- | --- |\n| `PROVIDER` | the email provider: `ses`, or `fake` for local development | none: no provider connected |\n| `APP_ORIGIN` | the origin the app is served from: scheme and host, no path | required |\n| `ARCHIVE_BASE_PATH` | the path the archive lives under | `/archive` |\n| `FROM_ADDRESS` | the `From:` of every email | required with a real provider |\n| `SENDING_DOMAIN` | the sending domain, shown in Settings | none |\n| `AWS_REGION` | the SES region | `us-east-1` in the template, required with SES |\n| `SES_MAX_SEND_RATE` | your SES account's maximum send rate, in messages a second | `14` |\n| `SUBREQUEST_BUDGET` | how many subrequests one minute's send work may use | `50` |\n| `MIN_LEAD_SECONDS` | the least time every send stays cancelable before it goes out | `300` |\n| `ARCHIVE_ORIGIN` | the origin archive links use, to put the archive on your website | `APP_ORIGIN` |\n| `MEDIA_PUBLIC_BASE` | the base URL images are served from | `APP_ORIGIN` + `/media` |\n| `NOTIFY_FROM` | the sender of notifications through Cloudflare's email | `Kestrel <kestrel@` + the app's hostname + `>` |\n| `ALLOW_FAKE_PROVIDER` | `true` lets `fake` run on a real hostname, for a demo that mails no one on purpose | unset |\n\n### `PROVIDER`\n\nUntil it's set, or while it's empty, the app runs with no provider connected. You can write and preview posts, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, `\"PROVIDER\": \"\"`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.\n\n`fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A hosted demo that runs `fake` on a real hostname on purpose sets `ALLOW_FAKE_PROVIDER` to `true`, which no other deployment needs, and which no other provider reads.\n\n### `FROM_ADDRESS` and `SENDING_DOMAIN`\n\n`FROM_ADDRESS` is the only address mail is sent from, and the identity your provider authenticates. `SENDING_DOMAIN` is for display: Settings shows it beside the From address. The app never checks that the two match, so set `SENDING_DOMAIN` to the part of `FROM_ADDRESS` after the `@`.\n\nNeither one is your publication's name, tagline, or logo. You set those in the editor, under **Settings \u2192 Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, your emails and reader pages show no name at all.\n\n### `ARCHIVE_BASE_PATH`\n\nEvery post you send carries its archive link, `ARCHIVE_BASE_PATH` followed by the post's slug, and those links are permanent. Changing the prefix later breaks every link already mailed, so pick it before your first send. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\nA missing leading slash is added, and a trailing slash is dropped.\n\n### `SES_MAX_SEND_RATE`\n\nThe app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. That's `1` in the sandbox, and whatever AWS grants after that. The default, 14, is a common starting rate, not your account's.\n\nA rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.\n\n### `SUBREQUEST_BUDGET`\n\nCloudflare caps how many subrequests one run of a Worker may make. Database queries and outbound requests both count. It also caps database queries alone: 50 a run on Workers Free, and 1,000 on Workers Paid. Each minute's send work is one run, so a large send goes out over several minutes, each stopping short of both caps.\n\nThis setting is the subrequest cap. The app keeps database queries under theirs on its own.\n\n- **The default, 50,** is the Workers Free limit, and safe on any plan. A minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.\n- **On Workers Paid,** which allows 10,000, set `\"SUBREQUEST_BUDGET\": \"10000\"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.\n- **If you raised the Worker's own limit** with `limits.subrequests`, you may set this to match.\n\nNever set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n### `MIN_LEAD_SECONDS`\n\nEvery send stays visible and cancelable for at least this long before it goes out, whether it's scheduled, sent now, or moved. It's the window to catch a mistake, including one in a send Claude prepared.\n\nThe floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead you can schedule. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.\n\n### `ARCHIVE_ORIGIN` and `MEDIA_PUBLIC_BASE`\n\nLeave them unset to keep everything on the app's own hostname.\n\n`ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname.\n\n`MEDIA_PUBLIC_BASE` serves images from another base URL, such as a custom domain on the image bucket. The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.\n\n### `NOTIFY_FROM`\n\nUsed only with the `NOTIFY` binding. It must be an address on a domain set up for Cloudflare's email. Without the binding, notifications come from `FROM_ADDRESS`, through your email provider.\n\n## Secrets\n\n| Secret | What it is | Needed for |\n| --- | --- | --- |\n| `ACCESS_TEAM_DOMAIN` | your Zero Trust team domain, such as `your-team.cloudflareaccess.com`; an `https://` or trailing slash is dropped | every deployment |\n| `ACCESS_AUD` | the Access application's audience tag | every deployment |\n| `ACCESS_ALLOWED_EMAILS` | a comma-separated list of the people allowed to sign in | optional |\n| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | the IAM user's access key | SES |\n| `SNS_TOPIC_ARN` | the SNS topic the SES webhook accepts; it refuses every other topic | SES |\n| `SES_CONFIGURATION_SET` | the configuration set named on every email, so SES reports bounces and complaints | SES, recommended |\n\n`ACCESS_ALLOWED_EMAILS` applies to people only. A service token, such as the one Claude uses, carries no email, so the Access policy alone decides whether it gets in.\n\nNever set `DEV_AUTH_SECRET` on a deployed environment. It's local development's stand-in for Access, and a deployed instance ignores it anyway. The app honors it only when the environment looks like local development: `PROVIDER` is `fake`, Access isn't set up, and `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A real provider, Access, or a real hostname each turns it off, and Access becomes the only way in.\n\n## Bindings and triggers\n\nWrangler doesn't carry bindings or vars over from the top level of `wrangler.jsonc` to an environment. Each environment's block declares its own.\n\n| Setting | What it is | Default |\n| --- | --- | --- |\n| `name` | the Worker's name, such as `kestrel-production` | required |\n| `routes` | the app's hostname, attached on deploy, and any route on your website | required |\n| `workers_dev`, `preview_urls` | the Worker's `workers.dev` and preview addresses | `false` in the template |\n| `triggers.crons` | the once-a-minute schedule, `* * * * *` | required |\n| `observability` | Workers Logs, where the app's log lines land | on |\n| `d1_databases`, binding `DB` | the database: posts, subscribers, sends, and settings | required |\n| `r2_buckets`, binding `MEDIA` | the bucket for images and your logo | required |\n| `send_email`, binding `NOTIFY` | Cloudflare's email, for notifications | none |\n\n### `routes`, `workers_dev`, and `preview_urls`\n\nThe template turns off the `workers.dev` addresses, so the app answers only on your hostname. That's where your Access application and any rate-limiting rule apply. Each deploy replaces the Worker's routes with the ones in the file, so add a route here, never in the dashboard.\n\n### `triggers.crons`\n\nThe schedule runs every minute. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.\n\n### `observability`\n\nEvery invocation is logged, with no sampling. Sampling could drop the one minute that reports a stuck or missed send.\n\n### `NOTIFY`\n\nWhen declared, notifications go through Cloudflare's email instead of your provider, so they still arrive when your provider refuses your account. Local development ignores it, and sends notifications to a stand-in that delivers nothing.\n\n## What the app refuses\n\nThe app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly:\n\n- `PROVIDER`, when set, must be exactly `ses`, `fake`, or `none`. A misspelling, or `SES`, is refused, never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).\n- `fake` runs only when `APP_ORIGIN` is on `localhost` or `127.0.0.1`, unless `ALLOW_FAKE_PROVIDER` is `true`. On a real hostname it would open signups whose confirmations go nowhere, and record every send as delivered. With `fake`, `ALLOW_FAKE_PROVIDER`, when set, must be `true`; every other provider ignores it.\n- `APP_ORIGIN` must be a plain `http` or `https` origin, with no path. So must `ARCHIVE_ORIGIN`, when set. `MEDIA_PUBLIC_BASE`, when set, must be a plain `http` or `https` URL, and may have a path. A trailing slash is dropped from each.\n- A real provider needs `FROM_ADDRESS`, and every secret the table marks for it. SES also needs `AWS_REGION`. `FROM_ADDRESS` must then be an email address, optionally with a display name in front, like `Name <address>`.\n- None of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com` or a name under it, the template's placeholder. That holds with no provider connected too, since every link the app builds would name it. Only `fake` allows it.\n- `SUBREQUEST_BUDGET`, `MIN_LEAD_SECONDS`, and `SES_MAX_SEND_RATE` must be whole numbers above zero. `MIN_LEAD_SECONDS` must also be within its bounds. Any other value is refused, never adjusted. The one exception is a `SUBREQUEST_BUDGET` below 30, which is raised to 30.\n- In local development, `SIMULATE_SENDS` must be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn't matter. A deployed instance ignores it.\n- `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are set together or not at all. With only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one. `ACCESS_TEAM_DOMAIN` must be your team's hostname, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in.\n\nUntil it's fixed, no sends go out, and each place says what its reader can act on:\n\n- **The editor** shows a page naming the setting, what's wrong with it, and how to fix it, with a link to its section here. Its own Docs tab can't load until then, so the link opens the project's site.\n- **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{\"error\":\"invalid_config\",\"message\":\u2026,\"variable\":\"APP_ORIGIN\"}`.\n- **The public pages readers see** answer `503` with a page saying the newsletter is temporarily unavailable. It names no setting.\n- **The Worker's log** records it as `config.invalid`.\n";
|
|
5669
|
+
var configuration_default = "# Configuration\n\nEvery deploy-time setting: what it does, its default, and what the app refuses. Each environment's settings live in its block under `env` in `wrangler.jsonc`, and its secrets in Cloudflare.\n\n- **Vars** are public values in the block's `vars`, safe to commit. A change takes effect when you deploy.\n- **Secrets** are stored with `npx wrangler secret put NAME --env production`, and never committed. A secret takes effect as soon as you store it, with no redeploy.\n- **Bindings and triggers** connect the Worker to its database, its image bucket, and its schedule.\n\nIn local development, `npm run dev` reads the top level of `wrangler.jsonc` and `.dev.vars`, which you copy from `.dev.vars.example`. Restart it after you change either.\n\nNone of these can be read or changed from inside the app, by the editor or by Claude. Settings in the editor shows the public ones, read-only.\n\n## Vars\n\n| Var | What it is | Default |\n| --- | --- | --- |\n| `PROVIDER` | the email provider: `ses`, or `fake` for local development | none: no provider connected |\n| `APP_ORIGIN` | the origin the app is served from: scheme and host, no path | required |\n| `ARCHIVE_BASE_PATH` | the path the archive lives under | `/archive` |\n| `FROM_ADDRESS` | the `From:` of every email | required with a real provider |\n| `SENDING_DOMAIN` | the sending domain, shown in Settings | none |\n| `AWS_REGION` | the SES region | `us-east-1` in the template, required with SES |\n| `SES_MAX_SEND_RATE` | your SES account's maximum send rate, in messages a second | `14` |\n| `SUBREQUEST_BUDGET` | how many subrequests one minute's send work may use | `50` |\n| `MIN_LEAD_SECONDS` | the least time every send stays cancelable before it goes out | `300` |\n| `ARCHIVE_ORIGIN` | the origin archive links use, to put the archive on your website | `APP_ORIGIN` |\n| `MEDIA_PUBLIC_BASE` | the base URL images are served from | `APP_ORIGIN` + `/media` |\n| `NOTIFY_FROM` | the sender of notifications through Cloudflare's email | `Kestrel <kestrel@` + the app's hostname + `>` |\n| `ALLOW_FAKE_PROVIDER` | `true` lets `fake` run on a real hostname, for a demo that mails no one on purpose | unset |\n\n### `PROVIDER`\n\nUntil it's set, or while it's empty, the app runs with no provider connected. You can write and preview posts, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, `\"PROVIDER\": \"\"`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.\n\n`fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A hosted demo that runs `fake` on a real hostname on purpose sets `ALLOW_FAKE_PROVIDER` to `true`, which no other deployment needs, and which no other provider reads.\n\n### `FROM_ADDRESS` and `SENDING_DOMAIN`\n\n`FROM_ADDRESS` is the only address mail is sent from, and the identity your provider authenticates. `SENDING_DOMAIN` is for display: Settings shows it beside the From address. The app never checks that the two match, so set `SENDING_DOMAIN` to the part of `FROM_ADDRESS` after the `@`.\n\nNeither one is your publication's name, tagline, or logo. You set those in the editor, under **Settings \u2192 Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, your emails and reader pages show no name at all.\n\n### `ARCHIVE_BASE_PATH`\n\nEvery post you send carries its archive link, `ARCHIVE_BASE_PATH` followed by the post's slug, and those links are permanent. Changing the prefix later breaks every link already mailed, so pick it before your first send. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\nA missing leading slash is added, and a trailing slash is dropped.\n\n### `SES_MAX_SEND_RATE`\n\nThe app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. That's `1` in the sandbox, and whatever AWS grants after that. The default, 14, is a common starting rate, not your account's.\n\nA rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.\n\n### `SUBREQUEST_BUDGET`\n\nCloudflare caps how many subrequests one run of a Worker may make. Database queries and outbound requests both count. It also caps database queries alone: 50 a run on Workers Free, and 1,000 on Workers Paid. Each minute's send work is one run, so a large send goes out over several minutes, each stopping short of both caps.\n\nThis setting is the subrequest cap. The app keeps database queries under theirs on its own.\n\n- **The default, 50,** is the Workers Free limit, and safe on any plan. A minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.\n- **On Workers Paid,** which allows 10,000, set `\"SUBREQUEST_BUDGET\": \"10000\"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.\n- **If you raised the Worker's own limit** with `limits.subrequests`, you may set this to match.\n\nIt's also how the app tells Workers Free from Workers Paid. At the default, the app runs on Free's limits:\n\n- One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more. Above it, up to 10,000 ([Import format](03-import-format.md#limits)).\n- An export reads 1,000 rows a page. Above it, 10,000.\n- The app warns before a send to more than 5,000 recipients, and after an import plan that leaves more than 3,000 confirmed subscribers. Either may use up D1's daily allowance of 100,000 rows written, which stops the app until midnight UTC ([As your list grows](../get-started/06-go-live.md#as-your-list-grows)). Neither warning stops you. Above the default, neither appears.\n\nNever set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n### `MIN_LEAD_SECONDS`\n\nEvery send stays visible and cancelable for at least this long before it goes out, whether it's scheduled, sent now, or moved. It's the window to catch a mistake, including one in a send Claude prepared.\n\nThe floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead you can schedule. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.\n\n### `ARCHIVE_ORIGIN` and `MEDIA_PUBLIC_BASE`\n\nLeave them unset to keep everything on the app's own hostname.\n\n`ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname.\n\n`MEDIA_PUBLIC_BASE` serves images from another base URL, such as a custom domain on the image bucket. The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.\n\n### `NOTIFY_FROM`\n\nUsed only with the `NOTIFY` binding. It must be an address on a domain set up for Cloudflare's email. Without the binding, notifications come from `FROM_ADDRESS`, through your email provider.\n\n## Secrets\n\n| Secret | What it is | Needed for |\n| --- | --- | --- |\n| `ACCESS_TEAM_DOMAIN` | your Zero Trust team domain, such as `your-team.cloudflareaccess.com`; an `https://` or trailing slash is dropped | every deployment |\n| `ACCESS_AUD` | the Access application's audience tag | every deployment |\n| `ACCESS_ALLOWED_EMAILS` | a comma-separated list of the people allowed to sign in | optional |\n| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | the IAM user's access key | SES |\n| `SNS_TOPIC_ARN` | the SNS topic the SES webhook accepts; it refuses every other topic | SES |\n| `SES_CONFIGURATION_SET` | the configuration set named on every email, so SES reports bounces and complaints; a name SES can't find in the region holds every list send until it's fixed | SES, recommended |\n\n`ACCESS_ALLOWED_EMAILS` applies to people only. A service token, such as the one Claude uses, carries no email, so the Access policy alone decides whether it gets in.\n\nNever set `DEV_AUTH_SECRET` on a deployed environment. It's local development's stand-in for Access, and a deployed instance ignores it anyway. The app honors it only when the environment looks like local development: `PROVIDER` is `fake`, Access isn't set up, and `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A real provider, Access, or a real hostname each turns it off, and Access becomes the only way in.\n\n## Bindings and triggers\n\nWrangler doesn't carry bindings or vars over from the top level of `wrangler.jsonc` to an environment. Each environment's block declares its own.\n\n| Setting | What it is | Default |\n| --- | --- | --- |\n| `name` | the Worker's name, such as `kestrel-production` | required |\n| `routes` | the app's hostname, attached on deploy, and any route on your website | required |\n| `workers_dev`, `preview_urls` | the Worker's `workers.dev` and preview addresses | `false` in the template |\n| `triggers.crons` | the once-a-minute schedule, `* * * * *` | required |\n| `observability` | Workers Logs, where the app's log lines land | on |\n| `d1_databases`, binding `DB` | the database: posts, subscribers, sends, and settings | required |\n| `r2_buckets`, binding `MEDIA` | the bucket for images and your logo | required |\n| `send_email`, binding `NOTIFY` | Cloudflare's email, for notifications | none |\n\n### `routes`, `workers_dev`, and `preview_urls`\n\nThe template turns off the `workers.dev` addresses, so the app answers only on your hostname. That's where your Access application and any rate-limiting rule apply. Each deploy replaces the Worker's routes with the ones in the file, so add a route here, never in the dashboard.\n\n### `triggers.crons`\n\nThe schedule runs every minute. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.\n\n### `observability`\n\nEvery invocation is logged, with no sampling. Sampling could drop the one minute that reports a stuck or missed send.\n\n### `NOTIFY`\n\nWhen declared, notifications go through Cloudflare's email instead of your provider, so they still arrive when your provider refuses your account. Local development ignores it, and sends notifications to a stand-in that delivers nothing.\n\n## What the app refuses\n\nThe app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly:\n\n- `PROVIDER`, when set, must be exactly `ses`, `fake`, or `none`. A misspelling, or `SES`, is refused, never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).\n- `fake` runs only when `APP_ORIGIN` is on `localhost` or `127.0.0.1`, unless `ALLOW_FAKE_PROVIDER` is `true`. On a real hostname it would open signups whose confirmations go nowhere, and record every send as delivered. With `fake`, `ALLOW_FAKE_PROVIDER`, when set, must be `true`; every other provider ignores it.\n- `APP_ORIGIN` must be a plain `http` or `https` origin, with no path. So must `ARCHIVE_ORIGIN`, when set. `MEDIA_PUBLIC_BASE`, when set, must be a plain `http` or `https` URL, and may have a path. A trailing slash is dropped from each.\n- A real provider needs `FROM_ADDRESS`, and every secret the table marks for it. SES also needs `AWS_REGION`. `FROM_ADDRESS` must then be an email address, optionally with a display name in front, like `Name <address>`.\n- None of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com` or a name under it, the template's placeholder. That holds with no provider connected too, since every link the app builds would name it. Only `fake` allows it.\n- `SUBREQUEST_BUDGET`, `MIN_LEAD_SECONDS`, and `SES_MAX_SEND_RATE` must be whole numbers above zero. `MIN_LEAD_SECONDS` must also be within its bounds. Any other value is refused, never adjusted. The one exception is a `SUBREQUEST_BUDGET` below 30, which is raised to 30.\n- In local development, `SIMULATE_SENDS` must be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn't matter. A deployed instance ignores it.\n- `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are set together or not at all. With only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one. `ACCESS_TEAM_DOMAIN` must be your team's hostname, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in.\n\nUntil it's fixed, no sends go out, and each place says what its reader can act on:\n\n- **The editor** shows a page naming the setting, what's wrong with it, and how to fix it, with a link to its section here. Its own Docs tab can't load until then, so the link opens the project's site.\n- **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{\"error\":\"invalid_config\",\"message\":\u2026,\"variable\":\"APP_ORIGIN\"}`.\n- **The public pages readers see** answer `503` with a page saying the newsletter is temporarily unavailable. It names no setting.\n- **The Worker's log** records it as `config.invalid`.\n";
|
|
4952
5670
|
|
|
4953
5671
|
// docs/reference/02-sending-domain-dns.md
|
|
4954
5672
|
var sending_domain_dns_default = "# Sending-domain DNS\n\nInboxes decide whether to trust your mail by checking records in the DNS of the domain it comes from. This page explains the three checks they run: SPF, DKIM, and DMARC. It covers the records [Connect Amazon SES](../get-started/04-ses.md) has you add, what each proves, how to check them, and when to tighten DMARC.\n\n## Why all three\n\nGmail requires every sender to pass SPF or DKIM. A sender of more than 5,000 messages a day to Gmail must have all three:\n\n- SPF and DKIM, both passing.\n- A DMARC record, with a policy of at least `p=none`.\n- Alignment: the domain in the `From:` header matches the SPF domain or the DKIM domain.\n\nGmail also requires one-click unsubscribe for such senders. Kestrel adds the `List-Unsubscribe` and `List-Unsubscribe-Post` headers to every email, so that part needs nothing from you. [Google's sender guidelines](https://support.google.com/a/answer/81126) list the rest.\n\nA small list may never reach 5,000 a day, but the same checks decide whether mail lands in the inbox. Make sure all three pass from the start.\n\n## Choose your sending name\n\n- **Send from a subdomain.** It's recommended to send from a subdomain such as `send.example.com`, rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn't put that at risk.\n- **Keep the app's name and the mail's name distinct.** The app and its public pages live at `newsletter.example.com`, and mail comes from `send.example.com`. Avoid `mail.` as the sending name, since mail servers read `mail.example.com` as a host that receives mail.\n\n## The records\n\nEvery record lives in your domain's DNS, `example.com`, under the sending name. In Cloudflare, a record's name leaves off your domain, so `_dmarc.send.example.com` is entered as `_dmarc.send`.\n\nSES generates the DKIM values. Copy them exactly as SES lists them, since they differ by account and region. You write the DMARC record yourself.\n\nSPF needs no record of yours on SES's default path, since it passes for a subdomain of `amazonses.com`. An SPF record, at `bounce.send`, comes only with the optional custom MAIL FROM domain.\n\n### SPF: authorize the return path\n\nSPF checks the message's return path, the address bounces go back to, not its `From:` header. DMARC passes when SPF or DKIM passes and aligns with the `From:` domain. DKIM aligns, so an aligned SPF is a second check in your favor, not a requirement.\n\nSES uses a subdomain of its own domain, `amazonses.com`, as the return path by default. SPF then passes for it but doesn't align, and DMARC passes through DKIM alone. A custom MAIL FROM domain, such as `bounce.send.example.com`, aligns SPF too. [Connect Amazon SES](../get-started/04-ses.md#3-optional-set-a-custom-mail-from-domain) sets one up, with an MX record that names your region and an SPF record:\n\n```\nbounce.send.example.com. MX 10 feedback-smtp.us-east-1.amazonses.com.\nbounce.send.example.com. TXT \"v=spf1 include:amazonses.com ~all\"\n```\n\nA name holds one SPF record. If one already exists there, add `include:amazonses.com` to it rather than adding a second.\n\n### DKIM: let the provider sign\n\nDKIM signs each message with a key your DNS publishes. An inbox checks the signature to tell the message came from you, unchanged.\n\nSES lists three `CNAME` records, each at `TOKEN._domainkey.send.example.com`. Their targets depend on your region and account, so copy them as SES lists them.\n\nIn Cloudflare, set every `CNAME` for DKIM to **DNS only**, not proxied. SES reports the domain as verified only once the records resolve.\n\n### DMARC: publish a policy and collect reports\n\nDMARC tells an inbox what to do with mail that fails both SPF and DKIM, and asks it to send you reports. It's a `TXT` record at `_dmarc.send.example.com`:\n\n```\n_dmarc.send.example.com. TXT \"v=DMARC1; p=none; rua=mailto:dmarc@example.com\"\n```\n\n- **`p=none`** is monitor mode. Inboxes report on failing mail, but deliver it as usual.\n- **`rua`** is where reports go. They arrive as attachments, usually one a day from each inbox provider.\n\nLeave alignment relaxed, the default, with no `aspf=s` or `adkim=s`. With a custom MAIL FROM domain, your return path is a subdomain of your sending name, so strict SPF alignment would fail.\n\nOnce the reports show your sends passing and aligned, tighten the policy to `p=quarantine`, which sends failing mail to spam. Later, tighten it to `p=reject`, which refuses it.\n\n## Verify alignment\n\nA test email proves alignment better than any DNS lookup. Send yourself one, as in [Verify it works](../get-started/05-verify.md#2-check-that-it-passes-authentication), and read its `Authentication-Results` header. In Gmail, open the message's menu, and select **Show original**. Expect:\n\n- `dkim=pass` for `send.example.com`.\n- `dmarc=pass` for `send.example.com`.\n- `spf=pass`. With a custom MAIL FROM domain, its domain is under `send.example.com`. Without one, it's a subdomain of `amazonses.com`. That's expected, and DMARC still passes.\n";
|
|
4955
5673
|
|
|
5674
|
+
// docs/reference/03-import-format.md
|
|
5675
|
+
var import_format_default = "# Import format\n\nKestrel imports a subscriber list in one documented format, `kestrel-subscribers/1`, and exports the whole list in the same format. This page describes the format, what an import does to each address, and the limits.\n\nKestrel never guesses what another service's export means, because a wrong guess about who agreed to be emailed can't be taken back. An export from Sendy, Mailchimp, or anywhere else is converted to this format first, by Claude or by hand. The API serves everything on this page at `GET /api/imports/format`, generated from the same definition the importer reads with, along with a CSV template at `GET /api/imports/template.csv` and a plain-list template at `GET /api/imports/template.txt`.\n\n## Two kinds of file\n\n- **The full CSV (preferred).** It carries unsubscribes, bounces, and how each person joined. It's what the export writes, and you can open it in a spreadsheet to check it before importing.\n- **A plain list (`.txt`).** One address per line, for a list that is only addresses. You say what they all are: subscribed (the default), unsubscribed, or bounced. It loses how each person joined, any times, the evidence of consent, and mixed states.\n\nThe file's name decides which it is: `.csv` or `.txt`. Anything else is refused.\n\n## The CSV\n\nA CSV file (RFC 4180, UTF-8) with a header row of the column names below, in any order, then one row per address. Column names are written exactly, in lowercase. A row records what the source claims, never what Kestrel will do: the import decides that, under the rules further down.\n\n| Column | Required | Values | Meaning |\n| --- | --- | --- | --- |\n| `email` | yes | an email address | The address, trimmed and lowercased before it is matched. An invalid one is still a row, counted as invalid and reported, never dropped. |\n| `status` | yes | `subscribed`, `pending`, `unsubscribed`, `none` | The consent claim. `none` is a known address with no consent claim, present only to carry a suppression. |\n| `opt_in` | yes | `double`, `single`, `unknown` | How consent was collected. `unknown` unless the source states it. |\n| `origin` | yes | a short lowercase name | Where consent was first collected, as the file claims it: `sendy`, `mailchimp`, `kestrel`, or `unknown`. |\n| `suppression` | no | `bounce`, `complaint`, `manual`, `erased` | A suppression on top of `status`: the address bounced, complained, was suppressed by hand, or was erased. |\n| `subscribed_at` | no | an instant with its zone | When the person subscribed, if the source states it. |\n| `confirmed_at` | no | an instant with its zone | When the person confirmed, if the source states it. |\n| `unsubscribed_at` | no | an instant with its zone | When the person left, if the source states it. A reader who confirmed in Kestrel after it stays confirmed. |\n| `suppressed_at` | no | an instant with its zone | When the address was suppressed, if the source states it. |\n| `evidence` | no | a JSON object, at most 500 bytes | The source's consent-relevant columns, verbatim, such as a signup IP or a form name. Nothing reads it to decide anything: it is the proof of consent and the audit trail of a conversion. |\n| `format` | no | `kestrel-subscribers/1` | The version the file is written in. A file without it is read as version 1. |\n\nAn instant is a UTC ISO-8601 time with its zone, such as `2024-03-01T09:30:00Z` or `2024-03-01T10:30:00+01:00`. A time without a zone is refused, never interpreted. An optional column may be left out, or left empty on a row.\n\nAny other column is refused, naming it. A source's other columns belong in `evidence`, as JSON. A file whose header names none of these columns isn't in the format at all, and is refused as such.\n\nHere is a small file:\n\n```\nemail,status,opt_in,origin,suppression,unsubscribed_at,evidence\nada@example.com,subscribed,double,sendy,,,\"{\"\"signup_ip\"\":\"\"203.0.113.7\"\"}\"\ngrace@example.com,subscribed,single,mailchimp,,,\nedsger@example.com,unsubscribed,unknown,unknown,,2025-01-20T08:00:00Z,\nbounced@example.com,none,unknown,unknown,bounce,,\n```\n\n**Versions.** Adding an optional column keeps the version. Changing what a column means makes a new version. Kestrel reads every version it has published, and refuses one it doesn't know, by name.\n\n## The plain list\n\n- One address per line. Each line is trimmed, blank lines are skipped, and CRLF line endings and a UTF-8 byte-order mark are accepted.\n- A line that isn't a single valid address, such as `Name <ada@example.com>` or a typo, is counted as invalid and listed among the rows left out. One bad line doesn't sink a list.\n- The whole file is refused when it isn't a plain list at all: not UTF-8 text, a line with a comma, tab, or semicolon (it's a table, so a `.csv`), or not one valid address in it.\n- Its rows arrive with `origin: unknown` and `opt_in: unknown`.\n\n## Plan, then commit\n\nAn import happens in two steps. Planning shows what the import would do to every row, against your list as it stands, and changes nothing, so you can plan a file as often as you need. Committing applies the plan, all of it or none of it, and sends no email.\n\nThe commit needs two things from you when they apply:\n\n- **Your attestation,** when anyone would be confirmed: \"Everyone I'm importing as subscribed opted in to receive [your publication]. This isn't a purchased, rented, or scraped list.\" It is kept with the import, with who gave it and when. An import that only unsubscribes or suppresses needs none.\n- **Each send that would grow,** acknowledged by a person. A post goes to the list as it stands when it fires, so an import can add to the audience of a scheduled send, or of one that has just fired and not yet fixed its audience. Claude can attest for you, but can't acknowledge a growing send: you finish that import in the dashboard.\n\nIf another import is committed after you plan, plan again. If your list changes between the plan and the commit in any other way, such as a reader unsubscribing, the commit still goes ahead. Every write checks the list as it stands, and the answer reports any row that turned out differently.\n\n## What an import does to each address\n\nEach row gets exactly one outcome. A row that both changes its status and adds a suppression reports its status outcome, and says it added a suppression too.\n\n| Outcome | When |\n| --- | --- |\n| `create_confirmed` | `subscribed`, with no subscriber and no suppression in Kestrel: added as confirmed. |\n| `confirm_pending` | `subscribed`, and pending in Kestrel (never confirmed): confirmed, and its confirmation link no longer works. |\n| `unchanged` | Already confirmed in Kestrel, or already in the state the row claims. A subscriber who joined through Kestrel keeps their own record. |\n| `mark_unsubscribed` | `unsubscribed`, and confirmed or pending in Kestrel, with no confirmation in Kestrel newer than the row's `unsubscribed_at`: unsubscribed. |\n| `create_unsubscribed` | `unsubscribed`, with no subscriber in Kestrel: recorded as unsubscribed, so the list remembers they left. |\n| `kept_confirmed_newer` | `unsubscribed`, but the reader confirmed in Kestrel after the source's `unsubscribed_at`: their newer action stands. |\n| `kept_unsubscribed` | `subscribed`, but unsubscribed in Kestrel, or pending again after leaving until they confirm in Kestrel themselves. An unsubscribe is never reversed. |\n| `blocked_by_suppression` | `subscribed`, but the address carries a suppression in Kestrel, a do-not-contact marker included: never added or confirmed. |\n| `suppression_added` | The row carries a suppression and the address has none in Kestrel, or only a do-not-contact marker, and nothing else changes. Applied whatever the row's `status`. |\n| `left_out` | `pending` or `none` with nothing to apply: counted, never confirmed. An import sends no email, so it can't start a confirmation either. |\n| `invalid` | The address isn't valid. |\n| `merged` | A repeat of an address earlier in the file, folded into it. |\n\nA suppression already in Kestrel is never replaced, with one exception: a bounce, a complaint, or a manual suppression replaces a do-not-contact marker left by an erasure, so a known bad address still gets no confirmation. An imported bounce or complaint is kept apart from one Kestrel saw itself, so the list says where each came from.\n\nAnyone can type an address into your subscribe form, which puts a reader who left back to pending until they confirm. An import treats such a reader as unsubscribed, so it never confirms someone who left in Kestrel.\n\n**Repeats of an address** are folded into one row first. Its status is the first of unsubscribed, subscribed, pending, and none that any repeat claims, and its suppression the first of erased, complaint, bounce, and manual. Its opt-in is the weakest claim. It keeps the earliest consent times and the latest unsubscribe and suppression times, and the origin and evidence of the row whose status won.\n\n## How imported subscribers read\n\nA subscriber an import confirmed is **confirmed**, like anyone who confirmed in Kestrel, and gets your posts. How they joined shows beside their state: imported, with the origin and opt-in method the file claimed, and the evidence. That holds even when the file says they confirmed through Kestrel itself. Only a confirmation Kestrel recorded reads as Kestrel's double opt-in, and a subscriber who later confirms in Kestrel becomes Kestrel's own.\n\n## Limits\n\n- **Rows:** up to 10,000 in one import. On the Workers Free plan's limits (`SUBREQUEST_BUDGET` unset), up to 1,000, since Free's 10 ms of CPU a request can't plan and commit more. Set [`SUBREQUEST_BUDGET`](01-configuration.md#subrequest_budget) on Workers Paid to take 10,000. Split a larger list into several files.\n- **Size:** a request of at most 10 MB, and `evidence` of at most 500 bytes a row.\n- **Database writes on Workers Free:** D1's free plan allows 100,000 rows written a day, and refuses every query past that until midnight UTC. Each new subscriber an import brings in writes about 7, and a send writes about 14 a recipient. So when an import leaves more than about 3,000 confirmed subscribers on Workers Free, import on one day and send on the next, by UTC. The plan says so when it applies, and never refuses. One import of 1,000 rows uses about 7% of a day's writes.\n\n## Exporting\n\nThe export reads your whole list out in this format, a page at a time, at `GET /api/subscribers/export`. Each page holds up to 1,000 rows on the Workers Free plan's limits, or 10,000 otherwise, and its `next` value asks for the page after it. Following `next` until it's `null` gives every address exactly once, even if the list changes while you read. An address added or removed partway through may or may not appear. To make one CSV in this format, write the pages' rows under a single header. In the CSV, a value that starts with `=`, `+`, `-`, or `@` is written with an apostrophe before it, so a spreadsheet shows it as text rather than running it as a formula; an import takes the apostrophe off again. It includes every subscriber with how they joined, their evidence, and their suppression, and every address suppressed with no subscriber behind it, written as `status: none`. A confirmed subscriber is written as `subscribed`. Importing an export into another Kestrel carries the list over: the same states and suppressions, with every confirmation arriving as imported, carrying its original evidence. Pending subscribers are left out on the way in, since an import sends no confirmation.\n";
|
|
5676
|
+
|
|
4956
5677
|
// src/docs/index.ts
|
|
4957
5678
|
var PAGES = {
|
|
4958
5679
|
"get-started/02-deploy.md": deploy_default,
|
|
@@ -4966,7 +5687,8 @@ var PAGES = {
|
|
|
4966
5687
|
"guides/07-upgrade.md": upgrade_default,
|
|
4967
5688
|
"guides/08-try-locally.md": try_locally_default,
|
|
4968
5689
|
"reference/01-configuration.md": configuration_default,
|
|
4969
|
-
"reference/02-sending-domain-dns.md": sending_domain_dns_default
|
|
5690
|
+
"reference/02-sending-domain-dns.md": sending_domain_dns_default,
|
|
5691
|
+
"reference/03-import-format.md": import_format_default
|
|
4970
5692
|
};
|
|
4971
5693
|
var PAGE_PATH = /^([a-z0-9-]+)\/\d{2}-([a-z0-9-]+)\.md$/;
|
|
4972
5694
|
function linksIn(tokens) {
|
|
@@ -5062,33 +5784,207 @@ function list(_c) {
|
|
|
5062
5784
|
return json(renderDocs());
|
|
5063
5785
|
}
|
|
5064
5786
|
|
|
5065
|
-
// src/routes/
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
return `posts/${postId}/${filename}`;
|
|
5069
|
-
}
|
|
5070
|
-
function baseName(name) {
|
|
5071
|
-
const parts = name.split(/[\\/]/);
|
|
5072
|
-
return parts[parts.length - 1] ?? "";
|
|
5787
|
+
// src/routes/posts.ts
|
|
5788
|
+
function author(c) {
|
|
5789
|
+
return c.principal?.email ?? c.principal?.kind ?? null;
|
|
5073
5790
|
}
|
|
5074
|
-
function
|
|
5791
|
+
async function readBody(c, opts = {}) {
|
|
5792
|
+
const raw = await readJsonObject(c, opts);
|
|
5793
|
+
const slug = optString(raw, "slug");
|
|
5794
|
+
const slugAuto = optBoolean(raw, "slug_auto");
|
|
5795
|
+
if (slug !== void 0 && slugAuto === true) {
|
|
5796
|
+
throw fieldError("slug", "slug cannot be set while slug_auto is true");
|
|
5797
|
+
}
|
|
5075
5798
|
return {
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5799
|
+
input: {
|
|
5800
|
+
subject: optString(raw, "subject"),
|
|
5801
|
+
slug,
|
|
5802
|
+
slug_auto: slugAuto,
|
|
5803
|
+
markdown: optString(raw, "markdown")
|
|
5804
|
+
},
|
|
5805
|
+
base_revision: optStringOrNull(raw, "base_revision") ?? null
|
|
5081
5806
|
};
|
|
5082
5807
|
}
|
|
5808
|
+
function baseRevision(c, body) {
|
|
5809
|
+
const header = c.req.headers.get("If-Match");
|
|
5810
|
+
if (header && header !== "*") {
|
|
5811
|
+
return header.replace(/^"(.*)"$/, "$1");
|
|
5812
|
+
}
|
|
5813
|
+
return body.base_revision;
|
|
5814
|
+
}
|
|
5815
|
+
function revisionHeaders(post) {
|
|
5816
|
+
return post.current_revision ? { ETag: `"${post.current_revision}"` } : void 0;
|
|
5817
|
+
}
|
|
5083
5818
|
async function requireDraft(c) {
|
|
5084
5819
|
const post = await getPost(c.env.DB, param(c, "id"));
|
|
5085
5820
|
if (!post) {
|
|
5086
5821
|
throw notFound("post");
|
|
5087
5822
|
}
|
|
5088
|
-
if (post.status
|
|
5089
|
-
throw
|
|
5823
|
+
if (post.status === "sent") {
|
|
5824
|
+
throw refusal(409, "post_not_draft", "This post has been sent, so it can\u2019t be changed.");
|
|
5090
5825
|
}
|
|
5091
|
-
|
|
5826
|
+
if (post.status !== "draft") {
|
|
5827
|
+
const active = await getActiveSendForPost(c.env.DB, post.id);
|
|
5828
|
+
throw refusal(
|
|
5829
|
+
409,
|
|
5830
|
+
"post_not_draft",
|
|
5831
|
+
active?.status === "sending" ? "This post is being sent, so it can\u2019t be changed." : "This post is scheduled, so it\u2019s locked. Cancel the schedule to edit it."
|
|
5832
|
+
);
|
|
5833
|
+
}
|
|
5834
|
+
return post;
|
|
5835
|
+
}
|
|
5836
|
+
async function createPost2(c) {
|
|
5837
|
+
const { input } = await readBody(c, { optional: true });
|
|
5838
|
+
const { post, revision } = await createPost(c.env.DB, input, author(c));
|
|
5839
|
+
const body = { post, revision_id: revision.id };
|
|
5840
|
+
return json(body, 201, revisionHeaders(post));
|
|
5841
|
+
}
|
|
5842
|
+
var POST_STATUSES = ["draft", "scheduled", "sent"];
|
|
5843
|
+
function parseStatusFilter(raw) {
|
|
5844
|
+
if (!raw) {
|
|
5845
|
+
return void 0;
|
|
5846
|
+
}
|
|
5847
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5848
|
+
for (const part of raw.split(",")) {
|
|
5849
|
+
const s = part.trim();
|
|
5850
|
+
if (POST_STATUSES.includes(s)) {
|
|
5851
|
+
seen.add(s);
|
|
5852
|
+
}
|
|
5853
|
+
}
|
|
5854
|
+
const list5 = [...seen];
|
|
5855
|
+
return list5.length === 0 ? void 0 : list5.length === 1 ? list5[0] : list5;
|
|
5856
|
+
}
|
|
5857
|
+
async function listPosts2(c) {
|
|
5858
|
+
const status = parseStatusFilter(c.url.searchParams.get("status"));
|
|
5859
|
+
const search = c.url.searchParams.get("search") ?? void 0;
|
|
5860
|
+
const filter = { status, search };
|
|
5861
|
+
const page = parseListParams(c.url, POST_LIST_SPEC);
|
|
5862
|
+
const now = Date.now();
|
|
5863
|
+
const seq = await currentSendSeq(c.env.DB);
|
|
5864
|
+
const [total, rows] = await Promise.all([
|
|
5865
|
+
countPosts(c.env.DB, filter),
|
|
5866
|
+
listPosts(c.env.DB, filter, page)
|
|
5867
|
+
]);
|
|
5868
|
+
const body = {
|
|
5869
|
+
posts: rows,
|
|
5870
|
+
page: listPage(total, page),
|
|
5871
|
+
cursor: encodeSendCursor({ seq, at: now })
|
|
5872
|
+
};
|
|
5873
|
+
return json(body);
|
|
5874
|
+
}
|
|
5875
|
+
async function getPost2(c) {
|
|
5876
|
+
const post = await getPost(c.env.DB, param(c, "id"));
|
|
5877
|
+
if (!post) {
|
|
5878
|
+
throw notFound("post");
|
|
5879
|
+
}
|
|
5880
|
+
const revision = await getCurrentRevision(c.env.DB, post);
|
|
5881
|
+
const active = post.status === "scheduled" ? await getActiveSendForPost(c.env.DB, post.id) : null;
|
|
5882
|
+
const sent = post.status === "sent" ? await latestSentSendForPost(c.env.DB, post.id) : null;
|
|
5883
|
+
const body = {
|
|
5884
|
+
post,
|
|
5885
|
+
markdown: revision?.markdown ?? "",
|
|
5886
|
+
author: revision?.author ?? null,
|
|
5887
|
+
// who wrote the current revision — the freshness poll names them
|
|
5888
|
+
// `remade_at`: when a template or identity change last re-made the frozen email
|
|
5889
|
+
// (SPEC §8), so the editor can say the earlier test no longer stands.
|
|
5890
|
+
scheduled: active && active.status === "scheduled" ? { id: active.id, fire_at: active.fire_at, remade_at: active.remade_at } : null,
|
|
5891
|
+
sending: active && active.status === "sending" ? { id: active.id } : null,
|
|
5892
|
+
sent: sent ? { id: sent.id } : null
|
|
5893
|
+
};
|
|
5894
|
+
return json(body, 200, revisionHeaders(post));
|
|
5895
|
+
}
|
|
5896
|
+
async function updatePost2(c) {
|
|
5897
|
+
const post = await requireDraft(c);
|
|
5898
|
+
const body = await readBody(c);
|
|
5899
|
+
const base = baseRevision(c, body);
|
|
5900
|
+
if (!base) {
|
|
5901
|
+
const required = {
|
|
5902
|
+
error: "base_required",
|
|
5903
|
+
message: "A save must name the revision it was based on. Read the post, then save with its current_revision as base_revision or If-Match.",
|
|
5904
|
+
field: "base_revision",
|
|
5905
|
+
post
|
|
5906
|
+
};
|
|
5907
|
+
return json(required, 428, revisionHeaders(post));
|
|
5908
|
+
}
|
|
5909
|
+
if (post.current_revision && base !== post.current_revision) {
|
|
5910
|
+
const current2 = await getCurrentRevision(c.env.DB, post);
|
|
5911
|
+
const stale = {
|
|
5912
|
+
error: "stale_revision",
|
|
5913
|
+
message: "This draft changed since you loaded it. Read it again before saving.",
|
|
5914
|
+
current_revision: post.current_revision,
|
|
5915
|
+
updated_at: post.updated_at,
|
|
5916
|
+
author: current2?.author ?? null
|
|
5917
|
+
};
|
|
5918
|
+
return json(stale, 409, revisionHeaders(post));
|
|
5919
|
+
}
|
|
5920
|
+
const { post: updated, revision } = await updatePost(c.env.DB, post, body.input, author(c));
|
|
5921
|
+
const saved = { post: updated, revision_id: revision.id };
|
|
5922
|
+
return json(saved, 200, revisionHeaders(updated));
|
|
5923
|
+
}
|
|
5924
|
+
async function deletePost2(c) {
|
|
5925
|
+
const post = await requireDraft(c);
|
|
5926
|
+
const imgs = await listImages(c.env.DB, post.id);
|
|
5927
|
+
await Promise.all(imgs.map((i) => c.env.MEDIA.delete(i.storage_key)));
|
|
5928
|
+
await deletePost(c.env.DB, post.id);
|
|
5929
|
+
return json({ deleted: true });
|
|
5930
|
+
}
|
|
5931
|
+
async function listRevisions2(c) {
|
|
5932
|
+
const post = await getPost(c.env.DB, param(c, "id"));
|
|
5933
|
+
if (!post) {
|
|
5934
|
+
throw notFound("post");
|
|
5935
|
+
}
|
|
5936
|
+
const revs = await listRevisions(c.env.DB, post.id);
|
|
5937
|
+
return json({
|
|
5938
|
+
revisions: revs.map((r, i) => ({
|
|
5939
|
+
n: i + 1,
|
|
5940
|
+
id: r.id,
|
|
5941
|
+
author: r.author,
|
|
5942
|
+
created_at: r.created_at,
|
|
5943
|
+
is_current: r.id === post.current_revision,
|
|
5944
|
+
metadata: JSON.parse(r.metadata)
|
|
5945
|
+
}))
|
|
5946
|
+
});
|
|
5947
|
+
}
|
|
5948
|
+
async function getRevision(c) {
|
|
5949
|
+
const post = await getPost(c.env.DB, param(c, "id"));
|
|
5950
|
+
if (!post) {
|
|
5951
|
+
throw notFound("post");
|
|
5952
|
+
}
|
|
5953
|
+
const n = Number(param(c, "n"));
|
|
5954
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
5955
|
+
throw badRequest("revision index must be a positive integer");
|
|
5956
|
+
}
|
|
5957
|
+
const rev = await getRevisionByIndex(c.env.DB, post.id, n);
|
|
5958
|
+
if (!rev) {
|
|
5959
|
+
throw notFound("revision");
|
|
5960
|
+
}
|
|
5961
|
+
return json({
|
|
5962
|
+
n,
|
|
5963
|
+
id: rev.id,
|
|
5964
|
+
author: rev.author,
|
|
5965
|
+
created_at: rev.created_at,
|
|
5966
|
+
markdown: rev.markdown,
|
|
5967
|
+
metadata: JSON.parse(rev.metadata)
|
|
5968
|
+
});
|
|
5969
|
+
}
|
|
5970
|
+
|
|
5971
|
+
// src/routes/images.ts
|
|
5972
|
+
var MAX_POST_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
5973
|
+
function storageKey(postId, filename) {
|
|
5974
|
+
return `posts/${postId}/${filename}`;
|
|
5975
|
+
}
|
|
5976
|
+
function baseName(name) {
|
|
5977
|
+
const parts = name.split(/[\\/]/);
|
|
5978
|
+
return parts[parts.length - 1] ?? "";
|
|
5979
|
+
}
|
|
5980
|
+
function publicImage(c, row) {
|
|
5981
|
+
return {
|
|
5982
|
+
filename: row.filename,
|
|
5983
|
+
content_type: row.content_type,
|
|
5984
|
+
width: row.width,
|
|
5985
|
+
height: row.height,
|
|
5986
|
+
url: `${c.config.mediaPublicBase}/${row.storage_key}`
|
|
5987
|
+
};
|
|
5092
5988
|
}
|
|
5093
5989
|
async function uploadImage(c) {
|
|
5094
5990
|
const post = await requireDraft(c);
|
|
@@ -5120,13 +6016,13 @@ async function uploadImage(c) {
|
|
|
5120
6016
|
throw badRequest("a filename is required");
|
|
5121
6017
|
}
|
|
5122
6018
|
if (!RASTER_IMAGE_TYPES.includes(contentType)) {
|
|
5123
|
-
throw badRequest("
|
|
6019
|
+
throw badRequest("An image must be a PNG, JPEG, WebP, or GIF.");
|
|
5124
6020
|
}
|
|
5125
6021
|
if (bytes.byteLength === 0) {
|
|
5126
|
-
throw badRequest("
|
|
6022
|
+
throw badRequest("The image file is empty.");
|
|
5127
6023
|
}
|
|
5128
6024
|
if (bytes.byteLength > MAX_POST_IMAGE_BYTES) {
|
|
5129
|
-
throw badRequest(`
|
|
6025
|
+
throw badRequest(`An image must be ${MAX_POST_IMAGE_BYTES / (1024 * 1024)} MB or smaller.`);
|
|
5130
6026
|
}
|
|
5131
6027
|
const key = storageKey(post.id, filename);
|
|
5132
6028
|
await c.env.MEDIA.put(key, bytes, { httpMetadata: { contentType } });
|
|
@@ -5180,180 +6076,899 @@ async function serveMedia(c) {
|
|
|
5180
6076
|
return new Response(obj.body, { headers: headers2 });
|
|
5181
6077
|
}
|
|
5182
6078
|
|
|
5183
|
-
// src/
|
|
5184
|
-
function
|
|
5185
|
-
return
|
|
6079
|
+
// src/db/notifications.ts
|
|
6080
|
+
function importNotice(db, importId, now) {
|
|
6081
|
+
return db.prepare(
|
|
6082
|
+
"INSERT INTO notifications (send_id, import_id, kind, episode, created_at, updated_at) VALUES (NULL, ?, 'import', 0, ?, ?)"
|
|
6083
|
+
).bind(importId, now, now);
|
|
5186
6084
|
}
|
|
5187
|
-
async function
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
6085
|
+
async function recordNotifications(db, now) {
|
|
6086
|
+
await db.prepare(
|
|
6087
|
+
`INSERT OR IGNORE INTO notifications (send_id, kind, episode, created_at, updated_at)
|
|
6088
|
+
SELECT id, 'finished', 0, ?1, ?1 FROM sends
|
|
6089
|
+
WHERE status = 'sent' AND completed_at >= ?2
|
|
6090
|
+
UNION ALL
|
|
6091
|
+
SELECT id, 'refused', halted_at, ?1, ?1 FROM sends
|
|
6092
|
+
WHERE status = 'sending' AND halt_reason = 'account' AND halted_at IS NOT NULL
|
|
6093
|
+
UNION ALL
|
|
6094
|
+
SELECT id, 'missed', 0, ?1, ?1 FROM sends
|
|
6095
|
+
WHERE (status = 'scheduled' AND fire_at < ?1 - ?3)
|
|
6096
|
+
OR (status IN ('sending', 'sent') AND started_at >= ?2 AND started_at - fire_at > ?3)
|
|
6097
|
+
UNION ALL
|
|
6098
|
+
SELECT id, 'wedged', 0, ?1, ?1 FROM sends WHERE ${WEDGED_SEND}
|
|
6099
|
+
UNION ALL
|
|
6100
|
+
SELECT id, 'stuck', 0, ?1, ?1 FROM sends
|
|
6101
|
+
WHERE status = 'sending' AND started_at < ?1 - ?4
|
|
6102
|
+
AND halt_reason IS NOT 'account' AND NOT (${WEDGED_SEND})
|
|
6103
|
+
AND NOT EXISTS (SELECT 1 FROM notifications r
|
|
6104
|
+
WHERE r.send_id = sends.id AND r.kind = 'refused')`
|
|
6105
|
+
).bind(now, now - NOTIFY_HORIZON_MS, MISSED_THRESHOLD_MS, STUCK_THRESHOLD_MS).run();
|
|
6106
|
+
}
|
|
6107
|
+
async function dueNotifications(db, limit) {
|
|
6108
|
+
const { results } = await db.prepare(
|
|
6109
|
+
`SELECT n.send_id, n.import_id, n.kind, n.episode, n.attempts,
|
|
6110
|
+
s.subject, s.status AS send_status, s.fire_at, s.started_at, s.completed_at,
|
|
6111
|
+
s.locked_until, s.halt_reason, s.halted_at, s.halt_cause, s.halt_error, s.c_pending, s.c_in_flight, s.c_accepted,
|
|
6112
|
+
s.c_delivered, s.c_bounced, s.c_complained, s.c_skipped, s.c_unsent,
|
|
6113
|
+
i.file_name, i.created_at, i.attestation_version, i.counts, i.suppressions_added
|
|
6114
|
+
FROM notifications n
|
|
6115
|
+
LEFT JOIN sends s ON s.id = n.send_id
|
|
6116
|
+
LEFT JOIN imports i ON i.id = n.import_id
|
|
6117
|
+
WHERE n.status = 'pending' AND (s.id IS NOT NULL OR i.id IS NOT NULL)
|
|
6118
|
+
ORDER BY n.created_at ASC, n.send_id ASC, n.kind ASC
|
|
6119
|
+
LIMIT ?`
|
|
6120
|
+
).bind(limit).all();
|
|
6121
|
+
return results.map((r) => {
|
|
6122
|
+
if (r.kind === "import") {
|
|
6123
|
+
return {
|
|
6124
|
+
send_id: null,
|
|
6125
|
+
import_id: r.import_id,
|
|
6126
|
+
kind: "import",
|
|
6127
|
+
episode: 0,
|
|
6128
|
+
attempts: r.attempts,
|
|
6129
|
+
file_name: r.file_name,
|
|
6130
|
+
created_at: r.created_at,
|
|
6131
|
+
attestation_version: r.attestation_version,
|
|
6132
|
+
counts: r.counts,
|
|
6133
|
+
suppressions_added: r.suppressions_added
|
|
6134
|
+
};
|
|
6135
|
+
}
|
|
6136
|
+
return r;
|
|
6137
|
+
});
|
|
6138
|
+
}
|
|
6139
|
+
var IN_KEYS = `EXISTS (SELECT 1 FROM json_each(?) k
|
|
6140
|
+
WHERE json_extract(k.value, '$[0]') IS notifications.send_id
|
|
6141
|
+
AND json_extract(k.value, '$[3]') IS notifications.import_id
|
|
6142
|
+
AND json_extract(k.value, '$[1]') = notifications.kind
|
|
6143
|
+
AND json_extract(k.value, '$[2]') = notifications.episode)`;
|
|
6144
|
+
async function claimNotifications(db, keys, now) {
|
|
6145
|
+
await db.prepare(
|
|
6146
|
+
`UPDATE notifications SET attempts = attempts + 1, updated_at = ?
|
|
6147
|
+
WHERE status = 'pending' AND ${IN_KEYS}`
|
|
6148
|
+
).bind(now, JSON.stringify(keys.map((k) => [k.send_id, k.kind, k.episode, k.import_id ?? null]))).run();
|
|
6149
|
+
}
|
|
6150
|
+
async function recordNotificationOutcome(db, key, outcome, now) {
|
|
6151
|
+
const status = outcome.status === "unsent" ? outcome.giveUp ? "failed" : "pending" : outcome.status;
|
|
6152
|
+
const error = outcome.status === "unsent" ? outcome.error : null;
|
|
6153
|
+
await db.prepare(
|
|
6154
|
+
`UPDATE notifications SET status = ?, error = ?, updated_at = ?
|
|
6155
|
+
WHERE send_id IS ? AND import_id IS ? AND kind = ? AND episode = ?`
|
|
6156
|
+
).bind(status, error, now, key.send_id, key.import_id ?? null, key.kind, key.episode).run();
|
|
6157
|
+
}
|
|
6158
|
+
async function recordNotificationTest(db, error, now) {
|
|
6159
|
+
await db.batch([
|
|
6160
|
+
db.prepare("DELETE FROM notifications WHERE kind = 'test'"),
|
|
6161
|
+
db.prepare(
|
|
6162
|
+
`INSERT INTO notifications (send_id, kind, episode, status, attempts, error, created_at, updated_at)
|
|
6163
|
+
VALUES (NULL, 'test', 0, ?, 1, ?, ?, ?)`
|
|
6164
|
+
).bind(error === null ? "sent" : "failed", error, now, now)
|
|
6165
|
+
]);
|
|
6166
|
+
}
|
|
6167
|
+
async function closeUnaddressed(db, now) {
|
|
6168
|
+
await db.prepare(
|
|
6169
|
+
"UPDATE notifications SET status = 'unaddressed', updated_at = ? WHERE status = 'pending'"
|
|
6170
|
+
).bind(now).run();
|
|
6171
|
+
}
|
|
6172
|
+
async function notificationStatus(db) {
|
|
6173
|
+
const { results } = await db.prepare(
|
|
6174
|
+
`SELECT * FROM (
|
|
6175
|
+
SELECT 'sent' AS which, n.kind, COALESCE(s.subject, i.file_name, '') AS subject,
|
|
6176
|
+
n.updated_at AS at, NULL AS error
|
|
6177
|
+
FROM notifications n LEFT JOIN sends s ON s.id = n.send_id
|
|
6178
|
+
LEFT JOIN imports i ON i.id = n.import_id
|
|
6179
|
+
WHERE n.status = 'sent' ORDER BY n.updated_at DESC LIMIT 1)
|
|
6180
|
+
UNION ALL
|
|
6181
|
+
SELECT * FROM (
|
|
6182
|
+
SELECT 'failed' AS which, n.kind, COALESCE(s.subject, i.file_name, '') AS subject,
|
|
6183
|
+
n.updated_at AS at, n.error
|
|
6184
|
+
FROM notifications n LEFT JOIN sends s ON s.id = n.send_id
|
|
6185
|
+
LEFT JOIN imports i ON i.id = n.import_id
|
|
6186
|
+
WHERE n.error IS NOT NULL AND n.status IN ('pending', 'failed')
|
|
6187
|
+
ORDER BY n.updated_at DESC LIMIT 1)`
|
|
6188
|
+
).all();
|
|
6189
|
+
const sent = results.find((r) => r.which === "sent");
|
|
6190
|
+
const failed = results.find((r) => r.which === "failed");
|
|
5194
6191
|
return {
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
slug,
|
|
5198
|
-
slug_auto: slugAuto,
|
|
5199
|
-
markdown: optString(raw, "markdown")
|
|
5200
|
-
},
|
|
5201
|
-
base_revision: optStringOrNull(raw, "base_revision") ?? null
|
|
6192
|
+
lastSent: sent ? { kind: sent.kind, subject: sent.subject, at: sent.at } : null,
|
|
6193
|
+
lastFailure: failed && (!sent || failed.at > sent.at) ? { kind: failed.kind, subject: failed.subject, at: failed.at, error: failed.error ?? "" } : null
|
|
5202
6194
|
};
|
|
5203
6195
|
}
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
6196
|
+
|
|
6197
|
+
// src/db/imports.ts
|
|
6198
|
+
var MAX_VALUE_BYTES = 1e6;
|
|
6199
|
+
var encoder = new TextEncoder();
|
|
6200
|
+
var NON_ASCII = /[\u0080-\uffff]/;
|
|
6201
|
+
function jsonChunks(items, maxBytes = MAX_VALUE_BYTES) {
|
|
6202
|
+
const chunks = [];
|
|
6203
|
+
let parts = [];
|
|
6204
|
+
let bytes = 2;
|
|
6205
|
+
for (const item of items) {
|
|
6206
|
+
const part = JSON.stringify(item);
|
|
6207
|
+
const size = (NON_ASCII.test(part) ? encoder.encode(part).length : part.length) + 1;
|
|
6208
|
+
if (parts.length > 0 && bytes + size > maxBytes) {
|
|
6209
|
+
chunks.push(`[${parts.join(",")}]`);
|
|
6210
|
+
parts = [];
|
|
6211
|
+
bytes = 2;
|
|
6212
|
+
}
|
|
6213
|
+
parts.push(part);
|
|
6214
|
+
bytes += size;
|
|
6215
|
+
}
|
|
6216
|
+
if (parts.length > 0) {
|
|
6217
|
+
chunks.push(`[${parts.join(",")}]`);
|
|
6218
|
+
}
|
|
6219
|
+
return chunks;
|
|
6220
|
+
}
|
|
6221
|
+
async function readListState(db, emails) {
|
|
6222
|
+
const state = { subscribers: /* @__PURE__ */ new Map(), suppressions: /* @__PURE__ */ new Map() };
|
|
6223
|
+
const chunks = jsonChunks(emails);
|
|
6224
|
+
if (chunks.length === 0) {
|
|
6225
|
+
return state;
|
|
6226
|
+
}
|
|
6227
|
+
const statements = chunks.flatMap((chunk3) => [
|
|
6228
|
+
db.prepare(
|
|
6229
|
+
"SELECT email, status, import_id, confirmed_at, unsubscribed_at FROM subscribers WHERE email IN (SELECT value FROM json_each(?))"
|
|
6230
|
+
).bind(chunk3),
|
|
6231
|
+
db.prepare(
|
|
6232
|
+
"SELECT email, reason FROM suppressions WHERE email IN (SELECT value FROM json_each(?))"
|
|
6233
|
+
).bind(chunk3)
|
|
6234
|
+
]);
|
|
6235
|
+
const results = await db.batch(statements);
|
|
6236
|
+
results.forEach((res, i) => {
|
|
6237
|
+
for (const r of res.results) {
|
|
6238
|
+
if (i % 2 === 0) {
|
|
6239
|
+
state.subscribers.set(r.email, {
|
|
6240
|
+
status: r.status,
|
|
6241
|
+
import_id: r.import_id,
|
|
6242
|
+
confirmed_at: r.confirmed_at,
|
|
6243
|
+
unsubscribed_at: r.unsubscribed_at
|
|
6244
|
+
});
|
|
6245
|
+
} else {
|
|
6246
|
+
state.suppressions.set(r.email, r.reason);
|
|
6247
|
+
}
|
|
6248
|
+
}
|
|
6249
|
+
});
|
|
6250
|
+
return state;
|
|
5210
6251
|
}
|
|
5211
|
-
function
|
|
5212
|
-
|
|
6252
|
+
async function scheduledSends(db) {
|
|
6253
|
+
const { results } = await db.prepare(
|
|
6254
|
+
`SELECT id, subject, fire_at FROM sends
|
|
6255
|
+
WHERE status = 'scheduled' OR (status = 'sending' AND audience_resolved_at IS NULL)
|
|
6256
|
+
ORDER BY fire_at ASC, id ASC`
|
|
6257
|
+
).all();
|
|
6258
|
+
return results;
|
|
5213
6259
|
}
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
6260
|
+
function toRecord(row) {
|
|
6261
|
+
return {
|
|
6262
|
+
id: row.id,
|
|
6263
|
+
created_at: row.created_at,
|
|
6264
|
+
principal: { kind: row.principal_kind, email: row.principal_email },
|
|
6265
|
+
attestation_version: row.attestation_version,
|
|
6266
|
+
file_name: row.file_name,
|
|
6267
|
+
file_sha256: row.file_sha256,
|
|
6268
|
+
plan_hash: row.plan_hash,
|
|
6269
|
+
counts: JSON.parse(row.counts),
|
|
6270
|
+
suppressions_added: row.suppressions_added
|
|
6271
|
+
};
|
|
6272
|
+
}
|
|
6273
|
+
async function latestImportId(db) {
|
|
6274
|
+
const row = await db.prepare("SELECT id FROM imports ORDER BY created_at DESC, id DESC LIMIT 1").first();
|
|
6275
|
+
return row?.id ?? null;
|
|
6276
|
+
}
|
|
6277
|
+
async function getImportByPlanHash(db, planHash) {
|
|
6278
|
+
const row = await db.prepare("SELECT * FROM imports WHERE plan_hash = ?").bind(planHash).first();
|
|
6279
|
+
return row ? toRecord(row) : null;
|
|
6280
|
+
}
|
|
6281
|
+
var v = (field) => `json_extract(j.value, '$.${field}')`;
|
|
6282
|
+
async function applyImport(db, writes, suppressionDetail) {
|
|
6283
|
+
const r = writes.record;
|
|
6284
|
+
const now = r.created_at;
|
|
6285
|
+
const statements = [
|
|
6286
|
+
db.prepare(
|
|
6287
|
+
`INSERT INTO imports (id, created_at, principal_kind, principal_email, attestation_version,
|
|
6288
|
+
file_name, file_sha256, plan_hash, counts, suppressions_added)
|
|
6289
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
6290
|
+
).bind(
|
|
6291
|
+
r.id,
|
|
6292
|
+
now,
|
|
6293
|
+
r.principal_kind,
|
|
6294
|
+
r.principal_email,
|
|
6295
|
+
r.attestation_version,
|
|
6296
|
+
r.file_name,
|
|
6297
|
+
r.file_sha256,
|
|
6298
|
+
r.plan_hash,
|
|
6299
|
+
JSON.stringify(r.counts),
|
|
6300
|
+
r.suppressions_added
|
|
6301
|
+
)
|
|
6302
|
+
];
|
|
6303
|
+
const kinds = [];
|
|
6304
|
+
const add2 = (kind, sql, rows, ...binds) => {
|
|
6305
|
+
for (const chunk3 of jsonChunks(rows)) {
|
|
6306
|
+
statements.push(db.prepare(sql).bind(chunk3, ...binds));
|
|
6307
|
+
kinds.push(kind);
|
|
6308
|
+
}
|
|
6309
|
+
};
|
|
6310
|
+
add2(
|
|
6311
|
+
"unsubscribed",
|
|
6312
|
+
`UPDATE subscribers SET status = 'unsubscribed', unsubscribed_at = COALESCE(${v("uat")}, ?2)
|
|
6313
|
+
FROM json_each(?1) AS j
|
|
6314
|
+
WHERE subscribers.email = ${v("e")}
|
|
6315
|
+
AND subscribers.status IN ('pending', 'confirmed')
|
|
6316
|
+
AND NOT (subscribers.status = 'confirmed' AND subscribers.import_id IS NULL
|
|
6317
|
+
AND ${v("uat")} IS NOT NULL AND COALESCE(subscribers.confirmed_at, 0) > ${v("uat")})
|
|
6318
|
+
RETURNING email`,
|
|
6319
|
+
writes.unsubscribe,
|
|
6320
|
+
now
|
|
6321
|
+
);
|
|
6322
|
+
add2(
|
|
6323
|
+
"addedUnsubscribed",
|
|
6324
|
+
`INSERT INTO subscribers (id, email, status, unsub_token, created_at, confirmed_at, unsubscribed_at,
|
|
6325
|
+
origin, opt_in, consent_evidence, import_id)
|
|
6326
|
+
SELECT ${v("id")}, ${v("e")}, 'unsubscribed', ${v("tok")}, ?2, ${v("cat")}, COALESCE(${v("uat")}, ?2),
|
|
6327
|
+
${v("org")}, ${v("opt")}, ${v("ev")}, ?3
|
|
6328
|
+
FROM json_each(?1) AS j WHERE true
|
|
6329
|
+
ON CONFLICT (email) DO NOTHING
|
|
6330
|
+
RETURNING email`,
|
|
6331
|
+
writes.addUnsubscribed,
|
|
6332
|
+
now,
|
|
6333
|
+
r.id
|
|
6334
|
+
);
|
|
6335
|
+
add2(
|
|
6336
|
+
"confirmed",
|
|
6337
|
+
`UPDATE subscribers SET status = 'confirmed', confirmed_at = ${v("cat")}, confirm_token = NULL,
|
|
6338
|
+
origin = ${v("org")}, opt_in = ${v("opt")}, consent_evidence = ${v("ev")},
|
|
6339
|
+
import_id = ?2
|
|
6340
|
+
FROM json_each(?1) AS j
|
|
6341
|
+
WHERE subscribers.email = ${v("e")} AND subscribers.status = 'pending'
|
|
6342
|
+
AND subscribers.unsubscribed_at IS NULL
|
|
6343
|
+
AND NOT EXISTS (SELECT 1 FROM suppressions s WHERE s.email = subscribers.email)
|
|
6344
|
+
RETURNING email`,
|
|
6345
|
+
[...writes.confirm, ...writes.addConfirmed],
|
|
6346
|
+
r.id
|
|
6347
|
+
);
|
|
6348
|
+
add2(
|
|
6349
|
+
"addedConfirmed",
|
|
6350
|
+
`INSERT INTO subscribers (id, email, status, unsub_token, created_at, confirmed_at,
|
|
6351
|
+
origin, opt_in, consent_evidence, import_id)
|
|
6352
|
+
SELECT ${v("id")}, ${v("e")}, 'confirmed', ${v("tok")}, ?2, ${v("cat")},
|
|
6353
|
+
${v("org")}, ${v("opt")}, ${v("ev")}, ?3
|
|
6354
|
+
FROM json_each(?1) AS j
|
|
6355
|
+
WHERE NOT EXISTS (SELECT 1 FROM suppressions s WHERE s.email = ${v("e")})
|
|
6356
|
+
ON CONFLICT (email) DO NOTHING
|
|
6357
|
+
RETURNING email`,
|
|
6358
|
+
writes.addConfirmed,
|
|
6359
|
+
now,
|
|
6360
|
+
r.id
|
|
6361
|
+
);
|
|
6362
|
+
add2(
|
|
6363
|
+
"suppressed",
|
|
6364
|
+
`INSERT INTO suppressions (email, reason, detail, created_at)
|
|
6365
|
+
SELECT ${v("e")}, ${v("r")}, ?2, COALESCE(${v("at")}, ?3) FROM json_each(?1) AS j WHERE true
|
|
6366
|
+
ON CONFLICT (email) DO UPDATE SET reason = excluded.reason, detail = excluded.detail,
|
|
6367
|
+
created_at = excluded.created_at
|
|
6368
|
+
WHERE suppressions.reason = 'erased' AND excluded.reason <> 'erased'
|
|
6369
|
+
RETURNING email`,
|
|
6370
|
+
writes.suppress,
|
|
6371
|
+
suppressionDetail,
|
|
6372
|
+
now
|
|
6373
|
+
);
|
|
6374
|
+
if (writes.notice) {
|
|
6375
|
+
statements.push(importNotice(db, r.id, now));
|
|
6376
|
+
}
|
|
6377
|
+
const results = await db.batch(statements);
|
|
6378
|
+
const applied = {
|
|
6379
|
+
unsubscribed: /* @__PURE__ */ new Set(),
|
|
6380
|
+
addedUnsubscribed: /* @__PURE__ */ new Set(),
|
|
6381
|
+
confirmed: /* @__PURE__ */ new Set(),
|
|
6382
|
+
addedConfirmed: /* @__PURE__ */ new Set(),
|
|
6383
|
+
suppressed: /* @__PURE__ */ new Set()
|
|
6384
|
+
};
|
|
6385
|
+
kinds.forEach((kind, i) => {
|
|
6386
|
+
for (const row of results[i + 1]?.results ?? []) {
|
|
6387
|
+
applied[kind].add(row.email);
|
|
6388
|
+
}
|
|
6389
|
+
});
|
|
6390
|
+
return applied;
|
|
6391
|
+
}
|
|
6392
|
+
async function setImportCounts(db, id, counts2, suppressionsAdded) {
|
|
6393
|
+
await db.prepare("UPDATE imports SET counts = ?, suppressions_added = ? WHERE id = ?").bind(JSON.stringify(counts2), suppressionsAdded, id).run();
|
|
6394
|
+
}
|
|
6395
|
+
async function listForExport(db, after, limit) {
|
|
6396
|
+
const { results } = await db.prepare(
|
|
6397
|
+
`SELECT * FROM (
|
|
6398
|
+
SELECT s.email, s.status, s.opt_in, s.origin, s.consent_evidence, s.created_at,
|
|
6399
|
+
s.confirmed_at, s.unsubscribed_at, p.reason, p.created_at AS suppressed_at
|
|
6400
|
+
FROM subscribers s LEFT JOIN suppressions p ON p.email = s.email
|
|
6401
|
+
WHERE s.email > ?1 ORDER BY s.email LIMIT ?2)
|
|
6402
|
+
UNION ALL
|
|
6403
|
+
SELECT * FROM (
|
|
6404
|
+
SELECT p.email, NULL, NULL, NULL, NULL, NULL, NULL, NULL, p.reason, p.created_at
|
|
6405
|
+
FROM suppressions p
|
|
6406
|
+
WHERE p.email > ?1 AND NOT EXISTS (SELECT 1 FROM subscribers s WHERE s.email = p.email)
|
|
6407
|
+
ORDER BY p.email LIMIT ?2)
|
|
6408
|
+
ORDER BY 1 LIMIT ?2`
|
|
6409
|
+
).bind(after ?? "", limit).all();
|
|
6410
|
+
return results;
|
|
6411
|
+
}
|
|
6412
|
+
|
|
6413
|
+
// src/services/imports.ts
|
|
6414
|
+
function maxImportRows(subrequestBudget) {
|
|
6415
|
+
return onFreePlanLimits({ subrequestBudget }) ? {
|
|
6416
|
+
maxRows: IMPORT_LIMITS.maxRowsFree,
|
|
6417
|
+
reason: `This deployment runs on the Workers Free plan\u2019s limits (SUBREQUEST_BUDGET unset), whose 10 ms of CPU per request can plan and commit about ${IMPORT_LIMITS.maxRowsFree.toLocaleString("en-US")} rows; on Workers Paid, set SUBREQUEST_BUDGET to take up to ${IMPORT_LIMITS.maxRows.toLocaleString("en-US")}.`
|
|
6418
|
+
} : { maxRows: IMPORT_LIMITS.maxRows, reason: null };
|
|
6419
|
+
}
|
|
6420
|
+
function exportPageRows(subrequestBudget) {
|
|
6421
|
+
return onFreePlanLimits({ subrequestBudget }) ? EXPORT_PAGE_ROWS.free : EXPORT_PAGE_ROWS.paid;
|
|
6422
|
+
}
|
|
6423
|
+
function emptyCounts() {
|
|
6424
|
+
return Object.fromEntries(IMPORT_OUTCOMES.map((o) => [o, 0]));
|
|
6425
|
+
}
|
|
6426
|
+
var WRITE_OUTCOMES = /* @__PURE__ */ new Set([
|
|
6427
|
+
"create_confirmed",
|
|
6428
|
+
"confirm_pending",
|
|
6429
|
+
"mark_unsubscribed",
|
|
6430
|
+
"create_unsubscribed"
|
|
6431
|
+
]);
|
|
6432
|
+
function classify(row, here, suppressionHere) {
|
|
6433
|
+
const suppressionAdded = row.suppression !== null && (suppressionHere === void 0 || suppressionHere === "erased" && row.suppression !== "erased");
|
|
6434
|
+
const leftHere = here?.status === "unsubscribed" || here?.status === "pending" && here.unsubscribed_at !== null;
|
|
6435
|
+
let outcome;
|
|
6436
|
+
if (row.status === "subscribed") {
|
|
6437
|
+
outcome = suppressionHere !== void 0 ? "blocked_by_suppression" : here === void 0 ? "create_confirmed" : leftHere ? "kept_unsubscribed" : here.status === "pending" ? "confirm_pending" : "unchanged";
|
|
6438
|
+
} else if (row.status === "unsubscribed") {
|
|
6439
|
+
const confirmedHereSince = here?.status === "confirmed" && here.import_id === null && row.unsubscribed_at !== null && (here.confirmed_at ?? 0) > row.unsubscribed_at;
|
|
6440
|
+
outcome = here === void 0 ? "create_unsubscribed" : here.status === "unsubscribed" ? "unchanged" : confirmedHereSince ? "kept_confirmed_newer" : "mark_unsubscribed";
|
|
6441
|
+
} else {
|
|
6442
|
+
outcome = "left_out";
|
|
5218
6443
|
}
|
|
5219
|
-
if (
|
|
5220
|
-
|
|
6444
|
+
if (suppressionAdded && (outcome === "unchanged" || outcome === "left_out")) {
|
|
6445
|
+
outcome = "suppression_added";
|
|
5221
6446
|
}
|
|
5222
|
-
return
|
|
6447
|
+
return { outcome, suppressionAdded };
|
|
5223
6448
|
}
|
|
5224
|
-
async function
|
|
5225
|
-
const
|
|
5226
|
-
|
|
5227
|
-
const body = { post, revision_id: revision.id };
|
|
5228
|
-
return json(body, 201, revisionHeaders(post));
|
|
6449
|
+
async function sha256Hex(data) {
|
|
6450
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data));
|
|
6451
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
5229
6452
|
}
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
6453
|
+
function fileSha256(text) {
|
|
6454
|
+
return sha256Hex(text);
|
|
6455
|
+
}
|
|
6456
|
+
function fileRefusal(error) {
|
|
6457
|
+
const { code, message: message2, ...at } = error;
|
|
6458
|
+
return new HttpError(400, code, message2, { field: "file", ...at });
|
|
6459
|
+
}
|
|
6460
|
+
async function planImport(db, input, {
|
|
6461
|
+
maxRows,
|
|
6462
|
+
maxRowsReason,
|
|
6463
|
+
publication,
|
|
6464
|
+
freePlan = false
|
|
6465
|
+
}) {
|
|
6466
|
+
const tooMany = (rows2) => new HttpError(
|
|
6467
|
+
400,
|
|
6468
|
+
"too_many_rows",
|
|
6469
|
+
`The file has ${rows2} rows; one import takes at most ${maxRows.toLocaleString("en-US")}.${maxRowsReason ? ` ${maxRowsReason}` : ""} Split it into files of up to ${maxRows.toLocaleString("en-US")} rows.`,
|
|
6470
|
+
{ field: "file", limit: maxRows }
|
|
6471
|
+
);
|
|
6472
|
+
let lines = 1;
|
|
6473
|
+
for (let at = input.text.indexOf("\n"); at !== -1; at = input.text.indexOf("\n", at + 1)) {
|
|
6474
|
+
lines += 1;
|
|
5234
6475
|
}
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
6476
|
+
if (lines > 2 * (maxRows + 1)) {
|
|
6477
|
+
throw tooMany(`about ${lines.toLocaleString("en-US")}`);
|
|
6478
|
+
}
|
|
6479
|
+
const parsed = parseImportFile(
|
|
6480
|
+
{ name: input.name, text: input.text },
|
|
6481
|
+
input.plainListStatus ?? "subscribed"
|
|
6482
|
+
);
|
|
6483
|
+
if (!parsed.ok) {
|
|
6484
|
+
throw fileRefusal(parsed.error);
|
|
6485
|
+
}
|
|
6486
|
+
const { kind, rows } = parsed.file;
|
|
6487
|
+
if (kind === "csv" && input.plainListStatus !== null) {
|
|
6488
|
+
throw new HttpError(
|
|
6489
|
+
400,
|
|
6490
|
+
"bad_request",
|
|
6491
|
+
"plain_list_status is for a plain list (.txt); a CSV says each row\u2019s status itself",
|
|
6492
|
+
{ field: "plain_list_status" }
|
|
6493
|
+
);
|
|
6494
|
+
}
|
|
6495
|
+
if (rows.length > maxRows) {
|
|
6496
|
+
throw tooMany(rows.length.toLocaleString("en-US"));
|
|
6497
|
+
}
|
|
6498
|
+
const merged = mergeRows(rows);
|
|
6499
|
+
const state = await readListState(
|
|
6500
|
+
db,
|
|
6501
|
+
merged.rows.map((r) => r.email)
|
|
6502
|
+
);
|
|
6503
|
+
const planned = merged.rows.map((row) => ({
|
|
6504
|
+
row,
|
|
6505
|
+
...classify(row, state.subscribers.get(row.email), state.suppressions.get(row.email))
|
|
6506
|
+
}));
|
|
6507
|
+
const counts2 = emptyCounts();
|
|
6508
|
+
const confirmed = {
|
|
6509
|
+
opt_in: { double: 0, single: 0, unknown: 0 },
|
|
6510
|
+
origin: {}
|
|
6511
|
+
};
|
|
6512
|
+
let suppressionsAdded = 0;
|
|
6513
|
+
let gains = 0;
|
|
6514
|
+
for (const p of planned) {
|
|
6515
|
+
counts2[p.outcome] += 1;
|
|
6516
|
+
suppressionsAdded += p.suppressionAdded ? 1 : 0;
|
|
6517
|
+
if (CONFIRMING_OUTCOMES.has(p.outcome)) {
|
|
6518
|
+
confirmed.opt_in[p.row.opt_in] += 1;
|
|
6519
|
+
confirmed.origin[p.row.origin] = (confirmed.origin[p.row.origin] ?? 0) + 1;
|
|
6520
|
+
gains += p.row.suppression === null ? 1 : 0;
|
|
6521
|
+
}
|
|
6522
|
+
}
|
|
6523
|
+
counts2.merged = merged.repeats.length;
|
|
6524
|
+
counts2.invalid = merged.invalid.length;
|
|
6525
|
+
const sendsGrowing = gains === 0 ? [] : (await scheduledSends(db)).map((s) => ({ ...s, gains }));
|
|
6526
|
+
const after = await latestImportId(db);
|
|
6527
|
+
const needsAttestation = counts2.create_confirmed + counts2.confirm_pending > 0;
|
|
6528
|
+
const sha = await fileSha256(input.text);
|
|
6529
|
+
const confirmedAfter = freePlan ? await audienceCount(db) + gains : 0;
|
|
6530
|
+
const planHash = await sha256Hex(
|
|
6531
|
+
JSON.stringify([
|
|
6532
|
+
"kestrel-import-plan/1",
|
|
6533
|
+
kind,
|
|
6534
|
+
sha,
|
|
6535
|
+
input.plainListStatus,
|
|
6536
|
+
needsAttestation,
|
|
6537
|
+
sendsGrowing.map((s) => s.id).sort(),
|
|
6538
|
+
after
|
|
6539
|
+
])
|
|
6540
|
+
);
|
|
6541
|
+
return {
|
|
6542
|
+
input,
|
|
6543
|
+
kind,
|
|
6544
|
+
fileSha256: sha,
|
|
6545
|
+
rowCount: rows.length,
|
|
6546
|
+
merged,
|
|
6547
|
+
planned,
|
|
6548
|
+
counts: counts2,
|
|
6549
|
+
suppressionsAdded,
|
|
6550
|
+
confirmed,
|
|
6551
|
+
sendsGrowing,
|
|
6552
|
+
attestation: needsAttestation ? { version: ATTESTATION.version, text: attestationText(publication) } : null,
|
|
6553
|
+
planHash,
|
|
6554
|
+
dailyWritesWarning: confirmedAfter > DAILY_WRITES_IMPORT_CONFIRMED ? dailyWritesImportWarning(confirmedAfter) : null
|
|
6555
|
+
};
|
|
6556
|
+
}
|
|
6557
|
+
function notConfirmedRows(planned, merged) {
|
|
6558
|
+
const out = [];
|
|
6559
|
+
for (const p of planned) {
|
|
6560
|
+
if (!CONFIRMING_OUTCOMES.has(p.outcome)) {
|
|
6561
|
+
out.push({
|
|
6562
|
+
row: p.row.row,
|
|
6563
|
+
email: p.row.email,
|
|
6564
|
+
outcome: p.outcome,
|
|
6565
|
+
suppression_added: p.suppressionAdded
|
|
6566
|
+
});
|
|
5240
6567
|
}
|
|
5241
6568
|
}
|
|
5242
|
-
const
|
|
5243
|
-
|
|
6569
|
+
for (const r of merged.repeats) {
|
|
6570
|
+
out.push({ row: r.row, email: r.email, outcome: "merged", suppression_added: false });
|
|
6571
|
+
}
|
|
6572
|
+
for (const r of merged.invalid) {
|
|
6573
|
+
out.push({ row: r.row, email: r.email, outcome: "invalid", suppression_added: false });
|
|
6574
|
+
}
|
|
6575
|
+
return out.sort((a, b) => a.row - b.row);
|
|
5244
6576
|
}
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
6577
|
+
function planResponse(plan2) {
|
|
6578
|
+
return {
|
|
6579
|
+
file: {
|
|
6580
|
+
name: plan2.input.name,
|
|
6581
|
+
kind: plan2.kind,
|
|
6582
|
+
rows: plan2.rowCount,
|
|
6583
|
+
addresses: plan2.merged.rows.length,
|
|
6584
|
+
plain_list_status: plan2.input.plainListStatus
|
|
6585
|
+
},
|
|
6586
|
+
counts: plan2.counts,
|
|
6587
|
+
suppressions_added: plan2.suppressionsAdded,
|
|
6588
|
+
confirmed: plan2.confirmed,
|
|
6589
|
+
rows: notConfirmedRows(plan2.planned, plan2.merged),
|
|
6590
|
+
sends_growing: plan2.sendsGrowing,
|
|
6591
|
+
attestation: plan2.attestation,
|
|
6592
|
+
plan_hash: plan2.planHash,
|
|
6593
|
+
daily_writes_warning: plan2.dailyWritesWarning
|
|
5260
6594
|
};
|
|
5261
|
-
return json(body);
|
|
5262
6595
|
}
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5266
|
-
|
|
6596
|
+
var SUPPRESSION_REASON = {
|
|
6597
|
+
bounce: "import_bounce",
|
|
6598
|
+
complaint: "import_complaint",
|
|
6599
|
+
manual: "manual",
|
|
6600
|
+
erased: "erased"
|
|
6601
|
+
};
|
|
6602
|
+
function replayResponse(record) {
|
|
6603
|
+
return {
|
|
6604
|
+
import: record,
|
|
6605
|
+
counts: record.counts,
|
|
6606
|
+
suppressions_added: record.suppressions_added,
|
|
6607
|
+
rows: [],
|
|
6608
|
+
differed: [],
|
|
6609
|
+
replayed: true
|
|
6610
|
+
};
|
|
6611
|
+
}
|
|
6612
|
+
async function commitImport(db, plan2, principal, now) {
|
|
6613
|
+
const id = newId();
|
|
6614
|
+
const writes = {
|
|
6615
|
+
record: {
|
|
6616
|
+
id,
|
|
6617
|
+
created_at: now,
|
|
6618
|
+
principal_kind: principal.kind,
|
|
6619
|
+
principal_email: principal.kind === "human" ? (principal.email ?? "").toLowerCase() : null,
|
|
6620
|
+
attestation_version: plan2.attestation ? plan2.attestation.version : null,
|
|
6621
|
+
file_name: plan2.input.name,
|
|
6622
|
+
file_sha256: plan2.fileSha256,
|
|
6623
|
+
plan_hash: plan2.planHash,
|
|
6624
|
+
counts: plan2.counts,
|
|
6625
|
+
suppressions_added: plan2.suppressionsAdded
|
|
6626
|
+
},
|
|
6627
|
+
unsubscribe: [],
|
|
6628
|
+
addUnsubscribed: [],
|
|
6629
|
+
confirm: [],
|
|
6630
|
+
addConfirmed: [],
|
|
6631
|
+
suppress: [],
|
|
6632
|
+
notice: principal.kind === "service"
|
|
6633
|
+
};
|
|
6634
|
+
const tokens = newTokens(plan2.counts.create_confirmed + plan2.counts.create_unsubscribed);
|
|
6635
|
+
const token2 = () => tokens.pop() ?? "";
|
|
6636
|
+
for (const { row, outcome, suppressionAdded } of plan2.planned) {
|
|
6637
|
+
const consent = {
|
|
6638
|
+
e: row.email,
|
|
6639
|
+
opt: row.opt_in,
|
|
6640
|
+
org: row.origin,
|
|
6641
|
+
ev: row.evidence,
|
|
6642
|
+
cat: row.confirmed_at ?? row.subscribed_at
|
|
6643
|
+
};
|
|
6644
|
+
if (outcome === "create_confirmed") {
|
|
6645
|
+
writes.addConfirmed.push({ ...consent, id: newId(), tok: token2(), uat: null });
|
|
6646
|
+
} else if (outcome === "confirm_pending") {
|
|
6647
|
+
writes.confirm.push(consent);
|
|
6648
|
+
} else if (outcome === "mark_unsubscribed") {
|
|
6649
|
+
writes.unsubscribe.push({ e: row.email, uat: row.unsubscribed_at });
|
|
6650
|
+
} else if (outcome === "create_unsubscribed") {
|
|
6651
|
+
writes.unsubscribe.push({ e: row.email, uat: row.unsubscribed_at });
|
|
6652
|
+
writes.addUnsubscribed.push({
|
|
6653
|
+
...consent,
|
|
6654
|
+
cat: row.confirmed_at,
|
|
6655
|
+
id: newId(),
|
|
6656
|
+
tok: token2(),
|
|
6657
|
+
uat: row.unsubscribed_at
|
|
6658
|
+
});
|
|
6659
|
+
}
|
|
6660
|
+
if (suppressionAdded && row.suppression !== null) {
|
|
6661
|
+
writes.suppress.push({
|
|
6662
|
+
e: row.email,
|
|
6663
|
+
r: SUPPRESSION_REASON[row.suppression],
|
|
6664
|
+
at: row.suppressed_at
|
|
6665
|
+
});
|
|
6666
|
+
}
|
|
5267
6667
|
}
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
6668
|
+
let applied;
|
|
6669
|
+
try {
|
|
6670
|
+
applied = await applyImport(db, writes, `imported from ${plan2.input.name}`);
|
|
6671
|
+
} catch (err) {
|
|
6672
|
+
const raced = await getImportByPlanHash(db, plan2.planHash);
|
|
6673
|
+
if (raced) {
|
|
6674
|
+
return replayResponse(raced);
|
|
6675
|
+
}
|
|
6676
|
+
throw err;
|
|
6677
|
+
}
|
|
6678
|
+
const actual = [];
|
|
6679
|
+
const unsettled = [];
|
|
6680
|
+
for (const p of plan2.planned) {
|
|
6681
|
+
const e = p.row.email;
|
|
6682
|
+
const outcome = applied.confirmed.has(e) ? "confirm_pending" : applied.addedConfirmed.has(e) ? "create_confirmed" : applied.unsubscribed.has(e) ? "mark_unsubscribed" : applied.addedUnsubscribed.has(e) ? "create_unsubscribed" : WRITE_OUTCOMES.has(p.outcome) ? void 0 : p.outcome;
|
|
6683
|
+
const suppressionAdded = applied.suppressed.has(e);
|
|
6684
|
+
if (outcome === void 0 || p.outcome === "suppression_added" && !suppressionAdded) {
|
|
6685
|
+
unsettled.push({ ...p, suppressionAdded });
|
|
6686
|
+
continue;
|
|
6687
|
+
}
|
|
6688
|
+
actual.push({ ...p, outcome, suppressionAdded });
|
|
6689
|
+
}
|
|
6690
|
+
if (unsettled.length > 0) {
|
|
6691
|
+
let state = { subscribers: /* @__PURE__ */ new Map(), suppressions: /* @__PURE__ */ new Map() };
|
|
6692
|
+
try {
|
|
6693
|
+
state = await readListState(
|
|
6694
|
+
db,
|
|
6695
|
+
unsettled.map((p) => p.row.email)
|
|
6696
|
+
);
|
|
6697
|
+
} catch (err) {
|
|
6698
|
+
log.error("import.incomplete", { importId: id, step: "reread", error: errorText(err) });
|
|
6699
|
+
}
|
|
6700
|
+
for (const p of unsettled) {
|
|
6701
|
+
const e = p.row.email;
|
|
6702
|
+
const { outcome } = classify(
|
|
6703
|
+
{ ...p.row, suppression: null },
|
|
6704
|
+
state.subscribers.get(e),
|
|
6705
|
+
applied.suppressed.has(e) ? void 0 : state.suppressions.get(e)
|
|
6706
|
+
);
|
|
6707
|
+
actual.push({ ...p, outcome: WRITE_OUTCOMES.has(outcome) ? "unchanged" : outcome });
|
|
6708
|
+
}
|
|
6709
|
+
}
|
|
6710
|
+
const counts2 = emptyCounts();
|
|
6711
|
+
let suppressionsAdded = 0;
|
|
6712
|
+
for (const p of actual) {
|
|
6713
|
+
counts2[p.outcome] += 1;
|
|
6714
|
+
suppressionsAdded += p.suppressionAdded ? 1 : 0;
|
|
6715
|
+
}
|
|
6716
|
+
counts2.merged = plan2.counts.merged;
|
|
6717
|
+
counts2.invalid = plan2.counts.invalid;
|
|
6718
|
+
const byEmail2 = new Map(plan2.planned.map((p) => [p.row.email, p]));
|
|
6719
|
+
const differed = [];
|
|
6720
|
+
for (const p of actual) {
|
|
6721
|
+
const was = byEmail2.get(p.row.email);
|
|
6722
|
+
if (was && (was.outcome !== p.outcome || was.suppressionAdded !== p.suppressionAdded)) {
|
|
6723
|
+
differed.push({
|
|
6724
|
+
row: p.row.row,
|
|
6725
|
+
email: p.row.email,
|
|
6726
|
+
planned: was.outcome,
|
|
6727
|
+
applied: p.outcome
|
|
6728
|
+
});
|
|
6729
|
+
}
|
|
6730
|
+
}
|
|
6731
|
+
differed.sort((a, b) => a.row - b.row);
|
|
6732
|
+
if (differed.length > 0) {
|
|
6733
|
+
try {
|
|
6734
|
+
await setImportCounts(db, id, counts2, suppressionsAdded);
|
|
6735
|
+
} catch (err) {
|
|
6736
|
+
log.error("import.incomplete", { importId: id, step: "counts", error: errorText(err) });
|
|
6737
|
+
}
|
|
6738
|
+
}
|
|
6739
|
+
return {
|
|
6740
|
+
import: {
|
|
6741
|
+
id,
|
|
6742
|
+
created_at: now,
|
|
6743
|
+
principal: { kind: writes.record.principal_kind, email: writes.record.principal_email },
|
|
6744
|
+
attestation_version: writes.record.attestation_version,
|
|
6745
|
+
file_name: writes.record.file_name,
|
|
6746
|
+
file_sha256: writes.record.file_sha256,
|
|
6747
|
+
plan_hash: writes.record.plan_hash,
|
|
6748
|
+
counts: counts2,
|
|
6749
|
+
suppressions_added: suppressionsAdded
|
|
6750
|
+
},
|
|
6751
|
+
counts: counts2,
|
|
6752
|
+
suppressions_added: suppressionsAdded,
|
|
6753
|
+
rows: notConfirmedRows(actual, plan2.merged),
|
|
6754
|
+
differed,
|
|
6755
|
+
replayed: false
|
|
5281
6756
|
};
|
|
5282
|
-
return json(body, 200, revisionHeaders(post));
|
|
5283
6757
|
}
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
6758
|
+
function planStale() {
|
|
6759
|
+
return refusal(
|
|
6760
|
+
409,
|
|
6761
|
+
"plan_stale",
|
|
6762
|
+
"The import would no longer do what its plan said: the file, its declared meaning, whether an attestation is needed, or the scheduled sends it would grow have changed, or another import was committed since. Plan it again and review the new plan."
|
|
6763
|
+
);
|
|
6764
|
+
}
|
|
6765
|
+
|
|
6766
|
+
// src/routes/imports.ts
|
|
6767
|
+
function format(c) {
|
|
6768
|
+
const { maxRows, reason } = maxImportRows(c.config.subrequestBudget);
|
|
6769
|
+
return json(describeFormat(maxRows, reason));
|
|
6770
|
+
}
|
|
6771
|
+
function download(body, type, filename) {
|
|
6772
|
+
return new Response(body, {
|
|
6773
|
+
headers: {
|
|
6774
|
+
"content-type": `${type}; charset=utf-8`,
|
|
6775
|
+
"content-disposition": `attachment; filename="${filename}"`
|
|
6776
|
+
}
|
|
6777
|
+
});
|
|
6778
|
+
}
|
|
6779
|
+
function templateCsv() {
|
|
6780
|
+
return download(TEMPLATE_CSV, "text/csv", "kestrel-subscribers.csv");
|
|
6781
|
+
}
|
|
6782
|
+
function templateTxt() {
|
|
6783
|
+
return download(TEMPLATE_TXT, "text/plain", "email-list.txt");
|
|
6784
|
+
}
|
|
6785
|
+
function refuseOversize(c, bytes) {
|
|
6786
|
+
const declared = Number(c.req.headers.get("content-length") ?? "0");
|
|
6787
|
+
if ((bytes ?? declared) > IMPORT_LIMITS.maxBodyBytes) {
|
|
6788
|
+
throw new HttpError(
|
|
6789
|
+
413,
|
|
6790
|
+
"body_too_large",
|
|
6791
|
+
`The request is over ${IMPORT_LIMITS.maxBodyBytes / (1024 * 1024)} MB. Split the file into smaller imports.`
|
|
6792
|
+
);
|
|
5307
6793
|
}
|
|
5308
|
-
const { post: updated, revision } = await updatePost(c.env.DB, post, body.input, author(c));
|
|
5309
|
-
const saved = { post: updated, revision_id: revision.id };
|
|
5310
|
-
return json(saved, 200, revisionHeaders(updated));
|
|
5311
6794
|
}
|
|
5312
|
-
|
|
5313
|
-
const
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
6795
|
+
function readInput(body) {
|
|
6796
|
+
const file = optObject(body, "file");
|
|
6797
|
+
if (!file) {
|
|
6798
|
+
throw fieldError("file", "file is required, as { name, text }");
|
|
6799
|
+
}
|
|
6800
|
+
const name = optString(file, "name", "file");
|
|
6801
|
+
if (!name) {
|
|
6802
|
+
throw fieldError("file.name", "file.name is required: the file\u2019s name, ending .csv or .txt");
|
|
6803
|
+
}
|
|
6804
|
+
const text = optString(file, "text", "file");
|
|
6805
|
+
if (text === void 0) {
|
|
6806
|
+
throw fieldError("file.text", "file.text is required: the file\u2019s contents as UTF-8 text");
|
|
6807
|
+
}
|
|
6808
|
+
const declared = body.plain_list_status === void 0 ? null : oneOf(body, "plain_list_status", PLAIN_LIST_STATUSES);
|
|
6809
|
+
return {
|
|
6810
|
+
name,
|
|
6811
|
+
text,
|
|
6812
|
+
plainListStatus: declared ?? (fileKindOf(name) === "txt" ? "subscribed" : null)
|
|
6813
|
+
};
|
|
5318
6814
|
}
|
|
5319
|
-
async function
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
throw notFound("post");
|
|
6815
|
+
async function plan(c, input) {
|
|
6816
|
+
if (new TextEncoder().encode(input.text).length > IMPORT_LIMITS.maxBodyBytes) {
|
|
6817
|
+
refuseOversize(c, IMPORT_LIMITS.maxBodyBytes + 1);
|
|
5323
6818
|
}
|
|
5324
|
-
const
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
is_current: r.id === post.current_revision,
|
|
5332
|
-
metadata: JSON.parse(r.metadata)
|
|
5333
|
-
}))
|
|
6819
|
+
const { maxRows, reason } = maxImportRows(c.config.subrequestBudget);
|
|
6820
|
+
const settings = await getSettings(c.env.DB);
|
|
6821
|
+
return planImport(c.env.DB, input, {
|
|
6822
|
+
maxRows,
|
|
6823
|
+
maxRowsReason: reason,
|
|
6824
|
+
publication: settings.publication.name,
|
|
6825
|
+
freePlan: onFreePlanLimits(c.config)
|
|
5334
6826
|
});
|
|
5335
6827
|
}
|
|
5336
|
-
async function
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
6828
|
+
async function planRoute(c) {
|
|
6829
|
+
refuseOversize(c);
|
|
6830
|
+
const input = readInput(await readJsonObject(c));
|
|
6831
|
+
return json(planResponse(await plan(c, input)));
|
|
6832
|
+
}
|
|
6833
|
+
async function commit(c) {
|
|
6834
|
+
refuseOversize(c);
|
|
6835
|
+
const body = await readJsonObject(c);
|
|
6836
|
+
const input = readInput(body);
|
|
6837
|
+
const planHash = optString(body, "plan_hash");
|
|
6838
|
+
if (!planHash) {
|
|
6839
|
+
throw fieldError("plan_hash", "plan_hash is required: the hash of the plan being committed");
|
|
5340
6840
|
}
|
|
5341
|
-
const
|
|
5342
|
-
if (
|
|
5343
|
-
throw
|
|
6841
|
+
const attestation = optObject(body, "attestation");
|
|
6842
|
+
if (attestation && (typeof attestation.version !== "number" || attestation.accepted !== true)) {
|
|
6843
|
+
throw fieldError(
|
|
6844
|
+
"attestation",
|
|
6845
|
+
"attestation must be { version, accepted: true }, with the version the plan returned"
|
|
6846
|
+
);
|
|
5344
6847
|
}
|
|
5345
|
-
const
|
|
5346
|
-
|
|
5347
|
-
|
|
6848
|
+
const acknowledged = new Set(optStringList(body, "acknowledge_sends") ?? []);
|
|
6849
|
+
const principal = c.principal;
|
|
6850
|
+
if (!principal) {
|
|
6851
|
+
throw badRequest("no principal");
|
|
5348
6852
|
}
|
|
5349
|
-
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
6853
|
+
const done = await getImportByPlanHash(c.env.DB, planHash);
|
|
6854
|
+
if (done && done.file_sha256 === await fileSha256(input.text)) {
|
|
6855
|
+
return json(replayResponse(done));
|
|
6856
|
+
}
|
|
6857
|
+
const p = await plan(c, input);
|
|
6858
|
+
if (p.planHash !== planHash) {
|
|
6859
|
+
throw planStale();
|
|
6860
|
+
}
|
|
6861
|
+
if (p.attestation && !(attestation?.accepted === true && attestation.version === p.attestation.version)) {
|
|
6862
|
+
throw refusal(
|
|
6863
|
+
409,
|
|
6864
|
+
"attestation_required",
|
|
6865
|
+
`This import confirms ${p.counts.create_confirmed + p.counts.confirm_pending} subscribers, so it needs the publisher\u2019s attestation: "${p.attestation.text}" Send attestation: { version: ${ATTESTATION.version}, accepted: true } once they have given it.`,
|
|
6866
|
+
{ attestation: p.attestation }
|
|
6867
|
+
);
|
|
6868
|
+
}
|
|
6869
|
+
if (p.sendsGrowing.length > 0) {
|
|
6870
|
+
if (principal.kind === "service") {
|
|
6871
|
+
throw refusal(
|
|
6872
|
+
409,
|
|
6873
|
+
"sends_acknowledgement_required",
|
|
6874
|
+
"This import would add people to the audience of a scheduled send, which only a person can acknowledge. Ask the publisher to finish it in the dashboard (Subscribers, Import), choosing the same file: the same plan appears there.",
|
|
6875
|
+
{ sends: p.sendsGrowing }
|
|
6876
|
+
);
|
|
6877
|
+
}
|
|
6878
|
+
const missing = p.sendsGrowing.filter((s) => !acknowledged.has(s.id));
|
|
6879
|
+
if (missing.length > 0) {
|
|
6880
|
+
throw refusal(
|
|
6881
|
+
409,
|
|
6882
|
+
"sends_acknowledgement_required",
|
|
6883
|
+
`This import would add ${p.sendsGrowing[0]?.gains ?? 0} people to the audience of ${missing.length === 1 ? "a scheduled send" : `${missing.length} scheduled sends`} not yet acknowledged. Acknowledge each one by its id in acknowledge_sends.`,
|
|
6884
|
+
{ sends: missing }
|
|
6885
|
+
);
|
|
6886
|
+
}
|
|
6887
|
+
}
|
|
6888
|
+
const result = await commitImport(c.env.DB, p, principal, Date.now());
|
|
6889
|
+
if (result.replayed) {
|
|
6890
|
+
return json(result);
|
|
6891
|
+
}
|
|
6892
|
+
log.info("import.committed", {
|
|
6893
|
+
importId: result.import.id,
|
|
6894
|
+
principal: principal.kind,
|
|
6895
|
+
rows: p.rowCount,
|
|
6896
|
+
confirmed: result.counts.create_confirmed + result.counts.confirm_pending,
|
|
6897
|
+
unsubscribed: result.counts.mark_unsubscribed + result.counts.create_unsubscribed,
|
|
6898
|
+
suppressed: result.suppressions_added,
|
|
6899
|
+
differed: result.differed.length
|
|
5356
6900
|
});
|
|
6901
|
+
return json(result, 201);
|
|
6902
|
+
}
|
|
6903
|
+
var STATUS_CLAIM = {
|
|
6904
|
+
confirmed: "subscribed",
|
|
6905
|
+
pending: "pending",
|
|
6906
|
+
unsubscribed: "unsubscribed"
|
|
6907
|
+
};
|
|
6908
|
+
var SUPPRESSION_CLAIM = {
|
|
6909
|
+
bounce: "bounce",
|
|
6910
|
+
import_bounce: "bounce",
|
|
6911
|
+
complaint: "complaint",
|
|
6912
|
+
import_complaint: "complaint",
|
|
6913
|
+
manual: "manual",
|
|
6914
|
+
erased: "erased"
|
|
6915
|
+
};
|
|
6916
|
+
var iso = (ms) => ms === null ? null : new Date(ms).toISOString();
|
|
6917
|
+
function encodeExportCursor(cursor) {
|
|
6918
|
+
const bytes = new TextEncoder().encode(JSON.stringify({ v: 1, ...cursor }));
|
|
6919
|
+
let binary = "";
|
|
6920
|
+
for (const byte of bytes) {
|
|
6921
|
+
binary += String.fromCharCode(byte);
|
|
6922
|
+
}
|
|
6923
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
6924
|
+
}
|
|
6925
|
+
function decodeExportCursor(raw) {
|
|
6926
|
+
try {
|
|
6927
|
+
const binary = atob(raw.replace(/-/g, "+").replace(/_/g, "/"));
|
|
6928
|
+
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
|
6929
|
+
const value = JSON.parse(
|
|
6930
|
+
new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes)
|
|
6931
|
+
);
|
|
6932
|
+
if (typeof value !== "object" || value === null) {
|
|
6933
|
+
return null;
|
|
6934
|
+
}
|
|
6935
|
+
const { v: v2, after, at } = value;
|
|
6936
|
+
return v2 === 1 && typeof after === "string" && after !== "" && Number.isSafeInteger(at) ? { after, at } : null;
|
|
6937
|
+
} catch {
|
|
6938
|
+
return null;
|
|
6939
|
+
}
|
|
6940
|
+
}
|
|
6941
|
+
async function exportList(c) {
|
|
6942
|
+
const raw = c.url.searchParams.get("after");
|
|
6943
|
+
const cursor = raw === null ? null : decodeExportCursor(raw);
|
|
6944
|
+
if (raw !== null && cursor === null) {
|
|
6945
|
+
throw fieldError("after", "after must be the next value a page of this export returned");
|
|
6946
|
+
}
|
|
6947
|
+
const at = cursor?.at ?? Date.now();
|
|
6948
|
+
const size = exportPageRows(c.config.subrequestBudget);
|
|
6949
|
+
const read = await listForExport(c.env.DB, cursor?.after ?? null, size + 1);
|
|
6950
|
+
const page = read.slice(0, size);
|
|
6951
|
+
const last = page.at(-1);
|
|
6952
|
+
const rows = page.map((r) => ({
|
|
6953
|
+
email: r.email,
|
|
6954
|
+
status: r.status === null ? "none" : STATUS_CLAIM[r.status] ?? "none",
|
|
6955
|
+
opt_in: r.opt_in ?? "unknown",
|
|
6956
|
+
origin: r.origin ?? "unknown",
|
|
6957
|
+
suppression: r.reason === null ? null : SUPPRESSION_CLAIM[r.reason] ?? "manual",
|
|
6958
|
+
subscribed_at: iso(r.created_at),
|
|
6959
|
+
confirmed_at: iso(r.confirmed_at),
|
|
6960
|
+
unsubscribed_at: iso(r.unsubscribed_at),
|
|
6961
|
+
suppressed_at: iso(r.suppressed_at),
|
|
6962
|
+
evidence: r.consent_evidence,
|
|
6963
|
+
format: IMPORT_FORMAT
|
|
6964
|
+
}));
|
|
6965
|
+
const body = {
|
|
6966
|
+
format: IMPORT_FORMAT,
|
|
6967
|
+
exported_at: at,
|
|
6968
|
+
subscribers: rows,
|
|
6969
|
+
next: read.length > size && last ? encodeExportCursor({ after: last.email, at }) : null
|
|
6970
|
+
};
|
|
6971
|
+
return json(body);
|
|
5357
6972
|
}
|
|
5358
6973
|
|
|
5359
6974
|
// src/emails/system.ts
|
|
@@ -5588,12 +7203,12 @@ function canonicalString(msg) {
|
|
|
5588
7203
|
const fields = msg;
|
|
5589
7204
|
let out = "";
|
|
5590
7205
|
for (const k of keys) {
|
|
5591
|
-
const
|
|
5592
|
-
if (
|
|
7206
|
+
const v2 = fields[k];
|
|
7207
|
+
if (v2 === void 0 || v2 === null) {
|
|
5593
7208
|
continue;
|
|
5594
7209
|
}
|
|
5595
7210
|
out += `${k}
|
|
5596
|
-
${String(
|
|
7211
|
+
${String(v2)}
|
|
5597
7212
|
`;
|
|
5598
7213
|
}
|
|
5599
7214
|
return out;
|
|
@@ -5812,6 +7427,13 @@ var ACCOUNT_ERROR_TYPES = /* @__PURE__ */ new Map([
|
|
|
5812
7427
|
["SendingPausedException", "suspended"],
|
|
5813
7428
|
["AccountSuspendedException", "suspended"],
|
|
5814
7429
|
["MailFromDomainNotVerifiedException", "sender"],
|
|
7430
|
+
// A resource the request names doesn't exist. Kestrel names only its configuration set
|
|
7431
|
+
// (SES_CONFIGURATION_SET), so this is a typo, a set deleted after setup, or the wrong
|
|
7432
|
+
// region: the deployment's to fix, and the same for every recipient, so it holds the send
|
|
7433
|
+
// rather than marking each one failed. Without a configuration set the request names no
|
|
7434
|
+
// resource that could be missing, so a 404 then isn't expected, and the advice still points
|
|
7435
|
+
// at the one setting that would cause it.
|
|
7436
|
+
["NotFoundException", "configuration"],
|
|
5815
7437
|
// The credentials themselves: unknown, wrongly signed, expired, or not permitted (a key
|
|
5816
7438
|
// whose policy lacks `ses:SendBulkEmail` among them).
|
|
5817
7439
|
["UnrecognizedClientException", "credentials"],
|
|
@@ -5822,7 +7444,7 @@ var ACCOUNT_ERROR_TYPES = /* @__PURE__ */ new Map([
|
|
|
5822
7444
|
]);
|
|
5823
7445
|
function classifySesError(status, type, message2 = "", senderUnverified = false) {
|
|
5824
7446
|
const throttled = status === 429 || /throttl|toomany/i.test(type);
|
|
5825
|
-
const cause = ACCOUNT_ERROR_TYPES.get(type) ?? (senderUnverified ? "sender" : throttled && /daily message quota/i.test(message2) ? "quota" : void 0);
|
|
7447
|
+
const cause = ACCOUNT_ERROR_TYPES.get(type) ?? (status === 404 ? "configuration" : void 0) ?? (senderUnverified ? "sender" : throttled && /daily message quota/i.test(message2) ? "quota" : void 0);
|
|
5826
7448
|
if (cause || status === 401 || status === 403) {
|
|
5827
7449
|
return { reason: "account", cause: cause ?? "credentials" };
|
|
5828
7450
|
}
|
|
@@ -5848,11 +7470,11 @@ var ENTRY_HALTS = /* @__PURE__ */ new Map([
|
|
|
5848
7470
|
["CONFIGURATION_SET_SENDING_PAUSED", account("suspended")],
|
|
5849
7471
|
["MAIL_FROM_DOMAIN_NOT_VERIFIED", account("sender")],
|
|
5850
7472
|
["ACCOUNT_DAILY_QUOTA_EXCEEDED", account("quota")],
|
|
5851
|
-
["CONFIGURATION_SET_DOES_NOT_EXIST", account("
|
|
5852
|
-
["CONFIGURATION_SET_NOT_FOUND", account("
|
|
5853
|
-
["INVALID_SENDING_POOL_NAME", account("
|
|
5854
|
-
["TEMPLATE_DOES_NOT_EXIST", account("
|
|
5855
|
-
["TEMPLATE_NOT_FOUND", account("
|
|
7473
|
+
["CONFIGURATION_SET_DOES_NOT_EXIST", account("configuration")],
|
|
7474
|
+
["CONFIGURATION_SET_NOT_FOUND", account("configuration")],
|
|
7475
|
+
["INVALID_SENDING_POOL_NAME", account("configuration")],
|
|
7476
|
+
["TEMPLATE_DOES_NOT_EXIST", account("configuration")],
|
|
7477
|
+
["TEMPLATE_NOT_FOUND", account("configuration")],
|
|
5856
7478
|
["ACCOUNT_THROTTLED", unavailable("rate_limit")],
|
|
5857
7479
|
["TRANSIENT_FAILURE", unavailable("outage")]
|
|
5858
7480
|
]);
|
|
@@ -6720,8 +8342,8 @@ async function confirmLinkState(db, token2) {
|
|
|
6720
8342
|
}
|
|
6721
8343
|
return linkIsLive(row) ? { kind: "ready", subscriber: row } : { kind: "expired", subscriber: row };
|
|
6722
8344
|
}
|
|
6723
|
-
async function confirmSubscription2(db, token2) {
|
|
6724
|
-
const row = token2 ? await confirm(db, token2, Date.now() - CONFIRM_LINK_TTL_MS) : null;
|
|
8345
|
+
async function confirmSubscription2(db, token2, ip) {
|
|
8346
|
+
const row = token2 ? await confirm(db, token2, Date.now() - CONFIRM_LINK_TTL_MS, ip) : null;
|
|
6725
8347
|
if (row?.status === "confirmed") {
|
|
6726
8348
|
return { kind: "confirmed", subscriber: row };
|
|
6727
8349
|
}
|
|
@@ -6749,8 +8371,8 @@ async function readEmail(c) {
|
|
|
6749
8371
|
}
|
|
6750
8372
|
if (ct.includes("form")) {
|
|
6751
8373
|
const f = await c.req.formData();
|
|
6752
|
-
const
|
|
6753
|
-
return typeof
|
|
8374
|
+
const v2 = f.get("email");
|
|
8375
|
+
return typeof v2 === "string" ? v2 : null;
|
|
6754
8376
|
}
|
|
6755
8377
|
return c.url.searchParams.get("email");
|
|
6756
8378
|
}
|
|
@@ -6763,9 +8385,9 @@ async function readToken(c) {
|
|
|
6763
8385
|
if (ct.includes("form")) {
|
|
6764
8386
|
try {
|
|
6765
8387
|
const f = await c.req.formData();
|
|
6766
|
-
const
|
|
6767
|
-
if (typeof
|
|
6768
|
-
return
|
|
8388
|
+
const v2 = f.get("token");
|
|
8389
|
+
if (typeof v2 === "string") {
|
|
8390
|
+
return v2;
|
|
6769
8391
|
}
|
|
6770
8392
|
} catch {
|
|
6771
8393
|
}
|
|
@@ -6848,7 +8470,8 @@ async function confirmLanding(c) {
|
|
|
6848
8470
|
}
|
|
6849
8471
|
async function confirm2(c) {
|
|
6850
8472
|
const token2 = await readToken(c);
|
|
6851
|
-
|
|
8473
|
+
const ip = c.req.headers.get("cf-connecting-ip");
|
|
8474
|
+
return confirmStatePage(c, await confirmSubscription2(c.env.DB, token2, ip), token2);
|
|
6852
8475
|
}
|
|
6853
8476
|
async function confirmStatePage(c, state, token2) {
|
|
6854
8477
|
const eyebrow = "Newsletter";
|
|
@@ -7068,7 +8691,7 @@ async function loadRenderInput(c) {
|
|
|
7068
8691
|
}
|
|
7069
8692
|
const revision = await getCurrentRevision(c.env.DB, post);
|
|
7070
8693
|
if (!revision) {
|
|
7071
|
-
throw badRequest("post has no content yet");
|
|
8694
|
+
throw badRequest("This post has no content yet. Write something, then try again.");
|
|
7072
8695
|
}
|
|
7073
8696
|
const imgs = await listImages(c.env.DB, post.id);
|
|
7074
8697
|
return { post, revision, images: imgs };
|
|
@@ -7131,9 +8754,13 @@ async function webPage(c) {
|
|
|
7131
8754
|
);
|
|
7132
8755
|
}
|
|
7133
8756
|
async function test(c) {
|
|
7134
|
-
const
|
|
8757
|
+
const raw = optString(await readJsonObject(c), "to") ?? "";
|
|
8758
|
+
const to = normalizeEmail(raw);
|
|
7135
8759
|
if (!isValidEmail(to)) {
|
|
7136
|
-
throw fieldError(
|
|
8760
|
+
throw fieldError(
|
|
8761
|
+
"to",
|
|
8762
|
+
to ? `\u201C${raw.trim()}\u201D isn\u2019t a valid email address. Fix it, then send again.` : "Enter the email address to send the test to."
|
|
8763
|
+
);
|
|
7137
8764
|
}
|
|
7138
8765
|
requireConnectedProvider(c.config);
|
|
7139
8766
|
const readAt = Date.now();
|
|
@@ -7215,14 +8842,14 @@ function resolveTestRecipients(body, defaults) {
|
|
|
7215
8842
|
const out = [];
|
|
7216
8843
|
for (const item of input) {
|
|
7217
8844
|
if (typeof item !== "string") {
|
|
7218
|
-
throw fieldError("to", "
|
|
8845
|
+
throw fieldError("to", "Each recipient must be an email address.");
|
|
7219
8846
|
}
|
|
7220
8847
|
const email2 = normalizeEmail(item);
|
|
7221
8848
|
if (!email2) {
|
|
7222
8849
|
continue;
|
|
7223
8850
|
}
|
|
7224
8851
|
if (!isValidEmail(email2)) {
|
|
7225
|
-
throw
|
|
8852
|
+
throw fieldError("to", `\u201C${item.trim()}\u201D isn\u2019t a valid email address. Fix or remove it.`);
|
|
7226
8853
|
}
|
|
7227
8854
|
if (seen.has(email2)) {
|
|
7228
8855
|
continue;
|
|
@@ -7237,12 +8864,16 @@ async function templateTest(c) {
|
|
|
7237
8864
|
const body = await readJsonObject(c, { optional: true });
|
|
7238
8865
|
const recipients = resolveTestRecipients(body, settings.testRecipients);
|
|
7239
8866
|
if (recipients.length === 0) {
|
|
7240
|
-
throw
|
|
7241
|
-
"
|
|
8867
|
+
throw fieldError(
|
|
8868
|
+
"to",
|
|
8869
|
+
"There\u2019s no one to send the test to. Enter an address, or set default test recipients in Settings."
|
|
7242
8870
|
);
|
|
7243
8871
|
}
|
|
7244
8872
|
if (recipients.length > MAX_TEMPLATE_TEST_RECIPIENTS) {
|
|
7245
|
-
throw
|
|
8873
|
+
throw fieldError(
|
|
8874
|
+
"to",
|
|
8875
|
+
`A test can go to at most ${MAX_TEMPLATE_TEST_RECIPIENTS} addresses. Remove some, then send again.`
|
|
8876
|
+
);
|
|
7246
8877
|
}
|
|
7247
8878
|
requireConnectedProvider(c.config);
|
|
7248
8879
|
const result = await render(sampleRenderInput(), c.config, resolveBranding(settings, c.config));
|
|
@@ -7398,15 +9029,15 @@ function parseFireAt(input) {
|
|
|
7398
9029
|
if (input.trim() !== "" && Number.isFinite(n)) {
|
|
7399
9030
|
return n;
|
|
7400
9031
|
}
|
|
7401
|
-
const
|
|
7402
|
-
if (!Number.isNaN(
|
|
9032
|
+
const iso2 = Date.parse(input);
|
|
9033
|
+
if (!Number.isNaN(iso2)) {
|
|
7403
9034
|
if (!ISO_WITH_OFFSET.test(input.trim())) {
|
|
7404
9035
|
throw fieldError(
|
|
7405
9036
|
"fire_at",
|
|
7406
9037
|
"fire_at must name its timezone: an ISO-8601 timestamp ending in Z or an offset like +02:00, or epoch milliseconds"
|
|
7407
9038
|
);
|
|
7408
9039
|
}
|
|
7409
|
-
return
|
|
9040
|
+
return iso2;
|
|
7410
9041
|
}
|
|
7411
9042
|
}
|
|
7412
9043
|
throw fieldError(
|
|
@@ -7414,9 +9045,8 @@ function parseFireAt(input) {
|
|
|
7414
9045
|
"fire_at must be an ISO-8601 timestamp with a Z or \xB1hh:mm offset, or epoch milliseconds"
|
|
7415
9046
|
);
|
|
7416
9047
|
}
|
|
7417
|
-
function parseFutureFireAt(input, minLeadMs
|
|
7418
|
-
|
|
7419
|
-
return acceptFireAt(parseFireAt(input), minLeadMs, { hint });
|
|
9048
|
+
function parseFutureFireAt(input, minLeadMs) {
|
|
9049
|
+
return acceptFireAt(parseFireAt(input), minLeadMs);
|
|
7420
9050
|
}
|
|
7421
9051
|
function rejectStrayTemplateChoice(body) {
|
|
7422
9052
|
if (body && typeof body === "object" && "template_revision" in body) {
|
|
@@ -7434,7 +9064,7 @@ async function schedule(c) {
|
|
|
7434
9064
|
const body = await readJsonObject(c);
|
|
7435
9065
|
rejectStrayTemplateChoice(body);
|
|
7436
9066
|
requireConnectedProvider(c.config);
|
|
7437
|
-
const fireAt = parseFutureFireAt(body.fire_at, c.config.minLeadMs
|
|
9067
|
+
const fireAt = parseFutureFireAt(body.fire_at, c.config.minLeadMs);
|
|
7438
9068
|
const send = await freeze(c.env, c.config, post, fireAt);
|
|
7439
9069
|
const frozen = unwrap(await viewWithCursor(c.env, send.id), "send");
|
|
7440
9070
|
return json(frozen, 201);
|
|
@@ -7476,34 +9106,39 @@ async function repeatAnswer(c, sendId) {
|
|
|
7476
9106
|
}
|
|
7477
9107
|
|
|
7478
9108
|
// src/send/resolve.ts
|
|
9109
|
+
var NOT_SENDING = {
|
|
9110
|
+
scheduled: "hasn\u2019t started",
|
|
9111
|
+
sent: "has finished",
|
|
9112
|
+
canceled: "was canceled"
|
|
9113
|
+
};
|
|
7479
9114
|
async function resolveStuckSend(env, sendId, outcome, actor) {
|
|
7480
9115
|
const send = await getSend(env.DB, sendId);
|
|
7481
9116
|
if (!send) {
|
|
7482
9117
|
throw notFound("send");
|
|
7483
9118
|
}
|
|
7484
|
-
const
|
|
9119
|
+
const refuse2 = async (code, message2) => refusal(409, code, message2, { send: await viewSend(env, send.id) });
|
|
7485
9120
|
const now = Date.now();
|
|
7486
9121
|
if (send.status === "sending" && send.locked_until !== null && send.locked_until > now) {
|
|
7487
|
-
throw await
|
|
9122
|
+
throw await refuse2(
|
|
7488
9123
|
"run_in_progress",
|
|
7489
|
-
"
|
|
9124
|
+
"This send is being worked on right now. Try Resolve again in a moment."
|
|
7490
9125
|
);
|
|
7491
9126
|
}
|
|
7492
9127
|
if (!isWedged(send)) {
|
|
7493
|
-
throw await
|
|
9128
|
+
throw await refuse2(
|
|
7494
9129
|
"not_wedged",
|
|
7495
|
-
send.status === "sending" ? "send
|
|
9130
|
+
send.status === "sending" ? "Nothing on this send needs resolving: it still has recipients to hand off, or a run has yet to look at those in flight." : `This send ${NOT_SENDING[send.status]}, so there\u2019s nothing to resolve.`
|
|
7496
9131
|
);
|
|
7497
9132
|
}
|
|
7498
9133
|
const lease = await acquireLease(env.DB, sendId, now, LEASE_TTL_MS);
|
|
7499
9134
|
if (!lease) {
|
|
7500
9135
|
const current2 = await getSend(env.DB, sendId);
|
|
7501
9136
|
if (!current2 || !isWedged(current2)) {
|
|
7502
|
-
throw await
|
|
9137
|
+
throw await refuse2("not_wedged", "Nothing on this send needs resolving any more.");
|
|
7503
9138
|
}
|
|
7504
|
-
throw await
|
|
9139
|
+
throw await refuse2(
|
|
7505
9140
|
"run_in_progress",
|
|
7506
|
-
"
|
|
9141
|
+
"This send is being worked on right now. Try Resolve again in a moment."
|
|
7507
9142
|
);
|
|
7508
9143
|
}
|
|
7509
9144
|
const note = outcome === "unsent" ? `operator adjudication: assumed NOT delivered (${actor})` : `operator adjudication: assumed delivered (${actor})`;
|
|
@@ -7580,15 +9215,15 @@ async function get(c) {
|
|
|
7580
9215
|
return json(body, 200, headers2);
|
|
7581
9216
|
}
|
|
7582
9217
|
async function email(c) {
|
|
7583
|
-
const
|
|
7584
|
-
if (
|
|
9218
|
+
const format2 = c.url.searchParams.get("format") ?? "html";
|
|
9219
|
+
if (format2 !== "html" && format2 !== "text") {
|
|
7585
9220
|
throw badRequest("format must be html or text", { field: "format" });
|
|
7586
9221
|
}
|
|
7587
9222
|
const send = await getSend(c.env.DB, param(c, "id"));
|
|
7588
9223
|
if (!send) {
|
|
7589
9224
|
throw notFound("send");
|
|
7590
9225
|
}
|
|
7591
|
-
return
|
|
9226
|
+
return format2 === "html" ? new Response(send.rendered_html, {
|
|
7592
9227
|
headers: { ...POST_PAGE_SECURITY_HEADERS, "content-type": "text/html; charset=utf-8" }
|
|
7593
9228
|
}) : new Response(send.rendered_text, {
|
|
7594
9229
|
headers: {
|
|
@@ -7649,7 +9284,11 @@ async function feed(c) {
|
|
|
7649
9284
|
now
|
|
7650
9285
|
);
|
|
7651
9286
|
const conditions = read.open.flatMap(
|
|
7652
|
-
(s) => sendConditions(s, now).map((cond) => ({
|
|
9287
|
+
(s) => sendConditions(s, now, onFreePlanLimits(c.config)).map((cond) => ({
|
|
9288
|
+
...cond,
|
|
9289
|
+
send_id: s.id,
|
|
9290
|
+
subject: s.subject
|
|
9291
|
+
}))
|
|
7653
9292
|
).sort(bySeverity);
|
|
7654
9293
|
const body = {
|
|
7655
9294
|
now,
|
|
@@ -7758,97 +9397,6 @@ async function resolve(c) {
|
|
|
7758
9397
|
return json(body);
|
|
7759
9398
|
}
|
|
7760
9399
|
|
|
7761
|
-
// src/db/notifications.ts
|
|
7762
|
-
async function recordNotifications(db, now) {
|
|
7763
|
-
await db.prepare(
|
|
7764
|
-
`INSERT OR IGNORE INTO notifications (send_id, kind, episode, created_at, updated_at)
|
|
7765
|
-
SELECT id, 'finished', 0, ?1, ?1 FROM sends
|
|
7766
|
-
WHERE status = 'sent' AND completed_at >= ?2
|
|
7767
|
-
UNION ALL
|
|
7768
|
-
SELECT id, 'refused', halted_at, ?1, ?1 FROM sends
|
|
7769
|
-
WHERE status = 'sending' AND halt_reason = 'account' AND halted_at IS NOT NULL
|
|
7770
|
-
UNION ALL
|
|
7771
|
-
SELECT id, 'missed', 0, ?1, ?1 FROM sends
|
|
7772
|
-
WHERE (status = 'scheduled' AND fire_at < ?1 - ?3)
|
|
7773
|
-
OR (status IN ('sending', 'sent') AND started_at >= ?2 AND started_at - fire_at > ?3)
|
|
7774
|
-
UNION ALL
|
|
7775
|
-
SELECT id, 'wedged', 0, ?1, ?1 FROM sends WHERE ${WEDGED_SEND}
|
|
7776
|
-
UNION ALL
|
|
7777
|
-
SELECT id, 'stuck', 0, ?1, ?1 FROM sends
|
|
7778
|
-
WHERE status = 'sending' AND started_at < ?1 - ?4
|
|
7779
|
-
AND halt_reason IS NOT 'account' AND NOT (${WEDGED_SEND})
|
|
7780
|
-
AND NOT EXISTS (SELECT 1 FROM notifications r
|
|
7781
|
-
WHERE r.send_id = sends.id AND r.kind = 'refused')`
|
|
7782
|
-
).bind(now, now - NOTIFY_HORIZON_MS, MISSED_THRESHOLD_MS, STUCK_THRESHOLD_MS).run();
|
|
7783
|
-
}
|
|
7784
|
-
async function dueNotifications(db, limit) {
|
|
7785
|
-
const { results } = await db.prepare(
|
|
7786
|
-
`SELECT n.send_id, n.kind, n.episode, n.attempts,
|
|
7787
|
-
s.subject, s.status AS send_status, s.fire_at, s.started_at, s.completed_at,
|
|
7788
|
-
s.locked_until, s.halt_reason, s.halted_at, s.halt_cause, s.halt_error, s.c_pending, s.c_in_flight, s.c_accepted,
|
|
7789
|
-
s.c_delivered, s.c_bounced, s.c_complained, s.c_skipped, s.c_unsent
|
|
7790
|
-
FROM notifications n JOIN sends s ON s.id = n.send_id
|
|
7791
|
-
WHERE n.status = 'pending'
|
|
7792
|
-
ORDER BY n.created_at ASC, n.send_id ASC, n.kind ASC
|
|
7793
|
-
LIMIT ?`
|
|
7794
|
-
).bind(limit).all();
|
|
7795
|
-
return results;
|
|
7796
|
-
}
|
|
7797
|
-
var IN_KEYS = `EXISTS (SELECT 1 FROM json_each(?) k
|
|
7798
|
-
WHERE json_extract(k.value, '$[0]') = notifications.send_id
|
|
7799
|
-
AND json_extract(k.value, '$[1]') = notifications.kind
|
|
7800
|
-
AND json_extract(k.value, '$[2]') = notifications.episode)`;
|
|
7801
|
-
async function claimNotifications(db, keys, now) {
|
|
7802
|
-
await db.prepare(
|
|
7803
|
-
`UPDATE notifications SET attempts = attempts + 1, updated_at = ?
|
|
7804
|
-
WHERE status = 'pending' AND ${IN_KEYS}`
|
|
7805
|
-
).bind(now, JSON.stringify(keys.map((k) => [k.send_id, k.kind, k.episode]))).run();
|
|
7806
|
-
}
|
|
7807
|
-
async function recordNotificationOutcome(db, key, outcome, now) {
|
|
7808
|
-
const status = outcome.status === "unsent" ? outcome.giveUp ? "failed" : "pending" : outcome.status;
|
|
7809
|
-
const error = outcome.status === "unsent" ? outcome.error : null;
|
|
7810
|
-
await db.prepare(
|
|
7811
|
-
`UPDATE notifications SET status = ?, error = ?, updated_at = ?
|
|
7812
|
-
WHERE send_id = ? AND kind = ? AND episode = ?`
|
|
7813
|
-
).bind(status, error, now, key.send_id, key.kind, key.episode).run();
|
|
7814
|
-
}
|
|
7815
|
-
async function recordNotificationTest(db, error, now) {
|
|
7816
|
-
await db.batch([
|
|
7817
|
-
db.prepare("DELETE FROM notifications WHERE kind = 'test'"),
|
|
7818
|
-
db.prepare(
|
|
7819
|
-
`INSERT INTO notifications (send_id, kind, episode, status, attempts, error, created_at, updated_at)
|
|
7820
|
-
VALUES (NULL, 'test', 0, ?, 1, ?, ?, ?)`
|
|
7821
|
-
).bind(error === null ? "sent" : "failed", error, now, now)
|
|
7822
|
-
]);
|
|
7823
|
-
}
|
|
7824
|
-
async function closeUnaddressed(db, now) {
|
|
7825
|
-
await db.prepare(
|
|
7826
|
-
"UPDATE notifications SET status = 'unaddressed', updated_at = ? WHERE status = 'pending'"
|
|
7827
|
-
).bind(now).run();
|
|
7828
|
-
}
|
|
7829
|
-
async function notificationStatus(db) {
|
|
7830
|
-
const { results } = await db.prepare(
|
|
7831
|
-
`SELECT * FROM (
|
|
7832
|
-
SELECT 'sent' AS which, n.kind, COALESCE(s.subject, '') AS subject,
|
|
7833
|
-
n.updated_at AS at, NULL AS error
|
|
7834
|
-
FROM notifications n LEFT JOIN sends s ON s.id = n.send_id
|
|
7835
|
-
WHERE n.status = 'sent' ORDER BY n.updated_at DESC LIMIT 1)
|
|
7836
|
-
UNION ALL
|
|
7837
|
-
SELECT * FROM (
|
|
7838
|
-
SELECT 'failed' AS which, n.kind, COALESCE(s.subject, '') AS subject,
|
|
7839
|
-
n.updated_at AS at, n.error
|
|
7840
|
-
FROM notifications n LEFT JOIN sends s ON s.id = n.send_id
|
|
7841
|
-
WHERE n.error IS NOT NULL AND n.status IN ('pending', 'failed')
|
|
7842
|
-
ORDER BY n.updated_at DESC LIMIT 1)`
|
|
7843
|
-
).all();
|
|
7844
|
-
const sent = results.find((r) => r.which === "sent");
|
|
7845
|
-
const failed = results.find((r) => r.which === "failed");
|
|
7846
|
-
return {
|
|
7847
|
-
lastSent: sent ? { kind: sent.kind, subject: sent.subject, at: sent.at } : null,
|
|
7848
|
-
lastFailure: failed && (!sent || failed.at > sent.at) ? { kind: failed.kind, subject: failed.subject, at: failed.at, error: failed.error ?? "" } : null
|
|
7849
|
-
};
|
|
7850
|
-
}
|
|
7851
|
-
|
|
7852
9400
|
// src/notify/channel.ts
|
|
7853
9401
|
function getNotifier(config, env) {
|
|
7854
9402
|
switch (config.notifyChannel) {
|
|
@@ -8003,6 +9551,23 @@ function composeNotification(n, config) {
|
|
|
8003
9551
|
}
|
|
8004
9552
|
}
|
|
8005
9553
|
}
|
|
9554
|
+
function composeImportNotification(n, config) {
|
|
9555
|
+
const counts2 = JSON.parse(n.counts);
|
|
9556
|
+
const c = (k) => counts2[k] ?? 0;
|
|
9557
|
+
const confirmed = c("create_confirmed") + c("confirm_pending");
|
|
9558
|
+
const unsubscribed = c("mark_unsubscribed") + c("create_unsubscribed");
|
|
9559
|
+
const notImported = c("blocked_by_suppression") + c("kept_unsubscribed") + c("kept_confirmed_newer") + c("left_out") + c("invalid");
|
|
9560
|
+
return message(
|
|
9561
|
+
`Claude imported ${confirmed} subscribers from ${n.file_name}`,
|
|
9562
|
+
[
|
|
9563
|
+
`Claude imported "${n.file_name}" into your list at ${when(n.created_at)}, through the API with its service token.`,
|
|
9564
|
+
`Confirmed as subscribers: ${confirmed}. Unsubscribed: ${unsubscribed}. Suppressions added: ${n.suppressions_added}. Not imported as subscribers (suppressed here, unsubscribed here, invalid, or with no consent claim): ${notImported}. Already on your list as the file says: ${c("unchanged")}.`,
|
|
9565
|
+
n.attestation_version === null ? "No one was confirmed, so no attestation was needed." : "Claude attested for you that everyone imported as subscribed opted in to receive your newsletter, and that the list isn\u2019t purchased, rented, or scraped. If that isn\u2019t so, remove those subscribers before your next post goes out.",
|
|
9566
|
+
"Nobody was emailed by the import."
|
|
9567
|
+
],
|
|
9568
|
+
dashboardLink(config.appOrigin, "/subscribers")
|
|
9569
|
+
);
|
|
9570
|
+
}
|
|
8006
9571
|
function sampleNotification(config) {
|
|
8007
9572
|
return message(
|
|
8008
9573
|
"Test: notifications from Kestrel",
|
|
@@ -8026,7 +9591,7 @@ function remakeRequired(sends) {
|
|
|
8026
9591
|
return new HttpError(
|
|
8027
9592
|
409,
|
|
8028
9593
|
"remake_required",
|
|
8029
|
-
`
|
|
9594
|
+
`Saving this change re-makes ${n} scheduled email${n === 1 ? "" : "s"}. Pass their ids in \`remake\` to confirm.`,
|
|
8030
9595
|
details
|
|
8031
9596
|
);
|
|
8032
9597
|
}
|
|
@@ -8036,7 +9601,7 @@ function remakeTooClose(sends, retryAfter, now) {
|
|
|
8036
9601
|
return new HttpError(
|
|
8037
9602
|
409,
|
|
8038
9603
|
"remake_too_close",
|
|
8039
|
-
|
|
9604
|
+
`\u201C${first.subject}\u201D sends in ${minutes3} minute${minutes3 === 1 ? "" : "s"}. Try again once it has sent.`,
|
|
8040
9605
|
{ retry_after: retryAfter, sends }
|
|
8041
9606
|
);
|
|
8042
9607
|
}
|
|
@@ -8143,7 +9708,10 @@ function deploymentView(cfg, principal) {
|
|
|
8143
9708
|
simulation: cfg.simulation,
|
|
8144
9709
|
// Whether `provider: "none"` is local dev's preview of not connected, so the editor
|
|
8145
9710
|
// words its way out as a restart rather than a deploy; false in every deployed env.
|
|
8146
|
-
notConnectedPreview: cfg.notConnectedPreview
|
|
9711
|
+
notConnectedPreview: cfg.notConnectedPreview,
|
|
9712
|
+
// Whether the deployment runs on Workers Free's limits, so the Schedule dialog can warn
|
|
9713
|
+
// before a send D1's daily writes may not cover, in the words the send's condition uses.
|
|
9714
|
+
freePlanLimits: onFreePlanLimits(cfg)
|
|
8147
9715
|
};
|
|
8148
9716
|
}
|
|
8149
9717
|
function publicationView(settings, cfg) {
|
|
@@ -8172,7 +9740,7 @@ function settingsView(settings, cfg) {
|
|
|
8172
9740
|
function renderedIdentityFields(settings, cfg) {
|
|
8173
9741
|
return identityFieldsInUse(resolveBranding(settings, cfg).template);
|
|
8174
9742
|
}
|
|
8175
|
-
var ADDRESS_NOT_PRINTED = "You
|
|
9743
|
+
var ADDRESS_NOT_PRINTED = "You\u2019ve added a mailing address in Settings, but this template doesn\u2019t include the {{ .Publication.Address }} field. If you send promotional email, add it to the footer. If you\u2019ve written your address into the template yourself, or don\u2019t need to provide one, you can ignore this or remove the address in Settings.";
|
|
8176
9744
|
async function inUseView(db, settings, cfg) {
|
|
8177
9745
|
const sends = await listScheduledSends(db);
|
|
8178
9746
|
return {
|
|
@@ -8194,7 +9762,9 @@ async function get2(c) {
|
|
|
8194
9762
|
async function notificationTest(c) {
|
|
8195
9763
|
const { to } = (await getSettings(c.env.DB)).notifications;
|
|
8196
9764
|
if (!to) {
|
|
8197
|
-
throw badRequest("
|
|
9765
|
+
throw badRequest("Add a notifications address first, then send a test.", {
|
|
9766
|
+
field: "notifications.to"
|
|
9767
|
+
});
|
|
8198
9768
|
}
|
|
8199
9769
|
if (c.config.notifyChannel === "provider") {
|
|
8200
9770
|
requireConnectedProvider(c.config);
|
|
@@ -8227,11 +9797,11 @@ async function update(c) {
|
|
|
8227
9797
|
const ack = readAck(body);
|
|
8228
9798
|
let warnings = [];
|
|
8229
9799
|
if (patch.emailTemplate !== void 0) {
|
|
8230
|
-
const
|
|
8231
|
-
if (
|
|
8232
|
-
throw badRequest(
|
|
9800
|
+
const v2 = validateEmailTemplate(patch.emailTemplate);
|
|
9801
|
+
if (v2.errors.length > 0) {
|
|
9802
|
+
throw badRequest(v2.errors.join(" "));
|
|
8233
9803
|
}
|
|
8234
|
-
warnings =
|
|
9804
|
+
warnings = v2.warnings;
|
|
8235
9805
|
}
|
|
8236
9806
|
const current2 = await getSettings(c.env.DB);
|
|
8237
9807
|
try {
|
|
@@ -8270,14 +9840,14 @@ async function uploadLogo(c) {
|
|
|
8270
9840
|
}
|
|
8271
9841
|
const type = mediaType(file.type);
|
|
8272
9842
|
if (!RASTER_IMAGE_TYPES.includes(type)) {
|
|
8273
|
-
throw badRequest("The logo must be a PNG, JPEG, WebP, or GIF. Most email apps don
|
|
9843
|
+
throw badRequest("The logo must be a PNG, JPEG, WebP, or GIF. Most email apps don\u2019t show SVG.");
|
|
8274
9844
|
}
|
|
8275
9845
|
const bytes = await file.arrayBuffer();
|
|
8276
9846
|
if (bytes.byteLength === 0) {
|
|
8277
|
-
throw badRequest("
|
|
9847
|
+
throw badRequest("The logo file is empty.");
|
|
8278
9848
|
}
|
|
8279
9849
|
if (bytes.byteLength > MAX_LOGO_BYTES) {
|
|
8280
|
-
throw badRequest("logo must be 512 KB or smaller");
|
|
9850
|
+
throw badRequest("The logo must be 512 KB or smaller.");
|
|
8281
9851
|
}
|
|
8282
9852
|
const version = Date.now();
|
|
8283
9853
|
const key = newLogoKey(version);
|
|
@@ -8319,9 +9889,9 @@ function readPatch(o) {
|
|
|
8319
9889
|
if (p !== void 0) {
|
|
8320
9890
|
const pub = {};
|
|
8321
9891
|
for (const k of ["name", "tagline", "address"]) {
|
|
8322
|
-
const
|
|
8323
|
-
if (
|
|
8324
|
-
pub[k] =
|
|
9892
|
+
const v2 = optString(p, k, "publication");
|
|
9893
|
+
if (v2 !== void 0) {
|
|
9894
|
+
pub[k] = v2;
|
|
8325
9895
|
}
|
|
8326
9896
|
}
|
|
8327
9897
|
patch.publication = pub;
|
|
@@ -8334,9 +9904,9 @@ function readPatch(o) {
|
|
|
8334
9904
|
if (ceBody !== void 0) {
|
|
8335
9905
|
const ce = {};
|
|
8336
9906
|
for (const k of ["subject", "body", "buttonLabel", "reassurance"]) {
|
|
8337
|
-
const
|
|
8338
|
-
if (
|
|
8339
|
-
ce[k] =
|
|
9907
|
+
const v2 = optString(ceBody, k, "confirmationEmail");
|
|
9908
|
+
if (v2 !== void 0) {
|
|
9909
|
+
ce[k] = v2;
|
|
8340
9910
|
}
|
|
8341
9911
|
}
|
|
8342
9912
|
patch.confirmationEmail = ce;
|
|
@@ -8354,7 +9924,7 @@ async function create(c) {
|
|
|
8354
9924
|
const raw = optString(await readJsonObject(c), "email");
|
|
8355
9925
|
const email2 = raw === void 0 ? "" : normalizeEmail(raw);
|
|
8356
9926
|
if (!email2 || !isValidEmail(email2)) {
|
|
8357
|
-
throw fieldError("email", "a valid email
|
|
9927
|
+
throw fieldError("email", "Enter a valid email address.");
|
|
8358
9928
|
}
|
|
8359
9929
|
const outcome = await requestSubscription(c, email2);
|
|
8360
9930
|
if (outcome.kind === "failed") {
|
|
@@ -8494,7 +10064,7 @@ function createRouter({
|
|
|
8494
10064
|
}) {
|
|
8495
10065
|
const devOnly = (...defs) => devMode ? defs : [];
|
|
8496
10066
|
const lead = `${formatLead(minLeadMs)} on this deployment (\`deployment.minLeadMs\` in \`GET /api/settings\`); a deployment sets it with \`MIN_LEAD_SECONDS\`, never below ${formatLead(MIN_LEAD_FLOOR_MS)}`;
|
|
8497
|
-
const conditionsText = `\`conditions\` is what is wrong with the send, or worth knowing, now (SPEC \xA78, \xA712), derived by the server from the send and the same on every route, most severe first: \`missed\` (still scheduled ${MISSED_THRESHOLD_MS / 6e4} minutes past its fire time: the sweep is not running), \`stuck\` (still sending ${STUCK_THRESHOLD_MS / 6e4} minutes after it started, whatever the cause), \`wedged\` (\`count\` recipients whose delivery is unknown, settled by Resolve), \`refused\` (the provider refusing the account, with its \`cause\`, its own words as \`error\`, the \`advice\` for the fix, which is outside this API, and \`retry_at\`; the send resumes on its own once the account is fixed, having consumed no one), \`provider_unavailable\` (an outage or a rate limit, retried on its own at \`retry_at\`), \`bounce_spike\` (a send finished within ${BOUNCE_SPIKE_RECENT_MS / 864e5} days whose confirmed bounces reached ${BOUNCE_SPIKE_RATE * 100}% of its audience at fire, at least ${BOUNCE_SPIKE_MIN}; \`bounced\` and \`rate\`),
|
|
10067
|
+
const conditionsText = `\`conditions\` is what is wrong with the send, or worth knowing, now (SPEC \xA78, \xA712), derived by the server from the send and the same on every route, most severe first: \`missed\` (still scheduled ${MISSED_THRESHOLD_MS / 6e4} minutes past its fire time: the sweep is not running), \`stuck\` (still sending ${STUCK_THRESHOLD_MS / 6e4} minutes after it started, whatever the cause), \`wedged\` (\`count\` recipients whose delivery is unknown, settled by Resolve), \`refused\` (the provider refusing the account, with its \`cause\`, its own words as \`error\`, the \`advice\` for the fix, which is outside this API, and \`retry_at\`; the send resumes on its own once the account is fixed, having consumed no one), \`provider_unavailable\` (an outage or a rate limit, retried on its own at \`retry_at\`), \`bounce_spike\` (a send finished within ${BOUNCE_SPIKE_RECENT_MS / 864e5} days whose confirmed bounces reached ${BOUNCE_SPIKE_RATE * 100}% of its audience at fire, at least ${BOUNCE_SPIKE_MIN}; \`bounced\` and \`rate\`), \`remade\` (a template or identity change re-made the scheduled email after its last test, \`tested_at\`; a test of the post clears it), and \`daily_writes\` (on the Workers Free plan's limits, a scheduled send to more than ${DAILY_WRITES_SEND_RECIPIENTS.toLocaleString("en-US")} recipients, which may spend D1's daily allowance of written rows and stop the instance until midnight UTC; \`recipients\`; a warning, never a refusal). Each has a \`severity\` (\`action\`: a person must act; \`warn\`: worth a look; \`info\`), \`since\` (null when the record does not say), a \`message\` to show or relay as it stands, and the \`action\` that settles it, if any. \`actions\` is exactly what the server would accept on the send now, each \`{name, method, path}\`: \`cancel\` and \`reschedule\` while it is scheduled and its fire time is still ahead, \`resolve\` while it is wedged.`;
|
|
8498
10068
|
const notConnected = "Refused with 409 `provider_not_connected` while no email provider is connected (SPEC \xA79), since nothing can be sent.";
|
|
8499
10069
|
const actionText = "An act carries no precondition on the send you read: it applies to the send as it stands, within the rules above, and the answer shows the result. An `If-Match` header is ignored. Every refusal carries the send as it stands in `send`, in the `GET /api/sends` row shape.";
|
|
8500
10070
|
const sendExample = (over = {}) => ({
|
|
@@ -8663,7 +10233,7 @@ function createRouter({
|
|
|
8663
10233
|
access: "admin",
|
|
8664
10234
|
resource: "settings",
|
|
8665
10235
|
summary: "Runtime preferences, a read-only reflection of deploy config (no secrets), and `inUse`: the scheduled sends a template or identity change would re-make.",
|
|
8666
|
-
description: "`deployment.provider` is `ses`; `fake`, a stand-in that records a send and delivers nothing (local development, a demo); or `none` while no email provider is connected (SPEC \xA79), when everything that would send is refused with 409 `provider_not_connected`. `deployment.notConnectedPreview` is true when that `none` is local development previewing the state (SPEC \xA710), whose way out is a restart rather than a deploy; it is false once deployed. `inUse.sends` is every scheduled send, soonest first; `inUse.retry_after` is the moment after which a save is no longer refused for the minimum lead (null when none is inside it); `inUse.identityFields` is the identity fields the current template renders, the only ones whose change reaches the email.",
|
|
10236
|
+
description: "`deployment.provider` is `ses`; `fake`, a stand-in that records a send and delivers nothing (local development, a demo); or `none` while no email provider is connected (SPEC \xA79), when everything that would send is refused with 409 `provider_not_connected`. `deployment.notConnectedPreview` is true when that `none` is local development previewing the state (SPEC \xA710), whose way out is a restart rather than a deploy; it is false once deployed. `deployment.freePlanLimits` is true on the Workers Free plan's limits (`SUBREQUEST_BUDGET` unset), where a scheduled send to more than 5,000 recipients carries the `daily_writes` condition. `inUse.sends` is every scheduled send, soonest first; `inUse.retry_after` is the moment after which a save is no longer refused for the minimum lead (null when none is inside it); `inUse.identityFields` is the identity fields the current template renders, the only ones whose change reaches the email.",
|
|
8667
10237
|
example: {
|
|
8668
10238
|
response: {
|
|
8669
10239
|
settings: { emailTemplate: "\u2026", publication: { name: "Marsh Lane" } },
|
|
@@ -8832,7 +10402,7 @@ function createRouter({
|
|
|
8832
10402
|
accepts: JSON_BODY,
|
|
8833
10403
|
resource: "posts",
|
|
8834
10404
|
summary: "Update a draft. Send the revision you read as `base_revision` (or If-Match): a save without one is a 428 `base_required` carrying the post as it stands, and one whose base is no longer current is a 409 `stale_revision`.",
|
|
8835
|
-
description: "Only a draft is editable; a scheduled post is soft-locked until its schedule is canceled. While the post's slug follows the subject (`slug_auto: true`), every save derives it from the subject again and numbers it if another post holds it, so take the slug from the answer. Sending `slug` sets it by hand and turns `slug_auto` off; `slug_auto: true` has it follow the subject again. Sending both is a 400 naming `slug`.",
|
|
10405
|
+
description: "Only a draft is editable; a scheduled post is soft-locked until its schedule is canceled, and a save to a post that is not a draft is a 409 `post_not_draft` (its message says whether canceling the schedule would unlock it). While the post's slug follows the subject (`slug_auto: true`), every save derives it from the subject again and numbers it if another post holds it, so take the slug from the answer. Sending `slug` sets it by hand and turns `slug_auto` off; `slug_auto: true` has it follow the subject again. Sending both is a 400 naming `slug`.",
|
|
8836
10406
|
example: {
|
|
8837
10407
|
request: {
|
|
8838
10408
|
subject: "Hello again, world",
|
|
@@ -8856,7 +10426,7 @@ function createRouter({
|
|
|
8856
10426
|
path: "/api/posts/:id",
|
|
8857
10427
|
access: "admin",
|
|
8858
10428
|
resource: "posts",
|
|
8859
|
-
summary: "Delete a draft and its revisions (drafts only).",
|
|
10429
|
+
summary: "Delete a draft and its revisions (drafts only; any other post is a 409 `post_not_draft`).",
|
|
8860
10430
|
handler: deletePost2
|
|
8861
10431
|
},
|
|
8862
10432
|
{
|
|
@@ -8882,7 +10452,7 @@ function createRouter({
|
|
|
8882
10452
|
access: "admin",
|
|
8883
10453
|
accepts: [...FORM_UPLOAD, ...RASTER_IMAGE_TYPES],
|
|
8884
10454
|
resource: "posts",
|
|
8885
|
-
summary: "Upload an image to a
|
|
10455
|
+
summary: "Upload an image to a draft (multipart form field `file`); a post that is not a draft is a 409 `post_not_draft`.",
|
|
8886
10456
|
handler: uploadImage
|
|
8887
10457
|
},
|
|
8888
10458
|
{
|
|
@@ -8898,7 +10468,7 @@ function createRouter({
|
|
|
8898
10468
|
path: "/api/posts/:id/images/:filename",
|
|
8899
10469
|
access: "admin",
|
|
8900
10470
|
resource: "posts",
|
|
8901
|
-
summary: "Delete one image from a post
|
|
10471
|
+
summary: "Delete one image from a draft; a post that is not a draft is a 409 `post_not_draft`.",
|
|
8902
10472
|
handler: deleteImage
|
|
8903
10473
|
},
|
|
8904
10474
|
// --- render: preview + test (authed); all go through the one render path ---
|
|
@@ -8985,7 +10555,7 @@ function createRouter({
|
|
|
8985
10555
|
accepts: JSON_BODY,
|
|
8986
10556
|
resource: "posts",
|
|
8987
10557
|
summary: `Freeze the render and schedule the send for a future time (at least ${formatLead(minLeadMs)} out).`,
|
|
8988
|
-
description: "Freezes the current draft, with the template and identity as they stand, onto a send row and soft-locks the post; cancelable until its fire time. A later template or identity change re-makes that frozen email after the publisher confirms it (SPEC \xA76); there is no per-send template to name, and a `template_revision` field is a 400. `fire_at` is epoch milliseconds or an ISO-8601 timestamp with a `Z` or `\xB1hh:mm` offset; a timestamp without one is a 400, since the Worker cannot know which local time was meant. `fire_at` must be at least the minimum lead out, a 400 `fire_at_too_soon` otherwise: " + lead + ". The time is then rounded up to the next whole minute (one already on the minute stays), since the sweep fires sends on the minute; the lead is checked on the time as requested, so rounding only lengthens the window. Answers with the new send's view (as `GET /api/sends/:id` describes it) and a `cursor` to follow it from. Refusals: 409 `active_send_exists` when the post already has its one active send (carried as `send`), 409 `post_not_draft`, 400 `subject_required` for an empty subject, 409 `settings_changed` when a template or identity save kept landing while it froze (try again), and 409 `provider_not_connected` while no email provider is connected (SPEC \xA79).",
|
|
10558
|
+
description: "Freezes the current draft, with the template and identity as they stand, onto a send row and soft-locks the post; cancelable until its fire time. A later template or identity change re-makes that frozen email after the publisher confirms it (SPEC \xA76); there is no per-send template to name, and a `template_revision` field is a 400. `fire_at` is epoch milliseconds or an ISO-8601 timestamp with a `Z` or `\xB1hh:mm` offset; a timestamp without one is a 400, since the Worker cannot know which local time was meant. `fire_at` must be at least the minimum lead out, a 400 `fire_at_too_soon` otherwise (for the soonest send, use Send now, `POST /api/posts/:id/send`): " + lead + ". The time is then rounded up to the next whole minute (one already on the minute stays), since the sweep fires sends on the minute; the lead is checked on the time as requested, so rounding only lengthens the window. Answers with the new send's view (as `GET /api/sends/:id` describes it) and a `cursor` to follow it from. Refusals: 409 `active_send_exists` when the post already has its one active send (carried as `send`), 409 `post_not_draft`, 400 `subject_required` for an empty subject, 409 `settings_changed` when a template or identity save kept landing while it froze (try again), and 409 `provider_not_connected` while no email provider is connected (SPEC \xA79).",
|
|
8989
10559
|
example: {
|
|
8990
10560
|
request: { fire_at: "2026-01-16T09:00:00Z" },
|
|
8991
10561
|
response: { send: scheduledExample, cursor: "57.1768467610000" }
|
|
@@ -9011,7 +10581,7 @@ function createRouter({
|
|
|
9011
10581
|
access: "admin",
|
|
9012
10582
|
resource: "sends",
|
|
9013
10583
|
summary: "List sends with delivery progress. Filter, sort, and paginate via query params; returns a `page` envelope. Each row is the send's view, the same shape `GET /api/sends/:id`, the feed, and every action answer with (see `GET /api/sends/:id`), read at the same moment, so a list row and the watch never read a send differently. A row's `rev` rises with every change a reader could see (a cancel, a move, a re-make, dispatch progress, a receipt), ordered across all sends. The response's `cursor` marks where this read stood among those changes: `<seq>.<at>`, the change sequence at the read and the server's time of it (epoch milliseconds), both decimal; pass it to `GET /api/sends/feed` as `since`. `audience.count` is a snapshot of the audience while a send is scheduled; once it fires (`audience.fixed`), it is the audience fixed at fire, which never grows.",
|
|
9014
|
-
description: "`provider.halt` describes a `sending` send whose provider refused its last batch as a whole (SPEC \xA712), and is null otherwise: its `reason`, `cause`, the provider's own words as `error`, and `since`. The send retries on its own, spaced out the longer the halt lasts: `retries` counts the halted attempts in a row and `retry_at` is when the next is due, up to an hour apart, and both reset the moment a batch is answered. `unavailable` is an outage or a rate limit, retried " + retrySchedule(HALT_BACKOFF_MS.unavailable) + ", and worth raising only once the send carries the `stuck` condition. `account` is the provider refusing the account itself, retried " + retrySchedule(HALT_BACKOFF_MS.account) + "; `cause` names what (`credentials`, `sender`, `quota`, `suspended`, or `not_connected`, a deployment with no provider connected). Either way no recipient has been consumed. The fix for `account` is outside this API (the provider's dashboard, or the deployment's secrets, which the API never exposes, SPEC \xA79), and once it lands the send resumes by itself at its next retry, so it is never rescheduled or sent again. There is no API action to retry sooner. " + conditionsText + " An unknown `status`, `failures`, `sort`, or `dir`, or a `limit` or `offset` that is not a whole number, is a 400 naming the field.",
|
|
10584
|
+
description: "`provider.halt` describes a `sending` send whose provider refused its last batch as a whole (SPEC \xA712), and is null otherwise: its `reason`, `cause`, the provider's own words as `error`, and `since`. The send retries on its own, spaced out the longer the halt lasts: `retries` counts the halted attempts in a row and `retry_at` is when the next is due, up to an hour apart, and both reset the moment a batch is answered. `unavailable` is an outage or a rate limit, retried " + retrySchedule(HALT_BACKOFF_MS.unavailable) + ", and worth raising only once the send carries the `stuck` condition. `account` is the provider refusing the account itself, retried " + retrySchedule(HALT_BACKOFF_MS.account) + "; `cause` names what (`credentials`, `configuration`, `sender`, `quota`, `suspended`, or `not_connected`, a deployment with no provider connected). Either way no recipient has been consumed. The fix for `account` is outside this API (the provider's dashboard, or the deployment's secrets, which the API never exposes, SPEC \xA79), and once it lands the send resumes by itself at its next retry, so it is never rescheduled or sent again. There is no API action to retry sooner. " + conditionsText + " An unknown `status`, `failures`, `sort`, or `dir`, or a `limit` or `offset` that is not a whole number, is a 400 naming the field.",
|
|
9015
10585
|
query: [
|
|
9016
10586
|
{
|
|
9017
10587
|
name: "status",
|
|
@@ -9285,6 +10855,44 @@ function createRouter({
|
|
|
9285
10855
|
],
|
|
9286
10856
|
handler: list3
|
|
9287
10857
|
},
|
|
10858
|
+
{
|
|
10859
|
+
// Before `/api/subscribers/:id`, which would otherwise take `export` as an id.
|
|
10860
|
+
method: "GET",
|
|
10861
|
+
path: "/api/subscribers/export",
|
|
10862
|
+
access: "admin",
|
|
10863
|
+
resource: "subscribers",
|
|
10864
|
+
summary: "The whole list in the import format (`kestrel-subscribers/1`, SPEC \xA77), a page at a time.",
|
|
10865
|
+
description: "Every subscriber (`confirmed` is written as `status: subscribed`) with how they joined, their evidence, and their suppression, plus every address suppressed with no subscriber behind it (`status: none`), an erased one as `suppression: erased`. An imported bounce or complaint is written as `bounce` or `complaint`. Times are UTC ISO-8601. Importing it into another instance carries the list over, every confirmation arriving as imported. Pages run in address order, and following `next` until it is `null` reads every address on the list for the whole export exactly once; one added or removed partway through may or may not appear. The server sets the page size: 1,000 rows on the Workers Free plan's limits (`SUBREQUEST_BUDGET` unset), 10,000 otherwise. `exported_at` is when the first page was read, the same on every page. For a CSV, write the pages' rows as the format's CSV, its header once: a value starting with `=`, `+`, `-`, or `@` carries a leading `'` so a spreadsheet won't run it as a formula, and the importer removes it.",
|
|
10866
|
+
query: [
|
|
10867
|
+
{
|
|
10868
|
+
name: "after",
|
|
10869
|
+
description: "The `next` a page returned, for the page after it; leave it out for the first. A value this server didn't issue is a 400 naming the field."
|
|
10870
|
+
}
|
|
10871
|
+
],
|
|
10872
|
+
example: {
|
|
10873
|
+
response: {
|
|
10874
|
+
format: "kestrel-subscribers/1",
|
|
10875
|
+
exported_at: 17684676e5,
|
|
10876
|
+
subscribers: [
|
|
10877
|
+
{
|
|
10878
|
+
email: "ada@example.com",
|
|
10879
|
+
status: "subscribed",
|
|
10880
|
+
opt_in: "double",
|
|
10881
|
+
origin: "kestrel",
|
|
10882
|
+
suppression: null,
|
|
10883
|
+
subscribed_at: "2026-01-02T09:30:00.000Z",
|
|
10884
|
+
confirmed_at: "2026-01-02T09:42:00.000Z",
|
|
10885
|
+
unsubscribed_at: null,
|
|
10886
|
+
suppressed_at: null,
|
|
10887
|
+
evidence: '{"confirmed_ip":"203.0.113.7"}',
|
|
10888
|
+
format: "kestrel-subscribers/1"
|
|
10889
|
+
}
|
|
10890
|
+
],
|
|
10891
|
+
next: "eyJ2IjoxLCJhZnRlciI6ImFkYUBleGFtcGxlLmNvbSIsImF0IjoxNzY4NDY3NjAwMDAwfQ"
|
|
10892
|
+
}
|
|
10893
|
+
},
|
|
10894
|
+
handler: exportList
|
|
10895
|
+
},
|
|
9288
10896
|
{
|
|
9289
10897
|
method: "GET",
|
|
9290
10898
|
path: "/api/subscribers/:id",
|
|
@@ -9301,6 +10909,109 @@ function createRouter({
|
|
|
9301
10909
|
summary: "Unsubscribe a subscriber (admin-initiated).",
|
|
9302
10910
|
handler: unsubscribe2
|
|
9303
10911
|
},
|
|
10912
|
+
// --- imports (authed admin) ---
|
|
10913
|
+
{
|
|
10914
|
+
method: "GET",
|
|
10915
|
+
path: "/api/imports/format",
|
|
10916
|
+
access: "admin",
|
|
10917
|
+
resource: "imports",
|
|
10918
|
+
summary: "Everything about the import format (SPEC \xA77), generated from the definition the importer reads with: the preferred file and why, each accepted file with when to use it and what it loses, the columns, the plain list's rules, this deployment's limits, the outcomes, and the attestation.",
|
|
10919
|
+
description: "Read it before converting a list. The full CSV (`kestrel-subscribers/1`) is preferred: it carries unsubscribes, bounces, and how each person joined, and it is what the export writes. A plain `.txt` list of addresses is the fallback for a list that is only addresses. Another service's export is converted to the format first; nothing is guessed. `limits.max_rows` is what this deployment takes, with `max_rows_reason` when it is below the format's own limit.",
|
|
10920
|
+
handler: format
|
|
10921
|
+
},
|
|
10922
|
+
{
|
|
10923
|
+
method: "GET",
|
|
10924
|
+
path: "/api/imports/template.csv",
|
|
10925
|
+
access: "admin",
|
|
10926
|
+
resource: "imports",
|
|
10927
|
+
summary: "A CSV template in the import format, showing its cases; uploaded unchanged it plans cleanly.",
|
|
10928
|
+
handler: templateCsv
|
|
10929
|
+
},
|
|
10930
|
+
{
|
|
10931
|
+
method: "GET",
|
|
10932
|
+
path: "/api/imports/template.txt",
|
|
10933
|
+
access: "admin",
|
|
10934
|
+
resource: "imports",
|
|
10935
|
+
summary: "A plain-list template: one address per line.",
|
|
10936
|
+
handler: templateTxt
|
|
10937
|
+
},
|
|
10938
|
+
{
|
|
10939
|
+
method: "POST",
|
|
10940
|
+
path: "/api/imports/plan",
|
|
10941
|
+
access: "admin",
|
|
10942
|
+
accepts: JSON_BODY,
|
|
10943
|
+
resource: "imports",
|
|
10944
|
+
summary: "Plan an import: what it would do to every row against the list as it stands. Writes nothing, so call it as often as a file needs.",
|
|
10945
|
+
description: `Body \`{ file: { name, text }, plain_list_status? }\`: the file's name (\`.csv\` for the format, \`.txt\` for a plain list) and its contents as UTF-8 text, and for a plain list what its addresses are, \`subscribed\` (the default), \`unsubscribed\`, or \`bounced\` (a 400 naming the field on a CSV, whose rows say). Answers \`counts\` (every row once, by outcome, summing to \`file.rows\`), \`suppressions_added\`, \`confirmed\` (the rows to be confirmed, by \`opt_in\` and \`origin\`), \`rows\` (each row that won't be confirmed, with its outcome), \`sends_growing\` (each send whose audience would grow, with \`gains\`: a scheduled one, or one that fired but hasn't fixed its audience yet), \`attestation\` (\`{ version, text }\` when anyone would be confirmed, else null), \`plan_hash\`, and \`daily_writes_warning\` (on the Workers Free plan's limits, the server's words when the list the import leaves holds more than ${DAILY_WRITES_IMPORT_CONFIRMED.toLocaleString("en-US")} confirmed subscribers, too many to import and send to on one day within D1's daily allowance of written rows; else null; never a refusal, and outside the hash). The outcomes and when each applies are in \`GET /api/imports/format\`. Refusals name what was wrong and what was expected: 400 \`unrecognized_format\` (not a file in the format at all, with \`row\` where it shows), 400 \`invalid_field\` (a column or value the format doesn't take, with \`row\`, \`column\`, and \`allowed\` where the values are a set), 400 \`too_many_rows\` (over this deployment's \`limit\`), and 413 \`body_too_large\` (over ${IMPORT_LIMITS.maxBodyBytes / (1024 * 1024)} MB).`,
|
|
10946
|
+
example: {
|
|
10947
|
+
request: {
|
|
10948
|
+
file: {
|
|
10949
|
+
name: "subscribers.csv",
|
|
10950
|
+
text: "email,status,opt_in,origin\r\nada@example.com,subscribed,double,sendy\r\nedsger@example.com,unsubscribed,unknown,sendy\r\n"
|
|
10951
|
+
}
|
|
10952
|
+
},
|
|
10953
|
+
response: {
|
|
10954
|
+
file: {
|
|
10955
|
+
name: "subscribers.csv",
|
|
10956
|
+
kind: "csv",
|
|
10957
|
+
rows: 2,
|
|
10958
|
+
addresses: 2,
|
|
10959
|
+
plain_list_status: null
|
|
10960
|
+
},
|
|
10961
|
+
counts: {
|
|
10962
|
+
create_confirmed: 1,
|
|
10963
|
+
confirm_pending: 0,
|
|
10964
|
+
unchanged: 0,
|
|
10965
|
+
mark_unsubscribed: 0,
|
|
10966
|
+
create_unsubscribed: 1,
|
|
10967
|
+
kept_confirmed_newer: 0,
|
|
10968
|
+
kept_unsubscribed: 0,
|
|
10969
|
+
blocked_by_suppression: 0,
|
|
10970
|
+
suppression_added: 0,
|
|
10971
|
+
left_out: 0,
|
|
10972
|
+
invalid: 0,
|
|
10973
|
+
merged: 0
|
|
10974
|
+
},
|
|
10975
|
+
suppressions_added: 0,
|
|
10976
|
+
confirmed: { opt_in: { double: 1, single: 0, unknown: 0 }, origin: { sendy: 1 } },
|
|
10977
|
+
rows: [
|
|
10978
|
+
{
|
|
10979
|
+
row: 2,
|
|
10980
|
+
email: "edsger@example.com",
|
|
10981
|
+
outcome: "create_unsubscribed",
|
|
10982
|
+
suppression_added: false
|
|
10983
|
+
}
|
|
10984
|
+
],
|
|
10985
|
+
sends_growing: [
|
|
10986
|
+
{ id: "s_xyz789", subject: "Spring migration", fire_at: 1768554e6, gains: 1 }
|
|
10987
|
+
],
|
|
10988
|
+
attestation: {
|
|
10989
|
+
version: 1,
|
|
10990
|
+
text: "Everyone I'm importing as subscribed opted in to receive Field Notes. This isn't a purchased, rented, or scraped list."
|
|
10991
|
+
},
|
|
10992
|
+
plan_hash: "9f2c\u2026"
|
|
10993
|
+
}
|
|
10994
|
+
},
|
|
10995
|
+
handler: planRoute
|
|
10996
|
+
},
|
|
10997
|
+
{
|
|
10998
|
+
method: "POST",
|
|
10999
|
+
path: "/api/imports",
|
|
11000
|
+
access: "admin",
|
|
11001
|
+
accepts: JSON_BODY,
|
|
11002
|
+
resource: "imports",
|
|
11003
|
+
summary: "Commit a plan: apply it all, or nothing, in one batch, and keep the import's record. Never sends an email.",
|
|
11004
|
+
description: "The plan's body plus `plan_hash`, `attestation: { version, accepted: true }` when the plan returned one, and `acknowledge_sends`: the id of every send in `sends_growing`. It plans again and refuses 409 `plan_stale` when the hash differs (the file, its declared meaning, whether an attestation is needed, or the growing sends changed, or another import was committed since; plan again). The list moving in between is not refused: every write re-checks the list as it stands, so a reader who unsubscribed meanwhile stays unsubscribed, and `differed` lists each row whose result differs from the commit's own plan. 409 `attestation_required` (with the `attestation` to show) when the plan confirms anyone and none was given. 409 `sends_acknowledgement_required` (with the `sends`) when a send that would grow isn't acknowledged, and always for a service token, which can't acknowledge one: the publisher finishes that import in the dashboard. A service token may attest; the record says so, and the publisher is emailed the counts. Answers 201 with the `import` record (never an address), `counts` and `suppressions_added` as applied, `rows` (those not confirmed, as applied), `differed`, and `replayed: false`. A commit repeated with the same plan and file answers 200 with the stored record and `replayed: true`, applying nothing twice. The plan route's refusals apply too.",
|
|
11005
|
+
example: {
|
|
11006
|
+
request: {
|
|
11007
|
+
file: { name: "subscribers.csv", text: "email,status,opt_in,origin\r\n\u2026" },
|
|
11008
|
+
plan_hash: "9f2c\u2026",
|
|
11009
|
+
attestation: { version: 1, accepted: true },
|
|
11010
|
+
acknowledge_sends: ["s_xyz789"]
|
|
11011
|
+
}
|
|
11012
|
+
},
|
|
11013
|
+
handler: commit
|
|
11014
|
+
},
|
|
9304
11015
|
// --- suppressions (authed admin) ---
|
|
9305
11016
|
{
|
|
9306
11017
|
method: "GET",
|
|
@@ -9478,7 +11189,9 @@ var SHIPPED_MIGRATIONS = [
|
|
|
9478
11189
|
"0001_init.sql",
|
|
9479
11190
|
"0002_sends_since_1_0.sql",
|
|
9480
11191
|
"0003_post_slug_auto.sql",
|
|
9481
|
-
"0004_keyed_pending_to_resolve.sql"
|
|
11192
|
+
"0004_keyed_pending_to_resolve.sql",
|
|
11193
|
+
"0005_imports.sql",
|
|
11194
|
+
"0006_deliveries_fewer_indexes.sql"
|
|
9482
11195
|
];
|
|
9483
11196
|
async function missingMigrations(db) {
|
|
9484
11197
|
let applied;
|
|
@@ -9616,6 +11329,8 @@ var NOTIFY_RESERVE = OPEN_COST + EACH_COST;
|
|
|
9616
11329
|
var MAX_PER_TICK = 10;
|
|
9617
11330
|
function stillHolds(n, now) {
|
|
9618
11331
|
switch (n.kind) {
|
|
11332
|
+
case "import":
|
|
11333
|
+
return true;
|
|
9619
11334
|
case "finished":
|
|
9620
11335
|
return true;
|
|
9621
11336
|
case "refused":
|
|
@@ -9657,17 +11372,19 @@ async function notifyPublisher(env, budget) {
|
|
|
9657
11372
|
await recordNotificationOutcome(db, n, { status: "cleared" }, Date.now());
|
|
9658
11373
|
continue;
|
|
9659
11374
|
}
|
|
9660
|
-
const
|
|
11375
|
+
const about = n.kind === "import" ? { importId: n.import_id } : { sendId: n.send_id };
|
|
11376
|
+
const key = `${n.send_id ?? n.import_id}-${n.kind}-${n.episode}`;
|
|
9661
11377
|
budget.request();
|
|
9662
11378
|
try {
|
|
9663
|
-
|
|
11379
|
+
const email2 = n.kind === "import" ? composeImportNotification(n, config) : composeNotification(n, config);
|
|
11380
|
+
await notifier.send(to, email2, key);
|
|
9664
11381
|
await recordNotificationOutcome(db, n, { status: "sent" }, Date.now());
|
|
9665
|
-
log.info("notify.sent", {
|
|
11382
|
+
log.info("notify.sent", { ...about, kind: n.kind, channel: notifier.channel });
|
|
9666
11383
|
} catch (err) {
|
|
9667
11384
|
const error = errorText(err);
|
|
9668
11385
|
const giveUp = n.attempts + 1 >= MAX_NOTIFY_ATTEMPTS;
|
|
9669
11386
|
log.warn("notify.failed", {
|
|
9670
|
-
|
|
11387
|
+
...about,
|
|
9671
11388
|
kind: n.kind,
|
|
9672
11389
|
channel: notifier.channel,
|
|
9673
11390
|
error,
|