@kurtbruns/kestrel 1.19.0 → 1.20.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.
@@ -1,4 +1,4 @@
1
- // @kurtbruns/kestrel 1.19.0: the Worker, built by scripts/build-package.mjs.
1
+ // @kurtbruns/kestrel 1.20.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(recipients) {
20
- return recipients > DAILY_WRITES_SEND_RECIPIENTS;
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(recipients) {
34
- return `On the Workers Free plan\u2019s limits, a send to ${recipients.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}`;
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 "Whoever runs the deployment replaces the SES access key in its secrets, or allows the key ses:SendEmail, ses:SendBulkEmail, ses:SendTemplatedEmail, and ses:SendBulkTemplatedEmail in its IAM policy.";
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 "The AWS account isn\u2019t signed up for SES: whoever runs the deployment enables SES for the account in the region AWS_REGION names, or settles the account\u2019s activation or billing with AWS.";
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 "Whoever runs the deployment checks 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 they create the set in the SES console.";
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 "The configuration set SES_CONFIGURATION_SET names sends through a dedicated IP pool SES can\u2019t find: whoever runs the deployment picks an existing pool in the set\u2019s sending options, or removes the pool, in the SES console.";
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 "SES read the request as naming something Kestrel never sends, such as a stored template: whoever runs the deployment checks that AWS_REGION names a region that offers SES, and updates Kestrel.";
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 "Whoever runs the deployment verifies the From address, or its domain, in SES, in the region AWS_REGION names.";
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 "SES says the sending domain isn\u2019t verified, or it can\u2019t read the sender\u2019s custom MAIL FROM domain\u2019s MX record: whoever runs the deployment checks that the sending domain is verified in the region AWS_REGION names, and checks the MAIL FROM domain\u2019s MX record in DNS or sets the identity\u2019s Behavior on MX failure to use the default MAIL FROM domain, in the SES console.";
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 "The SES account is still in the sandbox, which mails only addresses verified in SES: whoever runs the deployment requests production access on the SES console\u2019s account dashboard. To test before then, verify the reader\u2019s address in SES.";
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 "SES\u2019s 24-hour quota frees up on its own. In the sandbox (200 a day), whoever runs the deployment requests production access; otherwise they can ask AWS to raise the quota in the Service Quotas console.";
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 "AWS has shut down the account\u2019s sending, and only AWS can lift it: whoever runs the deployment opens a case with AWS Support from the SES console.";
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 "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, whoever runs the deployment turns it back on (aws sesv2 put-account-sending-attributes --sending-enabled).";
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 "Sending is turned off on the configuration set SES_CONFIGURATION_SET names, by hand or by an alarm set up on it: whoever runs the deployment turns it back on (aws sesv2 put-configuration-set-sending-options --sending-enabled, naming the set).";
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 "SES refuses this email itself, not a reader or the account, and the email can\u2019t change once its send has started: whoever runs the deployment asks AWS Support what SES refuses in it, quoting SES\u2019s words.";
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 "Whoever runs the deployment fixes the account with the provider.";
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 deployment is asking for more a second than the account allows. Whoever runs the deployment sets SES_MAX_SEND_RATE to the maximum send rate on the SES console\u2019s account dashboard, or removes it and lets the app read that rate by allowing its AWS key ses:GetAccount, and deploys. The send retries on its own meanwhile, and no one has been marked unsent because of this.`;
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
  }
@@ -974,10 +975,10 @@ var SUBSCRIBER_ACTIONS = {
974
975
 
975
976
  // src/generated/version.ts
976
977
  var BUILD_INFO = {
977
- version: "1.19.0",
978
- sha: "b941644",
979
- tag: "v1.19.0",
980
- buildTime: "2026-11-02T18:25:11.257Z",
978
+ version: "1.20.0",
979
+ sha: "7207ac2",
980
+ tag: "v1.20.0",
981
+ buildTime: "2026-11-04T20:05:33.195Z",
981
982
  repoUrl: "https://github.com/kurtbruns/kestrel"
982
983
  };
983
984
 
@@ -1511,7 +1512,7 @@ function sendWhere(filter) {
1511
1512
  }
1512
1513
  async function listSendsPage(db, filter, page) {
1513
1514
  const { clause, binds } = sendWhere(filter);
1514
- const [seq, count, list5] = await db.batch([
1515
+ const [seq, count2, list5] = await db.batch([
1515
1516
  db.prepare(`SELECT ${CURRENT_REV} AS seq`),
1516
1517
  db.prepare(`SELECT COUNT(*) AS n FROM sends ${clause}`).bind(...binds),
1517
1518
  db.prepare(
@@ -1522,7 +1523,7 @@ async function listSendsPage(db, filter, page) {
1522
1523
  ]);
1523
1524
  return {
1524
1525
  rows: list5?.results ?? [],
1525
- total: count?.results[0]?.n ?? 0,
1526
+ total: count2?.results[0]?.n ?? 0,
1526
1527
  seq: unwrap(seq?.results[0]?.seq, "the send sequence")
1527
1528
  };
1528
1529
  }
@@ -2366,185 +2367,6 @@ function readOutcomeCounts(json2) {
2366
2367
  );
2367
2368
  }
2368
2369
 
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
2370
  // src/db/imports.ts
2549
2371
  var MAX_VALUE_BYTES = 1e6;
2550
2372
  var encoder = new TextEncoder();
@@ -2569,9 +2391,9 @@ function jsonChunks(items, maxBytes = MAX_VALUE_BYTES) {
2569
2391
  }
2570
2392
  return chunks;
2571
2393
  }
2572
- async function readListState(db, emails) {
2394
+ async function readListState(db, emails2) {
2573
2395
  const state = { subscribers: /* @__PURE__ */ new Map(), suppressions: /* @__PURE__ */ new Map() };
2574
- const chunks = jsonChunks(emails);
2396
+ const chunks = jsonChunks(emails2);
2575
2397
  if (chunks.length === 0) {
2576
2398
  return state;
2577
2399
  }
@@ -2757,9 +2579,6 @@ async function applyImport(db, writes, suppressionDetail, forget = []) {
2757
2579
  );
2758
2580
  }
2759
2581
  statements.push(...forget);
2760
- if (writes.notice) {
2761
- statements.push(importNotice(db, r.id, now));
2762
- }
2763
2582
  const results = await db.batch(statements);
2764
2583
  const applied = {
2765
2584
  unsubscribed: /* @__PURE__ */ new Set(),
@@ -2802,9 +2621,9 @@ function idleFor(email2, now) {
2802
2621
  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
2622
  }
2804
2623
  var erasedNow = (email2) => `${email2} IN (SELECT email FROM subscribers WHERE status = 'unsubscribed' AND unsubscribe_cause = 'erased')`;
2805
- async function deliveriesNaming(db, emails) {
2624
+ async function deliveriesNaming(db, emails2) {
2806
2625
  let n = 0;
2807
- for (const chunk3 of jsonChunks(emails)) {
2626
+ for (const chunk3 of jsonChunks(emails2)) {
2808
2627
  const row = await db.prepare(
2809
2628
  "SELECT COUNT(*) AS n FROM deliveries WHERE email IN (SELECT value FROM json_each(?))"
2810
2629
  ).bind(chunk3).first();
@@ -2812,9 +2631,9 @@ async function deliveriesNaming(db, emails) {
2812
2631
  }
2813
2632
  return n;
2814
2633
  }
2815
- async function handingOff(db, emails, now) {
2634
+ async function handingOff(db, emails2, now) {
2816
2635
  const found = [];
2817
- for (const chunk3 of jsonChunks(emails)) {
2636
+ for (const chunk3 of jsonChunks(emails2)) {
2818
2637
  const { results } = await db.prepare(
2819
2638
  `SELECT DISTINCT d.email FROM deliveries d JOIN sends s ON s.id = d.send_id
2820
2639
  WHERE d.email IN (SELECT value FROM json_each(?1)) AND d.status IN ('pending', 'dispatched')
@@ -2831,10 +2650,10 @@ function withoutAddress(text2, email2, placeholder) {
2831
2650
  const pattern = new RegExp(email2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "giu");
2832
2651
  return text2.replace(pattern, placeholder);
2833
2652
  }
2834
- async function forgetDeliveries(db, emails, now) {
2835
- const placeholders = new Map(emails.map((e) => [e, `${ERASED_PREFIX}${newToken(8)}`]));
2653
+ async function forgetDeliveries(db, emails2, now) {
2654
+ const placeholders = new Map(emails2.map((e) => [e, `${ERASED_PREFIX}${newToken(8)}`]));
2836
2655
  const rewritten = [];
2837
- for (const chunk3 of jsonChunks(emails)) {
2656
+ for (const chunk3 of jsonChunks(emails2)) {
2838
2657
  const { results } = await db.prepare(
2839
2658
  "SELECT id, email, error, event_detail, provider_id FROM deliveries WHERE email IN (SELECT value FROM json_each(?))"
2840
2659
  ).bind(chunk3).all();
@@ -2852,7 +2671,7 @@ async function forgetDeliveries(db, emails, now) {
2852
2671
  }
2853
2672
  }
2854
2673
  const field = (name) => `${name} = (SELECT json_extract(value, '$.${name}') FROM json_each(?1) WHERE json_extract(value, '$.id') = deliveries.id)`;
2855
- const held = jsonChunks(emails).map(
2674
+ const held = jsonChunks(emails2).map(
2856
2675
  (chunk3) => db.prepare(
2857
2676
  `DELETE FROM held_receipts WHERE email IN (SELECT value FROM json_each(?1))
2858
2677
  AND ${erasedNow("held_receipts.email")} AND ${idleFor("held_receipts.email", "?2")}`
@@ -3200,11 +3019,11 @@ async function countSuppressions(db, filter) {
3200
3019
  const row = await db.prepare(`SELECT COUNT(*) AS n FROM suppressions ${clause}`).bind(...binds).first();
3201
3020
  return row?.n ?? 0;
3202
3021
  }
3203
- async function suppressionsFor(db, emails) {
3204
- if (emails.length === 0) {
3022
+ async function suppressionsFor(db, emails2) {
3023
+ if (emails2.length === 0) {
3205
3024
  return [];
3206
3025
  }
3207
- const { results } = await db.prepare("SELECT * FROM suppressions WHERE email IN (SELECT value FROM json_each(?))").bind(JSON.stringify(emails)).all();
3026
+ const { results } = await db.prepare("SELECT * FROM suppressions WHERE email IN (SELECT value FROM json_each(?))").bind(JSON.stringify(emails2)).all();
3208
3027
  return results;
3209
3028
  }
3210
3029
  async function liftSuppression(db, email2, reason) {
@@ -3223,6 +3042,21 @@ function mediaType(declared) {
3223
3042
  return (declared.split(";")[0] ?? "").trim().toLowerCase() || "application/octet-stream";
3224
3043
  }
3225
3044
 
3045
+ // src/lib/time.ts
3046
+ var LEASE_TTL_MS = 5 * 60 * 1e3;
3047
+ var MISSED_THRESHOLD_MS = 5 * 60 * 1e3;
3048
+ var HALT_BACKOFF_MS = {
3049
+ unavailable: [1, 2, 5, 15, 30, 60].map((m) => m * 60 * 1e3),
3050
+ account: [5, 15, 30, 60].map((m) => m * 60 * 1e3)
3051
+ };
3052
+ var HALT_RETRY_SLACK_MS = 30 * 1e3;
3053
+ var NOTIFY_HORIZON_MS = 24 * 60 * 60 * 1e3;
3054
+ var NO_RECEIPTS_NOTICE_AFTER_MS = 6 * 60 * 60 * 1e3;
3055
+ var MAX_NOTIFY_ATTEMPTS = 5;
3056
+ var MAX_DELIVERY_ATTEMPTS = 5;
3057
+ var CONFIRM_COOLDOWN_MS = 15 * 60 * 1e3;
3058
+ var CONFIRM_LINK_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
3059
+
3226
3060
  // shared/mcp.ts
3227
3061
  var MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18"];
3228
3062
  var MCP_VERSION_HEADER = "MCP-Protocol-Version";
@@ -5281,13 +5115,13 @@ var CONDITION_KINDS = {
5281
5115
  severity: "`action`",
5282
5116
  fields: "",
5283
5117
  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 deployment 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."
5118
+ 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
5119
  },
5286
5120
  stuck: {
5287
5121
  severity: "`warn`",
5288
5122
  fields: "",
5289
5123
  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 deployment reads the logs and checks the Cron Trigger. It carries on from where it stopped, mailing no one twice."
5124
+ 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
5125
  },
5292
5126
  wedged: {
5293
5127
  severity: "`action`",
@@ -5317,7 +5151,7 @@ var CONDITION_KINDS = {
5317
5151
  severity: "`warn`",
5318
5152
  fields: "`accepted`",
5319
5153
  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 deployment looks for `webhook.refused` in the logs and checks the provider's event subscription. It clears once any receipt lands."
5154
+ 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
5155
  },
5322
5156
  remade: {
5323
5157
  severity: "`info`",
@@ -5341,7 +5175,7 @@ var CONDITION_KINDS = {
5341
5175
  severity: "`warn`",
5342
5176
  fields: "`recipients`",
5343
5177
  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 deployment can move to Workers Paid, whose limits a send this size fits."
5178
+ 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
5179
  }
5346
5180
  };
5347
5181
  var HALT_CAUSES = {
@@ -6886,14 +6720,14 @@ function readSimulateAccount(v2, profile) {
6886
6720
  return null;
6887
6721
  }
6888
6722
  const [state = "", modifier, ...rest] = raw.split(":");
6889
- const problem = SIMULATED_ACCOUNT_PROBLEMS.includes(modifier ?? "") ? modifier : null;
6890
- if (rest.length > 0 || !SIMULATED_ACCOUNTS.includes(state) || modifier !== void 0 && modifier !== "unrouted" && !problem) {
6723
+ const problem2 = SIMULATED_ACCOUNT_PROBLEMS.includes(modifier ?? "") ? modifier : null;
6724
+ if (rest.length > 0 || !SIMULATED_ACCOUNTS.includes(state) || modifier !== void 0 && modifier !== "unrouted" && !problem2) {
6891
6725
  throw new ConfigError(
6892
6726
  "SIMULATE_ACCOUNT",
6893
6727
  `must be off, or one of ${SIMULATED_ACCOUNTS.join(", ")} optionally followed by :unrouted or one of :${SIMULATED_ACCOUNT_PROBLEMS.join(", :")}, not "${v2?.trim()}"`
6894
6728
  );
6895
6729
  }
6896
- if (state === "permission" && problem) {
6730
+ if (state === "permission" && problem2) {
6897
6731
  throw new ConfigError(
6898
6732
  "SIMULATE_ACCOUNT",
6899
6733
  `permission reads nothing of the account, so it takes no problem, not "${v2?.trim()}"`
@@ -6905,7 +6739,7 @@ function readSimulateAccount(v2, profile) {
6905
6739
  `stands in for the SES account, so it needs SIMULATE_SENDS=ses, not "${profile ?? "off"}"`
6906
6740
  );
6907
6741
  }
6908
- return { state, bounces_routed: modifier !== "unrouted", problem };
6742
+ return { state, bounces_routed: modifier !== "unrouted", problem: problem2 };
6909
6743
  }
6910
6744
  function parseEmailList(v2) {
6911
6745
  if (!v2) {
@@ -7804,9 +7638,21 @@ function ownMediaKey(href, mediaBase) {
7804
7638
 
7805
7639
  // src/render/markdown.ts
7806
7640
  import { Marked } from "marked";
7641
+ var UNPARSED_IMAGE = /!\[.*?\]\([^)\n]*\)?/g;
7807
7642
  function markdownToHtml(md, ctx) {
7808
7643
  const m = new Marked({ gfm: true, breaks: false });
7644
+ const referenced = /* @__PURE__ */ new Set();
7809
7645
  m.use({
7646
+ walkTokens(token2) {
7647
+ if (token2.type !== "text" || "tokens" in token2) {
7648
+ return;
7649
+ }
7650
+ for (const [written] of token2.raw.matchAll(UNPARSED_IMAGE)) {
7651
+ ctx.warnings.push(
7652
+ `\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.`
7653
+ );
7654
+ }
7655
+ },
7810
7656
  renderer: {
7811
7657
  image(token2) {
7812
7658
  const href = token2.href ?? "";
@@ -7817,6 +7663,9 @@ function markdownToHtml(md, ctx) {
7817
7663
  );
7818
7664
  }
7819
7665
  const { url, image, ownMedia } = resolveImageSrc(href, ctx.images, ctx.mediaBase);
7666
+ if (image) {
7667
+ referenced.add(image);
7668
+ }
7820
7669
  if (image && ownMedia) {
7821
7670
  ctx.warnings.push(
7822
7671
  `The image \u201C${href}\u201D should be referenced as \u201C${image.filename}\u201D. Use its file name, not its address.`
@@ -7836,7 +7685,15 @@ function markdownToHtml(md, ctx) {
7836
7685
  }
7837
7686
  }
7838
7687
  });
7839
- return m.parse(md);
7688
+ const html = m.parse(md);
7689
+ for (const image of ctx.images.values()) {
7690
+ const address = `${ctx.mediaBase}/${image.storage_key}`;
7691
+ const written = [address, encodeURI(address)].flatMap((a) => [a, escapeHtmlAttr(a)]);
7692
+ if (!referenced.has(image) && !written.some((a) => html.includes(a))) {
7693
+ ctx.warnings.push(`The uploaded image \u201C${image.filename}\u201D isn\u2019t used in this post.`);
7694
+ }
7695
+ }
7696
+ return html;
7840
7697
  }
7841
7698
  function sizeAttrs(w, h, maxWidth) {
7842
7699
  if (!w || !h) {
@@ -8053,17 +7910,17 @@ async function readSettings(db) {
8053
7910
  }
8054
7911
  function parseStored(data2) {
8055
7912
  let parsed;
8056
- let problem = "is not a JSON object";
7913
+ let problem2 = "is not a JSON object";
8057
7914
  try {
8058
7915
  parsed = JSON.parse(data2);
8059
7916
  } catch (err) {
8060
- problem = `is not valid JSON (${err instanceof Error ? err.message : String(err)})`;
7917
+ problem2 = `is not valid JSON (${err instanceof Error ? err.message : String(err)})`;
8061
7918
  }
8062
7919
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
8063
7920
  return parsed;
8064
7921
  }
8065
7922
  log.error("settings.corrupt", {
8066
- message: `the settings row (settings.id = 1) ${problem}; reads and writes of settings fail until it is repaired by hand`,
7923
+ message: `the settings row (settings.id = 1) ${problem2}; reads and writes of settings fail until it is repaired by hand`,
8067
7924
  length: data2.length
8068
7925
  });
8069
7926
  throw new HttpError(
@@ -8730,6 +8587,195 @@ function isRefusingCause(cause) {
8730
8587
  return cause !== null && REFUSING_CAUSES.includes(cause);
8731
8588
  }
8732
8589
 
8590
+ // src/db/notifications.ts
8591
+ function utcDayStart(now) {
8592
+ return Math.floor(now / 864e5) * 864e5;
8593
+ }
8594
+ async function sandboxNotice(db, now) {
8595
+ await db.prepare(
8596
+ "INSERT OR IGNORE INTO notifications (send_id, kind, episode, created_at, updated_at) VALUES (NULL, 'sandbox', ?, ?, ?)"
8597
+ ).bind(utcDayStart(now), now, now).run();
8598
+ }
8599
+ function outageNotice(db, now) {
8600
+ return db.prepare(
8601
+ `INSERT OR IGNORE INTO notifications (send_id, kind, episode, created_at, updated_at)
8602
+ SELECT NULL, 'not_sending', since, ?1, ?1 FROM sending WHERE id = 1 AND state = 'refusing'`
8603
+ ).bind(now);
8604
+ }
8605
+ var REFUSING_CAUSE_LIST = REFUSING_CAUSES.map((c) => `'${c}'`).join(", ");
8606
+ var HELD_BY_OUTAGE = `(sends.status = 'sending' AND sends.halt_reason = 'account'
8607
+ AND sends.halt_cause IN (${REFUSING_CAUSE_LIST}) AND NOT (${WEDGED_SEND}))`;
8608
+ async function postsHeldByOutage(db) {
8609
+ const { results } = await db.prepare(`SELECT subject FROM sends WHERE ${HELD_BY_OUTAGE} ORDER BY fire_at, id`).all();
8610
+ return results.map((r) => r.subject);
8611
+ }
8612
+ async function recordNotifications(db, now) {
8613
+ await db.prepare(
8614
+ // Two nested compounds, because D1 allows five terms in one.
8615
+ `INSERT OR IGNORE INTO notifications (send_id, kind, episode, created_at, updated_at)
8616
+ SELECT * FROM (
8617
+ SELECT id, 'finished', 0, ?1, ?1 FROM sends
8618
+ WHERE status = 'sent' AND completed_at >= ?2
8619
+ UNION ALL
8620
+ SELECT id, 'refused', halted_at, ?1, ?1 FROM sends s
8621
+ WHERE status = 'sending' AND halt_reason = 'account' AND halted_at IS NOT NULL
8622
+ AND NOT (${WEDGED_SEND})
8623
+ AND (halt_cause IS NULL OR halt_cause NOT IN (${REFUSING_CAUSE_LIST}))
8624
+ AND NOT EXISTS (SELECT 1 FROM sends o
8625
+ WHERE o.status = 'sending' AND o.halt_reason = 'account'
8626
+ AND o.halted_at = s.halted_at AND o.id < s.id
8627
+ AND NOT ${WEDGED_SEND})
8628
+ AND NOT EXISTS (SELECT 1 FROM notifications r
8629
+ WHERE r.kind = 'refused' AND r.episode = s.halted_at
8630
+ AND r.send_id <> s.id AND r.status <> 'cleared')
8631
+ UNION ALL
8632
+ SELECT id, 'missed', 0, ?1, ?1 FROM sends
8633
+ WHERE status = 'scheduled' AND fire_at < ?1 - ?3
8634
+ UNION ALL
8635
+ SELECT id, 'wedged', 0, ?1, ?1 FROM sends WHERE ${WEDGED_SEND}
8636
+ UNION ALL
8637
+ SELECT id, 'stuck', ${SEND_IDLE_SINCE}, ?1, ?1 FROM sends
8638
+ WHERE ${stuckSendSql("?1 - ?4")}
8639
+ )
8640
+ UNION ALL
8641
+ SELECT * FROM (
8642
+ SELECT NULL, 'not_sending', g.since, ?1, ?1 FROM sending g
8643
+ WHERE g.state = 'refusing' AND EXISTS (SELECT 1 FROM sends WHERE ${HELD_BY_OUTAGE})
8644
+ UNION ALL
8645
+ SELECT NULL, 'not_sending_held', g.since, ?1, ?1 FROM sending g
8646
+ WHERE g.state = 'refusing'
8647
+ AND EXISTS (SELECT 1 FROM notifications t
8648
+ WHERE t.kind = 'not_sending' AND t.episode = g.since
8649
+ AND t.status IN ('sent', 'failed', 'unaddressed')
8650
+ AND EXISTS (SELECT 1 FROM sends
8651
+ WHERE ${HELD_BY_OUTAGE}
8652
+ AND sends.halted_at >
8653
+ CASE t.status WHEN 'failed' THEN t.created_at
8654
+ ELSE t.updated_at END))
8655
+ UNION ALL
8656
+ SELECT id, 'no_receipts', 0, ?1, ?1 FROM sends s
8657
+ WHERE s.status = 'sent' AND s.c_accepted > 0
8658
+ AND s.completed_at >= ?2 AND s.completed_at < ?1 - ?5
8659
+ AND s.c_delivered + s.c_bounced + s.c_complained = 0
8660
+ AND NOT EXISTS (SELECT 1 FROM sends h
8661
+ WHERE h.completed_at >= s.completed_at
8662
+ AND h.c_delivered + h.c_bounced + h.c_complained > 0)
8663
+ AND NOT EXISTS (SELECT 1 FROM notifications r JOIN sends o ON o.id = r.send_id
8664
+ WHERE r.kind = 'no_receipts' AND r.status <> 'cleared'
8665
+ AND NOT EXISTS (SELECT 1 FROM sends h
8666
+ WHERE h.completed_at >= o.completed_at
8667
+ AND h.c_delivered + h.c_bounced + h.c_complained > 0))
8668
+ )`
8669
+ ).bind(
8670
+ now,
8671
+ now - NOTIFY_HORIZON_MS,
8672
+ MISSED_THRESHOLD_MS,
8673
+ STUCK_THRESHOLD_MS,
8674
+ NO_RECEIPTS_NOTICE_AFTER_MS
8675
+ ).run();
8676
+ }
8677
+ async function dueNotifications(db, limit) {
8678
+ const { results } = await db.prepare(
8679
+ `SELECT n.send_id, n.kind, n.episode, n.attempts, n.sent_to,
8680
+ s.subject, s.status AS send_status, s.fire_at, s.started_at, s.completed_at,
8681
+ 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,
8682
+ s.c_delivered, s.c_bounced, s.c_complained, s.c_skipped, s.c_unsent,
8683
+ EXISTS (SELECT 1 FROM sends h
8684
+ WHERE h.completed_at >= s.completed_at
8685
+ AND h.c_delivered + h.c_bounced + h.c_complained > 0) AS receipt_since
8686
+ FROM notifications n
8687
+ LEFT JOIN sends s ON s.id = n.send_id
8688
+ WHERE n.status = 'pending'
8689
+ AND (s.id IS NOT NULL OR n.kind IN ('sandbox', 'not_sending', 'not_sending_held'))
8690
+ ORDER BY n.created_at ASC, n.send_id ASC, n.kind ASC
8691
+ LIMIT ?`
8692
+ ).bind(limit).all();
8693
+ return results.map((r) => {
8694
+ const sentTo = readSentTo(r.sent_to);
8695
+ if (r.kind === "sandbox" || r.kind === "not_sending" || r.kind === "not_sending_held") {
8696
+ return {
8697
+ send_id: null,
8698
+ kind: r.kind,
8699
+ episode: r.episode,
8700
+ attempts: r.attempts,
8701
+ sent_to: sentTo
8702
+ };
8703
+ }
8704
+ return { ...r, sent_to: sentTo };
8705
+ });
8706
+ }
8707
+ function readSentTo(raw) {
8708
+ const list5 = JSON.parse(raw);
8709
+ return Array.isArray(list5) ? list5.filter((e) => typeof e === "string") : [];
8710
+ }
8711
+ var IN_KEYS = `EXISTS (SELECT 1 FROM json_each(?) k
8712
+ WHERE json_extract(k.value, '$[0]') IS notifications.send_id
8713
+ AND json_extract(k.value, '$[1]') = notifications.kind
8714
+ AND json_extract(k.value, '$[2]') = notifications.episode)`;
8715
+ async function claimNotifications(db, keys, now) {
8716
+ await db.prepare(
8717
+ `UPDATE notifications SET attempts = attempts + 1, updated_at = ?
8718
+ WHERE status = 'pending' AND ${IN_KEYS}`
8719
+ ).bind(now, JSON.stringify(keys.map((k) => [k.send_id, k.kind, k.episode]))).run();
8720
+ }
8721
+ async function recordNotificationOutcome(db, key, outcome, now) {
8722
+ if (outcome.status === "deferred") {
8723
+ await db.prepare(
8724
+ `UPDATE notifications SET sent_to = ?, attempts = MAX(attempts - 1, 0), updated_at = ?
8725
+ WHERE send_id IS ? AND kind = ? AND episode = ?`
8726
+ ).bind(JSON.stringify(outcome.sentTo), now, key.send_id, key.kind, key.episode).run();
8727
+ return;
8728
+ }
8729
+ const status = outcome.status === "unsent" ? outcome.giveUp ? "failed" : "pending" : outcome.status;
8730
+ const error2 = outcome.status === "unsent" ? outcome.error : null;
8731
+ const sentTo = outcome.status === "cleared" ? null : JSON.stringify(outcome.sentTo);
8732
+ await db.prepare(
8733
+ `UPDATE notifications SET status = ?, error = ?, sent_to = COALESCE(?, sent_to), updated_at = ?
8734
+ WHERE send_id IS ? AND kind = ? AND episode = ?`
8735
+ ).bind(status, error2, sentTo, now, key.send_id, key.kind, key.episode).run();
8736
+ }
8737
+ async function forgetNotification(db, key) {
8738
+ await db.prepare("DELETE FROM notifications WHERE send_id IS ? AND kind = ? AND episode = ?").bind(key.send_id, key.kind, key.episode).run();
8739
+ }
8740
+ async function recordNotificationTest(db, error2, now) {
8741
+ await db.batch([
8742
+ db.prepare("DELETE FROM notifications WHERE kind = 'test'"),
8743
+ db.prepare(
8744
+ `INSERT INTO notifications (send_id, kind, episode, status, attempts, error, created_at, updated_at)
8745
+ VALUES (NULL, 'test', 0, ?, 1, ?, ?, ?)`
8746
+ ).bind(error2 === null ? "sent" : "failed", error2, now, now)
8747
+ ]);
8748
+ }
8749
+ async function closeUnaddressed(db, now) {
8750
+ await db.prepare(
8751
+ "UPDATE notifications SET status = 'unaddressed', updated_at = ? WHERE status = 'pending'"
8752
+ ).bind(now).run();
8753
+ }
8754
+ async function notificationStatus(db) {
8755
+ const { results } = await db.prepare(
8756
+ `SELECT * FROM (
8757
+ SELECT 'sent' AS which, n.kind, COALESCE(s.subject, i.file_name, '') AS subject,
8758
+ n.updated_at AS at, NULL AS error
8759
+ FROM notifications n LEFT JOIN sends s ON s.id = n.send_id
8760
+ LEFT JOIN imports i ON i.id = n.import_id
8761
+ WHERE n.status = 'sent' ORDER BY n.updated_at DESC LIMIT 1)
8762
+ UNION ALL
8763
+ SELECT * FROM (
8764
+ SELECT 'failed' AS which, n.kind, COALESCE(s.subject, i.file_name, '') AS subject,
8765
+ n.updated_at AS at, n.error
8766
+ FROM notifications n LEFT JOIN sends s ON s.id = n.send_id
8767
+ LEFT JOIN imports i ON i.id = n.import_id
8768
+ WHERE n.error IS NOT NULL AND n.status IN ('pending', 'failed')
8769
+ ORDER BY n.updated_at DESC LIMIT 1)`
8770
+ ).all();
8771
+ const sent = results.find((r) => r.which === "sent");
8772
+ const failed = results.find((r) => r.which === "failed");
8773
+ return {
8774
+ last_sent: sent ? { kind: sent.kind, subject: sent.subject, at: sent.at } : null,
8775
+ last_failure: failed && (!sent || failed.at > sent.at) ? { kind: failed.kind, subject: failed.subject, at: failed.at, error: failed.error ?? "" } : null
8776
+ };
8777
+ }
8778
+
8733
8779
  // src/db/sending.ts
8734
8780
  async function readSending(db) {
8735
8781
  const r = await db.prepare("SELECT * FROM sending WHERE id = 1").first();
@@ -8800,7 +8846,7 @@ async function recordAccepted(db, by, at) {
8800
8846
  }
8801
8847
  async function recordRefusal(db, refusal2, at, tell) {
8802
8848
  const write = refusalStatement(db, refusal2, at);
8803
- await (tell ? db.batch([write, accountNotice(db, "not_sending", at)]) : write.run());
8849
+ await (tell ? db.batch([write, outageNotice(db, at)]) : write.run());
8804
8850
  }
8805
8851
 
8806
8852
  // src/services/sending.ts
@@ -9303,26 +9349,21 @@ async function listImages(db, postId) {
9303
9349
  function getImage(db, postId, filename) {
9304
9350
  return db.prepare("SELECT * FROM images WHERE post_id = ? AND filename = ?").bind(postId, filename).first();
9305
9351
  }
9306
- async function upsertImage(db, input) {
9307
- const existing = await getImage(db, input.postId, input.filename);
9308
- const now = Date.now();
9309
- if (existing) {
9310
- await db.prepare(
9311
- "UPDATE images SET storage_key = ?, content_type = ?, width = ?, height = ?, created_at = ? WHERE id = ?"
9312
- ).bind(input.storageKey, input.contentType, input.width, input.height, now, existing.id).run();
9313
- } else {
9314
- await db.prepare(
9315
- "INSERT INTO images (id, post_id, filename, storage_key, content_type, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
9316
- ).bind(
9317
- newId(),
9318
- input.postId,
9319
- input.filename,
9320
- input.storageKey,
9321
- input.contentType,
9322
- input.width,
9323
- input.height,
9324
- now
9325
- ).run();
9352
+ async function insertImageIfNameFree(db, input) {
9353
+ const { meta } = await db.prepare(
9354
+ "INSERT INTO images (id, post_id, filename, storage_key, content_type, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (post_id, filename) DO NOTHING"
9355
+ ).bind(
9356
+ newId(),
9357
+ input.postId,
9358
+ input.filename,
9359
+ input.storageKey,
9360
+ input.contentType,
9361
+ input.width,
9362
+ input.height,
9363
+ Date.now()
9364
+ ).run();
9365
+ if (meta.changes === 0) {
9366
+ return null;
9326
9367
  }
9327
9368
  return unwrap(await getImage(db, input.postId, input.filename), "image");
9328
9369
  }
@@ -9706,8 +9747,8 @@ function mapSesNotification(messageJson) {
9706
9747
  events.push({ type: "complained", providerId, email: r.emailAddress, detail });
9707
9748
  }
9708
9749
  } else if (kind === "Delivery" && payload.delivery) {
9709
- const recipients = payload.delivery.recipients ?? payload.mail?.destination ?? [];
9710
- for (const email2 of recipients) {
9750
+ const recipients2 = payload.delivery.recipients ?? payload.mail?.destination ?? [];
9751
+ for (const email2 of recipients2) {
9711
9752
  events.push({ type: "delivered", providerId, email: email2 });
9712
9753
  }
9713
9754
  }
@@ -9926,15 +9967,15 @@ var SesProvider = class {
9926
9967
  * template and data, so it is the same email (I5), and stays off SendBulkEmail's
9927
9968
  * one-request-a-second throttle, which the send loop's requests use to the full.
9928
9969
  */
9929
- async sendBatch(rendered, recipients, opts) {
9970
+ async sendBatch(rendered, recipients2, opts) {
9930
9971
  const out = [];
9931
9972
  const sendable = [];
9932
- for (const r of recipients) {
9933
- const problem = sesAddressProblem(r.email);
9934
- if (problem === null) {
9973
+ for (const r of recipients2) {
9974
+ const problem2 = sesAddressProblem(r.email);
9975
+ if (problem2 === null) {
9935
9976
  sendable.push(r);
9936
9977
  } else {
9937
- out.push({ email: r.email, accepted: false, retryable: false, error: problem });
9978
+ out.push({ email: r.email, accepted: false, retryable: false, error: problem2 });
9938
9979
  }
9939
9980
  }
9940
9981
  const content = sesTemplateContent(rendered);
@@ -10606,8 +10647,8 @@ var failCount = 0;
10606
10647
  function fakeOutbox() {
10607
10648
  return outbox;
10608
10649
  }
10609
- function deliverToOutbox(rendered, recipients, opts) {
10610
- return recipients.map((r) => {
10650
+ function deliverToOutbox(rendered, recipients2, opts) {
10651
+ return recipients2.map((r) => {
10611
10652
  const key = `${opts.batchKey ?? opts.tag}:${r.email}`;
10612
10653
  const existing = sentKeys.get(key);
10613
10654
  if (existing) {
@@ -10633,7 +10674,7 @@ function deliverToOutbox(rendered, recipients, opts) {
10633
10674
  var FakeProvider = class {
10634
10675
  name = "fake";
10635
10676
  maxBatch = Number.MAX_SAFE_INTEGER;
10636
- async sendBatch(rendered, recipients, opts) {
10677
+ async sendBatch(rendered, recipients2, opts) {
10637
10678
  if (failCount > 0) {
10638
10679
  failCount -= 1;
10639
10680
  return {
@@ -10641,7 +10682,7 @@ var FakeProvider = class {
10641
10682
  halt: { reason: "unavailable", cause: "outage", error: "fake transient failure" }
10642
10683
  };
10643
10684
  }
10644
- return { kind: "answered", results: deliverToOutbox(rendered, recipients, opts) };
10685
+ return { kind: "answered", results: deliverToOutbox(rendered, recipients2, opts) };
10645
10686
  }
10646
10687
  async parseWebhook(_req, _env) {
10647
10688
  return { events: [], response: new Response("ok") };
@@ -10753,7 +10794,7 @@ function sandboxVerified(email2, config) {
10753
10794
  const domain = email2.slice(email2.lastIndexOf("@") + 1).toLowerCase();
10754
10795
  return domain === config.sendingDomain.toLowerCase() || domain === SES_SIMULATOR_DOMAIN;
10755
10796
  }
10756
- function sandboxRefusal(recipients, config) {
10797
+ function sandboxRefusal(recipients2, config) {
10757
10798
  if (!config.simulation?.account) {
10758
10799
  return null;
10759
10800
  }
@@ -10761,7 +10802,7 @@ function sandboxRefusal(recipients, config) {
10761
10802
  if (row.answer?.productionAccess !== false) {
10762
10803
  return null;
10763
10804
  }
10764
- const unverified = recipients.filter((r) => !sandboxVerified(r.email, config));
10805
+ const unverified = recipients2.filter((r) => !sandboxVerified(r.email, config));
10765
10806
  if (unverified.length === 0) {
10766
10807
  return null;
10767
10808
  }
@@ -10877,14 +10918,14 @@ var SimProvider = class {
10877
10918
  this.maxRequestRate = traits.maxRequestRate;
10878
10919
  }
10879
10920
  /** One list batch is one request to the provider. */
10880
- async sendBatch(rendered, recipients, opts) {
10921
+ async sendBatch(rendered, recipients2, opts) {
10881
10922
  if (this.refusal) {
10882
10923
  return { kind: "halted", halt: this.refusal };
10883
10924
  }
10884
10925
  if (opts.purpose !== "list") {
10885
- return this.oneOff(rendered, recipients, opts);
10926
+ return this.oneOff(rendered, recipients2, opts);
10886
10927
  }
10887
- const sandboxed = sandboxRefusal(recipients, this.config);
10928
+ const sandboxed = sandboxRefusal(recipients2, this.config);
10888
10929
  if (sandboxed) {
10889
10930
  return { kind: "halted", halt: sandboxed };
10890
10931
  }
@@ -10919,12 +10960,12 @@ var SimProvider = class {
10919
10960
  }
10920
10961
  const resumed = state.quotaSpentAt === null ? 0 : ++state.resumed;
10921
10962
  await sleep(p.requestMs);
10922
- const results = this.answer(sendId, rendered, recipients, opts);
10963
+ const results = this.answer(sendId, rendered, recipients2, opts);
10923
10964
  if (realistic && this.lost(sendId, key, state, resumed)) {
10924
10965
  log.info("sim.request_lost", {
10925
10966
  sendId,
10926
10967
  profile: this.profileName,
10927
- recipients: recipients.length
10968
+ recipients: recipients2.length
10928
10969
  });
10929
10970
  throw new Error(
10930
10971
  "simulated network error: the connection closed before the provider answered"
@@ -10937,28 +10978,28 @@ var SimProvider = class {
10937
10978
  * outbox, unless the sandbox refuses the recipient. A refusal of the first request speaks
10938
10979
  * for the call, as the adapter's does; a later one holds back the rest.
10939
10980
  */
10940
- async oneOff(rendered, recipients, opts) {
10981
+ async oneOff(rendered, recipients2, opts) {
10941
10982
  const out = [];
10942
- for (const [i, r] of recipients.entries()) {
10943
- const refused2 = sandboxRefusal([r], this.config);
10944
- if (!refused2) {
10983
+ for (const [i, r] of recipients2.entries()) {
10984
+ const refused3 = sandboxRefusal([r], this.config);
10985
+ if (!refused3) {
10945
10986
  const answer = await this.fake.sendBatch(rendered, [r], opts);
10946
10987
  out.push(...answer.kind === "answered" ? answer.results : []);
10947
10988
  continue;
10948
10989
  }
10949
10990
  if (i === 0) {
10950
- return { kind: "halted", halt: refused2 };
10991
+ return { kind: "halted", halt: refused3 };
10951
10992
  }
10952
- for (const rest of recipients.slice(i)) {
10993
+ for (const rest of recipients2.slice(i)) {
10953
10994
  out.push({
10954
10995
  email: rest.email,
10955
10996
  accepted: false,
10956
10997
  retryable: true,
10957
- error: refused2.error,
10998
+ error: refused3.error,
10958
10999
  held: true
10959
11000
  });
10960
11001
  }
10961
- return { kind: "answered", results: out, halt: refused2 };
11002
+ return { kind: "answered", results: out, halt: refused3 };
10962
11003
  }
10963
11004
  return { kind: "answered", results: out };
10964
11005
  }
@@ -11094,7 +11135,7 @@ function simulatedAccountRow(account2, config, now) {
11094
11135
  }
11095
11136
  const production = account2.state === "production";
11096
11137
  const limits = production ? PRODUCTION_ACCOUNT : SANDBOX_ACCOUNT;
11097
- const problem = account2.problem;
11138
+ const problem2 = account2.problem;
11098
11139
  const identitySet = account2.bounces_routed ? "kestrel-mail" : null;
11099
11140
  const setName = config.sesConfigurationSet ?? identitySet;
11100
11141
  const answer = {
@@ -11104,21 +11145,21 @@ function simulatedAccountRow(account2, config, now) {
11104
11145
  maxSendRate: limits.maxSendRate,
11105
11146
  max24HourSend: limits.max24HourSend,
11106
11147
  sentLast24Hours: 0,
11107
- enforcement: problem === "shutdown" ? "shutdown" : problem === "probation" ? "probation" : "healthy",
11108
- sendingEnabled: problem !== "sending_disabled",
11148
+ enforcement: problem2 === "shutdown" ? "shutdown" : problem2 === "probation" ? "probation" : "healthy",
11149
+ sendingEnabled: problem2 !== "sending_disabled",
11109
11150
  identityConfigurationSet: { name: identitySet },
11110
- senderVerified: problem !== "sender_unverified"
11151
+ senderVerified: problem2 !== "sender_unverified"
11111
11152
  };
11112
11153
  if (setName) {
11113
11154
  answer.configurationSet = {
11114
11155
  name: setName,
11115
- exists: problem !== "set_missing",
11116
- sendingEnabled: problem !== "set_paused" && problem !== "set_missing"
11156
+ exists: problem2 !== "set_missing",
11157
+ sendingEnabled: problem2 !== "set_paused" && problem2 !== "set_missing"
11117
11158
  };
11118
11159
  }
11119
11160
  return {
11120
11161
  attemptedAt: now,
11121
- outcome: problem === "key_rejected" ? "rejected" : "ok",
11162
+ outcome: problem2 === "key_rejected" ? "rejected" : "ok",
11122
11163
  checkedAt: now,
11123
11164
  answer
11124
11165
  };
@@ -11638,7 +11679,7 @@ function readAgainAt(pace, now) {
11638
11679
  }
11639
11680
 
11640
11681
  // src/send/view.ts
11641
- function derivePhase(status, counts2, hasRetries, due, wedged, refused2, unavailable2, audienceFixed) {
11682
+ function derivePhase(status, counts2, hasRetries, due, wedged, refused3, unavailable2, audienceFixed) {
11642
11683
  switch (status) {
11643
11684
  case "scheduled":
11644
11685
  return due ? "due" : "scheduled";
@@ -11647,7 +11688,7 @@ function derivePhase(status, counts2, hasRetries, due, wedged, refused2, unavail
11647
11688
  case "sent":
11648
11689
  return counts2.accepted > 0 ? "settling" : "complete";
11649
11690
  default: {
11650
- if (wedged || refused2) {
11691
+ if (wedged || refused3) {
11651
11692
  return "needs_attention";
11652
11693
  }
11653
11694
  if (unavailable2 && (counts2.pending > 0 || !audienceFixed)) {
@@ -11669,17 +11710,17 @@ function round(n) {
11669
11710
  var TICK_MS = 6e4;
11670
11711
  var TICK_RUN_MS = 5e3;
11671
11712
  var TICK_START_GRACE_MS = 2e3;
11672
- function tickEstimate(startedAt, total, done, running, now) {
11673
- const remaining = Math.max(0, total - done);
11713
+ function tickEstimate(startedAt, total, done2, running, now) {
11714
+ const remaining = Math.max(0, total - done2);
11674
11715
  const startTick = Math.floor(startedAt / TICK_MS);
11675
11716
  const tick = Math.floor(now / TICK_MS);
11676
11717
  const tickStart = tick * TICK_MS;
11677
11718
  if (running) {
11678
11719
  const before = tick - startTick;
11679
- if (before < 1 || done <= 0) {
11720
+ if (before < 1 || done2 <= 0) {
11680
11721
  return null;
11681
11722
  }
11682
- const perTick2 = done / (before + 0.5);
11723
+ const perTick2 = done2 / (before + 0.5);
11683
11724
  const room = perTick2 / 2;
11684
11725
  if (remaining <= room) {
11685
11726
  return { perTick: perTick2, finishAt: Math.max(now, tickStart + TICK_RUN_MS) };
@@ -11689,10 +11730,10 @@ function tickEstimate(startedAt, total, done, running, now) {
11689
11730
  }
11690
11731
  const waiting = tick > startTick && now - tickStart < TICK_START_GRACE_MS;
11691
11732
  const ran = tick - startTick + (waiting ? 0 : 1);
11692
- if (ran < 1 || done <= 0) {
11733
+ if (ran < 1 || done2 <= 0) {
11693
11734
  return null;
11694
11735
  }
11695
- const perTick = done / ran;
11736
+ const perTick = done2 / ran;
11696
11737
  if (remaining === 0) {
11697
11738
  return { perTick, finishAt: now };
11698
11739
  }
@@ -11705,10 +11746,10 @@ function buildSendView(send, config, hasRetries, now, sandbox) {
11705
11746
  const published = send.status === "sending" || send.status === "sent";
11706
11747
  const summed = counts2.pending + counts2.dispatched + counts2.accepted + counts2.delivered + counts2.bounced + counts2.complained + counts2.skipped + counts2.unsent;
11707
11748
  const total = summed > 0 ? summed : send.recipient_count;
11708
- const done = Math.max(0, total - counts2.pending - counts2.dispatched);
11709
- const dispatchPercent = total > 0 ? round(100 * done / total) : 0;
11749
+ const done2 = Math.max(0, total - counts2.pending - counts2.dispatched);
11750
+ const dispatchPercent = total > 0 ? round(100 * done2 / total) : 0;
11710
11751
  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, done, running, now) : null;
11752
+ const estimate = send.status === "sending" && send.started_at != null ? tickEstimate(send.started_at, total, done2, running, now) : null;
11712
11753
  const ratePerMin = estimate ? round(estimate.perTick) : null;
11713
11754
  const etaMs = estimate ? Math.max(0, round(estimate.finishAt - now)) : null;
11714
11755
  const acceptedTotal = counts2.accepted + counts2.delivered + counts2.bounced + counts2.complained;
@@ -11725,7 +11766,7 @@ function buildSendView(send, config, hasRetries, now, sandbox) {
11725
11766
  since: send.halted_at,
11726
11767
  retry_at: send.halt_retry_at
11727
11768
  } : null;
11728
- const refused2 = halt?.reason === "account";
11769
+ const refused3 = halt?.reason === "account";
11729
11770
  const unavailable2 = halt?.reason === "unavailable";
11730
11771
  const phase = derivePhase(
11731
11772
  send.status,
@@ -11733,7 +11774,7 @@ function buildSendView(send, config, hasRetries, now, sandbox) {
11733
11774
  hasRetries,
11734
11775
  due,
11735
11776
  wedged,
11736
- refused2,
11777
+ refused3,
11737
11778
  unavailable2,
11738
11779
  send.audience_resolved_at !== null
11739
11780
  );
@@ -11764,7 +11805,7 @@ function buildSendView(send, config, hasRetries, now, sandbox) {
11764
11805
  },
11765
11806
  counts: counts2,
11766
11807
  dispatch: {
11767
- done,
11808
+ done: done2,
11768
11809
  percent: dispatchPercent,
11769
11810
  rate_per_min: ratePerMin,
11770
11811
  eta_ms: handingOff2 ? etaMs : null
@@ -12671,9 +12712,9 @@ var SCALE = {
12671
12712
  failureRate: 6e-3
12672
12713
  // per-send transport failures — do NOT suppress
12673
12714
  };
12674
- function drawDistinct(rand, count, n) {
12715
+ function drawDistinct(rand, count2, n) {
12675
12716
  const out = /* @__PURE__ */ new Set();
12676
- const want = Math.min(count, n);
12717
+ const want = Math.min(count2, n);
12677
12718
  let guard2 = want * 20 + 50;
12678
12719
  while (out.size < want && guard2-- > 0) {
12679
12720
  out.add(Math.floor(rand() * n));
@@ -12684,8 +12725,8 @@ function drawUnsentSlots(rand, audienceLen) {
12684
12725
  if (audienceLen === 0) {
12685
12726
  return /* @__PURE__ */ new Set();
12686
12727
  }
12687
- const count = Math.max(1, Math.round(audienceLen * SCALE.failureRate * (0.7 + rand() * 0.6)));
12688
- return new Set(drawDistinct(rand, count, audienceLen));
12728
+ const count2 = Math.max(1, Math.round(audienceLen * SCALE.failureRate * (0.7 + rand() * 0.6)));
12729
+ return new Set(drawDistinct(rand, count2, audienceLen));
12689
12730
  }
12690
12731
  function buildScaledAudience(t, size, rand) {
12691
12732
  const importId = newId();
@@ -12861,8 +12902,8 @@ function buildDeliveries(sendId, audience, sentAt, events, unsentSlots, skipped
12861
12902
  });
12862
12903
  }
12863
12904
  function testRecipients(posts, t) {
12864
- const emails = [...new Set(posts.flatMap((p) => p.sentTo ?? []))];
12865
- return emails.map((email2) => ({
12905
+ const emails2 = [...new Set(posts.flatMap((p) => p.sentTo ?? []))];
12906
+ return emails2.map((email2) => ({
12866
12907
  id: newId(),
12867
12908
  email: email2,
12868
12909
  status: "unsubscribed",
@@ -13181,28 +13222,28 @@ async function reset(c) {
13181
13222
  import { Lexer } from "marked";
13182
13223
 
13183
13224
  // 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';
13225
+ var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker, with a D1 database for posts and subscribers and an R2 bucket for images.\n\nIn this guide, you choose your hostnames, create your instance\'s repository, create the database and the bucket, and point Kestrel\'s production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname.\n\n## Before you begin\n\n- A [Cloudflare account](https://dash.cloudflare.com/sign-up), with your domain\'s DNS on Cloudflare. Your app\'s hostname and its login both live there.\n- [Node.js](https://nodejs.org/) 22 or later, and [Git](https://git-scm.com/).\n- A GitHub account, or another Git host, for your instance\'s repository.\n- An AWS account for the newsletter, before [Connect Amazon SES](05-ses.md). [Create an AWS account](04-aws-account.md) walks through it. You can start it now. Signing up needs an email address that receives mail, and AWS can take a while to activate a new account.\n\n## Choose your hostnames\n\nKestrel uses two hostnames: one for the app, and one your email is sent from.\n\n| Hostname | What it\'s for |\n| --- | --- |\n| `newsletter.example.com` | the editor, the API, and the public pages readers see |\n| `send.example.com` | the address your email comes from, and its SPF, DKIM, and DMARC records |\n\nThese guides put the app on a subdomain, which leaves any website you already have on `example.com` untouched. If `example.com` has no website of its own, the app can live there instead. Use `example.com` wherever these guides say `newsletter.example.com`.\n\nIt\'s recommended to send from a subdomain rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn\'t put that at risk. These guides assume a sending subdomain throughout. Avoid `mail.` as the sending name too, since mail servers read it as an inbound host. [Sending-domain DNS](../reference/02-sending-domain-dns.md#choose-your-sending-name) explains both.\n\nThroughout these guides, replace the example names with your own, and any value written as `REPLACE_WITH_\u2026`.\n\n## 1. Create your instance\'s repository\n\nYour instance\'s repository holds its configuration, its database migrations, and the release of Kestrel it runs. Kestrel itself installs from npm as `@kurtbruns/kestrel`, so the repository stays small, and an upgrade changes the version it names.\n\n1. On GitHub, [create a new repository](https://github.com/new) named after your publication. These guides use `newsletter`. It can be private. Leave it empty, with no README, license, or `.gitignore`.\n\n1. Clone it, and write a new instance into it. Replace `YOUR_USERNAME` with your GitHub username, and `newsletter` with your repository\'s name:\n\n ```bash\n git clone https://github.com/YOUR_USERNAME/newsletter.git\n cd newsletter\n npx kestrel init\n ```\n\n This writes the instance\'s files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release\'s database migrations, and a starting point for Claude Code in `.claude/`. The instance\'s `README.md` lists its commands.\n\n1. Install the release and its tools:\n\n ```bash\n npm install\n ```\n\n1. **(Optional)** Try Kestrel locally before you deploy it. Copy the local settings, and start the app:\n\n ```bash\n cp .dev.vars.example .dev.vars\n npm run dev\n ```\n\n Open http://localhost:8787/dashboard/, and run `npm run seed` in a second terminal to load a demo publication. Email goes to a stand-in, so nothing reaches a real inbox. Press Ctrl+C to stop the server when you\'re done. `init` prints these steps too, in this order.\n\n1. Commit the new instance, and push it. Your local settings and data stay out of the commit, since the instance\'s `.gitignore` lists them:\n\n ```bash\n git add -A\n git commit -m "Initialize Kestrel instance"\n git push -u origin HEAD\n ```\n\n## 2. Sign in to Cloudflare\n\nEvery command from here on acts on your Cloudflare account through the Wrangler CLI.\n\n1. Sign in. A browser window opens for you to approve the login:\n\n ```bash\n npx wrangler login\n ```\n\n1. Confirm which account you\'re signed in to:\n\n ```bash\n npx wrangler whoami\n ```\n\n It prints your email and the account name. If you belong to more than one account, make sure it\'s the one that holds your domain.\n\n## 3. Create the database and the image bucket\n\nEach command creates the resource and writes it into the `production` block of `wrangler.jsonc`, under the binding the app already uses. Name `--env production` on each one, or Wrangler writes to the top level, which is local development.\n\nThe commands below use `newsletter`. Use your instance\'s names instead: `kestrel init` printed them, and they\'re in the `production` block of `wrangler.jsonc`.\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create newsletter-production --env production --binding DB --update-config\n ```\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create newsletter-media-production --env production --binding MEDIA --update-config\n ```\n\nEach prints a snippet to add to your configuration. There\'s nothing to paste: `--update-config` has filled in the database\'s id, and the `MEDIA` binding already names the bucket. Wrangler also rewrites `wrangler.jsonc` in its own layout, with tabs and one value per line, and your settings stay.\n\n## 4. Configure production\n\n`wrangler.jsonc` holds two sets of settings. The top level is for local development, so leave it alone. The `production` block under `env` is yours to fill in.\n\n1. Open `wrangler.jsonc` and change the values marked here in the `production` block. Only the settings you change are shown; leave the others as they are:\n\n ```jsonc\n "production": {\n "routes": [\n {\n "pattern": "newsletter.example.com", // \u2190 your app\'s hostname\n "custom_domain": true\n }\n ],\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "newsletter-production", // named by init; leave it\n "database_id": "\u2026", // already filled in by section 3; leave it\n "migrations_dir": "migrations"\n }\n ],\n "vars": {\n "PROVIDER": "", // \u2190 stays empty until Connect Amazon SES\n "APP_ORIGIN": "https://newsletter.example.com", // \u2190 your app\'s hostname\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com", // \u2190 your sending hostname\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>" // \u2190 who your mail is from\n }\n }\n ```\n\n What each one does:\n\n - `routes` puts the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can\'t already have a CNAME record; delete one if it does. The block also turns off the `workers.dev` addresses, so the app answers only on your hostname.\n - `PROVIDER` stays empty until [Connect Amazon SES](05-ses.md) sets it. Until then the app runs without an email provider: posts can be written and previewed, but scheduling, test emails, and signups are refused. Never set it to `fake`, the top level\'s value, even when a wrangler warning suggests copying it: `fake` delivers nothing, and the app refuses it on your hostname.\n - `ARCHIVE_BASE_PATH` is permanent once the first post goes out. Every post sent carries its `/archive/\u2026` link, and changing the prefix later breaks the links already mailed. Keep `/archive` unless you have a reason not to.\n - `FROM_ADDRESS` is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`. The name before the `<` is what readers see as the sender. It also names the publication on its public pages, until the publisher sets a name under **Settings \u2192 Publication identity**. You can change the name at any time, and deploy again.\n\n Change every `example.com` value, the route included. The app refuses to run while any of its settings still names `example.com`.\n\n Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.\n\n1. Check the file. It fails if the configuration doesn\'t parse:\n\n ```bash\n npm run typecheck\n ```\n\n1. Commit your settings and push them to your repository:\n\n ```bash\n git commit -am "Configure production"\n git push\n ```\n\n## 5. Apply the schema\n\nCreate Kestrel\'s tables in the production database:\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it\'s about to apply and asks you to confirm. It applies only the ones the database hasn\'t seen, so running it again is harmless.\n\n## 6. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThis deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker\'s **Triggers** tab in the Cloudflare dashboard.\n\nAlways name `--env production`, here and on every wrangler command that stores a secret. Without it, wrangler deploys the top level, local development, as a second Worker that answers nowhere and never sends. It takes the top level\'s `name` in `wrangler.jsonc`, such as `newsletter-dev`. If that happens, remove it by that name:\n\n```bash\nnpx wrangler delete --name newsletter-dev\n```\n\n## Check it\n\n1. The app answers on your hostname:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n If `curl` reports a certificate error or can\'t resolve the host, Cloudflare is still setting up the hostname. Or your computer remembers it from before it existed. Try again over the next 30 minutes.\n\n1. The editor is locked. Until you set up Access in the next step, the app can\'t tell who is asking, so it lets no one in:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" https://newsletter.example.com/api/whoami\n ```\n\n ```\n 401\n ```\n\n In a browser, `https://newsletter.example.com/dashboard/` says the editor is locked until you set up Access. That\'s expected.\n\n1. In a browser, `https://newsletter.example.com/` shows the newsletter\'s public home page. Its subscribe page, `/subscribe`, says signups aren\'t open yet, until Connect Amazon SES has sent its first test email.\n\nIf the public pages instead say the newsletter is temporarily unavailable, and `/health` answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",\u2026}`, the setting it names is missing, malformed, or still on `example.com`. Fix it in `wrangler.jsonc`, commit, and deploy again.\n\nIf the public pages say the same, but `/health` answers `500` with `{"error":"schema_behind","missing":[\u2026],\u2026}`, the database hasn\'t run the migrations it lists, most often because step 5 was skipped or its prompt answered no. Run step 5\'s command, which the body\'s `message` repeats; the app answers as soon as it finishes, with no new deploy.\n\nNext, [lock the dashboard with Access](03-access.md).\n';
13185
13226
 
13186
13227
  // 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, or saving fails with an error naming `use_clientless_isolation_app_launcher_url`.\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. To let someone sign in another way, such as a one-time code by email, add it under **Zero Trust \u2192 Integrations \u2192 Identity providers**.\n\n## 4. Give the app its two values\n\nThe app verifies each login against your team domain, and checks that it was issued for this application.\n\n1. Find your team domain under **Zero Trust \u2192 Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`.\n\n1. Find the application\'s audience tag. Under **Access controls \u2192 Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.\n\n1. Store the team domain as a secret. The command prompts you for the value. Enter the hostname alone, without `https://`:\n\n ```bash\n npx wrangler secret put ACCESS_TEAM_DOMAIN --env production\n ```\n\n1. Store the audience tag the same way:\n\n ```bash\n npx wrangler secret put ACCESS_AUD --env production\n ```\n\n A secret takes effect as soon as you store it, with no redeploy.\n\n1. **(Optional, recommended)** Limit the editor to named people. The Access policy decides who can sign in. This list keeps that true if the policy is widened later, for another app or by mistake. 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\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\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\nNext, [create an AWS account](04-aws-account.md).\n';
13228
+ 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
13229
 
13189
13230
  // 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";
13231
+ 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
13232
 
13192
13233
  // 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';
13234
+ var ses_default = '# Connect Amazon SES\n\nThis page connects the app to Amazon Simple Email Service (Amazon SES), which delivers the newsletter\'s email. A new SES account starts in a sandbox, where it can send only to addresses you\'ve verified, until AWS reviews it. See [Request production access](06-production-access.md) to take it out of the sandbox.\n\nIn this guide, you set up how the app sends mail through SES and receives bounces and complaints, and how readers\' replies reach the publisher.\n\n## Before you begin\n\n- An AWS account for the newsletter, from [Create an AWS account](04-aws-account.md).\n- The publisher\'s inbox, for readers\' replies.\n\n## 1. Verify your sending hostname\n\nSES sends only from an identity you\'ve verified. Verifying the whole hostname lets any address on it send, and sets up DKIM, which lets inboxes trust your mail.\n\nThe same form sets a custom MAIL FROM domain, so SPF also passes for your domain. [SPF: authorize the return path](../reference/02-sending-domain-dns.md#spf-authorize-the-return-path) explains why it matters.\n\n1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose a region near you in the region menu at the top right.\n\n SES keeps identities, quotas, and the sandbox separately in each region, so do every step on this page in the region you chose. [Configuration](../reference/01-configuration.md#vars) says more about choosing one, under `AWS_REGION`.\n\n1. Note the region\'s code, such as `us-west-2`. The menu shows it beside the region\'s name, and [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each one too. AWS names a region in more than one way, such as United States (Oregon) and US West (Oregon), so go by the code. Later steps use it.\n\n1. Under **Configuration**, choose **Identities**, then **Create identity**.\n\n1. Select **Domain**, and enter `send.example.com`. Leave **Assign a default configuration set** and **Assign to a tenant** unchecked. Section 4 makes the configuration set, and makes it the default then.\n\n1. Select **Use a custom MAIL FROM domain**. In **MAIL FROM domain**, enter `bounce`; the form shows `.send.example.com` after it. For **Behavior on MX failure**, keep **Use default MAIL FROM domain**.\n\n1. Expand **Advanced DKIM settings**, and select **Easy DKIM**. Keep **RSA_2048_BIT** as the key length. Choose **Create identity**.\n\n1. The identity opens on its **Authentication** tab, which lists five DNS records to publish. Under DKIM, three CNAME records. Under **Custom MAIL FROM domain**, expand **Publish DNS records** for an MX record and a TXT record.\n\n1. Add all five to your domain\'s DNS records in Cloudflare, with their proxy status set to **DNS only**. SES can\'t read a proxied record, so check each one\'s status. You can import them in one go, or add them by hand:\n\n - **Import them.** Copy this into a text file. Replace each `REPLACE_WITH_NAME_\u2026` and `REPLACE_WITH_VALUE_\u2026` with a DKIM record\'s name and value, exactly as SES lists them, and keep the dot after each. Replace `us-east-1` with your region\'s code. In the Cloudflare dashboard, open your domain\'s **DNS \u2192 Records**, select **Import and Export**, and import the file with **Proxy imported DNS records** unchecked. SES\'s own **Download .csv record set** is in a format Cloudflare doesn\'t import.\n\n ```\n REPLACE_WITH_NAME_1. 3600 IN CNAME REPLACE_WITH_VALUE_1.\n REPLACE_WITH_NAME_2. 3600 IN CNAME REPLACE_WITH_VALUE_2.\n REPLACE_WITH_NAME_3. 3600 IN CNAME REPLACE_WITH_VALUE_3.\n bounce.send.example.com. 3600 IN MX 10 feedback-smtp.us-east-1.amazonses.com.\n bounce.send.example.com. 3600 IN TXT "v=spf1 include:amazonses.com ~all"\n ```\n\n - **Add them by hand.** Add each record exactly as SES lists it. Cloudflare adds your domain to the name, so each DKIM name ends in `._domainkey.send`, and the MAIL FROM records are named `bounce.send`.\n\n1. Wait until SES shows **Identity status** as **Verified**, and both **DKIM configuration** and **MAIL FROM configuration** as **Successful**.\n\n If they stay pending, check that each record\'s proxy status is **DNS only**.\n\n## 2. Add a DMARC record\n\nDMARC tells inboxes what to do with mail that fails its checks, and sends you reports about it. SES doesn\'t add this record, so you add it yourself. It starts in monitor mode, which reports without blocking anything.\n\n1. In the Cloudflare dashboard, open your domain\'s DNS records, and add a record:\n\n - **Type:** `TXT`\n - **Name:** `_dmarc.send`\n - **Content:** `v=DMARC1; p=none; rua=mailto:dmarc@example.com`\n\n1. Replace `dmarc@example.com` with an address on your own domain that receives mail, such as one forwarded to your inbox with Cloudflare Email Routing. Reports arrive there as attachments. An address on another domain, such as a Gmail address, gets no reports unless that domain publishes a record allowing them, which Gmail doesn\'t.\n\n1. Check that the record is published. This prints it:\n\n ```bash\n dig +short TXT _dmarc.send.example.com\n ```\n\n[Sending-domain DNS](../reference/02-sending-domain-dns.md#dmarc-publish-a-policy-and-collect-reports) explains each record, and when to tighten DMARC.\n\n## 3. Verify your test addresses\n\nA new SES account starts in the sandbox: it sends only to addresses you\'ve verified, up to 200 emails a day and one a second. The test emails in [Verify it works](07-verify.md) go to two addresses of your own, so verify those now. [Request production access](06-production-access.md) lifts the limit, once AWS grants it.\n\n1. Choose two addresses you own, such as a personal address and a work address. Verify it works uses both.\n\n1. For each, under **Identities**, choose **Create identity**, select **Email address**, and enter it. Leave **Assign a default configuration set** and **Assign to a tenant** unchecked. The form\'s notice about DMARC is about sending from the address, and the app only sends to it. Choose **Create identity**.\n\n1. Open the email AWS sends to each address, and follow its link. The identity\'s status changes to **Verified**.\n\n AWS\'s email and confirmation page say you can now send from the address. That\'s expected: SES has one kind of verified address, and the same verification lets the sandbox send to it.\n\n## 4. Send bounces and complaints to the app\n\nSES reports bounces and complaints through Amazon Simple Notification Service (SNS), which delivers them to the app. The app then stops mailing an address that bounced or complained. A configuration set says which events SES reports, and an SNS topic carries them.\n\n1. In SES, under **Configuration**, choose **Configuration sets**, then **Create set**. Name it `kestrel-mail`, keep every other option as it is, and choose **Create set**.\n\n1. On the set\'s **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, **Deliveries**, and **Rendering failures**, and choose **Next**. The app logs a rendering failure, an email SES couldn\'t build, as an error.\n\n Leave the other six off, since the app already knows about them or doesn\'t act on them. **Sends** repeats what the send records, and **Delivery delays** end as a bounce or a delivery. **Rejects** (a virus) and **Subscriptions** (SES\'s own unsubscribe) don\'t arise, since the app\'s emails carry no attachments and their own unsubscribe link. **Opens** and **Clicks** would add tracking the app doesn\'t record.\n\n1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled.\n\n1. On the same screen, under **SNS topic**, choose **Create SNS topic**. Name it `kestrel-events`, leave the display name empty, and choose **Create topic**. Then select `kestrel-events` in the topic menu, if it isn\'t already. Choose **Next**, then **Add destination**.\n\n1. Make the set your hostname\'s default, so SES reports on every email sent from it. Under **Identities**, open `send.example.com`, and choose its **Configuration set** tab, below the summary. Edit the **Default configuration set**, select **Assign a default configuration set**, select `kestrel-mail`, and save.\n\n1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), in the same region, and find its ARN. It looks like `arn:aws:sns:us-east-1:111122223333:kestrel-events`, where `us-east-1` is your region\'s code and `111122223333` is your account ID.\n\n1. Check that SES may publish to the topic, on its **Access policy** tab. A topic created from SES\'s **Add destination**, as above, already lets SES publish to it from your account. Leave that statement as it is. For a topic you made in the SNS console instead, choose **Edit**, expand **Access policy**, and add this statement to the `Statement` list:\n\n ```json\n {\n "Effect": "Allow",\n "Principal": { "Service": "ses.amazonaws.com" },\n "Action": "sns:Publish",\n "Resource": "REPLACE_WITH_TOPIC_ARN",\n "Condition": {\n "StringEquals": {\n "AWS:SourceAccount": "REPLACE_WITH_ACCOUNT_ID",\n "AWS:SourceArn": "arn:aws:ses:us-east-1:REPLACE_WITH_ACCOUNT_ID:configuration-set/kestrel-mail"\n }\n }\n }\n ```\n\n Replace the topic ARN, both copies of the account ID, and `us-east-1` with the values from your ARN. Choose **Save changes**.\n\n1. Store the topic\'s ARN in the app. In your instance\'s repository, run this, and paste the ARN at the prompt. The app accepts events only from this topic:\n\n ```bash\n npx wrangler secret put SNS_TOPIC_ARN --env production\n ```\n\n1. Store the configuration set\'s name, `kestrel-mail`. It\'s optional but recommended: the app names the set on every email, so SES reports on each one even without the hostname\'s default. Type it exactly as the set is named in this region, since SES refuses every email that names a set it can\'t find:\n\n ```bash\n npx wrangler secret put SES_CONFIGURATION_SET --env production\n ```\n\n## 5. Create the app\'s AWS key\n\nThe app sends through an AWS Identity and Access Management (IAM) user whose key can only send email and read the account\'s sending status. That limits the harm if the key ever leaks. Reading the status lets the app show whether SES is still in the sandbox and take its send rate from the account. It also warns, without sending anything, when the configuration set is missing or paused, the sender isn\'t verified, or bounces and complaints can\'t reach the app.\n\nIf `kestrel-sender` already exists, from an earlier release, don\'t create a second user or key. Update its policy instead, as the end of this section shows.\n\n1. In the [IAM console](https://console.aws.amazon.com/iam/), choose **Policies**, then **Create policy**.\n\n1. Choose the **JSON** editor, and replace its contents with this policy:\n\n ```json\n {\n "Version": "2012-10-17",\n "Statement": [\n {\n "Effect": "Allow",\n "Action": [\n "ses:SendEmail",\n "ses:SendBulkEmail",\n "ses:SendTemplatedEmail",\n "ses:SendBulkTemplatedEmail",\n "ses:GetAccount",\n "ses:GetEmailIdentity",\n "ses:GetConfigurationSet"\n ],\n "Resource": "*"\n }\n ]\n }\n ```\n\n1. Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. The review lists Pinpoint Email, SES, and SES v2, which is how IAM shows these SES actions.\n\n1. Choose **IAM users**, then **Create user**. Name it `kestrel-sender`, leave console access off, and choose **Next**.\n\n1. Choose **Attach policies directly**, search for `kestrel-send-email`, and select it. AWS recommends groups, which help manage many people, and one app\'s user doesn\'t need one. Choose **Next**, then **Create user**.\n\n1. Open `kestrel-sender`, and choose **Create access key**. It\'s an access key, not an API key. For the use case, choose **Application running outside AWS**, and choose **Next**.\n\n AWS suggests IAM Roles Anywhere instead. It needs a certificate authority of your own, so use the access key.\n\n1. For the description tag, enter the app\'s hostname, such as `newsletter.example.com`, so you can tell its key apart later. Choose **Create access key**.\n\n1. Keep the page open: AWS shows the secret access key only once. Store the access key ID in the app, and paste it at the prompt:\n\n ```bash\n npx wrangler secret put AWS_ACCESS_KEY_ID --env production\n ```\n\n1. Store the secret access key:\n\n ```bash\n npx wrangler secret put AWS_SECRET_ACCESS_KEY --env production\n ```\n\nTo update an existing key\'s policy, open **Policies** in the IAM console, then `kestrel-send-email`, and choose **Edit**. In the JSON editor, make its `Action` list match the policy above, and save. Without `ses:SendTemplatedEmail`, SES refuses even a test email. Once the key may call `ses:GetAccount`, the app\'s next check, within an hour, reads the account. Without `ses:GetConfigurationSet`, the app can\'t tell whether the configuration set exists, so it says nothing about it rather than guessing.\n\n## 6. Connect the app and deploy\n\nUntil now the app has run with no email provider connected, refusing to send. Setting `PROVIDER` to `ses` connects SES, with the secrets you stored in sections 4 and 5. If a required one is missing, the app refuses to run until you store it, and `/health` names it.\n\n1. In `wrangler.jsonc`, edit the `production` block\'s `vars`. Change `PROVIDER` to `ses` (it\'s `""` on a new instance; add it if your block has none). Change `AWS_REGION` to your region\'s code from section 1. Your other values stay as they are. The block ends up like this:\n\n ```jsonc\n "vars": {\n "PROVIDER": "ses", // \u2190 set to ses\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1" // \u2190 change: your region\'s code\n }\n ```\n\n On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan\'s limit, so each minute sends more. Leave it out on Workers Free. [As your list grows](../guides/09-as-your-list-grows.md) says when Workers Paid is worth it: for faster sends at any size, and for the daily database writes of a list past about 1,000 subscribers.\n\n The app reads its send rate from your SES account, so there\'s nothing to change when AWS grants production access. **Settings \u2192 Email sender** shows the rate and where it comes from.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Send through Amazon SES"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n1. Check that the app answers on your hostname:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n Any other answer names the setting the app is missing.\n\n1. In the editor, **Settings \u2192 Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before. Within a couple of minutes, a **Sandbox** pill appears beside it, once the app has read your SES account.\n\n1. In the editor, open **Template** and select **Send test email**, to an address you verified in [Verify your test addresses](#3-verify-your-test-addresses). It arrives. Signups stay closed until SES takes an email, so the **Not proven yet** pill beside Amazon SES in **Settings \u2192 Email sender** is gone once it has. If SES refuses it, the test\'s line says why and what fixes it.\n\n1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren\'t open yet.\n\n## 7. Subscribe the app to the topic\n\nSNS delivers events only to a subscriber that has confirmed. The app confirms on its own, once it checks that the request came from your topic.\n\n1. Check that the app\'s webhook is public. It refuses a request that isn\'t from SNS:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" -X POST https://newsletter.example.com/webhooks/ses\n ```\n\n ```\n 400\n ```\n\n Any other answer, such as a redirect to your Cloudflare login, means Access covers the webhook. Remove that path from your Access application.\n\n1. In the SNS console, open your topic, and choose **Create subscription**. Check that **Topic ARN** shows the `kestrel-events` topic, and choose it if it\'s empty.\n\n1. Set **Protocol** to **HTTPS**, and enter the endpoint:\n\n ```\n https://newsletter.example.com/webhooks/ses\n ```\n\n1. Leave raw message delivery off.\n\n1. Give SNS longer to retry. By default it retries a failed delivery 3 times over about a minute, then discards the event, so a bounce that arrives mid-deploy is lost. Expand **Delivery policy (HTTP/S)**, turn off **Use the default delivery policy**, and set these values:\n\n | Field | Value |\n | --- | --- |\n | Number of retries | `13` |\n | Retries without delay | `0` |\n | Minimum delay | `20` |\n | Minimum delay retries | `2` |\n | Maximum delay | `300` |\n | Maximum delay retries | `6` |\n | Retry-backoff function | Exponential |\n\n SNS then keeps trying for about 40 minutes. It allows an hour at most. The topic has a delivery policy of its own, under **Edit**. Setting it there instead also covers any subscription you add later.\n\n1. Choose **Create subscription**.\n\n1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.\n\n If it stays **Pending confirmation**, check that your Access application covers only `/dashboard` and `/api`. Check that `SNS_TOPIC_ARN` matches the topic\'s ARN, and that section 6\'s deploy finished. A mismatched ARN shows in the app\'s logs as a `webhook.refused` error naming both. Then request confirmation again from the topic\'s subscriptions. SNS doesn\'t let you delete a pending subscription, and removes it on its own after 48 hours.\n\n1. **(Optional)** Keep what SNS still can\'t deliver. On the subscription, choose **Edit**, and set a **Redrive policy (dead-letter queue)** to an SQS queue of your own. SNS then moves an event there instead of discarding it. [Amazon SNS dead-letter queues](https://docs.aws.amazon.com/sns/latest/dg/sns-dead-letter-queues.html) covers the queue and its permissions.\n\n## 8. Receive replies at your From address\n\nReplies to a post go to your From address, `newsletter@send.example.com`, which has no inbox yet, so they bounce. The reader is told your domain couldn\'t be found. This section forwards that address to the publisher\'s inbox with Cloudflare Email Routing. Forwarding only receives mail, so it doesn\'t change how SES sends.\n\n1. Check that your From address has no inbox yet. This prints nothing:\n\n ```bash\n dig +short MX send.example.com\n ```\n\n If it lists mail servers, `send.example.com` already receives mail elsewhere, and Email Routing would replace those servers. Check that your mail host has a mailbox for `newsletter@send.example.com`, and skip the rest of this section.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), go to **Compute \u2192 Email Service \u2192 Email Routing**.\n\n1. Add `send.example.com` to Email Routing: select `example.com`, then **Settings**, and add `send` under **Subdomains**.\n\n1. Cloudflare adds MX, SPF, and DKIM records for `send.example.com`, and locks them. Wait until they show as added. Its SPF record is for forwarding: SES\'s mail passes SPF at `bounce.send.example.com`, from section 1.\n\n1. Under **Destination Addresses**, add the publisher\'s inbox. Open the email Cloudflare sends to it, and select **Verify email address**. If it isn\'t your inbox, ask the publisher to do this step.\n\n1. Under **Routing Rules**, select **Create routing rule**. For **Email pattern**, enter `newsletter`, the part of your From address before the `@`, and select `send.example.com`. Set **Action** to **Send to an email**, select the publisher\'s inbox as the **Destination**, and save.\n\n1. **(Optional)** Give your DMARC reports an inbox too. If the address in your DMARC record doesn\'t receive mail, add a rule that forwards `dmarc` the same way. Then change the record\'s `rua` to `mailto:dmarc@send.example.com`.\n\n1. Check that your From address receives mail. This now lists Cloudflare\'s three `route` mail servers:\n\n ```bash\n dig +short MX send.example.com\n ```\n\n1. Send an email to `newsletter@send.example.com` from an address whose mail doesn\'t go through the publisher\'s inbox. Gmail, for one, hides a forwarded copy of mail you sent yourself. In Email Routing\'s **Activity log**, the email shows as **Forwarded**.\n\n If it bounces saying the domain couldn\'t be found, the sender\'s provider is still remembering that `send.example.com` had no mail servers. That happens after an email to it before this section, and lasts up to 30 minutes on Cloudflare\'s DNS. Wait, and send it again.\n\nOut-of-office and other automatic replies reach the publisher\'s inbox too. Bounces don\'t: they go to SES, which reports them to the app. The publisher answers from their own inbox, not from `newsletter@send.example.com`. To answer from the newsletter\'s address instead, give it a mailbox at a mail host such as [Fastmail](https://www.fastmail.help/hc/en-us/articles/360060591133) or [Google Workspace](https://support.google.com/a/answer/140034), in place of forwarding.\n\nSES is now connected in the sandbox. Until AWS grants production access, it sends only to the addresses you verified in section 3. It also sends to the SES test addresses Verify it works uses. SES charges for each email it sends, test emails included, as [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists.\n\nNext, [request production access](06-production-access.md).\n';
13194
13235
 
13195
13236
  // docs/get-started/06-production-access.md
13196
13237
  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
13238
 
13198
13239
  // 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. Both work in one step, with no login.\n\n1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender.\n\n1. Select it. In the editor, **Subscribers** shows the address as **Unsubscribed**, and later sends leave it out.\n\n## 6. Check that bounces and complaints come back\n\nWhen an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address: a bounce suppresses it, and a complaint unsubscribes it. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:\n\n - `bounce@simulator.amazonses.com`, which bounces\n - `complaint@simulator.amazonses.com`, which marks your mail as spam\n\n Each one gets a confirmation email, which bounces or is marked as spam.\n\n1. In the editor, **Subscribers** lists the bounce address as **Pending** with a **bounced** flag, and the complaint address as **Unsubscribed**. Open its row: its page says it left by a complaint. The app mails neither again.\n\nIf the bounce address has no flag, or the complaint address is still pending, SES's events aren't reaching the app. Check the configuration set in [Send bounces and complaints to the app](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.\n\n1. Filter on `event` equal to `send.completed`. Your send from section 4 is there.\n\n1. Filter on `event` equal to `send.scheduled`. Your two sends from section 4 are there, and `send.canceled` shows the one you canceled. Each line that records an action says who took it. Its `principal` is `human` or `service`, and its `client` names the program a person used, such as `editor`.\n\n1. Filter on `level` equal to `error`. A healthy instance shows nothing.\n\nTo follow one send, narrow the time range and filter on its `sendId`, since a long range may show only a sample. On the Free plan, Cloudflare stops keeping logs for the rest of the day once its daily allowance is used, as [its pricing page](https://developers.cloudflare.com/observability/pricing/) describes.\n\n## 8. 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";
13240
+ var verify_default = "# Verify it works\n\nThis page checks your instance end to end with real email, while the list is empty and every email goes only to you. You confirm that inboxes trust your mail, that posts look right in a mail client, and that bounces, complaints, and unsubscribes reach the app.\n\nYou can do all of this while SES is still in the sandbox, since every email goes to the addresses you verified in [Connect Amazon SES](05-ses.md#3-verify-your-test-addresses) or to SES's test addresses.\n\nIn this guide, you send yourself a test email, reply to it, and check that it passes authentication. You check that notifications arrive, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs, and clean up the test subscribers.\n\n## Before you begin\n\n- The two addresses you verified in SES in [Connect Amazon SES](05-ses.md#3-verify-your-test-addresses). Some steps use both.\n- A Gmail address, or another mail client that shows a message's original source and an **Unsubscribe** button.\n\n## 1. Send yourself a test email\n\nA test email goes through the same code as a real send. If the test looks right, the real send looks the same.\n\n1. In the editor at `https://newsletter.example.com/dashboard/`, create a post with a subject, a short body, and an image.\n\n1. Select **Send test email**, enter your address, and send it. If SES refuses it, the test's line says why and what fixes it.\n\n1. Open the email. It arrives in your inbox, not in spam, the image loads, and the subject reads as you wrote it. Its links work, but a test email unsubscribes no one; section 5 unsubscribes for real.\n\n1. Send a test email to your second address, not the publisher's inbox, and reply to it from there, as a reader would. The reply arrives in the publisher's inbox, the one you chose in [Connect Amazon SES](05-ses.md#8-receive-replies-at-your-from-address), or in your From address's own mailbox if you skipped forwarding. If it bounces, check the routing rule and the destination address there.\n\n## 2. Check that it passes authentication\n\nInboxes trust mail that passes DKIM and DMARC for the domain it's from. Passing both is the best single sign that your mail reaches the inbox.\n\n1. Open the test email's original source. In Gmail, open the message's menu and select **Show original**.\n\n1. Find the `Authentication-Results` header. It shows:\n\n - `dkim=pass` for `send.example.com`\n - `dmarc=pass` for `send.example.com`\n - `spf=pass`, for a hostname under `send.example.com`\n\n If SPF passes for `amazonses.com` instead, SES couldn't read your custom MAIL FROM domain's MX record, and used its own. DMARC still passes, but check the MAIL FROM records from [Connect Amazon SES](05-ses.md#1-verify-your-sending-hostname).\n\nIf a check fails, compare your DNS records with the ones in [Connect Amazon SES](05-ses.md#1-verify-your-sending-hostname). [Sending-domain DNS](../reference/02-sending-domain-dns.md#verify-alignment) explains each result.\n\n## 3. Check that notifications arrive\n\nKestrel emails each notifications address when a send finishes, and right away when one runs into a problem. Check that they reach you, using one of the addresses you verified in SES. While SES is in the sandbox, every notifications address has to be verified there too, unless notifications go through [Cloudflare's email](../guides/03-notifications.md), where each is verified with Cloudflare instead. If you're setting Kestrel up for someone else, you can add their address beside yours later.\n\n1. In the editor, open **Settings \u2192 Notifications**, type that address under **Send notifications to**, select **Add inbox**, and then **Save**.\n\n1. Select **Send a test notification**. It arrives at that address. If it doesn't, **Last notification**, on the same page, says what happened.\n\nThe publisher sets their own address in [Check your setup](../publishing/10-check-your-setup.md).\n\n## 4. Send a real post to yourself\n\nThis is a real send, to a list of one. It proves the whole path: subscribing, confirming, scheduling, and sending.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe with your second address.\n\n1. Open the confirmation email, follow its link, and select **Confirm subscription**. In the editor, **Subscribers** shows the address as **Confirmed**.\n\n1. Open the post, select **Schedule**, and then **Send now**.\n\n1. In the **Send now?** dialog, uncheck **Include in archive**, and select **Send now**. The post goes out as email only, so it doesn't become the publication's first archived post. The dashboard keeps its **Finish setting up** steps for the publisher. Every send waits at least five minutes before it goes out, so you can cancel a mistake.\n\n1. Cancel the send, to see that canceling stops it. Then send it again the same way, with **Include in archive** unchecked, and let it go.\n\n1. When the send finishes, the post arrives at your second address. A notification arrives at your first, with the subject `Sent:` and the post's title.\n\nIf the send shows in red under **Sent**, its line names the fix. Once that's fixed, the send resumes on its own.\n\n## 5. Unsubscribe\n\nEvery email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. The button works in one step, and the link asks you to confirm first. Neither needs a login.\n\n1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender. If your client shows none, use the **Unsubscribe** link in the email's footer, which asks you to confirm on a page.\n\n1. Select it. In the editor, **Subscribers** shows the address as **Unsubscribed**, and later sends leave it out.\n\n## 6. Check that bounces and complaints come back\n\nWhen an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address: a bounce suppresses it, and a complaint unsubscribes it. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:\n\n - `bounce@simulator.amazonses.com`, which bounces\n - `complaint@simulator.amazonses.com`, which marks your mail as spam\n\n Each one gets a confirmation email, which bounces or is marked as spam.\n\n1. In the editor, **Subscribers** lists the bounce address as **Pending** with a **bounced** flag, and the complaint address as **Unsubscribed**. Open its row: its page says it left by a complaint. The app mails neither again.\n\nIf the bounce address has no flag, or the complaint address is still pending, SES's events aren't reaching the app. Check the configuration set in [Send bounces and complaints to the app](05-ses.md#4-send-bounces-and-complaints-to-the-app), and the subscription in [Subscribe the app to the topic](05-ses.md#7-subscribe-the-app-to-the-topic).\n\n## 7. Read the logs\n\nThe app writes a log line for each thing it does, and Cloudflare keeps them for a few days, depending on your plan. Nothing else tells you if the once-a-minute schedule that sends posts stops, so it's worth knowing where to look.\n\n1. In the Cloudflare dashboard, go to **Workers & Pages**, select your Worker, the `name` in the `production` block of `wrangler.jsonc`, and select **Observability**.\n\n1. Filter on `event` equal to `sweep.tick`. A line arrives every minute. If none arrive, the schedule probably isn't running, and deploying again with `--env production` registers it.\n\n1. Filter on `event` equal to `send.completed`. Your send from section 4 is there.\n\n1. Filter on `event` equal to `send.scheduled`. Your two sends from section 4 are there, and `send.canceled` shows the one you canceled. Each line that records an action says who took it. Its `principal` is `human` or `service`, and its `client` names the program a person used, such as `editor`.\n\n1. Filter on `level` equal to `error`. A healthy instance shows nothing.\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
13241
 
13201
13242
  // 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, as [Check that AWS granted it](06-production-access.md#4-check-that-aws-granted-it) describes, so SES can send to anyone. From here, the publisher can set up the publication, and you can continue with the guides below.\n\n## Start publishing\n\nThe rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md).\n\nIf someone else is the publisher, let them sign in first:\n\n1. Add their email address to the policy on the Access application that lets you in, and to `ACCESS_ALLOWED_EMAILS` if you set it, as in [Give the app its two values](03-access.md#4-give-the-app-its-two-values).\n\n1. If they don't have a Cloudflare account, add another way to sign in, such as a one-time code by email. It's under **Zero Trust \u2192 Integrations \u2192 Identity providers**.\n\n1. Add their address under **Settings \u2192 Notifications** beside yours, so a problem reaches both of you.\n\n1. Send them the editor's address and a link to [Name your publication](../publishing/03-name-your-publication.md).\n\n## Optional next steps\n\nEach of these guides adds one thing, and you can come back to any of them later.\n\n- [Allow Claude to connect](../guides/01-allow-claude.md), so the publisher can connect Claude Desktop, claude.ai, or Claude Code, and have it draft, proofread, and schedule posts as them, through the same API the editor uses.\n- [Automate with a service token](../guides/02-automate.md), so a script can call the API with a service token, when no one is signed in.\n- [Notifications through Cloudflare's email](../guides/03-notifications.md), so problems are reported even when the email provider has stopped sending.\n- [Rate-limit the subscribe form](../guides/05-rate-limit.md), before the subscribe link is shared widely.\n- [As your list grows](../guides/09-as-your-list-grows.md), from about 1,000 subscribers.\n- [Upgrade to a new release](../guides/07-upgrade.md), when one comes out.\n";
13243
+ 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
13244
 
13204
13245
  // 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`; 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 Claude can't connect, [Claude can't connect](../publishing/06-troubleshooting.md#claude-cant-connect) lists what it reports and the fix.\n";
13246
+ 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
13247
 
13207
13248
  // docs/guides/02-automate.md
13208
13249
  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';
@@ -13251,10 +13292,10 @@ The From name and address your emails come from are set when Kestrel is deployed
13251
13292
  `;
13252
13293
 
13253
13294
  // 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";
13295
+ var write_a_post_default = "# Write and send a post\n\nWrite in Markdown, schedule it, then send yourself a test. Scheduling makes the exact email your list will get, so the test you send is what goes out.\n\n**New post**, on the Dashboard or under **Drafts**, opens a draft.\n\n## Write\n\nThe editor takes Markdown, and Kestrel saves as you type. The same Markdown becomes the email, in your [email template](11-email-template.md), and the post's page in your archive. **Preview** shows the email, and warns under it about anything it found wrong.\n\n- A blank line starts a new paragraph.\n- The opening lines are the preview a mail client shows beside the subject.\n- Paste or drop an image to add it: PNG, JPEG, WebP, or GIF, up to 5 MB. Kestrel may change its file name, turning spaces into hyphens and numbering a name the post already has, and inserts the new name for you.\n\n## Schedule and send\n\nChoose **Schedule**, pick a time under **Send at**, and confirm. The time must be at least five minutes away by default, or the lead whoever runs your instance set. That's your window to catch a mistake, and you can cancel or move the send until then. **Send now** sends after the same wait.\n\n**Then send yourself a test.** Scheduling locks the post and makes its email, so **Send test email** now sends exactly what your list receives. **Settings \u2192 Default test recipients** fills in the addresses. A test needs an email provider, which [Connect Amazon SES](../get-started/05-ses.md) sets up.\n\nTo change the post, choose **Cancel** on its banner, and it's a draft again. **Edit schedule** moves the time without unlocking it.\n\nAt the send time, the post goes to your confirmed subscribers, and its page goes up in your archive. Watch it go out under **Sent**. If a send needs you, its page says so, and [Troubleshooting](06-troubleshooting.md) covers each case.\n\n## Post settings\n\n**Post settings**, at the top right of the editor, holds two choices you can change until the post sends:\n\n- **Include in archive.** Uncheck it to send the post as email only, for a correction or a short notice. It stays off your archive and home page.\n- **Archive address.** It follows the subject until you type your own. It's fixed once the post sends.\n\nAfter a post sends, its page under **Sent** can take it out of your archive or put it back.\n";
13255
13296
 
13256
13297
  // 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";
13298
+ var troubleshooting_default = "# Troubleshooting\n\nWhen something needs you, Kestrel says so in a line on the send's page, under **Sent**. The top of the dashboard shows it too. Red needs you to act, and amber is worth a look. The line says what happened and what to do. This page covers what the screen can't: the fixes made outside Kestrel, and the decisions that are yours.\n\n**Set a notifications address, so a problem reaches you without your watching.** Set it under **Settings \u2192 Notifications**, in **Send notifications to**: type an address and choose **Add inbox**. Add the address of whoever runs your instance too, and both of you are told. With none set, Kestrel emails you nothing. [Notifications](#notifications), below, lists what you're told.\n\n| Symptom | Where the fix is |\n| --- | --- |\n| [A send is held or waiting](#a-send-is-held-or-waiting) | Usually outside Kestrel |\n| [Someone was mailed twice, or not at all](#someone-was-mailed-twice-or-not-at-all) | In Kestrel |\n| [A reader says they never got a post](#a-reader-says-they-never-got-a-post) | In Kestrel |\n| [A send still reads Settling](#a-send-still-reads-settling) | Nothing to fix, unless no receipts come at all |\n| [A test doesn't arrive](#a-test-doesnt-arrive) | Outside Kestrel |\n| [Signups aren't open yet, or are paused](#signups-arent-open-yet-or-are-paused) | In Kestrel, or outside it |\n| [The dashboard names a problem with your Amazon SES account](#a-problem-with-amazon-ses) | Outside Kestrel |\n| [An address bounced or complained](#an-address-bounced-or-complained) | Nothing to fix, unless you lift a suppression |\n| [A warning says to move to Workers Paid](../guides/09-as-your-list-grows.md) | Outside Kestrel |\n| [An import is refused](#an-import-is-refused) | In your file |\n| [Claude can't connect](#claude-cant-connect) | Usually outside Kestrel |\n\n## A send is held or waiting\n\nNothing is lost. A problem with your email provider never marks anyone unsent or mails anyone twice. The send waits where it is, and carries on once the problem is fixed. The send's line names the fix. When that fix is outside Kestrel, it's one of these:\n\n- **Amazon SES refuses your account or the send.** The line carries SES's words and the fix, almost always in the SES console. [Connect Amazon SES](../get-started/05-ses.md) sets up each piece SES checks: the sending domain, the AWS key, and the configuration set. [Request production access](../get-started/06-production-access.md) lifts the sandbox. When SES refuses your account, any other post that comes due meanwhile waits with it. When it refuses only this send's email, other posts go out as usual. While SES takes some readers and refuses the rest, as while a daily quota frees up, those it takes are sent. The line stays until SES takes everyone.\n- **SES keeps refusing for its sending rate.** Kestrel is sending faster than your SES account allows. **Settings \u2192 Email sender** shows the **Send rate** and where it comes from. If `SES_MAX_SEND_RATE` sets it, lower it to the maximum send rate on the SES console's account dashboard, or remove it, then deploy, as [Configuration](../reference/01-configuration.md#vars) describes. If it's Kestrel's default, the app can't read your account: allow its key `ses:GetAccount`, as [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) shows.\n- **No email provider is connected.** Connect one, as [Connect Amazon SES](../get-started/05-ses.md) describes, and the send goes out at its next retry.\n- **A send missed its time, or nothing has gone out for a while.** The part of Kestrel that sends has stopped, or keeps failing on this send. Check the Worker's Cron Trigger, as [Configuration](../reference/01-configuration.md#bindings-and-triggers) describes, and read the app's logs, as [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes. If another send is going out at the same time, this one may only be waiting its turn.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## Someone was mailed twice, or not at all\n\nKestrel never mails a reader twice on its own. When it can't tell whether your provider took some emails, it stops and asks you: the send's page shows **Resolve\u2026**. Choose **Assume not sent** unless you know otherwise, since it's the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it, such as from a reader who tells you so. Either choice mails no one.\n\nA reader recorded as **Unsent** is still on your list and gets your next post. That send never mails them again: their own email kept failing, SES won't take their address, or you chose **Assume not sent**.\n\n## A reader says they never got a post\n\nUnder **Subscribers**, search for their address and open their row. Their page lists every send they were in, newest first, with what happened on each: **Delivered**, a bounce, a complaint, **Unsent** with your email provider's words, or **Skipped** because they had unsubscribed or were suppressed by then. Each send links to its record. The page also shows how they joined and why they left, if they did. Any bounce or block on the address shows with the provider's words or yours. If they aren't on the list, or are still **pending**, they never confirmed, and only confirmed subscribers are sent your posts.\n\n## A send still reads Settling\n\nThat's normal. **Delivered** counts only the emails your provider confirmed reached the reader's mail server. Confirmations can lag by days, so the counts keep rising after a send finishes. Some are never confirmed at all, so a send can read **Settling** for good, as long as some receipts have come.\n\nWhen none come, the send's page says so an hour after it went out. Your provider's reports aren't reaching Kestrel, so its bounces suppress no one and its complaints unsubscribe no one. Your next post mails those addresses again. Look for `webhook.refused` errors in the app's logs, as [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes. Each names the SNS topic that reached Kestrel and the one it expects. Then check the subscription, as [Connect Amazon SES](../get-started/05-ses.md#7-subscribe-the-app-to-the-topic) describes. If someone else set up Kestrel for you, pass this along to them.\n\n## A test doesn't arrive\n\nWhile Amazon SES is in its sandbox, it mails only addresses verified in SES. Verify the address you test with in the SES console, or [request production access](../get-started/06-production-access.md). When the test's line says the provider didn't answer, check the inbox before you send it again, since the first may have arrived. A test SES refuses for your account has the same fix as [a send it refuses](#a-send-is-held-or-waiting). If someone else set up Kestrel for you, pass this along to them.\n\n## Signups aren't open yet, or are paused\n\nYour subscribe page takes signups only while email is working, so a reader is never told a link is coming when none can go. Every visitor gets the same page, whatever address they enter.\n\n- \"Signups aren\u2019t open yet\" means SES hasn't sent an email for your publication yet. Send one from **Template \u2192 Send test email**, to an address verified in SES while it's in the sandbox. Once SES takes it, signups open. Before an email provider is connected, the page says the same, and [Connect Amazon SES](../get-started/05-ses.md) is the fix.\n- \"Signups are paused\" means SES is refusing your email, every email including confirmations and tests. The dashboard's **Email isn\u2019t going out** line gives SES's words and the fix, as for [a problem with Amazon SES](#a-problem-with-amazon-ses). Then send a test email: once SES takes one, signups reopen.\n\nKestrel notices a refusal when an email meets it, or within about ten minutes from its check of your SES account. A refusal the check found lifts on its own within about ten minutes of the fix; one an email met lifts when a test email goes through. A reader who signed up just as it started may have been told a link was coming, and can sign up again once signups reopen.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## A problem with Amazon SES\n\n**Settings \u2192 Email sender** and the top of the dashboard name a problem with your Amazon SES account within about ten minutes, without sending anything. Red means no email can go out until it's fixed, tests and confirmations included. Amber is worth a look. Each line links the step that fixes it:\n\n- **The configuration set is missing, or its sending is off.** The set Kestrel sends with, the one `SES_CONFIGURATION_SET` names or the sending identity's default if that secret isn't set, isn't in your region, or its sending is turned off. Correct the name, or create the set, as [Connect Amazon SES](../get-started/05-ses.md#4-send-bounces-and-complaints-to-the-app) shows. Turn a set's sending back on in the SES console, under its configuration sets.\n- **SES rejects the AWS key.** The key ID is unknown, the secret doesn't match it, or the AWS account isn't signed up for SES. Store the key's ID and secret again, or create a new key, as [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) shows.\n- **The sender isn't verified.** SES has no verified identity for the From address in your region. Verify the sending hostname, as [Connect Amazon SES](../get-started/05-ses.md#1-verify-your-sending-hostname) shows.\n- **Sending is turned off for the account.** AWS documents turning it back on only with the AWS CLI, naming your region: `aws sesv2 put-account-sending-attributes --sending-enabled --region us-east-1`.\n- **AWS paused the account, or is reviewing it.** AWS does this when the account's bounce or complaint rate runs high. While it reviews, SES still sends, and the line is amber. Once it pauses sending, the line is red. Either way, AWS's email to the AWS account says what it needs: answer it in AWS Support, and check the addresses on your recent sends' records.\n\nThe app needs to read each of these. If its key isn't allowed to, the line says nothing rather than guessing. The policy in [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) allows all of it.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## An address bounced or complained\n\nKestrel acts on these for you, so a bad address can't keep hurting your sending reputation:\n\n- A hard bounce means the address can't receive mail. Kestrel never mails it again.\n- A soft bounce is temporary, such as a full mailbox. The address stays on your list.\n- A complaint means the reader marked your email as spam. Kestrel unsubscribes them, whichever email they reported, and their page, opened from their row, says they left by a complaint.\n\nUnder **Subscribers**, a bounced address keeps its status and carries a label saying why. Its page gives your email provider's words for the bounce and when it happened. When a recent send's bounces reach about 5% of its recipients, the dashboard warns you. That's about the rate at which email providers start to review a sender, so check the addresses on its record.\n\n### Lifting a suppression\n\nLift one only when you know the address works again, such as a reader who tells you their mailbox is fixed. Mailing an address that keeps failing can hurt every send after it. In a subscriber's \u22EF menu, **Unblock** lifts a block and **Clear** lifts a bounce, after asking you to confirm. A complaint isn't a suppression but the reader leaving, so nothing lifts it. They come back only by subscribing again themselves, and **Add subscriber** refuses them. Past records keep the bounce or complaint where it happened.\n\n## An import is refused\n\nNothing on your list changes. Kestrel refuses a file it can't read with certainty, and names the row and column it couldn't. Fix the file and import it again. Convert a file in another format first, with Claude or by hand. Move a column of your old service's into `evidence`. Split a file of more than 10 MB, or with too many rows. [The file format](01-your-list.md#the-file-format) describes what each column takes.\n\n## Claude can't connect\n\n[Connect Claude](08-connect-claude.md) covers connecting it. Most failures are in your instance's Cloudflare Access setup, and [Allow Claude to connect](../guides/01-allow-claude.md#check-it) lists their fixes. When connecting fails:\n\n- **Claude says it couldn't connect, or the sign-in failed.** Check that the connector's address is your publication's address followed by `/api/mcp`, and that your instance is deployed, not running on your own computer. If both are right, your instance's Access setup needs the fix.\n- Signing in shows `invalid_target`, or `kestrel login` says your instance \"doesn't offer a sign-in for programs\". Your instance's Access setup needs the fix.\n- `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in, and your instance's Access setup needs the fix. If either names `access_unidentified`, it's the \"Access let you in\" problem below. If `kestrel api` says your sign-in has ended, sign in again as [Connect Claude](08-connect-claude.md#claude-code) describes.\n- `kestrel api` says it couldn't renew your sign-in because its Access team answered 404 (not found). First check which instance it called: when you don't name one, it prints a line naming it before \"the only instance you're signed in to\", which may not be the one you meant. Name yours with `--url`. If it is yours, the Access team domain was most likely renamed or removed since you signed in. Sign in again with `npx kestrel login` and your instance's address, which finds the team it uses now.\n- **\"Access let you in, but didn't say who you are\".** Your login carried neither an email nor a service token. Sign out, then sign in through the policy that lets you in by email. If you have no such policy, your instance's Access setup needs the fix.\n- **Claude's changes read \"via the API\".** Claude called the API some way other than `kestrel api`, which names Claude Code. Your instance's instructions for Claude Code have it use `kestrel api` once they're brought up to date, as [Install the release](../guides/07-upgrade.md#2-install-the-release) describes. Changes reading \"a service token\" mean Claude still uses one, and [Connect Claude](08-connect-claude.md#claude-code) signs it in as you instead.\n\nIf someone else set up Kestrel for you, pass this along to them.\n\n## Where to look\n\nWhen a line doesn't settle it, these say the most about what happened:\n\n- The top of the dashboard: what needs someone now, in Kestrel's own words.\n- The send's page, under **Sent**: what happened on that send, when it next retries, and its record of each reader.\n- **Settings \u2192 Email sender**: the state of your Amazon SES account, and your **Send rate**.\n- **Settings \u2192 Notifications**: **Last notification** says whether the last one was delivered, or why not.\n- A subscriber's page, under **Subscribers**: every send they were in, and what happened on each.\n- The app's logs, in your instance's Cloudflare account: [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes them. If someone else set up Kestrel for you, pass this along to them.\n\n## Notifications\n\nWith an address set, Kestrel emails you:\n\n- when a send finishes, with how many emails your provider accepted, and how late it went out if it did\n- when no one's email was accepted for a finished send, since it can't be sent again\n- when email isn't going out and signups are paused: once when it starts, and once more if a post is held after an email that named none\n- when your provider refuses a send or hits a limit, or no provider is connected\n- when the SES sandbox refuses a signup's confirmation, at most once a day\n- when a send stops going out with nothing to explain it, waits for you to resolve it, or doesn't go out on time\n- when your provider has sent no receipts for six hours after a send, once until a receipt arrives\n\nEach says whether anyone has to act, and how. If the fix needs whoever set up your instance, you can forward it as it stands. Each goes to every address set, links to the page it's about, and is sent once while the problem lasts. A reader's address in your provider's words shows only as its domain, and in full on the send's page. If one address can't be reached, the others still get it, and **Last notification** names the one that failed. A bounce spike shows on the dashboard but isn't emailed. **Send a test notification** checks that one arrives.\n\nBy default, notifications go through your newsletter's own email provider. So when that provider refuses your account, the notification about it can't reach you either. You can send them another way, as [Notifications through Cloudflare's email](../guides/03-notifications.md) describes.\n";
13258
13299
 
13259
13300
  // docs/publishing/08-connect-claude.md
13260
13301
  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 +13307,10 @@ var share_your_subscribe_link_default = "# Share your subscribe link\n\nYour pub
13266
13307
  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
13308
 
13268
13309
  // 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 one-click unsubscribe link. Required. |\n| `{{ .Email.ViewInBrowserURL }}` | The post's own web link, which opens its page once it's sent, in your archive or not. |\n\n## Test it\n\n**Send test email** on the **Template** page sends a sample post through your saved template, so you see it in a real inbox. With unsaved edits, the button reads **Save & send test**, since a test always uses the saved template.\n\nA change to the template, or to a publication detail it shows, re-makes the email of every scheduled post, with the same content and send time. Kestrel asks before it saves, and each re-made post then asks you to send a new test. A change is refused while a post is within its last few minutes before sending. Posts already sending or sent never change.\n";
13310
+ 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
13311
 
13271
13312
  // 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";
13313
+ 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
13314
 
13274
13315
  // docs/README.md
13275
13316
  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";
@@ -13861,6 +13902,17 @@ function baseName(name) {
13861
13902
  const parts = name.split(/[\\/]/);
13862
13903
  return parts[parts.length - 1] ?? "";
13863
13904
  }
13905
+ var UNREFERENCEABLE = /[\s\p{Cc}<>()[\]`#?%]+/gu;
13906
+ function cleanName(name) {
13907
+ return baseName(name).replace(UNREFERENCEABLE, "-").replace(/^-+|-+$/g, "").replace(/-+\./g, ".");
13908
+ }
13909
+ function numberedName(filename, n) {
13910
+ if (n === 1) {
13911
+ return filename;
13912
+ }
13913
+ const dot = filename.lastIndexOf(".");
13914
+ return dot > 0 ? `${filename.slice(0, dot)}-${n}${filename.slice(dot)}` : `${filename}-${n}`;
13915
+ }
13864
13916
  function publicImage(c, row) {
13865
13917
  return {
13866
13918
  filename: row.filename,
@@ -13885,16 +13937,13 @@ async function uploadImage(c) {
13885
13937
  }
13886
13938
  imageField = { field: "file" };
13887
13939
  const override = form.get("filename");
13888
- filename = baseName(
13940
+ filename = cleanName(
13889
13941
  typeof override === "string" && override ? override : file.name || "upload"
13890
13942
  );
13891
13943
  contentType = mediaType(file.type);
13892
13944
  bytes = await file.arrayBuffer();
13893
13945
  } else {
13894
- filename = baseName(c.url.searchParams.get("filename") ?? "");
13895
- if (!filename) {
13896
- throw badRequest("filename query param required for a raw upload", { field: "filename" });
13897
- }
13946
+ filename = cleanName(c.url.searchParams.get("filename") ?? "");
13898
13947
  contentType = mediaType(ct);
13899
13948
  bytes = await c.req.arrayBuffer();
13900
13949
  }
@@ -13913,17 +13962,25 @@ async function uploadImage(c) {
13913
13962
  imageField
13914
13963
  );
13915
13964
  }
13916
- const key = storageKey(post.id, filename);
13917
- await c.env.MEDIA.put(key, bytes, { httpMetadata: { contentType } });
13918
13965
  const dims = probeImageDimensions(new Uint8Array(bytes));
13919
- const row = await upsertImage(c.env.DB, {
13920
- postId: post.id,
13921
- filename,
13922
- storageKey: key,
13923
- contentType,
13924
- width: dims?.width ?? null,
13925
- height: dims?.height ?? null
13926
- });
13966
+ let row = null;
13967
+ for (let n = 1; !row; n++) {
13968
+ const candidate = numberedName(filename, n);
13969
+ row = await insertImageIfNameFree(c.env.DB, {
13970
+ postId: post.id,
13971
+ filename: candidate,
13972
+ storageKey: storageKey(post.id, candidate),
13973
+ contentType,
13974
+ width: dims?.width ?? null,
13975
+ height: dims?.height ?? null
13976
+ });
13977
+ }
13978
+ try {
13979
+ await c.env.MEDIA.put(row.storage_key, bytes, { httpMetadata: { contentType } });
13980
+ } catch (e) {
13981
+ await deleteImageRow(c.env.DB, post.id, row.filename);
13982
+ throw e;
13983
+ }
13927
13984
  const body = { image: publicImage(c, row) };
13928
13985
  return json(body, 201);
13929
13986
  }
@@ -14222,8 +14279,7 @@ async function commitImport(db, plan2, principal, now) {
14222
14279
  confirm: [],
14223
14280
  addConfirmed: [],
14224
14281
  suppress: [],
14225
- erase: [],
14226
- notice: principal.kind === "service"
14282
+ erase: []
14227
14283
  };
14228
14284
  const tokens = newTokens(plan2.counts.added_confirmed + plan2.counts.added_unsubscribed);
14229
14285
  const token2 = () => tokens.pop() ?? "";
@@ -14467,9 +14523,9 @@ async function commit(c) {
14467
14523
  if (!principal) {
14468
14524
  throw badRequest("no principal");
14469
14525
  }
14470
- const done = await getImportByPlanHash(c.env.DB, planHash);
14471
- if (done && done.file_sha256 === await fileSha256(input.text)) {
14472
- return json(replayResponse(done));
14526
+ const done2 = await getImportByPlanHash(c.env.DB, planHash);
14527
+ if (done2 && done2.file_sha256 === await fileSha256(input.text)) {
14528
+ return json(replayResponse(done2));
14473
14529
  }
14474
14530
  const p = await plan(c, input);
14475
14531
  if (p.planHash !== planHash) {
@@ -14807,12 +14863,12 @@ function requireConnectedProvider(config) {
14807
14863
  );
14808
14864
  }
14809
14865
  }
14810
- function perRecipient(result2, recipients) {
14866
+ function perRecipient(result2, recipients2) {
14811
14867
  if (result2.kind === "answered") {
14812
14868
  return result2.results;
14813
14869
  }
14814
14870
  const { reason, error: error2 } = result2.halt;
14815
- return recipients.map((r) => ({
14871
+ return recipients2.map((r) => ({
14816
14872
  email: r.email,
14817
14873
  accepted: false,
14818
14874
  retryable: reason === "unavailable",
@@ -14891,10 +14947,10 @@ async function confirmationMessage(c, token2) {
14891
14947
  }
14892
14948
  async function sendConfirmation(c, message2, subscriber, attemptAt, fromReader) {
14893
14949
  const provider = getProvider(c.config, c.env);
14894
- const recipients = [{ email: subscriber.email, unsubscribeUrl: "" }];
14950
+ const recipients2 = [{ email: subscriber.email, unsubscribeUrl: "" }];
14895
14951
  let result2;
14896
14952
  try {
14897
- result2 = await provider.sendBatch(message2, recipients, {
14953
+ result2 = await provider.sendBatch(message2, recipients2, {
14898
14954
  // One tag per confirmation sent, never reused: the fake records once per tag and
14899
14955
  // address, and would otherwise fold a deliberate resend into the first.
14900
14956
  purpose: "confirmation",
@@ -15292,21 +15348,21 @@ var FakeNotifier = class {
15292
15348
  };
15293
15349
 
15294
15350
  // src/services/test_send.ts
15295
- async function sendTest(db, config, provider, email2, recipients, opts, kind) {
15351
+ async function sendTest(db, config, provider, email2, recipients2, opts, kind) {
15296
15352
  let answer;
15297
15353
  try {
15298
- answer = await provider.sendBatch(email2, recipients, opts);
15354
+ answer = await provider.sendBatch(email2, recipients2, opts);
15299
15355
  } catch (err) {
15300
15356
  const error2 = err instanceof Error ? err.message : String(err);
15301
15357
  const notSent = noAnswer(error2);
15302
- logNotSent(provider, kind, recipients.length, notSent);
15303
- return recipients.map((r) => ({ email: r.email, sent: false, notSent }));
15358
+ logNotSent(provider, kind, recipients2.length, notSent);
15359
+ return recipients2.map((r) => ({ email: r.email, sent: false, notSent }));
15304
15360
  }
15305
15361
  await noteContact(db, config, "test", contactOutcome(answer), Date.now());
15306
15362
  const halt = answer.halt;
15307
15363
  const unknown = [];
15308
- const refused2 = [];
15309
- const answers = perRecipient(answer, recipients).map((r) => {
15364
+ const refused3 = [];
15365
+ const answers = perRecipient(answer, recipients2).map((r) => {
15310
15366
  if (r.accepted) {
15311
15367
  return { email: r.email, sent: true };
15312
15368
  }
@@ -15315,15 +15371,15 @@ async function sendTest(db, config, provider, email2, recipients, opts, kind) {
15315
15371
  if (notSent.may_have_arrived) {
15316
15372
  unknown.push(notSent);
15317
15373
  } else if (covering || !r.retryable) {
15318
- refused2.push(notSent);
15374
+ refused3.push(notSent);
15319
15375
  }
15320
15376
  return { email: r.email, sent: false, notSent };
15321
15377
  });
15322
15378
  if (unknown[0]) {
15323
15379
  logNotSent(provider, kind, unknown.length, unknown[0]);
15324
15380
  }
15325
- if (refused2[0]) {
15326
- logNotSent(provider, kind, refused2.length, refused2[0]);
15381
+ if (refused3[0]) {
15382
+ logNotSent(provider, kind, refused3.length, refused3[0]);
15327
15383
  }
15328
15384
  return answers;
15329
15385
  }
@@ -15339,30 +15395,30 @@ function why(r, halt) {
15339
15395
  return noAnswer(r.error);
15340
15396
  }
15341
15397
  const error2 = halt?.error ?? r.error;
15342
- const refused2 = (message2) => ({ may_have_arrived: false, error: error2, message: message2 });
15398
+ const refused3 = (message2) => ({ may_have_arrived: false, error: error2, message: message2 });
15343
15399
  if (halt?.reason === "account") {
15344
- return refused2(
15400
+ return refused3(
15345
15401
  `The provider refused it: ${sentence(error2)} ${refusalAdvice(halt.cause)} Sending the test again gets the same answer until that is fixed.`
15346
15402
  );
15347
15403
  }
15348
15404
  if (halt) {
15349
- return refused2(
15405
+ return refused3(
15350
15406
  `The provider can\u2019t take mail right now: ${sentence(error2)} Send the test again in a few minutes.`
15351
15407
  );
15352
15408
  }
15353
15409
  if (r.retryable) {
15354
- return refused2(`It wasn\u2019t sent: ${sentence(error2)} Send the test again to try it.`);
15410
+ return refused3(`It wasn\u2019t sent: ${sentence(error2)} Send the test again to try it.`);
15355
15411
  }
15356
- return refused2(
15412
+ return refused3(
15357
15413
  `The provider refused it: ${sentence(error2)} Sending to this address again gets the same answer until what the provider names is fixed.`
15358
15414
  );
15359
15415
  }
15360
- function logNotSent(provider, kind, count, first) {
15416
+ function logNotSent(provider, kind, count2, first) {
15361
15417
  log.warn(first.may_have_arrived ? "test.unknown" : "test.refused", {
15362
15418
  provider: provider.name,
15363
15419
  test: kind.test,
15364
15420
  postId: kind.postId,
15365
- count,
15421
+ count: count2,
15366
15422
  error: first.error
15367
15423
  });
15368
15424
  }
@@ -15454,13 +15510,13 @@ async function test(c) {
15454
15510
  const { input, email: email2, warnings: warnings2, frozen } = await loadPostEmail(c);
15455
15511
  const provider = getProvider(c.config, c.env);
15456
15512
  const unsubscribeUrl = testUnsubscribeUrl(c.config);
15457
- const recipients = [{ email: to, unsubscribeUrl }];
15513
+ const recipients2 = [{ email: to, unsubscribeUrl }];
15458
15514
  const [res] = await sendTest(
15459
15515
  c.env.DB,
15460
15516
  c.config,
15461
15517
  provider,
15462
15518
  email2,
15463
- recipients,
15519
+ recipients2,
15464
15520
  {
15465
15521
  purpose: "test",
15466
15522
  tag: `test-${input.post.id}`,
@@ -15485,21 +15541,22 @@ async function test(c) {
15485
15541
  return json(result2);
15486
15542
  }
15487
15543
  var MAX_TEMPLATE_TEST_RECIPIENTS = 20;
15488
- var TEMPLATE_TEST_SUBJECT = "Template test \u2014 the starlings are back";
15544
+ var TEMPLATE_TEST_SUBJECT = "Template test: how your posts will look";
15545
+ var SAMPLE_POST_SUBJECT = "How your posts will look";
15489
15546
  var TEMPLATE_TEST_MARKDOWN = [
15490
- "# The starlings are back",
15547
+ `# ${SAMPLE_POST_SUBJECT}`,
15491
15548
  "",
15492
- "A cold front slid off the lake overnight, and with it the first big roost of the season \u2014 a few thousand birds turning over the water at dusk.",
15549
+ "This is a test of your email template, with sample text. A real post\u2019s Markdown fills this space.",
15493
15550
  "",
15494
- "Three things I noticed this week, and one question for you.",
15551
+ "It shows the parts of a post that a template styles:",
15495
15552
  "",
15496
- "- The light is going gold a full hour earlier.",
15497
- "- The maples on Marsh Lane have finally turned.",
15498
- "- Someone left a note in the little free library, addressed to no one.",
15553
+ "- Headings, like the one above",
15554
+ "- **Bold** text",
15555
+ "- [Links](https://example.com)",
15556
+ "- Lists, like this one",
15499
15557
  "",
15500
- "This is a **sample post**: [links](https://example.com) render like this, and a real post's Markdown fills this space."
15558
+ "> And quotes, like this one."
15501
15559
  ].join("\n");
15502
- var SAMPLE_POST_SUBJECT = "The starlings are back";
15503
15560
  function sampleRenderInput(subject = TEMPLATE_TEST_SUBJECT) {
15504
15561
  const now = Date.now();
15505
15562
  const post = {
@@ -15560,14 +15617,14 @@ function resolveTestRecipients(body, defaults) {
15560
15617
  async function templateTest(c) {
15561
15618
  const settings = await getSettings(c.env.DB);
15562
15619
  const body = await readJsonObject(c, { optional: true });
15563
- const recipients = resolveTestRecipients(body, settings.testRecipients);
15564
- if (recipients.length === 0) {
15620
+ const recipients2 = resolveTestRecipients(body, settings.testRecipients);
15621
+ if (recipients2.length === 0) {
15565
15622
  throw fieldError(
15566
15623
  "to",
15567
15624
  "There\u2019s no one to send the test to. Enter an address, or set default test recipients in Settings."
15568
15625
  );
15569
15626
  }
15570
- if (recipients.length > MAX_TEMPLATE_TEST_RECIPIENTS) {
15627
+ if (recipients2.length > MAX_TEMPLATE_TEST_RECIPIENTS) {
15571
15628
  throw fieldError(
15572
15629
  "to",
15573
15630
  `A test can go to at most ${MAX_TEMPLATE_TEST_RECIPIENTS} addresses. Remove some, then send again.`
@@ -15578,7 +15635,7 @@ async function templateTest(c) {
15578
15635
  const provider = getProvider(c.config, c.env);
15579
15636
  const unsubscribeUrl = testUnsubscribeUrl(c.config);
15580
15637
  const testId = `template-test-${crypto.randomUUID()}`;
15581
- const batch = recipients.map((email2) => ({ email: email2, unsubscribeUrl }));
15638
+ const batch = recipients2.map((email2) => ({ email: email2, unsubscribeUrl }));
15582
15639
  const answers = await sendTest(
15583
15640
  c.env.DB,
15584
15641
  c.config,
@@ -15591,11 +15648,11 @@ async function templateTest(c) {
15591
15648
  const notSent = answers.flatMap((a) => a.sent ? [] : [{ email: a.email, ...a.notSent }]);
15592
15649
  const report = {
15593
15650
  sent: answers.length - notSent.length,
15594
- total: recipients.length,
15651
+ total: recipients2.length,
15595
15652
  failed: notSent.map((n) => n.email),
15596
15653
  not_sent: notSent,
15597
15654
  provider: provider.name,
15598
- recipients,
15655
+ recipients: recipients2,
15599
15656
  subject: result2.subject,
15600
15657
  warnings: result2.warnings
15601
15658
  };
@@ -15770,13 +15827,13 @@ var ProviderNotifier = class {
15770
15827
  channel = "provider";
15771
15828
  async send(to, message2, key, contacts) {
15772
15829
  const provider = getProvider(this.config, this.env);
15773
- const recipients = [{ email: to, unsubscribeUrl: "" }];
15774
- const answer = await provider.sendBatch(message2, recipients, {
15830
+ const recipients2 = [{ email: to, unsubscribeUrl: "" }];
15831
+ const answer = await provider.sendBatch(message2, recipients2, {
15775
15832
  purpose: "notification",
15776
15833
  tag: `notify-${key}`
15777
15834
  });
15778
15835
  contacts.add("notification", contactOutcome(answer), Date.now());
15779
- const [result2] = perRecipient(answer, recipients);
15836
+ const [result2] = perRecipient(answer, recipients2);
15780
15837
  if (!result2?.accepted) {
15781
15838
  throw new Error(result2?.error ?? "the provider gave no answer for the recipient");
15782
15839
  }
@@ -15784,154 +15841,249 @@ var ProviderNotifier = class {
15784
15841
  };
15785
15842
 
15786
15843
  // src/notify/compose.ts
15844
+ var NO_ONE_HARMED = "No one has been marked unsent because of this, and no one will be mailed twice.";
15845
+ var WHY_YOU_GOT_THIS = "You get these because this address is listed under Settings \u2192 Notifications.";
15846
+ var CRON = "Check the Worker\u2019s Cron Trigger and logs.";
15787
15847
  function when(ms) {
15788
15848
  return `${new Date(ms).toISOString().slice(0, 16).replace("T", " ")} UTC`;
15789
15849
  }
15790
15850
  function minutes2(ms) {
15791
15851
  return Math.round(ms / 6e4);
15792
15852
  }
15853
+ function count(n) {
15854
+ return n.toLocaleString("en-US");
15855
+ }
15856
+ function emails(n) {
15857
+ return `${count(n)} ${n === 1 ? "email" : "emails"}`;
15858
+ }
15859
+ function recipients(n) {
15860
+ return `${count(n)} ${n === 1 ? "recipient" : "recipients"}`;
15861
+ }
15862
+ function said(error2) {
15863
+ return `SES said: ${scrub(error2?.trim() || "no detail given")}`;
15864
+ }
15793
15865
  function sendLink(config, sendId) {
15794
15866
  return dashboardLink(config.appOrigin, `/sent/${encodeURIComponent(sendId)}`);
15795
15867
  }
15796
15868
  function message(subject, paragraphs, link) {
15797
15869
  const text2 = `${paragraphs.join("\n\n")}
15798
15870
 
15799
- ${link}
15871
+ ${link.label}: ${link.url}
15872
+
15873
+ ${WHY_YOU_GOT_THIS}
15800
15874
  `;
15801
15875
  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
15876
  ${paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join("\n")}
15803
- <p><a href="${escapeHtmlAttr(link)}">${escapeHtml(link)}</a></p>
15877
+ <p><a href="${escapeHtmlAttr(link.url)}">${escapeHtml(link.label)}</a></p>
15878
+ <p style="font-size:13px;color:#71717a;">${escapeHtml(WHY_YOU_GOT_THIS)}</p>
15804
15879
  </body></html>`;
15805
15880
  return { subject, html, text: text2 };
15806
15881
  }
15882
+ function done(slots) {
15883
+ return message(
15884
+ slots.subject,
15885
+ slots.more ? [slots.happened, slots.more] : [slots.happened],
15886
+ slots.link
15887
+ );
15888
+ }
15889
+ function problem(slots) {
15890
+ const paragraphs = [
15891
+ slots.affected ? `${slots.happened} ${slots.affected}` : slots.happened,
15892
+ slots.said,
15893
+ slots.todo,
15894
+ slots.next
15895
+ ];
15896
+ return message(
15897
+ slots.subject,
15898
+ paragraphs.filter((p) => Boolean(p)),
15899
+ slots.link
15900
+ );
15901
+ }
15902
+ function finished(n, title, config) {
15903
+ const report = { label: "See the send report", url: sendLink(config, n.send_id) };
15904
+ const at = n.completed_at ? ` at ${when(n.completed_at)}` : "";
15905
+ const accepted2 = n.c_accepted + n.c_delivered + n.c_bounced + n.c_complained;
15906
+ const total = accepted2 + n.c_unsent + n.c_pending + n.c_in_flight;
15907
+ if (accepted2 === 0 && total > 0) {
15908
+ return problem({
15909
+ subject: `${title} didn\u2019t reach anyone`,
15910
+ happened: `${title} finished${at}, but your email provider accepted none of its ${emails(total)}.`,
15911
+ affected: "A finished post can\u2019t be sent again.",
15912
+ 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.",
15913
+ link: report
15914
+ });
15915
+ }
15916
+ const others = total - accepted2;
15917
+ 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.`;
15918
+ const lateBy = (n.started_at ?? n.fire_at) - n.fire_at;
15919
+ return done({
15920
+ subject: `Sent: ${n.subject}`,
15921
+ happened: `${title} finished sending${at}. ${outcome}`,
15922
+ 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,
15923
+ link: report
15924
+ });
15925
+ }
15926
+ function refused2(n, title, config) {
15927
+ const link = { label: "See this send", url: sendLink(config, n.send_id) };
15928
+ const at = when(n.halted_at ?? n.episode);
15929
+ const waits = "any other post that comes due waits with it";
15930
+ if (n.halt_cause === "not_connected") {
15931
+ return problem({
15932
+ subject: `${title} is waiting: no email provider is connected`,
15933
+ 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.`,
15934
+ todo: `${refusalAdvice(n.halt_cause)} ${PASS_THIS_ALONG}`,
15935
+ next: `It goes out within the hour after that, and ${waits}. ${NO_ONE_HARMED}`,
15936
+ link
15937
+ });
15938
+ }
15939
+ if (n.halt_cause === "invalid_request") {
15940
+ return problem({
15941
+ subject: `${title} is waiting: Amazon SES refused part of it`,
15942
+ happened: `Amazon SES refused part of ${title} as invalid at ${at}.`,
15943
+ affected: "Other posts go out as usual.",
15944
+ said: said(n.halt_error),
15945
+ // Not `refusalAdvice`, which is read where the address shows: here the email hid it.
15946
+ todo: "If SES\u2019s words name an address, the send\u2019s page shows it in full. Remove that subscriber, and the send skips them.",
15947
+ next: `It carries on at its next try. ${NO_ONE_HARMED}`,
15948
+ link
15949
+ });
15950
+ }
15951
+ if (isSendOwnHalt(n.halt_cause)) {
15952
+ return problem({
15953
+ subject: `${title} is waiting: Amazon SES refused it`,
15954
+ happened: `Amazon SES started refusing ${title} itself at ${at}.`,
15955
+ affected: "Other posts go out as usual.",
15956
+ said: said(n.halt_error),
15957
+ todo: refusalAdvice(n.halt_cause),
15958
+ next: `It keeps trying, at most an hour apart. ${NO_ONE_HARMED}`,
15959
+ link
15960
+ });
15961
+ }
15962
+ const held = n.c_pending + n.c_in_flight;
15963
+ const left = `${emails(held)} ${held === 1 ? "is" : "are"} still to go.`;
15964
+ if (n.halt_cause === "quota") {
15965
+ return problem({
15966
+ subject: `${title} is waiting: SES\u2019s daily quota is used up`,
15967
+ happened: `${title} stopped at ${at} because SES\u2019s 24-hour quota is used up.`,
15968
+ affected: left,
15969
+ said: said(n.halt_error),
15970
+ todo: refusalAdvice(n.halt_cause),
15971
+ next: `It carries on by itself when the quota frees up, trying at most an hour apart, and ${waits}. ${NO_ONE_HARMED}`,
15972
+ link
15973
+ });
15974
+ }
15975
+ if (n.halt_cause === "sandbox") {
15976
+ return problem({
15977
+ subject: `${title} is waiting: SES is still in the sandbox`,
15978
+ happened: `${title} stopped at ${at} because your SES account is still in the sandbox, which mails only addresses verified in SES.`,
15979
+ affected: left,
15980
+ said: said(n.halt_error),
15981
+ todo: refusalAdvice(n.halt_cause),
15982
+ next: `It carries on by itself once AWS grants production access, trying at most an hour apart, and ${waits}. ${NO_ONE_HARMED}`,
15983
+ link
15984
+ });
15985
+ }
15986
+ return problem({
15987
+ subject: `${title} is waiting: Amazon SES is refusing your account`,
15988
+ happened: `Amazon SES started refusing your account at ${at}, so ${title} is waiting.`,
15989
+ affected: left,
15990
+ said: said(n.halt_error),
15991
+ todo: refusalAdvice(n.halt_cause),
15992
+ next: `It carries on by itself within the hour once that\u2019s fixed, and ${waits}. ${NO_ONE_HARMED}`,
15993
+ link
15994
+ });
15995
+ }
15807
15996
  function composeNotification(n, config) {
15808
- const link = sendLink(config, n.send_id);
15997
+ const link = { label: "See this send", url: sendLink(config, n.send_id) };
15809
15998
  const title = `"${n.subject}"`;
15810
15999
  switch (n.kind) {
15811
- case "finished": {
15812
- const accepted2 = n.c_accepted + n.c_delivered + n.c_bounced + n.c_complained;
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
- }
16000
+ case "finished":
16001
+ return finished(n, title, config);
15824
16002
  case "refused":
15825
- if (n.halt_cause === "not_connected") {
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
- );
16003
+ return refused2(n, title, config);
15856
16004
  case "stuck":
15857
- return message(
15858
- `Problem with ${title}: nothing has gone out for ${minutes2(STUCK_THRESHOLD_MS)} minutes`,
15859
- [
15860
- `${title} has handed off no one since ${when(n.progress_at ?? n.started_at ?? n.episode)}, with no retry or provider problem to explain it: ${n.c_pending + n.c_in_flight} recipients have not been sent yet.`,
15861
- "The sweep that sends may have stopped or keep failing on this send, or another send may be taking every minute's sending allowance. Check the send's page and the app's logs. It picks up where it stopped, mailing no one twice."
15862
- ],
16005
+ return problem({
16006
+ subject: `${title} is waiting: nothing has gone out for ${minutes2(STUCK_THRESHOLD_MS)} minutes`,
16007
+ happened: `Nothing has gone out for ${title} since ${when(n.progress_at ?? n.started_at ?? n.episode)}, and no provider problem explains it.`,
16008
+ affected: `${recipients(n.c_pending + n.c_in_flight)} ${n.c_pending + n.c_in_flight === 1 ? "is" : "are"} still waiting.`,
16009
+ 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}`,
16010
+ next: "You don\u2019t need to restart it. It picks up where it stopped, mailing no one twice.",
15863
16011
  link
15864
- );
16012
+ });
15865
16013
  case "wedged":
15866
- return message(
15867
- `Problem with ${title}: waiting for you to resolve it`,
15868
- [
15869
- `${title} has handed off everyone it can, but the provider never answered for ${n.c_in_flight} recipients, so whether they were mailed is unknown. Sending them again could mail them twice, so the send waits for you.`,
15870
- "Open the send and choose Resolve. Kestrel can't tell whether these emails went out, so unless you know, Assume not sent is the safe default: those readers may miss this post but get the next one, and no one is mailed twice. Choose Assume sent when you know otherwise, such as a reader who says they got it."
15871
- ],
16014
+ return problem({
16015
+ subject: `${title} is waiting for you: ${recipients(n.c_in_flight)} to resolve`,
16016
+ happened: `Your email provider never answered for ${recipients(n.c_in_flight)} of ${title}.`,
16017
+ affected: "Kestrel can\u2019t tell whether they got it, and sending again could mail them twice.",
16018
+ todo: "Open the send and choose Resolve. Unless you know otherwise, choose Assume not sent.",
16019
+ 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.",
16020
+ link: { ...link, label: "Resolve this send" }
16021
+ });
16022
+ case "missed":
16023
+ return problem({
16024
+ subject: `${title} is waiting: it didn\u2019t go out on time`,
16025
+ happened: `${title} was due at ${when(n.fire_at)} and hasn\u2019t gone out.`,
16026
+ todo: `Kestrel\u2019s once-a-minute sending job has stopped or failed on it. ${CRON} ${PASS_THIS_ALONG}`,
16027
+ next: "It goes out as soon as that job runs again.",
15872
16028
  link
15873
- );
15874
- case "missed": {
15875
- const late = n.send_status === "scheduled" ? `${title} was due at ${when(n.fire_at)} and has not gone out.` : `${title} was due at ${when(n.fire_at)} and went out ${minutes2((n.started_at ?? n.fire_at) - n.fire_at)} minutes late.`;
15876
- return message(
15877
- `Problem with ${title}: it missed its fire time`,
15878
- [
15879
- late,
15880
- "A send only goes out late when the minute-by-minute sweep that fires it was not running or failed on it, so check the Worker's cron trigger and logs."
15881
- ],
16029
+ });
16030
+ case "no_receipts":
16031
+ return problem({
16032
+ subject: "Kestrel isn\u2019t hearing back from Amazon SES",
16033
+ 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.`,
16034
+ 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.",
16035
+ todo: `Look for webhook.refused in the logs, and check the provider\u2019s event subscription, including that Deliveries is selected. ${PASS_THIS_ALONG}`,
16036
+ next: "This clears once any receipt arrives. You\u2019re told once.",
15882
16037
  link
15883
- );
15884
- }
16038
+ });
15885
16039
  }
15886
16040
  }
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
16041
  function sampleNotification(config) {
15904
- return message(
15905
- "Test: notifications from Kestrel",
15906
- [
15907
- "This is a test of the notifications that tell you when a send goes out, and right away if a send runs into a problem.",
15908
- "If it arrived, the channel works; nothing else was sent."
15909
- ],
15910
- dashboardLink(config.appOrigin, "/settings")
15911
- );
16042
+ return done({
16043
+ subject: "Test: notifications from Kestrel",
16044
+ happened: "If you\u2019re reading this, notifications reach this address. Nothing else was sent.",
16045
+ more: "Kestrel writes here when a post finishes sending, and right away if something needs attention.",
16046
+ link: { label: "Open Settings", url: dashboardLink(config.appOrigin, "/settings") }
16047
+ });
15912
16048
  }
15913
16049
  function composeSandboxNotification(config) {
15914
- return message(
15915
- "A signup's confirmation was refused: SES is still in the sandbox",
15916
- [
15917
- "Someone signed up, and Amazon SES refused to send their confirmation email, because the SES account is still in the sandbox, which mails only addresses verified in SES. They were told a link is on its way, so they may try again.",
15918
- refusalAdvice("sandbox"),
15919
- "Until then, every signup from an address not verified in SES is refused the same way, and a post reaches only verified addresses. You are told at most once a day."
15920
- ],
15921
- dashboardLink(config.appOrigin, "/subscribers")
15922
- );
16050
+ return problem({
16051
+ subject: "A reader couldn\u2019t sign up: SES is still in the sandbox",
16052
+ 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.",
16053
+ 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.",
16054
+ todo: `Request production access on the SES console\u2019s account dashboard. ${PASS_THIS_ALONG}`,
16055
+ next: "You\u2019re told at most once a day until then.",
16056
+ link: { label: "Open the dashboard", url: dashboardLink(config.appOrigin, "/") }
16057
+ });
15923
16058
  }
15924
- function composeNotSendingNotification(refusal2, config) {
15925
- const found = refusal2.found_by === "account_check" ? "Kestrel's check of your SES account found that SES will refuse every email" : "Amazon SES refused an email for a reason that will refuse every email";
15926
- return message(
15927
- "Email isn't going out, and signups are paused",
15928
- [
15929
- `${found}: ${sentence(refusal2.error)}`,
15930
- refusal2.advice,
15931
- "Until an email goes through, the subscribe page tells readers signups are paused, and a scheduled send is held when it fires. Once it's fixed, send a test email from the Template screen: when SES takes it, signups reopen. You are told at most once a day while this lasts."
15932
- ],
15933
- dashboardLink(config.appOrigin, "/")
15934
- );
16059
+ function composeOutageNotification(refusal2, since, heldSubjects, config) {
16060
+ const posts = heldSubjects.map((s) => `"${s}"`);
16061
+ const first = posts[0];
16062
+ const link = { label: "Open the dashboard", url: dashboardLink(config.appOrigin, "/") };
16063
+ 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)}.`;
16064
+ if (!first) {
16065
+ return problem({
16066
+ subject: "Email isn\u2019t going out, and signups are paused",
16067
+ happened,
16068
+ affected: "Signups are paused, and any post that comes due will wait.",
16069
+ said: said(refusal2.error),
16070
+ todo: refusal2.advice,
16071
+ next: "Then send a test email from the Template screen. When SES takes it, signups reopen.",
16072
+ link
16073
+ });
16074
+ }
16075
+ const one = posts.length === 1;
16076
+ const waiting = one ? `${first} is waiting` : `${posts.length} posts are waiting`;
16077
+ const late = config.notifyChannel === "provider" ? " This email can be held up by the same problem, so it may be fixed already." : "";
16078
+ return problem({
16079
+ subject: `Email isn\u2019t going out, and ${waiting}`,
16080
+ happened,
16081
+ 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.`,
16082
+ said: said(refusal2.error),
16083
+ todo: refusal2.advice,
16084
+ 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}`,
16085
+ link
16086
+ });
15935
16087
  }
15936
16088
 
15937
16089
  // src/send/remake.ts
@@ -17543,7 +17695,7 @@ function createRouter({
17543
17695
  not_sent: [],
17544
17696
  provider: "fake",
17545
17697
  recipients: ["you@example.com"],
17546
- subject: "Template test \u2014 \u2026",
17698
+ subject: "Template test: how your posts will look",
17547
17699
  warnings: []
17548
17700
  }
17549
17701
  },
@@ -17824,11 +17976,11 @@ function createRouter({
17824
17976
  resource: "posts",
17825
17977
  title: "Upload an image",
17826
17978
  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, `![a short description](cover.jpg)`, never by its `url`, and the render resolves that name to the stored image. A reference by `url` warns in the preview. Uploading the same `filename` again replaces the image.",
17979
+ description: "The post's Markdown references the image by the `filename` the response gives, `![a short description](cover.jpg)`, never by its `url`, and the render resolves that name to the stored image. 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
17980
  query: [
17829
17981
  {
17830
17982
  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."
17983
+ 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
17984
  }
17833
17985
  ],
17834
17986
  body: [
@@ -17841,7 +17993,7 @@ function createRouter({
17841
17993
  {
17842
17994
  name: "filename",
17843
17995
  type: "string",
17844
- description: "The name to store it under, in place of the file's own."
17996
+ description: "The name to store it under, cleaned and numbered as above, in place of the file's own."
17845
17997
  }
17846
17998
  ],
17847
17999
  errors: [notDraft],
@@ -19239,7 +19391,8 @@ var SHIPPED_MIGRATIONS = [
19239
19391
  "0017_provider_account_rejected.sql",
19240
19392
  "0018_notify_addresses.sql",
19241
19393
  "0019_sending.sql",
19242
- "0020_notifications_not_sending.sql"
19394
+ "0020_notifications_not_sending.sql",
19395
+ "0021_notifications_outage_receipts.sql"
19243
19396
  ];
19244
19397
  async function missingMigrations(db) {
19245
19398
  let applied;
@@ -19375,15 +19528,20 @@ var OPEN_COST = 4;
19375
19528
  var EACH_COST = 2;
19376
19529
  var NOTIFY_RESERVE = OPEN_COST + EACH_COST;
19377
19530
  var MAX_PER_TICK = 10;
19531
+ function isOutageNotice(n) {
19532
+ return n.kind === "not_sending" || n.kind === "not_sending_held";
19533
+ }
19378
19534
  function stillHolds(n, now) {
19379
19535
  switch (n.kind) {
19380
- case "import":
19381
19536
  case "sandbox":
19382
19537
  return true;
19383
19538
  case "not_sending":
19539
+ case "not_sending_held":
19384
19540
  return true;
19385
19541
  case "finished":
19386
19542
  return true;
19543
+ case "no_receipts":
19544
+ return !n.receipt_since;
19387
19545
  case "refused":
19388
19546
  return n.send_status === "sending" && n.halt_reason === "account" && n.halted_at === n.episode && !isWedged({ ...n, status: n.send_status });
19389
19547
  case "stuck":
@@ -19391,7 +19549,7 @@ function stillHolds(n, now) {
19391
19549
  case "wedged":
19392
19550
  return isWedged({ ...n, status: n.send_status });
19393
19551
  case "missed":
19394
- return n.send_status === "scheduled" ? n.fire_at < now - MISSED_THRESHOLD_MS : n.started_at !== null && n.started_at - n.fire_at > MISSED_THRESHOLD_MS;
19552
+ return n.send_status === "scheduled" && n.fire_at < now - MISSED_THRESHOLD_MS;
19395
19553
  }
19396
19554
  }
19397
19555
  async function notifyPublisher(env, budget, contacts = new Contacts()) {
@@ -19412,8 +19570,13 @@ async function notifyPublisher(env, budget, contacts = new Contacts()) {
19412
19570
  return;
19413
19571
  }
19414
19572
  let refusal2 = null;
19415
- if (due.some((n) => n.kind === "not_sending")) {
19416
- refusal2 = sendingView(config, await readSending(db))?.refusal ?? null;
19573
+ let since = now;
19574
+ let held = [];
19575
+ if (due.some(isOutageNotice)) {
19576
+ const view = sendingView(config, await readSending(db));
19577
+ refusal2 = view?.refusal ?? null;
19578
+ since = view?.since ?? now;
19579
+ held = await postsHeldByOutage(db);
19417
19580
  due = due.slice(0, Math.floor((budget.left - (OPEN_COST - 2)) / EACH_COST));
19418
19581
  if (due.length === 0) {
19419
19582
  return;
@@ -19428,13 +19591,17 @@ async function notifyPublisher(env, budget, contacts = new Contacts()) {
19428
19591
  const notifier = getNotifier(config, env);
19429
19592
  for (const [i, n] of due.entries()) {
19430
19593
  const recordsLeft = due.length - i;
19431
- if (!stillHolds(n, Date.now()) || n.kind === "not_sending" && !refusal2) {
19594
+ if (n.kind === "not_sending_held" && refusal2 && held.length === 0) {
19595
+ await forgetNotification(db, n);
19596
+ continue;
19597
+ }
19598
+ if (!stillHolds(n, Date.now()) || isOutageNotice(n) && !refusal2) {
19432
19599
  await recordNotificationOutcome(db, n, { status: "cleared" }, Date.now());
19433
19600
  continue;
19434
19601
  }
19435
- const about = n.kind === "import" ? { importId: n.import_id } : n.kind === "sandbox" || n.kind === "not_sending" ? {} : { sendId: n.send_id };
19436
- const key = `${n.send_id ?? n.import_id ?? "account"}-${n.kind}-${n.episode}`;
19437
- const write = () => n.kind === "import" ? composeImportNotification(n, config) : n.kind === "sandbox" ? composeSandboxNotification(config) : n.kind === "not_sending" ? composeNotSendingNotification(unwrap(refusal2, "the refusal"), config) : composeNotification(n, config);
19602
+ const about = n.send_id === null ? {} : { sendId: n.send_id };
19603
+ const key = `${n.send_id ?? "account"}-${n.kind}-${n.episode}`;
19604
+ const write = () => n.kind === "sandbox" ? composeSandboxNotification(config) : isOutageNotice(n) ? composeOutageNotification(unwrap(refusal2, "the refusal"), since, held, config) : composeNotification(n, config);
19438
19605
  let email2;
19439
19606
  const sentTo = [...n.sent_to];
19440
19607
  const missed = [];
@@ -19627,11 +19794,11 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
19627
19794
  let unknown = 0;
19628
19795
  const calls = batches.map((b) => {
19629
19796
  const byAddress = /* @__PURE__ */ new Map();
19630
- const recipients = [];
19797
+ const recipients2 = [];
19631
19798
  for (const m of b.members) {
19632
19799
  if (m.unsub_token) {
19633
19800
  byAddress.set(m.email, m.id);
19634
- recipients.push({
19801
+ recipients2.push({
19635
19802
  email: m.email,
19636
19803
  unsubscribeUrl: `${config.appOrigin}/unsubscribe?token=${m.unsub_token}`
19637
19804
  });
@@ -19640,7 +19807,7 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
19640
19807
  result2.unsent += 1;
19641
19808
  }
19642
19809
  }
19643
- return { batch: b, byAddress, recipients };
19810
+ return { batch: b, byAddress, recipients: recipients2 };
19644
19811
  });
19645
19812
  const sent = calls.filter((c) => c.recipients.length > 0);
19646
19813
  const send2 = async (call2) => {
@@ -19749,15 +19916,15 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
19749
19916
  result2.refusal = halt;
19750
19917
  }
19751
19918
  }
19752
- const count = (status) => outcomes.filter((o) => o.status === status).length;
19919
+ const count2 = (status) => outcomes.filter((o) => o.status === status).length;
19753
19920
  const heldBack = outcomes.filter((o) => o.status === "pending" && o.attempt === false).length;
19754
19921
  log.info("send.batch", {
19755
19922
  ...tags,
19756
19923
  requests: answers.length,
19757
19924
  recipients: batches.reduce((n, b) => n + b.members.length, 0),
19758
- accepted: count("accepted"),
19759
- unsent: count("unsent"),
19760
- retried: count("pending") - heldBack,
19925
+ accepted: count2("accepted"),
19926
+ unsent: count2("unsent"),
19927
+ retried: count2("pending") - heldBack,
19761
19928
  held: heldCount + heldBack,
19762
19929
  unknown,
19763
19930
  latencyMs