@kurtbruns/kestrel 1.19.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 +69 -1
- package/bin/kestrel.mjs +1 -1
- package/cli/seed.mjs +2 -8
- package/dist/public/dashboard/app.js +307 -306
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +90 -51
- package/dist/worker/index.js +890 -559
- package/docs/get-started/02-deploy.md +3 -3
- package/docs/get-started/03-access.md +8 -4
- package/docs/get-started/04-aws-account.md +2 -0
- package/docs/get-started/05-ses.md +4 -2
- package/docs/get-started/07-verify.md +8 -6
- package/docs/get-started/08-go-live.md +12 -18
- package/docs/guides/01-allow-claude.md +7 -2
- 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 +2 -2
- package/docs/publishing/06-troubleshooting.md +52 -26
- package/docs/publishing/11-email-template.md +1 -1
- package/docs/publishing/README.md +1 -1
- package/docs/reference/02-sending-domain-dns.md +3 -3
- package/migrations/0021_notifications_outage_receipts.sql +46 -0
- 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) {
|
|
@@ -16,8 +16,8 @@ var FREE_PLAN_LIST = 1e3;
|
|
|
16
16
|
var DAILY_WRITES_SEND_RECIPIENTS = Math.floor(
|
|
17
17
|
D1_FREE_DAILY_WRITES * 7 / 10 / SEND_WRITES_PER_RECIPIENT
|
|
18
18
|
);
|
|
19
|
-
function sendSpendsDailyWrites(
|
|
20
|
-
return
|
|
19
|
+
function sendSpendsDailyWrites(recipients2) {
|
|
20
|
+
return recipients2 > DAILY_WRITES_SEND_RECIPIENTS;
|
|
21
21
|
}
|
|
22
22
|
var FREE_PLAN_LINE = `Workers Free suits a list of up to about ${FREE_PLAN_LIST.toLocaleString("en-US")} subscribers. Past about ${DAILY_WRITES_SEND_RECIPIENTS.toLocaleString("en-US")}, one send can use up the day\u2019s database writes and stop this newsletter until midnight UTC.`;
|
|
23
23
|
var FREE_PLAN_FIX = "Move to Workers Paid, or, if the account is on it already, set SUBREQUEST_BUDGET to 10000.";
|
|
@@ -30,8 +30,8 @@ function freePlanListNotice(confirmed) {
|
|
|
30
30
|
function freePlanImportAdvice(confirmedAfter) {
|
|
31
31
|
return `On the Workers Free plan\u2019s limits, this import leaves ${confirmedAfter.toLocaleString("en-US")} confirmed subscribers. ${FREE_PLAN_LINE} ${FREE_PLAN_FIX}`;
|
|
32
32
|
}
|
|
33
|
-
function dailyWritesSendWarning(
|
|
34
|
-
return `On the Workers Free plan\u2019s limits, a send to ${
|
|
33
|
+
function dailyWritesSendWarning(recipients2) {
|
|
34
|
+
return `On the Workers Free plan\u2019s limits, a send to ${recipients2.toLocaleString("en-US")} recipients may use up the day\u2019s database writes and stop this newsletter until midnight UTC, the send included. Workers Free suits a list of up to about ${FREE_PLAN_LIST.toLocaleString("en-US")} subscribers. ${FREE_PLAN_FIX}`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
// shared/email.ts
|
|
@@ -806,40 +806,41 @@ var NOT_CONNECTED_ADVICE = {
|
|
|
806
806
|
guide: " (the setup guide walks through it)",
|
|
807
807
|
after: ", then deploy."
|
|
808
808
|
};
|
|
809
|
+
var PASS_THIS_ALONG = "If someone else set up Kestrel for you, pass this along to them.";
|
|
809
810
|
function refusalAdvice(cause) {
|
|
810
811
|
switch (cause) {
|
|
811
812
|
case "credentials":
|
|
812
|
-
return
|
|
813
|
+
return `Replace the SES access key in the instance\u2019s secrets, or allow the key ses:SendEmail, ses:SendBulkEmail, ses:SendTemplatedEmail, and ses:SendBulkTemplatedEmail in its IAM policy. ${PASS_THIS_ALONG}`;
|
|
813
814
|
case "opt_in":
|
|
814
|
-
return
|
|
815
|
+
return `The AWS account isn\u2019t signed up for SES: enable SES for the account in the region AWS_REGION names, or settle the account\u2019s activation or billing with AWS. ${PASS_THIS_ALONG}`;
|
|
815
816
|
case "configuration":
|
|
816
|
-
return
|
|
817
|
+
return `Check that the configuration set Kestrel sends with exists in the SES region: the one SES_CONFIGURATION_SET names, or the sending identity\u2019s default if that secret isn\u2019t set. Or create the set in the SES console. ${PASS_THIS_ALONG}`;
|
|
817
818
|
case "sending_pool":
|
|
818
|
-
return
|
|
819
|
+
return `The configuration set SES_CONFIGURATION_SET names sends through a dedicated IP pool SES can\u2019t find: pick an existing pool in the set\u2019s sending options, or remove the pool, in the SES console. ${PASS_THIS_ALONG}`;
|
|
819
820
|
case "unsupported":
|
|
820
|
-
return
|
|
821
|
+
return `SES read the request as naming something Kestrel never sends, such as a stored template: check that AWS_REGION names a region that offers SES, and update Kestrel. ${PASS_THIS_ALONG}`;
|
|
821
822
|
case "sender":
|
|
822
|
-
return
|
|
823
|
+
return `Verify the From address, or its domain, in SES, in the region AWS_REGION names. ${PASS_THIS_ALONG}`;
|
|
823
824
|
case "mail_from":
|
|
824
|
-
return
|
|
825
|
+
return `SES says the sending domain isn\u2019t verified, or it can\u2019t read the sender\u2019s custom MAIL FROM domain\u2019s MX record: check that the sending domain is verified in the region AWS_REGION names, and check the MAIL FROM domain\u2019s MX record in DNS or set the identity\u2019s Behavior on MX failure to use the default MAIL FROM domain, in the SES console. ${PASS_THIS_ALONG}`;
|
|
825
826
|
case "sandbox":
|
|
826
|
-
return
|
|
827
|
+
return `The SES account is still in the sandbox, which mails only addresses verified in SES: request production access on the SES console\u2019s account dashboard. To test before then, verify the reader\u2019s address in SES. ${PASS_THIS_ALONG}`;
|
|
827
828
|
case "quota":
|
|
828
|
-
return
|
|
829
|
+
return `SES\u2019s 24-hour quota frees up on its own, so nothing is needed unless you want to send more next time: ask AWS to raise the quota in the Service Quotas console. In the sandbox (200 a day), request production access. ${PASS_THIS_ALONG}`;
|
|
829
830
|
case "suspended":
|
|
830
|
-
return
|
|
831
|
+
return `AWS has shut down the account\u2019s sending, and only AWS can lift it: open a case with AWS Support from the SES console. ${PASS_THIS_ALONG}`;
|
|
831
832
|
case "paused":
|
|
832
|
-
return
|
|
833
|
+
return `Sending is paused for the SES account. If AWS paused it under review, its email to the AWS account\u2019s address says what to fix; if it was turned off by hand, turn it back on (aws sesv2 put-account-sending-attributes --sending-enabled). ${PASS_THIS_ALONG}`;
|
|
833
834
|
case "set_paused":
|
|
834
|
-
return
|
|
835
|
+
return `Sending is turned off on the configuration set SES_CONFIGURATION_SET names, by hand or by an alarm set up on it: turn it back on (aws sesv2 put-configuration-set-sending-options --sending-enabled, naming the set). ${PASS_THIS_ALONG}`;
|
|
835
836
|
case "content":
|
|
836
|
-
return
|
|
837
|
+
return `This email can\u2019t change once its send has started, so ask AWS Support what SES refuses in it, quoting SES\u2019s words. ${PASS_THIS_ALONG}`;
|
|
837
838
|
case "invalid_request":
|
|
838
839
|
return "SES refused a batch of this send as invalid. If its words name an address, remove that subscriber: the send skips anyone no longer on the list, and resumes at its next retry.";
|
|
839
840
|
case "not_connected":
|
|
840
841
|
return `${NOT_CONNECTED_ADVICE.step}${NOT_CONNECTED_ADVICE.guide}${NOT_CONNECTED_ADVICE.after}`;
|
|
841
842
|
default:
|
|
842
|
-
return
|
|
843
|
+
return `Fix the account with the provider. ${PASS_THIS_ALONG}`;
|
|
843
844
|
}
|
|
844
845
|
}
|
|
845
846
|
var SELF_THROTTLE_HALTS = 3;
|
|
@@ -863,7 +864,7 @@ function refusedMessage(cause, error2) {
|
|
|
863
864
|
}
|
|
864
865
|
function unavailableMessage(send) {
|
|
865
866
|
if (isSelfThrottled(send)) {
|
|
866
|
-
return `The provider keeps refusing for its sending rate: ${sentence(send.halt_error)} A burst clears within a second, and this has refused the first request of ${send.halt_retries} retries in a row, minutes apart, so the
|
|
867
|
+
return `The provider keeps refusing for its sending rate: ${sentence(send.halt_error)} A burst clears within a second, and this has refused the first request of ${send.halt_retries} retries in a row, minutes apart, so the instance is asking for more a second than the account allows. Set SES_MAX_SEND_RATE to the maximum send rate on the SES console\u2019s account dashboard, or remove it and let the app read that rate by allowing its AWS key ses:GetAccount, and deploy. ${PASS_THIS_ALONG} The send retries on its own meanwhile, and no one has been marked unsent because of this.`;
|
|
867
868
|
}
|
|
868
869
|
return `The provider is unavailable: ${sentence(send.halt_error)} The send retries on its own, spaced out the longer this lasts, and no one has been marked unsent because of this.`;
|
|
869
870
|
}
|
|
@@ -888,6 +889,24 @@ var BOUNCE_SPIKE_RECENT_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
|
888
889
|
var NO_RECEIPTS_AFTER_MS = 60 * 60 * 1e3;
|
|
889
890
|
|
|
890
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
|
+
}
|
|
891
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.";
|
|
892
911
|
var MAX_NOTIFY_ADDRESSES = 5;
|
|
893
912
|
var SIMULATION_PROFILES = ["generic", "ses"];
|
|
@@ -974,10 +993,10 @@ var SUBSCRIBER_ACTIONS = {
|
|
|
974
993
|
|
|
975
994
|
// src/generated/version.ts
|
|
976
995
|
var BUILD_INFO = {
|
|
977
|
-
version: "1.
|
|
978
|
-
sha: "
|
|
979
|
-
tag: "v1.
|
|
980
|
-
buildTime: "2026-11-
|
|
996
|
+
version: "1.21.0",
|
|
997
|
+
sha: "fd376da",
|
|
998
|
+
tag: "v1.21.0",
|
|
999
|
+
buildTime: "2026-11-06T22:01:54.830Z",
|
|
981
1000
|
repoUrl: "https://github.com/kurtbruns/kestrel"
|
|
982
1001
|
};
|
|
983
1002
|
|
|
@@ -1217,6 +1236,9 @@ function whoActed(by) {
|
|
|
1217
1236
|
function errorText(err) {
|
|
1218
1237
|
return String(err?.message ?? err);
|
|
1219
1238
|
}
|
|
1239
|
+
function errorFields(err) {
|
|
1240
|
+
return { error: errorText(err), stack: err instanceof Error ? err.stack : void 0 };
|
|
1241
|
+
}
|
|
1220
1242
|
|
|
1221
1243
|
// src/lib/errors.ts
|
|
1222
1244
|
var HttpError = class extends Error {
|
|
@@ -1247,8 +1269,7 @@ function json(data2, status = 200, headers2) {
|
|
|
1247
1269
|
function logUncaught(err) {
|
|
1248
1270
|
log.error("request.error", {
|
|
1249
1271
|
name: err instanceof Error ? err.name : typeof err,
|
|
1250
|
-
|
|
1251
|
-
stack: err instanceof Error ? err.stack : void 0
|
|
1272
|
+
...errorFields(err)
|
|
1252
1273
|
});
|
|
1253
1274
|
}
|
|
1254
1275
|
function toErrorResponse(err) {
|
|
@@ -1511,7 +1532,7 @@ function sendWhere(filter) {
|
|
|
1511
1532
|
}
|
|
1512
1533
|
async function listSendsPage(db, filter, page) {
|
|
1513
1534
|
const { clause, binds } = sendWhere(filter);
|
|
1514
|
-
const [seq,
|
|
1535
|
+
const [seq, count2, list5] = await db.batch([
|
|
1515
1536
|
db.prepare(`SELECT ${CURRENT_REV} AS seq`),
|
|
1516
1537
|
db.prepare(`SELECT COUNT(*) AS n FROM sends ${clause}`).bind(...binds),
|
|
1517
1538
|
db.prepare(
|
|
@@ -1522,7 +1543,7 @@ async function listSendsPage(db, filter, page) {
|
|
|
1522
1543
|
]);
|
|
1523
1544
|
return {
|
|
1524
1545
|
rows: list5?.results ?? [],
|
|
1525
|
-
total:
|
|
1546
|
+
total: count2?.results[0]?.n ?? 0,
|
|
1526
1547
|
seq: unwrap(seq?.results[0]?.seq, "the send sequence")
|
|
1527
1548
|
};
|
|
1528
1549
|
}
|
|
@@ -2366,185 +2387,6 @@ function readOutcomeCounts(json2) {
|
|
|
2366
2387
|
);
|
|
2367
2388
|
}
|
|
2368
2389
|
|
|
2369
|
-
// src/lib/time.ts
|
|
2370
|
-
var LEASE_TTL_MS = 5 * 60 * 1e3;
|
|
2371
|
-
var MISSED_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
2372
|
-
var HALT_BACKOFF_MS = {
|
|
2373
|
-
unavailable: [1, 2, 5, 15, 30, 60].map((m) => m * 60 * 1e3),
|
|
2374
|
-
account: [5, 15, 30, 60].map((m) => m * 60 * 1e3)
|
|
2375
|
-
};
|
|
2376
|
-
var HALT_RETRY_SLACK_MS = 30 * 1e3;
|
|
2377
|
-
var NOTIFY_HORIZON_MS = 24 * 60 * 60 * 1e3;
|
|
2378
|
-
var MAX_NOTIFY_ATTEMPTS = 5;
|
|
2379
|
-
var MAX_DELIVERY_ATTEMPTS = 5;
|
|
2380
|
-
var CONFIRM_COOLDOWN_MS = 15 * 60 * 1e3;
|
|
2381
|
-
var CONFIRM_LINK_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
2382
|
-
|
|
2383
|
-
// src/db/notifications.ts
|
|
2384
|
-
function importNotice(db, importId, now) {
|
|
2385
|
-
return db.prepare(
|
|
2386
|
-
"INSERT INTO notifications (send_id, import_id, kind, episode, created_at, updated_at) VALUES (NULL, ?, 'import', 0, ?, ?)"
|
|
2387
|
-
).bind(importId, now, now);
|
|
2388
|
-
}
|
|
2389
|
-
function utcDayStart(now) {
|
|
2390
|
-
return Math.floor(now / 864e5) * 864e5;
|
|
2391
|
-
}
|
|
2392
|
-
async function sandboxNotice(db, now) {
|
|
2393
|
-
await accountNotice(db, "sandbox", now).run();
|
|
2394
|
-
}
|
|
2395
|
-
function accountNotice(db, kind, now) {
|
|
2396
|
-
return db.prepare(
|
|
2397
|
-
"INSERT OR IGNORE INTO notifications (send_id, import_id, kind, episode, created_at, updated_at) VALUES (NULL, NULL, ?, ?, ?, ?)"
|
|
2398
|
-
).bind(kind, utcDayStart(now), now, now);
|
|
2399
|
-
}
|
|
2400
|
-
async function recordNotifications(db, now) {
|
|
2401
|
-
await db.prepare(
|
|
2402
|
-
`INSERT OR IGNORE INTO notifications (send_id, kind, episode, created_at, updated_at)
|
|
2403
|
-
SELECT id, 'finished', 0, ?1, ?1 FROM sends
|
|
2404
|
-
WHERE status = 'sent' AND completed_at >= ?2
|
|
2405
|
-
UNION ALL
|
|
2406
|
-
SELECT id, 'refused', halted_at, ?1, ?1 FROM sends s
|
|
2407
|
-
WHERE status = 'sending' AND halt_reason = 'account' AND halted_at IS NOT NULL
|
|
2408
|
-
AND NOT (${WEDGED_SEND})
|
|
2409
|
-
AND NOT EXISTS (SELECT 1 FROM sends o
|
|
2410
|
-
WHERE o.status = 'sending' AND o.halt_reason = 'account'
|
|
2411
|
-
AND o.halted_at = s.halted_at AND o.id < s.id
|
|
2412
|
-
AND NOT ${WEDGED_SEND})
|
|
2413
|
-
AND NOT EXISTS (SELECT 1 FROM notifications r
|
|
2414
|
-
WHERE r.kind = 'refused' AND r.episode = s.halted_at
|
|
2415
|
-
AND r.send_id <> s.id AND r.status <> 'cleared')
|
|
2416
|
-
UNION ALL
|
|
2417
|
-
SELECT id, 'missed', 0, ?1, ?1 FROM sends
|
|
2418
|
-
WHERE (status = 'scheduled' AND fire_at < ?1 - ?3)
|
|
2419
|
-
OR (status IN ('sending', 'sent') AND started_at >= ?2 AND started_at - fire_at > ?3)
|
|
2420
|
-
UNION ALL
|
|
2421
|
-
SELECT id, 'wedged', 0, ?1, ?1 FROM sends WHERE ${WEDGED_SEND}
|
|
2422
|
-
UNION ALL
|
|
2423
|
-
SELECT id, 'stuck', ${SEND_IDLE_SINCE}, ?1, ?1 FROM sends
|
|
2424
|
-
WHERE ${stuckSendSql("?1 - ?4")}`
|
|
2425
|
-
).bind(now, now - NOTIFY_HORIZON_MS, MISSED_THRESHOLD_MS, STUCK_THRESHOLD_MS).run();
|
|
2426
|
-
}
|
|
2427
|
-
async function dueNotifications(db, limit) {
|
|
2428
|
-
const { results } = await db.prepare(
|
|
2429
|
-
`SELECT n.send_id, n.import_id, n.kind, n.episode, n.attempts, n.sent_to,
|
|
2430
|
-
s.subject, s.status AS send_status, s.fire_at, s.started_at, s.completed_at,
|
|
2431
|
-
s.locked_until, s.halt_reason, s.halted_at, s.halt_retry_at, s.progress_at, s.halt_cause, s.halt_error, s.c_pending, s.c_in_flight, s.c_accepted,
|
|
2432
|
-
s.c_delivered, s.c_bounced, s.c_complained, s.c_skipped, s.c_unsent,
|
|
2433
|
-
i.file_name, i.created_at, i.attestation_version, i.counts, i.suppressions_added
|
|
2434
|
-
FROM notifications n
|
|
2435
|
-
LEFT JOIN sends s ON s.id = n.send_id
|
|
2436
|
-
LEFT JOIN imports i ON i.id = n.import_id
|
|
2437
|
-
WHERE n.status = 'pending' AND (s.id IS NOT NULL OR i.id IS NOT NULL OR n.kind IN ('sandbox', 'not_sending'))
|
|
2438
|
-
ORDER BY n.created_at ASC, n.send_id ASC, n.kind ASC
|
|
2439
|
-
LIMIT ?`
|
|
2440
|
-
).bind(limit).all();
|
|
2441
|
-
return results.map((r) => {
|
|
2442
|
-
const sentTo = readSentTo(r.sent_to);
|
|
2443
|
-
if (r.kind === "sandbox" || r.kind === "not_sending") {
|
|
2444
|
-
return {
|
|
2445
|
-
send_id: null,
|
|
2446
|
-
import_id: null,
|
|
2447
|
-
kind: r.kind,
|
|
2448
|
-
episode: r.episode,
|
|
2449
|
-
attempts: r.attempts,
|
|
2450
|
-
sent_to: sentTo
|
|
2451
|
-
};
|
|
2452
|
-
}
|
|
2453
|
-
if (r.kind === "import") {
|
|
2454
|
-
return {
|
|
2455
|
-
send_id: null,
|
|
2456
|
-
import_id: r.import_id,
|
|
2457
|
-
kind: "import",
|
|
2458
|
-
episode: 0,
|
|
2459
|
-
attempts: r.attempts,
|
|
2460
|
-
sent_to: sentTo,
|
|
2461
|
-
file_name: r.file_name,
|
|
2462
|
-
created_at: r.created_at,
|
|
2463
|
-
attestation_version: r.attestation_version,
|
|
2464
|
-
counts: readOutcomeCounts(r.counts),
|
|
2465
|
-
suppressions_added: r.suppressions_added
|
|
2466
|
-
};
|
|
2467
|
-
}
|
|
2468
|
-
return { ...r, sent_to: sentTo };
|
|
2469
|
-
});
|
|
2470
|
-
}
|
|
2471
|
-
function readSentTo(raw) {
|
|
2472
|
-
const list5 = JSON.parse(raw);
|
|
2473
|
-
return Array.isArray(list5) ? list5.filter((e) => typeof e === "string") : [];
|
|
2474
|
-
}
|
|
2475
|
-
var IN_KEYS = `EXISTS (SELECT 1 FROM json_each(?) k
|
|
2476
|
-
WHERE json_extract(k.value, '$[0]') IS notifications.send_id
|
|
2477
|
-
AND json_extract(k.value, '$[3]') IS notifications.import_id
|
|
2478
|
-
AND json_extract(k.value, '$[1]') = notifications.kind
|
|
2479
|
-
AND json_extract(k.value, '$[2]') = notifications.episode)`;
|
|
2480
|
-
async function claimNotifications(db, keys, now) {
|
|
2481
|
-
await db.prepare(
|
|
2482
|
-
`UPDATE notifications SET attempts = attempts + 1, updated_at = ?
|
|
2483
|
-
WHERE status = 'pending' AND ${IN_KEYS}`
|
|
2484
|
-
).bind(now, JSON.stringify(keys.map((k) => [k.send_id, k.kind, k.episode, k.import_id ?? null]))).run();
|
|
2485
|
-
}
|
|
2486
|
-
async function recordNotificationOutcome(db, key, outcome, now) {
|
|
2487
|
-
if (outcome.status === "deferred") {
|
|
2488
|
-
await db.prepare(
|
|
2489
|
-
`UPDATE notifications SET sent_to = ?, attempts = MAX(attempts - 1, 0), updated_at = ?
|
|
2490
|
-
WHERE send_id IS ? AND import_id IS ? AND kind = ? AND episode = ?`
|
|
2491
|
-
).bind(
|
|
2492
|
-
JSON.stringify(outcome.sentTo),
|
|
2493
|
-
now,
|
|
2494
|
-
key.send_id,
|
|
2495
|
-
key.import_id ?? null,
|
|
2496
|
-
key.kind,
|
|
2497
|
-
key.episode
|
|
2498
|
-
).run();
|
|
2499
|
-
return;
|
|
2500
|
-
}
|
|
2501
|
-
const status = outcome.status === "unsent" ? outcome.giveUp ? "failed" : "pending" : outcome.status;
|
|
2502
|
-
const error2 = outcome.status === "unsent" ? outcome.error : null;
|
|
2503
|
-
const sentTo = outcome.status === "cleared" ? null : JSON.stringify(outcome.sentTo);
|
|
2504
|
-
await db.prepare(
|
|
2505
|
-
`UPDATE notifications SET status = ?, error = ?, sent_to = COALESCE(?, sent_to), updated_at = ?
|
|
2506
|
-
WHERE send_id IS ? AND import_id IS ? AND kind = ? AND episode = ?`
|
|
2507
|
-
).bind(status, error2, sentTo, now, key.send_id, key.import_id ?? null, key.kind, key.episode).run();
|
|
2508
|
-
}
|
|
2509
|
-
async function recordNotificationTest(db, error2, now) {
|
|
2510
|
-
await db.batch([
|
|
2511
|
-
db.prepare("DELETE FROM notifications WHERE kind = 'test'"),
|
|
2512
|
-
db.prepare(
|
|
2513
|
-
`INSERT INTO notifications (send_id, kind, episode, status, attempts, error, created_at, updated_at)
|
|
2514
|
-
VALUES (NULL, 'test', 0, ?, 1, ?, ?, ?)`
|
|
2515
|
-
).bind(error2 === null ? "sent" : "failed", error2, now, now)
|
|
2516
|
-
]);
|
|
2517
|
-
}
|
|
2518
|
-
async function closeUnaddressed(db, now) {
|
|
2519
|
-
await db.prepare(
|
|
2520
|
-
"UPDATE notifications SET status = 'unaddressed', updated_at = ? WHERE status = 'pending'"
|
|
2521
|
-
).bind(now).run();
|
|
2522
|
-
}
|
|
2523
|
-
async function notificationStatus(db) {
|
|
2524
|
-
const { results } = await db.prepare(
|
|
2525
|
-
`SELECT * FROM (
|
|
2526
|
-
SELECT 'sent' AS which, n.kind, COALESCE(s.subject, i.file_name, '') AS subject,
|
|
2527
|
-
n.updated_at AS at, NULL AS error
|
|
2528
|
-
FROM notifications n LEFT JOIN sends s ON s.id = n.send_id
|
|
2529
|
-
LEFT JOIN imports i ON i.id = n.import_id
|
|
2530
|
-
WHERE n.status = 'sent' ORDER BY n.updated_at DESC LIMIT 1)
|
|
2531
|
-
UNION ALL
|
|
2532
|
-
SELECT * FROM (
|
|
2533
|
-
SELECT 'failed' AS which, n.kind, COALESCE(s.subject, i.file_name, '') AS subject,
|
|
2534
|
-
n.updated_at AS at, n.error
|
|
2535
|
-
FROM notifications n LEFT JOIN sends s ON s.id = n.send_id
|
|
2536
|
-
LEFT JOIN imports i ON i.id = n.import_id
|
|
2537
|
-
WHERE n.error IS NOT NULL AND n.status IN ('pending', 'failed')
|
|
2538
|
-
ORDER BY n.updated_at DESC LIMIT 1)`
|
|
2539
|
-
).all();
|
|
2540
|
-
const sent = results.find((r) => r.which === "sent");
|
|
2541
|
-
const failed = results.find((r) => r.which === "failed");
|
|
2542
|
-
return {
|
|
2543
|
-
last_sent: sent ? { kind: sent.kind, subject: sent.subject, at: sent.at } : null,
|
|
2544
|
-
last_failure: failed && (!sent || failed.at > sent.at) ? { kind: failed.kind, subject: failed.subject, at: failed.at, error: failed.error ?? "" } : null
|
|
2545
|
-
};
|
|
2546
|
-
}
|
|
2547
|
-
|
|
2548
2390
|
// src/db/imports.ts
|
|
2549
2391
|
var MAX_VALUE_BYTES = 1e6;
|
|
2550
2392
|
var encoder = new TextEncoder();
|
|
@@ -2569,9 +2411,9 @@ function jsonChunks(items, maxBytes = MAX_VALUE_BYTES) {
|
|
|
2569
2411
|
}
|
|
2570
2412
|
return chunks;
|
|
2571
2413
|
}
|
|
2572
|
-
async function readListState(db,
|
|
2414
|
+
async function readListState(db, emails2) {
|
|
2573
2415
|
const state = { subscribers: /* @__PURE__ */ new Map(), suppressions: /* @__PURE__ */ new Map() };
|
|
2574
|
-
const chunks = jsonChunks(
|
|
2416
|
+
const chunks = jsonChunks(emails2);
|
|
2575
2417
|
if (chunks.length === 0) {
|
|
2576
2418
|
return state;
|
|
2577
2419
|
}
|
|
@@ -2757,9 +2599,6 @@ async function applyImport(db, writes, suppressionDetail, forget = []) {
|
|
|
2757
2599
|
);
|
|
2758
2600
|
}
|
|
2759
2601
|
statements.push(...forget);
|
|
2760
|
-
if (writes.notice) {
|
|
2761
|
-
statements.push(importNotice(db, r.id, now));
|
|
2762
|
-
}
|
|
2763
2602
|
const results = await db.batch(statements);
|
|
2764
2603
|
const applied = {
|
|
2765
2604
|
unsubscribed: /* @__PURE__ */ new Set(),
|
|
@@ -2802,9 +2641,9 @@ function idleFor(email2, now) {
|
|
|
2802
2641
|
return `NOT EXISTS (SELECT 1 FROM deliveries d JOIN sends s ON s.id = d.send_id WHERE d.email = ${email2} AND d.status IN ('pending', 'dispatched') AND s.locked_until >= ${now})`;
|
|
2803
2642
|
}
|
|
2804
2643
|
var erasedNow = (email2) => `${email2} IN (SELECT email FROM subscribers WHERE status = 'unsubscribed' AND unsubscribe_cause = 'erased')`;
|
|
2805
|
-
async function deliveriesNaming(db,
|
|
2644
|
+
async function deliveriesNaming(db, emails2) {
|
|
2806
2645
|
let n = 0;
|
|
2807
|
-
for (const chunk3 of jsonChunks(
|
|
2646
|
+
for (const chunk3 of jsonChunks(emails2)) {
|
|
2808
2647
|
const row = await db.prepare(
|
|
2809
2648
|
"SELECT COUNT(*) AS n FROM deliveries WHERE email IN (SELECT value FROM json_each(?))"
|
|
2810
2649
|
).bind(chunk3).first();
|
|
@@ -2812,9 +2651,9 @@ async function deliveriesNaming(db, emails) {
|
|
|
2812
2651
|
}
|
|
2813
2652
|
return n;
|
|
2814
2653
|
}
|
|
2815
|
-
async function handingOff(db,
|
|
2654
|
+
async function handingOff(db, emails2, now) {
|
|
2816
2655
|
const found = [];
|
|
2817
|
-
for (const chunk3 of jsonChunks(
|
|
2656
|
+
for (const chunk3 of jsonChunks(emails2)) {
|
|
2818
2657
|
const { results } = await db.prepare(
|
|
2819
2658
|
`SELECT DISTINCT d.email FROM deliveries d JOIN sends s ON s.id = d.send_id
|
|
2820
2659
|
WHERE d.email IN (SELECT value FROM json_each(?1)) AND d.status IN ('pending', 'dispatched')
|
|
@@ -2831,10 +2670,10 @@ function withoutAddress(text2, email2, placeholder) {
|
|
|
2831
2670
|
const pattern = new RegExp(email2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "giu");
|
|
2832
2671
|
return text2.replace(pattern, placeholder);
|
|
2833
2672
|
}
|
|
2834
|
-
async function forgetDeliveries(db,
|
|
2835
|
-
const placeholders = new Map(
|
|
2673
|
+
async function forgetDeliveries(db, emails2, now) {
|
|
2674
|
+
const placeholders = new Map(emails2.map((e) => [e, `${ERASED_PREFIX}${newToken(8)}`]));
|
|
2836
2675
|
const rewritten = [];
|
|
2837
|
-
for (const chunk3 of jsonChunks(
|
|
2676
|
+
for (const chunk3 of jsonChunks(emails2)) {
|
|
2838
2677
|
const { results } = await db.prepare(
|
|
2839
2678
|
"SELECT id, email, error, event_detail, provider_id FROM deliveries WHERE email IN (SELECT value FROM json_each(?))"
|
|
2840
2679
|
).bind(chunk3).all();
|
|
@@ -2852,7 +2691,7 @@ async function forgetDeliveries(db, emails, now) {
|
|
|
2852
2691
|
}
|
|
2853
2692
|
}
|
|
2854
2693
|
const field = (name) => `${name} = (SELECT json_extract(value, '$.${name}') FROM json_each(?1) WHERE json_extract(value, '$.id') = deliveries.id)`;
|
|
2855
|
-
const held = jsonChunks(
|
|
2694
|
+
const held = jsonChunks(emails2).map(
|
|
2856
2695
|
(chunk3) => db.prepare(
|
|
2857
2696
|
`DELETE FROM held_receipts WHERE email IN (SELECT value FROM json_each(?1))
|
|
2858
2697
|
AND ${erasedNow("held_receipts.email")} AND ${idleFor("held_receipts.email", "?2")}`
|
|
@@ -3200,11 +3039,11 @@ async function countSuppressions(db, filter) {
|
|
|
3200
3039
|
const row = await db.prepare(`SELECT COUNT(*) AS n FROM suppressions ${clause}`).bind(...binds).first();
|
|
3201
3040
|
return row?.n ?? 0;
|
|
3202
3041
|
}
|
|
3203
|
-
async function suppressionsFor(db,
|
|
3204
|
-
if (
|
|
3042
|
+
async function suppressionsFor(db, emails2) {
|
|
3043
|
+
if (emails2.length === 0) {
|
|
3205
3044
|
return [];
|
|
3206
3045
|
}
|
|
3207
|
-
const { results } = await db.prepare("SELECT * FROM suppressions WHERE email IN (SELECT value FROM json_each(?))").bind(JSON.stringify(
|
|
3046
|
+
const { results } = await db.prepare("SELECT * FROM suppressions WHERE email IN (SELECT value FROM json_each(?))").bind(JSON.stringify(emails2)).all();
|
|
3208
3047
|
return results;
|
|
3209
3048
|
}
|
|
3210
3049
|
async function liftSuppression(db, email2, reason) {
|
|
@@ -3212,6 +3051,18 @@ async function liftSuppression(db, email2, reason) {
|
|
|
3212
3051
|
return (res.meta.changes ?? 0) > 0;
|
|
3213
3052
|
}
|
|
3214
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
|
+
|
|
3215
3066
|
// src/lib/image_types.ts
|
|
3216
3067
|
var RASTER_IMAGE_TYPES = [
|
|
3217
3068
|
"image/png",
|
|
@@ -3220,9 +3071,61 @@ var RASTER_IMAGE_TYPES = [
|
|
|
3220
3071
|
"image/gif"
|
|
3221
3072
|
];
|
|
3222
3073
|
function mediaType(declared) {
|
|
3223
|
-
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;
|
|
3224
3112
|
}
|
|
3225
3113
|
|
|
3114
|
+
// src/lib/time.ts
|
|
3115
|
+
var LEASE_TTL_MS = 5 * 60 * 1e3;
|
|
3116
|
+
var MISSED_THRESHOLD_MS = 5 * 60 * 1e3;
|
|
3117
|
+
var HALT_BACKOFF_MS = {
|
|
3118
|
+
unavailable: [1, 2, 5, 15, 30, 60].map((m) => m * 60 * 1e3),
|
|
3119
|
+
account: [5, 15, 30, 60].map((m) => m * 60 * 1e3)
|
|
3120
|
+
};
|
|
3121
|
+
var HALT_RETRY_SLACK_MS = 30 * 1e3;
|
|
3122
|
+
var NOTIFY_HORIZON_MS = 24 * 60 * 60 * 1e3;
|
|
3123
|
+
var NO_RECEIPTS_NOTICE_AFTER_MS = 6 * 60 * 60 * 1e3;
|
|
3124
|
+
var MAX_NOTIFY_ATTEMPTS = 5;
|
|
3125
|
+
var MAX_DELIVERY_ATTEMPTS = 5;
|
|
3126
|
+
var CONFIRM_COOLDOWN_MS = 15 * 60 * 1e3;
|
|
3127
|
+
var CONFIRM_LINK_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
3128
|
+
|
|
3226
3129
|
// shared/mcp.ts
|
|
3227
3130
|
var MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18"];
|
|
3228
3131
|
var MCP_VERSION_HEADER = "MCP-Protocol-Version";
|
|
@@ -3557,7 +3460,7 @@ var CLIENT_PARAMETER = {
|
|
|
3557
3460
|
name: CLIENT_HEADER,
|
|
3558
3461
|
in: "header",
|
|
3559
3462
|
required: false,
|
|
3560
|
-
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.",
|
|
3561
3464
|
schema: { type: "string", pattern: CLIENT_NAME_PATTERN, maxLength: CLIENT_NAME_MAX }
|
|
3562
3465
|
};
|
|
3563
3466
|
var ADMIN_SECURITY = [
|
|
@@ -5013,8 +4916,10 @@ async function dispatch(c, router, tools, msg) {
|
|
|
5013
4916
|
switch (method) {
|
|
5014
4917
|
case "initialize": {
|
|
5015
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);
|
|
5016
4921
|
return result(id, {
|
|
5017
|
-
protocolVersion
|
|
4922
|
+
protocolVersion,
|
|
5018
4923
|
capabilities: { tools: { listChanged: false }, resources: { listChanged: false } },
|
|
5019
4924
|
serverInfo: { name: "kestrel", title: "Kestrel", version: buildInfo().version },
|
|
5020
4925
|
instructions: MCP_INSTRUCTIONS
|
|
@@ -5046,6 +4951,25 @@ async function dispatch(c, router, tools, msg) {
|
|
|
5046
4951
|
);
|
|
5047
4952
|
}
|
|
5048
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
|
+
}
|
|
5049
4973
|
var RESOURCE_NOT_FOUND = -32002;
|
|
5050
4974
|
var DOC_URI = "kestrel://docs/";
|
|
5051
4975
|
async function through(c, router, r, parts = {}) {
|
|
@@ -5152,7 +5076,7 @@ var REQUESTS = [
|
|
|
5152
5076
|
];
|
|
5153
5077
|
var AUTHENTICATION = [
|
|
5154
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.",
|
|
5155
|
-
"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."
|
|
5156
5080
|
];
|
|
5157
5081
|
var ERRORS = [
|
|
5158
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.",
|
|
@@ -5281,13 +5205,13 @@ var CONDITION_KINDS = {
|
|
|
5281
5205
|
severity: "`action`",
|
|
5282
5206
|
fields: "",
|
|
5283
5207
|
meaning: `Still scheduled ${MISSED_THRESHOLD_MS / 6e4} minutes past its fire time: the sweep that fires sends is not running.`,
|
|
5284
|
-
remedy: "Whoever runs the
|
|
5208
|
+
remedy: "Whoever runs the instance checks the Worker's Cron Trigger (`triggers.crons`) and its logs; locally, `npm run dev` runs the sweep. The send goes out as soon as the sweep runs again; nothing in this API fires it."
|
|
5285
5209
|
},
|
|
5286
5210
|
stuck: {
|
|
5287
5211
|
severity: "`warn`",
|
|
5288
5212
|
fields: "",
|
|
5289
5213
|
meaning: `Sending, with no recipient handed off for ${STUCK_THRESHOLD_MS / 6e4} minutes and no halt or wedge to explain it: the sweep has stopped, every run on it fails, or another send is taking every tick. A send that keeps handing off, however slowly, is never stuck.`,
|
|
5290
|
-
remedy: "If another send is going out, this one picks up once it finishes. Otherwise whoever runs the
|
|
5214
|
+
remedy: "If another send is going out, this one picks up once it finishes. Otherwise whoever runs the instance reads the logs and checks the Cron Trigger. It carries on from where it stopped, mailing no one twice."
|
|
5291
5215
|
},
|
|
5292
5216
|
wedged: {
|
|
5293
5217
|
severity: "`action`",
|
|
@@ -5317,7 +5241,7 @@ var CONDITION_KINDS = {
|
|
|
5317
5241
|
severity: "`warn`",
|
|
5318
5242
|
fields: "`accepted`",
|
|
5319
5243
|
meaning: `A send finished within ${RECENT_DAYS} days, ${NO_RECEIPTS_AFTER_MS / 6e4} minutes past dispatch, with no receipt for any of its \`accepted\`: the provider's events may not be reaching the webhook, so its bounces suppress no one and its complaints unsubscribe no one.`,
|
|
5320
|
-
remedy: "Whoever runs the
|
|
5244
|
+
remedy: "Whoever runs the instance looks for `webhook.refused` in the logs and checks the provider's event subscription. It clears once any receipt lands."
|
|
5321
5245
|
},
|
|
5322
5246
|
remade: {
|
|
5323
5247
|
severity: "`info`",
|
|
@@ -5341,7 +5265,7 @@ var CONDITION_KINDS = {
|
|
|
5341
5265
|
severity: "`warn`",
|
|
5342
5266
|
fields: "`recipients`",
|
|
5343
5267
|
meaning: `On the Workers Free plan's limits, a scheduled send to more than ${DAILY_WRITES_SEND_RECIPIENTS.toLocaleString("en-US")} recipients, judged by its audience alone, which may spend D1's daily written rows and stop the instance until midnight UTC.`,
|
|
5344
|
-
remedy: "Never a refusal: the send goes out as scheduled. Whoever runs the
|
|
5268
|
+
remedy: "Never a refusal: the send goes out as scheduled. Whoever runs the instance can move to Workers Paid, whose limits a send this size fits."
|
|
5345
5269
|
}
|
|
5346
5270
|
};
|
|
5347
5271
|
var HALT_CAUSES = {
|
|
@@ -5528,7 +5452,7 @@ var SEND_FIELDS = [
|
|
|
5528
5452
|
{
|
|
5529
5453
|
name: "scheduled_by",
|
|
5530
5454
|
type: "{ kind, email, token_id, client } | null",
|
|
5531
|
-
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."
|
|
5532
5456
|
},
|
|
5533
5457
|
{
|
|
5534
5458
|
name: "rescheduled_by",
|
|
@@ -5717,7 +5641,7 @@ var POST_FIELDS = [
|
|
|
5717
5641
|
{
|
|
5718
5642
|
name: "author_client",
|
|
5719
5643
|
type: "string | null",
|
|
5720
|
-
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."
|
|
5721
5645
|
},
|
|
5722
5646
|
{ name: "created_at", type: "number", description: "When it was made." },
|
|
5723
5647
|
{ name: "updated_at", type: "number", description: "When it last changed." },
|
|
@@ -5755,7 +5679,7 @@ var REVISION_FIELDS = [
|
|
|
5755
5679
|
{
|
|
5756
5680
|
name: "author_client",
|
|
5757
5681
|
type: "string | null",
|
|
5758
|
-
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."
|
|
5759
5683
|
},
|
|
5760
5684
|
{ name: "created_at", type: "number", description: "When it was saved." },
|
|
5761
5685
|
{ name: "metadata", type: "object", description: "The subject and slug it was saved with." },
|
|
@@ -6886,14 +6810,14 @@ function readSimulateAccount(v2, profile) {
|
|
|
6886
6810
|
return null;
|
|
6887
6811
|
}
|
|
6888
6812
|
const [state = "", modifier, ...rest] = raw.split(":");
|
|
6889
|
-
const
|
|
6890
|
-
if (rest.length > 0 || !SIMULATED_ACCOUNTS.includes(state) || modifier !== void 0 && modifier !== "unrouted" && !
|
|
6813
|
+
const problem2 = SIMULATED_ACCOUNT_PROBLEMS.includes(modifier ?? "") ? modifier : null;
|
|
6814
|
+
if (rest.length > 0 || !SIMULATED_ACCOUNTS.includes(state) || modifier !== void 0 && modifier !== "unrouted" && !problem2) {
|
|
6891
6815
|
throw new ConfigError(
|
|
6892
6816
|
"SIMULATE_ACCOUNT",
|
|
6893
6817
|
`must be off, or one of ${SIMULATED_ACCOUNTS.join(", ")} optionally followed by :unrouted or one of :${SIMULATED_ACCOUNT_PROBLEMS.join(", :")}, not "${v2?.trim()}"`
|
|
6894
6818
|
);
|
|
6895
6819
|
}
|
|
6896
|
-
if (state === "permission" &&
|
|
6820
|
+
if (state === "permission" && problem2) {
|
|
6897
6821
|
throw new ConfigError(
|
|
6898
6822
|
"SIMULATE_ACCOUNT",
|
|
6899
6823
|
`permission reads nothing of the account, so it takes no problem, not "${v2?.trim()}"`
|
|
@@ -6905,7 +6829,7 @@ function readSimulateAccount(v2, profile) {
|
|
|
6905
6829
|
`stands in for the SES account, so it needs SIMULATE_SENDS=ses, not "${profile ?? "off"}"`
|
|
6906
6830
|
);
|
|
6907
6831
|
}
|
|
6908
|
-
return { state, bounces_routed: modifier !== "unrouted", problem };
|
|
6832
|
+
return { state, bounces_routed: modifier !== "unrouted", problem: problem2 };
|
|
6909
6833
|
}
|
|
6910
6834
|
function parseEmailList(v2) {
|
|
6911
6835
|
if (!v2) {
|
|
@@ -6944,7 +6868,11 @@ function headers(formAction) {
|
|
|
6944
6868
|
return {
|
|
6945
6869
|
"content-security-policy": pagePolicy(formAction),
|
|
6946
6870
|
"x-frame-options": "DENY",
|
|
6947
|
-
"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"
|
|
6948
6876
|
};
|
|
6949
6877
|
}
|
|
6950
6878
|
var PAGE_SECURITY_HEADERS = headers("'self'");
|
|
@@ -7397,11 +7325,25 @@ var Router = class {
|
|
|
7397
7325
|
get routes() {
|
|
7398
7326
|
return this.compiled;
|
|
7399
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
|
+
*/
|
|
7400
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) {
|
|
7401
7343
|
const url = new URL(req.url);
|
|
7402
7344
|
const config = getConfig(env);
|
|
7403
7345
|
for (const route2 of this.compiled) {
|
|
7404
|
-
if (route2.def.method !==
|
|
7346
|
+
if (route2.def.method !== method) {
|
|
7405
7347
|
continue;
|
|
7406
7348
|
}
|
|
7407
7349
|
const match = route2.pattern.exec(url);
|
|
@@ -7804,9 +7746,21 @@ function ownMediaKey(href, mediaBase) {
|
|
|
7804
7746
|
|
|
7805
7747
|
// src/render/markdown.ts
|
|
7806
7748
|
import { Marked } from "marked";
|
|
7749
|
+
var UNPARSED_IMAGE = /!\[.*?\]\([^)\n]*\)?/g;
|
|
7807
7750
|
function markdownToHtml(md, ctx) {
|
|
7808
7751
|
const m = new Marked({ gfm: true, breaks: false });
|
|
7752
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
7809
7753
|
m.use({
|
|
7754
|
+
walkTokens(token2) {
|
|
7755
|
+
if (token2.type !== "text" || "tokens" in token2) {
|
|
7756
|
+
return;
|
|
7757
|
+
}
|
|
7758
|
+
for (const [written] of token2.raw.matchAll(UNPARSED_IMAGE)) {
|
|
7759
|
+
ctx.warnings.push(
|
|
7760
|
+
`\u201C${written}\u201D won\u2019t show as an image, so readers will see it as typed. Check it for a space or a stray bracket, and reference an uploaded image by its file name.`
|
|
7761
|
+
);
|
|
7762
|
+
}
|
|
7763
|
+
},
|
|
7810
7764
|
renderer: {
|
|
7811
7765
|
image(token2) {
|
|
7812
7766
|
const href = token2.href ?? "";
|
|
@@ -7817,6 +7771,9 @@ function markdownToHtml(md, ctx) {
|
|
|
7817
7771
|
);
|
|
7818
7772
|
}
|
|
7819
7773
|
const { url, image, ownMedia } = resolveImageSrc(href, ctx.images, ctx.mediaBase);
|
|
7774
|
+
if (image) {
|
|
7775
|
+
referenced.add(image);
|
|
7776
|
+
}
|
|
7820
7777
|
if (image && ownMedia) {
|
|
7821
7778
|
ctx.warnings.push(
|
|
7822
7779
|
`The image \u201C${href}\u201D should be referenced as \u201C${image.filename}\u201D. Use its file name, not its address.`
|
|
@@ -7836,7 +7793,15 @@ function markdownToHtml(md, ctx) {
|
|
|
7836
7793
|
}
|
|
7837
7794
|
}
|
|
7838
7795
|
});
|
|
7839
|
-
|
|
7796
|
+
const html = m.parse(md);
|
|
7797
|
+
for (const image of ctx.images.values()) {
|
|
7798
|
+
const address = `${ctx.mediaBase}/${image.storage_key}`;
|
|
7799
|
+
const written = [address, encodeURI(address)].flatMap((a) => [a, escapeHtmlAttr(a)]);
|
|
7800
|
+
if (!referenced.has(image) && !written.some((a) => html.includes(a))) {
|
|
7801
|
+
ctx.warnings.push(`The uploaded image \u201C${image.filename}\u201D isn\u2019t used in this post.`);
|
|
7802
|
+
}
|
|
7803
|
+
}
|
|
7804
|
+
return html;
|
|
7840
7805
|
}
|
|
7841
7806
|
function sizeAttrs(w, h, maxWidth) {
|
|
7842
7807
|
if (!w || !h) {
|
|
@@ -7921,13 +7886,14 @@ function emailLayout(i) {
|
|
|
7921
7886
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
7922
7887
|
<meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark">
|
|
7923
7888
|
<title>${escapeHtml(i.subject)}</title>${ARCHIVE_HEAD_ANCHOR}
|
|
7924
|
-
<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>
|
|
7925
7891
|
</head>
|
|
7926
7892
|
<body class="k-bg" style="margin:0;padding:0;background:#ffffff;">
|
|
7927
7893
|
${preheader}
|
|
7928
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;">
|
|
7929
7895
|
<table role="presentation" width="664" cellpadding="0" cellspacing="0" class="k-bg" style="max-width:664px;width:100%;background:#ffffff;">
|
|
7930
|
-
<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;">
|
|
7931
7897
|
${ARCHIVE_MASTHEAD_ANCHOR}${i.bodyHtml}
|
|
7932
7898
|
</td></tr>
|
|
7933
7899
|
</table>
|
|
@@ -8053,17 +8019,17 @@ async function readSettings(db) {
|
|
|
8053
8019
|
}
|
|
8054
8020
|
function parseStored(data2) {
|
|
8055
8021
|
let parsed;
|
|
8056
|
-
let
|
|
8022
|
+
let problem2 = "is not a JSON object";
|
|
8057
8023
|
try {
|
|
8058
8024
|
parsed = JSON.parse(data2);
|
|
8059
8025
|
} catch (err) {
|
|
8060
|
-
|
|
8026
|
+
problem2 = `is not valid JSON (${err instanceof Error ? err.message : String(err)})`;
|
|
8061
8027
|
}
|
|
8062
8028
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
8063
8029
|
return parsed;
|
|
8064
8030
|
}
|
|
8065
8031
|
log.error("settings.corrupt", {
|
|
8066
|
-
message: `the settings row (settings.id = 1) ${
|
|
8032
|
+
message: `the settings row (settings.id = 1) ${problem2}; reads and writes of settings fail until it is repaired by hand`,
|
|
8067
8033
|
length: data2.length
|
|
8068
8034
|
});
|
|
8069
8035
|
throw new HttpError(
|
|
@@ -8730,6 +8696,195 @@ function isRefusingCause(cause) {
|
|
|
8730
8696
|
return cause !== null && REFUSING_CAUSES.includes(cause);
|
|
8731
8697
|
}
|
|
8732
8698
|
|
|
8699
|
+
// src/db/notifications.ts
|
|
8700
|
+
function utcDayStart(now) {
|
|
8701
|
+
return Math.floor(now / 864e5) * 864e5;
|
|
8702
|
+
}
|
|
8703
|
+
async function sandboxNotice(db, now) {
|
|
8704
|
+
await db.prepare(
|
|
8705
|
+
"INSERT OR IGNORE INTO notifications (send_id, kind, episode, created_at, updated_at) VALUES (NULL, 'sandbox', ?, ?, ?)"
|
|
8706
|
+
).bind(utcDayStart(now), now, now).run();
|
|
8707
|
+
}
|
|
8708
|
+
function outageNotice(db, now) {
|
|
8709
|
+
return db.prepare(
|
|
8710
|
+
`INSERT OR IGNORE INTO notifications (send_id, kind, episode, created_at, updated_at)
|
|
8711
|
+
SELECT NULL, 'not_sending', since, ?1, ?1 FROM sending WHERE id = 1 AND state = 'refusing'`
|
|
8712
|
+
).bind(now);
|
|
8713
|
+
}
|
|
8714
|
+
var REFUSING_CAUSE_LIST = REFUSING_CAUSES.map((c) => `'${c}'`).join(", ");
|
|
8715
|
+
var HELD_BY_OUTAGE = `(sends.status = 'sending' AND sends.halt_reason = 'account'
|
|
8716
|
+
AND sends.halt_cause IN (${REFUSING_CAUSE_LIST}) AND NOT (${WEDGED_SEND}))`;
|
|
8717
|
+
async function postsHeldByOutage(db) {
|
|
8718
|
+
const { results } = await db.prepare(`SELECT subject FROM sends WHERE ${HELD_BY_OUTAGE} ORDER BY fire_at, id`).all();
|
|
8719
|
+
return results.map((r) => r.subject);
|
|
8720
|
+
}
|
|
8721
|
+
async function recordNotifications(db, now) {
|
|
8722
|
+
await db.prepare(
|
|
8723
|
+
// Two nested compounds, because D1 allows five terms in one.
|
|
8724
|
+
`INSERT OR IGNORE INTO notifications (send_id, kind, episode, created_at, updated_at)
|
|
8725
|
+
SELECT * FROM (
|
|
8726
|
+
SELECT id, 'finished', 0, ?1, ?1 FROM sends
|
|
8727
|
+
WHERE status = 'sent' AND completed_at >= ?2
|
|
8728
|
+
UNION ALL
|
|
8729
|
+
SELECT id, 'refused', halted_at, ?1, ?1 FROM sends s
|
|
8730
|
+
WHERE status = 'sending' AND halt_reason = 'account' AND halted_at IS NOT NULL
|
|
8731
|
+
AND NOT (${WEDGED_SEND})
|
|
8732
|
+
AND (halt_cause IS NULL OR halt_cause NOT IN (${REFUSING_CAUSE_LIST}))
|
|
8733
|
+
AND NOT EXISTS (SELECT 1 FROM sends o
|
|
8734
|
+
WHERE o.status = 'sending' AND o.halt_reason = 'account'
|
|
8735
|
+
AND o.halted_at = s.halted_at AND o.id < s.id
|
|
8736
|
+
AND NOT ${WEDGED_SEND})
|
|
8737
|
+
AND NOT EXISTS (SELECT 1 FROM notifications r
|
|
8738
|
+
WHERE r.kind = 'refused' AND r.episode = s.halted_at
|
|
8739
|
+
AND r.send_id <> s.id AND r.status <> 'cleared')
|
|
8740
|
+
UNION ALL
|
|
8741
|
+
SELECT id, 'missed', 0, ?1, ?1 FROM sends
|
|
8742
|
+
WHERE status = 'scheduled' AND fire_at < ?1 - ?3
|
|
8743
|
+
UNION ALL
|
|
8744
|
+
SELECT id, 'wedged', 0, ?1, ?1 FROM sends WHERE ${WEDGED_SEND}
|
|
8745
|
+
UNION ALL
|
|
8746
|
+
SELECT id, 'stuck', ${SEND_IDLE_SINCE}, ?1, ?1 FROM sends
|
|
8747
|
+
WHERE ${stuckSendSql("?1 - ?4")}
|
|
8748
|
+
)
|
|
8749
|
+
UNION ALL
|
|
8750
|
+
SELECT * FROM (
|
|
8751
|
+
SELECT NULL, 'not_sending', g.since, ?1, ?1 FROM sending g
|
|
8752
|
+
WHERE g.state = 'refusing' AND EXISTS (SELECT 1 FROM sends WHERE ${HELD_BY_OUTAGE})
|
|
8753
|
+
UNION ALL
|
|
8754
|
+
SELECT NULL, 'not_sending_held', g.since, ?1, ?1 FROM sending g
|
|
8755
|
+
WHERE g.state = 'refusing'
|
|
8756
|
+
AND EXISTS (SELECT 1 FROM notifications t
|
|
8757
|
+
WHERE t.kind = 'not_sending' AND t.episode = g.since
|
|
8758
|
+
AND t.status IN ('sent', 'failed', 'unaddressed')
|
|
8759
|
+
AND EXISTS (SELECT 1 FROM sends
|
|
8760
|
+
WHERE ${HELD_BY_OUTAGE}
|
|
8761
|
+
AND sends.halted_at >
|
|
8762
|
+
CASE t.status WHEN 'failed' THEN t.created_at
|
|
8763
|
+
ELSE t.updated_at END))
|
|
8764
|
+
UNION ALL
|
|
8765
|
+
SELECT id, 'no_receipts', 0, ?1, ?1 FROM sends s
|
|
8766
|
+
WHERE s.status = 'sent' AND s.c_accepted > 0
|
|
8767
|
+
AND s.completed_at >= ?2 AND s.completed_at < ?1 - ?5
|
|
8768
|
+
AND s.c_delivered + s.c_bounced + s.c_complained = 0
|
|
8769
|
+
AND NOT EXISTS (SELECT 1 FROM sends h
|
|
8770
|
+
WHERE h.completed_at >= s.completed_at
|
|
8771
|
+
AND h.c_delivered + h.c_bounced + h.c_complained > 0)
|
|
8772
|
+
AND NOT EXISTS (SELECT 1 FROM notifications r JOIN sends o ON o.id = r.send_id
|
|
8773
|
+
WHERE r.kind = 'no_receipts' AND r.status <> 'cleared'
|
|
8774
|
+
AND NOT EXISTS (SELECT 1 FROM sends h
|
|
8775
|
+
WHERE h.completed_at >= o.completed_at
|
|
8776
|
+
AND h.c_delivered + h.c_bounced + h.c_complained > 0))
|
|
8777
|
+
)`
|
|
8778
|
+
).bind(
|
|
8779
|
+
now,
|
|
8780
|
+
now - NOTIFY_HORIZON_MS,
|
|
8781
|
+
MISSED_THRESHOLD_MS,
|
|
8782
|
+
STUCK_THRESHOLD_MS,
|
|
8783
|
+
NO_RECEIPTS_NOTICE_AFTER_MS
|
|
8784
|
+
).run();
|
|
8785
|
+
}
|
|
8786
|
+
async function dueNotifications(db, limit) {
|
|
8787
|
+
const { results } = await db.prepare(
|
|
8788
|
+
`SELECT n.send_id, n.kind, n.episode, n.attempts, n.sent_to,
|
|
8789
|
+
s.subject, s.status AS send_status, s.fire_at, s.started_at, s.completed_at,
|
|
8790
|
+
s.locked_until, s.halt_reason, s.halted_at, s.halt_retry_at, s.progress_at, s.halt_cause, s.halt_error, s.c_pending, s.c_in_flight, s.c_accepted,
|
|
8791
|
+
s.c_delivered, s.c_bounced, s.c_complained, s.c_skipped, s.c_unsent,
|
|
8792
|
+
EXISTS (SELECT 1 FROM sends h
|
|
8793
|
+
WHERE h.completed_at >= s.completed_at
|
|
8794
|
+
AND h.c_delivered + h.c_bounced + h.c_complained > 0) AS receipt_since
|
|
8795
|
+
FROM notifications n
|
|
8796
|
+
LEFT JOIN sends s ON s.id = n.send_id
|
|
8797
|
+
WHERE n.status = 'pending'
|
|
8798
|
+
AND (s.id IS NOT NULL OR n.kind IN ('sandbox', 'not_sending', 'not_sending_held'))
|
|
8799
|
+
ORDER BY n.created_at ASC, n.send_id ASC, n.kind ASC
|
|
8800
|
+
LIMIT ?`
|
|
8801
|
+
).bind(limit).all();
|
|
8802
|
+
return results.map((r) => {
|
|
8803
|
+
const sentTo = readSentTo(r.sent_to);
|
|
8804
|
+
if (r.kind === "sandbox" || r.kind === "not_sending" || r.kind === "not_sending_held") {
|
|
8805
|
+
return {
|
|
8806
|
+
send_id: null,
|
|
8807
|
+
kind: r.kind,
|
|
8808
|
+
episode: r.episode,
|
|
8809
|
+
attempts: r.attempts,
|
|
8810
|
+
sent_to: sentTo
|
|
8811
|
+
};
|
|
8812
|
+
}
|
|
8813
|
+
return { ...r, sent_to: sentTo };
|
|
8814
|
+
});
|
|
8815
|
+
}
|
|
8816
|
+
function readSentTo(raw) {
|
|
8817
|
+
const list5 = JSON.parse(raw);
|
|
8818
|
+
return Array.isArray(list5) ? list5.filter((e) => typeof e === "string") : [];
|
|
8819
|
+
}
|
|
8820
|
+
var IN_KEYS = `EXISTS (SELECT 1 FROM json_each(?) k
|
|
8821
|
+
WHERE json_extract(k.value, '$[0]') IS notifications.send_id
|
|
8822
|
+
AND json_extract(k.value, '$[1]') = notifications.kind
|
|
8823
|
+
AND json_extract(k.value, '$[2]') = notifications.episode)`;
|
|
8824
|
+
async function claimNotifications(db, keys, now) {
|
|
8825
|
+
await db.prepare(
|
|
8826
|
+
`UPDATE notifications SET attempts = attempts + 1, updated_at = ?
|
|
8827
|
+
WHERE status = 'pending' AND ${IN_KEYS}`
|
|
8828
|
+
).bind(now, JSON.stringify(keys.map((k) => [k.send_id, k.kind, k.episode]))).run();
|
|
8829
|
+
}
|
|
8830
|
+
async function recordNotificationOutcome(db, key, outcome, now) {
|
|
8831
|
+
if (outcome.status === "deferred") {
|
|
8832
|
+
await db.prepare(
|
|
8833
|
+
`UPDATE notifications SET sent_to = ?, attempts = MAX(attempts - 1, 0), updated_at = ?
|
|
8834
|
+
WHERE send_id IS ? AND kind = ? AND episode = ?`
|
|
8835
|
+
).bind(JSON.stringify(outcome.sentTo), now, key.send_id, key.kind, key.episode).run();
|
|
8836
|
+
return;
|
|
8837
|
+
}
|
|
8838
|
+
const status = outcome.status === "unsent" ? outcome.giveUp ? "failed" : "pending" : outcome.status;
|
|
8839
|
+
const error2 = outcome.status === "unsent" ? outcome.error : null;
|
|
8840
|
+
const sentTo = outcome.status === "cleared" ? null : JSON.stringify(outcome.sentTo);
|
|
8841
|
+
await db.prepare(
|
|
8842
|
+
`UPDATE notifications SET status = ?, error = ?, sent_to = COALESCE(?, sent_to), updated_at = ?
|
|
8843
|
+
WHERE send_id IS ? AND kind = ? AND episode = ?`
|
|
8844
|
+
).bind(status, error2, sentTo, now, key.send_id, key.kind, key.episode).run();
|
|
8845
|
+
}
|
|
8846
|
+
async function forgetNotification(db, key) {
|
|
8847
|
+
await db.prepare("DELETE FROM notifications WHERE send_id IS ? AND kind = ? AND episode = ?").bind(key.send_id, key.kind, key.episode).run();
|
|
8848
|
+
}
|
|
8849
|
+
async function recordNotificationTest(db, error2, now) {
|
|
8850
|
+
await db.batch([
|
|
8851
|
+
db.prepare("DELETE FROM notifications WHERE kind = 'test'"),
|
|
8852
|
+
db.prepare(
|
|
8853
|
+
`INSERT INTO notifications (send_id, kind, episode, status, attempts, error, created_at, updated_at)
|
|
8854
|
+
VALUES (NULL, 'test', 0, ?, 1, ?, ?, ?)`
|
|
8855
|
+
).bind(error2 === null ? "sent" : "failed", error2, now, now)
|
|
8856
|
+
]);
|
|
8857
|
+
}
|
|
8858
|
+
async function closeUnaddressed(db, now) {
|
|
8859
|
+
await db.prepare(
|
|
8860
|
+
"UPDATE notifications SET status = 'unaddressed', updated_at = ? WHERE status = 'pending'"
|
|
8861
|
+
).bind(now).run();
|
|
8862
|
+
}
|
|
8863
|
+
async function notificationStatus(db) {
|
|
8864
|
+
const { results } = await db.prepare(
|
|
8865
|
+
`SELECT * FROM (
|
|
8866
|
+
SELECT 'sent' AS which, n.kind, COALESCE(s.subject, i.file_name, '') AS subject,
|
|
8867
|
+
n.updated_at AS at, NULL AS error
|
|
8868
|
+
FROM notifications n LEFT JOIN sends s ON s.id = n.send_id
|
|
8869
|
+
LEFT JOIN imports i ON i.id = n.import_id
|
|
8870
|
+
WHERE n.status = 'sent' ORDER BY n.updated_at DESC LIMIT 1)
|
|
8871
|
+
UNION ALL
|
|
8872
|
+
SELECT * FROM (
|
|
8873
|
+
SELECT 'failed' AS which, n.kind, COALESCE(s.subject, i.file_name, '') AS subject,
|
|
8874
|
+
n.updated_at AS at, n.error
|
|
8875
|
+
FROM notifications n LEFT JOIN sends s ON s.id = n.send_id
|
|
8876
|
+
LEFT JOIN imports i ON i.id = n.import_id
|
|
8877
|
+
WHERE n.error IS NOT NULL AND n.status IN ('pending', 'failed')
|
|
8878
|
+
ORDER BY n.updated_at DESC LIMIT 1)`
|
|
8879
|
+
).all();
|
|
8880
|
+
const sent = results.find((r) => r.which === "sent");
|
|
8881
|
+
const failed = results.find((r) => r.which === "failed");
|
|
8882
|
+
return {
|
|
8883
|
+
last_sent: sent ? { kind: sent.kind, subject: sent.subject, at: sent.at } : null,
|
|
8884
|
+
last_failure: failed && (!sent || failed.at > sent.at) ? { kind: failed.kind, subject: failed.subject, at: failed.at, error: failed.error ?? "" } : null
|
|
8885
|
+
};
|
|
8886
|
+
}
|
|
8887
|
+
|
|
8733
8888
|
// src/db/sending.ts
|
|
8734
8889
|
async function readSending(db) {
|
|
8735
8890
|
const r = await db.prepare("SELECT * FROM sending WHERE id = 1").first();
|
|
@@ -8800,7 +8955,7 @@ async function recordAccepted(db, by, at) {
|
|
|
8800
8955
|
}
|
|
8801
8956
|
async function recordRefusal(db, refusal2, at, tell) {
|
|
8802
8957
|
const write = refusalStatement(db, refusal2, at);
|
|
8803
|
-
await (tell ? db.batch([write,
|
|
8958
|
+
await (tell ? db.batch([write, outageNotice(db, at)]) : write.run());
|
|
8804
8959
|
}
|
|
8805
8960
|
|
|
8806
8961
|
// src/services/sending.ts
|
|
@@ -8845,7 +9000,7 @@ async function noteContact(db, config, purpose, outcome, at, tell = false) {
|
|
|
8845
9000
|
tell
|
|
8846
9001
|
);
|
|
8847
9002
|
} catch (err) {
|
|
8848
|
-
log.error("sending.record_failed", { purpose,
|
|
9003
|
+
log.error("sending.record_failed", { purpose, ...errorFields(err) });
|
|
8849
9004
|
}
|
|
8850
9005
|
}
|
|
8851
9006
|
var Contacts = class {
|
|
@@ -9303,26 +9458,21 @@ async function listImages(db, postId) {
|
|
|
9303
9458
|
function getImage(db, postId, filename) {
|
|
9304
9459
|
return db.prepare("SELECT * FROM images WHERE post_id = ? AND filename = ?").bind(postId, filename).first();
|
|
9305
9460
|
}
|
|
9306
|
-
async function
|
|
9307
|
-
const
|
|
9308
|
-
|
|
9309
|
-
|
|
9310
|
-
|
|
9311
|
-
|
|
9312
|
-
|
|
9313
|
-
|
|
9314
|
-
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9321
|
-
input.contentType,
|
|
9322
|
-
input.width,
|
|
9323
|
-
input.height,
|
|
9324
|
-
now
|
|
9325
|
-
).run();
|
|
9461
|
+
async function insertImageIfNameFree(db, input) {
|
|
9462
|
+
const { meta } = await db.prepare(
|
|
9463
|
+
"INSERT INTO images (id, post_id, filename, storage_key, content_type, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (post_id, filename) DO NOTHING"
|
|
9464
|
+
).bind(
|
|
9465
|
+
newId(),
|
|
9466
|
+
input.postId,
|
|
9467
|
+
input.filename,
|
|
9468
|
+
input.storageKey,
|
|
9469
|
+
input.contentType,
|
|
9470
|
+
input.width,
|
|
9471
|
+
input.height,
|
|
9472
|
+
Date.now()
|
|
9473
|
+
).run();
|
|
9474
|
+
if (meta.changes === 0) {
|
|
9475
|
+
return null;
|
|
9326
9476
|
}
|
|
9327
9477
|
return unwrap(await getImage(db, input.postId, input.filename), "image");
|
|
9328
9478
|
}
|
|
@@ -9706,8 +9856,8 @@ function mapSesNotification(messageJson) {
|
|
|
9706
9856
|
events.push({ type: "complained", providerId, email: r.emailAddress, detail });
|
|
9707
9857
|
}
|
|
9708
9858
|
} else if (kind === "Delivery" && payload.delivery) {
|
|
9709
|
-
const
|
|
9710
|
-
for (const email2 of
|
|
9859
|
+
const recipients2 = payload.delivery.recipients ?? payload.mail?.destination ?? [];
|
|
9860
|
+
for (const email2 of recipients2) {
|
|
9711
9861
|
events.push({ type: "delivered", providerId, email: email2 });
|
|
9712
9862
|
}
|
|
9713
9863
|
}
|
|
@@ -9926,15 +10076,15 @@ var SesProvider = class {
|
|
|
9926
10076
|
* template and data, so it is the same email (I5), and stays off SendBulkEmail's
|
|
9927
10077
|
* one-request-a-second throttle, which the send loop's requests use to the full.
|
|
9928
10078
|
*/
|
|
9929
|
-
async sendBatch(rendered,
|
|
10079
|
+
async sendBatch(rendered, recipients2, opts) {
|
|
9930
10080
|
const out = [];
|
|
9931
10081
|
const sendable = [];
|
|
9932
|
-
for (const r of
|
|
9933
|
-
const
|
|
9934
|
-
if (
|
|
10082
|
+
for (const r of recipients2) {
|
|
10083
|
+
const problem2 = sesAddressProblem(r.email);
|
|
10084
|
+
if (problem2 === null) {
|
|
9935
10085
|
sendable.push(r);
|
|
9936
10086
|
} else {
|
|
9937
|
-
out.push({ email: r.email, accepted: false, retryable: false, error:
|
|
10087
|
+
out.push({ email: r.email, accepted: false, retryable: false, error: problem2 });
|
|
9938
10088
|
}
|
|
9939
10089
|
}
|
|
9940
10090
|
const content = sesTemplateContent(rendered);
|
|
@@ -10606,8 +10756,8 @@ var failCount = 0;
|
|
|
10606
10756
|
function fakeOutbox() {
|
|
10607
10757
|
return outbox;
|
|
10608
10758
|
}
|
|
10609
|
-
function deliverToOutbox(rendered,
|
|
10610
|
-
return
|
|
10759
|
+
function deliverToOutbox(rendered, recipients2, opts) {
|
|
10760
|
+
return recipients2.map((r) => {
|
|
10611
10761
|
const key = `${opts.batchKey ?? opts.tag}:${r.email}`;
|
|
10612
10762
|
const existing = sentKeys.get(key);
|
|
10613
10763
|
if (existing) {
|
|
@@ -10633,7 +10783,7 @@ function deliverToOutbox(rendered, recipients, opts) {
|
|
|
10633
10783
|
var FakeProvider = class {
|
|
10634
10784
|
name = "fake";
|
|
10635
10785
|
maxBatch = Number.MAX_SAFE_INTEGER;
|
|
10636
|
-
async sendBatch(rendered,
|
|
10786
|
+
async sendBatch(rendered, recipients2, opts) {
|
|
10637
10787
|
if (failCount > 0) {
|
|
10638
10788
|
failCount -= 1;
|
|
10639
10789
|
return {
|
|
@@ -10641,7 +10791,7 @@ var FakeProvider = class {
|
|
|
10641
10791
|
halt: { reason: "unavailable", cause: "outage", error: "fake transient failure" }
|
|
10642
10792
|
};
|
|
10643
10793
|
}
|
|
10644
|
-
return { kind: "answered", results: deliverToOutbox(rendered,
|
|
10794
|
+
return { kind: "answered", results: deliverToOutbox(rendered, recipients2, opts) };
|
|
10645
10795
|
}
|
|
10646
10796
|
async parseWebhook(_req, _env) {
|
|
10647
10797
|
return { events: [], response: new Response("ok") };
|
|
@@ -10753,7 +10903,7 @@ function sandboxVerified(email2, config) {
|
|
|
10753
10903
|
const domain = email2.slice(email2.lastIndexOf("@") + 1).toLowerCase();
|
|
10754
10904
|
return domain === config.sendingDomain.toLowerCase() || domain === SES_SIMULATOR_DOMAIN;
|
|
10755
10905
|
}
|
|
10756
|
-
function sandboxRefusal(
|
|
10906
|
+
function sandboxRefusal(recipients2, config) {
|
|
10757
10907
|
if (!config.simulation?.account) {
|
|
10758
10908
|
return null;
|
|
10759
10909
|
}
|
|
@@ -10761,7 +10911,7 @@ function sandboxRefusal(recipients, config) {
|
|
|
10761
10911
|
if (row.answer?.productionAccess !== false) {
|
|
10762
10912
|
return null;
|
|
10763
10913
|
}
|
|
10764
|
-
const unverified =
|
|
10914
|
+
const unverified = recipients2.filter((r) => !sandboxVerified(r.email, config));
|
|
10765
10915
|
if (unverified.length === 0) {
|
|
10766
10916
|
return null;
|
|
10767
10917
|
}
|
|
@@ -10877,14 +11027,14 @@ var SimProvider = class {
|
|
|
10877
11027
|
this.maxRequestRate = traits.maxRequestRate;
|
|
10878
11028
|
}
|
|
10879
11029
|
/** One list batch is one request to the provider. */
|
|
10880
|
-
async sendBatch(rendered,
|
|
11030
|
+
async sendBatch(rendered, recipients2, opts) {
|
|
10881
11031
|
if (this.refusal) {
|
|
10882
11032
|
return { kind: "halted", halt: this.refusal };
|
|
10883
11033
|
}
|
|
10884
11034
|
if (opts.purpose !== "list") {
|
|
10885
|
-
return this.oneOff(rendered,
|
|
11035
|
+
return this.oneOff(rendered, recipients2, opts);
|
|
10886
11036
|
}
|
|
10887
|
-
const sandboxed = sandboxRefusal(
|
|
11037
|
+
const sandboxed = sandboxRefusal(recipients2, this.config);
|
|
10888
11038
|
if (sandboxed) {
|
|
10889
11039
|
return { kind: "halted", halt: sandboxed };
|
|
10890
11040
|
}
|
|
@@ -10919,12 +11069,12 @@ var SimProvider = class {
|
|
|
10919
11069
|
}
|
|
10920
11070
|
const resumed = state.quotaSpentAt === null ? 0 : ++state.resumed;
|
|
10921
11071
|
await sleep(p.requestMs);
|
|
10922
|
-
const results = this.answer(sendId, rendered,
|
|
11072
|
+
const results = this.answer(sendId, rendered, recipients2, opts);
|
|
10923
11073
|
if (realistic && this.lost(sendId, key, state, resumed)) {
|
|
10924
11074
|
log.info("sim.request_lost", {
|
|
10925
11075
|
sendId,
|
|
10926
11076
|
profile: this.profileName,
|
|
10927
|
-
recipients:
|
|
11077
|
+
recipients: recipients2.length
|
|
10928
11078
|
});
|
|
10929
11079
|
throw new Error(
|
|
10930
11080
|
"simulated network error: the connection closed before the provider answered"
|
|
@@ -10937,28 +11087,28 @@ var SimProvider = class {
|
|
|
10937
11087
|
* outbox, unless the sandbox refuses the recipient. A refusal of the first request speaks
|
|
10938
11088
|
* for the call, as the adapter's does; a later one holds back the rest.
|
|
10939
11089
|
*/
|
|
10940
|
-
async oneOff(rendered,
|
|
11090
|
+
async oneOff(rendered, recipients2, opts) {
|
|
10941
11091
|
const out = [];
|
|
10942
|
-
for (const [i, r] of
|
|
10943
|
-
const
|
|
10944
|
-
if (!
|
|
11092
|
+
for (const [i, r] of recipients2.entries()) {
|
|
11093
|
+
const refused3 = sandboxRefusal([r], this.config);
|
|
11094
|
+
if (!refused3) {
|
|
10945
11095
|
const answer = await this.fake.sendBatch(rendered, [r], opts);
|
|
10946
11096
|
out.push(...answer.kind === "answered" ? answer.results : []);
|
|
10947
11097
|
continue;
|
|
10948
11098
|
}
|
|
10949
11099
|
if (i === 0) {
|
|
10950
|
-
return { kind: "halted", halt:
|
|
11100
|
+
return { kind: "halted", halt: refused3 };
|
|
10951
11101
|
}
|
|
10952
|
-
for (const rest of
|
|
11102
|
+
for (const rest of recipients2.slice(i)) {
|
|
10953
11103
|
out.push({
|
|
10954
11104
|
email: rest.email,
|
|
10955
11105
|
accepted: false,
|
|
10956
11106
|
retryable: true,
|
|
10957
|
-
error:
|
|
11107
|
+
error: refused3.error,
|
|
10958
11108
|
held: true
|
|
10959
11109
|
});
|
|
10960
11110
|
}
|
|
10961
|
-
return { kind: "answered", results: out, halt:
|
|
11111
|
+
return { kind: "answered", results: out, halt: refused3 };
|
|
10962
11112
|
}
|
|
10963
11113
|
return { kind: "answered", results: out };
|
|
10964
11114
|
}
|
|
@@ -11094,7 +11244,7 @@ function simulatedAccountRow(account2, config, now) {
|
|
|
11094
11244
|
}
|
|
11095
11245
|
const production = account2.state === "production";
|
|
11096
11246
|
const limits = production ? PRODUCTION_ACCOUNT : SANDBOX_ACCOUNT;
|
|
11097
|
-
const
|
|
11247
|
+
const problem2 = account2.problem;
|
|
11098
11248
|
const identitySet = account2.bounces_routed ? "kestrel-mail" : null;
|
|
11099
11249
|
const setName = config.sesConfigurationSet ?? identitySet;
|
|
11100
11250
|
const answer = {
|
|
@@ -11104,21 +11254,21 @@ function simulatedAccountRow(account2, config, now) {
|
|
|
11104
11254
|
maxSendRate: limits.maxSendRate,
|
|
11105
11255
|
max24HourSend: limits.max24HourSend,
|
|
11106
11256
|
sentLast24Hours: 0,
|
|
11107
|
-
enforcement:
|
|
11108
|
-
sendingEnabled:
|
|
11257
|
+
enforcement: problem2 === "shutdown" ? "shutdown" : problem2 === "probation" ? "probation" : "healthy",
|
|
11258
|
+
sendingEnabled: problem2 !== "sending_disabled",
|
|
11109
11259
|
identityConfigurationSet: { name: identitySet },
|
|
11110
|
-
senderVerified:
|
|
11260
|
+
senderVerified: problem2 !== "sender_unverified"
|
|
11111
11261
|
};
|
|
11112
11262
|
if (setName) {
|
|
11113
11263
|
answer.configurationSet = {
|
|
11114
11264
|
name: setName,
|
|
11115
|
-
exists:
|
|
11116
|
-
sendingEnabled:
|
|
11265
|
+
exists: problem2 !== "set_missing",
|
|
11266
|
+
sendingEnabled: problem2 !== "set_paused" && problem2 !== "set_missing"
|
|
11117
11267
|
};
|
|
11118
11268
|
}
|
|
11119
11269
|
return {
|
|
11120
11270
|
attemptedAt: now,
|
|
11121
|
-
outcome:
|
|
11271
|
+
outcome: problem2 === "key_rejected" ? "rejected" : "ok",
|
|
11122
11272
|
checkedAt: now,
|
|
11123
11273
|
answer
|
|
11124
11274
|
};
|
|
@@ -11638,7 +11788,7 @@ function readAgainAt(pace, now) {
|
|
|
11638
11788
|
}
|
|
11639
11789
|
|
|
11640
11790
|
// src/send/view.ts
|
|
11641
|
-
function derivePhase(status, counts2, hasRetries, due, wedged,
|
|
11791
|
+
function derivePhase(status, counts2, hasRetries, due, wedged, refused3, unavailable2, audienceFixed) {
|
|
11642
11792
|
switch (status) {
|
|
11643
11793
|
case "scheduled":
|
|
11644
11794
|
return due ? "due" : "scheduled";
|
|
@@ -11647,7 +11797,7 @@ function derivePhase(status, counts2, hasRetries, due, wedged, refused2, unavail
|
|
|
11647
11797
|
case "sent":
|
|
11648
11798
|
return counts2.accepted > 0 ? "settling" : "complete";
|
|
11649
11799
|
default: {
|
|
11650
|
-
if (wedged ||
|
|
11800
|
+
if (wedged || refused3) {
|
|
11651
11801
|
return "needs_attention";
|
|
11652
11802
|
}
|
|
11653
11803
|
if (unavailable2 && (counts2.pending > 0 || !audienceFixed)) {
|
|
@@ -11669,17 +11819,17 @@ function round(n) {
|
|
|
11669
11819
|
var TICK_MS = 6e4;
|
|
11670
11820
|
var TICK_RUN_MS = 5e3;
|
|
11671
11821
|
var TICK_START_GRACE_MS = 2e3;
|
|
11672
|
-
function tickEstimate(startedAt, total,
|
|
11673
|
-
const remaining = Math.max(0, total -
|
|
11822
|
+
function tickEstimate(startedAt, total, done2, running, now) {
|
|
11823
|
+
const remaining = Math.max(0, total - done2);
|
|
11674
11824
|
const startTick = Math.floor(startedAt / TICK_MS);
|
|
11675
11825
|
const tick = Math.floor(now / TICK_MS);
|
|
11676
11826
|
const tickStart = tick * TICK_MS;
|
|
11677
11827
|
if (running) {
|
|
11678
11828
|
const before = tick - startTick;
|
|
11679
|
-
if (before < 1 ||
|
|
11829
|
+
if (before < 1 || done2 <= 0) {
|
|
11680
11830
|
return null;
|
|
11681
11831
|
}
|
|
11682
|
-
const perTick2 =
|
|
11832
|
+
const perTick2 = done2 / (before + 0.5);
|
|
11683
11833
|
const room = perTick2 / 2;
|
|
11684
11834
|
if (remaining <= room) {
|
|
11685
11835
|
return { perTick: perTick2, finishAt: Math.max(now, tickStart + TICK_RUN_MS) };
|
|
@@ -11689,10 +11839,10 @@ function tickEstimate(startedAt, total, done, running, now) {
|
|
|
11689
11839
|
}
|
|
11690
11840
|
const waiting = tick > startTick && now - tickStart < TICK_START_GRACE_MS;
|
|
11691
11841
|
const ran = tick - startTick + (waiting ? 0 : 1);
|
|
11692
|
-
if (ran < 1 ||
|
|
11842
|
+
if (ran < 1 || done2 <= 0) {
|
|
11693
11843
|
return null;
|
|
11694
11844
|
}
|
|
11695
|
-
const perTick =
|
|
11845
|
+
const perTick = done2 / ran;
|
|
11696
11846
|
if (remaining === 0) {
|
|
11697
11847
|
return { perTick, finishAt: now };
|
|
11698
11848
|
}
|
|
@@ -11705,10 +11855,10 @@ function buildSendView(send, config, hasRetries, now, sandbox) {
|
|
|
11705
11855
|
const published = send.status === "sending" || send.status === "sent";
|
|
11706
11856
|
const summed = counts2.pending + counts2.dispatched + counts2.accepted + counts2.delivered + counts2.bounced + counts2.complained + counts2.skipped + counts2.unsent;
|
|
11707
11857
|
const total = summed > 0 ? summed : send.recipient_count;
|
|
11708
|
-
const
|
|
11709
|
-
const dispatchPercent = total > 0 ? round(100 *
|
|
11858
|
+
const done2 = Math.max(0, total - counts2.pending - counts2.dispatched);
|
|
11859
|
+
const dispatchPercent = total > 0 ? round(100 * done2 / total) : 0;
|
|
11710
11860
|
const running = send.locked_until !== null && send.locked_until > now;
|
|
11711
|
-
const estimate = send.status === "sending" && send.started_at != null ? tickEstimate(send.started_at, total,
|
|
11861
|
+
const estimate = send.status === "sending" && send.started_at != null ? tickEstimate(send.started_at, total, done2, running, now) : null;
|
|
11712
11862
|
const ratePerMin = estimate ? round(estimate.perTick) : null;
|
|
11713
11863
|
const etaMs = estimate ? Math.max(0, round(estimate.finishAt - now)) : null;
|
|
11714
11864
|
const acceptedTotal = counts2.accepted + counts2.delivered + counts2.bounced + counts2.complained;
|
|
@@ -11725,7 +11875,7 @@ function buildSendView(send, config, hasRetries, now, sandbox) {
|
|
|
11725
11875
|
since: send.halted_at,
|
|
11726
11876
|
retry_at: send.halt_retry_at
|
|
11727
11877
|
} : null;
|
|
11728
|
-
const
|
|
11878
|
+
const refused3 = halt?.reason === "account";
|
|
11729
11879
|
const unavailable2 = halt?.reason === "unavailable";
|
|
11730
11880
|
const phase = derivePhase(
|
|
11731
11881
|
send.status,
|
|
@@ -11733,7 +11883,7 @@ function buildSendView(send, config, hasRetries, now, sandbox) {
|
|
|
11733
11883
|
hasRetries,
|
|
11734
11884
|
due,
|
|
11735
11885
|
wedged,
|
|
11736
|
-
|
|
11886
|
+
refused3,
|
|
11737
11887
|
unavailable2,
|
|
11738
11888
|
send.audience_resolved_at !== null
|
|
11739
11889
|
);
|
|
@@ -11764,7 +11914,7 @@ function buildSendView(send, config, hasRetries, now, sandbox) {
|
|
|
11764
11914
|
},
|
|
11765
11915
|
counts: counts2,
|
|
11766
11916
|
dispatch: {
|
|
11767
|
-
done,
|
|
11917
|
+
done: done2,
|
|
11768
11918
|
percent: dispatchPercent,
|
|
11769
11919
|
rate_per_min: ratePerMin,
|
|
11770
11920
|
eta_ms: handingOff2 ? etaMs : null
|
|
@@ -12671,9 +12821,9 @@ var SCALE = {
|
|
|
12671
12821
|
failureRate: 6e-3
|
|
12672
12822
|
// per-send transport failures — do NOT suppress
|
|
12673
12823
|
};
|
|
12674
|
-
function drawDistinct(rand,
|
|
12824
|
+
function drawDistinct(rand, count2, n) {
|
|
12675
12825
|
const out = /* @__PURE__ */ new Set();
|
|
12676
|
-
const want = Math.min(
|
|
12826
|
+
const want = Math.min(count2, n);
|
|
12677
12827
|
let guard2 = want * 20 + 50;
|
|
12678
12828
|
while (out.size < want && guard2-- > 0) {
|
|
12679
12829
|
out.add(Math.floor(rand() * n));
|
|
@@ -12684,8 +12834,8 @@ function drawUnsentSlots(rand, audienceLen) {
|
|
|
12684
12834
|
if (audienceLen === 0) {
|
|
12685
12835
|
return /* @__PURE__ */ new Set();
|
|
12686
12836
|
}
|
|
12687
|
-
const
|
|
12688
|
-
return new Set(drawDistinct(rand,
|
|
12837
|
+
const count2 = Math.max(1, Math.round(audienceLen * SCALE.failureRate * (0.7 + rand() * 0.6)));
|
|
12838
|
+
return new Set(drawDistinct(rand, count2, audienceLen));
|
|
12689
12839
|
}
|
|
12690
12840
|
function buildScaledAudience(t, size, rand) {
|
|
12691
12841
|
const importId = newId();
|
|
@@ -12861,8 +13011,8 @@ function buildDeliveries(sendId, audience, sentAt, events, unsentSlots, skipped
|
|
|
12861
13011
|
});
|
|
12862
13012
|
}
|
|
12863
13013
|
function testRecipients(posts, t) {
|
|
12864
|
-
const
|
|
12865
|
-
return
|
|
13014
|
+
const emails2 = [...new Set(posts.flatMap((p) => p.sentTo ?? []))];
|
|
13015
|
+
return emails2.map((email2) => ({
|
|
12866
13016
|
id: newId(),
|
|
12867
13017
|
email: email2,
|
|
12868
13018
|
status: "unsubscribed",
|
|
@@ -13181,40 +13331,40 @@ async function reset(c) {
|
|
|
13181
13331
|
import { Lexer } from "marked";
|
|
13182
13332
|
|
|
13183
13333
|
// docs/get-started/02-deploy.md
|
|
13184
|
-
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 says it could not resolve the host, Cloudflare is still setting up the new hostname. Try again in a few 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';
|
|
13185
13335
|
|
|
13186
13336
|
// docs/get-started/03-access.md
|
|
13187
|
-
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
|
|
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';
|
|
13188
13338
|
|
|
13189
13339
|
// docs/get-started/04-aws-account.md
|
|
13190
|
-
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\nNext, [connect Amazon SES](05-ses.md).\n";
|
|
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";
|
|
13191
13341
|
|
|
13192
13342
|
// docs/get-started/05-ses.md
|
|
13193
|
-
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## 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';
|
|
13194
13344
|
|
|
13195
13345
|
// docs/get-started/06-production-access.md
|
|
13196
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";
|
|
13197
13347
|
|
|
13198
13348
|
// docs/get-started/07-verify.md
|
|
13199
|
-
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.\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.\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\n## 5. Unsubscribe\n\nEvery email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button.
|
|
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";
|
|
13200
13350
|
|
|
13201
13351
|
// docs/get-started/08-go-live.md
|
|
13202
|
-
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
|
|
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";
|
|
13203
13353
|
|
|
13204
13354
|
// docs/guides/01-allow-claude.md
|
|
13205
|
-
var allow_claude_default = "# Allow Claude to connect\n\nConnecting Claude to the instance lets the publisher draft, test, and schedule posts by asking for them in Claude. Claude works through the same API as the dashboard, and its changes show as the publisher's, via Claude.\n\nIn this guide, you turn on Cloudflare's Managed OAuth for the Access application that locks the dashboard, then connect Claude, or send the publisher the address to connect to.\n\nTo run a script or scheduled job with no one present, use a service token instead, as [Automate with a service token](02-automate.md) describes.\n\n## Before you begin\n\nYou need:\n\n- The dashboard locked with Access, from [Lock the dashboard with Access](../get-started/03-access.md).\n- A deployed instance. Claude Desktop and claude.ai reach it from Anthropic's servers, so they can't reach one running on your own computer.\n\n## 1. Turn on Managed OAuth\n\nManaged OAuth lets an app such as Claude act with a person's account, the way the editor does in their browser. Cloudflare marks it as beta, and its [Managed OAuth](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/) page describes it.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Applications**, and select **Configure** on your application.\n\n1. On the **Additional settings** tab, select the **OAuth** filter, and find **Managed OAuth (Beta)**.\n\n1. Turn on **Managed OAuth**.\n\n1. Turn on **Allow localhost clients** and **Allow loopback clients**. Claude Code receives the person's approval on their own computer, at a `localhost` or `127.0.0.1` address.\n\n1. Under **Allowed redirect URIs**, add Claude's address, so Claude Desktop and claude.ai can connect:\n\n ```\n https://claude.ai/api/mcp/auth_callback\n ```\n\n Access accepts Claude only with an address listed here. Leave it out if no one will connect Claude Desktop or claude.ai.\n\n1. Set the **Grant session duration** to 1 week. A connection renews itself while it's in use until this ends, and then Claude asks the person to approve it again. Cloudflare recommends one to two weeks for a command-line tool.\n\n1. Save the application.\n\n1. Check that a request with no sign-in now says where an app signs in:\n\n ```bash\n curl -si https://newsletter.example.com/api/whoami\n ```\n\n You should see `401`, with a `www-authenticate` line that starts with `Bearer`. Access can take a few minutes more to start accepting sign-ins. Until it does, signing in shows `invalid_target
|
|
13355
|
+
var allow_claude_default = "# Allow Claude to connect\n\nConnecting Claude to the instance lets the publisher draft, test, and schedule posts by asking for them in Claude. Claude works through the same API as the dashboard, and its changes show as the publisher's, via Claude.\n\nIn this guide, you turn on Cloudflare's Managed OAuth for the Access application that locks the dashboard, then connect Claude, or send the publisher the address to connect to.\n\nTo run a script or scheduled job with no one present, use a service token instead, as [Automate with a service token](02-automate.md) describes.\n\n## Before you begin\n\nYou need:\n\n- The dashboard locked with Access, from [Lock the dashboard with Access](../get-started/03-access.md).\n- A deployed instance. Claude Desktop and claude.ai reach it from Anthropic's servers, so they can't reach one running on your own computer.\n\n## 1. Turn on Managed OAuth\n\nManaged OAuth lets an app such as Claude act with a person's account, the way the editor does in their browser. Cloudflare marks it as beta, and its [Managed OAuth](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/) page describes it.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Applications**, and select **Configure** on your application.\n\n1. On the **Additional settings** tab, select the **OAuth** filter, and find **Managed OAuth (Beta)**.\n\n1. Turn on **Managed OAuth**.\n\n1. Turn on **Allow localhost clients** and **Allow loopback clients**. Claude Code receives the person's approval on their own computer, at a `localhost` or `127.0.0.1` address.\n\n1. Under **Allowed redirect URIs**, add Claude's address, so Claude Desktop and claude.ai can connect:\n\n ```\n https://claude.ai/api/mcp/auth_callback\n ```\n\n Access accepts Claude only with an address listed here. Leave it out if no one will connect Claude Desktop or claude.ai.\n\n1. Set the **Grant session duration** to 1 week. A connection renews itself while it's in use until this ends, and then Claude asks the person to approve it again. Cloudflare recommends one to two weeks for a command-line tool.\n\n1. Save the application.\n\n1. Check that a request with no sign-in now says where an app signs in:\n\n ```bash\n curl -si https://newsletter.example.com/api/whoami\n ```\n\n You should see `401`, with a `www-authenticate` line that starts with `Bearer`. Access can take a few minutes more to start accepting sign-ins. Until it does, signing in shows `invalid_target` (`OAuth not enabled for the targeted resource`); wait, then try again.\n\n## 2. Connect Claude, or send the publisher the address\n\nThe publisher connects at the instance's address followed by `/api/mcp`:\n\n```\nhttps://newsletter.example.com/api/mcp\n```\n\nIf you're the publisher, connect now, as [Connect Claude](../publishing/08-connect-claude.md) describes. If someone else is, send them that address and a link to the same page.\n\n## Who can connect\n\nAnyone the Access application's Allow policy lets into the dashboard can connect Claude, since they sign in the same way. To stop someone connecting, remove them from that policy. If you set `ACCESS_ALLOWED_EMAILS`, store the list again without them: the app checks it on every request, so it takes effect at once.\n\n## Check it\n\n1. Connect Claude Desktop or claude.ai, as [Connect Claude](../publishing/08-connect-claude.md) describes. The connector lists Kestrel's tools, such as **Get the publication** and **Save a draft**.\n\n1. Ask Claude what the publication is called. It answers with the name the dashboard shows.\n\nIf a check fails:\n\n- **Claude can't connect, or the sign-in fails.** Check section 1's settings, and that the connector's address is the instance's address followed by `/api/mcp`.\n- `kestrel login` says the instance \"doesn't offer a sign-in for programs\". Managed OAuth is off. Turn it on, as section 1 describes.\n- `kestrel login` or `kestrel api` reports a `401`. If you set `ACCESS_ALLOWED_EMAILS`, check that it includes the person's address.\n- **The sign-in says Access didn't say who the person is.** The same goes for a `401` naming `access_unidentified`. The app accepts only an Allow policy on people's emails and a Service Auth policy on a service token. Remove or change any other kind.\n";
|
|
13206
13356
|
|
|
13207
13357
|
// docs/guides/02-automate.md
|
|
13208
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';
|
|
13209
13359
|
|
|
13210
13360
|
// docs/guides/03-notifications.md
|
|
13211
|
-
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";
|
|
13212
13362
|
|
|
13213
13363
|
// docs/guides/05-rate-limit.md
|
|
13214
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";
|
|
13215
13365
|
|
|
13216
13366
|
// docs/guides/07-upgrade.md
|
|
13217
|
-
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";
|
|
13218
13368
|
|
|
13219
13369
|
// docs/guides/09-as-your-list-grows.md
|
|
13220
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";
|
|
@@ -13234,6 +13384,14 @@ All three are optional, and you can change them at any time. How they appear in
|
|
|
13234
13384
|
|
|
13235
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).
|
|
13236
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
|
+
|
|
13237
13395
|
## When to provide a mailing address
|
|
13238
13396
|
|
|
13239
13397
|
**If your newsletter is mostly your own writing, you probably don't need one. If an email is mainly selling something, add one.**
|
|
@@ -13251,10 +13409,10 @@ The From name and address your emails come from are set when Kestrel is deployed
|
|
|
13251
13409
|
`;
|
|
13252
13410
|
|
|
13253
13411
|
// docs/publishing/04-write-a-post.md
|
|
13254
|
-
var write_a_post_default = "# Write and send a post\n\nWrite in Markdown, schedule it, then send yourself a test. Scheduling makes the exact email your list will get, so the test you send is what goes out.\n\n**New post**, on the Dashboard or under **Drafts**, opens a draft.\n\n## Write\n\nThe editor takes Markdown, and Kestrel saves as you type. The same Markdown becomes the email, in your [email template](11-email-template.md), and the post's page in your archive. **Preview** shows the email.\n\n- A blank line starts a new paragraph.\n- The opening lines are the preview a mail client shows beside the subject.\n- Paste or drop an image to add it: PNG, JPEG, WebP, or GIF, up to 5 MB.\n\n## Schedule and send\n\nChoose **Schedule**, pick a time under **Send at**, and confirm. The time must be at least five minutes away by default, or the lead whoever runs your instance set. That's your window to catch a mistake, and you can cancel or move the send until then. **Send now** sends after the same wait.\n\n**Then send yourself a test.** Scheduling locks the post and makes its email, so **Send test email** now sends exactly what your list receives. **Settings \u2192 Default test recipients** fills in the addresses. A test needs an email provider, which [Connect Amazon SES](../get-started/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";
|
|
13255
13413
|
|
|
13256
13414
|
// docs/publishing/06-troubleshooting.md
|
|
13257
|
-
var troubleshooting_default = "# Troubleshooting\n\nWhen something needs you, Kestrel says so in a line on the send's page, under **Sent**. The top of the dashboard shows it too. Red needs you to act, and amber is worth a look. The line says what happened, what to do, and who does it. This page covers what the screen can't: the fixes made outside Kestrel, and the decisions that are yours.\n\n**Set a notifications address, so a problem reaches you without your watching.** Set it under **Settings \u2192 Notifications**, in **Send notifications to**: 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](06-troubleshooting.md#notifications), below, lists what you're told.\n\n## A send is held or waiting\n\nNothing is lost. A problem with your email provider never marks anyone unsent or mails anyone twice. The send waits where it is, and carries on once the problem is fixed. The send's line names the fix. When that fix is outside Kestrel, whoever runs your instance makes it, and it's one of these:\n\n- **Amazon SES refuses your account or the send.** The line carries SES's words and the fix, almost always in the SES console. [Connect Amazon SES](../get-started/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\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.\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](06-troubleshooting.md#a-send-is-held-or-waiting).\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, which whoever runs your instance makes, 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\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, and whoever runs your instance makes the fix:\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\n## Your list outgrows Workers Free\n\nUpgrade the Cloudflare account to Workers Paid, and set `SUBREQUEST_BUDGET` so Kestrel sends faster, as [As your list grows](../guides/09-as-your-list-grows.md) describes. Kestrel warns you before a send that could use up the free plan's daily database writes, which stops the app until midnight UTC. [Bring your existing list](01-your-list.md#limits) explains the limits.\n\n## Bounces and complaints\n\nKestrel acts on these for you, so a bad address can't keep hurting your sending reputation:\n\n- A hard bounce means the address can't receive mail. Kestrel never mails it again.\n- A soft bounce is temporary, such as a full mailbox. The address stays on your list.\n- A complaint means the reader marked your email as spam. Kestrel unsubscribes them, whichever email they reported, and their page, opened from their row, says they left by a complaint.\n\nUnder **Subscribers**, a bounced address keeps its status and carries a label saying why. Its page gives your email provider's words for the bounce and when it happened. When a recent send's bounces reach about 5% of its recipients, the dashboard warns you. That's about the rate at which email providers start to review a sender, so check the addresses on its record.\n\n### Lifting a suppression\n\nLift one only when you know the address works again, such as a reader who tells you their mailbox is fixed. Mailing an address that keeps failing can hurt every send after it. In a subscriber's \u22EF menu, **Unblock** lifts a block and **Clear** lifts a bounce, after asking you to confirm. A complaint isn't a suppression but the reader leaving, so nothing lifts it. They come back only by subscribing again themselves, and **Add subscriber** refuses them. Past records keep the bounce or complaint where it happened.\n\n## An import is refused\n\nNothing on your list changes. Kestrel refuses a file it can't read with certainty, and names the row and column it couldn't. Fix the file and import it again. Convert a file in another format first, with Claude or by hand. Move a column of your old service's into `evidence`. Split a file of more than 10 MB, or with too many rows. [The file format](01-your-list.md#the-file-format) describes what each column takes.\n\n## Claude can't connect\n\nMost failures are in your instance's Cloudflare Access setup, made by whoever runs your instance. [Connect Claude](08-connect-claude.md) covers connecting it. When that fails:\n\n- **Claude says it couldn't connect, or the sign-in failed.** Your instance's Access application needs Managed OAuth set up as [Turn on Managed OAuth](../guides/01-allow-claude.md#1-turn-on-managed-oauth) describes, including Claude's sign-in address for Claude Desktop and claude.ai. Check that the connector's address is your publication's address followed by `/api/mcp`, and that your instance is deployed, not running on your own computer.\n- Signing in shows `invalid_target` (`OAuth not enabled for the targeted resource`). Access can take a few minutes to start accepting sign-ins once Managed OAuth is on. Wait, then try again.\n- `kestrel login` says your instance \"doesn't offer a sign-in for programs\". Managed OAuth is off. Turn it on, as the same section describes.\n- `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in. If you set `ACCESS_ALLOWED_EMAILS`, check that it includes the address you signed in with. If either names `access_unidentified`, it's the next problem. If `kestrel api` says your sign-in has ended, sign in again as [Connect Claude](08-connect-claude.md#claude-code) describes.\n- `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. Some other kind of Access policy let it through, such as a certificate or device rule, or a login method that gives no email. Kestrel accepts only an Allow policy on people's emails and a Service Auth policy on a service token. Sign out, then sign in through the policy that lets you in by email. Whoever runs your instance removes the other policy from its Access application, or changes it to one of those two kinds. A script that gets this needs a service token under a Service Auth policy, as [Allow the token in](../guides/02-automate.md#2-allow-the-token-in) describes.\n- **Claude's changes read \"via the API\".** Claude called the API some way other than `kestrel api`, which names Claude Code. Your instance's instructions for Claude Code have it use `kestrel api` once they're brought up to date, as [Install the release](../guides/07-upgrade.md#2-install-the-release) describes. Changes reading \"a service token\" mean Claude still uses one, and [Connect Claude](08-connect-claude.md#claude-code) signs it in as you instead.\n\n## Cloudflare won't save the Access application\n\nWhoever sets up your instance can meet this in [Lock the dashboard with Access](../get-started/03-access.md#2-create-the-access-application). Saving the application fails with an error naming `use_clientless_isolation_app_launcher_url`, saying it needs private destinations. The form has an empty private hostname or IP row beside the two public hostnames. Remove that row, and save again.\n\n## Notifications\n\nWith an address set, Kestrel emails you:\n\n- when a send finishes, with how many emails were accepted, unsent, and skipped\n- when your provider refuses your account or a send, or no provider is connected\n- when the SES sandbox refuses a signup's confirmation, at most once a day\n- when email isn't going out and signups are paused, found by a reader's signup or the check of your SES account, at most once a day while it lasts\n- when a send stops going out with nothing to explain it, waits for you to resolve it, or misses its time\n- when a service token imports a list, since no one saw it happen (an import you make yourself, or through Claude, sends nothing)\n\nEach goes to every address set, links to the page it's about, and is sent once while the problem lasts. 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";
|
|
13258
13416
|
|
|
13259
13417
|
// docs/publishing/08-connect-claude.md
|
|
13260
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";
|
|
@@ -13266,10 +13424,10 @@ var share_your_subscribe_link_default = "# Share your subscribe link\n\nYour pub
|
|
|
13266
13424
|
var check_your_setup_default = "# Check your setup\n\nThree checks before your readers get anything: is email getting out, will you hear about problems, and what will your readers get. The dashboard's setup steps tick each one off from what Kestrel sees, so you, and Claude, can tell what's left.\n\n**Email has to be connected first.** Until it is, nothing can be sent, and **Send a test email** waits on the dashboard's **Connect Amazon SES** step, which [Connect Amazon SES](../get-started/05-ses.md) covers.\n\n**Until AWS grants production access, Amazon SES mails only addresses verified in SES.** So test with a verified address, and hold the subscribe link until then. The dashboard's **Request production access** step says where AWS's review stands, and [Request production access](../get-started/06-production-access.md) covers the request.\n\n## 1. Send a test email\n\nChoose **Template** in the sidebar, then **Send test email**, and send a sample post to your own inboxes. This is the test that proves email gets out: signups stay closed until SES takes an email, and the step is checked off once it does. When it arrives, check:\n\n- **The From name and address,** which readers see first. They're set when your instance is deployed.\n- **Where it lands:** the inbox, Promotions, or spam. If it lands in spam, check your sending domain's records, as [Sending-domain DNS](../reference/02-sending-domain-dns.md) describes.\n- **A reply.** Reply to the test, and check that it reaches your inbox. Kestrel never sees replies, so this depends on [forwarding your From address](../get-started/05-ses.md#8-receive-replies-at-your-from-address).\n\nIf SES refuses the test, the dashboard says why in red above the steps, with what to check. Send another test once it's fixed.\n\n## 2. Get notified\n\nUnder **Settings \u2192 Notifications**, add your address under **Send notifications to** with **Add inbox**, save, and choose **Send a test notification**. It goes to each address. If someone else runs your instance, add their address too, so a problem reaches both of you. Kestrel then emails you when a send finishes or needs you, as [Notifications](06-troubleshooting.md#notifications) lists. The step is checked off once an address is saved and the last notification arrived. If the last notification didn't arrive, the dashboard says so above the steps, with the channel's words.\n\n## 3. See what your readers get\n\nOpen your subscribe page from **Settings \u2192 Ways to subscribe**, subscribe with a personal address, and confirm. You see what a new reader sees, and you appear under **Subscribers** as **Confirmed**.\n\nThen, once your first post is scheduled, **Send a test email for your first post** from its page. Scheduling makes the exact email your list will get, so this test is what goes out, and the step is checked off once it's sent. A test sent from a draft is a preview and doesn't count, and a change to your template or publication identity re-makes the scheduled email, so the step opens again until you test the new one. [Write and send a post](04-write-a-post.md) covers scheduling.\n\n**If you're bringing more than about 1,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import.** [Bring your existing list](01-your-list.md#limits) explains why.\n";
|
|
13267
13425
|
|
|
13268
13426
|
// docs/publishing/11-email-template.md
|
|
13269
|
-
var email_template_default = "# Edit your email template\n\nYou write each post in Markdown, and the template is the HTML frame it's sent in, like a layout in a static site generator. `{{ .Post.Body }}` marks where your post goes, and the rest is plain HTML around it. The built-in template works as it comes, so changing it is optional.\n\nTo change it, choose **Template** in the sidebar, edit the HTML, and choose **Save changes**. **Start from example** offers two starting points. **Signed** ends each post with your logo, name, and tagline, and **Plain** has just your post and the footer links.\n\n## What every template needs\n\nKestrel won't save a template without `{{ .Post.Body }}` and `{{ .Email.UnsubscribeURL }}`. Every email has to carry the post and a way to leave. The two **Required** pills above the editor turn green or red as your template gains or loses them.\n\nWrap a part in `{{ if .IsEmail }}` and `{{ end }}` to keep it off each post's public page. The starting templates do this with the unsubscribe link, the View in browser link, and the mailing address.\n\nA region is a marked part, not a general `if`: `.IsEmail` is its only condition, there's no `else`, and regions don't nest. Kestrel won't save a template with a region left open, an `{{ end }}` with nothing to close, another condition, or `{{ .Post.Body }}` inside a region, which would leave the post's page empty. Keep each region's two tags inside the same element, outside any attribute, and not loose between a table's rows or cells, where the browser would move what's inside. A template that breaks that rule saves, but every email shows the built-in template instead until it's fixed, and the preview and a test say so. An unsubscribe link inside a region is fine, since every email still carries it.\n\n## Variables\n\nA variable is replaced with its value in each email. Type it exactly as shown, or it renders as empty.\n\n| Variable | What it holds |\n| --- | --- |\n| `{{ .Post.Body }}` | Your post, rendered from Markdown. Required. |\n| `{{ .Post.Subject }}` | The post's subject line. |\n| `{{ .Publication.Name }}` | Your publication's name. |\n| `{{ .Publication.Tagline }}` | Your tagline. |\n| `{{ .Publication.Logo }}` | Your logo as an image, or nothing when no logo is set. |\n| `{{ .Publication.LogoURL }}` | Your logo's address, or empty. |\n| `{{ .Publication.Address }}` | Your mailing address, or empty. |\n| `{{ .Email.SentTo }}` | The address this email was sent to. |\n| `{{ .Email.UnsubscribeURL }}` | This reader's
|
|
13427
|
+
var email_template_default = "# Edit your email template\n\nYou write each post in Markdown, and the template is the HTML frame it's sent in, like a layout in a static site generator. `{{ .Post.Body }}` marks where your post goes, and the rest is plain HTML around it. The built-in template works as it comes, so changing it is optional.\n\nTo change it, choose **Template** in the sidebar, edit the HTML, and choose **Save changes**. **Start from example** offers two starting points. **Signed** ends each post with your logo, name, and tagline, and **Plain** has just your post and the footer links.\n\n## What every template needs\n\nKestrel won't save a template without `{{ .Post.Body }}` and `{{ .Email.UnsubscribeURL }}`. Every email has to carry the post and a way to leave. The two **Required** pills above the editor turn green or red as your template gains or loses them.\n\nWrap a part in `{{ if .IsEmail }}` and `{{ end }}` to keep it off each post's public page. The starting templates do this with the unsubscribe link, the View in browser link, and the mailing address.\n\nA region is a marked part, not a general `if`: `.IsEmail` is its only condition, there's no `else`, and regions don't nest. Kestrel won't save a template with a region left open, an `{{ end }}` with nothing to close, another condition, or `{{ .Post.Body }}` inside a region, which would leave the post's page empty. Keep each region's two tags inside the same element, outside any attribute, and not loose between a table's rows or cells, where the browser would move what's inside. A template that breaks that rule saves, but every email shows the built-in template instead until it's fixed, and the preview and a test say so. An unsubscribe link inside a region is fine, since every email still carries it.\n\n## Variables\n\nA variable is replaced with its value in each email. Type it exactly as shown, or it renders as empty.\n\n| Variable | What it holds |\n| --- | --- |\n| `{{ .Post.Body }}` | Your post, rendered from Markdown. Required. |\n| `{{ .Post.Subject }}` | The post's subject line. |\n| `{{ .Publication.Name }}` | Your publication's name. |\n| `{{ .Publication.Tagline }}` | Your tagline. |\n| `{{ .Publication.Logo }}` | Your logo as an image, or nothing when no logo is set. |\n| `{{ .Publication.LogoURL }}` | Your logo's address, or empty. |\n| `{{ .Publication.Address }}` | Your mailing address, or empty. |\n| `{{ .Email.SentTo }}` | The address this email was sent to. |\n| `{{ .Email.UnsubscribeURL }}` | This reader's unsubscribe link. Required. |\n| `{{ .Email.ViewInBrowserURL }}` | The post's own web link, which opens its page once it's sent, in your archive or not. |\n\n## Test it\n\n**Send test email** on the **Template** page sends a sample post through your saved template, so you see it in a real inbox. With unsaved edits, the button reads **Save & send test**, since a test always uses the saved template.\n\nA change to the template, or to a publication detail it shows, re-makes the email of every scheduled post, with the same content and send time. Kestrel asks before it saves, and each re-made post then asks you to send a new test. A change is refused while a post is within its last few minutes before sending. Posts already sending or sent never change.\n";
|
|
13270
13428
|
|
|
13271
13429
|
// docs/publishing/README.md
|
|
13272
|
-
var README_default = "# Publishing with Kestrel\n\nThese pages get you publishing in the dashboard, and cover the decisions to make before they matter. Deploying and running Kestrel itself is covered in [Self-hosting](../README.md).\n\n## Set up your publication\n\nThese are the dashboard's setup steps, in its order. Each is checked off from what Kestrel can see, which Claude reads too, except sharing your link, which Kestrel can't see. Connecting Amazon SES and requesting production access are for whoever runs your Kestrel instance, in [Connect Amazon SES](../get-started/05-ses.md) and [Request production access](../get-started/06-production-access.md). If you're starting without a list, skip importing one.\n\n1. Connect Amazon SES\n1. [Send a test email](10-check-your-setup.md)\n1. [Name your publication](03-name-your-publication.md)\n1. Get notified\n1. Request production access\n1. [Bring your existing list](01-your-list.md)\n1. [Write your first post](04-write-a-post.md)\n1. Schedule your first post\n1. Send a test email for your first post\n1. [Share your subscribe link](09-share-your-subscribe-link.md)\n\n## Go further\n\nThings you choose to do once you're running.\n\n- [Edit your email template](11-email-template.md)\n- [Connect Claude](08-connect-claude.md)\n\n## Rules and troubleshooting\n\nWhat the law asks of every send, and what to do when something goes wrong.\n\n- [Sending rules](02-sending-rules.md)\n- [Troubleshooting](06-troubleshooting.md)\n";
|
|
13430
|
+
var README_default = "# Publishing with Kestrel\n\nThese pages get you publishing in the dashboard, and cover the decisions to make before they matter. Deploying and running Kestrel itself is covered in [Self-hosting](../README.md).\n\n## Set up your publication\n\nThese are the dashboard's setup steps, in its order. Each is checked off from what Kestrel can see, which Claude reads too, except sharing your link, which Kestrel can't see. Connecting Amazon SES and requesting production access are for whoever runs your Kestrel instance, in [Connect Amazon SES](../get-started/05-ses.md) and [Request production access](../get-started/06-production-access.md). If you're starting without a list, skip importing one. Setup ends when your first post goes out to the archive, and an email-only send doesn't count.\n\n1. Connect Amazon SES\n1. [Send a test email](10-check-your-setup.md)\n1. [Name your publication](03-name-your-publication.md)\n1. Get notified\n1. Request production access\n1. [Bring your existing list](01-your-list.md)\n1. [Write your first post](04-write-a-post.md)\n1. Schedule your first post\n1. Send a test email for your first post\n1. [Share your subscribe link](09-share-your-subscribe-link.md)\n\n## Go further\n\nThings you choose to do once you're running.\n\n- [Edit your email template](11-email-template.md)\n- [Connect Claude](08-connect-claude.md)\n\n## Rules and troubleshooting\n\nWhat the law asks of every send, and what to do when something goes wrong.\n\n- [Sending rules](02-sending-rules.md)\n- [Troubleshooting](06-troubleshooting.md)\n";
|
|
13273
13431
|
|
|
13274
13432
|
// docs/README.md
|
|
13275
13433
|
var README_default2 = "# Self-hosting Kestrel\n\nThese pages cover deploying and running your own Kestrel, from an empty repository to a live newsletter on your Cloudflare account. Writing and sending the newsletter is covered in [Publishing](publishing/README.md). These pages call whoever writes and sends it the publisher, which may be you.\n\n## Get started\n\nThese are the steps to deploy a Kestrel instance and get it ready to send. Each one ends with a check, so you know it worked before you move on.\n\n1. [Deploy the app](get-started/02-deploy.md)\n1. [Lock the dashboard with Access](get-started/03-access.md)\n1. [Create an AWS account](get-started/04-aws-account.md)\n1. [Connect Amazon SES](get-started/05-ses.md)\n1. [Request production access](get-started/06-production-access.md)\n1. [Verify it works](get-started/07-verify.md)\n1. [Go live](get-started/08-go-live.md)\n\n## Guides\n\nEach one stands on its own. Pick the ones you need.\n\n- [Allow Claude to connect](guides/01-allow-claude.md)\n- [Automate with a service token](guides/02-automate.md)\n- [Notifications through Cloudflare's email](guides/03-notifications.md)\n- [Rate-limit the subscribe form](guides/05-rate-limit.md)\n- [As your list grows](guides/09-as-your-list-grows.md)\n- [Upgrade to a new release](guides/07-upgrade.md)\n\n## Reference\n\nThe detail behind the steps, for when you need it.\n\n- [Configuration](reference/01-configuration.md)\n- [Sending-domain DNS: SPF, DKIM, and DMARC](reference/02-sending-domain-dns.md)\n";
|
|
@@ -13278,7 +13436,7 @@ var README_default2 = "# Self-hosting Kestrel\n\nThese pages cover deploying and
|
|
|
13278
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';
|
|
13279
13437
|
|
|
13280
13438
|
// docs/reference/02-sending-domain-dns.md
|
|
13281
|
-
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";
|
|
13282
13440
|
|
|
13283
13441
|
// src/docs/index.ts
|
|
13284
13442
|
var PAGES = {
|
|
@@ -13402,6 +13560,13 @@ var TARGETS = new Map([
|
|
|
13402
13560
|
]);
|
|
13403
13561
|
var MD_HREF = /href="([^"#:]+\.md)(?:#([^"]*))?"/g;
|
|
13404
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
|
+
}
|
|
13405
13570
|
function rewriteDocLinks(html, from, slug) {
|
|
13406
13571
|
return html.replace(MD_HREF, (whole, href, anchor) => {
|
|
13407
13572
|
const target = TARGETS.get(resolvePath(from, href) ?? "");
|
|
@@ -13416,7 +13581,7 @@ function renderFragment(markdown3, from, slug) {
|
|
|
13416
13581
|
maxWidth: 720,
|
|
13417
13582
|
warnings: warnings2
|
|
13418
13583
|
});
|
|
13419
|
-
return rewriteDocLinks(sanitizeEmailHtml(html), from, slug);
|
|
13584
|
+
return linkSettingsNames(rewriteDocLinks(sanitizeEmailHtml(html), from, slug));
|
|
13420
13585
|
}
|
|
13421
13586
|
function renderSets() {
|
|
13422
13587
|
return INDEXES.map((set) => ({
|
|
@@ -13861,6 +14026,17 @@ function baseName(name) {
|
|
|
13861
14026
|
const parts = name.split(/[\\/]/);
|
|
13862
14027
|
return parts[parts.length - 1] ?? "";
|
|
13863
14028
|
}
|
|
14029
|
+
var UNREFERENCEABLE = /[\s\p{Cc}<>()[\]`#?%]+/gu;
|
|
14030
|
+
function cleanName(name) {
|
|
14031
|
+
return baseName(name).replace(UNREFERENCEABLE, "-").replace(/^-+|-+$/g, "").replace(/-+\./g, ".");
|
|
14032
|
+
}
|
|
14033
|
+
function numberedName(filename, n) {
|
|
14034
|
+
if (n === 1) {
|
|
14035
|
+
return filename;
|
|
14036
|
+
}
|
|
14037
|
+
const dot = filename.lastIndexOf(".");
|
|
14038
|
+
return dot > 0 ? `${filename.slice(0, dot)}-${n}${filename.slice(dot)}` : `${filename}-${n}`;
|
|
14039
|
+
}
|
|
13864
14040
|
function publicImage(c, row) {
|
|
13865
14041
|
return {
|
|
13866
14042
|
filename: row.filename,
|
|
@@ -13870,14 +14046,16 @@ function publicImage(c, row) {
|
|
|
13870
14046
|
url: `${c.config.mediaPublicBase}/${row.storage_key}`
|
|
13871
14047
|
};
|
|
13872
14048
|
}
|
|
14049
|
+
var NOT_AN_IMAGE = "An image must be a PNG, JPEG, WebP, or GIF.";
|
|
13873
14050
|
async function uploadImage(c) {
|
|
13874
14051
|
const post = await requireDraft(c);
|
|
13875
14052
|
const ct = c.req.headers.get("content-type") ?? "";
|
|
13876
14053
|
let filename;
|
|
13877
|
-
let
|
|
14054
|
+
let declared;
|
|
13878
14055
|
let bytes;
|
|
13879
14056
|
let imageField;
|
|
13880
|
-
|
|
14057
|
+
const isForm = ct.includes("multipart/form-data");
|
|
14058
|
+
if (isForm) {
|
|
13881
14059
|
const form = await readForm(c.req);
|
|
13882
14060
|
const file = form.get("file");
|
|
13883
14061
|
if (!(file instanceof File)) {
|
|
@@ -13885,45 +14063,58 @@ async function uploadImage(c) {
|
|
|
13885
14063
|
}
|
|
13886
14064
|
imageField = { field: "file" };
|
|
13887
14065
|
const override = form.get("filename");
|
|
13888
|
-
filename =
|
|
14066
|
+
filename = cleanName(
|
|
13889
14067
|
typeof override === "string" && override ? override : file.name || "upload"
|
|
13890
14068
|
);
|
|
13891
|
-
|
|
14069
|
+
declared = file.type;
|
|
13892
14070
|
bytes = await file.arrayBuffer();
|
|
13893
14071
|
} else {
|
|
13894
|
-
filename =
|
|
13895
|
-
|
|
13896
|
-
throw badRequest("filename query param required for a raw upload", { field: "filename" });
|
|
13897
|
-
}
|
|
13898
|
-
contentType = mediaType(ct);
|
|
14072
|
+
filename = cleanName(c.url.searchParams.get("filename") ?? "");
|
|
14073
|
+
declared = ct;
|
|
13899
14074
|
bytes = await c.req.arrayBuffer();
|
|
13900
14075
|
}
|
|
13901
14076
|
if (!filename) {
|
|
13902
14077
|
throw badRequest("a filename is required", { field: "filename" });
|
|
13903
14078
|
}
|
|
13904
|
-
if (!RASTER_IMAGE_TYPES.includes(
|
|
13905
|
-
throw badRequest(
|
|
14079
|
+
if (!isForm && !RASTER_IMAGE_TYPES.includes(mediaType(declared))) {
|
|
14080
|
+
throw badRequest(NOT_AN_IMAGE);
|
|
13906
14081
|
}
|
|
13907
14082
|
if (bytes.byteLength === 0) {
|
|
13908
14083
|
throw badRequest("The image file is empty.", imageField);
|
|
13909
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
|
+
}
|
|
13910
14093
|
if (bytes.byteLength > MAX_POST_IMAGE_BYTES) {
|
|
13911
14094
|
throw badRequest(
|
|
13912
14095
|
`An image must be ${MAX_POST_IMAGE_BYTES / (1024 * 1024)} MB or smaller.`,
|
|
13913
14096
|
imageField
|
|
13914
14097
|
);
|
|
13915
14098
|
}
|
|
13916
|
-
const key = storageKey(post.id, filename);
|
|
13917
|
-
await c.env.MEDIA.put(key, bytes, { httpMetadata: { contentType } });
|
|
13918
14099
|
const dims = probeImageDimensions(new Uint8Array(bytes));
|
|
13919
|
-
|
|
13920
|
-
|
|
13921
|
-
filename,
|
|
13922
|
-
|
|
13923
|
-
|
|
13924
|
-
|
|
13925
|
-
|
|
13926
|
-
|
|
14100
|
+
let row = null;
|
|
14101
|
+
for (let n = 1; !row; n++) {
|
|
14102
|
+
const candidate = numberedName(filename, n);
|
|
14103
|
+
row = await insertImageIfNameFree(c.env.DB, {
|
|
14104
|
+
postId: post.id,
|
|
14105
|
+
filename: candidate,
|
|
14106
|
+
storageKey: storageKey(post.id, candidate),
|
|
14107
|
+
contentType,
|
|
14108
|
+
width: dims?.width ?? null,
|
|
14109
|
+
height: dims?.height ?? null
|
|
14110
|
+
});
|
|
14111
|
+
}
|
|
14112
|
+
try {
|
|
14113
|
+
await c.env.MEDIA.put(row.storage_key, bytes, { httpMetadata: { contentType } });
|
|
14114
|
+
} catch (e) {
|
|
14115
|
+
await deleteImageRow(c.env.DB, post.id, row.filename);
|
|
14116
|
+
throw e;
|
|
14117
|
+
}
|
|
13927
14118
|
const body = { image: publicImage(c, row) };
|
|
13928
14119
|
return json(body, 201);
|
|
13929
14120
|
}
|
|
@@ -13960,6 +14151,7 @@ async function serveMedia(c) {
|
|
|
13960
14151
|
obj.writeHttpMetadata(headers2);
|
|
13961
14152
|
headers2.set("etag", obj.httpEtag);
|
|
13962
14153
|
headers2.set("cache-control", "public, max-age=3600");
|
|
14154
|
+
headers2.set("content-length", String(obj.size));
|
|
13963
14155
|
headers2.set("x-content-type-options", "nosniff");
|
|
13964
14156
|
headers2.set("content-security-policy", "sandbox");
|
|
13965
14157
|
return new Response(obj.body, { headers: headers2 });
|
|
@@ -14222,8 +14414,7 @@ async function commitImport(db, plan2, principal, now) {
|
|
|
14222
14414
|
confirm: [],
|
|
14223
14415
|
addConfirmed: [],
|
|
14224
14416
|
suppress: [],
|
|
14225
|
-
erase: []
|
|
14226
|
-
notice: principal.kind === "service"
|
|
14417
|
+
erase: []
|
|
14227
14418
|
};
|
|
14228
14419
|
const tokens = newTokens(plan2.counts.added_confirmed + plan2.counts.added_unsubscribed);
|
|
14229
14420
|
const token2 = () => tokens.pop() ?? "";
|
|
@@ -14467,9 +14658,9 @@ async function commit(c) {
|
|
|
14467
14658
|
if (!principal) {
|
|
14468
14659
|
throw badRequest("no principal");
|
|
14469
14660
|
}
|
|
14470
|
-
const
|
|
14471
|
-
if (
|
|
14472
|
-
return json(replayResponse(
|
|
14661
|
+
const done2 = await getImportByPlanHash(c.env.DB, planHash);
|
|
14662
|
+
if (done2 && done2.file_sha256 === await fileSha256(input.text)) {
|
|
14663
|
+
return json(replayResponse(done2));
|
|
14473
14664
|
}
|
|
14474
14665
|
const p = await plan(c, input);
|
|
14475
14666
|
if (p.planHash !== planHash) {
|
|
@@ -14687,24 +14878,24 @@ async function get2(c) {
|
|
|
14687
14878
|
|
|
14688
14879
|
// src/emails/system.ts
|
|
14689
14880
|
var FONT2 = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
|
|
14690
|
-
var
|
|
14881
|
+
var HEAD_STYLE = `<style>@media (prefers-color-scheme: dark) {
|
|
14691
14882
|
.k-sys-bg { background:#18181b !important; }
|
|
14692
|
-
.k-sys-card { background:#232327 !important; }
|
|
14693
14883
|
.k-sys-body, .k-sys-name { color:#ededed !important; }
|
|
14694
14884
|
.k-sys-tagline, .k-sys-muted { color:#a1a1aa !important; }
|
|
14695
14885
|
.k-sys-rule { border-color:#2e2e33 !important; }
|
|
14696
14886
|
.k-sys-btn { background:#e5e7eb !important; color:#18181b !important; }
|
|
14697
|
-
}
|
|
14887
|
+
}
|
|
14888
|
+
@media (max-width:560px) { .k-sys-body { padding-left:12px !important; padding-right:12px !important; } }</style>`;
|
|
14698
14889
|
function systemEmailLayout(subject, innerHtml) {
|
|
14699
14890
|
return `<!doctype html>
|
|
14700
14891
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
14701
14892
|
<meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark">
|
|
14702
14893
|
<title>${escapeHtml(subject)}</title>
|
|
14703
|
-
${
|
|
14704
|
-
<body class="k-sys-bg" style="margin:0;padding:0;background:#
|
|
14705
|
-
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
|
14706
|
-
<table role="presentation" width="
|
|
14707
|
-
<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;">
|
|
14708
14899
|
${innerHtml}
|
|
14709
14900
|
</td></tr>
|
|
14710
14901
|
</table>
|
|
@@ -14807,12 +14998,12 @@ function requireConnectedProvider(config) {
|
|
|
14807
14998
|
);
|
|
14808
14999
|
}
|
|
14809
15000
|
}
|
|
14810
|
-
function perRecipient(result2,
|
|
15001
|
+
function perRecipient(result2, recipients2) {
|
|
14811
15002
|
if (result2.kind === "answered") {
|
|
14812
15003
|
return result2.results;
|
|
14813
15004
|
}
|
|
14814
15005
|
const { reason, error: error2 } = result2.halt;
|
|
14815
|
-
return
|
|
15006
|
+
return recipients2.map((r) => ({
|
|
14816
15007
|
email: r.email,
|
|
14817
15008
|
accepted: false,
|
|
14818
15009
|
retryable: reason === "unavailable",
|
|
@@ -14891,10 +15082,10 @@ async function confirmationMessage(c, token2) {
|
|
|
14891
15082
|
}
|
|
14892
15083
|
async function sendConfirmation(c, message2, subscriber, attemptAt, fromReader) {
|
|
14893
15084
|
const provider = getProvider(c.config, c.env);
|
|
14894
|
-
const
|
|
15085
|
+
const recipients2 = [{ email: subscriber.email, unsubscribeUrl: "" }];
|
|
14895
15086
|
let result2;
|
|
14896
15087
|
try {
|
|
14897
|
-
result2 = await provider.sendBatch(message2,
|
|
15088
|
+
result2 = await provider.sendBatch(message2, recipients2, {
|
|
14898
15089
|
// One tag per confirmation sent, never reused: the fake records once per tag and
|
|
14899
15090
|
// address, and would otherwise fold a deliberate resend into the first.
|
|
14900
15091
|
purpose: "confirmation",
|
|
@@ -15292,21 +15483,21 @@ var FakeNotifier = class {
|
|
|
15292
15483
|
};
|
|
15293
15484
|
|
|
15294
15485
|
// src/services/test_send.ts
|
|
15295
|
-
async function sendTest(db, config, provider, email2,
|
|
15486
|
+
async function sendTest(db, config, provider, email2, recipients2, opts, kind) {
|
|
15296
15487
|
let answer;
|
|
15297
15488
|
try {
|
|
15298
|
-
answer = await provider.sendBatch(email2,
|
|
15489
|
+
answer = await provider.sendBatch(email2, recipients2, opts);
|
|
15299
15490
|
} catch (err) {
|
|
15300
15491
|
const error2 = err instanceof Error ? err.message : String(err);
|
|
15301
15492
|
const notSent = noAnswer(error2);
|
|
15302
|
-
logNotSent(provider, kind,
|
|
15303
|
-
return
|
|
15493
|
+
logNotSent(provider, kind, recipients2.length, notSent);
|
|
15494
|
+
return recipients2.map((r) => ({ email: r.email, sent: false, notSent }));
|
|
15304
15495
|
}
|
|
15305
15496
|
await noteContact(db, config, "test", contactOutcome(answer), Date.now());
|
|
15306
15497
|
const halt = answer.halt;
|
|
15307
15498
|
const unknown = [];
|
|
15308
|
-
const
|
|
15309
|
-
const answers = perRecipient(answer,
|
|
15499
|
+
const refused3 = [];
|
|
15500
|
+
const answers = perRecipient(answer, recipients2).map((r) => {
|
|
15310
15501
|
if (r.accepted) {
|
|
15311
15502
|
return { email: r.email, sent: true };
|
|
15312
15503
|
}
|
|
@@ -15315,15 +15506,15 @@ async function sendTest(db, config, provider, email2, recipients, opts, kind) {
|
|
|
15315
15506
|
if (notSent.may_have_arrived) {
|
|
15316
15507
|
unknown.push(notSent);
|
|
15317
15508
|
} else if (covering || !r.retryable) {
|
|
15318
|
-
|
|
15509
|
+
refused3.push(notSent);
|
|
15319
15510
|
}
|
|
15320
15511
|
return { email: r.email, sent: false, notSent };
|
|
15321
15512
|
});
|
|
15322
15513
|
if (unknown[0]) {
|
|
15323
15514
|
logNotSent(provider, kind, unknown.length, unknown[0]);
|
|
15324
15515
|
}
|
|
15325
|
-
if (
|
|
15326
|
-
logNotSent(provider, kind,
|
|
15516
|
+
if (refused3[0]) {
|
|
15517
|
+
logNotSent(provider, kind, refused3.length, refused3[0]);
|
|
15327
15518
|
}
|
|
15328
15519
|
return answers;
|
|
15329
15520
|
}
|
|
@@ -15339,30 +15530,30 @@ function why(r, halt) {
|
|
|
15339
15530
|
return noAnswer(r.error);
|
|
15340
15531
|
}
|
|
15341
15532
|
const error2 = halt?.error ?? r.error;
|
|
15342
|
-
const
|
|
15533
|
+
const refused3 = (message2) => ({ may_have_arrived: false, error: error2, message: message2 });
|
|
15343
15534
|
if (halt?.reason === "account") {
|
|
15344
|
-
return
|
|
15535
|
+
return refused3(
|
|
15345
15536
|
`The provider refused it: ${sentence(error2)} ${refusalAdvice(halt.cause)} Sending the test again gets the same answer until that is fixed.`
|
|
15346
15537
|
);
|
|
15347
15538
|
}
|
|
15348
15539
|
if (halt) {
|
|
15349
|
-
return
|
|
15540
|
+
return refused3(
|
|
15350
15541
|
`The provider can\u2019t take mail right now: ${sentence(error2)} Send the test again in a few minutes.`
|
|
15351
15542
|
);
|
|
15352
15543
|
}
|
|
15353
15544
|
if (r.retryable) {
|
|
15354
|
-
return
|
|
15545
|
+
return refused3(`It wasn\u2019t sent: ${sentence(error2)} Send the test again to try it.`);
|
|
15355
15546
|
}
|
|
15356
|
-
return
|
|
15547
|
+
return refused3(
|
|
15357
15548
|
`The provider refused it: ${sentence(error2)} Sending to this address again gets the same answer until what the provider names is fixed.`
|
|
15358
15549
|
);
|
|
15359
15550
|
}
|
|
15360
|
-
function logNotSent(provider, kind,
|
|
15551
|
+
function logNotSent(provider, kind, count2, first) {
|
|
15361
15552
|
log.warn(first.may_have_arrived ? "test.unknown" : "test.refused", {
|
|
15362
15553
|
provider: provider.name,
|
|
15363
15554
|
test: kind.test,
|
|
15364
15555
|
postId: kind.postId,
|
|
15365
|
-
count,
|
|
15556
|
+
count: count2,
|
|
15366
15557
|
error: first.error
|
|
15367
15558
|
});
|
|
15368
15559
|
}
|
|
@@ -15454,13 +15645,13 @@ async function test(c) {
|
|
|
15454
15645
|
const { input, email: email2, warnings: warnings2, frozen } = await loadPostEmail(c);
|
|
15455
15646
|
const provider = getProvider(c.config, c.env);
|
|
15456
15647
|
const unsubscribeUrl = testUnsubscribeUrl(c.config);
|
|
15457
|
-
const
|
|
15648
|
+
const recipients2 = [{ email: to, unsubscribeUrl }];
|
|
15458
15649
|
const [res] = await sendTest(
|
|
15459
15650
|
c.env.DB,
|
|
15460
15651
|
c.config,
|
|
15461
15652
|
provider,
|
|
15462
15653
|
email2,
|
|
15463
|
-
|
|
15654
|
+
recipients2,
|
|
15464
15655
|
{
|
|
15465
15656
|
purpose: "test",
|
|
15466
15657
|
tag: `test-${input.post.id}`,
|
|
@@ -15485,21 +15676,22 @@ async function test(c) {
|
|
|
15485
15676
|
return json(result2);
|
|
15486
15677
|
}
|
|
15487
15678
|
var MAX_TEMPLATE_TEST_RECIPIENTS = 20;
|
|
15488
|
-
var TEMPLATE_TEST_SUBJECT = "Template test
|
|
15679
|
+
var TEMPLATE_TEST_SUBJECT = "Template test: how your posts will look";
|
|
15680
|
+
var SAMPLE_POST_SUBJECT = "How your posts will look";
|
|
15489
15681
|
var TEMPLATE_TEST_MARKDOWN = [
|
|
15490
|
-
|
|
15682
|
+
`# ${SAMPLE_POST_SUBJECT}`,
|
|
15491
15683
|
"",
|
|
15492
|
-
"
|
|
15684
|
+
"This is a test of your email template, with sample text. A real post\u2019s Markdown fills this space.",
|
|
15493
15685
|
"",
|
|
15494
|
-
"
|
|
15686
|
+
"It shows the parts of a post that a template styles:",
|
|
15495
15687
|
"",
|
|
15496
|
-
"-
|
|
15497
|
-
"-
|
|
15498
|
-
"-
|
|
15688
|
+
"- Headings, like the one above",
|
|
15689
|
+
"- **Bold** text",
|
|
15690
|
+
"- [Links](https://example.com)",
|
|
15691
|
+
"- Lists, like this one",
|
|
15499
15692
|
"",
|
|
15500
|
-
"
|
|
15693
|
+
"> And quotes, like this one."
|
|
15501
15694
|
].join("\n");
|
|
15502
|
-
var SAMPLE_POST_SUBJECT = "The starlings are back";
|
|
15503
15695
|
function sampleRenderInput(subject = TEMPLATE_TEST_SUBJECT) {
|
|
15504
15696
|
const now = Date.now();
|
|
15505
15697
|
const post = {
|
|
@@ -15560,14 +15752,14 @@ function resolveTestRecipients(body, defaults) {
|
|
|
15560
15752
|
async function templateTest(c) {
|
|
15561
15753
|
const settings = await getSettings(c.env.DB);
|
|
15562
15754
|
const body = await readJsonObject(c, { optional: true });
|
|
15563
|
-
const
|
|
15564
|
-
if (
|
|
15755
|
+
const recipients2 = resolveTestRecipients(body, settings.testRecipients);
|
|
15756
|
+
if (recipients2.length === 0) {
|
|
15565
15757
|
throw fieldError(
|
|
15566
15758
|
"to",
|
|
15567
15759
|
"There\u2019s no one to send the test to. Enter an address, or set default test recipients in Settings."
|
|
15568
15760
|
);
|
|
15569
15761
|
}
|
|
15570
|
-
if (
|
|
15762
|
+
if (recipients2.length > MAX_TEMPLATE_TEST_RECIPIENTS) {
|
|
15571
15763
|
throw fieldError(
|
|
15572
15764
|
"to",
|
|
15573
15765
|
`A test can go to at most ${MAX_TEMPLATE_TEST_RECIPIENTS} addresses. Remove some, then send again.`
|
|
@@ -15578,7 +15770,7 @@ async function templateTest(c) {
|
|
|
15578
15770
|
const provider = getProvider(c.config, c.env);
|
|
15579
15771
|
const unsubscribeUrl = testUnsubscribeUrl(c.config);
|
|
15580
15772
|
const testId = `template-test-${crypto.randomUUID()}`;
|
|
15581
|
-
const batch =
|
|
15773
|
+
const batch = recipients2.map((email2) => ({ email: email2, unsubscribeUrl }));
|
|
15582
15774
|
const answers = await sendTest(
|
|
15583
15775
|
c.env.DB,
|
|
15584
15776
|
c.config,
|
|
@@ -15591,11 +15783,11 @@ async function templateTest(c) {
|
|
|
15591
15783
|
const notSent = answers.flatMap((a) => a.sent ? [] : [{ email: a.email, ...a.notSent }]);
|
|
15592
15784
|
const report = {
|
|
15593
15785
|
sent: answers.length - notSent.length,
|
|
15594
|
-
total:
|
|
15786
|
+
total: recipients2.length,
|
|
15595
15787
|
failed: notSent.map((n) => n.email),
|
|
15596
15788
|
not_sent: notSent,
|
|
15597
15789
|
provider: provider.name,
|
|
15598
|
-
recipients,
|
|
15790
|
+
recipients: recipients2,
|
|
15599
15791
|
subject: result2.subject,
|
|
15600
15792
|
warnings: result2.warnings
|
|
15601
15793
|
};
|
|
@@ -15770,13 +15962,13 @@ var ProviderNotifier = class {
|
|
|
15770
15962
|
channel = "provider";
|
|
15771
15963
|
async send(to, message2, key, contacts) {
|
|
15772
15964
|
const provider = getProvider(this.config, this.env);
|
|
15773
|
-
const
|
|
15774
|
-
const answer = await provider.sendBatch(message2,
|
|
15965
|
+
const recipients2 = [{ email: to, unsubscribeUrl: "" }];
|
|
15966
|
+
const answer = await provider.sendBatch(message2, recipients2, {
|
|
15775
15967
|
purpose: "notification",
|
|
15776
15968
|
tag: `notify-${key}`
|
|
15777
15969
|
});
|
|
15778
15970
|
contacts.add("notification", contactOutcome(answer), Date.now());
|
|
15779
|
-
const [result2] = perRecipient(answer,
|
|
15971
|
+
const [result2] = perRecipient(answer, recipients2);
|
|
15780
15972
|
if (!result2?.accepted) {
|
|
15781
15973
|
throw new Error(result2?.error ?? "the provider gave no answer for the recipient");
|
|
15782
15974
|
}
|
|
@@ -15784,154 +15976,252 @@ var ProviderNotifier = class {
|
|
|
15784
15976
|
};
|
|
15785
15977
|
|
|
15786
15978
|
// src/notify/compose.ts
|
|
15979
|
+
var NO_ONE_HARMED = "No one has been marked unsent because of this, and no one will be mailed twice.";
|
|
15980
|
+
var WHY_YOU_GOT_THIS = "You get these because this address is listed under Settings \u2192 Notifications.";
|
|
15981
|
+
var CRON = "Check the Worker\u2019s Cron Trigger and logs.";
|
|
15787
15982
|
function when(ms) {
|
|
15788
15983
|
return `${new Date(ms).toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
15789
15984
|
}
|
|
15790
15985
|
function minutes2(ms) {
|
|
15791
15986
|
return Math.round(ms / 6e4);
|
|
15792
15987
|
}
|
|
15988
|
+
function count(n) {
|
|
15989
|
+
return n.toLocaleString("en-US");
|
|
15990
|
+
}
|
|
15991
|
+
function emails(n) {
|
|
15992
|
+
return `${count(n)} ${n === 1 ? "email" : "emails"}`;
|
|
15993
|
+
}
|
|
15994
|
+
function recipients(n) {
|
|
15995
|
+
return `${count(n)} ${n === 1 ? "recipient" : "recipients"}`;
|
|
15996
|
+
}
|
|
15997
|
+
function said(error2) {
|
|
15998
|
+
return `SES said: ${scrub(error2?.trim() || "no detail given")}`;
|
|
15999
|
+
}
|
|
15793
16000
|
function sendLink(config, sendId) {
|
|
15794
16001
|
return dashboardLink(config.appOrigin, `/sent/${encodeURIComponent(sendId)}`);
|
|
15795
16002
|
}
|
|
15796
16003
|
function message(subject, paragraphs, link) {
|
|
15797
16004
|
const text2 = `${paragraphs.join("\n\n")}
|
|
15798
16005
|
|
|
15799
|
-
${link}
|
|
16006
|
+
${link.label}: ${link.url}
|
|
16007
|
+
|
|
16008
|
+
${WHY_YOU_GOT_THIS}
|
|
15800
16009
|
`;
|
|
15801
16010
|
const html = `<!doctype html><html><body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:15px;line-height:1.5;color:#18181b;">
|
|
15802
16011
|
${paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join("\n")}
|
|
15803
|
-
<p><a href="${escapeHtmlAttr(link)}">${escapeHtml(link)}</a></p>
|
|
16012
|
+
<p><a href="${escapeHtmlAttr(link.url)}">${escapeHtml(link.label)}</a></p>
|
|
16013
|
+
<p style="font-size:13px;color:#71717a;">${escapeHtml(WHY_YOU_GOT_THIS)}</p>
|
|
15804
16014
|
</body></html>`;
|
|
15805
16015
|
return { subject, html, text: text2 };
|
|
15806
16016
|
}
|
|
16017
|
+
function done(slots) {
|
|
16018
|
+
return message(
|
|
16019
|
+
slots.subject,
|
|
16020
|
+
slots.more ? [slots.happened, slots.more] : [slots.happened],
|
|
16021
|
+
slots.link
|
|
16022
|
+
);
|
|
16023
|
+
}
|
|
16024
|
+
function problem(slots) {
|
|
16025
|
+
const paragraphs = [
|
|
16026
|
+
slots.affected ? `${slots.happened} ${slots.affected}` : slots.happened,
|
|
16027
|
+
slots.said,
|
|
16028
|
+
slots.todo,
|
|
16029
|
+
slots.next
|
|
16030
|
+
];
|
|
16031
|
+
return message(
|
|
16032
|
+
slots.subject,
|
|
16033
|
+
paragraphs.filter((p) => Boolean(p)),
|
|
16034
|
+
slots.link
|
|
16035
|
+
);
|
|
16036
|
+
}
|
|
16037
|
+
function finished(n, title, config) {
|
|
16038
|
+
const report = { label: "See the send report", url: sendLink(config, n.send_id) };
|
|
16039
|
+
const at = n.completed_at ? ` at ${when(n.completed_at)}` : "";
|
|
16040
|
+
const accepted2 = n.c_accepted + n.c_delivered + n.c_bounced + n.c_complained;
|
|
16041
|
+
const total = accepted2 + n.c_unsent + n.c_pending + n.c_in_flight;
|
|
16042
|
+
if (accepted2 === 0 && total > 0) {
|
|
16043
|
+
return problem({
|
|
16044
|
+
subject: `${title} didn\u2019t reach anyone`,
|
|
16045
|
+
happened: `${title} finished${at}, but your email provider accepted none of its ${emails(total)}.`,
|
|
16046
|
+
affected: "A finished post can\u2019t be sent again.",
|
|
16047
|
+
todo: "The send report shows why each one failed. Once that\u2019s sorted out, put the post\u2019s content in a new post to send it.",
|
|
16048
|
+
link: report
|
|
16049
|
+
});
|
|
16050
|
+
}
|
|
16051
|
+
const others = total - accepted2;
|
|
16052
|
+
const outcome = total === 0 ? "Everyone it was going to had left the list since it was scheduled, so there was no one left to send to." : others === 0 ? `Your email provider accepted ${total === 1 ? "its one email" : `all ${emails(total)}`}.` : `Your email provider accepted ${count(accepted2)} of ${emails(total)}. The send report shows why ${others === 1 ? "the other one wasn\u2019t" : `the other ${count(others)} weren\u2019t`} sent.`;
|
|
16053
|
+
const lateBy = (n.started_at ?? n.fire_at) - n.fire_at;
|
|
16054
|
+
return done({
|
|
16055
|
+
subject: `Sent: ${n.subject}`,
|
|
16056
|
+
happened: `${title} finished sending${at}. ${outcome}`,
|
|
16057
|
+
more: lateBy > MISSED_THRESHOLD_MS ? `It went out ${minutes2(lateBy)} minutes late, because Kestrel\u2019s once-a-minute sending job wasn\u2019t running or failed on it. If this keeps happening, check the Worker\u2019s Cron Trigger and logs. ${PASS_THIS_ALONG}` : void 0,
|
|
16058
|
+
link: report
|
|
16059
|
+
});
|
|
16060
|
+
}
|
|
16061
|
+
function refused2(n, title, config) {
|
|
16062
|
+
const link = { label: "See this send", url: sendLink(config, n.send_id) };
|
|
16063
|
+
const at = when(n.halted_at ?? n.episode);
|
|
16064
|
+
const waits = "any other post that comes due waits with it";
|
|
16065
|
+
if (n.halt_cause === "not_connected") {
|
|
16066
|
+
return problem({
|
|
16067
|
+
subject: `${title} is waiting: no email provider is connected`,
|
|
16068
|
+
happened: `${title} came due at ${when(n.fire_at)}, but no email provider is connected to this Kestrel instance, so it hasn\u2019t gone out.`,
|
|
16069
|
+
todo: `${refusalAdvice(n.halt_cause)} ${PASS_THIS_ALONG}`,
|
|
16070
|
+
next: `It goes out within the hour after that, and ${waits}. ${NO_ONE_HARMED}`,
|
|
16071
|
+
link
|
|
16072
|
+
});
|
|
16073
|
+
}
|
|
16074
|
+
if (n.halt_cause === "invalid_request") {
|
|
16075
|
+
return problem({
|
|
16076
|
+
subject: `${title} is waiting: Amazon SES refused part of it`,
|
|
16077
|
+
happened: `Amazon SES refused part of ${title} as invalid at ${at}.`,
|
|
16078
|
+
affected: "Other posts go out as usual.",
|
|
16079
|
+
said: said(n.halt_error),
|
|
16080
|
+
// Not `refusalAdvice`, which is read where the address shows: here the email hid it.
|
|
16081
|
+
todo: "If SES\u2019s words name an address, the send\u2019s page shows it in full. Remove that subscriber, and the send skips them.",
|
|
16082
|
+
next: `It carries on at its next try. ${NO_ONE_HARMED}`,
|
|
16083
|
+
link
|
|
16084
|
+
});
|
|
16085
|
+
}
|
|
16086
|
+
if (isSendOwnHalt(n.halt_cause)) {
|
|
16087
|
+
return problem({
|
|
16088
|
+
subject: `${title} is waiting: Amazon SES refused it`,
|
|
16089
|
+
happened: `Amazon SES started refusing ${title} itself at ${at}.`,
|
|
16090
|
+
affected: "Other posts go out as usual.",
|
|
16091
|
+
said: said(n.halt_error),
|
|
16092
|
+
todo: refusalAdvice(n.halt_cause),
|
|
16093
|
+
next: `It keeps trying, at most an hour apart. ${NO_ONE_HARMED}`,
|
|
16094
|
+
link
|
|
16095
|
+
});
|
|
16096
|
+
}
|
|
16097
|
+
const held = n.c_pending + n.c_in_flight;
|
|
16098
|
+
const left = `${emails(held)} ${held === 1 ? "is" : "are"} still to go.`;
|
|
16099
|
+
if (n.halt_cause === "quota") {
|
|
16100
|
+
return problem({
|
|
16101
|
+
subject: `${title} is waiting: SES\u2019s daily quota is used up`,
|
|
16102
|
+
happened: `${title} stopped at ${at} because SES\u2019s 24-hour quota is used up.`,
|
|
16103
|
+
affected: left,
|
|
16104
|
+
said: said(n.halt_error),
|
|
16105
|
+
todo: refusalAdvice(n.halt_cause),
|
|
16106
|
+
next: `It carries on by itself when the quota frees up, trying at most an hour apart, and ${waits}. ${NO_ONE_HARMED}`,
|
|
16107
|
+
link
|
|
16108
|
+
});
|
|
16109
|
+
}
|
|
16110
|
+
if (n.halt_cause === "sandbox") {
|
|
16111
|
+
return problem({
|
|
16112
|
+
subject: `${title} is waiting: SES is still in the sandbox`,
|
|
16113
|
+
happened: `${title} stopped at ${at} because your SES account is still in the sandbox, which mails only addresses verified in SES.`,
|
|
16114
|
+
affected: left,
|
|
16115
|
+
said: said(n.halt_error),
|
|
16116
|
+
todo: refusalAdvice(n.halt_cause),
|
|
16117
|
+
next: `It carries on by itself once AWS grants production access, trying at most an hour apart, and ${waits}. ${NO_ONE_HARMED}`,
|
|
16118
|
+
link
|
|
16119
|
+
});
|
|
16120
|
+
}
|
|
16121
|
+
return problem({
|
|
16122
|
+
subject: `${title} is waiting: Amazon SES is refusing your account`,
|
|
16123
|
+
happened: `Amazon SES started refusing your account at ${at}, so ${title} is waiting.`,
|
|
16124
|
+
affected: left,
|
|
16125
|
+
said: said(n.halt_error),
|
|
16126
|
+
todo: refusalAdvice(n.halt_cause),
|
|
16127
|
+
next: `It carries on by itself within the hour once that\u2019s fixed, and ${waits}. ${NO_ONE_HARMED}`,
|
|
16128
|
+
link
|
|
16129
|
+
});
|
|
16130
|
+
}
|
|
15807
16131
|
function composeNotification(n, config) {
|
|
15808
|
-
const link = sendLink(config, n.send_id);
|
|
16132
|
+
const link = { label: "See this send", url: sendLink(config, n.send_id) };
|
|
15809
16133
|
const title = `"${n.subject}"`;
|
|
15810
16134
|
switch (n.kind) {
|
|
15811
|
-
case "finished":
|
|
15812
|
-
|
|
15813
|
-
const audience = accepted2 + n.c_unsent + n.c_skipped + n.c_pending + n.c_in_flight;
|
|
15814
|
-
return message(
|
|
15815
|
-
`Sent: ${n.subject}`,
|
|
15816
|
-
[
|
|
15817
|
-
`${title} finished sending${n.completed_at ? ` at ${when(n.completed_at)}` : ""}.`,
|
|
15818
|
-
`Accepted by the provider: ${accepted2} of ${audience}. Unsent: ${n.c_unsent}. Skipped (unsubscribed or suppressed since it was scheduled): ${n.c_skipped}.`,
|
|
15819
|
-
"Delivery receipts, bounces, and complaints keep arriving after this; the record shows them as they land."
|
|
15820
|
-
],
|
|
15821
|
-
link
|
|
15822
|
-
);
|
|
15823
|
-
}
|
|
16135
|
+
case "finished":
|
|
16136
|
+
return finished(n, title, config);
|
|
15824
16137
|
case "refused":
|
|
15825
|
-
|
|
15826
|
-
return message(
|
|
15827
|
-
`Problem with ${title}: no email provider is connected`,
|
|
15828
|
-
[
|
|
15829
|
-
`No email provider is connected to this deployment, so ${title} is held where it is.`,
|
|
15830
|
-
refusalAdvice(n.halt_cause),
|
|
15831
|
-
"No one has been marked unsent because of this. The send keeps trying, at most an hour apart, and goes out at the first try after a provider is connected, mailing no one twice. Any other post that comes due meanwhile waits with it, and you are not told again."
|
|
15832
|
-
],
|
|
15833
|
-
link
|
|
15834
|
-
);
|
|
15835
|
-
}
|
|
15836
|
-
if (isSendOwnHalt(n.halt_cause)) {
|
|
15837
|
-
return message(
|
|
15838
|
-
`Problem with ${title}: the provider is refusing it`,
|
|
15839
|
-
[
|
|
15840
|
-
`The email provider is refusing ${title} itself, so it is paused where it is. The provider said: ${n.halt_error || "no detail given"}`,
|
|
15841
|
-
refusalAdvice(n.halt_cause),
|
|
15842
|
-
"No one has been marked unsent because of this. The send keeps trying, at most an hour apart, mailing no one twice. Other posts go out as usual."
|
|
15843
|
-
],
|
|
15844
|
-
link
|
|
15845
|
-
);
|
|
15846
|
-
}
|
|
15847
|
-
return message(
|
|
15848
|
-
`Problem with ${title}: the provider is refusing your account`,
|
|
15849
|
-
[
|
|
15850
|
-
`The email provider is refusing this account, so ${title} is paused where it is. The provider said: ${n.halt_error || "no detail given"}`,
|
|
15851
|
-
refusalAdvice(n.halt_cause),
|
|
15852
|
-
"No one has been marked unsent because of this. The send keeps trying, at most an hour apart, and resumes on its own at the next try after the account is fixed, mailing no one twice. Any other post that comes due meanwhile waits with it, and you are not told again."
|
|
15853
|
-
],
|
|
15854
|
-
link
|
|
15855
|
-
);
|
|
16138
|
+
return refused2(n, title, config);
|
|
15856
16139
|
case "stuck":
|
|
15857
|
-
return
|
|
15858
|
-
|
|
15859
|
-
|
|
15860
|
-
|
|
15861
|
-
|
|
15862
|
-
|
|
16140
|
+
return problem({
|
|
16141
|
+
subject: `${title} is waiting: nothing has gone out for ${minutes2(STUCK_THRESHOLD_MS)} minutes`,
|
|
16142
|
+
happened: `Nothing has gone out for ${title} since ${when(n.progress_at ?? n.started_at ?? n.episode)}, and no provider problem explains it.`,
|
|
16143
|
+
affected: `${recipients(n.c_pending + n.c_in_flight)} ${n.c_pending + n.c_in_flight === 1 ? "is" : "are"} still waiting.`,
|
|
16144
|
+
todo: `If another post is going out, this one picks up once it finishes. Otherwise Kestrel\u2019s once-a-minute sending job may have stopped or keep failing. ${CRON} ${PASS_THIS_ALONG}`,
|
|
16145
|
+
next: "You don\u2019t need to restart it. It picks up where it stopped, mailing no one twice.",
|
|
15863
16146
|
link
|
|
15864
|
-
);
|
|
16147
|
+
});
|
|
15865
16148
|
case "wedged":
|
|
15866
|
-
return
|
|
15867
|
-
|
|
15868
|
-
|
|
15869
|
-
|
|
15870
|
-
|
|
15871
|
-
|
|
16149
|
+
return problem({
|
|
16150
|
+
subject: `${title} is waiting for you: ${recipients(n.c_in_flight)} to resolve`,
|
|
16151
|
+
happened: `Your email provider never answered for ${recipients(n.c_in_flight)} of ${title}.`,
|
|
16152
|
+
affected: "Kestrel can\u2019t tell whether they got it, and sending again could mail them twice.",
|
|
16153
|
+
todo: "Open the send and choose Resolve. Unless you know otherwise, choose Assume not sent.",
|
|
16154
|
+
next: "Those readers may miss this post but get the next one, and no one is mailed twice. The send finishes once it\u2019s resolved.",
|
|
16155
|
+
link: { ...link, label: "Resolve this send" }
|
|
16156
|
+
});
|
|
16157
|
+
case "missed":
|
|
16158
|
+
return problem({
|
|
16159
|
+
subject: `${title} is waiting: it didn\u2019t go out on time`,
|
|
16160
|
+
happened: `${title} was due at ${when(n.fire_at)} and hasn\u2019t gone out.`,
|
|
16161
|
+
todo: `Kestrel\u2019s once-a-minute sending job has stopped or failed on it. ${CRON} ${PASS_THIS_ALONG}`,
|
|
16162
|
+
next: "It goes out as soon as that job runs again.",
|
|
15872
16163
|
link
|
|
15873
|
-
);
|
|
15874
|
-
case "
|
|
15875
|
-
|
|
15876
|
-
|
|
15877
|
-
|
|
15878
|
-
|
|
15879
|
-
|
|
15880
|
-
|
|
15881
|
-
],
|
|
16164
|
+
});
|
|
16165
|
+
case "no_receipts":
|
|
16166
|
+
return problem({
|
|
16167
|
+
subject: "Kestrel isn\u2019t hearing back from Amazon SES",
|
|
16168
|
+
happened: `${NO_RECEIPTS_NOTICE_AFTER_MS / 36e5} hours after ${title} went out, Kestrel hasn\u2019t heard back from Amazon SES about any of its emails.`,
|
|
16169
|
+
affected: "Its receipts may not be reaching Kestrel. If so, an address that bounces isn\u2019t suppressed and a complaint doesn\u2019t unsubscribe anyone, so your next post mails them again.",
|
|
16170
|
+
todo: `Look for webhook.refused in the logs, and check the provider\u2019s event subscription, including that Deliveries is selected. ${PASS_THIS_ALONG}`,
|
|
16171
|
+
next: "This clears once any receipt arrives. You\u2019re told once.",
|
|
15882
16172
|
link
|
|
15883
|
-
);
|
|
15884
|
-
}
|
|
16173
|
+
});
|
|
15885
16174
|
}
|
|
15886
16175
|
}
|
|
15887
|
-
function composeImportNotification(n, config) {
|
|
15888
|
-
const c = (k) => n.counts[k];
|
|
15889
|
-
const confirmed = c("added_confirmed") + c("confirmed_pending");
|
|
15890
|
-
const unsubscribed = c("unsubscribed") + c("erased") + c("added_unsubscribed");
|
|
15891
|
-
const notImported = c("kept_suppressed") + c("kept_unsubscribed") + c("kept_confirmed_newer") + c("left_out") + c("invalid");
|
|
15892
|
-
return message(
|
|
15893
|
-
`A service token imported ${confirmed} subscribers from ${n.file_name}`,
|
|
15894
|
-
[
|
|
15895
|
-
`A service token imported "${n.file_name}" into your list at ${when(n.created_at)}, through the API, with no one signed in. Service tokens are the credentials your Cloudflare Access application gives to scripts and other automation.`,
|
|
15896
|
-
`Confirmed as subscribers: ${confirmed}. Unsubscribed: ${unsubscribed}. Suppressions added: ${n.suppressions_added}. Not imported as subscribers (suppressed here, unsubscribed here, invalid, or with no consent claim): ${notImported}. Already on your list as the file says: ${c("unchanged")}.`,
|
|
15897
|
-
n.attestation_version === null ? "No one was confirmed, so no attestation was needed." : "The service token attested for you that everyone imported as subscribed opted in to receive your newsletter, and that the list isn\u2019t purchased, rented, or scraped. If that isn\u2019t so, remove those subscribers before your next post goes out.",
|
|
15898
|
-
"Nobody was emailed by the import."
|
|
15899
|
-
],
|
|
15900
|
-
dashboardLink(config.appOrigin, "/subscribers")
|
|
15901
|
-
);
|
|
15902
|
-
}
|
|
15903
16176
|
function sampleNotification(config) {
|
|
15904
|
-
return
|
|
15905
|
-
"Test: notifications from Kestrel",
|
|
15906
|
-
|
|
15907
|
-
|
|
15908
|
-
|
|
15909
|
-
|
|
15910
|
-
|
|
15911
|
-
|
|
16177
|
+
return done({
|
|
16178
|
+
subject: "Test: notifications from Kestrel",
|
|
16179
|
+
happened: "If you\u2019re reading this, notifications reach this address. Nothing else was sent.",
|
|
16180
|
+
more: "Kestrel writes here when a post finishes sending, and right away if something needs attention.",
|
|
16181
|
+
link: {
|
|
16182
|
+
label: "Open Settings",
|
|
16183
|
+
url: dashboardLink(config.appOrigin, settingsRoute("notifications"))
|
|
16184
|
+
}
|
|
16185
|
+
});
|
|
15912
16186
|
}
|
|
15913
16187
|
function composeSandboxNotification(config) {
|
|
15914
|
-
return
|
|
15915
|
-
"A
|
|
15916
|
-
|
|
15917
|
-
|
|
15918
|
-
|
|
15919
|
-
|
|
15920
|
-
|
|
15921
|
-
|
|
15922
|
-
);
|
|
16188
|
+
return problem({
|
|
16189
|
+
subject: "A reader couldn\u2019t sign up: SES is still in the sandbox",
|
|
16190
|
+
happened: "Someone signed up, but Amazon SES refused to send their confirmation email. Your SES account is still in the sandbox, which mails only addresses verified in SES.",
|
|
16191
|
+
affected: "The reader was told a link is on its way, so they may try again. Until this changes, every signup from an unverified address is refused the same way, and a post reaches only verified addresses.",
|
|
16192
|
+
todo: `Request production access on the SES console\u2019s account dashboard. ${PASS_THIS_ALONG}`,
|
|
16193
|
+
next: "You\u2019re told at most once a day until then.",
|
|
16194
|
+
link: { label: "Open the dashboard", url: dashboardLink(config.appOrigin, "/") }
|
|
16195
|
+
});
|
|
15923
16196
|
}
|
|
15924
|
-
function
|
|
15925
|
-
const
|
|
15926
|
-
|
|
15927
|
-
|
|
15928
|
-
|
|
15929
|
-
|
|
15930
|
-
|
|
15931
|
-
"
|
|
15932
|
-
|
|
15933
|
-
|
|
15934
|
-
|
|
16197
|
+
function composeOutageNotification(refusal2, since, heldSubjects, config) {
|
|
16198
|
+
const posts = heldSubjects.map((s) => `"${s}"`);
|
|
16199
|
+
const first = posts[0];
|
|
16200
|
+
const link = { label: "Open the dashboard", url: dashboardLink(config.appOrigin, "/") };
|
|
16201
|
+
const happened = refusal2.found_by === "account_check" ? `Kestrel\u2019s check of your SES account found at ${when(since)} that SES will refuse every email.` : `Amazon SES started refusing your email at ${when(since)}.`;
|
|
16202
|
+
if (!first) {
|
|
16203
|
+
return problem({
|
|
16204
|
+
subject: "Email isn\u2019t going out, and signups are paused",
|
|
16205
|
+
happened,
|
|
16206
|
+
affected: "Signups are paused, and any post that comes due will wait.",
|
|
16207
|
+
said: said(refusal2.error),
|
|
16208
|
+
todo: refusal2.advice,
|
|
16209
|
+
next: "Then send a test email from the Template screen. When SES takes it, signups reopen.",
|
|
16210
|
+
link
|
|
16211
|
+
});
|
|
16212
|
+
}
|
|
16213
|
+
const one = posts.length === 1;
|
|
16214
|
+
const waiting = one ? `${first} is waiting` : `${posts.length} posts are waiting`;
|
|
16215
|
+
const late = config.notifyChannel === "provider" ? " This email can be held up by the same problem, so it may be fixed already." : "";
|
|
16216
|
+
return problem({
|
|
16217
|
+
subject: `Email isn\u2019t going out, and ${waiting}`,
|
|
16218
|
+
happened,
|
|
16219
|
+
affected: `Nothing can go out until it\u2019s fixed: ${one ? waiting : `${posts.slice(0, -1).join(", ")} and ${posts.at(-1)} are waiting`}, and signups are paused.`,
|
|
16220
|
+
said: said(refusal2.error),
|
|
16221
|
+
todo: refusal2.advice,
|
|
16222
|
+
next: `Once it\u2019s fixed, ${one ? `${first} carries on by itself` : "they carry on by themselves"} within the hour, and any other post that comes due waits with ${one ? "it" : "them"}. To reopen signups right away, send a test email from the Template screen. ${NO_ONE_HARMED}${late}`,
|
|
16223
|
+
link
|
|
16224
|
+
});
|
|
15935
16225
|
}
|
|
15936
16226
|
|
|
15937
16227
|
// src/send/remake.ts
|
|
@@ -16226,16 +16516,20 @@ async function uploadLogo(c) {
|
|
|
16226
16516
|
if (!(file instanceof File)) {
|
|
16227
16517
|
throw fieldError("file", "missing 'file' field");
|
|
16228
16518
|
}
|
|
16229
|
-
const
|
|
16230
|
-
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) {
|
|
16231
16525
|
throw fieldError(
|
|
16232
16526
|
"file",
|
|
16233
16527
|
"The logo must be a PNG, JPEG, WebP, or GIF. Most email apps don\u2019t show SVG."
|
|
16234
16528
|
);
|
|
16235
16529
|
}
|
|
16236
|
-
const
|
|
16237
|
-
if (
|
|
16238
|
-
throw fieldError("file",
|
|
16530
|
+
const conflict = imageLabelConflict(type, { name: file.name, declared: file.type });
|
|
16531
|
+
if (conflict) {
|
|
16532
|
+
throw fieldError("file", conflict);
|
|
16239
16533
|
}
|
|
16240
16534
|
if (bytes.byteLength > MAX_LOGO_BYTES) {
|
|
16241
16535
|
throw fieldError("file", `The logo must be ${MAX_LOGO_BYTES / 1024} KB or smaller.`);
|
|
@@ -17165,7 +17459,7 @@ function createRouter({
|
|
|
17165
17459
|
{
|
|
17166
17460
|
name: "principal",
|
|
17167
17461
|
type: "{ kind, email, token_id, client }",
|
|
17168
|
-
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."
|
|
17169
17463
|
},
|
|
17170
17464
|
{
|
|
17171
17465
|
name: "auth.mode",
|
|
@@ -17499,7 +17793,7 @@ function createRouter({
|
|
|
17499
17793
|
name: "file",
|
|
17500
17794
|
type: "file",
|
|
17501
17795
|
required: true,
|
|
17502
|
-
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.`
|
|
17503
17797
|
}
|
|
17504
17798
|
],
|
|
17505
17799
|
errors: remakeRefusals,
|
|
@@ -17543,7 +17837,7 @@ function createRouter({
|
|
|
17543
17837
|
not_sent: [],
|
|
17544
17838
|
provider: "fake",
|
|
17545
17839
|
recipients: ["you@example.com"],
|
|
17546
|
-
subject: "Template test
|
|
17840
|
+
subject: "Template test: how your posts will look",
|
|
17547
17841
|
warnings: []
|
|
17548
17842
|
}
|
|
17549
17843
|
},
|
|
@@ -17824,11 +18118,11 @@ function createRouter({
|
|
|
17824
18118
|
resource: "posts",
|
|
17825
18119
|
title: "Upload an image",
|
|
17826
18120
|
summary: "Upload an image to a draft, as a form or the raw bytes.",
|
|
17827
|
-
description: "The post's Markdown references the image by the `filename` the response gives, ``, never by its `url`, and the render resolves that name to the stored image.
|
|
18121
|
+
description: "The post's Markdown references the image by the `filename` the response gives, ``, never by its `url`, and the render resolves that name to the stored image. That name may differ from the one sent: each run of whitespace or of characters that would break a reference (`<>()[]`, a backtick, `#`, `?`, `%`) becomes a hyphen, keeping the case and leaving none at either end or before a dot, so `My Cover (2).png` is stored as `My-Cover-2.png`. The preview warns on a reference by `url`, on one that didn't become an image, and on an image the post doesn't reference. An upload never replaces an image: a name the post already has gets a number, so a second `cover.jpg` is stored as `cover-2.jpg`. To swap an image, upload the new one, reference its `filename`, and delete the old one.",
|
|
17828
18122
|
query: [
|
|
17829
18123
|
{
|
|
17830
18124
|
name: "filename",
|
|
17831
|
-
description: "For a raw upload (the image's own type as `Content-Type`, the bytes as the body), the name to store it under; required then."
|
|
18125
|
+
description: "For a raw upload (the image's own type as `Content-Type`, the bytes as the body), the name to store it under, cleaned and numbered as above; required then."
|
|
17832
18126
|
}
|
|
17833
18127
|
],
|
|
17834
18128
|
body: [
|
|
@@ -17836,12 +18130,12 @@ function createRouter({
|
|
|
17836
18130
|
name: "file",
|
|
17837
18131
|
type: "file",
|
|
17838
18132
|
required: true,
|
|
17839
|
-
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.`
|
|
17840
18134
|
},
|
|
17841
18135
|
{
|
|
17842
18136
|
name: "filename",
|
|
17843
18137
|
type: "string",
|
|
17844
|
-
description: "The name to store it under, in place of the file's own."
|
|
18138
|
+
description: "The name to store it under, cleaned and numbered as above, in place of the file's own."
|
|
17845
18139
|
}
|
|
17846
18140
|
],
|
|
17847
18141
|
errors: [notDraft],
|
|
@@ -19239,7 +19533,8 @@ var SHIPPED_MIGRATIONS = [
|
|
|
19239
19533
|
"0017_provider_account_rejected.sql",
|
|
19240
19534
|
"0018_notify_addresses.sql",
|
|
19241
19535
|
"0019_sending.sql",
|
|
19242
|
-
"0020_notifications_not_sending.sql"
|
|
19536
|
+
"0020_notifications_not_sending.sql",
|
|
19537
|
+
"0021_notifications_outage_receipts.sql"
|
|
19243
19538
|
];
|
|
19244
19539
|
async function missingMigrations(db) {
|
|
19245
19540
|
let applied;
|
|
@@ -19297,6 +19592,12 @@ async function schemaGap(db, devMode) {
|
|
|
19297
19592
|
};
|
|
19298
19593
|
}
|
|
19299
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
|
+
|
|
19300
19601
|
// src/send/budget.ts
|
|
19301
19602
|
var D1_QUERY_LIMIT = 1e3;
|
|
19302
19603
|
var Budget = class {
|
|
@@ -19375,15 +19676,20 @@ var OPEN_COST = 4;
|
|
|
19375
19676
|
var EACH_COST = 2;
|
|
19376
19677
|
var NOTIFY_RESERVE = OPEN_COST + EACH_COST;
|
|
19377
19678
|
var MAX_PER_TICK = 10;
|
|
19679
|
+
function isOutageNotice(n) {
|
|
19680
|
+
return n.kind === "not_sending" || n.kind === "not_sending_held";
|
|
19681
|
+
}
|
|
19378
19682
|
function stillHolds(n, now) {
|
|
19379
19683
|
switch (n.kind) {
|
|
19380
|
-
case "import":
|
|
19381
19684
|
case "sandbox":
|
|
19382
19685
|
return true;
|
|
19383
19686
|
case "not_sending":
|
|
19687
|
+
case "not_sending_held":
|
|
19384
19688
|
return true;
|
|
19385
19689
|
case "finished":
|
|
19386
19690
|
return true;
|
|
19691
|
+
case "no_receipts":
|
|
19692
|
+
return !n.receipt_since;
|
|
19387
19693
|
case "refused":
|
|
19388
19694
|
return n.send_status === "sending" && n.halt_reason === "account" && n.halted_at === n.episode && !isWedged({ ...n, status: n.send_status });
|
|
19389
19695
|
case "stuck":
|
|
@@ -19391,7 +19697,7 @@ function stillHolds(n, now) {
|
|
|
19391
19697
|
case "wedged":
|
|
19392
19698
|
return isWedged({ ...n, status: n.send_status });
|
|
19393
19699
|
case "missed":
|
|
19394
|
-
return n.send_status === "scheduled"
|
|
19700
|
+
return n.send_status === "scheduled" && n.fire_at < now - MISSED_THRESHOLD_MS;
|
|
19395
19701
|
}
|
|
19396
19702
|
}
|
|
19397
19703
|
async function notifyPublisher(env, budget, contacts = new Contacts()) {
|
|
@@ -19412,8 +19718,13 @@ async function notifyPublisher(env, budget, contacts = new Contacts()) {
|
|
|
19412
19718
|
return;
|
|
19413
19719
|
}
|
|
19414
19720
|
let refusal2 = null;
|
|
19415
|
-
|
|
19416
|
-
|
|
19721
|
+
let since = now;
|
|
19722
|
+
let held = [];
|
|
19723
|
+
if (due.some(isOutageNotice)) {
|
|
19724
|
+
const view = sendingView(config, await readSending(db));
|
|
19725
|
+
refusal2 = view?.refusal ?? null;
|
|
19726
|
+
since = view?.since ?? now;
|
|
19727
|
+
held = await postsHeldByOutage(db);
|
|
19417
19728
|
due = due.slice(0, Math.floor((budget.left - (OPEN_COST - 2)) / EACH_COST));
|
|
19418
19729
|
if (due.length === 0) {
|
|
19419
19730
|
return;
|
|
@@ -19428,13 +19739,17 @@ async function notifyPublisher(env, budget, contacts = new Contacts()) {
|
|
|
19428
19739
|
const notifier = getNotifier(config, env);
|
|
19429
19740
|
for (const [i, n] of due.entries()) {
|
|
19430
19741
|
const recordsLeft = due.length - i;
|
|
19431
|
-
if (
|
|
19742
|
+
if (n.kind === "not_sending_held" && refusal2 && held.length === 0) {
|
|
19743
|
+
await forgetNotification(db, n);
|
|
19744
|
+
continue;
|
|
19745
|
+
}
|
|
19746
|
+
if (!stillHolds(n, Date.now()) || isOutageNotice(n) && !refusal2) {
|
|
19432
19747
|
await recordNotificationOutcome(db, n, { status: "cleared" }, Date.now());
|
|
19433
19748
|
continue;
|
|
19434
19749
|
}
|
|
19435
|
-
const about = n.
|
|
19436
|
-
const key = `${n.send_id ??
|
|
19437
|
-
const write = () => n.kind === "
|
|
19750
|
+
const about = n.send_id === null ? {} : { sendId: n.send_id };
|
|
19751
|
+
const key = `${n.send_id ?? "account"}-${n.kind}-${n.episode}`;
|
|
19752
|
+
const write = () => n.kind === "sandbox" ? composeSandboxNotification(config) : isOutageNotice(n) ? composeOutageNotification(unwrap(refusal2, "the refusal"), since, held, config) : composeNotification(n, config);
|
|
19438
19753
|
let email2;
|
|
19439
19754
|
const sentTo = [...n.sent_to];
|
|
19440
19755
|
const missed = [];
|
|
@@ -19627,11 +19942,11 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
|
|
|
19627
19942
|
let unknown = 0;
|
|
19628
19943
|
const calls = batches.map((b) => {
|
|
19629
19944
|
const byAddress = /* @__PURE__ */ new Map();
|
|
19630
|
-
const
|
|
19945
|
+
const recipients2 = [];
|
|
19631
19946
|
for (const m of b.members) {
|
|
19632
19947
|
if (m.unsub_token) {
|
|
19633
19948
|
byAddress.set(m.email, m.id);
|
|
19634
|
-
|
|
19949
|
+
recipients2.push({
|
|
19635
19950
|
email: m.email,
|
|
19636
19951
|
unsubscribeUrl: `${config.appOrigin}/unsubscribe?token=${m.unsub_token}`
|
|
19637
19952
|
});
|
|
@@ -19640,7 +19955,7 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
|
|
|
19640
19955
|
result2.unsent += 1;
|
|
19641
19956
|
}
|
|
19642
19957
|
}
|
|
19643
|
-
return { batch: b, byAddress, recipients };
|
|
19958
|
+
return { batch: b, byAddress, recipients: recipients2 };
|
|
19644
19959
|
});
|
|
19645
19960
|
const sent = calls.filter((c) => c.recipients.length > 0);
|
|
19646
19961
|
const send2 = async (call2) => {
|
|
@@ -19749,15 +20064,15 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
|
|
|
19749
20064
|
result2.refusal = halt;
|
|
19750
20065
|
}
|
|
19751
20066
|
}
|
|
19752
|
-
const
|
|
20067
|
+
const count2 = (status) => outcomes.filter((o) => o.status === status).length;
|
|
19753
20068
|
const heldBack = outcomes.filter((o) => o.status === "pending" && o.attempt === false).length;
|
|
19754
20069
|
log.info("send.batch", {
|
|
19755
20070
|
...tags,
|
|
19756
20071
|
requests: answers.length,
|
|
19757
20072
|
recipients: batches.reduce((n, b) => n + b.members.length, 0),
|
|
19758
|
-
accepted:
|
|
19759
|
-
unsent:
|
|
19760
|
-
retried:
|
|
20073
|
+
accepted: count2("accepted"),
|
|
20074
|
+
unsent: count2("unsent"),
|
|
20075
|
+
retried: count2("pending") - heldBack,
|
|
19761
20076
|
held: heldCount + heldBack,
|
|
19762
20077
|
unknown,
|
|
19763
20078
|
latencyMs
|
|
@@ -19880,12 +20195,22 @@ async function sweep(env) {
|
|
|
19880
20195
|
await runTick(env, now, tick);
|
|
19881
20196
|
ok = true;
|
|
19882
20197
|
} catch (err) {
|
|
19883
|
-
|
|
20198
|
+
if (loggedStall("sweep", err)) {
|
|
20199
|
+
return;
|
|
20200
|
+
}
|
|
20201
|
+
log.error("sweep.error", errorFields(err));
|
|
19884
20202
|
throw err;
|
|
19885
20203
|
} finally {
|
|
19886
20204
|
log.info("sweep.tick", { ...tick, ok, durationMs: Date.now() - now });
|
|
19887
20205
|
}
|
|
19888
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
|
+
}
|
|
19889
20214
|
async function runTick(env, now, tick) {
|
|
19890
20215
|
const config = getConfig(env);
|
|
19891
20216
|
const handled = /* @__PURE__ */ new Set();
|
|
@@ -19900,7 +20225,7 @@ async function runTick(env, now, tick) {
|
|
|
19900
20225
|
rate ??= config.provider === "ses" ? accountForPacing(db, config).then(
|
|
19901
20226
|
(a) => sesSendRate(config, a).value,
|
|
19902
20227
|
(err) => {
|
|
19903
|
-
log.warn("provider.pacing_unread",
|
|
20228
|
+
log.warn("provider.pacing_unread", errorFields(err));
|
|
19904
20229
|
return config.sesMaxSendRate;
|
|
19905
20230
|
}
|
|
19906
20231
|
) : Promise.resolve(void 0);
|
|
@@ -19938,7 +20263,9 @@ async function runTick(env, now, tick) {
|
|
|
19938
20263
|
try {
|
|
19939
20264
|
await applyHeldReceipts(db, now, Math.min(heldRoom, HELD_RECEIPTS_PER_TICK));
|
|
19940
20265
|
} catch (err) {
|
|
19941
|
-
|
|
20266
|
+
if (!loggedStall("receipts", err)) {
|
|
20267
|
+
log.error("receipt.release_error", errorFields(err));
|
|
20268
|
+
}
|
|
19942
20269
|
}
|
|
19943
20270
|
}
|
|
19944
20271
|
for (const s of await stuckSends(env.DB, now - STUCK_THRESHOLD_MS)) {
|
|
@@ -19961,7 +20288,9 @@ async function runTick(env, now, tick) {
|
|
|
19961
20288
|
try {
|
|
19962
20289
|
await notifyPublisher(env, rest, contacts);
|
|
19963
20290
|
} catch (err) {
|
|
19964
|
-
|
|
20291
|
+
if (!loggedStall("notify", err)) {
|
|
20292
|
+
log.error("notify.error", errorFields(err));
|
|
20293
|
+
}
|
|
19965
20294
|
}
|
|
19966
20295
|
if (rest.affords(1)) {
|
|
19967
20296
|
await contacts.record(metered(env.DB, rest), config);
|
|
@@ -19970,7 +20299,9 @@ async function runTick(env, now, tick) {
|
|
|
19970
20299
|
try {
|
|
19971
20300
|
await refreshProviderAccount(env, config, rest, now);
|
|
19972
20301
|
} catch (err) {
|
|
19973
|
-
|
|
20302
|
+
if (!loggedStall("account", err)) {
|
|
20303
|
+
log.error("provider.account_error", errorFields(err));
|
|
20304
|
+
}
|
|
19974
20305
|
}
|
|
19975
20306
|
}
|
|
19976
20307
|
await drainSimulatedWebhooks(env, config);
|
|
@@ -19980,7 +20311,7 @@ async function safeRun(env, sendId, budget, window, sendRate, contacts) {
|
|
|
19980
20311
|
const run = await runSend(env, sendId, budget, window, sendRate);
|
|
19981
20312
|
contacts.add("list", runOutcome(run.accepted, run.refusal), run.answeredAt ?? Date.now());
|
|
19982
20313
|
} catch (err) {
|
|
19983
|
-
log.error("send.error", { sendId,
|
|
20314
|
+
log.error("send.error", { sendId, ...errorFields(err) });
|
|
19984
20315
|
}
|
|
19985
20316
|
}
|
|
19986
20317
|
|