@kurtbruns/kestrel 1.20.0 → 1.21.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 +37 -1
- package/bin/kestrel.mjs +1 -1
- package/cli/seed.mjs +2 -8
- package/dist/public/dashboard/app.js +297 -297
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +86 -51
- package/dist/worker/index.js +221 -57
- package/docs/get-started/02-deploy.md +2 -2
- package/docs/get-started/05-ses.md +2 -2
- package/docs/get-started/07-verify.md +1 -1
- package/docs/guides/03-notifications.md +17 -13
- package/docs/guides/07-upgrade.md +16 -2
- package/docs/publishing/03-name-your-publication.md +8 -0
- package/docs/publishing/04-write-a-post.md +1 -1
- package/docs/publishing/06-troubleshooting.md +1 -0
- package/docs/reference/02-sending-domain-dns.md +3 -3
- package/package.json +10 -10
- package/packages/kestrel/lib/api.mjs +19 -12
- package/packages/kestrel/lib/login.mjs +2 -2
- package/packages/kestrel/lib/media-types.mjs +34 -0
- package/packages/kestrel/lib/oauth.mjs +19 -2
- package/packages/kestrel/lib/openapi.mjs +1 -1
- package/template/README.md +1 -1
- package/template/claude/CLAUDE.md +1 -1
- package/template/claude/skills/upgrade/SKILL.md +1 -0
package/dist/worker/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @kurtbruns/kestrel 1.
|
|
1
|
+
// @kurtbruns/kestrel 1.21.0: the Worker, built by scripts/build-package.mjs.
|
|
2
2
|
|
|
3
3
|
// shared/archive_url.ts
|
|
4
4
|
function archivePostUrl(origin, basePath, slug) {
|
|
@@ -889,6 +889,24 @@ var BOUNCE_SPIKE_RECENT_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
|
889
889
|
var NO_RECEIPTS_AFTER_MS = 60 * 60 * 1e3;
|
|
890
890
|
|
|
891
891
|
// shared/settings.ts
|
|
892
|
+
var SETTINGS_SECTION_TITLES = {
|
|
893
|
+
"publication-identity": "Publication identity",
|
|
894
|
+
"ways-to-subscribe": "Ways to subscribe",
|
|
895
|
+
"confirmation-email": "Confirmation email",
|
|
896
|
+
"reader-pages": "Reader pages",
|
|
897
|
+
"email-template": "Email template",
|
|
898
|
+
"default-test-recipients": "Default test recipients",
|
|
899
|
+
notifications: "Notifications",
|
|
900
|
+
"email-sender": "Email sender",
|
|
901
|
+
instance: "Instance"
|
|
902
|
+
};
|
|
903
|
+
var SETTINGS_SECTIONS = Object.keys(SETTINGS_SECTION_TITLES);
|
|
904
|
+
function settingsRoute(section) {
|
|
905
|
+
return `/settings/${section}`;
|
|
906
|
+
}
|
|
907
|
+
function settingsSectionTitled(title) {
|
|
908
|
+
return SETTINGS_SECTIONS.find((s) => SETTINGS_SECTION_TITLES[s] === title) ?? null;
|
|
909
|
+
}
|
|
892
910
|
var ADDRESS_NOT_PRINTED = "You\u2019ve added a mailing address in Settings, but this template doesn\u2019t include the {{ .Publication.Address }} field. If you send promotional email, add it to the footer. If you\u2019ve written your address into the template yourself, or don\u2019t need to provide one, you can ignore this or remove the address in Settings.";
|
|
893
911
|
var MAX_NOTIFY_ADDRESSES = 5;
|
|
894
912
|
var SIMULATION_PROFILES = ["generic", "ses"];
|
|
@@ -975,10 +993,10 @@ var SUBSCRIBER_ACTIONS = {
|
|
|
975
993
|
|
|
976
994
|
// src/generated/version.ts
|
|
977
995
|
var BUILD_INFO = {
|
|
978
|
-
version: "1.
|
|
979
|
-
sha: "
|
|
980
|
-
tag: "v1.
|
|
981
|
-
buildTime: "2026-11-
|
|
996
|
+
version: "1.21.0",
|
|
997
|
+
sha: "fd376da",
|
|
998
|
+
tag: "v1.21.0",
|
|
999
|
+
buildTime: "2026-11-06T22:01:54.830Z",
|
|
982
1000
|
repoUrl: "https://github.com/kurtbruns/kestrel"
|
|
983
1001
|
};
|
|
984
1002
|
|
|
@@ -1218,6 +1236,9 @@ function whoActed(by) {
|
|
|
1218
1236
|
function errorText(err) {
|
|
1219
1237
|
return String(err?.message ?? err);
|
|
1220
1238
|
}
|
|
1239
|
+
function errorFields(err) {
|
|
1240
|
+
return { error: errorText(err), stack: err instanceof Error ? err.stack : void 0 };
|
|
1241
|
+
}
|
|
1221
1242
|
|
|
1222
1243
|
// src/lib/errors.ts
|
|
1223
1244
|
var HttpError = class extends Error {
|
|
@@ -1248,8 +1269,7 @@ function json(data2, status = 200, headers2) {
|
|
|
1248
1269
|
function logUncaught(err) {
|
|
1249
1270
|
log.error("request.error", {
|
|
1250
1271
|
name: err instanceof Error ? err.name : typeof err,
|
|
1251
|
-
|
|
1252
|
-
stack: err instanceof Error ? err.stack : void 0
|
|
1272
|
+
...errorFields(err)
|
|
1253
1273
|
});
|
|
1254
1274
|
}
|
|
1255
1275
|
function toErrorResponse(err) {
|
|
@@ -3031,6 +3051,18 @@ async function liftSuppression(db, email2, reason) {
|
|
|
3031
3051
|
return (res.meta.changes ?? 0) > 0;
|
|
3032
3052
|
}
|
|
3033
3053
|
|
|
3054
|
+
// shared/images.ts
|
|
3055
|
+
var IMAGE_TYPE_BY_EXTENSION = {
|
|
3056
|
+
".png": "image/png",
|
|
3057
|
+
".jpg": "image/jpeg",
|
|
3058
|
+
".jpeg": "image/jpeg",
|
|
3059
|
+
".webp": "image/webp",
|
|
3060
|
+
".gif": "image/gif"
|
|
3061
|
+
};
|
|
3062
|
+
function imageTypeOfName(name) {
|
|
3063
|
+
return IMAGE_TYPE_BY_EXTENSION[name.slice(name.lastIndexOf(".")).toLowerCase()];
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3034
3066
|
// src/lib/image_types.ts
|
|
3035
3067
|
var RASTER_IMAGE_TYPES = [
|
|
3036
3068
|
"image/png",
|
|
@@ -3039,7 +3071,44 @@ var RASTER_IMAGE_TYPES = [
|
|
|
3039
3071
|
"image/gif"
|
|
3040
3072
|
];
|
|
3041
3073
|
function mediaType(declared) {
|
|
3042
|
-
return (declared.split(";")[0] ?? "").trim().toLowerCase()
|
|
3074
|
+
return (declared.split(";")[0] ?? "").trim().toLowerCase();
|
|
3075
|
+
}
|
|
3076
|
+
function holds(b, signature, offset = 0) {
|
|
3077
|
+
return signature.every((byte, i) => b[offset + i] === byte);
|
|
3078
|
+
}
|
|
3079
|
+
var ascii = (text2) => Array.from(text2, (ch) => ch.charCodeAt(0));
|
|
3080
|
+
function imageTypeOf(b) {
|
|
3081
|
+
if (holds(b, [137, 80, 78, 71, 13, 10, 26, 10])) {
|
|
3082
|
+
return "image/png";
|
|
3083
|
+
}
|
|
3084
|
+
if (holds(b, [255, 216, 255])) {
|
|
3085
|
+
return "image/jpeg";
|
|
3086
|
+
}
|
|
3087
|
+
if (holds(b, ascii("GIF87a")) || holds(b, ascii("GIF89a"))) {
|
|
3088
|
+
return "image/gif";
|
|
3089
|
+
}
|
|
3090
|
+
if (holds(b, ascii("RIFF")) && holds(b, ascii("WEBP"), 8)) {
|
|
3091
|
+
return "image/webp";
|
|
3092
|
+
}
|
|
3093
|
+
return void 0;
|
|
3094
|
+
}
|
|
3095
|
+
var FORMAT_NAMES = {
|
|
3096
|
+
"image/png": "PNG",
|
|
3097
|
+
"image/jpeg": "JPEG",
|
|
3098
|
+
"image/webp": "WebP",
|
|
3099
|
+
"image/gif": "GIF"
|
|
3100
|
+
};
|
|
3101
|
+
function imageLabelConflict(shown, label) {
|
|
3102
|
+
const is = `This file is a ${FORMAT_NAMES[shown]}`;
|
|
3103
|
+
const named = imageTypeOfName(label.name);
|
|
3104
|
+
if (named && named !== shown) {
|
|
3105
|
+
return `${is}, but its name says ${FORMAT_NAMES[named]}. Rename it to match, or convert it.`;
|
|
3106
|
+
}
|
|
3107
|
+
const declared = mediaType(label.declared);
|
|
3108
|
+
if (RASTER_IMAGE_TYPES.includes(declared) && declared !== shown) {
|
|
3109
|
+
return `${is}, but its declared type says ${FORMAT_NAMES[declared]}, which a client may have read from the file's own name. Rename the file or send its real type, or convert it.`;
|
|
3110
|
+
}
|
|
3111
|
+
return void 0;
|
|
3043
3112
|
}
|
|
3044
3113
|
|
|
3045
3114
|
// src/lib/time.ts
|
|
@@ -3391,7 +3460,7 @@ var CLIENT_PARAMETER = {
|
|
|
3391
3460
|
name: CLIENT_HEADER,
|
|
3392
3461
|
in: "header",
|
|
3393
3462
|
required: false,
|
|
3394
|
-
description: "The program the request came from, in one short lowercase word (`editor`, `claude-code`), recorded beside who made a change. A malformed name is a 400 naming this header; a service token's is ignored.",
|
|
3463
|
+
description: "The program the request came from, in one short lowercase word (`editor`, `claude-code`, `kestrel-cli`, `claude`), recorded beside who made a change. A malformed name is a 400 naming this header; a service token's is ignored.",
|
|
3395
3464
|
schema: { type: "string", pattern: CLIENT_NAME_PATTERN, maxLength: CLIENT_NAME_MAX }
|
|
3396
3465
|
};
|
|
3397
3466
|
var ADMIN_SECURITY = [
|
|
@@ -4847,8 +4916,10 @@ async function dispatch(c, router, tools, msg) {
|
|
|
4847
4916
|
switch (method) {
|
|
4848
4917
|
case "initialize": {
|
|
4849
4918
|
const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
|
|
4919
|
+
const protocolVersion = SUPPORTED.includes(asked) ? asked : MCP_PROTOCOL_VERSIONS[0];
|
|
4920
|
+
logInitialize(c, protocolVersion, params);
|
|
4850
4921
|
return result(id, {
|
|
4851
|
-
protocolVersion
|
|
4922
|
+
protocolVersion,
|
|
4852
4923
|
capabilities: { tools: { listChanged: false }, resources: { listChanged: false } },
|
|
4853
4924
|
serverInfo: { name: "kestrel", title: "Kestrel", version: buildInfo().version },
|
|
4854
4925
|
instructions: MCP_INSTRUCTIONS
|
|
@@ -4880,6 +4951,25 @@ async function dispatch(c, router, tools, msg) {
|
|
|
4880
4951
|
);
|
|
4881
4952
|
}
|
|
4882
4953
|
}
|
|
4954
|
+
var PLAIN_TEXT = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,39}$/;
|
|
4955
|
+
var NOT_PLAIN = "(other)";
|
|
4956
|
+
function clientText(value) {
|
|
4957
|
+
if (typeof value !== "string") {
|
|
4958
|
+
return void 0;
|
|
4959
|
+
}
|
|
4960
|
+
return PLAIN_TEXT.test(value) ? value : NOT_PLAIN;
|
|
4961
|
+
}
|
|
4962
|
+
function logInitialize(c, protocolVersion, params) {
|
|
4963
|
+
const info = isObject3(params.clientInfo) ? params.clientInfo : {};
|
|
4964
|
+
log.info("mcp.initialize", {
|
|
4965
|
+
principal: c.principal?.kind,
|
|
4966
|
+
protocolVersion,
|
|
4967
|
+
protocolVersionAsked: clientText(params.protocolVersion),
|
|
4968
|
+
clientInfoName: clientText(info.name),
|
|
4969
|
+
clientInfoTitle: clientText(info.title),
|
|
4970
|
+
clientInfoVersion: clientText(info.version)
|
|
4971
|
+
});
|
|
4972
|
+
}
|
|
4883
4973
|
var RESOURCE_NOT_FOUND = -32002;
|
|
4884
4974
|
var DOC_URI = "kestrel://docs/";
|
|
4885
4975
|
async function through(c, router, r, parts = {}) {
|
|
@@ -4986,7 +5076,7 @@ var REQUESTS = [
|
|
|
4986
5076
|
];
|
|
4987
5077
|
var AUTHENTICATION = [
|
|
4988
5078
|
"Admin routes need a credential. Once deployed, the Cloudflare Access layer issues it, for a person or for automation. A person signs in through any client: the web editor through the browser, and Claude Code through `kestrel login`, after which `kestrel api` sends the access token as `Authorization: Bearer <token>`. A script that runs with no one present uses a service token, sent as the `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers. On a local instance a dev token stands in, sent as `Authorization: Bearer <token>`. Public routes take no credential. Webhooks are signed by the email provider and verified by the instance.",
|
|
4989
|
-
"A client names itself on every admin request in the `Kestrel-Client` header: one short lowercase word, its parts joined by hyphens,
|
|
5079
|
+
"A client names itself on every admin request in the `Kestrel-Client` header: one short lowercase word, its parts joined by hyphens. Who made a change is recorded as the person or the service token, from the credential, plus that name, and read back as `client` or `author_client`: `editor` means the web editor, `claude-code` means Claude Code through `kestrel api`, `kestrel-cli` means `kestrel api` run any other way, such as by a person in a terminal, their script, or a Claude Code older than v2.1.172, and `claude` means the instance's MCP server, the connector, which gives that name on every call whatever it is sent. A malformed name is a 400 naming `Kestrel-Client` in `field`; a name the instance doesn't know is recorded as given; with none, the change reads as made through the API. The name grants nothing: what only the web editor may do, acknowledging the sends an import would grow, is refused to every other name and to none. The instance can't verify a name, so no client of Kestrel's but the web editor claims `editor`, and `kestrel api` refuses it as `--client`. A service token's name is ignored, since the token already names the program."
|
|
4990
5080
|
];
|
|
4991
5081
|
var ERRORS = [
|
|
4992
5082
|
"Kestrel answers with the usual HTTP statuses:\n- Codes in the `2xx` range mean it worked.\n- Codes in the `4xx` range are a refusal about the request or the state of what it acts on. Its code says what to do next.\n- Codes in the `5xx` range mean the instance itself needs fixing where it is deployed, which nothing sent through this API can do.",
|
|
@@ -5362,7 +5452,7 @@ var SEND_FIELDS = [
|
|
|
5362
5452
|
{
|
|
5363
5453
|
name: "scheduled_by",
|
|
5364
5454
|
type: "{ kind, email, token_id, client } | null",
|
|
5365
|
-
description: "Who scheduled it, by a schedule or Send now, in the shape `GET /api/whoami` answers: `kind` `human` with their `email`, or `service`, a service token, with its Client ID as `token_id` when it names one; `client` is the client a person came through as it named itself in `Kestrel-Client` (`editor`, `claude-code`), null when it named none and on a service token. Null for a send scheduled before who acted was recorded."
|
|
5455
|
+
description: "Who scheduled it, by a schedule or Send now, in the shape `GET /api/whoami` answers: `kind` `human` with their `email`, or `service`, a service token, with its Client ID as `token_id` when it names one; `client` is the client a person came through as it named itself in `Kestrel-Client` (`editor`, `claude-code`, `kestrel-cli`, `claude`), null when it named none and on a service token. Null for a send scheduled before who acted was recorded."
|
|
5366
5456
|
},
|
|
5367
5457
|
{
|
|
5368
5458
|
name: "rescheduled_by",
|
|
@@ -5551,7 +5641,7 @@ var POST_FIELDS = [
|
|
|
5551
5641
|
{
|
|
5552
5642
|
name: "author_client",
|
|
5553
5643
|
type: "string | null",
|
|
5554
|
-
description: "The client the current revision's author came through, as it named itself in `Kestrel-Client
|
|
5644
|
+
description: "The client the current revision's author came through, as it named itself in `Kestrel-Client` (`editor`, `claude-code`, `kestrel-cli`, `claude`, or another name as given). Null when the request named none, and on a service token's revision, since the token already names the program."
|
|
5555
5645
|
},
|
|
5556
5646
|
{ name: "created_at", type: "number", description: "When it was made." },
|
|
5557
5647
|
{ name: "updated_at", type: "number", description: "When it last changed." },
|
|
@@ -5589,7 +5679,7 @@ var REVISION_FIELDS = [
|
|
|
5589
5679
|
{
|
|
5590
5680
|
name: "author_client",
|
|
5591
5681
|
type: "string | null",
|
|
5592
|
-
description: "The client its author came through, as it named itself in `Kestrel-Client` (`editor`, `claude-code`, or another name as given). Null when the request named none, and on a service token's revision."
|
|
5682
|
+
description: "The client its author came through, as it named itself in `Kestrel-Client` (`editor`, `claude-code`, `kestrel-cli`, `claude`, or another name as given). Null when the request named none, and on a service token's revision."
|
|
5593
5683
|
},
|
|
5594
5684
|
{ name: "created_at", type: "number", description: "When it was saved." },
|
|
5595
5685
|
{ name: "metadata", type: "object", description: "The subject and slug it was saved with." },
|
|
@@ -6778,7 +6868,11 @@ function headers(formAction) {
|
|
|
6778
6868
|
return {
|
|
6779
6869
|
"content-security-policy": pagePolicy(formAction),
|
|
6780
6870
|
"x-frame-options": "DENY",
|
|
6781
|
-
"x-content-type-options": "nosniff"
|
|
6871
|
+
"x-content-type-options": "nosniff",
|
|
6872
|
+
// On every page rather than the ones holding a secret today, so a new page can't forget
|
|
6873
|
+
// it. A browser's form post from such a page says `Origin: null`, which only the admin
|
|
6874
|
+
// surface's cross-site check would refuse, and no page here has a form that posts there.
|
|
6875
|
+
"referrer-policy": "no-referrer"
|
|
6782
6876
|
};
|
|
6783
6877
|
}
|
|
6784
6878
|
var PAGE_SECURITY_HEADERS = headers("'self'");
|
|
@@ -7231,11 +7325,25 @@ var Router = class {
|
|
|
7231
7325
|
get routes() {
|
|
7232
7326
|
return this.compiled;
|
|
7233
7327
|
}
|
|
7328
|
+
/**
|
|
7329
|
+
* Answer a request. A `HEAD` is answered as the `GET` for its path without the body, whether
|
|
7330
|
+
* that is the page, a refusal, or the 404: the whole `GET` runs, gate and all, so its status
|
|
7331
|
+
* and headers can't differ, and a path with only a `POST` is never reached. The request itself
|
|
7332
|
+
* stays a `HEAD`, for whatever reads or logs it.
|
|
7333
|
+
*/
|
|
7234
7334
|
async handle(req, env, ctx) {
|
|
7335
|
+
if (req.method !== "HEAD") {
|
|
7336
|
+
return this.dispatch(req, req.method, env, ctx);
|
|
7337
|
+
}
|
|
7338
|
+
const res = await this.dispatch(req, "GET", env, ctx);
|
|
7339
|
+
await res.body?.cancel();
|
|
7340
|
+
return new Response(null, res);
|
|
7341
|
+
}
|
|
7342
|
+
async dispatch(req, method, env, ctx) {
|
|
7235
7343
|
const url = new URL(req.url);
|
|
7236
7344
|
const config = getConfig(env);
|
|
7237
7345
|
for (const route2 of this.compiled) {
|
|
7238
|
-
if (route2.def.method !==
|
|
7346
|
+
if (route2.def.method !== method) {
|
|
7239
7347
|
continue;
|
|
7240
7348
|
}
|
|
7241
7349
|
const match = route2.pattern.exec(url);
|
|
@@ -7778,13 +7886,14 @@ function emailLayout(i) {
|
|
|
7778
7886
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
7779
7887
|
<meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark">
|
|
7780
7888
|
<title>${escapeHtml(i.subject)}</title>${ARCHIVE_HEAD_ANCHOR}
|
|
7781
|
-
<style>@media (prefers-color-scheme: dark) { .k-bg { background: #18181b !important; } }
|
|
7889
|
+
<style>@media (prefers-color-scheme: dark) { .k-bg { background: #18181b !important; } }
|
|
7890
|
+
@media (max-width:560px) { .k-pad { padding-left:12px !important; padding-right:12px !important; } }</style>
|
|
7782
7891
|
</head>
|
|
7783
7892
|
<body class="k-bg" style="margin:0;padding:0;background:#ffffff;">
|
|
7784
7893
|
${preheader}
|
|
7785
7894
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="k-bg" style="background:#ffffff;"><tr><td align="center" style="padding:8px 12px 48px;">
|
|
7786
7895
|
<table role="presentation" width="664" cellpadding="0" cellspacing="0" class="k-bg" style="max-width:664px;width:100%;background:#ffffff;">
|
|
7787
|
-
<tr><td style="padding:24px 32px 32px;font-family:${FONT};font-size:16px;line-height:1.6;color:#18181b;word-break:break-word;">
|
|
7896
|
+
<tr><td class="k-pad" style="padding:24px 32px 32px;font-family:${FONT};font-size:16px;line-height:1.6;color:#18181b;word-break:break-word;">
|
|
7788
7897
|
${ARCHIVE_MASTHEAD_ANCHOR}${i.bodyHtml}
|
|
7789
7898
|
</td></tr>
|
|
7790
7899
|
</table>
|
|
@@ -8891,7 +9000,7 @@ async function noteContact(db, config, purpose, outcome, at, tell = false) {
|
|
|
8891
9000
|
tell
|
|
8892
9001
|
);
|
|
8893
9002
|
} catch (err) {
|
|
8894
|
-
log.error("sending.record_failed", { purpose,
|
|
9003
|
+
log.error("sending.record_failed", { purpose, ...errorFields(err) });
|
|
8895
9004
|
}
|
|
8896
9005
|
}
|
|
8897
9006
|
var Contacts = class {
|
|
@@ -13222,7 +13331,7 @@ async function reset(c) {
|
|
|
13222
13331
|
import { Lexer } from "marked";
|
|
13223
13332
|
|
|
13224
13333
|
// docs/get-started/02-deploy.md
|
|
13225
|
-
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 for the newsletter, before [Connect Amazon SES](05-ses.md). [Create an AWS account](04-aws-account.md) walks through it. You can start it now. Signing up needs an email address that receives mail, and AWS can take a while to activate a new account.\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 after your publication. These guides use `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, and `newsletter` with your repository\'s name:\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. **(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. `init` prints these steps too, in this order.\n\n1. Commit the new instance, and push it. Your local settings and data stay out of the commit, since the instance\'s `.gitignore` lists them:\n\n ```bash\n git add -A\n git commit -m "Initialize Kestrel instance"\n git push -u origin HEAD\n ```\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\nThe commands below use `newsletter`. Use your instance\'s names instead: `kestrel init` printed them, and they\'re in the `production` block of `wrangler.jsonc`.\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create newsletter-production --env production --binding DB --update-config\n ```\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create newsletter-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": "newsletter-production", // named by init; leave it\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](05-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 `@`. The name before the `<` is what readers see as the sender. It also names the publication on its public pages, until the publisher sets a name under **Settings \u2192 Publication identity**. You can change the name at any time, and deploy again.\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 that answers nowhere and never sends. It takes the top level\'s `name` in `wrangler.jsonc`, such as `newsletter-dev`. If that happens, remove it by that name:\n\n```bash\nnpx wrangler delete --name newsletter-dev\n```\n\n## Check it\n\n1. The app answers on your hostname:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n If `curl` reports a certificate error or can\'t resolve the host, Cloudflare is still setting up the hostname. Or your computer remembers it from before it existed. Try again over the next 30 minutes.\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 has sent its first test email.\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';
|
|
13334
|
+
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 for the newsletter, before [Connect Amazon SES](05-ses.md). [Create an AWS account](04-aws-account.md) walks through it. You can start it now. Signing up needs an email address that receives mail, and AWS can take a while to activate a new account.\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 after your publication. These guides use `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, and `newsletter` with your repository\'s name:\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. **(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. `init` prints these steps too, in this order.\n\n1. Commit the new instance, and push it. Your local settings and data stay out of the commit, since the instance\'s `.gitignore` lists them:\n\n ```bash\n git add -A\n git commit -m "Initialize Kestrel instance"\n git push -u origin HEAD\n ```\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\nThe commands below use `newsletter`. Use your instance\'s names instead: `kestrel init` printed them, and they\'re in the `production` block of `wrangler.jsonc`.\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create newsletter-production --env production --binding DB --update-config\n ```\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create newsletter-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": "newsletter-production", // named by init; leave it\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](05-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 `@`. The name before the `<` is what readers see as the sender. It also names the publication on its public pages, until the publisher sets a name under **Settings \u2192 Publication identity**. You can change the name at any time, and deploy again.\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. Run by Claude, or in any other shell that isn\'t interactive, it answers its own prompt and applies them without asking. 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. Near its end, the output names the hostname the app now answers on, then its schedule and a version ID. 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 that answers nowhere and never sends. It takes the top level\'s `name` in `wrangler.jsonc`, such as `newsletter-dev`. If that happens, remove it by that name:\n\n```bash\nnpx wrangler delete --name newsletter-dev\n```\n\n## Check it\n\n1. The app answers on your hostname:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n If `curl` reports a certificate error or can\'t resolve the host, Cloudflare is still setting up the hostname. Or your computer remembers it from before it existed. Try again over the next 30 minutes.\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 has sent its first test email.\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';
|
|
13226
13335
|
|
|
13227
13336
|
// docs/get-started/03-access.md
|
|
13228
13337
|
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. Add only these two public hostnames. If the form also shows an empty private hostname or IP row, remove it. Otherwise saving fails with an error naming `use_clientless_isolation_app_launcher_url`, saying it needs private destinations.\n\n1. Name the application after your app\'s hostname, `newsletter.example.com`. Cloudflare suggests a name made from the subdomain, which won\'t tell two newsletters apart.\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. For example, a week may suit a publisher on their own computer.\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:** the publication\'s name and `publishers`, such as `Example 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 the policy you made should be one.\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. Store the list of addresses allowed in:\n\n ```bash\n npx wrangler secret put ACCESS_ALLOWED_EMAILS --env production\n ```\n\n At the prompt, enter the addresses separated by commas:\n\n ```\n you@example.com, publisher@example.com\n ```\n\n Spaces and capital letters don\'t matter. Without the list, 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. The same goes for anyone else you let in.\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 policy you made above.\n\n## Allow someone else in\n\nTo let someone else into the editor, such as the publisher or a co-editor, add their email address to the policy from [Allow yourself in](#3-allow-yourself-in). If you set `ACCESS_ALLOWED_EMAILS`, run the command again and enter the whole list with their address added, since the new value replaces the old one.\n\nIf they don\'t have a Cloudflare account, add another way to sign in, such as a one-time code by email, under **Zero Trust \u2192 Integrations \u2192 Identity providers**.\n\nNext, [create an AWS account](04-aws-account.md).\n';
|
|
@@ -13231,13 +13340,13 @@ var access_default = '# Lock the dashboard with Access\n\nCloudflare Access puts
|
|
|
13231
13340
|
var aws_account_default = "# Create an AWS account\n\nKestrel sends the newsletter's email through Amazon SES, which runs in an Amazon Web Services (AWS) account. This page assumes a brand-new account, made for the newsletter. If you'd rather use one you already have, skip to [Connect Amazon SES](05-ses.md).\n\nIf you plan to run multiple publications, you may want to consider setting up one account for each publication. With this setup, you can use [AWS Organizations](https://docs.aws.amazon.com/organizations/latest/userguide/orgs_introduction.html) to keep the accounts under one bill. Multiple publications can be run through one account. However, they share its sandbox, sending quotas, and suppression list, and AWS can pause sending for the whole account if one publication's bounces or complaints run high.\n\nIn this guide, you choose the account's email address, sign up, and secure the account's root user. Then you set a monthly budget, so AWS emails you if the account ever costs more than you expect.\n\n## Before you begin\n\n- A phone number, and a credit or debit card for AWS's charges.\n- Your domain's DNS on Cloudflare, if the account's email address will be forwarded with Cloudflare Email Routing.\n\n## 1. Choose the account's email address\n\nThe address you sign up with belongs to the account's root user, the identity that owns the account and everything in it. You sign in as the root user with it, AWS contacts you there about the account, and it's how you recover the account. AWS recommends an address used for nothing else, which forwards to whoever looks after the account, as its [root user best practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/root-user-best-practices.html) describe.\n\n1. Choose an address named for its purpose, on your own domain, such as `aws@example.com`. Use one you'll keep, since recovering the account depends on it.\n\n1. Make sure it receives mail before you sign up, since AWS emails it a code right away. If `example.com` receives no mail yet, forward the address to your inbox with Cloudflare Email Routing, as [Enable Email Routing](https://developers.cloudflare.com/email-routing/get-started/enable-email-routing/) describes. If `example.com` already receives mail at a mail host, create the address there instead, since Email Routing replaces a domain's mail servers.\n\n## 2. Sign up\n\nAWS may offer two ways to sign up. Choose **Sign up for AWS (advanced)**. The other way, **Sign up for AWS (new)**, makes an account with no root user, and chooses its region from your address. Its console differs from the one this guide describes. AWS's [Compare sign-up options](https://docs.aws.amazon.com/accounts/latest/reference/sign-up-for-aws.html) explains both.\n\n1. Open [Sign up for AWS](https://signin.aws.amazon.com/signup?request_type=register). If it offers a choice, select **Sign up for AWS (advanced)**.\n\n1. Enter the address from section 1 as the root user's email address, and an account name, such as your publication's name. Verify the address with the code AWS sends to it, and choose a password for the root user.\n\n1. Choose the paid plan. The free plan closes the account automatically after six months, or sooner once its credits are used, unless you upgrade it before then. The paid plan gets the same sign-up credits, and keeps running once they're used. [AWS Free Tier plans](https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/free-tier-plans.html) describes both.\n\n1. Enter your contact information and payment details, and confirm your phone number if AWS asks.\n\n1. Choose the free Basic support plan. It's enough for everything the setup guide does. Asking AWS to let SES send to anyone is a form in the SES console, with no paid support plan needed.\n\n1. Complete the sign-up. The console opens, signed in as the root user, and AWS emails the account's address when the account is ready.\n\n## 3. Secure the root user\n\nThe root user can do anything in the account, including close it, so it needs more than a password. AWS requires multi-factor authentication (MFA) for it, and asks you to set it up soon after your first sign-in.\n\n1. In the console, select your account name at the top right, and then **Security credentials**.\n\n1. Under **Multi-factor authentication (MFA)**, choose **Assign MFA device**. Enter a device name, select **Passkey or Security Key**, and follow the prompts. A passkey on your computer or phone works.\n\n1. Leave **Access keys** empty. A root user's key could do anything the root user can. [Connect Amazon SES](05-ses.md#5-create-the-apps-aws-key) makes a separate key that can only send email and read the account's sending status.\n\n## 4. Set a budget alert\n\nAWS charges for what you use, with no spending limit, so a mistake or a leaked key could run up a bill before you notice. A budget emails you as the month's costs near an amount you choose. SES charges for each email it sends, as [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists.\n\n1. In the console, open **Billing and Cost Management**, then **Budgets**, and choose **Create budget**.\n\n1. Under **Use a template (simplified)**, select **Monthly cost budget**.\n\n1. Enter a name, and an amount a little above what you expect to spend each month. Add the account's email address, or your own, as an email recipient. Choose **Create budget**.\n\n1. AWS emails each recipient to verify it, and sends alerts only to a verified address. Open that email while you're signed in to the account, and verify the address within 12 hours, as [Budgets email recipients](https://docs.aws.amazon.com/cost-management/latest/userguide/budgets-email-recipients.html) describes.\n\n## Check it\n\n1. Sign out, and sign in again as the root user, with the address from section 1. After the password, AWS asks for your passkey or security key.\n\n1. Under **Security credentials**, the MFA list shows your device, and **Access keys** lists none.\n\n1. Under **Budgets**, your budget is listed, and its email recipient shows as **Active**.\n\n If it shows as **Pending**, select **Resend verification** on its row, and verify the address as section 4 describes.\n\nNext, [connect Amazon SES](05-ses.md).\n";
|
|
13232
13341
|
|
|
13233
13342
|
// docs/get-started/05-ses.md
|
|
13234
|
-
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. See [Request production access](06-production-access.md) to take it out of the sandbox.\n\nIn this guide, you set up how the app sends mail through SES and receives bounces and complaints, and how readers\' replies reach the publisher.\n\n## Before you begin\n\n- An AWS account for the newsletter, from [Create an AWS account](04-aws-account.md).\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\nThe same form sets a custom MAIL FROM domain, so SPF also passes for your domain. [SPF: authorize the return path](../reference/02-sending-domain-dns.md#spf-authorize-the-return-path) explains why it matters.\n\n1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose a region near you in the region menu at the top right.\n\n SES keeps identities, quotas, and the sandbox separately in each region, so do every step on this page in the region you chose. [Configuration](../reference/01-configuration.md#vars) says more about choosing one, under `AWS_REGION`.\n\n1. Note the region\'s code, such as `us-west-2`. The menu shows it beside the region\'s name, and [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each one too. AWS names a region in more than one way, such as United States (Oregon) and US West (Oregon), so go by the code. Later steps use it.\n\n1. Under **Configuration**, choose **Identities**, then **Create identity**.\n\n1. Select **Domain**, and enter `send.example.com`. Leave **Assign a default configuration set** and **Assign to a tenant** unchecked. Section 4 makes the configuration set, and makes it the default then.\n\n1. Select **Use a custom MAIL FROM domain**. In **MAIL FROM domain**, enter `bounce`; the form shows `.send.example.com` after it. For **Behavior on MX failure**, keep **Use default MAIL FROM domain**.\n\n1. Expand **Advanced DKIM settings**, and select **Easy DKIM**. Keep **RSA_2048_BIT** as the key length. Choose **Create identity**.\n\n1. The identity opens on its **Authentication** tab, which lists five DNS records to publish. Under DKIM, three CNAME records. Under **Custom MAIL FROM domain**, expand **Publish DNS records** for an MX record and a TXT record.\n\n1. Add all five to your domain\'s DNS records in Cloudflare, with their proxy status set to **DNS only**. SES can\'t read a proxied record, so check each one\'s status. You can import them in one go, or add them by hand:\n\n - **Import them.** Copy this into a text file. Replace each `REPLACE_WITH_NAME_\u2026` and `REPLACE_WITH_VALUE_\u2026` with a DKIM record\'s name and value, exactly as SES lists them, and keep the dot after each. Replace `us-east-1` with your region\'s code. In the Cloudflare dashboard, open your domain\'s **DNS \u2192 Records**, select **Import and Export**, and import the file with **Proxy imported DNS records** unchecked. SES\'s own **Download .csv record set** is in a format Cloudflare doesn\'t import.\n\n ```\n REPLACE_WITH_NAME_1. 3600 IN CNAME REPLACE_WITH_VALUE_1.\n REPLACE_WITH_NAME_2. 3600 IN CNAME REPLACE_WITH_VALUE_2.\n REPLACE_WITH_NAME_3. 3600 IN CNAME REPLACE_WITH_VALUE_3.\n bounce.send.example.com. 3600 IN MX 10 feedback-smtp.us-east-1.amazonses.com.\n bounce.send.example.com. 3600 IN TXT "v=spf1 include:amazonses.com ~all"\n ```\n\n - **Add them by hand.** Add each record exactly as SES lists it. Cloudflare adds your domain to the name, so each DKIM name ends in `._domainkey.send`, and the MAIL FROM records are named `bounce.send`.\n\n1. Wait until SES shows **Identity status** as **Verified**, and both **DKIM configuration** and **MAIL FROM configuration** as **Successful**.\n\n If they stay pending, check that each record\'s proxy status is **DNS only**.\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 on your own domain that receives mail, such as one forwarded to your inbox with Cloudflare Email Routing. Reports arrive there as attachments. An address on another domain, such as a Gmail address, gets no reports unless that domain publishes a record allowing them, which Gmail doesn\'t.\n\n1. Check that the record is published. This prints it:\n\n ```bash\n dig +short TXT _dmarc.send.example.com\n ```\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. Verify your test addresses\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. The test emails in [Verify it works](07-verify.md) go to two addresses of your own, so verify those now. [Request production access](06-production-access.md) lifts the limit, once AWS grants it.\n\n1. Choose two addresses you own, such as a personal address and a work address. Verify it works uses both.\n\n1. For each, under **Identities**, choose **Create identity**, select **Email address**, and enter it. Leave **Assign a default configuration set** and **Assign to a tenant** unchecked. The form\'s notice about DMARC is about sending from the address, and the app only sends to it. Choose **Create identity**.\n\n1. Open the email AWS sends to each address, and follow its link. The identity\'s status changes to **Verified**.\n\n AWS\'s email and confirmation page say you can now send from the address. That\'s expected: SES has one kind of verified address, and the same verification lets the sandbox send to it.\n\n## 4. 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`, keep every other option as it is, 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\n Leave the other six off, since the app already knows about them or doesn\'t act on them. **Sends** repeats what the send records, and **Delivery delays** end as a bounce or a delivery. **Rejects** (a virus) and **Subscriptions** (SES\'s own unsubscribe) don\'t arise, since the app\'s emails carry no attachments and their own unsubscribe link. **Opens** and **Clicks** would add tracking the app doesn\'t record.\n\n1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled.\n\n1. On the same screen, under **SNS topic**, choose **Create SNS topic**. Name it `kestrel-events`, leave the display name empty, and choose **Create topic**. Then select `kestrel-events` in the topic menu, if it isn\'t already. Choose **Next**, then **Add destination**.\n\n1. Make the set your hostname\'s default, so SES reports on every email sent from it. Under **Identities**, open `send.example.com`, and choose its **Configuration set** tab, below the summary. Edit the **Default configuration set**, select **Assign a default configuration set**, select `kestrel-mail`, and save.\n\n1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), in the same region, 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. Check that SES may publish to the topic, on its **Access policy** tab. A topic created from SES\'s **Add destination**, as above, already lets SES publish to it from your account. Leave that statement as it is. For a topic you made in the SNS console instead, 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## 5. 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 and read the account\'s sending status. That limits the harm if the key ever leaks. Reading the status lets the app show whether SES is still in the sandbox and take its send rate from the account. It also warns, without sending anything, when the configuration set is missing or paused, the sender isn\'t verified, or bounces and complaints can\'t reach the app.\n\nIf `kestrel-sender` already exists, from an earlier release, don\'t create a second user or key. Update its policy instead, as the end of this section shows.\n\n1. In the [IAM console](https://console.aws.amazon.com/iam/), choose **Policies**, then **Create policy**.\n\n1. 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": [\n "ses:SendEmail",\n "ses:SendBulkEmail",\n "ses:SendTemplatedEmail",\n "ses:SendBulkTemplatedEmail",\n "ses:GetAccount",\n "ses:GetEmailIdentity",\n "ses:GetConfigurationSet"\n ],\n "Resource": "*"\n }\n ]\n }\n ```\n\n1. Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. The review lists Pinpoint Email, SES, and SES v2, which is how IAM shows these SES actions.\n\n1. Choose **IAM users**, then **Create user**. Name it `kestrel-sender`, leave console access off, and choose **Next**.\n\n1. Choose **Attach policies directly**, search for `kestrel-send-email`, and select it. AWS recommends groups, which help manage many people, and one app\'s user doesn\'t need one. Choose **Next**, then **Create user**.\n\n1. Open `kestrel-sender`, and choose **Create access key**. It\'s an access key, not an API key. For the use case, choose **Application running outside AWS**, and choose **Next**.\n\n AWS suggests IAM Roles Anywhere instead. It needs a certificate authority of your own, so use the access key.\n\n1. For the description tag, enter the app\'s hostname, such as `newsletter.example.com`, so you can tell its key apart later. Choose **Create access key**.\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\nTo update an existing key\'s policy, open **Policies** in the IAM console, then `kestrel-send-email`, and choose **Edit**. In the JSON editor, make its `Action` list match the policy above, and save. Without `ses:SendTemplatedEmail`, SES refuses even a test email. Once the key may call `ses:GetAccount`, the app\'s next check, within an hour, reads the account. Without `ses:GetConfigurationSet`, the app can\'t tell whether the configuration set exists, so it says nothing about it rather than guessing.\n\n## 6. Connect the app 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 4 and 5. 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). 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 }\n ```\n\n On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan\'s limit, so each minute sends more. Leave it out on Workers Free. [As your list grows](../guides/09-as-your-list-grows.md) says when Workers Paid is worth it: for faster sends at any size, and for the daily database writes of a list past about 1,000 subscribers.\n\n The app reads its send rate from your SES account, so there\'s nothing to change when AWS grants production access. **Settings \u2192 Email sender** shows the rate and where it comes from.\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\n1. Check that 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\n Any other answer names the setting the app is missing.\n\n1. In the editor, **Settings \u2192 Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before. Within a couple of minutes, a **Sandbox** pill appears beside it, once the app has read your SES account.\n\n1. In the editor, open **Template** and select **Send test email**, to an address you verified in [Verify your test addresses](#3-verify-your-test-addresses). It arrives. Signups stay closed until SES takes an email, so the **Not proven yet** pill beside Amazon SES in **Settings \u2192 Email sender** is gone once it has. If SES refuses it, the test\'s line says why and what fixes it.\n\n1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren\'t open yet.\n\n## 7. 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. Check that the app\'s webhook is public. It 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. In the SNS console, open your topic, and choose **Create subscription**. Check that **Topic ARN** shows the `kestrel-events` topic, and choose it if it\'s empty.\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.\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. Expand **Delivery policy (HTTP/S)**, turn off **Use the default delivery policy**, and set these values:\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. The topic has a delivery policy of its own, under **Edit**. Setting it there instead also covers any subscription you add later.\n\n1. 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`. Check that `SNS_TOPIC_ARN` matches the topic\'s ARN, and that section 6\'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. **(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## 8. 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. The reader is told your domain couldn\'t be found. 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 the rest of this section.\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. Its SPF record is for forwarding: SES\'s mail passes SPF at `bounce.send.example.com`, from section 1.\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\n1. Check that your From address receives mail. This now lists Cloudflare\'s three `route` mail servers:\n\n ```bash\n dig +short MX send.example.com\n ```\n\n1. Send an email to `newsletter@send.example.com` from an address whose mail doesn\'t go through the publisher\'s inbox. Gmail, for one, hides a forwarded copy of mail you sent yourself. In Email Routing\'s **Activity log**, the email shows as **Forwarded**.\n\n If it bounces saying the domain couldn\'t be found, the sender\'s provider is still remembering that `send.example.com` had no mail servers. That happens after an email to it before this section, and lasts up to 30 minutes on Cloudflare\'s DNS. Wait, and send it again.\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\nSES is now connected in the sandbox. Until AWS grants production access, it sends only to the addresses you verified in section 3. It also sends to the SES test addresses Verify it works uses. SES charges for each email it sends, test emails included, as [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists.\n\nNext, [request production access](06-production-access.md).\n';
|
|
13343
|
+
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. See [Request production access](06-production-access.md) to take it out of the sandbox.\n\nIn this guide, you set up how the app sends mail through SES and receives bounces and complaints, and how readers\' replies reach the publisher.\n\n## Before you begin\n\n- An AWS account for the newsletter, from [Create an AWS account](04-aws-account.md).\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\nThe same form sets a custom MAIL FROM domain, so SPF also passes for your domain. [SPF: authorize the return path](../reference/02-sending-domain-dns.md#spf-authorize-the-return-path) explains why it matters.\n\n1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose a region near you in the region menu at the top right.\n\n SES keeps identities, quotas, and the sandbox separately in each region, so do every step on this page in the region you chose. [Configuration](../reference/01-configuration.md#vars) says more about choosing one, under `AWS_REGION`.\n\n1. Note the region\'s code, such as `us-west-2`. The menu shows it beside the region\'s name, and [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each one too. AWS names a region in more than one way, such as United States (Oregon) and US West (Oregon), so go by the code. Later steps use it.\n\n1. Under **Configuration**, choose **Identities**, then **Create identity**.\n\n1. Select **Domain**, and enter `send.example.com`. Leave **Assign a default configuration set** and **Assign to a tenant** unchecked. Section 4 makes the configuration set, and makes it the default then.\n\n1. Select **Use a custom MAIL FROM domain**. In **MAIL FROM domain**, enter `bounce`; the form shows `.send.example.com` after it. For **Behavior on MX failure**, keep **Use default MAIL FROM domain**.\n\n1. Expand **Advanced DKIM settings**, and select **Easy DKIM**. Keep **RSA_2048_BIT** as the key length. Choose **Create identity**.\n\n1. The identity opens on its **Authentication** tab, which lists five DNS records to publish. Under DKIM, three CNAME records. Under **Custom MAIL FROM domain**, expand **Publish DNS records** for an MX record and a TXT record.\n\n1. Add all five to your domain\'s DNS records in Cloudflare, with their proxy status set to **DNS only**. SES can\'t read a proxied record, so check each one\'s status. You can import them in one go, or add them by hand:\n\n - **Import them.** Copy this into a text file. Replace each `REPLACE_WITH_NAME_\u2026` and `REPLACE_WITH_VALUE_\u2026` with a DKIM record\'s name and value, exactly as SES lists them, and keep the dot after each. Replace `us-east-1` with your region\'s code. In the Cloudflare dashboard, open your domain\'s **DNS \u2192 Records**, select **Import and Export**, and import the file with **Proxy imported DNS records** unchecked. SES\'s own **Download .csv record set** is in a format Cloudflare doesn\'t import.\n\n ```\n REPLACE_WITH_NAME_1. 3600 IN CNAME REPLACE_WITH_VALUE_1.\n REPLACE_WITH_NAME_2. 3600 IN CNAME REPLACE_WITH_VALUE_2.\n REPLACE_WITH_NAME_3. 3600 IN CNAME REPLACE_WITH_VALUE_3.\n bounce.send.example.com. 3600 IN MX 10 feedback-smtp.us-east-1.amazonses.com.\n bounce.send.example.com. 3600 IN TXT "v=spf1 include:amazonses.com ~all"\n ```\n\n - **Add them by hand.** Add each record exactly as SES lists it. Cloudflare adds your domain to the name, so each DKIM name ends in `._domainkey.send`, and the MAIL FROM records are named `bounce.send`.\n\n1. Wait until SES shows **Identity status** as **Verified**, and both **DKIM configuration** and **MAIL FROM configuration** as **Successful**.\n\n If they stay pending, check that each record\'s proxy status is **DNS only**.\n\n## 2. Add a DMARC record\n\nDMARC tells inboxes what to do with mail that fails its checks, and can ask them to send you reports. The `rua` tag that asks for them is optional, and the policy works without it. SES doesn\'t add this record, so you add it yourself. It starts in monitor mode, `p=none`, which blocks nothing, your own mail included. It only monitors: inboxes still deliver mail that fails, so it doesn\'t stop anyone sending as your domain.\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 on your own domain that receives mail, such as one forwarded to your inbox with Cloudflare Email Routing. Reports arrive there as attachments. An address on another domain, such as a Gmail address, gets no reports unless that domain publishes a record allowing them, which Gmail doesn\'t.\n\n1. Check that the record is published. This prints it:\n\n ```bash\n dig +short TXT _dmarc.send.example.com\n ```\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. Verify your test addresses\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. The test emails in [Verify it works](07-verify.md) go to two addresses of your own, so verify those now. [Request production access](06-production-access.md) lifts the limit, once AWS grants it.\n\n1. Choose two addresses you own, such as a personal address and a work address. Verify it works uses both.\n\n1. For each, under **Identities**, choose **Create identity**, select **Email address**, and enter it. Leave **Assign a default configuration set** and **Assign to a tenant** unchecked. The form\'s notice about DMARC is about sending from the address, and the app only sends to it. Choose **Create identity**.\n\n1. Open the email AWS sends to each address, and follow its link. The identity\'s status changes to **Verified**.\n\n AWS\'s email and confirmation page say you can now send from the address. That\'s expected: SES has one kind of verified address, and the same verification lets the sandbox send to it.\n\n## 4. 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`, keep every other option as it is, 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\n Leave the other six off, since the app already knows about them or doesn\'t act on them. **Sends** repeats what the send records, and **Delivery delays** end as a bounce or a delivery. **Rejects** (a virus) and **Subscriptions** (SES\'s own unsubscribe) don\'t arise, since the app\'s emails carry no attachments and their own unsubscribe link. **Opens** and **Clicks** would add tracking the app doesn\'t record.\n\n1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled.\n\n1. On the same screen, under **SNS topic**, choose **Create SNS topic**. Name it `kestrel-events`, leave the display name empty, and choose **Create topic**. Then select `kestrel-events` in the topic menu, if it isn\'t already. Choose **Next**, then **Add destination**.\n\n1. Make the set your hostname\'s default, so SES reports on every email sent from it. Under **Identities**, open `send.example.com`, and choose its **Configuration set** tab, below the summary. Edit the **Default configuration set**, select **Assign a default configuration set**, select `kestrel-mail`, and save.\n\n1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), in the same region, 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. Check that SES may publish to the topic, on its **Access policy** tab. A topic created from SES\'s **Add destination**, as above, already lets SES publish to it from your account. Leave that statement as it is. For a topic you made in the SNS console instead, 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## 5. 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 and read the account\'s sending status. That limits the harm if the key ever leaks. Reading the status lets the app show whether SES is still in the sandbox and take its send rate from the account. It also warns, without sending anything, when the configuration set is missing or paused, the sender isn\'t verified, or bounces and complaints can\'t reach the app.\n\nIf `kestrel-sender` already exists, from an earlier release, don\'t create a second user or key. Update its policy instead, as the end of this section shows.\n\n1. In the [IAM console](https://console.aws.amazon.com/iam/), choose **Policies**, then **Create policy**.\n\n1. 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": [\n "ses:SendEmail",\n "ses:SendBulkEmail",\n "ses:SendTemplatedEmail",\n "ses:SendBulkTemplatedEmail",\n "ses:GetAccount",\n "ses:GetEmailIdentity",\n "ses:GetConfigurationSet"\n ],\n "Resource": "*"\n }\n ]\n }\n ```\n\n1. Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. The review lists Pinpoint Email, SES, and SES v2, which is how IAM shows these SES actions.\n\n1. Choose **IAM users**, then **Create user**. Name it `kestrel-sender`, leave console access off, and choose **Next**.\n\n1. Choose **Attach policies directly**, search for `kestrel-send-email`, and select it. AWS recommends groups, which help manage many people, and one app\'s user doesn\'t need one. Choose **Next**, then **Create user**.\n\n1. Open `kestrel-sender`, and choose **Create access key**. It\'s an access key, not an API key. For the use case, choose **Application running outside AWS**, and choose **Next**.\n\n AWS suggests IAM Roles Anywhere instead. It needs a certificate authority of your own, so use the access key.\n\n1. For the description tag, enter the app\'s hostname, such as `newsletter.example.com`, so you can tell its key apart later. Choose **Create access key**.\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\nTo update an existing key\'s policy, open **Policies** in the IAM console, then `kestrel-send-email`, and choose **Edit**. In the JSON editor, make its `Action` list match the policy above, and save. Without `ses:SendTemplatedEmail`, SES refuses even a test email. Once the key may call `ses:GetAccount`, the app\'s next check, within an hour, reads the account. Without `ses:GetConfigurationSet`, the app can\'t tell whether the configuration set exists, so it says nothing about it rather than guessing.\n\n## 6. Connect the app 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 4 and 5. 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). 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 }\n ```\n\n On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan\'s limit, so each minute sends more. Leave it out on Workers Free. [As your list grows](../guides/09-as-your-list-grows.md) says when Workers Paid is worth it: for faster sends at any size, and for the daily database writes of a list past about 1,000 subscribers.\n\n The app reads its send rate from your SES account, so there\'s nothing to change when AWS grants production access. **Settings \u2192 Email sender** shows the rate and where it comes from.\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\n1. Check that 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\n Any other answer names the setting the app is missing.\n\n1. In the editor, **Settings \u2192 Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before. Within a couple of minutes, a **Sandbox** pill appears beside it, once the app has read your SES account.\n\n1. In the editor, open **Template** and select **Send test email**, to an address you verified in [Verify your test addresses](#3-verify-your-test-addresses). It arrives. Signups stay closed until SES takes an email, so the **Not proven yet** pill beside Amazon SES in **Settings \u2192 Email sender** is gone once it has. If SES refuses it, the test\'s line says why and what fixes it.\n\n1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren\'t open yet.\n\n## 7. 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. Check that the app\'s webhook is public. It 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. In the SNS console, open your topic, and choose **Create subscription**. Check that **Topic ARN** shows the `kestrel-events` topic, and choose it if it\'s empty.\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.\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. Expand **Delivery policy (HTTP/S)**, turn off **Use the default delivery policy**, and set these values:\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. The topic has a delivery policy of its own, under **Edit**. Setting it there instead also covers any subscription you add later.\n\n1. 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`. Check that `SNS_TOPIC_ARN` matches the topic\'s ARN, and that section 6\'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. **(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## 8. 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. The reader is told your domain couldn\'t be found. 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 the rest of this section.\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. Its SPF record is for forwarding: SES\'s mail passes SPF at `bounce.send.example.com`, from section 1.\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)** Forward your DMARC reports too, if you collect them and 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`. Reports come about once a day from each inbox provider that received your mail. A day or two after your next email goes out, check that they arrive. Email Routing\'s **Activity log** shows each forward\'s status.\n\n1. Check that your From address receives mail. This now lists Cloudflare\'s three `route` mail servers:\n\n ```bash\n dig +short MX send.example.com\n ```\n\n1. Send an email to `newsletter@send.example.com` from an address whose mail doesn\'t go through the publisher\'s inbox. Gmail, for one, hides a forwarded copy of mail you sent yourself. In Email Routing\'s **Activity log**, the email shows as **Forwarded**.\n\n If it bounces saying the domain couldn\'t be found, the sender\'s provider is still remembering that `send.example.com` had no mail servers. That happens after an email to it before this section, and lasts up to 30 minutes on Cloudflare\'s DNS. Wait, and send it again.\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\nSES is now connected in the sandbox. Until AWS grants production access, it sends only to the addresses you verified in section 3. It also sends to the SES test addresses Verify it works uses. SES charges for each email it sends, test emails included, as [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists.\n\nNext, [request production access](06-production-access.md).\n';
|
|
13235
13344
|
|
|
13236
13345
|
// docs/get-started/06-production-access.md
|
|
13237
13346
|
var production_access_default = "# Request production access\n\nOnce Amazon SES is connected, request production access to start sending email to subscribers. AWS reviews each request by hand, and its first answer can take up to 96 hours. Its reviewer looks for what's now in place: a website that says what the newsletter is, a subscribe form that confirms each signup by email, and bounces and complaints that reach the app.\n\nIn this guide, you name the publication, submit the request, and answer any questions AWS asks. Then, once AWS answers, you check that it granted access.\n\n## Before you begin\n\n- Amazon SES connected, from [Connect Amazon SES](05-ses.md).\n- The publication's name and tagline, from the publisher.\n\n## 1. Name the publication\n\nAWS may visit the publication's website to review the request. On a new instance with no posts, the tagline is the only line on the home page that says what the newsletter is about.\n\n1. In the editor, open **Settings \u2192 Publication identity**, and enter the name and tagline. Both can change later.\n\n1. If the newsletter will mainly sell something, add a mailing address there too, with **Add a mailing address**. [When to provide a mailing address](../publishing/03-name-your-publication.md#when-to-provide-a-mailing-address) explains when US law asks for one.\n\n1. Open `https://newsletter.example.com/` in a browser. The home page shows the tagline, and a subscribe form.\n\n## 2. Submit the request\n\nAWS reviews each request by hand, for the region the app sends from.\n\n1. In the [SES console](https://console.aws.amazon.com/ses/), choose the region the app sends from, the one in `AWS_REGION`. 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.\n\n1. Within about ten minutes, the editor's **Settings \u2192 Email sender** says AWS is reviewing your request for production access, and later whether AWS has granted it.\n\nWithin seconds of every request, AWS sends an automated email asking for more information, in a support case. The SES console then shows **More information needed**, and the editor says AWS hasn't granted production access yet and has replied in AWS Support, with the case number. That's expected, and not a refusal: answer it as the next section describes.\n\n## 3. Answer AWS's questions\n\nAWS asks how you collect addresses and handle bounces before it decides, every time. A newsletter may be asked for more detail again, or turned down the first time, sometimes without a reason. A reply that explains how the list is built usually gets further than a new request. Reply in the support case, whichever it is, or open a new request from the **Account dashboard**. The editor reads the same until AWS grants access, since AWS's account reports a request for more information and a refusal alike.\n\nIn the sandbox, the subscribe form's confirmation email reaches only addresses verified in SES. If AWS's reviewer tries the form, say that this is why no confirmation arrives. In your reply, cover:\n\n- That signups use double opt-in: each subscriber confirms by email before joining the list.\n- That every post offers one-click unsubscribe.\n- That bounces and complaints reach the app, and it stops mailing those addresses automatically. [Verify it works](07-verify.md#6-check-that-bounces-and-complaints-come-back) shows it working.\n- That the publication's website shows what the newsletter is, and how readers subscribe.\n- That an imported list was gathered with opt-in on the previous service.\n- How many subscribers the newsletter expects, and how often it sends.\n- A sample email. Send yourself a test of a post, as [Verify it works](07-verify.md#1-send-yourself-a-test-email) shows, and attach it to your reply or paste its text.\n\n## 4. Check that AWS granted it\n\nUntil AWS grants production access, SES sends only to your verified addresses, so check it before you go live.\n\n1. In the editor, open **Settings \u2192 Email sender**. The email provider reads **Production**. From the API, `npx kestrel api GET /api/provider` shows `state` as `production`.\n\n1. If it reads **Sandbox**, AWS hasn't granted access yet. If it also says AWS has replied, with a case number, answer in that case, as section 3 describes. Meanwhile, continue with [Verify it works](07-verify.md), which runs in the sandbox. Come back to this section when AWS answers.\n\n1. If it says Kestrel's AWS key needs permission to read your account, select **Add the permission** and follow the step it opens. The app checks again within an hour.\n\n1. If it reads just \"Amazon SES\", with nothing under it, the app hasn't heard from SES yet. Look again in ten minutes. If it still reads that way, the app's logs say why, in their `provider.account_checked` line, as [Read the logs](07-verify.md#7-read-the-logs) describes.\n\nNext, [verify it works](07-verify.md).\n";
|
|
13238
13347
|
|
|
13239
13348
|
// docs/get-started/07-verify.md
|
|
13240
|
-
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\nYou can do all of this while SES is still in the sandbox, since every email goes to the addresses you verified in [Connect Amazon SES](05-ses.md#3-verify-your-test-addresses) or to SES's test addresses.\n\nIn this guide, you send yourself a test email, reply to it, and check that it passes authentication. You check that notifications arrive, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs, and clean up the test subscribers.\n\n## Before you begin\n\n- The two addresses you verified in SES in [Connect Amazon SES](05-ses.md#3-verify-your-test-addresses). 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. If SES refuses it, the test's line says why and what fixes 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](05-ses.md#8-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 MAIL FROM records from [Connect Amazon SES](05-ses.md#1-verify-your-sending-hostname).\n\nIf a check fails, compare your DNS records with the ones in [Connect Amazon SES](05-ses.md#1-verify-your-sending-hostname). [Sending-domain DNS](../reference/02-sending-domain-dns.md#verify-alignment) explains each result.\n\n## 3. Check that notifications arrive\n\nKestrel emails each notifications address when a send finishes, and right away when one runs into a problem. Check that they reach you, using one of the addresses you verified in SES. While SES is in the sandbox, every notifications address has to be verified there too, unless notifications go through [Cloudflare's email](../guides/03-notifications.md), where each is verified with Cloudflare instead. If you're setting Kestrel up for someone else, you can add their address beside yours later.\n\n1. In the editor, open **Settings \u2192 Notifications**, type that address under **Send notifications to**, select **Add inbox**, and then **Save**.\n\n1. Select **Send a test notification**. It arrives at that address. If it doesn't, **Last notification**, on the same page, says what happened.\n\nThe publisher sets their own address 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\nIf the send shows in red under **Sent**, its line names the fix. Once that's fixed, the send resumes on its own.\n\n## 5. Unsubscribe\n\nEvery email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. The button works in one step, and the link asks you to confirm first. Neither needs a login.\n\n1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender. If your client shows none, use the **Unsubscribe** link in the email's footer, which asks you to confirm on a page.\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](05-ses.md#4-send-bounces-and-complaints-to-the-app), and the subscription in [Subscribe the app to the topic](05-ses.md#7-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 your Worker, the `name` in the `production` block of `wrangler.jsonc`, and select **Observability**.\n\n1. Filter on `event` equal to `sweep.tick`. A line arrives every minute. If none arrive, the schedule probably isn't running, and deploying again with `--env production` registers it.\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
|
|
13349
|
+
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\nYou can do all of this while SES is still in the sandbox, since every email goes to the addresses you verified in [Connect Amazon SES](05-ses.md#3-verify-your-test-addresses) or to SES's test addresses.\n\nIn this guide, you send yourself a test email, reply to it, and check that it passes authentication. You check that notifications arrive, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs, and clean up the test subscribers.\n\n## Before you begin\n\n- The two addresses you verified in SES in [Connect Amazon SES](05-ses.md#3-verify-your-test-addresses). 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. If SES refuses it, the test's line says why and what fixes 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](05-ses.md#8-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 MAIL FROM records from [Connect Amazon SES](05-ses.md#1-verify-your-sending-hostname).\n\nIf a check fails, compare your DNS records with the ones in [Connect Amazon SES](05-ses.md#1-verify-your-sending-hostname). [Sending-domain DNS](../reference/02-sending-domain-dns.md#verify-alignment) explains each result.\n\n## 3. Check that notifications arrive\n\nKestrel emails each notifications address when a send finishes, and right away when one runs into a problem. Check that they reach you, using one of the addresses you verified in SES. While SES is in the sandbox, every notifications address has to be verified there too, unless notifications go through [Cloudflare's email](../guides/03-notifications.md), where each is verified with Cloudflare instead. If you're setting Kestrel up for someone else, you can add their address beside yours later.\n\n1. In the editor, open **Settings \u2192 Notifications**, type that address under **Send notifications to**, select **Add inbox**, and then **Save**.\n\n1. Select **Send a test notification**. It arrives at that address. If it doesn't, **Last notification**, on the same page, says what happened.\n\nThe publisher sets their own address 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\nIf the send shows in red under **Sent**, its line names the fix. Once that's fixed, the send resumes on its own.\n\n## 5. Unsubscribe\n\nEvery email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. The button works in one step, and the link asks you to confirm first. Neither needs a login.\n\n1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender. If your client shows none, use the **Unsubscribe** link in the email's footer, which asks you to confirm on a page.\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](05-ses.md#4-send-bounces-and-complaints-to-the-app), and the subscription in [Subscribe the app to the topic](05-ses.md#7-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 your Worker, the `name` in the `production` block of `wrangler.jsonc`, and select **Observability**.\n\n1. Filter on `event` equal to `sweep.tick`. A line arrives every minute. If none arrive, the schedule probably isn't running, and deploying again with `--env production` registers it.\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. An occasional `d1.unavailable` line at `warn` is a brief database stall the next run makes up for, and needs nothing. The same line every minute means work isn't getting done, so if you set up alerts, alert on it repeating as well as on `error`.\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. 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 and AWS grants production access, your instance is ready to go live.\n\nNext, [go live](08-go-live.md).\n";
|
|
13241
13350
|
|
|
13242
13351
|
// docs/get-started/08-go-live.md
|
|
13243
13352
|
var go_live_default = "# Go live\n\nAt this point, your instance should be deployed and verified. Before anyone subscribes, [check that AWS granted production access](06-production-access.md#4-check-that-aws-granted-it), so SES can send to anyone. From here, where you go next depends on who's writing the newsletter.\n\n## Start publishing\n\nOpen the editor at `https://newsletter.example.com/dashboard/`. The dashboard's setup steps show what's left, and [Publishing](../publishing/README.md) covers each one.\n\n## Hand off to someone else\n\n[Let them into the editor](03-access.md#allow-someone-else-in), and add their address beside yours under **Settings \u2192 Notifications**, so a problem reaches both of you. Then send them the editor's address and a link to [Publishing](../publishing/README.md). The dashboard's setup steps show them what's left.\n\n## Optional guides\n\nEach one adds one thing, and you can come back to any of them later.\n\n- Before you share your subscribe link: [Rate-limit the subscribe form](../guides/05-rate-limit.md)\n- To hear about a problem even when SES has stopped sending: [Notifications through Cloudflare's email](../guides/03-notifications.md)\n- To have Claude draft, proofread, and schedule posts: [Allow Claude to connect](../guides/01-allow-claude.md)\n- To run scripts with no one signed in: [Automate with a service token](../guides/02-automate.md)\n- To see when your list needs Workers Paid or higher SES limits: [As your list grows](../guides/09-as-your-list-grows.md)\n- To move to a new release: [Upgrade to a new release](../guides/07-upgrade.md)\n";
|
|
@@ -13249,13 +13358,13 @@ var allow_claude_default = "# Allow Claude to connect\n\nConnecting Claude to th
|
|
|
13249
13358
|
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 Allow 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';
|
|
13250
13359
|
|
|
13251
13360
|
// docs/guides/03-notifications.md
|
|
13252
|
-
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 addresses 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 addresses saved, from [Verify it works](../get-started/07-verify.md#3-check-that-notifications-arrive).\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
|
|
13361
|
+
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 addresses 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 addresses saved, from [Verify it works](../get-started/07-verify.md#3-check-that-notifications-arrive).\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 the DNS records it needs for `newsletter.example.com`. Wait until the new row reads **Status: Enabled** and **DNS records: Locked**.\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/05-ses.md#8-receive-replies-at-your-from-address) sets up, is fine, since it only receives.\n\n## 2. Verify the notifications addresses\n\nCloudflare's email delivers only to addresses you've verified with it. Sending to a verified address is free on any plan. Notifications can go to several addresses, such as the publisher's and yours, and each one needs verifying.\n\n1. Under **Email Routing**, go to **Destination addresses**, and add each address saved for notifications that isn't already verified there.\n\n1. Open the email Cloudflare sends to each one, 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. If the publisher adds or changes a notifications address later, verify the new one too.\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 `env.production`, beside `r2_buckets` and not inside `vars`:\n\n ```jsonc\n \"send_email\": [{ \"name\": \"NOTIFY\" }],\n ```\n\n Give the binding only its name. This file is committed to your repository, so keep the notifications addresses out of it.\n\n1. **(Optional)** Notifications come from `Kestrel <kestrel@newsletter.example.com>`. To use another address on that hostname, set `NOTIFY_FROM` in `env.production.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 add wrangler.jsonc\n git commit -m \"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 The output lists the binding as `env.NOTIFY (unrestricted)`. Cloudflare still delivers only to the addresses you verified.\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**. **Last notification** shows it as delivered.\n\n1. Check that the test arrives at each notifications address. If it's in the spam folder, mark it **Not spam**.\n\n1. In Email Routing's **Activity log**, the test shows as **Delivered**. Go by this rather than Email Routing's summary, which [Cloudflare says](https://developers.cloudflare.com/email-service/platform/limits/) can list it as dropped even when it's delivered.\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/07-verify.md#7-read-the-logs) to check that `sweep.tick` still arrives every minute.\n- **The database being down.** Noticing a problem and telling you both need the database. [Read the logs](../get-started/07-verify.md#7-read-the-logs) to check for `d1.unavailable` repeating.\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";
|
|
13253
13362
|
|
|
13254
13363
|
// docs/guides/05-rate-limit.md
|
|
13255
13364
|
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";
|
|
13256
13365
|
|
|
13257
13366
|
// docs/guides/07-upgrade.md
|
|
13258
|
-
var upgrade_default = "# Upgrade to a new release\n\nA new release of Kestrel is a new version of the `@kurtbruns/kestrel` package. Upgrading changes the version your instance's repository names, brings in the release's database changes, and deploys. The app can't undo a database change, so you note a restore point first.\n\nIn this guide, you read what changed, install the release, and note where your database stands. Then you apply the database changes, deploy, and check the new version. If you run a staging environment, upgrade it first, and repeat each step for production once it checks out.\n\n## 1. Read what changed\n\nA release can ask you to change a setting or a record before it runs. The changelog says so, release by release.\n\n1. Find the version you run. In the editor, open the **Docs** or **API** tab. The foot of its contents list shows the version, then the commit. Your instance's `package.json` names it too, as the version of `@kurtbruns/kestrel`.\n\n1. Read the [release notes](https://github.com/kurtbruns/kestrel/releases) for every version after yours, up to the one you're moving to. Each release opens with an **Upgrading from\u2026** paragraph that says what to do. Its **Breaking** entries say what changed underneath you.\n\n## 2. Install the release\n\nEach step here changes only your instance's repository. Nothing reaches your deployed app until section 5.\n\n1. Install the release. Replace `X.Y.Z` with its version:\n\n ```bash\n npm install @kurtbruns/kestrel@X.Y.Z\n ```\n\n Your `package.json` now names exactly that version. Kestrel's versions aren't semantic versioning, so your instance never moves to a release you didn't choose.\n\n1. Bring in the release's database migrations:\n\n ```bash\n npm run sync-migrations\n ```\n\n It copies each new migration into `migrations/` and names the ones it added. It never overwrites one that's there.\n\n1. Check the Claude Code files Kestrel gave your instance:\n\n ```bash\n npm run check-context\n ```\n\n It compares each file in `.claude/` with the release's, and says what to do for each one:\n\n - **current**: nothing to do.\n - **Kestrel changed it, and you haven't edited it**: take the new one with `npm run check-context -- --update`.\n - **Kestrel changed it, and you've edited it**: it shows the difference from yours to the release's. Merge what you want into yours, by hand or with Claude, then record that you did with the command it prints.\n\n1. If the release's **Upgrading from\u2026** paragraph names a new setting, add it to `wrangler.jsonc`.\n\n1. Check the configuration and the types:\n\n ```bash\n npm run typecheck\n ```\n\n1. **(Optional)** Try the release on your computer. Apply its database changes to your local database, then start the app:\n\n ```bash\n npx wrangler d1 migrations apply DB --local\n npm run dev\n ```\n\n You see the new version with your local content. It can't show how the changes treat your real data. A staging environment can.\n\n1. Commit the upgrade as one commit, and push it. Going back, below, undoes this commit:\n\n ```bash\n git add -A\n git commit -m \"Upgrade Kestrel to X.Y.Z\"\n git push\n ```\n\n## 3. Note a restore point\n\nD1 keeps a history of your database, called Time Travel, for 30 days on Workers Paid and 7 on Workers Free. A bookmark marks one moment in it, so you can put the database back if the upgrade goes wrong.\n\n1. In the editor, check that no send is in progress. The dashboard lists any that are. Upgrade between sends.\n\n1. Note where the database stands now:\n\n ```bash\n npx wrangler d1 time-travel info DB --env production\n ```\n\n It prints a bookmark for the database's current state. Copy it, and keep it until the upgrade checks out.\n\n## 4. Apply the database changes\n\nA release changes the database only by adding a migration. Applying it keeps everything already in the database.\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it's about to apply, and asks you to confirm. When a release has none, it says there's nothing to apply. Run it on every upgrade.\n\n## 5. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\
|
|
13367
|
+
var upgrade_default = "# Upgrade to a new release\n\nA new release of Kestrel is a new version of the `@kurtbruns/kestrel` package. Upgrading changes the version your instance's repository names, brings in the release's database changes, and deploys. The app can't undo a database change, so you note a restore point first.\n\nIn this guide, you read what changed, install the release, and note where your database stands. Then you apply the database changes, deploy, and check the new version. If you run a staging environment, upgrade it first, and repeat each step for production once it checks out.\n\n## 1. Read what changed\n\nA release can ask you to change a setting or a record before it runs. The changelog says so, release by release.\n\n1. Find the version you run. In the editor, open the **Docs** or **API** tab. The foot of its contents list shows the version, then the commit. Your instance's `package.json` names it too, as the version of `@kurtbruns/kestrel`.\n\n1. Read the [release notes](https://github.com/kurtbruns/kestrel/releases) for every version after yours, up to the one you're moving to. Each release opens with an **Upgrading from\u2026** paragraph that says what to do. Its **Breaking** entries say what changed underneath you.\n\n## 2. Install the release\n\nEach step here changes only your instance's repository. Nothing reaches your deployed app until section 5.\n\n1. Start from a clean repository, so the upgrade's commit holds only the upgrade and going back undoes nothing else:\n\n ```bash\n git status\n ```\n\n If it lists anything, commit it first, as its own commit, or move it out of the repository.\n\n1. Install the release. Replace `X.Y.Z` with its version:\n\n ```bash\n npm install @kurtbruns/kestrel@X.Y.Z\n ```\n\n Your `package.json` now names exactly that version. Kestrel's versions aren't semantic versioning, so your instance never moves to a release you didn't choose.\n\n If npm stops with `ERESOLVE` and names wrangler, your `package.json` pins a wrangler older than the release needs. The error's `peer wrangler@` line gives the range it needs. Install both together, with that range in place of `WRANGLER_RANGE`:\n\n ```bash\n npm install @kurtbruns/kestrel@X.Y.Z \"wrangler@WRANGLER_RANGE\"\n ```\n\n1. Bring in the release's database migrations:\n\n ```bash\n npm run sync-migrations\n ```\n\n It copies each new migration into `migrations/` and names the ones it added. It never overwrites one that's there.\n\n1. Check the Claude Code files Kestrel gave your instance:\n\n ```bash\n npm run check-context\n ```\n\n It compares each file in `.claude/` with the release's, and says what to do for each one:\n\n - **current**: nothing to do.\n - **Kestrel changed it, and you haven't edited it**: take the new one with `npm run check-context -- --update`.\n - **Kestrel changed it, and you've edited it**: it shows the difference from yours to the release's. Merge what you want into yours, by hand or with Claude, then record that you did with the command it prints.\n\n1. If the release's **Upgrading from\u2026** paragraph names a new setting, add it to `wrangler.jsonc`.\n\n1. Check the configuration and the types:\n\n ```bash\n npm run typecheck\n ```\n\n1. **(Optional)** Try the release on your computer. Apply its database changes to your local database, then start the app:\n\n ```bash\n npx wrangler d1 migrations apply DB --local\n npm run dev\n ```\n\n You see the new version with your local content. It can't show how the changes treat your real data. A staging environment can.\n\n1. Commit the upgrade as one commit, and push it. Going back, below, undoes this commit:\n\n ```bash\n git add -A\n git commit -m \"Upgrade Kestrel to X.Y.Z\"\n git push\n ```\n\n## 3. Note a restore point\n\nD1 keeps a history of your database, called Time Travel, for 30 days on Workers Paid and 7 on Workers Free. A bookmark marks one moment in it, so you can put the database back if the upgrade goes wrong.\n\n1. In the editor, check that no send is in progress. The dashboard lists any that are. Upgrade between sends.\n\n1. Note where the database stands now:\n\n ```bash\n npx wrangler d1 time-travel info DB --env production\n ```\n\n It prints a bookmark for the database's current state. Copy it, and keep it until the upgrade checks out.\n\n## 4. Apply the database changes\n\nA release changes the database only by adding a migration. Applying it keeps everything already in the database.\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it's about to apply, and asks you to confirm. Run by Claude, or in any other shell that isn't interactive, it answers its own prompt and applies them without asking. When a release has none, it says there's nothing to apply. Run it on every upgrade.\n\n## 5. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nNear its end, the output names your app's hostname, then its schedule and a version ID.\n\n## Check it\n\n1. The app answers:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {\"status\":\"ok\",\"service\":\"kestrel\"}\n ```\n\n If it answers `500` with `{\"error\":\"schema_behind\",\"missing\":[\u2026],\u2026}` instead, the release was deployed before its database changes: the public pages say the newsletter is temporarily unavailable and nothing is sent. Apply them as in section 4; the app answers as soon as they're applied, with no new deploy.\n\n1. Reload the editor. The foot of the **Docs** tab's contents list shows the new version, which links to its release page.\n\n1. Send yourself a test email, as in [Verify it works](../get-started/07-verify.md#1-send-yourself-a-test-email). Don't send a real post to check an upgrade: on a live instance, it goes to your whole list.\n\n1. In the logs, `sweep.tick` still arrives every minute, as in [Verify it works](../get-started/07-verify.md#7-read-the-logs).\n\n## Go back\n\nIf the new release misbehaves, deploy the previous one, and put the database back to your bookmark.\n\n1. Find the upgrade's commit. Replace `X.Y.Z` with the release you're backing out. Its id comes first on the line:\n\n ```bash\n git log -1 --oneline --grep=\"Upgrade Kestrel to X.Y.Z\"\n ```\n\n1. Undo it. This adds a commit that puts back the previous release, its migrations, and your Claude Code files, and leaves anything you committed since as it is:\n\n ```bash\n git revert --no-edit REPLACE_WITH_COMMIT_ID\n ```\n\n1. Install the previous release, and deploy it:\n\n ```bash\n npm ci\n npx wrangler deploy --env production\n ```\n\n1. Restore the database to the bookmark from section 3:\n\n ```bash\n npx wrangler d1 time-travel restore DB --env production --bookmark=REPLACE_WITH_BOOKMARK\n ```\n\n1. Push the revert to your repository:\n\n ```bash\n git push\n ```\n\nA restore returns the whole database to that moment. Everything written since is gone: edits to posts, new subscribers, and the record of anything sent. Restore only soon after the upgrade.\n\nNever restore past a send that went out, or an unsubscribe recorded since the bookmark. The restored database wouldn't know of them, so it could mail those readers again, or mail someone who left. If either happened, stay on the new release, and fix forward.\n";
|
|
13259
13368
|
|
|
13260
13369
|
// docs/guides/09-as-your-list-grows.md
|
|
13261
13370
|
var as_your_list_grows_default = "# As your list grows\n\nA new instance runs on Cloudflare's free Workers plan with your starting Amazon SES limits. 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. As the list grows, two limits start to matter: how fast a send goes out, and how many database writes the free plan allows a day.\n\nIn this guide, you raise your SES limits, which helps on either plan, and upgrade to Workers Paid, which a larger list needs. Then you see how long a send takes after each.\n\n## 1. Raise your SES limits\n\nSES caps how many emails your account sends a day, and how many a second. A send that hits the daily quota waits, emails you, and resumes on its own once the quota frees up. Raising your maximum send rate to 50 a second costs nothing, and it makes every send faster.\n\n1. Ask AWS for a higher daily quota and a maximum send rate of 50, as AWS's [Increasing your Amazon SES sending quotas](https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-request-increase.html) describes.\n\n1. When AWS grants it, the app takes the new rate from your account within about ten minutes, and **Settings \u2192 Email sender** shows it under **Send rate**. If `SES_MAX_SEND_RATE` is set in `wrangler.jsonc`, the app keeps to the lower of the two, so remove it or raise it to match, then deploy, as [Configuration](../reference/01-configuration.md#vars) describes.\n\n## 2. Upgrade to Workers Paid\n\nCloudflare's $5 Workers Paid plan helps in two ways, at any size of list.\n\n- **Faster sends.** On the free plan, Cloudflare caps how much one minute's work can do. A send then uses your SES rate for only a few seconds of each minute: about 56 emails a minute at a rate of 14. On Workers Paid, a send uses about 50 seconds of each minute, about 700 emails a minute at the same rate. If sends feel slow, upgrading speeds them up more than raising your SES rate does. [How long a send takes](#how-long-a-send-takes) compares them.\n- **No daily limit on database writes.** On the free plan, [D1](https://developers.cloudflare.com/d1/platform/pricing/) allows 100,000 written rows a day, reset at midnight UTC. A send writes about 14 rows a recipient, receipts included, and an import about 7 for each new subscriber. So at 1,000 subscribers, an import of the whole list and two sends on one day use about a third of the day. Once the day's rows are spent, the whole app stops until midnight UTC, the SES webhook with it. That day's bounces and complaints are lost, and suppress or unsubscribe no one.\n\n It's recommended to upgrade from about 1,000 subscribers. Past that, the Dashboard and the Subscribers screen say so, and so does an import that would take the list past it. Past about 5,000, a send that could use up the day on its own also warns before it's scheduled. The app doesn't count the day's writes, so between the two, two large sends or several imports and a send on one UTC day can still use it up without a warning at that moment.\n\n1. Upgrade your Cloudflare account to the $5 [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/), which has no daily limit on writes.\n\n1. In `wrangler.jsonc`, add `\"SUBREQUEST_BUDGET\": \"10000\"` to the `production` block's `vars`. It tells the app it's on Workers Paid, so each minute sends far more. [Configuration](../reference/01-configuration.md#vars) describes `SUBREQUEST_BUDGET`.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am \"Run on Workers Paid\"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\nOver years, one more limit can matter: on the free plan, one [D1 database](https://developers.cloudflare.com/d1/platform/limits/) holds at most 500 MB, and every send keeps a row for each recipient in its record. Workers Paid raises it to 10 GB.\n\n## How long a send takes\n\n| Setup | 1,000 subscribers | 5,000 subscribers | 10,000 subscribers |\n| --- | --- | --- | --- |\n| Free plan, a starting SES rate of 14 | about 18 minutes | about 1.5 hours | over the free plan's daily writes |\n| Free plan, SES rate raised to 50 | about 5 minutes | about 25 minutes | over the free plan's daily writes |\n| Workers Paid, SES rate of 14 | about 2 minutes | about 7 minutes | about 15 minutes |\n| Workers Paid, SES rate of 50 | under a minute | about 2 minutes | about 4 minutes |\n\n## Check it\n\n1. **Settings \u2192 Email sender** shows your new maximum send rate under **Send rate**, \"from your SES account\".\n\n1. On Workers Paid, the import screen under **Subscribers \u2192 Import** reads \"CSV or .txt, up to 10,000 rows.\"\n";
|
|
@@ -13275,6 +13384,14 @@ All three are optional, and you can change them at any time. How they appear in
|
|
|
13275
13384
|
|
|
13276
13385
|
Write the tagline as one line that says what the newsletter covers and how often it goes out, such as "Monthly notes on the birds of the Hudson Valley." Until your first post, it's the only line on your home page that says what readers are signing up for. It's also what AWS sees if it reviews your site when you [request production access](../get-started/06-production-access.md).
|
|
13277
13386
|
|
|
13387
|
+
## Choose a logo
|
|
13388
|
+
|
|
13389
|
+
**Use a square PNG, JPEG, WebP, or GIF, at least 256 \xD7 256 pixels, on a solid background.** Drop it on the logo under **Settings \u2192 Publication identity**, or choose **Upload**, which reads **Replace** once you have a logo. It saves right away, without **Save changes**. If that would change scheduled emails, Kestrel asks you to confirm first.
|
|
13390
|
+
|
|
13391
|
+
Kestrel makes a 256 \xD7 256 PNG from your image before uploading it, so the original's file size doesn't matter. A wider or taller image is cropped to its center square, and a smaller one is scaled up and looks soft. An animated GIF keeps its first frame. Emails show the logo at 44 \xD7 44 pixels, so a simple mark reads better than fine detail.
|
|
13392
|
+
|
|
13393
|
+
A transparent background shows whatever is behind the logo, and the built-in templates turn dark when a reader's mail app is in dark mode. A solid background keeps a dark mark visible there.
|
|
13394
|
+
|
|
13278
13395
|
## When to provide a mailing address
|
|
13279
13396
|
|
|
13280
13397
|
**If your newsletter is mostly your own writing, you probably don't need one. If an email is mainly selling something, add one.**
|
|
@@ -13292,10 +13409,10 @@ The From name and address your emails come from are set when Kestrel is deployed
|
|
|
13292
13409
|
`;
|
|
13293
13410
|
|
|
13294
13411
|
// docs/publishing/04-write-a-post.md
|
|
13295
|
-
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, and warns under it about anything it found wrong.\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. Kestrel may change its file name, turning spaces into hyphens and numbering a name the post already has, and inserts the new name for you.\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/05-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";
|
|
13412
|
+
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, and warns under it about anything it found wrong.\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. A file named for a different format than it really is, like a JPEG called `photo.png`, is refused until you rename it. Kestrel may change its file name, turning spaces into hyphens and numbering a name the post already has, and inserts the new name for you.\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/05-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";
|
|
13296
13413
|
|
|
13297
13414
|
// docs/publishing/06-troubleshooting.md
|
|
13298
|
-
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 and what to do. 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**: type an address and choose **Add inbox**. Add the address of whoever runs your instance too, and both of you are told. With none set, Kestrel emails you nothing. [Notifications](#notifications), below, lists what you're told.\n\n| Symptom | Where the fix is |\n| --- | --- |\n| [A send is held or waiting](#a-send-is-held-or-waiting) | Usually outside Kestrel |\n| [Someone was mailed twice, or not at all](#someone-was-mailed-twice-or-not-at-all) | In Kestrel |\n| [A reader says they never got a post](#a-reader-says-they-never-got-a-post) | In Kestrel |\n| [A send still reads Settling](#a-send-still-reads-settling) | Nothing to fix, unless no receipts come at all |\n| [A test doesn't arrive](#a-test-doesnt-arrive) | Outside Kestrel |\n| [Signups aren't open yet, or are paused](#signups-arent-open-yet-or-are-paused) | In Kestrel, or outside it |\n| [The dashboard names a problem with your Amazon SES account](#a-problem-with-amazon-ses) | Outside Kestrel |\n| [An address bounced or complained](#an-address-bounced-or-complained) | Nothing to fix, unless you lift a suppression |\n| [A warning says to move to Workers Paid](../guides/09-as-your-list-grows.md) | Outside Kestrel |\n| [An import is refused](#an-import-is-refused) | In your file |\n| [Claude can't connect](#claude-cant-connect) | Usually outside Kestrel |\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, 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/05-ses.md) sets up each piece SES checks: the sending domain, the AWS key, and the configuration set. [Request production access](../get-started/06-production-access.md) lifts the sandbox. 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 sending faster than your SES account allows. **Settings \u2192 Email sender** shows the **Send rate** and where it comes from. If `SES_MAX_SEND_RATE` sets it, lower it to the maximum send rate on the SES console's account dashboard, or remove it, then deploy, as [Configuration](../reference/01-configuration.md#vars) describes. If it's Kestrel's default, the app can't read your account: allow its key `ses:GetAccount`, as [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) shows.\n- **No email provider is connected.** Connect one, as [Connect Amazon SES](../get-started/05-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/07-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\nIf someone else set up Kestrel for you, pass this along to them.\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/07-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/05-ses.md#7-subscribe-the-app-to-the-topic) describes. If someone else set up Kestrel for you, pass this along to them.\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](../get-started/06-production-access.md). 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](#a-send-is-held-or-waiting). If someone else set up Kestrel for you, pass this along to them.\n\n## Signups aren't open yet, or are paused\n\nYour subscribe page takes signups only while email is working, so a reader is never told a link is coming when none can go. Every visitor gets the same page, whatever address they enter.\n\n- \"Signups aren\u2019t open yet\" means SES hasn't sent an email for your publication yet. Send one from **Template \u2192 Send test email**, to an address verified in SES while it's in the sandbox. Once SES takes it, signups open. Before an email provider is connected, the page says the same, and [Connect Amazon SES](../get-started/05-ses.md) is the fix.\n- \"Signups are paused\" means SES is refusing your email, every email including confirmations and tests. The dashboard's **Email isn\u2019t going out** line gives SES's words and the fix, as for [a problem with Amazon SES](#a-problem-with-amazon-ses). Then send a test email: once SES takes one, signups reopen.\n\nKestrel notices a refusal when an email meets it, or within about ten minutes from its check of your SES account. A refusal the check found lifts on its own within about ten minutes of the fix; one an email met lifts when a test email goes through. A reader who signed up just as it started may have been told a link was coming, and can sign up again once signups reopen.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## A problem with Amazon SES\n\n**Settings \u2192 Email sender** and the top of the dashboard name a problem with your Amazon SES account within about ten minutes, without sending anything. Red means no email can go out until it's fixed, tests and confirmations included. Amber is worth a look. Each line links the step that fixes it:\n\n- **The configuration set is missing, or its sending is off.** The set Kestrel sends with, the one `SES_CONFIGURATION_SET` names or the sending identity's default if that secret isn't set, isn't in your region, or its sending is turned off. Correct the name, or create the set, as [Connect Amazon SES](../get-started/05-ses.md#4-send-bounces-and-complaints-to-the-app) shows. Turn a set's sending back on in the SES console, under its configuration sets.\n- **SES rejects the AWS key.** The key ID is unknown, the secret doesn't match it, or the AWS account isn't signed up for SES. Store the key's ID and secret again, or create a new key, as [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) shows.\n- **The sender isn't verified.** SES has no verified identity for the From address in your region. Verify the sending hostname, as [Connect Amazon SES](../get-started/05-ses.md#1-verify-your-sending-hostname) shows.\n- **Sending is turned off for the account.** AWS documents turning it back on only with the AWS CLI, naming your region: `aws sesv2 put-account-sending-attributes --sending-enabled --region us-east-1`.\n- **AWS paused the account, or is reviewing it.** AWS does this when the account's bounce or complaint rate runs high. While it reviews, SES still sends, and the line is amber. Once it pauses sending, the line is red. Either way, AWS's email to the AWS account says what it needs: answer it in AWS Support, and check the addresses on your recent sends' records.\n\nThe app needs to read each of these. If its key isn't allowed to, the line says nothing rather than guessing. The policy in [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) allows all of it.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## An address bounced or complained\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\n[Connect Claude](08-connect-claude.md) covers connecting it. Most failures are in your instance's Cloudflare Access setup, and [Allow Claude to connect](../guides/01-allow-claude.md#check-it) lists their fixes. When connecting fails:\n\n- **Claude says it couldn't connect, or the sign-in failed.** 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. If both are right, your instance's Access setup needs the fix.\n- Signing in shows `invalid_target`, or `kestrel login` says your instance \"doesn't offer a sign-in for programs\". Your instance's Access setup needs the fix.\n- `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in, and your instance's Access setup needs the fix. If either names `access_unidentified`, it's the \"Access let you in\" problem below. If `kestrel api` says your sign-in has ended, sign in again as [Connect Claude](08-connect-claude.md#claude-code) describes.\n- `kestrel api` says it couldn't renew your sign-in because its Access team answered 404 (not found). First check which instance it called: when you don't name one, it prints a line naming it before \"the only instance you're signed in to\", which may not be the one you meant. Name yours with `--url`. If it is yours, the Access team domain was most likely renamed or removed since you signed in. Sign in again with `npx kestrel login` and your instance's address, which finds the team it uses now.\n- **\"Access let you in, but didn't say who you are\".** Your login carried neither an email nor a service token. Sign out, then sign in through the policy that lets you in by email. If you have no such policy, your instance's Access setup needs the fix.\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\nIf someone else set up Kestrel for you, pass this along to them.\n\n## Where to look\n\nWhen a line doesn't settle it, these say the most about what happened:\n\n- The top of the dashboard: what needs someone now, in Kestrel's own words.\n- The send's page, under **Sent**: what happened on that send, when it next retries, and its record of each reader.\n- **Settings \u2192 Email sender**: the state of your Amazon SES account, and your **Send rate**.\n- **Settings \u2192 Notifications**: **Last notification** says whether the last one was delivered, or why not.\n- A subscriber's page, under **Subscribers**: every send they were in, and what happened on each.\n- The app's logs, in your instance's Cloudflare account: [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes them. If someone else set up Kestrel for you, pass this along to them.\n\n## Notifications\n\nWith an address set, Kestrel emails you:\n\n- when a send finishes, with how many emails your provider accepted, and how late it went out if it did\n- when no one's email was accepted for a finished send, since it can't be sent again\n- when email isn't going out and signups are paused: once when it starts, and once more if a post is held after an email that named none\n- when your provider refuses a send or hits a limit, 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 doesn't go out on time\n- when your provider has sent no receipts for six hours after a send, once until a receipt arrives\n\nEach says whether anyone has to act, and how. If the fix needs whoever set up your instance, you can forward it as it stands. Each goes to every address set, links to the page it's about, and is sent once while the problem lasts. A reader's address in your provider's words shows only as its domain, and in full on the send's page. If one address can't be reached, the others still get it, and **Last notification** names the one that failed. 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";
|
|
13415
|
+
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 and what to do. 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**: type an address and choose **Add inbox**. Add the address of whoever runs your instance too, and both of you are told. With none set, Kestrel emails you nothing. [Notifications](#notifications), below, lists what you're told.\n\n| Symptom | Where the fix is |\n| --- | --- |\n| [A send is held or waiting](#a-send-is-held-or-waiting) | Usually outside Kestrel |\n| [Someone was mailed twice, or not at all](#someone-was-mailed-twice-or-not-at-all) | In Kestrel |\n| [A reader says they never got a post](#a-reader-says-they-never-got-a-post) | In Kestrel |\n| [A send still reads Settling](#a-send-still-reads-settling) | Nothing to fix, unless no receipts come at all |\n| [A test doesn't arrive](#a-test-doesnt-arrive) | Outside Kestrel |\n| [Signups aren't open yet, or are paused](#signups-arent-open-yet-or-are-paused) | In Kestrel, or outside it |\n| [The dashboard names a problem with your Amazon SES account](#a-problem-with-amazon-ses) | Outside Kestrel |\n| [An address bounced or complained](#an-address-bounced-or-complained) | Nothing to fix, unless you lift a suppression |\n| [A warning says to move to Workers Paid](../guides/09-as-your-list-grows.md) | Outside Kestrel |\n| [An import is refused](#an-import-is-refused) | In your file |\n| [Claude can't connect](#claude-cant-connect) | Usually outside Kestrel |\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, 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/05-ses.md) sets up each piece SES checks: the sending domain, the AWS key, and the configuration set. [Request production access](../get-started/06-production-access.md) lifts the sandbox. 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 sending faster than your SES account allows. **Settings \u2192 Email sender** shows the **Send rate** and where it comes from. If `SES_MAX_SEND_RATE` sets it, lower it to the maximum send rate on the SES console's account dashboard, or remove it, then deploy, as [Configuration](../reference/01-configuration.md#vars) describes. If it's Kestrel's default, the app can't read your account: allow its key `ses:GetAccount`, as [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) shows.\n- **No email provider is connected.** Connect one, as [Connect Amazon SES](../get-started/05-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/07-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\nIf someone else set up Kestrel for you, pass this along to them.\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/07-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/05-ses.md#7-subscribe-the-app-to-the-topic) describes. If someone else set up Kestrel for you, pass this along to them.\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](../get-started/06-production-access.md). 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](#a-send-is-held-or-waiting). If someone else set up Kestrel for you, pass this along to them.\n\n## Signups aren't open yet, or are paused\n\nYour subscribe page takes signups only while email is working, so a reader is never told a link is coming when none can go. Every visitor gets the same page, whatever address they enter.\n\n- \"Signups aren\u2019t open yet\" means SES hasn't sent an email for your publication yet. Send one from **Template \u2192 Send test email**, to an address verified in SES while it's in the sandbox. Once SES takes it, signups open. Before an email provider is connected, the page says the same, and [Connect Amazon SES](../get-started/05-ses.md) is the fix.\n- \"Signups are paused\" means SES is refusing your email, every email including confirmations and tests. The dashboard's **Email isn\u2019t going out** line gives SES's words and the fix, as for [a problem with Amazon SES](#a-problem-with-amazon-ses). Then send a test email: once SES takes one, signups reopen.\n\nKestrel notices a refusal when an email meets it, or within about ten minutes from its check of your SES account. A refusal the check found lifts on its own within about ten minutes of the fix; one an email met lifts when a test email goes through. A reader who signed up just as it started may have been told a link was coming, and can sign up again once signups reopen.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## A problem with Amazon SES\n\n**Settings \u2192 Email sender** and the top of the dashboard name a problem with your Amazon SES account within about ten minutes, without sending anything. Red means no email can go out until it's fixed, tests and confirmations included. Amber is worth a look. Each line links the step that fixes it:\n\n- **The configuration set is missing, or its sending is off.** The set Kestrel sends with, the one `SES_CONFIGURATION_SET` names or the sending identity's default if that secret isn't set, isn't in your region, or its sending is turned off. Correct the name, or create the set, as [Connect Amazon SES](../get-started/05-ses.md#4-send-bounces-and-complaints-to-the-app) shows. Turn a set's sending back on in the SES console, under its configuration sets.\n- **SES rejects the AWS key.** The key ID is unknown, the secret doesn't match it, or the AWS account isn't signed up for SES. Store the key's ID and secret again, or create a new key, as [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) shows.\n- **The sender isn't verified.** SES has no verified identity for the From address in your region. Verify the sending hostname, as [Connect Amazon SES](../get-started/05-ses.md#1-verify-your-sending-hostname) shows.\n- **Sending is turned off for the account.** AWS documents turning it back on only with the AWS CLI, naming your region: `aws sesv2 put-account-sending-attributes --sending-enabled --region us-east-1`.\n- **AWS paused the account, or is reviewing it.** AWS does this when the account's bounce or complaint rate runs high. While it reviews, SES still sends, and the line is amber. Once it pauses sending, the line is red. Either way, AWS's email to the AWS account says what it needs: answer it in AWS Support, and check the addresses on your recent sends' records.\n\nThe app needs to read each of these. If its key isn't allowed to, the line says nothing rather than guessing. The policy in [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) allows all of it.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## An address bounced or complained\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\n[Connect Claude](08-connect-claude.md) covers connecting it. Most failures are in your instance's Cloudflare Access setup, and [Allow Claude to connect](../guides/01-allow-claude.md#check-it) lists their fixes. When connecting fails:\n\n- **Claude says it couldn't connect, or the sign-in failed.** 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. If both are right, your instance's Access setup needs the fix.\n- Signing in shows `invalid_target`, or `kestrel login` says your instance \"doesn't offer a sign-in for programs\". Your instance's Access setup needs the fix.\n- `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in, and your instance's Access setup needs the fix. If either names `access_unidentified`, it's the \"Access let you in\" problem below. If `kestrel api` says your sign-in has ended, sign in again as [Connect Claude](08-connect-claude.md#claude-code) describes.\n- `kestrel api` says it couldn't renew your sign-in because its Access team answered 404 (not found). First check which instance it called: when you don't name one, it prints a line naming it before \"the only instance you're signed in to\", which may not be the one you meant. Name yours with `--url`. If it is yours, the Access team domain was most likely renamed or removed since you signed in. Sign in again with `npx kestrel login` and your instance's address, which finds the team it uses now.\n- **\"Access let you in, but didn't say who you are\".** Your login carried neither an email nor a service token. Sign out, then sign in through the policy that lets you in by email. If you have no such policy, your instance's Access setup needs the fix.\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- **A change reads \"via the Kestrel CLI\".** The person it names made it with `kestrel api` outside Claude Code, by hand or through a script, so there's nothing to fix. If Claude Code made it, update Claude Code to v2.1.172 or later: an older one reads this way, and the dashboard can stop saying \"Claude is connected.\"\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## Where to look\n\nWhen a line doesn't settle it, these say the most about what happened:\n\n- The top of the dashboard: what needs someone now, in Kestrel's own words.\n- The send's page, under **Sent**: what happened on that send, when it next retries, and its record of each reader.\n- **Settings \u2192 Email sender**: the state of your Amazon SES account, and your **Send rate**.\n- **Settings \u2192 Notifications**: **Last notification** says whether the last one was delivered, or why not.\n- A subscriber's page, under **Subscribers**: every send they were in, and what happened on each.\n- The app's logs, in your instance's Cloudflare account: [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes them. If someone else set up Kestrel for you, pass this along to them.\n\n## Notifications\n\nWith an address set, Kestrel emails you:\n\n- when a send finishes, with how many emails your provider accepted, and how late it went out if it did\n- when no one's email was accepted for a finished send, since it can't be sent again\n- when email isn't going out and signups are paused: once when it starts, and once more if a post is held after an email that named none\n- when your provider refuses a send or hits a limit, 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 doesn't go out on time\n- when your provider has sent no receipts for six hours after a send, once until a receipt arrives\n\nEach says whether anyone has to act, and how. If the fix needs whoever set up your instance, you can forward it as it stands. Each goes to every address set, links to the page it's about, and is sent once while the problem lasts. A reader's address in your provider's words shows only as its domain, and in full on the send's page. If one address can't be reached, the others still get it, and **Last notification** names the one that failed. 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";
|
|
13299
13416
|
|
|
13300
13417
|
// docs/publishing/08-connect-claude.md
|
|
13301
13418
|
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 **Connect**. Sign in the way you do for the dashboard if asked, and choose **Allow**.\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";
|
|
@@ -13319,7 +13436,7 @@ var README_default2 = "# Self-hosting Kestrel\n\nThese pages cover deploying and
|
|
|
13319
13436
|
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, here for an instance named `newsletter`. [Deploy the app](../get-started/02-deploy.md) fills in its database and hostnames:\n\n```jsonc\n"production": {\n "name": "newsletter-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": "newsletter-production",\n "database_id": "REPLACE_WITH_PRODUCTION_D1_ID",\n "migrations_dir": "migrations"\n }\n ],\n "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "newsletter-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/05-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/05-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`. SES keeps identities, quotas, and the sandbox separately in each region, so set the region where you verified your sending hostname. On an AWS page, go by the region menu at the top right, not the page\'s address. A page for a global service, such as IAM or Billing, has no region to choose, so open SES before you choose. A few newer regions must be [enabled for your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html) first.\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`. With Amazon SES, when no identity covering it is verified for sending in `AWS_REGION`, **Settings \u2192 Email sender** says so in red within about ten minutes, without sending anything.\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 A cap on the send rate, in messages a second. Usually left unset: the app reads your SES account\'s maximum send rate itself, about every ten minutes while nothing is sending, and keeps to the lower of the two when both are known. Without either, it sends at `14`, a common starting rate, not your account\'s. Refused unless it\'s a whole number above zero. **Settings \u2192 Email sender** shows the rate a send keeps to, and where it comes from.\n\n The app sends SES one request a second, the most SES takes, and each request carries one second of the rate, up to 50 recipients. Set this only to send slower than your account allows, or when the app\'s key can\'t read the account (it needs `ses:GetAccount`). 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, such as `you@example.com, publisher@example.com`. Spaces and capital letters don\'t matter. 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. When SES rejects it, because the key ID is unknown, the secret doesn\'t match it, or the AWS account isn\'t signed up for SES, **Settings \u2192 Email sender** says so in red within about ten minutes, without sending anything.\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. **Settings \u2192 Email sender** says so in red within about ten minutes, without sending anything, and does the same when the set\'s sending is turned off, as long as the app\'s key may call `ses:GetConfigurationSet`.\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. `kestrel init` names the Worker, the database, and the bucket after the instance. The instance\'s name is its folder\'s name, made to fit the rule below (`My Newsletter` gives `my-newsletter`, and `newsletter.example.com` gives `newsletter-example-com`), or the name given with `init`\'s `--name`. An instance named `newsletter` deploys as `newsletter-production`, with the database of the same name and the bucket `newsletter-media-production`, and the top level\'s development Worker is `newsletter-dev`. A name takes lowercase letters, digits, and hyphens, starts and ends with a letter or digit, and is at most 46 characters, so every name made from it fits Cloudflare\'s rules. It can\'t be `kestrel`, which instances made by earlier releases use.\n\n Each instance in a Cloudflare account needs a name of its own, since a deploy under another instance\'s Worker name replaces that Worker. An instance made by an earlier release keeps its names, `kestrel-production` and `kestrel-media-production`, and needs no change.\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/07-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, and, for `ses`, by `:refuse=` and a cause SES refuses every email for (`credentials`, `opt_in`, `configuration`, `sender`, `mail_from`, `paused`, `set_paused`, `suspended`, or `unsupported`), as in `ses:refuse=configuration`. `SIMULATE_ACCOUNT` must be off, or, beside `SIMULATE_SENDS=ses`, the SES account state local development stands in for: `sandbox`, `pending`, `denied`, `production`, or `permission`, optionally followed by `:unrouted` or by one account problem, which shows its warning: `:set_missing`, `:set_paused`, `:key_rejected`, `:sending_disabled`, `:shutdown`, `:sender_unverified`, or `:probation`, as in `production:set_missing`. Case doesn\'t matter. A deployed instance ignores both.\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';
|
|
13320
13437
|
|
|
13321
13438
|
// docs/reference/02-sending-domain-dns.md
|
|
13322
|
-
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/05-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/05-ses.md#8-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/05-ses.md#1-verify-your-sending-hostname) 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
|
|
13439
|
+
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/05-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/05-ses.md#8-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/05-ses.md#1-verify-your-sending-hostname) 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 can ask 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, and it only monitors. Inboxes deliver failing mail as usual, so it doesn't stop anyone sending as your domain. It's where to start because it blocks nothing, your own mail included.\n- `rua` is where reports go. It's optional, and the policy works without it, though [Google's DMARC setup page](https://knowledge.workspace.google.com/admin/security/set-up-dmarc) recommends including it. Reports 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/07-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";
|
|
13323
13440
|
|
|
13324
13441
|
// src/docs/index.ts
|
|
13325
13442
|
var PAGES = {
|
|
@@ -13443,6 +13560,13 @@ var TARGETS = new Map([
|
|
|
13443
13560
|
]);
|
|
13444
13561
|
var MD_HREF = /href="([^"#:]+\.md)(?:#([^"]*))?"/g;
|
|
13445
13562
|
var SAME_PAGE_LINK = /href="#([^"/][^"]*)"/g;
|
|
13563
|
+
var SETTINGS_NAME = /<a\b[^>]*>[\s\S]*?<\/a>|<strong>Settings → ([^<→]+?)(?: → [^<]*)?<\/strong>/g;
|
|
13564
|
+
function linkSettingsNames(html) {
|
|
13565
|
+
return html.replace(SETTINGS_NAME, (whole, title) => {
|
|
13566
|
+
const section = title ? settingsSectionTitled(title) : null;
|
|
13567
|
+
return section ? `<a href="#${settingsRoute(section)}">${whole}</a>` : whole;
|
|
13568
|
+
});
|
|
13569
|
+
}
|
|
13446
13570
|
function rewriteDocLinks(html, from, slug) {
|
|
13447
13571
|
return html.replace(MD_HREF, (whole, href, anchor) => {
|
|
13448
13572
|
const target = TARGETS.get(resolvePath(from, href) ?? "");
|
|
@@ -13457,7 +13581,7 @@ function renderFragment(markdown3, from, slug) {
|
|
|
13457
13581
|
maxWidth: 720,
|
|
13458
13582
|
warnings: warnings2
|
|
13459
13583
|
});
|
|
13460
|
-
return rewriteDocLinks(sanitizeEmailHtml(html), from, slug);
|
|
13584
|
+
return linkSettingsNames(rewriteDocLinks(sanitizeEmailHtml(html), from, slug));
|
|
13461
13585
|
}
|
|
13462
13586
|
function renderSets() {
|
|
13463
13587
|
return INDEXES.map((set) => ({
|
|
@@ -13922,14 +14046,16 @@ function publicImage(c, row) {
|
|
|
13922
14046
|
url: `${c.config.mediaPublicBase}/${row.storage_key}`
|
|
13923
14047
|
};
|
|
13924
14048
|
}
|
|
14049
|
+
var NOT_AN_IMAGE = "An image must be a PNG, JPEG, WebP, or GIF.";
|
|
13925
14050
|
async function uploadImage(c) {
|
|
13926
14051
|
const post = await requireDraft(c);
|
|
13927
14052
|
const ct = c.req.headers.get("content-type") ?? "";
|
|
13928
14053
|
let filename;
|
|
13929
|
-
let
|
|
14054
|
+
let declared;
|
|
13930
14055
|
let bytes;
|
|
13931
14056
|
let imageField;
|
|
13932
|
-
|
|
14057
|
+
const isForm = ct.includes("multipart/form-data");
|
|
14058
|
+
if (isForm) {
|
|
13933
14059
|
const form = await readForm(c.req);
|
|
13934
14060
|
const file = form.get("file");
|
|
13935
14061
|
if (!(file instanceof File)) {
|
|
@@ -13940,22 +14066,30 @@ async function uploadImage(c) {
|
|
|
13940
14066
|
filename = cleanName(
|
|
13941
14067
|
typeof override === "string" && override ? override : file.name || "upload"
|
|
13942
14068
|
);
|
|
13943
|
-
|
|
14069
|
+
declared = file.type;
|
|
13944
14070
|
bytes = await file.arrayBuffer();
|
|
13945
14071
|
} else {
|
|
13946
14072
|
filename = cleanName(c.url.searchParams.get("filename") ?? "");
|
|
13947
|
-
|
|
14073
|
+
declared = ct;
|
|
13948
14074
|
bytes = await c.req.arrayBuffer();
|
|
13949
14075
|
}
|
|
13950
14076
|
if (!filename) {
|
|
13951
14077
|
throw badRequest("a filename is required", { field: "filename" });
|
|
13952
14078
|
}
|
|
13953
|
-
if (!RASTER_IMAGE_TYPES.includes(
|
|
13954
|
-
throw badRequest(
|
|
14079
|
+
if (!isForm && !RASTER_IMAGE_TYPES.includes(mediaType(declared))) {
|
|
14080
|
+
throw badRequest(NOT_AN_IMAGE);
|
|
13955
14081
|
}
|
|
13956
14082
|
if (bytes.byteLength === 0) {
|
|
13957
14083
|
throw badRequest("The image file is empty.", imageField);
|
|
13958
14084
|
}
|
|
14085
|
+
const contentType = imageTypeOf(new Uint8Array(bytes));
|
|
14086
|
+
if (!contentType) {
|
|
14087
|
+
throw badRequest(NOT_AN_IMAGE, imageField);
|
|
14088
|
+
}
|
|
14089
|
+
const conflict = imageLabelConflict(contentType, { name: filename, declared });
|
|
14090
|
+
if (conflict) {
|
|
14091
|
+
throw badRequest(conflict, imageField);
|
|
14092
|
+
}
|
|
13959
14093
|
if (bytes.byteLength > MAX_POST_IMAGE_BYTES) {
|
|
13960
14094
|
throw badRequest(
|
|
13961
14095
|
`An image must be ${MAX_POST_IMAGE_BYTES / (1024 * 1024)} MB or smaller.`,
|
|
@@ -14017,6 +14151,7 @@ async function serveMedia(c) {
|
|
|
14017
14151
|
obj.writeHttpMetadata(headers2);
|
|
14018
14152
|
headers2.set("etag", obj.httpEtag);
|
|
14019
14153
|
headers2.set("cache-control", "public, max-age=3600");
|
|
14154
|
+
headers2.set("content-length", String(obj.size));
|
|
14020
14155
|
headers2.set("x-content-type-options", "nosniff");
|
|
14021
14156
|
headers2.set("content-security-policy", "sandbox");
|
|
14022
14157
|
return new Response(obj.body, { headers: headers2 });
|
|
@@ -14743,24 +14878,24 @@ async function get2(c) {
|
|
|
14743
14878
|
|
|
14744
14879
|
// src/emails/system.ts
|
|
14745
14880
|
var FONT2 = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
|
|
14746
|
-
var
|
|
14881
|
+
var HEAD_STYLE = `<style>@media (prefers-color-scheme: dark) {
|
|
14747
14882
|
.k-sys-bg { background:#18181b !important; }
|
|
14748
|
-
.k-sys-card { background:#232327 !important; }
|
|
14749
14883
|
.k-sys-body, .k-sys-name { color:#ededed !important; }
|
|
14750
14884
|
.k-sys-tagline, .k-sys-muted { color:#a1a1aa !important; }
|
|
14751
14885
|
.k-sys-rule { border-color:#2e2e33 !important; }
|
|
14752
14886
|
.k-sys-btn { background:#e5e7eb !important; color:#18181b !important; }
|
|
14753
|
-
}
|
|
14887
|
+
}
|
|
14888
|
+
@media (max-width:560px) { .k-sys-body { padding-left:12px !important; padding-right:12px !important; } }</style>`;
|
|
14754
14889
|
function systemEmailLayout(subject, innerHtml) {
|
|
14755
14890
|
return `<!doctype html>
|
|
14756
14891
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
14757
14892
|
<meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark">
|
|
14758
14893
|
<title>${escapeHtml(subject)}</title>
|
|
14759
|
-
${
|
|
14760
|
-
<body class="k-sys-bg" style="margin:0;padding:0;background:#
|
|
14761
|
-
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
|
14762
|
-
<table role="presentation" width="
|
|
14763
|
-
<tr><td class="k-sys-body" style="padding:32px;font-family:${FONT2};font-size:16px;line-height:1.6;color:#18181b;">
|
|
14894
|
+
${HEAD_STYLE}</head>
|
|
14895
|
+
<body class="k-sys-bg" style="margin:0;padding:0;background:#ffffff;">
|
|
14896
|
+
<table class="k-sys-bg" role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff;"><tr><td align="center" style="padding:8px 12px 48px;">
|
|
14897
|
+
<table class="k-sys-bg" role="presentation" width="664" cellpadding="0" cellspacing="0" style="max-width:664px;width:100%;background:#ffffff;">
|
|
14898
|
+
<tr><td class="k-sys-body" style="padding:24px 32px 32px;font-family:${FONT2};font-size:16px;line-height:1.6;color:#18181b;word-break:break-word;">
|
|
14764
14899
|
${innerHtml}
|
|
14765
14900
|
</td></tr>
|
|
14766
14901
|
</table>
|
|
@@ -16043,7 +16178,10 @@ function sampleNotification(config) {
|
|
|
16043
16178
|
subject: "Test: notifications from Kestrel",
|
|
16044
16179
|
happened: "If you\u2019re reading this, notifications reach this address. Nothing else was sent.",
|
|
16045
16180
|
more: "Kestrel writes here when a post finishes sending, and right away if something needs attention.",
|
|
16046
|
-
link: {
|
|
16181
|
+
link: {
|
|
16182
|
+
label: "Open Settings",
|
|
16183
|
+
url: dashboardLink(config.appOrigin, settingsRoute("notifications"))
|
|
16184
|
+
}
|
|
16047
16185
|
});
|
|
16048
16186
|
}
|
|
16049
16187
|
function composeSandboxNotification(config) {
|
|
@@ -16378,16 +16516,20 @@ async function uploadLogo(c) {
|
|
|
16378
16516
|
if (!(file instanceof File)) {
|
|
16379
16517
|
throw fieldError("file", "missing 'file' field");
|
|
16380
16518
|
}
|
|
16381
|
-
const
|
|
16382
|
-
if (
|
|
16519
|
+
const bytes = await file.arrayBuffer();
|
|
16520
|
+
if (bytes.byteLength === 0) {
|
|
16521
|
+
throw fieldError("file", "The logo file is empty.");
|
|
16522
|
+
}
|
|
16523
|
+
const type = imageTypeOf(new Uint8Array(bytes));
|
|
16524
|
+
if (!type) {
|
|
16383
16525
|
throw fieldError(
|
|
16384
16526
|
"file",
|
|
16385
16527
|
"The logo must be a PNG, JPEG, WebP, or GIF. Most email apps don\u2019t show SVG."
|
|
16386
16528
|
);
|
|
16387
16529
|
}
|
|
16388
|
-
const
|
|
16389
|
-
if (
|
|
16390
|
-
throw fieldError("file",
|
|
16530
|
+
const conflict = imageLabelConflict(type, { name: file.name, declared: file.type });
|
|
16531
|
+
if (conflict) {
|
|
16532
|
+
throw fieldError("file", conflict);
|
|
16391
16533
|
}
|
|
16392
16534
|
if (bytes.byteLength > MAX_LOGO_BYTES) {
|
|
16393
16535
|
throw fieldError("file", `The logo must be ${MAX_LOGO_BYTES / 1024} KB or smaller.`);
|
|
@@ -17317,7 +17459,7 @@ function createRouter({
|
|
|
17317
17459
|
{
|
|
17318
17460
|
name: "principal",
|
|
17319
17461
|
type: "{ kind, email, token_id, client }",
|
|
17320
|
-
description: "`kind` `human`, signed in as `email`, or `service`, a service token, the credential for scripts and other automation, with no `email` and its Client ID as `token_id` when the token names one. `client` is the client a person's request came through, as it named itself in `Kestrel-Client` (`editor`, `claude-code`), or null when it named none; a service token's is always null, since the token already names the program."
|
|
17462
|
+
description: "`kind` `human`, signed in as `email`, or `service`, a service token, the credential for scripts and other automation, with no `email` and its Client ID as `token_id` when the token names one. `client` is the client a person's request came through, as it named itself in `Kestrel-Client` (`editor`, `claude-code`, `kestrel-cli`, `claude`), or null when it named none; a service token's is always null, since the token already names the program."
|
|
17321
17463
|
},
|
|
17322
17464
|
{
|
|
17323
17465
|
name: "auth.mode",
|
|
@@ -17651,7 +17793,7 @@ function createRouter({
|
|
|
17651
17793
|
name: "file",
|
|
17652
17794
|
type: "file",
|
|
17653
17795
|
required: true,
|
|
17654
|
-
description: `The logo: a PNG, JPEG, WebP, or GIF, at most ${MAX_LOGO_BYTES / 1024} KB, square and at least 256 \xD7 256 px, since every surface shows it as a square and an email stretches one that isn't.`
|
|
17796
|
+
description: `The logo: a PNG, JPEG, WebP, or GIF, at most ${MAX_LOGO_BYTES / 1024} KB, square and at least 256 \xD7 256 px, since every surface shows it as a square and an email stretches one that isn't. Its format is read from its bytes, and a name or declared type that names another of these formats is refused.`
|
|
17655
17797
|
}
|
|
17656
17798
|
],
|
|
17657
17799
|
errors: remakeRefusals,
|
|
@@ -17988,7 +18130,7 @@ function createRouter({
|
|
|
17988
18130
|
name: "file",
|
|
17989
18131
|
type: "file",
|
|
17990
18132
|
required: true,
|
|
17991
|
-
description: `The image: a PNG, JPEG, WebP, or GIF, at most ${MAX_POST_IMAGE_BYTES / (1024 * 1024)} MB.`
|
|
18133
|
+
description: `The image: a PNG, JPEG, WebP, or GIF, at most ${MAX_POST_IMAGE_BYTES / (1024 * 1024)} MB. Its format is read from its bytes, and a name or declared type that names another of these formats is refused.`
|
|
17992
18134
|
},
|
|
17993
18135
|
{
|
|
17994
18136
|
name: "filename",
|
|
@@ -19450,6 +19592,12 @@ async function schemaGap(db, devMode) {
|
|
|
19450
19592
|
};
|
|
19451
19593
|
}
|
|
19452
19594
|
|
|
19595
|
+
// src/lib/d1_stall.ts
|
|
19596
|
+
var STALL = "D1_ERROR: internal error; reference = ";
|
|
19597
|
+
function isD1Stall(err) {
|
|
19598
|
+
return errorText(err).startsWith(STALL);
|
|
19599
|
+
}
|
|
19600
|
+
|
|
19453
19601
|
// src/send/budget.ts
|
|
19454
19602
|
var D1_QUERY_LIMIT = 1e3;
|
|
19455
19603
|
var Budget = class {
|
|
@@ -20047,12 +20195,22 @@ async function sweep(env) {
|
|
|
20047
20195
|
await runTick(env, now, tick);
|
|
20048
20196
|
ok = true;
|
|
20049
20197
|
} catch (err) {
|
|
20050
|
-
|
|
20198
|
+
if (loggedStall("sweep", err)) {
|
|
20199
|
+
return;
|
|
20200
|
+
}
|
|
20201
|
+
log.error("sweep.error", errorFields(err));
|
|
20051
20202
|
throw err;
|
|
20052
20203
|
} finally {
|
|
20053
20204
|
log.info("sweep.tick", { ...tick, ok, durationMs: Date.now() - now });
|
|
20054
20205
|
}
|
|
20055
20206
|
}
|
|
20207
|
+
function loggedStall(during, err) {
|
|
20208
|
+
if (!isD1Stall(err)) {
|
|
20209
|
+
return false;
|
|
20210
|
+
}
|
|
20211
|
+
log.warn("d1.unavailable", { during, ...errorFields(err) });
|
|
20212
|
+
return true;
|
|
20213
|
+
}
|
|
20056
20214
|
async function runTick(env, now, tick) {
|
|
20057
20215
|
const config = getConfig(env);
|
|
20058
20216
|
const handled = /* @__PURE__ */ new Set();
|
|
@@ -20067,7 +20225,7 @@ async function runTick(env, now, tick) {
|
|
|
20067
20225
|
rate ??= config.provider === "ses" ? accountForPacing(db, config).then(
|
|
20068
20226
|
(a) => sesSendRate(config, a).value,
|
|
20069
20227
|
(err) => {
|
|
20070
|
-
log.warn("provider.pacing_unread",
|
|
20228
|
+
log.warn("provider.pacing_unread", errorFields(err));
|
|
20071
20229
|
return config.sesMaxSendRate;
|
|
20072
20230
|
}
|
|
20073
20231
|
) : Promise.resolve(void 0);
|
|
@@ -20105,7 +20263,9 @@ async function runTick(env, now, tick) {
|
|
|
20105
20263
|
try {
|
|
20106
20264
|
await applyHeldReceipts(db, now, Math.min(heldRoom, HELD_RECEIPTS_PER_TICK));
|
|
20107
20265
|
} catch (err) {
|
|
20108
|
-
|
|
20266
|
+
if (!loggedStall("receipts", err)) {
|
|
20267
|
+
log.error("receipt.release_error", errorFields(err));
|
|
20268
|
+
}
|
|
20109
20269
|
}
|
|
20110
20270
|
}
|
|
20111
20271
|
for (const s of await stuckSends(env.DB, now - STUCK_THRESHOLD_MS)) {
|
|
@@ -20128,7 +20288,9 @@ async function runTick(env, now, tick) {
|
|
|
20128
20288
|
try {
|
|
20129
20289
|
await notifyPublisher(env, rest, contacts);
|
|
20130
20290
|
} catch (err) {
|
|
20131
|
-
|
|
20291
|
+
if (!loggedStall("notify", err)) {
|
|
20292
|
+
log.error("notify.error", errorFields(err));
|
|
20293
|
+
}
|
|
20132
20294
|
}
|
|
20133
20295
|
if (rest.affords(1)) {
|
|
20134
20296
|
await contacts.record(metered(env.DB, rest), config);
|
|
@@ -20137,7 +20299,9 @@ async function runTick(env, now, tick) {
|
|
|
20137
20299
|
try {
|
|
20138
20300
|
await refreshProviderAccount(env, config, rest, now);
|
|
20139
20301
|
} catch (err) {
|
|
20140
|
-
|
|
20302
|
+
if (!loggedStall("account", err)) {
|
|
20303
|
+
log.error("provider.account_error", errorFields(err));
|
|
20304
|
+
}
|
|
20141
20305
|
}
|
|
20142
20306
|
}
|
|
20143
20307
|
await drainSimulatedWebhooks(env, config);
|
|
@@ -20147,7 +20311,7 @@ async function safeRun(env, sendId, budget, window, sendRate, contacts) {
|
|
|
20147
20311
|
const run = await runSend(env, sendId, budget, window, sendRate);
|
|
20148
20312
|
contacts.add("list", runOutcome(run.accepted, run.refusal), run.answeredAt ?? Date.now());
|
|
20149
20313
|
} catch (err) {
|
|
20150
|
-
log.error("send.error", { sendId,
|
|
20314
|
+
log.error("send.error", { sendId, ...errorFields(err) });
|
|
20151
20315
|
}
|
|
20152
20316
|
}
|
|
20153
20317
|
|