@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.
@@ -1,4 +1,4 @@
1
- // @kurtbruns/kestrel 1.5.0: the Worker, built by scripts/build-package.mjs.
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.5.0",
37
- sha: "cbf8e1d",
38
- tag: "v1.5.0",
39
- buildTime: "2026-10-09T18:42:46.844Z",
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
- requireRealProviderConfig(provider, env, { appOrigin, archiveOrigin, mediaPublicBase });
372
+ refusePlaceholders(provider, env, { appOrigin, archiveOrigin, mediaPublicBase });
357
373
  }
358
- const accessTeamDomain = orUndefined(env.ACCESS_TEAM_DOMAIN);
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: orUndefined(env.ACCESS_AUD),
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: devAuthSecret !== void 0,
395
+ devMode,
379
396
  devAuthSecret,
380
- // Only ever read in a dev-shaped env; a deployed env runs a real provider, so the
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 "fake";
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, origins) {
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 emailDomain(name, from) {
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
- throw new ConfigError(name, `must be an email address, optionally with a name, not "${from}"`);
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.text();
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 optStringOrNull(o, key, at) {
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 !== "number" || !Number.isSafeInteger(v) || v < 0) {
595
- throw fieldError(path(key, at), `${path(key, at)} must be a whole number of at least 0`);
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(s.completed_at) AS sent_at
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 s.status = 'sent'
952
+ WHERE ${PUBLISHED_SEND}
909
953
  GROUP BY s.post_id
910
- ORDER BY sent_at DESC
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, rev) {
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, rev = null) {
1155
- const guard2 = inWindowAt(now, rev);
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, rev = null) {
1159
- const guard2 = inWindowAt(now, rev);
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
- return db.prepare(`SELECT ${cols} FROM deliveries WHERE email = ? ORDER BY updated_at DESC LIMIT 1`).bind(normalizeEmail(u.email)).first();
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
- return db.prepare("SELECT * FROM posts WHERE id = ?").bind(id).first();
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
- return db.prepare("SELECT * FROM posts WHERE slug = ?").bind(slug).first();
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 slug = await uniqueSlug(db, slugify(input.slug ?? subject));
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 (input.slug !== void 0) {
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/db/settings.ts
1783
- var BRANDING_LOGO_KEY = "branding/logo";
1784
- var DEFAULT_CONFIRMATION_EMAIL = {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1827
1836
  }
1828
- function coerce(raw) {
1829
- const o = raw && typeof raw === "object" ? raw : {};
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, "&quot;").replace(/'/g, "&#39;");
1842
1839
  }
1843
- function coerceNotifications(raw) {
1844
- const o = raw && typeof raw === "object" ? raw : {};
1845
- return { to: typeof o.to === "string" ? o.to : "" };
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
- function resolveConfirmationEmail(settings) {
1848
- const c = settings.confirmationEmail;
1849
- const d = DEFAULT_CONFIRMATION_EMAIL;
1856
+
1857
+ // src/lib/page_headers.ts
1858
+ function headers(formAction) {
1850
1859
  return {
1851
- subject: c.subject.trim() || d.subject,
1852
- body: c.body.trim() || d.body,
1853
- buttonLabel: c.buttonLabel.trim() || d.buttonLabel,
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
- async function readSettings(db) {
1858
- const row = await db.prepare("SELECT data, updated_at FROM settings WHERE id = 1").first();
1859
- if (!row) {
1860
- return { settings: structuredClone(DEFAULT_SETTINGS), version: null };
1861
- }
1862
- return { settings: coerce(parseStored(row.data)), version: row.updated_at };
1863
- }
1864
- function parseStored(data) {
1865
- let parsed;
1866
- let problem = "is not a JSON object";
1867
- try {
1868
- parsed = JSON.parse(data);
1869
- } catch (err) {
1870
- problem = `is not valid JSON (${err instanceof Error ? err.message : String(err)})`;
1871
- }
1872
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1873
- return parsed;
1874
- }
1875
- log.error("settings.corrupt", {
1876
- message: `the settings row (settings.id = 1) ${problem}; reads and writes of settings fail until it is repaired by hand`,
1877
- length: data.length
1878
- });
1879
- throw new HttpError(
1880
- 500,
1881
- "settings_corrupt",
1882
- "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."
1883
- );
1884
- }
1885
- async function getSettings(db) {
1886
- return (await readSettings(db)).settings;
1887
- }
1888
- async function getSettingsForDisplay(db) {
1889
- try {
1890
- return await getSettings(db);
1891
- } catch (err) {
1892
- if (err instanceof HttpError && err.code === "settings_corrupt") {
1893
- return structuredClone(DEFAULT_SETTINGS);
1894
- }
1895
- throw err;
1896
- }
1897
- }
1898
- async function ensureSettingsRow(db) {
1899
- await db.prepare("INSERT OR IGNORE INTO settings (id, data, updated_at) VALUES (1, '{}', 0)").run();
1900
- }
1901
- function persistSettingsStmt(db, next, expected, guard2) {
1902
- const extra = guard2 ? ` AND ${guard2.sql}` : "";
1903
- return db.prepare(
1904
- `UPDATE settings SET data = ?, updated_at = MAX(?, updated_at + 1)
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
- async function updateSettings(db, patch) {
1927
- return updateSettingsWith(db, (current) => applyPatch(current, patch));
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2042
- }
2043
- function escapeHtmlAttr(s) {
2044
- return escapeHtml(s).replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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) { a:focus-visible, .r-sub:focus-visible { outline-color:#60a5fa; } }
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 &rarr;</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
- <header class="r-mast"><div class="r-mast-in">
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)} &rarr;</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 &rarr;</a></article>`;
2286
2088
  const list5 = opts.recent.length ? `<ul class="r-list">${opts.recent.map(postRow).join("")}</ul>` : "";
2287
- const archive = `<a class="r-arch" href="${escapeHtmlAttr(opts.archiveUrl)}">Browse the full archive &rarr;</a>`;
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
- title: opts.identity.name,
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: `Archive \xB7 ${opts.identity.name}`,
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;">&larr;</span>&nbsp;${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 TOKENS2 = {
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(TOKENS2);
2589
+ var EMAIL_TEMPLATE_VARIABLES = Object.keys(TOKENS);
2497
2590
  var KNOWN_VARS = new Set(EMAIL_TEMPLATE_VARIABLES);
2498
- var DELIVERY_TOKENS = Object.entries(TOKENS2).flatMap(
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 = TOKENS2[key];
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(() => initWasm(wasmModule));
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 || fromDisplayName(config.fromAddress),
2755
+ name: publicationName(p.name, config.fromAddress),
2665
2756
  tagline: p.tagline,
2666
- logoUrl: p.logo ? `${config.mediaPublicBase}/${BRANDING_LOGO_KEY}?v=${p.logo.version}` : "",
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/archive.ts
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 || fromDisplayName2(config.fromAddress),
2969
+ name: publicationName(p.name, config.fromAddress),
2884
2970
  tagline: p.tagline,
2885
- logoUrl: p.logo ? `${config.mediaPublicBase}/${BRANDING_LOGO_KEY}?v=${p.logo.version}` : ""
2971
+ logoUrl: publicationLogoUrl(config.mediaPublicBase, p.logo)
2886
2972
  };
2887
2973
  }
2888
- function formatSentDate(ms) {
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: formatSentDate(i.sent_at)
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: formatSentDate(i.sent_at)
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 latestSentSendForPost(c.env.DB, post.id) : null;
3064
+ const send = post ? await publishedSendForPost(c.env.DB, post.id) : null;
2944
3065
  if (!post || !send) {
2945
- return htmlPage(
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 identity = await readerIdentity(c, c.config);
2953
- const masthead2 = archiveMasthead({
2954
- name: identity.name,
2955
- dateLabel: formatSentDate(send.completed_at ?? send.fire_at),
2956
- // Back to the archive index the post belongs to, on the same (archive) origin —
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
- message: `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.`,
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 only once it is sent: before, the link would 404.
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, guard2) {
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, guard2 = {}) {
3837
- const send = await readForAction(env, sendId, guard2);
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, guard2.ifMatch ?? null).run();
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, guard2);
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, guard2 = {}) {
3864
- const send = await readForAction(env, sendId, guard2);
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, guard2.ifMatch ?? null),
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, guard2);
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
- await env.MEDIA.put(BRANDING_LOGO_KEY, logoFile.bytes, {
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({ token: jwt, kind: service ? "service" : "human" });
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.delete(BRANDING_LOGO_KEY);
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. You need what the [Overview](01-overview.md#before-you-begin) lists.\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 on your computer before you deploy it. Local development sends email to a stand-in, so nothing reaches a real inbox:\n\n ```bash\n cp .dev.vars.example .dev.vars\n npm run dev\n ```\n\n Open http://localhost:8787/dashboard/ to see the editor. To fill it with demo content, run `npm run seed` in a second terminal, and `npm run reset` to empty it again. Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop the server.\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 "PROVIDER": "fake", // \u2190 leave as is for now\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 - **`PROVIDER`** stays `fake` until you connect Resend in [step 4](04-resend.md). The `fake` transport delivers nothing, so don\'t schedule a post before then.\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 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.\n\nIf every request instead answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",\u2026}`, the setting it names is missing or malformed. Fix it in `wrangler.jsonc`, commit, and deploy again.\n\nNext, [lock the dashboard with Access](03-access.md).\n';
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. With either one missing, it can\'t verify any login, so it lets no one in.\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 Cloudflare lets you in but the editor answers `401`, check the two secrets. One may be missing or mistyped, or `ACCESS_ALLOWED_EMAILS` may leave out your address.\n\nNext, [connect Resend](04-resend.md).\n';
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 Resend\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. These guides use 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. Switch the provider and deploy\n\nStore both secrets before you switch. 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`, change `PROVIDER` from `fake` 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.\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';
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, the links work, and the subject reads as you wrote it.\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 Resend](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 Resend](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";
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\nYour instance works. What's left is making it yours, and telling 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, and mails no one twice. 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";
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. [Overview](get-started/01-overview.md)\n1. [Deploy the app](get-started/02-deploy.md)\n1. [Lock the dashboard with Access](get-started/03-access.md)\n1. [Connect Resend](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- [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";
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 Resend](../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";
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: optString(raw, "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">Double opt-in \u2014 we\u2019ll email a confirmation link to finish. Unsubscribe anytime.</p>`;
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: `Subscribe \xB7 ${identity.name}`,
6819
+ title: pageTitle("Subscribe", identity.name),
6658
6820
  mainHtml,
6659
6821
  devDashboardUrl: devDashboardUrl(c.config),
6660
6822
  status
6661
6823
  });
6662
6824
  }
6663
- async function subscribeForm(c) {
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">Subscribe to ${escapeHtml(identity.name)}</h1><p class="r-lead">${lede}</p>` + subscribeFormHtml();
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
- const identity2 = await readerIdentity(c, c.config);
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
- const identity = await readerIdentity(c, c.config);
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 dev = devDashboardUrl(c.config);
6887
+ async function confirmStatePage(c, state, token2) {
6888
+ const eyebrow = "Newsletter";
6703
6889
  switch (state.kind) {
6704
- case "ready":
6705
- return htmlPage(
6706
- "Confirm your subscription",
6707
- `<h1 style="margin-top:0;">Confirm your subscription</h1><p>Start sending the newsletter to <strong>${escapeHtml(
6708
- state.subscriber.email
6709
- )}</strong>?</p>
6710
- <form method="post" action="/confirm">
6711
- <input type="hidden" name="token" value="${escapeHtmlAttr(token2)}">
6712
- <button type="submit" class="btn">Confirm subscription</button>
6713
- </form>`,
6714
- 200,
6715
- dev
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 htmlPage(
6719
- "Subscribed",
6720
- `<h1 style="margin-top:0;">You're subscribed \u{1F389}</h1><p>Thanks for confirming <strong>${escapeHtml(
6721
- state.subscriber.email
6722
- )}</strong>. You'll hear from us soon.</p>`,
6723
- 200,
6724
- dev
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 htmlPage(
6728
- "Link expired",
6729
- `<h1 style="margin-top:0;">This link has expired</h1><p>Confirmation links only work for a limited time. We can send a new one to <strong>${escapeHtml(
6730
- state.subscriber.email
6731
- )}</strong>.</p>
6732
- <form method="post" action="/subscribe">
6733
- <input type="hidden" name="email" value="${escapeHtmlAttr(state.subscriber.email)}">
6734
- <button type="submit" class="btn">Send a new link</button>
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 htmlPage(
6741
- "Invalid link",
6742
- `<h1 style="margin-top:0;">This link is invalid</h1><p><a href="/subscribe">Subscribe again</a> to get a new one.</p>`,
6743
- 400,
6744
- dev
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 htmlPage(
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 htmlPage(
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 htmlPage(
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) ? htmlPage("Invalid link", `<h1 style="margin-top:0;">This link is invalid</h1>`, 400, dev) : new Response("invalid token", { status: 400 });
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
- return wantsHtml(c) ? htmlPage(
6788
- "Unsubscribed",
6789
- `<h1 style="margin-top:0;">You've been unsubscribed</h1><p>${escapeHtml(row.email)} won't receive further emails.</p>`,
6790
- 200,
6791
- dev
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
- ".Email.UnsubscribeURL": genericUnsubscribeUrl(c),
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 new Response(html, {
6862
- headers: {
6863
- ...POST_PAGE_SECURITY_HEADERS,
6864
- "content-type": "text/html; charset=utf-8",
6865
- "x-robots-tag": "noindex"
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 = `${c.config.appOrigin}/unsubscribe?test=1`;
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** sent to preview your email template \u2014 [links](https://example.com) render like this. A real post's Markdown fills this space."
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
- function sampleRenderInput() {
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
- subject: TEMPLATE_TEST_SUBJECT,
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: TEMPLATE_TEST_SUBJECT, slug: post.slug }),
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 = `${c.config.appOrigin}/unsubscribe?test=1`;
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
- const repeat = {
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
- const send = await freeze(c.env, c.config, post, fireAt);
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, guard2 = {}) {
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 (status, code, message2) => refusal(status, code, message2, { send: await viewSend(env, send.id) });
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(409, "not_wedged", "send is no longer wedged");
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, { ifMatch: parseIfMatch(c) });
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 `${config.appOrigin}/dashboard/#/sent/${encodeURIComponent(sendId)}`;
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
- `${config.appOrigin}/dashboard/#/settings`
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: cfg.accessTeamDomain ? "access" : "dev",
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: p.logo ? `${cfg.mediaPublicBase}/${BRANDING_LOGO_KEY}?v=${p.logo.version}` : ""
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(BRANDING_LOGO_KEY, bytes, { httpMetadata: { contentType: type } });
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
- () => c.env.MEDIA.delete(BRANDING_LOGO_KEY)
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 actionText = 'Send `If-Match: "<rev>"` (the send\'s `rev` as you last read it) to act only on the send you decided about: a send that has changed since is a 412 `precondition_failed` carrying the send as it stands. That includes a change your own earlier attempt made, so a retry after a lost answer that sends `If-Match` gets the 412, with the send showing whether the act landed; a retry without it is answered `changed: false`. Every refusal carries the send as it stands in `send`, in the `GET /api/sends` row shape.';
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) => json({
8202
- principal: c.principal,
8203
- auth: { mode: c.config.accessTeamDomain ? "access" : "dev" }
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: "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`).",
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: "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.",
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: { post: { id: "p_abc123", current_revision: "r_2", status: "draft" } }
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 / open-in-browser).",
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, and 409 `settings_changed` when a template or identity save kept landing while it froze (try again).",
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 `suspended`). 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.",
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. An action's `If-Match` takes this `ETag` or the bare `rev`.",
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', expected_count?}.",
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), and, when `expected_count` (the wedged count you decided on) is given, 409 `count_changed` if the count has moved. " + actionText,
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", expected_count: 12 },
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
- required: true
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
- required: true
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 sent post, newest first.",
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 frozen post's archive page / view-in-browser (I3).",
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);