@kurtbruns/kestrel 1.5.0 → 1.7.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/.dev.vars.example +5 -17
- package/CHANGELOG.md +78 -1
- package/README.md +1 -1
- package/cli/context.mjs +20 -11
- package/cli/dev-api.mjs +73 -9
- package/cli/dev-server.mjs +72 -0
- package/cli/dev.mjs +39 -11
- package/cli/init.mjs +8 -2
- package/cli/reset.mjs +2 -3
- package/cli/seed.mjs +7 -5
- package/cli/sync-migrations.mjs +5 -1
- package/dist/public/dashboard/app.js +366 -325
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +640 -126
- package/dist/worker/index.js +1198 -748
- package/docs/README.md +3 -3
- package/docs/get-started/02-deploy.md +29 -13
- package/docs/get-started/03-access.md +3 -3
- package/docs/get-started/04-resend.md +8 -6
- package/docs/get-started/05-verify.md +3 -3
- package/docs/get-started/06-go-live.md +2 -2
- package/docs/guides/02-ses.md +2 -2
- package/docs/guides/08-try-locally.md +108 -0
- package/docs/reference/01-configuration.md +18 -7
- package/docs/reference/02-sending-domain-dns.md +1 -1
- package/migrations/0003_post_slug_auto.sql +14 -0
- package/package.json +5 -6
- package/template/claude/CLAUDE.md +2 -2
- package/template/package.json +5 -0
- package/template/wrangler.jsonc +7 -6
- package/cli/dev-port.mjs +0 -57
- package/docs/get-started/01-overview.md +0 -47
package/dist/worker/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @kurtbruns/kestrel 1.
|
|
1
|
+
// @kurtbruns/kestrel 1.7.0: the Worker, built by scripts/build-package.mjs.
|
|
2
2
|
|
|
3
3
|
// shared/sends.ts
|
|
4
4
|
function refusalAdvice(cause) {
|
|
@@ -11,6 +11,8 @@ function refusalAdvice(cause) {
|
|
|
11
11
|
return "Wait for the provider's sending quota to reset, or raise it on your plan.";
|
|
12
12
|
case "suspended":
|
|
13
13
|
return "Settle the account's standing with the provider (for SES, in the SES console).";
|
|
14
|
+
case "not_connected":
|
|
15
|
+
return "Connect an email provider (the setup guide walks through it), then deploy again.";
|
|
14
16
|
default:
|
|
15
17
|
return "Fix the account with the provider.";
|
|
16
18
|
}
|
|
@@ -33,10 +35,10 @@ var BOUNCE_SPIKE_RECENT_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
|
33
35
|
|
|
34
36
|
// src/generated/version.ts
|
|
35
37
|
var BUILD_INFO = {
|
|
36
|
-
version: "1.
|
|
37
|
-
sha: "
|
|
38
|
-
tag: "v1.
|
|
39
|
-
buildTime: "2026-10-
|
|
38
|
+
version: "1.7.0",
|
|
39
|
+
sha: "f9749b7",
|
|
40
|
+
tag: "v1.7.0",
|
|
41
|
+
buildTime: "2026-10-12T20:33:17.866Z",
|
|
40
42
|
repoUrl: "https://github.com/kurtbruns/kestrel"
|
|
41
43
|
};
|
|
42
44
|
|
|
@@ -163,6 +165,14 @@ var MAX_DELIVERY_ATTEMPTS = 5;
|
|
|
163
165
|
var CONFIRM_COOLDOWN_MS = 15 * 60 * 1e3;
|
|
164
166
|
var CONFIRM_LINK_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
165
167
|
|
|
168
|
+
// src/lib/unwrap.ts
|
|
169
|
+
function unwrap(value, what) {
|
|
170
|
+
if (value == null) {
|
|
171
|
+
throw new Error(`expected ${what} to exist`);
|
|
172
|
+
}
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
|
|
166
176
|
// src/reference/index.ts
|
|
167
177
|
var TIERS = [
|
|
168
178
|
{
|
|
@@ -255,9 +265,9 @@ async function verifyAccessJwt(token2, config) {
|
|
|
255
265
|
if (!isHumanAllowed(email2, config.accessAllowedEmails)) {
|
|
256
266
|
return null;
|
|
257
267
|
}
|
|
258
|
-
return { kind: "human", email: email2 };
|
|
268
|
+
return { kind: "human", email: email2, via: "access" };
|
|
259
269
|
}
|
|
260
|
-
return { kind: "service" };
|
|
270
|
+
return { kind: "service", via: "access" };
|
|
261
271
|
} catch {
|
|
262
272
|
return null;
|
|
263
273
|
}
|
|
@@ -278,7 +288,7 @@ async function verifyDevToken(token2, secret) {
|
|
|
278
288
|
audience: DEV_AUDIENCE
|
|
279
289
|
});
|
|
280
290
|
const email2 = typeof payload.email === "string" ? payload.email : void 0;
|
|
281
|
-
return email2 ? { kind: "human", email: email2 } : { kind: "service" };
|
|
291
|
+
return email2 ? { kind: "human", email: email2, via: "dev" } : { kind: "service", via: "dev" };
|
|
282
292
|
} catch {
|
|
283
293
|
return null;
|
|
284
294
|
}
|
|
@@ -317,7 +327,10 @@ var SIMULATION_PROFILES = ["generic", "resend", "ses"];
|
|
|
317
327
|
var SIMULATION_FAULTS = ["realistic", "none"];
|
|
318
328
|
|
|
319
329
|
// src/providers/requirements.ts
|
|
320
|
-
var PROVIDER_NAMES = ["fake", "ses", "resend"];
|
|
330
|
+
var PROVIDER_NAMES = ["none", "fake", "ses", "resend"];
|
|
331
|
+
function isRealProvider(name) {
|
|
332
|
+
return name === "ses" || name === "resend";
|
|
333
|
+
}
|
|
321
334
|
var REQUIRED_VARS = {
|
|
322
335
|
ses: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION", "SNS_TOPIC_ARN"],
|
|
323
336
|
resend: ["RESEND_API_KEY", "RESEND_WEBHOOK_SECRET"]
|
|
@@ -352,12 +365,16 @@ function getConfig(env) {
|
|
|
352
365
|
}
|
|
353
366
|
const archiveOrigin = readOrigin("ARCHIVE_ORIGIN", env.ARCHIVE_ORIGIN) ?? appOrigin;
|
|
354
367
|
const mediaPublicBase = readBaseUrl("MEDIA_PUBLIC_BASE", env.MEDIA_PUBLIC_BASE) ?? `${appOrigin}/media`;
|
|
368
|
+
if (isRealProvider(provider)) {
|
|
369
|
+
requireRealProviderConfig(provider, env);
|
|
370
|
+
}
|
|
355
371
|
if (provider !== "fake") {
|
|
356
|
-
|
|
372
|
+
refusePlaceholders(provider, env, { appOrigin, archiveOrigin, mediaPublicBase });
|
|
357
373
|
}
|
|
358
|
-
const accessTeamDomain =
|
|
374
|
+
const { accessTeamDomain, accessAud } = readAccess(env);
|
|
359
375
|
const devShaped = provider === "fake" && !accessTeamDomain && isLoopbackOrigin(appOrigin);
|
|
360
376
|
const devAuthSecret = devShaped ? orUndefined(env.DEV_AUTH_SECRET) : void 0;
|
|
377
|
+
const devMode = devAuthSecret !== void 0;
|
|
361
378
|
const notifyChannel = devShaped ? "fake" : env.NOTIFY ? "cloudflare" : "provider";
|
|
362
379
|
return {
|
|
363
380
|
provider,
|
|
@@ -370,14 +387,15 @@ function getConfig(env) {
|
|
|
370
387
|
awsRegion: env.AWS_REGION,
|
|
371
388
|
sesMaxSendRate: readPositiveInt("SES_MAX_SEND_RATE", env.SES_MAX_SEND_RATE) ?? DEFAULT_SES_MAX_SEND_RATE,
|
|
372
389
|
accessTeamDomain,
|
|
373
|
-
accessAud
|
|
390
|
+
accessAud,
|
|
374
391
|
accessAllowedEmails: parseEmailList(env.ACCESS_ALLOWED_EMAILS),
|
|
375
392
|
// Show the dev-only dashboard link, and register the dev routes, exactly when the
|
|
376
393
|
// dev credential path is live (dev-shaped AND the secret resolved). Never true in a
|
|
377
394
|
// deployed env, so the reader surface stays clean of any admin link there.
|
|
378
|
-
devMode
|
|
395
|
+
devMode,
|
|
379
396
|
devAuthSecret,
|
|
380
|
-
|
|
397
|
+
devRunId: devMode ? orUndefined(env.DEV_RUN_ID) : void 0,
|
|
398
|
+
// Only ever read in a dev-shaped env; a deployed env is never dev-shaped, so the
|
|
381
399
|
// simulation can never engage there whatever the var says.
|
|
382
400
|
simulation: devShaped ? readSimulation(env.SIMULATE_SENDS) : null,
|
|
383
401
|
subrequestBudget: Math.max(
|
|
@@ -389,46 +407,73 @@ function getConfig(env) {
|
|
|
389
407
|
notifyFrom: notifyChannel === "cloudflare" ? orUndefined(env.NOTIFY_FROM) ?? `Kestrel <kestrel@${new URL(appOrigin).hostname}>` : env.FROM_ADDRESS
|
|
390
408
|
};
|
|
391
409
|
}
|
|
410
|
+
function readAccess(env) {
|
|
411
|
+
const accessTeamDomain = orUndefined(env.ACCESS_TEAM_DOMAIN?.trim());
|
|
412
|
+
const accessAud = orUndefined(env.ACCESS_AUD?.trim());
|
|
413
|
+
if (accessTeamDomain && !accessAud) {
|
|
414
|
+
throw new ConfigError(
|
|
415
|
+
"ACCESS_AUD",
|
|
416
|
+
"is not set, but ACCESS_TEAM_DOMAIN is; Access needs both to verify a sign-in"
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
if (accessAud && !accessTeamDomain) {
|
|
420
|
+
throw new ConfigError(
|
|
421
|
+
"ACCESS_TEAM_DOMAIN",
|
|
422
|
+
"is not set, but ACCESS_AUD is; Access needs both to verify a sign-in"
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return { accessTeamDomain, accessAud };
|
|
426
|
+
}
|
|
392
427
|
function readProvider(v) {
|
|
393
428
|
const raw = orUndefined(v);
|
|
394
429
|
if (raw === void 0) {
|
|
395
|
-
return "
|
|
430
|
+
return "none";
|
|
396
431
|
}
|
|
397
432
|
if (!isProviderName(raw)) {
|
|
398
433
|
throw new ConfigError("PROVIDER", `must be one of ${PROVIDER_NAMES.join(", ")}, not "${raw}"`);
|
|
399
434
|
}
|
|
400
435
|
return raw;
|
|
401
436
|
}
|
|
402
|
-
function requireRealProviderConfig(provider, env
|
|
437
|
+
function requireRealProviderConfig(provider, env) {
|
|
403
438
|
for (const name of [...REQUIRED_VARS[provider], "FROM_ADDRESS"]) {
|
|
404
439
|
const value = env[name];
|
|
405
440
|
if (typeof value !== "string" || value.trim() === "") {
|
|
406
441
|
throw new ConfigError(name, `is not set; PROVIDER "${provider}" needs it`);
|
|
407
442
|
}
|
|
408
443
|
}
|
|
444
|
+
if (!addressDomain(env.FROM_ADDRESS)) {
|
|
445
|
+
throw new ConfigError(
|
|
446
|
+
"FROM_ADDRESS",
|
|
447
|
+
`must be an email address, optionally with a name, not "${env.FROM_ADDRESS}"`
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function refusePlaceholders(provider, env, origins) {
|
|
409
452
|
const hosts = [
|
|
410
453
|
["APP_ORIGIN", new URL(origins.appOrigin).hostname],
|
|
411
454
|
["ARCHIVE_ORIGIN", new URL(origins.archiveOrigin).hostname],
|
|
412
|
-
["MEDIA_PUBLIC_BASE", new URL(origins.mediaPublicBase).hostname]
|
|
413
|
-
["FROM_ADDRESS", emailDomain("FROM_ADDRESS", env.FROM_ADDRESS)]
|
|
455
|
+
["MEDIA_PUBLIC_BASE", new URL(origins.mediaPublicBase).hostname]
|
|
414
456
|
];
|
|
415
|
-
if (orUndefined(env.SENDING_DOMAIN)) {
|
|
457
|
+
if (orUndefined(env.SENDING_DOMAIN?.trim())) {
|
|
416
458
|
hosts.push(["SENDING_DOMAIN", env.SENDING_DOMAIN.trim()]);
|
|
417
459
|
}
|
|
460
|
+
const from = orUndefined(env.FROM_ADDRESS?.trim());
|
|
461
|
+
const fromDomain = from && addressDomain(from);
|
|
462
|
+
if (fromDomain) {
|
|
463
|
+
hosts.push(["FROM_ADDRESS", fromDomain]);
|
|
464
|
+
}
|
|
465
|
+
const why = provider === "none" ? "replace it with your own domain" : `PROVIDER "${provider}" sends real email`;
|
|
418
466
|
for (const [name, host] of hosts) {
|
|
419
467
|
if (isPlaceholderHost(host)) {
|
|
420
|
-
throw new ConfigError(
|
|
421
|
-
name,
|
|
422
|
-
`is still on the template's example.com placeholder; PROVIDER "${provider}" sends real email`
|
|
423
|
-
);
|
|
468
|
+
throw new ConfigError(name, `is still on the template's example.com placeholder; ${why}`);
|
|
424
469
|
}
|
|
425
470
|
}
|
|
426
471
|
}
|
|
427
|
-
function
|
|
472
|
+
function addressDomain(from) {
|
|
428
473
|
const address = /<([^<>]*)>\s*$/.exec(from)?.[1] ?? from;
|
|
429
474
|
const at = address.lastIndexOf("@");
|
|
430
475
|
if (at < 1 || at === address.length - 1) {
|
|
431
|
-
|
|
476
|
+
return void 0;
|
|
432
477
|
}
|
|
433
478
|
return address.slice(at + 1).trim();
|
|
434
479
|
}
|
|
@@ -544,7 +589,7 @@ function mediaTypeOf(req) {
|
|
|
544
589
|
return declared ? (declared.split(";")[0] ?? "").trim().toLowerCase() || void 0 : void 0;
|
|
545
590
|
}
|
|
546
591
|
async function readJsonObject(c, opts = {}) {
|
|
547
|
-
const text = await c.req.
|
|
592
|
+
const text = new TextDecoder().decode(await c.req.arrayBuffer());
|
|
548
593
|
if (text.trim() === "") {
|
|
549
594
|
if (opts.optional) {
|
|
550
595
|
return {};
|
|
@@ -579,23 +624,23 @@ function optString(o, key, at) {
|
|
|
579
624
|
}
|
|
580
625
|
return v;
|
|
581
626
|
}
|
|
582
|
-
function
|
|
583
|
-
const v = o[key];
|
|
584
|
-
if (v === void 0 || v === null || typeof v === "string") {
|
|
585
|
-
return v;
|
|
586
|
-
}
|
|
587
|
-
throw fieldError(path(key, at), `${path(key, at)} must be a string or null`);
|
|
588
|
-
}
|
|
589
|
-
function optCount(o, key, at) {
|
|
627
|
+
function optBoolean(o, key, at) {
|
|
590
628
|
const v = o[key];
|
|
591
629
|
if (v === void 0) {
|
|
592
630
|
return void 0;
|
|
593
631
|
}
|
|
594
|
-
if (typeof v !== "
|
|
595
|
-
throw fieldError(path(key, at), `${path(key, at)} must be
|
|
632
|
+
if (typeof v !== "boolean") {
|
|
633
|
+
throw fieldError(path(key, at), `${path(key, at)} must be true or false`);
|
|
596
634
|
}
|
|
597
635
|
return v;
|
|
598
636
|
}
|
|
637
|
+
function optStringOrNull(o, key, at) {
|
|
638
|
+
const v = o[key];
|
|
639
|
+
if (v === void 0 || v === null || typeof v === "string") {
|
|
640
|
+
return v;
|
|
641
|
+
}
|
|
642
|
+
throw fieldError(path(key, at), `${path(key, at)} must be a string or null`);
|
|
643
|
+
}
|
|
599
644
|
function optStringList(o, key, at) {
|
|
600
645
|
const v = o[key];
|
|
601
646
|
if (v === void 0) {
|
|
@@ -756,14 +801,6 @@ function newToken(bytes = 32) {
|
|
|
756
801
|
return out;
|
|
757
802
|
}
|
|
758
803
|
|
|
759
|
-
// src/lib/unwrap.ts
|
|
760
|
-
function unwrap(value, what) {
|
|
761
|
-
if (value == null) {
|
|
762
|
-
throw new Error(`expected ${what} to exist`);
|
|
763
|
-
}
|
|
764
|
-
return value;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
804
|
// src/lib/list.ts
|
|
768
805
|
var DEFAULT_LIMIT = 50;
|
|
769
806
|
var MAX_LIMIT = 200;
|
|
@@ -901,13 +938,20 @@ function latestSentSendForPost(db, postId) {
|
|
|
901
938
|
"SELECT * FROM sends WHERE post_id = ? AND status = 'sent' ORDER BY completed_at DESC LIMIT 1"
|
|
902
939
|
).bind(postId).first();
|
|
903
940
|
}
|
|
941
|
+
var PUBLISHED_SEND = "s.status IN ('sending', 'sent')";
|
|
942
|
+
var PUBLISHED_AT = "COALESCE(s.started_at, s.fire_at)";
|
|
943
|
+
function publishedSendForPost(db, postId) {
|
|
944
|
+
return db.prepare(
|
|
945
|
+
`SELECT * FROM sends s WHERE s.post_id = ? AND ${PUBLISHED_SEND} ORDER BY ${PUBLISHED_AT} DESC LIMIT 1`
|
|
946
|
+
).bind(postId).first();
|
|
947
|
+
}
|
|
904
948
|
async function listPublishedPosts(db, limit = 200) {
|
|
905
949
|
const { results } = await db.prepare(
|
|
906
|
-
`SELECT p.slug AS slug, s.subject AS subject, MAX(
|
|
950
|
+
`SELECT p.slug AS slug, s.subject AS subject, MAX(${PUBLISHED_AT}) AS published_at
|
|
907
951
|
FROM sends s JOIN posts p ON p.id = s.post_id
|
|
908
|
-
WHERE
|
|
952
|
+
WHERE ${PUBLISHED_SEND}
|
|
909
953
|
GROUP BY s.post_id
|
|
910
|
-
ORDER BY
|
|
954
|
+
ORDER BY published_at DESC
|
|
911
955
|
LIMIT ?`
|
|
912
956
|
).bind(Math.min(limit, 1e3)).all();
|
|
913
957
|
return results;
|
|
@@ -1145,18 +1189,15 @@ function insertScheduledSendStmt(db, send, now, settingsVersion) {
|
|
|
1145
1189
|
...guard2.binds
|
|
1146
1190
|
);
|
|
1147
1191
|
}
|
|
1148
|
-
function inWindowAt(now
|
|
1149
|
-
return {
|
|
1150
|
-
sql: "status = 'scheduled' AND fire_at > ? AND (? IS NULL OR rev = ?)",
|
|
1151
|
-
binds: [now, rev, rev]
|
|
1152
|
-
};
|
|
1192
|
+
function inWindowAt(now) {
|
|
1193
|
+
return { sql: "status = 'scheduled' AND fire_at > ?", binds: [now] };
|
|
1153
1194
|
}
|
|
1154
|
-
function rescheduleStmt(db, sendId, fireAt, now
|
|
1155
|
-
const guard2 = inWindowAt(now
|
|
1195
|
+
function rescheduleStmt(db, sendId, fireAt, now) {
|
|
1196
|
+
const guard2 = inWindowAt(now);
|
|
1156
1197
|
return db.prepare(`UPDATE sends SET fire_at = ?, rev = ${NEXT_REV} WHERE id = ? AND ${guard2.sql}`).bind(fireAt, sendId, ...guard2.binds);
|
|
1157
1198
|
}
|
|
1158
|
-
function cancelStmt(db, sendId, now
|
|
1159
|
-
const guard2 = inWindowAt(now
|
|
1199
|
+
function cancelStmt(db, sendId, now) {
|
|
1200
|
+
const guard2 = inWindowAt(now);
|
|
1160
1201
|
return db.prepare(
|
|
1161
1202
|
`UPDATE sends SET status = 'canceled', completed_at = ?, rev = ${NEXT_REV} WHERE id = ? AND ${guard2.sql}`
|
|
1162
1203
|
).bind(now, sendId, ...guard2.binds);
|
|
@@ -1608,7 +1649,8 @@ async function findReceiptTarget(db, u) {
|
|
|
1608
1649
|
).bind(normalizeEmail(u.email)).first();
|
|
1609
1650
|
}
|
|
1610
1651
|
if (u.email) {
|
|
1611
|
-
|
|
1652
|
+
const row = await db.prepare(`SELECT ${cols} FROM deliveries WHERE email = ? ORDER BY updated_at DESC LIMIT 1`).bind(normalizeEmail(u.email)).first();
|
|
1653
|
+
return row && row.status !== "pending" && row.status !== "dispatched" ? row : null;
|
|
1612
1654
|
}
|
|
1613
1655
|
return null;
|
|
1614
1656
|
}
|
|
@@ -1625,15 +1667,20 @@ async function acceptedAwaitingEvent(db, limit) {
|
|
|
1625
1667
|
}
|
|
1626
1668
|
|
|
1627
1669
|
// src/db/posts.ts
|
|
1670
|
+
function fromStored(row) {
|
|
1671
|
+
return { ...row, slug_auto: row.slug_auto === 1 };
|
|
1672
|
+
}
|
|
1628
1673
|
function setPostStatusStmt(db, postId, from, to, now, guard2) {
|
|
1629
1674
|
const extra = guard2 ? ` AND ${guard2.sql}` : "";
|
|
1630
1675
|
return db.prepare(`UPDATE posts SET status = ?, updated_at = ? WHERE id = ? AND status = ?${extra}`).bind(to, now, postId, from, ...guard2?.binds ?? []);
|
|
1631
1676
|
}
|
|
1632
|
-
function getPost(db, id) {
|
|
1633
|
-
|
|
1677
|
+
async function getPost(db, id) {
|
|
1678
|
+
const row = await db.prepare("SELECT * FROM posts WHERE id = ?").bind(id).first();
|
|
1679
|
+
return row && fromStored(row);
|
|
1634
1680
|
}
|
|
1635
|
-
function getBySlug(db, slug) {
|
|
1636
|
-
|
|
1681
|
+
async function getBySlug(db, slug) {
|
|
1682
|
+
const row = await db.prepare("SELECT * FROM posts WHERE slug = ?").bind(slug).first();
|
|
1683
|
+
return row && fromStored(row);
|
|
1637
1684
|
}
|
|
1638
1685
|
var POST_LIST_SPEC = {
|
|
1639
1686
|
columns: {
|
|
@@ -1677,7 +1724,7 @@ async function listPosts(db, filter = {}, page) {
|
|
|
1677
1724
|
${order}
|
|
1678
1725
|
LIMIT ? OFFSET ?`
|
|
1679
1726
|
).bind(...binds, limit, offset).all();
|
|
1680
|
-
return results;
|
|
1727
|
+
return results.map(fromStored);
|
|
1681
1728
|
}
|
|
1682
1729
|
async function countPosts(db, filter = {}) {
|
|
1683
1730
|
const { clause, binds } = postWhere(filter);
|
|
@@ -1722,12 +1769,13 @@ async function createPost(db, input, author2) {
|
|
|
1722
1769
|
const revId = newId();
|
|
1723
1770
|
const subject = input.subject ?? "";
|
|
1724
1771
|
const markdown = input.markdown ?? "";
|
|
1725
|
-
const
|
|
1772
|
+
const auto = input.slug_auto ?? input.slug === void 0;
|
|
1773
|
+
const slug = await uniqueSlug(db, slugify(auto ? subject : input.slug ?? subject));
|
|
1726
1774
|
const metadata = JSON.stringify({ subject, slug });
|
|
1727
1775
|
await db.batch([
|
|
1728
1776
|
db.prepare(
|
|
1729
|
-
"INSERT INTO posts (id, slug, subject, status, current_revision, created_at, updated_at) VALUES (?, ?, ?, 'draft', ?, ?, ?)"
|
|
1730
|
-
).bind(id, slug, subject, revId, now, now),
|
|
1777
|
+
"INSERT INTO posts (id, slug, slug_auto, subject, status, current_revision, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?)"
|
|
1778
|
+
).bind(id, slug, auto ? 1 : 0, subject, revId, now, now),
|
|
1731
1779
|
db.prepare(
|
|
1732
1780
|
"INSERT INTO post_revisions (id, post_id, markdown, metadata, author, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
1733
1781
|
).bind(revId, id, markdown, metadata, author2, now)
|
|
@@ -1745,8 +1793,11 @@ async function updatePost(db, post, input, author2) {
|
|
|
1745
1793
|
const revId = newId();
|
|
1746
1794
|
const subject = input.subject ?? post.subject;
|
|
1747
1795
|
const markdown = input.markdown ?? current?.markdown ?? "";
|
|
1796
|
+
const auto = input.slug_auto ?? (input.slug === void 0 && post.slug_auto);
|
|
1748
1797
|
let slug = post.slug;
|
|
1749
|
-
if (
|
|
1798
|
+
if (auto) {
|
|
1799
|
+
slug = await uniqueSlug(db, slugify(subject), post.id);
|
|
1800
|
+
} else if (input.slug !== void 0) {
|
|
1750
1801
|
slug = await uniqueSlug(db, slugify(input.slug), post.id);
|
|
1751
1802
|
}
|
|
1752
1803
|
const metadata = JSON.stringify({ subject, slug });
|
|
@@ -1755,8 +1806,8 @@ async function updatePost(db, post, input, author2) {
|
|
|
1755
1806
|
"INSERT INTO post_revisions (id, post_id, markdown, metadata, author, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
1756
1807
|
).bind(revId, post.id, markdown, metadata, author2, now),
|
|
1757
1808
|
db.prepare(
|
|
1758
|
-
"UPDATE posts SET slug = ?, subject = ?, current_revision = ?, updated_at = ? WHERE id = ? AND status = 'draft'"
|
|
1759
|
-
).bind(slug, subject, revId, now, post.id)
|
|
1809
|
+
"UPDATE posts SET slug = ?, slug_auto = ?, subject = ?, current_revision = ?, updated_at = ? WHERE id = ? AND status = 'draft'"
|
|
1810
|
+
).bind(slug, auto ? 1 : 0, subject, revId, now, post.id)
|
|
1760
1811
|
]);
|
|
1761
1812
|
const updated = unwrap(await getPost(db, post.id), "post");
|
|
1762
1813
|
const revision = unwrap(
|
|
@@ -1779,387 +1830,89 @@ async function deletePost(db, id) {
|
|
|
1779
1830
|
]);
|
|
1780
1831
|
}
|
|
1781
1832
|
|
|
1782
|
-
// src/
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
subject: "Confirm your subscription",
|
|
1786
|
-
body: "Thanks for subscribing. Please confirm your email address to start receiving the newsletter.",
|
|
1787
|
-
buttonLabel: "Confirm subscription",
|
|
1788
|
-
reassurance: "If you didn't request this, you can safely ignore this email."
|
|
1789
|
-
};
|
|
1790
|
-
var DEFAULT_SETTINGS = {
|
|
1791
|
-
testRecipients: [],
|
|
1792
|
-
publication: { name: "", tagline: "", address: "", logo: null },
|
|
1793
|
-
emailTemplate: "",
|
|
1794
|
-
confirmationEmail: DEFAULT_CONFIRMATION_EMAIL,
|
|
1795
|
-
notifications: { to: "" }
|
|
1796
|
-
};
|
|
1797
|
-
var MAX_TEST_RECIPIENTS = 20;
|
|
1798
|
-
var MAX_NAME = 120;
|
|
1799
|
-
var MAX_TAGLINE = 200;
|
|
1800
|
-
var MAX_ADDRESS = 300;
|
|
1801
|
-
var MAX_TEMPLATE = 4e4;
|
|
1802
|
-
var MAX_CE_SUBJECT = 200;
|
|
1803
|
-
var MAX_CE_BODY = 1e3;
|
|
1804
|
-
var MAX_CE_BUTTON = 80;
|
|
1805
|
-
var MAX_CE_REASSURANCE = 400;
|
|
1806
|
-
function coercePublication(raw) {
|
|
1807
|
-
const o = raw && typeof raw === "object" ? raw : {};
|
|
1808
|
-
const str = (v) => typeof v === "string" ? v : "";
|
|
1809
|
-
const lr = o.logo;
|
|
1810
|
-
const logo = lr && typeof lr === "object" && typeof lr.version === "number" ? { version: lr.version, contentType: str(lr.contentType) || "image/png" } : null;
|
|
1811
|
-
return {
|
|
1812
|
-
name: str(o.name).slice(0, MAX_NAME),
|
|
1813
|
-
tagline: str(o.tagline).slice(0, MAX_TAGLINE),
|
|
1814
|
-
address: str(o.address).slice(0, MAX_ADDRESS),
|
|
1815
|
-
logo
|
|
1816
|
-
};
|
|
1817
|
-
}
|
|
1818
|
-
function coerceConfirmation(raw) {
|
|
1819
|
-
const o = raw && typeof raw === "object" ? raw : {};
|
|
1820
|
-
const str = (v) => typeof v === "string" ? v : "";
|
|
1821
|
-
return {
|
|
1822
|
-
subject: str(o.subject).slice(0, MAX_CE_SUBJECT),
|
|
1823
|
-
body: str(o.body).slice(0, MAX_CE_BODY),
|
|
1824
|
-
buttonLabel: str(o.buttonLabel).slice(0, MAX_CE_BUTTON),
|
|
1825
|
-
reassurance: str(o.reassurance).slice(0, MAX_CE_REASSURANCE)
|
|
1826
|
-
};
|
|
1833
|
+
// src/lib/html.ts
|
|
1834
|
+
function escapeHtml(s) {
|
|
1835
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1827
1836
|
}
|
|
1828
|
-
function
|
|
1829
|
-
|
|
1830
|
-
const list5 = Array.isArray(o.testRecipients) ? o.testRecipients : [];
|
|
1831
|
-
return {
|
|
1832
|
-
testRecipients: list5.filter((e) => typeof e === "string"),
|
|
1833
|
-
publication: coercePublication(o.publication),
|
|
1834
|
-
emailTemplate: typeof o.emailTemplate === "string" ? o.emailTemplate.slice(0, MAX_TEMPLATE) : "",
|
|
1835
|
-
// A row that predates this feature has no `confirmationEmail` key at all — that's
|
|
1836
|
-
// "never configured", so it gets the full built-in copy (reassurance included), NOT
|
|
1837
|
-
// a blank-reassurance row. Only once the key exists (the operator saved copy) does a
|
|
1838
|
-
// blank reassurance mean "deliberately omit the footer".
|
|
1839
|
-
confirmationEmail: "confirmationEmail" in o ? coerceConfirmation(o.confirmationEmail) : { ...DEFAULT_CONFIRMATION_EMAIL },
|
|
1840
|
-
notifications: coerceNotifications(o.notifications)
|
|
1841
|
-
};
|
|
1837
|
+
function escapeHtmlAttr(s) {
|
|
1838
|
+
return escapeHtml(s).replace(/"/g, """).replace(/'/g, "'");
|
|
1842
1839
|
}
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1840
|
+
|
|
1841
|
+
// shared/page_policy.ts
|
|
1842
|
+
function pagePolicy(formAction, opts = {}) {
|
|
1843
|
+
return [
|
|
1844
|
+
"default-src 'none'",
|
|
1845
|
+
"script-src 'none'",
|
|
1846
|
+
"object-src 'none'",
|
|
1847
|
+
"frame-src 'none'",
|
|
1848
|
+
"base-uri 'none'",
|
|
1849
|
+
`form-action ${formAction}`,
|
|
1850
|
+
opts.framed ? null : "frame-ancestors 'none'",
|
|
1851
|
+
"img-src * data:",
|
|
1852
|
+
"style-src 'unsafe-inline' https://fonts.googleapis.com",
|
|
1853
|
+
"font-src https://fonts.gstatic.com"
|
|
1854
|
+
].filter((d) => d !== null).join("; ");
|
|
1846
1855
|
}
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1856
|
+
|
|
1857
|
+
// src/lib/page_headers.ts
|
|
1858
|
+
function headers(formAction) {
|
|
1850
1859
|
return {
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
reassurance: c.reassurance
|
|
1860
|
+
"content-security-policy": pagePolicy(formAction),
|
|
1861
|
+
"x-frame-options": "DENY",
|
|
1862
|
+
"x-content-type-options": "nosniff"
|
|
1855
1863
|
};
|
|
1856
1864
|
}
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
WHERE id = 1 AND updated_at = ?${extra}`
|
|
1906
|
-
).bind(JSON.stringify(next), Date.now(), expected, ...guard2?.binds ?? []);
|
|
1907
|
-
}
|
|
1908
|
-
var WRITE_RETRIES = 5;
|
|
1909
|
-
var SettingsContention = class extends HttpError {
|
|
1910
|
-
constructor() {
|
|
1911
|
-
super(409, "conflict", "settings changed concurrently; try again");
|
|
1912
|
-
}
|
|
1913
|
-
};
|
|
1914
|
-
async function updateSettingsWith(db, derive) {
|
|
1915
|
-
await ensureSettingsRow(db);
|
|
1916
|
-
for (let attempt = 0; attempt < WRITE_RETRIES; attempt++) {
|
|
1917
|
-
const { settings, version } = await readSettings(db);
|
|
1918
|
-
const next = derive(settings);
|
|
1919
|
-
const res = await persistSettingsStmt(db, next, version ?? 0).run();
|
|
1920
|
-
if ((res.meta.changes ?? 0) > 0) {
|
|
1921
|
-
return next;
|
|
1922
|
-
}
|
|
1923
|
-
}
|
|
1924
|
-
throw new SettingsContention();
|
|
1865
|
+
var PAGE_SECURITY_HEADERS = headers("'self'");
|
|
1866
|
+
var POST_PAGE_SECURITY_HEADERS = headers("'none'");
|
|
1867
|
+
|
|
1868
|
+
// src/lib/page.ts
|
|
1869
|
+
var READER_BG_LIGHT = "#fbfbfa";
|
|
1870
|
+
var READER_BG_DARK = "#12110f";
|
|
1871
|
+
var APP_ACCENT_TOKENS = `
|
|
1872
|
+
:root { --k-accent:#3355cc; --k-accent-contrast:#ffffff; }
|
|
1873
|
+
@media (prefers-color-scheme: dark) { :root { --k-accent:#7d9bff; --k-accent-contrast:#10131f; } }
|
|
1874
|
+
`;
|
|
1875
|
+
var DEV_BADGE_STYLE = `${APP_ACCENT_TOKENS}
|
|
1876
|
+
.r-dev { position:fixed; right:18px; bottom:18px; z-index:50; display:inline-flex; align-items:center; gap:8px;
|
|
1877
|
+
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; line-height:1.6;
|
|
1878
|
+
font-size:13px; font-weight:600; text-decoration:none; padding:9px 15px 9px 10px; border-radius:999px;
|
|
1879
|
+
background:var(--k-accent); color:var(--k-accent-contrast); border:1px solid var(--k-accent);
|
|
1880
|
+
box-shadow:0 6px 20px rgba(0,0,0,.22); }
|
|
1881
|
+
.r-dev:hover { filter:brightness(1.08); }
|
|
1882
|
+
/* Inverted badge \u2014 solid contrast fill, accent-colored text \u2014 so the tiny "DEV"
|
|
1883
|
+
label clears WCAG AA on the accent pill in both themes (a translucent tint left
|
|
1884
|
+
it ~3.6:1). */
|
|
1885
|
+
.r-dev .r-dev-tag { font-size:9.5px; letter-spacing:.09em; text-transform:uppercase; font-weight:700;
|
|
1886
|
+
padding:2px 7px; border-radius:999px;
|
|
1887
|
+
background:var(--k-accent-contrast); color:var(--k-accent); }
|
|
1888
|
+
.r-dev:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
|
1889
|
+
@media (prefers-color-scheme: dark) { .r-dev:focus-visible { outline-color:#60a5fa; } }
|
|
1890
|
+
`;
|
|
1891
|
+
var READER_BANNER_STYLE = `${APP_ACCENT_TOKENS}
|
|
1892
|
+
.r-banner { background:var(--k-accent); color:var(--k-accent-contrast);
|
|
1893
|
+
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
|
1894
|
+
font-size:13.5px; font-weight:500; line-height:1.4; }
|
|
1895
|
+
/* The tag runs in, inline with the sentence, so on a phone the sentence wraps beneath it at
|
|
1896
|
+
the strip's full width rather than in a narrow column beside it. */
|
|
1897
|
+
.r-banner-in { max-width:var(--r-measure); margin:0 auto; padding:10px 24px; }
|
|
1898
|
+
.r-banner-tag { display:inline-block; margin-right:10px; font-size:10px; letter-spacing:.09em;
|
|
1899
|
+
text-transform:uppercase; font-weight:700; padding:1px 7px; border-radius:999px;
|
|
1900
|
+
background:var(--k-accent-contrast); color:var(--k-accent); }
|
|
1901
|
+
.r-banner-text { overflow-wrap:anywhere; }
|
|
1902
|
+
`;
|
|
1903
|
+
var READER_STYLE = `
|
|
1904
|
+
:root {
|
|
1905
|
+
color-scheme: light dark;
|
|
1906
|
+
--r-bg:${READER_BG_LIGHT}; --r-ink:#1b1a17; --r-card:#ffffff; --r-line:#e7e5df; --r-mut:#6b6a63;
|
|
1907
|
+
--r-serif:Fraunces,Georgia,'Iowan Old Style','Times New Roman',serif;
|
|
1908
|
+
--r-sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
|
1909
|
+
--r-measure:820px; /* one reading width shared by the masthead, body, and footer so their edges line up */
|
|
1910
|
+
/* The masthead sits on the card surface with a hairline rule (the footer's top
|
|
1911
|
+
rule, mirrored); the eyebrow/link accent inherits the ink color. */
|
|
1912
|
+
--brand-ink:currentColor;
|
|
1925
1913
|
}
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
}
|
|
1929
|
-
function applyPatch(current, patch) {
|
|
1930
|
-
const next = {
|
|
1931
|
-
...current,
|
|
1932
|
-
publication: { ...current.publication },
|
|
1933
|
-
confirmationEmail: { ...current.confirmationEmail },
|
|
1934
|
-
notifications: { ...current.notifications }
|
|
1935
|
-
};
|
|
1936
|
-
if (patch.testRecipients !== void 0) {
|
|
1937
|
-
next.testRecipients = normalizeRecipients(patch.testRecipients);
|
|
1938
|
-
}
|
|
1939
|
-
if (patch.publication !== void 0) {
|
|
1940
|
-
const p = patch.publication;
|
|
1941
|
-
if (p.name !== void 0) {
|
|
1942
|
-
next.publication.name = normalizeText(p.name, "name", MAX_NAME);
|
|
1943
|
-
}
|
|
1944
|
-
if (p.tagline !== void 0) {
|
|
1945
|
-
next.publication.tagline = normalizeText(p.tagline, "tagline", MAX_TAGLINE);
|
|
1946
|
-
}
|
|
1947
|
-
if (p.address !== void 0) {
|
|
1948
|
-
next.publication.address = normalizeText(p.address, "address", MAX_ADDRESS);
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1951
|
-
if (patch.emailTemplate !== void 0) {
|
|
1952
|
-
if (typeof patch.emailTemplate !== "string") {
|
|
1953
|
-
throw new Error("emailTemplate must be a string");
|
|
1954
|
-
}
|
|
1955
|
-
if (patch.emailTemplate.length > MAX_TEMPLATE) {
|
|
1956
|
-
throw new Error(`emailTemplate must be ${MAX_TEMPLATE} characters or fewer`);
|
|
1957
|
-
}
|
|
1958
|
-
next.emailTemplate = patch.emailTemplate;
|
|
1959
|
-
}
|
|
1960
|
-
if (patch.confirmationEmail !== void 0) {
|
|
1961
|
-
const c = patch.confirmationEmail;
|
|
1962
|
-
if (c.subject !== void 0) {
|
|
1963
|
-
next.confirmationEmail.subject = normalizeText(c.subject, "subject", MAX_CE_SUBJECT);
|
|
1964
|
-
}
|
|
1965
|
-
if (c.body !== void 0) {
|
|
1966
|
-
next.confirmationEmail.body = normalizeText(c.body, "message", MAX_CE_BODY);
|
|
1967
|
-
}
|
|
1968
|
-
if (c.buttonLabel !== void 0) {
|
|
1969
|
-
next.confirmationEmail.buttonLabel = normalizeText(
|
|
1970
|
-
c.buttonLabel,
|
|
1971
|
-
"button label",
|
|
1972
|
-
MAX_CE_BUTTON
|
|
1973
|
-
);
|
|
1974
|
-
}
|
|
1975
|
-
if (c.reassurance !== void 0) {
|
|
1976
|
-
next.confirmationEmail.reassurance = normalizeText(
|
|
1977
|
-
c.reassurance,
|
|
1978
|
-
"reassurance line",
|
|
1979
|
-
MAX_CE_REASSURANCE
|
|
1980
|
-
);
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
if (patch.notifications?.to !== void 0) {
|
|
1984
|
-
next.notifications.to = normalizeNotifyTo(patch.notifications.to);
|
|
1985
|
-
}
|
|
1986
|
-
return next;
|
|
1987
|
-
}
|
|
1988
|
-
function normalizeNotifyTo(raw) {
|
|
1989
|
-
if (typeof raw !== "string") {
|
|
1990
|
-
throw new Error("notifications address must be a string");
|
|
1991
|
-
}
|
|
1992
|
-
const email2 = normalizeEmail(raw);
|
|
1993
|
-
if (email2 && !isValidEmail(email2)) {
|
|
1994
|
-
throw new Error(`not a valid email address: ${raw}`);
|
|
1995
|
-
}
|
|
1996
|
-
return email2;
|
|
1997
|
-
}
|
|
1998
|
-
function withPublicationLogo(current, logo) {
|
|
1999
|
-
return { ...current, publication: { ...current.publication, logo } };
|
|
2000
|
-
}
|
|
2001
|
-
async function setPublicationLogo(db, logo) {
|
|
2002
|
-
return updateSettingsWith(db, (current) => withPublicationLogo(current, logo));
|
|
2003
|
-
}
|
|
2004
|
-
function normalizeText(v, what, max) {
|
|
2005
|
-
if (typeof v !== "string") {
|
|
2006
|
-
throw new Error(`${what} must be a string`);
|
|
2007
|
-
}
|
|
2008
|
-
return v.trim().slice(0, max);
|
|
2009
|
-
}
|
|
2010
|
-
function normalizeRecipients(input) {
|
|
2011
|
-
if (!Array.isArray(input)) {
|
|
2012
|
-
throw new Error("testRecipients must be a list of email addresses");
|
|
2013
|
-
}
|
|
2014
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2015
|
-
const out = [];
|
|
2016
|
-
for (const raw of input) {
|
|
2017
|
-
if (typeof raw !== "string") {
|
|
2018
|
-
throw new Error("each test recipient must be a string");
|
|
2019
|
-
}
|
|
2020
|
-
const email2 = normalizeEmail(raw);
|
|
2021
|
-
if (!email2) {
|
|
2022
|
-
continue;
|
|
2023
|
-
}
|
|
2024
|
-
if (!isValidEmail(email2)) {
|
|
2025
|
-
throw new Error(`not a valid email address: ${raw}`);
|
|
2026
|
-
}
|
|
2027
|
-
if (seen.has(email2)) {
|
|
2028
|
-
continue;
|
|
2029
|
-
}
|
|
2030
|
-
seen.add(email2);
|
|
2031
|
-
out.push(email2);
|
|
2032
|
-
}
|
|
2033
|
-
if (out.length > MAX_TEST_RECIPIENTS) {
|
|
2034
|
-
throw new Error(`at most ${MAX_TEST_RECIPIENTS} test recipients`);
|
|
2035
|
-
}
|
|
2036
|
-
return out;
|
|
2037
|
-
}
|
|
2038
|
-
|
|
2039
|
-
// src/lib/html.ts
|
|
2040
|
-
function escapeHtml(s) {
|
|
2041
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2042
|
-
}
|
|
2043
|
-
function escapeHtmlAttr(s) {
|
|
2044
|
-
return escapeHtml(s).replace(/"/g, """).replace(/'/g, "'");
|
|
2045
|
-
}
|
|
2046
|
-
|
|
2047
|
-
// src/lib/page_headers.ts
|
|
2048
|
-
function csp(formAction) {
|
|
2049
|
-
return [
|
|
2050
|
-
"default-src 'none'",
|
|
2051
|
-
"script-src 'none'",
|
|
2052
|
-
"object-src 'none'",
|
|
2053
|
-
"frame-src 'none'",
|
|
2054
|
-
"base-uri 'none'",
|
|
2055
|
-
`form-action ${formAction}`,
|
|
2056
|
-
"frame-ancestors 'none'",
|
|
2057
|
-
"img-src * data:",
|
|
2058
|
-
"style-src 'unsafe-inline' https://fonts.googleapis.com",
|
|
2059
|
-
"font-src https://fonts.gstatic.com"
|
|
2060
|
-
].join("; ");
|
|
2061
|
-
}
|
|
2062
|
-
function headers(formAction) {
|
|
2063
|
-
return {
|
|
2064
|
-
"content-security-policy": csp(formAction),
|
|
2065
|
-
"x-frame-options": "DENY",
|
|
2066
|
-
"x-content-type-options": "nosniff"
|
|
2067
|
-
};
|
|
2068
|
-
}
|
|
2069
|
-
var PAGE_SECURITY_HEADERS = headers("'self'");
|
|
2070
|
-
var POST_PAGE_SECURITY_HEADERS = headers("'none'");
|
|
2071
|
-
|
|
2072
|
-
// src/lib/page.ts
|
|
2073
|
-
var TOKENS = `
|
|
2074
|
-
:root {
|
|
2075
|
-
color-scheme: light dark;
|
|
2076
|
-
--fg:#18181b; --muted:#71717a; --line:#e4e4e7; --bg:#f4f4f5; --card:#fff;
|
|
2077
|
-
--accent:#18181b; --accent-fg:#fff; --danger:#b91c1c; --danger-fg:#fff;
|
|
2078
|
-
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
|
2079
|
-
}
|
|
2080
|
-
@media (prefers-color-scheme: dark) {
|
|
2081
|
-
:root { --fg:#ededed; --muted:#a1a1aa; --line:#2e2e33; --bg:#18181b; --card:#232327;
|
|
2082
|
-
--accent:#e5e7eb; --accent-fg:#18181b; --danger:#f87171; --danger-fg:#18181b; }
|
|
2083
|
-
}
|
|
2084
|
-
* { box-sizing: border-box; }
|
|
2085
|
-
body { margin:0; font-family:var(--font); background:var(--bg); color:var(--fg); }
|
|
2086
|
-
a { color: inherit; }
|
|
2087
|
-
.muted { color: var(--muted); }
|
|
2088
|
-
`;
|
|
2089
|
-
var CARD_STYLE = `${TOKENS}
|
|
2090
|
-
.wrap { max-width:480px; margin:64px auto; padding:32px; background:var(--card);
|
|
2091
|
-
border:1px solid var(--line); border-radius:10px; line-height:1.6; }
|
|
2092
|
-
h1 { margin-top:0; }
|
|
2093
|
-
input { font:inherit; font-size:16px; padding:10px; width:100%; border:1px solid var(--line);
|
|
2094
|
-
border-radius:6px; background:var(--card); color:var(--fg); }
|
|
2095
|
-
.btn { font:inherit; font-size:16px; margin-top:12px; padding:10px 18px; border-radius:6px;
|
|
2096
|
-
border:1px solid var(--accent); background:var(--accent); color:var(--accent-fg); cursor:pointer; }
|
|
2097
|
-
.btn:hover { filter: brightness(1.08); }
|
|
2098
|
-
.btn:active { transform: translateY(1px); }
|
|
2099
|
-
.btn-danger { border-color:var(--danger); background:var(--danger); color:var(--danger-fg); }
|
|
2100
|
-
.btn:focus-visible, input:focus-visible, a:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
|
2101
|
-
.wrap p a { text-decoration: underline; }
|
|
2102
|
-
@media (prefers-color-scheme: dark) { .btn:focus-visible, input:focus-visible, a:focus-visible { outline-color:#60a5fa; } }
|
|
2103
|
-
`;
|
|
2104
|
-
function htmlPage(title, bodyHtml, status = 200, devDashboardUrl2) {
|
|
2105
|
-
const doc = `<!doctype html>
|
|
2106
|
-
<html lang="en">
|
|
2107
|
-
<head>
|
|
2108
|
-
<meta charset="utf-8">
|
|
2109
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2110
|
-
<title>${escapeHtml(title)}</title>
|
|
2111
|
-
<style>${CARD_STYLE}</style>${devDashboardStyle(devDashboardUrl2)}
|
|
2112
|
-
</head>
|
|
2113
|
-
<body><div class="wrap">${bodyHtml}</div>${devDashboardBadge(devDashboardUrl2)}</body>
|
|
2114
|
-
</html>`;
|
|
2115
|
-
return new Response(doc, {
|
|
2116
|
-
status,
|
|
2117
|
-
headers: {
|
|
2118
|
-
...PAGE_SECURITY_HEADERS,
|
|
2119
|
-
"content-type": "text/html; charset=utf-8",
|
|
2120
|
-
"x-robots-tag": "noindex"
|
|
2121
|
-
}
|
|
2122
|
-
});
|
|
2123
|
-
}
|
|
2124
|
-
var READER_BG_LIGHT = "#fbfbfa";
|
|
2125
|
-
var READER_BG_DARK = "#12110f";
|
|
2126
|
-
var DEV_BADGE_STYLE = `
|
|
2127
|
-
/* The app's brand accent (DESIGN.md \xA73), mirrored here for the ONE dev-only element
|
|
2128
|
-
the reader surface carries. That badge is app chrome, not the publication's
|
|
2129
|
-
identity, so it wears the app's action color, matching a Primary action, rather
|
|
2130
|
-
than the reader ink; keep these values in sync with styles.css. This is the
|
|
2131
|
-
single, deliberate exception to "the reader surface is not styled from the admin
|
|
2132
|
-
tokens" (DESIGN.md, reader-surface note). */
|
|
2133
|
-
:root { --k-accent:#3355cc; --k-accent-contrast:#ffffff; }
|
|
2134
|
-
@media (prefers-color-scheme: dark) { :root { --k-accent:#7d9bff; --k-accent-contrast:#10131f; } }
|
|
2135
|
-
.r-dev { position:fixed; right:18px; bottom:18px; z-index:50; display:inline-flex; align-items:center; gap:8px;
|
|
2136
|
-
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; line-height:1.6;
|
|
2137
|
-
font-size:13px; font-weight:600; text-decoration:none; padding:9px 15px 9px 10px; border-radius:999px;
|
|
2138
|
-
background:var(--k-accent); color:var(--k-accent-contrast); border:1px solid var(--k-accent);
|
|
2139
|
-
box-shadow:0 6px 20px rgba(0,0,0,.22); }
|
|
2140
|
-
.r-dev:hover { filter:brightness(1.08); }
|
|
2141
|
-
/* Inverted badge \u2014 solid contrast fill, accent-colored text \u2014 so the tiny "DEV"
|
|
2142
|
-
label clears WCAG AA on the accent pill in both themes (a translucent tint left
|
|
2143
|
-
it ~3.6:1). */
|
|
2144
|
-
.r-dev .r-dev-tag { font-size:9.5px; letter-spacing:.09em; text-transform:uppercase; font-weight:700;
|
|
2145
|
-
padding:2px 7px; border-radius:999px;
|
|
2146
|
-
background:var(--k-accent-contrast); color:var(--k-accent); }
|
|
2147
|
-
.r-dev:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
|
2148
|
-
@media (prefers-color-scheme: dark) { .r-dev:focus-visible { outline-color:#60a5fa; } }
|
|
2149
|
-
`;
|
|
2150
|
-
var READER_STYLE = `
|
|
2151
|
-
:root {
|
|
2152
|
-
color-scheme: light dark;
|
|
2153
|
-
--r-bg:${READER_BG_LIGHT}; --r-ink:#1b1a17; --r-card:#ffffff; --r-line:#e7e5df; --r-mut:#6b6a63;
|
|
2154
|
-
--r-serif:Fraunces,Georgia,'Iowan Old Style','Times New Roman',serif;
|
|
2155
|
-
--r-sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
|
2156
|
-
--r-measure:820px; /* one reading width shared by the masthead, body, and footer so their edges line up */
|
|
2157
|
-
/* The masthead sits on the card surface with a hairline rule (the footer's top
|
|
2158
|
-
rule, mirrored); the eyebrow/link accent inherits the ink color. */
|
|
2159
|
-
--brand-ink:currentColor;
|
|
2160
|
-
}
|
|
2161
|
-
@media (prefers-color-scheme: dark) {
|
|
2162
|
-
:root { --r-bg:${READER_BG_DARK}; --r-ink:#ece9e3; --r-card:#1b1a17; --r-line:#2c2a25; --r-mut:#a5a199; }
|
|
1914
|
+
@media (prefers-color-scheme: dark) {
|
|
1915
|
+
:root { --r-bg:${READER_BG_DARK}; --r-ink:#ece9e3; --r-card:#1b1a17; --r-line:#2c2a25; --r-mut:#a5a199; }
|
|
2163
1916
|
}
|
|
2164
1917
|
* { box-sizing:border-box; }
|
|
2165
1918
|
body { margin:0; font-family:var(--r-sans); background:var(--r-bg); color:var(--r-ink); line-height:1.6; }
|
|
@@ -2204,10 +1957,13 @@ a.r-t:hover { text-decoration:underline; }
|
|
|
2204
1957
|
.r-empty { color:var(--r-mut); padding:20px 0; }
|
|
2205
1958
|
.r-arch { display:inline-block; margin-top:24px; font-size:14px; font-weight:600; color:var(--brand-ink); text-decoration:none; }
|
|
2206
1959
|
.r-arch:hover { text-decoration:underline; }
|
|
1960
|
+
/* A lede already ends in its own margin, so a link straight beneath it sits closer. */
|
|
1961
|
+
.r-lead + .r-arch { margin-top:0; }
|
|
2207
1962
|
|
|
2208
1963
|
/* Subscribe / message pages: a headline, lede, and a branded form. */
|
|
2209
1964
|
.r-h1 { font-family:var(--r-serif); font-size:30px; line-height:1.12; font-weight:600; letter-spacing:-.01em; margin:0 0 12px; }
|
|
2210
1965
|
.r-lead { font-size:16px; color:var(--r-mut); margin:0 0 22px; max-width:54ch; }
|
|
1966
|
+
.r-lead strong { color:var(--r-ink); font-weight:600; }
|
|
2211
1967
|
.r-form { display:flex; gap:10px; flex-wrap:wrap; max-width:460px; }
|
|
2212
1968
|
.r-form input { flex:1 1 220px; font:inherit; font-size:16px; padding:11px 13px; border:1px solid var(--r-line);
|
|
2213
1969
|
border-radius:9px; background:var(--r-card); color:var(--r-ink); }
|
|
@@ -2220,8 +1976,10 @@ a.r-t:hover { text-decoration:underline; }
|
|
|
2220
1976
|
.r-foot { border-top:1px solid var(--r-line); }
|
|
2221
1977
|
.r-foot-in { max-width:var(--r-measure); margin:0 auto; padding:22px 24px 40px; font-size:12.5px; color:var(--r-mut); }
|
|
2222
1978
|
|
|
2223
|
-
a:focus-visible, .r-sub:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
|
2224
|
-
@media (prefers-color-scheme: dark) {
|
|
1979
|
+
a:focus-visible, .r-sub:focus-visible, .r-btn:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
|
1980
|
+
@media (prefers-color-scheme: dark) {
|
|
1981
|
+
a:focus-visible, .r-sub:focus-visible, .r-btn:focus-visible { outline-color:#60a5fa; }
|
|
1982
|
+
}
|
|
2225
1983
|
@media (max-width:560px) {
|
|
2226
1984
|
.r-mast-in { flex-wrap:wrap; gap:12px; }
|
|
2227
1985
|
.r-cta { margin-left:0; }
|
|
@@ -2231,6 +1989,9 @@ a:focus-visible, .r-sub:focus-visible { outline:2px solid #2563eb; outline-offse
|
|
|
2231
1989
|
`;
|
|
2232
1990
|
var FRAUNCES_FONT_LINKS = `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..600;1,9..144,400..600&display=swap">`;
|
|
2233
1991
|
var ARCHIVE_POST_HEAD = FRAUNCES_FONT_LINKS + `<style>body.k-bg,body .k-bg,body .k-card{background:${READER_BG_LIGHT}!important}.k-mast{color:#52525b;border-bottom:1px solid #e4e4e7}.k-mast a{color:#18181b}@media(prefers-color-scheme:dark){body.k-bg,body .k-bg,body .k-card{background:${READER_BG_DARK}!important}.k-mast{color:#a1a1aa;border-bottom-color:#2e2e33}.k-mast a{color:#ededed}}</style>`;
|
|
1992
|
+
function readerIconLink(logoUrl) {
|
|
1993
|
+
return logoUrl ? `<link rel="icon" href="${escapeHtmlAttr(logoUrl)}">` : "";
|
|
1994
|
+
}
|
|
2234
1995
|
function devDashboardStyle(url) {
|
|
2235
1996
|
return url ? `<style>${DEV_BADGE_STYLE}</style>` : "";
|
|
2236
1997
|
}
|
|
@@ -2240,26 +2001,35 @@ function devDashboardBadge(url) {
|
|
|
2240
2001
|
function archiveDevDashboardChrome(url) {
|
|
2241
2002
|
return { head: devDashboardStyle(url), masthead: devDashboardBadge(url) };
|
|
2242
2003
|
}
|
|
2004
|
+
function readerBannerStyle(banner) {
|
|
2005
|
+
return banner ? `<style>${READER_BANNER_STYLE}</style>` : "";
|
|
2006
|
+
}
|
|
2243
2007
|
function readerPage(opts) {
|
|
2244
2008
|
const { name, tagline, logoUrl } = opts.identity;
|
|
2245
2009
|
const logo = logoUrl ? `<span class="r-logo"><img src="${escapeHtmlAttr(logoUrl)}" alt=""></span>` : "";
|
|
2010
|
+
const nameHtml = name ? `<span class="r-name">${escapeHtml(name)}</span>` : "";
|
|
2246
2011
|
const tag = tagline ? `<div class="r-tag">${escapeHtml(tagline)}</div>` : "";
|
|
2012
|
+
const text = nameHtml || tag ? `<span>${nameHtml}${tag}</span>` : "";
|
|
2013
|
+
const label = text ? "" : logo ? ' aria-label="Home"' : "";
|
|
2014
|
+
const brand = opts.homeUrl === null ? logo || text ? `<span class="r-brand">${logo}${text}</span>` : "" : `<a class="r-brand" href="${escapeHtmlAttr(opts.homeUrl)}"${label}>${logo || text ? `${logo}${text}` : "Archive"}</a>`;
|
|
2247
2015
|
const cta = opts.subscribeUrl ? `<span class="r-cta"><a class="r-sub" href="${escapeHtmlAttr(opts.subscribeUrl)}">Subscribe here →</a></span>` : "";
|
|
2016
|
+
const masthead2 = brand || cta ? `<header class="r-mast"><div class="r-mast-in">
|
|
2017
|
+
${brand}
|
|
2018
|
+
${cta}
|
|
2019
|
+
</div></header>
|
|
2020
|
+
` : "";
|
|
2021
|
+
const banner = opts.banner ? `<div class="r-banner" role="note"><div class="r-banner-in"><span class="r-banner-tag">${escapeHtml(opts.banner.tag)}</span><span class="r-banner-text">${escapeHtml(opts.banner.text)}</span></div></div>` : "";
|
|
2248
2022
|
const doc = `<!doctype html>
|
|
2249
2023
|
<html lang="en">
|
|
2250
2024
|
<head>
|
|
2251
2025
|
<meta charset="utf-8">
|
|
2252
2026
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2253
2027
|
<title>${escapeHtml(opts.title)}</title>
|
|
2254
|
-
${FRAUNCES_FONT_LINKS}
|
|
2255
|
-
<style>${READER_STYLE}</style>${devDashboardStyle(opts.devDashboardUrl)}
|
|
2028
|
+
${readerIconLink(logoUrl)}${FRAUNCES_FONT_LINKS}
|
|
2029
|
+
<style>${READER_STYLE}</style>${readerBannerStyle(opts.banner)}${devDashboardStyle(opts.devDashboardUrl)}
|
|
2256
2030
|
</head>
|
|
2257
2031
|
<body>
|
|
2258
|
-
<
|
|
2259
|
-
<a class="r-brand" href="${escapeHtmlAttr(opts.homeUrl)}">${logo}<span><span class="r-name">${escapeHtml(name)}</span>${tag}</span></a>
|
|
2260
|
-
${cta}
|
|
2261
|
-
</div></header>
|
|
2262
|
-
<main class="r-body">${opts.mainHtml}</main>
|
|
2032
|
+
${banner}${masthead2}<main class="r-body">${opts.mainHtml}</main>
|
|
2263
2033
|
<footer class="r-foot"><div class="r-foot-in">Powered by Kestrel</div></footer>
|
|
2264
2034
|
${devDashboardBadge(opts.devDashboardUrl)}
|
|
2265
2035
|
</body>
|
|
@@ -2269,10 +2039,42 @@ ${devDashboardBadge(opts.devDashboardUrl)}
|
|
|
2269
2039
|
headers: {
|
|
2270
2040
|
...PAGE_SECURITY_HEADERS,
|
|
2271
2041
|
"content-type": "text/html; charset=utf-8",
|
|
2272
|
-
"cache-control": "public, max-age=300"
|
|
2042
|
+
...opts.private ? { "cache-control": "no-store", "x-robots-tag": "noindex" } : { "cache-control": "public, max-age=300" }
|
|
2273
2043
|
}
|
|
2274
2044
|
});
|
|
2275
2045
|
}
|
|
2046
|
+
function pageTitle(page, name) {
|
|
2047
|
+
return name ? `${page} \xB7 ${name}` : page;
|
|
2048
|
+
}
|
|
2049
|
+
function messagePage(opts) {
|
|
2050
|
+
return readerPage({
|
|
2051
|
+
identity: opts.identity,
|
|
2052
|
+
homeUrl: opts.homeUrl,
|
|
2053
|
+
title: pageTitle(opts.title, opts.identity.name),
|
|
2054
|
+
mainHtml: `<p class="r-ey">${escapeHtml(opts.eyebrow)}</p><h1 class="r-h1">${escapeHtml(opts.headline)}</h1><p class="r-lead">${opts.ledeHtml}</p>` + (opts.afterHtml ?? ""),
|
|
2055
|
+
status: opts.status,
|
|
2056
|
+
devDashboardUrl: opts.devDashboardUrl,
|
|
2057
|
+
banner: opts.banner,
|
|
2058
|
+
private: true
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
function unavailablePage() {
|
|
2062
|
+
const res = messagePage({
|
|
2063
|
+
identity: { name: "" },
|
|
2064
|
+
// No way home: `/` is this same page until the configuration is fixed.
|
|
2065
|
+
homeUrl: null,
|
|
2066
|
+
title: "Temporarily unavailable",
|
|
2067
|
+
eyebrow: "Unavailable",
|
|
2068
|
+
headline: "This page is temporarily unavailable",
|
|
2069
|
+
ledeHtml: "The newsletter can't be reached right now. Please try again a little later.",
|
|
2070
|
+
status: 503
|
|
2071
|
+
});
|
|
2072
|
+
res.headers.set("retry-after", "300");
|
|
2073
|
+
return res;
|
|
2074
|
+
}
|
|
2075
|
+
function archiveLink(url, label) {
|
|
2076
|
+
return `<a class="r-arch" href="${escapeHtmlAttr(url)}">${escapeHtml(label)} →</a>`;
|
|
2077
|
+
}
|
|
2276
2078
|
function postRow(i) {
|
|
2277
2079
|
return `<li class="r-item"><span class="r-d">${escapeHtml(i.dateLabel)}</span><a class="r-t" href="${escapeHtmlAttr(i.url)}">${escapeHtml(i.title)}</a></li>`;
|
|
2278
2080
|
}
|
|
@@ -2284,14 +2086,14 @@ function landingPage(opts) {
|
|
|
2284
2086
|
const f = opts.featured;
|
|
2285
2087
|
const feature = `<p class="r-ey">Latest post</p><article class="r-feat"><p class="r-k">${escapeHtml(f.dateLabel)}</p><h2><a href="${escapeHtmlAttr(f.url)}">${escapeHtml(f.title)}</a></h2><a class="r-link" href="${escapeHtmlAttr(f.url)}">Read here →</a></article>`;
|
|
2286
2088
|
const list5 = opts.recent.length ? `<ul class="r-list">${opts.recent.map(postRow).join("")}</ul>` : "";
|
|
2287
|
-
|
|
2288
|
-
main = feature + list5 + archive;
|
|
2089
|
+
main = feature + list5 + archiveLink(opts.archiveUrl, "Browse the full archive");
|
|
2289
2090
|
}
|
|
2290
2091
|
return readerPage({
|
|
2291
2092
|
identity: opts.identity,
|
|
2292
2093
|
subscribeUrl: opts.subscribeUrl,
|
|
2293
2094
|
homeUrl: opts.homeUrl,
|
|
2294
|
-
|
|
2095
|
+
// The front door is titled by the publication; with no name, by what it is.
|
|
2096
|
+
title: opts.identity.name || "Home",
|
|
2295
2097
|
mainHtml: main,
|
|
2296
2098
|
devDashboardUrl: opts.devDashboardUrl
|
|
2297
2099
|
});
|
|
@@ -2302,7 +2104,7 @@ function archiveIndexPage(opts) {
|
|
|
2302
2104
|
identity: opts.identity,
|
|
2303
2105
|
subscribeUrl: opts.subscribeUrl,
|
|
2304
2106
|
homeUrl: opts.homeUrl,
|
|
2305
|
-
title:
|
|
2107
|
+
title: pageTitle("Archive", opts.identity.name),
|
|
2306
2108
|
mainHtml: `<p class="r-ey">Archive</p>${list5}`,
|
|
2307
2109
|
devDashboardUrl: opts.devDashboardUrl
|
|
2308
2110
|
});
|
|
@@ -2463,18 +2265,309 @@ ${ARCHIVE_MASTHEAD_ANCHOR}${i.bodyHtml}
|
|
|
2463
2265
|
</body></html>`;
|
|
2464
2266
|
}
|
|
2465
2267
|
function archiveMasthead(opts) {
|
|
2466
|
-
const name = escapeHtml(opts.name);
|
|
2268
|
+
const name = escapeHtml(opts.name || "Archive");
|
|
2467
2269
|
const date = escapeHtml(opts.dateLabel);
|
|
2468
2270
|
const url = escapeHtmlAttr(opts.indexUrl);
|
|
2469
2271
|
return `<div class="k-mast" style="display:flex;justify-content:space-between;align-items:baseline;gap:16px;font-family:${FONT};font-size:13px;line-height:1.5;padding-bottom:14px;margin-bottom:28px;"><a href="${url}" style="text-decoration:none;font-weight:600;font-size:15px;font-family:${HEADING_FONT};"><span style="font-family:${FONT};font-weight:500;">←</span> ${name}</a><span style="white-space:nowrap;">${date}</span></div>`;
|
|
2470
2272
|
}
|
|
2471
2273
|
|
|
2472
2274
|
// src/render/template_engine.ts
|
|
2473
|
-
import { initWasm, inline } from "@css-inline/css-inline-wasm";
|
|
2474
|
-
import wasmModule from "@css-inline/css-inline-wasm/index_bg.wasm";
|
|
2275
|
+
import { initWasm, inline } from "@css-inline/css-inline-wasm";
|
|
2276
|
+
import wasmModule from "@css-inline/css-inline-wasm/index_bg.wasm";
|
|
2277
|
+
|
|
2278
|
+
// shared/publication.ts
|
|
2279
|
+
function fromDisplayName(fromAddress) {
|
|
2280
|
+
const header = fromAddress.trim();
|
|
2281
|
+
const lt = header.lastIndexOf("<");
|
|
2282
|
+
if (lt < 0 || !header.endsWith(">")) {
|
|
2283
|
+
return "";
|
|
2284
|
+
}
|
|
2285
|
+
const display = header.slice(0, lt).trim();
|
|
2286
|
+
if (display.length >= 2 && display.startsWith('"') && display.endsWith('"')) {
|
|
2287
|
+
return display.slice(1, -1).replace(/\\(.)/g, "$1").trim();
|
|
2288
|
+
}
|
|
2289
|
+
return display;
|
|
2290
|
+
}
|
|
2291
|
+
function publicationName(name, fromAddress) {
|
|
2292
|
+
return name || fromDisplayName(fromAddress);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// src/db/settings.ts
|
|
2296
|
+
var BRANDING_LOGO_KEY = "branding/logo";
|
|
2297
|
+
function newLogoKey(version) {
|
|
2298
|
+
return `${BRANDING_LOGO_KEY}/${version}-${crypto.randomUUID().slice(0, 8)}`;
|
|
2299
|
+
}
|
|
2300
|
+
var LOGO_KEY = /^branding\/logo\/\d+-[0-9a-f]{8}$/;
|
|
2301
|
+
function publicationLogoUrl(mediaBase, logo) {
|
|
2302
|
+
if (!logo) {
|
|
2303
|
+
return "";
|
|
2304
|
+
}
|
|
2305
|
+
return logo.key ? `${mediaBase}/${logo.key}` : `${mediaBase}/${BRANDING_LOGO_KEY}?v=${logo.version}`;
|
|
2306
|
+
}
|
|
2307
|
+
var DEFAULT_CONFIRMATION_EMAIL = {
|
|
2308
|
+
subject: "Confirm your subscription",
|
|
2309
|
+
body: "Thanks for subscribing. Please confirm your email address to start receiving the newsletter.",
|
|
2310
|
+
buttonLabel: "Confirm subscription",
|
|
2311
|
+
reassurance: "If you didn't request this, you can safely ignore this email."
|
|
2312
|
+
};
|
|
2313
|
+
var DEFAULT_SETTINGS = {
|
|
2314
|
+
testRecipients: [],
|
|
2315
|
+
publication: { name: "", tagline: "", address: "", logo: null },
|
|
2316
|
+
emailTemplate: "",
|
|
2317
|
+
confirmationEmail: DEFAULT_CONFIRMATION_EMAIL,
|
|
2318
|
+
notifications: { to: "" }
|
|
2319
|
+
};
|
|
2320
|
+
var MAX_TEST_RECIPIENTS = 20;
|
|
2321
|
+
var MAX_NAME = 120;
|
|
2322
|
+
var MAX_TAGLINE = 200;
|
|
2323
|
+
var MAX_ADDRESS = 300;
|
|
2324
|
+
var MAX_TEMPLATE = 4e4;
|
|
2325
|
+
var MAX_CE_SUBJECT = 200;
|
|
2326
|
+
var MAX_CE_BODY = 1e3;
|
|
2327
|
+
var MAX_CE_BUTTON = 80;
|
|
2328
|
+
var MAX_CE_REASSURANCE = 400;
|
|
2329
|
+
function coercePublication(raw) {
|
|
2330
|
+
const o = raw && typeof raw === "object" ? raw : {};
|
|
2331
|
+
const str = (v) => typeof v === "string" ? v : "";
|
|
2332
|
+
const lr = o.logo;
|
|
2333
|
+
const key = lr && typeof lr.key === "string" && LOGO_KEY.test(lr.key);
|
|
2334
|
+
const logo = lr && typeof lr === "object" && typeof lr.version === "number" ? {
|
|
2335
|
+
version: lr.version,
|
|
2336
|
+
contentType: str(lr.contentType) || "image/png",
|
|
2337
|
+
...key ? { key: lr.key } : {}
|
|
2338
|
+
} : null;
|
|
2339
|
+
return {
|
|
2340
|
+
name: str(o.name).slice(0, MAX_NAME),
|
|
2341
|
+
tagline: str(o.tagline).slice(0, MAX_TAGLINE),
|
|
2342
|
+
address: str(o.address).slice(0, MAX_ADDRESS),
|
|
2343
|
+
logo
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
function coerceConfirmation(raw) {
|
|
2347
|
+
const o = raw && typeof raw === "object" ? raw : {};
|
|
2348
|
+
const str = (v) => typeof v === "string" ? v : "";
|
|
2349
|
+
return {
|
|
2350
|
+
subject: str(o.subject).slice(0, MAX_CE_SUBJECT),
|
|
2351
|
+
body: str(o.body).slice(0, MAX_CE_BODY),
|
|
2352
|
+
buttonLabel: str(o.buttonLabel).slice(0, MAX_CE_BUTTON),
|
|
2353
|
+
reassurance: str(o.reassurance).slice(0, MAX_CE_REASSURANCE)
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
function coerce(raw) {
|
|
2357
|
+
const o = raw && typeof raw === "object" ? raw : {};
|
|
2358
|
+
const list5 = Array.isArray(o.testRecipients) ? o.testRecipients : [];
|
|
2359
|
+
return {
|
|
2360
|
+
testRecipients: list5.filter((e) => typeof e === "string"),
|
|
2361
|
+
publication: coercePublication(o.publication),
|
|
2362
|
+
emailTemplate: typeof o.emailTemplate === "string" ? o.emailTemplate.slice(0, MAX_TEMPLATE) : "",
|
|
2363
|
+
// A row that predates this feature has no `confirmationEmail` key at all — that's
|
|
2364
|
+
// "never configured", so it gets the full built-in copy (reassurance included), NOT
|
|
2365
|
+
// a blank-reassurance row. Only once the key exists (the operator saved copy) does a
|
|
2366
|
+
// blank reassurance mean "deliberately omit the footer".
|
|
2367
|
+
confirmationEmail: "confirmationEmail" in o ? coerceConfirmation(o.confirmationEmail) : { ...DEFAULT_CONFIRMATION_EMAIL },
|
|
2368
|
+
notifications: coerceNotifications(o.notifications)
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
function coerceNotifications(raw) {
|
|
2372
|
+
const o = raw && typeof raw === "object" ? raw : {};
|
|
2373
|
+
return { to: typeof o.to === "string" ? o.to : "" };
|
|
2374
|
+
}
|
|
2375
|
+
function resolveConfirmationEmail(settings) {
|
|
2376
|
+
const c = settings.confirmationEmail;
|
|
2377
|
+
const d = DEFAULT_CONFIRMATION_EMAIL;
|
|
2378
|
+
return {
|
|
2379
|
+
subject: c.subject.trim() || d.subject,
|
|
2380
|
+
body: c.body.trim() || d.body,
|
|
2381
|
+
buttonLabel: c.buttonLabel.trim() || d.buttonLabel,
|
|
2382
|
+
reassurance: c.reassurance
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
async function readSettings(db) {
|
|
2386
|
+
const row = await db.prepare("SELECT data, updated_at FROM settings WHERE id = 1").first();
|
|
2387
|
+
if (!row) {
|
|
2388
|
+
return { settings: structuredClone(DEFAULT_SETTINGS), version: null };
|
|
2389
|
+
}
|
|
2390
|
+
return { settings: coerce(parseStored(row.data)), version: row.updated_at };
|
|
2391
|
+
}
|
|
2392
|
+
function parseStored(data) {
|
|
2393
|
+
let parsed;
|
|
2394
|
+
let problem = "is not a JSON object";
|
|
2395
|
+
try {
|
|
2396
|
+
parsed = JSON.parse(data);
|
|
2397
|
+
} catch (err) {
|
|
2398
|
+
problem = `is not valid JSON (${err instanceof Error ? err.message : String(err)})`;
|
|
2399
|
+
}
|
|
2400
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
2401
|
+
return parsed;
|
|
2402
|
+
}
|
|
2403
|
+
log.error("settings.corrupt", {
|
|
2404
|
+
message: `the settings row (settings.id = 1) ${problem}; reads and writes of settings fail until it is repaired by hand`,
|
|
2405
|
+
length: data.length
|
|
2406
|
+
});
|
|
2407
|
+
throw new HttpError(
|
|
2408
|
+
500,
|
|
2409
|
+
"settings_corrupt",
|
|
2410
|
+
"The stored settings can't be read, so they are left as they are and nothing will save over them. The server log (settings.corrupt) says what's wrong."
|
|
2411
|
+
);
|
|
2412
|
+
}
|
|
2413
|
+
async function getSettings(db) {
|
|
2414
|
+
return (await readSettings(db)).settings;
|
|
2415
|
+
}
|
|
2416
|
+
async function getSettingsForDisplay(db) {
|
|
2417
|
+
try {
|
|
2418
|
+
return await getSettings(db);
|
|
2419
|
+
} catch (err) {
|
|
2420
|
+
if (err instanceof HttpError && err.code === "settings_corrupt") {
|
|
2421
|
+
return structuredClone(DEFAULT_SETTINGS);
|
|
2422
|
+
}
|
|
2423
|
+
throw err;
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
async function ensureSettingsRow(db) {
|
|
2427
|
+
await db.prepare("INSERT OR IGNORE INTO settings (id, data, updated_at) VALUES (1, '{}', 0)").run();
|
|
2428
|
+
}
|
|
2429
|
+
function persistSettingsStmt(db, next, expected, guard2) {
|
|
2430
|
+
const extra = guard2 ? ` AND ${guard2.sql}` : "";
|
|
2431
|
+
return db.prepare(
|
|
2432
|
+
`UPDATE settings SET data = ?, updated_at = MAX(?, updated_at + 1)
|
|
2433
|
+
WHERE id = 1 AND updated_at = ?${extra}`
|
|
2434
|
+
).bind(JSON.stringify(next), Date.now(), expected, ...guard2?.binds ?? []);
|
|
2435
|
+
}
|
|
2436
|
+
var WRITE_RETRIES = 5;
|
|
2437
|
+
var SettingsContention = class extends HttpError {
|
|
2438
|
+
constructor() {
|
|
2439
|
+
super(409, "conflict", "settings changed concurrently; try again");
|
|
2440
|
+
}
|
|
2441
|
+
};
|
|
2442
|
+
async function updateSettingsWith(db, derive) {
|
|
2443
|
+
await ensureSettingsRow(db);
|
|
2444
|
+
for (let attempt = 0; attempt < WRITE_RETRIES; attempt++) {
|
|
2445
|
+
const { settings, version } = await readSettings(db);
|
|
2446
|
+
const next = derive(settings);
|
|
2447
|
+
const res = await persistSettingsStmt(db, next, version ?? 0).run();
|
|
2448
|
+
if ((res.meta.changes ?? 0) > 0) {
|
|
2449
|
+
return next;
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
throw new SettingsContention();
|
|
2453
|
+
}
|
|
2454
|
+
async function updateSettings(db, patch) {
|
|
2455
|
+
return updateSettingsWith(db, (current) => applyPatch(current, patch));
|
|
2456
|
+
}
|
|
2457
|
+
function applyPatch(current, patch) {
|
|
2458
|
+
const next = {
|
|
2459
|
+
...current,
|
|
2460
|
+
publication: { ...current.publication },
|
|
2461
|
+
confirmationEmail: { ...current.confirmationEmail },
|
|
2462
|
+
notifications: { ...current.notifications }
|
|
2463
|
+
};
|
|
2464
|
+
if (patch.testRecipients !== void 0) {
|
|
2465
|
+
next.testRecipients = normalizeRecipients(patch.testRecipients);
|
|
2466
|
+
}
|
|
2467
|
+
if (patch.publication !== void 0) {
|
|
2468
|
+
const p = patch.publication;
|
|
2469
|
+
if (p.name !== void 0) {
|
|
2470
|
+
next.publication.name = normalizeText(p.name, "name", MAX_NAME);
|
|
2471
|
+
}
|
|
2472
|
+
if (p.tagline !== void 0) {
|
|
2473
|
+
next.publication.tagline = normalizeText(p.tagline, "tagline", MAX_TAGLINE);
|
|
2474
|
+
}
|
|
2475
|
+
if (p.address !== void 0) {
|
|
2476
|
+
next.publication.address = normalizeText(p.address, "address", MAX_ADDRESS);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
if (patch.emailTemplate !== void 0) {
|
|
2480
|
+
if (typeof patch.emailTemplate !== "string") {
|
|
2481
|
+
throw new Error("emailTemplate must be a string");
|
|
2482
|
+
}
|
|
2483
|
+
if (patch.emailTemplate.length > MAX_TEMPLATE) {
|
|
2484
|
+
throw new Error(`emailTemplate must be ${MAX_TEMPLATE} characters or fewer`);
|
|
2485
|
+
}
|
|
2486
|
+
next.emailTemplate = patch.emailTemplate;
|
|
2487
|
+
}
|
|
2488
|
+
if (patch.confirmationEmail !== void 0) {
|
|
2489
|
+
const c = patch.confirmationEmail;
|
|
2490
|
+
if (c.subject !== void 0) {
|
|
2491
|
+
next.confirmationEmail.subject = normalizeText(c.subject, "subject", MAX_CE_SUBJECT);
|
|
2492
|
+
}
|
|
2493
|
+
if (c.body !== void 0) {
|
|
2494
|
+
next.confirmationEmail.body = normalizeText(c.body, "message", MAX_CE_BODY);
|
|
2495
|
+
}
|
|
2496
|
+
if (c.buttonLabel !== void 0) {
|
|
2497
|
+
next.confirmationEmail.buttonLabel = normalizeText(
|
|
2498
|
+
c.buttonLabel,
|
|
2499
|
+
"button label",
|
|
2500
|
+
MAX_CE_BUTTON
|
|
2501
|
+
);
|
|
2502
|
+
}
|
|
2503
|
+
if (c.reassurance !== void 0) {
|
|
2504
|
+
next.confirmationEmail.reassurance = normalizeText(
|
|
2505
|
+
c.reassurance,
|
|
2506
|
+
"reassurance line",
|
|
2507
|
+
MAX_CE_REASSURANCE
|
|
2508
|
+
);
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
if (patch.notifications?.to !== void 0) {
|
|
2512
|
+
next.notifications.to = normalizeNotifyTo(patch.notifications.to);
|
|
2513
|
+
}
|
|
2514
|
+
return next;
|
|
2515
|
+
}
|
|
2516
|
+
function normalizeNotifyTo(raw) {
|
|
2517
|
+
if (typeof raw !== "string") {
|
|
2518
|
+
throw new Error("notifications address must be a string");
|
|
2519
|
+
}
|
|
2520
|
+
const email2 = normalizeEmail(raw);
|
|
2521
|
+
if (email2 && !isValidEmail(email2)) {
|
|
2522
|
+
throw new Error(`not a valid email address: ${raw}`);
|
|
2523
|
+
}
|
|
2524
|
+
return email2;
|
|
2525
|
+
}
|
|
2526
|
+
function withPublicationLogo(current, logo) {
|
|
2527
|
+
return { ...current, publication: { ...current.publication, logo } };
|
|
2528
|
+
}
|
|
2529
|
+
async function setPublicationLogo(db, logo) {
|
|
2530
|
+
return updateSettingsWith(db, (current) => withPublicationLogo(current, logo));
|
|
2531
|
+
}
|
|
2532
|
+
function normalizeText(v, what, max) {
|
|
2533
|
+
if (typeof v !== "string") {
|
|
2534
|
+
throw new Error(`${what} must be a string`);
|
|
2535
|
+
}
|
|
2536
|
+
return v.trim().slice(0, max);
|
|
2537
|
+
}
|
|
2538
|
+
function normalizeRecipients(input) {
|
|
2539
|
+
if (!Array.isArray(input)) {
|
|
2540
|
+
throw new Error("testRecipients must be a list of email addresses");
|
|
2541
|
+
}
|
|
2542
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2543
|
+
const out = [];
|
|
2544
|
+
for (const raw of input) {
|
|
2545
|
+
if (typeof raw !== "string") {
|
|
2546
|
+
throw new Error("each test recipient must be a string");
|
|
2547
|
+
}
|
|
2548
|
+
const email2 = normalizeEmail(raw);
|
|
2549
|
+
if (!email2) {
|
|
2550
|
+
continue;
|
|
2551
|
+
}
|
|
2552
|
+
if (!isValidEmail(email2)) {
|
|
2553
|
+
throw new Error(`not a valid email address: ${raw}`);
|
|
2554
|
+
}
|
|
2555
|
+
if (seen.has(email2)) {
|
|
2556
|
+
continue;
|
|
2557
|
+
}
|
|
2558
|
+
seen.add(email2);
|
|
2559
|
+
out.push(email2);
|
|
2560
|
+
}
|
|
2561
|
+
if (out.length > MAX_TEST_RECIPIENTS) {
|
|
2562
|
+
throw new Error(`at most ${MAX_TEST_RECIPIENTS} test recipients`);
|
|
2563
|
+
}
|
|
2564
|
+
return out;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
// src/render/template_engine.ts
|
|
2475
2568
|
var UNSUB_SENTINEL = "%%UNSUBSCRIBE_URL%%";
|
|
2476
2569
|
var SENTTO_SENTINEL = "%%SENT_TO%%";
|
|
2477
|
-
var
|
|
2570
|
+
var TOKENS = {
|
|
2478
2571
|
".Post.Body": { phase: "render", escaping: "raw" },
|
|
2479
2572
|
".Post.Subject": { phase: "render", escaping: "attr" },
|
|
2480
2573
|
".Publication.Name": { phase: "render", escaping: "attr" },
|
|
@@ -2493,9 +2586,9 @@ var TOKENS2 = {
|
|
|
2493
2586
|
".Email.UnsubscribeURL": { phase: "delivery", escaping: "raw", sentinel: UNSUB_SENTINEL },
|
|
2494
2587
|
".Email.SentTo": { phase: "delivery", escaping: "attr", sentinel: SENTTO_SENTINEL }
|
|
2495
2588
|
};
|
|
2496
|
-
var EMAIL_TEMPLATE_VARIABLES = Object.keys(
|
|
2589
|
+
var EMAIL_TEMPLATE_VARIABLES = Object.keys(TOKENS);
|
|
2497
2590
|
var KNOWN_VARS = new Set(EMAIL_TEMPLATE_VARIABLES);
|
|
2498
|
-
var DELIVERY_TOKENS = Object.entries(
|
|
2591
|
+
var DELIVERY_TOKENS = Object.entries(TOKENS).flatMap(
|
|
2499
2592
|
([key, spec]) => spec.phase === "delivery" ? [{ key, escaping: spec.escaping, sentinel: spec.sentinel }] : []
|
|
2500
2593
|
);
|
|
2501
2594
|
var TOKEN2 = /\{\{\s*([\w.]+)\s*\}\}/g;
|
|
@@ -2511,7 +2604,7 @@ function markEmailOnlyRegions(html) {
|
|
|
2511
2604
|
}
|
|
2512
2605
|
function fillEmailTemplate(html, ctx) {
|
|
2513
2606
|
return markEmailOnlyRegions(html).replace(TOKEN2, (_m, key) => {
|
|
2514
|
-
const spec =
|
|
2607
|
+
const spec = TOKENS[key];
|
|
2515
2608
|
if (!spec) {
|
|
2516
2609
|
return "";
|
|
2517
2610
|
}
|
|
@@ -2648,22 +2741,20 @@ function onceUntilRejected(init) {
|
|
|
2648
2741
|
return ready;
|
|
2649
2742
|
};
|
|
2650
2743
|
}
|
|
2651
|
-
var inlinerReady = onceUntilRejected(
|
|
2744
|
+
var inlinerReady = onceUntilRejected(
|
|
2745
|
+
() => initWasm({ module_or_path: wasmModule })
|
|
2746
|
+
);
|
|
2652
2747
|
async function inlineEmailCss(html) {
|
|
2653
2748
|
await inlinerReady();
|
|
2654
2749
|
return inline(html, { keepAtRules: true });
|
|
2655
2750
|
}
|
|
2656
|
-
function fromDisplayName(fromAddress) {
|
|
2657
|
-
const m = /^\s*"?([^"<]*?)"?\s*<[^>]+>\s*$/.exec(fromAddress);
|
|
2658
|
-
return m?.[1] ? m[1].trim() : "";
|
|
2659
|
-
}
|
|
2660
2751
|
function resolveBranding(settings, config) {
|
|
2661
2752
|
const p = settings.publication;
|
|
2662
2753
|
return {
|
|
2663
2754
|
template: settings.emailTemplate.trim() ? settings.emailTemplate : DEFAULT_EMAIL_TEMPLATE,
|
|
2664
|
-
name: p.name
|
|
2755
|
+
name: publicationName(p.name, config.fromAddress),
|
|
2665
2756
|
tagline: p.tagline,
|
|
2666
|
-
logoUrl:
|
|
2757
|
+
logoUrl: publicationLogoUrl(config.mediaPublicBase, p.logo),
|
|
2667
2758
|
address: p.address
|
|
2668
2759
|
};
|
|
2669
2760
|
}
|
|
@@ -2871,21 +2962,32 @@ function substituteRecipient(r, ctx) {
|
|
|
2871
2962
|
};
|
|
2872
2963
|
}
|
|
2873
2964
|
|
|
2874
|
-
// src/routes/
|
|
2875
|
-
function fromDisplayName2(fromAddress) {
|
|
2876
|
-
const lt = fromAddress.indexOf("<");
|
|
2877
|
-
const display = (lt >= 0 ? fromAddress.slice(0, lt) : "").trim().replace(/^"|"$/g, "").trim();
|
|
2878
|
-
return display || "Newsletter";
|
|
2879
|
-
}
|
|
2965
|
+
// src/routes/reader.ts
|
|
2880
2966
|
async function readerIdentity(c, config) {
|
|
2881
2967
|
const { publication: p } = await getSettingsForDisplay(c.env.DB);
|
|
2882
2968
|
return {
|
|
2883
|
-
name: p.name
|
|
2969
|
+
name: publicationName(p.name, config.fromAddress),
|
|
2884
2970
|
tagline: p.tagline,
|
|
2885
|
-
logoUrl:
|
|
2971
|
+
logoUrl: publicationLogoUrl(config.mediaPublicBase, p.logo)
|
|
2886
2972
|
};
|
|
2887
2973
|
}
|
|
2888
|
-
function
|
|
2974
|
+
function archiveHomeUrl(config) {
|
|
2975
|
+
return `${config.archiveOrigin}${config.archiveBasePath}`;
|
|
2976
|
+
}
|
|
2977
|
+
function devDashboardUrl(config) {
|
|
2978
|
+
return config.devMode ? `${config.appOrigin}/dashboard/` : void 0;
|
|
2979
|
+
}
|
|
2980
|
+
async function readerMessage(c, page, identity) {
|
|
2981
|
+
return messagePage({
|
|
2982
|
+
...page,
|
|
2983
|
+
identity: identity ?? await readerIdentity(c, c.config),
|
|
2984
|
+
homeUrl: `${c.config.appOrigin}/`,
|
|
2985
|
+
devDashboardUrl: devDashboardUrl(c.config)
|
|
2986
|
+
});
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
// src/routes/archive.ts
|
|
2990
|
+
function formatPublishedDate(ms) {
|
|
2889
2991
|
return new Date(ms).toLocaleDateString("en-US", {
|
|
2890
2992
|
timeZone: "UTC",
|
|
2891
2993
|
year: "numeric",
|
|
@@ -2893,12 +2995,6 @@ function formatSentDate(ms) {
|
|
|
2893
2995
|
day: "numeric"
|
|
2894
2996
|
});
|
|
2895
2997
|
}
|
|
2896
|
-
function archiveHomeUrl(config) {
|
|
2897
|
-
return `${config.archiveOrigin}${config.archiveBasePath}`;
|
|
2898
|
-
}
|
|
2899
|
-
function devDashboardUrl(config) {
|
|
2900
|
-
return config.devMode ? `${config.appOrigin}/dashboard/` : void 0;
|
|
2901
|
-
}
|
|
2902
2998
|
async function landing(c) {
|
|
2903
2999
|
const [posts, identity] = await Promise.all([
|
|
2904
3000
|
listPublishedPosts(c.env.DB, 6),
|
|
@@ -2907,7 +3003,7 @@ async function landing(c) {
|
|
|
2907
3003
|
const mapped = posts.map((i) => ({
|
|
2908
3004
|
title: i.subject,
|
|
2909
3005
|
url: archiveUrl(c.config, i.slug),
|
|
2910
|
-
dateLabel:
|
|
3006
|
+
dateLabel: formatPublishedDate(i.published_at)
|
|
2911
3007
|
}));
|
|
2912
3008
|
const [featured, ...recent] = mapped;
|
|
2913
3009
|
return landingPage({
|
|
@@ -2932,37 +3028,49 @@ async function archiveIndex(c) {
|
|
|
2932
3028
|
posts: posts.map((i) => ({
|
|
2933
3029
|
title: i.subject,
|
|
2934
3030
|
url: archiveUrl(c.config, i.slug),
|
|
2935
|
-
dateLabel:
|
|
3031
|
+
dateLabel: formatPublishedDate(i.published_at)
|
|
2936
3032
|
})),
|
|
2937
3033
|
devDashboardUrl: devDashboardUrl(c.config)
|
|
2938
3034
|
});
|
|
2939
3035
|
}
|
|
3036
|
+
function webVersion(html, opts) {
|
|
3037
|
+
const masthead2 = archiveMasthead({
|
|
3038
|
+
name: opts.identity.name,
|
|
3039
|
+
dateLabel: formatPublishedDate(opts.dateMs),
|
|
3040
|
+
// Back to the archive index the post belongs to, on the same (archive) origin —
|
|
3041
|
+
// so an apex-hosted post stays on the apex instead of jumping to the app subdomain.
|
|
3042
|
+
indexUrl: archiveHomeUrl(opts.config)
|
|
3043
|
+
});
|
|
3044
|
+
const dev = archiveDevDashboardChrome(opts.devDashboardUrl);
|
|
3045
|
+
return fillDeliveryTokens(
|
|
3046
|
+
omitEmailOnly(html),
|
|
3047
|
+
{ ".Email.UnsubscribeURL": `${opts.config.appOrigin}/unsubscribe`, ".Email.SentTo": "" },
|
|
3048
|
+
"html"
|
|
3049
|
+
).split(ARCHIVE_MASTHEAD_ANCHOR).join(masthead2 + dev.masthead).split(ARCHIVE_HEAD_ANCHOR).join(ARCHIVE_POST_HEAD + readerIconLink(opts.identity.logoUrl) + dev.head);
|
|
3050
|
+
}
|
|
3051
|
+
function nothingHereYetPage(c) {
|
|
3052
|
+
return readerMessage(c, {
|
|
3053
|
+
title: "Nothing here yet",
|
|
3054
|
+
eyebrow: "Archive",
|
|
3055
|
+
headline: "Nothing here yet",
|
|
3056
|
+
ledeHtml: "A post appears here as soon as it\u2019s sent.",
|
|
3057
|
+
afterHtml: archiveLink(archiveHomeUrl(c.config), "Browse the archive"),
|
|
3058
|
+
status: 404
|
|
3059
|
+
});
|
|
3060
|
+
}
|
|
2940
3061
|
async function archivePage(c) {
|
|
2941
3062
|
const slug = param(c, "slug");
|
|
2942
3063
|
const post = await getBySlug(c.env.DB, slug);
|
|
2943
|
-
const send = post ? await
|
|
3064
|
+
const send = post ? await publishedSendForPost(c.env.DB, post.id) : null;
|
|
2944
3065
|
if (!post || !send) {
|
|
2945
|
-
return
|
|
2946
|
-
"Not found",
|
|
2947
|
-
`<h1 style="margin-top:0;">Not found</h1><p>This post isn't available.</p>`,
|
|
2948
|
-
404,
|
|
2949
|
-
devDashboardUrl(c.config)
|
|
2950
|
-
);
|
|
3066
|
+
return nothingHereYetPage(c);
|
|
2951
3067
|
}
|
|
2952
|
-
const
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
// so an apex-hosted post stays on the apex instead of jumping to the app subdomain.
|
|
2958
|
-
indexUrl: archiveHomeUrl(c.config)
|
|
3068
|
+
const html = webVersion(send.rendered_html, {
|
|
3069
|
+
config: c.config,
|
|
3070
|
+
identity: await readerIdentity(c, c.config),
|
|
3071
|
+
dateMs: send.started_at ?? send.fire_at,
|
|
3072
|
+
devDashboardUrl: devDashboardUrl(c.config)
|
|
2959
3073
|
});
|
|
2960
|
-
const dev = archiveDevDashboardChrome(devDashboardUrl(c.config));
|
|
2961
|
-
const html = fillDeliveryTokens(
|
|
2962
|
-
omitEmailOnly(send.rendered_html),
|
|
2963
|
-
{ ".Email.UnsubscribeURL": `${c.config.appOrigin}/unsubscribe`, ".Email.SentTo": "" },
|
|
2964
|
-
"html"
|
|
2965
|
-
).split(ARCHIVE_MASTHEAD_ANCHOR).join(masthead2 + dev.masthead).split(ARCHIVE_HEAD_ANCHOR).join(ARCHIVE_POST_HEAD + dev.head);
|
|
2966
3074
|
return new Response(html, {
|
|
2967
3075
|
headers: {
|
|
2968
3076
|
...POST_PAGE_SECURITY_HEADERS,
|
|
@@ -3029,10 +3137,11 @@ async function insertSuppressions(db, rows) {
|
|
|
3029
3137
|
async function insertPost(db, post, revision) {
|
|
3030
3138
|
await db.batch([
|
|
3031
3139
|
db.prepare(
|
|
3032
|
-
"INSERT INTO posts (id, slug, subject, status, current_revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
3140
|
+
"INSERT INTO posts (id, slug, slug_auto, subject, status, current_revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
3033
3141
|
).bind(
|
|
3034
3142
|
post.id,
|
|
3035
3143
|
post.slug,
|
|
3144
|
+
post.slug_auto ? 1 : 0,
|
|
3036
3145
|
post.subject,
|
|
3037
3146
|
post.status,
|
|
3038
3147
|
post.current_revision,
|
|
@@ -3468,7 +3577,9 @@ function sendConditions(send, now) {
|
|
|
3468
3577
|
kind: "refused",
|
|
3469
3578
|
severity: "action",
|
|
3470
3579
|
since: send.halted_at,
|
|
3471
|
-
|
|
3580
|
+
// No provider connected (SPEC §9) is held like a refusal but is no provider's doing,
|
|
3581
|
+
// so it is said as what it is, with the fix once.
|
|
3582
|
+
message: send.halt_cause === "not_connected" ? `No email provider is connected, so this send is held where it is. ${advice} No one has been marked unsent, and the send goes out at its first retry after a provider is connected.` : `The provider is refusing the account: ${sentence(send.halt_error)} ${advice} No one has been marked unsent, and the send resumes on its own at its next retry once the account is fixed.`,
|
|
3472
3583
|
action: null,
|
|
3473
3584
|
cause: send.halt_cause,
|
|
3474
3585
|
error: send.halt_error ?? "",
|
|
@@ -3701,8 +3812,8 @@ function buildSendView(send, config, hasRetries, now) {
|
|
|
3701
3812
|
deliveries: `/api/sends/${id}/deliveries`,
|
|
3702
3813
|
deliveries_csv: `/api/sends/${id}/deliveries.csv`,
|
|
3703
3814
|
post: `/api/posts/${send.post_id}`,
|
|
3704
|
-
// The archive serves a post's page
|
|
3705
|
-
archive: send.status === "sent" && send.post_slug !== null ? archiveUrl(config, send.post_slug) : null
|
|
3815
|
+
// The archive serves a post's page once its send starts: before, the link would 404.
|
|
3816
|
+
archive: (send.status === "sending" || send.status === "sent") && send.post_slug !== null ? archiveUrl(config, send.post_slug) : null
|
|
3706
3817
|
}
|
|
3707
3818
|
};
|
|
3708
3819
|
}
|
|
@@ -3812,19 +3923,11 @@ function isActiveSendConflict(err) {
|
|
|
3812
3923
|
const message2 = err instanceof Error ? err.message : String(err);
|
|
3813
3924
|
return /UNIQUE constraint failed:\s*sends\.post_id/i.test(message2);
|
|
3814
3925
|
}
|
|
3815
|
-
async function readForAction(env, sendId
|
|
3926
|
+
async function readForAction(env, sendId) {
|
|
3816
3927
|
const send = await getSend(env.DB, sendId);
|
|
3817
3928
|
if (!send) {
|
|
3818
3929
|
throw notFound("send");
|
|
3819
3930
|
}
|
|
3820
|
-
if (guard2.ifMatch !== void 0 && send.rev !== guard2.ifMatch) {
|
|
3821
|
-
throw refusal(
|
|
3822
|
-
412,
|
|
3823
|
-
"precondition_failed",
|
|
3824
|
-
`the send has changed since rev ${guard2.ifMatch} (it is at rev ${send.rev}); read it again and decide on what it is now`,
|
|
3825
|
-
{ send: await viewSend(env, send.id) }
|
|
3826
|
-
);
|
|
3827
|
-
}
|
|
3828
3931
|
return send;
|
|
3829
3932
|
}
|
|
3830
3933
|
async function windowClosed(env, send, what) {
|
|
@@ -3833,8 +3936,8 @@ async function windowClosed(env, send, what) {
|
|
|
3833
3936
|
send: await viewSend(env, send.id)
|
|
3834
3937
|
});
|
|
3835
3938
|
}
|
|
3836
|
-
async function reschedule(env, sendId, fireAt, minLeadMs
|
|
3837
|
-
const send = await readForAction(env, sendId
|
|
3939
|
+
async function reschedule(env, sendId, fireAt, minLeadMs) {
|
|
3940
|
+
const send = await readForAction(env, sendId);
|
|
3838
3941
|
const now = Date.now();
|
|
3839
3942
|
if (send.status === "canceled") {
|
|
3840
3943
|
throw refusal(409, "send_canceled", "send is canceled; schedule the post again instead", {
|
|
@@ -3848,9 +3951,9 @@ async function reschedule(env, sendId, fireAt, minLeadMs, guard2 = {}) {
|
|
|
3848
3951
|
return { send, changed: false };
|
|
3849
3952
|
}
|
|
3850
3953
|
const target = acceptFireAt(fireAt, minLeadMs, { now });
|
|
3851
|
-
const res = await rescheduleStmt(env.DB, sendId, target, now
|
|
3954
|
+
const res = await rescheduleStmt(env.DB, sendId, target, now).run();
|
|
3852
3955
|
if ((res.meta.changes ?? 0) === 0) {
|
|
3853
|
-
return reschedule(env, sendId, fireAt, minLeadMs
|
|
3956
|
+
return reschedule(env, sendId, fireAt, minLeadMs);
|
|
3854
3957
|
}
|
|
3855
3958
|
log.info("send.rescheduled", {
|
|
3856
3959
|
sendId,
|
|
@@ -3860,8 +3963,8 @@ async function reschedule(env, sendId, fireAt, minLeadMs, guard2 = {}) {
|
|
|
3860
3963
|
});
|
|
3861
3964
|
return { send: unwrap(await getSend(env.DB, sendId), "send"), changed: true };
|
|
3862
3965
|
}
|
|
3863
|
-
async function cancel(env, sendId
|
|
3864
|
-
const send = await readForAction(env, sendId
|
|
3966
|
+
async function cancel(env, sendId) {
|
|
3967
|
+
const send = await readForAction(env, sendId);
|
|
3865
3968
|
if (send.status === "canceled") {
|
|
3866
3969
|
return { send, changed: false };
|
|
3867
3970
|
}
|
|
@@ -3870,7 +3973,7 @@ async function cancel(env, sendId, guard2 = {}) {
|
|
|
3870
3973
|
throw await windowClosed(env, send, "canceled");
|
|
3871
3974
|
}
|
|
3872
3975
|
const [res] = await env.DB.batch([
|
|
3873
|
-
cancelStmt(env.DB, sendId, now
|
|
3976
|
+
cancelStmt(env.DB, sendId, now),
|
|
3874
3977
|
setPostStatusStmt(
|
|
3875
3978
|
env.DB,
|
|
3876
3979
|
send.post_id,
|
|
@@ -3881,7 +3984,7 @@ async function cancel(env, sendId, guard2 = {}) {
|
|
|
3881
3984
|
)
|
|
3882
3985
|
]);
|
|
3883
3986
|
if ((res?.meta.changes ?? 0) === 0) {
|
|
3884
|
-
return cancel(env, sendId
|
|
3987
|
+
return cancel(env, sendId);
|
|
3885
3988
|
}
|
|
3886
3989
|
log.info("send.canceled", { sendId, postId: send.post_id });
|
|
3887
3990
|
return { send: unwrap(await getSend(env.DB, sendId), "send"), changed: true };
|
|
@@ -4510,6 +4613,8 @@ function renderInputFor(id, demoPost, at, markdown) {
|
|
|
4510
4613
|
const post = {
|
|
4511
4614
|
id,
|
|
4512
4615
|
slug: demoPost.slug,
|
|
4616
|
+
// A demo slug that is just its subject's reads as following it, as a new post's would.
|
|
4617
|
+
slug_auto: demoPost.slug === slugify(demoPost.subject),
|
|
4513
4618
|
subject: demoPost.subject,
|
|
4514
4619
|
status: demoPost.status,
|
|
4515
4620
|
current_revision: `${id}-rev`,
|
|
@@ -4548,10 +4653,11 @@ async function seedDatabase(env, config, imageFiles = [], logoFile, options) {
|
|
|
4548
4653
|
});
|
|
4549
4654
|
let logoWritten = false;
|
|
4550
4655
|
if (logoFile) {
|
|
4551
|
-
|
|
4656
|
+
const key = newLogoKey(now);
|
|
4657
|
+
await env.MEDIA.put(key, logoFile.bytes, {
|
|
4552
4658
|
httpMetadata: { contentType: logoFile.contentType }
|
|
4553
4659
|
});
|
|
4554
|
-
await setPublicationLogo(db, { version: now, contentType: logoFile.contentType });
|
|
4660
|
+
await setPublicationLogo(db, { version: now, contentType: logoFile.contentType, key });
|
|
4555
4661
|
logoWritten = true;
|
|
4556
4662
|
}
|
|
4557
4663
|
const size = options?.size;
|
|
@@ -4695,7 +4801,11 @@ async function token(c) {
|
|
|
4695
4801
|
}
|
|
4696
4802
|
const service = c.url.searchParams.get("kind") === "service";
|
|
4697
4803
|
const jwt = await mintDevToken(c.config.devAuthSecret, service ? {} : { email: "dev@localhost" });
|
|
4698
|
-
return json({
|
|
4804
|
+
return json({
|
|
4805
|
+
token: jwt,
|
|
4806
|
+
kind: service ? "service" : "human",
|
|
4807
|
+
...c.config.devRunId ? { runId: c.config.devRunId } : {}
|
|
4808
|
+
});
|
|
4699
4809
|
}
|
|
4700
4810
|
async function seed(c) {
|
|
4701
4811
|
requireDevMode(c);
|
|
@@ -4734,7 +4844,8 @@ async function reset(c) {
|
|
|
4734
4844
|
requireDevMode(c);
|
|
4735
4845
|
await resetAll(c.env.DB);
|
|
4736
4846
|
try {
|
|
4737
|
-
await c.env.MEDIA.
|
|
4847
|
+
const { objects } = await c.env.MEDIA.list({ prefix: BRANDING_LOGO_KEY });
|
|
4848
|
+
await c.env.MEDIA.delete(objects.map((o) => o.key));
|
|
4738
4849
|
} catch {
|
|
4739
4850
|
}
|
|
4740
4851
|
return json({ reset: true, url: `${c.config.appOrigin}/dashboard/` });
|
|
@@ -4743,29 +4854,26 @@ async function reset(c) {
|
|
|
4743
4854
|
// src/docs/index.ts
|
|
4744
4855
|
import { Lexer } from "marked";
|
|
4745
4856
|
|
|
4746
|
-
// docs/get-started/01-overview.md
|
|
4747
|
-
var overview_default = "# Overview\n\nKestrel is a newsletter app you run yourself. You write a post in Markdown, preview it exactly as the email, and send it to a list you own. It runs on Cloudflare Workers, and an email provider delivers the mail.\n\nIn this guide, you deploy your own instance of Kestrel to your Cloudflare account and connect it to an email provider. You go from an empty repository to a newsletter that's ready for its first subscriber. Each step ends with a check, so you know it worked before you move on.\n\nYou run every step from your instance's repository, a small repository of your own. It holds your instance's configuration and names the release of Kestrel it runs, which installs from npm as `@kurtbruns/kestrel`. You return to it to deploy changes and upgrade to new releases. You don't need to run Kestrel on your computer to deploy it, but you can try it there first, with demo content.\n\n## Before you begin\n\nYou need:\n\n1. A [Cloudflare account](https://dash.cloudflare.com/sign-up), with your domain's DNS on Cloudflare. Your app's hostname and its login both live there.\n\n1. An account with an email provider: [Resend](https://resend.com/signup) or [Amazon SES](https://aws.amazon.com/ses/). These guides use Resend, the quicker to set up. Amazon SES costs less for a large list. It needs AWS to approve your account before it sends to subscribers, and Cloudflare's Workers Paid plan. To use SES, follow [Use Amazon SES instead of Resend](../guides/02-ses.md) in place of step 4.\n\n1. [Node.js](https://nodejs.org/) 22 or later, and [Git](https://git-scm.com/).\n\n1. A GitHub account, or another Git host, for your instance's repository.\n\n## Choose your hostnames\n\nKestrel uses two hostnames, because the app and the mail do different jobs:\n\n| Hostname | What it's for |\n| --- | --- |\n| `newsletter.example.com` | the editor, the API, and the public pages readers see |\n| `send.example.com` | the address your mail comes from, and its SPF, DKIM, and DMARC records |\n\nThese guides put the app on a subdomain, which leaves any website you already have on `example.com` untouched. If `example.com` has no website of its own, the app can live there instead. Use `example.com` wherever these guides say `newsletter.example.com`.\n\nIt's recommended to send from a subdomain 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. Avoid `mail.` as the sending name too, since mail servers read it as an inbound host. [Sending-domain DNS](../reference/02-sending-domain-dns.md#choose-your-sending-name) explains both.\n\nThroughout these guides, replace `example.com`, `newsletter.example.com`, and `send.example.com` with your own names. Replace any value written as `REPLACE_WITH_\u2026` too.\n\n## The steps\n\n1. **Overview**: what you need, and the hostnames you use.\n1. [Deploy the app](02-deploy.md): create your instance's repository, and put the app on your hostname.\n1. [Lock the dashboard with Access](03-access.md): add the login that keeps the editor and the API to you.\n1. [Connect Resend](04-resend.md): verify your sending domain, and connect Resend to the app.\n1. [Verify it works](05-verify.md): send real email to yourself, and test unsubscribing and bounces.\n1. [Go live](06-go-live.md): name your publication and share the subscribe link.\n\nFurther guides, like [connecting Claude](../guides/01-connect-claude.md) and [upgrading to a new release](../guides/07-upgrade.md), pick up from there.\n\nWhen you're ready, continue to [Deploy the app](02-deploy.md).\n";
|
|
4748
|
-
|
|
4749
4857
|
// docs/get-started/02-deploy.md
|
|
4750
|
-
var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker, with a D1 database for posts and subscribers and an R2 bucket for images.\n\nIn this guide, you create your instance\'s repository, create the database and the bucket, and point Kestrel\'s production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname.
|
|
4858
|
+
var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker, with a D1 database for posts and subscribers and an R2 bucket for images.\n\nIn this guide, you choose your hostnames, create your instance\'s repository, create the database and the bucket, and point Kestrel\'s production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname.\n\n## Before you begin\n\n- A [Cloudflare account](https://dash.cloudflare.com/sign-up), with your domain\'s DNS on Cloudflare. Your app\'s hostname and its login both live there.\n- [Node.js](https://nodejs.org/) 22 or later, and [Git](https://git-scm.com/).\n- A GitHub account, or another Git host, for your instance\'s repository.\n- A provider for sending email. [Resend](https://resend.com/signup) is a good default, and the one these guides walk through.\n\n## Choose your hostnames\n\nKestrel uses two hostnames: one for the app, and one your email is sent from.\n\n| Hostname | What it\'s for |\n| --- | --- |\n| `newsletter.example.com` | the editor, the API, and the public pages readers see |\n| `send.example.com` | the address your email comes from, and its SPF, DKIM, and DMARC records |\n\nThese guides put the app on a subdomain, which leaves any website you already have on `example.com` untouched. If `example.com` has no website of its own, the app can live there instead. Use `example.com` wherever these guides say `newsletter.example.com`.\n\nIt\'s recommended to send from a subdomain 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. Avoid `mail.` as the sending name too, since mail servers read it as an inbound host. [Sending-domain DNS](../reference/02-sending-domain-dns.md#choose-your-sending-name) explains both.\n\nThroughout these guides, replace the example names with your own, and any value written as `REPLACE_WITH_\u2026`.\n\n## 1. Create your instance\'s repository\n\nYour instance\'s repository holds its configuration, its database migrations, and the release of Kestrel it runs. Kestrel itself installs from npm as `@kurtbruns/kestrel`, so the repository stays small, and an upgrade changes the version it names.\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. Clone it, and write a new instance into it. Replace `YOUR_USERNAME` with your GitHub username:\n\n ```bash\n git clone https://github.com/YOUR_USERNAME/newsletter.git\n cd newsletter\n npx @kurtbruns/kestrel init\n ```\n\n This writes the instance\'s files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release\'s database migrations, and a starting point for Claude Code in `.claude/`. The instance\'s `README.md` lists its commands.\n\n1. Install the release and its tools:\n\n ```bash\n npm install\n ```\n\n1. Commit the new instance, and push it:\n\n ```bash\n git add -A\n git commit -m "Start a Kestrel instance"\n git push -u origin HEAD\n ```\n\n1. **(Optional)** Try Kestrel locally before you deploy it. Your instance is written and installed, so start at [Start the app](../guides/08-try-locally.md#2-start-the-app), and come back here when you\'re done. Email goes to a stand-in, so nothing reaches a real inbox.\n\n## 2. Sign in to Cloudflare\n\nEvery command from here on acts on your Cloudflare account through the Wrangler CLI.\n\n1. Sign in. A browser window opens for you to approve the login:\n\n ```bash\n npx wrangler login\n ```\n\n1. Confirm which account you\'re signed in to:\n\n ```bash\n npx wrangler whoami\n ```\n\n It prints your email and the account name. If you belong to more than one account, make sure it\'s the one that holds your domain.\n\n## 3. Create the database and the image bucket\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create kestrel-production\n ```\n\n It prints the new database\'s settings, including a `database_id`. Copy the id for the next section. If Wrangler offers to add the database to your configuration for you, decline: you add it to the production settings yourself, below.\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create kestrel-media-production\n ```\n\n## 4. Configure production\n\n`wrangler.jsonc` holds two sets of settings. The top level is for local development, so leave it alone. The `production` block under `env` is yours to fill in.\n\n1. Open `wrangler.jsonc` and change the values marked here in the `production` block:\n\n ```jsonc\n "production": {\n "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }], // \u2190 your app\'s hostname\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "REPLACE_WITH_PRODUCTION_D1_ID", // \u2190 the id from section 3\n "migrations_dir": "migrations"\n }\n ],\n "vars": {\n "APP_ORIGIN": "https://newsletter.example.com", // \u2190 your app\'s hostname\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com", // \u2190 your sending hostname\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>" // \u2190 who your mail is from\n }\n }\n ```\n\n What each one does:\n\n - **`routes`** puts the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can\'t already have a CNAME record; delete one if it does. The block also turns off the `workers.dev` addresses, so the app answers only on your hostname.\n - **No `PROVIDER` yet.** You add it in step 3, [Connect an email provider](04-resend.md). Until then the app runs without an email provider: you can write and preview posts, but scheduling, test emails, and signups are refused.\n - **`ARCHIVE_BASE_PATH`** is permanent once you send. Every post you send carries its `/archive/\u2026` link, and changing the prefix later breaks the links you\'ve already mailed. Keep `/archive` unless you have a reason not to.\n - **`FROM_ADDRESS`** is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.\n\n Change every `example.com` value, the route included, even before you connect a provider. The app checks its settings but can\'t see the route: it refuses to run while `APP_ORIGIN`, `SENDING_DOMAIN`, or `FROM_ADDRESS`, or `ARCHIVE_ORIGIN` or `MEDIA_PUBLIC_BASE` if you set them, is still on `example.com`, since its links and the address your email comes from would name a domain that isn\'t yours.\n\n Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.\n\n1. Check the file. This regenerates the binding types, and fails if the configuration doesn\'t parse:\n\n ```bash\n npm run typecheck\n ```\n\n1. Commit your settings and push them to your repository. An upgrade changes only the release your instance runs, so your settings stay as they are:\n\n ```bash\n git commit -am "Configure production"\n git push\n ```\n\n## 5. Apply the schema\n\nCreate Kestrel\'s tables in the production database:\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it\'s about to apply and asks you to confirm. It applies only the ones the database hasn\'t seen, so running it again is harmless.\n\n## 6. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThis deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker\'s **Triggers** tab in the Cloudflare dashboard.\n\n## Check it\n\n1. The app answers on your hostname. A new hostname can take a few minutes to get its certificate.\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The editor is locked. Until you set up Access in the next step, the app can\'t tell who is asking, so it lets no one in:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" https://newsletter.example.com/api/whoami\n ```\n\n ```\n 401\n ```\n\n1. In a browser, `https://newsletter.example.com/` shows your newsletter\'s public landing page. Its subscribe page, `/subscribe`, says signups aren\'t open yet, until step 3, Connect an email provider.\n\nIf the public pages instead say the newsletter is temporarily unavailable, and `/health` answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",\u2026}`, the setting it names is missing, malformed, or still on `example.com`. Fix it in `wrangler.jsonc`, commit, and deploy again.\n\nNext, [lock the dashboard with Access](03-access.md).\n';
|
|
4751
4859
|
|
|
4752
4860
|
// docs/get-started/03-access.md
|
|
4753
|
-
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 it without `https://`:\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.
|
|
4861
|
+
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 it without `https://`:\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 an email provider](04-resend.md).\n';
|
|
4754
4862
|
|
|
4755
4863
|
// docs/get-started/04-resend.md
|
|
4756
|
-
var resend_default = '# Connect
|
|
4864
|
+
var resend_default = '# Connect an email provider\n\nKestrel sends your posts through an email provider. The provider only delivers the mail: Kestrel keeps your list, each subscriber\'s consent, and the record of every send. This page connects Resend. To use Amazon SES instead, follow [Use Amazon SES instead of Resend](../guides/02-ses.md) in place of this page.\n\nIn this guide, you verify your sending hostname with Resend and add a DMARC record. Then you give the app an API key and a webhook for bounces and complaints, switch it to Resend, and deploy.\n\n## 1. Verify your sending hostname\n\nResend sends only from a domain whose DNS proves you own it. These records also let inboxes trust your mail.\n\n1. In Resend, go to [Domains](https://resend.com/domains), select **Add Domain**, and enter `send.example.com`.\n\n1. Select **Sign in to Cloudflare**, and allow Resend to edit your DNS. Resend adds its records to your zone for you.\n\n To add them by hand instead, copy each record from Resend into your zone\'s DNS records in Cloudflare. Because your sending hostname is itself a subdomain, some names look doubled, such as `send.send`. That\'s expected.\n\n1. Leave receiving turned off. Kestrel doesn\'t receive mail.\n\n1. Wait until Resend shows the domain 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. Resend 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. Create an API key\n\nThe app sends through this key. A key that can only send, from only your sending hostname, limits the harm if it ever leaks.\n\n1. In Resend, go to [API Keys](https://resend.com/api-keys), and select **Create API Key**.\n\n1. Fill in the key:\n\n - **Name:** `kestrel-production`\n - **Permission:** Sending access\n - **Domain:** `send.example.com`\n\n1. Copy the key. Resend shows it only once.\n\n1. Store it as a secret, and paste the key at the prompt:\n\n ```bash\n npx wrangler secret put RESEND_API_KEY --env production\n ```\n\n## 4. Add the webhook\n\nThe webhook tells the app when an address bounces or complains, and the app stops mailing it. Without it, you keep mailing those addresses, and your mail starts landing in spam.\n\n1. In Resend, go to [Webhooks](https://resend.com/webhooks), and select **Add Webhook**.\n\n1. Enter the endpoint URL:\n\n ```\n https://newsletter.example.com/webhooks/resend\n ```\n\n1. Select these three events, and create the webhook:\n\n - `email.delivered`\n - `email.bounced`\n - `email.complained`\n\n1. On the webhook\'s page, copy its signing secret. It starts with `whsec_`.\n\n1. Store it as a secret. The app checks every event against it:\n\n ```bash\n npx wrangler secret put RESEND_WEBHOOK_SECRET --env production\n ```\n\n## 5. Set the provider and deploy\n\nUntil now the app has run with no email provider connected, refusing to send. Setting `PROVIDER` connects Resend. Store both secrets before you set it. With `PROVIDER` set to `resend` and a secret missing, every request answers `500` and names the missing secret.\n\n1. In `wrangler.jsonc`, in the `production` block\'s `vars`, add `PROVIDER` set to `resend`:\n\n ```jsonc\n "PROVIDER": "resend",\n ```\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Send through Resend"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\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 without Resend\'s signature:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" -X POST https://newsletter.example.com/webhooks/resend\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. In the editor, **Settings \u2192 Email sender** shows Resend as the email provider, 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.\n\nResend\'s free plan sends up to 100 emails a day and 3,000 a month, test emails included. A list of more than about a hundred subscribers needs a paid plan, listed on [Resend\'s pricing](https://resend.com/pricing) page. For a large list, [Amazon SES](../guides/02-ses.md) may cost less.\n\nNext, [verify it works](05-verify.md).\n';
|
|
4757
4865
|
|
|
4758
4866
|
// docs/get-started/05-verify.md
|
|
4759
|
-
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.\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
|
|
4867
|
+
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.\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 Amazon SES and 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 an email provider](04-resend.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, your provider tells the app through the webhook. The app then stops mailing that address. Your provider has test addresses that act out each case without reaching a real person.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address for your provider:\n\n | Provider | Bounces | Marks as spam |\n | --- | --- | --- |\n | Resend | `bounced@resend.dev` | `complained@resend.dev` |\n | Amazon SES | `bounce@simulator.amazonses.com` | `complaint@simulator.amazonses.com` |\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, the webhook isn't reaching the app. Check its URL and signing secret in [Connect an email provider](04-resend.md#4-add-the-webhook).\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";
|
|
4760
4868
|
|
|
4761
4869
|
// docs/get-started/06-go-live.md
|
|
4762
|
-
var go_live_default = "# Go live\n\
|
|
4870
|
+
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 name your publication and share your subscribe link. You also see what to change as your list grows.\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. Set them before anyone sees those pages. Until you do, the name in your From address stands in.\n\n1. In the editor, open **Settings \u2192 Publication identity**.\n\n1. Enter 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 free plans. As your list grows, two limits come into play.\n\n- **Your email provider's limits.** Resend's free plan sends up to 100 emails a day. A send that hits the limit waits, and the app emails you. It resumes on its own once the limit lifts, or after you upgrade your Resend plan. For a large list, [Amazon SES](../guides/02-ses.md) may cost less.\n- **How fast a send goes out.** On Cloudflare's free Workers plan, Resend sends about 400 recipients a minute. On the $5 Workers Paid plan, set [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) to send far more each minute.\n\n## Check it\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";
|
|
4763
4871
|
|
|
4764
4872
|
// docs/guides/01-connect-claude.md
|
|
4765
4873
|
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';
|
|
4766
4874
|
|
|
4767
4875
|
// docs/guides/02-ses.md
|
|
4768
|
-
var ses_default = '# Use Amazon SES instead of Resend\n\nAmazon SES costs less than Resend for a large list, but takes more setup. AWS reviews your account before it lets you send to anyone but yourself. Kestrel also needs Cloudflare\'s Workers Paid plan to send through SES at a useful pace.\n\nIn this guide, you verify your sending hostname with SES, ask AWS for production access, and route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, switch it to SES, and deploy. Follow it in place of [Connect Resend](../get-started/04-resend.md).\n\n## Before you begin\n\nYou need:\n\n- An [AWS account](https://portal.aws.amazon.com/billing/signup).\n- Cloudflare\'s [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/).\n- The app deployed and locked, from [Deploy the app](../get-started/02-deploy.md) and [Lock the dashboard with Access](../get-started/03-access.md).\n\nTo move an instance that already sends through Resend, read [Switch an instance that already sends](#switch-an-instance-that-already-sends) first.\n\n## How fast SES sends\n\nA send goes out a slice at a time, one slice a minute. Each slice stays inside the subrequests Cloudflare allows one run of the Worker, which [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) sets. Resend takes up to 100 recipients a request. SES takes one, so each recipient costs far more of that allowance.\n\n- **Workers Free:** about 18 recipients a minute. A send to 1,000 subscribers takes about an hour, so use Resend on this plan.\n- **Workers Paid, with `SUBREQUEST_BUDGET` set to `10000`:** up to about 1,900 recipients a minute, within your SES account\'s sending rate. At 14 messages a second, that\'s about 700 a minute, and 10,000 subscribers take about fifteen minutes.\n\nAWS sets your account\'s sending rate when it grants production access. To send faster, ask AWS to raise it.\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. SES keeps identities, quotas, and the sandbox separately in each region, so do every step in this one.\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:\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. 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. Kestrel confirms every signup by email, and stops mailing an address that bounces or complains.\n\n1. While you wait, verify the two addresses you test with in [Verify it works](../get-started/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\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.\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-production`, and choose **Create set**.\n\n1. On the set\'s **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, and **Deliveries**, and choose **Next**.\n\n1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-events`, and keep **Event publishing** enabled. Choose **Next**.\n\n1. For **SNS topic**, choose **Create SNS topic**. Make it a **Standard** topic named `kestrel-production`. 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-production`, 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-production`.\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-production"\n }\n }\n }\n ```\n\n Replace the topic ARN, your 12-digit account ID, and the region. 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-production`, and leave console access off.\n\n1. When asked for permissions, choose **Attach policies directly**, and create a policy with this JSON:\n\n ```json\n {\n "Version": "2012-10-17",\n "Statement": [\n {\n "Effect": "Allow",\n "Action": "ses:SendEmail",\n "Resource": "*"\n }\n ]\n }\n ```\n\n Name the policy `kestrel-send-email`, attach it to the user, and create the 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. Switch the provider and deploy\n\nStore every secret before you switch. With `PROVIDER` set to `ses` and a secret missing, every request answers `500` and names the missing 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-production`. 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`, in the `production` block\'s `vars`, change these values:\n\n ```jsonc\n "PROVIDER": "ses",\n "AWS_REGION": "us-east-1", // \u2190 your SES region\n "SES_MAX_SEND_RATE": "1", // \u2190 your maximum send rate; 1 in the sandbox\n "SUBREQUEST_BUDGET": "10000",\n ```\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 app refused the request. Check that `SNS_TOPIC_ARN` matches the topic\'s ARN, and that the deploy in section 7 finished. Then delete the subscription, and create it again.\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 as the email provider, and your From address.\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\nNext, [verify it works](../get-started/05-verify.md).\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 those recipients were mailed. Resend accepts the same request again without mailing anyone twice, because Kestrel sends it under the same key. A different provider, or another account, has never seen that key, so it delivers the post again. That\'s at most the recipients of the one unanswered request, but each of them gets the post twice.\n\nTo have another provider finish a halted send, accept that risk. Otherwise, fix the old account, and let the send resume there.\n';
|
|
4876
|
+
var ses_default = '# Use Amazon SES instead of Resend\n\nAmazon SES costs less than Resend for a large list, but takes more setup. AWS reviews your account before it lets you send to anyone but yourself. Kestrel also needs Cloudflare\'s Workers Paid plan to send through SES at a useful pace.\n\nIn this guide, you verify your sending hostname with SES, ask AWS for production access, and route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, switch it to SES, and deploy. Follow it in place of [Connect an email provider](../get-started/04-resend.md).\n\n## Before you begin\n\nYou need:\n\n- An [AWS account](https://portal.aws.amazon.com/billing/signup).\n- Cloudflare\'s [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/).\n- The app deployed and locked, from [Deploy the app](../get-started/02-deploy.md) and [Lock the dashboard with Access](../get-started/03-access.md).\n\nTo move an instance that already sends through Resend, read [Switch an instance that already sends](#switch-an-instance-that-already-sends) first.\n\n## How fast SES sends\n\nA send goes out a slice at a time, one slice a minute. Each slice stays inside the subrequests Cloudflare allows one run of the Worker, which [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) sets. Resend takes up to 100 recipients a request. SES takes one, so each recipient costs far more of that allowance.\n\n- **Workers Free:** about 18 recipients a minute. A send to 1,000 subscribers takes about an hour, so use Resend on this plan.\n- **Workers Paid, with `SUBREQUEST_BUDGET` set to `10000`:** up to about 1,900 recipients a minute, within your SES account\'s sending rate. At 14 messages a second, that\'s about 700 a minute, and 10,000 subscribers take about fifteen minutes.\n\nAWS sets your account\'s sending rate when it grants production access. To send faster, ask AWS to raise it.\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. SES keeps identities, quotas, and the sandbox separately in each region, so do every step in this one.\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:\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. 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. Kestrel confirms every signup by email, and stops mailing an address that bounces or complains.\n\n1. While you wait, verify the two addresses you test with in [Verify it works](../get-started/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\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.\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-production`, and choose **Create set**.\n\n1. On the set\'s **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, and **Deliveries**, and choose **Next**.\n\n1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-events`, and keep **Event publishing** enabled. Choose **Next**.\n\n1. For **SNS topic**, choose **Create SNS topic**. Make it a **Standard** topic named `kestrel-production`. 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-production`, 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-production`.\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-production"\n }\n }\n }\n ```\n\n Replace the topic ARN, your 12-digit account ID, and the region. 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-production`, and leave console access off.\n\n1. When asked for permissions, choose **Attach policies directly**, and create a policy with this JSON:\n\n ```json\n {\n "Version": "2012-10-17",\n "Statement": [\n {\n "Effect": "Allow",\n "Action": "ses:SendEmail",\n "Resource": "*"\n }\n ]\n }\n ```\n\n Name the policy `kestrel-send-email`, attach it to the user, and create the 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. Switch the provider and deploy\n\nStore every secret before you switch. With `PROVIDER` set to `ses` and a secret missing, every request answers `500` and names the missing 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-production`. 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`, in the `production` block\'s `vars`, set these values, adding `PROVIDER` if it isn\'t there yet:\n\n ```jsonc\n "PROVIDER": "ses",\n "AWS_REGION": "us-east-1", // \u2190 your SES region\n "SES_MAX_SEND_RATE": "1", // \u2190 your maximum send rate; 1 in the sandbox\n "SUBREQUEST_BUDGET": "10000",\n ```\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 app refused the request. Check that `SNS_TOPIC_ARN` matches the topic\'s ARN, and that the deploy in section 7 finished. Then delete the subscription, and create it again.\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 as the email provider, and your From address.\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\nNext, [verify it works](../get-started/05-verify.md).\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 those recipients were mailed. Resend accepts the same request again without mailing anyone twice, because Kestrel sends it under the same key. A different provider, or another account, has never seen that key, so it delivers the post again. That\'s at most the recipients of the one unanswered request, but each of them gets the post twice.\n\nTo have another provider finish a halted send, accept that risk. Otherwise, fix the old account, and let the send resume there.\n';
|
|
4769
4877
|
|
|
4770
4878
|
// docs/guides/03-notifications.md
|
|
4771
4879
|
var notifications_default = "# Notifications through Cloudflare's email\n\nKestrel emails you when a send goes out, and right away when one runs into a problem. By default, these notifications go through your email provider. The one you need most, about your provider refusing your account, is the one your provider would refuse too. Cloudflare's email doesn't depend on your provider, so that notification still reaches you.\n\nIn this guide, you turn on Cloudflare's email for your app's hostname, and verify your own address with it. Then you add the binding that sends notifications through it, deploy, and send yourself a test.\n\n## Before you begin\n\nYou need:\n\n- Your notification address saved, from [Verify it works](../get-started/05-verify.md#3-set-up-your-notifications).\n- Your domain's DNS on Cloudflare, as for the rest of the app.\n\n## 1. Turn on Email Routing for your app's hostname\n\nCloudflare's email sends only from a domain set up with Email Routing. Use your app's hostname, `newsletter.example.com`. It receives no mail today, so turning Email Routing on there changes nothing else.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), go to **Compute \u2192 Email Service \u2192 Email Routing**.\n\n1. Add `newsletter.example.com` to Email Routing. Cloudflare adds subdomains from your domain's own settings: select `example.com`, then **Settings**, and add `newsletter` under **Subdomains**.\n\n1. Cloudflare adds MX, SPF, and DKIM records for `newsletter.example.com`, and locks them. Wait until they show as added.\n\nAvoid turning on Email Routing for a domain that already receives mail elsewhere, such as `example.com` with Google Workspace. Email Routing takes over that domain's MX records. Avoid `send.example.com` too, since your email provider sends from it.\n\n## 2. Verify your address\n\nCloudflare's email delivers only to addresses you've verified with it. Sending to a verified address is free on any plan.\n\n1. Under **Email Routing**, go to **Destination Addresses**, and add the address you saved for notifications.\n\n1. Open the email Cloudflare sends to that address, and select **Verify email address**.\n\nA verified address belongs to your Cloudflare account, so every environment in it can use it.\n\n## 3. Add the binding and deploy\n\nA `send_email` binding named `NOTIFY` moves notifications to Cloudflare's email. Without it, they go through your provider.\n\n1. In `wrangler.jsonc`, add the binding to the `production` block:\n\n ```jsonc\n \"send_email\": [\n { \"name\": \"NOTIFY\", \"allowed_destination_addresses\": [\"you@example.com\"] } // \u2190 your address\n ],\n ```\n\n `allowed_destination_addresses` is optional. It limits the binding to the addresses you list, so a changed setting in the editor can't send notifications anywhere else. Leave it out to allow any address you've verified.\n\n1. **(Optional)** Notifications come from `Kestrel <kestrel@newsletter.example.com>`. To use another address on that hostname, set `NOTIFY_FROM` in the block's `vars`:\n\n ```jsonc\n \"NOTIFY_FROM\": \"Kestrel <alerts@newsletter.example.com>\",\n ```\n\n1. Check the file. This regenerates the binding types:\n\n ```bash\n npm run typecheck\n ```\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am \"Send notifications through Cloudflare's email\"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## Check it\n\n1. In the editor, open **Settings \u2192 Notifications**. **Sent through** reads \"Cloudflare Email, separate from your newsletter's provider\", and **From address** shows `kestrel@newsletter.example.com`.\n\n1. Select **Send a test notification**. It arrives at your address, and **Last notification** shows it as delivered.\n\nIf the test isn't delivered, **Last notification** shows Cloudflare's reason, such as an address that isn't verified. Cloudflare's Email Routing summary lists notifications as dropped even when they're delivered, so check your inbox rather than the summary.\n\n## What notifications don't cover\n\n- **The schedule stopping.** The same once-a-minute schedule that sends your posts also notices problems. If the schedule itself stops, nothing tells you. [Read the logs](../get-started/05-verify.md#7-read-the-logs) to check that `sweep.tick` still arrives every minute.\n- **A notification that can't get through.** The app tries each one up to five times, a minute apart. A failure shows under **Settings \u2192 Notifications**, and in the logs as `notify.failed`.\n- **Problems while no address is set.** With the address blank, the app sends nothing, and saves nothing up for later.\n\nA notification never changes a send. One that fails never delays, pauses, or stops it.\n";
|
|
@@ -4776,18 +4884,20 @@ var rate_limit_default = "# Rate-limit the subscribe form\n\nThe subscribe form
|
|
|
4776
4884
|
// docs/guides/07-upgrade.md
|
|
4777
4885
|
var upgrade_default = "# Upgrade to a new release\n\nA new release of Kestrel is a new version of the `@kurtbruns/kestrel` package. Upgrading changes the version your instance's repository names, brings in the release's database changes, and deploys. The app can't undo a database change, so you note a restore point first.\n\nIn this guide, you read what changed, install the release, and note where your database stands. Then you apply the database changes, deploy, and check the new version. If you run a staging environment, upgrade it first, and repeat each step for production once it checks out.\n\n## 1. Read what changed\n\nA release can ask you to change a setting or a record before it runs. The changelog says so, release by release.\n\n1. Find the version you run. In the editor, open the **Docs** or **API** tab. The foot of its contents list shows the version, then the commit. Your instance's `package.json` names it too, as the version of `@kurtbruns/kestrel`.\n\n1. Read the [release notes](https://github.com/kurtbruns/kestrel/releases) for every version after yours, up to the one you're moving to. Each release opens with an **Upgrading from\u2026** paragraph that says what to do. Its **Breaking** entries say what changed underneath you.\n\n## 2. Install the release\n\nEach step here changes only your instance's repository. Nothing reaches your deployed app until section 5.\n\n1. Install the release. Replace `X.Y.Z` with its version:\n\n ```bash\n npm install @kurtbruns/kestrel@X.Y.Z\n ```\n\n Your `package.json` now names exactly that version. Kestrel's versions aren't semantic versioning, so your instance never moves to a release you didn't choose.\n\n1. Bring in the release's database migrations:\n\n ```bash\n npm run sync-migrations\n ```\n\n It copies each new migration into `migrations/` and names the ones it added. It never overwrites one that's there.\n\n1. Check the Claude Code files Kestrel gave your instance:\n\n ```bash\n npm run check-context\n ```\n\n It compares each file in `.claude/` with the release's, and says what to do for each one:\n\n - **current**: nothing to do.\n - **Kestrel changed it, and you haven't edited it**: take the new one with `npm run check-context -- --update`.\n - **Kestrel changed it, and you've edited it**: it shows the difference from yours to the release's. Merge what you want into yours, by hand or with Claude, then record that you did with the command it prints.\n\n1. If the release's **Upgrading from\u2026** paragraph names a new setting, add it to `wrangler.jsonc`.\n\n1. Check the configuration and the types:\n\n ```bash\n npm run typecheck\n ```\n\n1. **(Optional)** Try the release on your computer. Apply its database changes to your local database, then start the app:\n\n ```bash\n npx wrangler d1 migrations apply DB --local\n npm run dev\n ```\n\n You see the new version with your local content. It can't show how the changes treat your real data. A staging environment can.\n\n1. Commit the upgrade as one commit, and push it. Going back, below, undoes this commit:\n\n ```bash\n git add -A\n git commit -m \"Upgrade Kestrel to X.Y.Z\"\n git push\n ```\n\n## 3. Note a restore point\n\nD1 keeps a history of your database, called Time Travel, for 30 days on Workers Paid and 7 on Workers Free. A bookmark marks one moment in it, so you can put the database back if the upgrade goes wrong.\n\n1. In the editor, check that no send is in progress. The dashboard lists any that are. Upgrade between sends.\n\n1. Note where the database stands now:\n\n ```bash\n npx wrangler d1 time-travel info kestrel-production\n ```\n\n It prints a bookmark for the database's current state. Copy it, and keep it until the upgrade checks out.\n\n## 4. Apply the database changes\n\nA release changes the database only by adding a migration. Applying it keeps everything already in the database.\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it's about to apply, and asks you to confirm. When a release has none, it says there's nothing to apply. Run it on every upgrade.\n\n## 5. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThe output ends with your app's hostname.\n\n## Check it\n\n1. The app answers:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {\"status\":\"ok\",\"service\":\"kestrel\"}\n ```\n\n1. Reload the editor. The foot of the **Docs** tab's contents list shows the new version, which links to its release page.\n\n1. Send yourself a test email, as in [Verify it works](../get-started/05-verify.md#1-send-yourself-a-test-email). Don't send a real post to check an upgrade: on a live instance, it goes to your whole list.\n\n1. In the logs, `sweep.tick` still arrives every minute, as in [Verify it works](../get-started/05-verify.md#7-read-the-logs).\n\n## Go back\n\nIf the new release misbehaves, deploy the previous one, and put the database back to your bookmark.\n\n1. Find the upgrade's commit. Replace `X.Y.Z` with the release you're backing out. Its id comes first on the line:\n\n ```bash\n git log -1 --oneline --grep=\"Upgrade Kestrel to X.Y.Z\"\n ```\n\n1. Undo it. This adds a commit that puts back the previous release, its migrations, and your Claude Code files, and leaves anything you committed since as it is:\n\n ```bash\n git revert --no-edit REPLACE_WITH_COMMIT_ID\n ```\n\n1. Install the previous release, and deploy it:\n\n ```bash\n npm ci\n npx wrangler deploy --env production\n ```\n\n1. Restore the database to the bookmark from section 3:\n\n ```bash\n npx wrangler d1 time-travel restore kestrel-production --bookmark=REPLACE_WITH_BOOKMARK\n ```\n\n1. Push the revert to your repository:\n\n ```bash\n git push\n ```\n\nA restore returns the whole database to that moment. Everything written since is gone: edits to posts, new subscribers, and the record of anything sent. Restore only soon after the upgrade.\n\nNever restore past a send that went out, or an unsubscribe recorded since the bookmark. The restored database wouldn't know of them, so it could mail those readers again, or mail someone who left. If either happened, stay on the new release, and fix forward.\n";
|
|
4778
4886
|
|
|
4887
|
+
// docs/guides/08-try-locally.md
|
|
4888
|
+
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";
|
|
4889
|
+
|
|
4779
4890
|
// docs/README.md
|
|
4780
|
-
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.\n\n1. [
|
|
4891
|
+
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 an email provider](get-started/04-resend.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- [Use Amazon SES instead of Resend](guides/02-ses.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";
|
|
4781
4892
|
|
|
4782
4893
|
// docs/reference/01-configuration.md
|
|
4783
|
-
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.\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\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: `fake`, `resend`, or `ses` | required |\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; Resend ignores it | `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\n### `PROVIDER`\n\n`fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and so does a new deployment until its provider is connected. Never schedule a real post on it.\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.\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 starts SES requests no faster than this rate. Set it to the maximum send rate on your SES **Account dashboard**. 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. Through Resend, a minute sends about 400 recipients. Through SES, which takes one recipient a request, it sends about 18.\n- **On Workers Paid,** which allows 10,000, set `\"SUBREQUEST_BUDGET\": \"10000\"`. Resend then sends about 19,000 recipients a minute. SES sends as fast as your account's rate allows, up to about 1,900.\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` | 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| `RESEND_API_KEY` | the Resend API key | Resend |\n| `RESEND_WEBHOOK_SECRET` | the Resend webhook's signing secret | Resend |\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` must be exactly `fake`, `resend`, or `ses`. A misspelling, or `SES`, is refused, never taken for `fake`.\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`.\n- With a real provider, none of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com`, the template's placeholder.\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\nUntil it's fixed, every request answers `500` with a body naming the variable, such as `{\"error\":\"invalid_config\",\"variable\":\"APP_ORIGIN\",\u2026}`. The Worker's log records it, and no sends go out. The editor's page still loads, since it's a static file, but everything it asks the API for fails the same way.\n";
|
|
4894
|
+
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: `resend` or `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; Resend ignores it | `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\n### `PROVIDER`\n\nUntil it's set, 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. [Connect an email provider](../get-started/04-resend.md) sets it.\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 set it on a deployment that has readers: every subscriber would be recorded as sent the post, and never get it.\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 starts SES requests no faster than this rate. Set it to the maximum send rate on your SES **Account dashboard**. 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. Through Resend, a minute sends about 400 recipients. Through SES, which takes one recipient a request, it sends about 18.\n- **On Workers Paid,** which allows 10,000, set `\"SUBREQUEST_BUDGET\": \"10000\"`. Resend then sends about 19,000 recipients a minute. SES sends as fast as your account's rate allows, up to about 1,900.\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` | 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| `RESEND_API_KEY` | the Resend API key | Resend |\n| `RESEND_WEBHOOK_SECRET` | the Resend webhook's signing secret | Resend |\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 `resend`, `ses`, `fake`, or `none`. A misspelling, or `SES`, is refused, never taken for another.\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`.\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`, or the provider a local send models, `resend`, `ses`, or `1` for a generic one, optionally followed by `:none` for no injected failures. 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.\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\",\"variable\":\"APP_ORIGIN\",\u2026}`.\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";
|
|
4784
4895
|
|
|
4785
4896
|
// docs/reference/02-sending-domain-dns.md
|
|
4786
|
-
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 records that [Connect
|
|
4897
|
+
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 records that [Connect an email provider](../get-started/04-resend.md) and [Use Amazon SES instead of Resend](../guides/02-ses.md) have you add. It covers what each one 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, through either provider, 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 records decide whether mail lands in the inbox. Set up all three 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 three 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\nYour provider generates the SPF and DKIM values. Copy them exactly as its dashboard lists them, since the values differ by account and region. You write the DMARC record yourself.\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 with either provider, so an aligned SPF is a second check in your favor, not a requirement.\n\n- **Resend** uses a return path on a subdomain of your sending name, `send.send.example.com` by default. Its dashboard lists an MX record and an SPF `TXT` record for it. In Cloudflare, both are named `send.send`. That name looks doubled because your sending name is itself `send`.\n- **SES** uses its own domain, `amazonses.com`, as the return path by default. SPF then passes for `amazonses.com` but doesn't align, and DMARC passes through DKIM alone. A custom MAIL FROM domain, such as `bounce.send.example.com`, aligns SPF too. [Use Amazon SES instead of Resend](../guides/02-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 ```\n bounce.send.example.com. MX 10 feedback-smtp.us-east-1.amazonses.com.\n bounce.send.example.com. TXT \"v=spf1 include:amazonses.com ~all\"\n ```\n\nA name holds one SPF record. If one already exists there, add the provider's `include:` 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\n- **Resend** lists one `TXT` record at `resend._domainkey.send.example.com`. A domain added to Resend after August 2026 may get `CNAME` records instead.\n- **SES** 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. Your provider 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 checks, 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`. 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 Resend, or SES with a custom MAIL FROM domain, its domain is under `send.example.com`. With SES and no custom MAIL FROM domain, it's `amazonses.com`. That's expected, and DMARC still passes.\n";
|
|
4787
4898
|
|
|
4788
4899
|
// src/docs/index.ts
|
|
4789
4900
|
var PAGES = {
|
|
4790
|
-
"get-started/01-overview.md": overview_default,
|
|
4791
4901
|
"get-started/02-deploy.md": deploy_default,
|
|
4792
4902
|
"get-started/03-access.md": access_default,
|
|
4793
4903
|
"get-started/04-resend.md": resend_default,
|
|
@@ -4798,6 +4908,7 @@ var PAGES = {
|
|
|
4798
4908
|
"guides/03-notifications.md": notifications_default,
|
|
4799
4909
|
"guides/05-rate-limit.md": rate_limit_default,
|
|
4800
4910
|
"guides/07-upgrade.md": upgrade_default,
|
|
4911
|
+
"guides/08-try-locally.md": try_locally_default,
|
|
4801
4912
|
"reference/01-configuration.md": configuration_default,
|
|
4802
4913
|
"reference/02-sending-domain-dns.md": sending_domain_dns_default
|
|
4803
4914
|
};
|
|
@@ -5019,10 +5130,16 @@ function author(c) {
|
|
|
5019
5130
|
}
|
|
5020
5131
|
async function readBody(c, opts = {}) {
|
|
5021
5132
|
const raw = await readJsonObject(c, opts);
|
|
5133
|
+
const slug = optString(raw, "slug");
|
|
5134
|
+
const slugAuto = optBoolean(raw, "slug_auto");
|
|
5135
|
+
if (slug !== void 0 && slugAuto === true) {
|
|
5136
|
+
throw fieldError("slug", "slug cannot be set while slug_auto is true");
|
|
5137
|
+
}
|
|
5022
5138
|
return {
|
|
5023
5139
|
input: {
|
|
5024
5140
|
subject: optString(raw, "subject"),
|
|
5025
|
-
slug
|
|
5141
|
+
slug,
|
|
5142
|
+
slug_auto: slugAuto,
|
|
5026
5143
|
markdown: optString(raw, "markdown")
|
|
5027
5144
|
},
|
|
5028
5145
|
base_revision: optStringOrNull(raw, "base_revision") ?? null
|
|
@@ -5301,6 +5418,36 @@ var FakeProvider = class {
|
|
|
5301
5418
|
}
|
|
5302
5419
|
};
|
|
5303
5420
|
|
|
5421
|
+
// src/providers/none.ts
|
|
5422
|
+
var NOT_CONNECTED_ERROR = "No email provider is connected to this deployment.";
|
|
5423
|
+
var NotConnectedProvider = class {
|
|
5424
|
+
name = "none";
|
|
5425
|
+
// Traits the send loop reads before its first batch; nothing is ever carried, so they
|
|
5426
|
+
// only need to be valid.
|
|
5427
|
+
maxBatch = 100;
|
|
5428
|
+
idempotentRetry = false;
|
|
5429
|
+
async sendBatch(_rendered, _recipients, _opts) {
|
|
5430
|
+
return {
|
|
5431
|
+
kind: "halted",
|
|
5432
|
+
halt: {
|
|
5433
|
+
reason: "account",
|
|
5434
|
+
cause: "not_connected",
|
|
5435
|
+
error: NOT_CONNECTED_ERROR,
|
|
5436
|
+
mayHaveSent: false
|
|
5437
|
+
}
|
|
5438
|
+
};
|
|
5439
|
+
}
|
|
5440
|
+
async parseWebhook() {
|
|
5441
|
+
return {
|
|
5442
|
+
events: [],
|
|
5443
|
+
response: Response.json(
|
|
5444
|
+
{ error: "not_found", message: "no email provider is connected" },
|
|
5445
|
+
{ status: 404 }
|
|
5446
|
+
)
|
|
5447
|
+
};
|
|
5448
|
+
}
|
|
5449
|
+
};
|
|
5450
|
+
|
|
5304
5451
|
// src/lib/constant_time.ts
|
|
5305
5452
|
async function timingSafeEqual(a, b) {
|
|
5306
5453
|
const enc = new TextEncoder();
|
|
@@ -6476,6 +6623,8 @@ async function drainSimulatedWebhooks(env, config) {
|
|
|
6476
6623
|
// src/providers/index.ts
|
|
6477
6624
|
function getProvider(config, env) {
|
|
6478
6625
|
switch (config.provider) {
|
|
6626
|
+
case "none":
|
|
6627
|
+
return new NotConnectedProvider();
|
|
6479
6628
|
case "fake":
|
|
6480
6629
|
return config.simulation ? new SimProvider(config.simulation, config) : new FakeProvider();
|
|
6481
6630
|
case "ses":
|
|
@@ -6488,6 +6637,15 @@ function getProvider(config, env) {
|
|
|
6488
6637
|
}
|
|
6489
6638
|
}
|
|
6490
6639
|
}
|
|
6640
|
+
function requireConnectedProvider(config) {
|
|
6641
|
+
if (config.provider === "none") {
|
|
6642
|
+
throw refusal(
|
|
6643
|
+
409,
|
|
6644
|
+
"provider_not_connected",
|
|
6645
|
+
"No email provider is connected, so nothing can be sent yet. Connect one (the setup guide walks through it), then try again."
|
|
6646
|
+
);
|
|
6647
|
+
}
|
|
6648
|
+
}
|
|
6491
6649
|
function perRecipient(result, recipients) {
|
|
6492
6650
|
if (result.kind === "answered") {
|
|
6493
6651
|
return result.results;
|
|
@@ -6503,6 +6661,7 @@ function perRecipient(result, recipients) {
|
|
|
6503
6661
|
|
|
6504
6662
|
// src/services/subscriptions.ts
|
|
6505
6663
|
async function requestSubscription(c, email2) {
|
|
6664
|
+
requireConnectedProvider(c.config);
|
|
6506
6665
|
const db = c.env.DB;
|
|
6507
6666
|
const existing = await getByEmail(db, email2);
|
|
6508
6667
|
if (existing?.status === "confirmed") {
|
|
@@ -6647,35 +6806,64 @@ async function readToken(c) {
|
|
|
6647
6806
|
}
|
|
6648
6807
|
return "";
|
|
6649
6808
|
}
|
|
6809
|
+
function toPublication(action2, name) {
|
|
6810
|
+
return name ? `${action2} to ${name}` : action2;
|
|
6811
|
+
}
|
|
6650
6812
|
function subscribeFormHtml() {
|
|
6651
|
-
return `<form class="r-form" method="post" action="/subscribe"><input type="email" name="email" required placeholder="you@example.com" aria-label="Your email address"><button class="r-btn" type="submit">Subscribe</button></form><p class="r-fine">
|
|
6813
|
+
return `<form class="r-form" method="post" action="/subscribe"><input type="email" name="email" required placeholder="you@example.com" aria-label="Your email address"><button class="r-btn" type="submit">Subscribe</button></form><p class="r-fine">We\u2019ll email you a link to confirm. Unsubscribe anytime.</p>`;
|
|
6652
6814
|
}
|
|
6653
6815
|
function subscribePage(c, identity, mainHtml, status = 200) {
|
|
6654
6816
|
return readerPage({
|
|
6655
6817
|
identity,
|
|
6656
6818
|
homeUrl: `${c.config.appOrigin}/`,
|
|
6657
|
-
title:
|
|
6819
|
+
title: pageTitle("Subscribe", identity.name),
|
|
6658
6820
|
mainHtml,
|
|
6659
6821
|
devDashboardUrl: devDashboardUrl(c.config),
|
|
6660
6822
|
status
|
|
6661
6823
|
});
|
|
6662
6824
|
}
|
|
6663
|
-
|
|
6664
|
-
const identity = await readerIdentity(c, c.config);
|
|
6825
|
+
function subscribeFormPage(c, identity) {
|
|
6665
6826
|
const lede = identity.tagline ? escapeHtml(identity.tagline) : "Get the next post delivered to your inbox.";
|
|
6666
|
-
const main = `<p class="r-ey">Newsletter</p><h1 class="r-h1"
|
|
6827
|
+
const main = `<p class="r-ey">Newsletter</p><h1 class="r-h1">${escapeHtml(toPublication("Subscribe", identity.name))}</h1><p class="r-lead">${lede}</p>` + subscribeFormHtml();
|
|
6828
|
+
return subscribePage(c, identity, main);
|
|
6829
|
+
}
|
|
6830
|
+
function subscribeNotAnEmailPage(c, identity) {
|
|
6831
|
+
const main = `<p class="r-ey">Newsletter</p><h1 class="r-h1">That doesn\u2019t look like an email</h1><p class="r-lead">Please check the address and try again.</p>` + subscribeFormHtml();
|
|
6832
|
+
return subscribePage(c, identity, main, 400);
|
|
6833
|
+
}
|
|
6834
|
+
function subscribeNotOpenPage(c, identity, status = 200) {
|
|
6835
|
+
const main = `<p class="r-ey">Newsletter</p><h1 class="r-h1">Signups aren\u2019t open yet</h1><p class="r-lead">This newsletter isn\u2019t taking subscribers yet. Please check back soon.</p>`;
|
|
6836
|
+
return subscribePage(c, identity, main, status);
|
|
6837
|
+
}
|
|
6838
|
+
function subscribeCheckInboxPage(c, identity) {
|
|
6839
|
+
const main = `<p class="r-ey">Newsletter</p><h1 class="r-h1">Almost there</h1><p class="r-lead">If that address isn\u2019t subscribed yet, a confirmation link is on its way. Check your inbox to finish subscribing.</p>`;
|
|
6667
6840
|
return subscribePage(c, identity, main);
|
|
6668
6841
|
}
|
|
6842
|
+
async function subscribeForm(c) {
|
|
6843
|
+
const identity = await readerIdentity(c, c.config);
|
|
6844
|
+
return c.config.provider === "none" ? subscribeNotOpenPage(c, identity) : subscribeFormPage(c, identity);
|
|
6845
|
+
}
|
|
6669
6846
|
async function subscribe(c) {
|
|
6847
|
+
if (c.config.provider === "none") {
|
|
6848
|
+
log.warn("subscribe.not_connected");
|
|
6849
|
+
if (!wantsHtml(c)) {
|
|
6850
|
+
return json(
|
|
6851
|
+
{
|
|
6852
|
+
error: "provider_not_connected",
|
|
6853
|
+
message: "This newsletter isn't taking subscribers yet."
|
|
6854
|
+
},
|
|
6855
|
+
409
|
|
6856
|
+
);
|
|
6857
|
+
}
|
|
6858
|
+
return subscribeNotOpenPage(c, await readerIdentity(c, c.config), 409);
|
|
6859
|
+
}
|
|
6670
6860
|
const raw = await readEmail(c);
|
|
6671
6861
|
const email2 = raw ? normalizeEmail(raw) : "";
|
|
6672
6862
|
if (!email2 || !isValidEmail(email2)) {
|
|
6673
6863
|
if (!wantsHtml(c)) {
|
|
6674
6864
|
return json({ error: "bad_request", message: "a valid email is required" }, 400);
|
|
6675
6865
|
}
|
|
6676
|
-
|
|
6677
|
-
const main2 = `<p class="r-ey">Newsletter</p><h1 class="r-h1">That doesn\u2019t look like an email</h1><p class="r-lead">Please check the address and try again.</p>` + subscribeFormHtml();
|
|
6678
|
-
return subscribePage(c, identity2, main2, 400);
|
|
6866
|
+
return subscribeNotAnEmailPage(c, await readerIdentity(c, c.config));
|
|
6679
6867
|
}
|
|
6680
6868
|
c.ctx.waitUntil(
|
|
6681
6869
|
requestSubscription(c, email2).catch((err) => {
|
|
@@ -6686,9 +6874,7 @@ async function subscribe(c) {
|
|
|
6686
6874
|
const body = { status: "check_inbox" };
|
|
6687
6875
|
return json(body);
|
|
6688
6876
|
}
|
|
6689
|
-
|
|
6690
|
-
const main = `<p class="r-ey">Newsletter</p><h1 class="r-h1">Almost there</h1><p class="r-lead">If that address isn\u2019t subscribed yet, a confirmation link is on its way. Check your inbox to finish subscribing.</p>`;
|
|
6691
|
-
return subscribePage(c, identity, main);
|
|
6877
|
+
return subscribeCheckInboxPage(c, await readerIdentity(c, c.config));
|
|
6692
6878
|
}
|
|
6693
6879
|
async function confirmLanding(c) {
|
|
6694
6880
|
const token2 = c.url.searchParams.get("token") ?? "";
|
|
@@ -6698,98 +6884,197 @@ async function confirm2(c) {
|
|
|
6698
6884
|
const token2 = await readToken(c);
|
|
6699
6885
|
return confirmStatePage(c, await confirmSubscription2(c.env.DB, token2), token2);
|
|
6700
6886
|
}
|
|
6701
|
-
function confirmStatePage(c, state, token2) {
|
|
6702
|
-
const
|
|
6887
|
+
async function confirmStatePage(c, state, token2) {
|
|
6888
|
+
const eyebrow = "Newsletter";
|
|
6703
6889
|
switch (state.kind) {
|
|
6704
|
-
case "ready":
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
6712
|
-
|
|
6713
|
-
</form
|
|
6714
|
-
|
|
6715
|
-
|
|
6890
|
+
case "ready": {
|
|
6891
|
+
const identity = await readerIdentity(c, c.config);
|
|
6892
|
+
return readerMessage(
|
|
6893
|
+
c,
|
|
6894
|
+
{
|
|
6895
|
+
title: "Confirm your subscription",
|
|
6896
|
+
eyebrow,
|
|
6897
|
+
headline: toPublication("Confirm your subscription", identity.name),
|
|
6898
|
+
ledeHtml: `Start sending the newsletter to <strong>${escapeHtml(state.subscriber.email)}</strong>?`,
|
|
6899
|
+
afterHtml: `<form class="r-form" method="post" action="/confirm"><input type="hidden" name="token" value="${escapeHtmlAttr(token2)}"><button class="r-btn" type="submit">Confirm subscription</button></form>`
|
|
6900
|
+
},
|
|
6901
|
+
identity
|
|
6716
6902
|
);
|
|
6903
|
+
}
|
|
6717
6904
|
case "confirmed":
|
|
6718
|
-
return
|
|
6719
|
-
"Subscribed",
|
|
6720
|
-
|
|
6721
|
-
|
|
6722
|
-
)}</strong>.
|
|
6723
|
-
|
|
6724
|
-
|
|
6725
|
-
);
|
|
6905
|
+
return readerMessage(c, {
|
|
6906
|
+
title: "Subscribed",
|
|
6907
|
+
eyebrow,
|
|
6908
|
+
headline: "You\u2019re subscribed",
|
|
6909
|
+
ledeHtml: `Thanks for confirming <strong>${escapeHtml(state.subscriber.email)}</strong>. The next post will land in your inbox.`,
|
|
6910
|
+
afterHtml: archiveLink(archiveHomeUrl(c.config), "Browse the archive")
|
|
6911
|
+
});
|
|
6726
6912
|
case "expired":
|
|
6727
|
-
return
|
|
6728
|
-
"Link expired",
|
|
6729
|
-
|
|
6730
|
-
|
|
6731
|
-
)}</strong
|
|
6732
|
-
|
|
6733
|
-
|
|
6734
|
-
|
|
6735
|
-
</form>`,
|
|
6736
|
-
410,
|
|
6737
|
-
dev
|
|
6738
|
-
);
|
|
6913
|
+
return readerMessage(c, {
|
|
6914
|
+
title: "Link expired",
|
|
6915
|
+
eyebrow,
|
|
6916
|
+
headline: "This link has expired",
|
|
6917
|
+
ledeHtml: `Confirmation links only work for a limited time. We can send a new one to <strong>${escapeHtml(state.subscriber.email)}</strong>.`,
|
|
6918
|
+
afterHtml: `<form class="r-form" method="post" action="/subscribe"><input type="hidden" name="email" value="${escapeHtmlAttr(state.subscriber.email)}"><button class="r-btn" type="submit">Send a new link</button></form>`,
|
|
6919
|
+
status: 410
|
|
6920
|
+
});
|
|
6739
6921
|
case "invalid":
|
|
6740
|
-
return
|
|
6741
|
-
"Invalid link",
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6922
|
+
return readerMessage(c, {
|
|
6923
|
+
title: "Invalid link",
|
|
6924
|
+
eyebrow,
|
|
6925
|
+
headline: "This link is invalid",
|
|
6926
|
+
ledeHtml: `<a href="/subscribe">Subscribe again</a> to get a new one.`,
|
|
6927
|
+
status: 400
|
|
6928
|
+
});
|
|
6746
6929
|
}
|
|
6747
6930
|
}
|
|
6931
|
+
function unsubscribeInvalidPage(c) {
|
|
6932
|
+
return readerMessage(c, {
|
|
6933
|
+
title: "Invalid link",
|
|
6934
|
+
eyebrow: "Unsubscribe",
|
|
6935
|
+
headline: "This link is invalid",
|
|
6936
|
+
ledeHtml: "It may be incomplete. The unsubscribe link at the bottom of any email from the newsletter will work.",
|
|
6937
|
+
status: 400
|
|
6938
|
+
});
|
|
6939
|
+
}
|
|
6940
|
+
function unsubscribedPage(c, email2, banner) {
|
|
6941
|
+
return readerMessage(c, {
|
|
6942
|
+
title: "Unsubscribed",
|
|
6943
|
+
eyebrow: "Unsubscribe",
|
|
6944
|
+
headline: "You\u2019re unsubscribed",
|
|
6945
|
+
ledeHtml: `<strong>${escapeHtml(email2)}</strong> won\u2019t receive further emails.`,
|
|
6946
|
+
banner
|
|
6947
|
+
});
|
|
6948
|
+
}
|
|
6949
|
+
async function unsubscribeAskPage(c, email2, action2, banner) {
|
|
6950
|
+
const identity = await readerIdentity(c, c.config);
|
|
6951
|
+
return readerMessage(
|
|
6952
|
+
c,
|
|
6953
|
+
{
|
|
6954
|
+
title: "Unsubscribe",
|
|
6955
|
+
eyebrow: "Unsubscribe",
|
|
6956
|
+
headline: identity.name ? `Unsubscribe from ${identity.name}?` : "Unsubscribe?",
|
|
6957
|
+
ledeHtml: `We\u2019ll stop sending the newsletter to <strong>${escapeHtml(email2)}</strong>.`,
|
|
6958
|
+
afterHtml: `<form class="r-form" method="post" action="${escapeHtmlAttr(action2)}"><button class="r-btn" type="submit">Unsubscribe</button></form>`,
|
|
6959
|
+
banner
|
|
6960
|
+
},
|
|
6961
|
+
identity
|
|
6962
|
+
);
|
|
6963
|
+
}
|
|
6964
|
+
var TEST_UNSUBSCRIBE_PARAM = "test";
|
|
6965
|
+
function testUnsubscribeUrl(config) {
|
|
6966
|
+
return `${config.appOrigin}/unsubscribe?${TEST_UNSUBSCRIBE_PARAM}=1`;
|
|
6967
|
+
}
|
|
6968
|
+
function isTestUnsubscribe(c, token2) {
|
|
6969
|
+
return token2 === "" && c.url.searchParams.get(TEST_UNSUBSCRIBE_PARAM) === "1";
|
|
6970
|
+
}
|
|
6971
|
+
var TEST_UNSUBSCRIBE_EMAIL = "reader@example.com";
|
|
6972
|
+
var TEST_UNSUBSCRIBE_BANNER = {
|
|
6973
|
+
tag: "Test",
|
|
6974
|
+
text: "This is the page a subscriber sees. Nothing here changes anyone\u2019s subscription."
|
|
6975
|
+
};
|
|
6748
6976
|
async function unsubscribeLanding(c) {
|
|
6749
6977
|
const token2 = c.url.searchParams.get("token") ?? "";
|
|
6978
|
+
if (isTestUnsubscribe(c, token2)) {
|
|
6979
|
+
return unsubscribeAskPage(
|
|
6980
|
+
c,
|
|
6981
|
+
TEST_UNSUBSCRIBE_EMAIL,
|
|
6982
|
+
`/unsubscribe?${TEST_UNSUBSCRIBE_PARAM}=1`,
|
|
6983
|
+
TEST_UNSUBSCRIBE_BANNER
|
|
6984
|
+
);
|
|
6985
|
+
}
|
|
6750
6986
|
const row = await getByUnsubToken(c.env.DB, token2);
|
|
6751
|
-
const dev = devDashboardUrl(c.config);
|
|
6752
6987
|
if (!row) {
|
|
6753
|
-
return
|
|
6754
|
-
"Invalid link",
|
|
6755
|
-
`<h1 style="margin-top:0;">This link is invalid</h1>`,
|
|
6756
|
-
400,
|
|
6757
|
-
dev
|
|
6758
|
-
);
|
|
6988
|
+
return unsubscribeInvalidPage(c);
|
|
6759
6989
|
}
|
|
6760
6990
|
if (row.status === "unsubscribed") {
|
|
6761
|
-
return
|
|
6762
|
-
"Unsubscribed",
|
|
6763
|
-
`<h1 style="margin-top:0;">You're unsubscribed</h1><p>${escapeHtml(row.email)} won't receive further emails.</p>`,
|
|
6764
|
-
200,
|
|
6765
|
-
dev
|
|
6766
|
-
);
|
|
6991
|
+
return unsubscribedPage(c, row.email);
|
|
6767
6992
|
}
|
|
6768
|
-
return
|
|
6769
|
-
"Unsubscribe",
|
|
6770
|
-
`<h1>Unsubscribe?</h1><p>Stop sending the newsletter to <strong>${escapeHtml(
|
|
6771
|
-
row.email
|
|
6772
|
-
)}</strong>?</p>
|
|
6773
|
-
<form method="post" action="/unsubscribe?token=${encodeURIComponent(token2)}">
|
|
6774
|
-
<button type="submit" class="btn btn-danger">Unsubscribe</button>
|
|
6775
|
-
</form>`,
|
|
6776
|
-
200,
|
|
6777
|
-
dev
|
|
6778
|
-
);
|
|
6993
|
+
return unsubscribeAskPage(c, row.email, `/unsubscribe?token=${encodeURIComponent(token2)}`);
|
|
6779
6994
|
}
|
|
6780
6995
|
async function unsubscribe(c) {
|
|
6781
6996
|
const token2 = await readToken(c);
|
|
6997
|
+
if (isTestUnsubscribe(c, token2)) {
|
|
6998
|
+
return wantsHtml(c) ? unsubscribedPage(c, TEST_UNSUBSCRIBE_EMAIL, TEST_UNSUBSCRIBE_BANNER) : new Response("test email", { status: 200 });
|
|
6999
|
+
}
|
|
6782
7000
|
const row = await unsubscribeByToken(c.env.DB, token2);
|
|
6783
|
-
const dev = devDashboardUrl(c.config);
|
|
6784
7001
|
if (!row) {
|
|
6785
|
-
return wantsHtml(c) ?
|
|
7002
|
+
return wantsHtml(c) ? unsubscribeInvalidPage(c) : new Response("invalid token", { status: 400 });
|
|
7003
|
+
}
|
|
7004
|
+
return wantsHtml(c) ? unsubscribedPage(c, row.email) : new Response("unsubscribed", { status: 200 });
|
|
7005
|
+
}
|
|
7006
|
+
|
|
7007
|
+
// shared/reader_pages.ts
|
|
7008
|
+
var SUBSCRIBE = { base: "app", path: "/subscribe" };
|
|
7009
|
+
var CONFIRM = { base: "app", path: "/confirm?token=\u2026" };
|
|
7010
|
+
var UNSUBSCRIBE = { base: "app", path: "/unsubscribe?token=\u2026" };
|
|
7011
|
+
var READER_JOURNEYS = [
|
|
7012
|
+
{
|
|
7013
|
+
id: "subscribe",
|
|
7014
|
+
label: "Subscribe",
|
|
7015
|
+
flow: "steps",
|
|
7016
|
+
states: [
|
|
7017
|
+
{ id: "form", label: "Form", at: SUBSCRIBE },
|
|
7018
|
+
{ id: "check-inbox", label: "Check your inbox", at: SUBSCRIBE },
|
|
7019
|
+
{ id: "not-an-email", label: "Not an email", offPath: true, at: SUBSCRIBE }
|
|
7020
|
+
]
|
|
7021
|
+
},
|
|
7022
|
+
{
|
|
7023
|
+
id: "confirm",
|
|
7024
|
+
label: "Confirm",
|
|
7025
|
+
flow: "steps",
|
|
7026
|
+
states: [
|
|
7027
|
+
{ id: "ready", label: "Ready", at: CONFIRM },
|
|
7028
|
+
{ id: "confirmed", label: "Confirmed", at: CONFIRM },
|
|
7029
|
+
{ id: "expired", label: "Expired", offPath: true, at: CONFIRM },
|
|
7030
|
+
{ id: "invalid", label: "Invalid link", offPath: true, at: CONFIRM }
|
|
7031
|
+
]
|
|
7032
|
+
},
|
|
7033
|
+
{
|
|
7034
|
+
id: "unsubscribe",
|
|
7035
|
+
label: "Unsubscribe",
|
|
7036
|
+
flow: "steps",
|
|
7037
|
+
states: [
|
|
7038
|
+
{ id: "ask", label: "Ask", at: UNSUBSCRIBE },
|
|
7039
|
+
{ id: "done", label: "Unsubscribed", at: UNSUBSCRIBE },
|
|
7040
|
+
{ id: "invalid", label: "Invalid link", offPath: true, at: UNSUBSCRIBE }
|
|
7041
|
+
]
|
|
7042
|
+
},
|
|
7043
|
+
{
|
|
7044
|
+
id: "reading",
|
|
7045
|
+
label: "Reading",
|
|
7046
|
+
flow: "peers",
|
|
7047
|
+
states: [
|
|
7048
|
+
{ id: "landing", label: "Landing page", at: { base: "app", path: "/" } },
|
|
7049
|
+
{ id: "archive", label: "Archive", at: { base: "archive", path: "" } },
|
|
7050
|
+
// The sample post's slug, as the Worker's preview renders it.
|
|
7051
|
+
{ id: "post", label: "Post page", at: { base: "archive", path: "/sample-post" } },
|
|
7052
|
+
// A publication with nothing sent yet: what its first visitors get.
|
|
7053
|
+
{
|
|
7054
|
+
id: "landing-empty",
|
|
7055
|
+
label: "Landing, no posts yet",
|
|
7056
|
+
offPath: true,
|
|
7057
|
+
at: { base: "app", path: "/" }
|
|
7058
|
+
},
|
|
7059
|
+
{
|
|
7060
|
+
id: "archive-empty",
|
|
7061
|
+
label: "Archive, no posts yet",
|
|
7062
|
+
offPath: true,
|
|
7063
|
+
at: { base: "archive", path: "" }
|
|
7064
|
+
},
|
|
7065
|
+
{
|
|
7066
|
+
id: "not-found",
|
|
7067
|
+
label: "Nothing here yet",
|
|
7068
|
+
offPath: true,
|
|
7069
|
+
at: { base: "archive", path: "/\u2026" }
|
|
7070
|
+
}
|
|
7071
|
+
]
|
|
6786
7072
|
}
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
) : new Response("unsubscribed", { status: 200 });
|
|
7073
|
+
];
|
|
7074
|
+
function findReaderPage(journey, state) {
|
|
7075
|
+
const j = READER_JOURNEYS.find((x) => x.id === journey);
|
|
7076
|
+
const s = j?.states.find((x) => x.id === state);
|
|
7077
|
+
return j && s ? { journey: j, state: s } : null;
|
|
6793
7078
|
}
|
|
6794
7079
|
|
|
6795
7080
|
// src/notify/fake.ts
|
|
@@ -6822,9 +7107,6 @@ async function loadRenderInput(c) {
|
|
|
6822
7107
|
const imgs = await listImages(c.env.DB, post.id);
|
|
6823
7108
|
return { post, revision, images: imgs };
|
|
6824
7109
|
}
|
|
6825
|
-
function genericUnsubscribeUrl(c) {
|
|
6826
|
-
return `${c.config.appOrigin}/unsubscribe`;
|
|
6827
|
-
}
|
|
6828
7110
|
async function loadBranding(c) {
|
|
6829
7111
|
return resolveBranding(await getSettings(c.env.DB), c.config);
|
|
6830
7112
|
}
|
|
@@ -6836,11 +7118,21 @@ async function loadPostEmail(c) {
|
|
|
6836
7118
|
input,
|
|
6837
7119
|
email: { subject: send.subject, html: send.rendered_html, text: send.rendered_text },
|
|
6838
7120
|
warnings: [],
|
|
6839
|
-
frozen: { id: send.id }
|
|
7121
|
+
frozen: { id: send.id },
|
|
7122
|
+
dateMs: send.started_at ?? send.fire_at
|
|
6840
7123
|
};
|
|
6841
7124
|
}
|
|
6842
7125
|
const result = await render(input, c.config, await loadBranding(c));
|
|
6843
|
-
return { input, email: result, warnings: result.warnings, frozen: null };
|
|
7126
|
+
return { input, email: result, warnings: result.warnings, frozen: null, dateMs: Date.now() };
|
|
7127
|
+
}
|
|
7128
|
+
function postPage(html) {
|
|
7129
|
+
return new Response(html, {
|
|
7130
|
+
headers: {
|
|
7131
|
+
...POST_PAGE_SECURITY_HEADERS,
|
|
7132
|
+
"content-type": "text/html; charset=utf-8",
|
|
7133
|
+
"x-robots-tag": "noindex"
|
|
7134
|
+
}
|
|
7135
|
+
});
|
|
6844
7136
|
}
|
|
6845
7137
|
async function preview(c) {
|
|
6846
7138
|
const { input, email: email2, warnings, frozen } = await loadPostEmail(c);
|
|
@@ -6855,26 +7147,33 @@ async function preview(c) {
|
|
|
6855
7147
|
async function previewPage(c) {
|
|
6856
7148
|
const { email: email2 } = await loadPostEmail(c);
|
|
6857
7149
|
const html = substituteRecipient(email2, {
|
|
6858
|
-
|
|
7150
|
+
// The preview is the email shown to the publisher, so its link opens the same page
|
|
7151
|
+
// a test email's does, not one calling the link invalid (SPEC §7).
|
|
7152
|
+
".Email.UnsubscribeURL": testUnsubscribeUrl(c.config),
|
|
6859
7153
|
".Email.SentTo": ""
|
|
6860
7154
|
}).html;
|
|
6861
|
-
return
|
|
6862
|
-
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
6867
|
-
|
|
7155
|
+
return postPage(html);
|
|
7156
|
+
}
|
|
7157
|
+
async function webPage(c) {
|
|
7158
|
+
const { email: email2, dateMs } = await loadPostEmail(c);
|
|
7159
|
+
return postPage(
|
|
7160
|
+
webVersion(email2.html, {
|
|
7161
|
+
config: c.config,
|
|
7162
|
+
identity: await readerIdentity(c, c.config),
|
|
7163
|
+
dateMs
|
|
7164
|
+
})
|
|
7165
|
+
);
|
|
6868
7166
|
}
|
|
6869
7167
|
async function test(c) {
|
|
6870
7168
|
const to = normalizeEmail(optString(await readJsonObject(c), "to") ?? "");
|
|
6871
7169
|
if (!isValidEmail(to)) {
|
|
6872
7170
|
throw fieldError("to", "to must be an email address");
|
|
6873
7171
|
}
|
|
7172
|
+
requireConnectedProvider(c.config);
|
|
6874
7173
|
const readAt = Date.now();
|
|
6875
7174
|
const { input, email: email2, warnings, frozen } = await loadPostEmail(c);
|
|
6876
7175
|
const provider = getProvider(c.config, c.env);
|
|
6877
|
-
const unsubscribeUrl =
|
|
7176
|
+
const unsubscribeUrl = testUnsubscribeUrl(c.config);
|
|
6878
7177
|
const recipients = [{ email: to, unsubscribeUrl }];
|
|
6879
7178
|
const [res] = perRecipient(
|
|
6880
7179
|
await provider.sendBatch(email2, recipients, {
|
|
@@ -6911,14 +7210,16 @@ var TEMPLATE_TEST_MARKDOWN = [
|
|
|
6911
7210
|
"- The maples on Marsh Lane have finally turned.",
|
|
6912
7211
|
"- Someone left a note in the little free library, addressed to no one.",
|
|
6913
7212
|
"",
|
|
6914
|
-
"This is a **sample post
|
|
7213
|
+
"This is a **sample post**: [links](https://example.com) render like this, and a real post's Markdown fills this space."
|
|
6915
7214
|
].join("\n");
|
|
6916
|
-
|
|
7215
|
+
var SAMPLE_POST_SUBJECT = "The starlings are back";
|
|
7216
|
+
function sampleRenderInput(subject = TEMPLATE_TEST_SUBJECT) {
|
|
6917
7217
|
const now = Date.now();
|
|
6918
7218
|
const post = {
|
|
6919
7219
|
id: "sample",
|
|
6920
7220
|
slug: "sample-post",
|
|
6921
|
-
|
|
7221
|
+
slug_auto: false,
|
|
7222
|
+
subject,
|
|
6922
7223
|
status: "draft",
|
|
6923
7224
|
current_revision: "sample-rev",
|
|
6924
7225
|
created_at: now,
|
|
@@ -6928,7 +7229,7 @@ function sampleRenderInput() {
|
|
|
6928
7229
|
id: "sample-rev",
|
|
6929
7230
|
post_id: "sample",
|
|
6930
7231
|
markdown: TEMPLATE_TEST_MARKDOWN,
|
|
6931
|
-
metadata: JSON.stringify({ subject
|
|
7232
|
+
metadata: JSON.stringify({ subject, slug: post.slug }),
|
|
6932
7233
|
author: null,
|
|
6933
7234
|
created_at: now
|
|
6934
7235
|
};
|
|
@@ -6977,9 +7278,10 @@ async function templateTest(c) {
|
|
|
6977
7278
|
if (recipients.length > MAX_TEMPLATE_TEST_RECIPIENTS) {
|
|
6978
7279
|
throw badRequest(`at most ${MAX_TEMPLATE_TEST_RECIPIENTS} recipients per test`);
|
|
6979
7280
|
}
|
|
7281
|
+
requireConnectedProvider(c.config);
|
|
6980
7282
|
const result = await render(sampleRenderInput(), c.config, resolveBranding(settings, c.config));
|
|
6981
7283
|
const provider = getProvider(c.config, c.env);
|
|
6982
|
-
const unsubscribeUrl =
|
|
7284
|
+
const unsubscribeUrl = testUnsubscribeUrl(c.config);
|
|
6983
7285
|
const testId = `template-test-${crypto.randomUUID()}`;
|
|
6984
7286
|
const batch = recipients.map((email2) => ({ email: email2, unsubscribeUrl }));
|
|
6985
7287
|
const results = perRecipient(
|
|
@@ -7008,6 +7310,116 @@ async function devOutbox(c) {
|
|
|
7008
7310
|
return json({ messages: fakeOutbox(), notifications: fakeNotifications() });
|
|
7009
7311
|
}
|
|
7010
7312
|
|
|
7313
|
+
// src/routes/reader_pages.ts
|
|
7314
|
+
var SAMPLE_EMAIL = "reader@example.com";
|
|
7315
|
+
var DAY_MS = 864e5;
|
|
7316
|
+
function samplePosts(c, now) {
|
|
7317
|
+
return [
|
|
7318
|
+
{ title: SAMPLE_POST_SUBJECT, slug: "sample-post", at: now },
|
|
7319
|
+
{ title: "Notes from the first frost", slug: "first-frost", at: now - 7 * DAY_MS },
|
|
7320
|
+
{ title: "What the river kept", slug: "what-the-river-kept", at: now - 14 * DAY_MS }
|
|
7321
|
+
].map((p) => ({
|
|
7322
|
+
title: p.title,
|
|
7323
|
+
url: archiveUrl(c.config, p.slug),
|
|
7324
|
+
dateLabel: formatPublishedDate(p.at)
|
|
7325
|
+
}));
|
|
7326
|
+
}
|
|
7327
|
+
async function previewOf(c, journey, state) {
|
|
7328
|
+
const home = `${c.config.appOrigin}/`;
|
|
7329
|
+
const subscribeUrl = `${c.config.appOrigin}/subscribe`;
|
|
7330
|
+
const sample = { email: SAMPLE_EMAIL };
|
|
7331
|
+
switch (`${journey}/${state}`) {
|
|
7332
|
+
case "subscribe/form":
|
|
7333
|
+
return subscribeFormPage(c, await readerIdentity(c, c.config));
|
|
7334
|
+
case "subscribe/check-inbox":
|
|
7335
|
+
return subscribeCheckInboxPage(c, await readerIdentity(c, c.config));
|
|
7336
|
+
case "subscribe/not-an-email":
|
|
7337
|
+
return subscribeNotAnEmailPage(c, await readerIdentity(c, c.config));
|
|
7338
|
+
case "confirm/ready":
|
|
7339
|
+
return confirmStatePage(c, { kind: "ready", subscriber: sample }, "");
|
|
7340
|
+
case "confirm/confirmed":
|
|
7341
|
+
return confirmStatePage(c, { kind: "confirmed", subscriber: sample }, "");
|
|
7342
|
+
case "confirm/expired":
|
|
7343
|
+
return confirmStatePage(c, { kind: "expired", subscriber: sample }, "");
|
|
7344
|
+
case "confirm/invalid":
|
|
7345
|
+
return confirmStatePage(c, { kind: "invalid" }, "");
|
|
7346
|
+
case "unsubscribe/ask":
|
|
7347
|
+
return unsubscribeAskPage(c, SAMPLE_EMAIL, "/unsubscribe");
|
|
7348
|
+
case "unsubscribe/done":
|
|
7349
|
+
return unsubscribedPage(c, SAMPLE_EMAIL);
|
|
7350
|
+
case "unsubscribe/invalid":
|
|
7351
|
+
return unsubscribeInvalidPage(c);
|
|
7352
|
+
case "reading/landing": {
|
|
7353
|
+
const [featured, ...recent] = samplePosts(c, Date.now());
|
|
7354
|
+
return landingPage({
|
|
7355
|
+
identity: await readerIdentity(c, c.config),
|
|
7356
|
+
subscribeUrl,
|
|
7357
|
+
homeUrl: home,
|
|
7358
|
+
archiveUrl: archiveHomeUrl(c.config),
|
|
7359
|
+
featured,
|
|
7360
|
+
recent
|
|
7361
|
+
});
|
|
7362
|
+
}
|
|
7363
|
+
case "reading/archive":
|
|
7364
|
+
return archiveIndexPage({
|
|
7365
|
+
identity: await readerIdentity(c, c.config),
|
|
7366
|
+
subscribeUrl,
|
|
7367
|
+
homeUrl: home,
|
|
7368
|
+
posts: samplePosts(c, Date.now())
|
|
7369
|
+
});
|
|
7370
|
+
// The same two pages before the first post is sent.
|
|
7371
|
+
case "reading/landing-empty":
|
|
7372
|
+
return landingPage({
|
|
7373
|
+
identity: await readerIdentity(c, c.config),
|
|
7374
|
+
subscribeUrl,
|
|
7375
|
+
homeUrl: home,
|
|
7376
|
+
archiveUrl: archiveHomeUrl(c.config),
|
|
7377
|
+
recent: []
|
|
7378
|
+
});
|
|
7379
|
+
case "reading/archive-empty":
|
|
7380
|
+
return archiveIndexPage({
|
|
7381
|
+
identity: await readerIdentity(c, c.config),
|
|
7382
|
+
subscribeUrl,
|
|
7383
|
+
homeUrl: home,
|
|
7384
|
+
posts: []
|
|
7385
|
+
});
|
|
7386
|
+
case "reading/post": {
|
|
7387
|
+
const settings = await getSettings(c.env.DB);
|
|
7388
|
+
const email2 = await render(
|
|
7389
|
+
sampleRenderInput(SAMPLE_POST_SUBJECT),
|
|
7390
|
+
c.config,
|
|
7391
|
+
resolveBranding(settings, c.config)
|
|
7392
|
+
);
|
|
7393
|
+
const html = webVersion(email2.html, {
|
|
7394
|
+
config: c.config,
|
|
7395
|
+
identity: await readerIdentity(c, c.config),
|
|
7396
|
+
dateMs: Date.now()
|
|
7397
|
+
});
|
|
7398
|
+
return new Response(html);
|
|
7399
|
+
}
|
|
7400
|
+
case "reading/not-found":
|
|
7401
|
+
return nothingHereYetPage(c);
|
|
7402
|
+
}
|
|
7403
|
+
throw notFound("reader page");
|
|
7404
|
+
}
|
|
7405
|
+
async function readerPagePreview(c) {
|
|
7406
|
+
const journey = param(c, "journey");
|
|
7407
|
+
const state = param(c, "state");
|
|
7408
|
+
if (!findReaderPage(journey, state)) {
|
|
7409
|
+
throw notFound("reader page");
|
|
7410
|
+
}
|
|
7411
|
+
const pc = { ...c, config: { ...c.config, devMode: false } };
|
|
7412
|
+
const page = await previewOf(pc, journey, state);
|
|
7413
|
+
return new Response(page.body, {
|
|
7414
|
+
headers: {
|
|
7415
|
+
...POST_PAGE_SECURITY_HEADERS,
|
|
7416
|
+
"content-type": "text/html; charset=utf-8",
|
|
7417
|
+
"cache-control": "no-store",
|
|
7418
|
+
"x-robots-tag": "noindex"
|
|
7419
|
+
}
|
|
7420
|
+
});
|
|
7421
|
+
}
|
|
7422
|
+
|
|
7011
7423
|
// src/routes/schedule.ts
|
|
7012
7424
|
var ISO_WITH_OFFSET = /T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/i;
|
|
7013
7425
|
function parseFireAt(input) {
|
|
@@ -7054,6 +7466,7 @@ async function schedule(c) {
|
|
|
7054
7466
|
}
|
|
7055
7467
|
const body = await readJsonObject(c);
|
|
7056
7468
|
rejectStrayTemplateChoice(body);
|
|
7469
|
+
requireConnectedProvider(c.config);
|
|
7057
7470
|
const fireAt = parseFutureFireAt(body.fire_at, c.config.minLeadMs, true);
|
|
7058
7471
|
const send = await freeze(c.env, c.config, post, fireAt);
|
|
7059
7472
|
const frozen = unwrap(await viewWithCursor(c.env, send.id), "send");
|
|
@@ -7067,70 +7480,61 @@ async function sendNow(c) {
|
|
|
7067
7480
|
rejectStrayTemplateChoice(await readJsonObject(c, { optional: true }));
|
|
7068
7481
|
const active = await getActiveSendForPost(c.env.DB, post.id);
|
|
7069
7482
|
if (active) {
|
|
7070
|
-
|
|
7071
|
-
...unwrap(await viewWithCursor(c.env, active.id), "send"),
|
|
7072
|
-
idempotent: true
|
|
7073
|
-
};
|
|
7074
|
-
return json(repeat);
|
|
7483
|
+
return repeatAnswer(c, active.id);
|
|
7075
7484
|
}
|
|
7485
|
+
requireConnectedProvider(c.config);
|
|
7076
7486
|
const now = Date.now();
|
|
7077
7487
|
const fireAt = acceptFireAt(now + c.config.minLeadMs, c.config.minLeadMs, { now });
|
|
7078
|
-
|
|
7488
|
+
let send;
|
|
7489
|
+
try {
|
|
7490
|
+
send = await freeze(c.env, c.config, post, fireAt);
|
|
7491
|
+
} catch (err) {
|
|
7492
|
+
if (err instanceof HttpError && err.code === "active_send_exists") {
|
|
7493
|
+
const winner = await getActiveSendForPost(c.env.DB, post.id);
|
|
7494
|
+
if (winner) {
|
|
7495
|
+
return repeatAnswer(c, winner.id);
|
|
7496
|
+
}
|
|
7497
|
+
}
|
|
7498
|
+
throw err;
|
|
7499
|
+
}
|
|
7079
7500
|
const frozen = unwrap(await viewWithCursor(c.env, send.id), "send");
|
|
7080
7501
|
return json(frozen, 201);
|
|
7081
7502
|
}
|
|
7503
|
+
async function repeatAnswer(c, sendId) {
|
|
7504
|
+
const repeat = {
|
|
7505
|
+
...unwrap(await viewWithCursor(c.env, sendId), "send"),
|
|
7506
|
+
idempotent: true
|
|
7507
|
+
};
|
|
7508
|
+
return json(repeat);
|
|
7509
|
+
}
|
|
7082
7510
|
|
|
7083
7511
|
// src/send/resolve.ts
|
|
7084
|
-
async function resolveStuckSend(env, sendId, outcome, actor
|
|
7512
|
+
async function resolveStuckSend(env, sendId, outcome, actor) {
|
|
7085
7513
|
const send = await getSend(env.DB, sendId);
|
|
7086
7514
|
if (!send) {
|
|
7087
7515
|
throw notFound("send");
|
|
7088
7516
|
}
|
|
7089
|
-
const refuse = async (
|
|
7090
|
-
if (guard2.ifMatch !== void 0 && send.rev !== guard2.ifMatch) {
|
|
7091
|
-
throw await refuse(
|
|
7092
|
-
412,
|
|
7093
|
-
"precondition_failed",
|
|
7094
|
-
`the send has changed since rev ${guard2.ifMatch} (it is at rev ${send.rev}); read it again before resolving`
|
|
7095
|
-
);
|
|
7096
|
-
}
|
|
7517
|
+
const refuse = async (code, message2) => refusal(409, code, message2, { send: await viewSend(env, send.id) });
|
|
7097
7518
|
const now = Date.now();
|
|
7098
7519
|
if (send.status === "sending" && send.locked_until !== null && send.locked_until > now) {
|
|
7099
7520
|
throw await refuse(
|
|
7100
|
-
409,
|
|
7101
7521
|
"run_in_progress",
|
|
7102
7522
|
"this send is being worked on right now; try Resolve again in a moment"
|
|
7103
7523
|
);
|
|
7104
7524
|
}
|
|
7105
7525
|
if (!isWedged(send)) {
|
|
7106
7526
|
throw await refuse(
|
|
7107
|
-
409,
|
|
7108
7527
|
"not_wedged",
|
|
7109
7528
|
send.status === "sending" ? "send is not wedged: it still has recipients to hand off, or a run has yet to look at those in flight" : `send is ${send.status}, with no ambiguous deliveries to resolve`
|
|
7110
7529
|
);
|
|
7111
7530
|
}
|
|
7112
|
-
if (guard2.expectedCount !== void 0 && guard2.expectedCount !== send.c_in_flight) {
|
|
7113
|
-
throw await refuse(
|
|
7114
|
-
409,
|
|
7115
|
-
"count_changed",
|
|
7116
|
-
`the send has ${send.c_in_flight} ambiguous recipients, not the ${guard2.expectedCount} expected; read it again before resolving`
|
|
7117
|
-
);
|
|
7118
|
-
}
|
|
7119
7531
|
const lease = await acquireLease(env.DB, sendId, now, LEASE_TTL_MS);
|
|
7120
7532
|
if (!lease) {
|
|
7121
7533
|
const current = await getSend(env.DB, sendId);
|
|
7122
|
-
if (current && guard2.ifMatch !== void 0 && current.rev !== guard2.ifMatch) {
|
|
7123
|
-
throw await refuse(
|
|
7124
|
-
412,
|
|
7125
|
-
"precondition_failed",
|
|
7126
|
-
`the send has changed since rev ${guard2.ifMatch} (it is at rev ${current.rev}); read it again before resolving`
|
|
7127
|
-
);
|
|
7128
|
-
}
|
|
7129
7534
|
if (!current || !isWedged(current)) {
|
|
7130
|
-
throw await refuse(
|
|
7535
|
+
throw await refuse("not_wedged", "send is no longer wedged");
|
|
7131
7536
|
}
|
|
7132
7537
|
throw await refuse(
|
|
7133
|
-
409,
|
|
7134
7538
|
"run_in_progress",
|
|
7135
7539
|
"this send is being worked on right now; try Resolve again in a moment"
|
|
7136
7540
|
);
|
|
@@ -7363,21 +7767,9 @@ async function answerWith(c, id) {
|
|
|
7363
7767
|
}
|
|
7364
7768
|
return read;
|
|
7365
7769
|
}
|
|
7366
|
-
function parseIfMatch(c) {
|
|
7367
|
-
const raw = c.req.headers.get("if-match");
|
|
7368
|
-
if (raw === null) {
|
|
7369
|
-
return void 0;
|
|
7370
|
-
}
|
|
7371
|
-
const match = /^\s*(?:W\/)?"?(\d+)(?:-[0-9a-z]+)?"?\s*$/.exec(raw);
|
|
7372
|
-
const rev = match?.[1] === void 0 ? Number.NaN : Number(match[1]);
|
|
7373
|
-
if (!Number.isSafeInteger(rev)) {
|
|
7374
|
-
throw badRequest(`If-Match must be a send's rev, as "<rev>"`, { field: "If-Match" });
|
|
7375
|
-
}
|
|
7376
|
-
return rev;
|
|
7377
|
-
}
|
|
7378
7770
|
async function cancel2(c) {
|
|
7379
7771
|
const id = param(c, "id");
|
|
7380
|
-
const { changed } = await cancel(c.env, id
|
|
7772
|
+
const { changed } = await cancel(c.env, id);
|
|
7381
7773
|
const body = { ...await answerWith(c, id), changed };
|
|
7382
7774
|
return json(body);
|
|
7383
7775
|
}
|
|
@@ -7385,22 +7777,16 @@ async function reschedule2(c) {
|
|
|
7385
7777
|
const body = await readJsonObject(c);
|
|
7386
7778
|
const fireAt = parseFireAt(body.fire_at);
|
|
7387
7779
|
const id = param(c, "id");
|
|
7388
|
-
const { changed } = await reschedule(c.env, id, fireAt, c.config.minLeadMs
|
|
7389
|
-
ifMatch: parseIfMatch(c)
|
|
7390
|
-
});
|
|
7780
|
+
const { changed } = await reschedule(c.env, id, fireAt, c.config.minLeadMs);
|
|
7391
7781
|
const response = { ...await answerWith(c, id), changed };
|
|
7392
7782
|
return json(response);
|
|
7393
7783
|
}
|
|
7394
7784
|
async function resolve(c) {
|
|
7395
7785
|
const request = await readJsonObject(c);
|
|
7396
7786
|
const outcome = oneOf(request, "resolution", ["unsent", "accepted"]);
|
|
7397
|
-
const expectedCount = optCount(request, "expected_count");
|
|
7398
7787
|
const actor = c.principal?.email ?? "service";
|
|
7399
7788
|
const id = param(c, "id");
|
|
7400
|
-
const { resolved, completed } = await resolveStuckSend(c.env, id, outcome, actor
|
|
7401
|
-
ifMatch: parseIfMatch(c),
|
|
7402
|
-
expectedCount
|
|
7403
|
-
});
|
|
7789
|
+
const { resolved, completed } = await resolveStuckSend(c.env, id, outcome, actor);
|
|
7404
7790
|
const body = { ...await answerWith(c, id), resolved, completed };
|
|
7405
7791
|
return json(body);
|
|
7406
7792
|
}
|
|
@@ -7554,6 +7940,12 @@ var ProviderNotifier = class {
|
|
|
7554
7940
|
}
|
|
7555
7941
|
};
|
|
7556
7942
|
|
|
7943
|
+
// shared/dashboard_link.ts
|
|
7944
|
+
var PARAM = "to";
|
|
7945
|
+
function dashboardLink(appOrigin, route) {
|
|
7946
|
+
return `${appOrigin}/dashboard/?${PARAM}=${encodeURIComponent(route).replaceAll("%2F", "/")}`;
|
|
7947
|
+
}
|
|
7948
|
+
|
|
7557
7949
|
// src/notify/compose.ts
|
|
7558
7950
|
function when(ms) {
|
|
7559
7951
|
return `${new Date(ms).toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
@@ -7562,7 +7954,7 @@ function minutes2(ms) {
|
|
|
7562
7954
|
return Math.round(ms / 6e4);
|
|
7563
7955
|
}
|
|
7564
7956
|
function sendLink(config, sendId) {
|
|
7565
|
-
return
|
|
7957
|
+
return dashboardLink(config.appOrigin, `/sent/${encodeURIComponent(sendId)}`);
|
|
7566
7958
|
}
|
|
7567
7959
|
function message(subject, paragraphs, link) {
|
|
7568
7960
|
const text = `${paragraphs.join("\n\n")}
|
|
@@ -7593,6 +7985,17 @@ function composeNotification(n, config) {
|
|
|
7593
7985
|
);
|
|
7594
7986
|
}
|
|
7595
7987
|
case "refused":
|
|
7988
|
+
if (n.halt_cause === "not_connected") {
|
|
7989
|
+
return message(
|
|
7990
|
+
`Problem with ${title}: no email provider is connected`,
|
|
7991
|
+
[
|
|
7992
|
+
`No email provider is connected to this deployment, so ${title} is held where it is.`,
|
|
7993
|
+
refusalAdvice(n.halt_cause),
|
|
7994
|
+
"No one has been marked unsent. The send keeps trying, at most an hour apart, and goes out at the first try after a provider is connected, mailing no one twice."
|
|
7995
|
+
],
|
|
7996
|
+
link
|
|
7997
|
+
);
|
|
7998
|
+
}
|
|
7596
7999
|
return message(
|
|
7597
8000
|
`Problem with ${title}: the provider is refusing your account`,
|
|
7598
8001
|
[
|
|
@@ -7640,7 +8043,7 @@ function sampleNotification(config) {
|
|
|
7640
8043
|
"This is a test of the notifications that tell you when a send goes out, and right away if a send runs into a problem.",
|
|
7641
8044
|
"If it arrived, the channel works; nothing else was sent."
|
|
7642
8045
|
],
|
|
7643
|
-
|
|
8046
|
+
dashboardLink(config.appOrigin, "/settings")
|
|
7644
8047
|
);
|
|
7645
8048
|
}
|
|
7646
8049
|
|
|
@@ -7745,7 +8148,7 @@ async function saveSettingsRemaking(env, config, derive, ack, beforeWrite) {
|
|
|
7745
8148
|
|
|
7746
8149
|
// src/routes/settings.ts
|
|
7747
8150
|
var MAX_LOGO_BYTES = 512 * 1024;
|
|
7748
|
-
function deploymentView(cfg) {
|
|
8151
|
+
function deploymentView(cfg, principal) {
|
|
7749
8152
|
return {
|
|
7750
8153
|
provider: cfg.provider,
|
|
7751
8154
|
fromAddress: cfg.fromAddress,
|
|
@@ -7757,7 +8160,7 @@ function deploymentView(cfg) {
|
|
|
7757
8160
|
awsRegion: cfg.awsRegion,
|
|
7758
8161
|
// Booleans only — never the values.
|
|
7759
8162
|
accessConfigured: Boolean(cfg.accessTeamDomain && cfg.accessAud),
|
|
7760
|
-
authMode:
|
|
8163
|
+
authMode: principal.via,
|
|
7761
8164
|
// The running build (SPEC §9), so the editor can show/link it. Build metadata,
|
|
7762
8165
|
// resolved at build (src/build.ts) — not deploy config, and never a secret.
|
|
7763
8166
|
build: buildInfo(),
|
|
@@ -7779,7 +8182,7 @@ function publicationView(settings, cfg) {
|
|
|
7779
8182
|
name: p.name,
|
|
7780
8183
|
tagline: p.tagline,
|
|
7781
8184
|
address: p.address,
|
|
7782
|
-
logoUrl:
|
|
8185
|
+
logoUrl: publicationLogoUrl(cfg.mediaPublicBase, p.logo)
|
|
7783
8186
|
};
|
|
7784
8187
|
}
|
|
7785
8188
|
function settingsView(settings, cfg) {
|
|
@@ -7812,7 +8215,7 @@ async function get2(c) {
|
|
|
7812
8215
|
const settings = await getSettings(c.env.DB);
|
|
7813
8216
|
const body = {
|
|
7814
8217
|
settings: settingsView(settings, c.config),
|
|
7815
|
-
deployment: deploymentView(c.config),
|
|
8218
|
+
deployment: deploymentView(c.config, unwrap(c.principal, "principal")),
|
|
7816
8219
|
inUse: await inUseView(c.env.DB, settings, c.config),
|
|
7817
8220
|
notificationStatus: await notificationStatus(c.env.DB)
|
|
7818
8221
|
};
|
|
@@ -7823,6 +8226,9 @@ async function notificationTest(c) {
|
|
|
7823
8226
|
if (!to) {
|
|
7824
8227
|
throw badRequest("set a notifications address first", { field: "notifications.to" });
|
|
7825
8228
|
}
|
|
8229
|
+
if (c.config.notifyChannel === "provider") {
|
|
8230
|
+
requireConnectedProvider(c.config);
|
|
8231
|
+
}
|
|
7826
8232
|
const notifier = getNotifier(c.config, c.env);
|
|
7827
8233
|
try {
|
|
7828
8234
|
await notifier.send(to, sampleNotification(c.config), `test-${Date.now()}`);
|
|
@@ -7901,14 +8307,15 @@ async function uploadLogo(c) {
|
|
|
7901
8307
|
throw badRequest("logo must be 512 KB or smaller");
|
|
7902
8308
|
}
|
|
7903
8309
|
const version = Date.now();
|
|
8310
|
+
const key = newLogoKey(version);
|
|
7904
8311
|
const ack = readAckQuery(c);
|
|
7905
8312
|
const { settings, remade } = await saveSettingsRemaking(
|
|
7906
8313
|
c.env,
|
|
7907
8314
|
c.config,
|
|
7908
|
-
(current) => withPublicationLogo(current, { version, contentType: type }),
|
|
8315
|
+
(current) => withPublicationLogo(current, { version, contentType: type, key }),
|
|
7909
8316
|
ack,
|
|
7910
8317
|
async () => {
|
|
7911
|
-
await c.env.MEDIA.put(
|
|
8318
|
+
await c.env.MEDIA.put(key, bytes, { httpMetadata: { contentType: type } });
|
|
7912
8319
|
}
|
|
7913
8320
|
);
|
|
7914
8321
|
const body = { settings: settingsView(settings, c.config), remade };
|
|
@@ -7921,7 +8328,10 @@ async function deleteLogo(c) {
|
|
|
7921
8328
|
c.config,
|
|
7922
8329
|
(current) => withPublicationLogo(current, null),
|
|
7923
8330
|
ack,
|
|
7924
|
-
|
|
8331
|
+
// The bytes stay: emails already sent, and their archive pages, still show them (I3).
|
|
8332
|
+
// Removing the logo takes it out of what renders from now on, nothing more.
|
|
8333
|
+
async () => {
|
|
8334
|
+
}
|
|
7925
8335
|
);
|
|
7926
8336
|
const body = { settings: settingsView(settings, c.config), remade };
|
|
7927
8337
|
return json(body);
|
|
@@ -8104,6 +8514,9 @@ function retrySchedule(steps) {
|
|
|
8104
8514
|
const head = minutes3.length > 1 ? `${minutes3.slice(0, -1).join(", ")}, and ${minutes3.at(-1)}` : `${minutes3[0]}`;
|
|
8105
8515
|
return `after ${head} minutes and then every ${last} minutes`;
|
|
8106
8516
|
}
|
|
8517
|
+
function isReaderPath(pathname) {
|
|
8518
|
+
return !(pathname === "/api" || pathname.startsWith("/api/") || pathname === "/health" || pathname.startsWith("/webhooks/"));
|
|
8519
|
+
}
|
|
8107
8520
|
function createRouter({
|
|
8108
8521
|
archiveBasePath,
|
|
8109
8522
|
devMode,
|
|
@@ -8112,7 +8525,8 @@ function createRouter({
|
|
|
8112
8525
|
const devOnly = (...defs) => devMode ? defs : [];
|
|
8113
8526
|
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)}`;
|
|
8114
8527
|
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\`), and \`remade\` (a template or identity change re-made the scheduled email after its last test, \`tested_at\`; a test of the post clears it). 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.`;
|
|
8115
|
-
const
|
|
8528
|
+
const notConnected = "Refused with 409 `provider_not_connected` while no email provider is connected (SPEC \xA79), since nothing can be sent.";
|
|
8529
|
+
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.";
|
|
8116
8530
|
const sendExample = (over = {}) => ({
|
|
8117
8531
|
id: "s_xyz789",
|
|
8118
8532
|
post_id: "p_abc123",
|
|
@@ -8192,16 +8606,17 @@ function createRouter({
|
|
|
8192
8606
|
},
|
|
8193
8607
|
{
|
|
8194
8608
|
// Reports the authenticated principal and the auth mode, so the editor can show
|
|
8195
|
-
// identity (and offer Access sign-out) instead of prompting for a token.
|
|
8609
|
+
// identity (and offer Access sign-out) instead of prompting for a token. The mode is
|
|
8610
|
+
// how this request authenticated, read off the principal, never off the config.
|
|
8196
8611
|
method: "GET",
|
|
8197
8612
|
path: "/api/whoami",
|
|
8198
8613
|
access: "admin",
|
|
8199
8614
|
resource: "system",
|
|
8200
|
-
summary: "The authenticated principal and the auth mode (access or dev).",
|
|
8201
|
-
handler: (c) =>
|
|
8202
|
-
|
|
8203
|
-
|
|
8204
|
-
}
|
|
8615
|
+
summary: "The authenticated principal and the auth mode: how this request signed in (access or dev).",
|
|
8616
|
+
handler: (c) => {
|
|
8617
|
+
const { kind, email: email2, via } = unwrap(c.principal, "principal");
|
|
8618
|
+
return json({ principal: { kind, email: email2 }, auth: { mode: via } });
|
|
8619
|
+
}
|
|
8205
8620
|
},
|
|
8206
8621
|
{
|
|
8207
8622
|
// The running build (SPEC §9), so an API client (Claude, curl) can self-identify a
|
|
@@ -8234,7 +8649,7 @@ function createRouter({
|
|
|
8234
8649
|
access: "public",
|
|
8235
8650
|
resource: "dev",
|
|
8236
8651
|
summary: "Mint an admin token (local dev only).",
|
|
8237
|
-
description: "Public because it's how a local client gets the token every admin route requires. Registered only on a local instance.",
|
|
8652
|
+
description: "Public because it's how a local client gets the token every admin route requires. Registered only on a local instance. When `kestrel dev` started the server, the answer also carries `runId`, the ID it recorded for this run, which `kestrel seed` and `kestrel reset` check before wiping the database.",
|
|
8238
8653
|
query: [
|
|
8239
8654
|
{
|
|
8240
8655
|
name: "kind",
|
|
@@ -8277,7 +8692,7 @@ function createRouter({
|
|
|
8277
8692
|
access: "admin",
|
|
8278
8693
|
resource: "settings",
|
|
8279
8694
|
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.",
|
|
8280
|
-
description: "`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.",
|
|
8695
|
+
description: "`deployment.provider` is `resend` or `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`. `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.",
|
|
8281
8696
|
example: {
|
|
8282
8697
|
response: {
|
|
8283
8698
|
settings: { emailTemplate: "\u2026", publication: { name: "Marsh Lane" } },
|
|
@@ -8332,7 +8747,7 @@ function createRouter({
|
|
|
8332
8747
|
accepts: FORM_UPLOAD,
|
|
8333
8748
|
resource: "settings",
|
|
8334
8749
|
summary: "Upload the publication logo (multipart `file`); served publicly via /media.",
|
|
8335
|
-
description: "The logo is part of the identity, so when the template renders it and sends are scheduled this re-makes their emails under the same rule as `PUT /api/settings`, acknowledged as `?remake=` (comma-separated send ids) since the request carries no JSON body; the bytes are written only once the refusals are ruled out.",
|
|
8750
|
+
description: "The logo is part of the identity, so when the template renders it and sends are scheduled this re-makes their emails under the same rule as `PUT /api/settings`, acknowledged as `?remake=` (comma-separated send ids) since the request carries no JSON body; the bytes are written only once the refusals are ruled out. The image is stored as sent: every surface shows the logo as a square, and an email stretches one that isn't, so send a square image at least 256 \xD7 256 px (the editor crops and scales to that before uploading).",
|
|
8336
8751
|
handler: uploadLogo
|
|
8337
8752
|
},
|
|
8338
8753
|
{
|
|
@@ -8341,7 +8756,7 @@ function createRouter({
|
|
|
8341
8756
|
access: "admin",
|
|
8342
8757
|
resource: "settings",
|
|
8343
8758
|
summary: "Remove the publication logo.",
|
|
8344
|
-
description: "Under the same re-make rule as `PUT /api/settings` when the template renders the logo and sends are scheduled: acknowledged as `?remake=` (comma-separated send ids), refused inside the minimum lead.",
|
|
8759
|
+
description: "Under the same re-make rule as `PUT /api/settings` when the template renders the logo and sends are scheduled: acknowledged as `?remake=` (comma-separated send ids), refused inside the minimum lead. The image itself stays served, since emails already sent still show it (I3); only what renders from then on loses it.",
|
|
8345
8760
|
handler: deleteLogo
|
|
8346
8761
|
},
|
|
8347
8762
|
{
|
|
@@ -8354,7 +8769,7 @@ function createRouter({
|
|
|
8354
8769
|
accepts: JSON_BODY,
|
|
8355
8770
|
resource: "settings",
|
|
8356
8771
|
summary: "Send a sample post through the saved email template, to `to` or the default recipients (I5).",
|
|
8357
|
-
description:
|
|
8772
|
+
description: `Renders the stored template, never unsaved editor content; saving first is the client's job, and that save may need the re-make acknowledgement (see \`PUT /api/settings\`). ${notConnected}`,
|
|
8358
8773
|
example: {
|
|
8359
8774
|
request: { to: "you@example.com" },
|
|
8360
8775
|
response: { sent: 1, total: 1, provider: "fake", subject: "Template test \u2014 \u2026" }
|
|
@@ -8367,10 +8782,19 @@ function createRouter({
|
|
|
8367
8782
|
access: "admin",
|
|
8368
8783
|
resource: "settings",
|
|
8369
8784
|
summary: "Send a sample notification to the saved notifications address, through the live channel (SPEC \xA78).",
|
|
8370
|
-
description:
|
|
8785
|
+
description: `Takes no body and goes only to \`settings.notifications.to\`, so it cannot mail an arbitrary address; 400 when none is set. A channel that refuses (an unverified Cloudflare destination, say) is a 502 \`notify_failed\` carrying its words. Through the newsletter's provider, ${notConnected.charAt(0).toLowerCase()}${notConnected.slice(1)}`,
|
|
8371
8786
|
example: { response: { to: "you@example.com", channel: "cloudflare" } },
|
|
8372
8787
|
handler: notificationTest
|
|
8373
8788
|
},
|
|
8789
|
+
{
|
|
8790
|
+
method: "GET",
|
|
8791
|
+
path: "/api/reader-pages/:journey/:state",
|
|
8792
|
+
access: "admin",
|
|
8793
|
+
resource: "settings",
|
|
8794
|
+
summary: "A page a reader can land on, as a standalone HTML page: Settings' preview of it (SPEC \xA75).",
|
|
8795
|
+
description: "`journey` is `subscribe` (`form`, `check-inbox`, `not-an-email`), `confirm` (`ready`, `confirmed`, `expired`, `invalid`), `unsubscribe` (`ask`, `done`, `invalid`), or `reading` (`landing`, `archive`, `post`, `landing-empty`, `archive-empty`, `not-found`), naming the `state`. Rendered by the code that serves the real page, with the saved publication identity and a sample address and sample posts in place of a reader's own (the `-empty` pages show the landing page and archive before any post is sent), and nothing marking it as a preview, since Settings frames it as one; the post page renders the sample post through the saved template. Always 200, whatever status the real page carries; an unknown pair is a 404. A preview changes nothing: its forms submit nowhere (`form-action 'none'`).",
|
|
8796
|
+
handler: readerPagePreview
|
|
8797
|
+
},
|
|
8374
8798
|
// --- posts + revisions (authed) ---
|
|
8375
8799
|
{
|
|
8376
8800
|
method: "POST",
|
|
@@ -8378,11 +8802,11 @@ function createRouter({
|
|
|
8378
8802
|
access: "admin",
|
|
8379
8803
|
accepts: JSON_BODY,
|
|
8380
8804
|
resource: "posts",
|
|
8381
|
-
summary: "Create a draft post.",
|
|
8805
|
+
summary: "Create a draft post. Its slug follows the subject (`slug_auto: true`) unless you send a `slug`, which sets it by hand.",
|
|
8382
8806
|
example: {
|
|
8383
8807
|
request: { subject: "Hello, world", markdown: "# Hello\n\nWelcome." },
|
|
8384
8808
|
response: {
|
|
8385
|
-
post: { id: "p_abc123", status: "draft", slug: "hello-world" },
|
|
8809
|
+
post: { id: "p_abc123", status: "draft", slug: "hello-world", slug_auto: true },
|
|
8386
8810
|
revision_id: "r_1"
|
|
8387
8811
|
}
|
|
8388
8812
|
},
|
|
@@ -8431,15 +8855,22 @@ function createRouter({
|
|
|
8431
8855
|
accepts: JSON_BODY,
|
|
8432
8856
|
resource: "posts",
|
|
8433
8857
|
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`.",
|
|
8434
|
-
description: "Only a draft is editable; a scheduled post is soft-locked until its schedule is canceled.",
|
|
8858
|
+
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`.",
|
|
8435
8859
|
example: {
|
|
8436
8860
|
request: {
|
|
8437
|
-
subject: "Hello, world",
|
|
8438
|
-
slug: "hello-world",
|
|
8861
|
+
subject: "Hello again, world",
|
|
8439
8862
|
markdown: "# Hello\n\nEdited.",
|
|
8440
8863
|
base_revision: "r_1"
|
|
8441
8864
|
},
|
|
8442
|
-
response: {
|
|
8865
|
+
response: {
|
|
8866
|
+
post: {
|
|
8867
|
+
id: "p_abc123",
|
|
8868
|
+
current_revision: "r_2",
|
|
8869
|
+
status: "draft",
|
|
8870
|
+
slug: "hello-again-world",
|
|
8871
|
+
slug_auto: true
|
|
8872
|
+
}
|
|
8873
|
+
}
|
|
8443
8874
|
},
|
|
8444
8875
|
handler: updatePost2
|
|
8445
8876
|
},
|
|
@@ -8508,10 +8939,19 @@ function createRouter({
|
|
|
8508
8939
|
path: "/api/posts/:id/preview",
|
|
8509
8940
|
access: "admin",
|
|
8510
8941
|
resource: "posts",
|
|
8511
|
-
summary: "The rendered email as a standalone HTML page (editor preview
|
|
8942
|
+
summary: "The rendered email as a standalone HTML page (the editor's preview).",
|
|
8512
8943
|
description: "Once the post is scheduled this is its frozen copy, exactly as it will fire, and once sent the record's; a draft renders live (SPEC \xA75).",
|
|
8513
8944
|
handler: previewPage
|
|
8514
8945
|
},
|
|
8946
|
+
{
|
|
8947
|
+
method: "GET",
|
|
8948
|
+
path: "/api/posts/:id/web",
|
|
8949
|
+
access: "admin",
|
|
8950
|
+
resource: "posts",
|
|
8951
|
+
summary: "The post's web version as a standalone HTML page: what its public post page will show.",
|
|
8952
|
+
description: "Built from the same copy as the preview (a draft's live render, the frozen copy once scheduled, the record once sent) by the post page's own code: the email-only regions left out, the masthead and display font added (SPEC \xA75). Once the send starts it matches the public post page, less the local-dev dashboard link.",
|
|
8953
|
+
handler: webPage
|
|
8954
|
+
},
|
|
8515
8955
|
{
|
|
8516
8956
|
method: "POST",
|
|
8517
8957
|
path: "/api/posts/:id/test",
|
|
@@ -8519,7 +8959,7 @@ function createRouter({
|
|
|
8519
8959
|
accepts: JSON_BODY,
|
|
8520
8960
|
resource: "posts",
|
|
8521
8961
|
summary: "Send a test to one address through the same per-recipient path as a real send (I5).",
|
|
8522
|
-
description: "Once the post is scheduled this sends its frozen copy, exactly as it will fire, and once sent the record's; a draft's test is a live render (SPEC \xA75). The response's `frozen` says which. A test of a scheduled send's frozen copy is recorded as its `tested_at`, which clears the send's `remade` condition.",
|
|
8962
|
+
description: "Once the post is scheduled this sends its frozen copy, exactly as it will fire, and once sent the record's; a draft's test is a live render (SPEC \xA75). The response's `frozen` says which. A test of a scheduled send's frozen copy is recorded as its `tested_at`, which clears the send's `remade` condition. " + notConnected,
|
|
8523
8963
|
example: {
|
|
8524
8964
|
request: { to: "you@example.com" },
|
|
8525
8965
|
response: {
|
|
@@ -8568,7 +9008,7 @@ function createRouter({
|
|
|
8568
9008
|
accepts: JSON_BODY,
|
|
8569
9009
|
resource: "posts",
|
|
8570
9010
|
summary: `Freeze the render and schedule the send for a future time (at least ${formatLead(minLeadMs)} out).`,
|
|
8571
|
-
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,
|
|
9011
|
+
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).",
|
|
8572
9012
|
example: {
|
|
8573
9013
|
request: { fire_at: "2026-01-16T09:00:00Z" },
|
|
8574
9014
|
response: { send: scheduledExample, cursor: "57.1768467610000" }
|
|
@@ -8594,7 +9034,7 @@ function createRouter({
|
|
|
8594
9034
|
access: "admin",
|
|
8595
9035
|
resource: "sends",
|
|
8596
9036
|
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.",
|
|
8597
|
-
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`, or `
|
|
9037
|
+
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.",
|
|
8598
9038
|
query: [
|
|
8599
9039
|
{
|
|
8600
9040
|
name: "status",
|
|
@@ -8688,7 +9128,7 @@ function createRouter({
|
|
|
8688
9128
|
access: "admin",
|
|
8689
9129
|
resource: "sends",
|
|
8690
9130
|
summary: "One send: its view, the delivery-outcome breakdown of its record, and the `cursor` marking where this read stood among the changes to sends, to follow it from with `GET /api/sends/feed` (it carries no pace, so read the feed at once from it).",
|
|
8691
|
-
description: "`send` is the send's view, the one shape every send route carries (the list, the feed, every action's answer and refusal), so a client keeps one copy current from any of them and tells two apart by `rev`, the higher the newer. It holds the stored facts: `status` (`scheduled`, `sending`, `sent`, `canceled`), the times, `remade_at` (a template or identity change re-made it while scheduled) and `tested_at`, and `audience` (`count`, an estimate until the send fires and the audience at fire once `fixed`, from `fixed_at`). Beside them, what the server derives as of `as_of`, the server's clock at the read: `counts` (every recipient in one bucket), `dispatch` (hand-off progress; `rate_per_min` is what a sweep tick carries, one tick a minute, and `eta_ms` counts the ticks the rest needs, the wait between them included, given only while it is handing off and once a tick has finished, never while paused), `delivery` (receipts over accepted), `provider` (its `name`, and `halt` while the provider refuses its batches), `phase` (`scheduled`, `due`, `progressing`, `retrying`, `backing-off`, `needs-attention`, `settling`, `complete`, `canceled`), `conditions`, `actions`, and `next_change_at` (see `GET /api/sends/feed`). `links` names the send's other resources: the frozen email (`email_html`, `email_text`), the recipient rows and their CSV, the post, and `archive`, the published page, absolute, once the send is sent. " + conditionsText + " `outcomes` counts the record's recipients by outcome from its rows, summing to the audience at fire. The answer carries `ETag`: the send's `rev` and a tag of what the clock derives from it, so a request with `If-None-Match` answers 304 while nothing about the send has changed, written or derived.
|
|
9131
|
+
description: "`send` is the send's view, the one shape every send route carries (the list, the feed, every action's answer and refusal), so a client keeps one copy current from any of them and tells two apart by `rev`, the higher the newer. It holds the stored facts: `status` (`scheduled`, `sending`, `sent`, `canceled`), the times, `remade_at` (a template or identity change re-made it while scheduled) and `tested_at`, and `audience` (`count`, an estimate until the send fires and the audience at fire once `fixed`, from `fixed_at`). Beside them, what the server derives as of `as_of`, the server's clock at the read: `counts` (every recipient in one bucket), `dispatch` (hand-off progress; `rate_per_min` is what a sweep tick carries, one tick a minute, and `eta_ms` counts the ticks the rest needs, the wait between them included, given only while it is handing off and once a tick has finished, never while paused), `delivery` (receipts over accepted), `provider` (its `name`, and `halt` while the provider refuses its batches), `phase` (`scheduled`, `due`, `progressing`, `retrying`, `backing-off`, `needs-attention`, `settling`, `complete`, `canceled`), `conditions`, `actions`, and `next_change_at` (see `GET /api/sends/feed`). `links` names the send's other resources: the frozen email (`email_html`, `email_text`), the recipient rows and their CSV, the post, and `archive`, the published page, absolute, once the send is sent. " + conditionsText + " `outcomes` counts the record's recipients by outcome from its rows, summing to the audience at fire. The answer carries `ETag`: the send's `rev` and a tag of what the clock derives from it, so a request with `If-None-Match` answers 304 while nothing about the send has changed, written or derived.",
|
|
8692
9132
|
example: {
|
|
8693
9133
|
response: {
|
|
8694
9134
|
send: sendExample(),
|
|
@@ -8809,10 +9249,10 @@ function createRouter({
|
|
|
8809
9249
|
access: "admin",
|
|
8810
9250
|
accepts: JSON_BODY,
|
|
8811
9251
|
resource: "sends",
|
|
8812
|
-
summary: "Resolve a send wedged on ambiguous (dispatched) deliveries; body {resolution: 'unsent'|'accepted'
|
|
8813
|
-
description: "On a non-idempotent provider a mid-batch transport error leaves recipients `dispatched` \u2014 the loop won't blind-retry them (I4), so the send can't reach its completion gate. This adjudicates those rows: 'unsent' (assume not sent; the address is picked up by the next post) or 'accepted' (assume sent, operator-confirmed), then completes the send. Never re-mails an already-accepted recipient. Offered in `actions` exactly while the send is wedged. Refusals: 409 `run_in_progress` while a run holds the send (its in-flight rows may still get the provider's answer, so try again a moment later), 409 `not_wedged` when the send is not wedged (nothing ambiguous, recipients still to hand off, or a run yet to look at them)
|
|
9252
|
+
summary: "Resolve a send wedged on ambiguous (dispatched) deliveries; body {resolution: 'unsent'|'accepted'}.",
|
|
9253
|
+
description: "On a non-idempotent provider a mid-batch transport error leaves recipients `dispatched` \u2014 the loop won't blind-retry them (I4), so the send can't reach its completion gate. This adjudicates those rows: 'unsent' (assume not sent; the address is picked up by the next post) or 'accepted' (assume sent, operator-confirmed), then completes the send. Never re-mails an already-accepted recipient. Offered in `actions` exactly while the send is wedged. Refusals: 409 `run_in_progress` while a run holds the send (its in-flight rows may still get the provider's answer, so try again a moment later), 409 `not_wedged` when the send is not wedged (nothing ambiguous, recipients still to hand off, or a run yet to look at them) and when another Resolve has settled it. Once a send is wedged no run touches it, so the recipients Resolve settles are the ones the send showed; an `expected_count` in the body is ignored. " + actionText,
|
|
8814
9254
|
example: {
|
|
8815
|
-
request: { resolution: "unsent"
|
|
9255
|
+
request: { resolution: "unsent" },
|
|
8816
9256
|
response: {
|
|
8817
9257
|
send: sendExample({ status: "sent", phase: "settling", completed_at: 176846762e4 }),
|
|
8818
9258
|
cursor: "58.1768467620000",
|
|
@@ -8830,6 +9270,7 @@ function createRouter({
|
|
|
8830
9270
|
accepts: JSON_BODY,
|
|
8831
9271
|
resource: "subscribers",
|
|
8832
9272
|
summary: "Add a subscriber via the normal double opt-in (never an auto-confirm).",
|
|
9273
|
+
description: `${notConnected} Nothing is stored then, since the confirmation could not go.`,
|
|
8833
9274
|
example: { request: { email: "reader@example.com" } },
|
|
8834
9275
|
handler: create
|
|
8835
9276
|
},
|
|
@@ -8939,7 +9380,7 @@ function createRouter({
|
|
|
8939
9380
|
path: "/subscribe",
|
|
8940
9381
|
access: "public",
|
|
8941
9382
|
resource: "subscriptions",
|
|
8942
|
-
summary: "The public subscribe form (HTML).",
|
|
9383
|
+
summary: "The public subscribe form (HTML); while no email provider is connected, a page saying signups aren't open yet (SPEC \xA79).",
|
|
8943
9384
|
handler: subscribeForm
|
|
8944
9385
|
},
|
|
8945
9386
|
{
|
|
@@ -8948,7 +9389,7 @@ function createRouter({
|
|
|
8948
9389
|
access: "public",
|
|
8949
9390
|
resource: "subscriptions",
|
|
8950
9391
|
summary: "Request a subscription; starts the double opt-in (confirmation email).",
|
|
8951
|
-
description: "Answers the same way whatever the address's state (new, pending, confirmed, unsubscribed, or suppressed), and before any confirmation is sent, so neither the reply nor its timing reveals list membership. A confirmation goes out only if one is due: never to a confirmed or suppressed address, and at most one per address per cooldown. A confirmation the email provider refuses is logged and changes nothing, so submitting again is safe; the authed `POST /api/subscribers` reports the refusal.",
|
|
9392
|
+
description: "Answers the same way whatever the address's state (new, pending, confirmed, unsubscribed, or suppressed), and before any confirmation is sent, so neither the reply nor its timing reveals list membership. A confirmation goes out only if one is due: never to a confirmed or suppressed address, and at most one per address per cooldown. A confirmation the email provider refuses is logged and changes nothing, so submitting again is safe; the authed `POST /api/subscribers` reports the refusal. While no email provider is connected every request is refused alike, 409 `provider_not_connected` (as JSON, or the page saying signups aren't open yet), and nothing is stored (SPEC \xA79).",
|
|
8952
9393
|
example: {
|
|
8953
9394
|
request: { email: "you@example.com" },
|
|
8954
9395
|
response: { status: "check_inbox" }
|
|
@@ -8984,12 +9425,15 @@ function createRouter({
|
|
|
8984
9425
|
path: "/unsubscribe",
|
|
8985
9426
|
access: "public",
|
|
8986
9427
|
resource: "subscriptions",
|
|
8987
|
-
summary: "Unsubscribe landing page (`?token
|
|
9428
|
+
summary: "Unsubscribe landing page (`?token=`, or `?test=1` on a test email or a preview).",
|
|
8988
9429
|
query: [
|
|
8989
9430
|
{
|
|
8990
9431
|
name: "token",
|
|
8991
|
-
description: "The subscriber's unsubscribe token, from any delivered email's link."
|
|
8992
|
-
|
|
9432
|
+
description: "The subscriber's unsubscribe token, from any delivered email's link."
|
|
9433
|
+
},
|
|
9434
|
+
{
|
|
9435
|
+
name: "test",
|
|
9436
|
+
description: "`1` on the link of a test email or a preview, which carries no token: the page a subscriber sees, marked as a test and showing a sample address, whose button posts back with the marker. It unsubscribes no one. A token, when present, wins."
|
|
8993
9437
|
}
|
|
8994
9438
|
],
|
|
8995
9439
|
handler: unsubscribeLanding
|
|
@@ -8999,12 +9443,15 @@ function createRouter({
|
|
|
8999
9443
|
path: "/unsubscribe",
|
|
9000
9444
|
access: "public",
|
|
9001
9445
|
resource: "subscriptions",
|
|
9002
|
-
summary: "Process a one-click / form unsubscribe (`?token
|
|
9446
|
+
summary: "Process a one-click / form unsubscribe (`?token=`, or `?test=1` on a test email or a preview).",
|
|
9003
9447
|
query: [
|
|
9004
9448
|
{
|
|
9005
9449
|
name: "token",
|
|
9006
|
-
description: "The subscriber's unsubscribe token, from any delivered email's link."
|
|
9007
|
-
|
|
9450
|
+
description: "The subscriber's unsubscribe token, from any delivered email's link."
|
|
9451
|
+
},
|
|
9452
|
+
{
|
|
9453
|
+
name: "test",
|
|
9454
|
+
description: "`1` on the link of a test email or a preview, which carries no token: answers 200 (a form gets the page a subscriber sees once unsubscribed, marked as a test and showing a sample address) and unsubscribes no one. A token, when present, wins."
|
|
9008
9455
|
}
|
|
9009
9456
|
],
|
|
9010
9457
|
handler: unsubscribe
|
|
@@ -9030,7 +9477,7 @@ function createRouter({
|
|
|
9030
9477
|
path: `${archiveBasePath}{/}?`,
|
|
9031
9478
|
access: "public",
|
|
9032
9479
|
resource: "archive",
|
|
9033
|
-
summary: "The public archive index: every
|
|
9480
|
+
summary: "The public archive index: every published post (its send has started), newest first.",
|
|
9034
9481
|
handler: archiveIndex
|
|
9035
9482
|
},
|
|
9036
9483
|
{
|
|
@@ -9038,7 +9485,7 @@ function createRouter({
|
|
|
9038
9485
|
path: `${archiveBasePath}/:slug`,
|
|
9039
9486
|
access: "public",
|
|
9040
9487
|
resource: "archive",
|
|
9041
|
-
summary: "A
|
|
9488
|
+
summary: "A published post's archive page / view-in-browser (I3), from the moment its send starts.",
|
|
9042
9489
|
handler: archivePage
|
|
9043
9490
|
},
|
|
9044
9491
|
// --- media bytes (public; readers + archive load these unauthenticated) ---
|
|
@@ -9758,6 +10205,9 @@ async function handleFetch(request, appEnv, ctx) {
|
|
|
9758
10205
|
throw err;
|
|
9759
10206
|
}
|
|
9760
10207
|
reportConfigError(err);
|
|
10208
|
+
if (isReaderPath(new URL(request.url).pathname)) {
|
|
10209
|
+
return unavailablePage();
|
|
10210
|
+
}
|
|
9761
10211
|
return json({ error: "invalid_config", message: err.message, variable: err.variable }, 500);
|
|
9762
10212
|
}
|
|
9763
10213
|
return router.handle(request, appEnv, ctx);
|