@kurtbruns/kestrel 1.10.0 → 1.11.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 +13 -1
- package/README.md +19 -29
- package/dist/public/dashboard/app.js +1 -1
- package/dist/public/dashboard/app.js.map +2 -2
- package/dist/public/dashboard/index.html +1 -1
- package/dist/worker/index.js +86 -190
- package/docs/README.md +2 -2
- package/docs/get-started/02-deploy.md +12 -5
- package/docs/get-started/03-access.md +2 -0
- package/docs/get-started/04-ses.md +14 -14
- package/docs/get-started/05-verify.md +19 -7
- package/docs/get-started/06-go-live.md +13 -55
- package/docs/guides/01-connect-claude.md +4 -4
- package/docs/guides/03-notifications.md +10 -10
- package/docs/guides/09-as-your-list-grows.md +62 -0
- package/docs/publishing/01-your-list.md +118 -47
- package/docs/publishing/02-sending-rules.md +5 -5
- package/docs/publishing/03-name-your-publication.md +7 -93
- package/docs/publishing/04-write-a-post.md +30 -73
- package/docs/publishing/06-troubleshooting.md +74 -0
- package/docs/publishing/07-working-with-claude.md +18 -37
- package/docs/publishing/09-share-your-subscribe-link.md +11 -59
- package/docs/publishing/10-check-your-setup.md +12 -30
- package/docs/publishing/11-email-template.md +34 -0
- package/docs/publishing/README.md +12 -11
- package/docs/reference/01-configuration.md +6 -6
- package/docs/reference/02-sending-domain-dns.md +1 -1
- package/package.json +1 -1
- package/template/claude/skills/migrate-list/SKILL.md +2 -2
- package/docs/guides/08-try-locally.md +0 -108
- package/docs/publishing/05-scheduling-and-sending.md +0 -72
- package/docs/publishing/06-after-a-send.md +0 -109
- package/docs/publishing/08-import-format.md +0 -103
package/dist/worker/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @kurtbruns/kestrel 1.
|
|
1
|
+
// @kurtbruns/kestrel 1.11.0: the Worker, built by scripts/build-package.mjs.
|
|
2
2
|
|
|
3
3
|
// shared/daily_writes.ts
|
|
4
4
|
var D1_FREE_DAILY_WRITES = 1e5;
|
|
@@ -808,10 +808,10 @@ var SUBSCRIBER_ACTIONS = {
|
|
|
808
808
|
|
|
809
809
|
// src/generated/version.ts
|
|
810
810
|
var BUILD_INFO = {
|
|
811
|
-
version: "1.
|
|
812
|
-
sha: "
|
|
813
|
-
tag: "v1.
|
|
814
|
-
buildTime: "2026-10-
|
|
811
|
+
version: "1.11.0",
|
|
812
|
+
sha: "adbc478",
|
|
813
|
+
tag: "v1.11.0",
|
|
814
|
+
buildTime: "2026-10-15T15:19:12.740Z",
|
|
815
815
|
repoUrl: "https://github.com/kurtbruns/kestrel"
|
|
816
816
|
};
|
|
817
817
|
|
|
@@ -1017,7 +1017,7 @@ ${JSON.stringify(value, null, 2)}
|
|
|
1017
1017
|
\`\`\``;
|
|
1018
1018
|
}
|
|
1019
1019
|
function routeMarkdown(r) {
|
|
1020
|
-
const parts = [
|
|
1020
|
+
const parts = [`#### ${r.method} ${r.path}`, r.summary];
|
|
1021
1021
|
if (r.description) {
|
|
1022
1022
|
parts.push(r.description);
|
|
1023
1023
|
}
|
|
@@ -1045,7 +1045,7 @@ Every route this instance serves, generated from its registration, so every endp
|
|
|
1045
1045
|
const tiers = groups.map((g) => {
|
|
1046
1046
|
const resources = g.resources.map(
|
|
1047
1047
|
(res) => [
|
|
1048
|
-
|
|
1048
|
+
`### ${res.title}`,
|
|
1049
1049
|
...g.routes.filter((r) => r.resource === res.key).map(routeMarkdown)
|
|
1050
1050
|
].join("\n\n")
|
|
1051
1051
|
);
|
|
@@ -6237,25 +6237,25 @@ async function reset(c) {
|
|
|
6237
6237
|
import { Lexer } from "marked";
|
|
6238
6238
|
|
|
6239
6239
|
// docs/get-started/02-deploy.md
|
|
6240
|
-
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 @kurtbruns/kestrel init\n ```\n\n This writes the instance\'s files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release\'s database migrations, and a starting point for Claude Code in `.claude/`. The instance\'s `README.md` lists its commands.\n\n1. Install the release and its tools:\n\n ```bash\n npm install\n ```\n\n1. Commit the new instance, and push it:\n\n ```bash\n git add -A\n git commit -m "Start a Kestrel instance"\n git push -u origin HEAD\n ```\n\n1. **(Optional)** Try Kestrel locally before you deploy it. Your instance is written and installed, so start at [Start the app](../guides/08-try-locally.md#2-start-the-app), and come back here when you\'re done. Email goes to a stand-in, so nothing reaches a real inbox.\n\n## 2. Sign in to Cloudflare\n\nEvery command from here on acts on your Cloudflare account through the Wrangler CLI.\n\n1. Sign in. A browser window opens for you to approve the login:\n\n ```bash\n npx wrangler login\n ```\n\n1. Confirm which account you\'re signed in to:\n\n ```bash\n npx wrangler whoami\n ```\n\n It prints your email and the account name. If you belong to more than one account, make sure it\'s the one that holds your domain.\n\n## 3. Create the database and the image bucket\n\nEach command creates the resource and writes its id or name into the `production` block of `wrangler.jsonc`, under the binding the app already uses, so there\'s nothing to copy.\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create kestrel-production --env production --binding DB --update-config\n ```\n\n It prints the new database\'s settings, then a snippet it says to add to your configuration. Wrangler has already added it: it fills in the `database_id` of the production `DB` binding with the database\'s real id, in place of `REPLACE_WITH_PRODUCTION_D1_ID`, so there\'s nothing to paste.\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\n It prints a snippet to add to your configuration too. There\'s nothing to paste here either: the production `MEDIA` binding already names this bucket, so it stays as it is.\n\nWrangler rewrites the whole of `wrangler.jsonc` in its own layout when it saves: tabs, and one value per line. Your comments and settings stay; only the spacing changes.\n\nName `--env production` on every Wrangler command that creates or binds a resource. Without it, Wrangler writes to the top level, which is local development.\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. It\'s shown compactly here; in your file, after section 3, each value is on its own line:\n\n ```jsonc\n "production": {\n "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }], // \u2190 your app\'s hostname\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "\u2026", // already filled in by section 3; leave it\n "migrations_dir": "migrations"\n }\n ],\n "vars": {\n "PROVIDER": "", // \u2190 stays empty until step 3\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.** Step 3, [Connect Amazon SES](04-ses.md), sets it. Until then the app runs without an email provider: you can write and preview posts, 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 you send. Every post you send carries its `/archive/\u2026` link, and changing the prefix later breaks the links you\'ve already mailed. Keep `/archive` unless you have a reason not to.\n - **`FROM_ADDRESS`** is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.\n\n Change every `example.com` value, the route included, even before you connect a provider. The app checks its settings but can\'t see the route: it refuses to run while `APP_ORIGIN`, `SENDING_DOMAIN`, or `FROM_ADDRESS`, or `ARCHIVE_ORIGIN` or `MEDIA_PUBLIC_BASE` if you set them, is still on `example.com`, since its links and the address your email comes from would name a domain that isn\'t yours.\n\n Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.\n\n1. Check the file. This regenerates the binding types, and fails if the configuration doesn\'t parse:\n\n ```bash\n npm run typecheck\n ```\n\n Wrangler prints the binding types it generated and ends with a reminder to run it again whenever you change `wrangler.jsonc`. The type check that follows prints nothing when it passes.\n\n1. Commit your settings and push them to your repository. An upgrade changes only the release your instance runs, so your settings stay as they are:\n\n ```bash\n git commit -am "Configure production"\n git push\n ```\n\n## 5. Apply the schema\n\nCreate Kestrel\'s tables in the production database:\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it\'s about to apply and asks you to confirm. It applies only the ones the database hasn\'t seen, so running it again is harmless.\n\n## 6. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThis deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker\'s **Triggers** tab in the Cloudflare dashboard.\n\n## Check it\n\n1. The app answers on your hostname. A new hostname can take a few minutes to get its certificate.\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The editor is locked. Until you set up Access in the next step, the app can\'t tell who is asking, so it lets no one in:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" https://newsletter.example.com/api/whoami\n ```\n\n ```\n 401\n ```\n\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 your newsletter\'s public landing page. Its subscribe page, `/subscribe`, says signups aren\'t open yet, until step 3, 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';
|
|
6240
|
+
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 @kurtbruns/kestrel init\n ```\n\n This writes the instance\'s files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release\'s database migrations, and a starting point for Claude Code in `.claude/`. The instance\'s `README.md` lists its commands.\n\n1. Install the release and its tools:\n\n ```bash\n npm install\n ```\n\n1. Commit the new instance, and push it:\n\n ```bash\n git add -A\n git commit -m "Start a Kestrel instance"\n git push -u origin HEAD\n ```\n\n1. **(Optional)** Try Kestrel locally before you deploy it. 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. Stop the server with <kbd>Ctrl</kbd>+<kbd>C</kbd> 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 its id or name into the `production` block of `wrangler.jsonc`, under the binding the app already uses, so there\'s nothing to copy.\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create kestrel-production --env production --binding DB --update-config\n ```\n\n It prints the new database\'s settings, then a snippet it says to add to your configuration. Wrangler has already added it: it fills in the `database_id` of the production `DB` binding with the database\'s real id, in place of `REPLACE_WITH_PRODUCTION_D1_ID`, so there\'s nothing to paste.\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\n It prints a snippet to add to your configuration too. There\'s nothing to paste here either: the production `MEDIA` binding already names this bucket, so it stays as it is.\n\nWrangler rewrites the whole of `wrangler.jsonc` in its own layout when it saves: tabs, and one value per line. Your comments and settings stay; only the spacing changes.\n\nName `--env production` on every Wrangler command that creates or binds a resource. Without it, Wrangler writes to the top level, which is local development.\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. It\'s shown compactly here; in your file, after section 3, each value is on its own line:\n\n ```jsonc\n "production": {\n "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }], // \u2190 your app\'s hostname\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "\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.** [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, even before you connect a provider. The app checks its settings but can\'t see the route: it refuses to run while `APP_ORIGIN`, `SENDING_DOMAIN`, or `FROM_ADDRESS`, or `ARCHIVE_ORIGIN` or `MEDIA_PUBLIC_BASE` if you set them, is still on `example.com`, since its links and the address your email comes from would name a domain that isn\'t yours.\n\n Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.\n\n1. Check the file. This regenerates the binding types, and fails if the configuration doesn\'t parse:\n\n ```bash\n npm run typecheck\n ```\n\n Wrangler prints the binding types it generated and ends with a reminder to run it again whenever you change `wrangler.jsonc`. The type check that follows prints nothing when it passes.\n\n1. Commit your settings and push them to your repository. An upgrade changes only the release your instance runs, so your settings stay as they are:\n\n ```bash\n git commit -am "Configure production"\n git push\n ```\n\n## 5. Apply the schema\n\nCreate Kestrel\'s tables in the production database:\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it\'s about to apply and asks you to confirm. It applies only the ones the database hasn\'t seen, so running it again is harmless.\n\n## 6. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThis deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker\'s **Triggers** tab in the Cloudflare dashboard.\n\n## Check it\n\n1. The app answers on your hostname. A new hostname can take a few minutes to get its certificate.\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The editor is locked. Until you set up Access in the next step, the app can\'t tell who is asking, so it lets no one in:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" https://newsletter.example.com/api/whoami\n ```\n\n ```\n 401\n ```\n\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 landing 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';
|
|
6241
6241
|
|
|
6242
6242
|
// docs/get-started/03-access.md
|
|
6243
|
-
var access_default = '# Lock the dashboard with Access\n\nCloudflare Access puts a login in front of the editor and the API. Kestrel checks every request against that login as well, so a path left out of Access stays locked. The public pages readers see stay public.\n\nIn this guide, you set up Cloudflare Zero Trust, create one Access application that covers the editor and the API, and allow yourself in. Then you give the app two values from that application, and check that the login works.\n\n## 1. Set up Zero Trust\n\nAccess is part of Cloudflare Zero Trust. Your account needs a Zero Trust organization before it can create an Access application. Skip this section if your account already has one.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), select **Zero Trust**.\n\n1. Choose a team name. It becomes your team domain, `YOUR_TEAM.cloudflareaccess.com`, where your login page lives.\n\n1. Choose a plan. The Free plan covers a newsletter\'s editors. Cloudflare asks for payment details even on the Free plan, and doesn\'t charge them.\n\n## 2. Create the Access application\n\nThe editor lives under `/dashboard`, and the API under `/api`. One application covers both, so they share one login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Applications**, and select **Create new application**.\n\n1. Select **Self-hosted and private**.\n\n1. Select **Add public hostname**, and enter your app\'s hostname with the first path:\n\n - **Subdomain:** `newsletter`\n - **Domain:** `example.com`\n - **Path:** `dashboard`\n\n1. Add a second public hostname with the same subdomain and domain, and the path `api`:\n\n | Path | What it covers |\n | --- | --- |\n | `dashboard` | the editor |\n | `api` | everything the editor and Claude read and change: posts, sends, subscribers, settings, and the in-app docs |\n\n Each path covers everything under it. Leave every other path out. The landing page, the archive, subscribing, unsubscribing, images, and your email provider\'s webhooks must stay public.\n\n## 3. Allow yourself in\n\nEvery Access application denies everyone until a policy allows them.\n\n1. Under **Access policies**, create a new policy:\n\n - **Policy name:** `Publishers`\n - **Action:** Allow\n - **Include:** the **Emails** selector, with your email address\n\n1. Keep the login method a new Zero Trust organization starts with: you sign in with your Cloudflare account. To sign in another way, such as a one-time code by email, add it under **Zero Trust \u2192 Integrations \u2192 Identity providers** first.\n\n1. Select **Create**.\n\n## 4. Give the app its two values\n\nThe app verifies each login against your team domain, and checks that it was issued for this application.\n\n1. Find your team domain under **Zero Trust \u2192 Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`.\n\n1. Find the application\'s audience tag. Under **Access controls \u2192 Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.\n\n1. Store the team domain as a secret. The command prompts you for the value. Enter the hostname alone, without `https://`. If you paste it with `https://` or a trailing slash, the app drops them. Anything else that isn\'t a hostname is refused, naming `ACCESS_TEAM_DOMAIN`:\n\n ```bash\n npx wrangler secret put ACCESS_TEAM_DOMAIN --env production\n ```\n\n1. Store the audience tag the same way:\n\n ```bash\n npx wrangler secret put ACCESS_AUD --env production\n ```\n\n A secret takes effect as soon as you store it, with no redeploy. The app needs both values. While only one is stored, it refuses its configuration and names the missing one: the editor says so, `/health` answers `500` with `"variable":"ACCESS_AUD"`, and the public pages say the newsletter is temporarily unavailable. Storing the second clears it.\n\n1. **(Optional, recommended)** Limit the editor to named people. The Access policy decides who can sign in. This list keeps that true if the policy is widened later, for another app or by mistake. Enter a comma-separated list of email addresses at the prompt:\n\n ```bash\n npx wrangler secret put ACCESS_ALLOWED_EMAILS --env production\n ```\n\n Without it, the app lets in anyone your Access policy allows.\n\n## Check it\n\n1. In a private browser window, open `https://newsletter.example.com/dashboard/`. Cloudflare asks you to sign in.\n\n1. Sign in. The editor loads, and its sidebar shows the email address you signed in with.\n\n1. In the same window, open `https://newsletter.example.com/api/whoami`. It shows you as a signed-in person, verified through Access:\n\n ```\n {"principal":{"kind":"human","email":"you@example.com"},"auth":{"mode":"access"}}\n ```\n\n1. The public pages still need no login:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\nIf the editor says the configuration needs a fix, store the secret it names. If Cloudflare lets you in but the editor answers `401`, check the two secrets for a typo, or `ACCESS_ALLOWED_EMAILS` may leave out your address.\n\nNext, [connect Amazon SES](04-ses.md).\n';
|
|
6243
|
+
var access_default = '# Lock the dashboard with Access\n\nCloudflare Access puts a login in front of the editor and the API. Kestrel checks every request against that login as well, so a path left out of Access stays locked. The public pages readers see stay public.\n\nIn this guide, you set up Cloudflare Zero Trust, create one Access application that covers the editor and the API, and allow yourself in. Then you give the app two values from that application, and check that the login works.\n\n## 1. Set up Zero Trust\n\nAccess is part of Cloudflare Zero Trust. Your account needs a Zero Trust organization before it can create an Access application. Skip this section if your account already has one.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), select **Zero Trust**.\n\n1. Choose a team name. It becomes your team domain, `YOUR_TEAM.cloudflareaccess.com`, where your login page lives.\n\n1. Choose a plan. The Free plan covers a newsletter\'s editors. Cloudflare asks for payment details even on the Free plan, and doesn\'t charge them.\n\n## 2. Create the Access application\n\nThe editor lives under `/dashboard`, and the API under `/api`. One application covers both, so they share one login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Applications**, and select **Create new application**.\n\n1. Select **Self-hosted and private**.\n\n1. Select **Add public hostname**, and enter your app\'s hostname with the first path:\n\n - **Subdomain:** `newsletter`\n - **Domain:** `example.com`\n - **Path:** `dashboard`\n\n1. Add a second public hostname with the same subdomain and domain, and the path `api`:\n\n | Path | What it covers |\n | --- | --- |\n | `dashboard` | the editor |\n | `api` | everything the editor and Claude read and change: posts, sends, subscribers, settings, and the in-app docs |\n\n Each path covers everything under it. Leave every other path out. The landing page, the archive, subscribing, unsubscribing, images, and your email provider\'s webhooks must stay public.\n\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. Keep the login method a new Zero Trust organization starts with: you sign in with your Cloudflare account. To sign in another way, such as a one-time code by email, add it under **Zero Trust \u2192 Integrations \u2192 Identity providers** first.\n\n1. Select **Create**.\n\n## 4. Give the app its two values\n\nThe app verifies each login against your team domain, and checks that it was issued for this application.\n\n1. Find your team domain under **Zero Trust \u2192 Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`.\n\n1. Find the application\'s audience tag. Under **Access controls \u2192 Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.\n\n1. Store the team domain as a secret. The command prompts you for the value. Enter the hostname alone, without `https://`. If you paste it with `https://` or a trailing slash, the app drops them. Anything else that isn\'t a hostname is refused, naming `ACCESS_TEAM_DOMAIN`:\n\n ```bash\n npx wrangler secret put ACCESS_TEAM_DOMAIN --env production\n ```\n\n1. Store the audience tag the same way:\n\n ```bash\n npx wrangler secret put ACCESS_AUD --env production\n ```\n\n A secret takes effect as soon as you store it, with no redeploy. The app needs both values. While only one is stored, it refuses its configuration and names the missing one: the editor says so, `/health` answers `500` with `"variable":"ACCESS_AUD"`, and the public pages say the newsletter is temporarily unavailable. Storing the second clears it.\n\n1. **(Optional, recommended)** Limit the editor to named people. The Access policy decides who can sign in. This list keeps that true if the policy is widened later, for another app or by mistake. Enter a comma-separated list of email addresses at the prompt:\n\n ```bash\n npx wrangler secret put ACCESS_ALLOWED_EMAILS --env production\n ```\n\n Without it, the app lets in anyone your Access policy allows.\n\n## Check it\n\n1. In a private browser window, open `https://newsletter.example.com/dashboard/`. Cloudflare asks you to sign in.\n\n1. Sign in. The editor loads, and its sidebar shows the email address you signed in with.\n\n1. In the same window, open `https://newsletter.example.com/api/whoami`. It shows you as a signed-in person, verified through Access:\n\n ```\n {"principal":{"kind":"human","email":"you@example.com"},"auth":{"mode":"access"}}\n ```\n\n1. The public pages still need no login:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\nIf the editor says the configuration needs a fix, store the secret it names. If Cloudflare lets you in but the editor answers `401`, check the two secrets for a typo, or `ACCESS_ALLOWED_EMAILS` may leave out your address.\n\nNext, [connect Amazon SES](04-ses.md).\n';
|
|
6244
6244
|
|
|
6245
6245
|
// docs/get-started/04-ses.md
|
|
6246
|
-
var ses_default = '# Connect Amazon SES\n\nKestrel sends your posts through Amazon SES, Amazon\'s email service. SES only delivers the mail: Kestrel keeps your list, each subscriber\'s consent, and the record of every send. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.\n\nIn this guide, you verify your sending hostname with SES and add a DMARC record. You forward readers\' replies to your 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 one you already read, with Cloudflare Email Routing, which is free on every plan.\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 inbox you want replies in. Open the email Cloudflare sends to it, and select **Verify email address**. 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 your 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 your 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- **Your answers come from your inbox.** A forwarded reply comes from the reader\'s own address. When you answer it, your answer 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 you to do something, and Kestrel can\'t see them, so acting on them is yours. In the editor, open **Subscribers**, and use the reader\'s row menu. Choose **Unsubscribe** for a reader who asks to leave, and **Erase** for one who asks you to delete their data. A reader who never confirmed isn\'t mailed, so their row offers only **Erase**.\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 your publication first, since AWS may visit your website to review the request. In the editor, open **Settings \u2192 Publication identity**, and enter the name and tagline. Until you do, your public pages show the name in your From address. The subscribe form stays closed until section 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 your list:\n\n- Signups use double opt-in: each subscriber confirms by email before joining the list.\n- Every post offers one-click unsubscribe.\n- Bounces and complaints reach the app through SNS, and it stops mailing those addresses automatically.\n- How many subscribers you expect, and how often you send.\n\nYour account stays in the sandbox until AWS grants access, so don\'t go live before then. In the sandbox, a signup from any address you haven\'t verified fails, and a post reaches only your verified addresses.\n\nWhen AWS grants access, the **Account dashboard** shows your daily quota and your maximum send rate. You set the rate in the app in section 8. The rate decides how fast a send goes out, and [As your list grows](06-go-live.md#as-your-list-grows) says when to ask AWS to raise it.\n\n## 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. [As your list grows](06-go-live.md#as-your-list-grows) says when the plan is worth it.\n\n When AWS grants production access, set `SES_MAX_SEND_RATE` to the maximum send rate on the **Account dashboard**, and deploy again. The app keeps to that rate, so SES doesn\'t throttle your send.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Send through Amazon SES"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## 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\nIf it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:\n\n- **Access covers `/webhooks/ses`.** If your Access application covers more than `/dashboard` and `/api`, SNS gets the login page instead of the app. Remove that path from your Access application, so only `dashboard` and `api` remain.\n- **`SNS_TOPIC_ARN` matches the topic\'s ARN.**\n- **The deploy in section 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 your 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';
|
|
6246
|
+
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. 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, a signup from any address you haven\'t verified fails, and a post reaches only your verified addresses.\n\nWhen AWS grants access, the **Account dashboard** shows your daily quota and your maximum send rate. You set the rate in the app in section 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. [As your list grows](../guides/09-as-your-list-grows.md) says when the plan is worth it.\n\n When AWS grants production access, set `SES_MAX_SEND_RATE` to the maximum send rate on the **Account dashboard**, and deploy again. The app keeps to that rate, so SES doesn\'t throttle your send.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Send through Amazon SES"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## 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\nIf it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:\n\n- **Access covers `/webhooks/ses`.** If your Access application covers more than `/dashboard` and `/api`, SNS gets the login page instead of the app. Remove that path from your Access application, so only `dashboard` and `api` remain.\n- **`SNS_TOPIC_ARN` matches the topic\'s ARN.**\n- **The deploy in section 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';
|
|
6247
6247
|
|
|
6248
6248
|
// docs/get-started/05-verify.md
|
|
6249
|
-
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
|
|
6249
|
+
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, and check that SES can send to anyone.\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. Cancel the send, to see that canceling stops it. Then select **Send now** again, and let it go.\n\n1. When the send finishes, the post arrives at your second address. A notification arrives at your first, with the subject `Sent:` and the post's title.\n\n## 5. Unsubscribe\n\nEvery email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. Both work in one step, with no login.\n\n1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender.\n\n1. Select it. In the editor, **Subscribers** shows the address as **Unsubscribed**, and later sends leave it out.\n\n## 6. Check that bounces and complaints come back\n\nWhen an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:\n\n - `bounce@simulator.amazonses.com`, which bounces\n - `complaint@simulator.amazonses.com`, which marks your mail as spam\n\n Each one gets a confirmation email, which bounces or is marked as spam.\n\n1. In the editor, **Subscribers** lists both addresses as **Pending**, each with a **Suppressed** flag. The app never mails a suppressed address again.\n\nIf an address has no flag, SES's events aren't reaching the app. Check the configuration set and the SNS subscription in [Connect Amazon SES](04-ses.md#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\nWhen every section gives the result it describes, your instance is ready for subscribers.\n\nNext, [go live](06-go-live.md).\n";
|
|
6250
6250
|
|
|
6251
6251
|
// docs/get-started/06-go-live.md
|
|
6252
|
-
var go_live_default = "# Go live\n\
|
|
6252
|
+
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). The checklist itself shows only until the first post exists, so the test post from [Verify it works](05-verify.md) has already hidden it.\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- [Connect Claude to the API](../guides/01-connect-claude.md), so Claude can draft, proofread, and schedule posts through the same API the editor uses.\n- [Notifications through Cloudflare's email](../guides/03-notifications.md), so 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 5,000 subscribers.\n- [Upgrade to a new release](../guides/07-upgrade.md), when one comes out.\n";
|
|
6253
6253
|
|
|
6254
6254
|
// docs/guides/01-connect-claude.md
|
|
6255
|
-
var connect_claude_default = '# Connect Claude to the API\n\nClaude Code can draft, edit, proofread, and schedule
|
|
6255
|
+
var connect_claude_default = '# Connect Claude to the API\n\nClaude Code can draft, edit, proofread, and schedule posts through the same API the editor uses. It has its own Cloudflare Access credential, so its changes show as Claude\'s, not a person\'s. Anything it schedules waits out the review window, where the publisher can still cancel it.\n\nIn this guide, you create a service token for Claude Code, and add a policy for it to your Access application. Then you store the token where Claude Code reads it, check that it works, and point Claude at the API reference.\n\n## Before you begin\n\nYou need:\n\n- The dashboard locked with Access, from [Lock the dashboard with Access](../get-started/03-access.md).\n- [Claude Code](https://code.claude.com/docs/en/overview), run from your instance\'s repository.\n\n## 1. Create a service token\n\nA service token is a Client ID and a Client Secret that a program sends with each request. It carries no email, so the app tells it apart from your own login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Service credentials \u2192 Service Tokens**, and select **Create Service Token**.\n\n1. Name it `kestrel-claude`, and choose a **Service Token Duration**. A year is a good default.\n\n1. Select **Generate token**.\n\n1. Copy the **Client ID** and the **Client Secret**. Cloudflare shows the secret only once.\n\n## 2. Add a policy for the token\n\nYour application lets in only the people its `Publishers` policy names. A token needs a policy of its own, on the same application, because the app accepts only logins issued for that application.\n\n1. Under **Access controls \u2192 Applications**, select **Configure** on the application from [Lock the dashboard with Access](../get-started/03-access.md#2-create-the-access-application).\n\n1. Add a policy:\n\n - **Policy name:** `Claude`\n - **Action:** Service Auth\n - **Include:** the **Service Token** selector, with `kestrel-claude`\n\n1. Save the application.\n\n`ACCESS_ALLOWED_EMAILS` doesn\'t apply to the token, since it has no email. This policy is the only thing that lets it in.\n\n## 3. Store the token for Claude Code\n\nClaude Code loads the `env` block of `.claude/settings.local.json` into every session. That file stays on your computer: your repository\'s `.gitignore` keeps it out of git.\n\n1. Open `.claude/settings.local.json` in your instance\'s repository. Create it if it doesn\'t exist.\n\n1. Add an `env` block, beside anything already in the file:\n\n ```json\n {\n "env": {\n "KESTREL_URL": "https://newsletter.example.com",\n "CF_ACCESS_CLIENT_ID": "REPLACE_WITH_CLIENT_ID",\n "CF_ACCESS_CLIENT_SECRET": "REPLACE_WITH_CLIENT_SECRET"\n }\n }\n ```\n\n `KESTREL_URL` is your app\'s address. The other two are the Client ID and Client Secret from section 1.\n\n1. Start a new Claude Code session, so it loads the values.\n\nNever put the secret in `.claude/settings.json`. That file is committed.\n\n## 4. Point Claude at the API\n\nKestrel has no plugin or connector for Claude. Claude calls the HTTP API directly, and the API reference describes every route: its method and path, the body it takes, and an example. The editor\'s **API** tab shows the same reference.\n\nYour instance\'s `.claude/CLAUDE.md` already tells Claude Code how to call the API, so a session in your instance\'s repository reads the reference before its first call. If it doesn\'t, a prompt like this one points it there:\n\n```\nKestrel\'s API is at $KESTREL_URL. Send the CF-Access-Client-Id and\nCF-Access-Client-Secret headers from your environment with every request.\nRead GET /api/reference first, and use it for every call.\n```\n\nThe reference is also served as Markdown, `GET /api/reference.md`, which reads more easily for Claude than the JSON. So are the docs: `GET /api/docs/publishing.md` lists the publisher\'s pages, `GET /api/docs/self-hosting.md` this guide\'s, and each page is at `GET /api/docs/<slug>.md`.\n\nClaude reads the reference, and from there can list the posts, write a draft, and schedule it. The reference lists the body types each route takes. Most take JSON, sent with `Content-Type: application/json`. An image upload takes a multipart form, or the image file itself.\n\nTo bring subscribers over from another service, ask Claude to import the list, as [Bring your existing list](../publishing/01-your-list.md) describes. Its [file format](../publishing/01-your-list.md#the-file-format) section describes every column.\n\n## Check it\n\n1. Ask Claude to call `GET /api/whoami`. The app answers with a program\'s identity, not yours:\n\n ```\n {"principal":{"kind":"service"},"auth":{"mode":"access"}}\n ```\n\n1. Ask Claude to list the posts. It answers with the posts the editor shows.\n\n1. Open a draft in the editor, and ask Claude to change a word in it. The editor shows that the draft was changed elsewhere, last edited by **Claude**.\n\nIf Claude gets a `401`, a `403`, or a login page, check the policy from section 2, and the token\'s two values in `.claude/settings.local.json`. Check that the token hasn\'t expired, too.\n';
|
|
6256
6256
|
|
|
6257
6257
|
// docs/guides/03-notifications.md
|
|
6258
|
-
var notifications_default = "# Notifications through Cloudflare's email\n\nKestrel emails
|
|
6258
|
+
var notifications_default = "# Notifications through Cloudflare's email\n\nKestrel emails the publisher when a send finishes, and right away when one runs into a problem. By default, these notifications go through the email provider. The one that matters most, about the provider refusing your account, is the one the provider would refuse too. Cloudflare's email doesn't depend on the provider, so that notification still reaches the publisher.\n\nIn this guide, you turn on Cloudflare's email for your app's hostname, and verify the notifications address with it. Then you add the binding that sends notifications through it, deploy, and send yourself a test.\n\n## Before you begin\n\nYou need:\n\n- The notifications address saved, from [Verify it works](../get-started/05-verify.md#3-set-up-notifications).\n- Your domain's DNS on Cloudflare, as for the rest of the app.\n\n## 1. Turn on Email Routing for your app's hostname\n\nCloudflare's email sends only from a domain set up with Email Routing. Use your app's hostname, `newsletter.example.com`. It receives no mail today, so turning Email Routing on there changes nothing else.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), go to **Compute \u2192 Email Service \u2192 Email Routing**.\n\n1. Add `newsletter.example.com` to Email Routing. Cloudflare adds subdomains from your domain's own settings: select `example.com`, then **Settings**, and add `newsletter` under **Subdomains**.\n\n1. Cloudflare adds MX, SPF, and DKIM records for `newsletter.example.com`, and locks them. Wait until they show as added.\n\nAvoid turning on Email Routing for a domain that already receives mail elsewhere, such as `example.com` with Google Workspace. Email Routing takes over that domain's MX records. Don't send notifications from `send.example.com` either, since your email provider sends from it. Email Routing there to forward readers' replies, as [Connect Amazon SES](../get-started/04-ses.md#3-receive-replies-at-your-from-address) sets up, is fine, since it only receives.\n\n## 2. Verify the notifications address\n\nCloudflare's email delivers only to addresses you've verified with it. Sending to a verified address is free on any plan.\n\n1. Under **Email Routing**, go to **Destination Addresses**, and add the address you saved for notifications.\n\n1. Open the email Cloudflare sends to that address, and select **Verify email address**. If it's the publisher's address, ask them to do this step.\n\nA verified address belongs to your Cloudflare account, so every environment in it can use it.\n\n## 3. Add the binding and deploy\n\nA `send_email` binding named `NOTIFY` moves notifications to Cloudflare's email. Without it, they go through your provider.\n\n1. In `wrangler.jsonc`, add the binding to the `production` block:\n\n ```jsonc\n \"send_email\": [\n { \"name\": \"NOTIFY\", \"allowed_destination_addresses\": [\"you@example.com\"] } // \u2190 the notifications address\n ],\n ```\n\n `allowed_destination_addresses` is optional. It limits the binding to the addresses you list, so a changed setting in the editor can't send notifications anywhere else. Leave it out to allow any address you've verified. If the publisher changes the notifications address later, verify the new one and add it here too.\n\n1. **(Optional)** Notifications come from `Kestrel <kestrel@newsletter.example.com>`. To use another address on that hostname, set `NOTIFY_FROM` in the block's `vars`:\n\n ```jsonc\n \"NOTIFY_FROM\": \"Kestrel <alerts@newsletter.example.com>\",\n ```\n\n1. Check the file. This regenerates the binding types:\n\n ```bash\n npm run typecheck\n ```\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am \"Send notifications through Cloudflare's email\"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## Check it\n\n1. In the editor, open **Settings \u2192 Notifications**. **Sent through** reads \"Cloudflare Email, separate from your newsletter's provider\", and **From address** shows `kestrel@newsletter.example.com`.\n\n1. Select **Send a test notification**. It arrives at the notifications address, and **Last notification** shows it as delivered.\n\nIf the test isn't delivered, **Last notification** shows Cloudflare's reason, such as an address that isn't verified. Cloudflare's Email Routing summary lists notifications as dropped even when they're delivered, so check the notifications inbox rather than the summary.\n\n## What notifications don't cover\n\n- **The schedule stopping.** The same once-a-minute schedule that sends posts also notices problems. If the schedule itself stops, nothing tells you. [Read the logs](../get-started/05-verify.md#7-read-the-logs) to check that `sweep.tick` still arrives every minute.\n- **A notification that can't get through.** The app tries each one up to five times, a minute apart. A failure shows under **Settings \u2192 Notifications**, and in the logs as `notify.failed`.\n- **Problems while no address is set.** With the address blank, the app sends nothing, and saves nothing up for later.\n\nA notification never changes a send. One that fails never delays, pauses, or stops it.\n";
|
|
6259
6259
|
|
|
6260
6260
|
// docs/guides/05-rate-limit.md
|
|
6261
6261
|
var rate_limit_default = "# Rate-limit the subscribe form\n\nThe subscribe form is public, so anyone can submit it, as often as they like. The app already sends any one address at most one confirmation every 15 minutes, and answers every address the same way. It can't tell one visitor from another, though. Cloudflare can, so a rule at Cloudflare caps how often each visitor submits.\n\nIn this guide, you add a rate-limiting rule for the subscribe page, and check that it blocks a burst of requests. Nothing changes in the app.\n\n## 1. Add a rate-limiting rule\n\nCloudflare's Free plan includes one rate-limiting rule, and the subscribe form is a good place to spend it.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), open your domain, `example.com`, and go to **Security rules**.\n\n1. Select **Create rule**, then **Rate limiting rules**.\n\n1. Fill in the rule:\n\n - **Rule name:** `Subscribe form`\n - **If incoming requests match:** **URI Path** equals `/subscribe`\n - **With the same characteristics:** IP\n - **When rate exceeds:** 5 requests per 10 seconds\n - **Then take action:** Block, with a duration of 10 seconds\n\n The rule's expression reads:\n\n ```\n (http.request.uri.path eq \"/subscribe\")\n ```\n\n1. Select **Deploy**.\n\nOn the Free plan, a rule can match only on the path. It counts loads of the form as well as submissions, and `/subscribe` on every hostname in your domain. At five requests in 10 seconds, neither gets in a real reader's way.\n\nA paid plan allows a longer window, which fits a form better. On Pro, try 10 requests a minute, blocked for 10 minutes. Pro can also limit the rule to the app's hostname, with `and http.host eq \"newsletter.example.com\"`. Business can limit it to submissions, with `and http.request.method eq \"POST\"`.\n\nThe rule covers only your domain's hostnames. The Worker's `workers.dev` addresses would skip it, which is why your production settings keep `workers_dev` and `preview_urls` set to `false`. Keep them that way.\n\n## Check it\n\n1. Request the subscribe page eight times in a row:\n\n ```bash\n for i in $(seq 1 8); do curl -s -o /dev/null -w \"%{http_code}\\n\" https://newsletter.example.com/subscribe; done\n ```\n\n The first requests answer `200`. Once you pass five within 10 seconds, the rest answer `429`, Cloudflare's block. The switch can come a request or two later.\n\n1. Wait 10 seconds, and open `https://newsletter.example.com/subscribe` in a browser. The form loads again.\n\n1. In the dashboard, **Security rules** lists `Subscribe form` as active.\n";
|
|
@@ -6263,231 +6263,121 @@ var rate_limit_default = "# Rate-limit the subscribe form\n\nThe subscribe form
|
|
|
6263
6263
|
// docs/guides/07-upgrade.md
|
|
6264
6264
|
var upgrade_default = "# Upgrade to a new release\n\nA new release of Kestrel is a new version of the `@kurtbruns/kestrel` package. Upgrading changes the version your instance's repository names, brings in the release's database changes, and deploys. The app can't undo a database change, so you note a restore point first.\n\nIn this guide, you read what changed, install the release, and note where your database stands. Then you apply the database changes, deploy, and check the new version. If you run a staging environment, upgrade it first, and repeat each step for production once it checks out.\n\n## 1. Read what changed\n\nA release can ask you to change a setting or a record before it runs. The changelog says so, release by release.\n\n1. Find the version you run. In the editor, open the **Docs** or **API** tab. The foot of its contents list shows the version, then the commit. Your instance's `package.json` names it too, as the version of `@kurtbruns/kestrel`.\n\n1. Read the [release notes](https://github.com/kurtbruns/kestrel/releases) for every version after yours, up to the one you're moving to. Each release opens with an **Upgrading from\u2026** paragraph that says what to do. Its **Breaking** entries say what changed underneath you.\n\n## 2. Install the release\n\nEach step here changes only your instance's repository. Nothing reaches your deployed app until section 5.\n\n1. Install the release. Replace `X.Y.Z` with its version:\n\n ```bash\n npm install @kurtbruns/kestrel@X.Y.Z\n ```\n\n Your `package.json` now names exactly that version. Kestrel's versions aren't semantic versioning, so your instance never moves to a release you didn't choose.\n\n1. Bring in the release's database migrations:\n\n ```bash\n npm run sync-migrations\n ```\n\n It copies each new migration into `migrations/` and names the ones it added. It never overwrites one that's there.\n\n1. Check the Claude Code files Kestrel gave your instance:\n\n ```bash\n npm run check-context\n ```\n\n It compares each file in `.claude/` with the release's, and says what to do for each one:\n\n - **current**: nothing to do.\n - **Kestrel changed it, and you haven't edited it**: take the new one with `npm run check-context -- --update`.\n - **Kestrel changed it, and you've edited it**: it shows the difference from yours to the release's. Merge what you want into yours, by hand or with Claude, then record that you did with the command it prints.\n\n1. If the release's **Upgrading from\u2026** paragraph names a new setting, add it to `wrangler.jsonc`.\n\n1. Check the configuration and the types:\n\n ```bash\n npm run typecheck\n ```\n\n1. **(Optional)** Try the release on your computer. Apply its database changes to your local database, then start the app:\n\n ```bash\n npx wrangler d1 migrations apply DB --local\n npm run dev\n ```\n\n You see the new version with your local content. It can't show how the changes treat your real data. A staging environment can.\n\n1. Commit the upgrade as one commit, and push it. Going back, below, undoes this commit:\n\n ```bash\n git add -A\n git commit -m \"Upgrade Kestrel to X.Y.Z\"\n git push\n ```\n\n## 3. Note a restore point\n\nD1 keeps a history of your database, called Time Travel, for 30 days on Workers Paid and 7 on Workers Free. A bookmark marks one moment in it, so you can put the database back if the upgrade goes wrong.\n\n1. In the editor, check that no send is in progress. The dashboard lists any that are. Upgrade between sends.\n\n1. Note where the database stands now:\n\n ```bash\n npx wrangler d1 time-travel info kestrel-production\n ```\n\n It prints a bookmark for the database's current state. Copy it, and keep it until the upgrade checks out.\n\n## 4. Apply the database changes\n\nA release changes the database only by adding a migration. Applying it keeps everything already in the database.\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it's about to apply, and asks you to confirm. When a release has none, it says there's nothing to apply. Run it on every upgrade.\n\n## 5. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThe output ends with your app's hostname.\n\n## Check it\n\n1. The app answers:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {\"status\":\"ok\",\"service\":\"kestrel\"}\n ```\n\n If it answers `500` with `{\"error\":\"schema_behind\",\"missing\":[\u2026],\u2026}` instead, the release was deployed before its database changes: the public pages say the newsletter is temporarily unavailable and nothing is sent. Apply them as in section 4; the app answers as soon as they're applied, with no new deploy.\n\n1. Reload the editor. The foot of the **Docs** tab's contents list shows the new version, which links to its release page.\n\n1. Send yourself a test email, as in [Verify it works](../get-started/05-verify.md#1-send-yourself-a-test-email). Don't send a real post to check an upgrade: on a live instance, it goes to your whole list.\n\n1. In the logs, `sweep.tick` still arrives every minute, as in [Verify it works](../get-started/05-verify.md#7-read-the-logs).\n\n## Go back\n\nIf the new release misbehaves, deploy the previous one, and put the database back to your bookmark.\n\n1. Find the upgrade's commit. Replace `X.Y.Z` with the release you're backing out. Its id comes first on the line:\n\n ```bash\n git log -1 --oneline --grep=\"Upgrade Kestrel to X.Y.Z\"\n ```\n\n1. Undo it. This adds a commit that puts back the previous release, its migrations, and your Claude Code files, and leaves anything you committed since as it is:\n\n ```bash\n git revert --no-edit REPLACE_WITH_COMMIT_ID\n ```\n\n1. Install the previous release, and deploy it:\n\n ```bash\n npm ci\n npx wrangler deploy --env production\n ```\n\n1. Restore the database to the bookmark from section 3:\n\n ```bash\n npx wrangler d1 time-travel restore kestrel-production --bookmark=REPLACE_WITH_BOOKMARK\n ```\n\n1. Push the revert to your repository:\n\n ```bash\n git push\n ```\n\nA restore returns the whole database to that moment. Everything written since is gone: edits to posts, new subscribers, and the record of anything sent. Restore only soon after the upgrade.\n\nNever restore past a send that went out, or an unsubscribe recorded since the bookmark. The restored database wouldn't know of them, so it could mail those readers again, or mail someone who left. If either happened, stay on the new release, and fix forward.\n";
|
|
6265
6265
|
|
|
6266
|
-
// docs/guides/
|
|
6267
|
-
var
|
|
6266
|
+
// docs/guides/09-as-your-list-grows.md
|
|
6267
|
+
var as_your_list_grows_default = '# As your list grows\n\nA new instance runs on Cloudflare\'s free Workers plan with your starting Amazon SES limits. That suits a list of up to about 5,000 subscribers. Past that, two limits matter: how fast SES lets you send, and how many database writes the free plan allows a day.\n\nIn this guide, you raise your SES limits, which helps on either plan, and upgrade to Workers Paid, which a larger list needs. Then you see how long a send takes after each.\n\n## 1. Raise your SES limits\n\nSES caps how many emails your account sends a day, and how many a second. A send that hits the daily quota waits, emails you, and resumes on its own once the quota frees up. Raising your maximum send rate to 50 a second costs nothing, and it makes every send faster.\n\n1. Ask AWS for a higher daily quota and a maximum send rate of 50, as AWS\'s [Increasing your Amazon SES sending quotas](https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-request-increase.html) describes.\n\n1. When AWS grants it, the SES **Account dashboard** shows your new rate. In `wrangler.jsonc`, set the `production` block\'s [`SES_MAX_SEND_RATE`](../reference/01-configuration.md#ses_max_send_rate) to match.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Match the SES send rate"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## 2. Upgrade to Workers Paid\n\nOn the free plan, [D1](https://developers.cloudflare.com/d1/platform/pricing/) allows a fixed number of database writes a day. A send to about 7,000 recipients uses them all, and then the whole app stops until midnight UTC. Until you upgrade, the app warns the publisher before a send or an import that could use up the day. It\'s recommended to upgrade from about 5,000 subscribers.\n\n1. Upgrade your Cloudflare account to the $5 [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/), which has no daily limit on writes.\n\n1. In `wrangler.jsonc`, add `"SUBREQUEST_BUDGET": "10000"` to the `production` block\'s `vars`. It tells the app it\'s on Workers Paid, so each minute sends far more ([`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget)).\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Run on Workers Paid"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## How long a send takes\n\n| Setup | 1,000 subscribers | 5,000 subscribers | 10,000 subscribers |\n| --- | --- | --- | --- |\n| Free plan, a starting SES rate of 14 | about 18 minutes | about 1.5 hours | over the free plan\'s daily writes |\n| Free plan, SES rate raised to 50 | about 5 minutes | about 25 minutes | over the free plan\'s daily writes |\n| Workers Paid, SES rate of 14 | about 2 minutes | about 7 minutes | about 15 minutes |\n| Workers Paid, SES rate of 50 | under a minute | about 2 minutes | about 4 minutes |\n\n## Check it\n\n1. The SES **Account dashboard** shows the maximum send rate you set in `SES_MAX_SEND_RATE`.\n\n1. On Workers Paid, the import screen under **Subscribers \u2192 Import** reads "CSV or .txt, up to 10,000 rows."\n';
|
|
6268
6268
|
|
|
6269
6269
|
// docs/publishing/01-your-list.md
|
|
6270
|
-
var your_list_default = "# Bring your existing list\n\nAlmost no one starts a newsletter with an empty list. If your readers are on another service, bring them over before your first post, with the consent they gave there. It's the **Bring your existing list** step on your dashboard, and **Import** starts it. If you're starting fresh, skip this step: new readers join through [Share your subscribe link](09-share-your-subscribe-link.md).\n\nThis page covers the file Kestrel reads, what an import does and why, and the limits. It also covers taking your whole list out again at any time, in the same format. The dashboard's **Subscribers \u2192 Import** walks you through an import, and Claude reads this same page through the API.\n\n## The file Kestrel reads\n\nKestrel reads one documented format, `kestrel-subscribers/1`: a CSV file with a header row and one row per address. A row says what your old service recorded about a person, and Kestrel decides what that means here. Five columns decide what happens to a row:\n\n| Column | What it holds |\n| --- | --- |\n| `email` | The address. Kestrel trims it and ignores capitals when it matches. |\n| `status` | `subscribed`, `pending`, `unsubscribed`, or `none` (an address with no consent claim, present only to carry a bounce or a block). |\n| `opt_in` | How consent was collected: `double`, `single`, or `unknown`. |\n| `origin` | Where consent was collected, as a short name such as `sendy` or `mailchimp`. |\n| `subscribed_at` | When they subscribed, with its time zone, such as `2024-03-01T09:30:00Z`. Optional. |\n\nThe optional columns carry the rest: a bounce or complaint (`suppression`), when someone confirmed or left, and `evidence`, the old service's own consent details, such as a signup IP or a form name. [Import format](08-import-format.md) describes every column and value.\n\nThe import screen's **Download example file (CSV)** gives you `kestrel-import-example.csv`, a few rows that show each case. Open it in a spreadsheet to see the shape, and save your file as CSV UTF-8.\n\nKestrel never guesses what another service's export means, because a wrong guess about who agreed to be emailed can't be taken back. An export in any other shape is refused, with what's wrong and where. Convert it first, with Claude or by hand.\n\n## A plain list of addresses\n\nIf all you have is addresses, a `.txt` file with one address per line works too. After you choose it, you say what the addresses are: **Subscribed** (the default), **Unsubscribed**, or **Bounced**. Every address in the file is that one kind, so a plain list of subscribers and a plain list of bounces are two imports.\n\nA plain list loses what a full CSV carries: how and where each person joined, when, and the evidence. Kestrel records how they joined as unknown. When your old service can export more than addresses, prefer the CSV.\n\n## Let Claude convert your export\n\nExports from Sendy, Mailchimp, Substack, and other services all differ, and change without notice. If you use Claude Code with your instance, ask it to do the conversion:\n\n```\nPlease import my Sendy subscribers from ~/Downloads/sendy-export.csv into the newsletter.\n```\n\nClaude's `/migrate-list` skill reads Kestrel's format from your instance and asks you what anything ambiguous in the export means. It converts a few rows for you to check, then checks Kestrel's plan against its own counts. It asks for your attestation before it imports anyone as a subscriber. [Connect Claude to the API](../guides/01-connect-claude.md) sets Claude up.\n\nIf a scheduled post would reach the people the import adds, Claude stops and asks you to finish in the dashboard: choose the converted file Claude left in your working folder under **Subscribers \u2192 Import**, and the same plan appears for you to confirm.\n\n## What an import does\n\nNothing changes when you choose a file. Kestrel plans the import first and shows you what it would do to every row. You see who is added as a subscriber, who is recorded as having left, and who is suppressed, and which rows are repeats, invalid, or left out. You can download the rows that won't be imported as subscribers, to check them against your file. Only when you press **Import** does anything change, and then the import applies whole, or not at all. If a reader's consent changed between your plan and the import, for example someone unsubscribed or confirmed, their row follows your list as it stands then, and the result lists each row that turned out differently from the plan you saw. An import sends no email.\n\nTo import anyone as a subscriber, you confirm that everyone you're importing as subscribed opted in to receive your publication, and that the list isn't purchased, rented, or scraped. If you're unsure about some people, take them out of the file before importing. Kestrel keeps your attestation with the import's record.\n\nIf a post is scheduled, it goes to the list as it stands when it sends, so the people an import adds would receive it. The plan names each such post and how many more people it would reach, and you acknowledge each one before importing.\n\nIf your list or its scheduled posts changed since the plan in a way the plan rests on, such as another import, Kestrel plans your file again and shows you the new plan as things stand now, before anything happens.\n\n## Why your old service's record is honored\n\nKestrel normally confirms every new subscriber with double opt-in: they click a link in an email before they get any posts. Asking your whole list to confirm again would lose readers who already said yes, and would mail every one of them a request they didn't expect. So an import carries over what your old service recorded instead: the consent each person gave there, how it was collected, and your word for it.\n\nKestrel holds a few lines that an import can't cross:\n\n- **Someone who left stays gone.** An address that unsubscribed here is never subscribed again by an import, whatever the file says. Only that reader can come back, by confirming here.\n- **A bounce, a block, or a do-not-contact marker here always wins.** An import never lifts a suppression, so an address known to fail is never mailed. An address you erased keeps only a do-not-contact marker, and an import never records a subscriber for it again, not even as one who left.\n- **A reader's newer choice stands.** If someone confirmed here after your old service says they left, they stay subscribed.\n- **Repeats are folded together,** and when one row says subscribed and another says unsubscribed, unsubscribed wins.\n\nIn the subscriber list, imported subscribers are **Confirmed** and receive your posts. The **How they joined** column says they were imported, with the service and the opt-in method your file gave, such as \"Imported \xB7 Sendy, single opt-in\". Only someone who confirmed through Kestrel itself reads as \"Double opt-in\". Choose **View subscriber** from a subscriber's \u22EF menu to see the evidence.\n\n## Limits\n\n- **Rows.** One import takes up to 10,000 rows on Workers Paid, or 1,000 on the free plan's limits. The import screen says which applies to you. Split a larger list into several files, and import them one at a time.\n- **Daily database writes on the free plan.** Cloudflare's free plan allows a fixed number of database writes a day, and stops your newsletter until midnight UTC once they're used up. Bringing in a large list, in one file or several, and sending to it on the same day can use them up. So when the day's imports would use most of the day's writes, the plan warns you and suggests importing the file the next day, by UTC, and a post scheduled for a day imports have written warns you too, suggesting it go out the next day. Neither warning stops anything.\n- **When to move to Workers Paid.** From about 5,000 subscribers, it's recommended to move to the $5 Workers Paid plan, which has no daily limit on writes. Whoever runs your Kestrel makes the change, as [Go live](../get-started/06-go-live.md#as-your-list-grows) describes.\n\n## When readers reply\n\nKestrel only sends mail. A reader who replies to a post writes to your From address, so replies reach you once that address forwards to an inbox you read. Whoever runs your Kestrel sets that up outside Kestrel.\n\nWhen a reader replies asking to be taken off the list, carrying it out is up to you, since Kestrel never sees the reply. Find them under **Subscribers**, and choose **Unsubscribe** from their \u22EF menu. It takes effect at once. **Unsubscribe** is there for confirmed readers; one who never confirmed gets no posts, and **Erase** removes them.\n\nWhen a reader asks you to delete their data, choose **Erase** from their \u22EF menu instead. It removes the subscriber and how they joined, and can't be undone. Past send records show an erased recipient in their place. Kestrel keeps only a do-not-contact marker, or the bounce, complaint, or block the address already had, so the address is never mailed again. The reader can still rejoin by subscribing and confirming themselves.\n\n## Taking your list out\n\n**Subscribers \u2192 Export** downloads your whole list as one CSV in the same format: every subscriber with their state, how they joined and the evidence, and every bounced, complained, blocked, or erased address. It's yours to keep as a backup, or to import into another Kestrel, which carries the list over as it is. A value a spreadsheet would run as a formula is written as text, so the file is safe to open.\n\nClaude can read the same export a page at a time through the API, at `GET /api/subscribers/export`.\n\nNext, [Write your first post](04-write-a-post.md).\n";
|
|
6270
|
+
var your_list_default = "# Bring your existing list\n\nMoving to Kestrel from another newsletter service means bringing your subscribers with you, along with the consent they gave there. This guide explains how to export your list from the old service, convert it to Kestrel's format, and import it under **Subscribers \u2192 Import**. It also covers what carries over, the limits on an import, and taking your list out again. Kestrel shows you what an import will do before anything changes, and an import never emails your subscribers.\n\n**The first decision is whether to upgrade to Cloudflare's Workers Paid plan.** With more than about 5,000 subscribers, it's recommended to upgrade before you import. The free plan caps database writes per day, and a large import plus a send can use them up. When that happens, the app stops working until midnight UTC. Workers Paid is $5 a month and has no daily cap, and [As your list grows](../guides/09-as-your-list-grows.md) walks through the upgrade.\n\nIf you're starting without a list, skip this step and go on to [Write and send a post](04-write-a-post.md).\n\n## 1. Export from your old service\n\nExport everyone your old service knows about, not only your current subscribers. Unsubscribes and bounces matter too: once imported, Kestrel never mails someone who left or an address that fails. Include any consent details the export offers, such as signup dates, IP addresses, or form names. Kestrel keeps them with each subscriber as evidence of their consent.\n\n## 2. Convert it to Kestrel's format\n\nEvery service's export is different. Kestrel never guesses what one means, because a wrong guess about who agreed to be emailed can't be taken back. So you convert the export first, to one of two kinds of file:\n\n- **A CSV in Kestrel's format (preferred).** One row per address, with its status, how and where consent was collected, and any bounce or complaint. **Download example file (CSV)** on the import screen shows each case. Save your file as CSV UTF-8.\n- **A plain list (`.txt`).** One address per line, all of one kind: subscribed, unsubscribed, or bounced. Nothing else carries over, so use it only when addresses are all you have.\n\nA CSV in Kestrel's format looks like this, with the five columns that decide what happens to each row:\n\n```\nemail,status,opt_in,origin,subscribed_at\nada@example.com,subscribed,double,sendy,2024-03-01T09:30:00Z\ngrace@example.com,subscribed,single,mailchimp,\nedsger@example.com,unsubscribed,unknown,unknown,\n```\n\n[The file format](01-your-list.md#the-file-format), below, describes every column. If you use Claude Code with your instance, it can do the conversion for you, as [Working with Claude](07-working-with-claude.md) describes.\n\n## 3. Import it\n\nUnder **Subscribers \u2192 Import**, choose your file. Nothing changes yet: Kestrel plans the import and shows what it would do to every row. You see who is added, who is recorded as having left, who is suppressed, and which rows are left out. You can download the rows that won't be imported as subscribers, to check them against your file.\n\n**Import** then applies the plan, all of it or none of it. Before it does, you confirm two things when they apply:\n\n- **That everyone you're importing as subscribed opted in** to receive your publication, and the list isn't purchased, rented, or scraped. Kestrel keeps your attestation with the import.\n- **Each scheduled post that would grow.** A post goes to your list as it stands when it sends, so the people you import would receive it.\n\n## What carries over\n\nAsking your whole list to confirm again would lose readers who already said yes. So an import carries over the consent each person gave on your old service, and your word for it. Imported subscribers are **Confirmed** and receive your posts, and **How they joined** shows where they came from, such as \"Imported \xB7 Sendy, single opt-in\".\n\nA few things hold whatever the file says:\n\n- **Someone who unsubscribed here stays unsubscribed,** until they confirm again themselves.\n- **A bounce, a block, or an erasure here always wins.** An import never lifts one.\n- **A reader's newer choice stands.** Someone who confirmed here after your old service says they left stays subscribed.\n\n## Limits\n\n- **Rows.** Up to 10,000 in one import, or 1,000 on Cloudflare's free Workers plan. The import screen says which applies. Split a larger list into several files, and import them one at a time.\n- **Daily writes.** On the free Workers plan, an import or a large send warns you when it would use most of the day's database writes. Neither warning stops anything. [Limits in detail](01-your-list.md#limits-in-detail) gives the numbers.\n\n## Taking your list out\n\n**Subscribers \u2192 Export** downloads your whole list as one CSV in the same format. It holds every subscriber, how they joined, and every bounced, complained, blocked, or erased address. Keep it as a backup, or import it into another Kestrel to carry the list over.\n\n## The file format\n\nThis section defines `kestrel-subscribers/1` in full, for whoever converts a file, by hand or with Claude. The API serves the same definition at `GET /api/imports/format`, generated from the one the importer reads with. It serves the example files at `GET /api/imports/template.csv` and `GET /api/imports/template.txt`. A file's name decides its kind, `.csv` or `.txt`, and anything else is refused.\n\n### The CSV\n\nThe file is a CSV saved as UTF-8 (in a spreadsheet, **CSV UTF-8**). A header row names the columns below, in any order, then each row is one address. A value with a comma, a quote, or a line break in it is wrapped in double quotes. Each quote inside it is written twice, as spreadsheets write it (RFC 4180). Column names are written exactly, in lowercase. A row records what the source claims, never what Kestrel will do: the import decides that, under the rules further down.\n\n| Column | Required | Values | Meaning |\n| --- | --- | --- | --- |\n| `email` | yes | an email address | The address, trimmed and lowercased before it is matched. An invalid one is still a row, counted as invalid and reported, never dropped. |\n| `status` | yes | `subscribed`, `pending`, `unsubscribed`, `none` | The consent claim. `none` is a known address with no consent claim, present only to carry a suppression. |\n| `opt_in` | yes | `double`, `single`, `unknown` | How consent was collected. `unknown` unless the source states it. |\n| `origin` | yes | a short lowercase name | Where consent was first collected, as the file claims it: `sendy`, `mailchimp`, `kestrel`, or `unknown`. |\n| `suppression` | no | `bounce`, `complaint`, `manual`, `erased` | A suppression on top of `status`: the address bounced, complained, was suppressed by hand, or was erased. |\n| `subscribed_at` | no | an instant with its zone | When the person subscribed, if the source states it. |\n| `confirmed_at` | no | an instant with its zone | When the person confirmed, if the source states it. |\n| `unsubscribed_at` | no | an instant with its zone | When the person left, if the source states it. A reader who confirmed in Kestrel after it stays confirmed. |\n| `suppressed_at` | no | an instant with its zone | When the address was suppressed, if the source states it. |\n| `evidence` | no | a JSON object, at most 500 bytes | The source's consent-relevant columns, verbatim, such as a signup IP or a form name. Nothing reads it to decide anything: it is the proof of consent and the audit trail of a conversion. |\n| `format` | no | `kestrel-subscribers/1` | The version the file is written in. A file without it is read as version 1. |\n\nAn instant is a UTC ISO-8601 time with its zone, such as `2024-03-01T09:30:00Z` or `2024-03-01T10:30:00+01:00`. A time without a zone is refused, never interpreted. An optional column may be left out, or left empty on a row.\n\nAny other column is refused, naming it. A source's other columns belong in `evidence`, as JSON. A file whose header names none of these columns isn't in the format at all, and is refused as such.\n\nThe example file, `kestrel-import-example.csv`, shows each case: a double opt-in subscriber with evidence, a single opt-in one, someone who left, and a bounced address. Here are the same four with only the columns they use, since an optional column may be left out:\n\n```\nemail,status,opt_in,origin,suppression,unsubscribed_at,evidence\nada@example.com,subscribed,double,sendy,,,\"{\"\"signup_ip\"\":\"\"203.0.113.7\"\"}\"\ngrace@example.com,subscribed,single,mailchimp,,,\nedsger@example.com,unsubscribed,unknown,unknown,,2025-01-20T08:00:00Z,\nbounced@example.com,none,unknown,unknown,bounce,,\n```\n\n**Versions.** Adding an optional column keeps the version. Changing what a column means makes a new version. Kestrel reads every version it has published, and refuses one it doesn't know, by name.\n\n### The plain list\n\n- One address per line. Each line is trimmed, blank lines are skipped, and CRLF line endings and a UTF-8 byte-order mark are accepted.\n- A line that isn't a single valid address, such as `Name <ada@example.com>` or a typo, counts as invalid. It's listed among the rows left out. One bad line doesn't sink a list.\n- The whole file is refused when it isn't a plain list at all. That's a file that isn't UTF-8 text, or has no valid address in it. It's also one with a comma, tab, or semicolon in a line, since that makes it a table, so a `.csv`.\n- Its rows arrive with `origin: unknown` and `opt_in: unknown`.\n\n### Plan, then commit\n\nAn import happens in two steps. Planning shows what the import would do to every row, against your list as it stands. It changes nothing, so you can plan a file as often as you need. Committing applies the plan, all of it or none of it, and emails no subscriber.\n\nThe commit needs two things from you when they apply:\n\n- **Your attestation,** when anyone would be confirmed: \"Everyone I'm importing as subscribed opted in to receive [your publication]. This isn't a purchased, rented, or scraped list.\" It is kept with the import, with who gave it and when. An import that only unsubscribes or suppresses needs none.\n- **Each send that would grow,** acknowledged by a person. A post goes to the list as it stands when it fires. So an import can add to the audience of a scheduled send, or of one that has just fired and not yet fixed its audience. Claude can attest for you, but can't acknowledge a growing send: you finish that import in the dashboard.\n\nIf another import is committed after you plan, plan again. If your list changes between the plan and the commit in any other way, such as a reader unsubscribing, the commit still goes ahead. Every write checks the list as it stands, and the answer reports any row that turned out differently.\n\n### What an import does to each address\n\nEach row gets exactly one outcome. A row that both changes its status and adds a suppression reports its status outcome, and says it added a suppression too.\n\n| Outcome | When |\n| --- | --- |\n| `create_confirmed` | `subscribed`, with no subscriber and no suppression in Kestrel, and not erased in the file: added as confirmed. |\n| `confirm_pending` | `subscribed`, pending in Kestrel (never confirmed), and not erased in the file: confirmed, and its confirmation link no longer works. |\n| `unchanged` | Already confirmed in Kestrel, or already in the state the row claims. A subscriber who joined through Kestrel keeps their own record. |\n| `mark_unsubscribed` | `unsubscribed`, and confirmed or pending in Kestrel, with no confirmation in Kestrel newer than the row's `unsubscribed_at`: unsubscribed. |\n| `create_unsubscribed` | `unsubscribed`, with no subscriber in Kestrel, and not erased in Kestrel or in the file: recorded as unsubscribed, so the list remembers they left. |\n| `kept_confirmed_newer` | `unsubscribed`, but the reader confirmed in Kestrel after the source's `unsubscribed_at`: their newer action stands. |\n| `kept_unsubscribed` | `subscribed`, but unsubscribed in Kestrel, or pending again after leaving until they confirm in Kestrel themselves. An unsubscribe is never reversed. |\n| `blocked_by_suppression` | `subscribed`, but the address carries a suppression in Kestrel, a do-not-contact marker included: never added or confirmed. Also `unsubscribed`, when the address's suppression in Kestrel is the do-not-contact marker an erasure left. An address erased in the file gets this outcome too, when its row is `subscribed` or `unsubscribed` with no subscriber in Kestrel. So does one that's `subscribed` and pending in Kestrel, which stays pending. The file's `erased` suppression is still added. In each case no subscriber is recorded or confirmed, so an import never brings back an erased person's data. |\n| `suppression_added` | The row carries a suppression and the address has none in Kestrel, or only a do-not-contact marker, and nothing else changes. Applied whatever the row's `status`. |\n| `left_out` | `pending` or `none` with nothing to apply: counted, never confirmed. An import sends no email, so it can't start a confirmation either. |\n| `invalid` | The address isn't valid. |\n| `merged` | A repeat of an address earlier in the file, folded into it. |\n\nA suppression already in Kestrel is never replaced, with one exception. A bounce, a complaint, or a manual suppression replaces a do-not-contact marker left by an erasure, so a known bad address still gets no confirmation. An imported bounce or complaint is kept apart from one Kestrel saw itself, so the list says where each came from.\n\nAnyone can type an address into your subscribe form, which puts a reader who left back to pending until they confirm. An import treats such a reader as unsubscribed, so it never confirms someone who left in Kestrel.\n\n**Repeats of an address** are folded into one row first. Its status is the first of unsubscribed, subscribed, pending, and none that any repeat claims. Its suppression is the first of erased, complaint, bounce, and manual. Its opt-in is the weakest claim. It keeps the earliest consent times and the latest unsubscribe and suppression times, and the origin and evidence of the row whose status won.\n\nA subscriber an import confirmed is **confirmed**, like anyone who confirmed in Kestrel, and gets your posts. How they joined shows beside their state: imported, with the origin and opt-in method the file claimed, and the evidence. That holds even when the file says they confirmed through Kestrel itself. Only a confirmation Kestrel recorded reads as Kestrel's double opt-in, and a subscriber who later confirms in Kestrel becomes Kestrel's own.\n\n### Limits in detail\n\n- **Rows:** up to 10,000 in one import. On the free Workers plan's limits it's 1,000, since that plan's processing time per request can't plan and commit more. [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) tells Kestrel which plan it's on.\n- **Size:** an import request of at most 10 MB, and at most 500 bytes of `evidence` a row.\n- **Daily database writes on the free Workers plan:** 100,000 written rows a day. Once they're spent, the app stops working until midnight UTC. Each new subscriber an import brings in writes 7 rows, and a send writes about 14 a recipient. So an import of 1,000 new subscribers uses about 7% of a day, and about ten of them use most of it.\n- **The warnings:** an import's plan warns when it and the day's earlier imports, by UTC, would use more than 70% of the day's writes. It suggests importing the file the next day. A scheduled post warns when its send and that day's imports would use most of the day. With no imports, that's a send to more than 5,000 recipients. It suggests sending the next day, and its banner repeats the warning while it's scheduled. Neither warning stops anything.\n\n### Exporting\n\nThe export reads your whole list out in this format, a page at a time, at `GET /api/subscribers/export`. Each page holds up to 1,000 rows on the free Workers plan's limits, or 10,000 otherwise. Its `next` value asks for the page after it. Following `next` until it's `null` gives every address exactly once, even if the list changes while you read. An address added or removed partway through may or may not appear.\n\nTo make one CSV in this format, write the pages' rows under a single header. The dashboard's **Export** button, on the Subscribers screen, does this for you and downloads the whole list as one CSV. In the CSV, a value that starts with `=`, `+`, `-`, or `@` is written with an apostrophe before it. A spreadsheet then shows it as text rather than running it as a formula, and an import takes the apostrophe off again.\n\nThe export includes every subscriber with how they joined, their evidence, and their suppression. It also includes every address suppressed with no subscriber behind it, written as `status: none`. A confirmed subscriber is written as `subscribed`.\n\nImporting an export into another Kestrel carries the list over: the same states and suppressions, with every confirmation arriving as imported, carrying its original evidence. Pending subscribers are left out on the way in, since an import sends no confirmation.\n";
|
|
6271
6271
|
|
|
6272
6272
|
// docs/publishing/02-sending-rules.md
|
|
6273
|
-
var sending_rules_default = "# Sending rules\n\nEmail law asks a few things of every newsletter. Kestrel handles the ones a sending app can, and the rest are yours, since they depend on what you write and whom you write to.\n\nThis page covers what Kestrel handles, what you handle, and how to act on a reader's request to leave or be deleted. It describes US law (CAN-SPAM) unless it says otherwise. It's general information, not legal advice. When your situation is unclear, ask a lawyer.\n\n## What Kestrel handles\n\n- **Consent.** Kestrel mails only people who confirmed they want your newsletter. Someone who subscribes gets an email with a link, and only once they confirm do they receive posts. People you import come with your old service's record of their consent, and your word for it ([Bring your existing list](01-your-list.md#
|
|
6273
|
+
var sending_rules_default = "# Sending rules\n\nEmail law asks a few things of every newsletter. Kestrel handles the ones a sending app can, and the rest are yours, since they depend on what you write and whom you write to.\n\nThis page covers what Kestrel handles, what you handle, and how to act on a reader's request to leave or be deleted. It describes US law (CAN-SPAM) unless it says otherwise. It's general information, not legal advice. When your situation is unclear, ask a lawyer.\n\n## What Kestrel handles\n\n- **Consent.** Kestrel mails only people who confirmed they want your newsletter. Someone who subscribes gets an email with a link, and only once they confirm do they receive posts. People you import come with your old service's record of their consent, and your word for it ([Bring your existing list](01-your-list.md#what-carries-over)).\n- **Unsubscribing.** Every email has an unsubscribe link, and the one-click unsubscribe header that Gmail and Yahoo ask bulk senders for. It takes effect at once. US law allows up to 10 business days.\n- **Bounces and complaints.** An address that bounces, or a reader who marks your email as spam, is never mailed again. So a bad address can't keep hurting your sending reputation.\n- **Sender authentication.** Your emails are signed with DKIM for your own domain, and SPF and DMARC records in its DNS vouch for them. You set them up in [Connect Amazon SES](../get-started/04-ses.md), and mailbox providers now expect them.\n\n## What you handle\n\n- **An honest From name and subject line.** The From name should say who the email is from, and the subject should match what's inside. The From name and address are set when Kestrel is deployed ([Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy)).\n- **Marking ads as ads.** When an email is an advertisement, say so clearly. In a newsletter that's mostly writing, labeling a sponsor's section is enough.\n- **A mailing address, when an email is commercial.** Most newsletters don't need one. [When to provide a mailing address](03-name-your-publication.md#when-to-provide-a-mailing-address) says when you do, beside where you add one.\n- **Requests readers send you.** A reader who replies to a post writes to your From address. Replies reach you once that address forwards to an inbox you read, as [Connect Amazon SES](../get-started/04-ses.md#3-receive-replies-at-your-from-address) sets up. Kestrel never sees a reply, so carrying out what a reader asks is up to you.\n\n## When a reader asks to leave or to be deleted\n\nKestrel offers readers no form for deleting their data, so they ask you. In many places the law gives a reader that right. The European Union's GDPR, for example, may apply if you have readers there.\n\n- **Say how to reach you.** Your privacy notice, wherever you publish it, should say how readers ask you about their data. Replying to any post works once replies reach your inbox.\n- **To stop the email,** find a confirmed reader under **Subscribers**, and choose **Unsubscribe** from their \u22EF menu. It records that they asked, and they can rejoin by subscribing again. A reader who never confirmed gets no posts, so **Erase** is the choice for them.\n- **To stop mailing someone on your own judgment,** not at their request, choose **Block** instead. It stops every email to the address whatever its status, and **Unblock** undoes it.\n- **To delete their data,** choose **Erase** from the same menu. It removes the subscriber and how they joined, and can't be undone. Past send records show an erased recipient in their place. Kestrel keeps only a do-not-contact marker, or the bounce, complaint, or block the address already had, so it's never mailed again. The reader can still rejoin by subscribing and confirming themselves.\n- **Act promptly.** Under the [GDPR](https://eur-lex.europa.eu/eli/reg/2016/679/oj), you answer within a month in most cases (Article 12). The right to have data deleted is Article 17, and Article 13 asks you to tell readers about it when they sign up.\n\n## Outside the US\n\nOther countries have their own rules, and they often ask more. Canada's anti-spam law (CASL) usually counts more email as commercial, and asks for a mailing address in it ([Government of Canada guidance](https://ised-isde.canada.ca/site/canada-anti-spam-legislation/en)). The GDPR covers how you keep readers' data. If many of your readers live outside the US, check the rules where they live.\n\nThe FTC's [CAN-SPAM compliance guide](https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business) is the plain-language source for the US rules on this page.\n";
|
|
6274
6274
|
|
|
6275
6275
|
// docs/publishing/03-name-your-publication.md
|
|
6276
|
-
var name_your_publication_default = "# Name your publication\n\nGive your newsletter a name and a look before your first post.
|
|
6276
|
+
var name_your_publication_default = "# Name your publication\n\nGive your newsletter a name and a look before your first post. Set the name, tagline, and logo under **Settings \u2192 Publication identity**. They appear in every email your template prints them in, on the public pages readers see, and in the confirmation email new subscribers get.\n\nAll three are optional, and you can change them at any time. How they appear in each email comes from your email template, which you can [edit](11-email-template.md).\n\n## When to provide a mailing address\n\n**If your newsletter is mostly your own writing, you probably don't need one. If an email is mainly selling something, add one.**\n\nUS law (CAN-SPAM) asks for a postal address in commercial email. The FTC [treats a newsletter people subscribed to](https://www.ftc.gov/sites/default/files/documents/federal_register_notices/definitions-and-implementation-under-can-spam-act-16-cfr-part-316/080521canspamact.pdf) as a relationship message instead, even with a few ads in it. An email whose main purpose is to sell, such as a launch, a sale, or a sponsor's promotion, is commercial. Canada's law (CASL) asks for an address more often ([Government of Canada guidance](https://ised-isde.canada.ca/site/canada-anti-spam-legislation/en)).\n\nA P.O. box or a registered private mailbox counts, so you never have to publish your home address. To add one, choose **Add a mailing address** under **Settings \u2192 Publication identity**. The built-in templates print it in the email footer and keep it off your public pages.\n\nThis is general information, not legal advice. The FTC's [CAN-SPAM compliance guide](https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business) has the detail, and [Sending rules](02-sending-rules.md) covers the rest of what the law asks of a newsletter.\n\n## Who sets the From name and address\n\nThe From name and address your emails come from are set when Kestrel is deployed, so **Settings \u2192 Email sender** shows them read-only. To change them, follow [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy).\n\n";
|
|
6277
6277
|
|
|
6278
6278
|
// docs/publishing/04-write-a-post.md
|
|
6279
|
-
var write_a_post_default = "# Write
|
|
6279
|
+
var write_a_post_default = "# Write and send a post\n\nYou write a post in Markdown, test it, and schedule it. Scheduling locks the post and makes the exact email that will go out. Then you send yourself a test of that copy, so the email you tested is the one your list receives.\n\n**New post**, on the Dashboard or under **Drafts**, opens a draft.\n\n## Writing\n\nKestrel's editor is a Markdown editor. You write a post in Markdown, the way you would for a static site generator or with an AI assistant. Kestrel places it in your [email template](11-email-template.md). The same Markdown makes the email and the post's archive page, so you never format a post twice. **Preview** shows the email as it will look, and Kestrel saves as you type.\n\nA few things to know as you write:\n\n- **A blank line starts a new paragraph.** In an email, a single line break doesn't.\n- **The opening lines** are the preview a mail client shows beside the subject.\n- **Images** go in by paste or drop, and Kestrel inserts a reference by file name, such as ``. Replace the words in brackets with a short description. PNG, JPEG, WebP, or GIF, up to 5 MB each.\n- **The subject** sets the post's slug, the end of its permanent archive address, such as `newsletter.example.com/archive/spring-update`. The slug is fixed once you schedule.\n\n## Test it\n\n**Send test email** sends the real email, through the same template and code as the send, to the addresses you name. **Settings \u2192 Default test recipients** fills them in for you. Nobody on your list gets anything, and a test's unsubscribe link unsubscribes no one.\n\nMail clients each draw email a little differently, so trust a test over **Preview**, especially for tables, code, and HTML. A test needs an email provider, which [Connect Amazon SES](../get-started/04-ses.md) sets up.\n\n## Schedule and send\n\nChoose **Schedule** at the foot of the editor, pick a time under **Send at**, and confirm. The time must be at least the minimum lead away: five minutes, unless your deployment sets a different [`MIN_LEAD_SECONDS`](../reference/01-configuration.md#min_lead_seconds). Neither the dashboard nor Claude can shorten it. Times are to the minute, and one with seconds rounds up.\n\nScheduling makes the email once, from the post, your template, and your publication's details as they are at that moment. That frozen copy is what goes out, and it's also the post's page in your archive. So the post is locked, and **Preview** and **Send test email** show the frozen copy.\n\n**Send yourself a test now.** It's the copy your list receives, and the time until the send is your window to catch a mistake, including one Claude made.\n\nChanging your template, or a publication detail it shows, re-makes the email of every scheduled post, with the same content and send time. Kestrel asks before it saves, and each re-made post then asks you to send a new test. A change is refused while a post is within the minimum lead of its send time, and Kestrel says when to try again. Posts already sending or sent never change.\n\n**Send now \u2192**, under the time picker, is for a post you've already reviewed. It still waits out the window, scheduling the post for the minimum lead from now.\n\n### Changing your mind\n\n**Cancel**, on the post's banner or its card under **Sent**, makes the post a draft again at once. Edit it, test again, and schedule it again. **Reschedule** moves the send time without unlocking the post, and your tests still count. You can do either until the send time.\n\n### Who receives it\n\nA post goes to your list as it stands when it sends: every confirmed subscriber, except addresses that bounced, complained, or were blocked. Someone who confirms while it's scheduled receives it. Someone who unsubscribes before their email is handed to your provider doesn't, even partway through the send.\n\nSo a recipient count shown before the send is a snapshot. An import that adds subscribers while a post is scheduled adds them to its audience too, and asks you first ([Bring your existing list](01-your-list.md#3-import-it)).\n\n### Warnings when you schedule\n\n- **No email provider connected.** The Schedule dialog can't schedule until one is connected ([Connect Amazon SES](../get-started/04-ses.md)). If the provider goes after a post is scheduled, the post is held, stays cancelable, and goes out once one is connected.\n- **Daily database writes.** On Cloudflare's free Workers plan, a large send may use up the day's writes. The dialog warns you without stopping you, as [Bring your existing list](01-your-list.md#limits) explains.\n\n### When it sends\n\nAt the send time, the post moves to its send page under **Sent**, where you can watch it go out. From then it can't be edited, canceled, or moved. Its archive page goes up at the same moment, showing exactly the copy that was sent, so every email's \"view in browser\" link works. Whatever interrupts a send, such as your provider's daily quota running out, it waits and resumes where it stopped. No reader gets the same post twice.\n\nIf a send needs your attention, the send page and the dashboard say so. [Troubleshooting](06-troubleshooting.md) covers each case.\n";
|
|
6280
6280
|
|
|
6281
|
-
// docs/publishing/
|
|
6282
|
-
var
|
|
6281
|
+
// docs/publishing/06-troubleshooting.md
|
|
6282
|
+
var troubleshooting_default = `# Troubleshooting
|
|
6283
6283
|
|
|
6284
|
-
|
|
6284
|
+
When a send needs you, its page and the dashboard's health block say so in a red or amber line. Red needs you to act, and amber is worth a look. Find the line's words below for what it means and what to do.
|
|
6285
6285
|
|
|
6286
|
-
|
|
6286
|
+
**Set a notifications address, so a problem reaches you without your watching.** Set it under **Settings \u2192 Notifications**, in **Send notifications to**. With none set, Kestrel emails you nothing. [Notifications](06-troubleshooting.md#notifications), below, lists what you're told.
|
|
6287
6287
|
|
|
6288
|
-
##
|
|
6288
|
+
## A send is waiting or held
|
|
6289
6289
|
|
|
6290
|
-
|
|
6290
|
+
A problem with your email provider never marks anyone unsent. The send waits where it is, and carries on from there once the problem clears.
|
|
6291
6291
|
|
|
6292
|
-
The
|
|
6292
|
+
- **"The provider is refusing the account".** Your provider won't take mail from your account, and its own words follow, with the fix they point to. The fix is almost always in your Amazon SES account or Kestrel's deployment, and the message names it. A spent daily sending quota frees up within a day, and you can ask AWS for a higher one, as [As your list grows](../guides/09-as-your-list-grows.md) describes.
|
|
6293
|
+
- **"No email provider is connected".** The send is held until one is connected ([Connect Amazon SES](../get-started/04-ses.md)), and goes out once it is.
|
|
6294
|
+
- **"The provider is unavailable".** An outage or a rate limit on the provider's side. The send retries on its own, so there's nothing to do.
|
|
6295
|
+
- **"Still sending \u2026 after it started".** A send is taking longer than expected, and may be retrying. If the line stays, read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes.
|
|
6296
|
+
- **"The fire time passed \u2026 and the send has not started".** The Cloudflare Cron Trigger that starts sends on time isn't running. The send goes out as soon as it runs again. Check the Worker's trigger, as [\`triggers.crons\`](../reference/01-configuration.md#triggerscrons) describes.
|
|
6297
|
+
- **"The provider never answered for \u2026".** Rarely, Kestrel can't tell whether your provider took some emails, and sending again could mail those readers twice. **Resolve\u2026** on the send's page asks you to decide. **Assume not sent** is the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it.
|
|
6293
6298
|
|
|
6294
|
-
|
|
6299
|
+
## Before a send
|
|
6295
6300
|
|
|
6296
|
-
|
|
6301
|
+
- **"The template or publication details changed this email's look after your last test".** Send a new test of the post, since your last one no longer shows what goes out.
|
|
6302
|
+
- **The daily database writes warning.** On Cloudflare's free Workers plan, a large send may use up the day's writes, and then the app stops working until midnight UTC. [Bring your existing list](01-your-list.md#limits) explains the limit, and when to upgrade to Workers Paid.
|
|
6297
6303
|
|
|
6298
|
-
|
|
6304
|
+
## Reading a send's record
|
|
6299
6305
|
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
## What scheduling freezes
|
|
6303
|
-
|
|
6304
|
-
Scheduling makes the email once, from the post, your email template, and your publication's details as they are at that moment. That frozen copy is what goes out, and it's also the post's page in your archive.
|
|
6305
|
-
|
|
6306
|
-
So the post is locked while it's scheduled. It opens on **Preview**, which shows the frozen copy. On **Edit**, you can still read, select, and copy the text, and the composer's foot says "Cancel the schedule to edit". **Send test email** still works, and sends you the frozen copy that will go out.
|
|
6307
|
-
|
|
6308
|
-
To change the post's content, cancel the schedule, edit, test again, and schedule it again. That resets your review, so your last test is always of the copy that sends.
|
|
6309
|
-
|
|
6310
|
-
A change to your template, or to a publication detail the template shows, is different. It re-makes the email of every scheduled post, at the same send times, with the same content. Kestrel asks before it saves, in a dialog confirmed with **Save and apply**. Afterwards each re-made post's banner asks you to send a new test. The dashboard's card reads "Look changed since your last test" until you do. A save like this is refused while any scheduled post is within the minimum lead of its send time. The refusal says when to try again. [Name your publication](03-name-your-publication.md) covers the template and those details.
|
|
6311
|
-
|
|
6312
|
-
## Canceling
|
|
6313
|
-
|
|
6314
|
-
**Cancel** on the scheduled banner, or on the **Scheduled** card on **Sent**, stops the send at once. No confirmation is asked, since nothing is lost: the post is a draft again, editable, and you can schedule it again whenever you like.
|
|
6315
|
-
|
|
6316
|
-
You can cancel until the send time itself. From then the banner reads "Preparing to send\u2026" and its buttons are unavailable, because the email is going out. A cancel that arrives just after the send time is refused with "Too late to cancel: the send time has passed, so this email is going out."
|
|
6317
|
-
|
|
6318
|
-
## Moving the send time
|
|
6319
|
-
|
|
6320
|
-
**Reschedule** moves when a scheduled post sends, without canceling it. It opens the same time picker, and **Reschedule** confirms it. The content stays frozen, your tests still count, and the post stays cancelable until the new time.
|
|
6321
|
-
|
|
6322
|
-
The new time follows the same rule as scheduling: at least the minimum lead away. Like canceling, you can move a send only before its send time.
|
|
6323
|
-
|
|
6324
|
-
## Sending now
|
|
6325
|
-
|
|
6326
|
-
Under the time picker in the Schedule dialog, "Or send as soon as possible." leads to **Send now \u2192**. It's for a post you've already reviewed and want out without picking a time.
|
|
6327
|
-
|
|
6328
|
-
Sending now still goes through the review window. The **Send now?** view says how many recipients it reaches and when, such as "in 5 to 6 minutes". The range is the minimum lead, plus rounding up to the next minute. Confirm with **Send now**, and the post is locked and scheduled for that time, cancelable until then like any other.
|
|
6329
|
-
|
|
6330
|
-
## Who receives it
|
|
6331
|
-
|
|
6332
|
-
A post goes to your list as it stands when it sends: every confirmed subscriber, except addresses that bounced, complained, or were blocked. Someone who confirms while the post is scheduled receives it. Someone who unsubscribes before their email is handed to your email provider doesn't, even partway through the send.
|
|
6333
|
-
|
|
6334
|
-
So the recipient count the **Send now?** view shows is a snapshot, not a promise. An import that adds subscribers while a post is scheduled adds them to its audience too. The import asks you to acknowledge that first ([Bring your existing list](01-your-list.md#what-an-import-does)).
|
|
6335
|
-
|
|
6336
|
-
## Warnings in the Schedule dialog
|
|
6337
|
-
|
|
6338
|
-
The Schedule dialog warns you about two things before you schedule.
|
|
6339
|
-
|
|
6340
|
-
- **No email provider connected.** The dialog says the post can't be scheduled yet, and its buttons are unavailable. Connecting a provider is for whoever runs your Kestrel, as [Connect Amazon SES](../get-started/04-ses.md) describes. If the provider goes after a post is scheduled, its banner reads "Will be held: no email provider is connected." The post stays cancelable, and goes out once a provider is connected.
|
|
6341
|
-
- **Daily database writes.** On the Workers Free plan's limits, a large send may use up the day's database writes, which stops your newsletter until midnight UTC. The dialog warns you, and the post's banner repeats the warning for as long as it's scheduled. It never stops you from scheduling. [Bring your existing list](01-your-list.md#limits) explains the limit and when it's worth moving to Workers Paid.
|
|
6342
|
-
|
|
6343
|
-
## When it sends
|
|
6344
|
-
|
|
6345
|
-
At the send time, the post moves from the editor to its send page under **Sent**, where you can watch it go out. If you have the post open, the page switches over with a toast saying why. From here the post can't be edited, canceled, or moved.
|
|
6346
|
-
|
|
6347
|
-
A send goes out a slice at a time. A large list takes several slices, and between them the send still reads **Sending**, with "Paused between ticks. It resumes on the next sweep."
|
|
6348
|
-
|
|
6349
|
-
If your email provider refuses to take more, for example because your account's daily sending quota is spent, the send waits. Nobody is marked unsent, and it tries again on its own, picking up where it stopped once the quota frees up. Raising the quota is for whoever runs your Kestrel ([As your list grows](../get-started/06-go-live.md#as-your-list-grows)).
|
|
6350
|
-
|
|
6351
|
-
Whatever interrupts a send, it resumes rather than starting over. No reader is ever mailed the same post twice.
|
|
6352
|
-
|
|
6353
|
-
[After a send](06-after-a-send.md) covers what the send page shows once it's out: the record, bounces, complaints, unsent recipients, and your archive page.
|
|
6354
|
-
`;
|
|
6355
|
-
|
|
6356
|
-
// docs/publishing/06-after-a-send.md
|
|
6357
|
-
var after_a_send_default = `# After a send
|
|
6358
|
-
|
|
6359
|
-
Once a send starts, Kestrel keeps a permanent record of it: who it went to, and what happened to each email. The post goes up on its public page in your archive at the same moment, exactly as it was sent. Nothing about either can be edited, and you don't have to watch: Kestrel emails you when the send finishes, and when something needs you.
|
|
6360
|
-
|
|
6361
|
-
This page covers a send's record, unsent recipients, bounces and complaints, your notifications, the dashboard's views, and the post's public page.
|
|
6362
|
-
|
|
6363
|
-
## The send's record
|
|
6364
|
-
|
|
6365
|
-
Every send has its own page, which you open from **Sent** or from the dashboard's **Sent** list. While the send goes out, the page is a live view of its progress. Once it finishes, the same page becomes its record, in place.
|
|
6366
|
-
|
|
6367
|
-
The record opens with five counts, over everyone the send went to:
|
|
6306
|
+
A send's page becomes its permanent record once it finishes. Its counts cover everyone the send went to:
|
|
6368
6307
|
|
|
6369
6308
|
| Count | What it means |
|
|
6370
6309
|
| --- | --- |
|
|
6371
|
-
| **
|
|
6372
|
-
| **
|
|
6373
|
-
| **
|
|
6374
|
-
| **
|
|
6375
|
-
| **Unsent** | Recipients Kestrel couldn't hand to your email provider. [Unsent recipients](#unsent-recipients), below, says why. |
|
|
6376
|
-
|
|
6377
|
-
A line under the counts accounts for every recipient by name, so the numbers always add up. It also names two kinds the counts leave out. A recipient **accepted, awaiting a delivery receipt** was taken by your email provider, with no word yet on what happened next. A **skipped** recipient unsubscribed or was suppressed before their turn, so they were never mailed.
|
|
6378
|
-
|
|
6379
|
-
Below that is the full list of recipients. It opens on **Failures** (bounced, complained, and unsent), and **Delivered** and **All** are one click away. You can search it by address. Each row shows the outcome, the provider's own words about it, and when it happened. A person you erased reads as **Erased recipient** in place of their address. **Export recipients (CSV)** downloads the whole list.
|
|
6380
|
-
|
|
6381
|
-
### Accepted, then settled
|
|
6382
|
-
|
|
6383
|
-
Delivery happens in two stages, and the record keeps them apart. First Kestrel hands each email to your email provider, which accepts it, usually within minutes. Later, the provider reports what happened to each email: delivered, bounced, or complained. Those reports can lag by seconds or by days.
|
|
6384
|
-
|
|
6385
|
-
So a send is **sent** once every recipient has been handed off. Its record then keeps updating as reports arrive. The page says **Settling** while it waits, and **Complete** once every accepted email is confirmed. Some accepted emails are never confirmed at all, so a send can stay **Settling** for good. That's normal, not a fault.
|
|
6310
|
+
| **Delivered** | Your provider confirmed the email reached the reader's mail server. |
|
|
6311
|
+
| **Bounced** | The receiving server turned the email away. |
|
|
6312
|
+
| **Complained** | The reader marked the email as spam. |
|
|
6313
|
+
| **Unsent** | Kestrel couldn't hand the email to your provider. |
|
|
6386
6314
|
|
|
6387
|
-
|
|
6315
|
+
Delivered always means confirmed, not just accepted by your provider. Confirmations can lag by days, so counts keep rising after a send finishes. Some emails are never confirmed at all, so a send can read **Settling** for good, which is normal.
|
|
6388
6316
|
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
An unsent recipient never got this post, and is still on your list. They get your next post as usual.
|
|
6392
|
-
|
|
6393
|
-
A recipient is recorded unsent in two cases:
|
|
6394
|
-
|
|
6395
|
-
- **Their own email kept failing.** When handing off one address hits a passing error, Kestrel tries again a few times, then records it unsent. The row's detail gives the provider's words.
|
|
6396
|
-
- **You chose Assume not sent.** Rarely, Kestrel can't tell whether your provider took some emails. The send waits for you, and **Resolve\u2026** on its page asks you to decide. **Assume not sent** is the safe default: those readers may miss this post, but no one is mailed twice.
|
|
6397
|
-
|
|
6398
|
-
A problem with your email provider itself never marks anyone unsent. While the provider is down, or refusing your account, the send waits and tries again. Once the problem is fixed, it carries on from where it stopped.
|
|
6317
|
+
**Unsent recipients** are still on your list, and get your next post. A recipient is unsent when their own email kept failing, with the provider's words on the row, or when you chose **Assume not sent**.
|
|
6399
6318
|
|
|
6400
6319
|
## Bounces and complaints
|
|
6401
6320
|
|
|
6402
|
-
|
|
6321
|
+
Kestrel acts on these for you, so a bad address can't keep hurting your sending reputation:
|
|
6403
6322
|
|
|
6404
|
-
- **A hard bounce** means the address can't receive mail
|
|
6405
|
-
- **A soft bounce** is temporary, such as a full mailbox.
|
|
6406
|
-
- **A complaint** means the reader marked your email as spam. Kestrel
|
|
6323
|
+
- **A hard bounce** means the address can't receive mail. Kestrel never mails it again.
|
|
6324
|
+
- **A soft bounce** is temporary, such as a full mailbox. The address stays on your list.
|
|
6325
|
+
- **A complaint** means the reader marked your email as spam. Kestrel never mails them again.
|
|
6407
6326
|
|
|
6408
|
-
|
|
6409
|
-
|
|
6410
|
-
When a recent send's bounces reach about 5% of its recipients, the dashboard warns you. That's about the rate at which email providers start to review a sender, so it's worth finding out why.
|
|
6327
|
+
Under **Subscribers**, a suppressed address keeps its status and carries a label saying why. When a recent send's bounces reach about 5% of its recipients, the dashboard warns you. That's about the rate at which email providers start to review a sender, so check the addresses on its record.
|
|
6411
6328
|
|
|
6412
6329
|
### Lifting a suppression
|
|
6413
6330
|
|
|
6414
|
-
|
|
6415
|
-
|
|
6416
|
-
A block is yours to lift: **Unblock** in the subscriber's \u22EF menu, which asks you to confirm. It's offered for a block you made, or one an import brought. The dashboard has no control for lifting a bounce or a complaint, but Claude can do it for you through the API, as [Working with Claude](07-working-with-claude.md) describes. Lifting a suppression changes nothing on past records: the bounce or complaint stays where it happened.
|
|
6417
|
-
|
|
6418
|
-
## Notifications
|
|
6419
|
-
|
|
6420
|
-
Kestrel emails you when a send finishes, and when a send runs into a problem it can't settle on its own. So you only need to open the dashboard to act.
|
|
6421
|
-
|
|
6422
|
-
Set the address under **Settings \u2192 Notifications**, in **Send notifications to**. **Send a test notification** checks that one arrives. With no address set, Kestrel sends nothing, and doesn't save anything up for later.
|
|
6423
|
-
|
|
6424
|
-
You're told:
|
|
6425
|
-
|
|
6426
|
-
- **When a send finishes,** with how many emails your provider accepted, how many were unsent, and how many were skipped.
|
|
6427
|
-
- **When your provider refuses your account,** with the provider's own words and what they point to. Amazon SES reports a spent daily quota this way too.
|
|
6428
|
-
- **When a send is held because no email provider is connected,** and that it goes out once one is.
|
|
6429
|
-
- **When a send stays in progress too long.**
|
|
6430
|
-
- **When a send waits for you to resolve it.**
|
|
6431
|
-
- **When a send missed its time,** going out late or not at all.
|
|
6432
|
-
- **When Claude imports a list for you,** with what the import changed.
|
|
6433
|
-
|
|
6434
|
-
Each notification links straight to the page it's about. Each event is told once, not again while it lasts, and the dashboard keeps showing it until it clears. A bounce spike is shown on the dashboard, but isn't emailed.
|
|
6435
|
-
|
|
6436
|
-
The **Last notification** row in the same section says whether the latest one was **Delivered** or **Not delivered**. Notifications are separate from your newsletter: one that fails never delays or changes a send.
|
|
6437
|
-
|
|
6438
|
-
Whoever runs your Kestrel chooses how notifications are sent. By default they go through your newsletter's own email provider. Then the one about your provider refusing your account can't reach you, since that provider would refuse it too. Whoever runs your Kestrel can send them another way that avoids this, as [Notifications through Cloudflare's email](../guides/03-notifications.md) describes.
|
|
6331
|
+
Lift one only when you know the address works again, such as a reader who tells you their mailbox is fixed. Mailing an address that keeps failing can hurt every send after it. **Unblock**, in a subscriber's \u22EF menu, lifts a block. The dashboard can't lift a bounce or a complaint, but [Claude](07-working-with-claude.md) can, when you ask. Past records keep the bounce or complaint where it happened.
|
|
6439
6332
|
|
|
6440
|
-
##
|
|
6333
|
+
## An import is refused
|
|
6441
6334
|
|
|
6442
|
-
|
|
6335
|
+
Kestrel refuses a file it can't read with certainty, and says what's wrong and where. Nothing on your list changes.
|
|
6443
6336
|
|
|
6444
|
-
- **
|
|
6445
|
-
- **
|
|
6446
|
-
- **
|
|
6447
|
-
- **
|
|
6337
|
+
- **Not in the format.** The file isn't a Kestrel CSV or a plain list. Convert it first, with Claude or by hand.
|
|
6338
|
+
- **A column or value the format doesn't take,** named with its row and column. Fix it in the file, or move a column of your old service's into \`evidence\`.
|
|
6339
|
+
- **Too many rows** for one import. Split the file.
|
|
6340
|
+
- **Too large.** An import takes up to 10 MB.
|
|
6448
6341
|
|
|
6449
|
-
|
|
6342
|
+
[The file format](01-your-list.md#the-file-format) describes what each column takes.
|
|
6450
6343
|
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
While a send goes out, its page shows two bars. The first fills as your provider accepts each email, and the second fills behind it as deliveries are confirmed. A label beside the subject says how it's going, such as **Sending**, **Retrying**, or **Backing off**.
|
|
6454
|
-
|
|
6455
|
-
## The post's public page
|
|
6456
|
-
|
|
6457
|
-
Your post goes up in your archive the moment its send starts. So the "view in browser" link in every email works as soon as it lands. Its publish date is the day its send started.
|
|
6344
|
+
## Notifications
|
|
6458
6345
|
|
|
6459
|
-
|
|
6346
|
+
With an address set, Kestrel emails you:
|
|
6460
6347
|
|
|
6461
|
-
|
|
6348
|
+
- when a send finishes, with how many emails were accepted, unsent, and skipped
|
|
6349
|
+
- when your provider refuses your account, or no provider is connected
|
|
6350
|
+
- when a send stays in progress too long, waits for you to resolve it, or misses its time
|
|
6351
|
+
- when Claude imports a list for you
|
|
6462
6352
|
|
|
6463
|
-
|
|
6353
|
+
Each links to the page it's about, and is sent once while the problem lasts. A bounce spike shows on the dashboard but isn't emailed. **Send a test notification** checks that one arrives.
|
|
6464
6354
|
|
|
6465
|
-
|
|
6355
|
+
By default, notifications go through your newsletter's own email provider. So when that provider refuses your account, the notification about it can't reach you either. You can send them another way, as [Notifications through Cloudflare's email](../guides/03-notifications.md) describes.
|
|
6466
6356
|
`;
|
|
6467
6357
|
|
|
6468
6358
|
// docs/publishing/07-working-with-claude.md
|
|
6469
|
-
var working_with_claude_default = "# Working with Claude\n\
|
|
6470
|
-
|
|
6471
|
-
// docs/publishing/08-import-format.md
|
|
6472
|
-
var import_format_default = "# Subscriber import format\n\nKestrel imports a subscriber list in one documented format, `kestrel-subscribers/1`, and exports the whole list in the same format. This page describes the format, what an import does to each address, and the limits.\n\nKestrel never guesses what another service's export means, because a wrong guess about who agreed to be emailed can't be taken back. An export from Sendy, Mailchimp, or anywhere else is converted to this format first, by Claude or by hand. The API serves everything on this page at `GET /api/imports/format`, generated from the same definition the importer reads with, along with an example of each kind of file, `kestrel-import-example.csv` at `GET /api/imports/template.csv` and `kestrel-import-example.txt` at `GET /api/imports/template.txt`. The import screen offers both as **Download example file**.\n\n## Two kinds of file\n\n- **The full CSV (preferred).** It carries unsubscribes, bounces, and how each person joined. It's what the export writes, and you can open it in a spreadsheet to check it before importing.\n- **A plain list (`.txt`).** One address per line, for a list that is only addresses. You say what they all are: subscribed (the default), unsubscribed, or bounced. Nothing else carries over: not how or when each person joined, when anyone left, or any evidence of their consent. And since every address is the one kind you choose, unsubscribes and bounces each need a file of their own.\n\nThe file's name decides which it is: `.csv` or `.txt`. Anything else is refused.\n\n## The CSV\n\nA CSV file saved as UTF-8 (in a spreadsheet, **CSV UTF-8**), with a header row of the column names below, in any order, then one row per address. A value with a comma, a quote, or a line break in it is wrapped in double quotes, with each quote inside written twice, as spreadsheets write it (RFC 4180). Column names are written exactly, in lowercase. A row records what the source claims, never what Kestrel will do: the import decides that, under the rules further down.\n\n| Column | Required | Values | Meaning |\n| --- | --- | --- | --- |\n| `email` | yes | an email address | The address, trimmed and lowercased before it is matched. An invalid one is still a row, counted as invalid and reported, never dropped. |\n| `status` | yes | `subscribed`, `pending`, `unsubscribed`, `none` | The consent claim. `none` is a known address with no consent claim, present only to carry a suppression. |\n| `opt_in` | yes | `double`, `single`, `unknown` | How consent was collected. `unknown` unless the source states it. |\n| `origin` | yes | a short lowercase name | Where consent was first collected, as the file claims it: `sendy`, `mailchimp`, `kestrel`, or `unknown`. |\n| `suppression` | no | `bounce`, `complaint`, `manual`, `erased` | A suppression on top of `status`: the address bounced, complained, was suppressed by hand, or was erased. |\n| `subscribed_at` | no | an instant with its zone | When the person subscribed, if the source states it. |\n| `confirmed_at` | no | an instant with its zone | When the person confirmed, if the source states it. |\n| `unsubscribed_at` | no | an instant with its zone | When the person left, if the source states it. A reader who confirmed in Kestrel after it stays confirmed. |\n| `suppressed_at` | no | an instant with its zone | When the address was suppressed, if the source states it. |\n| `evidence` | no | a JSON object, at most 500 bytes | The source's consent-relevant columns, verbatim, such as a signup IP or a form name. Nothing reads it to decide anything: it is the proof of consent and the audit trail of a conversion. |\n| `format` | no | `kestrel-subscribers/1` | The version the file is written in. A file without it is read as version 1. |\n\nAn instant is a UTC ISO-8601 time with its zone, such as `2024-03-01T09:30:00Z` or `2024-03-01T10:30:00+01:00`. A time without a zone is refused, never interpreted. An optional column may be left out, or left empty on a row.\n\nAny other column is refused, naming it. A source's other columns belong in `evidence`, as JSON. A file whose header names none of these columns isn't in the format at all, and is refused as such.\n\nThe example file, `kestrel-import-example.csv`, shows each case: a double opt-in subscriber with evidence, a single opt-in one, someone who left, and a bounced address. Here are the same four with only the columns they use, since an optional column may be left out:\n\n```\nemail,status,opt_in,origin,suppression,unsubscribed_at,evidence\nada@example.com,subscribed,double,sendy,,,\"{\"\"signup_ip\"\":\"\"203.0.113.7\"\"}\"\ngrace@example.com,subscribed,single,mailchimp,,,\nedsger@example.com,unsubscribed,unknown,unknown,,2025-01-20T08:00:00Z,\nbounced@example.com,none,unknown,unknown,bounce,,\n```\n\n**Versions.** Adding an optional column keeps the version. Changing what a column means makes a new version. Kestrel reads every version it has published, and refuses one it doesn't know, by name.\n\n## The plain list\n\n- One address per line. Each line is trimmed, blank lines are skipped, and CRLF line endings and a UTF-8 byte-order mark are accepted.\n- A line that isn't a single valid address, such as `Name <ada@example.com>` or a typo, is counted as invalid and listed among the rows left out. One bad line doesn't sink a list.\n- The whole file is refused when it isn't a plain list at all: not UTF-8 text, a line with a comma, tab, or semicolon (it's a table, so a `.csv`), or not one valid address in it.\n- Its rows arrive with `origin: unknown` and `opt_in: unknown`.\n\n## Plan, then commit\n\nAn import happens in two steps. Planning shows what the import would do to every row, against your list as it stands, and changes nothing, so you can plan a file as often as you need. Committing applies the plan, all of it or none of it, and sends no email.\n\nThe commit needs two things from you when they apply:\n\n- **Your attestation,** when anyone would be confirmed: \"Everyone I'm importing as subscribed opted in to receive [your publication]. This isn't a purchased, rented, or scraped list.\" It is kept with the import, with who gave it and when. An import that only unsubscribes or suppresses needs none.\n- **Each send that would grow,** acknowledged by a person. A post goes to the list as it stands when it fires, so an import can add to the audience of a scheduled send, or of one that has just fired and not yet fixed its audience. Claude can attest for you, but can't acknowledge a growing send: you finish that import in the dashboard.\n\nIf another import is committed after you plan, plan again. If your list changes between the plan and the commit in any other way, such as a reader unsubscribing, the commit still goes ahead. Every write checks the list as it stands, and the answer reports any row that turned out differently.\n\n## What an import does to each address\n\nEach row gets exactly one outcome. A row that both changes its status and adds a suppression reports its status outcome, and says it added a suppression too.\n\n| Outcome | When |\n| --- | --- |\n| `create_confirmed` | `subscribed`, with no subscriber and no suppression in Kestrel, and not erased in the file: added as confirmed. |\n| `confirm_pending` | `subscribed`, pending in Kestrel (never confirmed), and not erased in the file: confirmed, and its confirmation link no longer works. |\n| `unchanged` | Already confirmed in Kestrel, or already in the state the row claims. A subscriber who joined through Kestrel keeps their own record. |\n| `mark_unsubscribed` | `unsubscribed`, and confirmed or pending in Kestrel, with no confirmation in Kestrel newer than the row's `unsubscribed_at`: unsubscribed. |\n| `create_unsubscribed` | `unsubscribed`, with no subscriber in Kestrel, and not erased in Kestrel or in the file: recorded as unsubscribed, so the list remembers they left. |\n| `kept_confirmed_newer` | `unsubscribed`, but the reader confirmed in Kestrel after the source's `unsubscribed_at`: their newer action stands. |\n| `kept_unsubscribed` | `subscribed`, but unsubscribed in Kestrel, or pending again after leaving until they confirm in Kestrel themselves. An unsubscribe is never reversed. |\n| `blocked_by_suppression` | `subscribed`, but the address carries a suppression in Kestrel, a do-not-contact marker included: never added or confirmed. Also `unsubscribed` for an address whose suppression in Kestrel is the do-not-contact marker an erasure left, and, for an address erased in the file (its `suppression` is `erased`, which is still added), `subscribed` or `unsubscribed` with no subscriber in Kestrel, or `subscribed` and pending in Kestrel, which stays pending: no subscriber is recorded or confirmed, so an import never brings back an erased person's data. |\n| `suppression_added` | The row carries a suppression and the address has none in Kestrel, or only a do-not-contact marker, and nothing else changes. Applied whatever the row's `status`. |\n| `left_out` | `pending` or `none` with nothing to apply: counted, never confirmed. An import sends no email, so it can't start a confirmation either. |\n| `invalid` | The address isn't valid. |\n| `merged` | A repeat of an address earlier in the file, folded into it. |\n\nA suppression already in Kestrel is never replaced, with one exception: a bounce, a complaint, or a manual suppression replaces a do-not-contact marker left by an erasure, so a known bad address still gets no confirmation. An imported bounce or complaint is kept apart from one Kestrel saw itself, so the list says where each came from.\n\nAnyone can type an address into your subscribe form, which puts a reader who left back to pending until they confirm. An import treats such a reader as unsubscribed, so it never confirms someone who left in Kestrel.\n\n**Repeats of an address** are folded into one row first. Its status is the first of unsubscribed, subscribed, pending, and none that any repeat claims, and its suppression the first of erased, complaint, bounce, and manual. Its opt-in is the weakest claim. It keeps the earliest consent times and the latest unsubscribe and suppression times, and the origin and evidence of the row whose status won.\n\n## How imported subscribers read\n\nA subscriber an import confirmed is **confirmed**, like anyone who confirmed in Kestrel, and gets your posts. How they joined shows beside their state: imported, with the origin and opt-in method the file claimed, and the evidence. That holds even when the file says they confirmed through Kestrel itself. Only a confirmation Kestrel recorded reads as Kestrel's double opt-in, and a subscriber who later confirms in Kestrel becomes Kestrel's own.\n\n## Limits\n\n- **Rows:** up to 10,000 in one import. On the Workers Free plan's limits (`SUBREQUEST_BUDGET` unset), up to 1,000, since Free's 10 ms of CPU a request can't plan and commit more. Set [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) on Workers Paid to take 10,000. Split a larger list into several files.\n- **Size:** a request of at most 10 MB, and `evidence` of at most 500 bytes a row.\n- **Database writes on Workers Free:** D1's free plan allows 100,000 rows written a day, and refuses every query past that until midnight UTC. Each new subscriber an import brings in writes 7 rows, and a send writes about 14 a recipient. So one import of 1,000 new subscribers uses about 7% of a day's writes, and about 10 of them use most of a day. On Workers Free, the plan warns when it and the day's earlier imports, by UTC, would use more than 70% of the day, and advises importing the file the next day. A send scheduled for a day imports have written warns in the same way, and advises sending the next day. Neither warning refuses.\n\n## Exporting\n\nThe export reads your whole list out in this format, a page at a time, at `GET /api/subscribers/export`. Each page holds up to 1,000 rows on the Workers Free plan's limits, or 10,000 otherwise, and its `next` value asks for the page after it. Following `next` until it's `null` gives every address exactly once, even if the list changes while you read. An address added or removed partway through may or may not appear. To make one CSV in this format, write the pages' rows under a single header. The dashboard's **Export** button, on the Subscribers screen, does this for you and downloads the whole list as one CSV. In the CSV, a value that starts with `=`, `+`, `-`, or `@` is written with an apostrophe before it, so a spreadsheet shows it as text rather than running it as a formula; an import takes the apostrophe off again. It includes every subscriber with how they joined, their evidence, and their suppression, and every address suppressed with no subscriber behind it, written as `status: none`. A confirmed subscriber is written as `subscribed`. Importing an export into another Kestrel carries the list over: the same states and suppressions, with every confirmation arriving as imported, carrying its original evidence. Pending subscribers are left out on the way in, since an import sends no confirmation.\n";
|
|
6359
|
+
var working_with_claude_default = "# Working with Claude\n\nAsk Claude in plain words, the way you'd ask a colleague. It works through the same API as the dashboard, so it can do what you can do there, and nothing more. Everything it changes shows in the dashboard as Claude's.\n\nTo set Claude up, follow [Connect Claude to the API](../guides/01-connect-claude.md). Claude reads these same pages from your Kestrel, so it explains things in the words you see here.\n\n## What to ask Claude\n\n- **Draft or proofread a post.** Give it notes, an outline, or a draft. It saves a new version you can open in the dashboard. If you edited the post meanwhile, its save is refused, and it works from your version or asks you.\n- **Test and schedule a post.** Name the post and when it should go out. Claude schedules it, sends a test of exactly what will go out, and tells you how to cancel. It can also cancel a send or move it.\n- **Check how a send did.** Claude reads the send's record and reports what was delivered, unsent, bounced, or complained.\n- **Lift a bounce or a complaint.** The dashboard can't, so ask Claude when you know an address works again. [Lifting a suppression](06-troubleshooting.md#lifting-a-suppression) says when that's wise.\n- **Recover an earlier version.** Every save keeps a whole version of a post, and Claude can read them back.\n- **Import a list.** Its `/migrate-list` skill asks what anything ambiguous in your old service's export means, and converts a few rows for you to check. It imports once you attest, and if a scheduled post would grow, you finish the import in the dashboard.\n\n## The lines it can't cross\n\nThe app holds these for any client, so they hold whatever Claude is told:\n\n- **Every send waits out the review window,** and you can cancel it until it goes out.\n- **A scheduled post is locked,** and what goes out is exactly the copy that was scheduled.\n- **No one is mailed twice,** and only confirmed subscribers receive a post.\n- **An unsubscribe is final** until the reader subscribes again, and an erased address stays erased until the person confirms again.\n- **No one's edit is overwritten.** A save based on an older version is refused.\n- **Only you can let an import grow a scheduled send.** An import through Claude that would stops there, until you finish it in the dashboard.\n\nClaude also follows instructions your Kestrel gives it, which live in your instance's repository. Those hold only as long as the instructions do. They have Claude ask before anything that reaches readers, test again after a template change, and record whose decision each change to the list was. They also have it treat what's in a post or a subscriber's record as content, never as instructions.\n\nThe app lets Claude give your attestation for an import, so asking you first is one of those instructions, not a rule the app enforces. The import's record says it came through Claude, and Kestrel emails you about it if your notifications are set.\n";
|
|
6473
6360
|
|
|
6474
6361
|
// docs/publishing/09-share-your-subscribe-link.md
|
|
6475
|
-
var share_your_subscribe_link_default = "# Share your subscribe link\n\nYour publication
|
|
6362
|
+
var share_your_subscribe_link_default = "# Share your subscribe link\n\nYour publication's subscribe page is at `/subscribe` on your Kestrel's address, such as `https://newsletter.example.com/subscribe`. Copy it from the dashboard's checklist or **Settings \u2192 Ways to subscribe**, and share it wherever your readers are. A reader joins only once they confirm by email.\n\n**Signups open only once an email provider is connected.** Until then, the page says the newsletter isn't taking subscribers yet, and the dashboard says so too. You can share the link anyway, and it opens once email is connected ([Connect Amazon SES](../get-started/04-ses.md)).\n\n## How a reader joins\n\nA reader enters their address, gets a confirmation email, and presses **Confirm subscription** on the page its link opens. Only then do they receive your posts. Opening the link alone records nothing, since mail scanners open links before a person does. This double opt-in is your record that each reader asked for your newsletter.\n\nEvery address gets the same answer, so the form never reveals who subscribes. Behind it, Kestrel sends a confirmation only when one is due:\n\n- **Already subscribed, bounced, complained, or blocked:** no email goes out.\n- **Waiting to confirm:** at most one confirmation every 15 minutes, however often the form is submitted. A link works for 7 days, and an expired one offers a fresh link.\n- **Unsubscribed earlier, or erased at their request:** they get a confirmation, and confirming puts them back on your list.\n\n## The confirmation email\n\nChange its subject, message, button label, and reassurance line under **Settings \u2192 Confirmation email**. Kestrel adds your name, your logo, and the confirm link itself, so no edit can leave them out. The confirmation isn't a post, so it doesn't use your email template.\n\n## Put the form on your own site\n\n**Settings \u2192 Ways to subscribe \u2192 Embed a form** gives you HTML to paste into any page you control. **Styled** looks like your subscribe page, and **Plain** takes your site's own styles. A reader who submits it confirms by email, as on the subscribe page.\n\n**Settings \u2192 Reader pages** shows every page a reader can land on, at desktop and phone widths, without walking through the steps. You can also limit how often anyone submits the form, which slows down abuse ([Rate-limit the subscribe form](../guides/05-rate-limit.md)).\n";
|
|
6476
6363
|
|
|
6477
6364
|
// docs/publishing/10-check-your-setup.md
|
|
6478
|
-
var check_your_setup_default = "# Check your setup\n\nBefore your readers get anything, see it arrive yourself, and make sure a problem will reach you without your watching for it. Send yourself a test, subscribe yourself, and set where Kestrel emails you
|
|
6365
|
+
var check_your_setup_default = "# Check your setup\n\nBefore your readers get anything, see it arrive yourself, and make sure a problem will reach you without your watching for it. Send yourself a test and reply to it, subscribe yourself, and set where Kestrel emails you.\n\nIf you're bringing a list of more than about 5,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import it. On the free plan, a large import and a send can use up the day's database writes. Then the app stops working until midnight UTC. [As your list grows](../guides/09-as-your-list-grows.md) walks through the upgrade.\n\n## Send yourself a test\n\nChoose **Template** in the sidebar, then **Send test email**, and send a sample post to your own inboxes. When it arrives, check:\n\n- **The From name and address,** which readers see first. They're set when Kestrel is deployed ([Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy)).\n- **Where it lands:** the inbox, Promotions, or spam. If it lands in spam, check your sending domain's records, as [Sending-domain DNS](../reference/02-sending-domain-dns.md) describes.\n- **A reply.** Reply to the test, and check that it reaches your inbox. Kestrel never sees replies, so this depends on [forwarding your From address](../get-started/04-ses.md#3-receive-replies-at-your-from-address).\n\n## Subscribe yourself\n\nOpen your subscribe page, from the link under **Settings \u2192 Ways to subscribe**, and subscribe with a personal address. Then confirm. You see what a new reader sees, and you then appear under **Subscribers** as **Confirmed**. [Share your subscribe link](09-share-your-subscribe-link.md) covers the page and the confirmation email.\n\n## Set your notifications address\n\nUnder **Settings \u2192 Notifications**, fill in **Send notifications to**, save, and choose **Send a test notification**. Kestrel then emails you when a send finishes or needs you. [Notifications](06-troubleshooting.md#notifications) lists what you're told, and why a second way to send them is worth asking for.\n\n## If email isn't connected\n\nUntil an email provider is connected, nothing can be sent, and the dashboard says so. The checklist shows **Connect Amazon SES** in place of this step, as [Connect Amazon SES](../get-started/04-ses.md) describes. **Settings \u2192 Email sender** shows your From address, sending domain, and provider, read-only.\n\n## As your list grows\n\nA growing list meets your Amazon SES daily sending quota, longer sends, and on Cloudflare's free Workers plan, a cap on daily database writes. Kestrel tells you when you reach each one, and [As your list grows](../guides/09-as-your-list-grows.md) covers raising each.\n\n";
|
|
6366
|
+
|
|
6367
|
+
// docs/publishing/11-email-template.md
|
|
6368
|
+
var email_template_default = "# Edit your email template\n\nYou write each post in Markdown, and the template is the HTML frame it's sent in, like a layout in a static site generator. `{{ .Post.Body }}` marks where your post goes, and the rest is plain HTML around it. The built-in template works as it comes, so changing it is optional.\n\nTo change it, choose **Template** in the sidebar, edit the HTML, and choose **Save changes**. **Start from example** offers two starting points. **Signed** ends each post with your logo, name, and tagline, and **Plain** has just your post and the footer links.\n\n## What every template needs\n\nKestrel won't save a template without `{{ .Post.Body }}` and `{{ .Email.UnsubscribeURL }}`. Every email has to carry the post and a way to leave. The two **Required** pills above the editor turn green or red as your template gains or loses them.\n\nWrap a part in `{{ if .IsEmail }}` and `{{ end }}` to keep it off each post's public page. The starting templates do this with the unsubscribe link, the View in browser link, and the mailing address.\n\n## Variables\n\nA variable is replaced with its value in each email. Type it exactly as shown, or it renders as empty.\n\n| Variable | What it holds |\n| --- | --- |\n| `{{ .Post.Body }}` | Your post, rendered from Markdown. Required. |\n| `{{ .Post.Subject }}` | The post's subject line. |\n| `{{ .Publication.Name }}` | Your publication's name. |\n| `{{ .Publication.Tagline }}` | Your tagline. |\n| `{{ .Publication.Logo }}` | Your logo as an image, or nothing when no logo is set. |\n| `{{ .Publication.LogoURL }}` | Your logo's address, or empty. |\n| `{{ .Publication.Address }}` | Your mailing address, or empty. |\n| `{{ .Email.SentTo }}` | The address this email was sent to. |\n| `{{ .Email.UnsubscribeURL }}` | This reader's one-click unsubscribe link. Required. |\n| `{{ .Email.ViewInBrowserURL }}` | The post's public page. |\n\n## Test it\n\n**Send test email** on the **Template** page sends a sample post through your saved template, so you see it in a real inbox. With unsaved edits, the button reads **Save & send test**, since a test always uses the saved template.\n\nA change to the template re-makes the email of every scheduled post, as [Schedule and send](04-write-a-post.md#schedule-and-send) describes.\n";
|
|
6479
6369
|
|
|
6480
6370
|
// docs/publishing/README.md
|
|
6481
|
-
var README_default = "# Publishing with Kestrel\n\nThese pages
|
|
6371
|
+
var README_default = "# Publishing with Kestrel\n\nThese pages get you publishing in the dashboard, and cover the decisions to make before they matter. Deploying and running Kestrel itself is covered in [Self-hosting](../README.md).\n\n## Set up your publication\n\nThe steps to set up a new publication, in the order the dashboard's setup checklist lists them.\n\n1. [Name your publication](03-name-your-publication.md)\n1. [Check your setup](10-check-your-setup.md)\n1. [Bring your existing list](01-your-list.md)\n1. [Write and send a post](04-write-a-post.md)\n1. [Share your subscribe link](09-share-your-subscribe-link.md)\n\n## Go further\n\nThings you choose to do once you're running.\n\n- [Edit your email template](11-email-template.md)\n- [Working with Claude](07-working-with-claude.md)\n\n## Rules and troubleshooting\n\nWhat the law asks of every send, and what to do when something goes wrong.\n\n- [Sending rules](02-sending-rules.md)\n- [Troubleshooting](06-troubleshooting.md)\n";
|
|
6482
6372
|
|
|
6483
6373
|
// docs/README.md
|
|
6484
|
-
var README_default2 = "# Self-hosting Kestrel\n\nThese pages
|
|
6374
|
+
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\nFollow these steps, from an empty repository to a live newsletter. Each one ends with a check, so you know it worked before you move on.\n\n1. [Deploy the app](get-started/02-deploy.md)\n1. [Lock the dashboard with Access](get-started/03-access.md)\n1. [Connect Amazon SES](get-started/04-ses.md)\n1. [Verify it works](get-started/05-verify.md)\n1. [Go live](get-started/06-go-live.md)\n\n## Guides\n\nEach one stands on its own. Pick the ones you need.\n\n- [Connect Claude to the API](guides/01-connect-claude.md)\n- [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";
|
|
6485
6375
|
|
|
6486
6376
|
// docs/reference/01-configuration.md
|
|
6487
|
-
var configuration_default = "# Configuration\n\nEvery deploy-time setting: what it does, its default, and what the app refuses. Each environment's settings live in its block under `env` in `wrangler.jsonc`, and its secrets in Cloudflare.\n\n- **Vars** are public values in the block's `vars`, safe to commit. A change takes effect when you deploy.\n- **Secrets** are stored with `npx wrangler secret put NAME --env production`, and never committed. A secret takes effect as soon as you store it, with no redeploy.\n- **Bindings and triggers** connect the Worker to its database, its image bucket, and its schedule.\n\nIn local development, `npm run dev` reads the top level of `wrangler.jsonc` and `.dev.vars`, which you copy from `.dev.vars.example`. Restart it after you change either.\n\nNone of these can be read or changed from inside the app, by the editor or by Claude. Settings in the editor shows the public ones, read-only.\n\n## Vars\n\n| Var | What it is | Default |\n| --- | --- | --- |\n| `PROVIDER` | the email provider: `ses`, or `fake` for local development | none: no provider connected |\n| `APP_ORIGIN` | the origin the app is served from: scheme and host, no path | required |\n| `ARCHIVE_BASE_PATH` | the path the archive lives under | `/archive` |\n| `FROM_ADDRESS` | the `From:` of every email | required with a real provider |\n| `SENDING_DOMAIN` | the sending domain, shown in Settings | none |\n| `AWS_REGION` | the SES region | `us-east-1` in the template, required with SES |\n| `SES_MAX_SEND_RATE` | your SES account's maximum send rate, in messages a second | `14` |\n| `SUBREQUEST_BUDGET` | how many subrequests one minute's send work may use | `50` |\n| `MIN_LEAD_SECONDS` | the least time every send stays cancelable before it goes out | `300` |\n| `ARCHIVE_ORIGIN` | the origin archive links use, to put the archive on your website | `APP_ORIGIN` |\n| `MEDIA_PUBLIC_BASE` | the base URL images are served from | `APP_ORIGIN` + `/media` |\n| `NOTIFY_FROM` | the sender of notifications through Cloudflare's email | `Kestrel <kestrel@` + the app's hostname + `>` |\n| `ALLOW_FAKE_PROVIDER` | `true` lets `fake` run on a real hostname, for a demo that mails no one on purpose | unset |\n\n### `PROVIDER`\n\nUntil it's set, or while it's empty, the app runs with no provider connected. You can write and preview posts, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, `\"PROVIDER\": \"\"`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.\n\n`fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A hosted demo that runs `fake` on a real hostname on purpose sets `ALLOW_FAKE_PROVIDER` to `true`, which no other deployment needs, and which no other provider reads.\n\n### `FROM_ADDRESS` and `SENDING_DOMAIN`\n\n`FROM_ADDRESS` is the only address mail is sent from, and the identity your provider authenticates. `SENDING_DOMAIN` is for display: Settings shows it beside the From address. The app never checks that the two match, so set `SENDING_DOMAIN` to the part of `FROM_ADDRESS` after the `@`.\n\nNeither one is your publication's name, tagline, or logo. You set those in the editor, under **Settings \u2192 Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, your emails and reader pages show no name at all.\n\n### `ARCHIVE_BASE_PATH`\n\nEvery post you send carries its archive link, `ARCHIVE_BASE_PATH` followed by the post's slug, and those links are permanent. Changing the prefix later breaks every link already mailed, so pick it before your first send. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\nA missing leading slash is added, and a trailing slash is dropped.\n\n### `SES_MAX_SEND_RATE`\n\nThe app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. That's `1` in the sandbox, and whatever AWS grants after that. The default, 14, is a common starting rate, not your account's.\n\nA rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.\n\n### `SUBREQUEST_BUDGET`\n\nCloudflare caps how many subrequests one run of a Worker may make. Database queries and outbound requests both count. It also caps database queries alone: 50 a run on Workers Free, and 1,000 on Workers Paid. Each minute's send work is one run, so a large send goes out over several minutes, each stopping short of both caps.\n\nThis setting is the subrequest cap. The app keeps database queries under theirs on its own.\n\n- **The default, 50,** is the Workers Free limit, and safe on any plan. A minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.\n- **On Workers Paid,** which allows 10,000, set `\"SUBREQUEST_BUDGET\": \"10000\"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.\n- **If you raised the Worker's own limit** with `limits.subrequests`, you may set this to match.\n\nIt's also how the app tells Workers Free from Workers Paid. At the default, the app runs on Free's limits:\n\n- One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more. Above it, up to 10,000 ([Import format](../publishing/08-import-format.md#limits)).\n- An export reads 1,000 rows a page. Above it, 10,000.\n- The app warns before a send to more than 5,000 recipients (fewer on a day you've imported), and on an import plan that, with the day's earlier imports, would use most of a day's writes. Either may use up D1's daily allowance of 100,000 rows written, which stops the app until midnight UTC ([As your list grows](../get-started/06-go-live.md#as-your-list-grows)). Neither warning stops you. Above the default, neither appears.\n\nNever set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n### `MIN_LEAD_SECONDS`\n\nEvery send stays visible and cancelable for at least this long before it goes out, whether it's scheduled, sent now, or moved. It's the window to catch a mistake, including one in a send Claude prepared.\n\nThe floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead you can schedule. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.\n\n### `ARCHIVE_ORIGIN` and `MEDIA_PUBLIC_BASE`\n\nLeave them unset to keep everything on the app's own hostname.\n\n`ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname.\n\n`MEDIA_PUBLIC_BASE` serves images from another base URL, such as a custom domain on the image bucket. The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.\n\n### `NOTIFY_FROM`\n\nUsed only with the `NOTIFY` binding. It must be an address on a domain set up for Cloudflare's email. Without the binding, notifications come from `FROM_ADDRESS`, through your email provider.\n\n## Secrets\n\n| Secret | What it is | Needed for |\n| --- | --- | --- |\n| `ACCESS_TEAM_DOMAIN` | your Zero Trust team domain, such as `your-team.cloudflareaccess.com`; an `https://` or trailing slash is dropped | every deployment |\n| `ACCESS_AUD` | the Access application's audience tag | every deployment |\n| `ACCESS_ALLOWED_EMAILS` | a comma-separated list of the people allowed to sign in | optional |\n| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | the IAM user's access key | SES |\n| `SNS_TOPIC_ARN` | the SNS topic the SES webhook accepts; it refuses every other topic | SES |\n| `SES_CONFIGURATION_SET` | the configuration set named on every email, so SES reports bounces and complaints; a name SES can't find in the region holds every list send until it's fixed | SES, recommended |\n\n`ACCESS_ALLOWED_EMAILS` applies to people only. A service token, such as the one Claude uses, carries no email, so the Access policy alone decides whether it gets in.\n\nNever set `DEV_AUTH_SECRET` on a deployed environment. It's local development's stand-in for Access, and a deployed instance ignores it anyway. The app honors it only when the environment looks like local development: `PROVIDER` is `fake`, Access isn't set up, and `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A real provider, Access, or a real hostname each turns it off, and Access becomes the only way in.\n\n## Bindings and triggers\n\nWrangler doesn't carry bindings or vars over from the top level of `wrangler.jsonc` to an environment. Each environment's block declares its own.\n\n| Setting | What it is | Default |\n| --- | --- | --- |\n| `name` | the Worker's name, such as `kestrel-production` | required |\n| `routes` | the app's hostname, attached on deploy, and any route on your website | required |\n| `workers_dev`, `preview_urls` | the Worker's `workers.dev` and preview addresses | `false` in the template |\n| `triggers.crons` | the once-a-minute schedule, `* * * * *` | required |\n| `observability` | Workers Logs, where the app's log lines land | on |\n| `d1_databases`, binding `DB` | the database: posts, subscribers, sends, and settings | required |\n| `r2_buckets`, binding `MEDIA` | the bucket for images and your logo | required |\n| `send_email`, binding `NOTIFY` | Cloudflare's email, for notifications | none |\n\n### `routes`, `workers_dev`, and `preview_urls`\n\nThe template turns off the `workers.dev` addresses, so the app answers only on your hostname. That's where your Access application and any rate-limiting rule apply. Each deploy replaces the Worker's routes with the ones in the file, so add a route here, never in the dashboard.\n\n### `triggers.crons`\n\nThe schedule runs every minute. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.\n\n### `observability`\n\nEvery invocation is logged, with no sampling. Sampling could drop the one minute that reports a stuck or missed send.\n\n### `NOTIFY`\n\nWhen declared, notifications go through Cloudflare's email instead of your provider, so they still arrive when your provider refuses your account. Local development ignores it, and sends notifications to a stand-in that delivers nothing.\n\n## What the app refuses\n\nThe app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly:\n\n- `PROVIDER`, when set, must be exactly `ses`, `fake`, or `none`. A misspelling, or `SES`, is refused, never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).\n- `fake` runs only when `APP_ORIGIN` is on `localhost` or `127.0.0.1`, unless `ALLOW_FAKE_PROVIDER` is `true`. On a real hostname it would open signups whose confirmations go nowhere, and record every send as delivered. With `fake`, `ALLOW_FAKE_PROVIDER`, when set, must be `true`; every other provider ignores it.\n- `APP_ORIGIN` must be a plain `http` or `https` origin, with no path. So must `ARCHIVE_ORIGIN`, when set. `MEDIA_PUBLIC_BASE`, when set, must be a plain `http` or `https` URL, and may have a path. A trailing slash is dropped from each.\n- A real provider needs `FROM_ADDRESS`, and every secret the table marks for it. SES also needs `AWS_REGION`. `FROM_ADDRESS` must then be an email address, optionally with a display name in front, like `Name <address>`.\n- None of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com` or a name under it, the template's placeholder. That holds with no provider connected too, since every link the app builds would name it. Only `fake` allows it.\n- `SUBREQUEST_BUDGET`, `MIN_LEAD_SECONDS`, and `SES_MAX_SEND_RATE` must be whole numbers above zero. `MIN_LEAD_SECONDS` must also be within its bounds. Any other value is refused, never adjusted. The one exception is a `SUBREQUEST_BUDGET` below 30, which is raised to 30.\n- In local development, `SIMULATE_SENDS` must be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn't matter. A deployed instance ignores it.\n- `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are set together or not at all. With only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one. `ACCESS_TEAM_DOMAIN` must be your team's hostname, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in.\n\nUntil it's fixed, no sends go out, and each place says what its reader can act on:\n\n- **The editor** shows a page naming the setting, what's wrong with it, and how to fix it, with a link to its section here. Its own Docs tab can't load until then, so the link opens the project's site.\n- **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{\"error\":\"invalid_config\",\"message\":\u2026,\"variable\":\"APP_ORIGIN\"}`.\n- **The public pages readers see** answer `503` with a page saying the newsletter is temporarily unavailable. It names no setting.\n- **The Worker's log** records it as `config.invalid`.\n";
|
|
6377
|
+
var configuration_default = "# Configuration\n\nEvery deploy-time setting: what it does, its default, and what the app refuses. Each environment's settings live in its block under `env` in `wrangler.jsonc`, and its secrets in Cloudflare.\n\n- **Vars** are public values in the block's `vars`, safe to commit. A change takes effect when you deploy.\n- **Secrets** are stored with `npx wrangler secret put NAME --env production`, and never committed. A secret takes effect as soon as you store it, with no redeploy.\n- **Bindings and triggers** connect the Worker to its database, its image bucket, and its schedule.\n\nIn local development, `npm run dev` reads the top level of `wrangler.jsonc` and `.dev.vars`, which you copy from `.dev.vars.example`. Restart it after you change either.\n\nNone of these can be read or changed from inside the app, by the editor or by Claude. Settings in the editor shows the public ones, read-only.\n\n## Vars\n\n| Var | What it is | Default |\n| --- | --- | --- |\n| `PROVIDER` | the email provider: `ses`, or `fake` for local development | none: no provider connected |\n| `APP_ORIGIN` | the origin the app is served from: scheme and host, no path | required |\n| `ARCHIVE_BASE_PATH` | the path the archive lives under | `/archive` |\n| `FROM_ADDRESS` | the `From:` of every email | required with a real provider |\n| `SENDING_DOMAIN` | the sending domain, shown in Settings | none |\n| `AWS_REGION` | the SES region | `us-east-1` in the template, required with SES |\n| `SES_MAX_SEND_RATE` | your SES account's maximum send rate, in messages a second | `14` |\n| `SUBREQUEST_BUDGET` | how many subrequests one minute's send work may use | `50` |\n| `MIN_LEAD_SECONDS` | the least time every send stays cancelable before it goes out | `300` |\n| `ARCHIVE_ORIGIN` | the origin archive links use, to put the archive on your website | `APP_ORIGIN` |\n| `MEDIA_PUBLIC_BASE` | the base URL images are served from | `APP_ORIGIN` + `/media` |\n| `NOTIFY_FROM` | the sender of notifications through Cloudflare's email | `Kestrel <kestrel@` + the app's hostname + `>` |\n| `ALLOW_FAKE_PROVIDER` | `true` lets `fake` run on a real hostname, for a demo that mails no one on purpose | unset |\n\n### `PROVIDER`\n\nUntil it's set, or while it's empty, the app runs with no provider connected. 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, `\"PROVIDER\": \"\"`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.\n\n`fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A hosted demo that runs `fake` on a real hostname on purpose sets `ALLOW_FAKE_PROVIDER` to `true`, which no other deployment needs, and which no other provider reads.\n\n### `FROM_ADDRESS` and `SENDING_DOMAIN`\n\n`FROM_ADDRESS` is the only address mail is sent from, and the identity your provider authenticates. `SENDING_DOMAIN` is for display: Settings shows it beside the From address. The app never checks that the two match, so set `SENDING_DOMAIN` to the part of `FROM_ADDRESS` after the `@`.\n\nNeither one is 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### `ARCHIVE_BASE_PATH`\n\nEvery post sent carries its archive link, `ARCHIVE_BASE_PATH` followed by the post's slug, and those links are permanent. Changing the prefix later breaks every link already mailed, so pick it before the first send. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\nA missing leading slash is added, and a trailing slash is dropped.\n\n### `SES_MAX_SEND_RATE`\n\nThe app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. That's `1` in the sandbox, and whatever AWS grants after that. The default, 14, is a common starting rate, not your account's.\n\nA rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.\n\n### `SUBREQUEST_BUDGET`\n\nCloudflare caps how many subrequests one run of a Worker may make. Database queries and outbound requests both count. It also caps database queries alone: 50 a run on Workers Free, and 1,000 on Workers Paid. Each minute's send work is one run, so a large send goes out over several minutes, each stopping short of both caps.\n\nThis setting is the subrequest cap. The app keeps database queries under theirs on its own.\n\n- **The default, 50,** is the Workers Free limit, and safe on any plan. A minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.\n- **On Workers Paid,** which allows 10,000, set `\"SUBREQUEST_BUDGET\": \"10000\"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.\n- **If you raised the Worker's own limit** with `limits.subrequests`, you may set this to match.\n\nIt's also how the app tells Workers Free from Workers Paid. At the default, the app runs on Free's limits:\n\n- One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more. Above it, up to 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)).\n- An export reads 1,000 rows a page. Above it, 10,000.\n- The app warns before a send to more than 5,000 recipients (fewer on a day with imports), and on an import plan that, with the day's earlier imports, would use most of a day's writes. Either may use up D1's daily allowance of 100,000 rows written, which stops the app until midnight UTC ([As your list grows](../guides/09-as-your-list-grows.md)). Neither warning stops you. Above the default, neither appears.\n\nNever set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n### `MIN_LEAD_SECONDS`\n\nEvery send stays visible and cancelable for at least this long before it goes out, whether it's scheduled, sent now, or moved. It's the window to catch a mistake, including one in a send Claude prepared.\n\nThe floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead 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` and `MEDIA_PUBLIC_BASE`\n\nLeave them unset to keep everything on the app's own hostname.\n\n`ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname.\n\n`MEDIA_PUBLIC_BASE` serves images from another base URL, such as a custom domain on the image bucket. The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.\n\n### `NOTIFY_FROM`\n\nUsed only with the `NOTIFY` binding. It must be an address on a domain set up for Cloudflare's email. Without the binding, notifications come from `FROM_ADDRESS`, through your email provider.\n\n## Secrets\n\n| Secret | What it is | Needed for |\n| --- | --- | --- |\n| `ACCESS_TEAM_DOMAIN` | your Zero Trust team domain, such as `your-team.cloudflareaccess.com`; an `https://` or trailing slash is dropped | every deployment |\n| `ACCESS_AUD` | the Access application's audience tag | every deployment |\n| `ACCESS_ALLOWED_EMAILS` | a comma-separated list of the people allowed to sign in | optional |\n| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | the IAM user's access key | SES |\n| `SNS_TOPIC_ARN` | the SNS topic the SES webhook accepts; it refuses every other topic | SES |\n| `SES_CONFIGURATION_SET` | the configuration set named on every email, so SES reports bounces and complaints; a name SES can't find in the region holds every list send until it's fixed | SES, recommended |\n\n`ACCESS_ALLOWED_EMAILS` applies to people only. A service token, such as the one Claude uses, carries no email, so the Access policy alone decides whether it gets in.\n\nNever set `DEV_AUTH_SECRET` on a deployed environment. It's local development's stand-in for Access, and a deployed instance ignores it anyway. The app honors it only when the environment looks like local development: `PROVIDER` is `fake`, Access isn't set up, and `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A real provider, Access, or a real hostname each turns it off, and Access becomes the only way in.\n\n## Bindings and triggers\n\nWrangler doesn't carry bindings or vars over from the top level of `wrangler.jsonc` to an environment. Each environment's block declares its own.\n\n| Setting | What it is | Default |\n| --- | --- | --- |\n| `name` | the Worker's name, such as `kestrel-production` | required |\n| `routes` | the app's hostname, attached on deploy, and any route on your website | required |\n| `workers_dev`, `preview_urls` | the Worker's `workers.dev` and preview addresses | `false` in the template |\n| `triggers.crons` | the once-a-minute schedule, `* * * * *` | required |\n| `observability` | Workers Logs, where the app's log lines land | on |\n| `d1_databases`, binding `DB` | the database: posts, subscribers, sends, and settings | required |\n| `r2_buckets`, binding `MEDIA` | the bucket for images and your logo | required |\n| `send_email`, binding `NOTIFY` | Cloudflare's email, for notifications | none |\n\n### `routes`, `workers_dev`, and `preview_urls`\n\nThe template turns off the `workers.dev` addresses, so the app answers only on your hostname. That's where your Access application and any rate-limiting rule apply. Each deploy replaces the Worker's routes with the ones in the file, so add a route here, never in the dashboard.\n\n### `triggers.crons`\n\nThe schedule runs every minute. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.\n\n### `observability`\n\nEvery invocation is logged, with no sampling. Sampling could drop the one minute that reports a stuck or missed send.\n\n### `NOTIFY`\n\nWhen declared, notifications go through Cloudflare's email instead of your provider, so they still arrive when your provider refuses your account. Local development ignores it, and sends notifications to a stand-in that delivers nothing.\n\n## What the app refuses\n\nThe app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly:\n\n- `PROVIDER`, when set, must be exactly `ses`, `fake`, or `none`. A misspelling, or `SES`, is refused, never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).\n- `fake` runs only when `APP_ORIGIN` is on `localhost` or `127.0.0.1`, unless `ALLOW_FAKE_PROVIDER` is `true`. On a real hostname it would open signups whose confirmations go nowhere, and record every send as delivered. With `fake`, `ALLOW_FAKE_PROVIDER`, when set, must be `true`; every other provider ignores it.\n- `APP_ORIGIN` must be a plain `http` or `https` origin, with no path. So must `ARCHIVE_ORIGIN`, when set. `MEDIA_PUBLIC_BASE`, when set, must be a plain `http` or `https` URL, and may have a path. A trailing slash is dropped from each.\n- A real provider needs `FROM_ADDRESS`, and every secret the table marks for it. SES also needs `AWS_REGION`. `FROM_ADDRESS` must then be an email address, optionally with a display name in front, like `Name <address>`.\n- None of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com` or a name under it, the template's placeholder. That holds with no provider connected too, since every link the app builds would name it. Only `fake` allows it.\n- `SUBREQUEST_BUDGET`, `MIN_LEAD_SECONDS`, and `SES_MAX_SEND_RATE` must be whole numbers above zero. `MIN_LEAD_SECONDS` must also be within its bounds. Any other value is refused, never adjusted. The one exception is a `SUBREQUEST_BUDGET` below 30, which is raised to 30.\n- In local development, `SIMULATE_SENDS` must be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn't matter. A deployed instance ignores it.\n- `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are set together or not at all. With only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one. `ACCESS_TEAM_DOMAIN` must be your team's hostname, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in.\n\nUntil it's fixed, no sends go out, and each place says what its reader can act on:\n\n- **The editor** shows a page naming the setting, what's wrong with it, and how to fix it, with a link to its section here. Its own Docs tab can't load until then, so the link opens the project's site.\n- **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{\"error\":\"invalid_config\",\"message\":\u2026,\"variable\":\"APP_ORIGIN\"}`.\n- **The public pages readers see** answer `503` with a page saying the newsletter is temporarily unavailable. It names no setting.\n- **The Worker's log** records it as `config.invalid`.\n";
|
|
6488
6378
|
|
|
6489
6379
|
// docs/reference/02-sending-domain-dns.md
|
|
6490
|
-
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
|
|
6380
|
+
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";
|
|
6491
6381
|
|
|
6492
6382
|
// shared/docs.ts
|
|
6493
6383
|
function headingAnchor(text) {
|
|
@@ -6500,10 +6390,9 @@ var PAGES = {
|
|
|
6500
6390
|
"publishing/02-sending-rules.md": sending_rules_default,
|
|
6501
6391
|
"publishing/03-name-your-publication.md": name_your_publication_default,
|
|
6502
6392
|
"publishing/04-write-a-post.md": write_a_post_default,
|
|
6503
|
-
"publishing/
|
|
6504
|
-
"publishing/06-after-a-send.md": after_a_send_default,
|
|
6393
|
+
"publishing/06-troubleshooting.md": troubleshooting_default,
|
|
6505
6394
|
"publishing/07-working-with-claude.md": working_with_claude_default,
|
|
6506
|
-
"publishing/
|
|
6395
|
+
"publishing/11-email-template.md": email_template_default,
|
|
6507
6396
|
"publishing/09-share-your-subscribe-link.md": share_your_subscribe_link_default,
|
|
6508
6397
|
"publishing/10-check-your-setup.md": check_your_setup_default,
|
|
6509
6398
|
"get-started/02-deploy.md": deploy_default,
|
|
@@ -6515,7 +6404,7 @@ var PAGES = {
|
|
|
6515
6404
|
"guides/03-notifications.md": notifications_default,
|
|
6516
6405
|
"guides/05-rate-limit.md": rate_limit_default,
|
|
6517
6406
|
"guides/07-upgrade.md": upgrade_default,
|
|
6518
|
-
"guides/
|
|
6407
|
+
"guides/09-as-your-list-grows.md": as_your_list_grows_default,
|
|
6519
6408
|
"reference/01-configuration.md": configuration_default,
|
|
6520
6409
|
"reference/02-sending-domain-dns.md": sending_domain_dns_default
|
|
6521
6410
|
};
|
|
@@ -6656,8 +6545,15 @@ function renderDocs() {
|
|
|
6656
6545
|
}
|
|
6657
6546
|
var MD_LINK = /\]\(([^)\s#:]+\.md)(#[^)\s]*)?\)/g;
|
|
6658
6547
|
var MD_CODE = /^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$|`[^`\n]+`/gm;
|
|
6548
|
+
var FORMER_SLUGS = /* @__PURE__ */ new Map([
|
|
6549
|
+
["import-format", "your-list"],
|
|
6550
|
+
["scheduling-and-sending", "write-a-post"],
|
|
6551
|
+
["after-a-send", "troubleshooting"],
|
|
6552
|
+
["try-locally", "deploy"]
|
|
6553
|
+
]);
|
|
6659
6554
|
function docMarkdown(name) {
|
|
6660
|
-
const
|
|
6555
|
+
const slug = FORMER_SLUGS.get(name) ?? name;
|
|
6556
|
+
const doc = DOCS.find((d) => d.slug === slug);
|
|
6661
6557
|
const set = SET_SOURCES.find((s) => s.id === name);
|
|
6662
6558
|
const from = doc?.path ?? set?.path;
|
|
6663
6559
|
const markdown2 = doc?.markdown ?? set?.index;
|
|
@@ -6989,7 +6885,7 @@ function maxImportRows(subrequestBudget) {
|
|
|
6989
6885
|
return onFreePlanLimits({ subrequestBudget }) ? {
|
|
6990
6886
|
maxRows: IMPORT_LIMITS.maxRowsFree,
|
|
6991
6887
|
// Said to the publisher, who may never have seen Cloudflare: the setting behind it
|
|
6992
|
-
// is the operator's, in the API reference and
|
|
6888
|
+
// is the operator's, in the API reference and Bring your existing list's file format.
|
|
6993
6889
|
reason: `That\u2019s the limit on Cloudflare\u2019s Workers Free plan. Whoever runs your Kestrel can raise it to ${IMPORT_LIMITS.maxRows.toLocaleString("en-US")} rows by moving to Workers Paid.`
|
|
6994
6890
|
} : { maxRows: IMPORT_LIMITS.maxRows, reason: null };
|
|
6995
6891
|
}
|