@kurtbruns/kestrel 1.6.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.6.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.6.0",
37
- sha: "25b8b69",
38
- tag: "v1.6.0",
39
- buildTime: "2026-10-12T03:08:02.808Z",
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,10 +365,13 @@ 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;
361
377
  const devMode = devAuthSecret !== void 0;
@@ -371,7 +387,7 @@ function getConfig(env) {
371
387
  awsRegion: env.AWS_REGION,
372
388
  sesMaxSendRate: readPositiveInt("SES_MAX_SEND_RATE", env.SES_MAX_SEND_RATE) ?? DEFAULT_SES_MAX_SEND_RATE,
373
389
  accessTeamDomain,
374
- accessAud: orUndefined(env.ACCESS_AUD),
390
+ accessAud,
375
391
  accessAllowedEmails: parseEmailList(env.ACCESS_ALLOWED_EMAILS),
376
392
  // Show the dev-only dashboard link, and register the dev routes, exactly when the
377
393
  // dev credential path is live (dev-shaped AND the secret resolved). Never true in a
@@ -379,7 +395,7 @@ function getConfig(env) {
379
395
  devMode,
380
396
  devAuthSecret,
381
397
  devRunId: devMode ? orUndefined(env.DEV_RUN_ID) : void 0,
382
- // Only ever read in a dev-shaped env; a deployed env runs a real provider, so the
398
+ // Only ever read in a dev-shaped env; a deployed env is never dev-shaped, so the
383
399
  // simulation can never engage there whatever the var says.
384
400
  simulation: devShaped ? readSimulation(env.SIMULATE_SENDS) : null,
385
401
  subrequestBudget: Math.max(
@@ -391,46 +407,73 @@ function getConfig(env) {
391
407
  notifyFrom: notifyChannel === "cloudflare" ? orUndefined(env.NOTIFY_FROM) ?? `Kestrel <kestrel@${new URL(appOrigin).hostname}>` : env.FROM_ADDRESS
392
408
  };
393
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
+ }
394
427
  function readProvider(v) {
395
428
  const raw = orUndefined(v);
396
429
  if (raw === void 0) {
397
- return "fake";
430
+ return "none";
398
431
  }
399
432
  if (!isProviderName(raw)) {
400
433
  throw new ConfigError("PROVIDER", `must be one of ${PROVIDER_NAMES.join(", ")}, not "${raw}"`);
401
434
  }
402
435
  return raw;
403
436
  }
404
- function requireRealProviderConfig(provider, env, origins) {
437
+ function requireRealProviderConfig(provider, env) {
405
438
  for (const name of [...REQUIRED_VARS[provider], "FROM_ADDRESS"]) {
406
439
  const value = env[name];
407
440
  if (typeof value !== "string" || value.trim() === "") {
408
441
  throw new ConfigError(name, `is not set; PROVIDER "${provider}" needs it`);
409
442
  }
410
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) {
411
452
  const hosts = [
412
453
  ["APP_ORIGIN", new URL(origins.appOrigin).hostname],
413
454
  ["ARCHIVE_ORIGIN", new URL(origins.archiveOrigin).hostname],
414
- ["MEDIA_PUBLIC_BASE", new URL(origins.mediaPublicBase).hostname],
415
- ["FROM_ADDRESS", emailDomain("FROM_ADDRESS", env.FROM_ADDRESS)]
455
+ ["MEDIA_PUBLIC_BASE", new URL(origins.mediaPublicBase).hostname]
416
456
  ];
417
- if (orUndefined(env.SENDING_DOMAIN)) {
457
+ if (orUndefined(env.SENDING_DOMAIN?.trim())) {
418
458
  hosts.push(["SENDING_DOMAIN", env.SENDING_DOMAIN.trim()]);
419
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`;
420
466
  for (const [name, host] of hosts) {
421
467
  if (isPlaceholderHost(host)) {
422
- throw new ConfigError(
423
- name,
424
- `is still on the template's example.com placeholder; PROVIDER "${provider}" sends real email`
425
- );
468
+ throw new ConfigError(name, `is still on the template's example.com placeholder; ${why}`);
426
469
  }
427
470
  }
428
471
  }
429
- function emailDomain(name, from) {
472
+ function addressDomain(from) {
430
473
  const address = /<([^<>]*)>\s*$/.exec(from)?.[1] ?? from;
431
474
  const at = address.lastIndexOf("@");
432
475
  if (at < 1 || at === address.length - 1) {
433
- throw new ConfigError(name, `must be an email address, optionally with a name, not "${from}"`);
476
+ return void 0;
434
477
  }
435
478
  return address.slice(at + 1).trim();
436
479
  }
@@ -758,14 +801,6 @@ function newToken(bytes = 32) {
758
801
  return out;
759
802
  }
760
803
 
761
- // src/lib/unwrap.ts
762
- function unwrap(value, what) {
763
- if (value == null) {
764
- throw new Error(`expected ${what} to exist`);
765
- }
766
- return value;
767
- }
768
-
769
804
  // src/lib/list.ts
770
805
  var DEFAULT_LIMIT = 50;
771
806
  var MAX_LIMIT = 200;
@@ -1954,6 +1989,9 @@ a:focus-visible, .r-sub:focus-visible, .r-btn:focus-visible { outline:2px solid
1954
1989
  `;
1955
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">`;
1956
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
+ }
1957
1995
  function devDashboardStyle(url) {
1958
1996
  return url ? `<style>${DEV_BADGE_STYLE}</style>` : "";
1959
1997
  }
@@ -1973,13 +2011,13 @@ function readerPage(opts) {
1973
2011
  const tag = tagline ? `<div class="r-tag">${escapeHtml(tagline)}</div>` : "";
1974
2012
  const text = nameHtml || tag ? `<span>${nameHtml}${tag}</span>` : "";
1975
2013
  const label = text ? "" : logo ? ' aria-label="Home"' : "";
1976
- const brand = `<a class="r-brand" href="${escapeHtmlAttr(opts.homeUrl)}"${label}>${logo || text ? `${logo}${text}` : "Archive"}</a>`;
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>`;
1977
2015
  const cta = opts.subscribeUrl ? `<span class="r-cta"><a class="r-sub" href="${escapeHtmlAttr(opts.subscribeUrl)}">Subscribe here &rarr;</a></span>` : "";
1978
- const masthead2 = `<header class="r-mast"><div class="r-mast-in">
2016
+ const masthead2 = brand || cta ? `<header class="r-mast"><div class="r-mast-in">
1979
2017
  ${brand}
1980
2018
  ${cta}
1981
2019
  </div></header>
1982
- `;
2020
+ ` : "";
1983
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>` : "";
1984
2022
  const doc = `<!doctype html>
1985
2023
  <html lang="en">
@@ -1987,7 +2025,7 @@ ${cta}
1987
2025
  <meta charset="utf-8">
1988
2026
  <meta name="viewport" content="width=device-width, initial-scale=1">
1989
2027
  <title>${escapeHtml(opts.title)}</title>
1990
- ${FRAUNCES_FONT_LINKS}
2028
+ ${readerIconLink(logoUrl)}${FRAUNCES_FONT_LINKS}
1991
2029
  <style>${READER_STYLE}</style>${readerBannerStyle(opts.banner)}${devDashboardStyle(opts.devDashboardUrl)}
1992
2030
  </head>
1993
2031
  <body>
@@ -2020,6 +2058,20 @@ function messagePage(opts) {
2020
2058
  private: true
2021
2059
  });
2022
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
+ }
2023
2075
  function archiveLink(url, label) {
2024
2076
  return `<a class="r-arch" href="${escapeHtmlAttr(url)}">${escapeHtml(label)} &rarr;</a>`;
2025
2077
  }
@@ -2994,7 +3046,7 @@ function webVersion(html, opts) {
2994
3046
  omitEmailOnly(html),
2995
3047
  { ".Email.UnsubscribeURL": `${opts.config.appOrigin}/unsubscribe`, ".Email.SentTo": "" },
2996
3048
  "html"
2997
- ).split(ARCHIVE_MASTHEAD_ANCHOR).join(masthead2 + dev.masthead).split(ARCHIVE_HEAD_ANCHOR).join(ARCHIVE_POST_HEAD + dev.head);
3049
+ ).split(ARCHIVE_MASTHEAD_ANCHOR).join(masthead2 + dev.masthead).split(ARCHIVE_HEAD_ANCHOR).join(ARCHIVE_POST_HEAD + readerIconLink(opts.identity.logoUrl) + dev.head);
2998
3050
  }
2999
3051
  function nothingHereYetPage(c) {
3000
3052
  return readerMessage(c, {
@@ -3525,7 +3577,9 @@ function sendConditions(send, now) {
3525
3577
  kind: "refused",
3526
3578
  severity: "action",
3527
3579
  since: send.halted_at,
3528
- 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.`,
3529
3583
  action: null,
3530
3584
  cause: send.halt_cause,
3531
3585
  error: send.halt_error ?? "",
@@ -4800,29 +4854,26 @@ async function reset(c) {
4800
4854
  // src/docs/index.ts
4801
4855
  import { Lexer } from "marked";
4802
4856
 
4803
- // docs/get-started/01-overview.md
4804
- 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";
4805
-
4806
4857
  // docs/get-started/02-deploy.md
4807
- 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 in the same folder, 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';
4808
4859
 
4809
4860
  // docs/get-started/03-access.md
4810
- 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';
4811
4862
 
4812
4863
  // docs/get-started/04-resend.md
4813
- 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';
4814
4865
 
4815
4866
  // docs/get-started/05-verify.md
4816
- 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 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";
4817
4868
 
4818
4869
  // docs/get-started/06-go-live.md
4819
- 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";
4820
4871
 
4821
4872
  // docs/guides/01-connect-claude.md
4822
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';
4823
4874
 
4824
4875
  // docs/guides/02-ses.md
4825
- 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';
4826
4877
 
4827
4878
  // docs/guides/03-notifications.md
4828
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";
@@ -4833,18 +4884,20 @@ var rate_limit_default = "# Rate-limit the subscribe form\n\nThe subscribe form
4833
4884
  // docs/guides/07-upgrade.md
4834
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";
4835
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
+
4836
4890
  // docs/README.md
4837
- 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";
4838
4892
 
4839
4893
  // docs/reference/01-configuration.md
4840
- 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, 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` 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";
4841
4895
 
4842
4896
  // docs/reference/02-sending-domain-dns.md
4843
- 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";
4844
4898
 
4845
4899
  // src/docs/index.ts
4846
4900
  var PAGES = {
4847
- "get-started/01-overview.md": overview_default,
4848
4901
  "get-started/02-deploy.md": deploy_default,
4849
4902
  "get-started/03-access.md": access_default,
4850
4903
  "get-started/04-resend.md": resend_default,
@@ -4855,6 +4908,7 @@ var PAGES = {
4855
4908
  "guides/03-notifications.md": notifications_default,
4856
4909
  "guides/05-rate-limit.md": rate_limit_default,
4857
4910
  "guides/07-upgrade.md": upgrade_default,
4911
+ "guides/08-try-locally.md": try_locally_default,
4858
4912
  "reference/01-configuration.md": configuration_default,
4859
4913
  "reference/02-sending-domain-dns.md": sending_domain_dns_default
4860
4914
  };
@@ -5364,6 +5418,36 @@ var FakeProvider = class {
5364
5418
  }
5365
5419
  };
5366
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
+
5367
5451
  // src/lib/constant_time.ts
5368
5452
  async function timingSafeEqual(a, b) {
5369
5453
  const enc = new TextEncoder();
@@ -6539,6 +6623,8 @@ async function drainSimulatedWebhooks(env, config) {
6539
6623
  // src/providers/index.ts
6540
6624
  function getProvider(config, env) {
6541
6625
  switch (config.provider) {
6626
+ case "none":
6627
+ return new NotConnectedProvider();
6542
6628
  case "fake":
6543
6629
  return config.simulation ? new SimProvider(config.simulation, config) : new FakeProvider();
6544
6630
  case "ses":
@@ -6551,6 +6637,15 @@ function getProvider(config, env) {
6551
6637
  }
6552
6638
  }
6553
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
+ }
6554
6649
  function perRecipient(result, recipients) {
6555
6650
  if (result.kind === "answered") {
6556
6651
  return result.results;
@@ -6566,6 +6661,7 @@ function perRecipient(result, recipients) {
6566
6661
 
6567
6662
  // src/services/subscriptions.ts
6568
6663
  async function requestSubscription(c, email2) {
6664
+ requireConnectedProvider(c.config);
6569
6665
  const db = c.env.DB;
6570
6666
  const existing = await getByEmail(db, email2);
6571
6667
  if (existing?.status === "confirmed") {
@@ -6735,14 +6831,32 @@ function subscribeNotAnEmailPage(c, identity) {
6735
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();
6736
6832
  return subscribePage(c, identity, main, 400);
6737
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
+ }
6738
6838
  function subscribeCheckInboxPage(c, identity) {
6739
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>`;
6740
6840
  return subscribePage(c, identity, main);
6741
6841
  }
6742
6842
  async function subscribeForm(c) {
6743
- return subscribeFormPage(c, await readerIdentity(c, c.config));
6843
+ const identity = await readerIdentity(c, c.config);
6844
+ return c.config.provider === "none" ? subscribeNotOpenPage(c, identity) : subscribeFormPage(c, identity);
6744
6845
  }
6745
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
+ }
6746
6860
  const raw = await readEmail(c);
6747
6861
  const email2 = raw ? normalizeEmail(raw) : "";
6748
6862
  if (!email2 || !isValidEmail(email2)) {
@@ -7055,6 +7169,7 @@ async function test(c) {
7055
7169
  if (!isValidEmail(to)) {
7056
7170
  throw fieldError("to", "to must be an email address");
7057
7171
  }
7172
+ requireConnectedProvider(c.config);
7058
7173
  const readAt = Date.now();
7059
7174
  const { input, email: email2, warnings, frozen } = await loadPostEmail(c);
7060
7175
  const provider = getProvider(c.config, c.env);
@@ -7163,6 +7278,7 @@ async function templateTest(c) {
7163
7278
  if (recipients.length > MAX_TEMPLATE_TEST_RECIPIENTS) {
7164
7279
  throw badRequest(`at most ${MAX_TEMPLATE_TEST_RECIPIENTS} recipients per test`);
7165
7280
  }
7281
+ requireConnectedProvider(c.config);
7166
7282
  const result = await render(sampleRenderInput(), c.config, resolveBranding(settings, c.config));
7167
7283
  const provider = getProvider(c.config, c.env);
7168
7284
  const unsubscribeUrl = testUnsubscribeUrl(c.config);
@@ -7350,6 +7466,7 @@ async function schedule(c) {
7350
7466
  }
7351
7467
  const body = await readJsonObject(c);
7352
7468
  rejectStrayTemplateChoice(body);
7469
+ requireConnectedProvider(c.config);
7353
7470
  const fireAt = parseFutureFireAt(body.fire_at, c.config.minLeadMs, true);
7354
7471
  const send = await freeze(c.env, c.config, post, fireAt);
7355
7472
  const frozen = unwrap(await viewWithCursor(c.env, send.id), "send");
@@ -7365,6 +7482,7 @@ async function sendNow(c) {
7365
7482
  if (active) {
7366
7483
  return repeatAnswer(c, active.id);
7367
7484
  }
7485
+ requireConnectedProvider(c.config);
7368
7486
  const now = Date.now();
7369
7487
  const fireAt = acceptFireAt(now + c.config.minLeadMs, c.config.minLeadMs, { now });
7370
7488
  let send;
@@ -7867,6 +7985,17 @@ function composeNotification(n, config) {
7867
7985
  );
7868
7986
  }
7869
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
+ }
7870
7999
  return message(
7871
8000
  `Problem with ${title}: the provider is refusing your account`,
7872
8001
  [
@@ -8019,7 +8148,7 @@ async function saveSettingsRemaking(env, config, derive, ack, beforeWrite) {
8019
8148
 
8020
8149
  // src/routes/settings.ts
8021
8150
  var MAX_LOGO_BYTES = 512 * 1024;
8022
- function deploymentView(cfg) {
8151
+ function deploymentView(cfg, principal) {
8023
8152
  return {
8024
8153
  provider: cfg.provider,
8025
8154
  fromAddress: cfg.fromAddress,
@@ -8031,7 +8160,7 @@ function deploymentView(cfg) {
8031
8160
  awsRegion: cfg.awsRegion,
8032
8161
  // Booleans only — never the values.
8033
8162
  accessConfigured: Boolean(cfg.accessTeamDomain && cfg.accessAud),
8034
- authMode: cfg.accessTeamDomain ? "access" : "dev",
8163
+ authMode: principal.via,
8035
8164
  // The running build (SPEC §9), so the editor can show/link it. Build metadata,
8036
8165
  // resolved at build (src/build.ts) — not deploy config, and never a secret.
8037
8166
  build: buildInfo(),
@@ -8086,7 +8215,7 @@ async function get2(c) {
8086
8215
  const settings = await getSettings(c.env.DB);
8087
8216
  const body = {
8088
8217
  settings: settingsView(settings, c.config),
8089
- deployment: deploymentView(c.config),
8218
+ deployment: deploymentView(c.config, unwrap(c.principal, "principal")),
8090
8219
  inUse: await inUseView(c.env.DB, settings, c.config),
8091
8220
  notificationStatus: await notificationStatus(c.env.DB)
8092
8221
  };
@@ -8097,6 +8226,9 @@ async function notificationTest(c) {
8097
8226
  if (!to) {
8098
8227
  throw badRequest("set a notifications address first", { field: "notifications.to" });
8099
8228
  }
8229
+ if (c.config.notifyChannel === "provider") {
8230
+ requireConnectedProvider(c.config);
8231
+ }
8100
8232
  const notifier = getNotifier(c.config, c.env);
8101
8233
  try {
8102
8234
  await notifier.send(to, sampleNotification(c.config), `test-${Date.now()}`);
@@ -8382,6 +8514,9 @@ function retrySchedule(steps) {
8382
8514
  const head = minutes3.length > 1 ? `${minutes3.slice(0, -1).join(", ")}, and ${minutes3.at(-1)}` : `${minutes3[0]}`;
8383
8515
  return `after ${head} minutes and then every ${last} minutes`;
8384
8516
  }
8517
+ function isReaderPath(pathname) {
8518
+ return !(pathname === "/api" || pathname.startsWith("/api/") || pathname === "/health" || pathname.startsWith("/webhooks/"));
8519
+ }
8385
8520
  function createRouter({
8386
8521
  archiveBasePath,
8387
8522
  devMode,
@@ -8390,6 +8525,7 @@ function createRouter({
8390
8525
  const devOnly = (...defs) => devMode ? defs : [];
8391
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)}`;
8392
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.`;
8528
+ const notConnected = "Refused with 409 `provider_not_connected` while no email provider is connected (SPEC \xA79), since nothing can be sent.";
8393
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.";
8394
8530
  const sendExample = (over = {}) => ({
8395
8531
  id: "s_xyz789",
@@ -8470,16 +8606,17 @@ function createRouter({
8470
8606
  },
8471
8607
  {
8472
8608
  // Reports the authenticated principal and the auth mode, so the editor can show
8473
- // 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.
8474
8611
  method: "GET",
8475
8612
  path: "/api/whoami",
8476
8613
  access: "admin",
8477
8614
  resource: "system",
8478
- summary: "The authenticated principal and the auth mode (access or dev).",
8479
- handler: (c) => json({
8480
- principal: c.principal,
8481
- auth: { mode: c.config.accessTeamDomain ? "access" : "dev" }
8482
- })
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
+ }
8483
8620
  },
8484
8621
  {
8485
8622
  // The running build (SPEC §9), so an API client (Claude, curl) can self-identify a
@@ -8555,7 +8692,7 @@ function createRouter({
8555
8692
  access: "admin",
8556
8693
  resource: "settings",
8557
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.",
8558
- 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.",
8559
8696
  example: {
8560
8697
  response: {
8561
8698
  settings: { emailTemplate: "\u2026", publication: { name: "Marsh Lane" } },
@@ -8632,7 +8769,7 @@ function createRouter({
8632
8769
  accepts: JSON_BODY,
8633
8770
  resource: "settings",
8634
8771
  summary: "Send a sample post through the saved email template, to `to` or the default recipients (I5).",
8635
- 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}`,
8636
8773
  example: {
8637
8774
  request: { to: "you@example.com" },
8638
8775
  response: { sent: 1, total: 1, provider: "fake", subject: "Template test \u2014 \u2026" }
@@ -8645,7 +8782,7 @@ function createRouter({
8645
8782
  access: "admin",
8646
8783
  resource: "settings",
8647
8784
  summary: "Send a sample notification to the saved notifications address, through the live channel (SPEC \xA78).",
8648
- 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)}`,
8649
8786
  example: { response: { to: "you@example.com", channel: "cloudflare" } },
8650
8787
  handler: notificationTest
8651
8788
  },
@@ -8822,7 +8959,7 @@ function createRouter({
8822
8959
  accepts: JSON_BODY,
8823
8960
  resource: "posts",
8824
8961
  summary: "Send a test to one address through the same per-recipient path as a real send (I5).",
8825
- 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,
8826
8963
  example: {
8827
8964
  request: { to: "you@example.com" },
8828
8965
  response: {
@@ -8871,7 +9008,7 @@ function createRouter({
8871
9008
  accepts: JSON_BODY,
8872
9009
  resource: "posts",
8873
9010
  summary: `Freeze the render and schedule the send for a future time (at least ${formatLead(minLeadMs)} out).`,
8874
- 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).",
8875
9012
  example: {
8876
9013
  request: { fire_at: "2026-01-16T09:00:00Z" },
8877
9014
  response: { send: scheduledExample, cursor: "57.1768467610000" }
@@ -8897,7 +9034,7 @@ function createRouter({
8897
9034
  access: "admin",
8898
9035
  resource: "sends",
8899
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.",
8900
- 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.",
8901
9038
  query: [
8902
9039
  {
8903
9040
  name: "status",
@@ -9133,6 +9270,7 @@ function createRouter({
9133
9270
  accepts: JSON_BODY,
9134
9271
  resource: "subscribers",
9135
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.`,
9136
9274
  example: { request: { email: "reader@example.com" } },
9137
9275
  handler: create
9138
9276
  },
@@ -9242,7 +9380,7 @@ function createRouter({
9242
9380
  path: "/subscribe",
9243
9381
  access: "public",
9244
9382
  resource: "subscriptions",
9245
- 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).",
9246
9384
  handler: subscribeForm
9247
9385
  },
9248
9386
  {
@@ -9251,7 +9389,7 @@ function createRouter({
9251
9389
  access: "public",
9252
9390
  resource: "subscriptions",
9253
9391
  summary: "Request a subscription; starts the double opt-in (confirmation email).",
9254
- 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).",
9255
9393
  example: {
9256
9394
  request: { email: "you@example.com" },
9257
9395
  response: { status: "check_inbox" }
@@ -10067,6 +10205,9 @@ async function handleFetch(request, appEnv, ctx) {
10067
10205
  throw err;
10068
10206
  }
10069
10207
  reportConfigError(err);
10208
+ if (isReaderPath(new URL(request.url).pathname)) {
10209
+ return unavailablePage();
10210
+ }
10070
10211
  return json({ error: "invalid_config", message: err.message, variable: err.variable }, 500);
10071
10212
  }
10072
10213
  return router.handle(request, appEnv, ctx);