@kurtbruns/kestrel 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,35 @@ To move a running instance from one version to another, follow [Upgrade to a new
10
10
 
11
11
  <!-- Add entries under Added / Changed / Fixed / Breaking. One operator-facing line each; see .claude/rules/changelog.md. Upgrade steps (a migration, a new setting) go once, in the Upgrading paragraph, never on an entry. -->
12
12
 
13
+ ## [1.16.0] - 2026-10-26
14
+
15
+ **Upgrading from 1.15.1.** At the top level of your instance's `wrangler.jsonc`, outside `env`, add `"workers_dev": false` and `"preview_urls": false`, and change `triggers` to `{ "crons": [] }`, as a new instance now has, so a deploy without `--env production` answers on no address and never runs the send sweep. Keep the empty list rather than deleting the line, since only a list clears a schedule, and keep the `* * * * *` cron in `production`'s own `triggers`. If you ever deployed without `--env production`, that Worker is still live: remove it with `npx wrangler delete --name kestrel`. A log filter or alert on `auth.service` finds nothing now: filter on `principal` = `service` instead. Run `npm run check-context` in your instance's repository: this release changes the operator's Claude context, and a copy you've edited is merged by hand.
16
+
17
+ ### Added
18
+
19
+ - Two new log lines: `send.scheduled` (info) for each schedule and Send now, with `act` saying which, and `send.resolved` (warn) for each Resolve of a wedged send, with the resolution and how many recipients it settled. A failed `mcp.call` now carries `code`, the refusal code of the route that turned it down, so failed tool calls can be filtered (SPEC §12).
20
+
21
+ ### Changed
22
+
23
+ - The operator's Claude context (`.claude/CLAUDE.md`) says how to put images in a post: upload each from a file on disk, since a pasted image isn't one, and reference it by its filename with alt text, never by the upload's URL, which can leave a sent post with a broken image. Merge it into your instance's copy; `kestrel check-context` shows the change.
24
+ - A new instance's development settings now answer on no address and run no schedule if deployed by mistake, without `--env production`, so a deploy that forgets it can't put the email stand-in and the local sign-in on a public address, or run the send sweep through the stand-in. The setup guide's Deploy step says how to remove such a deploy.
25
+ - A safety caution in the instance's Claude context: never deploy or store a secret without `--env production`, keep the top level's addresses and schedule off, and how to remove a deploy that went out without it, with the person's agreement.
26
+ - The setup guide's logs section now says that a search over a long range may show only a sample of the lines, how to narrow it to follow one send, and that on the Free plan a very large send can leave the rest of the day unlogged once Cloudflare's daily allowance is used up.
27
+
28
+ ### Fixed
29
+
30
+ - An image referenced by its full address on your instance, as an upload answers it, is now checked like one referenced by name: the preview, test, and schedule say to use the file name, and warn when the image is another post's or has been deleted, instead of staying silent while the sent email carried a broken image (SPEC §4).
31
+
32
+ ### Breaking
33
+
34
+ - The app no longer logs `auth.service` on every request a service token makes, since Cloudflare's own request and Access logs already record each one. Instead every log line for a publisher's action, the new `send.scheduled` and `send.resolved` included, says who took it: `principal` (`human` or `service`) and, for a person, `client` (`editor`, `claude-code`, `claude`, or the name a script gives), never the email or the token (SPEC §12).
35
+
36
+ ## [1.15.1] - 2026-10-23
37
+
38
+ ### Changed
39
+
40
+ - Kestrel now needs wrangler 4.152.0 or later, which carries fixes for security advisories in the local runtime it bundles. A new instance starts on it, and installing this release moves an existing instance's wrangler up on its own, with no change to its `package.json`.
41
+
13
42
  ## [1.15.0] - 2026-10-23
14
43
 
15
44
  **Upgrading from 1.14.0.** Nothing to add: your instance keeps running every `kestrel` command, `login` and `api` included, from the `@kurtbruns/kestrel` it already installs, which ships the client too. Don't add `kestrel` as a dependency, since its `kestrel` command would collide with that one. `npx -p @kurtbruns/kestrel kestrel …` keeps working, and `npx kestrel …` now does the same from any folder.
@@ -625,7 +654,9 @@ The first tagged release: a self-contained newsletter app on a Cloudflare Worker
625
654
  - A build-version stamp: every instance reports its version, commit, and build time, read-only in the editor and at `GET /api/version`.
626
655
  - This changelog and a semantic-versioning release process.
627
656
 
628
- [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.15.0...HEAD
657
+ [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.16.0...HEAD
658
+ [1.16.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.16.0
659
+ [1.15.1]: https://github.com/kurtbruns/kestrel/releases/tag/v1.15.1
629
660
  [1.15.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.15.0
630
661
  [1.14.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.14.0
631
662
  [1.13.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.13.0
@@ -1,8 +1,8 @@
1
1
  /*
2
2
  * The local send sweep, on production's schedule, and the simulated receipts on theirs.
3
3
  *
4
- * Deployed, a Cron Trigger (`* * * * *` in wrangler.jsonc) runs the send sweep once a
5
- * minute, on the minute: it fires due sends and resumes interrupted and halted ones.
4
+ * Deployed, a Cron Trigger (`* * * * *` in an instance's `production`) runs the send sweep
5
+ * once a minute, on the minute: it fires due sends and resumes interrupted and halted ones.
6
6
  * `wrangler dev` never fires a cron on its own; it only exposes a URL that runs the
7
7
  * scheduled handler once. Without something calling it, a local send scheduled for 10:05 is
8
8
  * never sent and a paused send never resumes.
@@ -23,7 +23,7 @@
23
23
  const SCHEDULED_PATH = "/cdn-cgi/local/scheduled";
24
24
  const MINUTE = 60_000;
25
25
 
26
- /** The deployed sweep's cron (wrangler.jsonc's trigger). */
26
+ /** The deployed sweep's cron (the `production` trigger in template/wrangler.jsonc). */
27
27
  const SWEEP_CRON = "* * * * *";
28
28
 
29
29
  /** The cron value the Worker reads as "settle simulated receipts only": `RECEIPTS_CRON` in
@@ -1,4 +1,4 @@
1
- // @kurtbruns/kestrel 1.15.0: the Worker, built by scripts/build-package.mjs.
1
+ // @kurtbruns/kestrel 1.16.0: the Worker, built by scripts/build-package.mjs.
2
2
 
3
3
  // shared/archive_url.ts
4
4
  function archivePostUrl(origin, basePath, slug) {
@@ -934,10 +934,10 @@ var SUBSCRIBER_ACTIONS = {
934
934
 
935
935
  // src/generated/version.ts
936
936
  var BUILD_INFO = {
937
- version: "1.15.0",
938
- sha: "57b846b",
939
- tag: "v1.15.0",
940
- buildTime: "2026-10-23T14:03:58.360Z",
937
+ version: "1.16.0",
938
+ sha: "59f0856",
939
+ tag: "v1.16.0",
940
+ buildTime: "2026-10-26T14:53:56.423Z",
941
941
  repoUrl: "https://github.com/kurtbruns/kestrel"
942
942
  };
943
943
 
@@ -1164,6 +1164,9 @@ var log = {
1164
1164
  warn: (event, fields = {}) => emit("warn", event, fields),
1165
1165
  info: (event, fields = {}) => emit("info", event, fields)
1166
1166
  };
1167
+ function whoActed(by) {
1168
+ return { principal: by.kind, client: by.kind === "human" ? by.client ?? void 0 : void 0 };
1169
+ }
1167
1170
  function errorText(err) {
1168
1171
  return String(err?.message ?? err);
1169
1172
  }
@@ -2955,7 +2958,8 @@ async function erase(db, id) {
2955
2958
  return {
2956
2959
  kind: "erased",
2957
2960
  subscriber: row,
2958
- deliveries: moved2.slice(deliveries3.rewrite.length).reduce((n, r) => n + (r.meta.changes ?? 0), 0)
2961
+ deliveries: moved2.slice(deliveries3.rewrite.length).reduce((n, r) => n + (r.meta.changes ?? 0), 0),
2962
+ unchanged: moved2.every((r) => (r.meta.changes ?? 0) === 0)
2959
2963
  };
2960
2964
  }
2961
2965
  const idle = idleFor("?1", "?2");
@@ -2982,7 +2986,8 @@ async function erase(db, id) {
2982
2986
  return {
2983
2987
  kind: "erased",
2984
2988
  subscriber: after,
2985
- deliveries: moved.reduce((n, r) => n + (r.meta.changes ?? 0), 0)
2989
+ deliveries: moved.reduce((n, r) => n + (r.meta.changes ?? 0), 0),
2990
+ unchanged: false
2986
2991
  };
2987
2992
  }
2988
2993
  async function addSuppression(db, email2, reason, detail) {
@@ -3591,13 +3596,11 @@ function principalFromClaims(payload, allowed) {
3591
3596
  if (email2) {
3592
3597
  return isHumanAllowed(email2, allowed) ? { kind: "human", email: email2, via: "access" } : null;
3593
3598
  }
3594
- const claims = Object.keys(payload).sort().join(",");
3595
3599
  const tokenId = typeof payload.common_name === "string" ? payload.common_name : void 0;
3596
3600
  if (tokenId) {
3597
- log.info("auth.service", { claims });
3598
3601
  return { kind: "service", tokenId, via: "access" };
3599
3602
  }
3600
- log.warn("auth.unidentified", { claims });
3603
+ log.warn("auth.unidentified", { claims: Object.keys(payload).sort().join(",") });
3601
3604
  return "access_unidentified";
3602
3605
  }
3603
3606
 
@@ -3641,6 +3644,10 @@ function iso(ms) {
3641
3644
  function isObject2(v2) {
3642
3645
  return typeof v2 === "object" && v2 !== null && !Array.isArray(v2);
3643
3646
  }
3647
+ var refusalCodes = /* @__PURE__ */ new WeakMap();
3648
+ function refusalCodeOf(result2) {
3649
+ return refusalCodes.get(result2);
3650
+ }
3644
3651
  function refused(a, lines = []) {
3645
3652
  const body = isObject2(a.body) ? a.body : {};
3646
3653
  const code = typeof body.error === "string" ? body.error : void 0;
@@ -3654,7 +3661,11 @@ function refused(a, lines = []) {
3654
3661
  if (typeof body.field === "string") {
3655
3662
  out.push(`Fix: \`${body.field}\`.`);
3656
3663
  }
3657
- return text([...out, ...lines].join("\n"), true);
3664
+ const result2 = text([...out, ...lines].join("\n"), true);
3665
+ if (code) {
3666
+ refusalCodes.set(result2, code);
3667
+ }
3668
+ return result2;
3658
3669
  }
3659
3670
  function postBrief(p) {
3660
3671
  return {
@@ -4868,12 +4879,15 @@ function allows(t, r) {
4868
4879
  }
4869
4880
  async function call(c, router, t, args) {
4870
4881
  if (args !== void 0 && !isObject3(args)) {
4871
- return { content: [{ type: "text", text: "arguments must be an object" }], isError: true };
4882
+ return logged(t, {
4883
+ content: [{ type: "text", text: "arguments must be an object" }],
4884
+ isError: true
4885
+ });
4872
4886
  }
4873
4887
  const given = args ?? {};
4874
4888
  const stray = unknownArgs(t, given);
4875
4889
  if (stray) {
4876
- return { content: [{ type: "text", text: stray }], isError: true };
4890
+ return logged(t, { content: [{ type: "text", text: stray }], isError: true });
4877
4891
  }
4878
4892
  const api = {
4879
4893
  async call(r, parts = {}) {
@@ -4911,7 +4925,10 @@ async function call(c, router, t, args) {
4911
4925
  isError: true
4912
4926
  };
4913
4927
  }
4914
- log.info("mcp.call", { tool: t.tool.name, error: out.isError });
4928
+ return logged(t, out);
4929
+ }
4930
+ function logged(t, out) {
4931
+ log.info("mcp.call", { tool: t.tool.name, error: out.isError, code: refusalCodeOf(out) });
4915
4932
  return out;
4916
4933
  }
4917
4934
 
@@ -7388,15 +7405,43 @@ function buildImageMap(images) {
7388
7405
  return map;
7389
7406
  }
7390
7407
  function resolveImageSrc(href, images, mediaBase) {
7408
+ const key = ownMediaKey(href, mediaBase);
7409
+ if (key !== null) {
7410
+ const image2 = [...images.values()].find((img) => img.storage_key === key) ?? null;
7411
+ return { url: image2 ? `${mediaBase}/${image2.storage_key}` : href, image: image2, ownMedia: true };
7412
+ }
7391
7413
  if (/^https?:\/\//i.test(href) || href.startsWith("data:") || href.startsWith("cid:")) {
7392
- return { url: href, image: null };
7414
+ return { url: href, image: null, ownMedia: false };
7393
7415
  }
7394
7416
  const name = href.split("/").pop() ?? href;
7395
7417
  const image = images.get(name) ?? images.get(href) ?? null;
7396
7418
  if (image) {
7397
- return { url: `${mediaBase}/${image.storage_key}`, image };
7419
+ return { url: `${mediaBase}/${image.storage_key}`, image, ownMedia: false };
7420
+ }
7421
+ return { url: href, image: null, ownMedia: false };
7422
+ }
7423
+ function ownMediaKey(href, mediaBase) {
7424
+ if (!/^https?:\/\//i.test(href)) {
7425
+ return null;
7426
+ }
7427
+ let ref;
7428
+ let base;
7429
+ try {
7430
+ ref = new URL(href);
7431
+ base = new URL(mediaBase);
7432
+ } catch {
7433
+ return null;
7434
+ }
7435
+ const prefix = base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`;
7436
+ if (ref.origin !== base.origin || !ref.pathname.startsWith(prefix)) {
7437
+ return null;
7438
+ }
7439
+ const rest = ref.pathname.slice(prefix.length);
7440
+ try {
7441
+ return decodeURIComponent(rest);
7442
+ } catch {
7443
+ return rest;
7398
7444
  }
7399
- return { url: href, image: null };
7400
7445
  }
7401
7446
 
7402
7447
  // src/render/markdown.ts
@@ -7413,8 +7458,16 @@ function markdownToHtml(md, ctx) {
7413
7458
  `The image \u201C${href}\u201D has no alt text. Describe it between the square brackets.`
7414
7459
  );
7415
7460
  }
7416
- const { url, image } = resolveImageSrc(href, ctx.images, ctx.mediaBase);
7417
- if (!image && !/^https?:\/\//i.test(url)) {
7461
+ const { url, image, ownMedia } = resolveImageSrc(href, ctx.images, ctx.mediaBase);
7462
+ if (image && ownMedia) {
7463
+ ctx.warnings.push(
7464
+ `The image \u201C${href}\u201D should be referenced as \u201C${image.filename}\u201D. Use its file name, not its address.`
7465
+ );
7466
+ } else if (!image && ownMedia) {
7467
+ ctx.warnings.push(
7468
+ `The image \u201C${href}\u201D isn\u2019t on this post. Upload it to this post and reference it by its file name.`
7469
+ );
7470
+ } else if (!image && !/^https?:\/\//i.test(url)) {
7418
7471
  ctx.warnings.push(
7419
7472
  `The image \u201C${href}\u201D isn\u2019t on this post. Upload it, or check the file name.`
7420
7473
  );
@@ -9234,6 +9287,13 @@ async function freeze(env, config, post, fireAt, postSettings = {}, recorded = n
9234
9287
  throw err;
9235
9288
  }
9236
9289
  if ((results[0]?.meta.changes ?? 0) > 0) {
9290
+ log.info("send.scheduled", {
9291
+ sendId: id,
9292
+ postId: post.id,
9293
+ fireAt: new Date(fireAt).toISOString(),
9294
+ act: recorded?.act,
9295
+ ...recorded ? whoActed(recorded.by) : {}
9296
+ });
9237
9297
  return unwrap(await getSend(env.DB, id), "send");
9238
9298
  }
9239
9299
  }
@@ -9296,7 +9356,8 @@ async function reschedule(env, sendId, fireAt, minLeadMs, by = null) {
9296
9356
  sendId,
9297
9357
  postId: send.post_id,
9298
9358
  fireAt: new Date(target).toISOString(),
9299
- movedMs: target - send.fire_at
9359
+ movedMs: target - send.fire_at,
9360
+ ...by ? whoActed(by) : {}
9300
9361
  });
9301
9362
  return { send: unwrap(await getSend(env.DB, sendId), "send"), changed: true };
9302
9363
  }
@@ -9324,7 +9385,7 @@ async function cancel(env, sendId, by = null) {
9324
9385
  if ((res?.meta.changes ?? 0) === 0) {
9325
9386
  return cancel(env, sendId, by);
9326
9387
  }
9327
- log.info("send.canceled", { sendId, postId: send.post_id });
9388
+ log.info("send.canceled", { sendId, postId: send.post_id, ...by ? whoActed(by) : {} });
9328
9389
  return { send: unwrap(await getSend(env.DB, sendId), "send"), changed: true };
9329
9390
  }
9330
9391
 
@@ -10477,7 +10538,7 @@ async function reset(c) {
10477
10538
  import { Lexer } from "marked";
10478
10539
 
10479
10540
  // docs/get-started/02-deploy.md
10480
- 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- An [AWS account](https://portal.aws.amazon.com/billing/signup), for Amazon SES, which sends your email.\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 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. Copy the local settings, and start the app:\n\n ```bash\n cp .dev.vars.example .dev.vars\n npm run dev\n ```\n\n Open http://localhost:8787/dashboard/, and run `npm run seed` in a second terminal to load a demo publication. Email goes to a stand-in, so nothing reaches a real inbox. Press Ctrl+C to stop the server when you\'re done.\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\nEach command creates the resource and writes it into the `production` block of `wrangler.jsonc`, under the binding the app already uses. Name `--env production` on each one, or Wrangler writes to the top level, which is local development.\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create kestrel-production --env production --binding DB --update-config\n ```\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create kestrel-media-production --env production --binding MEDIA --update-config\n ```\n\nEach prints a snippet to add to your configuration. There\'s nothing to paste: `--update-config` has filled in the database\'s id, and the `MEDIA` binding already names the bucket. Wrangler also rewrites `wrangler.jsonc` in its own layout, with tabs and one value per line, and your settings stay.\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. Only the settings you change are shown; leave the others as they are:\n\n ```jsonc\n "production": {\n "routes": [\n {\n "pattern": "newsletter.example.com", // \u2190 your app\'s hostname\n "custom_domain": true\n }\n ],\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "\u2026", // already filled in by section 3; leave it\n "migrations_dir": "migrations"\n }\n ],\n "vars": {\n "PROVIDER": "", // \u2190 stays empty until Connect Amazon SES\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 empty until [Connect Amazon SES](04-ses.md) sets it. Until then the app runs without an email provider: posts can be written and previewed, but scheduling, test emails, and signups are refused. Never set it to `fake`, the top level\'s value, even when a wrangler warning suggests copying it: `fake` delivers nothing, and the app refuses it on your hostname.\n - `ARCHIVE_BASE_PATH` is permanent once the first post goes out. Every post sent carries its `/archive/\u2026` link, and changing the prefix later breaks the links 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. The app refuses to run while any of its settings still names `example.com`.\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. It 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:\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\n In a browser, `https://newsletter.example.com/dashboard/` says the editor is locked until you set up Access. That\'s expected.\n\n1. In a browser, `https://newsletter.example.com/` shows the newsletter\'s public home page. Its subscribe page, `/subscribe`, says signups aren\'t open yet, until Connect Amazon SES.\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\nIf the public pages say the same, but `/health` answers `500` with `{"error":"schema_behind","missing":[\u2026],\u2026}`, the database hasn\'t run the migrations it lists, most often because step 5 was skipped or its prompt answered no. Run step 5\'s command, which the body\'s `message` repeats; the app answers as soon as it finishes, with no new deploy.\n\nNext, [lock the dashboard with Access](03-access.md).\n';
10541
+ 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- An [AWS account](https://portal.aws.amazon.com/billing/signup), for Amazon SES, which sends your email.\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 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. Copy the local settings, and start the app:\n\n ```bash\n cp .dev.vars.example .dev.vars\n npm run dev\n ```\n\n Open http://localhost:8787/dashboard/, and run `npm run seed` in a second terminal to load a demo publication. Email goes to a stand-in, so nothing reaches a real inbox. Press Ctrl+C to stop the server when you\'re done.\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\nEach command creates the resource and writes it into the `production` block of `wrangler.jsonc`, under the binding the app already uses. Name `--env production` on each one, or Wrangler writes to the top level, which is local development.\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create kestrel-production --env production --binding DB --update-config\n ```\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create kestrel-media-production --env production --binding MEDIA --update-config\n ```\n\nEach prints a snippet to add to your configuration. There\'s nothing to paste: `--update-config` has filled in the database\'s id, and the `MEDIA` binding already names the bucket. Wrangler also rewrites `wrangler.jsonc` in its own layout, with tabs and one value per line, and your settings stay.\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. Only the settings you change are shown; leave the others as they are:\n\n ```jsonc\n "production": {\n "routes": [\n {\n "pattern": "newsletter.example.com", // \u2190 your app\'s hostname\n "custom_domain": true\n }\n ],\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "\u2026", // already filled in by section 3; leave it\n "migrations_dir": "migrations"\n }\n ],\n "vars": {\n "PROVIDER": "", // \u2190 stays empty until Connect Amazon SES\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 empty until [Connect Amazon SES](04-ses.md) sets it. Until then the app runs without an email provider: posts can be written and previewed, but scheduling, test emails, and signups are refused. Never set it to `fake`, the top level\'s value, even when a wrangler warning suggests copying it: `fake` delivers nothing, and the app refuses it on your hostname.\n - `ARCHIVE_BASE_PATH` is permanent once the first post goes out. Every post sent carries its `/archive/\u2026` link, and changing the prefix later breaks the links 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. The app refuses to run while any of its settings still names `example.com`.\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. It 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:\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\nAlways name `--env production`, here and on every wrangler command that stores a secret. Without it, wrangler uses the top level, which is local development. A deploy then creates a second Worker named `kestrel`, which runs with an email stand-in that delivers nothing and a sign-in meant only for your machine. The top level turns off every address it could answer on and the schedule that sends posts, so no one can reach it and it never sends, but it does nothing useful. If you deployed without `--env production`, remove that Worker and deploy again with it:\n\n```bash\nnpx wrangler delete --name kestrel\n```\n\nLeave the top level\'s `"workers_dev": false`, `"preview_urls": false`, and `"triggers": { "crons": [] }` as they are, and never add a route there: they are what keep a deploy like that inert. Keep the empty `crons` rather than deleting the line, since only an empty list clears a schedule an earlier deploy left.\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\n In a browser, `https://newsletter.example.com/dashboard/` says the editor is locked until you set up Access. That\'s expected.\n\n1. In a browser, `https://newsletter.example.com/` shows the newsletter\'s public home page. Its subscribe page, `/subscribe`, says signups aren\'t open yet, until Connect Amazon SES.\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\nIf the public pages say the same, but `/health` answers `500` with `{"error":"schema_behind","missing":[\u2026],\u2026}`, the database hasn\'t run the migrations it lists, most often because step 5 was skipped or its prompt answered no. Run step 5\'s command, which the body\'s `message` repeats; the app answers as soon as it finishes, with no new deploy.\n\nNext, [lock the dashboard with Access](03-access.md).\n';
10481
10542
 
10482
10543
  // docs/get-started/03-access.md
10483
10544
  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, the in-app docs, and the MCP server Claude Desktop and claude.ai connect to (`/api/mcp`) |\n\n Each path covers everything under it. Leave every other path out. The home page, the archive, subscribing, unsubscribing, images, and your email provider\'s webhooks must stay public.\n\n1. **(Optional)** Choose how long a sign-in lasts. The application\'s session duration defaults to 24 hours, so everyone signs in again each day. You can set anything from immediately to one month, as Cloudflare\'s [session management](https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/session-management/) describes. A longer session suits a publisher on their own computer, and a shorter one is safer on a shared one.\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. Select **Create**.\n\nCloudflare\'s own login is on by default, and admits members of your Cloudflare account, so the email in your `Publishers` policy should be one. To let someone sign in another way, such as a one-time code by email, add it under **Zero Trust \u2192 Integrations \u2192 Identity providers**.\n\nThe app accepts only two kinds of policy on this application. One is an Allow policy on people\'s emails, like this one. The other is a Service Auth policy on a service token, from [Automate with a service token](../guides/02-automate.md). A login through any other kind, such as a certificate, a device check, or an identity provider that gives no email, is refused with a `401`, and the editor says so.\n\n## 4. Give the app its two values\n\nThe app verifies each login against your team domain, and checks that it was issued for this application.\n\n1. Find your team domain under **Zero Trust \u2192 Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`.\n\n1. Find the application\'s audience tag. Under **Access controls \u2192 Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.\n\n1. Store the team domain as a secret. The command prompts you for the value. Enter the hostname alone, without `https://`:\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.\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","client":null},"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. The app needs both values, so while only one is stored, `/health` answers `500` naming the other, and the public pages say the newsletter is temporarily unavailable. 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. If the editor says Access let you in but didn\'t say who you are, your login carried no email: sign out, and sign in through the `Publishers` policy.\n\nNext, [connect Amazon SES](04-ses.md).\n';
@@ -10486,7 +10547,7 @@ var access_default = '# Lock the dashboard with Access\n\nCloudflare Access puts
10486
10547
  var ses_default = '# Connect Amazon SES\n\nKestrel sends the newsletter\'s posts through Amazon SES, Amazon\'s email service. SES only delivers the mail: Kestrel keeps the list, each subscriber\'s consent, and the record of every send. SES prices every email alike, with no split between transactional and marketing mail, and costs the least at every list size above a tiny one. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.\n\nIn this guide, you verify your sending hostname with SES and add a DMARC record. You forward readers\' replies to the publisher\'s inbox, and ask AWS for production access. You also route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, set the provider, and deploy.\n\n## Before you begin\n\n- An [AWS account](https://portal.aws.amazon.com/billing/signup).\n\nTo move an instance that already sends through another provider or another SES account, read [Switch an instance that already sends](#switch-an-instance-that-already-sends) first.\n\n## 1. Verify your sending hostname\n\nSES sends only from an identity you\'ve verified. Verifying the whole hostname, rather than one address, lets any address on it send. It also sets up DKIM, which lets inboxes trust your mail.\n\n1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose the region you send from in the menu at the top right. Any region that offers SES works, so pick one near you. A few newer regions must be [enabled for your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html) before you can use them. SES keeps identities, quotas, and the sandbox separately in each region, so do every step in this one.\n\n1. Note the region\'s code. The menu shows a name, such as Europe (Ireland), but later steps need its code, such as `eu-west-1`. [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each region\'s code beside its name. The MAIL FROM record, the SNS access policy, and `AWS_REGION` all use this one code.\n\n1. Under **Configuration**, choose **Identities**, then **Create identity**.\n\n1. Select **Domain**, and enter `send.example.com`. Keep the default DKIM settings: **Easy DKIM**, with a 2048-bit key. Choose **Create identity**.\n\n1. On the identity\'s **Authentication** tab, expand **Publish DNS records**. It lists three CNAME records.\n\n1. In the Cloudflare dashboard, add each record to your domain\'s DNS records, exactly as SES lists it. Set each one\'s proxy status to **DNS only**. Cloudflare adds your domain to the name, so a name ends in `._domainkey.send`.\n\n1. Wait until SES shows **DKIM configuration** as **Successful** and **Identity status** as **Verified**.\n\n## 2. Add a DMARC record\n\nDMARC tells inboxes what to do with mail that fails its checks, and sends you reports about it. SES doesn\'t add this record, so you add it yourself. It starts in monitor mode, which reports without blocking anything.\n\n1. In the Cloudflare dashboard, open your domain\'s DNS records, and add a record:\n\n - **Type:** `TXT`\n - **Name:** `_dmarc.send`\n - **Content:** `v=DMARC1; p=none; rua=mailto:dmarc@example.com`\n\n1. Replace `dmarc@example.com` with an address that receives mail. Reports arrive there as attachments.\n\n[Sending-domain DNS](../reference/02-sending-domain-dns.md#dmarc-publish-a-policy-and-collect-reports) explains each record, and when to tighten DMARC.\n\n## 3. Receive replies at your From address\n\nWhen a reader replies to a post, or to their confirmation email, the reply goes to your From address, `newsletter@send.example.com`. Nothing receives mail there yet, so the reply bounces. Kestrel only sends mail, so you give the address an inbox yourself. Here you forward it to the publisher\'s inbox, with Cloudflare Email Routing, which is free on every plan. The publisher is whoever writes and sends the newsletter, which may be you.\n\n`send.example.com` receives no other mail, so turning on Email Routing there changes nothing else. Your SES records sit on other names under it. Inboxes check SES\'s mail for SPF at its return path, not here, so sending is unaffected.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), go to **Compute \u2192 Email Service \u2192 Email Routing**.\n\n1. Add `send.example.com` to Email Routing. Cloudflare adds subdomains from your domain\'s own settings: select `example.com`, then **Settings**, and add `send` under **Subdomains**.\n\n1. Cloudflare adds MX, SPF, and DKIM records for `send.example.com`, and locks them. Wait until they show as added.\n\n1. Under **Destination Addresses**, add the publisher\'s inbox. Open the email Cloudflare sends to it, and select **Verify email address**. If it isn\'t your inbox, ask the publisher to do this step. An address you\'ve already verified with Cloudflare, such as one for [notifications](../guides/03-notifications.md), is listed already.\n\n1. Under **Routing Rules**, select **Create routing rule**. For **Email pattern**, enter `newsletter`, the part of your From address before the `@`, and select `send.example.com`. Set **Action** to **Send to an email**, select the publisher\'s inbox as the **Destination**, and save.\n\nOn a subdomain, Email Routing forwards only the addresses you add a rule for, with no catch-all. That\'s all a From address needs.\n\n- **Automatic replies arrive too.** Out-of-office and other automatic replies go to your From address, so they reach the publisher\'s inbox. Bounces don\'t: they go to SES\'s return path, and the app handles them as before.\n- **Your DMARC reports need an inbox too.** Your DMARC record\'s report address may not receive mail. If so, change the record\'s `rua` to `mailto:dmarc@send.example.com`, and add a second rule that forwards `dmarc` the same way.\n- **Answers come from the publisher\'s inbox.** A forwarded reply comes from the reader\'s own address. An answer to it goes out from the inbox it was forwarded to, not from `newsletter@send.example.com`.\n\nTo answer from the newsletter\'s own address, give it a real mailbox instead of forwarding it. Point `send.example.com`\'s MX records at a mail host you use, such as [Fastmail](https://www.fastmail.help/hc/en-us/articles/360060591133) or [Google Workspace](https://support.google.com/a/answer/140034). Then create `newsletter@send.example.com` there, as the host\'s docs describe. If you send from it, the host\'s SPF and DKIM records go on `send.example.com` beside SES\'s. SES\'s mail is unaffected, since its SPF is checked at its return path. And if your From address is on a domain that already receives mail, replies already reach it, and you can skip this section.\n\nSome replies ask the publisher to do something, and Kestrel can\'t see them, so the publisher acts on them, as [Sending rules](../publishing/02-sending-rules.md#when-a-reader-asks-to-leave-or-to-be-deleted) describes.\n\n## 4. (Optional) Set a custom MAIL FROM domain\n\nSES sends with its own return path by default, so SPF passes for `amazonses.com` rather than for your hostname. DMARC still passes, through DKIM. A custom MAIL FROM domain makes SPF pass for your hostname too, a second check in your favor.\n\n1. In SES, open the `send.example.com` identity. Under **Custom MAIL FROM domain**, choose **Edit**.\n\n1. Select **Use a custom MAIL FROM domain**, and enter `bounce.send.example.com`. For **Behavior on MX failure**, keep **Use default MAIL FROM domain**. Choose **Save changes**.\n\n1. In Cloudflare, add the two records SES lists. Replace `us-east-1` with your region\'s code from section 1:\n\n - **Type:** `MX`, **Name:** `bounce.send`, **Mail server:** `feedback-smtp.us-east-1.amazonses.com`, **Priority:** `10`\n - **Type:** `TXT`, **Name:** `bounce.send`, **Content:** `v=spf1 include:amazonses.com ~all`\n\n1. Wait until SES shows the MAIL FROM domain as **Successful**.\n\n## 5. Request production access\n\nA new SES account starts in the sandbox. It sends only to addresses you\'ve verified in SES, up to 200 emails a day and one a second. Ask AWS to lift those limits now, and test while you wait.\n\n1. Name the publication first, or ask the publisher for its name, since AWS may visit its website to review the request. In the editor, open **Settings \u2192 Publication identity**, and enter the name and tagline. Until then, the public pages show the name in your From address. The subscribe form stays closed until section 8 sets the provider.\n\n1. In SES, open the **Account dashboard**, select **View Get set up page**, and then **Request production access**.\n\n1. Fill in the request:\n\n - **Mail type:** Marketing\n - **Website URL:** `https://newsletter.example.com/`\n\n1. Check the acknowledgement, and select **Submit request**. AWS Support answers by email, and may ask how you collect addresses and handle bounces.\n\n1. While you wait, verify the two addresses you test with in [Verify it works](05-verify.md#before-you-begin), so the sandbox lets you mail them. For each, under **Identities**, choose **Create identity**, select **Email address**, and follow the link in the email AWS sends.\n\nIf AWS asks for more detail, reply in the support case. If it denies the request, reply there, or open a new request from the **Account dashboard**. Either way, say how Kestrel handles the list:\n\n- Signups use double opt-in: each subscriber confirms by email before joining the list.\n- Every post offers one-click unsubscribe.\n- Bounces and complaints reach the app through SNS, and it stops mailing those addresses automatically.\n- How many subscribers the newsletter expects, and how often it sends.\n\nYour account stays in the sandbox until AWS grants access, so don\'t go live before then. In the sandbox, SES refuses mail to any address you haven\'t verified:\n\n- A signup from an unverified address gets no confirmation email. The app emails the publisher about it, at most once a day.\n- A post goes to your verified addresses and then waits, with the line "The provider is refusing the account" and SES\'s words. No reader is marked unsent. Once AWS grants access, the post goes to the rest at its next retry, mailing no one twice.\n- A daily quota refusal in the sandbox (200 emails a day) means the same thing: request production access rather than a higher quota.\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 8. The rate decides how fast a send goes out, and [As your list grows](../guides/09-as-your-list-grows.md) says when to ask AWS to raise it.\n\n## 6. Send bounces and complaints to SNS\n\nSES reports what happens to each email through SNS, the AWS notification service. A configuration set says which events to report, and an SNS topic carries them to the app.\n\n1. In SES, under **Configuration**, choose **Configuration sets**, then **Create set**. Name it `kestrel-mail`, and choose **Create set**.\n\n1. On the set\'s **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, **Deliveries**, and **Rendering failures**, and choose **Next**. A rendering failure is an email SES took but couldn\'t build. It shouldn\'t happen, and the app logs it as an error if it does.\n\n1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled. Choose **Next**.\n\n1. For **SNS topic**, choose **Create SNS topic**. Make it a **Standard** topic named `kestrel-events`. Choose **Next**, then **Add destination**.\n\n1. Make the set your hostname\'s default, so SES reports on every email sent from it. Open the `send.example.com` identity, and choose the **Configuration set** tab. Under **Default configuration set**, choose **Edit**, select `kestrel-mail`, and save.\n\n1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), and copy its ARN. It looks like `arn:aws:sns:us-east-1:111122223333:kestrel-events`. After `sns` comes your region\'s code, then your 12-digit account ID. The account menu at the top right of the console also shows the account ID.\n\n1. Let SES publish to the topic. Choose **Edit**, expand **Access policy**, and add this statement to the `Statement` list:\n\n ```json\n {\n "Effect": "Allow",\n "Principal": { "Service": "ses.amazonaws.com" },\n "Action": "sns:Publish",\n "Resource": "REPLACE_WITH_TOPIC_ARN",\n "Condition": {\n "StringEquals": {\n "AWS:SourceAccount": "REPLACE_WITH_ACCOUNT_ID",\n "AWS:SourceArn": "arn:aws:ses:us-east-1:REPLACE_WITH_ACCOUNT_ID:configuration-set/kestrel-mail"\n }\n }\n }\n ```\n\n Replace the topic ARN, and both copies of the account ID, with the values from your ARN. Replace `us-east-1` with your region\'s code, the same one the ARN shows. Choose **Save changes**.\n\nYou subscribe the app to the topic in section 9, once it runs on SES.\n\n## 7. Create the app\'s AWS key\n\nThe app sends through an IAM user whose key can only send email. That limits the harm if the key ever leaks.\n\n1. In the [IAM console](https://console.aws.amazon.com/iam/), go to **Users**, and choose **Create user**. Name it `kestrel-sender`, and leave console access off.\n\n1. When asked for permissions, choose **Attach policies directly**, then **Create policy**. The policy editor opens in a new browser tab.\n\n1. In the new tab, choose the **JSON** editor, and replace its contents with this policy:\n\n ```json\n {\n "Version": "2012-10-17",\n "Statement": [\n {\n "Effect": "Allow",\n "Action": ["ses:SendEmail", "ses:SendBulkEmail"],\n "Resource": "*"\n }\n ]\n }\n ```\n\n Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. You can close this tab.\n\n1. Go back to the first tab, where you\'re creating the user. Refresh the policy list, search for `kestrel-send-email`, and select it.\n\n1. Choose **Next**, then **Create user**.\n\n1. Open the user, and on the **Security credentials** tab, choose **Create access key**. For the use case, choose **Application running outside AWS**.\n\n1. Copy the access key ID and the secret access key. AWS shows the secret only once.\n\n## 8. Set the provider and deploy\n\nUntil now the app has run with no email provider connected, refusing to send. Setting `PROVIDER` connects SES. Store the secrets before you set it. With `PROVIDER` set to `ses` and a required secret missing, the app refuses to run, and sends nothing, until you store it. `/health` and the editor\'s **A setting needs fixing** page name the missing secret, and reader pages say the newsletter is temporarily unavailable. `SES_CONFIGURATION_SET` is the one optional secret.\n\n1. Store the access key ID, and paste it at the prompt:\n\n ```bash\n npx wrangler secret put AWS_ACCESS_KEY_ID --env production\n ```\n\n1. Store the secret access key:\n\n ```bash\n npx wrangler secret put AWS_SECRET_ACCESS_KEY --env production\n ```\n\n1. Store the topic ARN from section 6. The app accepts events only from this topic:\n\n ```bash\n npx wrangler secret put SNS_TOPIC_ARN --env production\n ```\n\n1. Store the configuration set\'s name, `kestrel-mail`. It\'s optional but recommended: the app names it on every email, so SES reports on each one even without the default. Type it exactly as the set is named in this region: if SES can\'t find the set, it refuses every email, and a list send is held until the name is fixed or the set created:\n\n ```bash\n npx wrangler secret put SES_CONFIGURATION_SET --env production\n ```\n\n1. In `wrangler.jsonc`, edit the `production` block\'s `vars`. Change `PROVIDER` to `ses` (it\'s `""` on a new instance; add it if your block has none), and add `SES_MAX_SEND_RATE`. Change `AWS_REGION` to your region\'s code from section 1. Your other values stay as they are. The block ends up like this:\n\n ```jsonc\n "vars": {\n "PROVIDER": "ses", // \u2190 set to ses\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1", // \u2190 change: your region\'s code\n "SES_MAX_SEND_RATE": "1" // \u2190 new: your maximum send rate; 1 in the sandbox\n }\n ```\n\n On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan\'s limit, so each minute sends more. Leave it out on Workers Free, which suits a list of up to about 1,000 subscribers. [As your list grows](../guides/09-as-your-list-grows.md) says why.\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## 9. 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\n1. Give SNS longer to retry. By default it retries a failed delivery 3 times over about a minute, then discards the event, so a bounce that arrives while the app is briefly down or mid-deploy is lost, and the address gets your next post. Open the topic, choose **Edit**, and expand **Delivery policy (HTTP/S)**. Clear **Use the default delivery policy**, set these, and choose **Save changes**:\n\n | Field | Value |\n | --- | --- |\n | Number of retries | `13` |\n | Retries without delay | `0` |\n | Minimum delay | `20` |\n | Minimum delay retries | `2` |\n | Maximum delay | `300` |\n | Maximum delay retries | `6` |\n | Retry-backoff function | Exponential |\n\n SNS then keeps trying for most of an hour, the longest it allows. It never retries an event the app refused. The app refuses only an event it can\'t verify came from your topic, and logs an error when one claims to be from SNS.\n\n1. **(Optional)** Keep what SNS still can\'t deliver. On the subscription, choose **Edit**, and set a **Redrive policy (dead-letter queue)** to an SQS queue of your own. SNS then moves an event there instead of discarding it, where you can read it later. [Amazon SNS dead-letter queues](https://docs.aws.amazon.com/sns/latest/dg/sns-dead-letter-queues.html) covers the queue and its permissions.\n\nIf it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:\n\n- **Access covers `/webhooks/ses`.** If your Access application covers more than `/dashboard` and `/api`, SNS gets the login page instead of the app. Remove that path from your Access application, so only `dashboard` and `api` remain.\n- **`SNS_TOPIC_ARN` matches the topic\'s ARN.** If it doesn\'t, the app\'s logs show a `webhook.refused` error naming both.\n- **The deploy in section 8 finished.**\n\nOnce you\'ve fixed the cause, select the subscription, and choose **Request confirmation**. SNS sends the request again, and the app confirms it. SNS doesn\'t let you delete a subscription while it\'s pending, and removes it on its own after 48 hours.\n\n## Switch an instance that already sends\n\nChange `PROVIDER`, or move to another account with the same provider, only while no send is sending. The dashboard lists any send in progress.\n\nWhen a request to a provider gets no answer, Kestrel can\'t know whether its recipients were mailed. Moving to SES, from another provider or another SES account, mails none of them twice. Kestrel never repeats an unanswered request to SES, so those recipients wait for you to decide whether they were mailed. Use **Resolve** on the send\'s page.\n\nKestrel can\'t tell whether those emails went out. SES\'s console shows sent messages only if you\'ve turned on Virtual Deliverability Manager, which this guide doesn\'t set up. So **Assume not sent** is the safe default. Those readers may miss this post, but they get your next one. No one is mailed twice. Choose **Assume sent** only when you know otherwise, such as a reader who says they got it.\n\n## Check it\n\n1. The app answers on your hostname:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The webhook is public, and refuses a request that isn\'t from SNS:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" -X POST https://newsletter.example.com/webhooks/ses\n ```\n\n ```\n 400\n ```\n\n Any other answer, such as a redirect to your Cloudflare login, means Access covers the webhook. Remove that path from your Access application.\n\n1. The SNS subscription shows **Confirmed**.\n\n1. Your From address has somewhere to go. In Cloudflare, your domain\'s DNS records show the ones Email Routing added for `send`, beside section 1\'s DKIM records:\n\n - **Type:** `MX`, **Name:** `send`, three of them, for `route1.mx.cloudflare.net`, `route2.mx.cloudflare.net`, and `route3.mx.cloudflare.net`\n - **Type:** `TXT`, **Name:** `send`, **Content:** `v=spf1 include:_spf.mx.cloudflare.net ~all`\n - **Type:** `TXT`, **Name:** `cf2024-1._domainkey.send`, Cloudflare\'s DKIM key\n\n The world sees the MX records too:\n\n ```bash\n dig +short MX send.example.com\n ```\n\n It lists the three `route` servers. Email Routing\'s **Routing Rules** shows your rule for `newsletter@send.example.com`, and **Destination Addresses** shows the publisher\'s inbox as verified. A reply to your test email in the next step proves the forwarding works.\n\n1. In the editor, **Settings \u2192 Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before.\n\n1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren\'t open yet.\n\nThe test sends in the next step prove the rest. In the sandbox, send them only to the addresses you verified in section 5, and to the SES test addresses that page lists.\n\nSES charges for each email it sends, test emails included. [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists the rates.\n\nNext, [verify it works](05-verify.md).\n';
10487
10548
 
10488
10549
  // docs/get-started/05-verify.md
10489
- 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 the list is empty and every email goes only to you.\n\nIn this guide, you send yourself a test email, reply to it, and check that it passes authentication. Then you set up notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs, check that SES can send to anyone, and clean up the test subscribers.\n\n## Before you begin\n\n- Two email addresses you own, such as a personal address and a work address. Some steps use both. While your SES account is in the sandbox, verify both in SES first, as [Connect Amazon SES](04-ses.md#5-request-production-access) describes.\n- A Gmail address, or another mail client that shows a message's original source and an **Unsubscribe** button.\n\n## 1. Send yourself a test email\n\nA test email goes through the same code as a real send. If the test looks right, the real send looks the same.\n\n1. In the editor at `https://newsletter.example.com/dashboard/`, create a post with a subject, a short body, and an image.\n\n1. Select **Send test email**, enter your address, and send it.\n\n1. Open the email. It should arrive in your inbox, not in spam. The image loads and the subject reads as you wrote it. Its **View in browser** link opens \"Nothing here yet\" until the post goes out, and its unsubscribe link opens the page a subscriber sees, marked as a test and showing a sample address. Its button walks through to the confirmation, but a test email unsubscribes no one. Section 5 unsubscribes for real.\n\n1. Reply to it, as a reader would. Reply from an address other than the inbox your From address forwards to, such as your second address. Gmail doesn't show you a message you sent when it comes back to you. Send that address a test email too if you need one. The reply arrives in the inbox you chose in [Connect Amazon SES](04-ses.md#3-receive-replies-at-your-from-address). The app can't see your forwarding, so this is the only proof that readers can reach the publisher. If the reply bounces, check the routing rule and the destination address there.\n\n## 2. Check that it passes authentication\n\nInboxes trust mail that passes DKIM and DMARC for the domain it's from. Passing both is the best single sign that your mail reaches the inbox.\n\n1. Open the test email's original source. In Gmail, open the message's menu and select **Show original**.\n\n1. Find the `Authentication-Results` header. It shows:\n\n - `dkim=pass` for `send.example.com`\n - `dmarc=pass` for `send.example.com`\n - `spf=pass`, for a hostname under `send.example.com`\n\n With no custom MAIL FROM domain, SPF passes for `amazonses.com` instead. That's expected, and DMARC still passes.\n\nIf a check fails, compare your DNS records with the ones in [Connect Amazon SES](04-ses.md#1-verify-your-sending-hostname). [Sending-domain DNS](../reference/02-sending-domain-dns.md#verify-alignment) explains each result.\n\n## 3. Set up notifications\n\nKestrel emails the notifications address 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\nUse your own address to test. If you're not the publisher, they set theirs in [Check your setup](../publishing/10-check-your-setup.md).\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 the 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. Uncheck **Include in archive**. The post still goes to subscribers, but it's sent as email only: it isn't in the public archive or on the home page. That way the test proves the send without becoming the publication's first archived post, and the dashboard keeps its **Finish setting up** steps for the publisher.\n\n1. Cancel the send, to see that canceling stops it. Then select **Schedule** and **Send now** again, check that **Include in archive** is still unchecked, 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\nIf you verified this instance before Email only existed, the test post is in the archive. Open it under **Sent**, and select **Remove from archive\u2026**.\n\n## 5. Unsubscribe\n\nEvery email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. Both work in one step, with no login.\n\n1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender.\n\n1. Select it. In the editor, **Subscribers** shows the address as **Unsubscribed**, and later sends leave it out.\n\n## 6. Check that bounces and complaints come back\n\nWhen an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address: a bounce suppresses it, and a complaint unsubscribes it. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:\n\n - `bounce@simulator.amazonses.com`, which bounces\n - `complaint@simulator.amazonses.com`, which marks your mail as spam\n\n Each one gets a confirmation email, which bounces or is marked as spam.\n\n1. In the editor, **Subscribers** lists the bounce address as **Pending** with a **bounced** flag, and the complaint address as **Unsubscribed**. Open its row: its page says it left by a complaint. The app mails neither again.\n\nIf the bounce address has no flag, or the complaint address is still pending, SES's events aren't reaching the app. Check the configuration set and the SNS subscription in [Connect Amazon SES](04-ses.md#6-send-bounces-and-complaints-to-sns).\n\n## 7. Read the logs\n\nThe app writes a log line for each thing it does, and Cloudflare keeps them for a few days. Nothing else tells you if the once-a-minute schedule that sends 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\n## 8. Check that SES can send to anyone\n\n[Connect Amazon SES](04-ses.md#5-request-production-access) asked AWS for production access. Until AWS grants it, the sandbox mails only your verified addresses, so check it before you go live.\n\n1. In SES, open the **Account dashboard** in the region the app sends from. It no longer says your account is in the sandbox, and it shows your daily quota and maximum send rate.\n\n1. Check that `SES_MAX_SEND_RATE` in `wrangler.jsonc` matches that rate, and that you deployed after setting it, as [Connect Amazon SES](04-ses.md#8-set-the-provider-and-deploy) describes.\n\nIf the dashboard still says sandbox, AWS hasn't answered yet. Every other section works in the sandbox, so come back to this one when it does.\n\n## 9. Clean up the test subscribers\n\nThe checks leave test addresses on the list. Erase them, so the publisher starts with only real readers.\n\n1. In the editor, open **Subscribers**.\n\n1. Erase `bounce@simulator.amazonses.com` and `complaint@simulator.amazonses.com`. Open each one's menu (\u22EF), and select **Erase**.\n\n1. Erase your second address too, if it won't be a reader.\n\nErasing keeps only the address, so it's never mailed again, but the person can subscribe again through the subscribe page. Erasing your own address doesn't lock you out.\n\nWhen every section gives the result it describes, your instance is ready for subscribers.\n\nNext, [go live](06-go-live.md).\n";
10550
+ 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 the list is empty and every email goes only to you.\n\nIn this guide, you send yourself a test email, reply to it, and check that it passes authentication. Then you set up notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs, check that SES can send to anyone, and clean up the test subscribers.\n\n## Before you begin\n\n- Two email addresses you own, such as a personal address and a work address. Some steps use both. While your SES account is in the sandbox, verify both in SES first, as [Connect Amazon SES](04-ses.md#5-request-production-access) describes.\n- A Gmail address, or another mail client that shows a message's original source and an **Unsubscribe** button.\n\n## 1. Send yourself a test email\n\nA test email goes through the same code as a real send. If the test looks right, the real send looks the same.\n\n1. In the editor at `https://newsletter.example.com/dashboard/`, create a post with a subject, a short body, and an image.\n\n1. Select **Send test email**, enter your address, and send it.\n\n1. Open the email. It should arrive in your inbox, not in spam. The image loads and the subject reads as you wrote it. Its **View in browser** link opens \"Nothing here yet\" until the post goes out, and its unsubscribe link opens the page a subscriber sees, marked as a test and showing a sample address. Its button walks through to the confirmation, but a test email unsubscribes no one. Section 5 unsubscribes for real.\n\n1. Reply to it, as a reader would. Reply from an address other than the inbox your From address forwards to, such as your second address. Gmail doesn't show you a message you sent when it comes back to you. Send that address a test email too if you need one. The reply arrives in the inbox you chose in [Connect Amazon SES](04-ses.md#3-receive-replies-at-your-from-address). The app can't see your forwarding, so this is the only proof that readers can reach the publisher. If the reply bounces, check the routing rule and the destination address there.\n\n## 2. Check that it passes authentication\n\nInboxes trust mail that passes DKIM and DMARC for the domain it's from. Passing both is the best single sign that your mail reaches the inbox.\n\n1. Open the test email's original source. In Gmail, open the message's menu and select **Show original**.\n\n1. Find the `Authentication-Results` header. It shows:\n\n - `dkim=pass` for `send.example.com`\n - `dmarc=pass` for `send.example.com`\n - `spf=pass`, for a hostname under `send.example.com`\n\n With no custom MAIL FROM domain, SPF passes for `amazonses.com` instead. That's expected, and DMARC still passes.\n\nIf a check fails, compare your DNS records with the ones in [Connect Amazon SES](04-ses.md#1-verify-your-sending-hostname). [Sending-domain DNS](../reference/02-sending-domain-dns.md#verify-alignment) explains each result.\n\n## 3. Set up notifications\n\nKestrel emails the notifications address 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\nUse your own address to test. If you're not the publisher, they set theirs in [Check your setup](../publishing/10-check-your-setup.md).\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 the 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. Uncheck **Include in archive**. The post still goes to subscribers, but it's sent as email only: it isn't in the public archive or on the home page. That way the test proves the send without becoming the publication's first archived post, and the dashboard keeps its **Finish setting up** steps for the publisher.\n\n1. Cancel the send, to see that canceling stops it. Then select **Schedule** and **Send now** again, check that **Include in archive** is still unchecked, 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\nIf you verified this instance before Email only existed, the test post is in the archive. Open it under **Sent**, and select **Remove from archive\u2026**.\n\n## 5. Unsubscribe\n\nEvery email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. Both work in one step, with no login.\n\n1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender.\n\n1. Select it. In the editor, **Subscribers** shows the address as **Unsubscribed**, and later sends leave it out.\n\n## 6. Check that bounces and complaints come back\n\nWhen an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address: a bounce suppresses it, and a complaint unsubscribes it. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:\n\n - `bounce@simulator.amazonses.com`, which bounces\n - `complaint@simulator.amazonses.com`, which marks your mail as spam\n\n Each one gets a confirmation email, which bounces or is marked as spam.\n\n1. In the editor, **Subscribers** lists the bounce address as **Pending** with a **bounced** flag, and the complaint address as **Unsubscribed**. Open its row: its page says it left by a complaint. The app mails neither again.\n\nIf the bounce address has no flag, or the complaint address is still pending, SES's events aren't reaching the app. Check the configuration set and the SNS subscription in [Connect Amazon SES](04-ses.md#6-send-bounces-and-complaints-to-sns).\n\n## 7. Read the logs\n\nThe app writes a log line for each thing it does, and Cloudflare keeps them for a few days, depending on your plan. Nothing else tells you if the once-a-minute schedule that sends 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 `event` equal to `send.scheduled`. Your two sends from section 4 are there, each with `act` equal to `send_now`, `principal` equal to `human`, and `client` equal to `editor`, and `send.canceled` shows the one you canceled. Each line that records an action, such as `send.canceled` or `import.committed`, says who took it the same way: `principal` is `human` for a person or `service` for a service token, and `client` names the program a person used. A service token's lines have no `client`, and neither does a person's through a program that doesn't name itself.\n\n1. Filter on `principal` equal to `service` to see what automation changed. On a new instance it finds nothing. Filter on `client` equal to `claude` or `claude-code` to see what Claude changed.\n\n1. Filter on `level` equal to `error`. A healthy instance shows nothing.\n\nIf you search a long time range, Cloudflare may show only a sample of the matching lines. To follow one send, narrow the range to a few hours and filter on its `sendId`. On the Free plan, Cloudflare stops keeping logs for the rest of the day once the day's allowance is used up, so a very large send can leave the rest of that day unlogged. [Cloudflare's pricing page](https://developers.cloudflare.com/observability/pricing/) has the current limits and how long each plan keeps logs.\n\n## 8. Check that SES can send to anyone\n\n[Connect Amazon SES](04-ses.md#5-request-production-access) asked AWS for production access. Until AWS grants it, the sandbox mails only your verified addresses, so check it before you go live.\n\n1. In SES, open the **Account dashboard** in the region the app sends from. It no longer says your account is in the sandbox, and it shows your daily quota and maximum send rate.\n\n1. Check that `SES_MAX_SEND_RATE` in `wrangler.jsonc` matches that rate, and that you deployed after setting it, as [Connect Amazon SES](04-ses.md#8-set-the-provider-and-deploy) describes.\n\nIf the dashboard still says sandbox, AWS hasn't answered yet. Every other section works in the sandbox, so come back to this one when it does.\n\n## 9. Clean up the test subscribers\n\nThe checks leave test addresses on the list. Erase them, so the publisher starts with only real readers.\n\n1. In the editor, open **Subscribers**.\n\n1. Erase `bounce@simulator.amazonses.com` and `complaint@simulator.amazonses.com`. Open each one's menu (\u22EF), and select **Erase**.\n\n1. Erase your second address too, if it won't be a reader.\n\nErasing keeps only the address, so it's never mailed again, but the person can subscribe again through the subscribe page. Erasing your own address doesn't lock you out.\n\nWhen every section gives the result it describes, your instance is ready for subscribers.\n\nNext, [go live](06-go-live.md).\n";
10490
10551
 
10491
10552
  // docs/get-started/06-go-live.md
10492
10553
  var go_live_default = "# Go live\n\nAt this point, your instance should be deployed, verified, and out of the SES sandbox, so it can send to anyone. From here, the publisher can set up the publication, and you can continue with the guides below.\n\n## Start publishing\n\nThe rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md).\n\nIf someone else is the publisher, let them sign in first:\n\n1. Add their email address to the `Publishers` policy on the Access application, as in [Allow yourself in](03-access.md#3-allow-yourself-in). If you set `ACCESS_ALLOWED_EMAILS`, store the list again with their address added.\n\n1. If they don't have a Cloudflare account, add another way to sign in, such as a one-time code by email. It's under **Zero Trust \u2192 Integrations \u2192 Identity providers**.\n\n1. Send them the editor's address and a link to [Name your publication](../publishing/03-name-your-publication.md).\n\n## Optional next steps\n\nEach of these guides adds one thing, and you can come back to any of them later.\n\n- [Allow Claude to connect](../guides/01-allow-claude.md), so the publisher can connect Claude Desktop, claude.ai, or Claude Code, and have it draft, proofread, and schedule posts as them, through the same API the editor uses.\n- [Automate with a service token](../guides/02-automate.md), so a script can call the API with a service token, when no one is signed in.\n- [Notifications through Cloudflare's email](../guides/03-notifications.md), so problems are reported even when the email provider has stopped sending.\n- [Rate-limit the subscribe form](../guides/05-rate-limit.md), before the subscribe link is shared widely.\n- [As your list grows](../guides/09-as-your-list-grows.md), from about 1,000 subscribers.\n- [Upgrade to a new release](../guides/07-upgrade.md), when one comes out.\n";
@@ -10543,7 +10604,7 @@ var README_default = "# Publishing with Kestrel\n\nThese pages get you publishin
10543
10604
  var README_default2 = "# Self-hosting Kestrel\n\nThese pages cover deploying and running your own Kestrel, from an empty repository to a live newsletter on your Cloudflare account. Writing and sending the newsletter is covered in [Publishing](publishing/README.md). These pages call whoever writes and sends it the publisher, which may be you.\n\n## Get started\n\nThese are the steps to deploy a Kestrel instance and get it ready to send. Each one ends with a check, so you know it worked before you move on.\n\n1. [Deploy the app](get-started/02-deploy.md)\n1. [Lock the dashboard with Access](get-started/03-access.md)\n1. [Connect Amazon SES](get-started/04-ses.md)\n1. [Verify it works](get-started/05-verify.md)\n1. [Go live](get-started/06-go-live.md)\n\n## Guides\n\nEach one stands on its own. Pick the ones you need.\n\n- [Allow Claude to connect](guides/01-allow-claude.md)\n- [Automate with a service token](guides/02-automate.md)\n- [Notifications through Cloudflare's email](guides/03-notifications.md)\n- [Rate-limit the subscribe form](guides/05-rate-limit.md)\n- [As your list grows](guides/09-as-your-list-grows.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: SPF, DKIM, and DMARC](reference/02-sending-domain-dns.md)\n";
10544
10605
 
10545
10606
  // docs/reference/01-configuration.md
10546
- 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\nThis is the `production` block a new instance starts with. [Deploy the app](../get-started/02-deploy.md) fills in its database and hostnames:\n\n```jsonc\n"production": {\n "name": "kestrel-production",\n "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }],\n "workers_dev": false,\n "preview_urls": false,\n "triggers": { "crons": ["* * * * *"] },\n "observability": { "enabled": true },\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "REPLACE_WITH_PRODUCTION_D1_ID",\n "migrations_dir": "migrations"\n }\n ],\n "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "kestrel-media-production" }],\n "vars": {\n "PROVIDER": "",\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1"\n }\n}\n```\n\n## Vars\n\nVars go in the environment block\'s `vars`. Every value is a string.\n\n- `PROVIDER` optional\n\n The email provider: `ses`, or `fake` for local development. Default: none, so no provider is connected. Refused unless it\'s exactly `ses`, `fake`, or `none`: a misspelling, or `SES`, is never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).\n\n Until it\'s set, or while it\'s empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren\'t open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance\'s `production` block declares it empty, so wrangler doesn\'t warn that the top level\'s value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.\n\n `fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`, since on a real hostname it would open signups whose confirmations go nowhere.\n\n- `ALLOW_FAKE_PROVIDER` optional\n\n `true` lets `fake` run on a real hostname, for a hosted demo that mails no one on purpose. No other deployment needs it. With `fake`, it must be `true` when set; every other provider ignores it.\n\n- `APP_ORIGIN` required\n\n The origin the app is served from: scheme and host, no path. Refused unless it\'s a plain `http` or `https` origin with no path, and never on `example.com` or a name under it, unless `PROVIDER` is `fake`. A trailing slash is dropped.\n\n- `AWS_REGION` required with SES\n\n The SES region. The template sets `us-east-1`.\n\n- `FROM_ADDRESS` required with a real provider\n\n The `From:` of every email, and the identity your provider authenticates. Refused unless it\'s an email address, optionally with a display name in front, like `Name <address>`, and never on `example.com`, unless `PROVIDER` is `fake`.\n\n It isn\'t the 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, the emails and reader pages show no name at all.\n\n- `SENDING_DOMAIN` optional\n\n The sending domain, shown in Settings beside the From address. The app never checks that it matches `FROM_ADDRESS`, so set it to the part of `FROM_ADDRESS` after the `@`. Never on `example.com`, unless `PROVIDER` is `fake`.\n\n- `ARCHIVE_BASE_PATH` optional\n\n The path the archive lives under. Default: `/archive`. A missing leading slash is added, and a trailing slash is dropped.\n\n A listed post\'s archive address is `ARCHIVE_BASE_PATH` followed by the post\'s slug. Emails link through each post\'s unlisted link, `/email/<token>` on `APP_ORIGIN`, which forwards to the archive address, so changing the prefix later breaks no email that carries one, only archive addresses people have shared. Emails sent by 1.11 or earlier carry the archive address itself, and changing the prefix breaks those. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\n- `SES_MAX_SEND_RATE` optional\n\n Your SES account\'s maximum send rate, in messages a second. Default: `14`, a common starting rate, not your account\'s. Refused unless it\'s a whole number above zero.\n\n The app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**: `1` in the sandbox, and whatever AWS grants after that. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. A 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` optional\n\n How many subrequests one minute\'s send work may use, which also tells the app which Workers plan it\'s on. Default: `50`, the Workers Free limit, and safe on any plan. Refused unless it\'s a whole number above zero. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n Cloudflare 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. This setting is the subrequest cap. The app keeps database queries under theirs on its own.\n\n - At the default, a minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.\n - On Workers Paid, which allows 10,000, set `"SUBREQUEST_BUDGET": "10000"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.\n - If you raised the Worker\'s own limit with `limits.subrequests`, you may set this to match.\n\n Never 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.\n\n At the default, the app runs on Workers Free\'s limits. One import takes up to 1,000 rows, since Free\'s 10 ms of CPU a request can\'t plan and commit more, and an export reads 1,000 rows a page. Above it, both take 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)). Workers Free suits a list of up to about 1,000 subscribers. Past about 5,000, one send can use up the day\'s database writes and stop the newsletter until midnight UTC. So the Dashboard and the Subscribers screen recommend Workers Paid once the confirmed list passes 1,000, and so does an import plan that would take it past. The app also warns before a send to more than 5,000 recipients ([As your list grows](../guides/09-as-your-list-grows.md)). None of these stops you. Above the default, none appears, so set it on an account already on Workers Paid.\n\n- `MIN_LEAD_SECONDS` optional\n\n The least time every send stays cancelable before it goes out, whether it\'s scheduled, sent now, or moved. Default: `300`, five minutes. Refused unless it\'s a whole number from 60 to 86400, one day.\n\n It\'s the window to catch a mistake, including one in a send Claude prepared. The floor is 60, since sends go out from a once-a-minute schedule. It\'s the least wait before every send, not a limit on how far ahead a post can be scheduled. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.\n\n- `ARCHIVE_ORIGIN` optional\n\n The origin archive links use, to put the archive on your website. Default: `APP_ORIGIN`. Refused unless it\'s a plain `http` or `https` origin with no path, and never on `example.com`, unless `PROVIDER` is `fake`. A trailing slash is dropped.\n\n The app doesn\'t answer on that origin 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. Crawlers read your website\'s own `robots.txt` for the archive pages there. The app\'s `/llms.txt` stays on the app\'s hostname, and its archive link points at your website.\n\n- `MEDIA_PUBLIC_BASE` optional\n\n The base URL images are served from, such as a custom domain on the image bucket. Default: `APP_ORIGIN` followed by `/media`. Refused unless it\'s a plain `http` or `https` URL, which may have a path, and never on `example.com`, unless `PROVIDER` is `fake`. A trailing slash is dropped.\n\n 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` optional\n\n The sender of notifications through Cloudflare\'s email. Default: `kestrel@` on the app\'s hostname, named Kestrel, such as `Kestrel <kestrel@newsletter.example.com>`. It\'s used only with the `NOTIFY` binding, and must be an address on a domain set up for Cloudflare\'s email.\n\n## Secrets\n\nStore each with `npx wrangler secret put NAME --env production`, which prompts for the value.\n\n- `ACCESS_TEAM_DOMAIN` required\n\n Your Zero Trust team domain, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in. [Lock the dashboard with Access](../get-started/03-access.md#4-give-the-app-its-two-values) shows where to find it.\n\n- `ACCESS_AUD` required\n\n The Access application\'s audience tag. It\'s set together with `ACCESS_TEAM_DOMAIN` 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\n- `ACCESS_ALLOWED_EMAILS` optional\n\n A comma-separated list of the people allowed to sign in. Without it, the app lets in anyone the Access policy allows, so it keeps the editor limited to named people if the policy is widened later. It applies to people only. A service token, the credential a script uses, carries no email, so the Access policy alone decides whether it gets in.\n\n- `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` required with SES\n\n The IAM user\'s access key, which sends through SES.\n\n- `SNS_TOPIC_ARN` required with SES\n\n The SNS topic the SES webhook accepts. The webhook refuses a report from every other topic, and logs `webhook.refused` naming both.\n\n- `SES_CONFIGURATION_SET` optional, recommended with SES\n\n The configuration set named on every email, so SES reports bounces and complaints. A name SES can\'t find in the region holds every list send until it\'s fixed.\n\n- `DEV_AUTH_SECRET` never on a deployed environment\n\n Local development\'s stand-in for Access. 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- `name` required\n\n The Worker\'s name, such as `kestrel-production`.\n\n- `routes` required\n\n The app\'s hostname, attached on deploy, and any route on your website. Each deploy replaces the Worker\'s routes with the ones in the file, so add a route here, never in the dashboard.\n\n- `workers_dev` and `preview_urls` optional\n\n The Worker\'s `workers.dev` and preview addresses. The template sets both to `false`, so the app answers only on your hostname. That\'s where your Access application and any rate-limiting rule apply.\n\n- `triggers.crons` required\n\n The once-a-minute schedule, `* * * * *`. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.\n\n- `observability` optional\n\n Workers Logs, where the app\'s log lines land. On in the template. Every invocation is logged, with no sampling, since sampling could drop the one minute that reports a stuck or missed send.\n\n- `DB` required\n\n The `d1_databases` binding for the database: posts, subscribers, sends, and settings.\n\n- `MEDIA` required\n\n The `r2_buckets` binding for the bucket of images and your logo.\n\n- `NOTIFY` optional\n\n The `send_email` binding for Cloudflare\'s email. When declared, notifications go through it instead of your email provider, so they still arrive when your provider refuses your account. Without it, notifications come from `FROM_ADDRESS`, through your provider. 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, and each setting above says what it refuses. In local development, `SIMULATE_SENDS` must also be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn\'t matter. A deployed instance ignores it.\n\nUntil it\'s fixed, no sends go out, and each place says what its reader can act on:\n\n- **The editor** shows a page naming the setting, what\'s wrong with it, and how to fix it, with a link to its section here. Its own Docs tab can\'t load until then, so the link opens the project\'s site.\n- **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{"error":"invalid_config","message":\u2026,"variable":"APP_ORIGIN"}`.\n- **The public pages readers see** answer `503` with a page saying the newsletter is temporarily unavailable. It names no setting.\n- **The Worker\'s log** records it as `config.invalid`.\n';
10607
+ 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\nThis is the `production` block a new instance starts with. [Deploy the app](../get-started/02-deploy.md) fills in its database and hostnames:\n\n```jsonc\n"production": {\n "name": "kestrel-production",\n "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }],\n "workers_dev": false,\n "preview_urls": false,\n "triggers": { "crons": ["* * * * *"] },\n "observability": { "enabled": true },\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "REPLACE_WITH_PRODUCTION_D1_ID",\n "migrations_dir": "migrations"\n }\n ],\n "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "kestrel-media-production" }],\n "vars": {\n "PROVIDER": "",\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1"\n }\n}\n```\n\n## Vars\n\nVars go in the environment block\'s `vars`. Every value is a string.\n\n- `PROVIDER` optional\n\n The email provider: `ses`, or `fake` for local development. Default: none, so no provider is connected. Refused unless it\'s exactly `ses`, `fake`, or `none`: a misspelling, or `SES`, is never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).\n\n Until it\'s set, or while it\'s empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren\'t open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance\'s `production` block declares it empty, so wrangler doesn\'t warn that the top level\'s value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.\n\n `fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`, since on a real hostname it would open signups whose confirmations go nowhere.\n\n- `ALLOW_FAKE_PROVIDER` optional\n\n `true` lets `fake` run on a real hostname, for a hosted demo that mails no one on purpose. No other deployment needs it. With `fake`, it must be `true` when set; every other provider ignores it.\n\n- `APP_ORIGIN` required\n\n The origin the app is served from: scheme and host, no path. Refused unless it\'s a plain `http` or `https` origin with no path, and never on `example.com` or a name under it, unless `PROVIDER` is `fake`. A trailing slash is dropped.\n\n- `AWS_REGION` required with SES\n\n The SES region. The template sets `us-east-1`.\n\n- `FROM_ADDRESS` required with a real provider\n\n The `From:` of every email, and the identity your provider authenticates. Refused unless it\'s an email address, optionally with a display name in front, like `Name <address>`, and never on `example.com`, unless `PROVIDER` is `fake`.\n\n It isn\'t the 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, the emails and reader pages show no name at all.\n\n- `SENDING_DOMAIN` optional\n\n The sending domain, shown in Settings beside the From address. The app never checks that it matches `FROM_ADDRESS`, so set it to the part of `FROM_ADDRESS` after the `@`. Never on `example.com`, unless `PROVIDER` is `fake`.\n\n- `ARCHIVE_BASE_PATH` optional\n\n The path the archive lives under. Default: `/archive`. A missing leading slash is added, and a trailing slash is dropped.\n\n A listed post\'s archive address is `ARCHIVE_BASE_PATH` followed by the post\'s slug. Emails link through each post\'s unlisted link, `/email/<token>` on `APP_ORIGIN`, which forwards to the archive address, so changing the prefix later breaks no email that carries one, only archive addresses people have shared. Emails sent by 1.11 or earlier carry the archive address itself, and changing the prefix breaks those. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\n- `SES_MAX_SEND_RATE` optional\n\n Your SES account\'s maximum send rate, in messages a second. Default: `14`, a common starting rate, not your account\'s. Refused unless it\'s a whole number above zero.\n\n The app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**: `1` in the sandbox, and whatever AWS grants after that. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. A 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` optional\n\n How many subrequests one minute\'s send work may use, which also tells the app which Workers plan it\'s on. Default: `50`, the Workers Free limit, and safe on any plan. Refused unless it\'s a whole number above zero. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n Cloudflare 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. This setting is the subrequest cap. The app keeps database queries under theirs on its own.\n\n - At the default, a minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.\n - On Workers Paid, which allows 10,000, set `"SUBREQUEST_BUDGET": "10000"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.\n - If you raised the Worker\'s own limit with `limits.subrequests`, you may set this to match.\n\n Never 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.\n\n At the default, the app runs on Workers Free\'s limits. One import takes up to 1,000 rows, since Free\'s 10 ms of CPU a request can\'t plan and commit more, and an export reads 1,000 rows a page. Above it, both take 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)). Workers Free suits a list of up to about 1,000 subscribers. Past about 5,000, one send can use up the day\'s database writes and stop the newsletter until midnight UTC. So the Dashboard and the Subscribers screen recommend Workers Paid once the confirmed list passes 1,000, and so does an import plan that would take it past. The app also warns before a send to more than 5,000 recipients ([As your list grows](../guides/09-as-your-list-grows.md)). None of these stops you. Above the default, none appears, so set it on an account already on Workers Paid.\n\n- `MIN_LEAD_SECONDS` optional\n\n The least time every send stays cancelable before it goes out, whether it\'s scheduled, sent now, or moved. Default: `300`, five minutes. Refused unless it\'s a whole number from 60 to 86400, one day.\n\n It\'s the window to catch a mistake, including one in a send Claude prepared. The floor is 60, since sends go out from a once-a-minute schedule. It\'s the least wait before every send, not a limit on how far ahead a post can be scheduled. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.\n\n- `ARCHIVE_ORIGIN` optional\n\n The origin archive links use, to put the archive on your website. Default: `APP_ORIGIN`. Refused unless it\'s a plain `http` or `https` origin with no path, and never on `example.com`, unless `PROVIDER` is `fake`. A trailing slash is dropped.\n\n The app doesn\'t answer on that origin 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. Crawlers read your website\'s own `robots.txt` for the archive pages there. The app\'s `/llms.txt` stays on the app\'s hostname, and its archive link points at your website.\n\n- `MEDIA_PUBLIC_BASE` optional\n\n The base URL images are served from, such as a custom domain on the image bucket. Default: `APP_ORIGIN` followed by `/media`. Refused unless it\'s a plain `http` or `https` URL, which may have a path, and never on `example.com`, unless `PROVIDER` is `fake`. A trailing slash is dropped.\n\n 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` optional\n\n The sender of notifications through Cloudflare\'s email. Default: `kestrel@` on the app\'s hostname, named Kestrel, such as `Kestrel <kestrel@newsletter.example.com>`. It\'s used only with the `NOTIFY` binding, and must be an address on a domain set up for Cloudflare\'s email.\n\n## Secrets\n\nStore each with `npx wrangler secret put NAME --env production`, which prompts for the value.\n\n- `ACCESS_TEAM_DOMAIN` required\n\n Your Zero Trust team domain, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in. [Lock the dashboard with Access](../get-started/03-access.md#4-give-the-app-its-two-values) shows where to find it.\n\n- `ACCESS_AUD` required\n\n The Access application\'s audience tag. It\'s set together with `ACCESS_TEAM_DOMAIN` 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\n- `ACCESS_ALLOWED_EMAILS` optional\n\n A comma-separated list of the people allowed to sign in. Without it, the app lets in anyone the Access policy allows, so it keeps the editor limited to named people if the policy is widened later. It applies to people only. A service token, the credential a script uses, carries no email, so the Access policy alone decides whether it gets in.\n\n- `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` required with SES\n\n The IAM user\'s access key, which sends through SES.\n\n- `SNS_TOPIC_ARN` required with SES\n\n The SNS topic the SES webhook accepts. The webhook refuses a report from every other topic, and logs `webhook.refused` naming both.\n\n- `SES_CONFIGURATION_SET` optional, recommended with SES\n\n The configuration set named on every email, so SES reports bounces and complaints. A name SES can\'t find in the region holds every list send until it\'s fixed.\n\n- `DEV_AUTH_SECRET` never on a deployed environment\n\n Local development\'s stand-in for Access. 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- `name` required\n\n The Worker\'s name, such as `kestrel-production`.\n\n- `routes` required\n\n The app\'s hostname, attached on deploy, and any route on your website. Each deploy replaces the Worker\'s routes with the ones in the file, so add a route here, never in the dashboard.\n\n- `workers_dev` and `preview_urls` optional\n\n The Worker\'s `workers.dev` and preview addresses. The template sets both to `false`, so the app answers only on your hostname. That\'s where your Access application and any rate-limiting rule apply.\n\n- `triggers.crons` required\n\n The once-a-minute schedule, `* * * * *`. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.\n\n- `observability` optional\n\n Workers Logs, where the app\'s log lines land. On in the template. Kestrel sets no sampling, since sampling could drop the one minute that reports a stuck or missed send, so leave `head_sampling_rate` at its default of 1. Cloudflare\'s own daily limit on the Free plan can still stop new logs for the rest of the day, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes.\n\n- `DB` required\n\n The `d1_databases` binding for the database: posts, subscribers, sends, and settings.\n\n- `MEDIA` required\n\n The `r2_buckets` binding for the bucket of images and your logo.\n\n- `NOTIFY` optional\n\n The `send_email` binding for Cloudflare\'s email. When declared, notifications go through it instead of your email provider, so they still arrive when your provider refuses your account. Without it, notifications come from `FROM_ADDRESS`, through your provider. 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, and each setting above says what it refuses. In local development, `SIMULATE_SENDS` must also be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn\'t matter. A deployed instance ignores it.\n\nUntil it\'s fixed, no sends go out, and each place says what its reader can act on:\n\n- **The editor** shows a page naming the setting, what\'s wrong with it, and how to fix it, with a link to its section here. Its own Docs tab can\'t load until then, so the link opens the project\'s site.\n- **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{"error":"invalid_config","message":\u2026,"variable":"APP_ORIGIN"}`.\n- **The public pages readers see** answer `503` with a page saying the newsletter is temporarily unavailable. It names no setting.\n- **The Worker\'s log** records it as `config.invalid`.\n';
10547
10608
 
10548
10609
  // docs/reference/02-sending-domain-dns.md
10549
10610
  var sending_domain_dns_default = "# Sending-domain DNS: SPF, DKIM, and DMARC\n\nInboxes decide whether to trust your mail by checking records in the DNS of the domain it comes from. This page explains the three checks they run: SPF, DKIM, and DMARC. It covers the records [Connect Amazon SES](../get-started/04-ses.md) has you add, what each proves, how to check them, and when to tighten DMARC.\n\n## Why all three\n\nGmail requires every sender to pass SPF or DKIM. A sender of more than 5,000 messages a day to Gmail must have all three:\n\n- SPF and DKIM, both passing.\n- A DMARC record, with a policy of at least `p=none`.\n- Alignment: the domain in the `From:` header matches the SPF domain or the DKIM domain.\n\nGmail also requires one-click unsubscribe for such senders. Kestrel adds the `List-Unsubscribe` and `List-Unsubscribe-Post` headers to every email, so that part needs nothing from you. [Google's sender guidelines](https://support.google.com/a/answer/81126) list the rest.\n\nA small list may never reach 5,000 a day, but the same checks decide whether mail lands in the inbox. Make sure all three pass from the start.\n\n## Choose your sending name\n\n- **Send from a subdomain.** It's recommended to send from a subdomain such as `send.example.com`, rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn't put that at risk.\n- **Keep the app's name and the mail's name distinct.** The app and its public pages live at `newsletter.example.com`, and mail comes from `send.example.com`. Avoid `mail.` as the sending name, since mail servers read `mail.example.com` as a host that receives mail.\n\n## The records\n\nEvery record lives in your domain's DNS, `example.com`, under the sending name. In Cloudflare, a record's name leaves off your domain, so `_dmarc.send.example.com` is entered as `_dmarc.send`.\n\nSES generates the DKIM values. Copy them exactly as SES lists them, since they differ by account and region. You write the DMARC record yourself.\n\nSPF needs no record of yours on SES's default path, since it passes for a subdomain of `amazonses.com`. An SPF record, at `bounce.send`, comes only with the optional custom MAIL FROM domain.\n\nEmail Routing adds MX, SPF, and DKIM records at `send` itself, the DKIM one under the selector `cf2024-1`. They're for mail sent *to* your From address, so readers' replies reach the publisher ([Connect Amazon SES](../get-started/04-ses.md#3-receive-replies-at-your-from-address)). They don't affect your sending: inboxes check SES's mail for SPF at its return path, and for DKIM against SES's own keys.\n\n### SPF: authorize the return path\n\nSPF checks the message's return path, the address bounces go back to, not its `From:` header. DMARC passes when SPF or DKIM passes and aligns with the `From:` domain. DKIM aligns, so an aligned SPF is a second check in your favor, not a requirement.\n\nSES uses a subdomain of its own domain, `amazonses.com`, as the return path by default. SPF then passes for it but doesn't align, and DMARC passes through DKIM alone. A custom MAIL FROM domain, such as `bounce.send.example.com`, aligns SPF too. [Connect Amazon SES](../get-started/04-ses.md#4-optional-set-a-custom-mail-from-domain) sets one up, with an MX record that names your region and an SPF record:\n\n```\nbounce.send.example.com. MX 10 feedback-smtp.us-east-1.amazonses.com.\nbounce.send.example.com. TXT \"v=spf1 include:amazonses.com ~all\"\n```\n\nA name holds one SPF record. If one already exists there, add `include:amazonses.com` to it rather than adding a second.\n\n### DKIM: let the provider sign\n\nDKIM signs each message with a key your DNS publishes. An inbox checks the signature to tell the message came from you, unchanged.\n\nSES lists three `CNAME` records, each at `TOKEN._domainkey.send.example.com`. Their targets depend on your region and account, so copy them as SES lists them.\n\nIn Cloudflare, set every `CNAME` for DKIM to **DNS only**, not proxied. SES reports the domain as verified only once the records resolve.\n\n### DMARC: publish a policy and collect reports\n\nDMARC tells an inbox what to do with mail that fails both SPF and DKIM, and asks it to send you reports. It's a `TXT` record at `_dmarc.send.example.com`:\n\n```\n_dmarc.send.example.com. TXT \"v=DMARC1; p=none; rua=mailto:dmarc@example.com\"\n```\n\n- `p=none` is monitor mode. Inboxes report on failing mail, but deliver it as usual.\n- `rua` is where reports go. They arrive as attachments, usually one a day from each inbox provider.\n\nLeave alignment relaxed, the default, with no `aspf=s` or `adkim=s`. With a custom MAIL FROM domain, your return path is a subdomain of your sending name, so strict SPF alignment would fail.\n\nOnce the reports show your sends passing and aligned, tighten the policy to `p=quarantine`, which sends failing mail to spam. Later, tighten it to `p=reject`, which refuses it.\n\n## Verify alignment\n\nA test email proves alignment better than any DNS lookup. Send yourself one, as in [Verify it works](../get-started/05-verify.md#2-check-that-it-passes-authentication), and read its `Authentication-Results` header. In Gmail, open the message's menu, and select **Show original**. Expect:\n\n- `dkim=pass` for `send.example.com`.\n- `dmarc=pass` for `send.example.com`.\n- `spf=pass`. With a custom MAIL FROM domain, its domain is under `send.example.com`. Without one, it's a subdomain of `amazonses.com`. That's expected, and DMARC still passes.\n";
@@ -11771,7 +11832,7 @@ async function commit(c) {
11771
11832
  }
11772
11833
  log.info("import.committed", {
11773
11834
  importId: result2.import.id,
11774
- principal: principal.kind,
11835
+ ...whoActed(principal),
11775
11836
  rows: p.rowCount,
11776
11837
  confirmed: result2.counts.added_confirmed + result2.counts.confirmed_pending,
11777
11838
  unsubscribed: result2.counts.unsubscribed + result2.counts.erased + result2.counts.added_unsubscribed,
@@ -15016,6 +15077,14 @@ async function resolveStuckSend(env, sendId, outcome, by) {
15016
15077
  } else {
15017
15078
  await releaseLease(env.DB, sendId, lease);
15018
15079
  }
15080
+ log.warn("send.resolved", {
15081
+ sendId,
15082
+ postId: send.post_id,
15083
+ resolution: outcome,
15084
+ resolved,
15085
+ completed,
15086
+ ...whoActed(by)
15087
+ });
15019
15088
  return { resolved, completed };
15020
15089
  }
15021
15090
  function noteName(by) {
@@ -15408,7 +15477,12 @@ async function erase3(c) {
15408
15477
  { subscriber: subscriber && await withSuppression(c.env.DB, subscriber) }
15409
15478
  );
15410
15479
  }
15411
- log.info("subscriber.erased", { deliveries: outcome.deliveries });
15480
+ if (!outcome.unchanged) {
15481
+ log.info("subscriber.erased", {
15482
+ deliveries: outcome.deliveries,
15483
+ ...whoActed(unwrap(c.principal, "principal"))
15484
+ });
15485
+ }
15412
15486
  const body = {
15413
15487
  subscriber: await withSuppression(c.env.DB, outcome.subscriber),
15414
15488
  deliveries: outcome.deliveries
@@ -15447,7 +15521,11 @@ async function add(c) {
15447
15521
  const detail = optString(body, "detail");
15448
15522
  const out = await block(c.env.DB, email2, detail);
15449
15523
  if (out.added) {
15450
- log.info("suppression.added", { source: "manual", count: 1 });
15524
+ log.info("suppression.added", {
15525
+ source: "manual",
15526
+ count: 1,
15527
+ ...whoActed(unwrap(c.principal, "principal"))
15528
+ });
15451
15529
  }
15452
15530
  return json(out, out.added ? 201 : 200);
15453
15531
  }
@@ -16326,7 +16404,7 @@ function createRouter({
16326
16404
  resource: "posts",
16327
16405
  title: "Upload an image",
16328
16406
  summary: "Upload an image to a draft, as a form or the raw bytes.",
16329
- description: "The post's Markdown references the image by the `filename` the response gives, `![a short description](cover.jpg)`, and the render resolves that name to the stored image.",
16407
+ description: "The post's Markdown references the image by the `filename` the response gives, `![a short description](cover.jpg)`, never by its `url`, and the render resolves that name to the stored image. A reference by `url` warns in the preview. Uploading the same `filename` again replaces the image.",
16330
16408
  query: [
16331
16409
  {
16332
16410
  name: "filename",
@@ -16356,6 +16434,17 @@ function createRouter({
16356
16434
  }
16357
16435
  ]
16358
16436
  },
16437
+ example: {
16438
+ response: {
16439
+ image: {
16440
+ filename: "cover.jpg",
16441
+ content_type: "image/jpeg",
16442
+ width: 1200,
16443
+ height: 800,
16444
+ url: "https://newsletter.example.com/media/posts/p_abc123/cover.jpg"
16445
+ }
16446
+ }
16447
+ },
16359
16448
  handler: uploadImage
16360
16449
  },
16361
16450
  {
@@ -175,6 +175,14 @@ npx wrangler deploy --env production
175
175
 
176
176
  This 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.
177
177
 
178
+ Always name `--env production`, here and on every wrangler command that stores a secret. Without it, wrangler uses the top level, which is local development. A deploy then creates a second Worker named `kestrel`, which runs with an email stand-in that delivers nothing and a sign-in meant only for your machine. The top level turns off every address it could answer on and the schedule that sends posts, so no one can reach it and it never sends, but it does nothing useful. If you deployed without `--env production`, remove that Worker and deploy again with it:
179
+
180
+ ```bash
181
+ npx wrangler delete --name kestrel
182
+ ```
183
+
184
+ Leave the top level's `"workers_dev": false`, `"preview_urls": false`, and `"triggers": { "crons": [] }` as they are, and never add a route there: they are what keep a deploy like that inert. Keep the empty `crons` rather than deleting the line, since only an empty list clears a schedule an earlier deploy left.
185
+
178
186
  ## Check it
179
187
 
180
188
  1. The app answers on your hostname. A new hostname can take a few minutes to get its certificate.
@@ -92,7 +92,7 @@ If the bounce address has no flag, or the complaint address is still pending, SE
92
92
 
93
93
  ## 7. Read the logs
94
94
 
95
- The 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 posts stops, so it's worth knowing where to look.
95
+ The app writes a log line for each thing it does, and Cloudflare keeps them for a few days, depending on your plan. Nothing else tells you if the once-a-minute schedule that sends posts stops, so it's worth knowing where to look.
96
96
 
97
97
  1. In the Cloudflare dashboard, go to **Workers & Pages**, select `kestrel-production`, and select **Observability**.
98
98
 
@@ -100,8 +100,14 @@ The app writes a log line for each thing it does, and Cloudflare keeps them for
100
100
 
101
101
  1. Filter on `event` equal to `send.completed`. Your send from section 4 is there.
102
102
 
103
+ 1. Filter on `event` equal to `send.scheduled`. Your two sends from section 4 are there, each with `act` equal to `send_now`, `principal` equal to `human`, and `client` equal to `editor`, and `send.canceled` shows the one you canceled. Each line that records an action, such as `send.canceled` or `import.committed`, says who took it the same way: `principal` is `human` for a person or `service` for a service token, and `client` names the program a person used. A service token's lines have no `client`, and neither does a person's through a program that doesn't name itself.
104
+
105
+ 1. Filter on `principal` equal to `service` to see what automation changed. On a new instance it finds nothing. Filter on `client` equal to `claude` or `claude-code` to see what Claude changed.
106
+
103
107
  1. Filter on `level` equal to `error`. A healthy instance shows nothing.
104
108
 
109
+ If you search a long time range, Cloudflare may show only a sample of the matching lines. To follow one send, narrow the range to a few hours and filter on its `sendId`. On the Free plan, Cloudflare stops keeping logs for the rest of the day once the day's allowance is used up, so a very large send can leave the rest of that day unlogged. [Cloudflare's pricing page](https://developers.cloudflare.com/observability/pricing/) has the current limits and how long each plan keeps logs.
110
+
105
111
  ## 8. Check that SES can send to anyone
106
112
 
107
113
  [Connect Amazon SES](04-ses.md#5-request-production-access) asked AWS for production access. Until AWS grants it, the sandbox mails only your verified addresses, so check it before you go live.
@@ -176,7 +176,7 @@ Wrangler doesn't carry bindings or vars over from the top level of `wrangler.jso
176
176
 
177
177
  - `observability` optional
178
178
 
179
- Workers Logs, where the app's log lines land. On in the template. Every invocation is logged, with no sampling, since sampling could drop the one minute that reports a stuck or missed send.
179
+ Workers Logs, where the app's log lines land. On in the template. Kestrel sets no sampling, since sampling could drop the one minute that reports a stuck or missed send, so leave `head_sampling_rate` at its default of 1. Cloudflare's own daily limit on the Free plan can still stop new logs for the rest of the day, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes.
180
180
 
181
181
  - `DB` required
182
182
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurtbruns/kestrel",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "description": "An open-source, self-hosted newsletter app where you own the list and the archive. Work in the editor, or just ask Claude. Runs on Cloudflare Workers.",
6
6
  "license": "MIT",
@@ -48,8 +48,6 @@
48
48
  "seed": "node bin/kestrel.mjs seed",
49
49
  "reset": "node bin/kestrel.mjs reset",
50
50
  "simulate-send": "node scripts/simulate-send.mjs",
51
- "predeploy": "node scripts/stamp-version.mjs",
52
- "deploy": "node scripts/require-env.mjs deploy",
53
51
  "types": "wrangler types",
54
52
  "typecheck": "node scripts/stamp-version.mjs --if-changed && wrangler types && tsc --noEmit && tsc --noEmit -p client",
55
53
  "client:build": "node scripts/build-client.mjs",
@@ -65,7 +63,6 @@
65
63
  "check": "biome check --write .",
66
64
  "ci": "biome ci .",
67
65
  "migrate:local": "wrangler d1 migrations apply DB --local",
68
- "migrate:remote": "node scripts/require-env.mjs d1 migrations apply DB --remote",
69
66
  "check:package": "node scripts/check-package.mjs"
70
67
  },
71
68
  "dependencies": {
@@ -75,17 +72,17 @@
75
72
  "marked": "^18.0.12"
76
73
  },
77
74
  "peerDependencies": {
78
- "wrangler": "^4.129.1"
75
+ "wrangler": "^4.152.0"
79
76
  },
80
77
  "devDependencies": {
81
78
  "@biomejs/biome": "2.5.6",
82
- "@cloudflare/vitest-pool-workers": "^0.22.0",
79
+ "@cloudflare/vitest-plugin": "^1.4.7",
83
80
  "@types/node": "^22.20.1",
84
81
  "esbuild": "0.28.1",
85
82
  "happy-dom": "^20.14.5",
86
83
  "typescript": "~5.9.3",
87
- "vitest": "^4.1.0",
88
- "wrangler": "^4.129.1"
84
+ "vitest": "^4.1.12",
85
+ "wrangler": "^4.152.0"
89
86
  },
90
87
  "allowScripts": {
91
88
  "esbuild": true,
@@ -16,6 +16,7 @@ Deploying, migrating the production database, and anything that sends mail or ch
16
16
 
17
17
  - **Follow the guide page, step by step.** Each page is a complete procedure. Do what a step says, ask the person for what only they can do (a dashboard click, a DNS record at their registrar), and end with the page's **Check it** list, reporting each check's result.
18
18
  - **Deploying, migrating, and creating or binding a resource are plain wrangler,** always naming the environment, since the top level is development: `npx wrangler deploy --env production`, `npx wrangler d1 migrations apply DB --remote --env production`, and `npx wrangler d1 create NAME --env production --binding DB --update-config`, which writes the binding into `production` instead of offering to add it to the top level.
19
+ - **Never deploy or store a secret without `--env production`.** Without it, wrangler acts on the top level: a deploy creates a second Worker, named `kestrel`, running the stand-in transport and the local sign-in, and a secret lands on that Worker instead of production. The top level turns off every address that Worker could answer on and its send schedule; keep `workers_dev` and `preview_urls` there `false` and its `crons` present and empty (deleting the line leaves an earlier schedule running), and never add a route there. If a deploy went out without `--env`, tell the person, and remove it with `npx wrangler delete --name kestrel` once they agree.
19
20
  - **Upgrade with the `/upgrade` skill.** It follows the guide's upgrade page.
20
21
  - **Bring a list from another service with the `/migrate-list` skill.** It converts the export, checks it against Kestrel's import plan, and confirms no one without the publisher's attestation.
21
22
  - **Secrets never pass through you.** The person enters each one themselves with `npx wrangler secret put NAME --env production`, which prompts for the value; never ask for a secret in chat. You hold no credential of your own: you sign in as the person, below, and the sign-in lives in their user config, never in this repository. Never write a secret into `wrangler.jsonc`, `.claude/settings.json`, or a commit.
@@ -28,6 +29,7 @@ Deploying, migrating the production database, and anything that sends mail or ch
28
29
  - **Locally,** with `npm run dev` running, add `--local`: it calls this folder's dev server as its stand-in person, via Claude Code, with no sign-in. `GET /api/dev/outbox` shows the email the stand-in caught.
29
30
  - **`npx kestrel api --list` lists the instance's routes** (`--list sends` one resource's), and **`npx kestrel api help <method> <path>` describes one**: its parameters, body fields, answer, and refusals with their next moves. Both read the instance's own OpenAPI document, so they match the release it runs.
30
31
  - **Read `GET /api/reference.md` first:** how to authenticate, what a status and an error body tell you and the refusals any route can give, paging, acting safely, and every route on one line. **Before calling a route, read its resource's reference,** `GET /api/reference/<resource>.md` (the index names it, such as `sends.md`): the object the routes return, and each route's parameters, body, answer, refusals (each with what to do next), and an example. Read a resource's reference once per conversation, not before every call.
32
+ - **Posts with images:** upload each image from a file on disk (an image pasted into the chat isn't one; ask the person to save it into this folder), then reference it in the Markdown by its `filename`, with alt text, `![a short description](cover.jpg)`, never by the upload's `url`. The preview warns on a `url` reference, and on a name the post doesn't have.
31
33
  - **Never overwrite someone else's edit.** A save names the revision it was based on (the posts reference says how). On `stale_revision`, someone else changed the post (the refusal's `author` says who): read it again and re-apply your change to the newer revision, or ask.
32
34
  - **Offer only what a post's or a send's `actions` list,** as the reference's Acting safely says, so a post with no `schedule` or `send_now` (no email provider connected) isn't offered for sending. Read the answer to an act, or its refusal, before telling the publisher what happened or deciding again: it carries the resource as it now stands.
33
35
  - **Resolve a wedged send only on the publisher's choice.** Relay the `wedged` condition's message and ask which `resolution` they want, in the words the editor's Resolve dialog shows them: `unsent` is **Assume not sent**, which puts those readers in front of the next post, and `accepted` is **Assume sent**, which records them as sent. Never choose it yourself.
@@ -19,9 +19,16 @@
19
19
  // start while an origin or a sending setting is still on example.com, provider or not.
20
20
  // ---------------------------------------------------------------------------
21
21
 
22
- // The send sweep, once a minute. `npm run dev` runs it locally, where wrangler never
23
- // fires a cron.
24
- "triggers": { "crons": ["* * * * *"] },
22
+ // Deploy only with `--env production`. A wrangler command without it acts on this
23
+ // development config, which runs the stand-in transport and the local sign-in. These lines
24
+ // keep a deploy of it inert: no address it answers on, and no schedule, so its send sweep
25
+ // never runs, whatever database it is bound to. Leave them as they are, the empty `crons`
26
+ // included: deleting it instead would leave a schedule from an earlier deploy running.
27
+ // `npm run dev` runs the sweep locally on its own clock, and `production` below declares
28
+ // its schedule.
29
+ "workers_dev": false,
30
+ "preview_urls": false,
31
+ "triggers": { "crons": [] },
25
32
 
26
33
  // The editor, built by the release. Requests with no file fall through to the Worker.
27
34
  "assets": {