@kurtbruns/kestrel 1.15.1 → 1.17.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 +33 -1
- package/cli/sweep-ticker.mjs +3 -3
- package/dist/public/dashboard/app.js +1 -1
- package/dist/public/dashboard/app.js.map +1 -1
- package/dist/public/dashboard/index.html +1 -1
- package/dist/worker/index.js +127 -38
- package/docs/get-started/02-deploy.md +8 -2
- package/docs/get-started/03-access.md +5 -3
- package/docs/get-started/04-ses.md +82 -115
- package/docs/get-started/05-verify.md +18 -16
- package/docs/guides/01-allow-claude.md +1 -1
- package/docs/guides/03-notifications.md +2 -2
- package/docs/publishing/02-sending-rules.md +2 -2
- package/docs/publishing/03-name-your-publication.md +1 -1
- package/docs/publishing/06-troubleshooting.md +29 -68
- package/docs/publishing/10-check-your-setup.md +1 -1
- package/docs/reference/01-configuration.md +5 -3
- package/docs/reference/02-sending-domain-dns.md +3 -3
- package/package.json +1 -4
- package/template/claude/CLAUDE.md +2 -0
- package/template/wrangler.jsonc +10 -3
package/dist/worker/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @kurtbruns/kestrel 1.
|
|
1
|
+
// @kurtbruns/kestrel 1.17.0: the Worker, built by scripts/build-package.mjs.
|
|
2
2
|
|
|
3
3
|
// shared/archive_url.ts
|
|
4
4
|
function archivePostUrl(origin, basePath, slug) {
|
|
@@ -934,10 +934,10 @@ var SUBSCRIBER_ACTIONS = {
|
|
|
934
934
|
|
|
935
935
|
// src/generated/version.ts
|
|
936
936
|
var BUILD_INFO = {
|
|
937
|
-
version: "1.
|
|
938
|
-
sha: "
|
|
939
|
-
tag: "v1.
|
|
940
|
-
buildTime: "2026-10-
|
|
937
|
+
version: "1.17.0",
|
|
938
|
+
sha: "4a4c047",
|
|
939
|
+
tag: "v1.17.0",
|
|
940
|
+
buildTime: "2026-10-26T17:42:24.923Z",
|
|
941
941
|
repoUrl: "https://github.com/kurtbruns/kestrel"
|
|
942
942
|
};
|
|
943
943
|
|
|
@@ -1164,6 +1164,9 @@ var log = {
|
|
|
1164
1164
|
warn: (event, fields = {}) => emit("warn", event, fields),
|
|
1165
1165
|
info: (event, fields = {}) => emit("info", event, fields)
|
|
1166
1166
|
};
|
|
1167
|
+
function whoActed(by) {
|
|
1168
|
+
return { principal: by.kind, client: by.kind === "human" ? by.client ?? void 0 : void 0 };
|
|
1169
|
+
}
|
|
1167
1170
|
function errorText(err) {
|
|
1168
1171
|
return String(err?.message ?? err);
|
|
1169
1172
|
}
|
|
@@ -2955,7 +2958,8 @@ async function erase(db, id) {
|
|
|
2955
2958
|
return {
|
|
2956
2959
|
kind: "erased",
|
|
2957
2960
|
subscriber: row,
|
|
2958
|
-
deliveries: moved2.slice(deliveries3.rewrite.length).reduce((n, r) => n + (r.meta.changes ?? 0), 0)
|
|
2961
|
+
deliveries: moved2.slice(deliveries3.rewrite.length).reduce((n, r) => n + (r.meta.changes ?? 0), 0),
|
|
2962
|
+
unchanged: moved2.every((r) => (r.meta.changes ?? 0) === 0)
|
|
2959
2963
|
};
|
|
2960
2964
|
}
|
|
2961
2965
|
const idle = idleFor("?1", "?2");
|
|
@@ -2982,7 +2986,8 @@ async function erase(db, id) {
|
|
|
2982
2986
|
return {
|
|
2983
2987
|
kind: "erased",
|
|
2984
2988
|
subscriber: after,
|
|
2985
|
-
deliveries: moved.reduce((n, r) => n + (r.meta.changes ?? 0), 0)
|
|
2989
|
+
deliveries: moved.reduce((n, r) => n + (r.meta.changes ?? 0), 0),
|
|
2990
|
+
unchanged: false
|
|
2986
2991
|
};
|
|
2987
2992
|
}
|
|
2988
2993
|
async function addSuppression(db, email2, reason, detail) {
|
|
@@ -3591,13 +3596,11 @@ function principalFromClaims(payload, allowed) {
|
|
|
3591
3596
|
if (email2) {
|
|
3592
3597
|
return isHumanAllowed(email2, allowed) ? { kind: "human", email: email2, via: "access" } : null;
|
|
3593
3598
|
}
|
|
3594
|
-
const claims = Object.keys(payload).sort().join(",");
|
|
3595
3599
|
const tokenId = typeof payload.common_name === "string" ? payload.common_name : void 0;
|
|
3596
3600
|
if (tokenId) {
|
|
3597
|
-
log.info("auth.service", { claims });
|
|
3598
3601
|
return { kind: "service", tokenId, via: "access" };
|
|
3599
3602
|
}
|
|
3600
|
-
log.warn("auth.unidentified", { claims });
|
|
3603
|
+
log.warn("auth.unidentified", { claims: Object.keys(payload).sort().join(",") });
|
|
3601
3604
|
return "access_unidentified";
|
|
3602
3605
|
}
|
|
3603
3606
|
|
|
@@ -3641,6 +3644,10 @@ function iso(ms) {
|
|
|
3641
3644
|
function isObject2(v2) {
|
|
3642
3645
|
return typeof v2 === "object" && v2 !== null && !Array.isArray(v2);
|
|
3643
3646
|
}
|
|
3647
|
+
var refusalCodes = /* @__PURE__ */ new WeakMap();
|
|
3648
|
+
function refusalCodeOf(result2) {
|
|
3649
|
+
return refusalCodes.get(result2);
|
|
3650
|
+
}
|
|
3644
3651
|
function refused(a, lines = []) {
|
|
3645
3652
|
const body = isObject2(a.body) ? a.body : {};
|
|
3646
3653
|
const code = typeof body.error === "string" ? body.error : void 0;
|
|
@@ -3654,7 +3661,11 @@ function refused(a, lines = []) {
|
|
|
3654
3661
|
if (typeof body.field === "string") {
|
|
3655
3662
|
out.push(`Fix: \`${body.field}\`.`);
|
|
3656
3663
|
}
|
|
3657
|
-
|
|
3664
|
+
const result2 = text([...out, ...lines].join("\n"), true);
|
|
3665
|
+
if (code) {
|
|
3666
|
+
refusalCodes.set(result2, code);
|
|
3667
|
+
}
|
|
3668
|
+
return result2;
|
|
3658
3669
|
}
|
|
3659
3670
|
function postBrief(p) {
|
|
3660
3671
|
return {
|
|
@@ -4868,12 +4879,15 @@ function allows(t, r) {
|
|
|
4868
4879
|
}
|
|
4869
4880
|
async function call(c, router, t, args) {
|
|
4870
4881
|
if (args !== void 0 && !isObject3(args)) {
|
|
4871
|
-
return
|
|
4882
|
+
return logged(t, {
|
|
4883
|
+
content: [{ type: "text", text: "arguments must be an object" }],
|
|
4884
|
+
isError: true
|
|
4885
|
+
});
|
|
4872
4886
|
}
|
|
4873
4887
|
const given = args ?? {};
|
|
4874
4888
|
const stray = unknownArgs(t, given);
|
|
4875
4889
|
if (stray) {
|
|
4876
|
-
return { content: [{ type: "text", text: stray }], isError: true };
|
|
4890
|
+
return logged(t, { content: [{ type: "text", text: stray }], isError: true });
|
|
4877
4891
|
}
|
|
4878
4892
|
const api = {
|
|
4879
4893
|
async call(r, parts = {}) {
|
|
@@ -4911,7 +4925,10 @@ async function call(c, router, t, args) {
|
|
|
4911
4925
|
isError: true
|
|
4912
4926
|
};
|
|
4913
4927
|
}
|
|
4914
|
-
|
|
4928
|
+
return logged(t, out);
|
|
4929
|
+
}
|
|
4930
|
+
function logged(t, out) {
|
|
4931
|
+
log.info("mcp.call", { tool: t.tool.name, error: out.isError, code: refusalCodeOf(out) });
|
|
4915
4932
|
return out;
|
|
4916
4933
|
}
|
|
4917
4934
|
|
|
@@ -6386,7 +6403,7 @@ function readProvider(v2) {
|
|
|
6386
6403
|
if (raw === "resend") {
|
|
6387
6404
|
throw new ConfigError(
|
|
6388
6405
|
"PROVIDER",
|
|
6389
|
-
'is "resend", which this release no longer supports. Set it to "ses": Connect Amazon SES in the setup guide walks through it
|
|
6406
|
+
'is "resend", which this release no longer supports. Set it to "ses": Connect Amazon SES in the setup guide walks through it'
|
|
6390
6407
|
);
|
|
6391
6408
|
}
|
|
6392
6409
|
if (!isProviderName(raw)) {
|
|
@@ -7388,15 +7405,43 @@ function buildImageMap(images) {
|
|
|
7388
7405
|
return map;
|
|
7389
7406
|
}
|
|
7390
7407
|
function resolveImageSrc(href, images, mediaBase) {
|
|
7408
|
+
const key = ownMediaKey(href, mediaBase);
|
|
7409
|
+
if (key !== null) {
|
|
7410
|
+
const image2 = [...images.values()].find((img) => img.storage_key === key) ?? null;
|
|
7411
|
+
return { url: image2 ? `${mediaBase}/${image2.storage_key}` : href, image: image2, ownMedia: true };
|
|
7412
|
+
}
|
|
7391
7413
|
if (/^https?:\/\//i.test(href) || href.startsWith("data:") || href.startsWith("cid:")) {
|
|
7392
|
-
return { url: href, image: null };
|
|
7414
|
+
return { url: href, image: null, ownMedia: false };
|
|
7393
7415
|
}
|
|
7394
7416
|
const name = href.split("/").pop() ?? href;
|
|
7395
7417
|
const image = images.get(name) ?? images.get(href) ?? null;
|
|
7396
7418
|
if (image) {
|
|
7397
|
-
return { url: `${mediaBase}/${image.storage_key}`, image };
|
|
7419
|
+
return { url: `${mediaBase}/${image.storage_key}`, image, ownMedia: false };
|
|
7420
|
+
}
|
|
7421
|
+
return { url: href, image: null, ownMedia: false };
|
|
7422
|
+
}
|
|
7423
|
+
function ownMediaKey(href, mediaBase) {
|
|
7424
|
+
if (!/^https?:\/\//i.test(href)) {
|
|
7425
|
+
return null;
|
|
7426
|
+
}
|
|
7427
|
+
let ref;
|
|
7428
|
+
let base;
|
|
7429
|
+
try {
|
|
7430
|
+
ref = new URL(href);
|
|
7431
|
+
base = new URL(mediaBase);
|
|
7432
|
+
} catch {
|
|
7433
|
+
return null;
|
|
7434
|
+
}
|
|
7435
|
+
const prefix = base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`;
|
|
7436
|
+
if (ref.origin !== base.origin || !ref.pathname.startsWith(prefix)) {
|
|
7437
|
+
return null;
|
|
7438
|
+
}
|
|
7439
|
+
const rest = ref.pathname.slice(prefix.length);
|
|
7440
|
+
try {
|
|
7441
|
+
return decodeURIComponent(rest);
|
|
7442
|
+
} catch {
|
|
7443
|
+
return rest;
|
|
7398
7444
|
}
|
|
7399
|
-
return { url: href, image: null };
|
|
7400
7445
|
}
|
|
7401
7446
|
|
|
7402
7447
|
// src/render/markdown.ts
|
|
@@ -7413,8 +7458,16 @@ function markdownToHtml(md, ctx) {
|
|
|
7413
7458
|
`The image \u201C${href}\u201D has no alt text. Describe it between the square brackets.`
|
|
7414
7459
|
);
|
|
7415
7460
|
}
|
|
7416
|
-
const { url, image } = resolveImageSrc(href, ctx.images, ctx.mediaBase);
|
|
7417
|
-
if (
|
|
7461
|
+
const { url, image, ownMedia } = resolveImageSrc(href, ctx.images, ctx.mediaBase);
|
|
7462
|
+
if (image && ownMedia) {
|
|
7463
|
+
ctx.warnings.push(
|
|
7464
|
+
`The image \u201C${href}\u201D should be referenced as \u201C${image.filename}\u201D. Use its file name, not its address.`
|
|
7465
|
+
);
|
|
7466
|
+
} else if (!image && ownMedia) {
|
|
7467
|
+
ctx.warnings.push(
|
|
7468
|
+
`The image \u201C${href}\u201D isn\u2019t on this post. Upload it to this post and reference it by its file name.`
|
|
7469
|
+
);
|
|
7470
|
+
} else if (!image && !/^https?:\/\//i.test(url)) {
|
|
7418
7471
|
ctx.warnings.push(
|
|
7419
7472
|
`The image \u201C${href}\u201D isn\u2019t on this post. Upload it, or check the file name.`
|
|
7420
7473
|
);
|
|
@@ -9234,6 +9287,13 @@ async function freeze(env, config, post, fireAt, postSettings = {}, recorded = n
|
|
|
9234
9287
|
throw err;
|
|
9235
9288
|
}
|
|
9236
9289
|
if ((results[0]?.meta.changes ?? 0) > 0) {
|
|
9290
|
+
log.info("send.scheduled", {
|
|
9291
|
+
sendId: id,
|
|
9292
|
+
postId: post.id,
|
|
9293
|
+
fireAt: new Date(fireAt).toISOString(),
|
|
9294
|
+
act: recorded?.act,
|
|
9295
|
+
...recorded ? whoActed(recorded.by) : {}
|
|
9296
|
+
});
|
|
9237
9297
|
return unwrap(await getSend(env.DB, id), "send");
|
|
9238
9298
|
}
|
|
9239
9299
|
}
|
|
@@ -9296,7 +9356,8 @@ async function reschedule(env, sendId, fireAt, minLeadMs, by = null) {
|
|
|
9296
9356
|
sendId,
|
|
9297
9357
|
postId: send.post_id,
|
|
9298
9358
|
fireAt: new Date(target).toISOString(),
|
|
9299
|
-
movedMs: target - send.fire_at
|
|
9359
|
+
movedMs: target - send.fire_at,
|
|
9360
|
+
...by ? whoActed(by) : {}
|
|
9300
9361
|
});
|
|
9301
9362
|
return { send: unwrap(await getSend(env.DB, sendId), "send"), changed: true };
|
|
9302
9363
|
}
|
|
@@ -9324,7 +9385,7 @@ async function cancel(env, sendId, by = null) {
|
|
|
9324
9385
|
if ((res?.meta.changes ?? 0) === 0) {
|
|
9325
9386
|
return cancel(env, sendId, by);
|
|
9326
9387
|
}
|
|
9327
|
-
log.info("send.canceled", { sendId, postId: send.post_id });
|
|
9388
|
+
log.info("send.canceled", { sendId, postId: send.post_id, ...by ? whoActed(by) : {} });
|
|
9328
9389
|
return { send: unwrap(await getSend(env.DB, sendId), "send"), changed: true };
|
|
9329
9390
|
}
|
|
9330
9391
|
|
|
@@ -10477,28 +10538,28 @@ async function reset(c) {
|
|
|
10477
10538
|
import { Lexer } from "marked";
|
|
10478
10539
|
|
|
10479
10540
|
// docs/get-started/02-deploy.md
|
|
10480
|
-
var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker, with a D1 database for posts and subscribers and an R2 bucket for images.\n\nIn this guide, you choose your hostnames, create your instance\'s repository, create the database and the bucket, and point Kestrel\'s production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname.\n\n## Before you begin\n\n- A [Cloudflare account](https://dash.cloudflare.com/sign-up), with your domain\'s DNS on Cloudflare. Your app\'s hostname and its login both live there.\n- [Node.js](https://nodejs.org/) 22 or later, and [Git](https://git-scm.com/).\n- A GitHub account, or another Git host, for your instance\'s repository.\n- An [AWS account](https://portal.aws.amazon.com/billing/signup), for Amazon SES, which sends your email.\n\n## Choose your hostnames\n\nKestrel uses two hostnames: one for the app, and one your email is sent from.\n\n| Hostname | What it\'s for |\n| --- | --- |\n| `newsletter.example.com` | the editor, the API, and the public pages readers see |\n| `send.example.com` | the address your email comes from, and its SPF, DKIM, and DMARC records |\n\nThese guides put the app on a subdomain, which leaves any website you already have on `example.com` untouched. If `example.com` has no website of its own, the app can live there instead. Use `example.com` wherever these guides say `newsletter.example.com`.\n\nIt\'s recommended to send from a subdomain rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn\'t put that at risk. Avoid `mail.` as the sending name too, since mail servers read it as an inbound host. [Sending-domain DNS](../reference/02-sending-domain-dns.md#choose-your-sending-name) explains both.\n\nThroughout these guides, replace the example names with your own, and any value written as `REPLACE_WITH_\u2026`.\n\n## 1. Create your instance\'s repository\n\nYour instance\'s repository holds its configuration, its database migrations, and the release of Kestrel it runs. Kestrel itself installs from npm as `@kurtbruns/kestrel`, so the repository stays small, and an upgrade changes the version it names.\n\n1. On GitHub, [create a new repository](https://github.com/new) named `newsletter`. It can be private. Leave it empty, with no README, license, or `.gitignore`.\n\n1. Clone it, and write a new instance into it. Replace `YOUR_USERNAME` with your GitHub username:\n\n ```bash\n git clone https://github.com/YOUR_USERNAME/newsletter.git\n cd newsletter\n npx kestrel init\n ```\n\n This writes the instance\'s files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release\'s database migrations, and a starting point for Claude Code in `.claude/`. The instance\'s `README.md` lists its commands.\n\n1. Install the release and its tools:\n\n ```bash\n npm install\n ```\n\n1. Commit the new instance, and push it:\n\n ```bash\n git add -A\n git commit -m "Start a Kestrel instance"\n git push -u origin HEAD\n ```\n\n1. **(Optional)** Try Kestrel locally before you deploy it. Copy the local settings, and start the app:\n\n ```bash\n cp .dev.vars.example .dev.vars\n npm run dev\n ```\n\n Open http://localhost:8787/dashboard/, and run `npm run seed` in a second terminal to load a demo publication. Email goes to a stand-in, so nothing reaches a real inbox. Press Ctrl+C to stop the server when you\'re done.\n\n## 2. Sign in to Cloudflare\n\nEvery command from here on acts on your Cloudflare account through the Wrangler CLI.\n\n1. Sign in. A browser window opens for you to approve the login:\n\n ```bash\n npx wrangler login\n ```\n\n1. Confirm which account you\'re signed in to:\n\n ```bash\n npx wrangler whoami\n ```\n\n It prints your email and the account name. If you belong to more than one account, make sure it\'s the one that holds your domain.\n\n## 3. Create the database and the image bucket\n\nEach command creates the resource and writes it into the `production` block of `wrangler.jsonc`, under the binding the app already uses. Name `--env production` on each one, or Wrangler writes to the top level, which is local development.\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create kestrel-production --env production --binding DB --update-config\n ```\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create kestrel-media-production --env production --binding MEDIA --update-config\n ```\n\nEach prints a snippet to add to your configuration. There\'s nothing to paste: `--update-config` has filled in the database\'s id, and the `MEDIA` binding already names the bucket. Wrangler also rewrites `wrangler.jsonc` in its own layout, with tabs and one value per line, and your settings stay.\n\n## 4. Configure production\n\n`wrangler.jsonc` holds two sets of settings. The top level is for local development, so leave it alone. The `production` block under `env` is yours to fill in.\n\n1. Open `wrangler.jsonc` and change the values marked here in the `production` block. Only the settings you change are shown; leave the others as they are:\n\n ```jsonc\n "production": {\n "routes": [\n {\n "pattern": "newsletter.example.com", // \u2190 your app\'s hostname\n "custom_domain": true\n }\n ],\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "\u2026", // already filled in by section 3; leave it\n "migrations_dir": "migrations"\n }\n ],\n "vars": {\n "PROVIDER": "", // \u2190 stays empty until Connect Amazon SES\n "APP_ORIGIN": "https://newsletter.example.com", // \u2190 your app\'s hostname\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com", // \u2190 your sending hostname\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>" // \u2190 who your mail is from\n }\n }\n ```\n\n What each one does:\n\n - `routes` puts the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can\'t already have a CNAME record; delete one if it does. The block also turns off the `workers.dev` addresses, so the app answers only on your hostname.\n - `PROVIDER` stays empty until [Connect Amazon SES](04-ses.md) sets it. Until then the app runs without an email provider: posts can be written and previewed, but scheduling, test emails, and signups are refused. Never set it to `fake`, the top level\'s value, even when a wrangler warning suggests copying it: `fake` delivers nothing, and the app refuses it on your hostname.\n - `ARCHIVE_BASE_PATH` is permanent once the first post goes out. Every post sent carries its `/archive/\u2026` link, and changing the prefix later breaks the links already mailed. Keep `/archive` unless you have a reason not to.\n - `FROM_ADDRESS` is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.\n\n Change every `example.com` value, the route included. The app refuses to run while any of its settings still names `example.com`.\n\n Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.\n\n1. Check the file. It fails if the configuration doesn\'t parse:\n\n ```bash\n npm run typecheck\n ```\n\n1. Commit your settings and push them to your repository:\n\n ```bash\n git commit -am "Configure production"\n git push\n ```\n\n## 5. Apply the schema\n\nCreate Kestrel\'s tables in the production database:\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it\'s about to apply and asks you to confirm. It applies only the ones the database hasn\'t seen, so running it again is harmless.\n\n## 6. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThis deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker\'s **Triggers** tab in the Cloudflare dashboard.\n\n## Check it\n\n1. The app answers on your hostname. A new hostname can take a few minutes to get its certificate.\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The editor is locked. Until you set up Access in the next step, the app can\'t tell who is asking, so it lets no one in:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" https://newsletter.example.com/api/whoami\n ```\n\n ```\n 401\n ```\n\n In a browser, `https://newsletter.example.com/dashboard/` says the editor is locked until you set up Access. That\'s expected.\n\n1. In a browser, `https://newsletter.example.com/` shows the newsletter\'s public home page. Its subscribe page, `/subscribe`, says signups aren\'t open yet, until Connect Amazon SES.\n\nIf the public pages instead say the newsletter is temporarily unavailable, and `/health` answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",\u2026}`, the setting it names is missing, malformed, or still on `example.com`. Fix it in `wrangler.jsonc`, commit, and deploy again.\n\nIf the public pages say the same, but `/health` answers `500` with `{"error":"schema_behind","missing":[\u2026],\u2026}`, the database hasn\'t run the migrations it lists, most often because step 5 was skipped or its prompt answered no. Run step 5\'s command, which the body\'s `message` repeats; the app answers as soon as it finishes, with no new deploy.\n\nNext, [lock the dashboard with Access](03-access.md).\n';
|
|
10541
|
+
var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker, with a D1 database for posts and subscribers and an R2 bucket for images.\n\nIn this guide, you choose your hostnames, create your instance\'s repository, create the database and the bucket, and point Kestrel\'s production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname.\n\n## Before you begin\n\n- A [Cloudflare account](https://dash.cloudflare.com/sign-up), with your domain\'s DNS on Cloudflare. Your app\'s hostname and its login both live there.\n- [Node.js](https://nodejs.org/) 22 or later, and [Git](https://git-scm.com/).\n- A GitHub account, or another Git host, for your instance\'s repository.\n- An [AWS account](https://portal.aws.amazon.com/billing/signup), for Amazon SES, which sends your email.\n\n## Choose your hostnames\n\nKestrel uses two hostnames: one for the app, and one your email is sent from.\n\n| Hostname | What it\'s for |\n| --- | --- |\n| `newsletter.example.com` | the editor, the API, and the public pages readers see |\n| `send.example.com` | the address your email comes from, and its SPF, DKIM, and DMARC records |\n\nThese guides put the app on a subdomain, which leaves any website you already have on `example.com` untouched. If `example.com` has no website of its own, the app can live there instead. Use `example.com` wherever these guides say `newsletter.example.com`.\n\nIt\'s recommended to send from a subdomain rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn\'t put that at risk. These guides assume a sending subdomain throughout. Avoid `mail.` as the sending name too, since mail servers read it as an inbound host. [Sending-domain DNS](../reference/02-sending-domain-dns.md#choose-your-sending-name) explains both.\n\nThroughout these guides, replace the example names with your own, and any value written as `REPLACE_WITH_\u2026`.\n\n## 1. Create your instance\'s repository\n\nYour instance\'s repository holds its configuration, its database migrations, and the release of Kestrel it runs. Kestrel itself installs from npm as `@kurtbruns/kestrel`, so the repository stays small, and an upgrade changes the version it names.\n\n1. On GitHub, [create a new repository](https://github.com/new) named `newsletter`. It can be private. Leave it empty, with no README, license, or `.gitignore`.\n\n1. Clone it, and write a new instance into it. Replace `YOUR_USERNAME` with your GitHub username:\n\n ```bash\n git clone https://github.com/YOUR_USERNAME/newsletter.git\n cd newsletter\n npx kestrel init\n ```\n\n This writes the instance\'s files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release\'s database migrations, and a starting point for Claude Code in `.claude/`. The instance\'s `README.md` lists its commands.\n\n1. Install the release and its tools:\n\n ```bash\n npm install\n ```\n\n1. Commit the new instance, and push it:\n\n ```bash\n git add -A\n git commit -m "Start a Kestrel instance"\n git push -u origin HEAD\n ```\n\n1. **(Optional)** Try Kestrel locally before you deploy it. Copy the local settings, and start the app:\n\n ```bash\n cp .dev.vars.example .dev.vars\n npm run dev\n ```\n\n Open http://localhost:8787/dashboard/, and run `npm run seed` in a second terminal to load a demo publication. Email goes to a stand-in, so nothing reaches a real inbox. Press Ctrl+C to stop the server when you\'re done.\n\n## 2. Sign in to Cloudflare\n\nEvery command from here on acts on your Cloudflare account through the Wrangler CLI.\n\n1. Sign in. A browser window opens for you to approve the login:\n\n ```bash\n npx wrangler login\n ```\n\n1. Confirm which account you\'re signed in to:\n\n ```bash\n npx wrangler whoami\n ```\n\n It prints your email and the account name. If you belong to more than one account, make sure it\'s the one that holds your domain.\n\n## 3. Create the database and the image bucket\n\nEach command creates the resource and writes it into the `production` block of `wrangler.jsonc`, under the binding the app already uses. Name `--env production` on each one, or Wrangler writes to the top level, which is local development.\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create kestrel-production --env production --binding DB --update-config\n ```\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create kestrel-media-production --env production --binding MEDIA --update-config\n ```\n\nEach prints a snippet to add to your configuration. There\'s nothing to paste: `--update-config` has filled in the database\'s id, and the `MEDIA` binding already names the bucket. Wrangler also rewrites `wrangler.jsonc` in its own layout, with tabs and one value per line, and your settings stay.\n\n## 4. Configure production\n\n`wrangler.jsonc` holds two sets of settings. The top level is for local development, so leave it alone. The `production` block under `env` is yours to fill in.\n\n1. Open `wrangler.jsonc` and change the values marked here in the `production` block. Only the settings you change are shown; leave the others as they are:\n\n ```jsonc\n "production": {\n "routes": [\n {\n "pattern": "newsletter.example.com", // \u2190 your app\'s hostname\n "custom_domain": true\n }\n ],\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "\u2026", // already filled in by section 3; leave it\n "migrations_dir": "migrations"\n }\n ],\n "vars": {\n "PROVIDER": "", // \u2190 stays empty until Connect Amazon SES\n "APP_ORIGIN": "https://newsletter.example.com", // \u2190 your app\'s hostname\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com", // \u2190 your sending hostname\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>" // \u2190 who your mail is from\n }\n }\n ```\n\n What each one does:\n\n - `routes` puts the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can\'t already have a CNAME record; delete one if it does. The block also turns off the `workers.dev` addresses, so the app answers only on your hostname.\n - `PROVIDER` stays empty until [Connect Amazon SES](04-ses.md) sets it. Until then the app runs without an email provider: posts can be written and previewed, but scheduling, test emails, and signups are refused. Never set it to `fake`, the top level\'s value, even when a wrangler warning suggests copying it: `fake` delivers nothing, and the app refuses it on your hostname.\n - `ARCHIVE_BASE_PATH` is permanent once the first post goes out. Every post sent carries its `/archive/\u2026` link, and changing the prefix later breaks the links already mailed. Keep `/archive` unless you have a reason not to.\n - `FROM_ADDRESS` is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.\n\n Change every `example.com` value, the route included. The app refuses to run while any of its settings still names `example.com`.\n\n Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.\n\n1. Check the file. It fails if the configuration doesn\'t parse:\n\n ```bash\n npm run typecheck\n ```\n\n1. Commit your settings and push them to your repository:\n\n ```bash\n git commit -am "Configure production"\n git push\n ```\n\n## 5. Apply the schema\n\nCreate Kestrel\'s tables in the production database:\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it\'s about to apply and asks you to confirm. It applies only the ones the database hasn\'t seen, so running it again is harmless.\n\n## 6. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThis deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker\'s **Triggers** tab in the Cloudflare dashboard.\n\nAlways name `--env production`, here and on every wrangler command that stores a secret. Without it, wrangler deploys the top level, local development, as a second Worker named `kestrel` that answers nowhere and never sends. If that happens, remove it:\n\n```bash\nnpx wrangler delete --name kestrel\n```\n\n## Check it\n\n1. The app answers on your hostname. If `curl` reports a certificate error, Cloudflare is still issuing the new hostname\'s certificate; try again shortly.\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The editor is locked. Until you set up Access in the next step, the app can\'t tell who is asking, so it lets no one in:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" https://newsletter.example.com/api/whoami\n ```\n\n ```\n 401\n ```\n\n In a browser, `https://newsletter.example.com/dashboard/` says the editor is locked until you set up Access. That\'s expected.\n\n1. In a browser, `https://newsletter.example.com/` shows the newsletter\'s public home page. Its subscribe page, `/subscribe`, says signups aren\'t open yet, until Connect Amazon SES.\n\nIf the public pages instead say the newsletter is temporarily unavailable, and `/health` answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",\u2026}`, the setting it names is missing, malformed, or still on `example.com`. Fix it in `wrangler.jsonc`, commit, and deploy again.\n\nIf the public pages say the same, but `/health` answers `500` with `{"error":"schema_behind","missing":[\u2026],\u2026}`, the database hasn\'t run the migrations it lists, most often because step 5 was skipped or its prompt answered no. Run step 5\'s command, which the body\'s `message` repeats; the app answers as soon as it finishes, with no new deploy.\n\nNext, [lock the dashboard with Access](03-access.md).\n';
|
|
10481
10542
|
|
|
10482
10543
|
// docs/get-started/03-access.md
|
|
10483
|
-
var access_default = '# Lock the dashboard with Access\n\nCloudflare Access puts a login in front of the editor and the API. Kestrel checks every request against that login as well, so a path left out of Access stays locked. The public pages readers see stay public.\n\nIn this guide, you set up Cloudflare Zero Trust, create one Access application that covers the editor and the API, and allow yourself in. Then you give the app two values from that application, and check that the login works.\n\n## 1. Set up Zero Trust\n\nAccess is part of Cloudflare Zero Trust. Your account needs a Zero Trust organization before it can create an Access application. Skip this section if your account already has one.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), select **Zero Trust**.\n\n1. Choose a team name. It becomes your team domain, `YOUR_TEAM.cloudflareaccess.com`, where your login page lives.\n\n1. Choose a plan. The Free plan covers a newsletter\'s editors. Cloudflare asks for payment details even on the Free plan, and doesn\'t charge them.\n\n## 2. Create the Access application\n\nThe editor lives under `/dashboard`, and the API under `/api`. One application covers both, so they share one login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Applications**, and select **Create new application**.\n\n1. Select **Self-hosted and private**.\n\n1. Select **Add public hostname**, and enter your app\'s hostname with the first path:\n\n - **Subdomain:** `newsletter`\n - **Domain:** `example.com`\n - **Path:** `dashboard`\n\n1. Add a second public hostname with the same subdomain and domain, and the path `api`:\n\n | Path | What it covers |\n | --- | --- |\n | `dashboard` | the editor |\n | `api` | everything the editor and Claude read and change: posts, sends, subscribers, settings, the in-app docs, and the MCP server Claude Desktop and claude.ai connect to (`/api/mcp`) |\n\n Each path covers everything under it. Leave every other path out. The home page, the archive, subscribing, unsubscribing, images, and your email provider\'s webhooks must stay public.\n\n1. **(Optional)** Choose how long a sign-in lasts. The application\'s session duration defaults to 24 hours, so everyone signs in again each day. You can set anything from immediately to one month, as Cloudflare\'s [session management](https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/session-management/) describes. A longer session suits a publisher on their own computer, and a shorter one is safer on a shared one.\n\n## 3. Allow yourself in\n\nEvery Access application denies everyone until a policy allows them.\n\n1. Under **Access policies**, create a new policy:\n\n - **Policy name:** `Publishers`\n - **Action:** Allow\n - **Include:** the **Emails** selector, with your email address\n\n1. Select **Create**.\n\nCloudflare\'s own login is on by default, and admits members of your Cloudflare account, so the email in your `Publishers` policy should be one. To let someone sign in another way, such as a one-time code by email, add it under **Zero Trust \u2192 Integrations \u2192 Identity providers**.\n\
|
|
10544
|
+
var access_default = '# Lock the dashboard with Access\n\nCloudflare Access puts a login in front of the editor and the API. Kestrel checks every request against that login as well, so a path left out of Access stays locked. The public pages readers see stay public.\n\nIn this guide, you set up Cloudflare Zero Trust, create one Access application that covers the editor and the API, and allow yourself in. Then you give the app two values from that application, and check that the login works.\n\n## 1. Set up Zero Trust\n\nAccess is part of Cloudflare Zero Trust. Your account needs a Zero Trust organization before it can create an Access application. Skip this section if your account already has one.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), select **Zero Trust**.\n\n1. Choose a team name. It becomes your team domain, `YOUR_TEAM.cloudflareaccess.com`, where your login page lives.\n\n1. Choose a plan. The Free plan covers a newsletter\'s editors. Cloudflare asks for payment details even on the Free plan, and doesn\'t charge them.\n\n## 2. Create the Access application\n\nThe editor lives under `/dashboard`, and the API under `/api`. One application covers both, so they share one login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Applications**, and select **Create new application**.\n\n1. Select **Self-hosted and private**.\n\n1. Select **Add public hostname**, and enter your app\'s hostname with the first path:\n\n - **Subdomain:** `newsletter`\n - **Domain:** `example.com`\n - **Path:** `dashboard`\n\n1. Add a second public hostname with the same subdomain and domain, and the path `api`:\n\n | Path | What it covers |\n | --- | --- |\n | `dashboard` | the editor |\n | `api` | everything the editor and Claude read and change: posts, sends, subscribers, settings, the in-app docs, and the MCP server Claude Desktop and claude.ai connect to (`/api/mcp`) |\n\n Each path covers everything under it. Leave every other path out. The home page, the archive, subscribing, unsubscribing, images, and your email provider\'s webhooks must stay public.\n\n1. **(Optional)** Choose how long a sign-in lasts. The application\'s session duration defaults to 24 hours, so everyone signs in again each day. You can set anything from immediately to one month, as Cloudflare\'s [session management](https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/session-management/) describes. A longer session suits a publisher on their own computer, and a shorter one is safer on a shared one.\n\n## 3. Allow yourself in\n\nEvery Access application denies everyone until a policy allows them.\n\n1. Under **Access policies**, create a new policy:\n\n - **Policy name:** `Publishers`\n - **Action:** Allow\n - **Include:** the **Emails** selector, with your email address\n\n1. Select **Create**.\n\nCloudflare\'s own login is on by default, and admits members of your Cloudflare account, so the email in your `Publishers` policy should be one. To let someone sign in another way, such as a one-time code by email, add it under **Zero Trust \u2192 Integrations \u2192 Identity providers**.\n\n## 4. Give the app its two values\n\nThe app verifies each login against your team domain, and checks that it was issued for this application.\n\n1. Find your team domain under **Zero Trust \u2192 Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`.\n\n1. Find the application\'s audience tag. Under **Access controls \u2192 Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.\n\n1. Store the team domain as a secret. The command prompts you for the value. Enter the hostname alone, without `https://`:\n\n ```bash\n npx wrangler secret put ACCESS_TEAM_DOMAIN --env production\n ```\n\n1. Store the audience tag the same way:\n\n ```bash\n npx wrangler secret put ACCESS_AUD --env production\n ```\n\n A secret takes effect as soon as you store it, with no redeploy.\n\n1. **(Optional, recommended)** Limit the editor to named people. The Access policy decides who can sign in. This list keeps that true if the policy is widened later, for another app or by mistake. Enter a comma-separated list of email addresses at the prompt:\n\n ```bash\n npx wrangler secret put ACCESS_ALLOWED_EMAILS --env production\n ```\n\n Without it, the app lets in anyone your Access policy allows.\n\n## Check it\n\n1. In a private browser window, open `https://newsletter.example.com/dashboard/`. Cloudflare asks you to sign in.\n\n1. Sign in. The editor loads, and its sidebar shows the email address you signed in with.\n\n1. In the same window, open `https://newsletter.example.com/api/whoami`. It shows you as a signed-in person, verified through Access:\n\n ```\n {"principal":{"kind":"human","email":"you@example.com","client":null},"auth":{"mode":"access"}}\n ```\n\n1. The public pages still need no login:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\nIf a check fails:\n\n- **The editor says the configuration needs a fix.** Store the secret it names. While only one of the two is stored, `/health` answers `500` naming the other, and the public pages say the newsletter is temporarily unavailable.\n- **Cloudflare lets you in, but the editor answers `401`.** Check the two secrets for a typo, and that `ACCESS_ALLOWED_EMAILS`, if you set it, includes your address.\n- **The editor says Access let you in but didn\'t say who you are.** Your login carried no email, which happens through a kind of policy the app doesn\'t accept, such as a device check. Sign out, and sign in through the `Publishers` policy.\n\nNext, [connect Amazon SES](04-ses.md).\n';
|
|
10484
10545
|
|
|
10485
10546
|
// docs/get-started/04-ses.md
|
|
10486
|
-
var ses_default = '# Connect Amazon SES\n\nKestrel sends the newsletter\'s posts through Amazon SES, Amazon\'s email service. SES only delivers the mail: Kestrel keeps the list, each subscriber\'s consent, and the record of every send. SES prices every email alike, with no split between transactional and marketing mail, and costs the least at every list size above a tiny one. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.\n\nIn this guide, you verify your sending hostname with SES and add a DMARC record. You forward readers\' replies to the publisher\'s inbox, and ask AWS for production access. You also route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, set the provider, and deploy.\n\n## Before you begin\n\n- An [AWS account](https://portal.aws.amazon.com/billing/signup).\n\nTo move an instance that already sends through another provider or another SES account, read [Switch an instance that already sends](#switch-an-instance-that-already-sends) first.\n\n## 1. Verify your sending hostname\n\nSES sends only from an identity you\'ve verified. Verifying the whole hostname, rather than one address, lets any address on it send. It also sets up DKIM, which lets inboxes trust your mail.\n\n1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose the region you send from in the menu at the top right. Any region that offers SES works, so pick one near you. A few newer regions must be [enabled for your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html) before you can use them. SES keeps identities, quotas, and the sandbox separately in each region, so do every step in this one.\n\n1. Note the region\'s code. The menu shows a name, such as Europe (Ireland), but later steps need its code, such as `eu-west-1`. [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each region\'s code beside its name. The MAIL FROM record, the SNS access policy, and `AWS_REGION` all use this one code.\n\n1. Under **Configuration**, choose **Identities**, then **Create identity**.\n\n1. Select **Domain**, and enter `send.example.com`. Keep the default DKIM settings: **Easy DKIM**, with a 2048-bit key. Choose **Create identity**.\n\n1. On the identity\'s **Authentication** tab, expand **Publish DNS records**. It lists three CNAME records.\n\n1. In the Cloudflare dashboard, add each record to your domain\'s DNS records, exactly as SES lists it. Set each one\'s proxy status to **DNS only**. Cloudflare adds your domain to the name, so a name ends in `._domainkey.send`.\n\n1. Wait until SES shows **DKIM configuration** as **Successful** and **Identity status** as **Verified**.\n\n## 2. Add a DMARC record\n\nDMARC tells inboxes what to do with mail that fails its checks, and sends you reports about it. SES doesn\'t add this record, so you add it yourself. It starts in monitor mode, which reports without blocking anything.\n\n1. In the Cloudflare dashboard, open your domain\'s DNS records, and add a record:\n\n - **Type:** `TXT`\n - **Name:** `_dmarc.send`\n - **Content:** `v=DMARC1; p=none; rua=mailto:dmarc@example.com`\n\n1. Replace `dmarc@example.com` with an address that receives mail. Reports arrive there as attachments.\n\n[Sending-domain DNS](../reference/02-sending-domain-dns.md#dmarc-publish-a-policy-and-collect-reports) explains each record, and when to tighten DMARC.\n\n## 3. Receive replies at your From address\n\nWhen a reader replies to a post, or to their confirmation email, the reply goes to your From address, `newsletter@send.example.com`. Nothing receives mail there yet, so the reply bounces. Kestrel only sends mail, so you give the address an inbox yourself. Here you forward it to the publisher\'s inbox, with Cloudflare Email Routing, which is free on every plan. The publisher is whoever writes and sends the newsletter, which may be you.\n\n`send.example.com` receives no other mail, so turning on Email Routing there changes nothing else. Your SES records sit on other names under it. Inboxes check SES\'s mail for SPF at its return path, not here, so sending is unaffected.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), go to **Compute \u2192 Email Service \u2192 Email Routing**.\n\n1. Add `send.example.com` to Email Routing. Cloudflare adds subdomains from your domain\'s own settings: select `example.com`, then **Settings**, and add `send` under **Subdomains**.\n\n1. Cloudflare adds MX, SPF, and DKIM records for `send.example.com`, and locks them. Wait until they show as added.\n\n1. Under **Destination Addresses**, add the publisher\'s inbox. Open the email Cloudflare sends to it, and select **Verify email address**. If it isn\'t your inbox, ask the publisher to do this step. An address you\'ve already verified with Cloudflare, such as one for [notifications](../guides/03-notifications.md), is listed already.\n\n1. Under **Routing Rules**, select **Create routing rule**. For **Email pattern**, enter `newsletter`, the part of your From address before the `@`, and select `send.example.com`. Set **Action** to **Send to an email**, select the publisher\'s inbox as the **Destination**, and save.\n\nOn a subdomain, Email Routing forwards only the addresses you add a rule for, with no catch-all. That\'s all a From address needs.\n\n- **Automatic replies arrive too.** Out-of-office and other automatic replies go to your From address, so they reach the publisher\'s inbox. Bounces don\'t: they go to SES\'s return path, and the app handles them as before.\n- **Your DMARC reports need an inbox too.** Your DMARC record\'s report address may not receive mail. If so, change the record\'s `rua` to `mailto:dmarc@send.example.com`, and add a second rule that forwards `dmarc` the same way.\n- **Answers come from the publisher\'s inbox.** A forwarded reply comes from the reader\'s own address. An answer to it goes out from the inbox it was forwarded to, not from `newsletter@send.example.com`.\n\nTo answer from the newsletter\'s own address, give it a real mailbox instead of forwarding it. Point `send.example.com`\'s MX records at a mail host you use, such as [Fastmail](https://www.fastmail.help/hc/en-us/articles/360060591133) or [Google Workspace](https://support.google.com/a/answer/140034). Then create `newsletter@send.example.com` there, as the host\'s docs describe. If you send from it, the host\'s SPF and DKIM records go on `send.example.com` beside SES\'s. SES\'s mail is unaffected, since its SPF is checked at its return path. And if your From address is on a domain that already receives mail, replies already reach it, and you can skip this section.\n\nSome replies ask the publisher to do something, and Kestrel can\'t see them, so the publisher acts on them, as [Sending rules](../publishing/02-sending-rules.md#when-a-reader-asks-to-leave-or-to-be-deleted) describes.\n\n## 4. (Optional) Set a custom MAIL FROM domain\n\nSES sends with its own return path by default, so SPF passes for `amazonses.com` rather than for your hostname. DMARC still passes, through DKIM. A custom MAIL FROM domain makes SPF pass for your hostname too, a second check in your favor.\n\n1. In SES, open the `send.example.com` identity. Under **Custom MAIL FROM domain**, choose **Edit**.\n\n1. Select **Use a custom MAIL FROM domain**, and enter `bounce.send.example.com`. For **Behavior on MX failure**, keep **Use default MAIL FROM domain**. Choose **Save changes**.\n\n1. In Cloudflare, add the two records SES lists. Replace `us-east-1` with your region\'s code from section 1:\n\n - **Type:** `MX`, **Name:** `bounce.send`, **Mail server:** `feedback-smtp.us-east-1.amazonses.com`, **Priority:** `10`\n - **Type:** `TXT`, **Name:** `bounce.send`, **Content:** `v=spf1 include:amazonses.com ~all`\n\n1. Wait until SES shows the MAIL FROM domain as **Successful**.\n\n## 5. Request production access\n\nA new SES account starts in the sandbox. It sends only to addresses you\'ve verified in SES, up to 200 emails a day and one a second. Ask AWS to lift those limits now, and test while you wait.\n\n1. Name the publication first, or ask the publisher for its name, since AWS may visit its website to review the request. In the editor, open **Settings \u2192 Publication identity**, and enter the name and tagline. Until then, the public pages show the name in your From address. The subscribe form stays closed until section 8 sets the provider.\n\n1. In SES, open the **Account dashboard**, select **View Get set up page**, and then **Request production access**.\n\n1. Fill in the request:\n\n - **Mail type:** Marketing\n - **Website URL:** `https://newsletter.example.com/`\n\n1. Check the acknowledgement, and select **Submit request**. AWS Support answers by email, and may ask how you collect addresses and handle bounces.\n\n1. While you wait, verify the two addresses you test with in [Verify it works](05-verify.md#before-you-begin), so the sandbox lets you mail them. For each, under **Identities**, choose **Create identity**, select **Email address**, and follow the link in the email AWS sends.\n\nIf AWS asks for more detail, reply in the support case. If it denies the request, reply there, or open a new request from the **Account dashboard**. Either way, say how Kestrel handles the list:\n\n- Signups use double opt-in: each subscriber confirms by email before joining the list.\n- Every post offers one-click unsubscribe.\n- Bounces and complaints reach the app through SNS, and it stops mailing those addresses automatically.\n- How many subscribers the newsletter expects, and how often it sends.\n\nYour account stays in the sandbox until AWS grants access, so don\'t go live before then. In the sandbox, SES refuses mail to any address you haven\'t verified:\n\n- A signup from an unverified address gets no confirmation email. The app emails the publisher about it, at most once a day.\n- A post goes to your verified addresses and then waits, with the line "The provider is refusing the account" and SES\'s words. No reader is marked unsent. Once AWS grants access, the post goes to the rest at its next retry, mailing no one twice.\n- A daily quota refusal in the sandbox (200 emails a day) means the same thing: request production access rather than a higher quota.\n\nWhen AWS grants access, the **Account dashboard** shows your daily quota and your maximum send rate. You set the rate in the app in section 8. The rate decides how fast a send goes out, and [As your list grows](../guides/09-as-your-list-grows.md) says when to ask AWS to raise it.\n\n## 6. Send bounces and complaints to SNS\n\nSES reports what happens to each email through SNS, the AWS notification service. A configuration set says which events to report, and an SNS topic carries them to the app.\n\n1. In SES, under **Configuration**, choose **Configuration sets**, then **Create set**. Name it `kestrel-mail`, and choose **Create set**.\n\n1. On the set\'s **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, **Deliveries**, and **Rendering failures**, and choose **Next**. A rendering failure is an email SES took but couldn\'t build. It shouldn\'t happen, and the app logs it as an error if it does.\n\n1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled. Choose **Next**.\n\n1. For **SNS topic**, choose **Create SNS topic**. Make it a **Standard** topic named `kestrel-events`. Choose **Next**, then **Add destination**.\n\n1. Make the set your hostname\'s default, so SES reports on every email sent from it. Open the `send.example.com` identity, and choose the **Configuration set** tab. Under **Default configuration set**, choose **Edit**, select `kestrel-mail`, and save.\n\n1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), and copy its ARN. It looks like `arn:aws:sns:us-east-1:111122223333:kestrel-events`. After `sns` comes your region\'s code, then your 12-digit account ID. The account menu at the top right of the console also shows the account ID.\n\n1. Let SES publish to the topic. Choose **Edit**, expand **Access policy**, and add this statement to the `Statement` list:\n\n ```json\n {\n "Effect": "Allow",\n "Principal": { "Service": "ses.amazonaws.com" },\n "Action": "sns:Publish",\n "Resource": "REPLACE_WITH_TOPIC_ARN",\n "Condition": {\n "StringEquals": {\n "AWS:SourceAccount": "REPLACE_WITH_ACCOUNT_ID",\n "AWS:SourceArn": "arn:aws:ses:us-east-1:REPLACE_WITH_ACCOUNT_ID:configuration-set/kestrel-mail"\n }\n }\n }\n ```\n\n Replace the topic ARN, and both copies of the account ID, with the values from your ARN. Replace `us-east-1` with your region\'s code, the same one the ARN shows. Choose **Save changes**.\n\nYou subscribe the app to the topic in section 9, once it runs on SES.\n\n## 7. Create the app\'s AWS key\n\nThe app sends through an IAM user whose key can only send email. That limits the harm if the key ever leaks.\n\n1. In the [IAM console](https://console.aws.amazon.com/iam/), go to **Users**, and choose **Create user**. Name it `kestrel-sender`, and leave console access off.\n\n1. When asked for permissions, choose **Attach policies directly**, then **Create policy**. The policy editor opens in a new browser tab.\n\n1. In the new tab, choose the **JSON** editor, and replace its contents with this policy:\n\n ```json\n {\n "Version": "2012-10-17",\n "Statement": [\n {\n "Effect": "Allow",\n "Action": ["ses:SendEmail", "ses:SendBulkEmail"],\n "Resource": "*"\n }\n ]\n }\n ```\n\n Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. You can close this tab.\n\n1. Go back to the first tab, where you\'re creating the user. Refresh the policy list, search for `kestrel-send-email`, and select it.\n\n1. Choose **Next**, then **Create user**.\n\n1. Open the user, and on the **Security credentials** tab, choose **Create access key**. For the use case, choose **Application running outside AWS**.\n\n1. Copy the access key ID and the secret access key. AWS shows the secret only once.\n\n## 8. Set the provider and deploy\n\nUntil now the app has run with no email provider connected, refusing to send. Setting `PROVIDER` connects SES. Store the secrets before you set it. With `PROVIDER` set to `ses` and a required secret missing, the app refuses to run, and sends nothing, until you store it. `/health` and the editor\'s **A setting needs fixing** page name the missing secret, and reader pages say the newsletter is temporarily unavailable. `SES_CONFIGURATION_SET` is the one optional secret.\n\n1. Store the access key ID, and paste it at the prompt:\n\n ```bash\n npx wrangler secret put AWS_ACCESS_KEY_ID --env production\n ```\n\n1. Store the secret access key:\n\n ```bash\n npx wrangler secret put AWS_SECRET_ACCESS_KEY --env production\n ```\n\n1. Store the topic ARN from section 6. The app accepts events only from this topic:\n\n ```bash\n npx wrangler secret put SNS_TOPIC_ARN --env production\n ```\n\n1. Store the configuration set\'s name, `kestrel-mail`. It\'s optional but recommended: the app names it on every email, so SES reports on each one even without the default. Type it exactly as the set is named in this region: if SES can\'t find the set, it refuses every email, and a list send is held until the name is fixed or the set created:\n\n ```bash\n npx wrangler secret put SES_CONFIGURATION_SET --env production\n ```\n\n1. In `wrangler.jsonc`, edit the `production` block\'s `vars`. Change `PROVIDER` to `ses` (it\'s `""` on a new instance; add it if your block has none), and add `SES_MAX_SEND_RATE`. Change `AWS_REGION` to your region\'s code from section 1. Your other values stay as they are. The block ends up like this:\n\n ```jsonc\n "vars": {\n "PROVIDER": "ses", // \u2190 set to ses\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1", // \u2190 change: your region\'s code\n "SES_MAX_SEND_RATE": "1" // \u2190 new: your maximum send rate; 1 in the sandbox\n }\n ```\n\n On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan\'s limit, so each minute sends more. Leave it out on Workers Free, which suits a list of up to about 1,000 subscribers. [As your list grows](../guides/09-as-your-list-grows.md) says why.\n\n When AWS grants production access, set `SES_MAX_SEND_RATE` to the maximum send rate on the **Account dashboard**, and deploy again. The app keeps to that rate, so SES doesn\'t throttle your send.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Send through Amazon SES"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## 9. Subscribe the app to the topic\n\nSNS delivers events only to a subscriber that has confirmed. The app confirms on its own, once it checks that the request came from your topic.\n\n1. In the SNS console, open your topic, and choose **Create subscription**.\n\n1. Set **Protocol** to **HTTPS**, and enter the endpoint:\n\n ```\n https://newsletter.example.com/webhooks/ses\n ```\n\n1. Leave raw message delivery off, and choose **Create subscription**.\n\n1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.\n\n1. Give SNS longer to retry. By default it retries a failed delivery 3 times over about a minute, then discards the event, so a bounce that arrives while the app is briefly down or mid-deploy is lost, and the address gets your next post. Open the topic, choose **Edit**, and expand **Delivery policy (HTTP/S)**. Clear **Use the default delivery policy**, set these, and choose **Save changes**:\n\n | Field | Value |\n | --- | --- |\n | Number of retries | `13` |\n | Retries without delay | `0` |\n | Minimum delay | `20` |\n | Minimum delay retries | `2` |\n | Maximum delay | `300` |\n | Maximum delay retries | `6` |\n | Retry-backoff function | Exponential |\n\n SNS then keeps trying for most of an hour, the longest it allows. It never retries an event the app refused. The app refuses only an event it can\'t verify came from your topic, and logs an error when one claims to be from SNS.\n\n1. **(Optional)** Keep what SNS still can\'t deliver. On the subscription, choose **Edit**, and set a **Redrive policy (dead-letter queue)** to an SQS queue of your own. SNS then moves an event there instead of discarding it, where you can read it later. [Amazon SNS dead-letter queues](https://docs.aws.amazon.com/sns/latest/dg/sns-dead-letter-queues.html) covers the queue and its permissions.\n\nIf it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:\n\n- **Access covers `/webhooks/ses`.** If your Access application covers more than `/dashboard` and `/api`, SNS gets the login page instead of the app. Remove that path from your Access application, so only `dashboard` and `api` remain.\n- **`SNS_TOPIC_ARN` matches the topic\'s ARN.** If it doesn\'t, the app\'s logs show a `webhook.refused` error naming both.\n- **The deploy in section 8 finished.**\n\nOnce you\'ve fixed the cause, select the subscription, and choose **Request confirmation**. SNS sends the request again, and the app confirms it. SNS doesn\'t let you delete a subscription while it\'s pending, and removes it on its own after 48 hours.\n\n## Switch an instance that already sends\n\nChange `PROVIDER`, or move to another account with the same provider, only while no send is sending. The dashboard lists any send in progress.\n\nWhen a request to a provider gets no answer, Kestrel can\'t know whether its recipients were mailed. Moving to SES, from another provider or another SES account, mails none of them twice. Kestrel never repeats an unanswered request to SES, so those recipients wait for you to decide whether they were mailed. Use **Resolve** on the send\'s page.\n\nKestrel can\'t tell whether those emails went out. SES\'s console shows sent messages only if you\'ve turned on Virtual Deliverability Manager, which this guide doesn\'t set up. So **Assume not sent** is the safe default. Those readers may miss this post, but they get your next one. No one is mailed twice. Choose **Assume sent** only when you know otherwise, such as a reader who says they got it.\n\n## Check it\n\n1. The app answers on your hostname:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The webhook is public, and refuses a request that isn\'t from SNS:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" -X POST https://newsletter.example.com/webhooks/ses\n ```\n\n ```\n 400\n ```\n\n Any other answer, such as a redirect to your Cloudflare login, means Access covers the webhook. Remove that path from your Access application.\n\n1. The SNS subscription shows **Confirmed**.\n\n1. Your From address has somewhere to go. In Cloudflare, your domain\'s DNS records show the ones Email Routing added for `send`, beside section 1\'s DKIM records:\n\n - **Type:** `MX`, **Name:** `send`, three of them, for `route1.mx.cloudflare.net`, `route2.mx.cloudflare.net`, and `route3.mx.cloudflare.net`\n - **Type:** `TXT`, **Name:** `send`, **Content:** `v=spf1 include:_spf.mx.cloudflare.net ~all`\n - **Type:** `TXT`, **Name:** `cf2024-1._domainkey.send`, Cloudflare\'s DKIM key\n\n The world sees the MX records too:\n\n ```bash\n dig +short MX send.example.com\n ```\n\n It lists the three `route` servers. Email Routing\'s **Routing Rules** shows your rule for `newsletter@send.example.com`, and **Destination Addresses** shows the publisher\'s inbox as verified. A reply to your test email in the next step proves the forwarding works.\n\n1. In the editor, **Settings \u2192 Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before.\n\n1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren\'t open yet.\n\nThe test sends in the next step prove the rest. In the sandbox, send them only to the addresses you verified in section 5, and to the SES test addresses that page lists.\n\nSES charges for each email it sends, test emails included. [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists the rates.\n\nNext, [verify it works](05-verify.md).\n';
|
|
10547
|
+
var ses_default = '# Connect Amazon SES\n\nThis page connects the app to Amazon Simple Email Service (Amazon SES), which delivers the newsletter\'s email. A new SES account starts in a sandbox, where it can send only to addresses you\'ve verified, until AWS reviews it. This page requests that review early, so you can test in the sandbox while you wait.\n\nIn this guide, you verify your sending hostname, set a custom MAIL FROM domain, add a DMARC record, and request production access. You then route bounces and complaints to the app, give it an AWS key, and deploy it with SES connected. Last, you forward readers\' replies to the publisher\'s inbox.\n\n## Before you begin\n\n- An [AWS account](https://portal.aws.amazon.com/billing/signup), if you haven\'t created one already.\n- The publisher\'s inbox, for readers\' replies.\n\n## 1. Verify your sending hostname\n\nSES sends only from an identity you\'ve verified. Verifying the whole hostname lets any address on it send, and sets up DKIM, which lets inboxes trust your mail.\n\n1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose a region near you in the menu at the top right. SES keeps identities, quotas, and the sandbox separately in each region, so do every step on this page in this one. A few newer regions must be [enabled for your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html) first.\n\n1. Note the region\'s code, such as `eu-west-1`. The menu shows only its name, such as Europe (Ireland), and [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each code beside its name. Later steps use this 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, and 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, with its proxy status set to **DNS only**. Cloudflare adds your domain to the name, so each name ends in `._domainkey.send`.\n\n1. Wait until SES shows **DKIM configuration** as **Successful** and **Identity status** as **Verified**.\n\n## 2. Set a custom MAIL FROM domain\n\nBy default, SES sends with its own return path, so Gmail shows your mail as "mailed-by `amazonses.com`", and only DKIM ties it to your domain. A custom MAIL FROM domain puts your hostname there too, so SPF also passes for your domain. Your mail then still passes DMARC, the check section 3 sets up, if DKIM ever fails. It also meets Gmail\'s bulk-sender rule to set up SPF for your domain.\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, with your region\'s code in place of `us-east-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\'s status as successful.\n\n## 3. 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## 4. Request production access\n\nA new SES account starts in the sandbox: it sends only to addresses you\'ve verified, 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, since AWS may visit its website to review the request. In the editor, open **Settings \u2192 Publication identity**, and enter the name and tagline, or ask the publisher for them.\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\n Leave the other fields as they are.\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. Verify two addresses you own, such as a personal address and a work address, so the sandbox lets you mail them. Your test emails go to these. 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, or denies the request, reply in the support case, or open a new request from the **Account dashboard**. 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, and it stops mailing those addresses automatically.\n- How many subscribers the newsletter expects, and how often it sends.\n\nIn the meantime, continue with the rest of this guide.\n\n## 5. Send bounces and complaints to the app\n\nSES reports bounces and complaints through Amazon Simple Notification Service (SNS), which delivers them to the app. The app then stops mailing an address that bounced or complained. A configuration set says which events SES reports, and an SNS topic carries them.\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**. The app logs a rendering failure, an email SES couldn\'t build, as an error.\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 find its ARN. It looks like `arn:aws:sns:us-east-1:111122223333:kestrel-events`, where `us-east-1` is your region\'s code and `111122223333` is your 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, both copies of the account ID, and `us-east-1` with the values from your ARN. Choose **Save changes**.\n\n1. Store the topic\'s ARN in the app. In your instance\'s repository, run this, and paste the ARN at the prompt. 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 the set on every email, so SES reports on each one even without the hostname\'s default. Type it exactly as the set is named in this region, since SES refuses every email that names a set it can\'t find:\n\n ```bash\n npx wrangler secret put SES_CONFIGURATION_SET --env production\n ```\n\n## 6. Create the app\'s AWS key\n\nThe app sends through an AWS Identity and Access Management (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. Keep the page open: AWS shows the secret access key only once. Store the access key ID in the app, 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\n## 7. Set the provider and deploy\n\nUntil now the app has run with no email provider connected, refusing to send. Setting `PROVIDER` to `ses` connects SES, with the secrets you stored in sections 5 and 6. If a required one is missing, the app refuses to run until you store it, and `/health` names it.\n\n1. In `wrangler.jsonc`, edit the `production` block\'s `vars`. Change `PROVIDER` to `ses` (it\'s `""` on a new instance; add it if your block has none), and add `SES_MAX_SEND_RATE`. Change `AWS_REGION` to your region\'s code from section 1. Your other values stay as they are. The block ends up like this:\n\n ```jsonc\n "vars": {\n "PROVIDER": "ses", // \u2190 set to ses\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1", // \u2190 change: your region\'s code\n "SES_MAX_SEND_RATE": "1" // \u2190 new: your maximum send rate; 1 in the sandbox\n }\n ```\n\n On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan\'s limit, so each minute sends more. Leave it out on Workers Free, which suits a list of up to about 1,000 subscribers. [As your list grows](../guides/09-as-your-list-grows.md) says why.\n\n When AWS grants production access, set `SES_MAX_SEND_RATE` to the maximum send rate on the **Account dashboard**, and deploy again. The app keeps to that rate, so SES doesn\'t throttle your send.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Send through Amazon SES"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## 8. Subscribe the app to the topic\n\nSNS delivers events only to a subscriber that has confirmed. The app confirms on its own, once it checks that the request came from your topic.\n\n1. In the SNS console, open your topic, and choose **Create subscription**.\n\n1. Set **Protocol** to **HTTPS**, and enter the endpoint:\n\n ```\n https://newsletter.example.com/webhooks/ses\n ```\n\n1. Leave raw message delivery off, and choose **Create subscription**.\n\n1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.\n\n If it stays **Pending confirmation**, check that your Access application covers only `/dashboard` and `/api`, that `SNS_TOPIC_ARN` matches the topic\'s ARN, and that section 7\'s deploy finished. A mismatched ARN shows in the app\'s logs as a `webhook.refused` error naming both. Then request confirmation again from the topic\'s subscriptions. SNS doesn\'t let you delete a pending subscription, and removes it on its own after 48 hours.\n\n1. Give SNS longer to retry. By default it retries a failed delivery 3 times over about a minute, then discards the event, so a bounce that arrives mid-deploy is lost. Open the topic, choose **Edit**, and expand **Delivery retry policy (HTTP/S)**. Turn off the default policy, set these values, and choose **Save changes**:\n\n | Field | Value |\n | --- | --- |\n | Number of retries | `13` |\n | Retries without delay | `0` |\n | Minimum delay | `20` |\n | Minimum delay retries | `2` |\n | Maximum delay | `300` |\n | Maximum delay retries | `6` |\n | Retry-backoff function | Exponential |\n\n SNS then keeps trying for about 40 minutes. It allows an hour at most.\n\n1. **(Optional)** Keep what SNS still can\'t deliver. On the subscription, choose **Edit**, and set a **Redrive policy (dead-letter queue)** to an SQS queue of your own. SNS then moves an event there instead of discarding it. [Amazon SNS dead-letter queues](https://docs.aws.amazon.com/sns/latest/dg/sns-dead-letter-queues.html) covers the queue and its permissions.\n\n## 9. Receive replies at your From address\n\nReplies to a post go to your From address, `newsletter@send.example.com`, which has no inbox yet, so they bounce. This section forwards that address to the publisher\'s inbox with Cloudflare Email Routing. Forwarding only receives mail, so it doesn\'t change how SES sends.\n\n1. Check that your From address has no inbox yet. This prints nothing:\n\n ```bash\n dig +short MX send.example.com\n ```\n\n If it lists mail servers, `send.example.com` already receives mail elsewhere, and Email Routing would replace those servers. Check that your mail host has a mailbox for `newsletter@send.example.com`, and skip to [Check it](#check-it).\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: 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.\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\n1. **(Optional)** Give your DMARC reports an inbox too. If the address in your DMARC record doesn\'t receive mail, add a rule that forwards `dmarc` the same way. Then change the record\'s `rua` to `mailto:dmarc@send.example.com`.\n\nOut-of-office and other automatic replies reach the publisher\'s inbox too. Bounces don\'t: they go to SES, which reports them to the app. The publisher answers from their own inbox, not from `newsletter@send.example.com`. To answer from the newsletter\'s address instead, give it a mailbox at a mail host such as [Fastmail](https://www.fastmail.help/hc/en-us/articles/360060591133) or [Google Workspace](https://support.google.com/a/answer/140034), in place of forwarding.\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 receives mail. This lists Cloudflare\'s three `route` mail servers, or your own mail host\'s if you skipped section 9\'s forwarding:\n\n ```bash\n dig +short MX send.example.com\n ```\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 emails in the next step prove the rest, including a reply. In the sandbox, they go only to the addresses you verified in section 4, and to the SES test addresses that page lists. SES charges for each email it sends, test emails included, as [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists.\n\nNext, [verify it works](05-verify.md).\n';
|
|
10487
10548
|
|
|
10488
10549
|
// docs/get-started/05-verify.md
|
|
10489
|
-
var verify_default = "# Verify it works\n\
|
|
10550
|
+
var verify_default = "# Verify it works\n\nThis page checks your instance end to end with real email, while the list is empty and every email goes only to you. You confirm that inboxes trust your mail, that posts look right in a mail client, and that bounces, complaints, and unsubscribes reach the app.\n\nIn this guide, you send yourself a test email, reply to it, and check that it passes authentication. Then you set up notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs, check that SES can send to anyone, and clean up the test subscribers.\n\n## Before you begin\n\n- The two addresses you verified in SES in [Connect Amazon SES](04-ses.md#4-request-production-access). Some steps use both.\n- A Gmail address, or another mail client that shows a message's original source and an **Unsubscribe** button.\n\n## 1. Send yourself a test email\n\nA test email goes through the same code as a real send. If the test looks right, the real send looks the same.\n\n1. In the editor at `https://newsletter.example.com/dashboard/`, create a post with a subject, a short body, and an image.\n\n1. Select **Send test email**, enter your address, and send it.\n\n1. Open the email. It arrives in your inbox, not in spam, the image loads, and the subject reads as you wrote it. Its links work, but a test email unsubscribes no one; section 5 unsubscribes for real.\n\n1. Send a test email to your second address, not the publisher's inbox, and reply to it from there, as a reader would. The reply arrives in the publisher's inbox, the one you chose in [Connect Amazon SES](04-ses.md#9-receive-replies-at-your-from-address), or in your From address's own mailbox if you skipped forwarding. If it 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 If SPF passes for `amazonses.com` instead, SES couldn't read your custom MAIL FROM domain's MX record, and used its own. DMARC still passes, but check the records from [Connect Amazon SES](04-ses.md#2-set-a-custom-mail-from-domain).\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.\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 subscription**. In the editor, **Subscribers** shows the address as **Confirmed**.\n\n1. Open the post, select **Schedule**, and then **Send now**.\n\n1. In the **Send now?** dialog, uncheck **Include in archive**, and select **Send now**. The post goes out as email only, so it doesn't become the publication's first archived post. The dashboard keeps its **Finish setting up** steps for the publisher. 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 send it again the same way, with **Include in archive** unchecked, and let it go.\n\n1. When the send finishes, the post arrives at your second address. A notification arrives at your first, with the subject `Sent:` and the post's title.\n\n## 5. Unsubscribe\n\nEvery email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. Both work in one step, with no login.\n\n1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender.\n\n1. Select it. In the editor, **Subscribers** shows the address as **Unsubscribed**, and later sends leave it out.\n\n## 6. Check that bounces and complaints come back\n\nWhen an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address: a bounce suppresses it, and a complaint unsubscribes it. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:\n\n - `bounce@simulator.amazonses.com`, which bounces\n - `complaint@simulator.amazonses.com`, which marks your mail as spam\n\n Each one gets a confirmation email, which bounces or is marked as spam.\n\n1. In the editor, **Subscribers** lists the bounce address as **Pending** with a **bounced** flag, and the complaint address as **Unsubscribed**. Open its row: its page says it left by a complaint. The app mails neither again.\n\nIf the bounce address has no flag, or the complaint address is still pending, SES's events aren't reaching the app. Check the configuration set in [Send bounces and complaints to the app](04-ses.md#5-send-bounces-and-complaints-to-the-app), and the subscription in [Subscribe the app to the topic](04-ses.md#8-subscribe-the-app-to-the-topic).\n\n## 7. Read the logs\n\nThe app writes a log line for each thing it does, and Cloudflare keeps them for a few days, depending on your plan. Nothing else tells you if the once-a-minute schedule that sends posts stops, so it's worth knowing where to look.\n\n1. In the Cloudflare dashboard, go to **Workers & Pages**, select `kestrel-production`, and select **Observability**.\n\n1. Filter on `event` equal to `sweep.tick`. A line arrives every minute.\n\n1. Filter on `event` equal to `send.completed`. Your send from section 4 is there.\n\n1. Filter on `event` equal to `send.scheduled`. Your two sends from section 4 are there, and `send.canceled` shows the one you canceled. Each line that records an action says who took it. Its `principal` is `human` or `service`, and its `client` names the program a person used, such as `editor`.\n\n1. Filter on `level` equal to `error`. A healthy instance shows nothing.\n\nTo follow one send, narrow the time range and filter on its `sendId`, since a long range may show only a sample. On the Free plan, Cloudflare stops keeping logs for the rest of the day once its daily allowance is used, as [its pricing page](https://developers.cloudflare.com/observability/pricing/) describes.\n\n## 8. Check that SES can send to anyone\n\n[Connect Amazon SES](04-ses.md#4-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#7-set-the-provider-and-deploy) describes.\n\nIf the dashboard still says sandbox, AWS hasn't answered yet. Every other section works in the sandbox, so come back to this one when it does.\n\n## 9. Clean up the test subscribers\n\nThe checks leave test addresses on the list. Erase them, so the publisher starts with only real readers.\n\n1. In the editor, open **Subscribers**.\n\n1. Erase `bounce@simulator.amazonses.com` and `complaint@simulator.amazonses.com`. Open each one's menu (\u22EF), and select **Erase**.\n\n1. Erase your second address too, if it won't be a reader.\n\nErasing keeps only the address, so it's never mailed again, but the person can subscribe again through the subscribe page. Erasing your own address doesn't lock you out.\n\nWhen every section gives the result it describes, your instance is ready for subscribers.\n\nNext, [go live](06-go-live.md).\n";
|
|
10490
10551
|
|
|
10491
10552
|
// docs/get-started/06-go-live.md
|
|
10492
10553
|
var go_live_default = "# Go live\n\nAt this point, your instance should be deployed, verified, and out of the SES sandbox, so it can send to anyone. From here, the publisher can set up the publication, and you can continue with the guides below.\n\n## Start publishing\n\nThe rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md).\n\nIf someone else is the publisher, let them sign in first:\n\n1. Add their email address to the `Publishers` policy on the Access application, as in [Allow yourself in](03-access.md#3-allow-yourself-in). If you set `ACCESS_ALLOWED_EMAILS`, store the list again with their address added.\n\n1. If they don't have a Cloudflare account, add another way to sign in, such as a one-time code by email. It's under **Zero Trust \u2192 Integrations \u2192 Identity providers**.\n\n1. Send them the editor's address and a link to [Name your publication](../publishing/03-name-your-publication.md).\n\n## Optional next steps\n\nEach of these guides adds one thing, and you can come back to any of them later.\n\n- [Allow Claude to connect](../guides/01-allow-claude.md), so the publisher can connect Claude Desktop, claude.ai, or Claude Code, and have it draft, proofread, and schedule posts as them, through the same API the editor uses.\n- [Automate with a service token](../guides/02-automate.md), so a script can call the API with a service token, when no one is signed in.\n- [Notifications through Cloudflare's email](../guides/03-notifications.md), so problems are reported even when the email provider has stopped sending.\n- [Rate-limit the subscribe form](../guides/05-rate-limit.md), before the subscribe link is shared widely.\n- [As your list grows](../guides/09-as-your-list-grows.md), from about 1,000 subscribers.\n- [Upgrade to a new release](../guides/07-upgrade.md), when one comes out.\n";
|
|
10493
10554
|
|
|
10494
10555
|
// docs/guides/01-allow-claude.md
|
|
10495
|
-
var allow_claude_default = "# Allow Claude to connect\n\nConnecting Claude to the instance lets the publisher draft, test, and schedule posts by asking for them in Claude. Claude works through the same API as the dashboard, and its changes show as the publisher's, via Claude.\n\nIn this guide, you turn on Cloudflare's Managed OAuth for the Access application that locks the dashboard, then connect Claude, or send the publisher the address to connect to.\n\nTo run a script or scheduled job with no one present, use a service token instead, as [Automate with a service token](02-automate.md) describes.\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- A deployed instance. Claude Desktop and claude.ai reach it from Anthropic's servers, so they can't reach one running on your own computer.\n\n## 1. Turn on Managed OAuth\n\nManaged OAuth lets an app such as Claude act with a person's account, the way the editor does in their browser. Cloudflare marks it as beta, and its [Managed OAuth](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/) page describes it.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Applications**, and select **
|
|
10556
|
+
var allow_claude_default = "# Allow Claude to connect\n\nConnecting Claude to the instance lets the publisher draft, test, and schedule posts by asking for them in Claude. Claude works through the same API as the dashboard, and its changes show as the publisher's, via Claude.\n\nIn this guide, you turn on Cloudflare's Managed OAuth for the Access application that locks the dashboard, then connect Claude, or send the publisher the address to connect to.\n\nTo run a script or scheduled job with no one present, use a service token instead, as [Automate with a service token](02-automate.md) describes.\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- A deployed instance. Claude Desktop and claude.ai reach it from Anthropic's servers, so they can't reach one running on your own computer.\n\n## 1. Turn on Managed OAuth\n\nManaged OAuth lets an app such as Claude act with a person's account, the way the editor does in their browser. Cloudflare marks it as beta, and its [Managed OAuth](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/) page describes it.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Applications**, and select **Configure** on your application.\n\n1. On the **Additional settings** tab, select the **OAuth** filter, and find **Managed OAuth (Beta)**.\n\n1. Turn on **Managed OAuth**.\n\n1. Turn on **Allow localhost clients** and **Allow loopback clients**. Claude Code receives the person's approval on their own computer, at a `localhost` or `127.0.0.1` address.\n\n1. Under **Allowed redirect URIs**, add Claude's address, so Claude Desktop and claude.ai can connect:\n\n ```\n https://claude.ai/api/mcp/auth_callback\n ```\n\n Access accepts Claude only with an address listed here. Leave it out if no one will connect Claude Desktop or claude.ai.\n\n1. Set the **Grant session duration** to 1 week. A connection renews itself while it's in use until this ends, and then Claude asks the person to approve it again. Cloudflare recommends one to two weeks for a command-line tool.\n\n1. Save the application.\n\n1. Check that a request with no sign-in now says where an app signs in:\n\n ```bash\n curl -si https://newsletter.example.com/api/whoami\n ```\n\n It answers `401`, and its `www-authenticate` line starts with `Bearer` and names `resource_metadata`. Without Managed OAuth, Cloudflare answers with a redirect to its login page instead. A browser still gets the login page either way.\n\n## 2. Connect Claude, or send the publisher the address\n\nThe publisher connects at the instance's address followed by `/api/mcp`:\n\n```\nhttps://newsletter.example.com/api/mcp\n```\n\nIf you're the publisher, connect now, as [Connect Claude](../publishing/08-connect-claude.md) describes. If someone else is, send them that address and a link to the same page.\n\n## Who can connect\n\nAnyone the `Publishers` policy lets into the dashboard can connect Claude, since they sign in the same way. To stop someone connecting, remove them from that policy. If you set `ACCESS_ALLOWED_EMAILS`, store the list again without them: the app checks it on every request, so it takes effect at once.\n\n## Check it\n\n1. Connect Claude Desktop or claude.ai, as [Connect Claude](../publishing/08-connect-claude.md) describes. The connector lists Kestrel's tools, such as **Get the publication** and **Save a draft**.\n\n1. Ask Claude what the publication is called. It answers with the name the dashboard shows.\n\nIf Claude can't connect, [Claude can't connect](../publishing/06-troubleshooting.md#claude-cant-connect) lists what it reports and the fix.\n";
|
|
10496
10557
|
|
|
10497
10558
|
// docs/guides/02-automate.md
|
|
10498
10559
|
var automate_default = '# Automate with a service token\n\nA service token lets a script or scheduled job call the API, such as a job that drafts a weekly digest. Kestrel labels these changes **a service token**, rather than with a person\'s name.\n\nIn this guide, you create a service token, allow it in through the Access application, give it to your script, and check that it works. To connect Claude as yourself instead, follow [Allow Claude to connect](01-allow-claude.md).\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- The script or job that will call the API, and somewhere it keeps secrets.\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 a person\'s sign-in.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Service credentials \u2192 Service Tokens**, and select **Create Service Token**.\n\n1. Name it for what will use it, such as `kestrel-automation`, 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. Allow the token in\n\nThe application lets in only the people its `Publishers` policy names, so the token needs a policy of its own on the same 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:** `Automation`\n - **Action:** Service Auth\n - **Include:** the **Service Token** selector, with your token\n\n1. Save the application.\n\nThis policy is the only thing that lets the token in. `ACCESS_ALLOWED_EMAILS` doesn\'t apply to it, since it has no email.\n\n## 3. Give the token to your script\n\nThe script sends the two values as headers on every request: `CF-Access-Client-Id` with the Client ID, and `CF-Access-Client-Secret` with the Client Secret.\n\n1. Store the Client ID and the Client Secret where your script reads secrets, such as its environment, your CI\'s secrets, or a password manager. Never commit the secret to a repository.\n\n1. Have the script send both headers with every request to `https://newsletter.example.com/api/\u2026`. For example, this request saves a new draft:\n\n ```bash\n curl -s https://newsletter.example.com/api/posts \\\n -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \\\n -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \\\n -H "Content-Type: application/json" \\\n -d \'{"subject":"Weekly digest","markdown":"# This week\\n\\nThree things worth reading."}\'\n ```\n\n## Check it\n\n1. Call `GET /api/whoami` with the two headers. The app answers with a program\'s identity, not a person\'s:\n\n ```bash\n curl -s https://newsletter.example.com/api/whoami \\\n -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \\\n -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"\n ```\n\n ```\n {"principal":{"kind":"service","token_id":"e367c0ffee5eb4.access","client":null},"auth":{"mode":"access"}}\n ```\n\n The `token_id` is the Client ID Zero Trust shows beside your token. If it\'s a different one, the script is using another token.\n\n1. Call `GET /api/posts`. It answers with the posts the editor shows.\n\n1. Open a draft in the editor, and have the script save a change to it. The editor shows that the draft was changed elsewhere, last edited by **a service token**.\n\nIf the script gets a `401`, a `403`, or a login page, check the policy from section 2, and the token\'s two values. The policy\'s action must be Service Auth, since the app refuses a login through any other kind of policy with a `401` whose `reason` is `access_unidentified`. Check that the token hasn\'t expired, too.\n\n## Renew or revoke a token\n\nA service token stops working when its duration ends. Before then, extend it on the token under **Access controls \u2192 Service credentials \u2192 Service Tokens**, or create a new token, add it to the `Automation` policy, give it to the script, and then delete the old one.\n\nTo stop a script at once, delete its token there. Its next request is refused.\n\n## More information\n\nThe API reference describes every route: its method and path, the body it takes, its refusals, and an example. The editor\'s **API** tab shows it, and a program reads it as Markdown: `GET /api/reference.md` is the index, and `GET /api/reference/<resource>.md` holds one resource\'s routes in full. `GET /api/openapi.json` is the same reference as an OpenAPI 3.1 document, for a tool that imports one.\n';
|
|
10499
10560
|
|
|
10500
10561
|
// docs/guides/03-notifications.md
|
|
10501
|
-
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#
|
|
10562
|
+
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#9-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 <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";
|
|
10502
10563
|
|
|
10503
10564
|
// docs/guides/05-rate-limit.md
|
|
10504
10565
|
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";
|
|
@@ -10513,16 +10574,16 @@ var as_your_list_grows_default = "# As your list grows\n\nA new instance runs on
|
|
|
10513
10574
|
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.** Workers Free suits a list of up to about 1,000 subscribers. Past about 5,000, one send can use up the day's database writes and stop the newsletter until midnight UTC. With more than about 1,000 subscribers, it's recommended to upgrade before you import. 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, complaints, 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, why anyone who left did, and any bounce. **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. Claude can do the conversion for you, once you [connect it](08-connect-claude.md). In your instance's repository, Claude Code's `/migrate-list` skill asks what anything ambiguous in your old service's export means, and converts a few rows for you to check.\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 each one's page, opened from their row, shows where they came from, such as \"Imported \xB7 Sendy, single opt-in\".\n\n**Why someone left carries over too.** Your file can say a person unsubscribed themselves, that you unsubscribed them when they asked, that they reported your email as spam, or that they asked to be erased. Someone who complained or was erased can't be added back by hand, only by subscribing themselves, and an erasure is recorded with the address alone.\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 or a block 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- **The free plan.** On the free Workers plan, an import that would take your list past about 1,000 subscribers recommends Workers Paid, and so do the Dashboard and the Subscribers screen once it's past. A send to more than about 5,000 warns too. None of these stops anything.\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, why anyone who left did, and every bounced or blocked 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/2` 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| `unsubscribe_cause` | no | `reader`, `publisher`, `complaint`, `erased` | Why an `unsubscribed` row left: they unsubscribed themselves, you unsubscribed them on request, they reported your email as spam, or they asked to be erased. Empty when the source doesn't say, and recorded as having left elsewhere. Only on an `unsubscribed` row. |\n| `suppression` | no | `bounce`, `manual` | A suppression on top of `status`: the address bounced, or was blocked by hand. Version 1 also took `complaint` and `erased`, read now as an `unsubscribed` row with that cause. |\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`, `kestrel-subscribers/2` | The version the row is written in. A row without it may use either version's values. |\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 unsubscribed themselves, 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,unsubscribe_cause,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,reader,,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. Version 2 added `unsubscribe_cause`, and with it a complaint and an erasure left `suppression`. A row of version 1 that says `complaint` or `erased` there reads as an `unsubscribed` row with that cause, whatever its `status`. A row that names no version may use either version's values, since both read it alike, but not a cause beside a version 1 complaint or erasure.\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 only you in the editor can acknowledge a growing send, and Claude never speaks as the editor, so 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| `added_confirmed` | `subscribed`, with no subscriber and no suppression in Kestrel: added as confirmed. |\n| `confirmed_pending` | `subscribed`, pending in Kestrel (never confirmed): 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| `unsubscribed` | `unsubscribed`, and confirmed or pending in Kestrel, with no confirmation in Kestrel newer than the row's `unsubscribed_at`: unsubscribed, with the row's cause. Also an address already unsubscribed in Kestrel for a weaker reason, which the row's complaint replaces. |\n| `erased` | `unsubscribed` with cause `erased`, for someone in Kestrel it unsubscribes, or who already left for another reason: erased, as **Erase** does. Their record keeps only the address and when, and your past sends show an erased recipient in place of the address. Named apart so you see who on your list the file erases. |\n| `added_unsubscribed` | `unsubscribed`, with no subscriber in Kestrel: recorded as unsubscribed, with the row's cause, 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| `kept_suppressed` | `subscribed`, but the address carries a suppression in Kestrel: never added or confirmed. |\n| `suppressed` | The row carries a suppression and the address has none in Kestrel, 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: the first one an address gets stands. An imported bounce is kept apart from one Kestrel saw itself, so the list says where each came from.\n\n**Why someone left.** A row with no `unsubscribe_cause` is recorded as having left elsewhere, which is all an import can know. One whose cause is `erased` is recorded with its address alone, whatever else the row says. A subscriber already in Kestrel whom it unsubscribes is erased, as **Erase** erases them: their consent history, how they joined, and its evidence go, and your past sends show an erased recipient in place of the address. The plan names each such person apart, as `erased`, so you see who on your list the file erases before you commit. A commit that lands while a send is handing one of them to your email provider is refused, and you try again a minute later. Someone already unsubscribed in Kestrel keeps the stronger of the two reasons: an erasure, then a complaint, then the reason Kestrel already has.\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 reason for leaving is the strongest: an erasure, then a complaint, then the first of the rest. Its suppression is the first of 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. Their page, opened from their row, says how they joined: imported, with the origin and opt-in method the file claimed, the evidence, and the import that brought them: the file, when, by whom, and the attestation. 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. A setting on your instance tells Kestrel which plan it's on, as [As your list grows](../guides/09-as-your-list-grows.md) describes.\n- **Size:** an import request of at most 10 MB, and at most 500 bytes of `evidence` a row.\n- **The free Workers plan:** it suits a list of up to about 1,000 subscribers. Past about 5,000, one send can use up the day's database writes and stop the newsletter until midnight UTC. An import's plan recommends Workers Paid when it would leave more than 1,000 confirmed subscribers, and the Dashboard and the Subscribers screen say the same while the list is past it. A scheduled post to more than 5,000 recipients warns before it's scheduled, and its banner repeats the warning while it's scheduled. None of these stops anything. [As your list grows](../guides/09-as-your-list-grows.md) has the numbers behind them.\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, why they left when they have, and their suppression, each in a column of its own. 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, the same reasons for leaving, an erasure's included, and the same 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";
|
|
10514
10575
|
|
|
10515
10576
|
// docs/publishing/02-sending-rules.md
|
|
10516
|
-
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 is never mailed again, so a bad address can't keep hurting your sending reputation. A reader who marks your email as spam is unsubscribed, as if they had used the link, and you can't add them back: only their own sign-up does.\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 ([
|
|
10577
|
+
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 is never mailed again, so a bad address can't keep hurting your sending reputation. A reader who marks your email as spam is unsubscribed, as if they had used the link, and you can't add them back: only their own sign-up does.\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 ([Deploy the app](../get-started/02-deploy.md#4-configure-production)).\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#9-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 the reader under **Subscribers**, and choose **Unsubscribe** from their \u22EF menu. It works whether or not they've confirmed, records that they asked, and they can rejoin by subscribing again.\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, even for someone who already unsubscribed. It unsubscribes them and deletes how they joined and the evidence for it, and can't be undone. Past send records show an erased recipient in their place. Kestrel keeps only the address and when you erased it, so it's never mailed again, beside any bounce or block the address already had. The reader can still rejoin by subscribing and confirming themselves, but you can't add them back.\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";
|
|
10517
10578
|
|
|
10518
10579
|
// docs/publishing/03-name-your-publication.md
|
|
10519
|
-
var name_your_publication_default = "# Name your publication\n\nGive your publication 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 [
|
|
10580
|
+
var name_your_publication_default = "# Name your publication\n\nGive your publication 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 [Deploy the app](../get-started/02-deploy.md#4-configure-production).\n\n";
|
|
10520
10581
|
|
|
10521
10582
|
// docs/publishing/04-write-a-post.md
|
|
10522
10583
|
var write_a_post_default = "# Write and send a post\n\nWrite in Markdown, schedule it, then send yourself a test. Scheduling makes the exact email your list will get, so the test you send is what goes out.\n\n**New post**, on the Dashboard or under **Drafts**, opens a draft.\n\n## Write\n\nThe editor takes Markdown, and Kestrel saves as you type. The same Markdown becomes the email, in your [email template](11-email-template.md), and the post's page in your archive. **Preview** shows the email.\n\n- A blank line starts a new paragraph.\n- The opening lines are the preview a mail client shows beside the subject.\n- Paste or drop an image to add it: PNG, JPEG, WebP, or GIF, up to 5 MB.\n\n## Schedule and send\n\nChoose **Schedule**, pick a time under **Send at**, and confirm. The time must be at least five minutes away by default, or the lead whoever runs your instance set. That's your window to catch a mistake, and you can cancel or move the send until then. **Send now** sends after the same wait.\n\n**Then send yourself a test.** Scheduling locks the post and makes its email, so **Send test email** now sends exactly what your list receives. **Settings \u2192 Default test recipients** fills in the addresses. A test needs an email provider, which [Connect Amazon SES](../get-started/04-ses.md) sets up.\n\nTo change the post, choose **Cancel** on its banner, and it's a draft again. **Edit schedule** moves the time without unlocking it.\n\nAt the send time, the post goes to your confirmed subscribers, and its page goes up in your archive. Watch it go out under **Sent**. If a send needs you, its page says so, and [Troubleshooting](06-troubleshooting.md) covers each case.\n\n## Post settings\n\n**Post settings**, at the top right of the editor, holds two choices you can change until the post sends:\n\n- **Include in archive.** Uncheck it to send the post as email only, for a correction or a short notice. It stays off your archive and home page.\n- **Archive address.** It follows the subject until you type your own. It's fixed once the post sends.\n\nAfter a post sends, its page under **Sent** can take it out of your archive or put it back.\n";
|
|
10523
10584
|
|
|
10524
10585
|
// docs/publishing/06-troubleshooting.md
|
|
10525
|
-
var troubleshooting_default = "# Troubleshooting\n\nWhen 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.\n\n**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.\n\n## A send is waiting or held\n\nA problem with your email provider never marks anyone unsent. The send waits where it is, and carries on from there once the problem clears. \"No one has been marked unsent because of this\" means this problem: readers whose own email failed earlier stay unsent.\n\n- **\"The provider is refusing the account\".** Your provider won't take mail from your account, and its own words follow, with the fix and who makes it. The fix is almost always in your Amazon SES account or your instance, made by whoever runs it. [What SES refuses, and the fix](#what-ses-refuses-and-the-fix), below, lists each one. Any other post that comes due meanwhile waits with it, without asking SES, and you're told once. While a quota frees up a few at a time, or while the SES sandbox takes only your verified addresses, SES takes some readers on each retry and refuses the rest: those it takes are sent, and the line stays, with the time it began, until SES takes everyone it's asked to.\n- **\"The provider is refusing this send\".** SES refuses this post's email itself, or a batch of it as invalid, rather than your account. Its words follow. Other posts go out as usual. If SES's words name a reader's address, remove that subscriber: the send skips them and carries on at its next retry. If SES refuses the email itself, the email can't change once its send has started, so whoever runs your instance asks AWS Support why, quoting SES's words.\n- **\"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.\n- **\"The provider is unavailable\".** An outage or a rate limit on the provider's side. The send reads **Backing off** and says when it next retries, spacing its retries out up to an hour apart while the outage lasts, so there's nothing to do. Any other post that comes due meanwhile waits with it.\n- **\"The provider keeps refusing for its sending rate\".** An amber line. SES has refused the first request of several retries in a row for its sending rate, which a short burst never does, so Kestrel is set to send faster than your SES account allows. Whoever runs your instance sets `SES_MAX_SEND_RATE` to the **Maximum send rate** on the SES console's **Account dashboard** (1 a second in the sandbox), and deploys, as [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy) describes. The send retries on its own meanwhile.\n- **\"Nothing has gone out for \u2026\".** A send has handed off no one for half an hour, with no retry or provider problem to explain it. The part of Kestrel that sends has stopped or keeps failing on this send, or another send going out at the same time is taking all of its turn. A large send going out slowly never shows this. If another send is going out, this one picks up once it finishes. Read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes, and check the Worker's trigger, as [Configuration](../reference/01-configuration.md#bindings-and-triggers) describes. The send carries on from where it stopped once it runs again, mailing no one twice.\n- **\"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 [Configuration](../reference/01-configuration.md#bindings-and-triggers) describes.\n- **\"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.\n\n## Before a send\n\n- **A test says \"The provider refused it\".** Your provider didn't take the test, and its own words follow, with the fix where Kestrel knows it. Sending again gets the same answer until that's fixed. While Amazon SES is in its sandbox, it sends only to addresses verified in SES, so verify the address you test with in the SES console, or request production access, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Words about the account, such as a key or the sending domain, point at the same fix as [a send the provider refuses](06-troubleshooting.md#a-send-is-waiting-or-held).\n- **A test says \"The provider didn't answer, so the test may have arrived\".** Kestrel can't tell whether your provider took it. Check the inbox before you send again, since a second test may arrive twice.\n- **\"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.\n- **The daily database writes warning.** On Cloudflare's free Workers plan, a send to more than about 5,000 recipients 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.\n- **\"Workers Free suits a list of up to about 1,000 subscribers\".** Your list is past what the free plan suits. Upgrade to Workers Paid ([As your list grows](../guides/09-as-your-list-grows.md)). If the account is on Workers Paid already, set `SUBREQUEST_BUDGET` so Kestrel knows, and the notice goes.\n\n## Reading a send's record\n\nA send's page becomes its permanent record once it finishes. Its counts cover everyone the send went to:\n\n| Count | What it means |\n| --- | --- |\n| **Delivered** | Your provider confirmed the email reached the reader's mail server. |\n| **Bounced** | The receiving server turned the email away. |\n| **Complained** | The reader marked the email as spam. |\n| **Unsent** | Kestrel couldn't hand the email to your provider. |\n\nDelivered 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, as long as some receipts have come.\n\n**\"No delivery receipt has arrived for any of \u2026\".** An hour after a send went out, Kestrel has heard back about none of its readers. Your provider's reports probably aren't reaching Kestrel, so its bounces suppress no one and its complaints unsubscribe no one, and your next post mails those addresses again. Look for `webhook.refused` errors in the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes. It names the SNS topic that reached Kestrel and the one it expects. Then check the subscription in [Connect Amazon SES](../get-started/04-ses.md#9-subscribe-the-app-to-the-topic). The line goes as soon as a receipt arrives.\n\n**Unsent recipients** are still on your list, and get your next post. A recipient is unsent when their own email kept failing, or their address isn't one SES accepts, with the provider's words on the row, or when you chose **Assume not sent**.\n\n### What SES refuses, and the fix\n\nWhen SES refuses your account or a send, the red line carries SES's words and the fix. Almost every fix is made by whoever runs your instance, in the SES console or its settings.\n\n| SES says | What it means | The fix |\n| --- | --- | --- |\n| Email address is not verified, naming a reader | The SES account is still in the sandbox, which mails only verified addresses. | Request production access on the SES console's **Account dashboard**, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Signups fail until then, and you're emailed about it once a day. |\n| Email address is not verified, naming your From address or its domain | The sending identity isn't verified in this SES region. | Verify the From address or its domain in SES, in the region your instance sends from. |\n| Mail FROM domain is not verified | SES can't read the custom MAIL FROM domain's MX record. | Check that record in DNS, or set the identity's **Behavior on MX failure** to use the default MAIL FROM domain. |\n| Daily message quota exceeded | The 24-hour sending quota is spent. It frees up on its own. | In the sandbox (200 a day), request production access. Otherwise, ask AWS for a higher quota, as [As your list grows](../guides/09-as-your-list-grows.md) describes. |\n| Sending paused (account) | AWS paused the account under review, or sending was turned off by hand. | Follow AWS's email to the AWS account's address, or turn sending back on. |\n| Sending paused (configuration set) | Sending is turned off on the configuration set, by hand or by an alarm. | Turn sending back on for that set. |\n| Account suspended | AWS shut down the account's sending. | Only AWS can lift it: open a case with AWS Support. |\n| An access key, signature, or permission error | Your instance's SES key is wrong, expired, or lacks permission. | Replace the key, or give it `ses:SendEmail` and `ses:SendBulkEmail`. |\n| A subscription is needed (OptInRequired) | The AWS account isn't signed up for SES. | Enable SES for the account, or settle its activation or billing with AWS. |\n| Configuration set does not exist | `SES_CONFIGURATION_SET` names a set that isn't in this region. | Fix the name, or create the set. |\n| Sending pool not found | The configuration set uses a dedicated IP pool that doesn't exist. | Pick an existing pool in the set's sending options, or remove it. |\n| The message was rejected | SES refuses this email itself. | Ask AWS Support why. Other posts go out as usual. |\n| The request is invalid | SES refused a batch of this send. | If SES's words name a reader's address, remove that subscriber. |\n\nA server error from SES may come after SES already took the emails, so Kestrel never sends that batch again: its readers wait for **Resolve\u2026**, as for \"The provider never answered for \u2026\".\n\n## A reader says they never got a post\n\nUnder **Subscribers**, search for their address and open their row. Their page lists every send they were in, newest first, with what happened on each: **Delivered**, a bounce, a complaint, **Unsent** with your email provider's words, or **Skipped** because they had unsubscribed or were suppressed by then. Each send links to its record. The page also shows how they joined, any bounce or block on the address with the provider's words or yours, and why they left, if they did. If they aren't on the list, or are still **pending**, they never confirmed, and only confirmed subscribers are sent your posts.\n\n## Bounces and complaints\n\nKestrel acts on these for you, so a bad address can't keep hurting your sending reputation:\n\n- **A hard bounce** means the address can't receive mail. Kestrel never mails it again.\n- **A soft bounce** is temporary, such as a full mailbox. The address stays on your list.\n- **A complaint** means the reader marked your email as spam. Kestrel unsubscribes them, whichever email they reported, and their page, opened from their row, says they left by a complaint.\n\nUnder **Subscribers**, a bounced address keeps its status and carries a label saying why, and its page, opened from its row, gives your email provider's words for the bounce and when it happened. 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.\n\n### Lifting a suppression\n\nLift 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. In a subscriber's \u22EF menu, **Unblock** lifts a block and **Clear** lifts a bounce, after asking you to confirm. A complaint isn't a suppression but the reader leaving, so nothing lifts it: they come back only by subscribing again themselves, and **Add subscriber** refuses them. Past records keep the bounce or complaint where it happened.\n\n## An import is refused\n\nKestrel refuses a file it can't read with certainty, and says what's wrong and where. Nothing on your list changes.\n\n- **Not in the format.** The file isn't a Kestrel CSV or a plain list. Convert it first, with Claude or by hand.\n- **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`.\n- **Too many rows** for one import. Split the file.\n- **Too large.** An import takes up to 10 MB.\n\n[The file format](01-your-list.md#the-file-format) describes what each column takes.\n\n## Claude can't connect\n\n[Connect Claude](08-connect-claude.md) covers connecting it. When that fails, find what Claude or the dashboard says:\n\n- **Claude says it couldn't connect, or the sign-in failed.** Your instance's Access application needs Managed OAuth set up as [Turn on Managed OAuth](../guides/01-allow-claude.md#1-turn-on-managed-oauth) describes, including Claude's sign-in address for Claude Desktop and claude.ai. Check that the connector's address is your publication's address followed by `/api/mcp`, and that your instance is deployed, not running on your own computer.\n- `kestrel login` says your instance \"doesn't offer a sign-in for programs\". Managed OAuth is off. Turn it on, as the same section describes.\n- `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in. If you set `ACCESS_ALLOWED_EMAILS`, check that it includes the address you signed in with. If `kestrel login` names `access_unidentified` after the `401`, or `kestrel api` prints `reason: access_unidentified`, it's the next problem: both then say that Access sent neither an email address nor a service token. If `kestrel api` says your sign-in has ended, sign in again as [Connect Claude](08-connect-claude.md#claude-code) describes.\n- **\"Access let you in, but didn't say who you are\".** The editor shows this, and the API refuses with a `401` whose `reason` is `access_unidentified`, when your login carried neither an email nor a service token. Some other kind of Access policy let it through, such as a certificate or device rule, or a login method that gives no email. Kestrel accepts only an Allow policy on people's emails and a Service Auth policy on a service token. Sign out, then sign in through the `Publishers` policy. Whoever runs your instance removes the other policy from its Access application, or changes it to one of those two kinds.\n- **Claude's changes read \"via the API\".** A request made with your sign-in named no client, such as one from Claude calling the API some way other than `kestrel api`, which names Claude Code. Your instance's instructions for Claude Code have it use `kestrel api`, once they're brought up to date, as [Install the release](../guides/07-upgrade.md#2-install-the-release) describes. Changes reading \"a service token\" mean Claude still uses one, and [Connect Claude](08-connect-claude.md#claude-code) signs it in as you instead.\n- A script gets a `401` whose `reason` is `access_unidentified`. Access let the script in through a policy Kestrel doesn't accept. It needs a service token under a Service Auth policy, as [Allow the token in](../guides/02-automate.md#2-allow-the-token-in) describes.\n\n## Notifications\n\nWith an address set, Kestrel emails you:\n\n- when a send finishes, with how many emails were accepted, unsent, and skipped\n- when your provider refuses your account or a send, or no provider is connected\n- when the SES sandbox refuses a signup's confirmation, at most once a day\n- when a send stops going out with nothing to explain it, waits for you to resolve it, or misses its time\n- when a service token imports a list, since no one saw it happen (an import you make yourself, or through Claude, sends nothing)\n\nEach 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.\n\nBy 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.\n";
|
|
10586
|
+
var troubleshooting_default = "# Troubleshooting\n\nWhen something needs you, Kestrel says so in a line on the send's page, under **Sent**. The top of the dashboard shows it too. Red needs you to act, and amber is worth a look. The line says what happened, what to do, and who does it. This page covers what the screen can't: the fixes made outside Kestrel, and the decisions that are yours.\n\n**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.\n\n## A send is held or waiting\n\nNothing is lost. A problem with your email provider never marks anyone unsent or mails anyone twice. The send waits where it is, and carries on once the problem is fixed. The send's line names the fix. When that fix is outside Kestrel, whoever runs your instance makes it, and it's one of these:\n\n- **Amazon SES refuses your account or the send.** The line carries SES's words and the fix, almost always in the SES console. [Connect Amazon SES](../get-started/04-ses.md) sets up each piece SES checks: production access, the sending domain, the AWS key, and the configuration set. When SES refuses your account, any other post that comes due meanwhile waits with it. When it refuses only this send's email, other posts go out as usual. While SES takes some readers and refuses the rest, as while a daily quota frees up, those it takes are sent. The line stays until SES takes everyone.\n- **SES keeps refusing for its sending rate.** Kestrel is set to send faster than your SES account allows. Set `SES_MAX_SEND_RATE` to the maximum send rate on the SES console's account dashboard, which is 1 a second in the sandbox. Then deploy, as [Connect Amazon SES](../get-started/04-ses.md#7-set-the-provider-and-deploy) describes.\n- **No email provider is connected.** Connect one, as [Connect Amazon SES](../get-started/04-ses.md) describes, and the send goes out at its next retry.\n- **A send missed its time, or nothing has gone out for a while.** The part of Kestrel that sends has stopped, or keeps failing on this send. Check the Worker's Cron Trigger, as [Configuration](../reference/01-configuration.md#bindings-and-triggers) describes, and read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes. If another send is going out at the same time, this one may only be waiting its turn.\n\n## Someone was mailed twice, or not at all\n\nKestrel never mails a reader twice on its own. When it can't tell whether your provider took some emails, it stops and asks you: the send's page shows **Resolve\u2026**. Choose **Assume not sent** unless you know otherwise, since it's the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it, such as from a reader who tells you so. Either choice mails no one.\n\nA reader recorded as **Unsent** is still on your list and gets your next post. That send never mails them again: their own email kept failing, SES won't take their address, or you chose **Assume not sent**.\n\n## A reader says they never got a post\n\nUnder **Subscribers**, search for their address and open their row. Their page lists every send they were in, newest first, with what happened on each: **Delivered**, a bounce, a complaint, **Unsent** with your email provider's words, or **Skipped** because they had unsubscribed or were suppressed by then. Each send links to its record. The page also shows how they joined and why they left, if they did. Any bounce or block on the address shows with the provider's words or yours. If they aren't on the list, or are still **pending**, they never confirmed, and only confirmed subscribers are sent your posts.\n\n## A send still reads Settling\n\nThat's normal. **Delivered** counts only the emails your provider confirmed reached the reader's mail server. Confirmations can lag by days, so the counts keep rising after a send finishes. Some are never confirmed at all, so a send can read **Settling** for good, as long as some receipts have come.\n\nWhen none come, the send's page says so an hour after it went out. Your provider's reports aren't reaching Kestrel, so its bounces suppress no one and its complaints unsubscribe no one. Your next post mails those addresses again. Look for `webhook.refused` errors in the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes. Each names the SNS topic that reached Kestrel and the one it expects. Then check the subscription, as [Connect Amazon SES](../get-started/04-ses.md#8-subscribe-the-app-to-the-topic) describes.\n\n## A test doesn't arrive\n\nWhile Amazon SES is in its sandbox, it mails only addresses verified in SES. Verify the address you test with in the SES console, or request production access, as [Connect Amazon SES](../get-started/04-ses.md#4-request-production-access) describes. When the test's line says the provider didn't answer, check the inbox before you send it again, since the first may have arrived. A test SES refuses for your account has the same fix as [a send it refuses](06-troubleshooting.md#a-send-is-held-or-waiting).\n\n## Your list outgrows Workers Free\n\nUpgrade the Cloudflare account to Workers Paid, and set `SUBREQUEST_BUDGET` so Kestrel sends faster, as [As your list grows](../guides/09-as-your-list-grows.md) describes. Kestrel warns you before a send that could use up the free plan's daily database writes, which stops the app until midnight UTC. [Bring your existing list](01-your-list.md#limits) explains the limits.\n\n## Bounces and complaints\n\nKestrel acts on these for you, so a bad address can't keep hurting your sending reputation:\n\n- A hard bounce means the address can't receive mail. Kestrel never mails it again.\n- A soft bounce is temporary, such as a full mailbox. The address stays on your list.\n- A complaint means the reader marked your email as spam. Kestrel unsubscribes them, whichever email they reported, and their page, opened from their row, says they left by a complaint.\n\nUnder **Subscribers**, a bounced address keeps its status and carries a label saying why. Its page gives your email provider's words for the bounce and when it happened. 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.\n\n### Lifting a suppression\n\nLift 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. In a subscriber's \u22EF menu, **Unblock** lifts a block and **Clear** lifts a bounce, after asking you to confirm. A complaint isn't a suppression but the reader leaving, so nothing lifts it. They come back only by subscribing again themselves, and **Add subscriber** refuses them. Past records keep the bounce or complaint where it happened.\n\n## An import is refused\n\nNothing on your list changes. Kestrel refuses a file it can't read with certainty, and names the row and column it couldn't. Fix the file and import it again. Convert a file in another format first, with Claude or by hand. Move a column of your old service's into `evidence`. Split a file of more than 10 MB, or with too many rows. [The file format](01-your-list.md#the-file-format) describes what each column takes.\n\n## Claude can't connect\n\nMost failures are in your instance's Cloudflare Access setup, made by whoever runs your instance. [Connect Claude](08-connect-claude.md) covers connecting it. When that fails:\n\n- **Claude says it couldn't connect, or the sign-in failed.** Your instance's Access application needs Managed OAuth set up as [Turn on Managed OAuth](../guides/01-allow-claude.md#1-turn-on-managed-oauth) describes, including Claude's sign-in address for Claude Desktop and claude.ai. Check that the connector's address is your publication's address followed by `/api/mcp`, and that your instance is deployed, not running on your own computer.\n- `kestrel login` says your instance \"doesn't offer a sign-in for programs\". Managed OAuth is off. Turn it on, as the same section describes.\n- `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in. If you set `ACCESS_ALLOWED_EMAILS`, check that it includes the address you signed in with. If either names `access_unidentified`, it's the next problem. If `kestrel api` says your sign-in has ended, sign in again as [Connect Claude](08-connect-claude.md#claude-code) describes.\n- **\"Access let you in, but didn't say who you are\".** Your login carried neither an email nor a service token. Some other kind of Access policy let it through, such as a certificate or device rule, or a login method that gives no email. Kestrel accepts only an Allow policy on people's emails and a Service Auth policy on a service token. Sign out, then sign in through the `Publishers` policy. Whoever runs your instance removes the other policy from its Access application, or changes it to one of those two kinds. A script that gets this needs a service token under a Service Auth policy, as [Allow the token in](../guides/02-automate.md#2-allow-the-token-in) describes.\n- **Claude's changes read \"via the API\".** Claude called the API some way other than `kestrel api`, which names Claude Code. Your instance's instructions for Claude Code have it use `kestrel api` once they're brought up to date, as [Install the release](../guides/07-upgrade.md#2-install-the-release) describes. Changes reading \"a service token\" mean Claude still uses one, and [Connect Claude](08-connect-claude.md#claude-code) signs it in as you instead.\n\n## Notifications\n\nWith an address set, Kestrel emails you:\n\n- when a send finishes, with how many emails were accepted, unsent, and skipped\n- when your provider refuses your account or a send, or no provider is connected\n- when the SES sandbox refuses a signup's confirmation, at most once a day\n- when a send stops going out with nothing to explain it, waits for you to resolve it, or misses its time\n- when a service token imports a list, since no one saw it happen (an import you make yourself, or through Claude, sends nothing)\n\nEach 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.\n\nBy 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.\n";
|
|
10526
10587
|
|
|
10527
10588
|
// docs/publishing/08-connect-claude.md
|
|
10528
10589
|
var connect_claude_default = "# Connect Claude\n\nConnecting Claude to Kestrel lets you publish by asking Claude. It drafts, tests, and schedules posts as you, and its changes show as yours, via Claude.\n\n**Your Kestrel instance has to allow this first,** as [Allow Claude to connect](../guides/01-allow-claude.md) describes.\n\nHow you connect depends on where you talk to Claude:\n\n- **In Claude Desktop or claude.ai,** add Kestrel as a connector.\n- **In Claude Code,** add Kestrel as a connector, or sign in with the Kestrel CLI to use the API directly.\n\n## Claude Desktop or claude.ai\n\n1. In Claude, go to **Customize \u2192 Connectors**, select **Add**, then **Add custom connector**.\n1. Name it, and enter your publication's address followed by `/api/mcp`, such as `https://newsletter.example.com/api/mcp`.\n1. Under **OAuth client**, choose **Register automatically**.\n1. Select **Add**, then sign in the way you do for the dashboard, and approve the sign-in.\n\nOn a Team or Enterprise plan, an Owner of your Claude organization adds the connector under **Organization settings \u2192 Connectors** instead, and you then select **Connect** on it. Anthropic's [custom connectors guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) has the details.\n\nTo check it works, ask Claude what your publication is called.\n\n## Claude Code\n\nClaude Code can use the same connector as Claude Desktop and claude.ai. To reach the whole API instead, images included, sign Claude Code in with the Kestrel CLI:\n\n1. Run this, with your publication's address:\n\n ```bash\n npx kestrel login https://newsletter.example.com\n ```\n\n1. Approve the sign-in in the browser that opens. The terminal then says who you're signed in as.\n\nThe sign-in stays on your computer. Opened in your instance's repository, Claude Code then calls Kestrel's API as you through `kestrel api`, the way `gh` calls GitHub, following your instance's instructions for Claude. Its changes show as yours, via Claude Code.\n\n## How long a sign-in lasts\n\nThe sign-in renews itself while you use Claude, until your instance's grant session ends, a week if it follows the setup guide. Then Claude asks you to approve a new one.\n\nTo disconnect, remove the connector under **Customize \u2192 Connectors**. To sign out of the Kestrel CLI, run `npx kestrel logout`.\n\n## Working with Claude\n\nAsk Claude in plain words, the way you'd ask a colleague, and ask it what it can do: it knows its tools, and reads these pages from Kestrel. For example:\n\n- \"Proofread my draft titled 'First frost' and schedule it to go out Friday at 7am.\"\n- \"Add jack@example.com to the list.\"\n- \"Import the subscribers in this CSV from my old newsletter.\"\n\nWhatever Claude is asked, Kestrel holds these for any client:\n\n- **Every send waits out the review window,** and you can cancel it until it goes out.\n- **What goes out is exactly the copy that was scheduled.** A scheduled post is locked.\n- **No one is mailed twice,** and only confirmed subscribers receive a post.\n- **An unsubscribe is final** until the reader subscribes 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,** in the dashboard.\n\nChanges Claude makes show in the dashboard as yours, such as \"Scheduled by you@example.com via Claude\".\n";
|
|
@@ -10531,7 +10592,7 @@ var connect_claude_default = "# Connect Claude\n\nConnecting Claude to Kestrel l
|
|
|
10531
10592
|
var share_your_subscribe_link_default = "# Share your subscribe link\n\nYour publication's subscribe page is at `/subscribe` on your publication'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, 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, for any reason, a complaint or an erasure included:** they get a confirmation, and confirming puts them back on your list. This form is the only way back for someone who complained or was erased.\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";
|
|
10532
10593
|
|
|
10533
10594
|
// docs/publishing/10-check-your-setup.md
|
|
10534
|
-
var check_your_setup_default = "# Check your setup\n\nBefore your readers get anything, check three things yourself: your email arrives and replies reach you, subscribing works, and problems will reach you.\n\n**Email has to be connected first.** Until it is, nothing can be sent, and the dashboard's checklist shows **Connect Amazon SES** in place of this step, as [Connect Amazon SES](../get-started/04-ses.md) describes.\n\n## 1. 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 your instance is deployed.\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#
|
|
10595
|
+
var check_your_setup_default = "# Check your setup\n\nBefore your readers get anything, check three things yourself: your email arrives and replies reach you, subscribing works, and problems will reach you.\n\n**Email has to be connected first.** Until it is, nothing can be sent, and the dashboard's checklist shows **Connect Amazon SES** in place of this step, as [Connect Amazon SES](../get-started/04-ses.md) describes.\n\n## 1. 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 your instance is deployed.\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#9-receive-replies-at-your-from-address).\n\n## 2. Subscribe yourself\n\nOpen your subscribe page from **Settings \u2192 Ways to subscribe**, subscribe with a personal address, and confirm. You see what a new reader sees, and you appear under **Subscribers** as **Confirmed**.\n\n## 3. 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, as [Notifications](06-troubleshooting.md#notifications) lists.\n\n**If you're bringing more than about 1,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import.** [Bring your existing list](01-your-list.md#limits) explains why.\n";
|
|
10535
10596
|
|
|
10536
10597
|
// docs/publishing/11-email-template.md
|
|
10537
10598
|
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\nA region is a marked part, not a general `if`: `.IsEmail` is its only condition, there's no `else`, and regions don't nest. Kestrel won't save a template with a region left open, an `{{ end }}` with nothing to close, another condition, or `{{ .Post.Body }}` inside a region, which would leave the post's page empty. Keep each region's two tags inside the same element, outside any attribute, and not loose between a table's rows or cells, where the browser would move what's inside. A template that breaks that rule saves, but every email shows the built-in template instead until it's fixed, and the preview and a test say so. An unsubscribe link inside a region is fine, since every email still carries it.\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 own web link, which opens its page once it's sent, in your archive or not. |\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, or to 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 its last few minutes before sending. Posts already sending or sent never change.\n";
|
|
@@ -10543,10 +10604,10 @@ var README_default = "# Publishing with Kestrel\n\nThese pages get you publishin
|
|
|
10543
10604
|
var README_default2 = "# Self-hosting Kestrel\n\nThese pages cover deploying and running your own Kestrel, from an empty repository to a live newsletter on your Cloudflare account. Writing and sending the newsletter is covered in [Publishing](publishing/README.md). These pages call whoever writes and sends it the publisher, which may be you.\n\n## Get started\n\nThese are the steps to deploy a Kestrel instance and get it ready to send. Each one ends with a check, so you know it worked before you move on.\n\n1. [Deploy the app](get-started/02-deploy.md)\n1. [Lock the dashboard with Access](get-started/03-access.md)\n1. [Connect Amazon SES](get-started/04-ses.md)\n1. [Verify it works](get-started/05-verify.md)\n1. [Go live](get-started/06-go-live.md)\n\n## Guides\n\nEach one stands on its own. Pick the ones you need.\n\n- [Allow Claude to connect](guides/01-allow-claude.md)\n- [Automate with a service token](guides/02-automate.md)\n- [Notifications through Cloudflare's email](guides/03-notifications.md)\n- [Rate-limit the subscribe form](guides/05-rate-limit.md)\n- [As your list grows](guides/09-as-your-list-grows.md)\n- [Upgrade to a new release](guides/07-upgrade.md)\n\n## Reference\n\nThe detail behind the steps, for when you need it.\n\n- [Configuration](reference/01-configuration.md)\n- [Sending-domain DNS: SPF, DKIM, and DMARC](reference/02-sending-domain-dns.md)\n";
|
|
10544
10605
|
|
|
10545
10606
|
// docs/reference/01-configuration.md
|
|
10546
|
-
var configuration_default = '# Configuration\n\nEvery deploy-time setting: what it does, its default, and what the app refuses. Each environment\'s settings live in its block under `env` in `wrangler.jsonc`, and its secrets in Cloudflare.\n\n- **Vars** are public values in the block\'s `vars`, safe to commit. A change takes effect when you deploy.\n- **Secrets** are stored with `npx wrangler secret put NAME --env production`, and never committed. A secret takes effect as soon as you store it, with no redeploy.\n- **Bindings and triggers** connect the Worker to its database, its image bucket, and its schedule.\n\nIn local development, `npm run dev` reads the top level of `wrangler.jsonc` and `.dev.vars`, which you copy from `.dev.vars.example`. Restart it after you change either.\n\nNone of these can be read or changed from inside the app, by the editor or by Claude. Settings in the editor shows the public ones, read-only.\n\nThis is the `production` block a new instance starts with. [Deploy the app](../get-started/02-deploy.md) fills in its database and hostnames:\n\n```jsonc\n"production": {\n "name": "kestrel-production",\n "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }],\n "workers_dev": false,\n "preview_urls": false,\n "triggers": { "crons": ["* * * * *"] },\n "observability": { "enabled": true },\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "REPLACE_WITH_PRODUCTION_D1_ID",\n "migrations_dir": "migrations"\n }\n ],\n "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "kestrel-media-production" }],\n "vars": {\n "PROVIDER": "",\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1"\n }\n}\n```\n\n## Vars\n\nVars go in the environment block\'s `vars`. Every value is a string.\n\n- `PROVIDER` optional\n\n The email provider: `ses`, or `fake` for local development. Default: none, so no provider is connected. Refused unless it\'s exactly `ses`, `fake`, or `none`: a misspelling, or `SES`, is never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).\n\n Until it\'s set, or while it\'s empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren\'t open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance\'s `production` block declares it empty, so wrangler doesn\'t warn that the top level\'s value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.\n\n `fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`, since on a real hostname it would open signups whose confirmations go nowhere.\n\n- `ALLOW_FAKE_PROVIDER` optional\n\n `true` lets `fake` run on a real hostname, for a hosted demo that mails no one on purpose. No other deployment needs it. With `fake`, it must be `true` when set; every other provider ignores it.\n\n- `APP_ORIGIN` required\n\n The origin the app is served from: scheme and host, no path. Refused unless it\'s a plain `http` or `https` origin with no path, and never on `example.com` or a name under it, unless `PROVIDER` is `fake`. A trailing slash is dropped.\n\n- `AWS_REGION` required with SES\n\n The SES region. The template sets `us-east-1`.\n\n- `FROM_ADDRESS` required with a real provider\n\n The `From:` of every email, and the identity your provider authenticates. Refused unless it\'s an email address, optionally with a display name in front, like `Name <address>`, and never on `example.com`, unless `PROVIDER` is `fake`.\n\n It isn\'t the publication\'s name, tagline, or logo. You set those in the editor, under **Settings \u2192 Publication identity**. Until you do, the From address\'s display name stands in for the name, and with neither, the emails and reader pages show no name at all.\n\n- `SENDING_DOMAIN` optional\n\n The sending domain, shown in Settings beside the From address. The app never checks that it matches `FROM_ADDRESS`, so set it to the part of `FROM_ADDRESS` after the `@`. Never on `example.com`, unless `PROVIDER` is `fake`.\n\n- `ARCHIVE_BASE_PATH` optional\n\n The path the archive lives under. Default: `/archive`. A missing leading slash is added, and a trailing slash is dropped.\n\n A listed post\'s archive address is `ARCHIVE_BASE_PATH` followed by the post\'s slug. Emails link through each post\'s unlisted link, `/email/<token>` on `APP_ORIGIN`, which forwards to the archive address, so changing the prefix later breaks no email that carries one, only archive addresses people have shared. Emails sent by 1.11 or earlier carry the archive address itself, and changing the prefix breaks those. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\n- `SES_MAX_SEND_RATE` optional\n\n Your SES account\'s maximum send rate, in messages a second. Default: `14`, a common starting rate, not your account\'s. Refused unless it\'s a whole number above zero.\n\n The app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**: `1` in the sandbox, and whatever AWS grants after that. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. A rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.\n\n- `SUBREQUEST_BUDGET` optional\n\n How many subrequests one minute\'s send work may use, which also tells the app which Workers plan it\'s on. Default: `50`, the Workers Free limit, and safe on any plan. Refused unless it\'s a whole number above zero. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n Cloudflare caps how many subrequests one run of a Worker may make. Database queries and outbound requests both count. It also caps database queries alone: 50 a run on Workers Free, and 1,000 on Workers Paid. Each minute\'s send work is one run, so a large send goes out over several minutes, each stopping short of both caps. This setting is the subrequest cap. The app keeps database queries under theirs on its own.\n\n - At the default, a minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.\n - On Workers Paid, which allows 10,000, set `"SUBREQUEST_BUDGET": "10000"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.\n - If you raised the Worker\'s own limit with `limits.subrequests`, you may set this to match.\n\n Never set it above your plan\'s limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up.\n\n At the default, the app runs on Workers Free\'s limits. One import takes up to 1,000 rows, since Free\'s 10 ms of CPU a request can\'t plan and commit more, and an export reads 1,000 rows a page. Above it, both take 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)). Workers Free suits a list of up to about 1,000 subscribers. Past about 5,000, one send can use up the day\'s database writes and stop the newsletter until midnight UTC. So the Dashboard and the Subscribers screen recommend Workers Paid once the confirmed list passes 1,000, and so does an import plan that would take it past. The app also warns before a send to more than 5,000 recipients ([As your list grows](../guides/09-as-your-list-grows.md)). None of these stops you. Above the default, none appears, so set it on an account already on Workers Paid.\n\n- `MIN_LEAD_SECONDS` optional\n\n The least time every send stays cancelable before it goes out, whether it\'s scheduled, sent now, or moved. Default: `300`, five minutes. Refused unless it\'s a whole number from 60 to 86400, one day.\n\n It\'s the window to catch a mistake, including one in a send Claude prepared. The floor is 60, since sends go out from a once-a-minute schedule. It\'s the least wait before every send, not a limit on how far ahead a post can be scheduled. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.\n\n- `ARCHIVE_ORIGIN` optional\n\n The origin archive links use, to put the archive on your website. Default: `APP_ORIGIN`. Refused unless it\'s a plain `http` or `https` origin with no path, and never on `example.com`, unless `PROVIDER` is `fake`. A trailing slash is dropped.\n\n The app doesn\'t answer on that origin on its own. A Worker route on that domain, such as `example.com/archive*` in the environment\'s `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname. Crawlers read your website\'s own `robots.txt` for the archive pages there. The app\'s `/llms.txt` stays on the app\'s hostname, and its archive link points at your website.\n\n- `MEDIA_PUBLIC_BASE` optional\n\n The base URL images are served from, such as a custom domain on the image bucket. Default: `APP_ORIGIN` followed by `/media`. Refused unless it\'s a plain `http` or `https` URL, which may have a path, and never on `example.com`, unless `PROVIDER` is `fake`. A trailing slash is dropped.\n\n The app\'s own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket\'s custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.\n\n- `NOTIFY_FROM` optional\n\n The sender of notifications through Cloudflare\'s email. Default: `kestrel@` on the app\'s hostname, named Kestrel, such as `Kestrel <kestrel@newsletter.example.com>`. It\'s used only with the `NOTIFY` binding, and must be an address on a domain set up for Cloudflare\'s email.\n\n## Secrets\n\nStore each with `npx wrangler secret put NAME --env production`, which prompts for the value.\n\n- `ACCESS_TEAM_DOMAIN` required\n\n Your Zero Trust team domain, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in. [Lock the dashboard with Access](../get-started/03-access.md#4-give-the-app-its-two-values) shows where to find it.\n\n- `ACCESS_AUD` required\n\n The Access application\'s audience tag. It\'s set together with `ACCESS_TEAM_DOMAIN` or not at all: with only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one.\n\n- `ACCESS_ALLOWED_EMAILS` optional\n\n A comma-separated list of the people allowed to sign in. Without it, the app lets in anyone the Access policy allows, so it keeps the editor limited to named people if the policy is widened later. It applies to people only. A service token, the credential a script uses, carries no email, so the Access policy alone decides whether it gets in.\n\n- `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` required with SES\n\n The IAM user\'s access key, which sends through SES.\n\n- `SNS_TOPIC_ARN` required with SES\n\n The SNS topic the SES webhook accepts. The webhook refuses a report from every other topic, and logs `webhook.refused` naming both.\n\n- `SES_CONFIGURATION_SET` optional, recommended with SES\n\n The configuration set named on every email, so SES reports bounces and complaints. A name SES can\'t find in the region holds every list send until it\'s fixed.\n\n- `DEV_AUTH_SECRET` never on a deployed environment\n\n Local development\'s stand-in for Access. A deployed instance ignores it anyway. The app honors it only when the environment looks like local development: `PROVIDER` is `fake`, Access isn\'t set up, and `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A real provider, Access, or a real hostname each turns it off, and Access becomes the only way in.\n\n## Bindings and triggers\n\nWrangler doesn\'t carry bindings or vars over from the top level of `wrangler.jsonc` to an environment. Each environment\'s block declares its own.\n\n- `name` required\n\n The Worker\'s name, such as `kestrel-production`.\n\n- `routes` required\n\n The app\'s hostname, attached on deploy, and any route on your website. Each deploy replaces the Worker\'s routes with the ones in the file, so add a route here, never in the dashboard.\n\n- `workers_dev` and `preview_urls` optional\n\n The Worker\'s `workers.dev` and preview addresses. The template sets both to `false`, so the app answers only on your hostname. That\'s where your Access application and any rate-limiting rule apply.\n\n- `triggers.crons` required\n\n The once-a-minute schedule, `* * * * *`. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.\n\n- `observability` optional\n\n Workers Logs, where the app\'s log lines land. On in the template. Every invocation is logged, with no sampling, since sampling could drop the one minute that reports a stuck or missed send.\n\n- `DB` required\n\n The `d1_databases` binding for the database: posts, subscribers, sends, and settings.\n\n- `MEDIA` required\n\n The `r2_buckets` binding for the bucket of images and your logo.\n\n- `NOTIFY` optional\n\n The `send_email` binding for Cloudflare\'s email. When declared, notifications go through it instead of your email provider, so they still arrive when your provider refuses your account. Without it, notifications come from `FROM_ADDRESS`, through your provider. Local development ignores it, and sends notifications to a stand-in that delivers nothing.\n\n## What the app refuses\n\nThe app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly, and each setting above says what it refuses. In local development, `SIMULATE_SENDS` must also be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn\'t matter. A deployed instance ignores it.\n\nUntil it\'s fixed, no sends go out, and each place says what its reader can act on:\n\n- **The editor** shows a page naming the setting, what\'s wrong with it, and how to fix it, with a link to its section here. Its own Docs tab can\'t load until then, so the link opens the project\'s site.\n- **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{"error":"invalid_config","message":\u2026,"variable":"APP_ORIGIN"}`.\n- **The public pages readers see** answer `503` with a page saying the newsletter is temporarily unavailable. It names no setting.\n- **The Worker\'s log** records it as `config.invalid`.\n';
|
|
10607
|
+
var configuration_default = '# Configuration\n\nEvery deploy-time setting: what it does, its default, and what the app refuses. Each environment\'s settings live in its block under `env` in `wrangler.jsonc`, and its secrets in Cloudflare.\n\n- **Vars** are public values in the block\'s `vars`, safe to commit. A change takes effect when you deploy.\n- **Secrets** are stored with `npx wrangler secret put NAME --env production`, and never committed. A secret takes effect as soon as you store it, with no redeploy.\n- **Bindings and triggers** connect the Worker to its database, its image bucket, and its schedule.\n\nIn local development, `npm run dev` reads the top level of `wrangler.jsonc` and `.dev.vars`, which you copy from `.dev.vars.example`. Restart it after you change either. The top level turns off `workers_dev` and `preview_urls`, empties `triggers.crons`, and has no route. So a deploy that forgets `--env production` answers nowhere and never sends. Leave them as they are, and keep the empty `crons`, since only an empty list clears a schedule an earlier deploy left.\n\nNone of these can be read or changed from inside the app, by the editor or by Claude. Settings in the editor shows the public ones, read-only.\n\nThis is the `production` block a new instance starts with. [Deploy the app](../get-started/02-deploy.md) fills in its database and hostnames:\n\n```jsonc\n"production": {\n "name": "kestrel-production",\n "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }],\n "workers_dev": false,\n "preview_urls": false,\n "triggers": { "crons": ["* * * * *"] },\n "observability": { "enabled": true },\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "REPLACE_WITH_PRODUCTION_D1_ID",\n "migrations_dir": "migrations"\n }\n ],\n "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "kestrel-media-production" }],\n "vars": {\n "PROVIDER": "",\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1"\n }\n}\n```\n\n## Vars\n\nVars go in the environment block\'s `vars`. Every value is a string.\n\n- `PROVIDER` optional\n\n The email provider: `ses`, or `fake` for local development. Default: none, so no provider is connected. Refused unless it\'s exactly `ses`, `fake`, or `none`: a misspelling, or `SES`, is never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md).\n\n Until it\'s set, or while it\'s empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren\'t open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance\'s `production` block declares it empty, so wrangler doesn\'t warn that the top level\'s value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.\n\n Change it, or move to another SES account or region, only while no send is sending. The dashboard lists any send in progress.\n\n `fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`, since on a real hostname it would open signups whose confirmations go nowhere.\n\n- `ALLOW_FAKE_PROVIDER` optional\n\n `true` lets `fake` run on a real hostname, for a hosted demo that mails no one on purpose. No other deployment needs it. With `fake`, it must be `true` when set; every other provider ignores it.\n\n- `APP_ORIGIN` required\n\n The origin the app is served from: scheme and host, no path. Refused unless it\'s a plain `http` or `https` origin with no path, and never on `example.com` or a name under it, unless `PROVIDER` is `fake`. A trailing slash is dropped.\n\n- `AWS_REGION` required with SES\n\n The SES region. The template sets `us-east-1`.\n\n- `FROM_ADDRESS` required with a real provider\n\n The `From:` of every email, and the identity your provider authenticates. Refused unless it\'s an email address, optionally with a display name in front, like `Name <address>`, and never on `example.com`, unless `PROVIDER` is `fake`.\n\n It isn\'t the publication\'s name, tagline, or logo. You set those in the editor, under **Settings \u2192 Publication identity**. Until you do, the From address\'s display name stands in for the name, and with neither, the emails and reader pages show no name at all.\n\n- `SENDING_DOMAIN` optional\n\n The sending domain, shown in Settings beside the From address. The app never checks that it matches `FROM_ADDRESS`, so set it to the part of `FROM_ADDRESS` after the `@`. Never on `example.com`, unless `PROVIDER` is `fake`.\n\n- `ARCHIVE_BASE_PATH` optional\n\n The path the archive lives under. Default: `/archive`. A missing leading slash is added, and a trailing slash is dropped.\n\n A listed post\'s archive address is `ARCHIVE_BASE_PATH` followed by the post\'s slug. Emails link through each post\'s unlisted link, `/email/<token>` on `APP_ORIGIN`, which forwards to the archive address, so changing the prefix later breaks no email that carries one, only archive addresses people have shared. Emails sent by 1.11 or earlier carry the archive address itself, and changing the prefix breaks those. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\n- `SES_MAX_SEND_RATE` optional\n\n Your SES account\'s maximum send rate, in messages a second. Default: `14`, a common starting rate, not your account\'s. Refused unless it\'s a whole number above zero.\n\n The app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**: `1` in the sandbox, and whatever AWS grants after that. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. A rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.\n\n- `SUBREQUEST_BUDGET` optional\n\n How many subrequests one minute\'s send work may use, which also tells the app which Workers plan it\'s on. Default: `50`, the Workers Free limit, and safe on any plan. Refused unless it\'s a whole number above zero. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n Cloudflare caps how many subrequests one run of a Worker may make. Database queries and outbound requests both count. It also caps database queries alone: 50 a run on Workers Free, and 1,000 on Workers Paid. Each minute\'s send work is one run, so a large send goes out over several minutes, each stopping short of both caps. This setting is the subrequest cap. The app keeps database queries under theirs on its own.\n\n - At the default, a minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.\n - On Workers Paid, which allows 10,000, set `"SUBREQUEST_BUDGET": "10000"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.\n - If you raised the Worker\'s own limit with `limits.subrequests`, you may set this to match.\n\n Never set it above your plan\'s limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up.\n\n At the default, the app runs on Workers Free\'s limits. One import takes up to 1,000 rows, since Free\'s 10 ms of CPU a request can\'t plan and commit more, and an export reads 1,000 rows a page. Above it, both take 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)). Workers Free suits a list of up to about 1,000 subscribers. Past about 5,000, one send can use up the day\'s database writes and stop the newsletter until midnight UTC. So the Dashboard and the Subscribers screen recommend Workers Paid once the confirmed list passes 1,000, and so does an import plan that would take it past. The app also warns before a send to more than 5,000 recipients ([As your list grows](../guides/09-as-your-list-grows.md)). None of these stops you. Above the default, none appears, so set it on an account already on Workers Paid.\n\n- `MIN_LEAD_SECONDS` optional\n\n The least time every send stays cancelable before it goes out, whether it\'s scheduled, sent now, or moved. Default: `300`, five minutes. Refused unless it\'s a whole number from 60 to 86400, one day.\n\n It\'s the window to catch a mistake, including one in a send Claude prepared. The floor is 60, since sends go out from a once-a-minute schedule. It\'s the least wait before every send, not a limit on how far ahead a post can be scheduled. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.\n\n- `ARCHIVE_ORIGIN` optional\n\n The origin archive links use, to put the archive on your website. Default: `APP_ORIGIN`. Refused unless it\'s a plain `http` or `https` origin with no path, and never on `example.com`, unless `PROVIDER` is `fake`. A trailing slash is dropped.\n\n The app doesn\'t answer on that origin on its own. A Worker route on that domain, such as `example.com/archive*` in the environment\'s `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname. Crawlers read your website\'s own `robots.txt` for the archive pages there. The app\'s `/llms.txt` stays on the app\'s hostname, and its archive link points at your website.\n\n- `MEDIA_PUBLIC_BASE` optional\n\n The base URL images are served from, such as a custom domain on the image bucket. Default: `APP_ORIGIN` followed by `/media`. Refused unless it\'s a plain `http` or `https` URL, which may have a path, and never on `example.com`, unless `PROVIDER` is `fake`. A trailing slash is dropped.\n\n The app\'s own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket\'s custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.\n\n- `NOTIFY_FROM` optional\n\n The sender of notifications through Cloudflare\'s email. Default: `kestrel@` on the app\'s hostname, named Kestrel, such as `Kestrel <kestrel@newsletter.example.com>`. It\'s used only with the `NOTIFY` binding, and must be an address on a domain set up for Cloudflare\'s email.\n\n## Secrets\n\nStore each with `npx wrangler secret put NAME --env production`, which prompts for the value.\n\n- `ACCESS_TEAM_DOMAIN` required\n\n Your Zero Trust team domain, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in. [Lock the dashboard with Access](../get-started/03-access.md#4-give-the-app-its-two-values) shows where to find it.\n\n- `ACCESS_AUD` required\n\n The Access application\'s audience tag. It\'s set together with `ACCESS_TEAM_DOMAIN` or not at all: with only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one.\n\n- `ACCESS_ALLOWED_EMAILS` optional\n\n A comma-separated list of the people allowed to sign in. Without it, the app lets in anyone the Access policy allows, so it keeps the editor limited to named people if the policy is widened later. It applies to people only. A service token, the credential a script uses, carries no email, so the Access policy alone decides whether it gets in.\n\n- `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` required with SES\n\n The IAM user\'s access key, which sends through SES.\n\n- `SNS_TOPIC_ARN` required with SES\n\n The SNS topic the SES webhook accepts. The webhook refuses a report from every other topic, and logs `webhook.refused` naming both.\n\n- `SES_CONFIGURATION_SET` optional, recommended with SES\n\n The configuration set named on every email, so SES reports bounces and complaints. A name SES can\'t find in the region holds every list send until it\'s fixed.\n\n- `DEV_AUTH_SECRET` never on a deployed environment\n\n Local development\'s stand-in for Access. A deployed instance ignores it anyway. The app honors it only when the environment looks like local development: `PROVIDER` is `fake`, Access isn\'t set up, and `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A real provider, Access, or a real hostname each turns it off, and Access becomes the only way in.\n\n## Bindings and triggers\n\nWrangler doesn\'t carry bindings or vars over from the top level of `wrangler.jsonc` to an environment. Each environment\'s block declares its own.\n\n- `name` required\n\n The Worker\'s name, such as `kestrel-production`.\n\n- `routes` required\n\n The app\'s hostname, attached on deploy, and any route on your website. Each deploy replaces the Worker\'s routes with the ones in the file, so add a route here, never in the dashboard.\n\n- `workers_dev` and `preview_urls` optional\n\n The Worker\'s `workers.dev` and preview addresses. The template sets both to `false`, so the app answers only on your hostname. That\'s where your Access application and any rate-limiting rule apply.\n\n- `triggers.crons` required\n\n The once-a-minute schedule, `* * * * *`. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.\n\n- `observability` optional\n\n Workers Logs, where the app\'s log lines land. On in the template. Kestrel sets no sampling, since sampling could drop the one minute that reports a stuck or missed send, so leave `head_sampling_rate` at its default of 1. Cloudflare\'s own daily limit on the Free plan can still stop new logs for the rest of the day, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes.\n\n- `DB` required\n\n The `d1_databases` binding for the database: posts, subscribers, sends, and settings.\n\n- `MEDIA` required\n\n The `r2_buckets` binding for the bucket of images and your logo.\n\n- `NOTIFY` optional\n\n The `send_email` binding for Cloudflare\'s email. When declared, notifications go through it instead of your email provider, so they still arrive when your provider refuses your account. Without it, notifications come from `FROM_ADDRESS`, through your provider. Local development ignores it, and sends notifications to a stand-in that delivers nothing.\n\n## What the app refuses\n\nThe app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly, and each setting above says what it refuses. In local development, `SIMULATE_SENDS` must also be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn\'t matter. A deployed instance ignores it.\n\nUntil it\'s fixed, no sends go out, and each place says what its reader can act on:\n\n- **The editor** shows a page naming the setting, what\'s wrong with it, and how to fix it, with a link to its section here. Its own Docs tab can\'t load until then, so the link opens the project\'s site.\n- **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{"error":"invalid_config","message":\u2026,"variable":"APP_ORIGIN"}`.\n- **The public pages readers see** answer `503` with a page saying the newsletter is temporarily unavailable. It names no setting.\n- **The Worker\'s log** records it as `config.invalid`.\n';
|
|
10547
10608
|
|
|
10548
10609
|
// docs/reference/02-sending-domain-dns.md
|
|
10549
|
-
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
|
|
10610
|
+
var sending_domain_dns_default = "# Sending-domain DNS: SPF, DKIM, and DMARC\n\nInboxes decide whether to trust your mail by checking records in the DNS of the domain it comes from. This page explains the three checks they run: SPF, DKIM, and DMARC. It covers the records [Connect Amazon SES](../get-started/04-ses.md) has you add, what each proves, how to check them, and when to tighten DMARC.\n\n## Why all three\n\nGmail requires every sender to pass SPF or DKIM. A sender of more than 5,000 messages a day to Gmail must have all three:\n\n- SPF and DKIM, both passing.\n- A DMARC record, with a policy of at least `p=none`.\n- Alignment: the domain in the `From:` header matches the SPF domain or the DKIM domain.\n\nGmail also requires one-click unsubscribe for such senders. Kestrel adds the `List-Unsubscribe` and `List-Unsubscribe-Post` headers to every email, so that part needs nothing from you. [Google's sender guidelines](https://support.google.com/a/answer/81126) list the rest.\n\nA small list may never reach 5,000 a day, but the same checks decide whether mail lands in the inbox. Make sure all three pass from the start.\n\n## Choose your sending name\n\n- **Send from a subdomain.** It's recommended to send from a subdomain such as `send.example.com`, rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn't put that at risk.\n- **Keep the app's name and the mail's name distinct.** The app and its public pages live at `newsletter.example.com`, and mail comes from `send.example.com`. Avoid `mail.` as the sending name, since mail servers read `mail.example.com` as a host that receives mail.\n\n## The records\n\nEvery record lives in your domain's DNS, `example.com`, under the sending name. In Cloudflare, a record's name leaves off your domain, so `_dmarc.send.example.com` is entered as `_dmarc.send`.\n\nSES generates the DKIM values. Copy them exactly as SES lists them, since they differ by account and region. You write the DMARC record yourself.\n\nSPF needs no record of yours on SES's default path, since it passes for a subdomain of `amazonses.com`. An SPF record, at `bounce.send`, comes with the 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#9-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#2-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";
|
|
10550
10611
|
|
|
10551
10612
|
// src/docs/index.ts
|
|
10552
10613
|
var PAGES = {
|
|
@@ -11771,7 +11832,7 @@ async function commit(c) {
|
|
|
11771
11832
|
}
|
|
11772
11833
|
log.info("import.committed", {
|
|
11773
11834
|
importId: result2.import.id,
|
|
11774
|
-
principal
|
|
11835
|
+
...whoActed(principal),
|
|
11775
11836
|
rows: p.rowCount,
|
|
11776
11837
|
confirmed: result2.counts.added_confirmed + result2.counts.confirmed_pending,
|
|
11777
11838
|
unsubscribed: result2.counts.unsubscribed + result2.counts.erased + result2.counts.added_unsubscribed,
|
|
@@ -15016,6 +15077,14 @@ async function resolveStuckSend(env, sendId, outcome, by) {
|
|
|
15016
15077
|
} else {
|
|
15017
15078
|
await releaseLease(env.DB, sendId, lease);
|
|
15018
15079
|
}
|
|
15080
|
+
log.warn("send.resolved", {
|
|
15081
|
+
sendId,
|
|
15082
|
+
postId: send.post_id,
|
|
15083
|
+
resolution: outcome,
|
|
15084
|
+
resolved,
|
|
15085
|
+
completed,
|
|
15086
|
+
...whoActed(by)
|
|
15087
|
+
});
|
|
15019
15088
|
return { resolved, completed };
|
|
15020
15089
|
}
|
|
15021
15090
|
function noteName(by) {
|
|
@@ -15408,7 +15477,12 @@ async function erase3(c) {
|
|
|
15408
15477
|
{ subscriber: subscriber && await withSuppression(c.env.DB, subscriber) }
|
|
15409
15478
|
);
|
|
15410
15479
|
}
|
|
15411
|
-
|
|
15480
|
+
if (!outcome.unchanged) {
|
|
15481
|
+
log.info("subscriber.erased", {
|
|
15482
|
+
deliveries: outcome.deliveries,
|
|
15483
|
+
...whoActed(unwrap(c.principal, "principal"))
|
|
15484
|
+
});
|
|
15485
|
+
}
|
|
15412
15486
|
const body = {
|
|
15413
15487
|
subscriber: await withSuppression(c.env.DB, outcome.subscriber),
|
|
15414
15488
|
deliveries: outcome.deliveries
|
|
@@ -15447,7 +15521,11 @@ async function add(c) {
|
|
|
15447
15521
|
const detail = optString(body, "detail");
|
|
15448
15522
|
const out = await block(c.env.DB, email2, detail);
|
|
15449
15523
|
if (out.added) {
|
|
15450
|
-
log.info("suppression.added", {
|
|
15524
|
+
log.info("suppression.added", {
|
|
15525
|
+
source: "manual",
|
|
15526
|
+
count: 1,
|
|
15527
|
+
...whoActed(unwrap(c.principal, "principal"))
|
|
15528
|
+
});
|
|
15451
15529
|
}
|
|
15452
15530
|
return json(out, out.added ? 201 : 200);
|
|
15453
15531
|
}
|
|
@@ -16326,7 +16404,7 @@ function createRouter({
|
|
|
16326
16404
|
resource: "posts",
|
|
16327
16405
|
title: "Upload an image",
|
|
16328
16406
|
summary: "Upload an image to a draft, as a form or the raw bytes.",
|
|
16329
|
-
description: "The post's Markdown references the image by the `filename` the response gives, ``, and the render resolves that name to the stored image.",
|
|
16407
|
+
description: "The post's Markdown references the image by the `filename` the response gives, ``, never by its `url`, and the render resolves that name to the stored image. A reference by `url` warns in the preview. Uploading the same `filename` again replaces the image.",
|
|
16330
16408
|
query: [
|
|
16331
16409
|
{
|
|
16332
16410
|
name: "filename",
|
|
@@ -16356,6 +16434,17 @@ function createRouter({
|
|
|
16356
16434
|
}
|
|
16357
16435
|
]
|
|
16358
16436
|
},
|
|
16437
|
+
example: {
|
|
16438
|
+
response: {
|
|
16439
|
+
image: {
|
|
16440
|
+
filename: "cover.jpg",
|
|
16441
|
+
content_type: "image/jpeg",
|
|
16442
|
+
width: 1200,
|
|
16443
|
+
height: 800,
|
|
16444
|
+
url: "https://newsletter.example.com/media/posts/p_abc123/cover.jpg"
|
|
16445
|
+
}
|
|
16446
|
+
}
|
|
16447
|
+
},
|
|
16359
16448
|
handler: uploadImage
|
|
16360
16449
|
},
|
|
16361
16450
|
{
|