@kurtbruns/kestrel 1.11.0 → 1.12.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,13 @@
1
- // @kurtbruns/kestrel 1.11.0: the Worker, built by scripts/build-package.mjs.
1
+ // @kurtbruns/kestrel 1.12.0: the Worker, built by scripts/build-package.mjs.
2
+
3
+ // shared/archive_url.ts
4
+ function archivePostUrl(origin, basePath, slug) {
5
+ return `${origin}${basePath}/${slug}`;
6
+ }
7
+ var UNLISTED_PATH = "/email";
8
+ function unlistedPostUrl(appOrigin, token2) {
9
+ return `${appOrigin}${UNLISTED_PATH}/${token2}`;
10
+ }
2
11
 
3
12
  // shared/daily_writes.ts
4
13
  var D1_FREE_DAILY_WRITES = 1e5;
@@ -707,6 +716,13 @@ function describeFormat(maxRows, maxRowsReason) {
707
716
  }
708
717
 
709
718
  // shared/sends.ts
719
+ var SEND_OWN_HALT_CAUSES = ["content", "invalid_request"];
720
+ function isSendOwnHalt(cause) {
721
+ return cause !== null && SEND_OWN_HALT_CAUSES.includes(cause);
722
+ }
723
+ function haltRank(halt) {
724
+ return halt.reason === "unavailable" ? 0 : isSendOwnHalt(halt.cause) ? 1 : 2;
725
+ }
710
726
  var NOT_CONNECTED_ADVICE = {
711
727
  step: "Connect Amazon SES",
712
728
  guide: " (the setup guide walks through it)",
@@ -715,21 +731,63 @@ var NOT_CONNECTED_ADVICE = {
715
731
  function refusalAdvice(cause) {
716
732
  switch (cause) {
717
733
  case "credentials":
718
- return "Replace the SES access key in the deployment\u2019s secrets, or give it ses:SendEmail and ses:SendBulkEmail in its IAM policy.";
734
+ return "Whoever runs the deployment replaces the SES access key in its secrets, or gives the key ses:SendEmail and ses:SendBulkEmail in its IAM policy.";
735
+ case "opt_in":
736
+ 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.";
719
737
  case "configuration":
720
- return "Check that the SES_CONFIGURATION_SET secret names a configuration set in your SES region, or create the set in the SES console.";
738
+ return "Whoever runs the deployment checks that the SES_CONFIGURATION_SET secret names a configuration set in the SES region, or creates the set in the SES console.";
739
+ case "sending_pool":
740
+ 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.";
741
+ case "unsupported":
742
+ 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.";
721
743
  case "sender":
722
- return "Verify the sending domain or from-address with the provider.";
744
+ return "Whoever runs the deployment verifies the From address, or its domain, in SES, in the region AWS_REGION names.";
745
+ case "mail_from":
746
+ 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.";
747
+ case "sandbox":
748
+ 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.";
723
749
  case "quota":
724
- return "Wait for SES\u2019s 24-hour quota to free up, or ask AWS to raise it in the Service Quotas console.";
750
+ 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.";
725
751
  case "suspended":
726
- return "Settle the account\u2019s standing with the provider (for SES, in the SES console).";
752
+ 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.";
753
+ case "paused":
754
+ 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).";
755
+ case "set_paused":
756
+ 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).";
757
+ case "content":
758
+ 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.";
759
+ case "invalid_request":
760
+ 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.";
727
761
  case "not_connected":
728
762
  return `${NOT_CONNECTED_ADVICE.step}${NOT_CONNECTED_ADVICE.guide}${NOT_CONNECTED_ADVICE.after}`;
729
763
  default:
730
- return "Fix the account with the provider.";
764
+ return "Whoever runs the deployment fixes the account with the provider.";
731
765
  }
732
766
  }
767
+ var SELF_THROTTLE_HALTS = 3;
768
+ function isSelfThrottled(send) {
769
+ return send.halt_reason === "unavailable" && send.halt_cause === "rate_limit" && send.halt_retries >= SELF_THROTTLE_HALTS;
770
+ }
771
+ function sentence(error) {
772
+ const text = error?.trim() || "no detail given";
773
+ return /[.!?]$/.test(text) ? text : `${text}.`;
774
+ }
775
+ function refusedMessage(cause, error) {
776
+ const advice = refusalAdvice(cause);
777
+ if (cause === "not_connected") {
778
+ return `No email provider is connected, so this send is held where it is. ${advice} No one has been marked unsent because of this, and the send goes out at its first retry after a provider is connected.`;
779
+ }
780
+ if (isSendOwnHalt(cause)) {
781
+ return `The provider is refusing this send: ${sentence(error)} ${advice} No one has been marked unsent because of this, and the send tries again on its own, spaced out the longer this lasts.`;
782
+ }
783
+ return `The provider is refusing the account: ${sentence(error)} ${advice} No one has been marked unsent because of this, and the send resumes on its own at its next retry once the account is fixed.`;
784
+ }
785
+ function unavailableMessage(send) {
786
+ if (isSelfThrottled(send)) {
787
+ 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 (1 a second in the sandbox), and deploys. The send retries on its own meanwhile, and no one has been marked unsent because of this.`;
788
+ }
789
+ 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.`;
790
+ }
733
791
  var STUCK_THRESHOLD_MS = 30 * 60 * 1e3;
734
792
  var DEFAULT_MIN_LEAD_MS = 5 * 60 * 1e3;
735
793
  var MIN_LEAD_FLOOR_MS = 60 * 1e3;
@@ -748,6 +806,7 @@ function tooSoonMessage(minLeadMs) {
748
806
  var BOUNCE_SPIKE_RATE = 0.05;
749
807
  var BOUNCE_SPIKE_MIN = 3;
750
808
  var BOUNCE_SPIKE_RECENT_MS = 7 * 24 * 60 * 60 * 1e3;
809
+ var NO_RECEIPTS_AFTER_MS = 60 * 60 * 1e3;
751
810
  var DAILY_WRITES_SEND_RECIPIENTS = Math.floor(
752
811
  DAILY_WRITES_BUDGET / SEND_WRITES_PER_RECIPIENT
753
812
  );
@@ -808,10 +867,10 @@ var SUBSCRIBER_ACTIONS = {
808
867
 
809
868
  // src/generated/version.ts
810
869
  var BUILD_INFO = {
811
- version: "1.11.0",
812
- sha: "adbc478",
813
- tag: "v1.11.0",
814
- buildTime: "2026-10-15T15:19:12.740Z",
870
+ version: "1.12.0",
871
+ sha: "27e2c43",
872
+ tag: "v1.12.0",
873
+ buildTime: "2026-10-16T18:01:02.170Z",
815
874
  repoUrl: "https://github.com/kurtbruns/kestrel"
816
875
  };
817
876
 
@@ -835,6 +894,17 @@ function buildInfo() {
835
894
 
836
895
  // src/lib/log.ts
837
896
  import { AsyncLocalStorage } from "node:async_hooks";
897
+
898
+ // src/lib/redact.ts
899
+ var CREDENTIAL = /(Credential=)[^,\s&"']+/gi;
900
+ var SIGNATURE = /(Signature=)[0-9a-f]+/gi;
901
+ var SECURITY_TOKEN = /(x-amz-security-token[:=]\s*)[^\s&,"']+/gi;
902
+ var ACCESS_KEY_ID = /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g;
903
+ function redactCredentials(text) {
904
+ return text.replace(CREDENTIAL, "$1\u2026").replace(SIGNATURE, "$1\u2026").replace(SECURITY_TOKEN, "$1\u2026").replace(ACCESS_KEY_ID, "\u2026");
905
+ }
906
+
907
+ // src/lib/log.ts
838
908
  var runs = new AsyncLocalStorage();
839
909
  function withRun(run, fn) {
840
910
  return runs.run(run, fn);
@@ -845,7 +915,7 @@ function newRun() {
845
915
  var ADDRESS = /(?:"[^"\r\n]*"|[^\s<>"'(),;:@/?&=[\]]+?)(?:@|%40)(\[[^\]\s]*\]|[\p{L}\p{N}_-]+(?:\.[\p{L}\p{N}_-]+)*)/giu;
846
916
  var TOKEN = /([?&;]token=)[^&\s"'#<>]+/gi;
847
917
  function scrub(text) {
848
- return text.replace(ADDRESS, "\u2026@$1").replace(TOKEN, "$1\u2026");
918
+ return redactCredentials(text).replace(ADDRESS, "\u2026@$1").replace(TOKEN, "$1\u2026");
849
919
  }
850
920
  function emit(level, event, fields) {
851
921
  try {
@@ -1083,26 +1153,34 @@ async function verifyAccessJwt(token2, config) {
1083
1153
  issuer,
1084
1154
  audience: config.accessAud
1085
1155
  });
1086
- const email2 = typeof payload.email === "string" ? payload.email : void 0;
1087
- if (email2) {
1088
- if (!isHumanAllowed(email2, config.accessAllowedEmails)) {
1089
- return null;
1090
- }
1091
- return { kind: "human", email: email2, via: "access" };
1092
- }
1093
- return { kind: "service", via: "access" };
1156
+ return principalFromClaims(payload, config.accessAllowedEmails);
1094
1157
  } catch {
1095
1158
  return null;
1096
1159
  }
1097
1160
  }
1161
+ function principalFromClaims(payload, allowed) {
1162
+ const email2 = typeof payload.email === "string" ? payload.email : void 0;
1163
+ if (email2) {
1164
+ return isHumanAllowed(email2, allowed) ? { kind: "human", email: email2, via: "access" } : null;
1165
+ }
1166
+ const claims = Object.keys(payload).sort().join(",");
1167
+ const tokenId = typeof payload.common_name === "string" ? payload.common_name : void 0;
1168
+ if (tokenId) {
1169
+ log.info("auth.service", { claims });
1170
+ return { kind: "service", tokenId, via: "access" };
1171
+ }
1172
+ log.warn("auth.unidentified", { claims });
1173
+ return { kind: "service", via: "access" };
1174
+ }
1098
1175
 
1099
1176
  // src/auth/dev_token.ts
1100
1177
  import { jwtVerify as jwtVerify2, SignJWT } from "jose";
1101
1178
  var DEV_ISSUER = "kestrel-dev";
1102
1179
  var DEV_AUDIENCE = "kestrel-admin";
1180
+ var DEV_TOKEN_ID = "kestrel-dev.access";
1103
1181
  var keyFor = (secret) => new TextEncoder().encode(secret);
1104
1182
  async function mintDevToken(secret, claims = {}, ttl = "12h") {
1105
- return new SignJWT(claims.email ? { email: claims.email } : {}).setProtectedHeader({ alg: "HS256" }).setIssuer(DEV_ISSUER).setAudience(DEV_AUDIENCE).setIssuedAt().setExpirationTime(ttl).sign(keyFor(secret));
1183
+ return new SignJWT(claims.email ? { email: claims.email } : { common_name: DEV_TOKEN_ID }).setProtectedHeader({ alg: "HS256" }).setIssuer(DEV_ISSUER).setAudience(DEV_AUDIENCE).setIssuedAt().setExpirationTime(ttl).sign(keyFor(secret));
1106
1184
  }
1107
1185
  async function verifyDevToken(token2, secret) {
1108
1186
  try {
@@ -1111,7 +1189,11 @@ async function verifyDevToken(token2, secret) {
1111
1189
  audience: DEV_AUDIENCE
1112
1190
  });
1113
1191
  const email2 = typeof payload.email === "string" ? payload.email : void 0;
1114
- return email2 ? { kind: "human", email: email2, via: "dev" } : { kind: "service", via: "dev" };
1192
+ if (email2) {
1193
+ return { kind: "human", email: email2, via: "dev" };
1194
+ }
1195
+ const tokenId = typeof payload.common_name === "string" ? payload.common_name : void 0;
1196
+ return { kind: "service", via: "dev", ...tokenId ? { tokenId } : {} };
1115
1197
  } catch {
1116
1198
  return null;
1117
1199
  }
@@ -1330,10 +1412,10 @@ function refusePlaceholders(provider, env, origins) {
1330
1412
  if (fromDomain) {
1331
1413
  hosts.push(["FROM_ADDRESS", fromDomain]);
1332
1414
  }
1333
- const why = provider === "none" ? "replace it with your own domain" : `PROVIDER "${provider}" sends real email`;
1415
+ const why2 = provider === "none" ? "replace it with your own domain" : `PROVIDER "${provider}" sends real email`;
1334
1416
  for (const [name, host] of hosts) {
1335
1417
  if (isPlaceholderHost(host)) {
1336
- throw new ConfigError(name, `is still on the template's example.com placeholder; ${why}`);
1418
+ throw new ConfigError(name, `is still on the template's example.com placeholder; ${why2}`);
1337
1419
  }
1338
1420
  }
1339
1421
  }
@@ -1864,8 +1946,12 @@ function isForeignBrowserRequest(c) {
1864
1946
  const origin = c.req.headers.get("origin");
1865
1947
  return origin !== null && origin !== c.url.origin;
1866
1948
  }
1949
+ async function isEmptyBody(req) {
1950
+ const bytes = await req.clone().arrayBuffer();
1951
+ return new TextDecoder().decode(bytes).trim() === "";
1952
+ }
1867
1953
  function refuseForeignWrite(accepts) {
1868
- return (c) => {
1954
+ return async (c) => {
1869
1955
  if (isForeignBrowserRequest(c)) {
1870
1956
  throw new HttpError(
1871
1957
  403,
@@ -1875,6 +1961,9 @@ function refuseForeignWrite(accepts) {
1875
1961
  }
1876
1962
  const type = mediaTypeOf(c.req);
1877
1963
  if (type !== void 0 && !accepts.includes(type)) {
1964
+ if (accepts.length === 0 && type === "application/json" && await isEmptyBody(c.req)) {
1965
+ return void 0;
1966
+ }
1878
1967
  throw unsupportedMediaType(
1879
1968
  accepts.length === 0 ? `this route takes no body, so no Content-Type (got ${type})` : `this route accepts ${accepts.join(" or ")}, not ${type}`
1880
1969
  );
@@ -1973,6 +2062,9 @@ function newToken(bytes = 32) {
1973
2062
  }
1974
2063
  return out;
1975
2064
  }
2065
+ function newUnlistedToken() {
2066
+ return newToken(16).replace(/(.{8})(?=.)/g, "$1-");
2067
+ }
1976
2068
  function newTokens(n, bytes = 32) {
1977
2069
  const buf = new Uint8Array(n * bytes);
1978
2070
  for (let at = 0; at < buf.length; at += 65536) {
@@ -2070,6 +2162,9 @@ function tombstoneSendsStmt(db, where, ...binds) {
2070
2162
  `INSERT OR REPLACE INTO send_tombstones (id, rev) SELECT id, ${NEXT_REV} FROM sends WHERE ${where}`
2071
2163
  ).bind(...binds);
2072
2164
  }
2165
+ function stampSendsOfPostStmt(db, postId) {
2166
+ return db.prepare(`UPDATE sends SET rev = ${NEXT_REV} WHERE post_id = ?`).bind(postId);
2167
+ }
2073
2168
  function bucketCol(status, event) {
2074
2169
  if (event === "delivered") {
2075
2170
  return "c_delivered";
@@ -2134,11 +2229,25 @@ function publishedSendForPost(db, postId) {
2134
2229
  `SELECT * FROM sends s WHERE s.post_id = ? AND ${PUBLISHED_SEND} ORDER BY ${PUBLISHED_AT} DESC LIMIT 1`
2135
2230
  ).bind(postId).first();
2136
2231
  }
2232
+ async function publicationsFor(db, postIds) {
2233
+ if (postIds.length === 0) {
2234
+ return /* @__PURE__ */ new Map();
2235
+ }
2236
+ const { results } = await db.prepare(
2237
+ `SELECT s.post_id AS post_id, MAX(${PUBLISHED_AT}) AS published_at, s.web_link AS web_link
2238
+ FROM sends s
2239
+ WHERE s.post_id IN (SELECT value FROM json_each(?)) AND ${PUBLISHED_SEND}
2240
+ GROUP BY s.post_id`
2241
+ ).bind(JSON.stringify(postIds)).all();
2242
+ return new Map(
2243
+ results.map((r) => [r.post_id, { published_at: r.published_at, web_link: r.web_link }])
2244
+ );
2245
+ }
2137
2246
  async function listPublishedPosts(db, limit = 200) {
2138
2247
  const { results } = await db.prepare(
2139
2248
  `SELECT p.slug AS slug, s.subject AS subject, MAX(${PUBLISHED_AT}) AS published_at
2140
2249
  FROM sends s JOIN posts p ON p.id = s.post_id
2141
- WHERE ${PUBLISHED_SEND}
2250
+ WHERE ${PUBLISHED_SEND} AND p.email_only = 0
2142
2251
  GROUP BY s.post_id
2143
2252
  ORDER BY published_at DESC
2144
2253
  LIMIT ?`
@@ -2155,8 +2264,15 @@ var SEND_LIST_SPEC = {
2155
2264
  defaultSort: "fire",
2156
2265
  defaultDir: "desc"
2157
2266
  };
2158
- var SEND_LIST_COLS = "id, post_id, status, fire_at, subject, recipient_count, locked_until, scheduled_at, started_at, completed_at, audience_resolved_at, remade_at, tested_at, halt_reason, halt_cause, halt_error, halted_at, halt_retries, halt_retry_at, c_pending, c_in_flight, c_accepted, c_delivered, c_bounced, c_complained, c_skipped, c_unsent, rev";
2159
- var VIEW_COLS = `${SEND_LIST_COLS}, (SELECT p.slug FROM posts p WHERE p.id = sends.post_id) AS post_slug`;
2267
+ var SEND_LIST_COLS = "id, post_id, status, fire_at, subject, recipient_count, locked_until, scheduled_at, started_at, completed_at, audience_resolved_at, remade_at, tested_at, halt_reason, halt_cause, halt_error, halted_at, halt_retries, halt_retry_at, progress_at, c_pending, c_in_flight, c_accepted, c_delivered, c_bounced, c_complained, c_skipped, c_unsent, rev";
2268
+ var postCol = (col, as) => `(SELECT p.${col} FROM posts p WHERE p.id = sends.post_id) AS ${as}`;
2269
+ var VIEW_COLS = [
2270
+ SEND_LIST_COLS,
2271
+ "web_link",
2272
+ postCol("slug", "post_slug"),
2273
+ postCol("unlisted_token", "post_unlisted_token"),
2274
+ postCol("email_only", "post_email_only")
2275
+ ].join(", ");
2160
2276
  async function getSendViewRow(db, id) {
2161
2277
  return db.prepare(
2162
2278
  `SELECT ${VIEW_COLS},
@@ -2202,6 +2318,11 @@ async function currentSendSeq(db) {
2202
2318
  const row = await db.prepare(`SELECT ${CURRENT_REV} AS seq`).first();
2203
2319
  return unwrap(row?.seq, "the send sequence");
2204
2320
  }
2321
+ var WEDGED_SEND = "(status = 'sending' AND c_pending = 0 AND c_in_flight > 0 AND locked_until IS NULL)";
2322
+ var SEND_IDLE_SINCE = "MAX(COALESCE(progress_at, started_at), COALESCE(halt_retry_at, 0))";
2323
+ function stuckSendSql(cutoff) {
2324
+ return `(status = 'sending' AND started_at IS NOT NULL AND NOT ${WEDGED_SEND} AND ${SEND_IDLE_SINCE} < ${cutoff})`;
2325
+ }
2205
2326
  var LIVE_SEND = "(status = 'scheduled' AND fire_at <= ?1) OR status = 'sending' OR (status = 'sent' AND c_accepted > 0 AND completed_at >= ?2)";
2206
2327
  var CHANGES = `SELECT rev FROM sends WHERE rev > ?2
2207
2328
  UNION ALL SELECT rev FROM send_tombstones WHERE rev > ?2`;
@@ -2211,7 +2332,10 @@ var CHANGED_SINCE = `(${IN_PAGE})
2211
2332
  OR (status = 'scheduled' AND (
2212
2333
  (fire_at > ?3 AND fire_at <= ?1)
2213
2334
  OR (fire_at + ?4 >= ?3 AND fire_at + ?4 < ?1)))
2214
- OR (status = 'sending' AND started_at + ?5 >= ?3 AND started_at + ?5 < ?1)`;
2335
+ OR (status = 'sending' AND started_at IS NOT NULL AND NOT ${WEDGED_SEND}
2336
+ AND ${SEND_IDLE_SINCE} + ?5 >= ?3 AND ${SEND_IDLE_SINCE} + ?5 < ?1)
2337
+ OR (status = 'sent' AND c_accepted > 0 AND c_delivered + c_bounced + c_complained = 0
2338
+ AND completed_at + ?7 >= ?3 AND completed_at + ?7 < ?1)`;
2215
2339
  async function sendFeed(db, now, since, settledSince, thresholds, limit, recentSince) {
2216
2340
  const select = `SELECT ${VIEW_COLS},
2217
2341
  CASE WHEN status = 'sending' THEN ${activeRetriesSql("sends.id")} ELSE 0 END AS has_retries
@@ -2225,7 +2349,15 @@ async function sendFeed(db, now, since, settledSince, thresholds, limit, recentS
2225
2349
  db.prepare(
2226
2350
  "SELECT MAX(completed_at) AS at FROM sends WHERE status = 'sent' AND c_accepted > 0"
2227
2351
  ),
2228
- since ? db.prepare(`${select} WHERE ${CHANGED_SINCE} ORDER BY fire_at ASC, id ASC`).bind(now, since.seq, since.at, thresholds.missedMs, thresholds.stuckMs, limit) : db.prepare(`${select} WHERE ${LIVE_SEND} ORDER BY fire_at ASC, id ASC`).bind(now, settledSince)
2352
+ since ? db.prepare(`${select} WHERE ${CHANGED_SINCE} ORDER BY fire_at ASC, id ASC`).bind(
2353
+ now,
2354
+ since.seq,
2355
+ since.at,
2356
+ thresholds.missedMs,
2357
+ thresholds.stuckMs,
2358
+ limit,
2359
+ thresholds.noReceiptsMs
2360
+ ) : db.prepare(`${select} WHERE ${LIVE_SEND} ORDER BY fire_at ASC, id ASC`).bind(now, settledSince)
2229
2361
  ];
2230
2362
  if (since) {
2231
2363
  statements.push(
@@ -2354,6 +2486,9 @@ async function countDeliveriesFiltered(db, sendId, view, search) {
2354
2486
  const row = await db.prepare(`SELECT COUNT(*) AS n FROM deliveries d ${clause}`).bind(...binds).first();
2355
2487
  return row?.n ?? 0;
2356
2488
  }
2489
+ function renderWarnings(send) {
2490
+ return JSON.parse(send.render_warnings);
2491
+ }
2357
2492
  function settingsVersionIs(version) {
2358
2493
  return {
2359
2494
  sql: "COALESCE((SELECT updated_at FROM settings WHERE id = 1), 0) = ?",
@@ -2363,8 +2498,8 @@ function settingsVersionIs(version) {
2363
2498
  function insertScheduledSendStmt(db, send, now, settingsVersion) {
2364
2499
  const guard2 = settingsVersionIs(settingsVersion);
2365
2500
  return db.prepare(
2366
- `INSERT INTO sends (id, post_id, status, fire_at, rendered_html, rendered_text, subject, recipient_count, scheduled_at, rev)
2367
- SELECT ?, ?, 'scheduled', ?, ?, ?, ?, ?, ?, ${NEXT_REV}
2501
+ `INSERT INTO sends (id, post_id, status, fire_at, rendered_html, rendered_text, subject, render_warnings, recipient_count, scheduled_at, web_link, rev)
2502
+ SELECT ?, ?, 'scheduled', ?, ?, ?, ?, ?, ?, ?, 'unlisted', ${NEXT_REV}
2368
2503
  WHERE ${guard2.sql}`
2369
2504
  ).bind(
2370
2505
  send.id,
@@ -2373,6 +2508,7 @@ function insertScheduledSendStmt(db, send, now, settingsVersion) {
2373
2508
  send.rendered_html,
2374
2509
  send.rendered_text,
2375
2510
  send.subject,
2511
+ JSON.stringify(send.warnings),
2376
2512
  send.recipient_count,
2377
2513
  now,
2378
2514
  ...guard2.binds
@@ -2419,15 +2555,22 @@ function remakeGuard(settingsVersion, minFireAt, ackIds) {
2419
2555
  }
2420
2556
  function remakeSendStmt(db, sendId, render2, now, guard2) {
2421
2557
  return db.prepare(
2422
- `UPDATE sends SET rendered_html = ?, rendered_text = ?, subject = ?, remade_at = ?, rev = ${NEXT_REV}
2558
+ `UPDATE sends SET rendered_html = ?, rendered_text = ?, subject = ?, render_warnings = ?, remade_at = ?, web_link = 'unlisted', rev = ${NEXT_REV}
2423
2559
  WHERE id = ? AND status = 'scheduled' AND ${guard2.sql}`
2424
- ).bind(render2.rendered_html, render2.rendered_text, render2.subject, now, sendId, ...guard2.binds);
2560
+ ).bind(
2561
+ render2.rendered_html,
2562
+ render2.rendered_text,
2563
+ render2.subject,
2564
+ JSON.stringify(render2.warnings),
2565
+ now,
2566
+ sendId,
2567
+ ...guard2.binds
2568
+ );
2425
2569
  }
2426
2570
  async function dueSends(db, now) {
2427
2571
  const { results } = await db.prepare("SELECT * FROM sends WHERE status = 'scheduled' AND fire_at <= ? ORDER BY fire_at ASC").bind(now).all();
2428
2572
  return results;
2429
2573
  }
2430
- var WEDGED_SEND = "(status = 'sending' AND c_pending = 0 AND c_in_flight > 0 AND locked_until IS NULL)";
2431
2574
  async function resumableSends(db, now, retryDueBy) {
2432
2575
  const { results } = await db.prepare(
2433
2576
  `SELECT * FROM sends WHERE status = 'sending' AND (locked_until IS NULL OR locked_until < ?)
@@ -2436,14 +2579,50 @@ async function resumableSends(db, now, retryDueBy) {
2436
2579
  ).bind(now, retryDueBy).all();
2437
2580
  return results;
2438
2581
  }
2582
+ async function accountHalt(db, sendId, aheadOf) {
2583
+ const row = await db.prepare(
2584
+ `SELECT id, halt_reason, halt_cause, halt_error, halted_at, halt_retries, halt_retry_at
2585
+ FROM sends
2586
+ WHERE status = 'sending' AND id <> ? AND halt_reason IS NOT NULL
2587
+ AND halt_retry_at > ? AND NOT ${WEDGED_SEND}
2588
+ AND (halt_cause IS NULL OR halt_cause NOT IN (SELECT value FROM json_each(?)))
2589
+ ORDER BY halt_reason = 'account' DESC, halt_retry_at DESC
2590
+ LIMIT 1`
2591
+ ).bind(sendId, aheadOf, JSON.stringify(SEND_OWN_HALT_CAUSES)).first();
2592
+ return row ? {
2593
+ sendId: row.id,
2594
+ reason: row.halt_reason,
2595
+ cause: row.halt_cause,
2596
+ error: row.halt_error ?? "",
2597
+ since: row.halted_at ?? row.halt_retry_at,
2598
+ retries: row.halt_retries,
2599
+ retryAt: row.halt_retry_at
2600
+ } : null;
2601
+ }
2602
+ async function joinHalt(db, sendId, lease, halt) {
2603
+ await db.prepare(
2604
+ `UPDATE sends SET halt_reason = ?, halt_cause = ?, halt_error = ?,
2605
+ halted_at = CASE WHEN halt_reason IS ? AND halted_at IS NOT NULL THEN halted_at ELSE ? END,
2606
+ halt_retries = ?, halt_retry_at = ?, rev = ${NEXT_REV}
2607
+ WHERE id = ? AND lease_token = ?`
2608
+ ).bind(
2609
+ halt.reason,
2610
+ halt.cause,
2611
+ halt.error,
2612
+ halt.reason,
2613
+ halt.since,
2614
+ halt.retries,
2615
+ halt.retryAt,
2616
+ sendId,
2617
+ lease
2618
+ ).run();
2619
+ }
2439
2620
  async function wedgedSends(db) {
2440
2621
  const { results } = await db.prepare(`SELECT * FROM sends WHERE ${WEDGED_SEND} ORDER BY started_at ASC`).all();
2441
2622
  return results;
2442
2623
  }
2443
- async function stuckSends(db, olderThan) {
2444
- const { results } = await db.prepare(
2445
- "SELECT * FROM sends WHERE status = 'sending' AND started_at IS NOT NULL AND started_at < ?"
2446
- ).bind(olderThan).all();
2624
+ async function stuckSends(db, idleBefore) {
2625
+ const { results } = await db.prepare(`SELECT * FROM sends WHERE ${stuckSendSql("?")} ORDER BY started_at ASC`).bind(idleBefore).all();
2447
2626
  return results;
2448
2627
  }
2449
2628
  async function acquireLease(db, sendId, now, leaseTtlMs) {
@@ -2627,7 +2806,7 @@ var OUTCOME_BUCKET = {
2627
2806
  skipped: "c_skipped",
2628
2807
  pending: "c_pending"
2629
2808
  };
2630
- async function settleDeliveries(db, sendId, lease, from, outcomes, now, answered = false) {
2809
+ async function settleDeliveries(db, sendId, lease, from, outcomes, now, answer = { progressed: false, clears: false }) {
2631
2810
  if (outcomes.length === 0) {
2632
2811
  return;
2633
2812
  }
@@ -2649,7 +2828,12 @@ async function settleDeliveries(db, sendId, lease, from, outcomes, now, answered
2649
2828
  }
2650
2829
  const cols = [...deltas.keys()];
2651
2830
  const sets = cols.map((c) => `${c} = ${c} + ?`);
2652
- if (answered) {
2831
+ const values = cols.map((c) => deltas.get(c) ?? 0);
2832
+ if (answer.progressed) {
2833
+ sets.push("progress_at = ?");
2834
+ values.push(now);
2835
+ }
2836
+ if (answer.clears) {
2653
2837
  sets.push(
2654
2838
  "halt_reason = NULL",
2655
2839
  "halt_cause = NULL",
@@ -2678,7 +2862,7 @@ async function settleDeliveries(db, sendId, lease, from, outcomes, now, answered
2678
2862
  // The column names come from the fixed `CounterCol` union, never user input.
2679
2863
  db.prepare(
2680
2864
  `UPDATE sends SET ${sets.join(", ")}, rev = ${NEXT_REV} WHERE id = ? AND lease_token = ?`
2681
- ).bind(...cols.map((c) => deltas.get(c) ?? 0), sendId, lease)
2865
+ ).bind(...values, sendId, lease)
2682
2866
  ]);
2683
2867
  }
2684
2868
  async function openDeliveryCount(db, sendId) {
@@ -2800,12 +2984,117 @@ async function acceptedAwaitingEvent(db, limit) {
2800
2984
 
2801
2985
  // src/db/posts.ts
2802
2986
  function fromStored(row) {
2803
- return { ...row, slug_auto: row.slug_auto === 1 };
2987
+ return {
2988
+ ...row,
2989
+ slug_auto: row.slug_auto === 1,
2990
+ email_only: row.email_only === 1
2991
+ };
2804
2992
  }
2805
2993
  function setPostStatusStmt(db, postId, from, to, now, guard2) {
2806
2994
  const extra = guard2 ? ` AND ${guard2.sql}` : "";
2807
2995
  return db.prepare(`UPDATE posts SET status = ?, updated_at = ? WHERE id = ? AND status = ?${extra}`).bind(to, now, postId, from, ...guard2?.binds ?? []);
2808
2996
  }
2997
+ function slugOf(text) {
2998
+ return slugify(text) || EMPTY_SUBJECT_SLUG;
2999
+ }
3000
+ var SLUG_MAX = 80;
3001
+ function slugWellFormed(slug) {
3002
+ return slug.length <= SLUG_MAX && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug);
3003
+ }
3004
+ var SlugRefused = class extends Error {
3005
+ constructor(code) {
3006
+ super(code);
3007
+ this.code = code;
3008
+ this.name = "SlugRefused";
3009
+ }
3010
+ code;
3011
+ };
3012
+ async function resolveSlug(db, current2, change) {
3013
+ const auto = change.slugAuto ?? (change.slug === void 0 && current2.slugAuto);
3014
+ if (!auto && change.slug === void 0 && current2.slug === void 0) {
3015
+ return { slug: await uniqueSlug(db, slugOf(current2.subject), current2.postId), auto: false };
3016
+ }
3017
+ if (!auto) {
3018
+ const slug = change.slug ?? current2.slug ?? slugOf(current2.subject);
3019
+ if (change.slug !== void 0) {
3020
+ if (!slugWellFormed(slug)) {
3021
+ return { refused: "slug_invalid" };
3022
+ }
3023
+ if (await slugTaken(db, slug, current2.postId)) {
3024
+ return { refused: "slug_taken" };
3025
+ }
3026
+ }
3027
+ return { slug, auto: false };
3028
+ }
3029
+ const wanted = slugOf(current2.subject);
3030
+ if (!await slugTaken(db, wanted, current2.postId)) {
3031
+ return { slug: wanted, auto: true };
3032
+ }
3033
+ if (current2.slug && isNumberOf(current2.slug, wanted)) {
3034
+ return { slug: current2.slug, auto: true };
3035
+ }
3036
+ return { slug: await uniqueSlug(db, wanted, current2.postId), auto: true };
3037
+ }
3038
+ function isNumberOf(slug, base) {
3039
+ return slug.startsWith(`${base}-`) && /^[1-9][0-9]*$/.test(slug.slice(base.length + 1));
3040
+ }
3041
+ async function subjectSlugHeld(db, post) {
3042
+ const wanted = slugify(post.subject);
3043
+ if (!post.slug_auto || !wanted || wanted === post.slug) {
3044
+ return null;
3045
+ }
3046
+ if (!await slugTaken(db, wanted, post.id)) {
3047
+ return null;
3048
+ }
3049
+ return await publishedSendForPost(db, post.id) ? null : wanted;
3050
+ }
3051
+ function isSlugCollision(err) {
3052
+ return /UNIQUE constraint failed:\s*posts\.slug/i.test(
3053
+ err instanceof Error ? err.message : String(err)
3054
+ );
3055
+ }
3056
+ function setPublishingStmt(db, post, input, guard2) {
3057
+ const sets = [];
3058
+ const binds = [];
3059
+ if (input.emailOnly !== void 0) {
3060
+ sets.push("email_only = ?");
3061
+ binds.push(input.emailOnly ? 1 : 0);
3062
+ }
3063
+ const where = ["id = ?"];
3064
+ if (slugMoves(post, input)) {
3065
+ const slug = unwrap(input.slug, "slug");
3066
+ sets.push("slug = ?", "slug_auto = ?");
3067
+ binds.push(slug.slug, slug.auto ? 1 : 0);
3068
+ where.push(
3069
+ "NOT EXISTS (SELECT 1 FROM sends WHERE sends.post_id = posts.id AND sends.status IN ('sending', 'sent'))"
3070
+ );
3071
+ }
3072
+ if (guard2) {
3073
+ where.push(guard2.sql);
3074
+ }
3075
+ return db.prepare(
3076
+ `UPDATE posts SET ${sets.length ? sets.join(", ") : "id = id"} WHERE ${where.join(" AND ")}`
3077
+ ).bind(...binds, post.id, ...guard2?.binds ?? []);
3078
+ }
3079
+ function slugMoves(post, input) {
3080
+ return input.slug !== void 0 && (input.slug.slug !== post.slug || input.slug.auto !== post.slug_auto);
3081
+ }
3082
+ function publishingChanges(post, input) {
3083
+ return input.emailOnly !== void 0 && input.emailOnly !== post.email_only || slugMoves(post, input);
3084
+ }
3085
+ async function setPublishing(db, post, input) {
3086
+ if (!publishingChanges(post, input)) {
3087
+ return "unchanged";
3088
+ }
3089
+ const [written] = await db.batch([
3090
+ setPublishingStmt(db, post, input),
3091
+ stampSendsOfPostStmt(db, post.id)
3092
+ ]);
3093
+ if (unwrap(written, "settings write").meta.changes > 0) {
3094
+ return "changed";
3095
+ }
3096
+ return await getPost(db, post.id) ? "address_fixed" : "missing";
3097
+ }
2809
3098
  async function getPost(db, id) {
2810
3099
  const row = await db.prepare("SELECT * FROM posts WHERE id = ?").bind(id).first();
2811
3100
  return row && fromStored(row);
@@ -2814,6 +3103,10 @@ async function getBySlug(db, slug) {
2814
3103
  const row = await db.prepare("SELECT * FROM posts WHERE slug = ?").bind(slug).first();
2815
3104
  return row && fromStored(row);
2816
3105
  }
3106
+ async function getByUnlistedToken(db, token2) {
3107
+ const row = await db.prepare("SELECT * FROM posts WHERE unlisted_token = ?").bind(token2).first();
3108
+ return row && fromStored(row);
3109
+ }
2817
3110
  var POST_LIST_SPEC = {
2818
3111
  columns: {
2819
3112
  updated: "p.updated_at",
@@ -2901,8 +3194,15 @@ async function createPost(db, input, author2) {
2901
3194
  const revId = newId();
2902
3195
  const subject = input.subject ?? "";
2903
3196
  const markdown2 = input.markdown ?? "";
2904
- const auto = input.slug_auto ?? input.slug === void 0;
2905
- const slug = await uniqueSlug(db, slugify(auto ? subject : input.slug ?? subject));
3197
+ const resolved = await resolveSlug(
3198
+ db,
3199
+ { slugAuto: input.slug === void 0, subject },
3200
+ { slug: input.slug, slugAuto: input.slug_auto }
3201
+ );
3202
+ if ("refused" in resolved) {
3203
+ throw new SlugRefused(resolved.refused);
3204
+ }
3205
+ const { slug, auto } = resolved;
2906
3206
  const metadata = JSON.stringify({ subject, slug });
2907
3207
  await db.batch([
2908
3208
  db.prepare(
@@ -2925,13 +3225,15 @@ async function updatePost(db, post, input, author2) {
2925
3225
  const revId = newId();
2926
3226
  const subject = input.subject ?? post.subject;
2927
3227
  const markdown2 = input.markdown ?? current2?.markdown ?? "";
2928
- const auto = input.slug_auto ?? (input.slug === void 0 && post.slug_auto);
2929
- let slug = post.slug;
2930
- if (auto) {
2931
- slug = await uniqueSlug(db, slugify(subject), post.id);
2932
- } else if (input.slug !== void 0) {
2933
- slug = await uniqueSlug(db, slugify(input.slug), post.id);
3228
+ const resolved = await resolveSlug(
3229
+ db,
3230
+ { postId: post.id, slug: post.slug, slugAuto: post.slug_auto, subject },
3231
+ { slug: input.slug, slugAuto: input.slug_auto }
3232
+ );
3233
+ if ("refused" in resolved) {
3234
+ throw new SlugRefused(resolved.refused);
2934
3235
  }
3236
+ const { slug, auto } = resolved;
2935
3237
  const metadata = JSON.stringify({ subject, slug });
2936
3238
  await db.batch([
2937
3239
  db.prepare(
@@ -2962,9 +3264,18 @@ async function deletePost(db, id) {
2962
3264
  ]);
2963
3265
  }
2964
3266
 
2965
- // shared/archive_url.ts
2966
- function archivePostUrl(origin, basePath, slug) {
2967
- return `${origin}${basePath}/${slug}`;
3267
+ // src/lib/archive_address.ts
3268
+ function withAddresses(config, post, publication) {
3269
+ return {
3270
+ ...post,
3271
+ archive_url: archivePostUrl(config.archiveOrigin, config.archiveBasePath, post.slug),
3272
+ unlisted_url: unlistedPostUrl(config.appOrigin, post.unlisted_token),
3273
+ published_at: publication?.published_at ?? null,
3274
+ archive_live: publication !== null && archiveAnswers({ emailOnly: post.email_only, webLink: publication.web_link })
3275
+ };
3276
+ }
3277
+ function archiveAnswers(post) {
3278
+ return !post.emailOnly || post.webLink === "archive";
2968
3279
  }
2969
3280
 
2970
3281
  // shared/email_logo.ts
@@ -3780,9 +4091,9 @@ var EMAIL_MAX_WIDTH = 600;
3780
4091
  function readMeta(revision, post) {
3781
4092
  try {
3782
4093
  const m = JSON.parse(revision.metadata);
3783
- return { subject: m.subject ?? post.subject, slug: m.slug ?? post.slug };
4094
+ return { subject: m.subject ?? post.subject };
3784
4095
  } catch {
3785
- return { subject: post.subject, slug: post.slug };
4096
+ return { subject: post.subject };
3786
4097
  }
3787
4098
  }
3788
4099
  function derivePreheader(bodyText) {
@@ -3809,7 +4120,7 @@ async function render(input, config, branding = defaultBranding()) {
3809
4120
  );
3810
4121
  }
3811
4122
  const subject = hasSubject ? meta.subject : "(no subject)";
3812
- const viewInBrowserUrl = archiveUrl(config, meta.slug);
4123
+ const viewInBrowserUrl = unlistedPostUrl(config.appOrigin, input.post.unlisted_token);
3813
4124
  const validation = validateEmailTemplate(branding.template);
3814
4125
  let template = branding.template;
3815
4126
  if (validation.errors.length > 0) {
@@ -3957,13 +4268,7 @@ function nothingHereYetPage(c) {
3957
4268
  status: 404
3958
4269
  });
3959
4270
  }
3960
- async function archivePage(c) {
3961
- const slug = param(c, "slug");
3962
- const post = await getBySlug(c.env.DB, slug);
3963
- const send = post ? await publishedSendForPost(c.env.DB, post.id) : null;
3964
- if (!post || !send) {
3965
- return nothingHereYetPage(c);
3966
- }
4271
+ async function postPage(c, post, send) {
3967
4272
  const html = webVersion(send.rendered_html, {
3968
4273
  config: c.config,
3969
4274
  identity: await readerIdentity(c, c.config),
@@ -3974,10 +4279,40 @@ async function archivePage(c) {
3974
4279
  headers: {
3975
4280
  ...POST_PAGE_SECURITY_HEADERS,
3976
4281
  "content-type": "text/html; charset=utf-8",
3977
- "cache-control": "public, max-age=3600"
4282
+ // Checked with the app on every view, since the listing can change at any time: a page
4283
+ // taken out of the archive must stop answering at once, and an unlisted link must start
4284
+ // forwarding once its post is added (SPEC §5).
4285
+ "cache-control": "no-cache",
4286
+ ...post.email_only ? { "x-robots-tag": "noindex" } : {}
3978
4287
  }
3979
4288
  });
3980
4289
  }
4290
+ async function archivePage(c) {
4291
+ const post = await getBySlug(c.env.DB, param(c, "slug"));
4292
+ const send = post ? await publishedSendForPost(c.env.DB, post.id) : null;
4293
+ if (!post || !send || !archiveAnswers({ emailOnly: post.email_only, webLink: send.web_link })) {
4294
+ return nothingHereYetPage(c);
4295
+ }
4296
+ return postPage(c, post, send);
4297
+ }
4298
+ async function unlistedPage(c) {
4299
+ const post = await getByUnlistedToken(c.env.DB, param(c, "token"));
4300
+ const send = post ? await publishedSendForPost(c.env.DB, post.id) : null;
4301
+ if (!post || !send) {
4302
+ return nothingHereYetPage(c);
4303
+ }
4304
+ if (!post.email_only) {
4305
+ return new Response(null, {
4306
+ status: 302,
4307
+ headers: {
4308
+ location: archiveUrl(c.config, post.slug),
4309
+ "cache-control": "no-store",
4310
+ "x-robots-tag": "noindex"
4311
+ }
4312
+ });
4313
+ }
4314
+ return postPage(c, post, send);
4315
+ }
3981
4316
 
3982
4317
  // src/db/seed.ts
3983
4318
  function chunk(items, size) {
@@ -4036,13 +4371,15 @@ async function insertSuppressions(db, rows) {
4036
4371
  async function insertPost(db, post, revision) {
4037
4372
  await db.batch([
4038
4373
  db.prepare(
4039
- "INSERT INTO posts (id, slug, slug_auto, subject, status, current_revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
4374
+ "INSERT INTO posts (id, slug, slug_auto, unlisted_token, subject, status, email_only, current_revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
4040
4375
  ).bind(
4041
4376
  post.id,
4042
4377
  post.slug,
4043
4378
  post.slug_auto ? 1 : 0,
4379
+ post.unlisted_token ?? null,
4044
4380
  post.subject,
4045
4381
  post.status,
4382
+ post.email_only ? 1 : 0,
4046
4383
  post.current_revision,
4047
4384
  post.created_at,
4048
4385
  post.updated_at
@@ -4076,8 +4413,8 @@ async function insertImage(db, row) {
4076
4413
  async function insertSend(db, row) {
4077
4414
  await db.prepare(
4078
4415
  `INSERT INTO sends
4079
- (id, post_id, status, fire_at, rendered_html, rendered_text, subject, recipient_count, locked_until, scheduled_at, started_at, completed_at, audience_resolved_at, rev)
4080
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ${NEXT_REV})`
4416
+ (id, post_id, status, fire_at, rendered_html, rendered_text, subject, recipient_count, locked_until, scheduled_at, started_at, completed_at, audience_resolved_at, web_link, rev)
4417
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, 'unlisted', ${NEXT_REV})`
4081
4418
  ).bind(
4082
4419
  row.id,
4083
4420
  row.post_id,
@@ -4462,6 +4799,26 @@ async function deleteImageRow(db, postId, filename) {
4462
4799
  await db.prepare("DELETE FROM images WHERE post_id = ? AND filename = ?").bind(postId, filename).run();
4463
4800
  }
4464
4801
 
4802
+ // src/lib/slug_refusals.ts
4803
+ function slugRefusal(code) {
4804
+ return code === "slug_invalid" ? refusal(
4805
+ 400,
4806
+ "slug_invalid",
4807
+ "Use lowercase letters and numbers, with single hyphens between words, up to 80 characters.",
4808
+ { field: "slug" }
4809
+ ) : refusal(409, "slug_taken", "Another post already has this address. Choose another.", {
4810
+ field: "slug"
4811
+ });
4812
+ }
4813
+ function slugPermanent(field, why2) {
4814
+ return refusal(
4815
+ 409,
4816
+ "slug_permanent",
4817
+ why2 === "sent" ? "This post\u2019s archive address is fixed once it\u2019s sent, since people may have its link." : "This post\u2019s archive address is fixed: its scheduled email already links to it.",
4818
+ { field }
4819
+ );
4820
+ }
4821
+
4465
4822
  // shared/cursor.ts
4466
4823
  var PATTERN = /^(\d+)\.(\d+)$/;
4467
4824
  function encodeSendCursor(cursor) {
@@ -4483,14 +4840,28 @@ function importNotice(db, importId, now) {
4483
4840
  "INSERT INTO notifications (send_id, import_id, kind, episode, created_at, updated_at) VALUES (NULL, ?, 'import', 0, ?, ?)"
4484
4841
  ).bind(importId, now, now);
4485
4842
  }
4843
+ function utcDayStart2(now) {
4844
+ return Math.floor(now / 864e5) * 864e5;
4845
+ }
4846
+ async function sandboxNotice(db, now) {
4847
+ await db.prepare(
4848
+ "INSERT OR IGNORE INTO notifications (send_id, import_id, kind, episode, created_at, updated_at) VALUES (NULL, NULL, 'sandbox', ?, ?, ?)"
4849
+ ).bind(utcDayStart2(now), now, now).run();
4850
+ }
4486
4851
  async function recordNotifications(db, now) {
4487
4852
  await db.prepare(
4488
4853
  `INSERT OR IGNORE INTO notifications (send_id, kind, episode, created_at, updated_at)
4489
4854
  SELECT id, 'finished', 0, ?1, ?1 FROM sends
4490
4855
  WHERE status = 'sent' AND completed_at >= ?2
4491
4856
  UNION ALL
4492
- SELECT id, 'refused', halted_at, ?1, ?1 FROM sends
4857
+ SELECT id, 'refused', halted_at, ?1, ?1 FROM sends s
4493
4858
  WHERE status = 'sending' AND halt_reason = 'account' AND halted_at IS NOT NULL
4859
+ AND NOT EXISTS (SELECT 1 FROM sends o
4860
+ WHERE o.status = 'sending' AND o.halt_reason = 'account'
4861
+ AND o.halted_at = s.halted_at AND o.id < s.id)
4862
+ AND NOT EXISTS (SELECT 1 FROM notifications r
4863
+ WHERE r.kind = 'refused' AND r.episode = s.halted_at
4864
+ AND r.send_id <> s.id)
4494
4865
  UNION ALL
4495
4866
  SELECT id, 'missed', 0, ?1, ?1 FROM sends
4496
4867
  WHERE (status = 'scheduled' AND fire_at < ?1 - ?3)
@@ -4498,28 +4869,34 @@ async function recordNotifications(db, now) {
4498
4869
  UNION ALL
4499
4870
  SELECT id, 'wedged', 0, ?1, ?1 FROM sends WHERE ${WEDGED_SEND}
4500
4871
  UNION ALL
4501
- SELECT id, 'stuck', 0, ?1, ?1 FROM sends
4502
- WHERE status = 'sending' AND started_at < ?1 - ?4
4503
- AND halt_reason IS NOT 'account' AND NOT (${WEDGED_SEND})
4504
- AND NOT EXISTS (SELECT 1 FROM notifications r
4505
- WHERE r.send_id = sends.id AND r.kind = 'refused')`
4872
+ SELECT id, 'stuck', ${SEND_IDLE_SINCE}, ?1, ?1 FROM sends
4873
+ WHERE ${stuckSendSql("?1 - ?4")}`
4506
4874
  ).bind(now, now - NOTIFY_HORIZON_MS, MISSED_THRESHOLD_MS, STUCK_THRESHOLD_MS).run();
4507
4875
  }
4508
4876
  async function dueNotifications(db, limit) {
4509
4877
  const { results } = await db.prepare(
4510
4878
  `SELECT n.send_id, n.import_id, n.kind, n.episode, n.attempts,
4511
4879
  s.subject, s.status AS send_status, s.fire_at, s.started_at, s.completed_at,
4512
- s.locked_until, s.halt_reason, s.halted_at, s.halt_cause, s.halt_error, s.c_pending, s.c_in_flight, s.c_accepted,
4880
+ 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,
4513
4881
  s.c_delivered, s.c_bounced, s.c_complained, s.c_skipped, s.c_unsent,
4514
4882
  i.file_name, i.created_at, i.attestation_version, i.counts, i.suppressions_added
4515
4883
  FROM notifications n
4516
4884
  LEFT JOIN sends s ON s.id = n.send_id
4517
4885
  LEFT JOIN imports i ON i.id = n.import_id
4518
- WHERE n.status = 'pending' AND (s.id IS NOT NULL OR i.id IS NOT NULL)
4886
+ WHERE n.status = 'pending' AND (s.id IS NOT NULL OR i.id IS NOT NULL OR n.kind = 'sandbox')
4519
4887
  ORDER BY n.created_at ASC, n.send_id ASC, n.kind ASC
4520
4888
  LIMIT ?`
4521
4889
  ).bind(limit).all();
4522
4890
  return results.map((r) => {
4891
+ if (r.kind === "sandbox") {
4892
+ return {
4893
+ send_id: null,
4894
+ import_id: null,
4895
+ kind: "sandbox",
4896
+ episode: r.episode,
4897
+ attempts: r.attempts
4898
+ };
4899
+ }
4523
4900
  if (r.kind === "import") {
4524
4901
  return {
4525
4902
  send_id: null,
@@ -4832,13 +5209,25 @@ function isWedged(send) {
4832
5209
  return send.status === "sending" && send.c_pending === 0 && send.c_in_flight > 0 && send.locked_until === null;
4833
5210
  }
4834
5211
 
5212
+ // src/send/stuck.ts
5213
+ function idleSince(send) {
5214
+ if (send.status !== "sending" || send.started_at === null || isWedged(send)) {
5215
+ return null;
5216
+ }
5217
+ return Math.max(send.progress_at ?? send.started_at, send.halt_retry_at ?? 0);
5218
+ }
5219
+ function stuckAt(send) {
5220
+ const idle = idleSince(send);
5221
+ return idle === null ? null : idle + STUCK_THRESHOLD_MS;
5222
+ }
5223
+ function isStuck(send, now) {
5224
+ const at = stuckAt(send);
5225
+ return at !== null && now > at;
5226
+ }
5227
+
4835
5228
  // src/send/conditions.ts
4836
5229
  var minutes = (ms) => Math.max(1, Math.round(ms / 6e4));
4837
5230
  var plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
4838
- function sentence(error) {
4839
- const text = error?.trim() || "no detail given";
4840
- return /[.!?]$/.test(text) ? text : `${text}.`;
4841
- }
4842
5231
  var action = (send, name) => ({
4843
5232
  name,
4844
5233
  method: "POST",
@@ -4904,37 +5293,38 @@ function sendConditions(send, now, day) {
4904
5293
  }
4905
5294
  const retried = !isWedged(send);
4906
5295
  if (retried && send.halt_reason === "account") {
4907
- const advice = refusalAdvice(send.halt_cause);
4908
5296
  out.push({
4909
5297
  kind: "refused",
4910
5298
  severity: "action",
4911
5299
  since: send.halted_at,
4912
- // No provider connected (SPEC §9) is held like a refusal but is no provider's doing,
4913
- // so it is said as what it is, with the fix once.
4914
- message: send.halt_cause === "not_connected" ? `No email provider is connected, so this send is held where it is. ${advice} No one has been marked unsent, and the send goes out at its first retry after a provider is connected.` : `The provider is refusing the account: ${sentence(send.halt_error)} ${advice} No one has been marked unsent, and the send resumes on its own at its next retry once the account is fixed.`,
5300
+ message: refusedMessage(send.halt_cause, send.halt_error),
4915
5301
  action: null,
4916
5302
  cause: send.halt_cause,
4917
5303
  error: send.halt_error ?? "",
4918
- advice,
5304
+ advice: refusalAdvice(send.halt_cause),
4919
5305
  retry_at: send.halt_retry_at
4920
5306
  });
4921
5307
  } else if (retried && send.halt_reason === "unavailable") {
4922
5308
  out.push({
4923
5309
  kind: "provider_unavailable",
4924
- severity: "info",
5310
+ // A rate limit that outlasts any burst is the deployment's setting, which a person
5311
+ // fixes, so it is worth a look rather than only shown.
5312
+ severity: isSelfThrottled(send) ? "warn" : "info",
4925
5313
  since: send.halted_at,
4926
- message: `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.`,
5314
+ message: unavailableMessage(send),
4927
5315
  action: null,
4928
5316
  error: send.halt_error ?? "",
4929
5317
  retry_at: send.halt_retry_at
4930
5318
  });
4931
5319
  }
4932
- if (send.started_at !== null && now - send.started_at > STUCK_THRESHOLD_MS) {
5320
+ const stuck = stuckAt(send);
5321
+ if (stuck !== null && now > stuck) {
5322
+ const idle = now - (send.progress_at ?? send.started_at ?? now);
4933
5323
  out.push({
4934
5324
  kind: "stuck",
4935
5325
  severity: "warn",
4936
- since: send.started_at + STUCK_THRESHOLD_MS,
4937
- message: `Still sending ${plural(minutes(now - send.started_at), "minute")} after it started; it may be retrying.`,
5326
+ since: stuck,
5327
+ message: `Nothing has gone out for ${plural(minutes(idle), "minute")}, with no retry or provider problem to explain it. Sending may have stopped, or another send may be taking its turn. It picks up where it stopped, mailing no one twice.`,
4938
5328
  action: null
4939
5329
  });
4940
5330
  }
@@ -4951,6 +5341,17 @@ function sendConditions(send, now, day) {
4951
5341
  rate
4952
5342
  });
4953
5343
  }
5344
+ const heard = send.c_delivered + send.c_bounced + send.c_complained;
5345
+ if (send.status === "sent" && send.completed_at !== null && now - send.completed_at > NO_RECEIPTS_AFTER_MS && now - send.completed_at <= BOUNCE_SPIKE_RECENT_MS && send.c_accepted > 0 && heard === 0) {
5346
+ out.push({
5347
+ kind: "no_receipts",
5348
+ severity: "warn",
5349
+ since: send.completed_at + NO_RECEIPTS_AFTER_MS,
5350
+ message: `No delivery receipt has arrived for any of the ${plural(send.c_accepted, "recipient")} the provider took, ${plural(Math.round((now - send.completed_at) / 36e5) || 1, "hour")} after the send went out. The provider's events may not be reaching this app, so its bounces and complaints suppress no one. Check the webhook and the app's error log.`,
5351
+ action: null,
5352
+ accepted: send.c_accepted
5353
+ });
5354
+ }
4954
5355
  return out.sort(bySeverity);
4955
5356
  }
4956
5357
 
@@ -4977,18 +5378,13 @@ function nextChangeAt(send, now) {
4977
5378
  if (send.locked_until !== null && send.locked_until > now) {
4978
5379
  return now;
4979
5380
  }
4980
- let next = null;
4981
5381
  if (isWedged(send)) {
4982
- } else if (send.halt_retry_at !== null) {
4983
- next = Math.max(now, send.halt_retry_at - HALT_RETRY_SLACK_MS);
4984
- } else {
4985
- return now;
5382
+ return null;
4986
5383
  }
4987
- const stuckAt = send.started_at === null ? null : send.started_at + STUCK_THRESHOLD_MS;
4988
- if (stuckAt !== null && stuckAt >= now) {
4989
- next = next === null ? stuckAt : Math.min(next, stuckAt);
5384
+ if (send.halt_retry_at !== null) {
5385
+ return Math.max(now, send.halt_retry_at - HALT_RETRY_SLACK_MS);
4990
5386
  }
4991
- return next;
5387
+ return now;
4992
5388
  }
4993
5389
  function feedPace(sends, settlingSince, now) {
4994
5390
  let moving = false;
@@ -5019,7 +5415,7 @@ function readAgainAt(pace, now) {
5019
5415
  }
5020
5416
 
5021
5417
  // src/send/view.ts
5022
- function derivePhase(status, counts2, hasRetries, due, wedged, refused, unavailable2) {
5418
+ function derivePhase(status, counts2, hasRetries, due, wedged, refused, unavailable2, audienceFixed) {
5023
5419
  switch (status) {
5024
5420
  case "scheduled":
5025
5421
  return due ? "due" : "scheduled";
@@ -5031,10 +5427,13 @@ function derivePhase(status, counts2, hasRetries, due, wedged, refused, unavaila
5031
5427
  if (wedged || refused) {
5032
5428
  return "needs-attention";
5033
5429
  }
5430
+ if (unavailable2 && (counts2.pending > 0 || !audienceFixed)) {
5431
+ return "backing-off";
5432
+ }
5034
5433
  if (counts2.in_flight > 0) {
5035
5434
  return hasRetries ? "retrying" : "progressing";
5036
5435
  }
5037
- if (counts2.pending > 0 && (hasRetries || unavailable2)) {
5436
+ if (counts2.pending > 0 && hasRetries) {
5038
5437
  return "backing-off";
5039
5438
  }
5040
5439
  return "progressing";
@@ -5080,6 +5479,7 @@ function tickEstimate(startedAt, total, done, running, now) {
5080
5479
  }
5081
5480
  function buildSendView(send, config, hasRetries, now, day) {
5082
5481
  const counts2 = countsOf(send);
5482
+ const published = send.status === "sending" || send.status === "sent";
5083
5483
  const summed = counts2.pending + counts2.in_flight + counts2.accepted + counts2.delivered + counts2.bounced + counts2.complained + counts2.skipped + counts2.unsent;
5084
5484
  const total = summed > 0 ? summed : send.recipient_count;
5085
5485
  const done = Math.max(0, total - counts2.pending - counts2.in_flight);
@@ -5104,13 +5504,24 @@ function buildSendView(send, config, hasRetries, now, day) {
5104
5504
  } : null;
5105
5505
  const refused = halt?.reason === "account";
5106
5506
  const unavailable2 = halt?.reason === "unavailable";
5107
- const phase = derivePhase(send.status, counts2, hasRetries, due, wedged, refused, unavailable2);
5108
- const handingOff = phase === "progressing" || phase === "retrying";
5507
+ const phase = derivePhase(
5508
+ send.status,
5509
+ counts2,
5510
+ hasRetries,
5511
+ due,
5512
+ wedged,
5513
+ refused,
5514
+ unavailable2,
5515
+ send.audience_resolved_at !== null
5516
+ );
5517
+ const handingOff = (phase === "progressing" || phase === "retrying") && !isStuck(send, now);
5109
5518
  return {
5110
5519
  id,
5111
5520
  post_id: send.post_id,
5112
5521
  subject: send.subject,
5113
5522
  status: send.status,
5523
+ email_only: send.post_email_only === 1,
5524
+ web_link: send.web_link,
5114
5525
  rev: send.rev,
5115
5526
  as_of: now,
5116
5527
  fire_at: send.fire_at,
@@ -5144,8 +5555,10 @@ function buildSendView(send, config, hasRetries, now, day) {
5144
5555
  deliveries: `/api/sends/${id}/deliveries`,
5145
5556
  deliveries_csv: `/api/sends/${id}/deliveries.csv`,
5146
5557
  post: `/api/posts/${send.post_id}`,
5147
- // The archive serves a post's page once its send starts: before, the link would 404.
5148
- archive: (send.status === "sending" || send.status === "sent") && send.post_slug !== null ? archiveUrl(config, send.post_slug) : null
5558
+ // Each address answers only once the send starts; the archive's only while the post is
5559
+ // listed, or whatever the listing for an email that carries it (`archiveAnswers`).
5560
+ archive: published && send.post_slug !== null && archiveAnswers({ emailOnly: send.post_email_only === 1, webLink: send.web_link }) ? archiveUrl(config, send.post_slug) : null,
5561
+ unlisted: published && send.post_unlisted_token !== null ? unlistedPostUrl(config.appOrigin, send.post_unlisted_token) : null
5149
5562
  }
5150
5563
  };
5151
5564
  }
@@ -5184,7 +5597,12 @@ async function renderPost(env, config, post, branding) {
5184
5597
  }
5185
5598
  const images = await listImages(env.DB, post.id);
5186
5599
  const rendered = await render({ post, revision, images }, config, branding);
5187
- return { rendered_html: rendered.html, rendered_text: rendered.text, subject: rendered.subject };
5600
+ return {
5601
+ rendered_html: rendered.html,
5602
+ rendered_text: rendered.text,
5603
+ subject: rendered.subject,
5604
+ warnings: rendered.warnings
5605
+ };
5188
5606
  }
5189
5607
  var MINUTE_MS = 60 * 1e3;
5190
5608
  function onTheMinute(fireAt) {
@@ -5199,8 +5617,8 @@ function acceptFireAt(requested, minLeadMs, { now = Date.now() } = {}) {
5199
5617
  return onTheMinute(requested);
5200
5618
  }
5201
5619
  var FREEZE_RETRIES = 3;
5202
- var ACTIVE_SEND_EXISTS = "This post already has a send scheduled or going out. Reschedule or cancel that send instead.";
5203
- async function freeze(env, config, post, fireAt) {
5620
+ var ACTIVE_SEND_EXISTS = "This post already has a send scheduled or going out. Edit its schedule or cancel it instead.";
5621
+ async function freeze(env, config, post, fireAt, postSettings = {}) {
5204
5622
  const active = await getActiveSendForPost(env.DB, post.id);
5205
5623
  if (active) {
5206
5624
  throw await activeSendExists(env, active);
@@ -5209,7 +5627,7 @@ async function freeze(env, config, post, fireAt) {
5209
5627
  throw refusal(
5210
5628
  409,
5211
5629
  "post_not_draft",
5212
- post.status === "sent" ? "This post has already been sent, so it can\u2019t be scheduled again." : "This post is already scheduled. Reschedule or cancel its send instead."
5630
+ post.status === "sent" ? "This post has already been sent, so it can\u2019t be scheduled again." : "This post is already scheduled. Edit its schedule or cancel it instead."
5213
5631
  );
5214
5632
  }
5215
5633
  if (!post.subject.trim()) {
@@ -5224,6 +5642,10 @@ async function freeze(env, config, post, fireAt) {
5224
5642
  const now = Date.now();
5225
5643
  const id = newId();
5226
5644
  const settingsVersion = version ?? 0;
5645
+ const listing = publishingChanges(post, postSettings) ? [
5646
+ setPublishingStmt(env.DB, post, postSettings, settingsVersionIs(settingsVersion)),
5647
+ stampSendsOfPostStmt(env.DB, post.id)
5648
+ ] : [];
5227
5649
  let results;
5228
5650
  try {
5229
5651
  results = await env.DB.batch([
@@ -5240,9 +5662,13 @@ async function freeze(env, config, post, fireAt) {
5240
5662
  "scheduled",
5241
5663
  now,
5242
5664
  settingsVersionIs(settingsVersion)
5243
- )
5665
+ ),
5666
+ ...listing
5244
5667
  ]);
5245
5668
  } catch (err) {
5669
+ if (isSlugCollision(err)) {
5670
+ throw slugRefusal("slug_taken");
5671
+ }
5246
5672
  if (isActiveSendConflict(err)) {
5247
5673
  const winner = await getActiveSendForPost(env.DB, post.id);
5248
5674
  throw winner ? await activeSendExists(env, winner) : refusal(409, "active_send_exists", ACTIVE_SEND_EXISTS);
@@ -5276,8 +5702,8 @@ async function readForAction(env, sendId) {
5276
5702
  return send;
5277
5703
  }
5278
5704
  async function windowClosed(env, send, what) {
5279
- const why = send.status === "scheduled" ? "its fire time has passed and it\u2019s about to send" : `it\u2019s ${send.status}`;
5280
- return refusal(409, "window_closed", `This send can no longer be ${what}: ${why}.`, {
5705
+ const why2 = send.status === "scheduled" ? "its fire time has passed and it\u2019s about to send" : `it\u2019s ${send.status}`;
5706
+ return refusal(409, "window_closed", `This send can no longer be ${what}: ${why2}.`, {
5281
5707
  send: await viewSend(env, send.id)
5282
5708
  });
5283
5709
  }
@@ -5340,6 +5766,9 @@ async function cancel(env, sendId) {
5340
5766
  return { send: unwrap(await getSend(env.DB, sendId), "send"), changed: true };
5341
5767
  }
5342
5768
 
5769
+ // demo/posts/0-test-email/index.md
5770
+ var test_email_default = "---\nsubject: Test email\nslug: test-email\nstatus: sent\nemail_only: true\nsent_to: proof@fieldnotes.example\n---\n\n# Test email\n\nThis is the test send that proved Field Notes works end to end, before its list came in: subscribing, confirming, scheduling, and a real send landing in a real inbox.\n\nIt went out as email only, so it isn't in the archive or on the home page. Its web version still opens from the email, which is how you're reading this.\n";
5771
+
5343
5772
  // demo/posts/1-the-hovering-hunter/index.md
5344
5773
  var the_hovering_hunter_default = "---\nsubject: The hovering hunter\nslug: the-hovering-hunter\nstatus: sent\n---\n\n# The hovering hunter\n\nWelcome to Field Notes, a demo publication of the Kestrel application. Everything here, from the subscribers to the sends to this archive, is sample data, so look around and change whatever you like.\n\nThe app is named after a small falcon called a [kestrel](https://en.wikipedia.org/wiki/Common_kestrel). One way to spot a kestrel is by how it hunts, using a technique called wind-hovering. The bird faces into the wind and hovers in place, watching the ground below for prey. Here's what a kestrel looks like:\n\n![A male Canarian kestrel in flight, wings spread over a green field](kestrel.webp)\n\n*A male Canarian kestrel in strong wind. Taken in Funchal, Madeira by [u/treecreaper](https://www.reddit.com/r/birdsofprey/comments/1rlew5p/canarian_kestrel/).*\n\nTo see what Kestrel can do, open the dashboard and try editing one of the drafts.\n";
5345
5774
 
@@ -5353,7 +5782,7 @@ var publishing_default = "---\nsubject: Publishing\nslug: publishing\nstatus: se
5353
5782
  var working_with_robots_default = "---\nsubject: Working with robots\nslug: working-with-robots\nstatus: sent\n---\n\n# Working with robots\n\nGive Claude a piece of writing and ask it to schedule an email to the list for Friday morning, and it does the thing you asked for.\n\nKestrel is a full-fledged application built for publishers, and it's designed with this in mind:\n\n1. Posts are written in Markdown.\n2. Everything is built on an API.\n3. The app's interface and Claude both use that API, so anything you can do in the editor, you can ask Claude to do.\n\nPublishing and scheduling can be stressful, especially when it comes to last-minute edits. With Kestrel, you can ask Claude to proofread and schedule for you, and trust that it's done right.\n\nThe next issue for this demo publication is already scheduled. You can find it in the dashboard.\n";
5354
5783
 
5355
5784
  // demo/posts/5-a-quick-note/index.md
5356
- var a_quick_note_default = "---\nsubject: A quick note\nslug: a-quick-note\nstatus: scheduled\n---\n\n# A quick note\n\nNot every issue has to be long. Sometimes the best newsletter is a quick note that there's something new to read, watch, or listen to.\n\nThanks for reading Field Notes.\n";
5785
+ var a_quick_note_default = "---\nsubject: A quick note\nslug: a-quick-note\nstatus: scheduled\n---\n\n# A quick note\n\nNot every issue has to be long. Sometimes the best newsletter is a quick note that there's something new to read, watch, or listen to.\n\nA note like this one doesn't always belong in your archive. To send it as email only, uncheck **Include in archive** in its Post settings.\n\nThanks for reading Field Notes.\n";
5357
5786
 
5358
5787
  // demo/posts/6-try-editing-this-draft/index.md
5359
5788
  var try_editing_this_draft_default = "---\nsubject: Try editing this draft\nslug: try-editing-this-draft\nstatus: draft\nedited: 2 days ago\n---\n\n# Try editing this draft\n\n*(Draft. Nobody has seen this but you.)*\n\nThis post is here to experiment on. Change a word, add a heading, drop in an image, and watch the preview follow.\n\nA few things to try:\n\n- **Preview** shows the email exactly as a subscriber will get it.\n- **Send test** mails it to the test inboxes in Settings. In this demo they're `fieldnotes.example` addresses, which never reach a real inbox.\n- **Schedule** puts it in the queue behind the review window.\n\nNothing you do to a draft reaches the list until you schedule it and its window closes.\n";
@@ -5414,7 +5843,12 @@ function postImages(markdown2) {
5414
5843
  function parseDemoPost(bundle, text) {
5415
5844
  const file = `demo/posts/${bundle}/index.md`;
5416
5845
  const { fields, body } = parseFrontMatter(file, text);
5417
- const f = readFields(file, fields, ["subject", "slug", "status"], ["edited"]);
5846
+ const f = readFields(
5847
+ file,
5848
+ fields,
5849
+ ["subject", "slug", "status"],
5850
+ ["edited", "email_only", "sent_to"]
5851
+ );
5418
5852
  const status = f.status;
5419
5853
  if (!STATUSES.includes(status)) {
5420
5854
  throw new Error(`${file}: status must be sent, scheduled, or draft, not "${f.status}"`);
@@ -5427,6 +5861,20 @@ function parseDemoPost(bundle, text) {
5427
5861
  }
5428
5862
  editedDaysAgo = Number(days[1]);
5429
5863
  }
5864
+ let emailOnly;
5865
+ if (f.email_only != null) {
5866
+ if (f.email_only !== "true" && f.email_only !== "false" || status === "draft") {
5867
+ throw new Error(`${file}: "email_only" is true or false, on a sent or scheduled post`);
5868
+ }
5869
+ emailOnly = f.email_only === "true";
5870
+ }
5871
+ let sentTo;
5872
+ if (f.sent_to != null) {
5873
+ sentTo = f.sent_to.split(",").map((a) => a.trim()).filter(Boolean);
5874
+ if (status !== "sent" || !sentTo.length || sentTo.some((a) => !a.endsWith(".example"))) {
5875
+ throw new Error(`${file}: "sent_to" is for sent posts, a list of .example addresses`);
5876
+ }
5877
+ }
5430
5878
  if (!body) {
5431
5879
  throw new Error(`${file}: the post has no body`);
5432
5880
  }
@@ -5438,7 +5886,9 @@ function parseDemoPost(bundle, text) {
5438
5886
  status,
5439
5887
  markdown: body,
5440
5888
  images: postImages(body),
5441
- ...editedDaysAgo != null ? { editedDaysAgo } : {}
5889
+ ...editedDaysAgo != null ? { editedDaysAgo } : {},
5890
+ ...emailOnly != null ? { emailOnly } : {},
5891
+ ...sentTo ? { sentTo } : {}
5442
5892
  };
5443
5893
  }
5444
5894
  function parseDemoPublication(file, text) {
@@ -5453,6 +5903,7 @@ function parseDemoPublication(file, text) {
5453
5903
  }
5454
5904
  function loadDemo() {
5455
5905
  const posts = [
5906
+ ["0-test-email", test_email_default],
5456
5907
  ["1-the-hovering-hunter", the_hovering_hunter_default],
5457
5908
  ["2-an-old-fashioned-list", an_old_fashioned_list_default],
5458
5909
  ["3-publishing", publishing_default],
@@ -5477,9 +5928,11 @@ function demoPostId(position) {
5477
5928
  function buildTimeline(now) {
5478
5929
  const first = now - 26 * DAY;
5479
5930
  const send2At = first + WEEK;
5931
+ const importAt = first - 12 * DAY;
5480
5932
  return {
5481
5933
  now,
5482
- importAt: first - 12 * DAY,
5934
+ testSendAt: importAt - 2 * DAY,
5935
+ importAt,
5483
5936
  bounceAt: send2At + DAY,
5484
5937
  complaintAt: send2At + 2 * DAY,
5485
5938
  scheduledFireAt: onTheMinute(now + 2 * DAY),
@@ -5991,14 +6444,31 @@ function buildDeliveries(sendId, audience, sentAt, events, unsentSlots) {
5991
6444
  return base;
5992
6445
  });
5993
6446
  }
6447
+ function testRecipients(posts, t) {
6448
+ const emails = [...new Set(posts.flatMap((p) => p.sentTo ?? []))];
6449
+ return emails.map((email2) => ({
6450
+ id: newId(),
6451
+ email: email2,
6452
+ status: "unsubscribed",
6453
+ confirm_token: newToken(),
6454
+ confirm_sent_at: t.testSendAt - HOUR,
6455
+ unsub_token: newToken(),
6456
+ created_at: t.testSendAt - HOUR,
6457
+ confirmed_at: t.testSendAt - 50 * 60 * 1e3,
6458
+ unsubscribed_at: t.testSendAt + 2 * HOUR
6459
+ }));
6460
+ }
5994
6461
  function renderInputFor(id, demoPost, at, markdown2) {
5995
6462
  const post = {
5996
6463
  id,
5997
6464
  slug: demoPost.slug,
5998
6465
  // A demo slug that is just its subject's reads as following it, as a new post's would.
5999
6466
  slug_auto: demoPost.slug === slugify(demoPost.subject),
6467
+ // Made here rather than by the database, since the render below carries it.
6468
+ unlisted_token: newUnlistedToken(),
6000
6469
  subject: demoPost.subject,
6001
6470
  status: demoPost.status,
6471
+ email_only: demoPost.emailOnly ?? false,
6002
6472
  current_revision: `${id}-rev`,
6003
6473
  created_at: at,
6004
6474
  updated_at: at
@@ -6018,7 +6488,7 @@ async function seedDatabase(env, config, imageFiles = [], logoFile, options) {
6018
6488
  const now = Date.now();
6019
6489
  const timeline = buildTimeline(now);
6020
6490
  const demo = loadDemo();
6021
- const sentPosts = demo.posts.filter((p) => p.status === "sent").length;
6491
+ const sentPosts = demo.posts.filter((p) => p.status === "sent" && !p.sentTo).length;
6022
6492
  if (sentPosts > timeline.sentAt.length) {
6023
6493
  throw new Error(
6024
6494
  `demo/posts has ${sentPosts} sent posts, but the seed timeline has ${timeline.sentAt.length} sends`
@@ -6045,6 +6515,7 @@ async function seedDatabase(env, config, imageFiles = [], logoFile, options) {
6045
6515
  const size = options?.size;
6046
6516
  const rand = makePrng(options?.seed ?? DEFAULT_SEED);
6047
6517
  const built = size != null ? buildScaledAudience(timeline, size, rand) : buildAudience(timeline);
6518
+ built.subscribers.push(...testRecipients(demo.posts, timeline));
6048
6519
  await insertSubscribers(db, built.subscribers);
6049
6520
  await insertSuppressions(db, built.suppressions);
6050
6521
  const audience = await audienceEmails(db);
@@ -6121,6 +6592,37 @@ async function seedDatabase(env, config, imageFiles = [], logoFile, options) {
6121
6592
  counts2.scheduled++;
6122
6593
  continue;
6123
6594
  }
6595
+ if (demoPost.sentTo) {
6596
+ const sentAt = timeline.testSendAt;
6597
+ const fireAt2 = onTheMinute(sentAt) - 60 * 1e3;
6598
+ const { post: post2, revision: revision2 } = renderInputFor(id, demoPost, sentAt, markdown2);
6599
+ const result2 = await render({ post: post2, revision: revision2, images }, config, branding);
6600
+ await insertPostWithImages(post2, revision2);
6601
+ const sendId2 = newId();
6602
+ await insertSend(db, {
6603
+ id: sendId2,
6604
+ post_id: id,
6605
+ status: "sent",
6606
+ fire_at: fireAt2,
6607
+ rendered_html: result2.html,
6608
+ rendered_text: result2.text,
6609
+ subject: result2.subject,
6610
+ recipient_count: demoPost.sentTo.length,
6611
+ scheduled_at: fireAt2 - 6 * 60 * 1e3,
6612
+ // Send now: the minimum lead, then the minute
6613
+ started_at: fireAt2,
6614
+ completed_at: sentAt
6615
+ });
6616
+ const deliveries3 = buildDeliveries(sendId2, demoPost.sentTo, sentAt, /* @__PURE__ */ new Map(), /* @__PURE__ */ new Set());
6617
+ await insertDeliveries(db, deliveries3);
6618
+ await recomputeSendCounters(db, sendId2);
6619
+ counts2.sent++;
6620
+ counts2.deliveries += deliveries3.length;
6621
+ if (!demoPost.emailOnly) {
6622
+ archiveUrls.push(`${config.archiveOrigin}${config.archiveBasePath}/${demoPost.slug}`);
6623
+ }
6624
+ continue;
6625
+ }
6124
6626
  const completedAt = unwrap(timeline.sentAt[sentIndex], "send timeline slot");
6125
6627
  const sentAudience = unwrap(built.sentAudiences[sentIndex], "frozen send audience");
6126
6628
  const fireAt = onTheMinute(completedAt) - 60 * 1e3;
@@ -6149,7 +6651,9 @@ async function seedDatabase(env, config, imageFiles = [], logoFile, options) {
6149
6651
  await recomputeSendCounters(db, sendId);
6150
6652
  counts2.sent++;
6151
6653
  counts2.deliveries += deliveries2.length;
6152
- archiveUrls.push(`${config.archiveOrigin}${config.archiveBasePath}/${demoPost.slug}`);
6654
+ if (!demoPost.emailOnly) {
6655
+ archiveUrls.push(`${config.archiveOrigin}${config.archiveBasePath}/${demoPost.slug}`);
6656
+ }
6153
6657
  sentIndex++;
6154
6658
  }
6155
6659
  const bucket = (status) => built.subscribers.filter((s) => s.status === status).length;
@@ -6243,16 +6747,16 @@ var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker,
6243
6747
  var access_default = '# Lock the dashboard with Access\n\nCloudflare Access puts a login in front of the editor and the API. Kestrel checks every request against that login as well, so a path left out of Access stays locked. The public pages readers see stay public.\n\nIn this guide, you set up Cloudflare Zero Trust, create one Access application that covers the editor and the API, and allow yourself in. Then you give the app two values from that application, and check that the login works.\n\n## 1. Set up Zero Trust\n\nAccess is part of Cloudflare Zero Trust. Your account needs a Zero Trust organization before it can create an Access application. Skip this section if your account already has one.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), select **Zero Trust**.\n\n1. Choose a team name. It becomes your team domain, `YOUR_TEAM.cloudflareaccess.com`, where your login page lives.\n\n1. Choose a plan. The Free plan covers a newsletter\'s editors. Cloudflare asks for payment details even on the Free plan, and doesn\'t charge them.\n\n## 2. Create the Access application\n\nThe editor lives under `/dashboard`, and the API under `/api`. One application covers both, so they share one login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Applications**, and select **Create new application**.\n\n1. Select **Self-hosted and private**.\n\n1. Select **Add public hostname**, and enter your app\'s hostname with the first path:\n\n - **Subdomain:** `newsletter`\n - **Domain:** `example.com`\n - **Path:** `dashboard`\n\n1. Add a second public hostname with the same subdomain and domain, and the path `api`:\n\n | Path | What it covers |\n | --- | --- |\n | `dashboard` | the editor |\n | `api` | everything the editor and Claude read and change: posts, sends, subscribers, settings, and the in-app docs |\n\n Each path covers everything under it. Leave every other path out. The landing page, the archive, subscribing, unsubscribing, images, and your email provider\'s webhooks must stay public.\n\n1. **(Optional)** Choose how long a sign-in lasts. The application\'s session duration defaults to 24 hours, so everyone signs in again each day. You can set anything from immediately to one month, as Cloudflare\'s [session management](https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/session-management/) describes. A longer session suits a publisher on their own computer, and a shorter one is safer on a shared one.\n\n## 3. Allow yourself in\n\nEvery Access application denies everyone until a policy allows them.\n\n1. Under **Access policies**, create a new policy:\n\n - **Policy name:** `Publishers`\n - **Action:** Allow\n - **Include:** the **Emails** selector, with your email address\n\n1. Keep the login method a new Zero Trust organization starts with: you sign in with your Cloudflare account. To sign in another way, such as a one-time code by email, add it under **Zero Trust \u2192 Integrations \u2192 Identity providers** first.\n\n1. Select **Create**.\n\n## 4. Give the app its two values\n\nThe app verifies each login against your team domain, and checks that it was issued for this application.\n\n1. Find your team domain under **Zero Trust \u2192 Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`.\n\n1. Find the application\'s audience tag. Under **Access controls \u2192 Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.\n\n1. Store the team domain as a secret. The command prompts you for the value. Enter the hostname alone, without `https://`. If you paste it with `https://` or a trailing slash, the app drops them. Anything else that isn\'t a hostname is refused, naming `ACCESS_TEAM_DOMAIN`:\n\n ```bash\n npx wrangler secret put ACCESS_TEAM_DOMAIN --env production\n ```\n\n1. Store the audience tag the same way:\n\n ```bash\n npx wrangler secret put ACCESS_AUD --env production\n ```\n\n A secret takes effect as soon as you store it, with no redeploy. The app needs both values. While only one is stored, it refuses its configuration and names the missing one: the editor says so, `/health` answers `500` with `"variable":"ACCESS_AUD"`, and the public pages say the newsletter is temporarily unavailable. Storing the second clears it.\n\n1. **(Optional, recommended)** Limit the editor to named people. The Access policy decides who can sign in. This list keeps that true if the policy is widened later, for another app or by mistake. Enter a comma-separated list of email addresses at the prompt:\n\n ```bash\n npx wrangler secret put ACCESS_ALLOWED_EMAILS --env production\n ```\n\n Without it, the app lets in anyone your Access policy allows.\n\n## Check it\n\n1. In a private browser window, open `https://newsletter.example.com/dashboard/`. Cloudflare asks you to sign in.\n\n1. Sign in. The editor loads, and its sidebar shows the email address you signed in with.\n\n1. In the same window, open `https://newsletter.example.com/api/whoami`. It shows you as a signed-in person, verified through Access:\n\n ```\n {"principal":{"kind":"human","email":"you@example.com"},"auth":{"mode":"access"}}\n ```\n\n1. The public pages still need no login:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\nIf the editor says the configuration needs a fix, store the secret it names. If Cloudflare lets you in but the editor answers `401`, check the two secrets for a typo, or `ACCESS_ALLOWED_EMAILS` may leave out your address.\n\nNext, [connect Amazon SES](04-ses.md).\n';
6244
6748
 
6245
6749
  // docs/get-started/04-ses.md
6246
- var ses_default = '# Connect Amazon SES\n\nKestrel sends the newsletter\'s posts through Amazon SES, Amazon\'s email service. SES only delivers the mail: Kestrel keeps the list, each subscriber\'s consent, and the record of every send. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.\n\nIn this guide, you verify your sending hostname with SES and add a DMARC record. You forward readers\' replies to the publisher\'s inbox, and ask AWS for production access. You also route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, set the provider, and deploy.\n\n## Before you begin\n\n- An [AWS account](https://portal.aws.amazon.com/billing/signup).\n\nTo move an instance that already sends through another provider or another SES account, read [Switch an instance that already sends](#switch-an-instance-that-already-sends) first.\n\n## 1. Verify your sending hostname\n\nSES sends only from an identity you\'ve verified. Verifying the whole hostname, rather than one address, lets any address on it send. It also sets up DKIM, which lets inboxes trust your mail.\n\n1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose the region you send from in the menu at the top right. Any region that offers SES works, so pick one near you. A few newer regions must be [enabled for your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html) before you can use them. SES keeps identities, quotas, and the sandbox separately in each region, so do every step in this one.\n\n1. Note the region\'s code. The menu shows a name, such as Europe (Ireland), but later steps need its code, such as `eu-west-1`. [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each region\'s code beside its name. The MAIL FROM record, the SNS access policy, and `AWS_REGION` all use this one code.\n\n1. Under **Configuration**, choose **Identities**, then **Create identity**.\n\n1. Select **Domain**, and enter `send.example.com`. Keep the default DKIM settings: **Easy DKIM**, with a 2048-bit key. Choose **Create identity**.\n\n1. On the identity\'s **Authentication** tab, expand **Publish DNS records**. It lists three CNAME records.\n\n1. In the Cloudflare dashboard, add each record to your domain\'s DNS records, exactly as SES lists it. Set each one\'s proxy status to **DNS only**. Cloudflare adds your domain to the name, so a name ends in `._domainkey.send`.\n\n1. Wait until SES shows **DKIM configuration** as **Successful** and **Identity status** as **Verified**.\n\n## 2. Add a DMARC record\n\nDMARC tells inboxes what to do with mail that fails its checks, and sends you reports about it. SES doesn\'t add this record, so you add it yourself. It starts in monitor mode, which reports without blocking anything.\n\n1. In the Cloudflare dashboard, open your domain\'s DNS records, and add a record:\n\n - **Type:** `TXT`\n - **Name:** `_dmarc.send`\n - **Content:** `v=DMARC1; p=none; rua=mailto:dmarc@example.com`\n\n1. Replace `dmarc@example.com` with an address that receives mail. Reports arrive there as attachments.\n\n[Sending-domain DNS](../reference/02-sending-domain-dns.md#dmarc-publish-a-policy-and-collect-reports) explains each record, and when to tighten DMARC.\n\n## 3. Receive replies at your From address\n\nWhen a reader replies to a post, or to their confirmation email, the reply goes to your From address, `newsletter@send.example.com`. Nothing receives mail there yet, so the reply bounces. Kestrel only sends mail, so you give the address an inbox yourself. Here you forward it to the publisher\'s inbox, with Cloudflare Email Routing, which is free on every plan. The publisher is whoever writes and sends the newsletter, which may be you.\n\n`send.example.com` receives no other mail, so turning on Email Routing there changes nothing else. Your SES records sit on other names under it. Inboxes check SES\'s mail for SPF at its return path, not here, so sending is unaffected.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), go to **Compute \u2192 Email Service \u2192 Email Routing**.\n\n1. Add `send.example.com` to Email Routing. Cloudflare adds subdomains from your domain\'s own settings: select `example.com`, then **Settings**, and add `send` under **Subdomains**.\n\n1. Cloudflare adds MX, SPF, and DKIM records for `send.example.com`, and locks them. Wait until they show as added.\n\n1. Under **Destination Addresses**, add the publisher\'s inbox. Open the email Cloudflare sends to it, and select **Verify email address**. If it isn\'t your inbox, ask the publisher to do this step. An address you\'ve already verified with Cloudflare, such as one for [notifications](../guides/03-notifications.md), is listed already.\n\n1. Under **Routing Rules**, select **Create routing rule**. For **Email pattern**, enter `newsletter`, the part of your From address before the `@`, and select `send.example.com`. Set **Action** to **Send to an email**, select the publisher\'s inbox as the **Destination**, and save.\n\nOn a subdomain, Email Routing forwards only the addresses you add a rule for, with no catch-all. That\'s all a From address needs.\n\n- **Automatic replies arrive too.** Out-of-office and other automatic replies go to your From address, so they reach the publisher\'s inbox. Bounces don\'t: they go to SES\'s return path, and the app handles them as before.\n- **Your DMARC reports need an inbox too.** Your DMARC record\'s report address may not receive mail. If so, change the record\'s `rua` to `mailto:dmarc@send.example.com`, and add a second rule that forwards `dmarc` the same way.\n- **Answers come from the publisher\'s inbox.** A forwarded reply comes from the reader\'s own address. An answer to it goes out from the inbox it was forwarded to, not from `newsletter@send.example.com`.\n\nTo answer from the newsletter\'s own address, give it a real mailbox instead of forwarding it. Point `send.example.com`\'s MX records at a mail host you use, such as [Fastmail](https://www.fastmail.help/hc/en-us/articles/360060591133) or [Google Workspace](https://support.google.com/a/answer/140034). Then create `newsletter@send.example.com` there, as the host\'s docs describe. If you send from it, the host\'s SPF and DKIM records go on `send.example.com` beside SES\'s. SES\'s mail is unaffected, since its SPF is checked at its return path. And if your From address is on a domain that already receives mail, replies already reach it, and you can skip this section.\n\nSome replies ask the publisher to do something, and Kestrel can\'t see them, so the publisher acts on them, as [Sending rules](../publishing/02-sending-rules.md#when-a-reader-asks-to-leave-or-to-be-deleted) describes.\n\n## 4. (Optional) Set a custom MAIL FROM domain\n\nSES sends with its own return path by default, so SPF passes for `amazonses.com` rather than for your hostname. DMARC still passes, through DKIM. A custom MAIL FROM domain makes SPF pass for your hostname too, a second check in your favor.\n\n1. In SES, open the `send.example.com` identity. Under **Custom MAIL FROM domain**, choose **Edit**.\n\n1. Select **Use a custom MAIL FROM domain**, and enter `bounce.send.example.com`. For **Behavior on MX failure**, keep **Use default MAIL FROM domain**. Choose **Save changes**.\n\n1. In Cloudflare, add the two records SES lists. Replace `us-east-1` with your region\'s code from section 1:\n\n - **Type:** `MX`, **Name:** `bounce.send`, **Mail server:** `feedback-smtp.us-east-1.amazonses.com`, **Priority:** `10`\n - **Type:** `TXT`, **Name:** `bounce.send`, **Content:** `v=spf1 include:amazonses.com ~all`\n\n1. Wait until SES shows the MAIL FROM domain as **Successful**.\n\n## 5. Request production access\n\nA new SES account starts in the sandbox. It sends only to addresses you\'ve verified in SES, up to 200 emails a day and one a second. Ask AWS to lift those limits now, and test while you wait.\n\n1. Name the publication first, or ask the publisher for its name, since AWS may visit its website to review the request. In the editor, open **Settings \u2192 Publication identity**, and enter the name and tagline. Until then, the public pages show the name in your From address. The subscribe form stays closed until section 8 sets the provider.\n\n1. In SES, open the **Account dashboard**, select **View Get set up page**, and then **Request production access**.\n\n1. Fill in the request:\n\n - **Mail type:** Marketing\n - **Website URL:** `https://newsletter.example.com/`\n\n1. Check the acknowledgement, and select **Submit request**. AWS Support answers by email, and may ask how you collect addresses and handle bounces.\n\n1. While you wait, verify the two addresses you test with in [Verify it works](05-verify.md#before-you-begin), so the sandbox lets you mail them. For each, under **Identities**, choose **Create identity**, select **Email address**, and follow the link in the email AWS sends.\n\nIf AWS asks for more detail, reply in the support case. If it denies the request, reply there, or open a new request from the **Account dashboard**. Either way, say how Kestrel handles the list:\n\n- Signups use double opt-in: each subscriber confirms by email before joining the list.\n- Every post offers one-click unsubscribe.\n- Bounces and complaints reach the app through SNS, and it stops mailing those addresses automatically.\n- How many subscribers the newsletter expects, and how often it sends.\n\nYour account stays in the sandbox until AWS grants access, so don\'t go live before then. In the sandbox, a signup from any address you haven\'t verified fails, and a post reaches only your verified addresses.\n\nWhen AWS grants access, the **Account dashboard** shows your daily quota and your maximum send rate. You set the rate in the app in section 8. The rate decides how fast a send goes out, and [As your list grows](../guides/09-as-your-list-grows.md) says when to ask AWS to raise it.\n\n## 6. Send bounces and complaints to SNS\n\nSES reports what happens to each email through SNS, the AWS notification service. A configuration set says which events to report, and an SNS topic carries them to the app.\n\n1. In SES, under **Configuration**, choose **Configuration sets**, then **Create set**. Name it `kestrel-mail`, and choose **Create set**.\n\n1. On the set\'s **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, **Deliveries**, and **Rendering failures**, and choose **Next**. A rendering failure is an email SES took but couldn\'t build. It shouldn\'t happen, and the app logs it as an error if it does.\n\n1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled. Choose **Next**.\n\n1. For **SNS topic**, choose **Create SNS topic**. Make it a **Standard** topic named `kestrel-events`. Choose **Next**, then **Add destination**.\n\n1. Make the set your hostname\'s default, so SES reports on every email sent from it. Open the `send.example.com` identity, and choose the **Configuration set** tab. Under **Default configuration set**, choose **Edit**, select `kestrel-mail`, and save.\n\n1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), and copy its ARN. It looks like `arn:aws:sns:us-east-1:111122223333:kestrel-events`. After `sns` comes your region\'s code, then your 12-digit account ID. The account menu at the top right of the console also shows the account ID.\n\n1. Let SES publish to the topic. Choose **Edit**, expand **Access policy**, and add this statement to the `Statement` list:\n\n ```json\n {\n "Effect": "Allow",\n "Principal": { "Service": "ses.amazonaws.com" },\n "Action": "sns:Publish",\n "Resource": "REPLACE_WITH_TOPIC_ARN",\n "Condition": {\n "StringEquals": {\n "AWS:SourceAccount": "REPLACE_WITH_ACCOUNT_ID",\n "AWS:SourceArn": "arn:aws:ses:us-east-1:REPLACE_WITH_ACCOUNT_ID:configuration-set/kestrel-mail"\n }\n }\n }\n ```\n\n Replace the topic ARN, and both copies of the account ID, with the values from your ARN. Replace `us-east-1` with your region\'s code, the same one the ARN shows. Choose **Save changes**.\n\nYou subscribe the app to the topic in section 9, once it runs on SES.\n\n## 7. Create the app\'s AWS key\n\nThe app sends through an IAM user whose key can only send email. That limits the harm if the key ever leaks.\n\n1. In the [IAM console](https://console.aws.amazon.com/iam/), go to **Users**, and choose **Create user**. Name it `kestrel-sender`, and leave console access off.\n\n1. When asked for permissions, choose **Attach policies directly**, then **Create policy**. The policy editor opens in a new browser tab.\n\n1. In the new tab, choose the **JSON** editor, and replace its contents with this policy:\n\n ```json\n {\n "Version": "2012-10-17",\n "Statement": [\n {\n "Effect": "Allow",\n "Action": ["ses:SendEmail", "ses:SendBulkEmail"],\n "Resource": "*"\n }\n ]\n }\n ```\n\n Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. You can close this tab.\n\n1. Go back to the first tab, where you\'re creating the user. Refresh the policy list, search for `kestrel-send-email`, and select it.\n\n1. Choose **Next**, then **Create user**.\n\n1. Open the user, and on the **Security credentials** tab, choose **Create access key**. For the use case, choose **Application running outside AWS**.\n\n1. Copy the access key ID and the secret access key. AWS shows the secret only once.\n\n## 8. Set the provider and deploy\n\nUntil now the app has run with no email provider connected, refusing to send. Setting `PROVIDER` connects SES. Store the secrets before you set it. With `PROVIDER` set to `ses` and a required secret missing, the app refuses to run, and sends nothing, until you store it. `/health` and the editor\'s **A setting needs fixing** page name the missing secret, and reader pages say the newsletter is temporarily unavailable. `SES_CONFIGURATION_SET` is the one optional secret.\n\n1. Store the access key ID, and paste it at the prompt:\n\n ```bash\n npx wrangler secret put AWS_ACCESS_KEY_ID --env production\n ```\n\n1. Store the secret access key:\n\n ```bash\n npx wrangler secret put AWS_SECRET_ACCESS_KEY --env production\n ```\n\n1. Store the topic ARN from section 6. The app accepts events only from this topic:\n\n ```bash\n npx wrangler secret put SNS_TOPIC_ARN --env production\n ```\n\n1. Store the configuration set\'s name, `kestrel-mail`. It\'s optional but recommended: the app names it on every email, so SES reports on each one even without the default. Type it exactly as the set is named in this region: if SES can\'t find the set, it refuses every email, and a list send is held until the name is fixed or the set created:\n\n ```bash\n npx wrangler secret put SES_CONFIGURATION_SET --env production\n ```\n\n1. In `wrangler.jsonc`, edit the `production` block\'s `vars`. Change `PROVIDER` to `ses` (it\'s `""` on a new instance; add it if your block has none), and add `SES_MAX_SEND_RATE`. Change `AWS_REGION` to your region\'s code from section 1. Your other values stay as they are. The block ends up like this:\n\n ```jsonc\n "vars": {\n "PROVIDER": "ses", // \u2190 set to ses\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1", // \u2190 change: your region\'s code\n "SES_MAX_SEND_RATE": "1" // \u2190 new: your maximum send rate; 1 in the sandbox\n }\n ```\n\n On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan\'s limit, so each minute sends more. Leave it out on Workers Free. [As your list grows](../guides/09-as-your-list-grows.md) says when the plan is worth it.\n\n When AWS grants production access, set `SES_MAX_SEND_RATE` to the maximum send rate on the **Account dashboard**, and deploy again. The app keeps to that rate, so SES doesn\'t throttle your send.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Send through Amazon SES"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## 9. Subscribe the app to the topic\n\nSNS delivers events only to a subscriber that has confirmed. The app confirms on its own, once it checks that the request came from your topic.\n\n1. In the SNS console, open your topic, and choose **Create subscription**.\n\n1. Set **Protocol** to **HTTPS**, and enter the endpoint:\n\n ```\n https://newsletter.example.com/webhooks/ses\n ```\n\n1. Leave raw message delivery off, and choose **Create subscription**.\n\n1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.\n\nIf it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:\n\n- **Access covers `/webhooks/ses`.** If your Access application covers more than `/dashboard` and `/api`, SNS gets the login page instead of the app. Remove that path from your Access application, so only `dashboard` and `api` remain.\n- **`SNS_TOPIC_ARN` matches the topic\'s ARN.**\n- **The deploy in section 8 finished.**\n\nOnce you\'ve fixed the cause, select the subscription, and choose **Request confirmation**. SNS sends the request again, and the app confirms it. SNS doesn\'t let you delete a subscription while it\'s pending, and removes it on its own after 48 hours.\n\n## Switch an instance that already sends\n\nChange `PROVIDER`, or move to another account with the same provider, only while no send is sending. The dashboard lists any send in progress.\n\nWhen a request to a provider gets no answer, Kestrel can\'t know whether its recipients were mailed. Moving to SES, from another provider or another SES account, mails none of them twice. Kestrel never repeats an unanswered request to SES, so those recipients wait for you to decide whether they were mailed. Use **Resolve** on the send\'s page.\n\nKestrel can\'t tell whether those emails went out. SES\'s console shows sent messages only if you\'ve turned on Virtual Deliverability Manager, which this guide doesn\'t set up. So **Assume not sent** is the safe default. Those readers may miss this post, but they get your next one. No one is mailed twice. Choose **Assume sent** only when you know otherwise, such as a reader who says they got it.\n\n## Check it\n\n1. The app answers on your hostname:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The webhook is public, and refuses a request that isn\'t from SNS:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" -X POST https://newsletter.example.com/webhooks/ses\n ```\n\n ```\n 400\n ```\n\n Any other answer, such as a redirect to your Cloudflare login, means Access covers the webhook. Remove that path from your Access application.\n\n1. The SNS subscription shows **Confirmed**.\n\n1. Your From address has somewhere to go. In Cloudflare, your domain\'s DNS records show the ones Email Routing added for `send`, beside section 1\'s DKIM records:\n\n - **Type:** `MX`, **Name:** `send`, three of them, for `route1.mx.cloudflare.net`, `route2.mx.cloudflare.net`, and `route3.mx.cloudflare.net`\n - **Type:** `TXT`, **Name:** `send`, **Content:** `v=spf1 include:_spf.mx.cloudflare.net ~all`\n - **Type:** `TXT`, **Name:** `cf2024-1._domainkey.send`, Cloudflare\'s DKIM key\n\n The world sees the MX records too:\n\n ```bash\n dig +short MX send.example.com\n ```\n\n It lists the three `route` servers. Email Routing\'s **Routing Rules** shows your rule for `newsletter@send.example.com`, and **Destination Addresses** shows the publisher\'s inbox as verified. A reply to your test email in the next step proves the forwarding works.\n\n1. In the editor, **Settings \u2192 Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before.\n\n1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren\'t open yet.\n\nThe test sends in the next step prove the rest. In the sandbox, send them only to the addresses you verified in section 5, and to the SES test addresses that page lists.\n\nSES charges for each email it sends, test emails included. [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists the rates.\n\nNext, [verify it works](05-verify.md).\n';
6750
+ var ses_default = '# Connect Amazon SES\n\nKestrel sends the newsletter\'s posts through Amazon SES, Amazon\'s email service. SES only delivers the mail: Kestrel keeps the list, each subscriber\'s consent, and the record of every send. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.\n\nIn this guide, you verify your sending hostname with SES and add a DMARC record. You forward readers\' replies to the publisher\'s inbox, and ask AWS for production access. You also route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, set the provider, and deploy.\n\n## Before you begin\n\n- An [AWS account](https://portal.aws.amazon.com/billing/signup).\n\nTo move an instance that already sends through another provider or another SES account, read [Switch an instance that already sends](#switch-an-instance-that-already-sends) first.\n\n## 1. Verify your sending hostname\n\nSES sends only from an identity you\'ve verified. Verifying the whole hostname, rather than one address, lets any address on it send. It also sets up DKIM, which lets inboxes trust your mail.\n\n1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose the region you send from in the menu at the top right. Any region that offers SES works, so pick one near you. A few newer regions must be [enabled for your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html) before you can use them. SES keeps identities, quotas, and the sandbox separately in each region, so do every step in this one.\n\n1. Note the region\'s code. The menu shows a name, such as Europe (Ireland), but later steps need its code, such as `eu-west-1`. [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each region\'s code beside its name. The MAIL FROM record, the SNS access policy, and `AWS_REGION` all use this one code.\n\n1. Under **Configuration**, choose **Identities**, then **Create identity**.\n\n1. Select **Domain**, and enter `send.example.com`. Keep the default DKIM settings: **Easy DKIM**, with a 2048-bit key. Choose **Create identity**.\n\n1. On the identity\'s **Authentication** tab, expand **Publish DNS records**. It lists three CNAME records.\n\n1. In the Cloudflare dashboard, add each record to your domain\'s DNS records, exactly as SES lists it. Set each one\'s proxy status to **DNS only**. Cloudflare adds your domain to the name, so a name ends in `._domainkey.send`.\n\n1. Wait until SES shows **DKIM configuration** as **Successful** and **Identity status** as **Verified**.\n\n## 2. Add a DMARC record\n\nDMARC tells inboxes what to do with mail that fails its checks, and sends you reports about it. SES doesn\'t add this record, so you add it yourself. It starts in monitor mode, which reports without blocking anything.\n\n1. In the Cloudflare dashboard, open your domain\'s DNS records, and add a record:\n\n - **Type:** `TXT`\n - **Name:** `_dmarc.send`\n - **Content:** `v=DMARC1; p=none; rua=mailto:dmarc@example.com`\n\n1. Replace `dmarc@example.com` with an address that receives mail. Reports arrive there as attachments.\n\n[Sending-domain DNS](../reference/02-sending-domain-dns.md#dmarc-publish-a-policy-and-collect-reports) explains each record, and when to tighten DMARC.\n\n## 3. Receive replies at your From address\n\nWhen a reader replies to a post, or to their confirmation email, the reply goes to your From address, `newsletter@send.example.com`. Nothing receives mail there yet, so the reply bounces. Kestrel only sends mail, so you give the address an inbox yourself. Here you forward it to the publisher\'s inbox, with Cloudflare Email Routing, which is free on every plan. The publisher is whoever writes and sends the newsletter, which may be you.\n\n`send.example.com` receives no other mail, so turning on Email Routing there changes nothing else. Your SES records sit on other names under it. Inboxes check SES\'s mail for SPF at its return path, not here, so sending is unaffected.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), go to **Compute \u2192 Email Service \u2192 Email Routing**.\n\n1. Add `send.example.com` to Email Routing. Cloudflare adds subdomains from your domain\'s own settings: select `example.com`, then **Settings**, and add `send` under **Subdomains**.\n\n1. Cloudflare adds MX, SPF, and DKIM records for `send.example.com`, and locks them. Wait until they show as added.\n\n1. Under **Destination Addresses**, add the publisher\'s inbox. Open the email Cloudflare sends to it, and select **Verify email address**. If it isn\'t your inbox, ask the publisher to do this step. An address you\'ve already verified with Cloudflare, such as one for [notifications](../guides/03-notifications.md), is listed already.\n\n1. Under **Routing Rules**, select **Create routing rule**. For **Email pattern**, enter `newsletter`, the part of your From address before the `@`, and select `send.example.com`. Set **Action** to **Send to an email**, select the publisher\'s inbox as the **Destination**, and save.\n\nOn a subdomain, Email Routing forwards only the addresses you add a rule for, with no catch-all. That\'s all a From address needs.\n\n- **Automatic replies arrive too.** Out-of-office and other automatic replies go to your From address, so they reach the publisher\'s inbox. Bounces don\'t: they go to SES\'s return path, and the app handles them as before.\n- **Your DMARC reports need an inbox too.** Your DMARC record\'s report address may not receive mail. If so, change the record\'s `rua` to `mailto:dmarc@send.example.com`, and add a second rule that forwards `dmarc` the same way.\n- **Answers come from the publisher\'s inbox.** A forwarded reply comes from the reader\'s own address. An answer to it goes out from the inbox it was forwarded to, not from `newsletter@send.example.com`.\n\nTo answer from the newsletter\'s own address, give it a real mailbox instead of forwarding it. Point `send.example.com`\'s MX records at a mail host you use, such as [Fastmail](https://www.fastmail.help/hc/en-us/articles/360060591133) or [Google Workspace](https://support.google.com/a/answer/140034). Then create `newsletter@send.example.com` there, as the host\'s docs describe. If you send from it, the host\'s SPF and DKIM records go on `send.example.com` beside SES\'s. SES\'s mail is unaffected, since its SPF is checked at its return path. And if your From address is on a domain that already receives mail, replies already reach it, and you can skip this section.\n\nSome replies ask the publisher to do something, and Kestrel can\'t see them, so the publisher acts on them, as [Sending rules](../publishing/02-sending-rules.md#when-a-reader-asks-to-leave-or-to-be-deleted) describes.\n\n## 4. (Optional) Set a custom MAIL FROM domain\n\nSES sends with its own return path by default, so SPF passes for `amazonses.com` rather than for your hostname. DMARC still passes, through DKIM. A custom MAIL FROM domain makes SPF pass for your hostname too, a second check in your favor.\n\n1. In SES, open the `send.example.com` identity. Under **Custom MAIL FROM domain**, choose **Edit**.\n\n1. Select **Use a custom MAIL FROM domain**, and enter `bounce.send.example.com`. For **Behavior on MX failure**, keep **Use default MAIL FROM domain**. Choose **Save changes**.\n\n1. In Cloudflare, add the two records SES lists. Replace `us-east-1` with your region\'s code from section 1:\n\n - **Type:** `MX`, **Name:** `bounce.send`, **Mail server:** `feedback-smtp.us-east-1.amazonses.com`, **Priority:** `10`\n - **Type:** `TXT`, **Name:** `bounce.send`, **Content:** `v=spf1 include:amazonses.com ~all`\n\n1. Wait until SES shows the MAIL FROM domain as **Successful**.\n\n## 5. Request production access\n\nA new SES account starts in the sandbox. It sends only to addresses you\'ve verified in SES, up to 200 emails a day and one a second. Ask AWS to lift those limits now, and test while you wait.\n\n1. Name the publication first, or ask the publisher for its name, since AWS may visit its website to review the request. In the editor, open **Settings \u2192 Publication identity**, and enter the name and tagline. Until then, the public pages show the name in your From address. The subscribe form stays closed until section 8 sets the provider.\n\n1. In SES, open the **Account dashboard**, select **View Get set up page**, and then **Request production access**.\n\n1. Fill in the request:\n\n - **Mail type:** Marketing\n - **Website URL:** `https://newsletter.example.com/`\n\n1. Check the acknowledgement, and select **Submit request**. AWS Support answers by email, and may ask how you collect addresses and handle bounces.\n\n1. While you wait, verify the two addresses you test with in [Verify it works](05-verify.md#before-you-begin), so the sandbox lets you mail them. For each, under **Identities**, choose **Create identity**, select **Email address**, and follow the link in the email AWS sends.\n\nIf AWS asks for more detail, reply in the support case. If it denies the request, reply there, or open a new request from the **Account dashboard**. Either way, say how Kestrel handles the list:\n\n- Signups use double opt-in: each subscriber confirms by email before joining the list.\n- Every post offers one-click unsubscribe.\n- Bounces and complaints reach the app through SNS, and it stops mailing those addresses automatically.\n- How many subscribers the newsletter expects, and how often it sends.\n\nYour account stays in the sandbox until AWS grants access, so don\'t go live before then. In the sandbox, SES refuses mail to any address you haven\'t verified:\n\n- A signup from an unverified address gets no confirmation email. The app emails the publisher about it, at most once a day.\n- A post goes to your verified addresses and then waits, with the line "The provider is refusing the account" and SES\'s words. No reader is marked unsent. Once AWS grants access, the post goes to the rest at its next retry, mailing no one twice.\n- A daily quota refusal in the sandbox (200 emails a day) means the same thing: request production access rather than a higher quota.\n\nWhen AWS grants access, the **Account dashboard** shows your daily quota and your maximum send rate. You set the rate in the app in section 8. The rate decides how fast a send goes out, and [As your list grows](../guides/09-as-your-list-grows.md) says when to ask AWS to raise it.\n\n## 6. Send bounces and complaints to SNS\n\nSES reports what happens to each email through SNS, the AWS notification service. A configuration set says which events to report, and an SNS topic carries them to the app.\n\n1. In SES, under **Configuration**, choose **Configuration sets**, then **Create set**. Name it `kestrel-mail`, and choose **Create set**.\n\n1. On the set\'s **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, **Deliveries**, and **Rendering failures**, and choose **Next**. A rendering failure is an email SES took but couldn\'t build. It shouldn\'t happen, and the app logs it as an error if it does.\n\n1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled. Choose **Next**.\n\n1. For **SNS topic**, choose **Create SNS topic**. Make it a **Standard** topic named `kestrel-events`. Choose **Next**, then **Add destination**.\n\n1. Make the set your hostname\'s default, so SES reports on every email sent from it. Open the `send.example.com` identity, and choose the **Configuration set** tab. Under **Default configuration set**, choose **Edit**, select `kestrel-mail`, and save.\n\n1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), and copy its ARN. It looks like `arn:aws:sns:us-east-1:111122223333:kestrel-events`. After `sns` comes your region\'s code, then your 12-digit account ID. The account menu at the top right of the console also shows the account ID.\n\n1. Let SES publish to the topic. Choose **Edit**, expand **Access policy**, and add this statement to the `Statement` list:\n\n ```json\n {\n "Effect": "Allow",\n "Principal": { "Service": "ses.amazonaws.com" },\n "Action": "sns:Publish",\n "Resource": "REPLACE_WITH_TOPIC_ARN",\n "Condition": {\n "StringEquals": {\n "AWS:SourceAccount": "REPLACE_WITH_ACCOUNT_ID",\n "AWS:SourceArn": "arn:aws:ses:us-east-1:REPLACE_WITH_ACCOUNT_ID:configuration-set/kestrel-mail"\n }\n }\n }\n ```\n\n Replace the topic ARN, and both copies of the account ID, with the values from your ARN. Replace `us-east-1` with your region\'s code, the same one the ARN shows. Choose **Save changes**.\n\nYou subscribe the app to the topic in section 9, once it runs on SES.\n\n## 7. Create the app\'s AWS key\n\nThe app sends through an IAM user whose key can only send email. That limits the harm if the key ever leaks.\n\n1. In the [IAM console](https://console.aws.amazon.com/iam/), go to **Users**, and choose **Create user**. Name it `kestrel-sender`, and leave console access off.\n\n1. When asked for permissions, choose **Attach policies directly**, then **Create policy**. The policy editor opens in a new browser tab.\n\n1. In the new tab, choose the **JSON** editor, and replace its contents with this policy:\n\n ```json\n {\n "Version": "2012-10-17",\n "Statement": [\n {\n "Effect": "Allow",\n "Action": ["ses:SendEmail", "ses:SendBulkEmail"],\n "Resource": "*"\n }\n ]\n }\n ```\n\n Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. You can close this tab.\n\n1. Go back to the first tab, where you\'re creating the user. Refresh the policy list, search for `kestrel-send-email`, and select it.\n\n1. Choose **Next**, then **Create user**.\n\n1. Open the user, and on the **Security credentials** tab, choose **Create access key**. For the use case, choose **Application running outside AWS**.\n\n1. Copy the access key ID and the secret access key. AWS shows the secret only once.\n\n## 8. Set the provider and deploy\n\nUntil now the app has run with no email provider connected, refusing to send. Setting `PROVIDER` connects SES. Store the secrets before you set it. With `PROVIDER` set to `ses` and a required secret missing, the app refuses to run, and sends nothing, until you store it. `/health` and the editor\'s **A setting needs fixing** page name the missing secret, and reader pages say the newsletter is temporarily unavailable. `SES_CONFIGURATION_SET` is the one optional secret.\n\n1. Store the access key ID, and paste it at the prompt:\n\n ```bash\n npx wrangler secret put AWS_ACCESS_KEY_ID --env production\n ```\n\n1. Store the secret access key:\n\n ```bash\n npx wrangler secret put AWS_SECRET_ACCESS_KEY --env production\n ```\n\n1. Store the topic ARN from section 6. The app accepts events only from this topic:\n\n ```bash\n npx wrangler secret put SNS_TOPIC_ARN --env production\n ```\n\n1. Store the configuration set\'s name, `kestrel-mail`. It\'s optional but recommended: the app names it on every email, so SES reports on each one even without the default. Type it exactly as the set is named in this region: if SES can\'t find the set, it refuses every email, and a list send is held until the name is fixed or the set created:\n\n ```bash\n npx wrangler secret put SES_CONFIGURATION_SET --env production\n ```\n\n1. In `wrangler.jsonc`, edit the `production` block\'s `vars`. Change `PROVIDER` to `ses` (it\'s `""` on a new instance; add it if your block has none), and add `SES_MAX_SEND_RATE`. Change `AWS_REGION` to your region\'s code from section 1. Your other values stay as they are. The block ends up like this:\n\n ```jsonc\n "vars": {\n "PROVIDER": "ses", // \u2190 set to ses\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1", // \u2190 change: your region\'s code\n "SES_MAX_SEND_RATE": "1" // \u2190 new: your maximum send rate; 1 in the sandbox\n }\n ```\n\n On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan\'s limit, so each minute sends more. Leave it out on Workers Free. [As your list grows](../guides/09-as-your-list-grows.md) says when the plan is worth it.\n\n When AWS grants production access, set `SES_MAX_SEND_RATE` to the maximum send rate on the **Account dashboard**, and deploy again. The app keeps to that rate, so SES doesn\'t throttle your send.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Send through Amazon SES"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## 9. Subscribe the app to the topic\n\nSNS delivers events only to a subscriber that has confirmed. The app confirms on its own, once it checks that the request came from your topic.\n\n1. In the SNS console, open your topic, and choose **Create subscription**.\n\n1. Set **Protocol** to **HTTPS**, and enter the endpoint:\n\n ```\n https://newsletter.example.com/webhooks/ses\n ```\n\n1. Leave raw message delivery off, and choose **Create subscription**.\n\n1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.\n\n1. Give SNS longer to retry. By default it retries a failed delivery 3 times over about a minute, then discards the event, so a bounce that arrives while the app is briefly down or mid-deploy is lost, and the address gets your next post. Open the topic, choose **Edit**, and expand **Delivery policy (HTTP/S)**. Clear **Use the default delivery policy**, set these, and choose **Save changes**:\n\n | Field | Value |\n | --- | --- |\n | Number of retries | `13` |\n | Retries without delay | `0` |\n | Minimum delay | `20` |\n | Minimum delay retries | `2` |\n | Maximum delay | `300` |\n | Maximum delay retries | `6` |\n | Retry-backoff function | Exponential |\n\n SNS then keeps trying for most of an hour, the longest it allows. It never retries an event the app refused. The app refuses only an event it can\'t verify came from your topic, and logs an error when one claims to be from SNS.\n\n1. **(Optional)** Keep what SNS still can\'t deliver. On the subscription, choose **Edit**, and set a **Redrive policy (dead-letter queue)** to an SQS queue of your own. SNS then moves an event there instead of discarding it, where you can read it later. [Amazon SNS dead-letter queues](https://docs.aws.amazon.com/sns/latest/dg/sns-dead-letter-queues.html) covers the queue and its permissions.\n\nIf it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:\n\n- **Access covers `/webhooks/ses`.** If your Access application covers more than `/dashboard` and `/api`, SNS gets the login page instead of the app. Remove that path from your Access application, so only `dashboard` and `api` remain.\n- **`SNS_TOPIC_ARN` matches the topic\'s ARN.** If it doesn\'t, the app\'s logs show a `webhook.refused` error naming both.\n- **The deploy in section 8 finished.**\n\nOnce you\'ve fixed the cause, select the subscription, and choose **Request confirmation**. SNS sends the request again, and the app confirms it. SNS doesn\'t let you delete a subscription while it\'s pending, and removes it on its own after 48 hours.\n\n## Switch an instance that already sends\n\nChange `PROVIDER`, or move to another account with the same provider, only while no send is sending. The dashboard lists any send in progress.\n\nWhen a request to a provider gets no answer, Kestrel can\'t know whether its recipients were mailed. Moving to SES, from another provider or another SES account, mails none of them twice. Kestrel never repeats an unanswered request to SES, so those recipients wait for you to decide whether they were mailed. Use **Resolve** on the send\'s page.\n\nKestrel can\'t tell whether those emails went out. SES\'s console shows sent messages only if you\'ve turned on Virtual Deliverability Manager, which this guide doesn\'t set up. So **Assume not sent** is the safe default. Those readers may miss this post, but they get your next one. No one is mailed twice. Choose **Assume sent** only when you know otherwise, such as a reader who says they got it.\n\n## Check it\n\n1. The app answers on your hostname:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The webhook is public, and refuses a request that isn\'t from SNS:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" -X POST https://newsletter.example.com/webhooks/ses\n ```\n\n ```\n 400\n ```\n\n Any other answer, such as a redirect to your Cloudflare login, means Access covers the webhook. Remove that path from your Access application.\n\n1. The SNS subscription shows **Confirmed**.\n\n1. Your From address has somewhere to go. In Cloudflare, your domain\'s DNS records show the ones Email Routing added for `send`, beside section 1\'s DKIM records:\n\n - **Type:** `MX`, **Name:** `send`, three of them, for `route1.mx.cloudflare.net`, `route2.mx.cloudflare.net`, and `route3.mx.cloudflare.net`\n - **Type:** `TXT`, **Name:** `send`, **Content:** `v=spf1 include:_spf.mx.cloudflare.net ~all`\n - **Type:** `TXT`, **Name:** `cf2024-1._domainkey.send`, Cloudflare\'s DKIM key\n\n The world sees the MX records too:\n\n ```bash\n dig +short MX send.example.com\n ```\n\n It lists the three `route` servers. Email Routing\'s **Routing Rules** shows your rule for `newsletter@send.example.com`, and **Destination Addresses** shows the publisher\'s inbox as verified. A reply to your test email in the next step proves the forwarding works.\n\n1. In the editor, **Settings \u2192 Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before.\n\n1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren\'t open yet.\n\nThe test sends in the next step prove the rest. In the sandbox, send them only to the addresses you verified in section 5, and to the SES test addresses that page lists.\n\nSES charges for each email it sends, test emails included. [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists the rates.\n\nNext, [verify it works](05-verify.md).\n';
6247
6751
 
6248
6752
  // docs/get-started/05-verify.md
6249
- var verify_default = "# Verify it works\n\nSome things only show up with real email: whether inboxes trust your mail, how it looks in a real mail client, and whether bounces and unsubscribes come back to the app. Check them now, while the list is empty and every email goes only to you.\n\nIn this guide, you send yourself a test email, reply to it, and check that it passes authentication. Then you set up notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs, and check that SES can send to anyone.\n\n## Before you begin\n\n- Two email addresses you own, such as a personal address and a work address. Some steps use both. While your SES account is in the sandbox, verify both in SES first, as [Connect Amazon SES](04-ses.md#5-request-production-access) describes.\n- A Gmail address, or another mail client that shows a message's original source and an **Unsubscribe** button.\n\n## 1. Send yourself a test email\n\nA test email goes through the same code as a real send. If the test looks right, the real send looks the same.\n\n1. In the editor at `https://newsletter.example.com/dashboard/`, create a post with a subject, a short body, and an image.\n\n1. Select **Send test email**, enter your address, and send it.\n\n1. Open the email. It should arrive in your inbox, not in spam. The image loads and the subject reads as you wrote it. Its **View in browser** link opens \"Nothing here yet\" until the post goes out, and its unsubscribe link opens the page a subscriber sees, marked as a test and showing a sample address. Its button walks through to the confirmation, but a test email unsubscribes no one. Section 5 unsubscribes for real.\n\n1. Reply to it, as a reader would. Reply from an address other than the inbox your From address forwards to, such as your second address. Gmail doesn't show you a message you sent when it comes back to you. Send that address a test email too if you need one. The reply arrives in the inbox you chose in [Connect Amazon SES](04-ses.md#3-receive-replies-at-your-from-address). The app can't see your forwarding, so this is the only proof that readers can reach the publisher. If the reply bounces, check the routing rule and the destination address there.\n\n## 2. Check that it passes authentication\n\nInboxes trust mail that passes DKIM and DMARC for the domain it's from. Passing both is the best single sign that your mail reaches the inbox.\n\n1. Open the test email's original source. In Gmail, open the message's menu and select **Show original**.\n\n1. Find the `Authentication-Results` header. It shows:\n\n - `dkim=pass` for `send.example.com`\n - `dmarc=pass` for `send.example.com`\n - `spf=pass`, for a hostname under `send.example.com`\n\n With no custom MAIL FROM domain, SPF passes for `amazonses.com` instead. That's expected, and DMARC still passes.\n\nIf a check fails, compare your DNS records with the ones in [Connect Amazon SES](04-ses.md#1-verify-your-sending-hostname). [Sending-domain DNS](../reference/02-sending-domain-dns.md#verify-alignment) explains each result.\n\n## 3. Set up notifications\n\nKestrel emails the notifications address when a send finishes, and right away when one runs into a problem. Notifications go through your email provider. To send them through Cloudflare instead, see [Notifications through Cloudflare's email](../guides/03-notifications.md).\n\n1. In the editor, open **Settings \u2192 Notifications**.\n\n1. Enter your address, and select **Save**.\n\n1. Select **Send a test notification**. It arrives at that address.\n\nUse your own address to test. If you're not the publisher, they set theirs in [Check your setup](../publishing/10-check-your-setup.md).\n\n## 4. Send a real post to yourself\n\nThis is a real send, to a list of one. It proves the whole path: subscribing, confirming, scheduling, and sending.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe with your second address.\n\n1. Open the confirmation email, follow its link, and select **Confirm**. In the editor, **Subscribers** shows the address as **Confirmed**.\n\n1. Open the post, select **Schedule**, and then **Send now**. Every send waits at least five minutes before it goes out, so you can cancel a mistake.\n\n1. Cancel the send, to see that canceling stops it. Then select **Send now** again, and let it go.\n\n1. When the send finishes, the post arrives at your second address. A notification arrives at your first, with the subject `Sent:` and the post's title.\n\n## 5. Unsubscribe\n\nEvery email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. Both work in one step, with no login.\n\n1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender.\n\n1. Select it. In the editor, **Subscribers** shows the address as **Unsubscribed**, and later sends leave it out.\n\n## 6. Check that bounces and complaints come back\n\nWhen an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:\n\n - `bounce@simulator.amazonses.com`, which bounces\n - `complaint@simulator.amazonses.com`, which marks your mail as spam\n\n Each one gets a confirmation email, which bounces or is marked as spam.\n\n1. In the editor, **Subscribers** lists both addresses as **Pending**, each with a **Suppressed** flag. The app never mails a suppressed address again.\n\nIf an address has no flag, SES's events aren't reaching the app. Check the configuration set and the SNS subscription in [Connect Amazon SES](04-ses.md#6-send-bounces-and-complaints-to-sns).\n\n## 7. Read the logs\n\nThe app writes a log line for each thing it does, and Cloudflare keeps them for a few days. Nothing else tells you if the once-a-minute schedule that sends posts stops, so it's worth knowing where to look.\n\n1. In the Cloudflare dashboard, go to **Workers & Pages**, select `kestrel-production`, and select **Observability**.\n\n1. Filter on `event` equal to `sweep.tick`. A line arrives every minute.\n\n1. Filter on `event` equal to `send.completed`. Your send from section 4 is there.\n\n1. Filter on `level` equal to `error`. A healthy instance shows nothing.\n\n## 8. Check that SES can send to anyone\n\n[Connect Amazon SES](04-ses.md#5-request-production-access) asked AWS for production access. Until AWS grants it, the sandbox mails only your verified addresses, so check it before you go live.\n\n1. In SES, open the **Account dashboard** in the region the app sends from. It no longer says your account is in the sandbox, and it shows your daily quota and maximum send rate.\n\n1. Check that `SES_MAX_SEND_RATE` in `wrangler.jsonc` matches that rate, and that you deployed after setting it, as [Connect Amazon SES](04-ses.md#8-set-the-provider-and-deploy) describes.\n\nIf the dashboard still says sandbox, AWS hasn't answered yet. Every other section works in the sandbox, so come back to this one when it does.\n\nWhen every section gives the result it describes, your instance is ready for subscribers.\n\nNext, [go live](06-go-live.md).\n";
6753
+ var verify_default = "# Verify it works\n\nSome things only show up with real email: whether inboxes trust your mail, how it looks in a real mail client, and whether bounces and unsubscribes come back to the app. Check them now, while the list is empty and every email goes only to you.\n\nIn this guide, you send yourself a test email, reply to it, and check that it passes authentication. Then you set up notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs, check that SES can send to anyone, and clean up the test subscribers.\n\n## Before you begin\n\n- Two email addresses you own, such as a personal address and a work address. Some steps use both. While your SES account is in the sandbox, verify both in SES first, as [Connect Amazon SES](04-ses.md#5-request-production-access) describes.\n- A Gmail address, or another mail client that shows a message's original source and an **Unsubscribe** button.\n\n## 1. Send yourself a test email\n\nA test email goes through the same code as a real send. If the test looks right, the real send looks the same.\n\n1. In the editor at `https://newsletter.example.com/dashboard/`, create a post with a subject, a short body, and an image.\n\n1. Select **Send test email**, enter your address, and send it.\n\n1. Open the email. It should arrive in your inbox, not in spam. The image loads and the subject reads as you wrote it. Its **View in browser** link opens \"Nothing here yet\" until the post goes out, and its unsubscribe link opens the page a subscriber sees, marked as a test and showing a sample address. Its button walks through to the confirmation, but a test email unsubscribes no one. Section 5 unsubscribes for real.\n\n1. Reply to it, as a reader would. Reply from an address other than the inbox your From address forwards to, such as your second address. Gmail doesn't show you a message you sent when it comes back to you. Send that address a test email too if you need one. The reply arrives in the inbox you chose in [Connect Amazon SES](04-ses.md#3-receive-replies-at-your-from-address). The app can't see your forwarding, so this is the only proof that readers can reach the publisher. If the reply bounces, check the routing rule and the destination address there.\n\n## 2. Check that it passes authentication\n\nInboxes trust mail that passes DKIM and DMARC for the domain it's from. Passing both is the best single sign that your mail reaches the inbox.\n\n1. Open the test email's original source. In Gmail, open the message's menu and select **Show original**.\n\n1. Find the `Authentication-Results` header. It shows:\n\n - `dkim=pass` for `send.example.com`\n - `dmarc=pass` for `send.example.com`\n - `spf=pass`, for a hostname under `send.example.com`\n\n With no custom MAIL FROM domain, SPF passes for `amazonses.com` instead. That's expected, and DMARC still passes.\n\nIf a check fails, compare your DNS records with the ones in [Connect Amazon SES](04-ses.md#1-verify-your-sending-hostname). [Sending-domain DNS](../reference/02-sending-domain-dns.md#verify-alignment) explains each result.\n\n## 3. Set up notifications\n\nKestrel emails the notifications address when a send finishes, and right away when one runs into a problem. Notifications go through your email provider. To send them through Cloudflare instead, see [Notifications through Cloudflare's email](../guides/03-notifications.md).\n\n1. In the editor, open **Settings \u2192 Notifications**.\n\n1. Enter your address, and select **Save**.\n\n1. Select **Send a test notification**. It arrives at that address.\n\nUse your own address to test. If you're not the publisher, they set theirs in [Check your setup](../publishing/10-check-your-setup.md).\n\n## 4. Send a real post to yourself\n\nThis is a real send, to a list of one. It proves the whole path: subscribing, confirming, scheduling, and sending.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe with your second address.\n\n1. Open the confirmation email, follow its link, and select **Confirm**. In the editor, **Subscribers** shows the address as **Confirmed**.\n\n1. Open the post, select **Schedule**, and then **Send now**. Every send waits at least five minutes before it goes out, so you can cancel a mistake.\n\n1. Uncheck **Include in archive**. The post still goes to subscribers, but it's sent as email only: it isn't in the public archive or on the home page. That way the test proves the send without becoming the publication's first archived post, and the dashboard keeps its **Finish setting up** steps for the publisher.\n\n1. Cancel the send, to see that canceling stops it. Then select **Schedule** and **Send now** again, check that **Include in archive** is still 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 you verified this instance before Email only existed, the test post is in the archive. Open it under **Sent**, and select **Remove from archive\u2026**.\n\n## 5. Unsubscribe\n\nEvery email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. Both work in one step, with no login.\n\n1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender.\n\n1. Select it. In the editor, **Subscribers** shows the address as **Unsubscribed**, and later sends leave it out.\n\n## 6. Check that bounces and complaints come back\n\nWhen an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:\n\n - `bounce@simulator.amazonses.com`, which bounces\n - `complaint@simulator.amazonses.com`, which marks your mail as spam\n\n Each one gets a confirmation email, which bounces or is marked as spam.\n\n1. In the editor, **Subscribers** lists both addresses as **Pending**, each with a **Suppressed** flag. The app never mails a suppressed address again.\n\nIf an address has no flag, SES's events aren't reaching the app. Check the configuration set and the SNS subscription in [Connect Amazon SES](04-ses.md#6-send-bounces-and-complaints-to-sns).\n\n## 7. Read the logs\n\nThe app writes a log line for each thing it does, and Cloudflare keeps them for a few days. Nothing else tells you if the once-a-minute schedule that sends posts stops, so it's worth knowing where to look.\n\n1. In the Cloudflare dashboard, go to **Workers & Pages**, select `kestrel-production`, and select **Observability**.\n\n1. Filter on `event` equal to `sweep.tick`. A line arrives every minute.\n\n1. Filter on `event` equal to `send.completed`. Your send from section 4 is there.\n\n1. Filter on `level` equal to `error`. A healthy instance shows nothing.\n\n## 8. Check that SES can send to anyone\n\n[Connect Amazon SES](04-ses.md#5-request-production-access) asked AWS for production access. Until AWS grants it, the sandbox mails only your verified addresses, so check it before you go live.\n\n1. In SES, open the **Account dashboard** in the region the app sends from. It no longer says your account is in the sandbox, and it shows your daily quota and maximum send rate.\n\n1. Check that `SES_MAX_SEND_RATE` in `wrangler.jsonc` matches that rate, and that you deployed after setting it, as [Connect Amazon SES](04-ses.md#8-set-the-provider-and-deploy) describes.\n\nIf the dashboard still says sandbox, AWS hasn't answered yet. Every other section works in the sandbox, so come back to this one when it does.\n\n## 9. Clean up the test subscribers\n\nThe checks leave test addresses on the list. Erase them, so the publisher starts with only real readers.\n\n1. In the editor, open **Subscribers**.\n\n1. Erase `bounce@simulator.amazonses.com` and `complaint@simulator.amazonses.com`. Open each one's menu (\u22EF), and select **Erase**.\n\n1. Erase your second address too, if it won't be a reader.\n\nErasing keeps a do-not-contact marker, but the person can subscribe again through the subscribe page. Erasing your own address doesn't lock you out.\n\nWhen every section gives the result it describes, your instance is ready for subscribers.\n\nNext, [go live](06-go-live.md).\n";
6250
6754
 
6251
6755
  // docs/get-started/06-go-live.md
6252
- var go_live_default = "# Go live\n\nAt this point, your instance should be deployed, verified, and out of the SES sandbox, so it can send to anyone. From here, the publisher can set up the publication, and you can continue with the guides below.\n\n## Start publishing\n\nThe rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md). The checklist itself shows only until the first post exists, so the test post from [Verify it works](05-verify.md) has already hidden it.\n\nIf someone else is the publisher, let them sign in first:\n\n1. Add their email address to the `Publishers` policy on the Access application, as in [Allow yourself in](03-access.md#3-allow-yourself-in). If you set `ACCESS_ALLOWED_EMAILS`, store the list again with their address added.\n\n1. If they don't have a Cloudflare account, add another way to sign in, such as a one-time code by email. It's under **Zero Trust \u2192 Integrations \u2192 Identity providers**.\n\n1. Send them the editor's address and a link to [Name your publication](../publishing/03-name-your-publication.md).\n\n## Optional next steps\n\nEach of these guides adds one thing, and you can come back to any of them later.\n\n- [Connect Claude to the API](../guides/01-connect-claude.md), so Claude can draft, proofread, and schedule posts through the same API the editor uses.\n- [Notifications through Cloudflare's email](../guides/03-notifications.md), so problems are reported even when the email provider has stopped sending.\n- [Rate-limit the subscribe form](../guides/05-rate-limit.md), before the subscribe link is shared widely.\n- [As your list grows](../guides/09-as-your-list-grows.md), from about 5,000 subscribers.\n- [Upgrade to a new release](../guides/07-upgrade.md), when one comes out.\n";
6756
+ var go_live_default = "# Go live\n\nAt this point, your instance should be deployed, verified, and out of the SES sandbox, so it can send to anyone. From here, the publisher can set up the publication, and you can continue with the guides below.\n\n## Start publishing\n\nThe rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md).\n\nIf someone else is the publisher, let them sign in first:\n\n1. Add their email address to the `Publishers` policy on the Access application, as in [Allow yourself in](03-access.md#3-allow-yourself-in). If you set `ACCESS_ALLOWED_EMAILS`, store the list again with their address added.\n\n1. If they don't have a Cloudflare account, add another way to sign in, such as a one-time code by email. It's under **Zero Trust \u2192 Integrations \u2192 Identity providers**.\n\n1. Send them the editor's address and a link to [Name your publication](../publishing/03-name-your-publication.md).\n\n## Optional next steps\n\nEach of these guides adds one thing, and you can come back to any of them later.\n\n- [Connect Claude to the API](../guides/01-connect-claude.md), so Claude can draft, proofread, and schedule posts through the same API the editor uses.\n- [Notifications through Cloudflare's email](../guides/03-notifications.md), so problems are reported even when the email provider has stopped sending.\n- [Rate-limit the subscribe form](../guides/05-rate-limit.md), before the subscribe link is shared widely.\n- [As your list grows](../guides/09-as-your-list-grows.md), from about 5,000 subscribers.\n- [Upgrade to a new release](../guides/07-upgrade.md), when one comes out.\n";
6253
6757
 
6254
6758
  // docs/guides/01-connect-claude.md
6255
- var connect_claude_default = '# Connect Claude to the API\n\nClaude Code can draft, edit, proofread, and schedule posts through the same API the editor uses. It has its own Cloudflare Access credential, so its changes show as Claude\'s, not a person\'s. Anything it schedules waits out the review window, where the publisher can still cancel it.\n\nIn this guide, you create a service token for Claude Code, and add a policy for it to your Access application. Then you store the token where Claude Code reads it, check that it works, and point Claude at the API reference.\n\n## Before you begin\n\nYou need:\n\n- The dashboard locked with Access, from [Lock the dashboard with Access](../get-started/03-access.md).\n- [Claude Code](https://code.claude.com/docs/en/overview), run from your instance\'s repository.\n\n## 1. Create a service token\n\nA service token is a Client ID and a Client Secret that a program sends with each request. It carries no email, so the app tells it apart from your own login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Service credentials \u2192 Service Tokens**, and select **Create Service Token**.\n\n1. Name it `kestrel-claude`, and choose a **Service Token Duration**. A year is a good default.\n\n1. Select **Generate token**.\n\n1. Copy the **Client ID** and the **Client Secret**. Cloudflare shows the secret only once.\n\n## 2. Add a policy for the token\n\nYour application lets in only the people its `Publishers` policy names. A token needs a policy of its own, on the same application, because the app accepts only logins issued for that application.\n\n1. Under **Access controls \u2192 Applications**, select **Configure** on the application from [Lock the dashboard with Access](../get-started/03-access.md#2-create-the-access-application).\n\n1. Add a policy:\n\n - **Policy name:** `Claude`\n - **Action:** Service Auth\n - **Include:** the **Service Token** selector, with `kestrel-claude`\n\n1. Save the application.\n\n`ACCESS_ALLOWED_EMAILS` doesn\'t apply to the token, since it has no email. This policy is the only thing that lets it in.\n\n## 3. Store the token for Claude Code\n\nClaude Code loads the `env` block of `.claude/settings.local.json` into every session. That file stays on your computer: your repository\'s `.gitignore` keeps it out of git.\n\n1. Open `.claude/settings.local.json` in your instance\'s repository. Create it if it doesn\'t exist.\n\n1. Add an `env` block, beside anything already in the file:\n\n ```json\n {\n "env": {\n "KESTREL_URL": "https://newsletter.example.com",\n "CF_ACCESS_CLIENT_ID": "REPLACE_WITH_CLIENT_ID",\n "CF_ACCESS_CLIENT_SECRET": "REPLACE_WITH_CLIENT_SECRET"\n }\n }\n ```\n\n `KESTREL_URL` is your app\'s address. The other two are the Client ID and Client Secret from section 1.\n\n1. Start a new Claude Code session, so it loads the values.\n\nNever put the secret in `.claude/settings.json`. That file is committed.\n\n## 4. Point Claude at the API\n\nKestrel has no plugin or connector for Claude. Claude calls the HTTP API directly, and the API reference describes every route: its method and path, the body it takes, and an example. The editor\'s **API** tab shows the same reference.\n\nYour instance\'s `.claude/CLAUDE.md` already tells Claude Code how to call the API, so a session in your instance\'s repository reads the reference before its first call. If it doesn\'t, a prompt like this one points it there:\n\n```\nKestrel\'s API is at $KESTREL_URL. Send the CF-Access-Client-Id and\nCF-Access-Client-Secret headers from your environment with every request.\nRead GET /api/reference first, and use it for every call.\n```\n\nThe reference is also served as Markdown, `GET /api/reference.md`, which reads more easily for Claude than the JSON. So are the docs: `GET /api/docs/publishing.md` lists the publisher\'s pages, `GET /api/docs/self-hosting.md` this guide\'s, and each page is at `GET /api/docs/<slug>.md`.\n\nClaude reads the reference, and from there can list the posts, write a draft, and schedule it. The reference lists the body types each route takes. Most take JSON, sent with `Content-Type: application/json`. An image upload takes a multipart form, or the image file itself.\n\nTo bring subscribers over from another service, ask Claude to import the list, as [Bring your existing list](../publishing/01-your-list.md) describes. Its [file format](../publishing/01-your-list.md#the-file-format) section describes every column.\n\n## Check it\n\n1. Ask Claude to call `GET /api/whoami`. The app answers with a program\'s identity, not yours:\n\n ```\n {"principal":{"kind":"service"},"auth":{"mode":"access"}}\n ```\n\n1. Ask Claude to list the posts. It answers with the posts the editor shows.\n\n1. Open a draft in the editor, and ask Claude to change a word in it. The editor shows that the draft was changed elsewhere, last edited by **Claude**.\n\nIf Claude gets a `401`, a `403`, or a login page, check the policy from section 2, and the token\'s two values in `.claude/settings.local.json`. Check that the token hasn\'t expired, too.\n';
6759
+ var connect_claude_default = '# Connect Claude to the API\n\nClaude Code can draft, edit, proofread, and schedule posts through the same API the editor uses. It has its own Cloudflare Access credential, so its changes show as Claude\'s, not a person\'s. Anything it schedules waits out the review window, where the publisher can still cancel it.\n\nIn this guide, you create a service token for Claude Code, and add a policy for it to your Access application. Then you store the token where Claude Code reads it, check that it works, and point Claude at the API reference.\n\n## Before you begin\n\nYou need:\n\n- The dashboard locked with Access, from [Lock the dashboard with Access](../get-started/03-access.md).\n- [Claude Code](https://code.claude.com/docs/en/overview), run from your instance\'s repository.\n\n## 1. Create a service token\n\nA service token is a Client ID and a Client Secret that a program sends with each request. It carries no email, so the app tells it apart from your own login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Service credentials \u2192 Service Tokens**, and select **Create Service Token**.\n\n1. Name it `kestrel-claude`, and choose a **Service Token Duration**. A year is a good default.\n\n1. Select **Generate token**.\n\n1. Copy the **Client ID** and the **Client Secret**. Cloudflare shows the secret only once.\n\n## 2. Add a policy for the token\n\nYour application lets in only the people its `Publishers` policy names. A token needs a policy of its own, on the same application, because the app accepts only logins issued for that application.\n\n1. Under **Access controls \u2192 Applications**, select **Configure** on the application from [Lock the dashboard with Access](../get-started/03-access.md#2-create-the-access-application).\n\n1. Add a policy:\n\n - **Policy name:** `Claude`\n - **Action:** Service Auth\n - **Include:** the **Service Token** selector, with `kestrel-claude`\n\n1. Save the application.\n\n`ACCESS_ALLOWED_EMAILS` doesn\'t apply to the token, since it has no email. This policy is the only thing that lets it in.\n\n## 3. Store the token for Claude Code\n\nClaude Code loads the `env` block of `.claude/settings.local.json` into every session. That file stays on your computer: your repository\'s `.gitignore` keeps it out of git.\n\n1. Open `.claude/settings.local.json` in your instance\'s repository. Create it if it doesn\'t exist.\n\n1. Add an `env` block, beside anything already in the file:\n\n ```json\n {\n "env": {\n "KESTREL_URL": "https://newsletter.example.com",\n "CF_ACCESS_CLIENT_ID": "REPLACE_WITH_CLIENT_ID",\n "CF_ACCESS_CLIENT_SECRET": "REPLACE_WITH_CLIENT_SECRET"\n }\n }\n ```\n\n `KESTREL_URL` is your app\'s address. The other two are the Client ID and Client Secret from section 1.\n\n1. Start a new Claude Code session, so it loads the values.\n\nNever put the secret in `.claude/settings.json`. That file is committed.\n\n## 4. Point Claude at the API\n\nKestrel has no plugin or connector for Claude. Claude calls the HTTP API directly, and the API reference describes every route: its method and path, the body it takes, and an example. The editor\'s **API** tab shows the same reference.\n\nYour instance\'s `.claude/CLAUDE.md` already tells Claude Code how to call the API, so a session in your instance\'s repository reads the reference before its first call. If it doesn\'t, a prompt like this one points it there:\n\n```\nKestrel\'s API is at $KESTREL_URL. Send the CF-Access-Client-Id and\nCF-Access-Client-Secret headers from your environment with every request.\nRead GET /api/reference first, and use it for every call.\n```\n\nThe reference is also served as Markdown, `GET /api/reference.md`, which reads more easily for Claude than the JSON. So are the docs: `GET /api/docs/publishing.md` lists the publisher\'s pages, `GET /api/docs/self-hosting.md` this guide\'s, and each page is at `GET /api/docs/<slug>.md`.\n\nClaude reads the reference, and from there can list the posts, write a draft, and schedule it. The reference lists the body types each route takes. Most take JSON, sent with `Content-Type: application/json`. An image upload takes a multipart form, or the image file itself.\n\nTo bring subscribers over from another service, ask Claude to import the list, as [Bring your existing list](../publishing/01-your-list.md) describes. Its [file format](../publishing/01-your-list.md#the-file-format) section describes every column.\n\n## Check it\n\n1. Ask Claude to call `GET /api/whoami`. The app answers with a program\'s identity, not yours:\n\n ```\n {"principal":{"kind":"service","tokenId":"e367c0ffee5eb4.access"},"auth":{"mode":"access"}}\n ```\n\n The `tokenId` is the Client ID Zero Trust shows beside `kestrel-claude`. If it\'s a different one, Claude Code is using another token.\n\n1. Ask Claude to list the posts. It answers with the posts the editor shows.\n\n1. Open a draft in the editor, and ask Claude to change a word in it. The editor shows that the draft was changed elsewhere, last edited by **Claude**.\n\nIf Claude gets a `401`, a `403`, or a login page, check the policy from section 2, and the token\'s two values in `.claude/settings.local.json`. Check that the token hasn\'t expired, too.\n';
6256
6760
 
6257
6761
  // docs/guides/03-notifications.md
6258
6762
  var notifications_default = "# Notifications through Cloudflare's email\n\nKestrel emails the publisher when a send finishes, and right away when one runs into a problem. By default, these notifications go through the email provider. The one that matters most, about the provider refusing your account, is the one the provider would refuse too. Cloudflare's email doesn't depend on the provider, so that notification still reaches the publisher.\n\nIn this guide, you turn on Cloudflare's email for your app's hostname, and verify the notifications address with it. Then you add the binding that sends notifications through it, deploy, and send yourself a test.\n\n## Before you begin\n\nYou need:\n\n- The notifications address saved, from [Verify it works](../get-started/05-verify.md#3-set-up-notifications).\n- Your domain's DNS on Cloudflare, as for the rest of the app.\n\n## 1. Turn on Email Routing for your app's hostname\n\nCloudflare's email sends only from a domain set up with Email Routing. Use your app's hostname, `newsletter.example.com`. It receives no mail today, so turning Email Routing on there changes nothing else.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), go to **Compute \u2192 Email Service \u2192 Email Routing**.\n\n1. Add `newsletter.example.com` to Email Routing. Cloudflare adds subdomains from your domain's own settings: select `example.com`, then **Settings**, and add `newsletter` under **Subdomains**.\n\n1. Cloudflare adds MX, SPF, and DKIM records for `newsletter.example.com`, and locks them. Wait until they show as added.\n\nAvoid turning on Email Routing for a domain that already receives mail elsewhere, such as `example.com` with Google Workspace. Email Routing takes over that domain's MX records. Don't send notifications from `send.example.com` either, since your email provider sends from it. Email Routing there to forward readers' replies, as [Connect Amazon SES](../get-started/04-ses.md#3-receive-replies-at-your-from-address) sets up, is fine, since it only receives.\n\n## 2. Verify the notifications address\n\nCloudflare's email delivers only to addresses you've verified with it. Sending to a verified address is free on any plan.\n\n1. Under **Email Routing**, go to **Destination Addresses**, and add the address you saved for notifications.\n\n1. Open the email Cloudflare sends to that address, and select **Verify email address**. If it's the publisher's address, ask them to do this step.\n\nA verified address belongs to your Cloudflare account, so every environment in it can use it.\n\n## 3. Add the binding and deploy\n\nA `send_email` binding named `NOTIFY` moves notifications to Cloudflare's email. Without it, they go through your provider.\n\n1. In `wrangler.jsonc`, add the binding to the `production` block:\n\n ```jsonc\n \"send_email\": [\n { \"name\": \"NOTIFY\", \"allowed_destination_addresses\": [\"you@example.com\"] } // \u2190 the notifications address\n ],\n ```\n\n `allowed_destination_addresses` is optional. It limits the binding to the addresses you list, so a changed setting in the editor can't send notifications anywhere else. Leave it out to allow any address you've verified. If the publisher changes the notifications address later, verify the new one and add it here too.\n\n1. **(Optional)** Notifications come from `Kestrel <kestrel@newsletter.example.com>`. To use another address on that hostname, set `NOTIFY_FROM` in the block's `vars`:\n\n ```jsonc\n \"NOTIFY_FROM\": \"Kestrel <alerts@newsletter.example.com>\",\n ```\n\n1. Check the file. This regenerates the binding types:\n\n ```bash\n npm run typecheck\n ```\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am \"Send notifications through Cloudflare's email\"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## Check it\n\n1. In the editor, open **Settings \u2192 Notifications**. **Sent through** reads \"Cloudflare Email, separate from your newsletter's provider\", and **From address** shows `kestrel@newsletter.example.com`.\n\n1. Select **Send a test notification**. It arrives at the notifications address, and **Last notification** shows it as delivered.\n\nIf the test isn't delivered, **Last notification** shows Cloudflare's reason, such as an address that isn't verified. Cloudflare's Email Routing summary lists notifications as dropped even when they're delivered, so check the notifications inbox rather than the summary.\n\n## What notifications don't cover\n\n- **The schedule stopping.** The same once-a-minute schedule that sends posts also notices problems. If the schedule itself stops, nothing tells you. [Read the logs](../get-started/05-verify.md#7-read-the-logs) to check that `sweep.tick` still arrives every minute.\n- **A notification that can't get through.** The app tries each one up to five times, a minute apart. A failure shows under **Settings \u2192 Notifications**, and in the logs as `notify.failed`.\n- **Problems while no address is set.** With the address blank, the app sends nothing, and saves nothing up for later.\n\nA notification never changes a send. One that fails never delays, pauses, or stops it.\n";
@@ -6276,7 +6780,7 @@ var sending_rules_default = "# Sending rules\n\nEmail law asks a few things of e
6276
6780
  var name_your_publication_default = "# Name your publication\n\nGive your newsletter a name and a look before your first post. Set the name, tagline, and logo under **Settings \u2192 Publication identity**. They appear in every email your template prints them in, on the public pages readers see, and in the confirmation email new subscribers get.\n\nAll three are optional, and you can change them at any time. How they appear in each email comes from your email template, which you can [edit](11-email-template.md).\n\n## When to provide a mailing address\n\n**If your newsletter is mostly your own writing, you probably don't need one. If an email is mainly selling something, add one.**\n\nUS law (CAN-SPAM) asks for a postal address in commercial email. The FTC [treats a newsletter people subscribed to](https://www.ftc.gov/sites/default/files/documents/federal_register_notices/definitions-and-implementation-under-can-spam-act-16-cfr-part-316/080521canspamact.pdf) as a relationship message instead, even with a few ads in it. An email whose main purpose is to sell, such as a launch, a sale, or a sponsor's promotion, is commercial. Canada's law (CASL) asks for an address more often ([Government of Canada guidance](https://ised-isde.canada.ca/site/canada-anti-spam-legislation/en)).\n\nA P.O. box or a registered private mailbox counts, so you never have to publish your home address. To add one, choose **Add a mailing address** under **Settings \u2192 Publication identity**. The built-in templates print it in the email footer and keep it off your public pages.\n\nThis is general information, not legal advice. The FTC's [CAN-SPAM compliance guide](https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business) has the detail, and [Sending rules](02-sending-rules.md) covers the rest of what the law asks of a newsletter.\n\n## Who sets the From name and address\n\nThe From name and address your emails come from are set when Kestrel is deployed, so **Settings \u2192 Email sender** shows them read-only. To change them, follow [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy).\n\n";
6277
6781
 
6278
6782
  // docs/publishing/04-write-a-post.md
6279
- var write_a_post_default = "# Write and send a post\n\nYou write a post in Markdown, test it, and schedule it. Scheduling locks the post and makes the exact email that will go out. Then you send yourself a test of that copy, so the email you tested is the one your list receives.\n\n**New post**, on the Dashboard or under **Drafts**, opens a draft.\n\n## Writing\n\nKestrel's editor is a Markdown editor. You write a post in Markdown, the way you would for a static site generator or with an AI assistant. Kestrel places it in your [email template](11-email-template.md). The same Markdown makes the email and the post's archive page, so you never format a post twice. **Preview** shows the email as it will look, and Kestrel saves as you type.\n\nA few things to know as you write:\n\n- **A blank line starts a new paragraph.** In an email, a single line break doesn't.\n- **The opening lines** are the preview a mail client shows beside the subject.\n- **Images** go in by paste or drop, and Kestrel inserts a reference by file name, such as `![cover.jpg](cover.jpg)`. Replace the words in brackets with a short description. PNG, JPEG, WebP, or GIF, up to 5 MB each.\n- **The subject** sets the post's slug, the end of its permanent archive address, such as `newsletter.example.com/archive/spring-update`. The slug is fixed once you schedule.\n\n## Test it\n\n**Send test email** sends the real email, through the same template and code as the send, to the addresses you name. **Settings \u2192 Default test recipients** fills them in for you. Nobody on your list gets anything, and a test's unsubscribe link unsubscribes no one.\n\nMail clients each draw email a little differently, so trust a test over **Preview**, especially for tables, code, and HTML. A test needs an email provider, which [Connect Amazon SES](../get-started/04-ses.md) sets up.\n\n## Schedule and send\n\nChoose **Schedule** at the foot of the editor, pick a time under **Send at**, and confirm. The time must be at least the minimum lead away: five minutes, unless your deployment sets a different [`MIN_LEAD_SECONDS`](../reference/01-configuration.md#min_lead_seconds). Neither the dashboard nor Claude can shorten it. Times are to the minute, and one with seconds rounds up.\n\nScheduling makes the email once, from the post, your template, and your publication's details as they are at that moment. That frozen copy is what goes out, and it's also the post's page in your archive. So the post is locked, and **Preview** and **Send test email** show the frozen copy.\n\n**Send yourself a test now.** It's the copy your list receives, and the time until the send is your window to catch a mistake, including one Claude made.\n\nChanging your template, or a publication detail it shows, re-makes the email of every scheduled post, with the same content and send time. Kestrel asks before it saves, and each re-made post then asks you to send a new test. A change is refused while a post is within the minimum lead of its send time, and Kestrel says when to try again. Posts already sending or sent never change.\n\n**Send now \u2192**, under the time picker, is for a post you've already reviewed. It still waits out the window, scheduling the post for the minimum lead from now.\n\n### Changing your mind\n\n**Cancel**, on the post's banner or its card under **Sent**, makes the post a draft again at once. Edit it, test again, and schedule it again. **Reschedule** moves the send time without unlocking the post, and your tests still count. You can do either until the send time.\n\n### Who receives it\n\nA post goes to your list as it stands when it sends: every confirmed subscriber, except addresses that bounced, complained, or were blocked. Someone who confirms while it's scheduled receives it. Someone who unsubscribes before their email is handed to your provider doesn't, even partway through the send.\n\nSo a recipient count shown before the send is a snapshot. An import that adds subscribers while a post is scheduled adds them to its audience too, and asks you first ([Bring your existing list](01-your-list.md#3-import-it)).\n\n### Warnings when you schedule\n\n- **No email provider connected.** The Schedule dialog can't schedule until one is connected ([Connect Amazon SES](../get-started/04-ses.md)). If the provider goes after a post is scheduled, the post is held, stays cancelable, and goes out once one is connected.\n- **Daily database writes.** On Cloudflare's free Workers plan, a large send may use up the day's writes. The dialog warns you without stopping you, as [Bring your existing list](01-your-list.md#limits) explains.\n\n### When it sends\n\nAt the send time, the post moves to its send page under **Sent**, where you can watch it go out. From then it can't be edited, canceled, or moved. Its archive page goes up at the same moment, showing exactly the copy that was sent, so every email's \"view in browser\" link works. Whatever interrupts a send, such as your provider's daily quota running out, it waits and resumes where it stopped. No reader gets the same post twice.\n\nIf a send needs your attention, the send page and the dashboard say so. [Troubleshooting](06-troubleshooting.md) covers each case.\n";
6783
+ var write_a_post_default = "# Write and send a post\n\nYou write a post in Markdown, test it, and schedule it. Scheduling locks the post and makes the exact email that will go out. Then you send yourself a test of that copy, so the email you tested is the one your list receives.\n\n**New post**, on the Dashboard or under **Drafts**, opens a draft.\n\n## Writing\n\nKestrel's editor is a Markdown editor. You write a post in Markdown, the way you would for a static site generator or with an AI assistant. Kestrel places it in your [email template](11-email-template.md). The same Markdown makes the email and the post's archive page, so you never format a post twice. **Preview** shows the email as it will look, and Kestrel saves as you type.\n\nA few things to know as you write:\n\n- **A blank line starts a new paragraph.** In an email, a single line break doesn't.\n- **The opening lines** are the preview a mail client shows beside the subject.\n- **Images** go in by paste or drop, and Kestrel inserts a reference by file name, such as `![cover.jpg](cover.jpg)`. Replace the words in brackets with a short description. PNG, JPEG, WebP, or GIF, up to 5 MB each.\n- **Where it will go** is in **Post settings**, at the top right: whether it's in your archive, and its address, such as `newsletter.example.com/archive/spring-update`, whose slug follows the subject (below).\n\n## Test it\n\n**Send test email** sends the real email, through the same template and code as the send, to the addresses you name. **Settings \u2192 Default test recipients** fills them in for you. Nobody on your list gets anything, nothing appears in your archive, and a test's unsubscribe link unsubscribes no one.\n\nMail clients each draw email a little differently, so trust a test over **Preview**, especially for tables, code, and HTML. A test needs an email provider, which [Connect Amazon SES](../get-started/04-ses.md) sets up.\n\n## Schedule and send\n\nChoose **Schedule** at the foot of the editor, pick a time under **Send at**, and confirm. The time must be at least the minimum lead away: five minutes, unless your deployment sets a different [`MIN_LEAD_SECONDS`](../reference/01-configuration.md#min_lead_seconds). Neither the dashboard nor Claude can shorten it. Times are to the minute, and one with seconds rounds up.\n\nScheduling makes the email once, from the post, your template, and your publication's details as they are at that moment. That frozen copy is what goes out, and it's also the post's page in your archive. So the post is locked, and **Preview** and **Send test email** show the frozen copy.\n\n**Send yourself a test now.** It's the copy your list receives, and the time until the send is your window to catch a mistake, including one Claude made.\n\nChanging your template, or a publication detail it shows, re-makes the email of every scheduled post, with the same content and send time. Kestrel asks before it saves, and each re-made post then asks you to send a new test. A change is refused while a post is within the minimum lead of its send time, and Kestrel says when to try again. Posts already sending or sent never change.\n\n**Send now \u2192**, under the time picker, is for a post you've already reviewed. It still waits out the window, scheduling the post for the minimum lead from now.\n\n### Post settings\n\n**Post settings**, at the top right of the editor, holds two choices. Neither is part of the email, so both stay open while the post is scheduled. Nothing changes until you select **Save**.\n\n**Include in archive** is checked unless you uncheck it. Unchecked, the post goes to your list as usual but is sent as email only: it stays off your public archive and your home page, and its archive address shows nothing. Use that for a post meant for the inbox: a correction to an earlier email, a short-lived notice, or a test. The editor marks such a post **Email only**.\n\n**Archive address** is where the post's page will be in your archive. Only the last part, the slug, is yours to type; it follows the subject until you do. Each post's address is its own: if you type one another post has, or one with spaces or capitals, Kestrel says so and keeps the address as it was. If another post already has the address your subject would give, Kestrel adds a number (`spring-update-2`) and tells you, so you can choose another. The address is fixed once the post starts sending.\n\nThe Schedule, Send now, and Edit schedule dialogs show the same settings under the time, so you can still change them there. A change made in a dialog applies when you schedule, send, or save, and canceling leaves the post as it was. Once a post is scheduled, its banner names where it's going.\n\nEvery email links its web version through the post's own **unlisted link**, which never changes. It opens the archive page while the post is in your archive, and the page itself while it's email only. Anyone with the email can open it, but nothing lists it, and search engines are asked not to.\n\nA sent post's page under **Sent** shows its address with **Remove from archive\u2026** or **Add to archive\u2026**. Both ask first. Removing a post takes its archive address down, so links people shared stop working, but the address stays the post's: add it back and those links work again. Neither changes what was sent.\n\n### Changing your mind\n\n**Cancel**, on the post's banner or its card under **Sent**, makes the post a draft again at once. Edit it, test again, and schedule it again. **Edit schedule** changes the send time, whether the post is in your archive, or both, without unlocking the post, and your tests still count. You can do either until the send time.\n\n### Who receives it\n\nA post goes to your list as it stands when it sends: every confirmed subscriber, except addresses that bounced, complained, or were blocked. Someone who confirms while it's scheduled receives it. Someone who unsubscribes before their email is handed to your provider doesn't, even partway through the send.\n\nSo a recipient count shown before the send is a snapshot. An import that adds subscribers while a post is scheduled adds them to its audience too, and asks you first ([Bring your existing list](01-your-list.md#3-import-it)).\n\n### Warnings when you schedule\n\n- **No email provider connected.** The Schedule dialog can't schedule until one is connected ([Connect Amazon SES](../get-started/04-ses.md)). If the provider goes after a post is scheduled, the post is held, stays cancelable, and goes out once one is connected.\n- **Daily database writes.** On Cloudflare's free Workers plan, a large send may use up the day's writes. The dialog warns you without stopping you, as [Bring your existing list](01-your-list.md#limits) explains.\n\n### When it sends\n\nAt the send time, the post moves to its send page under **Sent**, where you can watch it go out. From then it can't be edited, canceled, or moved. Its page goes up at the same moment, showing exactly the copy that was sent, so every email's \"view in browser\" link works. Whatever interrupts a send, such as your provider's daily quota running out, it waits and resumes where it stopped. No reader gets the same post twice.\n\nIf a send needs your attention, the send page and the dashboard say so. [Troubleshooting](06-troubleshooting.md) covers each case.\n";
6280
6784
 
6281
6785
  // docs/publishing/06-troubleshooting.md
6282
6786
  var troubleshooting_default = `# Troubleshooting
@@ -6287,17 +6791,21 @@ When a send needs you, its page and the dashboard's health block say so in a red
6287
6791
 
6288
6792
  ## A send is waiting or held
6289
6793
 
6290
- A problem with your email provider never marks anyone unsent. The send waits where it is, and carries on from there once the problem clears.
6794
+ A problem with your email provider never marks anyone unsent. The send waits where it is, and carries on from there once the problem clears. "No one has been marked unsent because of this" means this problem: readers whose own email failed earlier stay unsent.
6291
6795
 
6292
- - **"The provider is refusing the account".** Your provider won't take mail from your account, and its own words follow, with the fix they point to. The fix is almost always in your Amazon SES account or Kestrel's deployment, and the message names it. A spent daily sending quota frees up within a day, and you can ask AWS for a higher one, as [As your list grows](../guides/09-as-your-list-grows.md) describes.
6796
+ - **"The provider is refusing the account".** Your provider won't take mail from your account, and its own words follow, with the fix and who makes it. The fix is almost always in your Amazon SES account or Kestrel's deployment, made by whoever runs it. [What SES refuses, and the fix](#what-ses-refuses-and-the-fix), below, lists each one. Any other post that comes due meanwhile waits with it, without asking SES, and you're told once. While a quota frees up a few at a time, or while the SES sandbox takes only your verified addresses, SES takes some readers on each retry and refuses the rest: those it takes are sent, and the line stays, with the time it began, until SES takes everyone it's asked to.
6797
+ - **"The provider is refusing this send".** SES refuses this post's email itself, or a batch of it as invalid, rather than your account. Its words follow. Other posts go out as usual. If SES's words name a reader's address, remove that subscriber: the send skips them and carries on at its next retry. If SES refuses the email itself, the email can't change once its send has started, so whoever runs the deployment asks AWS Support why, quoting SES's words.
6293
6798
  - **"No email provider is connected".** The send is held until one is connected ([Connect Amazon SES](../get-started/04-ses.md)), and goes out once it is.
6294
- - **"The provider is unavailable".** An outage or a rate limit on the provider's side. The send retries on its own, so there's nothing to do.
6295
- - **"Still sending \u2026 after it started".** A send is taking longer than expected, and may be retrying. If the line stays, read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes.
6799
+ - **"The provider is unavailable".** An outage or a rate limit on the provider's side. The send reads **Backing off** and says when it next retries, spacing its retries out up to an hour apart while the outage lasts, so there's nothing to do. Any other post that comes due meanwhile waits with it.
6800
+ - **"The provider keeps refusing for its sending rate".** An amber line. SES has refused the first request of several retries in a row for its sending rate, which a short burst never does, so Kestrel is set to send faster than your SES account allows. Whoever runs the deployment sets \`SES_MAX_SEND_RATE\` to the **Maximum send rate** on the SES console's **Account dashboard** (1 a second in the sandbox), and deploys, as [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy) describes. The send retries on its own meanwhile.
6801
+ - **"Nothing has gone out for \u2026".** A send has handed off no one for half an hour, with no retry or provider problem to explain it. The part of Kestrel that sends has stopped or keeps failing on this send, or another send going out at the same time is taking all of its turn. A large send going out slowly never shows this. If another send is going out, this one picks up once it finishes. Read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes, and check the Worker's trigger, as [\`triggers.crons\`](../reference/01-configuration.md#triggerscrons) describes. The send carries on from where it stopped once it runs again, mailing no one twice.
6296
6802
  - **"The fire time passed \u2026 and the send has not started".** The Cloudflare Cron Trigger that starts sends on time isn't running. The send goes out as soon as it runs again. Check the Worker's trigger, as [\`triggers.crons\`](../reference/01-configuration.md#triggerscrons) describes.
6297
6803
  - **"The provider never answered for \u2026".** Rarely, Kestrel can't tell whether your provider took some emails, and sending again could mail those readers twice. **Resolve\u2026** on the send's page asks you to decide. **Assume not sent** is the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it.
6298
6804
 
6299
6805
  ## Before a send
6300
6806
 
6807
+ - **A test says "The provider refused it".** Your provider didn't take the test, and its own words follow, with the fix where Kestrel knows it. Sending again gets the same answer until that's fixed. While Amazon SES is in its sandbox, it sends only to addresses verified in SES, so verify the address you test with in the SES console, or request production access, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Words about the account, such as a key or the sending domain, point at the same fix as [a send the provider refuses](06-troubleshooting.md#a-send-is-waiting-or-held).
6808
+ - **A test says "The provider didn't answer, so the test may have arrived".** Kestrel can't tell whether your provider took it. Check the inbox before you send again, since a second test may arrive twice.
6301
6809
  - **"The template or publication details changed this email's look after your last test".** Send a new test of the post, since your last one no longer shows what goes out.
6302
6810
  - **The daily database writes warning.** On Cloudflare's free Workers plan, a large send may use up the day's writes, and then the app stops working until midnight UTC. [Bring your existing list](01-your-list.md#limits) explains the limit, and when to upgrade to Workers Paid.
6303
6811
 
@@ -6312,9 +6820,33 @@ A send's page becomes its permanent record once it finishes. Its counts cover ev
6312
6820
  | **Complained** | The reader marked the email as spam. |
6313
6821
  | **Unsent** | Kestrel couldn't hand the email to your provider. |
6314
6822
 
6315
- Delivered always means confirmed, not just accepted by your provider. Confirmations can lag by days, so counts keep rising after a send finishes. Some emails are never confirmed at all, so a send can read **Settling** for good, which is normal.
6823
+ Delivered always means confirmed, not just accepted by your provider. Confirmations can lag by days, so counts keep rising after a send finishes. Some emails are never confirmed at all, so a send can read **Settling** for good, which is normal, as long as some receipts have come.
6824
+
6825
+ **"No delivery receipt has arrived for any of \u2026".** An hour after a send went out, Kestrel has heard back about none of its readers. Your provider's reports probably aren't reaching Kestrel, so its bounces and complaints suppress no one, and your next post mails those addresses again. Look for \`webhook.refused\` errors in the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes. It names the SNS topic that reached Kestrel and the one it expects. Then check the subscription in [Connect Amazon SES](../get-started/04-ses.md#9-subscribe-the-app-to-the-topic). The line goes as soon as a receipt arrives.
6826
+
6827
+ **Unsent recipients** are still on your list, and get your next post. A recipient is unsent when their own email kept failing, or their address isn't one SES accepts, with the provider's words on the row, or when you chose **Assume not sent**.
6828
+
6829
+ ### What SES refuses, and the fix
6316
6830
 
6317
- **Unsent recipients** are still on your list, and get your next post. A recipient is unsent when their own email kept failing, with the provider's words on the row, or when you chose **Assume not sent**.
6831
+ When SES refuses your account or a send, the red line carries SES's words and the fix. Almost every fix is made by whoever runs the deployment, in the SES console or the deployment's settings.
6832
+
6833
+ | SES says | What it means | The fix |
6834
+ | --- | --- | --- |
6835
+ | Email address is not verified, naming a reader | The SES account is still in the sandbox, which mails only verified addresses. | Request production access on the SES console's **Account dashboard**, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Signups fail until then, and you're emailed about it once a day. |
6836
+ | Email address is not verified, naming your From address or its domain | The sending identity isn't verified in this SES region. | Verify the From address or its domain in SES, in the region the deployment sends from. |
6837
+ | Mail FROM domain is not verified | SES can't read the custom MAIL FROM domain's MX record. | Check that record in DNS, or set the identity's **Behavior on MX failure** to use the default MAIL FROM domain. |
6838
+ | Daily message quota exceeded | The 24-hour sending quota is spent. It frees up on its own. | In the sandbox (200 a day), request production access. Otherwise, ask AWS for a higher quota, as [As your list grows](../guides/09-as-your-list-grows.md) describes. |
6839
+ | Sending paused (account) | AWS paused the account under review, or sending was turned off by hand. | Follow AWS's email to the AWS account's address, or turn sending back on. |
6840
+ | Sending paused (configuration set) | Sending is turned off on the configuration set, by hand or by an alarm. | Turn sending back on for that set. |
6841
+ | Account suspended | AWS shut down the account's sending. | Only AWS can lift it: open a case with AWS Support. |
6842
+ | An access key, signature, or permission error | The deployment's SES key is wrong, expired, or lacks permission. | Replace the key, or give it \`ses:SendEmail\` and \`ses:SendBulkEmail\`. |
6843
+ | A subscription is needed (OptInRequired) | The AWS account isn't signed up for SES. | Enable SES for the account, or settle its activation or billing with AWS. |
6844
+ | Configuration set does not exist | \`SES_CONFIGURATION_SET\` names a set that isn't in this region. | Fix the name, or create the set. |
6845
+ | Sending pool not found | The configuration set uses a dedicated IP pool that doesn't exist. | Pick an existing pool in the set's sending options, or remove it. |
6846
+ | The message was rejected | SES refuses this email itself. | Ask AWS Support why. Other posts go out as usual. |
6847
+ | The request is invalid | SES refused a batch of this send. | If SES's words name a reader's address, remove that subscriber. |
6848
+
6849
+ A server error from SES may come after SES already took the emails, so Kestrel never sends that batch again: its readers wait for **Resolve\u2026**, as for "The provider never answered for \u2026".
6318
6850
 
6319
6851
  ## Bounces and complaints
6320
6852
 
@@ -6346,8 +6878,9 @@ Kestrel refuses a file it can't read with certainty, and says what's wrong and w
6346
6878
  With an address set, Kestrel emails you:
6347
6879
 
6348
6880
  - when a send finishes, with how many emails were accepted, unsent, and skipped
6349
- - when your provider refuses your account, or no provider is connected
6350
- - when a send stays in progress too long, waits for you to resolve it, or misses its time
6881
+ - when your provider refuses your account or a send, or no provider is connected
6882
+ - when the SES sandbox refuses a signup's confirmation, at most once a day
6883
+ - when a send stops going out with nothing to explain it, waits for you to resolve it, or misses its time
6351
6884
  - when Claude imports a list for you
6352
6885
 
6353
6886
  Each links to the page it's about, and is sent once while the problem lasts. A bounce spike shows on the dashboard but isn't emailed. **Send a test notification** checks that one arrives.
@@ -6356,7 +6889,7 @@ By default, notifications go through your newsletter's own email provider. So wh
6356
6889
  `;
6357
6890
 
6358
6891
  // docs/publishing/07-working-with-claude.md
6359
- var working_with_claude_default = "# Working with Claude\n\nAsk Claude in plain words, the way you'd ask a colleague. It works through the same API as the dashboard, so it can do what you can do there, and nothing more. Everything it changes shows in the dashboard as Claude's.\n\nTo set Claude up, follow [Connect Claude to the API](../guides/01-connect-claude.md). Claude reads these same pages from your Kestrel, so it explains things in the words you see here.\n\n## What to ask Claude\n\n- **Draft or proofread a post.** Give it notes, an outline, or a draft. It saves a new version you can open in the dashboard. If you edited the post meanwhile, its save is refused, and it works from your version or asks you.\n- **Test and schedule a post.** Name the post and when it should go out. Claude schedules it, sends a test of exactly what will go out, and tells you how to cancel. It can also cancel a send or move it.\n- **Check how a send did.** Claude reads the send's record and reports what was delivered, unsent, bounced, or complained.\n- **Lift a bounce or a complaint.** The dashboard can't, so ask Claude when you know an address works again. [Lifting a suppression](06-troubleshooting.md#lifting-a-suppression) says when that's wise.\n- **Recover an earlier version.** Every save keeps a whole version of a post, and Claude can read them back.\n- **Import a list.** Its `/migrate-list` skill asks what anything ambiguous in your old service's export means, and converts a few rows for you to check. It imports once you attest, and if a scheduled post would grow, you finish the import in the dashboard.\n\n## The lines it can't cross\n\nThe app holds these for any client, so they hold whatever Claude is told:\n\n- **Every send waits out the review window,** and you can cancel it until it goes out.\n- **A scheduled post is locked,** and what goes out is exactly the copy that was scheduled.\n- **No one is mailed twice,** and only confirmed subscribers receive a post.\n- **An unsubscribe is final** until the reader subscribes again, and an erased address stays erased until the person confirms again.\n- **No one's edit is overwritten.** A save based on an older version is refused.\n- **Only you can let an import grow a scheduled send.** An import through Claude that would stops there, until you finish it in the dashboard.\n\nClaude also follows instructions your Kestrel gives it, which live in your instance's repository. Those hold only as long as the instructions do. They have Claude ask before anything that reaches readers, test again after a template change, and record whose decision each change to the list was. They also have it treat what's in a post or a subscriber's record as content, never as instructions.\n\nThe app lets Claude give your attestation for an import, so asking you first is one of those instructions, not a rule the app enforces. The import's record says it came through Claude, and Kestrel emails you about it if your notifications are set.\n";
6892
+ var working_with_claude_default = "# Working with Claude\n\nAsk Claude in plain words, the way you'd ask a colleague. It works through the same API as the dashboard, so it can do what you can do there, and nothing more. Everything it changes shows in the dashboard as Claude's.\n\nTo set Claude up, follow [Connect Claude to the API](../guides/01-connect-claude.md). Claude reads these same pages from your Kestrel, so it explains things in the words you see here.\n\n## What to ask Claude\n\n- **Draft or proofread a post.** Give it notes, an outline, or a draft. It saves a new version you can open in the dashboard. If you edited the post meanwhile, its save is refused, and it works from your version or asks you.\n- **Test and schedule a post.** Name the post and when it should go out, and say so if it should be email only. Claude tells you whether the post will be in your archive, and at which address, before it schedules. Then it sends a test of exactly what will go out and tells you how to cancel. It can also cancel a send or move it.\n- **Take a sent post out of your archive, or put it back.** Claude asks first, as the dashboard does, since taking a post out breaks links people shared.\n- **Check how a send did.** Claude reads the send's record and reports what was delivered, unsent, bounced, or complained.\n- **Lift a bounce or a complaint.** The dashboard can't, so ask Claude when you know an address works again. [Lifting a suppression](06-troubleshooting.md#lifting-a-suppression) says when that's wise.\n- **Recover an earlier version.** Every save keeps a whole version of a post, and Claude can read them back.\n- **Import a list.** Its `/migrate-list` skill asks what anything ambiguous in your old service's export means, and converts a few rows for you to check. It imports once you attest, and if a scheduled post would grow, you finish the import in the dashboard.\n\n## The lines it can't cross\n\nThe app holds these for any client, so they hold whatever Claude is told:\n\n- **Every send waits out the review window,** and you can cancel it until it goes out.\n- **A scheduled post is locked,** and what goes out is exactly the copy that was scheduled.\n- **No one is mailed twice,** and only confirmed subscribers receive a post.\n- **An unsubscribe is final** until the reader subscribes again, and an erased address stays erased until the person confirms again.\n- **No one's edit is overwritten.** A save based on an older version is refused.\n- **Only you can let an import grow a scheduled send.** An import through Claude that would stops there, until you finish it in the dashboard.\n\nClaude also follows instructions your Kestrel gives it, which live in your instance's repository. Those hold only as long as the instructions do. They have Claude ask before anything that reaches readers, say whether a post will be in your archive before it's sent, test again after a template change, and record whose decision each change to the list was. They also have it treat what's in a post or a subscriber's record as content, never as instructions.\n\nThe app lets Claude give your attestation for an import, so asking you first is one of those instructions, not a rule the app enforces. The import's record says it came through Claude, and Kestrel emails you about it if your notifications are set.\n";
6360
6893
 
6361
6894
  // docs/publishing/09-share-your-subscribe-link.md
6362
6895
  var share_your_subscribe_link_default = "# Share your subscribe link\n\nYour publication's subscribe page is at `/subscribe` on your Kestrel's address, such as `https://newsletter.example.com/subscribe`. Copy it from the dashboard's checklist or **Settings \u2192 Ways to subscribe**, and share it wherever your readers are. A reader joins only once they confirm by email.\n\n**Signups open only once an email provider is connected.** Until then, the page says the newsletter isn't taking subscribers yet, and the dashboard says so too. You can share the link anyway, and it opens once email is connected ([Connect Amazon SES](../get-started/04-ses.md)).\n\n## How a reader joins\n\nA reader enters their address, gets a confirmation email, and presses **Confirm subscription** on the page its link opens. Only then do they receive your posts. Opening the link alone records nothing, since mail scanners open links before a person does. This double opt-in is your record that each reader asked for your newsletter.\n\nEvery address gets the same answer, so the form never reveals who subscribes. Behind it, Kestrel sends a confirmation only when one is due:\n\n- **Already subscribed, bounced, complained, or blocked:** no email goes out.\n- **Waiting to confirm:** at most one confirmation every 15 minutes, however often the form is submitted. A link works for 7 days, and an expired one offers a fresh link.\n- **Unsubscribed earlier, or erased at their request:** they get a confirmation, and confirming puts them back on your list.\n\n## The confirmation email\n\nChange its subject, message, button label, and reassurance line under **Settings \u2192 Confirmation email**. Kestrel adds your name, your logo, and the confirm link itself, so no edit can leave them out. The confirmation isn't a post, so it doesn't use your email template.\n\n## Put the form on your own site\n\n**Settings \u2192 Ways to subscribe \u2192 Embed a form** gives you HTML to paste into any page you control. **Styled** looks like your subscribe page, and **Plain** takes your site's own styles. A reader who submits it confirms by email, as on the subscribe page.\n\n**Settings \u2192 Reader pages** shows every page a reader can land on, at desktop and phone widths, without walking through the steps. You can also limit how often anyone submits the form, which slows down abuse ([Rate-limit the subscribe form](../guides/05-rate-limit.md)).\n";
@@ -6365,16 +6898,16 @@ var share_your_subscribe_link_default = "# Share your subscribe link\n\nYour pub
6365
6898
  var check_your_setup_default = "# Check your setup\n\nBefore your readers get anything, see it arrive yourself, and make sure a problem will reach you without your watching for it. Send yourself a test and reply to it, subscribe yourself, and set where Kestrel emails you.\n\nIf you're bringing a list of more than about 5,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import it. On the free plan, a large import and a send can use up the day's database writes. Then the app stops working until midnight UTC. [As your list grows](../guides/09-as-your-list-grows.md) walks through the upgrade.\n\n## Send yourself a test\n\nChoose **Template** in the sidebar, then **Send test email**, and send a sample post to your own inboxes. When it arrives, check:\n\n- **The From name and address,** which readers see first. They're set when Kestrel is deployed ([Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy)).\n- **Where it lands:** the inbox, Promotions, or spam. If it lands in spam, check your sending domain's records, as [Sending-domain DNS](../reference/02-sending-domain-dns.md) describes.\n- **A reply.** Reply to the test, and check that it reaches your inbox. Kestrel never sees replies, so this depends on [forwarding your From address](../get-started/04-ses.md#3-receive-replies-at-your-from-address).\n\n## Subscribe yourself\n\nOpen your subscribe page, from the link under **Settings \u2192 Ways to subscribe**, and subscribe with a personal address. Then confirm. You see what a new reader sees, and you then appear under **Subscribers** as **Confirmed**. [Share your subscribe link](09-share-your-subscribe-link.md) covers the page and the confirmation email.\n\n## Set your notifications address\n\nUnder **Settings \u2192 Notifications**, fill in **Send notifications to**, save, and choose **Send a test notification**. Kestrel then emails you when a send finishes or needs you. [Notifications](06-troubleshooting.md#notifications) lists what you're told, and why a second way to send them is worth asking for.\n\n## If email isn't connected\n\nUntil an email provider is connected, nothing can be sent, and the dashboard says so. The checklist shows **Connect Amazon SES** in place of this step, as [Connect Amazon SES](../get-started/04-ses.md) describes. **Settings \u2192 Email sender** shows your From address, sending domain, and provider, read-only.\n\n## As your list grows\n\nA growing list meets your Amazon SES daily sending quota, longer sends, and on Cloudflare's free Workers plan, a cap on daily database writes. Kestrel tells you when you reach each one, and [As your list grows](../guides/09-as-your-list-grows.md) covers raising each.\n\n";
6366
6899
 
6367
6900
  // docs/publishing/11-email-template.md
6368
- var email_template_default = "# Edit your email template\n\nYou write each post in Markdown, and the template is the HTML frame it's sent in, like a layout in a static site generator. `{{ .Post.Body }}` marks where your post goes, and the rest is plain HTML around it. The built-in template works as it comes, so changing it is optional.\n\nTo change it, choose **Template** in the sidebar, edit the HTML, and choose **Save changes**. **Start from example** offers two starting points. **Signed** ends each post with your logo, name, and tagline, and **Plain** has just your post and the footer links.\n\n## What every template needs\n\nKestrel won't save a template without `{{ .Post.Body }}` and `{{ .Email.UnsubscribeURL }}`. Every email has to carry the post and a way to leave. The two **Required** pills above the editor turn green or red as your template gains or loses them.\n\nWrap a part in `{{ if .IsEmail }}` and `{{ end }}` to keep it off each post's public page. The starting templates do this with the unsubscribe link, the View in browser link, and the mailing address.\n\n## Variables\n\nA variable is replaced with its value in each email. Type it exactly as shown, or it renders as empty.\n\n| Variable | What it holds |\n| --- | --- |\n| `{{ .Post.Body }}` | Your post, rendered from Markdown. Required. |\n| `{{ .Post.Subject }}` | The post's subject line. |\n| `{{ .Publication.Name }}` | Your publication's name. |\n| `{{ .Publication.Tagline }}` | Your tagline. |\n| `{{ .Publication.Logo }}` | Your logo as an image, or nothing when no logo is set. |\n| `{{ .Publication.LogoURL }}` | Your logo's address, or empty. |\n| `{{ .Publication.Address }}` | Your mailing address, or empty. |\n| `{{ .Email.SentTo }}` | The address this email was sent to. |\n| `{{ .Email.UnsubscribeURL }}` | This reader's one-click unsubscribe link. Required. |\n| `{{ .Email.ViewInBrowserURL }}` | The post's public page. |\n\n## Test it\n\n**Send test email** on the **Template** page sends a sample post through your saved template, so you see it in a real inbox. With unsaved edits, the button reads **Save & send test**, since a test always uses the saved template.\n\nA change to the template re-makes the email of every scheduled post, as [Schedule and send](04-write-a-post.md#schedule-and-send) describes.\n";
6901
+ var email_template_default = "# Edit your email template\n\nYou write each post in Markdown, and the template is the HTML frame it's sent in, like a layout in a static site generator. `{{ .Post.Body }}` marks where your post goes, and the rest is plain HTML around it. The built-in template works as it comes, so changing it is optional.\n\nTo change it, choose **Template** in the sidebar, edit the HTML, and choose **Save changes**. **Start from example** offers two starting points. **Signed** ends each post with your logo, name, and tagline, and **Plain** has just your post and the footer links.\n\n## What every template needs\n\nKestrel won't save a template without `{{ .Post.Body }}` and `{{ .Email.UnsubscribeURL }}`. Every email has to carry the post and a way to leave. The two **Required** pills above the editor turn green or red as your template gains or loses them.\n\nWrap a part in `{{ if .IsEmail }}` and `{{ end }}` to keep it off each post's public page. The starting templates do this with the unsubscribe link, the View in browser link, and the mailing address.\n\n## Variables\n\nA variable is replaced with its value in each email. Type it exactly as shown, or it renders as empty.\n\n| Variable | What it holds |\n| --- | --- |\n| `{{ .Post.Body }}` | Your post, rendered from Markdown. Required. |\n| `{{ .Post.Subject }}` | The post's subject line. |\n| `{{ .Publication.Name }}` | Your publication's name. |\n| `{{ .Publication.Tagline }}` | Your tagline. |\n| `{{ .Publication.Logo }}` | Your logo as an image, or nothing when no logo is set. |\n| `{{ .Publication.LogoURL }}` | Your logo's address, or empty. |\n| `{{ .Publication.Address }}` | Your mailing address, or empty. |\n| `{{ .Email.SentTo }}` | The address this email was sent to. |\n| `{{ .Email.UnsubscribeURL }}` | This reader's one-click unsubscribe link. Required. |\n| `{{ .Email.ViewInBrowserURL }}` | The post's 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 re-makes the email of every scheduled post, as [Schedule and send](04-write-a-post.md#schedule-and-send) describes.\n";
6369
6902
 
6370
6903
  // docs/publishing/README.md
6371
- var README_default = "# Publishing with Kestrel\n\nThese pages get you publishing in the dashboard, and cover the decisions to make before they matter. Deploying and running Kestrel itself is covered in [Self-hosting](../README.md).\n\n## Set up your publication\n\nThe steps to set up a new publication, in the order the dashboard's setup checklist lists them.\n\n1. [Name your publication](03-name-your-publication.md)\n1. [Check your setup](10-check-your-setup.md)\n1. [Bring your existing list](01-your-list.md)\n1. [Write and send a post](04-write-a-post.md)\n1. [Share your subscribe link](09-share-your-subscribe-link.md)\n\n## Go further\n\nThings you choose to do once you're running.\n\n- [Edit your email template](11-email-template.md)\n- [Working with Claude](07-working-with-claude.md)\n\n## Rules and troubleshooting\n\nWhat the law asks of every send, and what to do when something goes wrong.\n\n- [Sending rules](02-sending-rules.md)\n- [Troubleshooting](06-troubleshooting.md)\n";
6904
+ var README_default = "# Publishing with Kestrel\n\nThese pages get you publishing in the dashboard, and cover the decisions to make before they matter. Deploying and running Kestrel itself is covered in [Self-hosting](../README.md).\n\n## Set up your publication\n\nThe steps to set up a new publication, in the order the dashboard's setup checklist lists them. Once you've started a post, they stay on the dashboard as **Finish setting up** until you publish your first post, one that goes to your subscribers and onto your site. That's the fourth step.\n\n1. [Name your publication](03-name-your-publication.md)\n1. [Check your setup](10-check-your-setup.md)\n1. [Bring your existing list](01-your-list.md)\n1. [Write and send a post](04-write-a-post.md)\n1. [Share your subscribe link](09-share-your-subscribe-link.md)\n\n## Go further\n\nThings you choose to do once you're running.\n\n- [Edit your email template](11-email-template.md)\n- [Working with Claude](07-working-with-claude.md)\n\n## Rules and troubleshooting\n\nWhat the law asks of every send, and what to do when something goes wrong.\n\n- [Sending rules](02-sending-rules.md)\n- [Troubleshooting](06-troubleshooting.md)\n";
6372
6905
 
6373
6906
  // docs/README.md
6374
6907
  var README_default2 = "# Self-hosting Kestrel\n\nThese pages cover deploying and running your own Kestrel, from an empty repository to a live newsletter on your Cloudflare account. Writing and sending the newsletter is covered in [Publishing](publishing/README.md). These pages call whoever writes and sends it the publisher, which may be you.\n\n## Get started\n\nFollow these steps, from an empty repository to a live newsletter. Each one ends with a check, so you know it worked before you move on.\n\n1. [Deploy the app](get-started/02-deploy.md)\n1. [Lock the dashboard with Access](get-started/03-access.md)\n1. [Connect Amazon SES](get-started/04-ses.md)\n1. [Verify it works](get-started/05-verify.md)\n1. [Go live](get-started/06-go-live.md)\n\n## Guides\n\nEach one stands on its own. Pick the ones you need.\n\n- [Connect Claude to the API](guides/01-connect-claude.md)\n- [Notifications through Cloudflare's email](guides/03-notifications.md)\n- [Rate-limit the subscribe form](guides/05-rate-limit.md)\n- [As your list grows](guides/09-as-your-list-grows.md)\n- [Upgrade to a new release](guides/07-upgrade.md)\n\n## Reference\n\nThe detail behind the steps, for when you need it.\n\n- [Configuration](reference/01-configuration.md)\n- [Sending-domain DNS: SPF, DKIM, and DMARC](reference/02-sending-domain-dns.md)\n";
6375
6908
 
6376
6909
  // docs/reference/01-configuration.md
6377
- var configuration_default = "# Configuration\n\nEvery deploy-time setting: what it does, its default, and what the app refuses. Each environment's settings live in its block under `env` in `wrangler.jsonc`, and its secrets in Cloudflare.\n\n- **Vars** are public values in the block's `vars`, safe to commit. A change takes effect when you deploy.\n- **Secrets** are stored with `npx wrangler secret put NAME --env production`, and never committed. A secret takes effect as soon as you store it, with no redeploy.\n- **Bindings and triggers** connect the Worker to its database, its image bucket, and its schedule.\n\nIn local development, `npm run dev` reads the top level of `wrangler.jsonc` and `.dev.vars`, which you copy from `.dev.vars.example`. Restart it after you change either.\n\nNone of these can be read or changed from inside the app, by the editor or by Claude. Settings in the editor shows the public ones, read-only.\n\n## Vars\n\n| Var | What it is | Default |\n| --- | --- | --- |\n| `PROVIDER` | the email provider: `ses`, or `fake` for local development | none: no provider connected |\n| `APP_ORIGIN` | the origin the app is served from: scheme and host, no path | required |\n| `ARCHIVE_BASE_PATH` | the path the archive lives under | `/archive` |\n| `FROM_ADDRESS` | the `From:` of every email | required with a real provider |\n| `SENDING_DOMAIN` | the sending domain, shown in Settings | none |\n| `AWS_REGION` | the SES region | `us-east-1` in the template, required with SES |\n| `SES_MAX_SEND_RATE` | your SES account's maximum send rate, in messages a second | `14` |\n| `SUBREQUEST_BUDGET` | how many subrequests one minute's send work may use | `50` |\n| `MIN_LEAD_SECONDS` | the least time every send stays cancelable before it goes out | `300` |\n| `ARCHIVE_ORIGIN` | the origin archive links use, to put the archive on your website | `APP_ORIGIN` |\n| `MEDIA_PUBLIC_BASE` | the base URL images are served from | `APP_ORIGIN` + `/media` |\n| `NOTIFY_FROM` | the sender of notifications through Cloudflare's email | `Kestrel <kestrel@` + the app's hostname + `>` |\n| `ALLOW_FAKE_PROVIDER` | `true` lets `fake` run on a real hostname, for a demo that mails no one on purpose | unset |\n\n### `PROVIDER`\n\nUntil it's set, or while it's empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, `\"PROVIDER\": \"\"`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.\n\n`fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A hosted demo that runs `fake` on a real hostname on purpose sets `ALLOW_FAKE_PROVIDER` to `true`, which no other deployment needs, and which no other provider reads.\n\n### `FROM_ADDRESS` and `SENDING_DOMAIN`\n\n`FROM_ADDRESS` is the only address mail is sent from, and the identity your provider authenticates. `SENDING_DOMAIN` is for display: Settings shows it beside the From address. The app never checks that the two match, so set `SENDING_DOMAIN` to the part of `FROM_ADDRESS` after the `@`.\n\nNeither one is the publication's name, tagline, or logo. You set those in the editor, under **Settings \u2192 Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, the emails and reader pages show no name at all.\n\n### `ARCHIVE_BASE_PATH`\n\nEvery post sent carries its archive link, `ARCHIVE_BASE_PATH` followed by the post's slug, and those links are permanent. Changing the prefix later breaks every link already mailed, so pick it before the first send. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\nA missing leading slash is added, and a trailing slash is dropped.\n\n### `SES_MAX_SEND_RATE`\n\nThe app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. That's `1` in the sandbox, and whatever AWS grants after that. The default, 14, is a common starting rate, not your account's.\n\nA rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.\n\n### `SUBREQUEST_BUDGET`\n\nCloudflare caps how many subrequests one run of a Worker may make. Database queries and outbound requests both count. It also caps database queries alone: 50 a run on Workers Free, and 1,000 on Workers Paid. Each minute's send work is one run, so a large send goes out over several minutes, each stopping short of both caps.\n\nThis setting is the subrequest cap. The app keeps database queries under theirs on its own.\n\n- **The default, 50,** is the Workers Free limit, and safe on any plan. A minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.\n- **On Workers Paid,** which allows 10,000, set `\"SUBREQUEST_BUDGET\": \"10000\"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.\n- **If you raised the Worker's own limit** with `limits.subrequests`, you may set this to match.\n\nIt's also how the app tells Workers Free from Workers Paid. At the default, the app runs on Free's limits:\n\n- One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more. Above it, up to 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)).\n- An export reads 1,000 rows a page. Above it, 10,000.\n- The app warns before a send to more than 5,000 recipients (fewer on a day with imports), and on an import plan that, with the day's earlier imports, would use most of a day's writes. Either may use up D1's daily allowance of 100,000 rows written, which stops the app until midnight UTC ([As your list grows](../guides/09-as-your-list-grows.md)). Neither warning stops you. Above the default, neither appears.\n\nNever set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n### `MIN_LEAD_SECONDS`\n\nEvery send stays visible and cancelable for at least this long before it goes out, whether it's scheduled, sent now, or moved. It's the window to catch a mistake, including one in a send Claude prepared.\n\nThe floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead a post can be scheduled. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.\n\n### `ARCHIVE_ORIGIN` and `MEDIA_PUBLIC_BASE`\n\nLeave them unset to keep everything on the app's own hostname.\n\n`ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname.\n\n`MEDIA_PUBLIC_BASE` serves images from another base URL, such as a custom domain on the image bucket. The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.\n\n### `NOTIFY_FROM`\n\nUsed only with the `NOTIFY` binding. It must be an address on a domain set up for Cloudflare's email. Without the binding, notifications come from `FROM_ADDRESS`, through your email provider.\n\n## Secrets\n\n| Secret | What it is | Needed for |\n| --- | --- | --- |\n| `ACCESS_TEAM_DOMAIN` | your Zero Trust team domain, such as `your-team.cloudflareaccess.com`; an `https://` or trailing slash is dropped | every deployment |\n| `ACCESS_AUD` | the Access application's audience tag | every deployment |\n| `ACCESS_ALLOWED_EMAILS` | a comma-separated list of the people allowed to sign in | optional |\n| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | the IAM user's access key | SES |\n| `SNS_TOPIC_ARN` | the SNS topic the SES webhook accepts; it refuses every other topic | SES |\n| `SES_CONFIGURATION_SET` | the configuration set named on every email, so SES reports bounces and complaints; a name SES can't find in the region holds every list send until it's fixed | SES, recommended |\n\n`ACCESS_ALLOWED_EMAILS` applies to people only. A service token, such as the one Claude uses, carries no email, so the Access policy alone decides whether it gets in.\n\nNever set `DEV_AUTH_SECRET` on a deployed environment. It's local development's stand-in for Access, and a deployed instance ignores it anyway. The app honors it only when the environment looks like local development: `PROVIDER` is `fake`, Access isn't set up, and `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A real provider, Access, or a real hostname each turns it off, and Access becomes the only way in.\n\n## Bindings and triggers\n\nWrangler doesn't carry bindings or vars over from the top level of `wrangler.jsonc` to an environment. Each environment's block declares its own.\n\n| Setting | What it is | Default |\n| --- | --- | --- |\n| `name` | the Worker's name, such as `kestrel-production` | required |\n| `routes` | the app's hostname, attached on deploy, and any route on your website | required |\n| `workers_dev`, `preview_urls` | the Worker's `workers.dev` and preview addresses | `false` in the template |\n| `triggers.crons` | the once-a-minute schedule, `* * * * *` | required |\n| `observability` | Workers Logs, where the app's log lines land | on |\n| `d1_databases`, binding `DB` | the database: posts, subscribers, sends, and settings | required |\n| `r2_buckets`, binding `MEDIA` | the bucket for images and your logo | required |\n| `send_email`, binding `NOTIFY` | Cloudflare's email, for notifications | none |\n\n### `routes`, `workers_dev`, and `preview_urls`\n\nThe template turns off the `workers.dev` addresses, so the app answers only on your hostname. That's where your Access application and any rate-limiting rule apply. Each deploy replaces the Worker's routes with the ones in the file, so add a route here, never in the dashboard.\n\n### `triggers.crons`\n\nThe schedule runs every minute. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.\n\n### `observability`\n\nEvery invocation is logged, with no sampling. Sampling could drop the one minute that reports a stuck or missed send.\n\n### `NOTIFY`\n\nWhen declared, notifications go through Cloudflare's email instead of your provider, so they still arrive when your provider refuses your account. Local development ignores it, and sends notifications to a stand-in that delivers nothing.\n\n## What the app refuses\n\nThe app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly:\n\n- `PROVIDER`, when set, must be exactly `ses`, `fake`, or `none`. A misspelling, or `SES`, is refused, never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).\n- `fake` runs only when `APP_ORIGIN` is on `localhost` or `127.0.0.1`, unless `ALLOW_FAKE_PROVIDER` is `true`. On a real hostname it would open signups whose confirmations go nowhere, and record every send as delivered. With `fake`, `ALLOW_FAKE_PROVIDER`, when set, must be `true`; every other provider ignores it.\n- `APP_ORIGIN` must be a plain `http` or `https` origin, with no path. So must `ARCHIVE_ORIGIN`, when set. `MEDIA_PUBLIC_BASE`, when set, must be a plain `http` or `https` URL, and may have a path. A trailing slash is dropped from each.\n- A real provider needs `FROM_ADDRESS`, and every secret the table marks for it. SES also needs `AWS_REGION`. `FROM_ADDRESS` must then be an email address, optionally with a display name in front, like `Name <address>`.\n- None of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com` or a name under it, the template's placeholder. That holds with no provider connected too, since every link the app builds would name it. Only `fake` allows it.\n- `SUBREQUEST_BUDGET`, `MIN_LEAD_SECONDS`, and `SES_MAX_SEND_RATE` must be whole numbers above zero. `MIN_LEAD_SECONDS` must also be within its bounds. Any other value is refused, never adjusted. The one exception is a `SUBREQUEST_BUDGET` below 30, which is raised to 30.\n- In local development, `SIMULATE_SENDS` must be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn't matter. A deployed instance ignores it.\n- `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are set together or not at all. With only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one. `ACCESS_TEAM_DOMAIN` must be your team's hostname, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in.\n\nUntil it's fixed, no sends go out, and each place says what its reader can act on:\n\n- **The editor** shows a page naming the setting, what's wrong with it, and how to fix it, with a link to its section here. Its own Docs tab can't load until then, so the link opens the project's site.\n- **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{\"error\":\"invalid_config\",\"message\":\u2026,\"variable\":\"APP_ORIGIN\"}`.\n- **The public pages readers see** answer `503` with a page saying the newsletter is temporarily unavailable. It names no setting.\n- **The Worker's log** records it as `config.invalid`.\n";
6910
+ var configuration_default = "# Configuration\n\nEvery deploy-time setting: what it does, its default, and what the app refuses. Each environment's settings live in its block under `env` in `wrangler.jsonc`, and its secrets in Cloudflare.\n\n- **Vars** are public values in the block's `vars`, safe to commit. A change takes effect when you deploy.\n- **Secrets** are stored with `npx wrangler secret put NAME --env production`, and never committed. A secret takes effect as soon as you store it, with no redeploy.\n- **Bindings and triggers** connect the Worker to its database, its image bucket, and its schedule.\n\nIn local development, `npm run dev` reads the top level of `wrangler.jsonc` and `.dev.vars`, which you copy from `.dev.vars.example`. Restart it after you change either.\n\nNone of these can be read or changed from inside the app, by the editor or by Claude. Settings in the editor shows the public ones, read-only.\n\n## Vars\n\n| Var | What it is | Default |\n| --- | --- | --- |\n| `PROVIDER` | the email provider: `ses`, or `fake` for local development | none: no provider connected |\n| `APP_ORIGIN` | the origin the app is served from: scheme and host, no path | required |\n| `ARCHIVE_BASE_PATH` | the path the archive lives under | `/archive` |\n| `FROM_ADDRESS` | the `From:` of every email | required with a real provider |\n| `SENDING_DOMAIN` | the sending domain, shown in Settings | none |\n| `AWS_REGION` | the SES region | `us-east-1` in the template, required with SES |\n| `SES_MAX_SEND_RATE` | your SES account's maximum send rate, in messages a second | `14` |\n| `SUBREQUEST_BUDGET` | how many subrequests one minute's send work may use | `50` |\n| `MIN_LEAD_SECONDS` | the least time every send stays cancelable before it goes out | `300` |\n| `ARCHIVE_ORIGIN` | the origin archive links use, to put the archive on your website | `APP_ORIGIN` |\n| `MEDIA_PUBLIC_BASE` | the base URL images are served from | `APP_ORIGIN` + `/media` |\n| `NOTIFY_FROM` | the sender of notifications through Cloudflare's email | `Kestrel <kestrel@` + the app's hostname + `>` |\n| `ALLOW_FAKE_PROVIDER` | `true` lets `fake` run on a real hostname, for a demo that mails no one on purpose | unset |\n\n### `PROVIDER`\n\nUntil it's set, or while it's empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, `\"PROVIDER\": \"\"`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.\n\n`fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A hosted demo that runs `fake` on a real hostname on purpose sets `ALLOW_FAKE_PROVIDER` to `true`, which no other deployment needs, and which no other provider reads.\n\n### `FROM_ADDRESS` and `SENDING_DOMAIN`\n\n`FROM_ADDRESS` is the only address mail is sent from, and the identity your provider authenticates. `SENDING_DOMAIN` is for display: Settings shows it beside the From address. The app never checks that the two match, so set `SENDING_DOMAIN` to the part of `FROM_ADDRESS` after the `@`.\n\nNeither one is the publication's name, tagline, or logo. You set those in the editor, under **Settings \u2192 Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, the emails and reader pages show no name at all.\n\n### `ARCHIVE_BASE_PATH`\n\nA listed post's archive address is `ARCHIVE_BASE_PATH` followed by the post's slug. Emails link through each post's unlisted link, `/email/<token>` on `APP_ORIGIN`, which forwards to the archive address, so changing the prefix later breaks no email that carries one, only archive addresses people have shared. Emails sent by 1.11 or earlier carry the archive address itself, and changing the prefix breaks those. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\nA missing leading slash is added, and a trailing slash is dropped.\n\n### `SES_MAX_SEND_RATE`\n\nThe app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. That's `1` in the sandbox, and whatever AWS grants after that. The default, 14, is a common starting rate, not your account's.\n\nA rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.\n\n### `SUBREQUEST_BUDGET`\n\nCloudflare caps how many subrequests one run of a Worker may make. Database queries and outbound requests both count. It also caps database queries alone: 50 a run on Workers Free, and 1,000 on Workers Paid. Each minute's send work is one run, so a large send goes out over several minutes, each stopping short of both caps.\n\nThis setting is the subrequest cap. The app keeps database queries under theirs on its own.\n\n- **The default, 50,** is the Workers Free limit, and safe on any plan. A minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.\n- **On Workers Paid,** which allows 10,000, set `\"SUBREQUEST_BUDGET\": \"10000\"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.\n- **If you raised the Worker's own limit** with `limits.subrequests`, you may set this to match.\n\nIt's also how the app tells Workers Free from Workers Paid. At the default, the app runs on Free's limits:\n\n- One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more. Above it, up to 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)).\n- An export reads 1,000 rows a page. Above it, 10,000.\n- The app warns before a send to more than 5,000 recipients (fewer on a day with imports), and on an import plan that, with the day's earlier imports, would use most of a day's writes. Either may use up D1's daily allowance of 100,000 rows written, which stops the app until midnight UTC ([As your list grows](../guides/09-as-your-list-grows.md)). Neither warning stops you. Above the default, neither appears.\n\nNever set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n### `MIN_LEAD_SECONDS`\n\nEvery send stays visible and cancelable for at least this long before it goes out, whether it's scheduled, sent now, or moved. It's the window to catch a mistake, including one in a send Claude prepared.\n\nThe floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead a post can be scheduled. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.\n\n### `ARCHIVE_ORIGIN` and `MEDIA_PUBLIC_BASE`\n\nLeave them unset to keep everything on the app's own hostname.\n\n`ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname.\n\n`MEDIA_PUBLIC_BASE` serves images from another base URL, such as a custom domain on the image bucket. The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.\n\n### `NOTIFY_FROM`\n\nUsed only with the `NOTIFY` binding. It must be an address on a domain set up for Cloudflare's email. Without the binding, notifications come from `FROM_ADDRESS`, through your email provider.\n\n## Secrets\n\n| Secret | What it is | Needed for |\n| --- | --- | --- |\n| `ACCESS_TEAM_DOMAIN` | your Zero Trust team domain, such as `your-team.cloudflareaccess.com`; an `https://` or trailing slash is dropped | every deployment |\n| `ACCESS_AUD` | the Access application's audience tag | every deployment |\n| `ACCESS_ALLOWED_EMAILS` | a comma-separated list of the people allowed to sign in | optional |\n| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | the IAM user's access key | SES |\n| `SNS_TOPIC_ARN` | the SNS topic the SES webhook accepts; it refuses every other topic | SES |\n| `SES_CONFIGURATION_SET` | the configuration set named on every email, so SES reports bounces and complaints; a name SES can't find in the region holds every list send until it's fixed | SES, recommended |\n\n`ACCESS_ALLOWED_EMAILS` applies to people only. A service token, such as the one Claude uses, carries no email, so the Access policy alone decides whether it gets in.\n\nNever set `DEV_AUTH_SECRET` on a deployed environment. It's local development's stand-in for Access, and a deployed instance ignores it anyway. The app honors it only when the environment looks like local development: `PROVIDER` is `fake`, Access isn't set up, and `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A real provider, Access, or a real hostname each turns it off, and Access becomes the only way in.\n\n## Bindings and triggers\n\nWrangler doesn't carry bindings or vars over from the top level of `wrangler.jsonc` to an environment. Each environment's block declares its own.\n\n| Setting | What it is | Default |\n| --- | --- | --- |\n| `name` | the Worker's name, such as `kestrel-production` | required |\n| `routes` | the app's hostname, attached on deploy, and any route on your website | required |\n| `workers_dev`, `preview_urls` | the Worker's `workers.dev` and preview addresses | `false` in the template |\n| `triggers.crons` | the once-a-minute schedule, `* * * * *` | required |\n| `observability` | Workers Logs, where the app's log lines land | on |\n| `d1_databases`, binding `DB` | the database: posts, subscribers, sends, and settings | required |\n| `r2_buckets`, binding `MEDIA` | the bucket for images and your logo | required |\n| `send_email`, binding `NOTIFY` | Cloudflare's email, for notifications | none |\n\n### `routes`, `workers_dev`, and `preview_urls`\n\nThe template turns off the `workers.dev` addresses, so the app answers only on your hostname. That's where your Access application and any rate-limiting rule apply. Each deploy replaces the Worker's routes with the ones in the file, so add a route here, never in the dashboard.\n\n### `triggers.crons`\n\nThe schedule runs every minute. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.\n\n### `observability`\n\nEvery invocation is logged, with no sampling. Sampling could drop the one minute that reports a stuck or missed send.\n\n### `NOTIFY`\n\nWhen declared, notifications go through Cloudflare's email instead of your provider, so they still arrive when your provider refuses your account. Local development ignores it, and sends notifications to a stand-in that delivers nothing.\n\n## What the app refuses\n\nThe app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly:\n\n- `PROVIDER`, when set, must be exactly `ses`, `fake`, or `none`. A misspelling, or `SES`, is refused, never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).\n- `fake` runs only when `APP_ORIGIN` is on `localhost` or `127.0.0.1`, unless `ALLOW_FAKE_PROVIDER` is `true`. On a real hostname it would open signups whose confirmations go nowhere, and record every send as delivered. With `fake`, `ALLOW_FAKE_PROVIDER`, when set, must be `true`; every other provider ignores it.\n- `APP_ORIGIN` must be a plain `http` or `https` origin, with no path. So must `ARCHIVE_ORIGIN`, when set. `MEDIA_PUBLIC_BASE`, when set, must be a plain `http` or `https` URL, and may have a path. A trailing slash is dropped from each.\n- A real provider needs `FROM_ADDRESS`, and every secret the table marks for it. SES also needs `AWS_REGION`. `FROM_ADDRESS` must then be an email address, optionally with a display name in front, like `Name <address>`.\n- None of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com` or a name under it, the template's placeholder. That holds with no provider connected too, since every link the app builds would name it. Only `fake` allows it.\n- `SUBREQUEST_BUDGET`, `MIN_LEAD_SECONDS`, and `SES_MAX_SEND_RATE` must be whole numbers above zero. `MIN_LEAD_SECONDS` must also be within its bounds. Any other value is refused, never adjusted. The one exception is a `SUBREQUEST_BUDGET` below 30, which is raised to 30.\n- In local development, `SIMULATE_SENDS` must be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn't matter. A deployed instance ignores it.\n- `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are set together or not at all. With only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one. `ACCESS_TEAM_DOMAIN` must be your team's hostname, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in.\n\nUntil it's fixed, no sends go out, and each place says what its reader can act on:\n\n- **The editor** shows a page naming the setting, what's wrong with it, and how to fix it, with a link to its section here. Its own Docs tab can't load until then, so the link opens the project's site.\n- **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{\"error\":\"invalid_config\",\"message\":\u2026,\"variable\":\"APP_ORIGIN\"}`.\n- **The public pages readers see** answer `503` with a page saying the newsletter is temporarily unavailable. It names no setting.\n- **The Worker's log** records it as `config.invalid`.\n";
6378
6911
 
6379
6912
  // docs/reference/02-sending-domain-dns.md
6380
6913
  var sending_domain_dns_default = "# Sending-domain DNS: SPF, DKIM, and DMARC\n\nInboxes decide whether to trust your mail by checking records in the DNS of the domain it comes from. This page explains the three checks they run: SPF, DKIM, and DMARC. It covers the records [Connect Amazon SES](../get-started/04-ses.md) has you add, what each proves, how to check them, and when to tighten DMARC.\n\n## Why all three\n\nGmail requires every sender to pass SPF or DKIM. A sender of more than 5,000 messages a day to Gmail must have all three:\n\n- SPF and DKIM, both passing.\n- A DMARC record, with a policy of at least `p=none`.\n- Alignment: the domain in the `From:` header matches the SPF domain or the DKIM domain.\n\nGmail also requires one-click unsubscribe for such senders. Kestrel adds the `List-Unsubscribe` and `List-Unsubscribe-Post` headers to every email, so that part needs nothing from you. [Google's sender guidelines](https://support.google.com/a/answer/81126) list the rest.\n\nA small list may never reach 5,000 a day, but the same checks decide whether mail lands in the inbox. Make sure all three pass from the start.\n\n## Choose your sending name\n\n- **Send from a subdomain.** It's recommended to send from a subdomain such as `send.example.com`, rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn't put that at risk.\n- **Keep the app's name and the mail's name distinct.** The app and its public pages live at `newsletter.example.com`, and mail comes from `send.example.com`. Avoid `mail.` as the sending name, since mail servers read `mail.example.com` as a host that receives mail.\n\n## The records\n\nEvery record lives in your domain's DNS, `example.com`, under the sending name. In Cloudflare, a record's name leaves off your domain, so `_dmarc.send.example.com` is entered as `_dmarc.send`.\n\nSES generates the DKIM values. Copy them exactly as SES lists them, since they differ by account and region. You write the DMARC record yourself.\n\nSPF needs no record of yours on SES's default path, since it passes for a subdomain of `amazonses.com`. An SPF record, at `bounce.send`, comes only with the optional custom MAIL FROM domain.\n\nEmail Routing adds MX, SPF, and DKIM records at `send` itself, the DKIM one under the selector `cf2024-1`. They're for mail sent *to* your From address, so readers' replies reach the publisher ([Connect Amazon SES](../get-started/04-ses.md#3-receive-replies-at-your-from-address)). They don't affect your sending: inboxes check SES's mail for SPF at its return path, and for DKIM against SES's own keys.\n\n### SPF: authorize the return path\n\nSPF checks the message's return path, the address bounces go back to, not its `From:` header. DMARC passes when SPF or DKIM passes and aligns with the `From:` domain. DKIM aligns, so an aligned SPF is a second check in your favor, not a requirement.\n\nSES uses a subdomain of its own domain, `amazonses.com`, as the return path by default. SPF then passes for it but doesn't align, and DMARC passes through DKIM alone. A custom MAIL FROM domain, such as `bounce.send.example.com`, aligns SPF too. [Connect Amazon SES](../get-started/04-ses.md#4-optional-set-a-custom-mail-from-domain) sets one up, with an MX record that names your region and an SPF record:\n\n```\nbounce.send.example.com. MX 10 feedback-smtp.us-east-1.amazonses.com.\nbounce.send.example.com. TXT \"v=spf1 include:amazonses.com ~all\"\n```\n\nA name holds one SPF record. If one already exists there, add `include:amazonses.com` to it rather than adding a second.\n\n### DKIM: let the provider sign\n\nDKIM signs each message with a key your DNS publishes. An inbox checks the signature to tell the message came from you, unchanged.\n\nSES lists three `CNAME` records, each at `TOKEN._domainkey.send.example.com`. Their targets depend on your region and account, so copy them as SES lists them.\n\nIn Cloudflare, set every `CNAME` for DKIM to **DNS only**, not proxied. SES reports the domain as verified only once the records resolve.\n\n### DMARC: publish a policy and collect reports\n\nDMARC tells an inbox what to do with mail that fails both SPF and DKIM, and asks it to send you reports. It's a `TXT` record at `_dmarc.send.example.com`:\n\n```\n_dmarc.send.example.com. TXT \"v=DMARC1; p=none; rua=mailto:dmarc@example.com\"\n```\n\n- **`p=none`** is monitor mode. Inboxes report on failing mail, but deliver it as usual.\n- **`rua`** is where reports go. They arrive as attachments, usually one a day from each inbox provider.\n\nLeave alignment relaxed, the default, with no `aspf=s` or `adkim=s`. With a custom MAIL FROM domain, your return path is a subdomain of your sending name, so strict SPF alignment would fail.\n\nOnce the reports show your sends passing and aligned, tighten the policy to `p=quarantine`, which sends failing mail to spam. Later, tighten it to `p=reject`, which refuses it.\n\n## Verify alignment\n\nA test email proves alignment better than any DNS lookup. Send yourself one, as in [Verify it works](../get-started/05-verify.md#2-check-that-it-passes-authentication), and read its `Authentication-Results` header. In Gmail, open the message's menu, and select **Show original**. Expect:\n\n- `dkim=pass` for `send.example.com`.\n- `dmarc=pass` for `send.example.com`.\n- `spf=pass`. With a custom MAIL FROM domain, its domain is under `send.example.com`. Without one, it's a subdomain of `amazonses.com`. That's expected, and DMARC still passes.\n";
@@ -6588,6 +7121,74 @@ function markdownResponse(body) {
6588
7121
  return new Response(body, { headers: { "content-type": "text/markdown; charset=utf-8" } });
6589
7122
  }
6590
7123
 
7124
+ // src/routes/post_settings.ts
7125
+ var SLUG_ATTEMPTS = 3;
7126
+ function readSettingsBody(body) {
7127
+ const emailOnly = optBoolean(body, "email_only");
7128
+ const slug = optString(body, "slug");
7129
+ const slugAuto = optBoolean(body, "slug_auto");
7130
+ if (slug !== void 0 && slugAuto === true) {
7131
+ throw fieldError("slug", "slug cannot be set while slug_auto is true");
7132
+ }
7133
+ return { emailOnly, slug, slugAuto };
7134
+ }
7135
+ async function resolveSettings(db, post, body) {
7136
+ const input = { emailOnly: body.emailOnly };
7137
+ if (body.slug === void 0 && body.slugAuto === void 0) {
7138
+ return input;
7139
+ }
7140
+ const fixed = await addressFixed(db, post);
7141
+ if (fixed) {
7142
+ const moves = body.slug !== void 0 && body.slug !== post.slug || body.slugAuto === true;
7143
+ if (moves) {
7144
+ throw slugPermanent(body.slug !== void 0 ? "slug" : "slug_auto", fixed);
7145
+ }
7146
+ return input;
7147
+ }
7148
+ const resolved = await resolveSlug(
7149
+ db,
7150
+ { postId: post.id, slug: post.slug, slugAuto: post.slug_auto, subject: post.subject },
7151
+ { slug: body.slug, slugAuto: body.slugAuto }
7152
+ );
7153
+ if ("refused" in resolved) {
7154
+ throw slugRefusal(resolved.refused);
7155
+ }
7156
+ input.slug = resolved;
7157
+ return input;
7158
+ }
7159
+ async function applySettings(db, post, body, resolved) {
7160
+ let input = resolved;
7161
+ let outcome;
7162
+ for (let attempt = 1; ; attempt++) {
7163
+ try {
7164
+ outcome = await setPublishing(db, post, input);
7165
+ break;
7166
+ } catch (err) {
7167
+ if (!isSlugCollision(err)) {
7168
+ throw err;
7169
+ }
7170
+ if (body.slug !== void 0 || attempt >= SLUG_ATTEMPTS) {
7171
+ throw slugRefusal("slug_taken");
7172
+ }
7173
+ input = await resolveSettings(db, post, body);
7174
+ }
7175
+ }
7176
+ if (outcome === "missing") {
7177
+ throw notFound("post");
7178
+ }
7179
+ if (outcome === "address_fixed") {
7180
+ throw slugPermanent(body.slug !== void 0 ? "slug" : "slug_auto", "sent");
7181
+ }
7182
+ return outcome === "changed";
7183
+ }
7184
+ async function addressFixed(db, post) {
7185
+ if (await publishedSendForPost(db, post.id)) {
7186
+ return "sent";
7187
+ }
7188
+ const active = await getActiveSendForPost(db, post.id);
7189
+ return active?.web_link === "archive" ? "linked" : null;
7190
+ }
7191
+
6591
7192
  // src/routes/posts.ts
6592
7193
  function author(c) {
6593
7194
  return c.principal?.email ?? c.principal?.kind ?? null;
@@ -6616,6 +7217,11 @@ function baseRevision(c, body) {
6616
7217
  }
6617
7218
  return body.base_revision;
6618
7219
  }
7220
+ var UNPUBLISHED = null;
7221
+ async function answered(c, post) {
7222
+ const published = await publicationsFor(c.env.DB, [post.id]);
7223
+ return withAddresses(c.config, post, published.get(post.id) ?? null);
7224
+ }
6619
7225
  function revisionHeaders(post) {
6620
7226
  return post.current_revision ? { ETag: `"${post.current_revision}"` } : void 0;
6621
7227
  }
@@ -6639,8 +7245,15 @@ async function requireDraft(c) {
6639
7245
  }
6640
7246
  async function createPost2(c) {
6641
7247
  const { input } = await readBody(c, { optional: true });
6642
- const { post, revision } = await createPost(c.env.DB, input, author(c));
6643
- const body = { post, revision_id: revision.id };
7248
+ const { post, revision } = await slugGuarded(
7249
+ input,
7250
+ () => createPost(c.env.DB, input, author(c))
7251
+ );
7252
+ const body = {
7253
+ post: withAddresses(c.config, post, UNPUBLISHED),
7254
+ subject_slug_held: await subjectSlugHeld(c.env.DB, post),
7255
+ revision_id: revision.id
7256
+ };
6644
7257
  return json(body, 201, revisionHeaders(post));
6645
7258
  }
6646
7259
  var POST_STATUSES = ["draft", "scheduled", "sent"];
@@ -6669,8 +7282,12 @@ async function listPosts2(c) {
6669
7282
  countPosts(c.env.DB, filter),
6670
7283
  listPosts(c.env.DB, filter, page)
6671
7284
  ]);
7285
+ const published = await publicationsFor(
7286
+ c.env.DB,
7287
+ rows.map((row) => row.id)
7288
+ );
6672
7289
  const body = {
6673
- posts: rows,
7290
+ posts: rows.map((row) => withAddresses(c.config, row, published.get(row.id) ?? null)),
6674
7291
  page: listPage(total, page),
6675
7292
  cursor: encodeSendCursor({ seq, at: now })
6676
7293
  };
@@ -6685,7 +7302,8 @@ async function getPost2(c) {
6685
7302
  const active = post.status === "scheduled" ? await getActiveSendForPost(c.env.DB, post.id) : null;
6686
7303
  const sent = post.status === "sent" ? await latestSentSendForPost(c.env.DB, post.id) : null;
6687
7304
  const body = {
6688
- post,
7305
+ post: await answered(c, post),
7306
+ subject_slug_held: await subjectSlugHeld(c.env.DB, post),
6689
7307
  markdown: revision?.markdown ?? "",
6690
7308
  author: revision?.author ?? null,
6691
7309
  // who wrote the current revision — the freshness poll names them
@@ -6706,7 +7324,7 @@ async function updatePost2(c) {
6706
7324
  error: "base_required",
6707
7325
  message: "A save must name the revision it was based on. Read the post, then save with its current_revision as base_revision or If-Match.",
6708
7326
  field: "base_revision",
6709
- post
7327
+ post: withAddresses(c.config, post, UNPUBLISHED)
6710
7328
  };
6711
7329
  return json(required, 428, revisionHeaders(post));
6712
7330
  }
@@ -6717,12 +7335,21 @@ async function updatePost2(c) {
6717
7335
  message: "This draft changed since you loaded it. Read it again before saving.",
6718
7336
  current_revision: post.current_revision,
6719
7337
  updated_at: post.updated_at,
6720
- author: current2?.author ?? null
7338
+ author: current2?.author ?? null,
7339
+ post: withAddresses(c.config, post, UNPUBLISHED),
7340
+ markdown: current2?.markdown ?? ""
6721
7341
  };
6722
7342
  return json(stale, 409, revisionHeaders(post));
6723
7343
  }
6724
- const { post: updated, revision } = await updatePost(c.env.DB, post, body.input, author(c));
6725
- const saved = { post: updated, revision_id: revision.id };
7344
+ const { post: updated, revision } = await slugGuarded(
7345
+ body.input,
7346
+ () => updatePost(c.env.DB, post, body.input, author(c))
7347
+ );
7348
+ const saved = {
7349
+ post: withAddresses(c.config, updated, UNPUBLISHED),
7350
+ subject_slug_held: await subjectSlugHeld(c.env.DB, updated),
7351
+ revision_id: revision.id
7352
+ };
6726
7353
  return json(saved, 200, revisionHeaders(updated));
6727
7354
  }
6728
7355
  async function deletePost2(c) {
@@ -6771,6 +7398,47 @@ async function getRevision(c) {
6771
7398
  metadata: JSON.parse(rev.metadata)
6772
7399
  });
6773
7400
  }
7401
+ var SLUG_ATTEMPTS2 = 3;
7402
+ async function slugGuarded(input, write) {
7403
+ for (let attempt = 1; ; attempt++) {
7404
+ try {
7405
+ return await write();
7406
+ } catch (err) {
7407
+ if (err instanceof SlugRefused) {
7408
+ throw slugRefusal(err.code);
7409
+ }
7410
+ if (isSlugCollision(err)) {
7411
+ if (input.slug === void 0 && attempt < SLUG_ATTEMPTS2) {
7412
+ continue;
7413
+ }
7414
+ throw slugRefusal("slug_taken");
7415
+ }
7416
+ throw err;
7417
+ }
7418
+ }
7419
+ }
7420
+ async function setSettings(c) {
7421
+ const fields = readSettingsBody(await readJsonObject(c));
7422
+ if (fields.emailOnly === void 0 && fields.slug === void 0 && fields.slugAuto === void 0) {
7423
+ throw fieldError(
7424
+ "email_only",
7425
+ "Name what to change: email_only (true for email only, false to list the post in the archive), slug, or slug_auto"
7426
+ );
7427
+ }
7428
+ const post = await getPost(c.env.DB, param(c, "id"));
7429
+ if (!post) {
7430
+ throw notFound("post");
7431
+ }
7432
+ const input = await resolveSettings(c.env.DB, post, fields);
7433
+ const changed = await applySettings(c.env.DB, post, fields, input);
7434
+ const now = changed ? unwrap(await getPost(c.env.DB, post.id), "post") : post;
7435
+ const answer = {
7436
+ post: await answered(c, now),
7437
+ subject_slug_held: await subjectSlugHeld(c.env.DB, now),
7438
+ changed
7439
+ };
7440
+ return json(answer);
7441
+ }
6774
7442
 
6775
7443
  // src/routes/images.ts
6776
7444
  var MAX_POST_IMAGE_BYTES = 5 * 1024 * 1024;
@@ -7654,6 +8322,20 @@ function sesFromAddress(from) {
7654
8322
  function sesCanAddress(email2) {
7655
8323
  return ASCII_ONLY.test(email2);
7656
8324
  }
8325
+ var LOCAL_PART = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/i;
8326
+ var DOMAIN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i;
8327
+ function sesAddressProblem(email2) {
8328
+ if (!sesCanAddress(email2)) {
8329
+ return "ses can't send to an address with characters outside ASCII";
8330
+ }
8331
+ const at = email2.lastIndexOf("@");
8332
+ const local = email2.slice(0, at);
8333
+ const domain = email2.slice(at + 1);
8334
+ if (at <= 0 || !LOCAL_PART.test(local) || !DOMAIN.test(domain)) {
8335
+ return "ses can't send to this address: it isn't a well-formed address";
8336
+ }
8337
+ return null;
8338
+ }
7657
8339
 
7658
8340
  // src/providers/sns.ts
7659
8341
  var SNS_HOST = /^sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$/;
@@ -7773,6 +8455,8 @@ function spkiFromPem(pem) {
7773
8455
  }
7774
8456
  throw new Error("PEM has neither a PUBLIC KEY nor a CERTIFICATE");
7775
8457
  }
8458
+ var CertUnavailable = class extends Error {
8459
+ };
7776
8460
  async function fetchVerifyKey(certUrl, hash) {
7777
8461
  const cacheKey = `${hash}:${certUrl}`;
7778
8462
  const cached = keyCache.get(cacheKey);
@@ -7788,11 +8472,22 @@ async function fetchVerifyKey(certUrl, hash) {
7788
8472
  if (u.protocol !== "https:" || !isSnsHost(u.hostname)) {
7789
8473
  throw new Error(`untrusted SigningCertURL host: ${u.hostname}`);
7790
8474
  }
7791
- const res = await fetch(certUrl);
8475
+ let res;
8476
+ let pem;
8477
+ try {
8478
+ res = await fetch(certUrl);
8479
+ pem = res.ok ? await res.text() : "";
8480
+ } catch (err) {
8481
+ throw new CertUnavailable(
8482
+ `signing cert fetch failed: ${err instanceof Error ? err.message : String(err)}`
8483
+ );
8484
+ }
8485
+ if (res.status >= 500 || res.status === 429) {
8486
+ throw new CertUnavailable(`signing cert fetch failed: ${res.status}`);
8487
+ }
7792
8488
  if (!res.ok) {
7793
8489
  throw new Error(`signing cert fetch failed: ${res.status}`);
7794
8490
  }
7795
- const pem = await res.text();
7796
8491
  const key = await crypto.subtle.importKey(
7797
8492
  "spki",
7798
8493
  spkiFromPem(pem),
@@ -7803,25 +8498,39 @@ async function fetchVerifyKey(certUrl, hash) {
7803
8498
  keyCache.set(cacheKey, key);
7804
8499
  return key;
7805
8500
  }
7806
- async function verifySnsSignature(msg) {
7807
- if (!msg.Signature || !msg.SigningCertURL) {
8501
+ function isSnsCertUrl(certUrl) {
8502
+ try {
8503
+ const u = new URL(certUrl);
8504
+ return u.protocol === "https:" && isSnsHost(u.hostname);
8505
+ } catch {
7808
8506
  return false;
7809
8507
  }
8508
+ }
8509
+ async function verifySnsSignature(msg) {
8510
+ if (typeof msg.Signature !== "string" || !msg.Signature || typeof msg.SigningCertURL !== "string" || !isSnsCertUrl(msg.SigningCertURL)) {
8511
+ return "unsigned";
8512
+ }
7810
8513
  const hash = msg.SignatureVersion === "2" ? "SHA-256" : msg.SignatureVersion === "1" ? "SHA-1" : null;
7811
8514
  if (!hash) {
7812
- return false;
8515
+ return "unsigned";
7813
8516
  }
7814
8517
  let key;
7815
8518
  try {
7816
8519
  key = await fetchVerifyKey(msg.SigningCertURL, hash);
7817
- } catch {
7818
- return false;
8520
+ } catch (err) {
8521
+ return err instanceof CertUnavailable ? "unavailable" : "invalid";
7819
8522
  }
7820
8523
  try {
7821
8524
  const data = new TextEncoder().encode(canonicalString(msg));
7822
- return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, base64ToBytes(msg.Signature), data);
8525
+ const ok = await crypto.subtle.verify(
8526
+ "RSASSA-PKCS1-v1_5",
8527
+ key,
8528
+ base64ToBytes(msg.Signature),
8529
+ data
8530
+ );
8531
+ return ok ? "valid" : "invalid";
7823
8532
  } catch {
7824
- return false;
8533
+ return "invalid";
7825
8534
  }
7826
8535
  }
7827
8536
  function sesRenderingFailure(messageJson) {
@@ -7892,20 +8601,23 @@ function sesTraits(config) {
7892
8601
  }
7893
8602
  function sesErrorText(status, type, message2) {
7894
8603
  const detail = [type, message2].filter(Boolean).join(": ");
7895
- return `ses ${status}${detail ? ` ${detail}` : ""}`;
8604
+ return redactCredentials(`ses ${status}${detail ? ` ${detail}` : ""}`);
7896
8605
  }
7897
8606
  function sesEntryErrorText(status, error) {
7898
- return `ses ${status}${error ? `: ${error}` : ""}`;
8607
+ return redactCredentials(`ses ${status}${error ? `: ${error}` : ""}`);
7899
8608
  }
7900
8609
  function textResponse(body, status) {
7901
8610
  return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
7902
8611
  }
7903
8612
  var ACCOUNT_ERROR_TYPES = /* @__PURE__ */ new Map([
7904
- // AWS paused the account's sending (it does so on bounce or complaint trouble) or
7905
- // restricted it for good; either way, until the operator acts in the SES console.
7906
- ["SendingPausedException", "suspended"],
8613
+ // AWS restricted the account's sending for good, over its sending practices.
7907
8614
  ["AccountSuspendedException", "suspended"],
7908
- ["MailFromDomainNotVerifiedException", "sender"],
8615
+ // Sending is paused: by AWS under review, or turned off by hand (`sendingPaused` tells a
8616
+ // configuration set's pause apart by SES's words).
8617
+ ["SendingPausedException", "paused"],
8618
+ // SES's API reference says the sending domain isn't verified, its troubleshooting page that
8619
+ // it can't read the custom MAIL FROM domain's MX record; the advice names both.
8620
+ ["MailFromDomainNotVerifiedException", "mail_from"],
7909
8621
  // A resource the request names doesn't exist. Kestrel names only its configuration set
7910
8622
  // (SES_CONFIGURATION_SET), so this is a typo, a set deleted after setup, or the wrong
7911
8623
  // region: the deployment's to fix, and the same for every recipient, so it holds the send
@@ -7913,55 +8625,132 @@ var ACCOUNT_ERROR_TYPES = /* @__PURE__ */ new Map([
7913
8625
  // resource that could be missing, so a 404 then isn't expected, and the advice still points
7914
8626
  // at the one setting that would cause it.
7915
8627
  ["NotFoundException", "configuration"],
7916
- // The credentials themselves: unknown, wrongly signed, expired, or not permitted (a key
7917
- // whose policy lacks `ses:SendBulkEmail` among them).
8628
+ // An action SES doesn't know: the request and the endpoint disagree.
8629
+ ["UnknownOperationException", "unsupported"],
8630
+ // A request SES couldn't parse as HTTP is Kestrel's to fix, never a recipient's.
8631
+ ["MalformedHttpRequestException", "unsupported"],
8632
+ // The AWS account isn't signed up for SES, which the key can't change.
8633
+ ["OptInRequired", "opt_in"],
8634
+ // The credentials themselves: unknown, missing, wrongly or incompletely signed, expired, or
8635
+ // not permitted (a key whose policy lacks `ses:SendBulkEmail` among them). AWS's common
8636
+ // errors carry some of these with a 400, which is why the type, not the status, decides; it
8637
+ // names some both with and without the `Exception` suffix, depending on the front end that
8638
+ // answered, so both spellings are here.
7918
8639
  ["UnrecognizedClientException", "credentials"],
7919
8640
  ["InvalidClientTokenId", "credentials"],
8641
+ ["MissingAuthenticationToken", "credentials"],
8642
+ ["MissingAuthenticationTokenException", "credentials"],
7920
8643
  ["SignatureDoesNotMatch", "credentials"],
8644
+ ["IncompleteSignature", "credentials"],
8645
+ ["IncompleteSignatureException", "credentials"],
8646
+ ["InvalidSignatureException", "credentials"],
8647
+ ["RequestExpired", "credentials"],
7921
8648
  ["ExpiredTokenException", "credentials"],
7922
- ["AccessDeniedException", "credentials"]
8649
+ ["AccessDeniedException", "credentials"],
8650
+ ["NotAuthorized", "credentials"],
8651
+ // The request as a whole is too large: the email itself, the same for every batch of it.
8652
+ ["RequestEntityTooLargeException", "content"]
7923
8653
  ]);
7924
- function classifySesError(status, type, message2 = "", senderUnverified = false) {
8654
+ var UNAVAILABLE_ERROR_TYPES = /* @__PURE__ */ new Set(["RequestTimeoutException", "LimitExceededException"]);
8655
+ var UNKNOWN_FATE_ERROR_TYPES = /* @__PURE__ */ new Set(["RequestAbortedException"]);
8656
+ function sesFateUnknown(status, type) {
8657
+ return status >= 500 || UNKNOWN_FATE_ERROR_TYPES.has(type);
8658
+ }
8659
+ function unverifiedIdentities(message2) {
8660
+ if (!/not verified/i.test(message2)) {
8661
+ return null;
8662
+ }
8663
+ const list5 = /failed the check in region[^:]*:(.*)$/is.exec(message2)?.[1] ?? "";
8664
+ return list5.split(",").map(
8665
+ (id) => id.trim().replace(/(\.\.\.|…|\.)$/, "").trim().toLowerCase()
8666
+ ).filter(Boolean);
8667
+ }
8668
+ function isSenderIdentity(identity, from) {
8669
+ const address = bareAddress(from);
8670
+ if (identity.includes("@")) {
8671
+ return identity === address;
8672
+ }
8673
+ const domain = address.slice(address.lastIndexOf("@") + 1);
8674
+ return identity === domain || domain.endsWith(`.${identity}`);
8675
+ }
8676
+ function verificationCause(message2, from) {
8677
+ const ids = unverifiedIdentities(message2);
8678
+ if (ids === null) {
8679
+ return null;
8680
+ }
8681
+ if (ids.length === 0 || ids.some((id) => isSenderIdentity(id, from))) {
8682
+ return "sender";
8683
+ }
8684
+ return "sandbox";
8685
+ }
8686
+ function classifySesError(status, type, message2 = "", from = "") {
7925
8687
  const throttled = status === 429 || /throttl|toomany/i.test(type);
7926
- const cause = ACCOUNT_ERROR_TYPES.get(type) ?? (status === 404 ? "configuration" : void 0) ?? (senderUnverified ? "sender" : throttled && /daily message quota/i.test(message2) ? "quota" : void 0);
7927
- if (cause || status === 401 || status === 403) {
7928
- return { reason: "account", cause: cause ?? "credentials" };
8688
+ if ((throttled || type === "LimitExceededException") && /daily (message|sending) quota/i.test(message2)) {
8689
+ return account("quota");
8690
+ }
8691
+ const named = ACCOUNT_ERROR_TYPES.get(type);
8692
+ if (named) {
8693
+ return account(named === "paused" && /configuration set/i.test(message2) ? "set_paused" : named);
8694
+ }
8695
+ if (type === "MessageRejected") {
8696
+ return account(verificationCause(message2, from) ?? "content");
8697
+ }
8698
+ if (status === 404) {
8699
+ return account("configuration");
8700
+ }
8701
+ if (status === 401 || status === 403) {
8702
+ return account("credentials");
7929
8703
  }
7930
8704
  if (throttled) {
7931
- return { reason: "unavailable", cause: "rate_limit" };
8705
+ return unavailable("rate_limit");
7932
8706
  }
7933
- if (status >= 500) {
7934
- return { reason: "unavailable", cause: "outage" };
8707
+ if (status === 408 || UNAVAILABLE_ERROR_TYPES.has(type) || status >= 500) {
8708
+ return unavailable("outage");
7935
8709
  }
7936
8710
  return null;
7937
8711
  }
7938
- var account = (cause) => ({
7939
- reason: "account",
7940
- cause
7941
- });
7942
- var unavailable = (cause) => ({
7943
- reason: "unavailable",
7944
- cause
7945
- });
8712
+ function account(cause) {
8713
+ return { reason: "account", cause };
8714
+ }
8715
+ function unavailable(cause) {
8716
+ return { reason: "unavailable", cause };
8717
+ }
7946
8718
  var ENTRY_HALTS = /* @__PURE__ */ new Map([
7947
8719
  ["ACCOUNT_SUSPENDED", account("suspended")],
7948
- ["ACCOUNT_SENDING_PAUSED", account("suspended")],
7949
- ["CONFIGURATION_SET_SENDING_PAUSED", account("suspended")],
7950
- ["MAIL_FROM_DOMAIN_NOT_VERIFIED", account("sender")],
8720
+ ["ACCOUNT_SENDING_PAUSED", account("paused")],
8721
+ ["CONFIGURATION_SET_SENDING_PAUSED", account("set_paused")],
8722
+ ["MAIL_FROM_DOMAIN_NOT_VERIFIED", account("mail_from")],
7951
8723
  ["ACCOUNT_DAILY_QUOTA_EXCEEDED", account("quota")],
7952
8724
  ["CONFIGURATION_SET_DOES_NOT_EXIST", account("configuration")],
7953
8725
  ["CONFIGURATION_SET_NOT_FOUND", account("configuration")],
7954
- ["INVALID_SENDING_POOL_NAME", account("configuration")],
7955
- ["TEMPLATE_DOES_NOT_EXIST", account("configuration")],
7956
- ["TEMPLATE_NOT_FOUND", account("configuration")],
8726
+ ["INVALID_SENDING_POOL_NAME", account("sending_pool")],
8727
+ ["TEMPLATE_DOES_NOT_EXIST", account("unsupported")],
8728
+ ["TEMPLATE_NOT_FOUND", account("unsupported")],
7957
8729
  ["ACCOUNT_THROTTLED", unavailable("rate_limit")],
7958
8730
  ["TRANSIENT_FAILURE", unavailable("outage")]
7959
8731
  ]);
7960
- function classifySesEntry(status, senderUnverified = false) {
7961
- return ENTRY_HALTS.get(status) ?? (senderUnverified ? account("sender") : null);
8732
+ var ENTRY_RECIPIENT_FAILURES = /* @__PURE__ */ new Map([
8733
+ ["INVALID_PARAMETER", { retryable: false }],
8734
+ ["INVALID_PARAMETER_VALUE", { retryable: false }],
8735
+ ["FAILED", { retryable: true }]
8736
+ ]);
8737
+ function classifySesEntry(status, words = "", from = "") {
8738
+ const halt = ENTRY_HALTS.get(status);
8739
+ if (halt) {
8740
+ return { kind: "halt", halt };
8741
+ }
8742
+ if (status === "MESSAGE_REJECTED") {
8743
+ const verification = verificationCause(words, from);
8744
+ return verification ? { kind: "halt", halt: account(verification) } : { kind: "rejected" };
8745
+ }
8746
+ const own = ENTRY_RECIPIENT_FAILURES.get(status);
8747
+ if (own) {
8748
+ return { kind: "recipient", retryable: own.retryable };
8749
+ }
8750
+ return { kind: "unknown" };
7962
8751
  }
7963
8752
  function worse(a, b) {
7964
- return !a || a.reason === "unavailable" && b.reason === "account" ? b : a;
8753
+ return !a || haltRank(b) > haltRank(a) ? b : a;
7965
8754
  }
7966
8755
  var SesProvider = class {
7967
8756
  name = "ses";
@@ -8001,15 +8790,11 @@ var SesProvider = class {
8001
8790
  const out = [];
8002
8791
  const sendable = [];
8003
8792
  for (const r of recipients) {
8004
- if (sesCanAddress(r.email)) {
8793
+ const problem = sesAddressProblem(r.email);
8794
+ if (problem === null) {
8005
8795
  sendable.push(r);
8006
8796
  } else {
8007
- out.push({
8008
- email: r.email,
8009
- accepted: false,
8010
- retryable: false,
8011
- error: "ses can't send to an address with characters outside ASCII"
8012
- });
8797
+ out.push({ email: r.email, accepted: false, retryable: false, error: problem });
8013
8798
  }
8014
8799
  }
8015
8800
  const content = sesTemplateContent(rendered);
@@ -8021,7 +8806,7 @@ var SesProvider = class {
8021
8806
  try {
8022
8807
  answer = opts.purpose === "list" ? await this.sendBulk(content, batch) : await this.sendSingle(content, unwrap(batch[0], "a recipient"));
8023
8808
  } catch (err) {
8024
- if (opts.purpose === "list" || at === 0 && out.length === 0) {
8809
+ if (opts.purpose === "list" || out.length === 0 && sendable.length === batch.length) {
8025
8810
  throw err;
8026
8811
  }
8027
8812
  const words = err instanceof Error ? err.message : String(err);
@@ -8030,11 +8815,17 @@ var SesProvider = class {
8030
8815
  email: r.email,
8031
8816
  accepted: false,
8032
8817
  retryable: false,
8033
- error: `ses gave no answer (${words}), so it may have arrived`
8818
+ error: `ses gave no answer (${words})`,
8819
+ unknown: true
8034
8820
  });
8035
8821
  }
8036
8822
  for (const rest of sendable.slice(at + size)) {
8037
- out.push({ email: rest.email, accepted: false, retryable: true, error: "not sent" });
8823
+ out.push({
8824
+ email: rest.email,
8825
+ accepted: false,
8826
+ retryable: true,
8827
+ error: "not tried, since an earlier request got no answer"
8828
+ });
8038
8829
  }
8039
8830
  break;
8040
8831
  }
@@ -8090,8 +8881,9 @@ var SesProvider = class {
8090
8881
  * (that risks a duplicate, violating I4): it throws, and the send loop leaves its
8091
8882
  * recipients `dispatched` for a human. A request SES never answers is ended the same
8092
8883
  * way: the timeout rejects the fetch, so it throws down that same path. aws4fetch
8093
- * carries `signal` onto the signed Request. A real HTTP error response is unambiguous:
8094
- * SES did NOT accept.
8884
+ * carries `signal` onto the signed Request. An HTTP error response says SES did not
8885
+ * accept, except a 5xx or an aborted request (`sesFateUnknown`), which a list send treats
8886
+ * as no answer.
8095
8887
  */
8096
8888
  post(path2, payload) {
8097
8889
  if (this.configurationSet) {
@@ -8104,18 +8896,14 @@ var SesProvider = class {
8104
8896
  signal: AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS)
8105
8897
  });
8106
8898
  }
8107
- /** An SES error response as a halt, or as SES's words when it is about the message. */
8899
+ /** An SES error response: its type, its halt, or SES's words when it may be about a
8900
+ * recipient. */
8108
8901
  async refusal(res) {
8109
8902
  const bodyText = await res.text().catch(() => "");
8110
8903
  const { type, message: message2 } = parseSesError(bodyText, res.headers.get("x-amzn-ErrorType"));
8111
8904
  const error = sesErrorText(res.status, type, message2);
8112
- const halt = classifySesError(
8113
- res.status,
8114
- type,
8115
- message2,
8116
- type === "MessageRejected" && this.namesSender(message2)
8117
- );
8118
- return { halt: halt ? { ...halt, error } : null, error };
8905
+ const halt = classifySesError(res.status, type, message2, this.from);
8906
+ return { type, halt: halt ? { ...halt, error } : null, error };
8119
8907
  }
8120
8908
  /** One SendEmail with the inline template, for a one-off message to one recipient. */
8121
8909
  async sendSingle(content, r) {
@@ -8160,14 +8948,14 @@ var SesProvider = class {
8160
8948
  }))
8161
8949
  });
8162
8950
  if (!res.ok) {
8163
- const { halt: halt2, error } = await this.refusal(res);
8951
+ const { type, halt: halt2, error } = await this.refusal(res);
8952
+ if (sesFateUnknown(res.status, type)) {
8953
+ throw new Error(`${error} (SES may have taken the batch before answering)`);
8954
+ }
8164
8955
  if (halt2) {
8165
8956
  return { kind: "halted", halt: halt2 };
8166
8957
  }
8167
- return {
8168
- kind: "answered",
8169
- results: batch.map((r) => ({ email: r.email, accepted: false, retryable: false, error }))
8170
- };
8958
+ return this.invalidBatch(batch, error);
8171
8959
  }
8172
8960
  let entries = [];
8173
8961
  try {
@@ -8178,6 +8966,8 @@ var SesProvider = class {
8178
8966
  }
8179
8967
  const results = [];
8180
8968
  const heldBack = [];
8969
+ const rejected = [];
8970
+ const own = [];
8181
8971
  let halt = null;
8182
8972
  let short = false;
8183
8973
  for (const [i, r] of batch.entries()) {
@@ -8192,16 +8982,45 @@ var SesProvider = class {
8192
8982
  continue;
8193
8983
  }
8194
8984
  const words = entry2.Error ?? "";
8195
- const error = sesEntryErrorText(status || "FAILED", words);
8196
- const entryHalt = classifySesEntry(
8197
- status,
8198
- status === "MESSAGE_REJECTED" && this.namesSender(words)
8199
- );
8200
- if (entryHalt) {
8201
- halt = worse(halt, { ...entryHalt, error });
8985
+ const error = sesEntryErrorText(status || "no status", words);
8986
+ const verdict = classifySesEntry(status, words, this.from);
8987
+ if (verdict.kind === "unknown") {
8988
+ log.warn("provider.unrecognized", { provider: "ses", status: status || null, error });
8989
+ short = true;
8990
+ } else if (verdict.kind === "halt") {
8991
+ halt = worse(halt, { ...verdict.halt, error });
8202
8992
  heldBack.push({ email: r.email, error });
8203
- } else {
8993
+ } else if (namesAddress(words, r.email)) {
8204
8994
  results.push({ email: r.email, accepted: false, retryable: false, error });
8995
+ } else if (verdict.kind === "rejected") {
8996
+ rejected.push({ email: r.email, error });
8997
+ } else {
8998
+ own.push({ email: r.email, error, status, retryable: verdict.retryable });
8999
+ }
9000
+ }
9001
+ const tookAny = results.some((r) => r.accepted);
9002
+ if (rejected.length > 0 && !tookAny) {
9003
+ halt = worse(halt, {
9004
+ reason: "account",
9005
+ cause: "content",
9006
+ error: unwrap(rejected[0], "a rejection").error
9007
+ });
9008
+ heldBack.push(...rejected);
9009
+ } else {
9010
+ for (const r of rejected) {
9011
+ results.push({ email: r.email, accepted: false, retryable: false, error: r.error });
9012
+ }
9013
+ }
9014
+ for (const r of own) {
9015
+ const alike = own.filter((o) => o.status === r.status).length;
9016
+ if (alike > 1 && !tookAny) {
9017
+ halt = worse(halt, {
9018
+ ...r.status === "FAILED" ? unavailable("outage") : account("invalid_request"),
9019
+ error: r.error
9020
+ });
9021
+ heldBack.push({ email: r.email, error: r.error });
9022
+ } else {
9023
+ results.push({ email: r.email, accepted: false, retryable: r.retryable, error: r.error });
8205
9024
  }
8206
9025
  }
8207
9026
  if (halt && !short && !results.some((r) => r.accepted)) {
@@ -8221,11 +9040,32 @@ var SesProvider = class {
8221
9040
  }
8222
9041
  return { kind: "answered", results, halt };
8223
9042
  }
8224
- /** Whether SES's "not verified" words name this deployment's own sender, which makes a
8225
- * rejection the operator's (a sandbox account, or a sending identity that lapsed)
8226
- * rather than the recipient's. */
8227
- namesSender(message2) {
8228
- return /not verified/i.test(message2) && message2.toLowerCase().includes(bareAddress(this.from));
9043
+ /**
9044
+ * A list batch SES refused as invalid, for a reason that may be one recipient's (a bad
9045
+ * address SES's syntax check refuses) rather than the email's. A batch of one is that
9046
+ * recipient's own, permanently. In a larger batch, a recipient SES's words name is
9047
+ * refused alone and the rest go back to the queue, untouched, for the next request; with
9048
+ * no one named, no one can be blamed, so the send holds for a person to read SES's words
9049
+ * (`invalid_request`), rather than mark a whole batch unsent for one address.
9050
+ */
9051
+ invalidBatch(batch, error) {
9052
+ if (batch.length === 1) {
9053
+ const only = unwrap(batch[0], "a recipient");
9054
+ return {
9055
+ kind: "answered",
9056
+ results: [{ email: only.email, accepted: false, retryable: false, error }]
9057
+ };
9058
+ }
9059
+ const named = batch.filter((r) => namesAddress(error, r.email));
9060
+ if (named.length === 0) {
9061
+ return { kind: "halted", halt: { reason: "account", cause: "invalid_request", error } };
9062
+ }
9063
+ return {
9064
+ kind: "answered",
9065
+ results: batch.map(
9066
+ (r) => named.includes(r) ? { email: r.email, accepted: false, retryable: false, error } : { email: r.email, accepted: false, retryable: true, error: "not sent", held: true }
9067
+ )
9068
+ };
8229
9069
  }
8230
9070
  async parseWebhook(req, env) {
8231
9071
  const expectedTopic = env.SNS_TOPIC_ARN?.trim();
@@ -8238,11 +9078,27 @@ var SesProvider = class {
8238
9078
  } catch {
8239
9079
  return { events: [], response: textResponse("invalid json", 400) };
8240
9080
  }
8241
- if (!await verifySnsSignature(msg)) {
9081
+ if (typeof msg !== "object" || msg === null) {
9082
+ return { events: [], response: textResponse("invalid json", 400) };
9083
+ }
9084
+ const topic = typeof msg.TopicArn === "string" ? msg.TopicArn.slice(0, 256) : null;
9085
+ const refuse2 = (reason, body) => {
9086
+ log.error("webhook.refused", { provider: "ses", reason, topic, expected: expectedTopic });
9087
+ return { events: [], response: textResponse(body, 403) };
9088
+ };
9089
+ const signature = await verifySnsSignature(msg);
9090
+ if (signature === "unavailable") {
9091
+ log.warn("webhook.cert_unavailable", { provider: "ses", topic });
9092
+ return { events: [], response: textResponse("signing certificate unavailable", 503) };
9093
+ }
9094
+ if (signature === "unsigned") {
8242
9095
  return { events: [], response: textResponse("invalid signature", 403) };
8243
9096
  }
9097
+ if (signature !== "valid") {
9098
+ return refuse2("signature", "invalid signature");
9099
+ }
8244
9100
  if (msg.TopicArn !== expectedTopic) {
8245
- return { events: [], response: textResponse("unexpected topic", 403) };
9101
+ return refuse2("topic", "unexpected topic");
8246
9102
  }
8247
9103
  switch (msg.Type) {
8248
9104
  case "SubscriptionConfirmation": {
@@ -8303,6 +9159,20 @@ function bareAddress(from) {
8303
9159
  const angle = /<([^>]+)>/.exec(from);
8304
9160
  return (angle?.[1] ?? from).trim().toLowerCase();
8305
9161
  }
9162
+ var ADDRESS_CHAR = /[a-z0-9.!#$%&'*+/=?^_`{|}~@-]/i;
9163
+ function namesAddress(words, email2) {
9164
+ const text = words.toLowerCase();
9165
+ const target = email2.toLowerCase();
9166
+ for (let at = text.indexOf(target); at >= 0; at = text.indexOf(target, at + 1)) {
9167
+ const before = text[at - 1];
9168
+ const after = text.slice(at + target.length, at + target.length + 2);
9169
+ const endsHere = after === "" || !ADDRESS_CHAR.test(after[0] ?? "") || /^\.(\s|$)/.test(after);
9170
+ if ((before === void 0 || !ADDRESS_CHAR.test(before)) && endsHere) {
9171
+ return true;
9172
+ }
9173
+ }
9174
+ return false;
9175
+ }
8306
9176
 
8307
9177
  // src/services/webhook_events.ts
8308
9178
  async function applyDeliveryEvents(db, events) {
@@ -8760,6 +9630,13 @@ async function requestSubscription(c, email2) {
8760
9630
  created
8761
9631
  );
8762
9632
  log.warn("subscribe.confirmation_refused", { provider: c.config.provider, error: sent.error });
9633
+ if (sent.cause === "sandbox") {
9634
+ try {
9635
+ await sandboxNotice(db, now);
9636
+ } catch (err) {
9637
+ log.error("subscribe.failed", { provider: c.config.provider, error: errorText(err) });
9638
+ }
9639
+ }
8763
9640
  return { kind: "failed", delivered: "no", error: sent.error };
8764
9641
  }
8765
9642
  const armed = await armConfirmation(db, subscriber.id, token2, now, subscriber.status);
@@ -8803,7 +9680,7 @@ async function sendConfirmation(c, message2, subscriber, attemptAt) {
8803
9680
  return { delivered: "unknown", error: err instanceof Error ? err.message : String(err) };
8804
9681
  }
8805
9682
  if (result.kind === "halted") {
8806
- return { delivered: "no", error: result.halt.error };
9683
+ return { delivered: "no", error: result.halt.error, cause: result.halt.cause };
8807
9684
  }
8808
9685
  const [answer] = result.results;
8809
9686
  if (!answer) {
@@ -9162,6 +10039,81 @@ var FakeNotifier = class {
9162
10039
  }
9163
10040
  };
9164
10041
 
10042
+ // src/services/test_send.ts
10043
+ async function sendTest(provider, email2, recipients, opts, kind) {
10044
+ let answer;
10045
+ try {
10046
+ answer = await provider.sendBatch(email2, recipients, opts);
10047
+ } catch (err) {
10048
+ const error = err instanceof Error ? err.message : String(err);
10049
+ const notSent = noAnswer(error);
10050
+ logNotSent(provider, kind, recipients.length, notSent);
10051
+ return recipients.map((r) => ({ email: r.email, sent: false, notSent }));
10052
+ }
10053
+ const halt = answer.halt;
10054
+ const unknown = [];
10055
+ const refused = [];
10056
+ const answers = perRecipient(answer, recipients).map((r) => {
10057
+ if (r.accepted) {
10058
+ return { email: r.email, sent: true };
10059
+ }
10060
+ const covering = answer.kind === "halted" || r.held ? halt : void 0;
10061
+ const notSent = why(r, covering);
10062
+ if (notSent.may_have_arrived) {
10063
+ unknown.push(notSent);
10064
+ } else if (covering || !r.retryable) {
10065
+ refused.push(notSent);
10066
+ }
10067
+ return { email: r.email, sent: false, notSent };
10068
+ });
10069
+ if (unknown[0]) {
10070
+ logNotSent(provider, kind, unknown.length, unknown[0]);
10071
+ }
10072
+ if (refused[0]) {
10073
+ logNotSent(provider, kind, refused.length, refused[0]);
10074
+ }
10075
+ return answers;
10076
+ }
10077
+ function noAnswer(error) {
10078
+ return {
10079
+ may_have_arrived: true,
10080
+ error,
10081
+ message: `The provider didn\u2019t answer, so the test may have arrived: ${sentence(error)} Check the inbox before sending it again.`
10082
+ };
10083
+ }
10084
+ function why(r, halt) {
10085
+ if (r.unknown) {
10086
+ return noAnswer(r.error);
10087
+ }
10088
+ const error = halt?.error ?? r.error;
10089
+ const refused = (message2) => ({ may_have_arrived: false, error, message: message2 });
10090
+ if (halt?.reason === "account") {
10091
+ return refused(
10092
+ `The provider refused it: ${sentence(error)} ${refusalAdvice(halt.cause)} Sending the test again gets the same answer until that is fixed.`
10093
+ );
10094
+ }
10095
+ if (halt) {
10096
+ return refused(
10097
+ `The provider can\u2019t take mail right now: ${sentence(error)} Send the test again in a few minutes.`
10098
+ );
10099
+ }
10100
+ if (r.retryable) {
10101
+ return refused(`It wasn\u2019t sent: ${sentence(error)} Send the test again to try it.`);
10102
+ }
10103
+ return refused(
10104
+ `The provider refused it: ${sentence(error)} Sending to this address again gets the same answer until what the provider names is fixed.`
10105
+ );
10106
+ }
10107
+ function logNotSent(provider, kind, count, first) {
10108
+ log.warn(first.may_have_arrived ? "test.unknown" : "test.refused", {
10109
+ provider: provider.name,
10110
+ test: kind.test,
10111
+ postId: kind.postId,
10112
+ count,
10113
+ error: first.error
10114
+ });
10115
+ }
10116
+
9165
10117
  // src/routes/render_actions.ts
9166
10118
  async function loadRenderInput(c) {
9167
10119
  const post = await getPost(c.env.DB, param(c, "id"));
@@ -9185,7 +10137,9 @@ async function loadPostEmail(c) {
9185
10137
  return {
9186
10138
  input,
9187
10139
  email: { subject: send.subject, html: send.rendered_html, text: send.rendered_text },
9188
- warnings: [],
10140
+ // What the render said when it froze this copy, kept with it: the copy is what goes
10141
+ // out, so a test of it reports what it found (SPEC §5).
10142
+ warnings: renderWarnings(send),
9189
10143
  frozen: { id: send.id },
9190
10144
  dateMs: send.started_at ?? send.fire_at
9191
10145
  };
@@ -9193,7 +10147,7 @@ async function loadPostEmail(c) {
9193
10147
  const result = await render(input, c.config, await loadBranding(c));
9194
10148
  return { input, email: result, warnings: result.warnings, frozen: null, dateMs: Date.now() };
9195
10149
  }
9196
- function postPage(html) {
10150
+ function postPage2(html) {
9197
10151
  return new Response(html, {
9198
10152
  headers: {
9199
10153
  ...POST_PAGE_SECURITY_HEADERS,
@@ -9220,11 +10174,11 @@ async function previewPage(c) {
9220
10174
  ".Email.UnsubscribeURL": testUnsubscribeUrl(c.config),
9221
10175
  ".Email.SentTo": ""
9222
10176
  }).html;
9223
- return postPage(html);
10177
+ return postPage2(html);
9224
10178
  }
9225
10179
  async function webPage(c) {
9226
10180
  const { email: email2, dateMs } = await loadPostEmail(c);
9227
- return postPage(
10181
+ return postPage2(
9228
10182
  webVersion(email2.html, {
9229
10183
  config: c.config,
9230
10184
  identity: await readerIdentity(c, c.config),
@@ -9247,19 +10201,24 @@ async function test(c) {
9247
10201
  const provider = getProvider(c.config, c.env);
9248
10202
  const unsubscribeUrl = testUnsubscribeUrl(c.config);
9249
10203
  const recipients = [{ email: to, unsubscribeUrl }];
9250
- const [res] = perRecipient(
9251
- await provider.sendBatch(email2, recipients, {
10204
+ const [res] = await sendTest(
10205
+ provider,
10206
+ email2,
10207
+ recipients,
10208
+ {
9252
10209
  purpose: "test",
9253
10210
  tag: `test-${input.post.id}`,
9254
10211
  batchKey: `test-${input.post.id}-${crypto.randomUUID()}`
9255
- }),
9256
- recipients
10212
+ },
10213
+ { test: "post", postId: input.post.id }
9257
10214
  );
9258
- if (frozen && res?.accepted === true) {
10215
+ const sent = res?.sent === true;
10216
+ if (frozen && sent) {
9259
10217
  await markTested(c.env.DB, frozen.id, readAt, Date.now());
9260
10218
  }
9261
10219
  const result = {
9262
- sent: res?.accepted === true,
10220
+ sent,
10221
+ not_sent: res && !res.sent ? res.notSent : null,
9263
10222
  provider: provider.name,
9264
10223
  to,
9265
10224
  subject: email2.subject,
@@ -9291,8 +10250,11 @@ function sampleRenderInput(subject = TEMPLATE_TEST_SUBJECT) {
9291
10250
  id: "sample",
9292
10251
  slug: "sample-post",
9293
10252
  slug_auto: false,
10253
+ // No post has this token, so the sample's "view in browser" opens "Nothing here yet".
10254
+ unlisted_token: "sample",
9294
10255
  subject,
9295
10256
  status: "draft",
10257
+ email_only: false,
9296
10258
  current_revision: "sample-rev",
9297
10259
  created_at: now,
9298
10260
  updated_at: now
@@ -9360,19 +10322,19 @@ async function templateTest(c) {
9360
10322
  const unsubscribeUrl = testUnsubscribeUrl(c.config);
9361
10323
  const testId = `template-test-${crypto.randomUUID()}`;
9362
10324
  const batch = recipients.map((email2) => ({ email: email2, unsubscribeUrl }));
9363
- const results = perRecipient(
9364
- await provider.sendBatch(result, batch, {
9365
- purpose: "test",
9366
- tag: testId,
9367
- batchKey: testId
9368
- }),
9369
- batch
10325
+ const answers = await sendTest(
10326
+ provider,
10327
+ result,
10328
+ batch,
10329
+ { purpose: "test", tag: testId, batchKey: testId },
10330
+ { test: "template" }
9370
10331
  );
9371
- const failed = results.filter((r) => !r.accepted).map((r) => r.email);
10332
+ const notSent = answers.flatMap((a) => a.sent ? [] : [{ email: a.email, ...a.notSent }]);
9372
10333
  const report = {
9373
- sent: results.filter((r) => r.accepted).length,
10334
+ sent: answers.length - notSent.length,
9374
10335
  total: recipients.length,
9375
- failed,
10336
+ failed: notSent.map((n) => n.email),
10337
+ not_sent: notSent,
9376
10338
  provider: provider.name,
9377
10339
  recipients,
9378
10340
  subject: result.subject,
@@ -9542,45 +10504,56 @@ async function schedule(c) {
9542
10504
  }
9543
10505
  const body = await readJsonObject(c);
9544
10506
  rejectStrayTemplateChoice(body);
10507
+ const fields = readSettingsBody(body);
9545
10508
  requireConnectedProvider(c.config);
9546
10509
  const fireAt = parseFutureFireAt(body.fire_at, c.config.minLeadMs);
9547
- const send = await freeze(c.env, c.config, post, fireAt);
9548
- const frozen = unwrap(await viewWithCursor(c.env, send.id), "send");
9549
- return json(frozen, 201);
10510
+ const settings = await resolveSettings(c.env.DB, post, fields);
10511
+ const send = await freeze(c.env, c.config, post, fireAt, settings);
10512
+ return json(await frozenAnswer(c, send), 201);
9550
10513
  }
9551
10514
  async function sendNow(c) {
9552
10515
  const post = await getPost(c.env.DB, param(c, "id"));
9553
10516
  if (!post) {
9554
10517
  throw notFound("post");
9555
10518
  }
9556
- rejectStrayTemplateChoice(await readJsonObject(c, { optional: true }));
10519
+ const body = await readJsonObject(c, { optional: true });
10520
+ rejectStrayTemplateChoice(body);
10521
+ const fields = readSettingsBody(body);
9557
10522
  const active = await getActiveSendForPost(c.env.DB, post.id);
9558
10523
  if (active) {
9559
- return repeatAnswer(c, active.id);
10524
+ return repeatAnswer(c, active, fields);
9560
10525
  }
9561
10526
  requireConnectedProvider(c.config);
9562
10527
  const now = Date.now();
9563
10528
  const fireAt = acceptFireAt(now + c.config.minLeadMs, c.config.minLeadMs, { now });
10529
+ const settings = await resolveSettings(c.env.DB, post, fields);
9564
10530
  let send;
9565
10531
  try {
9566
- send = await freeze(c.env, c.config, post, fireAt);
10532
+ send = await freeze(c.env, c.config, post, fireAt, settings);
9567
10533
  } catch (err) {
9568
10534
  if (err instanceof HttpError && err.code === "active_send_exists") {
9569
10535
  const winner = await getActiveSendForPost(c.env.DB, post.id);
9570
10536
  if (winner) {
9571
- return repeatAnswer(c, winner.id);
10537
+ return repeatAnswer(c, winner, fields);
9572
10538
  }
9573
10539
  }
9574
10540
  throw err;
9575
10541
  }
9576
- const frozen = unwrap(await viewWithCursor(c.env, send.id), "send");
9577
- return json(frozen, 201);
10542
+ return json(await frozenAnswer(c, send), 201);
9578
10543
  }
9579
- async function repeatAnswer(c, sendId) {
9580
- const repeat = {
9581
- ...unwrap(await viewWithCursor(c.env, sendId), "send"),
9582
- idempotent: true
10544
+ async function frozenAnswer(c, send) {
10545
+ return {
10546
+ ...unwrap(await viewWithCursor(c.env, send.id), "send"),
10547
+ warnings: renderWarnings(send)
9583
10548
  };
10549
+ }
10550
+ async function repeatAnswer(c, send, fields) {
10551
+ const post = unwrap(await getPost(c.env.DB, param(c, "id")), "post");
10552
+ const differs = fields.emailOnly !== void 0 && fields.emailOnly !== post.email_only || fields.slug !== void 0 && fields.slug !== post.slug || fields.slugAuto !== void 0 && fields.slugAuto !== post.slug_auto;
10553
+ if (differs) {
10554
+ throw await activeSendExists(c.env, send);
10555
+ }
10556
+ const repeat = { ...await frozenAnswer(c, send), idempotent: true };
9584
10557
  return json(repeat);
9585
10558
  }
9586
10559
 
@@ -9748,7 +10721,11 @@ async function feed(c) {
9748
10721
  now,
9749
10722
  since,
9750
10723
  now - SETTLE_FOLLOW_MS,
9751
- { missedMs: MISSED_THRESHOLD_MS, stuckMs: STUCK_THRESHOLD_MS },
10724
+ {
10725
+ missedMs: MISSED_THRESHOLD_MS,
10726
+ stuckMs: STUCK_THRESHOLD_MS,
10727
+ noReceiptsMs: NO_RECEIPTS_AFTER_MS
10728
+ },
9752
10729
  limit,
9753
10730
  now - BOUNCE_SPIKE_RECENT_MS
9754
10731
  );
@@ -9864,16 +10841,28 @@ async function cancel2(c) {
9864
10841
  }
9865
10842
  async function reschedule2(c) {
9866
10843
  const body = await readJsonObject(c);
10844
+ rejectSettingsFields(body);
9867
10845
  const fireAt = parseFireAt(body.fire_at);
9868
10846
  const id = param(c, "id");
9869
10847
  const { changed } = await reschedule(c.env, id, fireAt, c.config.minLeadMs);
9870
10848
  const response = { ...await answerWith(c, id), changed };
9871
10849
  return json(response);
9872
10850
  }
10851
+ function rejectSettingsFields(body) {
10852
+ for (const field of ["email_only", "slug", "slug_auto"]) {
10853
+ if (field in body) {
10854
+ throw fieldError(
10855
+ field,
10856
+ `${field} is not a field of a reschedule, which moves only the send time: change a post\u2019s settings with PUT /api/posts/:id/settings`
10857
+ );
10858
+ }
10859
+ }
10860
+ }
9873
10861
  async function resolve(c) {
9874
10862
  const request = await readJsonObject(c);
9875
10863
  const outcome = oneOf(request, "resolution", ["unsent", "accepted"]);
9876
- const actor = c.principal?.email ?? "service";
10864
+ const principal = unwrap(c.principal, "principal");
10865
+ const actor = principal.email ?? (principal.tokenId ? `service ${principal.tokenId}` : "service");
9877
10866
  const id = param(c, "id");
9878
10867
  const { resolved, completed } = await resolveStuckSend(c.env, id, outcome, actor);
9879
10868
  const body = { ...await answerWith(c, id), resolved, completed };
@@ -9989,7 +10978,18 @@ function composeNotification(n, config) {
9989
10978
  [
9990
10979
  `No email provider is connected to this deployment, so ${title} is held where it is.`,
9991
10980
  refusalAdvice(n.halt_cause),
9992
- "No one has been marked unsent. 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."
10981
+ "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."
10982
+ ],
10983
+ link
10984
+ );
10985
+ }
10986
+ if (isSendOwnHalt(n.halt_cause)) {
10987
+ return message(
10988
+ `Problem with ${title}: the provider is refusing it`,
10989
+ [
10990
+ `The email provider is refusing ${title} itself, so it is paused where it is. The provider said: ${n.halt_error || "no detail given"}`,
10991
+ refusalAdvice(n.halt_cause),
10992
+ "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."
9993
10993
  ],
9994
10994
  link
9995
10995
  );
@@ -9999,16 +10999,16 @@ function composeNotification(n, config) {
9999
10999
  [
10000
11000
  `The email provider is refusing this account, so ${title} is paused where it is. The provider said: ${n.halt_error || "no detail given"}`,
10001
11001
  refusalAdvice(n.halt_cause),
10002
- "No one has been marked unsent. 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."
11002
+ "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."
10003
11003
  ],
10004
11004
  link
10005
11005
  );
10006
11006
  case "stuck":
10007
11007
  return message(
10008
- `Problem with ${title}: still sending after ${minutes2(STUCK_THRESHOLD_MS)} minutes`,
11008
+ `Problem with ${title}: nothing has gone out for ${minutes2(STUCK_THRESHOLD_MS)} minutes`,
10009
11009
  [
10010
- `${title} started sending${n.started_at ? ` at ${when(n.started_at)}` : ""} and is still going: ${n.c_pending + n.c_in_flight} recipients have not been sent yet.`,
10011
- "It keeps retrying on its own. If it stays like this, check the provider's status and the send's page."
11010
+ `${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.`,
11011
+ "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."
10012
11012
  ],
10013
11013
  link
10014
11014
  );
@@ -10061,6 +11061,17 @@ function sampleNotification(config) {
10061
11061
  dashboardLink(config.appOrigin, "/settings")
10062
11062
  );
10063
11063
  }
11064
+ function composeSandboxNotification(config) {
11065
+ return message(
11066
+ "A signup's confirmation was refused: SES is still in the sandbox",
11067
+ [
11068
+ "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.",
11069
+ refusalAdvice("sandbox"),
11070
+ "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."
11071
+ ],
11072
+ dashboardLink(config.appOrigin, "/subscribers")
11073
+ );
11074
+ }
10064
11075
 
10065
11076
  // src/send/remake.ts
10066
11077
  function insideLead(scheduled, minLeadMs, now) {
@@ -10363,9 +11374,9 @@ async function deleteLogo(c) {
10363
11374
  }
10364
11375
  function readPatch(o) {
10365
11376
  const patch = {};
10366
- const testRecipients = optStringList(o, "testRecipients");
10367
- if (testRecipients !== void 0) {
10368
- patch.testRecipients = testRecipients;
11377
+ const testRecipients2 = optStringList(o, "testRecipients");
11378
+ if (testRecipients2 !== void 0) {
11379
+ patch.testRecipients = testRecipients2;
10369
11380
  }
10370
11381
  const p = optObject(o, "publication");
10371
11382
  if (p !== void 0) {
@@ -10610,7 +11621,7 @@ function createRouter({
10610
11621
  }) {
10611
11622
  const devOnly = (...defs) => devMode ? defs : [];
10612
11623
  const lead = `${formatLead(minLeadMs)} on this deployment (\`deployment.minLeadMs\` in \`GET /api/settings\`); a deployment sets it with \`MIN_LEAD_SECONDS\`, never below ${formatLead(MIN_LEAD_FLOOR_MS)}`;
10613
- const conditionsText = `\`conditions\` is what is wrong with the send, or worth knowing, now (SPEC \xA78, \xA712), derived by the server from the send and the same on every route, most severe first: \`missed\` (still scheduled ${MISSED_THRESHOLD_MS / 6e4} minutes past its fire time: the sweep is not running), \`stuck\` (still sending ${STUCK_THRESHOLD_MS / 6e4} minutes after it started, whatever the cause), \`wedged\` (\`count\` recipients whose delivery is unknown, settled by Resolve), \`refused\` (the provider refusing the account, with its \`cause\`, its own words as \`error\`, the \`advice\` for the fix, which is outside this API, and \`retry_at\`; the send resumes on its own once the account is fixed, having consumed no one), \`provider_unavailable\` (an outage or a rate limit, retried on its own at \`retry_at\`), \`bounce_spike\` (a send finished within ${BOUNCE_SPIKE_RECENT_MS / 864e5} days whose confirmed bounces reached ${BOUNCE_SPIKE_RATE * 100}% of its audience at fire, at least ${BOUNCE_SPIKE_MIN}; \`bounced\` and \`rate\`), \`remade\` (a template or identity change re-made the scheduled email after its last test, \`tested_at\`; a test of the post clears it), and \`daily_writes\` (on the Workers Free plan's limits, a scheduled send to more than ${DAILY_WRITES_SEND_RECIPIENTS.toLocaleString("en-US")} recipients, or to fewer when imports already wrote on the UTC day it fires, which may spend D1's daily allowance of written rows and stop the instance until midnight UTC; \`recipients\`; a warning, never a refusal). Each has a \`severity\` (\`action\`: a person must act; \`warn\`: worth a look; \`info\`), \`since\` (null when the record does not say), a \`message\` to show or relay as it stands, and the \`action\` that settles it, if any. \`actions\` is exactly what the server would accept on the send now, each \`{name, method, path}\`: \`cancel\` and \`reschedule\` while it is scheduled and its fire time is still ahead, \`resolve\` while it is wedged.`;
11624
+ const conditionsText = `\`conditions\` is what is wrong with the send, or worth knowing, now (SPEC \xA78, \xA712), derived by the server from the send and the same on every route, most severe first: \`missed\` (still scheduled ${MISSED_THRESHOLD_MS / 6e4} minutes past its fire time: the sweep is not running), \`stuck\` (in flight too long: no recipient handed off for ${STUCK_THRESHOLD_MS / 6e4} minutes and nothing to explain it, since a halt explains the wait until its \`retry_at\` and a wedged send waits for Resolve; a send that keeps handing off is never stuck, however long it takes), \`wedged\` (\`count\` recipients whose delivery is unknown, settled by Resolve), \`refused\` (the provider refusing the account, or this send's own email, with its \`cause\`, its own words as \`error\`, the \`advice\` for the fix, which is outside this API, and \`retry_at\`; the send resumes on its own once the account is fixed, having consumed no one), \`provider_unavailable\` (an outage or a rate limit, retried on its own at \`retry_at\`; \`warn\` rather than \`info\` for a rate limit that has refused the first request of ${SELF_THROTTLE_HALTS} retries in a row, which the deployment's SES_MAX_SEND_RATE causes, and its message says so), \`bounce_spike\` (a send finished within ${BOUNCE_SPIKE_RECENT_MS / 864e5} days whose confirmed bounces reached ${BOUNCE_SPIKE_RATE * 100}% of its audience at fire, at least ${BOUNCE_SPIKE_MIN}; \`bounced\` and \`rate\`), \`no_receipts\` (a send finished within ${BOUNCE_SPIKE_RECENT_MS / 864e5} days, ${NO_RECEIPTS_AFTER_MS / 6e4} minutes or more after its dispatch finished, with not one delivery, bounce, or complaint receipt for the \`accepted\` recipients the provider took: the provider's events may not be reaching the webhook, so bounces and complaints suppress no one), \`remade\` (a template or identity change re-made the scheduled email after its last test, \`tested_at\`; a test of the post clears it), and \`daily_writes\` (on the Workers Free plan's limits, a scheduled send to more than ${DAILY_WRITES_SEND_RECIPIENTS.toLocaleString("en-US")} recipients, or to fewer when imports already wrote on the UTC day it fires, which may spend D1's daily allowance of written rows and stop the instance until midnight UTC; \`recipients\`; a warning, never a refusal). Each has a \`severity\` (\`action\`: a person must act; \`warn\`: worth a look; \`info\`), \`since\` (null when the record does not say), a \`message\` to show or relay as it stands, and the \`action\` that settles it, if any. \`actions\` is exactly what the server would accept on the send now, each \`{name, method, path}\`: \`cancel\` and \`reschedule\` while it is scheduled and its fire time is still ahead, \`resolve\` while it is wedged.`;
10614
11625
  const notConnected = "Refused with 409 `provider_not_connected` while no email provider is connected (SPEC \xA79), since nothing can be sent.";
10615
11626
  const actionText = "An act carries no precondition on the send you read: it applies to the send as it stands, within the rules above, and the answer shows the result. An `If-Match` header is ignored. Every refusal carries the send as it stands in `send`, in the `GET /api/sends` row shape.";
10616
11627
  const sendExample = (over = {}) => ({
@@ -10618,6 +11629,7 @@ function createRouter({
10618
11629
  post_id: "p_abc123",
10619
11630
  subject: "Spring migration",
10620
11631
  status: "sending",
11632
+ email_only: false,
10621
11633
  rev: 57,
10622
11634
  as_of: 176846761e4,
10623
11635
  fire_at: 17684676e5,
@@ -10699,10 +11711,10 @@ function createRouter({
10699
11711
  path: "/api/whoami",
10700
11712
  access: "admin",
10701
11713
  resource: "system",
10702
- summary: "The authenticated principal and the auth mode: how this request signed in (access or dev).",
11714
+ summary: "The authenticated principal and the auth mode: how this request signed in (access or dev). A service token's principal carries its Client ID as `tokenId`.",
10703
11715
  handler: (c) => {
10704
- const { kind, email: email2, via } = unwrap(c.principal, "principal");
10705
- return json({ principal: { kind, email: email2 }, auth: { mode: via } });
11716
+ const { kind, email: email2, tokenId, via } = unwrap(c.principal, "principal");
11717
+ return json({ principal: { kind, email: email2, tokenId }, auth: { mode: via } });
10706
11718
  }
10707
11719
  },
10708
11720
  {
@@ -10875,13 +11887,14 @@ function createRouter({
10875
11887
  accepts: JSON_BODY,
10876
11888
  resource: "settings",
10877
11889
  summary: "Send a sample post through the saved email template, to `to` or the default recipients (I5).",
10878
- description: `Renders the stored template, never unsaved editor content; saving first is the client's job, and that save may need the re-make acknowledgement (see \`PUT /api/settings\`). The answer names the recipients the provider didn't accept in \`failed\`, so a client can offer exactly those again and no one gets a second copy. ${notConnected}`,
11890
+ description: `Renders the stored template, never unsaved editor content; saving first is the client's job, and that save may need the re-make acknowledgement (see \`PUT /api/settings\`). The answer names the recipients the provider didn't accept in \`failed\`, so a client can offer exactly those again and no one gets a second copy, and says why for each in \`not_sent\`, in the same order (see \`POST /api/posts/:id/test\`). ${notConnected}`,
10879
11891
  example: {
10880
11892
  request: { to: "you@example.com" },
10881
11893
  response: {
10882
11894
  sent: 1,
10883
11895
  total: 1,
10884
11896
  failed: [],
11897
+ not_sent: [],
10885
11898
  provider: "fake",
10886
11899
  subject: "Template test \u2014 \u2026"
10887
11900
  }
@@ -10915,10 +11928,22 @@ function createRouter({
10915
11928
  accepts: JSON_BODY,
10916
11929
  resource: "posts",
10917
11930
  summary: "Create a draft post. Its slug follows the subject (`slug_auto: true`) unless you send a `slug`, which sets it by hand.",
11931
+ description: "The post is made with its unlisted link, `unlisted_url` (`<app origin>/email/<unlisted_token>`), which every test and send of it carries as view-in-browser and which never changes (SPEC \xA75). Every post a route answers carries both its addresses, absolute: `unlisted_url`, and `archive_url`, where a listed post is published once its send starts, whether or not it shows anything yet. Beside them, `published_at` is when the post's send started (null before), and `archive_live` whether `archive_url` shows the post now. A reader can see the post at `archive_url` when `archive_live`, otherwise at `unlisted_url` once `published_at` is set; which to share is the publisher's call. `status` stays `scheduled` while the send is going out, so read `published_at`, not `status`, for whether a post is public. The slug is the post's archive address, unique among all posts, drafts included (SPEC \xA74). A slug sent by hand must be lowercase letters and numbers with single hyphens, a 400 `slug_invalid` otherwise, and no other post's, a 409 `slug_taken` otherwise. A slug that follows the subject takes the subject's slug, or the first free number (`-2`) when another post holds it, and `subject_slug_held` in the answer names the slug it couldn't take. That field is not a refusal: the save succeeded, with the numbered slug.",
10918
11932
  example: {
10919
11933
  request: { subject: "Hello, world", markdown: "# Hello\n\nWelcome." },
10920
11934
  response: {
10921
- post: { id: "p_abc123", status: "draft", slug: "hello-world", slug_auto: true },
11935
+ post: {
11936
+ id: "p_abc123",
11937
+ status: "draft",
11938
+ slug: "hello-world",
11939
+ slug_auto: true,
11940
+ unlisted_token: "9f2c41d0-7ab35e8c-6d1f04b2-a7e9c350",
11941
+ archive_url: "https://newsletter.example.com/archive/hello-world",
11942
+ unlisted_url: "https://newsletter.example.com/email/9f2c41d0-7ab35e8c-6d1f04b2-a7e9c350",
11943
+ published_at: null,
11944
+ archive_live: false
11945
+ },
11946
+ subject_slug_held: null,
10922
11947
  revision_id: "r_1"
10923
11948
  }
10924
11949
  },
@@ -10958,6 +11983,7 @@ function createRouter({
10958
11983
  access: "admin",
10959
11984
  resource: "posts",
10960
11985
  summary: "One post with its current markdown and any active schedule; `scheduled.remade_at` says when a template or identity change last re-made its email.",
11986
+ description: "`subject_slug_held` is the slug the post's subject gives it when another post holds it, so its address didn't follow the subject; null otherwise, and once the post is published, since its address is fixed (SPEC \xA74). It reports a numbered slug, not a refusal.",
10961
11987
  handler: getPost2
10962
11988
  },
10963
11989
  {
@@ -10967,7 +11993,7 @@ function createRouter({
10967
11993
  accepts: JSON_BODY,
10968
11994
  resource: "posts",
10969
11995
  summary: "Update a draft. Send the revision you read as `base_revision` (or If-Match): a save without one is a 428 `base_required` carrying the post as it stands, and one whose base is no longer current is a 409 `stale_revision`.",
10970
- description: "Only a draft is editable; a scheduled post is soft-locked until its schedule is canceled, and a save to a post that is not a draft is a 409 `post_not_draft` (its message says whether canceling the schedule would unlock it). While the post's slug follows the subject (`slug_auto: true`), every save derives it from the subject again and numbers it if another post holds it, so take the slug from the answer. Sending `slug` sets it by hand and turns `slug_auto` off; `slug_auto: true` has it follow the subject again. Sending both is a 400 naming `slug`.",
11996
+ description: "Only a draft is editable; a scheduled post is soft-locked until its schedule is canceled, and a save to a post that is not a draft is a 409 `post_not_draft` (its message says whether canceling the schedule would unlock it). While the post's slug follows the subject (`slug_auto: true`), every save derives it from the subject again, taking the first free number after the subject's slug (`-2`) when another post holds it, and `subject_slug_held` names the one it couldn't take. Take the slug from the answer. Sending `slug` sets it by hand and turns `slug_auto` off; `slug_auto: true` has it follow the subject again. Sending both is a 400 naming `slug`. A slug set by hand that is malformed is a 400 `slug_invalid`, and one another post holds a 409 `slug_taken`; neither saves anything.",
10971
11997
  example: {
10972
11998
  request: {
10973
11999
  subject: "Hello again, world",
@@ -10994,6 +12020,33 @@ function createRouter({
10994
12020
  summary: "Delete a draft and its revisions (drafts only; any other post is a 409 `post_not_draft`).",
10995
12021
  handler: deletePost2
10996
12022
  },
12023
+ {
12024
+ method: "PUT",
12025
+ path: "/api/posts/:id/settings",
12026
+ access: "admin",
12027
+ accepts: JSON_BODY,
12028
+ resource: "posts",
12029
+ summary: "Set a post's publishing options: where it is listed (`email_only`) and its archive address (`slug`, `slug_auto`) (SPEC \xA74, \xA75).",
12030
+ description: "Any subset of the three. Allowed in any state, a scheduled or sent post's included: none is part of the frozen email, which carries the post's unlisted link, so a change never re-makes a scheduled send or alters a sent one (I3). `email_only: true` mails the post as usual but leaves it out of the archive index and the landing page, and its archive address stops answering, though it stays the post's; its unlisted link opens the page. `false` lists it. Schedule and Send now take the same three fields; a reschedule takes none, since it moves only the time. `slug` sets the archive address by hand and `slug_auto: true` has it follow the subject again, as for a save: a malformed slug is a 400 `slug_invalid` and another post's a 409 `slug_taken`. Once the post's send has started, or while its scheduled email (frozen before unlisted links) links its archive address, its address is fixed: a change to it is a 409 `slug_permanent`, while naming the address it has is fine. Each of the post's sends carries the listing and the address in its view, so they take a new `rev` and the send feed reports them. Answers with the post as it now stands (its `archive_live` says at once whether its archive address shows it), its `subject_slug_held`, and `changed: false` when nothing changed. A body naming none of the three, or a field of the wrong type, is a 400 naming it.",
12031
+ example: {
12032
+ request: { email_only: false, slug: "hello-world" },
12033
+ response: {
12034
+ post: {
12035
+ id: "p_abc123",
12036
+ status: "sent",
12037
+ slug: "hello-world",
12038
+ email_only: false,
12039
+ archive_url: "https://newsletter.example.com/archive/hello-world",
12040
+ unlisted_url: "https://newsletter.example.com/email/9f2c41d0-7ab35e8c-6d1f04b2-a7e9c350",
12041
+ published_at: 17684676e5,
12042
+ archive_live: true
12043
+ },
12044
+ subject_slug_held: null,
12045
+ changed: true
12046
+ }
12047
+ },
12048
+ handler: setSettings
12049
+ },
10997
12050
  {
10998
12051
  method: "GET",
10999
12052
  path: "/api/posts/:id/revisions",
@@ -11018,6 +12071,7 @@ function createRouter({
11018
12071
  accepts: [...FORM_UPLOAD, ...RASTER_IMAGE_TYPES],
11019
12072
  resource: "posts",
11020
12073
  summary: "Upload an image to a draft (multipart form field `file`); a post that is not a draft is a 409 `post_not_draft`.",
12074
+ description: "The post's Markdown references the image by the `filename` the response gives, `![a short description](cover.jpg)`, and the render resolves that name to the stored image.",
11021
12075
  handler: uploadImage
11022
12076
  },
11023
12077
  {
@@ -11043,7 +12097,7 @@ function createRouter({
11043
12097
  access: "admin",
11044
12098
  resource: "posts",
11045
12099
  summary: "Render the post to its email (returns the hosted URL, subject, warnings, and `frozen`).",
11046
- description: "Once the post is scheduled this is its frozen copy, exactly as it will fire, and once sent the record's; a draft renders live (SPEC \xA75).",
12100
+ description: "Once the post is scheduled this is its frozen copy, exactly as it will fire, and once sent the record's; a draft renders live (SPEC \xA75). A frozen copy's `warnings` are the ones its render gave when it was frozen or last re-made.",
11047
12101
  handler: preview
11048
12102
  },
11049
12103
  {
@@ -11071,11 +12125,12 @@ function createRouter({
11071
12125
  accepts: JSON_BODY,
11072
12126
  resource: "posts",
11073
12127
  summary: "Send a test to one address through the same per-recipient path as a real send (I5).",
11074
- description: "Once the post is scheduled this sends its frozen copy, exactly as it will fire, and once sent the record's; a draft's test is a live render (SPEC \xA75). The response's `frozen` says which. A test of a scheduled send's frozen copy is recorded as its `tested_at`, which clears the send's `remade` condition. " + notConnected,
12128
+ description: "Once the post is scheduled this sends its frozen copy, exactly as it will fire, and once sent the record's; a draft's test is a live render (SPEC \xA75). The response's `frozen` says which, and its `warnings` are that copy's: a frozen copy's are the ones its render gave when it was frozen or last re-made. A test of a scheduled send's frozen copy is recorded as its `tested_at`, which clears the send's `remade` condition. `sent` is true only when the provider took the test. When it didn't, `not_sent` says why: the provider's own words as `error` (never a credential), a `message` to show or relay as it stands, with the fix where it is known (the same advice as a send's `refused` condition, so a test refused for the account or the address isn't simply tried again), and `may_have_arrived`, true when the provider never answered: then the test may be in the inbox, and the answer is still a 200, never a server error. " + notConnected,
11075
12129
  example: {
11076
12130
  request: { to: "you@example.com" },
11077
12131
  response: {
11078
12132
  sent: true,
12133
+ not_sent: null,
11079
12134
  provider: "ses",
11080
12135
  to: "you@example.com",
11081
12136
  subject: "Hello, world",
@@ -11120,10 +12175,10 @@ function createRouter({
11120
12175
  accepts: JSON_BODY,
11121
12176
  resource: "posts",
11122
12177
  summary: `Freeze the render and schedule the send for a future time (at least ${formatLead(minLeadMs)} out).`,
11123
- description: "Freezes the current draft, with the template and identity as they stand, onto a send row and soft-locks the post; cancelable until its fire time. A later template or identity change re-makes that frozen email after the publisher confirms it (SPEC \xA76); there is no per-send template to name, and a `template_revision` field is a 400. `fire_at` is epoch milliseconds or an ISO-8601 timestamp with a `Z` or `\xB1hh:mm` offset; a timestamp without one is a 400, since the Worker cannot know which local time was meant. `fire_at` must be at least the minimum lead out, a 400 `fire_at_too_soon` otherwise (for the soonest send, use Send now, `POST /api/posts/:id/send`): " + lead + ". The time is then rounded up to the next whole minute (one already on the minute stays), since the sweep fires sends on the minute; the lead is checked on the time as requested, so rounding only lengthens the window. Answers with the new send's view (as `GET /api/sends/:id` describes it) and a `cursor` to follow it from. Refusals: 409 `active_send_exists` when the post already has its one active send (carried as `send`), 409 `post_not_draft`, 400 `subject_required` for an empty subject, 409 `settings_changed` when a template or identity save kept landing while it froze (try again), and 409 `provider_not_connected` while no email provider is connected (SPEC \xA79).",
12178
+ description: "Freezes the current draft, with the template and identity as they stand, onto a send row and soft-locks the post; cancelable until its fire time. A later template or identity change re-makes that frozen email after the publisher confirms it (SPEC \xA76); there is no per-send template to name, and a `template_revision` field is a 400. `fire_at` is epoch milliseconds or an ISO-8601 timestamp with a `Z` or `\xB1hh:mm` offset; a timestamp without one is a 400, since the Worker cannot know which local time was meant. `fire_at` must be at least the minimum lead out, a 400 `fire_at_too_soon` otherwise (for the soonest send, use Send now, `POST /api/posts/:id/send`): " + lead + ". The time is then rounded up to the next whole minute (one already on the minute stays), since the sweep fires sends on the minute; the lead is checked on the time as requested, so rounding only lengthens the window. `email_only` (optional) sets the post's listing as it is scheduled (SPEC \xA75): `true` mails it as usual but leaves it out of the archive index and the landing page, `false` lists it; absent, the post keeps the listing it has, which is in the archive unless it was set otherwise. `slug` and `slug_auto` (optional) set its archive address as `PUT /api/posts/:id/settings` does, checked before anything is frozen, so a 400 `slug_invalid` or 409 `slug_taken` schedules nothing, and a refused schedule changes neither. None is part of the frozen email. Answers with the new send's view (as `GET /api/sends/:id` describes it), a `cursor` to follow it from, and `warnings`, what the render found wrong with the frozen copy (an image the post never uploaded, say), which preview and test of the copy report too (SPEC \xA75). Refusals: 409 `active_send_exists` when the post already has its one active send (carried as `send`), 409 `post_not_draft`, 400 `subject_required` for an empty subject, 409 `settings_changed` when a template or identity save kept landing while it froze (try again), and 409 `provider_not_connected` while no email provider is connected (SPEC \xA79).",
11124
12179
  example: {
11125
12180
  request: { fire_at: "2026-01-16T09:00:00Z" },
11126
- response: { send: scheduledExample, cursor: "57.1768467610000" }
12181
+ response: { send: scheduledExample, cursor: "57.1768467610000", warnings: [] }
11127
12182
  },
11128
12183
  handler: schedule
11129
12184
  },
@@ -11134,9 +12189,10 @@ function createRouter({
11134
12189
  accepts: JSON_BODY,
11135
12190
  resource: "posts",
11136
12191
  summary: "Send now: freeze and schedule one minimum lead out, on the minute, cancelable until then. Idempotent per post.",
11137
- description: "The same freeze as scheduling, with the template and identity as they stand, and `fire_at` set to now plus the minimum lead, rounded up to the next whole minute as every fire time is: " + lead + ". For nearly all of that window the send is inside the minimum lead, so a template or identity save that would re-make it is refused until it has fired. A second call for a post already in its window answers with that send and `idempotent: true`; the other refusals are as for scheduling.",
12192
+ description: "The same freeze as scheduling, with the template and identity as they stand, and `fire_at` set to now plus the minimum lead, rounded up to the next whole minute as every fire time is: " + lead + ". For nearly all of that window the send is inside the minimum lead, so a template or identity save that would re-make it is refused until it has fired. An optional JSON body may carry `email_only`, `slug`, and `slug_auto`, the post's settings, as for scheduling. A second call for a post already in its window answers with that send and `idempotent: true`, and changes nothing, its listing included; one that names settings the post doesn't have is a 409 `active_send_exists` carrying the send, so a caller that asked for email only never reads success for a post going out listed (change them with `PUT /api/posts/:id/settings`). Its answer carries `warnings` as scheduling's does, a repeat's included. The other refusals are as for scheduling.",
11138
12193
  example: {
11139
- response: { send: scheduledExample, cursor: "57.1768467610000" }
12194
+ request: { email_only: true },
12195
+ response: { send: scheduledExample, cursor: "57.1768467610000", warnings: [] }
11140
12196
  },
11141
12197
  handler: sendNow
11142
12198
  },
@@ -11146,7 +12202,7 @@ function createRouter({
11146
12202
  access: "admin",
11147
12203
  resource: "sends",
11148
12204
  summary: "List sends with delivery progress. Filter, sort, and paginate via query params; returns a `page` envelope. Each row is the send's view, the same shape `GET /api/sends/:id`, the feed, and every action answer with (see `GET /api/sends/:id`), read at the same moment, so a list row and the watch never read a send differently. A row's `rev` rises with every change a reader could see (a cancel, a move, a re-make, dispatch progress, a receipt), ordered across all sends. The response's `cursor` marks where this read stood among those changes: `<seq>.<at>`, the change sequence at the read and the server's time of it (epoch milliseconds), both decimal; pass it to `GET /api/sends/feed` as `since`. `audience.count` is a snapshot of the audience while a send is scheduled; once it fires (`audience.fixed`), it is the audience fixed at fire, which never grows.",
11149
- description: "`provider.halt` describes a `sending` send whose provider refused its last batch as a whole (SPEC \xA712), and is null otherwise: its `reason`, `cause`, the provider's own words as `error`, and `since`. The send retries on its own, spaced out the longer the halt lasts: `retries` counts the halted attempts in a row and `retry_at` is when the next is due, up to an hour apart, and both reset the moment a batch is answered. `unavailable` is an outage or a rate limit, retried " + retrySchedule(HALT_BACKOFF_MS.unavailable) + ", and worth raising only once the send carries the `stuck` condition. `account` is the provider refusing the account itself, retried " + retrySchedule(HALT_BACKOFF_MS.account) + "; `cause` names what (`credentials`, `configuration`, `sender`, `quota`, `suspended`, or `not_connected`, a deployment with no provider connected). Either way no recipient has been consumed. The fix for `account` is outside this API (the provider's dashboard, or the deployment's secrets, which the API never exposes, SPEC \xA79), and once it lands the send resumes by itself at its next retry, so it is never rescheduled or sent again. There is no API action to retry sooner. " + conditionsText + " An unknown `status`, `failures`, `sort`, or `dir`, or a `limit` or `offset` that is not a whole number, is a 400 naming the field.",
12205
+ description: "`provider.halt` describes a `sending` send whose provider refused its last batch as a whole (SPEC \xA712), and is null otherwise: its `reason`, `cause`, the provider's own words as `error`, and `since`. A halt is the account's or the provider's, so a send that fires or resumes while another send waits one out joins it without asking the provider, unless the halt is the provider refusing that one send (`content` or `invalid_request`, below): it carries the same halt and retries with it, and fixes its audience when it first hands off (`audience.fixed` stays false until then). The send retries on its own, spaced out the longer the halt lasts: `retries` counts the halted attempts in a row and `retry_at` is when the next is due, up to an hour apart, and both reset the moment the provider takes anyone, except that an answer that takes some and refuses the rest for the account (a quota freeing up a few at a time) continues an `account` halt, its `since` and its spacing. `unavailable` is an outage or a rate limit, retried " + retrySchedule(HALT_BACKOFF_MS.unavailable) + ", shown as the `provider_unavailable` condition and the `backing-off` phase while it lasts; it clears on its own, so it is never also `stuck` until its retry is long overdue. `account` is the provider refusing the account itself, retried " + retrySchedule(HALT_BACKOFF_MS.account) + "; `cause` names what (`credentials`, `opt_in`, `configuration`, `sending_pool`, `unsupported`, `sender`, `mail_from`, `sandbox`, `quota`, `suspended`, `paused`, `set_paused`, or `not_connected`, a deployment with no provider connected; or this send's own `content`, an email the provider refuses, or `invalid_request`, a batch it refused as invalid without naming a recipient), and the condition's `advice` says the fix and who makes it. An `unavailable` halt's `cause` is `rate_limit` or `outage`. Either way no recipient has been consumed. The fix for `account` is outside this API (the provider's dashboard, or the deployment's secrets, which the API never exposes, SPEC \xA79), and once it lands the send resumes by itself at its next retry, so it is never rescheduled or sent again. There is no API action to retry sooner. " + conditionsText + " An unknown `status`, `failures`, `sort`, or `dir`, or a `limit` or `offset` that is not a whole number, is a 400 naming the field.",
11150
12206
  query: [
11151
12207
  {
11152
12208
  name: "status",
@@ -11180,7 +12236,7 @@ function createRouter({
11180
12236
  access: "admin",
11181
12237
  resource: "sends",
11182
12238
  summary: "What changed among sends since a cursor, each send with its phase, counters, conditions, and actions, every open condition across the sends, and when to read again: what a client follows to keep up with sends, whichever client changed them, without polling each one.",
11183
- description: `With \`since\` (the \`cursor\` from \`GET /api/sends\`, \`GET /api/sends/:id\`, or this route's last read), \`sends\` is every send that changed after that read, whatever its state: a cancel, a move, a re-make, a new schedule, dispatch progress, a receipt, a completion. It also holds each send the clock changed with no write since then: one whose fire time passed (\`due\`), one past the ${MISSED_THRESHOLD_MS / 6e4}-minute missed tolerance (the \`missed\` condition), and one in flight past the ${STUCK_THRESHOLD_MS / 6e4}-minute stuck threshold (\`stuck\`). A send turns wedged (\`wedged\`) by a write, when the run that left recipients with an unknown fate hands it back, so it is reported like any other change. \`removed\` is every send removed after the cursor (a canceled send deleted with its post), each \`{id, rev}\`, so a client drops it rather than keeping a row for a send that is gone. Without \`since\`, \`sends\` is every send that can change on its own: due, \`sending\`, or settling (\`sent\` within the last ${SETTLE_FOLLOW_MS / 6e4} minutes with a recipient still awaiting a receipt), and \`removed\` is empty. Either way each send is its view, the shape \`GET /api/sends/:id\` describes, read off the send's counters, never its delivery rows, so a send reads the same here as on its page; soonest fire first. \`cursor\` is where this read stood, to pass as \`since\` next time: \`<seq>.<at>\`, the change sequence at the read and the server's time of it (\`now\`), both decimal. The format is part of this contract, so a client holding cursors from several reads (a list, and one send's page) may compare them and follow everything from one read: the cursor with the smaller of each number answers for both. A read reports at most \`limit\` changes after the cursor, in the order they were made, and never splits the changes one write made; the sends the clock changed are always all reported. When more are waiting, \`more\` is true, \`cursor\` stands where the read stopped, and \`read_again_at\` is \`now\`: read again at once from it. A cursor ahead of this database (a sequence above the current one, or a read time more than a minute after \`now\`, as after a reset or a restore of the database) is a 409 \`cursor_ahead\` naming \`since\`: nothing after it would ever be reported, so read the sends again (\`GET /api/sends\` or \`GET /api/sends/:id\`) and follow from that read's cursor. \`read_again_at\` is when to read again, by the server's clock (\`now\`), from each unfinished send's \`next_change_at\`, the earliest it can change with no one acting: about 3 seconds while any send can move now (due, short of the missed tolerance, or sending with a run in hand or work queued for the next tick); while sends are only settling, 3 seconds, then 15, then 60, by how long ago the youngest finished dispatch; otherwise about once a minute (one sweep tick); and never later than just past the next change the clock or the sweep will make (a fire time, a halted send's next retry, the stuck threshold). A send waiting on a person (the provider refusing the account, a wedged send awaiting Resolve, a missed fire time) does not quicken it. A client that reads again then is never more than a few seconds behind a send that is moving, behind a settling send's receipts by at most that pace, and behind any other change (the other client's cancel or move, a Resolve, a late receipt) by about a minute. \`read_again_at\` is advisory: reading sooner, such as right after acting on a send, is always fine, and reading late or skipping a read loses nothing, since the next read from the last cursor reports every send that changed in between. \`GET /api/sends\` and \`GET /api/sends/:id\` carry no pace of their own: a client following from one of them reads this route at once, then keeps to its \`read_again_at\`. \`conditions\` repeats every open condition across the sends that can have one (scheduled, sending, and sent within the bounce-spike window), each with its \`send_id\` and \`subject\`, whether or not the send changed, so one read shows every open problem; most severe first. ${conditionsText} Reading it changes nothing.`,
12239
+ description: `With \`since\` (the \`cursor\` from \`GET /api/sends\`, \`GET /api/sends/:id\`, or this route's last read), \`sends\` is every send that changed after that read, whatever its state: a cancel, a move, a re-make, a new schedule, dispatch progress, a receipt, a completion. It also holds each send the clock changed with no write since then: one whose fire time passed (\`due\`), one past the ${MISSED_THRESHOLD_MS / 6e4}-minute missed tolerance (the \`missed\` condition), and one idle past the ${STUCK_THRESHOLD_MS / 6e4}-minute stuck threshold (\`stuck\`). A send turns wedged (\`wedged\`) by a write, when the run that left recipients with an unknown fate hands it back, so it is reported like any other change. \`removed\` is every send removed after the cursor (a canceled send deleted with its post), each \`{id, rev}\`, so a client drops it rather than keeping a row for a send that is gone. Without \`since\`, \`sends\` is every send that can change on its own: due, \`sending\`, or settling (\`sent\` within the last ${SETTLE_FOLLOW_MS / 6e4} minutes with a recipient still awaiting a receipt), and \`removed\` is empty. Either way each send is its view, the shape \`GET /api/sends/:id\` describes, read off the send's counters, never its delivery rows, so a send reads the same here as on its page; soonest fire first. \`cursor\` is where this read stood, to pass as \`since\` next time: \`<seq>.<at>\`, the change sequence at the read and the server's time of it (\`now\`), both decimal. The format is part of this contract, so a client holding cursors from several reads (a list, and one send's page) may compare them and follow everything from one read: the cursor with the smaller of each number answers for both. A read reports at most \`limit\` changes after the cursor, in the order they were made, and never splits the changes one write made; the sends the clock changed are always all reported. When more are waiting, \`more\` is true, \`cursor\` stands where the read stopped, and \`read_again_at\` is \`now\`: read again at once from it. A cursor ahead of this database (a sequence above the current one, or a read time more than a minute after \`now\`, as after a reset or a restore of the database) is a 409 \`cursor_ahead\` naming \`since\`: nothing after it would ever be reported, so read the sends again (\`GET /api/sends\` or \`GET /api/sends/:id\`) and follow from that read's cursor. \`read_again_at\` is when to read again, by the server's clock (\`now\`), from each unfinished send's \`next_change_at\`, the earliest it can change with no one acting: about 3 seconds while any send can move now (due, short of the missed tolerance, or sending with a run in hand or work queued for the next tick); while sends are only settling, 3 seconds, then 15, then 60, by how long ago the youngest finished dispatch; otherwise about once a minute (one sweep tick); and never later than just past the next change the clock or the sweep will make (a fire time, a halted send's next retry). A send waiting on a person (the provider refusing the account, a wedged send awaiting Resolve, a missed fire time) does not quicken it. A client that reads again then is never more than a few seconds behind a send that is moving, behind a settling send's receipts by at most that pace, and behind any other change (the other client's cancel or move, a Resolve, a late receipt) by about a minute. \`read_again_at\` is advisory: reading sooner, such as right after acting on a send, is always fine, and reading late or skipping a read loses nothing, since the next read from the last cursor reports every send that changed in between. \`GET /api/sends\` and \`GET /api/sends/:id\` carry no pace of their own: a client following from one of them reads this route at once, then keeps to its \`read_again_at\`. \`conditions\` repeats every open condition across the sends that can have one (scheduled, sending, and sent within the bounce-spike window), each with its \`send_id\` and \`subject\`, whether or not the send changed, so one read shows every open problem; most severe first. ${conditionsText} Reading it changes nothing.`,
11184
12240
  query: [
11185
12241
  {
11186
12242
  name: "since",
@@ -11240,7 +12296,7 @@ function createRouter({
11240
12296
  access: "admin",
11241
12297
  resource: "sends",
11242
12298
  summary: "One send: its view, the delivery-outcome breakdown of its record, and the `cursor` marking where this read stood among the changes to sends, to follow it from with `GET /api/sends/feed` (it carries no pace, so read the feed at once from it).",
11243
- description: "`send` is the send's view, the one shape every send route carries (the list, the feed, every action's answer and refusal), so a client keeps one copy current from any of them and tells two apart by `rev`, the higher the newer. It holds the stored facts: `status` (`scheduled`, `sending`, `sent`, `canceled`), the times, `remade_at` (a template or identity change re-made it while scheduled) and `tested_at`, and `audience` (`count`, an estimate until the send fires and the audience at fire once `fixed`, from `fixed_at`). Beside them, what the server derives as of `as_of`, the server's clock at the read: `counts` (every recipient in one bucket), `dispatch` (hand-off progress; `rate_per_min` is what a sweep tick carries, one tick a minute, and `eta_ms` counts the ticks the rest needs, the wait between them included, given only while it is handing off and once a tick has finished, never while paused), `delivery` (receipts over accepted), `provider` (its `name`, and `halt` while the provider refuses its batches), `phase` (`scheduled`, `due`, `progressing`, `retrying`, `backing-off`, `needs-attention`, `settling`, `complete`, `canceled`), `conditions`, `actions`, and `next_change_at` (see `GET /api/sends/feed`). `links` names the send's other resources: the frozen email (`email_html`, `email_text`), the recipient rows and their CSV, the post, and `archive`, the published page, absolute, once the send is sent. " + conditionsText + " `outcomes` counts the record's recipients by outcome from its rows, summing to the audience at fire. The answer carries `ETag`: the send's `rev` and a tag of what the clock derives from it, so a request with `If-None-Match` answers 304 while nothing about the send has changed, written or derived.",
12299
+ description: "`send` is the send's view, the one shape every send route carries (the list, the feed, every action's answer and refusal), so a client keeps one copy current from any of them and tells two apart by `rev`, the higher the newer. It holds the stored facts: `status` (`scheduled`, `sending`, `sent`, `canceled`), the times, `remade_at` (a template or identity change re-made it while scheduled) and `tested_at`, and `audience` (`count`, an estimate until the send fires and the audience at fire once `fixed`, from `fixed_at`). Beside them, what the server derives as of `as_of`, the server's clock at the read: `counts` (every recipient in one bucket), `dispatch` (hand-off progress; `rate_per_min` is what a sweep tick carries, one tick a minute, and `eta_ms` counts the ticks the rest needs, the wait between them included, given only while it is handing off and once a tick has finished, never while paused or in flight too long), `delivery` (receipts over accepted), `provider` (its `name`, and `halt` while the provider refuses its batches), `phase` (`scheduled`, `due`, `progressing`, `retrying`, `backing-off`, `needs-attention`, `settling`, `complete`, `canceled`), `conditions`, `actions`, and `next_change_at` (see `GET /api/sends/feed`). `links` names the send's other resources: the frozen email (`email_html`, `email_text`), the recipient rows and their CSV, the post, and `archive`, the published page, absolute, once the send is sent. " + conditionsText + " `outcomes` counts the record's recipients by outcome from its rows, summing to the audience at fire. The answer carries `ETag`: the send's `rev` and a tag of what the clock derives from it, so a request with `If-None-Match` answers 304 while nothing about the send has changed, written or derived.",
11244
12300
  example: {
11245
12301
  response: {
11246
12302
  send: sendExample(),
@@ -11348,7 +12404,7 @@ function createRouter({
11348
12404
  accepts: JSON_BODY,
11349
12405
  resource: "sends",
11350
12406
  summary: "Move a scheduled send's fire time without re-freezing the render (I3, I6).",
11351
- description: "Updates only `fire_at` on a send still in its review window: the frozen render is untouched (the audience is resolved when the send fires) and the review window is preserved; a re-made send keeps its `remade_at`. Distinct from cancel \u2192 edit \u2192 schedule again, which is for content changes. The same `fire_at` form as scheduling (epoch milliseconds or an ISO-8601 timestamp with a `Z` or `\xB1hh:mm` offset), and the same minimum lead, a 400 `fire_at_too_soon` otherwise: " + lead + ". As when scheduling, the new time is rounded up to the next whole minute, after the lead is checked on the time as requested. A move to the time the send already has (after that rounding) answers 200 with `changed: false` before the lead is checked, so a retried move is safe. Once the fire time has passed it is a 409 `window_closed`, and a canceled send is a 409 `send_canceled` (schedule the post again instead). " + actionText,
12407
+ description: "Updates only `fire_at` on a send still in its review window: the frozen render is untouched (the audience is resolved when the send fires) and the review window is preserved; a re-made send keeps its `remade_at`. Distinct from cancel \u2192 edit \u2192 schedule again, which is for content changes. The same `fire_at` form as scheduling (epoch milliseconds or an ISO-8601 timestamp with a `Z` or `\xB1hh:mm` offset), and the same minimum lead, a 400 `fire_at_too_soon` otherwise: " + lead + ". As when scheduling, the new time is rounded up to the next whole minute, after the lead is checked on the time as requested. A move to the time the send already has (after that rounding) answers 200 with `changed: false` before the lead is checked, so a retried move is safe. Once the fire time has passed it is a 409 `window_closed`, and a canceled send is a 409 `send_canceled` (schedule the post again instead). The post's settings are not a reschedule's: `email_only`, `slug`, or `slug_auto` is a 400 naming the field, which moves nothing; change them with `PUT /api/posts/:id/settings`. " + actionText,
11352
12408
  example: {
11353
12409
  request: { fire_at: "2026-01-16T09:00:00Z" },
11354
12410
  response: { send: scheduledExample, cursor: "58.1768467620000", changed: true }
@@ -11738,7 +12794,19 @@ function createRouter({
11738
12794
  handler: unsubscribe
11739
12795
  },
11740
12796
  // --- delivery webhooks (public; provider-signature-verified, not requireAuth) ---
11741
- // --- archive / view-in-browser (public; serves the frozen record, I3) ---
12797
+ // --- unlisted links (public; the "view in browser" every email carries, SPEC §5) ---
12798
+ // On the app's own origin and independent of ARCHIVE_BASE_PATH, so moving the archive
12799
+ // breaks no email already sent.
12800
+ {
12801
+ method: "GET",
12802
+ path: `${UNLISTED_PATH}/:token`,
12803
+ access: "public",
12804
+ resource: "archive",
12805
+ summary: "A post's unlisted link, the view-in-browser link every test and send carries (SPEC \xA75).",
12806
+ description: "Before the post's send starts, the same `Nothing here yet` page (404) as any unknown token. Once it has: while the post is listed, a temporary redirect (302, `Cache-Control: no-store`, `X-Robots-Tag: noindex`) to its own archive address, since the listing can change, and never to anything the request names; while it is email only, the post's page itself, with `X-Robots-Tag: noindex` and `Cache-Control: no-cache`. Public, but never listed or linked from any page. A GET only reads, so a mail client's link scanner following it changes nothing.",
12807
+ handler: unlistedPage
12808
+ },
12809
+ // --- archive (public; serves the frozen record, I3) ---
11742
12810
  // Registered at ARCHIVE_BASE_PATH (default /archive) so the index, the post
11743
12811
  // pages, and the emitted archive URLs always share one source. Self-contained by
11744
12812
  // default; an apex zone can additionally route <base>/* to this Worker (SPEC §11).
@@ -11758,7 +12826,8 @@ function createRouter({
11758
12826
  path: `${archiveBasePath}/:slug`,
11759
12827
  access: "public",
11760
12828
  resource: "archive",
11761
- summary: "A published post's archive page / view-in-browser (I3), from the moment its send starts.",
12829
+ summary: "A published post's archive page (I3), from the moment a listed post's send starts, while it stays listed.",
12830
+ description: "An email-only post's address answers the `Nothing here yet` page (404) but stays the post's, so listing it again brings it back. The exception is a post whose email carries this address as its view-in-browser link (one sent before unlisted links): its address answers whatever its listing, with `X-Robots-Tag: noindex` while it is email only. The page is sent with `Cache-Control: no-cache`, so a listing change takes effect at once.",
11762
12831
  handler: archivePage
11763
12832
  },
11764
12833
  // --- media bytes (public; readers + archive load these unauthenticated) ---
@@ -11784,7 +12853,12 @@ var SHIPPED_MIGRATIONS = [
11784
12853
  "0003_post_slug_auto.sql",
11785
12854
  "0004_keyed_pending_to_resolve.sql",
11786
12855
  "0005_imports.sql",
11787
- "0006_deliveries_fewer_indexes.sql"
12856
+ "0006_deliveries_fewer_indexes.sql",
12857
+ "0007_sends_progress_at.sql",
12858
+ "0008_notifications_sandbox.sql",
12859
+ "0009_post_email_only.sql",
12860
+ "0010_unlisted_links.sql",
12861
+ "0011_sends_render_warnings.sql"
11788
12862
  ];
11789
12863
  async function missingMigrations(db) {
11790
12864
  let applied;
@@ -11923,13 +12997,14 @@ var MAX_PER_TICK = 10;
11923
12997
  function stillHolds(n, now) {
11924
12998
  switch (n.kind) {
11925
12999
  case "import":
13000
+ case "sandbox":
11926
13001
  return true;
11927
13002
  case "finished":
11928
13003
  return true;
11929
13004
  case "refused":
11930
13005
  return n.send_status === "sending" && n.halt_reason === "account" && n.halted_at === n.episode;
11931
13006
  case "stuck":
11932
- return n.send_status === "sending";
13007
+ return idleSince({ ...n, status: n.send_status }) === n.episode;
11933
13008
  case "wedged":
11934
13009
  return isWedged({ ...n, status: n.send_status });
11935
13010
  case "missed":
@@ -11965,11 +13040,11 @@ async function notifyPublisher(env, budget) {
11965
13040
  await recordNotificationOutcome(db, n, { status: "cleared" }, Date.now());
11966
13041
  continue;
11967
13042
  }
11968
- const about = n.kind === "import" ? { importId: n.import_id } : { sendId: n.send_id };
11969
- const key = `${n.send_id ?? n.import_id}-${n.kind}-${n.episode}`;
13043
+ const about = n.kind === "import" ? { importId: n.import_id } : n.kind === "sandbox" ? {} : { sendId: n.send_id };
13044
+ const key = `${n.send_id ?? n.import_id ?? "account"}-${n.kind}-${n.episode}`;
11970
13045
  budget.request();
11971
13046
  try {
11972
- const email2 = n.kind === "import" ? composeImportNotification(n, config) : composeNotification(n, config);
13047
+ const email2 = n.kind === "import" ? composeImportNotification(n, config) : n.kind === "sandbox" ? composeSandboxNotification(config) : composeNotification(n, config);
11973
13048
  await notifier.send(to, email2, key);
11974
13049
  await recordNotificationOutcome(db, n, { status: "sent" }, Date.now());
11975
13050
  log.info("notify.sent", { ...about, kind: n.kind, channel: notifier.channel });
@@ -12034,13 +13109,13 @@ var SendWindow = class {
12034
13109
  // src/send/loop.ts
12035
13110
  var MAX_CHUNK = 500;
12036
13111
  var GROUP_RECIPIENTS = 10;
12037
- var OPEN_COST2 = 5;
13112
+ var OPEN_COST2 = 6;
12038
13113
  var GROUP_COST = 8;
12039
13114
  var GROUP_TYPICAL_COST = 5;
12040
13115
  var CLOSE_COST = 4;
12041
13116
  var MIN_RUN_COST = OPEN_COST2 + GROUP_COST + 1 + CLOSE_COST;
12042
13117
  function worseHalt(current2, next) {
12043
- return !current2 || current2.reason === "unavailable" && next.reason === "account" ? next : current2;
13118
+ return !current2 || haltRank(next) > haltRank(current2) ? next : current2;
12044
13119
  }
12045
13120
  function chunk2(items, size) {
12046
13121
  const out = [];
@@ -12095,6 +13170,20 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
12095
13170
  renewAt = Date.now() + LEASE_TTL_MS / 2;
12096
13171
  return renewLease(db, sendId, lease, Date.now() + LEASE_TTL_MS);
12097
13172
  };
13173
+ const standing = await accountHalt(db, sendId, Date.now() + HALT_RETRY_SLACK_MS);
13174
+ if (standing) {
13175
+ await joinHalt(db, sendId, lease, standing);
13176
+ log.warn("send.halted", {
13177
+ ...tags,
13178
+ reason: standing.reason,
13179
+ cause: standing.cause,
13180
+ error: standing.error,
13181
+ retryAt: new Date(standing.retryAt).toISOString(),
13182
+ heldBy: standing.sendId
13183
+ });
13184
+ await releaseLease(db, sendId, lease);
13185
+ return { ...empty, leased: true, halt: standing.reason };
13186
+ }
12098
13187
  if (send.audience_resolved_at === null) {
12099
13188
  await resolveAudience(db, sendId, now);
12100
13189
  }
@@ -12110,7 +13199,7 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
12110
13199
  const held = [];
12111
13200
  let halt = null;
12112
13201
  let requeued = 0;
12113
- let answered = false;
13202
+ let progressed = false;
12114
13203
  let complete = true;
12115
13204
  let latencyMs = 0;
12116
13205
  let heldCount = 0;
@@ -12178,7 +13267,7 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
12178
13267
  batchHalt = answer.halt;
12179
13268
  } else {
12180
13269
  if (answer.results.some((r) => r.accepted || r.held !== true)) {
12181
- answered = true;
13270
+ progressed = true;
12182
13271
  }
12183
13272
  if (answer.halt) {
12184
13273
  halt = worseHalt(halt, answer.halt);
@@ -12216,7 +13305,11 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
12216
13305
  halt = worseHalt(halt, batchHalt);
12217
13306
  }
12218
13307
  }
12219
- await settleDeliveries(db, sendId, lease, "dispatched", outcomes, Date.now(), answered);
13308
+ const cleared = progressed && halt?.reason !== "account";
13309
+ await settleDeliveries(db, sendId, lease, "dispatched", outcomes, Date.now(), {
13310
+ progressed,
13311
+ clears: cleared
13312
+ });
12220
13313
  const heldAt = Date.now();
12221
13314
  await holdBatch(
12222
13315
  db,
@@ -12244,7 +13337,7 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
12244
13337
  unknown,
12245
13338
  latencyMs
12246
13339
  });
12247
- if (answered && halted) {
13340
+ if (cleared && halted) {
12248
13341
  log.info("send.resumed", {
12249
13342
  ...tags,
12250
13343
  reason: halted.reason,
@@ -12374,6 +13467,20 @@ async function runTick(env, now, tick) {
12374
13467
  const budget = new Budget(whole.limit - held, whole.queryLimit - held);
12375
13468
  const db = metered(env.DB, budget);
12376
13469
  const window = new SendWindow(getProvider(config, env).maxRequestRate);
13470
+ const resumable = await resumableSends(db, now, now + HALT_RETRY_SLACK_MS);
13471
+ const resume = async (s) => {
13472
+ if (handled.has(s.id) || !budget.affords(MIN_RUN_COST)) {
13473
+ return;
13474
+ }
13475
+ handled.add(s.id);
13476
+ tick.resumable += 1;
13477
+ await safeRun(env, s.id, budget, window);
13478
+ };
13479
+ for (const s of resumable) {
13480
+ if (s.halt_reason !== null) {
13481
+ await resume(s);
13482
+ }
13483
+ }
12377
13484
  for (const s of await dueSends(db, now)) {
12378
13485
  const lag = now - s.fire_at;
12379
13486
  if (lag > MISSED_THRESHOLD_MS) {
@@ -12384,17 +13491,16 @@ async function runTick(env, now, tick) {
12384
13491
  tick.due += 1;
12385
13492
  await safeRun(env, s.id, budget, window);
12386
13493
  }
12387
- const resumable = budget.affords(1 + MIN_RUN_COST) ? await resumableSends(db, now, now + HALT_RETRY_SLACK_MS) : [];
12388
13494
  for (const s of resumable) {
12389
- if (handled.has(s.id)) {
12390
- continue;
12391
- }
12392
- handled.add(s.id);
12393
- tick.resumable += 1;
12394
- await safeRun(env, s.id, budget, window);
13495
+ await resume(s);
12395
13496
  }
12396
13497
  for (const s of await stuckSends(env.DB, now - STUCK_THRESHOLD_MS)) {
12397
- log.error("send.stuck", { sendId: s.id, postId: s.post_id, startedAt: s.started_at });
13498
+ log.error("send.stuck", {
13499
+ sendId: s.id,
13500
+ postId: s.post_id,
13501
+ startedAt: s.started_at,
13502
+ idleSince: idleSince(s)
13503
+ });
12398
13504
  tick.stuck += 1;
12399
13505
  }
12400
13506
  for (const s of await wedgedSends(env.DB)) {