@kurtbruns/kestrel 1.10.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.
- package/CHANGELOG.md +64 -1
- package/README.md +17 -28
- package/cli/reset.mjs +2 -0
- package/cli/seed.mjs +2 -0
- package/demo/README.md +4 -2
- package/demo/posts/0-test-email/index.md +13 -0
- package/demo/posts/5-a-quick-note/index.md +2 -0
- package/dist/public/dashboard/app.js +302 -293
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +434 -54
- package/dist/worker/index.js +1472 -470
- package/docs/README.md +2 -2
- package/docs/get-started/02-deploy.md +12 -5
- package/docs/get-started/03-access.md +2 -0
- package/docs/get-started/04-ses.md +36 -16
- package/docs/get-started/05-verify.md +36 -8
- package/docs/get-started/06-go-live.md +13 -55
- package/docs/guides/01-connect-claude.md +7 -5
- package/docs/guides/03-notifications.md +10 -10
- package/docs/guides/09-as-your-list-grows.md +62 -0
- package/docs/publishing/01-your-list.md +118 -47
- package/docs/publishing/02-sending-rules.md +5 -5
- package/docs/publishing/03-name-your-publication.md +7 -93
- package/docs/publishing/04-write-a-post.md +37 -66
- package/docs/publishing/06-troubleshooting.md +103 -0
- package/docs/publishing/07-working-with-claude.md +19 -37
- package/docs/publishing/09-share-your-subscribe-link.md +11 -59
- package/docs/publishing/10-check-your-setup.md +12 -30
- package/docs/publishing/11-email-template.md +34 -0
- package/docs/publishing/README.md +12 -11
- package/docs/reference/01-configuration.md +6 -6
- package/docs/reference/02-sending-domain-dns.md +1 -1
- package/migrations/0007_sends_progress_at.sql +12 -0
- package/migrations/0008_notifications_sandbox.sql +35 -0
- package/migrations/0009_post_email_only.sql +12 -0
- package/migrations/0010_unlisted_links.sql +43 -0
- package/migrations/0011_sends_render_warnings.sql +13 -0
- package/package.json +1 -1
- package/template/claude/CLAUDE.md +5 -2
- package/template/claude/skills/migrate-list/SKILL.md +2 -2
- package/docs/guides/08-try-locally.md +0 -108
- package/docs/publishing/05-scheduling-and-sending.md +0 -72
- package/docs/publishing/06-after-a-send.md +0 -109
- package/docs/publishing/08-import-format.md +0 -103
package/dist/worker/index.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
// @kurtbruns/kestrel 1.
|
|
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,20 +731,62 @@ var NOT_CONNECTED_ADVICE = {
|
|
|
715
731
|
function refusalAdvice(cause) {
|
|
716
732
|
switch (cause) {
|
|
717
733
|
case "credentials":
|
|
718
|
-
return "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
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 "
|
|
764
|
+
return "Whoever runs the deployment fixes the account with the provider.";
|
|
765
|
+
}
|
|
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.`;
|
|
731
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.`;
|
|
732
790
|
}
|
|
733
791
|
var STUCK_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
734
792
|
var DEFAULT_MIN_LEAD_MS = 5 * 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.
|
|
812
|
-
sha: "
|
|
813
|
-
tag: "v1.
|
|
814
|
-
buildTime: "2026-10-
|
|
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 {
|
|
@@ -1017,7 +1087,7 @@ ${JSON.stringify(value, null, 2)}
|
|
|
1017
1087
|
\`\`\``;
|
|
1018
1088
|
}
|
|
1019
1089
|
function routeMarkdown(r) {
|
|
1020
|
-
const parts = [
|
|
1090
|
+
const parts = [`#### ${r.method} ${r.path}`, r.summary];
|
|
1021
1091
|
if (r.description) {
|
|
1022
1092
|
parts.push(r.description);
|
|
1023
1093
|
}
|
|
@@ -1045,7 +1115,7 @@ Every route this instance serves, generated from its registration, so every endp
|
|
|
1045
1115
|
const tiers = groups.map((g) => {
|
|
1046
1116
|
const resources = g.resources.map(
|
|
1047
1117
|
(res) => [
|
|
1048
|
-
|
|
1118
|
+
`### ${res.title}`,
|
|
1049
1119
|
...g.routes.filter((r) => r.resource === res.key).map(routeMarkdown)
|
|
1050
1120
|
].join("\n\n")
|
|
1051
1121
|
);
|
|
@@ -1083,26 +1153,34 @@ async function verifyAccessJwt(token2, config) {
|
|
|
1083
1153
|
issuer,
|
|
1084
1154
|
audience: config.accessAud
|
|
1085
1155
|
});
|
|
1086
|
-
|
|
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
|
-
|
|
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
|
|
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; ${
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(...
|
|
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 {
|
|
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
|
|
2905
|
-
|
|
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
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
slug
|
|
2932
|
-
|
|
2933
|
-
|
|
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
|
-
//
|
|
2966
|
-
function
|
|
2967
|
-
return
|
|
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
|
|
4094
|
+
return { subject: m.subject ?? post.subject };
|
|
3784
4095
|
} catch {
|
|
3785
|
-
return { subject: post.subject
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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',
|
|
4502
|
-
WHERE
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
4937
|
-
message: `
|
|
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
|
-
|
|
4983
|
-
next = Math.max(now, send.halt_retry_at - HALT_RETRY_SLACK_MS);
|
|
4984
|
-
} else {
|
|
4985
|
-
return now;
|
|
5382
|
+
return null;
|
|
4986
5383
|
}
|
|
4987
|
-
|
|
4988
|
-
|
|
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
|
|
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 &&
|
|
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(
|
|
5108
|
-
|
|
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
|
-
//
|
|
5148
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
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
|
|
5280
|
-
return refusal(409, "window_closed", `This send can no longer be ${what}: ${
|
|
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\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(
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -6237,25 +6741,25 @@ async function reset(c) {
|
|
|
6237
6741
|
import { Lexer } from "marked";
|
|
6238
6742
|
|
|
6239
6743
|
// docs/get-started/02-deploy.md
|
|
6240
|
-
var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker, with a D1 database for posts and subscribers and an R2 bucket for images.\n\nIn this guide, you choose your hostnames, create your instance\'s repository, create the database and the bucket, and point Kestrel\'s production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname.\n\n## Before you begin\n\n- A [Cloudflare account](https://dash.cloudflare.com/sign-up), with your domain\'s DNS on Cloudflare. Your app\'s hostname and its login both live there.\n- [Node.js](https://nodejs.org/) 22 or later, and [Git](https://git-scm.com/).\n- A GitHub account, or another Git host, for your instance\'s repository.\n- An [AWS account](https://portal.aws.amazon.com/billing/signup), for Amazon SES, which sends your email.\n\n## Choose your hostnames\n\nKestrel uses two hostnames: one for the app, and one your email is sent from.\n\n| Hostname | What it\'s for |\n| --- | --- |\n| `newsletter.example.com` | the editor, the API, and the public pages readers see |\n| `send.example.com` | the address your email comes from, and its SPF, DKIM, and DMARC records |\n\nThese guides put the app on a subdomain, which leaves any website you already have on `example.com` untouched. If `example.com` has no website of its own, the app can live there instead. Use `example.com` wherever these guides say `newsletter.example.com`.\n\nIt\'s recommended to send from a subdomain rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn\'t put that at risk. Avoid `mail.` as the sending name too, since mail servers read it as an inbound host. [Sending-domain DNS](../reference/02-sending-domain-dns.md#choose-your-sending-name) explains both.\n\nThroughout these guides, replace the example names with your own, and any value written as `REPLACE_WITH_\u2026`.\n\n## 1. Create your instance\'s repository\n\nYour instance\'s repository holds its configuration, its database migrations, and the release of Kestrel it runs. Kestrel itself installs from npm as `@kurtbruns/kestrel`, so the repository stays small, and an upgrade changes the version it names.\n\n1. On GitHub, [create a new repository](https://github.com/new) named `newsletter`. It can be private. Leave it empty, with no README, license, or `.gitignore`.\n\n1. Clone it, and write a new instance into it. Replace `YOUR_USERNAME` with your GitHub username:\n\n ```bash\n git clone https://github.com/YOUR_USERNAME/newsletter.git\n cd newsletter\n npx @kurtbruns/kestrel init\n ```\n\n This writes the instance\'s files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release\'s database migrations, and a starting point for Claude Code in `.claude/`. The instance\'s `README.md` lists its commands.\n\n1. Install the release and its tools:\n\n ```bash\n npm install\n ```\n\n1. Commit the new instance, and push it:\n\n ```bash\n git add -A\n git commit -m "Start a Kestrel instance"\n git push -u origin HEAD\n ```\n\n1. **(Optional)** Try Kestrel locally before you deploy it. Your instance is written and installed, so start at [Start the app](../guides/08-try-locally.md#2-start-the-app), and come back here when you\'re done. Email goes to a stand-in, so nothing reaches a real inbox.\n\n## 2. Sign in to Cloudflare\n\nEvery command from here on acts on your Cloudflare account through the Wrangler CLI.\n\n1. Sign in. A browser window opens for you to approve the login:\n\n ```bash\n npx wrangler login\n ```\n\n1. Confirm which account you\'re signed in to:\n\n ```bash\n npx wrangler whoami\n ```\n\n It prints your email and the account name. If you belong to more than one account, make sure it\'s the one that holds your domain.\n\n## 3. Create the database and the image bucket\n\nEach command creates the resource and writes its id or name into the `production` block of `wrangler.jsonc`, under the binding the app already uses, so there\'s nothing to copy.\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create kestrel-production --env production --binding DB --update-config\n ```\n\n It prints the new database\'s settings, then a snippet it says to add to your configuration. Wrangler has already added it: it fills in the `database_id` of the production `DB` binding with the database\'s real id, in place of `REPLACE_WITH_PRODUCTION_D1_ID`, so there\'s nothing to paste.\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create kestrel-media-production --env production --binding MEDIA --update-config\n ```\n\n It prints a snippet to add to your configuration too. There\'s nothing to paste here either: the production `MEDIA` binding already names this bucket, so it stays as it is.\n\nWrangler rewrites the whole of `wrangler.jsonc` in its own layout when it saves: tabs, and one value per line. Your comments and settings stay; only the spacing changes.\n\nName `--env production` on every Wrangler command that creates or binds a resource. Without it, Wrangler writes to the top level, which is local development.\n\n## 4. Configure production\n\n`wrangler.jsonc` holds two sets of settings. The top level is for local development, so leave it alone. The `production` block under `env` is yours to fill in.\n\n1. Open `wrangler.jsonc` and change the values marked here in the `production` block. It\'s shown compactly here; in your file, after section 3, each value is on its own line:\n\n ```jsonc\n "production": {\n "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }], // \u2190 your app\'s hostname\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "\u2026", // already filled in by section 3; leave it\n "migrations_dir": "migrations"\n }\n ],\n "vars": {\n "PROVIDER": "", // \u2190 stays empty until step 3\n "APP_ORIGIN": "https://newsletter.example.com", // \u2190 your app\'s hostname\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com", // \u2190 your sending hostname\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>" // \u2190 who your mail is from\n }\n }\n ```\n\n What each one does:\n\n - **`routes`** puts the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can\'t already have a CNAME record; delete one if it does. The block also turns off the `workers.dev` addresses, so the app answers only on your hostname.\n - **`PROVIDER` stays empty.** Step 3, [Connect Amazon SES](04-ses.md), sets it. Until then the app runs without an email provider: you can write and preview posts, but scheduling, test emails, and signups are refused. Never set it to `fake`, the top level\'s value, even when a wrangler warning suggests copying it: `fake` delivers nothing, and the app refuses it on your hostname.\n - **`ARCHIVE_BASE_PATH`** is permanent once you send. Every post you send carries its `/archive/\u2026` link, and changing the prefix later breaks the links you\'ve already mailed. Keep `/archive` unless you have a reason not to.\n - **`FROM_ADDRESS`** is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.\n\n Change every `example.com` value, the route included, even before you connect a provider. The app checks its settings but can\'t see the route: it refuses to run while `APP_ORIGIN`, `SENDING_DOMAIN`, or `FROM_ADDRESS`, or `ARCHIVE_ORIGIN` or `MEDIA_PUBLIC_BASE` if you set them, is still on `example.com`, since its links and the address your email comes from would name a domain that isn\'t yours.\n\n Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.\n\n1. Check the file. This regenerates the binding types, and fails if the configuration doesn\'t parse:\n\n ```bash\n npm run typecheck\n ```\n\n Wrangler prints the binding types it generated and ends with a reminder to run it again whenever you change `wrangler.jsonc`. The type check that follows prints nothing when it passes.\n\n1. Commit your settings and push them to your repository. An upgrade changes only the release your instance runs, so your settings stay as they are:\n\n ```bash\n git commit -am "Configure production"\n git push\n ```\n\n## 5. Apply the schema\n\nCreate Kestrel\'s tables in the production database:\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it\'s about to apply and asks you to confirm. It applies only the ones the database hasn\'t seen, so running it again is harmless.\n\n## 6. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThis deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker\'s **Triggers** tab in the Cloudflare dashboard.\n\n## Check it\n\n1. The app answers on your hostname. A new hostname can take a few minutes to get its certificate.\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The editor is locked. Until you set up Access in the next step, the app can\'t tell who is asking, so it lets no one in:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" https://newsletter.example.com/api/whoami\n ```\n\n ```\n 401\n ```\n\n In a browser, `https://newsletter.example.com/dashboard/` says the editor is locked until you set up Access. That\'s expected.\n\n1. In a browser, `https://newsletter.example.com/` shows your newsletter\'s public landing page. Its subscribe page, `/subscribe`, says signups aren\'t open yet, until step 3, Connect Amazon SES.\n\nIf the public pages instead say the newsletter is temporarily unavailable, and `/health` answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",\u2026}`, the setting it names is missing, malformed, or still on `example.com`. Fix it in `wrangler.jsonc`, commit, and deploy again.\n\nIf the public pages say the same, but `/health` answers `500` with `{"error":"schema_behind","missing":[\u2026],\u2026}`, the database hasn\'t run the migrations it lists, most often because step 5 was skipped or its prompt answered no. Run step 5\'s command, which the body\'s `message` repeats; the app answers as soon as it finishes, with no new deploy.\n\nNext, [lock the dashboard with Access](03-access.md).\n';
|
|
6744
|
+
var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker, with a D1 database for posts and subscribers and an R2 bucket for images.\n\nIn this guide, you choose your hostnames, create your instance\'s repository, create the database and the bucket, and point Kestrel\'s production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname.\n\n## Before you begin\n\n- A [Cloudflare account](https://dash.cloudflare.com/sign-up), with your domain\'s DNS on Cloudflare. Your app\'s hostname and its login both live there.\n- [Node.js](https://nodejs.org/) 22 or later, and [Git](https://git-scm.com/).\n- A GitHub account, or another Git host, for your instance\'s repository.\n- An [AWS account](https://portal.aws.amazon.com/billing/signup), for Amazon SES, which sends your email.\n\n## Choose your hostnames\n\nKestrel uses two hostnames: one for the app, and one your email is sent from.\n\n| Hostname | What it\'s for |\n| --- | --- |\n| `newsletter.example.com` | the editor, the API, and the public pages readers see |\n| `send.example.com` | the address your email comes from, and its SPF, DKIM, and DMARC records |\n\nThese guides put the app on a subdomain, which leaves any website you already have on `example.com` untouched. If `example.com` has no website of its own, the app can live there instead. Use `example.com` wherever these guides say `newsletter.example.com`.\n\nIt\'s recommended to send from a subdomain rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn\'t put that at risk. Avoid `mail.` as the sending name too, since mail servers read it as an inbound host. [Sending-domain DNS](../reference/02-sending-domain-dns.md#choose-your-sending-name) explains both.\n\nThroughout these guides, replace the example names with your own, and any value written as `REPLACE_WITH_\u2026`.\n\n## 1. Create your instance\'s repository\n\nYour instance\'s repository holds its configuration, its database migrations, and the release of Kestrel it runs. Kestrel itself installs from npm as `@kurtbruns/kestrel`, so the repository stays small, and an upgrade changes the version it names.\n\n1. On GitHub, [create a new repository](https://github.com/new) named `newsletter`. It can be private. Leave it empty, with no README, license, or `.gitignore`.\n\n1. Clone it, and write a new instance into it. Replace `YOUR_USERNAME` with your GitHub username:\n\n ```bash\n git clone https://github.com/YOUR_USERNAME/newsletter.git\n cd newsletter\n npx @kurtbruns/kestrel init\n ```\n\n This writes the instance\'s files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release\'s database migrations, and a starting point for Claude Code in `.claude/`. The instance\'s `README.md` lists its commands.\n\n1. Install the release and its tools:\n\n ```bash\n npm install\n ```\n\n1. Commit the new instance, and push it:\n\n ```bash\n git add -A\n git commit -m "Start a Kestrel instance"\n git push -u origin HEAD\n ```\n\n1. **(Optional)** Try Kestrel locally before you deploy it. Copy the local settings, and start the app:\n\n ```bash\n cp .dev.vars.example .dev.vars\n npm run dev\n ```\n\n Open http://localhost:8787/dashboard/, and run `npm run seed` in a second terminal to load a demo publication. Email goes to a stand-in, so nothing reaches a real inbox. Stop the server with <kbd>Ctrl</kbd>+<kbd>C</kbd> when you\'re done.\n\n## 2. Sign in to Cloudflare\n\nEvery command from here on acts on your Cloudflare account through the Wrangler CLI.\n\n1. Sign in. A browser window opens for you to approve the login:\n\n ```bash\n npx wrangler login\n ```\n\n1. Confirm which account you\'re signed in to:\n\n ```bash\n npx wrangler whoami\n ```\n\n It prints your email and the account name. If you belong to more than one account, make sure it\'s the one that holds your domain.\n\n## 3. Create the database and the image bucket\n\nEach command creates the resource and writes its id or name into the `production` block of `wrangler.jsonc`, under the binding the app already uses, so there\'s nothing to copy.\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create kestrel-production --env production --binding DB --update-config\n ```\n\n It prints the new database\'s settings, then a snippet it says to add to your configuration. Wrangler has already added it: it fills in the `database_id` of the production `DB` binding with the database\'s real id, in place of `REPLACE_WITH_PRODUCTION_D1_ID`, so there\'s nothing to paste.\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create kestrel-media-production --env production --binding MEDIA --update-config\n ```\n\n It prints a snippet to add to your configuration too. There\'s nothing to paste here either: the production `MEDIA` binding already names this bucket, so it stays as it is.\n\nWrangler rewrites the whole of `wrangler.jsonc` in its own layout when it saves: tabs, and one value per line. Your comments and settings stay; only the spacing changes.\n\nName `--env production` on every Wrangler command that creates or binds a resource. Without it, Wrangler writes to the top level, which is local development.\n\n## 4. Configure production\n\n`wrangler.jsonc` holds two sets of settings. The top level is for local development, so leave it alone. The `production` block under `env` is yours to fill in.\n\n1. Open `wrangler.jsonc` and change the values marked here in the `production` block. It\'s shown compactly here; in your file, after section 3, each value is on its own line:\n\n ```jsonc\n "production": {\n "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }], // \u2190 your app\'s hostname\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "\u2026", // already filled in by section 3; leave it\n "migrations_dir": "migrations"\n }\n ],\n "vars": {\n "PROVIDER": "", // \u2190 stays empty until Connect Amazon SES\n "APP_ORIGIN": "https://newsletter.example.com", // \u2190 your app\'s hostname\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com", // \u2190 your sending hostname\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>" // \u2190 who your mail is from\n }\n }\n ```\n\n What each one does:\n\n - **`routes`** puts the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can\'t already have a CNAME record; delete one if it does. The block also turns off the `workers.dev` addresses, so the app answers only on your hostname.\n - **`PROVIDER` stays empty.** [Connect Amazon SES](04-ses.md) sets it. Until then the app runs without an email provider: posts can be written and previewed, but scheduling, test emails, and signups are refused. Never set it to `fake`, the top level\'s value, even when a wrangler warning suggests copying it: `fake` delivers nothing, and the app refuses it on your hostname.\n - **`ARCHIVE_BASE_PATH`** is permanent once the first post goes out. Every post sent carries its `/archive/\u2026` link, and changing the prefix later breaks the links already mailed. Keep `/archive` unless you have a reason not to.\n - **`FROM_ADDRESS`** is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.\n\n Change every `example.com` value, the route included, even before you connect a provider. The app checks its settings but can\'t see the route: it refuses to run while `APP_ORIGIN`, `SENDING_DOMAIN`, or `FROM_ADDRESS`, or `ARCHIVE_ORIGIN` or `MEDIA_PUBLIC_BASE` if you set them, is still on `example.com`, since its links and the address your email comes from would name a domain that isn\'t yours.\n\n Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.\n\n1. Check the file. This regenerates the binding types, and fails if the configuration doesn\'t parse:\n\n ```bash\n npm run typecheck\n ```\n\n Wrangler prints the binding types it generated and ends with a reminder to run it again whenever you change `wrangler.jsonc`. The type check that follows prints nothing when it passes.\n\n1. Commit your settings and push them to your repository. An upgrade changes only the release your instance runs, so your settings stay as they are:\n\n ```bash\n git commit -am "Configure production"\n git push\n ```\n\n## 5. Apply the schema\n\nCreate Kestrel\'s tables in the production database:\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it\'s about to apply and asks you to confirm. It applies only the ones the database hasn\'t seen, so running it again is harmless.\n\n## 6. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThis deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker\'s **Triggers** tab in the Cloudflare dashboard.\n\n## Check it\n\n1. The app answers on your hostname. A new hostname can take a few minutes to get its certificate.\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The editor is locked. Until you set up Access in the next step, the app can\'t tell who is asking, so it lets no one in:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" https://newsletter.example.com/api/whoami\n ```\n\n ```\n 401\n ```\n\n In a browser, `https://newsletter.example.com/dashboard/` says the editor is locked until you set up Access. That\'s expected.\n\n1. In a browser, `https://newsletter.example.com/` shows the newsletter\'s public landing page. Its subscribe page, `/subscribe`, says signups aren\'t open yet, until Connect Amazon SES.\n\nIf the public pages instead say the newsletter is temporarily unavailable, and `/health` answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",\u2026}`, the setting it names is missing, malformed, or still on `example.com`. Fix it in `wrangler.jsonc`, commit, and deploy again.\n\nIf the public pages say the same, but `/health` answers `500` with `{"error":"schema_behind","missing":[\u2026],\u2026}`, the database hasn\'t run the migrations it lists, most often because step 5 was skipped or its prompt answered no. Run step 5\'s command, which the body\'s `message` repeats; the app answers as soon as it finishes, with no new deploy.\n\nNext, [lock the dashboard with Access](03-access.md).\n';
|
|
6241
6745
|
|
|
6242
6746
|
// docs/get-started/03-access.md
|
|
6243
|
-
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\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';
|
|
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 your posts through Amazon SES, Amazon\'s email service. SES only delivers the mail: Kestrel keeps your 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 your 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 one you already read, with Cloudflare Email Routing, which is free on every plan.\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 inbox you want replies in. Open the email Cloudflare sends to it, and select **Verify email address**. 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 your 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 your 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- **Your answers come from your inbox.** A forwarded reply comes from the reader\'s own address. When you answer it, your answer 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 you to do something, and Kestrel can\'t see them, so acting on them is yours. In the editor, open **Subscribers**, and use the reader\'s row menu. Choose **Unsubscribe** for a reader who asks to leave, and **Erase** for one who asks you to delete their data. A reader who never confirmed isn\'t mailed, so their row offers only **Erase**.\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 your publication first, since AWS may visit your website to review the request. In the editor, open **Settings \u2192 Publication identity**, and enter the name and tagline. Until you do, your 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 your 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 you expect, and how often you send.\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](06-go-live.md#as-your-list-grows) 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](06-go-live.md#as-your-list-grows) 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 your 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
|
|
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\
|
|
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
|
|
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
|
-
var notifications_default = "# Notifications through Cloudflare's email\n\nKestrel emails
|
|
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";
|
|
6259
6763
|
|
|
6260
6764
|
// docs/guides/05-rate-limit.md
|
|
6261
6765
|
var rate_limit_default = "# Rate-limit the subscribe form\n\nThe subscribe form is public, so anyone can submit it, as often as they like. The app already sends any one address at most one confirmation every 15 minutes, and answers every address the same way. It can't tell one visitor from another, though. Cloudflare can, so a rule at Cloudflare caps how often each visitor submits.\n\nIn this guide, you add a rate-limiting rule for the subscribe page, and check that it blocks a burst of requests. Nothing changes in the app.\n\n## 1. Add a rate-limiting rule\n\nCloudflare's Free plan includes one rate-limiting rule, and the subscribe form is a good place to spend it.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), open your domain, `example.com`, and go to **Security rules**.\n\n1. Select **Create rule**, then **Rate limiting rules**.\n\n1. Fill in the rule:\n\n - **Rule name:** `Subscribe form`\n - **If incoming requests match:** **URI Path** equals `/subscribe`\n - **With the same characteristics:** IP\n - **When rate exceeds:** 5 requests per 10 seconds\n - **Then take action:** Block, with a duration of 10 seconds\n\n The rule's expression reads:\n\n ```\n (http.request.uri.path eq \"/subscribe\")\n ```\n\n1. Select **Deploy**.\n\nOn the Free plan, a rule can match only on the path. It counts loads of the form as well as submissions, and `/subscribe` on every hostname in your domain. At five requests in 10 seconds, neither gets in a real reader's way.\n\nA paid plan allows a longer window, which fits a form better. On Pro, try 10 requests a minute, blocked for 10 minutes. Pro can also limit the rule to the app's hostname, with `and http.host eq \"newsletter.example.com\"`. Business can limit it to submissions, with `and http.request.method eq \"POST\"`.\n\nThe rule covers only your domain's hostnames. The Worker's `workers.dev` addresses would skip it, which is why your production settings keep `workers_dev` and `preview_urls` set to `false`. Keep them that way.\n\n## Check it\n\n1. Request the subscribe page eight times in a row:\n\n ```bash\n for i in $(seq 1 8); do curl -s -o /dev/null -w \"%{http_code}\\n\" https://newsletter.example.com/subscribe; done\n ```\n\n The first requests answer `200`. Once you pass five within 10 seconds, the rest answer `429`, Cloudflare's block. The switch can come a request or two later.\n\n1. Wait 10 seconds, and open `https://newsletter.example.com/subscribe` in a browser. The form loads again.\n\n1. In the dashboard, **Security rules** lists `Subscribe form` as active.\n";
|
|
@@ -6263,231 +6767,150 @@ var rate_limit_default = "# Rate-limit the subscribe form\n\nThe subscribe form
|
|
|
6263
6767
|
// docs/guides/07-upgrade.md
|
|
6264
6768
|
var upgrade_default = "# Upgrade to a new release\n\nA new release of Kestrel is a new version of the `@kurtbruns/kestrel` package. Upgrading changes the version your instance's repository names, brings in the release's database changes, and deploys. The app can't undo a database change, so you note a restore point first.\n\nIn this guide, you read what changed, install the release, and note where your database stands. Then you apply the database changes, deploy, and check the new version. If you run a staging environment, upgrade it first, and repeat each step for production once it checks out.\n\n## 1. Read what changed\n\nA release can ask you to change a setting or a record before it runs. The changelog says so, release by release.\n\n1. Find the version you run. In the editor, open the **Docs** or **API** tab. The foot of its contents list shows the version, then the commit. Your instance's `package.json` names it too, as the version of `@kurtbruns/kestrel`.\n\n1. Read the [release notes](https://github.com/kurtbruns/kestrel/releases) for every version after yours, up to the one you're moving to. Each release opens with an **Upgrading from\u2026** paragraph that says what to do. Its **Breaking** entries say what changed underneath you.\n\n## 2. Install the release\n\nEach step here changes only your instance's repository. Nothing reaches your deployed app until section 5.\n\n1. Install the release. Replace `X.Y.Z` with its version:\n\n ```bash\n npm install @kurtbruns/kestrel@X.Y.Z\n ```\n\n Your `package.json` now names exactly that version. Kestrel's versions aren't semantic versioning, so your instance never moves to a release you didn't choose.\n\n1. Bring in the release's database migrations:\n\n ```bash\n npm run sync-migrations\n ```\n\n It copies each new migration into `migrations/` and names the ones it added. It never overwrites one that's there.\n\n1. Check the Claude Code files Kestrel gave your instance:\n\n ```bash\n npm run check-context\n ```\n\n It compares each file in `.claude/` with the release's, and says what to do for each one:\n\n - **current**: nothing to do.\n - **Kestrel changed it, and you haven't edited it**: take the new one with `npm run check-context -- --update`.\n - **Kestrel changed it, and you've edited it**: it shows the difference from yours to the release's. Merge what you want into yours, by hand or with Claude, then record that you did with the command it prints.\n\n1. If the release's **Upgrading from\u2026** paragraph names a new setting, add it to `wrangler.jsonc`.\n\n1. Check the configuration and the types:\n\n ```bash\n npm run typecheck\n ```\n\n1. **(Optional)** Try the release on your computer. Apply its database changes to your local database, then start the app:\n\n ```bash\n npx wrangler d1 migrations apply DB --local\n npm run dev\n ```\n\n You see the new version with your local content. It can't show how the changes treat your real data. A staging environment can.\n\n1. Commit the upgrade as one commit, and push it. Going back, below, undoes this commit:\n\n ```bash\n git add -A\n git commit -m \"Upgrade Kestrel to X.Y.Z\"\n git push\n ```\n\n## 3. Note a restore point\n\nD1 keeps a history of your database, called Time Travel, for 30 days on Workers Paid and 7 on Workers Free. A bookmark marks one moment in it, so you can put the database back if the upgrade goes wrong.\n\n1. In the editor, check that no send is in progress. The dashboard lists any that are. Upgrade between sends.\n\n1. Note where the database stands now:\n\n ```bash\n npx wrangler d1 time-travel info kestrel-production\n ```\n\n It prints a bookmark for the database's current state. Copy it, and keep it until the upgrade checks out.\n\n## 4. Apply the database changes\n\nA release changes the database only by adding a migration. Applying it keeps everything already in the database.\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it's about to apply, and asks you to confirm. When a release has none, it says there's nothing to apply. Run it on every upgrade.\n\n## 5. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThe output ends with your app's hostname.\n\n## Check it\n\n1. The app answers:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {\"status\":\"ok\",\"service\":\"kestrel\"}\n ```\n\n If it answers `500` with `{\"error\":\"schema_behind\",\"missing\":[\u2026],\u2026}` instead, the release was deployed before its database changes: the public pages say the newsletter is temporarily unavailable and nothing is sent. Apply them as in section 4; the app answers as soon as they're applied, with no new deploy.\n\n1. Reload the editor. The foot of the **Docs** tab's contents list shows the new version, which links to its release page.\n\n1. Send yourself a test email, as in [Verify it works](../get-started/05-verify.md#1-send-yourself-a-test-email). Don't send a real post to check an upgrade: on a live instance, it goes to your whole list.\n\n1. In the logs, `sweep.tick` still arrives every minute, as in [Verify it works](../get-started/05-verify.md#7-read-the-logs).\n\n## Go back\n\nIf the new release misbehaves, deploy the previous one, and put the database back to your bookmark.\n\n1. Find the upgrade's commit. Replace `X.Y.Z` with the release you're backing out. Its id comes first on the line:\n\n ```bash\n git log -1 --oneline --grep=\"Upgrade Kestrel to X.Y.Z\"\n ```\n\n1. Undo it. This adds a commit that puts back the previous release, its migrations, and your Claude Code files, and leaves anything you committed since as it is:\n\n ```bash\n git revert --no-edit REPLACE_WITH_COMMIT_ID\n ```\n\n1. Install the previous release, and deploy it:\n\n ```bash\n npm ci\n npx wrangler deploy --env production\n ```\n\n1. Restore the database to the bookmark from section 3:\n\n ```bash\n npx wrangler d1 time-travel restore kestrel-production --bookmark=REPLACE_WITH_BOOKMARK\n ```\n\n1. Push the revert to your repository:\n\n ```bash\n git push\n ```\n\nA restore returns the whole database to that moment. Everything written since is gone: edits to posts, new subscribers, and the record of anything sent. Restore only soon after the upgrade.\n\nNever restore past a send that went out, or an unsubscribe recorded since the bookmark. The restored database wouldn't know of them, so it could mail those readers again, or mail someone who left. If either happened, stay on the new release, and fix forward.\n";
|
|
6265
6769
|
|
|
6266
|
-
// docs/guides/
|
|
6267
|
-
var
|
|
6770
|
+
// docs/guides/09-as-your-list-grows.md
|
|
6771
|
+
var as_your_list_grows_default = '# As your list grows\n\nA new instance runs on Cloudflare\'s free Workers plan with your starting Amazon SES limits. That suits a list of up to about 5,000 subscribers. Past that, two limits matter: how fast SES lets you send, and how many database writes the free plan allows a day.\n\nIn this guide, you raise your SES limits, which helps on either plan, and upgrade to Workers Paid, which a larger list needs. Then you see how long a send takes after each.\n\n## 1. Raise your SES limits\n\nSES caps how many emails your account sends a day, and how many a second. A send that hits the daily quota waits, emails you, and resumes on its own once the quota frees up. Raising your maximum send rate to 50 a second costs nothing, and it makes every send faster.\n\n1. Ask AWS for a higher daily quota and a maximum send rate of 50, as AWS\'s [Increasing your Amazon SES sending quotas](https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-request-increase.html) describes.\n\n1. When AWS grants it, the SES **Account dashboard** shows your new rate. In `wrangler.jsonc`, set the `production` block\'s [`SES_MAX_SEND_RATE`](../reference/01-configuration.md#ses_max_send_rate) to match.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Match the SES send rate"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## 2. Upgrade to Workers Paid\n\nOn the free plan, [D1](https://developers.cloudflare.com/d1/platform/pricing/) allows a fixed number of database writes a day. A send to about 7,000 recipients uses them all, and then the whole app stops until midnight UTC. Until you upgrade, the app warns the publisher before a send or an import that could use up the day. It\'s recommended to upgrade from about 5,000 subscribers.\n\n1. Upgrade your Cloudflare account to the $5 [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/), which has no daily limit on writes.\n\n1. In `wrangler.jsonc`, add `"SUBREQUEST_BUDGET": "10000"` to the `production` block\'s `vars`. It tells the app it\'s on Workers Paid, so each minute sends far more ([`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget)).\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Run on Workers Paid"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## How long a send takes\n\n| Setup | 1,000 subscribers | 5,000 subscribers | 10,000 subscribers |\n| --- | --- | --- | --- |\n| Free plan, a starting SES rate of 14 | about 18 minutes | about 1.5 hours | over the free plan\'s daily writes |\n| Free plan, SES rate raised to 50 | about 5 minutes | about 25 minutes | over the free plan\'s daily writes |\n| Workers Paid, SES rate of 14 | about 2 minutes | about 7 minutes | about 15 minutes |\n| Workers Paid, SES rate of 50 | under a minute | about 2 minutes | about 4 minutes |\n\n## Check it\n\n1. The SES **Account dashboard** shows the maximum send rate you set in `SES_MAX_SEND_RATE`.\n\n1. On Workers Paid, the import screen under **Subscribers \u2192 Import** reads "CSV or .txt, up to 10,000 rows."\n';
|
|
6268
6772
|
|
|
6269
6773
|
// docs/publishing/01-your-list.md
|
|
6270
|
-
var your_list_default = "# Bring your existing list\n\nAlmost no one starts a newsletter with an empty list. If your readers are on another service, bring them over before your first post, with the consent they gave there. It's the **Bring your existing list** step on your dashboard, and **Import** starts it. If you're starting fresh, skip this step: new readers join through [Share your subscribe link](09-share-your-subscribe-link.md).\n\nThis page covers the file Kestrel reads, what an import does and why, and the limits. It also covers taking your whole list out again at any time, in the same format. The dashboard's **Subscribers \u2192 Import** walks you through an import, and Claude reads this same page through the API.\n\n## The file Kestrel reads\n\nKestrel reads one documented format, `kestrel-subscribers/1`: a CSV file with a header row and one row per address. A row says what your old service recorded about a person, and Kestrel decides what that means here. Five columns decide what happens to a row:\n\n| Column | What it holds |\n| --- | --- |\n| `email` | The address. Kestrel trims it and ignores capitals when it matches. |\n| `status` | `subscribed`, `pending`, `unsubscribed`, or `none` (an address with no consent claim, present only to carry a bounce or a block). |\n| `opt_in` | How consent was collected: `double`, `single`, or `unknown`. |\n| `origin` | Where consent was collected, as a short name such as `sendy` or `mailchimp`. |\n| `subscribed_at` | When they subscribed, with its time zone, such as `2024-03-01T09:30:00Z`. Optional. |\n\nThe optional columns carry the rest: a bounce or complaint (`suppression`), when someone confirmed or left, and `evidence`, the old service's own consent details, such as a signup IP or a form name. [Import format](08-import-format.md) describes every column and value.\n\nThe import screen's **Download example file (CSV)** gives you `kestrel-import-example.csv`, a few rows that show each case. Open it in a spreadsheet to see the shape, and save your file as CSV UTF-8.\n\nKestrel never guesses what another service's export means, because a wrong guess about who agreed to be emailed can't be taken back. An export in any other shape is refused, with what's wrong and where. Convert it first, with Claude or by hand.\n\n## A plain list of addresses\n\nIf all you have is addresses, a `.txt` file with one address per line works too. After you choose it, you say what the addresses are: **Subscribed** (the default), **Unsubscribed**, or **Bounced**. Every address in the file is that one kind, so a plain list of subscribers and a plain list of bounces are two imports.\n\nA plain list loses what a full CSV carries: how and where each person joined, when, and the evidence. Kestrel records how they joined as unknown. When your old service can export more than addresses, prefer the CSV.\n\n## Let Claude convert your export\n\nExports from Sendy, Mailchimp, Substack, and other services all differ, and change without notice. If you use Claude Code with your instance, ask it to do the conversion:\n\n```\nPlease import my Sendy subscribers from ~/Downloads/sendy-export.csv into the newsletter.\n```\n\nClaude's `/migrate-list` skill reads Kestrel's format from your instance and asks you what anything ambiguous in the export means. It converts a few rows for you to check, then checks Kestrel's plan against its own counts. It asks for your attestation before it imports anyone as a subscriber. [Connect Claude to the API](../guides/01-connect-claude.md) sets Claude up.\n\nIf a scheduled post would reach the people the import adds, Claude stops and asks you to finish in the dashboard: choose the converted file Claude left in your working folder under **Subscribers \u2192 Import**, and the same plan appears for you to confirm.\n\n## What an import does\n\nNothing changes when you choose a file. Kestrel plans the import first and shows you what it would do to every row. You see who is added as a subscriber, who is recorded as having left, and who is suppressed, and which rows are repeats, invalid, or left out. You can download the rows that won't be imported as subscribers, to check them against your file. Only when you press **Import** does anything change, and then the import applies whole, or not at all. If a reader's consent changed between your plan and the import, for example someone unsubscribed or confirmed, their row follows your list as it stands then, and the result lists each row that turned out differently from the plan you saw. An import sends no email.\n\nTo import anyone as a subscriber, you confirm that everyone you're importing as subscribed opted in to receive your publication, and that the list isn't purchased, rented, or scraped. If you're unsure about some people, take them out of the file before importing. Kestrel keeps your attestation with the import's record.\n\nIf a post is scheduled, it goes to the list as it stands when it sends, so the people an import adds would receive it. The plan names each such post and how many more people it would reach, and you acknowledge each one before importing.\n\nIf your list or its scheduled posts changed since the plan in a way the plan rests on, such as another import, Kestrel plans your file again and shows you the new plan as things stand now, before anything happens.\n\n## Why your old service's record is honored\n\nKestrel normally confirms every new subscriber with double opt-in: they click a link in an email before they get any posts. Asking your whole list to confirm again would lose readers who already said yes, and would mail every one of them a request they didn't expect. So an import carries over what your old service recorded instead: the consent each person gave there, how it was collected, and your word for it.\n\nKestrel holds a few lines that an import can't cross:\n\n- **Someone who left stays gone.** An address that unsubscribed here is never subscribed again by an import, whatever the file says. Only that reader can come back, by confirming here.\n- **A bounce, a block, or a do-not-contact marker here always wins.** An import never lifts a suppression, so an address known to fail is never mailed. An address you erased keeps only a do-not-contact marker, and an import never records a subscriber for it again, not even as one who left.\n- **A reader's newer choice stands.** If someone confirmed here after your old service says they left, they stay subscribed.\n- **Repeats are folded together,** and when one row says subscribed and another says unsubscribed, unsubscribed wins.\n\nIn the subscriber list, imported subscribers are **Confirmed** and receive your posts. The **How they joined** column says they were imported, with the service and the opt-in method your file gave, such as \"Imported \xB7 Sendy, single opt-in\". Only someone who confirmed through Kestrel itself reads as \"Double opt-in\". Choose **View subscriber** from a subscriber's \u22EF menu to see the evidence.\n\n## Limits\n\n- **Rows.** One import takes up to 10,000 rows on Workers Paid, or 1,000 on the free plan's limits. The import screen says which applies to you. Split a larger list into several files, and import them one at a time.\n- **Daily database writes on the free plan.** Cloudflare's free plan allows a fixed number of database writes a day, and stops your newsletter until midnight UTC once they're used up. Bringing in a large list, in one file or several, and sending to it on the same day can use them up. So when the day's imports would use most of the day's writes, the plan warns you and suggests importing the file the next day, by UTC, and a post scheduled for a day imports have written warns you too, suggesting it go out the next day. Neither warning stops anything.\n- **When to move to Workers Paid.** From about 5,000 subscribers, it's recommended to move to the $5 Workers Paid plan, which has no daily limit on writes. Whoever runs your Kestrel makes the change, as [Go live](../get-started/06-go-live.md#as-your-list-grows) describes.\n\n## When readers reply\n\nKestrel only sends mail. A reader who replies to a post writes to your From address, so replies reach you once that address forwards to an inbox you read. Whoever runs your Kestrel sets that up outside Kestrel.\n\nWhen a reader replies asking to be taken off the list, carrying it out is up to you, since Kestrel never sees the reply. Find them under **Subscribers**, and choose **Unsubscribe** from their \u22EF menu. It takes effect at once. **Unsubscribe** is there for confirmed readers; one who never confirmed gets no posts, and **Erase** removes them.\n\nWhen a reader asks you to delete their data, choose **Erase** from their \u22EF menu instead. It removes the subscriber and how they joined, and can't be undone. Past send records show an erased recipient in their place. Kestrel keeps only a do-not-contact marker, or the bounce, complaint, or block the address already had, so the address is never mailed again. The reader can still rejoin by subscribing and confirming themselves.\n\n## Taking your list out\n\n**Subscribers \u2192 Export** downloads your whole list as one CSV in the same format: every subscriber with their state, how they joined and the evidence, and every bounced, complained, blocked, or erased address. It's yours to keep as a backup, or to import into another Kestrel, which carries the list over as it is. A value a spreadsheet would run as a formula is written as text, so the file is safe to open.\n\nClaude can read the same export a page at a time through the API, at `GET /api/subscribers/export`.\n\nNext, [Write your first post](04-write-a-post.md).\n";
|
|
6774
|
+
var your_list_default = "# Bring your existing list\n\nMoving to Kestrel from another newsletter service means bringing your subscribers with you, along with the consent they gave there. This guide explains how to export your list from the old service, convert it to Kestrel's format, and import it under **Subscribers \u2192 Import**. It also covers what carries over, the limits on an import, and taking your list out again. Kestrel shows you what an import will do before anything changes, and an import never emails your subscribers.\n\n**The first decision is whether to upgrade to Cloudflare's Workers Paid plan.** With more than about 5,000 subscribers, it's recommended to upgrade before you import. The free plan caps database writes per day, and a large import plus a send can use them up. When that happens, the app stops working until midnight UTC. Workers Paid is $5 a month and has no daily cap, and [As your list grows](../guides/09-as-your-list-grows.md) walks through the upgrade.\n\nIf you're starting without a list, skip this step and go on to [Write and send a post](04-write-a-post.md).\n\n## 1. Export from your old service\n\nExport everyone your old service knows about, not only your current subscribers. Unsubscribes and bounces matter too: once imported, Kestrel never mails someone who left or an address that fails. Include any consent details the export offers, such as signup dates, IP addresses, or form names. Kestrel keeps them with each subscriber as evidence of their consent.\n\n## 2. Convert it to Kestrel's format\n\nEvery service's export is different. Kestrel never guesses what one means, because a wrong guess about who agreed to be emailed can't be taken back. So you convert the export first, to one of two kinds of file:\n\n- **A CSV in Kestrel's format (preferred).** One row per address, with its status, how and where consent was collected, and any bounce or complaint. **Download example file (CSV)** on the import screen shows each case. Save your file as CSV UTF-8.\n- **A plain list (`.txt`).** One address per line, all of one kind: subscribed, unsubscribed, or bounced. Nothing else carries over, so use it only when addresses are all you have.\n\nA CSV in Kestrel's format looks like this, with the five columns that decide what happens to each row:\n\n```\nemail,status,opt_in,origin,subscribed_at\nada@example.com,subscribed,double,sendy,2024-03-01T09:30:00Z\ngrace@example.com,subscribed,single,mailchimp,\nedsger@example.com,unsubscribed,unknown,unknown,\n```\n\n[The file format](01-your-list.md#the-file-format), below, describes every column. If you use Claude Code with your instance, it can do the conversion for you, as [Working with Claude](07-working-with-claude.md) describes.\n\n## 3. Import it\n\nUnder **Subscribers \u2192 Import**, choose your file. Nothing changes yet: Kestrel plans the import and shows what it would do to every row. You see who is added, who is recorded as having left, who is suppressed, and which rows are left out. You can download the rows that won't be imported as subscribers, to check them against your file.\n\n**Import** then applies the plan, all of it or none of it. Before it does, you confirm two things when they apply:\n\n- **That everyone you're importing as subscribed opted in** to receive your publication, and the list isn't purchased, rented, or scraped. Kestrel keeps your attestation with the import.\n- **Each scheduled post that would grow.** A post goes to your list as it stands when it sends, so the people you import would receive it.\n\n## What carries over\n\nAsking your whole list to confirm again would lose readers who already said yes. So an import carries over the consent each person gave on your old service, and your word for it. Imported subscribers are **Confirmed** and receive your posts, and **How they joined** shows where they came from, such as \"Imported \xB7 Sendy, single opt-in\".\n\nA few things hold whatever the file says:\n\n- **Someone who unsubscribed here stays unsubscribed,** until they confirm again themselves.\n- **A bounce, a block, or an erasure here always wins.** An import never lifts one.\n- **A reader's newer choice stands.** Someone who confirmed here after your old service says they left stays subscribed.\n\n## Limits\n\n- **Rows.** Up to 10,000 in one import, or 1,000 on Cloudflare's free Workers plan. The import screen says which applies. Split a larger list into several files, and import them one at a time.\n- **Daily writes.** On the free Workers plan, an import or a large send warns you when it would use most of the day's database writes. Neither warning stops anything. [Limits in detail](01-your-list.md#limits-in-detail) gives the numbers.\n\n## Taking your list out\n\n**Subscribers \u2192 Export** downloads your whole list as one CSV in the same format. It holds every subscriber, how they joined, and every bounced, complained, blocked, or erased address. Keep it as a backup, or import it into another Kestrel to carry the list over.\n\n## The file format\n\nThis section defines `kestrel-subscribers/1` in full, for whoever converts a file, by hand or with Claude. The API serves the same definition at `GET /api/imports/format`, generated from the one the importer reads with. It serves the example files at `GET /api/imports/template.csv` and `GET /api/imports/template.txt`. A file's name decides its kind, `.csv` or `.txt`, and anything else is refused.\n\n### The CSV\n\nThe file is a CSV saved as UTF-8 (in a spreadsheet, **CSV UTF-8**). A header row names the columns below, in any order, then each row is one address. A value with a comma, a quote, or a line break in it is wrapped in double quotes. Each quote inside it is written twice, as spreadsheets write it (RFC 4180). Column names are written exactly, in lowercase. A row records what the source claims, never what Kestrel will do: the import decides that, under the rules further down.\n\n| Column | Required | Values | Meaning |\n| --- | --- | --- | --- |\n| `email` | yes | an email address | The address, trimmed and lowercased before it is matched. An invalid one is still a row, counted as invalid and reported, never dropped. |\n| `status` | yes | `subscribed`, `pending`, `unsubscribed`, `none` | The consent claim. `none` is a known address with no consent claim, present only to carry a suppression. |\n| `opt_in` | yes | `double`, `single`, `unknown` | How consent was collected. `unknown` unless the source states it. |\n| `origin` | yes | a short lowercase name | Where consent was first collected, as the file claims it: `sendy`, `mailchimp`, `kestrel`, or `unknown`. |\n| `suppression` | no | `bounce`, `complaint`, `manual`, `erased` | A suppression on top of `status`: the address bounced, complained, was suppressed by hand, or was erased. |\n| `subscribed_at` | no | an instant with its zone | When the person subscribed, if the source states it. |\n| `confirmed_at` | no | an instant with its zone | When the person confirmed, if the source states it. |\n| `unsubscribed_at` | no | an instant with its zone | When the person left, if the source states it. A reader who confirmed in Kestrel after it stays confirmed. |\n| `suppressed_at` | no | an instant with its zone | When the address was suppressed, if the source states it. |\n| `evidence` | no | a JSON object, at most 500 bytes | The source's consent-relevant columns, verbatim, such as a signup IP or a form name. Nothing reads it to decide anything: it is the proof of consent and the audit trail of a conversion. |\n| `format` | no | `kestrel-subscribers/1` | The version the file is written in. A file without it is read as version 1. |\n\nAn instant is a UTC ISO-8601 time with its zone, such as `2024-03-01T09:30:00Z` or `2024-03-01T10:30:00+01:00`. A time without a zone is refused, never interpreted. An optional column may be left out, or left empty on a row.\n\nAny other column is refused, naming it. A source's other columns belong in `evidence`, as JSON. A file whose header names none of these columns isn't in the format at all, and is refused as such.\n\nThe example file, `kestrel-import-example.csv`, shows each case: a double opt-in subscriber with evidence, a single opt-in one, someone who left, and a bounced address. Here are the same four with only the columns they use, since an optional column may be left out:\n\n```\nemail,status,opt_in,origin,suppression,unsubscribed_at,evidence\nada@example.com,subscribed,double,sendy,,,\"{\"\"signup_ip\"\":\"\"203.0.113.7\"\"}\"\ngrace@example.com,subscribed,single,mailchimp,,,\nedsger@example.com,unsubscribed,unknown,unknown,,2025-01-20T08:00:00Z,\nbounced@example.com,none,unknown,unknown,bounce,,\n```\n\n**Versions.** Adding an optional column keeps the version. Changing what a column means makes a new version. Kestrel reads every version it has published, and refuses one it doesn't know, by name.\n\n### The plain list\n\n- One address per line. Each line is trimmed, blank lines are skipped, and CRLF line endings and a UTF-8 byte-order mark are accepted.\n- A line that isn't a single valid address, such as `Name <ada@example.com>` or a typo, counts as invalid. It's listed among the rows left out. One bad line doesn't sink a list.\n- The whole file is refused when it isn't a plain list at all. That's a file that isn't UTF-8 text, or has no valid address in it. It's also one with a comma, tab, or semicolon in a line, since that makes it a table, so a `.csv`.\n- Its rows arrive with `origin: unknown` and `opt_in: unknown`.\n\n### Plan, then commit\n\nAn import happens in two steps. Planning shows what the import would do to every row, against your list as it stands. It changes nothing, so you can plan a file as often as you need. Committing applies the plan, all of it or none of it, and emails no subscriber.\n\nThe commit needs two things from you when they apply:\n\n- **Your attestation,** when anyone would be confirmed: \"Everyone I'm importing as subscribed opted in to receive [your publication]. This isn't a purchased, rented, or scraped list.\" It is kept with the import, with who gave it and when. An import that only unsubscribes or suppresses needs none.\n- **Each send that would grow,** acknowledged by a person. A post goes to the list as it stands when it fires. So an import can add to the audience of a scheduled send, or of one that has just fired and not yet fixed its audience. Claude can attest for you, but can't acknowledge a growing send: you finish that import in the dashboard.\n\nIf another import is committed after you plan, plan again. If your list changes between the plan and the commit in any other way, such as a reader unsubscribing, the commit still goes ahead. Every write checks the list as it stands, and the answer reports any row that turned out differently.\n\n### What an import does to each address\n\nEach row gets exactly one outcome. A row that both changes its status and adds a suppression reports its status outcome, and says it added a suppression too.\n\n| Outcome | When |\n| --- | --- |\n| `create_confirmed` | `subscribed`, with no subscriber and no suppression in Kestrel, and not erased in the file: added as confirmed. |\n| `confirm_pending` | `subscribed`, pending in Kestrel (never confirmed), and not erased in the file: confirmed, and its confirmation link no longer works. |\n| `unchanged` | Already confirmed in Kestrel, or already in the state the row claims. A subscriber who joined through Kestrel keeps their own record. |\n| `mark_unsubscribed` | `unsubscribed`, and confirmed or pending in Kestrel, with no confirmation in Kestrel newer than the row's `unsubscribed_at`: unsubscribed. |\n| `create_unsubscribed` | `unsubscribed`, with no subscriber in Kestrel, and not erased in Kestrel or in the file: recorded as unsubscribed, so the list remembers they left. |\n| `kept_confirmed_newer` | `unsubscribed`, but the reader confirmed in Kestrel after the source's `unsubscribed_at`: their newer action stands. |\n| `kept_unsubscribed` | `subscribed`, but unsubscribed in Kestrel, or pending again after leaving until they confirm in Kestrel themselves. An unsubscribe is never reversed. |\n| `blocked_by_suppression` | `subscribed`, but the address carries a suppression in Kestrel, a do-not-contact marker included: never added or confirmed. Also `unsubscribed`, when the address's suppression in Kestrel is the do-not-contact marker an erasure left. An address erased in the file gets this outcome too, when its row is `subscribed` or `unsubscribed` with no subscriber in Kestrel. So does one that's `subscribed` and pending in Kestrel, which stays pending. The file's `erased` suppression is still added. In each case no subscriber is recorded or confirmed, so an import never brings back an erased person's data. |\n| `suppression_added` | The row carries a suppression and the address has none in Kestrel, or only a do-not-contact marker, and nothing else changes. Applied whatever the row's `status`. |\n| `left_out` | `pending` or `none` with nothing to apply: counted, never confirmed. An import sends no email, so it can't start a confirmation either. |\n| `invalid` | The address isn't valid. |\n| `merged` | A repeat of an address earlier in the file, folded into it. |\n\nA suppression already in Kestrel is never replaced, with one exception. A bounce, a complaint, or a manual suppression replaces a do-not-contact marker left by an erasure, so a known bad address still gets no confirmation. An imported bounce or complaint is kept apart from one Kestrel saw itself, so the list says where each came from.\n\nAnyone can type an address into your subscribe form, which puts a reader who left back to pending until they confirm. An import treats such a reader as unsubscribed, so it never confirms someone who left in Kestrel.\n\n**Repeats of an address** are folded into one row first. Its status is the first of unsubscribed, subscribed, pending, and none that any repeat claims. Its suppression is the first of erased, complaint, bounce, and manual. Its opt-in is the weakest claim. It keeps the earliest consent times and the latest unsubscribe and suppression times, and the origin and evidence of the row whose status won.\n\nA subscriber an import confirmed is **confirmed**, like anyone who confirmed in Kestrel, and gets your posts. How they joined shows beside their state: imported, with the origin and opt-in method the file claimed, and the evidence. That holds even when the file says they confirmed through Kestrel itself. Only a confirmation Kestrel recorded reads as Kestrel's double opt-in, and a subscriber who later confirms in Kestrel becomes Kestrel's own.\n\n### Limits in detail\n\n- **Rows:** up to 10,000 in one import. On the free Workers plan's limits it's 1,000, since that plan's processing time per request can't plan and commit more. [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) tells Kestrel which plan it's on.\n- **Size:** an import request of at most 10 MB, and at most 500 bytes of `evidence` a row.\n- **Daily database writes on the free Workers plan:** 100,000 written rows a day. Once they're spent, the app stops working until midnight UTC. Each new subscriber an import brings in writes 7 rows, and a send writes about 14 a recipient. So an import of 1,000 new subscribers uses about 7% of a day, and about ten of them use most of it.\n- **The warnings:** an import's plan warns when it and the day's earlier imports, by UTC, would use more than 70% of the day's writes. It suggests importing the file the next day. A scheduled post warns when its send and that day's imports would use most of the day. With no imports, that's a send to more than 5,000 recipients. It suggests sending the next day, and its banner repeats the warning while it's scheduled. Neither warning stops anything.\n\n### Exporting\n\nThe export reads your whole list out in this format, a page at a time, at `GET /api/subscribers/export`. Each page holds up to 1,000 rows on the free Workers plan's limits, or 10,000 otherwise. Its `next` value asks for the page after it. Following `next` until it's `null` gives every address exactly once, even if the list changes while you read. An address added or removed partway through may or may not appear.\n\nTo make one CSV in this format, write the pages' rows under a single header. The dashboard's **Export** button, on the Subscribers screen, does this for you and downloads the whole list as one CSV. In the CSV, a value that starts with `=`, `+`, `-`, or `@` is written with an apostrophe before it. A spreadsheet then shows it as text rather than running it as a formula, and an import takes the apostrophe off again.\n\nThe export includes every subscriber with how they joined, their evidence, and their suppression. It also includes every address suppressed with no subscriber behind it, written as `status: none`. A confirmed subscriber is written as `subscribed`.\n\nImporting an export into another Kestrel carries the list over: the same states and suppressions, with every confirmation arriving as imported, carrying its original evidence. Pending subscribers are left out on the way in, since an import sends no confirmation.\n";
|
|
6271
6775
|
|
|
6272
6776
|
// docs/publishing/02-sending-rules.md
|
|
6273
|
-
var sending_rules_default = "# Sending rules\n\nEmail law asks a few things of every newsletter. Kestrel handles the ones a sending app can, and the rest are yours, since they depend on what you write and whom you write to.\n\nThis page covers what Kestrel handles, what you handle, and how to act on a reader's request to leave or be deleted. It describes US law (CAN-SPAM) unless it says otherwise. It's general information, not legal advice. When your situation is unclear, ask a lawyer.\n\n## What Kestrel handles\n\n- **Consent.** Kestrel mails only people who confirmed they want your newsletter. Someone who subscribes gets an email with a link, and only once they confirm do they receive posts. People you import come with your old service's record of their consent, and your word for it ([Bring your existing list](01-your-list.md#
|
|
6777
|
+
var sending_rules_default = "# Sending rules\n\nEmail law asks a few things of every newsletter. Kestrel handles the ones a sending app can, and the rest are yours, since they depend on what you write and whom you write to.\n\nThis page covers what Kestrel handles, what you handle, and how to act on a reader's request to leave or be deleted. It describes US law (CAN-SPAM) unless it says otherwise. It's general information, not legal advice. When your situation is unclear, ask a lawyer.\n\n## What Kestrel handles\n\n- **Consent.** Kestrel mails only people who confirmed they want your newsletter. Someone who subscribes gets an email with a link, and only once they confirm do they receive posts. People you import come with your old service's record of their consent, and your word for it ([Bring your existing list](01-your-list.md#what-carries-over)).\n- **Unsubscribing.** Every email has an unsubscribe link, and the one-click unsubscribe header that Gmail and Yahoo ask bulk senders for. It takes effect at once. US law allows up to 10 business days.\n- **Bounces and complaints.** An address that bounces, or a reader who marks your email as spam, is never mailed again. So a bad address can't keep hurting your sending reputation.\n- **Sender authentication.** Your emails are signed with DKIM for your own domain, and SPF and DMARC records in its DNS vouch for them. You set them up in [Connect Amazon SES](../get-started/04-ses.md), and mailbox providers now expect them.\n\n## What you handle\n\n- **An honest From name and subject line.** The From name should say who the email is from, and the subject should match what's inside. The From name and address are set when Kestrel is deployed ([Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy)).\n- **Marking ads as ads.** When an email is an advertisement, say so clearly. In a newsletter that's mostly writing, labeling a sponsor's section is enough.\n- **A mailing address, when an email is commercial.** Most newsletters don't need one. [When to provide a mailing address](03-name-your-publication.md#when-to-provide-a-mailing-address) says when you do, beside where you add one.\n- **Requests readers send you.** A reader who replies to a post writes to your From address. Replies reach you once that address forwards to an inbox you read, as [Connect Amazon SES](../get-started/04-ses.md#3-receive-replies-at-your-from-address) sets up. Kestrel never sees a reply, so carrying out what a reader asks is up to you.\n\n## When a reader asks to leave or to be deleted\n\nKestrel offers readers no form for deleting their data, so they ask you. In many places the law gives a reader that right. The European Union's GDPR, for example, may apply if you have readers there.\n\n- **Say how to reach you.** Your privacy notice, wherever you publish it, should say how readers ask you about their data. Replying to any post works once replies reach your inbox.\n- **To stop the email,** find a confirmed reader under **Subscribers**, and choose **Unsubscribe** from their \u22EF menu. It records that they asked, and they can rejoin by subscribing again. A reader who never confirmed gets no posts, so **Erase** is the choice for them.\n- **To stop mailing someone on your own judgment,** not at their request, choose **Block** instead. It stops every email to the address whatever its status, and **Unblock** undoes it.\n- **To delete their data,** choose **Erase** from the same menu. It removes the subscriber and how they joined, and can't be undone. Past send records show an erased recipient in their place. Kestrel keeps only a do-not-contact marker, or the bounce, complaint, or block the address already had, so it's never mailed again. The reader can still rejoin by subscribing and confirming themselves.\n- **Act promptly.** Under the [GDPR](https://eur-lex.europa.eu/eli/reg/2016/679/oj), you answer within a month in most cases (Article 12). The right to have data deleted is Article 17, and Article 13 asks you to tell readers about it when they sign up.\n\n## Outside the US\n\nOther countries have their own rules, and they often ask more. Canada's anti-spam law (CASL) usually counts more email as commercial, and asks for a mailing address in it ([Government of Canada guidance](https://ised-isde.canada.ca/site/canada-anti-spam-legislation/en)). The GDPR covers how you keep readers' data. If many of your readers live outside the US, check the rules where they live.\n\nThe FTC's [CAN-SPAM compliance guide](https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business) is the plain-language source for the US rules on this page.\n";
|
|
6274
6778
|
|
|
6275
6779
|
// docs/publishing/03-name-your-publication.md
|
|
6276
|
-
var name_your_publication_default = "# Name your publication\n\nGive your newsletter a name and a look before your first post.
|
|
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
|
|
6280
|
-
|
|
6281
|
-
// docs/publishing/05-scheduling-and-sending.md
|
|
6282
|
-
var scheduling_and_sending_default = `# Scheduling and sending
|
|
6283
|
-
|
|
6284
|
-
Every post goes out on a schedule. Scheduling makes the email and locks the post, and the send stays visible and cancelable until its time comes. That window is your review, and nothing can skip it, not even **Send now**.
|
|
6285
|
-
|
|
6286
|
-
This page covers scheduling, what it freezes, canceling, moving the send time, and sending now. It also covers who receives a post, and what happens when it goes out.
|
|
6287
|
-
|
|
6288
|
-
## Scheduling a post
|
|
6289
|
-
|
|
6290
|
-
**Schedule** at the foot of the editor opens a dialog where you pick a time under **Send at**, then confirm with **Schedule**. The post goes out at that time, and until then you can cancel it or move it.
|
|
6291
|
-
|
|
6292
|
-
The time you pick must be at least the minimum lead away. That's five minutes, unless whoever runs your Kestrel set it differently ([Configuration](../reference/01-configuration.md#min_lead_seconds)). A time closer than that is refused under the picker, with the reason. The lead isn't a setting in the dashboard, and Claude can't change it either. So every send gets a window to catch a mistake, including one Claude prepared.
|
|
6293
|
-
|
|
6294
|
-
Send times are to the minute. A time with seconds is rounded up to the next whole minute, which only lengthens the window.
|
|
6295
|
-
|
|
6296
|
-
A post needs a subject to schedule. If the subject is empty, pressing **Schedule** marks the **Subject** field with "Add a subject before you schedule." An empty body gets a warning, not a refusal.
|
|
6297
|
-
|
|
6298
|
-
After you schedule, you stay on the post. A toast names the send time and suggests you send yourself a test, since the window is where you review. The post now shows the scheduled banner: "Scheduled for" the time, with a countdown ("Sends in \u2026"), and **Reschedule** and **Cancel** beside it.
|
|
6299
|
-
|
|
6300
|
-
You manage a scheduled post from that banner, or from the **Scheduled** card on **Sent**. The dashboard's **Scheduled** card shows the countdown and opens the post.
|
|
6301
|
-
|
|
6302
|
-
## What scheduling freezes
|
|
6303
|
-
|
|
6304
|
-
Scheduling makes the email once, from the post, your email 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.
|
|
6305
|
-
|
|
6306
|
-
So the post is locked while it's scheduled. It opens on **Preview**, which shows the frozen copy. On **Edit**, you can still read, select, and copy the text, and the composer's foot says "Cancel the schedule to edit". **Send test email** still works, and sends you the frozen copy that will go out.
|
|
6307
|
-
|
|
6308
|
-
To change the post's content, cancel the schedule, edit, test again, and schedule it again. That resets your review, so your last test is always of the copy that sends.
|
|
6309
|
-
|
|
6310
|
-
A change to your template, or to a publication detail the template shows, is different. It re-makes the email of every scheduled post, at the same send times, with the same content. Kestrel asks before it saves, in a dialog confirmed with **Save and apply**. Afterwards each re-made post's banner asks you to send a new test. The dashboard's card reads "Look changed since your last test" until you do. A save like this is refused while any scheduled post is within the minimum lead of its send time. The refusal says when to try again. [Name your publication](03-name-your-publication.md) covers the template and those details.
|
|
6311
|
-
|
|
6312
|
-
## Canceling
|
|
6313
|
-
|
|
6314
|
-
**Cancel** on the scheduled banner, or on the **Scheduled** card on **Sent**, stops the send at once. No confirmation is asked, since nothing is lost: the post is a draft again, editable, and you can schedule it again whenever you like.
|
|
6315
|
-
|
|
6316
|
-
You can cancel until the send time itself. From then the banner reads "Preparing to send\u2026" and its buttons are unavailable, because the email is going out. A cancel that arrives just after the send time is refused with "Too late to cancel: the send time has passed, so this email is going out."
|
|
6317
|
-
|
|
6318
|
-
## Moving the send time
|
|
6319
|
-
|
|
6320
|
-
**Reschedule** moves when a scheduled post sends, without canceling it. It opens the same time picker, and **Reschedule** confirms it. The content stays frozen, your tests still count, and the post stays cancelable until the new time.
|
|
6321
|
-
|
|
6322
|
-
The new time follows the same rule as scheduling: at least the minimum lead away. Like canceling, you can move a send only before its send time.
|
|
6323
|
-
|
|
6324
|
-
## Sending now
|
|
6325
|
-
|
|
6326
|
-
Under the time picker in the Schedule dialog, "Or send as soon as possible." leads to **Send now \u2192**. It's for a post you've already reviewed and want out without picking a time.
|
|
6327
|
-
|
|
6328
|
-
Sending now still goes through the review window. The **Send now?** view says how many recipients it reaches and when, such as "in 5 to 6 minutes". The range is the minimum lead, plus rounding up to the next minute. Confirm with **Send now**, and the post is locked and scheduled for that time, cancelable until then like any other.
|
|
6329
|
-
|
|
6330
|
-
## Who receives it
|
|
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 ``. 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";
|
|
6331
6784
|
|
|
6332
|
-
|
|
6785
|
+
// docs/publishing/06-troubleshooting.md
|
|
6786
|
+
var troubleshooting_default = `# Troubleshooting
|
|
6333
6787
|
|
|
6334
|
-
|
|
6788
|
+
When a send needs you, its page and the dashboard's health block say so in a red or amber line. Red needs you to act, and amber is worth a look. Find the line's words below for what it means and what to do.
|
|
6335
6789
|
|
|
6336
|
-
|
|
6790
|
+
**Set a notifications address, so a problem reaches you without your watching.** Set it under **Settings \u2192 Notifications**, in **Send notifications to**. With none set, Kestrel emails you nothing. [Notifications](06-troubleshooting.md#notifications), below, lists what you're told.
|
|
6337
6791
|
|
|
6338
|
-
|
|
6792
|
+
## A send is waiting or held
|
|
6339
6793
|
|
|
6340
|
-
|
|
6341
|
-
- **Daily database writes.** On the Workers Free plan's limits, a large send may use up the day's database writes, which stops your newsletter until midnight UTC. The dialog warns you, and the post's banner repeats the warning for as long as it's scheduled. It never stops you from scheduling. [Bring your existing list](01-your-list.md#limits) explains the limit and when it's worth moving to Workers Paid.
|
|
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.
|
|
6342
6795
|
|
|
6343
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
6344
6804
|
|
|
6345
|
-
|
|
6805
|
+
## Before a send
|
|
6346
6806
|
|
|
6347
|
-
A
|
|
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.
|
|
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.
|
|
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.
|
|
6348
6811
|
|
|
6349
|
-
|
|
6812
|
+
## Reading a send's record
|
|
6350
6813
|
|
|
6351
|
-
|
|
6352
|
-
|
|
6353
|
-
[After a send](06-after-a-send.md) covers what the send page shows once it's out: the record, bounces, complaints, unsent recipients, and your archive page.
|
|
6354
|
-
`;
|
|
6355
|
-
|
|
6356
|
-
// docs/publishing/06-after-a-send.md
|
|
6357
|
-
var after_a_send_default = `# After a send
|
|
6358
|
-
|
|
6359
|
-
Once a send starts, Kestrel keeps a permanent record of it: who it went to, and what happened to each email. The post goes up on its public page in your archive at the same moment, exactly as it was sent. Nothing about either can be edited, and you don't have to watch: Kestrel emails you when the send finishes, and when something needs you.
|
|
6360
|
-
|
|
6361
|
-
This page covers a send's record, unsent recipients, bounces and complaints, your notifications, the dashboard's views, and the post's public page.
|
|
6362
|
-
|
|
6363
|
-
## The send's record
|
|
6364
|
-
|
|
6365
|
-
Every send has its own page, which you open from **Sent** or from the dashboard's **Sent** list. While the send goes out, the page is a live view of its progress. Once it finishes, the same page becomes its record, in place.
|
|
6366
|
-
|
|
6367
|
-
The record opens with five counts, over everyone the send went to:
|
|
6814
|
+
A send's page becomes its permanent record once it finishes. Its counts cover everyone the send went to:
|
|
6368
6815
|
|
|
6369
6816
|
| Count | What it means |
|
|
6370
6817
|
| --- | --- |
|
|
6371
|
-
| **
|
|
6372
|
-
| **
|
|
6373
|
-
| **
|
|
6374
|
-
| **
|
|
6375
|
-
| **Unsent** | Recipients Kestrel couldn't hand to your email provider. [Unsent recipients](#unsent-recipients), below, says why. |
|
|
6818
|
+
| **Delivered** | Your provider confirmed the email reached the reader's mail server. |
|
|
6819
|
+
| **Bounced** | The receiving server turned the email away. |
|
|
6820
|
+
| **Complained** | The reader marked the email as spam. |
|
|
6821
|
+
| **Unsent** | Kestrel couldn't hand the email to your provider. |
|
|
6376
6822
|
|
|
6377
|
-
|
|
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.
|
|
6378
6824
|
|
|
6379
|
-
|
|
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.
|
|
6380
6826
|
|
|
6381
|
-
|
|
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**.
|
|
6382
6828
|
|
|
6383
|
-
|
|
6829
|
+
### What SES refuses, and the fix
|
|
6384
6830
|
|
|
6385
|
-
|
|
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.
|
|
6386
6832
|
|
|
6387
|
-
|
|
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. |
|
|
6388
6848
|
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
An unsent recipient never got this post, and is still on your list. They get your next post as usual.
|
|
6392
|
-
|
|
6393
|
-
A recipient is recorded unsent in two cases:
|
|
6394
|
-
|
|
6395
|
-
- **Their own email kept failing.** When handing off one address hits a passing error, Kestrel tries again a few times, then records it unsent. The row's detail gives the provider's words.
|
|
6396
|
-
- **You chose Assume not sent.** Rarely, Kestrel can't tell whether your provider took some emails. The send waits for you, and **Resolve\u2026** on its page asks you to decide. **Assume not sent** is the safe default: those readers may miss this post, but no one is mailed twice.
|
|
6397
|
-
|
|
6398
|
-
A problem with your email provider itself never marks anyone unsent. While the provider is down, or refusing your account, the send waits and tries again. Once the problem is fixed, it carries on from where it stopped.
|
|
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".
|
|
6399
6850
|
|
|
6400
6851
|
## Bounces and complaints
|
|
6401
6852
|
|
|
6402
|
-
|
|
6853
|
+
Kestrel acts on these for you, so a bad address can't keep hurting your sending reputation:
|
|
6403
6854
|
|
|
6404
|
-
- **A hard bounce** means the address can't receive mail
|
|
6405
|
-
- **A soft bounce** is temporary, such as a full mailbox.
|
|
6406
|
-
- **A complaint** means the reader marked your email as spam. Kestrel
|
|
6855
|
+
- **A hard bounce** means the address can't receive mail. Kestrel never mails it again.
|
|
6856
|
+
- **A soft bounce** is temporary, such as a full mailbox. The address stays on your list.
|
|
6857
|
+
- **A complaint** means the reader marked your email as spam. Kestrel never mails them again.
|
|
6407
6858
|
|
|
6408
|
-
|
|
6409
|
-
|
|
6410
|
-
When a recent send's bounces reach about 5% of its recipients, the dashboard warns you. That's about the rate at which email providers start to review a sender, so it's worth finding out why.
|
|
6859
|
+
Under **Subscribers**, a suppressed address keeps its status and carries a label saying why. When a recent send's bounces reach about 5% of its recipients, the dashboard warns you. That's about the rate at which email providers start to review a sender, so check the addresses on its record.
|
|
6411
6860
|
|
|
6412
6861
|
### Lifting a suppression
|
|
6413
6862
|
|
|
6414
|
-
|
|
6415
|
-
|
|
6416
|
-
A block is yours to lift: **Unblock** in the subscriber's \u22EF menu, which asks you to confirm. It's offered for a block you made, or one an import brought. The dashboard has no control for lifting a bounce or a complaint, but Claude can do it for you through the API, as [Working with Claude](07-working-with-claude.md) describes. Lifting a suppression changes nothing on past records: the bounce or complaint stays where it happened.
|
|
6417
|
-
|
|
6418
|
-
## Notifications
|
|
6863
|
+
Lift one only when you know the address works again, such as a reader who tells you their mailbox is fixed. Mailing an address that keeps failing can hurt every send after it. **Unblock**, in a subscriber's \u22EF menu, lifts a block. The dashboard can't lift a bounce or a complaint, but [Claude](07-working-with-claude.md) can, when you ask. Past records keep the bounce or complaint where it happened.
|
|
6419
6864
|
|
|
6420
|
-
|
|
6865
|
+
## An import is refused
|
|
6421
6866
|
|
|
6422
|
-
|
|
6867
|
+
Kestrel refuses a file it can't read with certainty, and says what's wrong and where. Nothing on your list changes.
|
|
6423
6868
|
|
|
6424
|
-
|
|
6869
|
+
- **Not in the format.** The file isn't a Kestrel CSV or a plain list. Convert it first, with Claude or by hand.
|
|
6870
|
+
- **A column or value the format doesn't take,** named with its row and column. Fix it in the file, or move a column of your old service's into \`evidence\`.
|
|
6871
|
+
- **Too many rows** for one import. Split the file.
|
|
6872
|
+
- **Too large.** An import takes up to 10 MB.
|
|
6425
6873
|
|
|
6426
|
-
|
|
6427
|
-
- **When your provider refuses your account,** with the provider's own words and what they point to. Amazon SES reports a spent daily quota this way too.
|
|
6428
|
-
- **When a send is held because no email provider is connected,** and that it goes out once one is.
|
|
6429
|
-
- **When a send stays in progress too long.**
|
|
6430
|
-
- **When a send waits for you to resolve it.**
|
|
6431
|
-
- **When a send missed its time,** going out late or not at all.
|
|
6432
|
-
- **When Claude imports a list for you,** with what the import changed.
|
|
6874
|
+
[The file format](01-your-list.md#the-file-format) describes what each column takes.
|
|
6433
6875
|
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
The **Last notification** row in the same section says whether the latest one was **Delivered** or **Not delivered**. Notifications are separate from your newsletter: one that fails never delays or changes a send.
|
|
6437
|
-
|
|
6438
|
-
Whoever runs your Kestrel chooses how notifications are sent. By default they go through your newsletter's own email provider. Then the one about your provider refusing your account can't reach you, since that provider would refuse it too. Whoever runs your Kestrel can send them another way that avoids this, as [Notifications through Cloudflare's email](../guides/03-notifications.md) describes.
|
|
6439
|
-
|
|
6440
|
-
## Seeing what's happening
|
|
6441
|
-
|
|
6442
|
-
The **Dashboard** shows what needs you first, then what's going on:
|
|
6443
|
-
|
|
6444
|
-
- **A health block,** only when something needs your attention, such as a send waiting for you to resolve it. Red needs you to act; amber is worth a look.
|
|
6445
|
-
- **Active sends,** a card for each send going out now, with its progress.
|
|
6446
|
-
- **Subscribers,** counts of **Confirmed**, **Pending**, **Unsubscribed**, and **Suppressed**. Each opens the list filtered to that group.
|
|
6447
|
-
- **Sent,** your five latest sends, each with its **Delivered** count and a link to its public page.
|
|
6448
|
-
|
|
6449
|
-
**Sent** in the sidebar has the rest: anything that needs attention, your scheduled sends, sends in progress, and every send so far. Search them by subject, or turn on **With delivery failures** to list only sends with a bounce, a complaint, or an unsent recipient.
|
|
6450
|
-
|
|
6451
|
-
Wherever a send is listed, its number is **Delivered**, with any complaints, bounces, or unsent recipients named beside it. Every page keeps up on its own, so you never need to reload.
|
|
6452
|
-
|
|
6453
|
-
While a send goes out, its page shows two bars. The first fills as your provider accepts each email, and the second fills behind it as deliveries are confirmed. A label beside the subject says how it's going, such as **Sending**, **Retrying**, or **Backing off**.
|
|
6454
|
-
|
|
6455
|
-
## The post's public page
|
|
6456
|
-
|
|
6457
|
-
Your post goes up in your archive the moment its send starts. So the "view in browser" link in every email works as soon as it lands. Its publish date is the day its send started.
|
|
6876
|
+
## Notifications
|
|
6458
6877
|
|
|
6459
|
-
|
|
6878
|
+
With an address set, Kestrel emails you:
|
|
6460
6879
|
|
|
6461
|
-
|
|
6880
|
+
- when a send finishes, with how many emails were accepted, unsent, and skipped
|
|
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
|
|
6884
|
+
- when Claude imports a list for you
|
|
6462
6885
|
|
|
6463
|
-
|
|
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.
|
|
6464
6887
|
|
|
6465
|
-
|
|
6888
|
+
By default, notifications go through your newsletter's own email provider. So when that provider refuses your account, the notification about it can't reach you either. You can send them another way, as [Notifications through Cloudflare's email](../guides/03-notifications.md) describes.
|
|
6466
6889
|
`;
|
|
6467
6890
|
|
|
6468
6891
|
// docs/publishing/07-working-with-claude.md
|
|
6469
|
-
var working_with_claude_default = "# Working with Claude\n\
|
|
6470
|
-
|
|
6471
|
-
// docs/publishing/08-import-format.md
|
|
6472
|
-
var import_format_default = "# Subscriber import format\n\nKestrel imports a subscriber list in one documented format, `kestrel-subscribers/1`, and exports the whole list in the same format. This page describes the format, what an import does to each address, and the limits.\n\nKestrel never guesses what another service's export means, because a wrong guess about who agreed to be emailed can't be taken back. An export from Sendy, Mailchimp, or anywhere else is converted to this format first, by Claude or by hand. The API serves everything on this page at `GET /api/imports/format`, generated from the same definition the importer reads with, along with an example of each kind of file, `kestrel-import-example.csv` at `GET /api/imports/template.csv` and `kestrel-import-example.txt` at `GET /api/imports/template.txt`. The import screen offers both as **Download example file**.\n\n## Two kinds of file\n\n- **The full CSV (preferred).** It carries unsubscribes, bounces, and how each person joined. It's what the export writes, and you can open it in a spreadsheet to check it before importing.\n- **A plain list (`.txt`).** One address per line, for a list that is only addresses. You say what they all are: subscribed (the default), unsubscribed, or bounced. Nothing else carries over: not how or when each person joined, when anyone left, or any evidence of their consent. And since every address is the one kind you choose, unsubscribes and bounces each need a file of their own.\n\nThe file's name decides which it is: `.csv` or `.txt`. Anything else is refused.\n\n## The CSV\n\nA CSV file saved as UTF-8 (in a spreadsheet, **CSV UTF-8**), with a header row of the column names below, in any order, then one row per address. A value with a comma, a quote, or a line break in it is wrapped in double quotes, with each quote inside written twice, as spreadsheets write it (RFC 4180). Column names are written exactly, in lowercase. A row records what the source claims, never what Kestrel will do: the import decides that, under the rules further down.\n\n| Column | Required | Values | Meaning |\n| --- | --- | --- | --- |\n| `email` | yes | an email address | The address, trimmed and lowercased before it is matched. An invalid one is still a row, counted as invalid and reported, never dropped. |\n| `status` | yes | `subscribed`, `pending`, `unsubscribed`, `none` | The consent claim. `none` is a known address with no consent claim, present only to carry a suppression. |\n| `opt_in` | yes | `double`, `single`, `unknown` | How consent was collected. `unknown` unless the source states it. |\n| `origin` | yes | a short lowercase name | Where consent was first collected, as the file claims it: `sendy`, `mailchimp`, `kestrel`, or `unknown`. |\n| `suppression` | no | `bounce`, `complaint`, `manual`, `erased` | A suppression on top of `status`: the address bounced, complained, was suppressed by hand, or was erased. |\n| `subscribed_at` | no | an instant with its zone | When the person subscribed, if the source states it. |\n| `confirmed_at` | no | an instant with its zone | When the person confirmed, if the source states it. |\n| `unsubscribed_at` | no | an instant with its zone | When the person left, if the source states it. A reader who confirmed in Kestrel after it stays confirmed. |\n| `suppressed_at` | no | an instant with its zone | When the address was suppressed, if the source states it. |\n| `evidence` | no | a JSON object, at most 500 bytes | The source's consent-relevant columns, verbatim, such as a signup IP or a form name. Nothing reads it to decide anything: it is the proof of consent and the audit trail of a conversion. |\n| `format` | no | `kestrel-subscribers/1` | The version the file is written in. A file without it is read as version 1. |\n\nAn instant is a UTC ISO-8601 time with its zone, such as `2024-03-01T09:30:00Z` or `2024-03-01T10:30:00+01:00`. A time without a zone is refused, never interpreted. An optional column may be left out, or left empty on a row.\n\nAny other column is refused, naming it. A source's other columns belong in `evidence`, as JSON. A file whose header names none of these columns isn't in the format at all, and is refused as such.\n\nThe example file, `kestrel-import-example.csv`, shows each case: a double opt-in subscriber with evidence, a single opt-in one, someone who left, and a bounced address. Here are the same four with only the columns they use, since an optional column may be left out:\n\n```\nemail,status,opt_in,origin,suppression,unsubscribed_at,evidence\nada@example.com,subscribed,double,sendy,,,\"{\"\"signup_ip\"\":\"\"203.0.113.7\"\"}\"\ngrace@example.com,subscribed,single,mailchimp,,,\nedsger@example.com,unsubscribed,unknown,unknown,,2025-01-20T08:00:00Z,\nbounced@example.com,none,unknown,unknown,bounce,,\n```\n\n**Versions.** Adding an optional column keeps the version. Changing what a column means makes a new version. Kestrel reads every version it has published, and refuses one it doesn't know, by name.\n\n## The plain list\n\n- One address per line. Each line is trimmed, blank lines are skipped, and CRLF line endings and a UTF-8 byte-order mark are accepted.\n- A line that isn't a single valid address, such as `Name <ada@example.com>` or a typo, is counted as invalid and listed among the rows left out. One bad line doesn't sink a list.\n- The whole file is refused when it isn't a plain list at all: not UTF-8 text, a line with a comma, tab, or semicolon (it's a table, so a `.csv`), or not one valid address in it.\n- Its rows arrive with `origin: unknown` and `opt_in: unknown`.\n\n## Plan, then commit\n\nAn import happens in two steps. Planning shows what the import would do to every row, against your list as it stands, and changes nothing, so you can plan a file as often as you need. Committing applies the plan, all of it or none of it, and sends no email.\n\nThe commit needs two things from you when they apply:\n\n- **Your attestation,** when anyone would be confirmed: \"Everyone I'm importing as subscribed opted in to receive [your publication]. This isn't a purchased, rented, or scraped list.\" It is kept with the import, with who gave it and when. An import that only unsubscribes or suppresses needs none.\n- **Each send that would grow,** acknowledged by a person. A post goes to the list as it stands when it fires, so an import can add to the audience of a scheduled send, or of one that has just fired and not yet fixed its audience. Claude can attest for you, but can't acknowledge a growing send: you finish that import in the dashboard.\n\nIf another import is committed after you plan, plan again. If your list changes between the plan and the commit in any other way, such as a reader unsubscribing, the commit still goes ahead. Every write checks the list as it stands, and the answer reports any row that turned out differently.\n\n## What an import does to each address\n\nEach row gets exactly one outcome. A row that both changes its status and adds a suppression reports its status outcome, and says it added a suppression too.\n\n| Outcome | When |\n| --- | --- |\n| `create_confirmed` | `subscribed`, with no subscriber and no suppression in Kestrel, and not erased in the file: added as confirmed. |\n| `confirm_pending` | `subscribed`, pending in Kestrel (never confirmed), and not erased in the file: confirmed, and its confirmation link no longer works. |\n| `unchanged` | Already confirmed in Kestrel, or already in the state the row claims. A subscriber who joined through Kestrel keeps their own record. |\n| `mark_unsubscribed` | `unsubscribed`, and confirmed or pending in Kestrel, with no confirmation in Kestrel newer than the row's `unsubscribed_at`: unsubscribed. |\n| `create_unsubscribed` | `unsubscribed`, with no subscriber in Kestrel, and not erased in Kestrel or in the file: recorded as unsubscribed, so the list remembers they left. |\n| `kept_confirmed_newer` | `unsubscribed`, but the reader confirmed in Kestrel after the source's `unsubscribed_at`: their newer action stands. |\n| `kept_unsubscribed` | `subscribed`, but unsubscribed in Kestrel, or pending again after leaving until they confirm in Kestrel themselves. An unsubscribe is never reversed. |\n| `blocked_by_suppression` | `subscribed`, but the address carries a suppression in Kestrel, a do-not-contact marker included: never added or confirmed. Also `unsubscribed` for an address whose suppression in Kestrel is the do-not-contact marker an erasure left, and, for an address erased in the file (its `suppression` is `erased`, which is still added), `subscribed` or `unsubscribed` with no subscriber in Kestrel, or `subscribed` and pending in Kestrel, which stays pending: no subscriber is recorded or confirmed, so an import never brings back an erased person's data. |\n| `suppression_added` | The row carries a suppression and the address has none in Kestrel, or only a do-not-contact marker, and nothing else changes. Applied whatever the row's `status`. |\n| `left_out` | `pending` or `none` with nothing to apply: counted, never confirmed. An import sends no email, so it can't start a confirmation either. |\n| `invalid` | The address isn't valid. |\n| `merged` | A repeat of an address earlier in the file, folded into it. |\n\nA suppression already in Kestrel is never replaced, with one exception: a bounce, a complaint, or a manual suppression replaces a do-not-contact marker left by an erasure, so a known bad address still gets no confirmation. An imported bounce or complaint is kept apart from one Kestrel saw itself, so the list says where each came from.\n\nAnyone can type an address into your subscribe form, which puts a reader who left back to pending until they confirm. An import treats such a reader as unsubscribed, so it never confirms someone who left in Kestrel.\n\n**Repeats of an address** are folded into one row first. Its status is the first of unsubscribed, subscribed, pending, and none that any repeat claims, and its suppression the first of erased, complaint, bounce, and manual. Its opt-in is the weakest claim. It keeps the earliest consent times and the latest unsubscribe and suppression times, and the origin and evidence of the row whose status won.\n\n## How imported subscribers read\n\nA subscriber an import confirmed is **confirmed**, like anyone who confirmed in Kestrel, and gets your posts. How they joined shows beside their state: imported, with the origin and opt-in method the file claimed, and the evidence. That holds even when the file says they confirmed through Kestrel itself. Only a confirmation Kestrel recorded reads as Kestrel's double opt-in, and a subscriber who later confirms in Kestrel becomes Kestrel's own.\n\n## Limits\n\n- **Rows:** up to 10,000 in one import. On the Workers Free plan's limits (`SUBREQUEST_BUDGET` unset), up to 1,000, since Free's 10 ms of CPU a request can't plan and commit more. Set [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) on Workers Paid to take 10,000. Split a larger list into several files.\n- **Size:** a request of at most 10 MB, and `evidence` of at most 500 bytes a row.\n- **Database writes on Workers Free:** D1's free plan allows 100,000 rows written a day, and refuses every query past that until midnight UTC. Each new subscriber an import brings in writes 7 rows, and a send writes about 14 a recipient. So one import of 1,000 new subscribers uses about 7% of a day's writes, and about 10 of them use most of a day. On Workers Free, the plan warns when it and the day's earlier imports, by UTC, would use more than 70% of the day, and advises importing the file the next day. A send scheduled for a day imports have written warns in the same way, and advises sending the next day. Neither warning refuses.\n\n## Exporting\n\nThe export reads your whole list out in this format, a page at a time, at `GET /api/subscribers/export`. Each page holds up to 1,000 rows on the Workers Free plan's limits, or 10,000 otherwise, and its `next` value asks for the page after it. Following `next` until it's `null` gives every address exactly once, even if the list changes while you read. An address added or removed partway through may or may not appear. To make one CSV in this format, write the pages' rows under a single header. The dashboard's **Export** button, on the Subscribers screen, does this for you and downloads the whole list as one CSV. In the CSV, a value that starts with `=`, `+`, `-`, or `@` is written with an apostrophe before it, so a spreadsheet shows it as text rather than running it as a formula; an import takes the apostrophe off again. It includes every subscriber with how they joined, their evidence, and their suppression, and every address suppressed with no subscriber behind it, written as `status: none`. A confirmed subscriber is written as `subscribed`. Importing an export into another Kestrel carries the list over: the same states and suppressions, with every confirmation arriving as imported, carrying its original evidence. Pending subscribers are left out on the way in, since an import sends no confirmation.\n";
|
|
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";
|
|
6473
6893
|
|
|
6474
6894
|
// docs/publishing/09-share-your-subscribe-link.md
|
|
6475
|
-
var share_your_subscribe_link_default = "# Share your subscribe link\n\nYour publication
|
|
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";
|
|
6476
6896
|
|
|
6477
6897
|
// docs/publishing/10-check-your-setup.md
|
|
6478
|
-
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, subscribe yourself, and set where Kestrel emails you
|
|
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";
|
|
6899
|
+
|
|
6900
|
+
// docs/publishing/11-email-template.md
|
|
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";
|
|
6479
6902
|
|
|
6480
6903
|
// docs/publishing/README.md
|
|
6481
|
-
var README_default = "# Publishing with Kestrel\n\nThese pages
|
|
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";
|
|
6482
6905
|
|
|
6483
6906
|
// docs/README.md
|
|
6484
|
-
var README_default2 = "# Self-hosting Kestrel\n\nThese pages
|
|
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";
|
|
6485
6908
|
|
|
6486
6909
|
// docs/reference/01-configuration.md
|
|
6487
|
-
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. You can write and preview posts, 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 your 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, your emails and reader pages show no name at all.\n\n### `ARCHIVE_BASE_PATH`\n\nEvery post you send 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 your 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 ([Import format](../publishing/08-import-format.md#limits)).\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 you've imported), 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](../get-started/06-go-live.md#as-your-list-grows)). 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 you can schedule. 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";
|
|
6488
6911
|
|
|
6489
6912
|
// docs/reference/02-sending-domain-dns.md
|
|
6490
|
-
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
|
|
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";
|
|
6491
6914
|
|
|
6492
6915
|
// shared/docs.ts
|
|
6493
6916
|
function headingAnchor(text) {
|
|
@@ -6500,10 +6923,9 @@ var PAGES = {
|
|
|
6500
6923
|
"publishing/02-sending-rules.md": sending_rules_default,
|
|
6501
6924
|
"publishing/03-name-your-publication.md": name_your_publication_default,
|
|
6502
6925
|
"publishing/04-write-a-post.md": write_a_post_default,
|
|
6503
|
-
"publishing/
|
|
6504
|
-
"publishing/06-after-a-send.md": after_a_send_default,
|
|
6926
|
+
"publishing/06-troubleshooting.md": troubleshooting_default,
|
|
6505
6927
|
"publishing/07-working-with-claude.md": working_with_claude_default,
|
|
6506
|
-
"publishing/
|
|
6928
|
+
"publishing/11-email-template.md": email_template_default,
|
|
6507
6929
|
"publishing/09-share-your-subscribe-link.md": share_your_subscribe_link_default,
|
|
6508
6930
|
"publishing/10-check-your-setup.md": check_your_setup_default,
|
|
6509
6931
|
"get-started/02-deploy.md": deploy_default,
|
|
@@ -6515,7 +6937,7 @@ var PAGES = {
|
|
|
6515
6937
|
"guides/03-notifications.md": notifications_default,
|
|
6516
6938
|
"guides/05-rate-limit.md": rate_limit_default,
|
|
6517
6939
|
"guides/07-upgrade.md": upgrade_default,
|
|
6518
|
-
"guides/
|
|
6940
|
+
"guides/09-as-your-list-grows.md": as_your_list_grows_default,
|
|
6519
6941
|
"reference/01-configuration.md": configuration_default,
|
|
6520
6942
|
"reference/02-sending-domain-dns.md": sending_domain_dns_default
|
|
6521
6943
|
};
|
|
@@ -6656,8 +7078,15 @@ function renderDocs() {
|
|
|
6656
7078
|
}
|
|
6657
7079
|
var MD_LINK = /\]\(([^)\s#:]+\.md)(#[^)\s]*)?\)/g;
|
|
6658
7080
|
var MD_CODE = /^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$|`[^`\n]+`/gm;
|
|
7081
|
+
var FORMER_SLUGS = /* @__PURE__ */ new Map([
|
|
7082
|
+
["import-format", "your-list"],
|
|
7083
|
+
["scheduling-and-sending", "write-a-post"],
|
|
7084
|
+
["after-a-send", "troubleshooting"],
|
|
7085
|
+
["try-locally", "deploy"]
|
|
7086
|
+
]);
|
|
6659
7087
|
function docMarkdown(name) {
|
|
6660
|
-
const
|
|
7088
|
+
const slug = FORMER_SLUGS.get(name) ?? name;
|
|
7089
|
+
const doc = DOCS.find((d) => d.slug === slug);
|
|
6661
7090
|
const set = SET_SOURCES.find((s) => s.id === name);
|
|
6662
7091
|
const from = doc?.path ?? set?.path;
|
|
6663
7092
|
const markdown2 = doc?.markdown ?? set?.index;
|
|
@@ -6692,6 +7121,74 @@ function markdownResponse(body) {
|
|
|
6692
7121
|
return new Response(body, { headers: { "content-type": "text/markdown; charset=utf-8" } });
|
|
6693
7122
|
}
|
|
6694
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
|
+
|
|
6695
7192
|
// src/routes/posts.ts
|
|
6696
7193
|
function author(c) {
|
|
6697
7194
|
return c.principal?.email ?? c.principal?.kind ?? null;
|
|
@@ -6720,6 +7217,11 @@ function baseRevision(c, body) {
|
|
|
6720
7217
|
}
|
|
6721
7218
|
return body.base_revision;
|
|
6722
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
|
+
}
|
|
6723
7225
|
function revisionHeaders(post) {
|
|
6724
7226
|
return post.current_revision ? { ETag: `"${post.current_revision}"` } : void 0;
|
|
6725
7227
|
}
|
|
@@ -6743,8 +7245,15 @@ async function requireDraft(c) {
|
|
|
6743
7245
|
}
|
|
6744
7246
|
async function createPost2(c) {
|
|
6745
7247
|
const { input } = await readBody(c, { optional: true });
|
|
6746
|
-
const { post, revision } = await
|
|
6747
|
-
|
|
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
|
+
};
|
|
6748
7257
|
return json(body, 201, revisionHeaders(post));
|
|
6749
7258
|
}
|
|
6750
7259
|
var POST_STATUSES = ["draft", "scheduled", "sent"];
|
|
@@ -6773,8 +7282,12 @@ async function listPosts2(c) {
|
|
|
6773
7282
|
countPosts(c.env.DB, filter),
|
|
6774
7283
|
listPosts(c.env.DB, filter, page)
|
|
6775
7284
|
]);
|
|
7285
|
+
const published = await publicationsFor(
|
|
7286
|
+
c.env.DB,
|
|
7287
|
+
rows.map((row) => row.id)
|
|
7288
|
+
);
|
|
6776
7289
|
const body = {
|
|
6777
|
-
posts: rows,
|
|
7290
|
+
posts: rows.map((row) => withAddresses(c.config, row, published.get(row.id) ?? null)),
|
|
6778
7291
|
page: listPage(total, page),
|
|
6779
7292
|
cursor: encodeSendCursor({ seq, at: now })
|
|
6780
7293
|
};
|
|
@@ -6789,7 +7302,8 @@ async function getPost2(c) {
|
|
|
6789
7302
|
const active = post.status === "scheduled" ? await getActiveSendForPost(c.env.DB, post.id) : null;
|
|
6790
7303
|
const sent = post.status === "sent" ? await latestSentSendForPost(c.env.DB, post.id) : null;
|
|
6791
7304
|
const body = {
|
|
6792
|
-
post,
|
|
7305
|
+
post: await answered(c, post),
|
|
7306
|
+
subject_slug_held: await subjectSlugHeld(c.env.DB, post),
|
|
6793
7307
|
markdown: revision?.markdown ?? "",
|
|
6794
7308
|
author: revision?.author ?? null,
|
|
6795
7309
|
// who wrote the current revision — the freshness poll names them
|
|
@@ -6810,7 +7324,7 @@ async function updatePost2(c) {
|
|
|
6810
7324
|
error: "base_required",
|
|
6811
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.",
|
|
6812
7326
|
field: "base_revision",
|
|
6813
|
-
post
|
|
7327
|
+
post: withAddresses(c.config, post, UNPUBLISHED)
|
|
6814
7328
|
};
|
|
6815
7329
|
return json(required, 428, revisionHeaders(post));
|
|
6816
7330
|
}
|
|
@@ -6821,12 +7335,21 @@ async function updatePost2(c) {
|
|
|
6821
7335
|
message: "This draft changed since you loaded it. Read it again before saving.",
|
|
6822
7336
|
current_revision: post.current_revision,
|
|
6823
7337
|
updated_at: post.updated_at,
|
|
6824
|
-
author: current2?.author ?? null
|
|
7338
|
+
author: current2?.author ?? null,
|
|
7339
|
+
post: withAddresses(c.config, post, UNPUBLISHED),
|
|
7340
|
+
markdown: current2?.markdown ?? ""
|
|
6825
7341
|
};
|
|
6826
7342
|
return json(stale, 409, revisionHeaders(post));
|
|
6827
7343
|
}
|
|
6828
|
-
const { post: updated, revision } = await
|
|
6829
|
-
|
|
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
|
+
};
|
|
6830
7353
|
return json(saved, 200, revisionHeaders(updated));
|
|
6831
7354
|
}
|
|
6832
7355
|
async function deletePost2(c) {
|
|
@@ -6875,6 +7398,47 @@ async function getRevision(c) {
|
|
|
6875
7398
|
metadata: JSON.parse(rev.metadata)
|
|
6876
7399
|
});
|
|
6877
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
|
+
}
|
|
6878
7442
|
|
|
6879
7443
|
// src/routes/images.ts
|
|
6880
7444
|
var MAX_POST_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
@@ -6989,7 +7553,7 @@ function maxImportRows(subrequestBudget) {
|
|
|
6989
7553
|
return onFreePlanLimits({ subrequestBudget }) ? {
|
|
6990
7554
|
maxRows: IMPORT_LIMITS.maxRowsFree,
|
|
6991
7555
|
// Said to the publisher, who may never have seen Cloudflare: the setting behind it
|
|
6992
|
-
// is the operator's, in the API reference and
|
|
7556
|
+
// is the operator's, in the API reference and Bring your existing list's file format.
|
|
6993
7557
|
reason: `That\u2019s the limit on Cloudflare\u2019s Workers Free plan. Whoever runs your Kestrel can raise it to ${IMPORT_LIMITS.maxRows.toLocaleString("en-US")} rows by moving to Workers Paid.`
|
|
6994
7558
|
} : { maxRows: IMPORT_LIMITS.maxRows, reason: null };
|
|
6995
7559
|
}
|
|
@@ -7758,6 +8322,20 @@ function sesFromAddress(from) {
|
|
|
7758
8322
|
function sesCanAddress(email2) {
|
|
7759
8323
|
return ASCII_ONLY.test(email2);
|
|
7760
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
|
+
}
|
|
7761
8339
|
|
|
7762
8340
|
// src/providers/sns.ts
|
|
7763
8341
|
var SNS_HOST = /^sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$/;
|
|
@@ -7877,6 +8455,8 @@ function spkiFromPem(pem) {
|
|
|
7877
8455
|
}
|
|
7878
8456
|
throw new Error("PEM has neither a PUBLIC KEY nor a CERTIFICATE");
|
|
7879
8457
|
}
|
|
8458
|
+
var CertUnavailable = class extends Error {
|
|
8459
|
+
};
|
|
7880
8460
|
async function fetchVerifyKey(certUrl, hash) {
|
|
7881
8461
|
const cacheKey = `${hash}:${certUrl}`;
|
|
7882
8462
|
const cached = keyCache.get(cacheKey);
|
|
@@ -7892,11 +8472,22 @@ async function fetchVerifyKey(certUrl, hash) {
|
|
|
7892
8472
|
if (u.protocol !== "https:" || !isSnsHost(u.hostname)) {
|
|
7893
8473
|
throw new Error(`untrusted SigningCertURL host: ${u.hostname}`);
|
|
7894
8474
|
}
|
|
7895
|
-
|
|
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
|
+
}
|
|
7896
8488
|
if (!res.ok) {
|
|
7897
8489
|
throw new Error(`signing cert fetch failed: ${res.status}`);
|
|
7898
8490
|
}
|
|
7899
|
-
const pem = await res.text();
|
|
7900
8491
|
const key = await crypto.subtle.importKey(
|
|
7901
8492
|
"spki",
|
|
7902
8493
|
spkiFromPem(pem),
|
|
@@ -7907,25 +8498,39 @@ async function fetchVerifyKey(certUrl, hash) {
|
|
|
7907
8498
|
keyCache.set(cacheKey, key);
|
|
7908
8499
|
return key;
|
|
7909
8500
|
}
|
|
7910
|
-
|
|
7911
|
-
|
|
8501
|
+
function isSnsCertUrl(certUrl) {
|
|
8502
|
+
try {
|
|
8503
|
+
const u = new URL(certUrl);
|
|
8504
|
+
return u.protocol === "https:" && isSnsHost(u.hostname);
|
|
8505
|
+
} catch {
|
|
7912
8506
|
return false;
|
|
7913
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
|
+
}
|
|
7914
8513
|
const hash = msg.SignatureVersion === "2" ? "SHA-256" : msg.SignatureVersion === "1" ? "SHA-1" : null;
|
|
7915
8514
|
if (!hash) {
|
|
7916
|
-
return
|
|
8515
|
+
return "unsigned";
|
|
7917
8516
|
}
|
|
7918
8517
|
let key;
|
|
7919
8518
|
try {
|
|
7920
8519
|
key = await fetchVerifyKey(msg.SigningCertURL, hash);
|
|
7921
|
-
} catch {
|
|
7922
|
-
return
|
|
8520
|
+
} catch (err) {
|
|
8521
|
+
return err instanceof CertUnavailable ? "unavailable" : "invalid";
|
|
7923
8522
|
}
|
|
7924
8523
|
try {
|
|
7925
8524
|
const data = new TextEncoder().encode(canonicalString(msg));
|
|
7926
|
-
|
|
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";
|
|
7927
8532
|
} catch {
|
|
7928
|
-
return
|
|
8533
|
+
return "invalid";
|
|
7929
8534
|
}
|
|
7930
8535
|
}
|
|
7931
8536
|
function sesRenderingFailure(messageJson) {
|
|
@@ -7996,20 +8601,23 @@ function sesTraits(config) {
|
|
|
7996
8601
|
}
|
|
7997
8602
|
function sesErrorText(status, type, message2) {
|
|
7998
8603
|
const detail = [type, message2].filter(Boolean).join(": ");
|
|
7999
|
-
return `ses ${status}${detail ? ` ${detail}` : ""}
|
|
8604
|
+
return redactCredentials(`ses ${status}${detail ? ` ${detail}` : ""}`);
|
|
8000
8605
|
}
|
|
8001
8606
|
function sesEntryErrorText(status, error) {
|
|
8002
|
-
return `ses ${status}${error ? `: ${error}` : ""}
|
|
8607
|
+
return redactCredentials(`ses ${status}${error ? `: ${error}` : ""}`);
|
|
8003
8608
|
}
|
|
8004
8609
|
function textResponse(body, status) {
|
|
8005
8610
|
return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
8006
8611
|
}
|
|
8007
8612
|
var ACCOUNT_ERROR_TYPES = /* @__PURE__ */ new Map([
|
|
8008
|
-
// AWS
|
|
8009
|
-
// restricted it for good; either way, until the operator acts in the SES console.
|
|
8010
|
-
["SendingPausedException", "suspended"],
|
|
8613
|
+
// AWS restricted the account's sending for good, over its sending practices.
|
|
8011
8614
|
["AccountSuspendedException", "suspended"],
|
|
8012
|
-
|
|
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"],
|
|
8013
8621
|
// A resource the request names doesn't exist. Kestrel names only its configuration set
|
|
8014
8622
|
// (SES_CONFIGURATION_SET), so this is a typo, a set deleted after setup, or the wrong
|
|
8015
8623
|
// region: the deployment's to fix, and the same for every recipient, so it holds the send
|
|
@@ -8017,55 +8625,132 @@ var ACCOUNT_ERROR_TYPES = /* @__PURE__ */ new Map([
|
|
|
8017
8625
|
// resource that could be missing, so a 404 then isn't expected, and the advice still points
|
|
8018
8626
|
// at the one setting that would cause it.
|
|
8019
8627
|
["NotFoundException", "configuration"],
|
|
8020
|
-
//
|
|
8021
|
-
|
|
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.
|
|
8022
8639
|
["UnrecognizedClientException", "credentials"],
|
|
8023
8640
|
["InvalidClientTokenId", "credentials"],
|
|
8641
|
+
["MissingAuthenticationToken", "credentials"],
|
|
8642
|
+
["MissingAuthenticationTokenException", "credentials"],
|
|
8024
8643
|
["SignatureDoesNotMatch", "credentials"],
|
|
8644
|
+
["IncompleteSignature", "credentials"],
|
|
8645
|
+
["IncompleteSignatureException", "credentials"],
|
|
8646
|
+
["InvalidSignatureException", "credentials"],
|
|
8647
|
+
["RequestExpired", "credentials"],
|
|
8025
8648
|
["ExpiredTokenException", "credentials"],
|
|
8026
|
-
["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"]
|
|
8027
8653
|
]);
|
|
8028
|
-
|
|
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 = "") {
|
|
8029
8687
|
const throttled = status === 429 || /throttl|toomany/i.test(type);
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
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");
|
|
8033
8703
|
}
|
|
8034
8704
|
if (throttled) {
|
|
8035
|
-
return
|
|
8705
|
+
return unavailable("rate_limit");
|
|
8036
8706
|
}
|
|
8037
|
-
if (status >= 500) {
|
|
8038
|
-
return
|
|
8707
|
+
if (status === 408 || UNAVAILABLE_ERROR_TYPES.has(type) || status >= 500) {
|
|
8708
|
+
return unavailable("outage");
|
|
8039
8709
|
}
|
|
8040
8710
|
return null;
|
|
8041
8711
|
}
|
|
8042
|
-
|
|
8043
|
-
reason: "account",
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
|
|
8047
|
-
|
|
8048
|
-
cause
|
|
8049
|
-
});
|
|
8712
|
+
function account(cause) {
|
|
8713
|
+
return { reason: "account", cause };
|
|
8714
|
+
}
|
|
8715
|
+
function unavailable(cause) {
|
|
8716
|
+
return { reason: "unavailable", cause };
|
|
8717
|
+
}
|
|
8050
8718
|
var ENTRY_HALTS = /* @__PURE__ */ new Map([
|
|
8051
8719
|
["ACCOUNT_SUSPENDED", account("suspended")],
|
|
8052
|
-
["ACCOUNT_SENDING_PAUSED", account("
|
|
8053
|
-
["CONFIGURATION_SET_SENDING_PAUSED", account("
|
|
8054
|
-
["MAIL_FROM_DOMAIN_NOT_VERIFIED", account("
|
|
8720
|
+
["ACCOUNT_SENDING_PAUSED", account("paused")],
|
|
8721
|
+
["CONFIGURATION_SET_SENDING_PAUSED", account("set_paused")],
|
|
8722
|
+
["MAIL_FROM_DOMAIN_NOT_VERIFIED", account("mail_from")],
|
|
8055
8723
|
["ACCOUNT_DAILY_QUOTA_EXCEEDED", account("quota")],
|
|
8056
8724
|
["CONFIGURATION_SET_DOES_NOT_EXIST", account("configuration")],
|
|
8057
8725
|
["CONFIGURATION_SET_NOT_FOUND", account("configuration")],
|
|
8058
|
-
["INVALID_SENDING_POOL_NAME", account("
|
|
8059
|
-
["TEMPLATE_DOES_NOT_EXIST", account("
|
|
8060
|
-
["TEMPLATE_NOT_FOUND", account("
|
|
8726
|
+
["INVALID_SENDING_POOL_NAME", account("sending_pool")],
|
|
8727
|
+
["TEMPLATE_DOES_NOT_EXIST", account("unsupported")],
|
|
8728
|
+
["TEMPLATE_NOT_FOUND", account("unsupported")],
|
|
8061
8729
|
["ACCOUNT_THROTTLED", unavailable("rate_limit")],
|
|
8062
8730
|
["TRANSIENT_FAILURE", unavailable("outage")]
|
|
8063
8731
|
]);
|
|
8064
|
-
|
|
8065
|
-
|
|
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" };
|
|
8066
8751
|
}
|
|
8067
8752
|
function worse(a, b) {
|
|
8068
|
-
return !a ||
|
|
8753
|
+
return !a || haltRank(b) > haltRank(a) ? b : a;
|
|
8069
8754
|
}
|
|
8070
8755
|
var SesProvider = class {
|
|
8071
8756
|
name = "ses";
|
|
@@ -8105,15 +8790,11 @@ var SesProvider = class {
|
|
|
8105
8790
|
const out = [];
|
|
8106
8791
|
const sendable = [];
|
|
8107
8792
|
for (const r of recipients) {
|
|
8108
|
-
|
|
8793
|
+
const problem = sesAddressProblem(r.email);
|
|
8794
|
+
if (problem === null) {
|
|
8109
8795
|
sendable.push(r);
|
|
8110
8796
|
} else {
|
|
8111
|
-
out.push({
|
|
8112
|
-
email: r.email,
|
|
8113
|
-
accepted: false,
|
|
8114
|
-
retryable: false,
|
|
8115
|
-
error: "ses can't send to an address with characters outside ASCII"
|
|
8116
|
-
});
|
|
8797
|
+
out.push({ email: r.email, accepted: false, retryable: false, error: problem });
|
|
8117
8798
|
}
|
|
8118
8799
|
}
|
|
8119
8800
|
const content = sesTemplateContent(rendered);
|
|
@@ -8125,7 +8806,7 @@ var SesProvider = class {
|
|
|
8125
8806
|
try {
|
|
8126
8807
|
answer = opts.purpose === "list" ? await this.sendBulk(content, batch) : await this.sendSingle(content, unwrap(batch[0], "a recipient"));
|
|
8127
8808
|
} catch (err) {
|
|
8128
|
-
if (opts.purpose === "list" ||
|
|
8809
|
+
if (opts.purpose === "list" || out.length === 0 && sendable.length === batch.length) {
|
|
8129
8810
|
throw err;
|
|
8130
8811
|
}
|
|
8131
8812
|
const words = err instanceof Error ? err.message : String(err);
|
|
@@ -8134,11 +8815,17 @@ var SesProvider = class {
|
|
|
8134
8815
|
email: r.email,
|
|
8135
8816
|
accepted: false,
|
|
8136
8817
|
retryable: false,
|
|
8137
|
-
error: `ses gave no answer (${words})
|
|
8818
|
+
error: `ses gave no answer (${words})`,
|
|
8819
|
+
unknown: true
|
|
8138
8820
|
});
|
|
8139
8821
|
}
|
|
8140
8822
|
for (const rest of sendable.slice(at + size)) {
|
|
8141
|
-
out.push({
|
|
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
|
+
});
|
|
8142
8829
|
}
|
|
8143
8830
|
break;
|
|
8144
8831
|
}
|
|
@@ -8194,8 +8881,9 @@ var SesProvider = class {
|
|
|
8194
8881
|
* (that risks a duplicate, violating I4): it throws, and the send loop leaves its
|
|
8195
8882
|
* recipients `dispatched` for a human. A request SES never answers is ended the same
|
|
8196
8883
|
* way: the timeout rejects the fetch, so it throws down that same path. aws4fetch
|
|
8197
|
-
* carries `signal` onto the signed Request.
|
|
8198
|
-
*
|
|
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.
|
|
8199
8887
|
*/
|
|
8200
8888
|
post(path2, payload) {
|
|
8201
8889
|
if (this.configurationSet) {
|
|
@@ -8208,18 +8896,14 @@ var SesProvider = class {
|
|
|
8208
8896
|
signal: AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS)
|
|
8209
8897
|
});
|
|
8210
8898
|
}
|
|
8211
|
-
/** An SES error response
|
|
8899
|
+
/** An SES error response: its type, its halt, or SES's words when it may be about a
|
|
8900
|
+
* recipient. */
|
|
8212
8901
|
async refusal(res) {
|
|
8213
8902
|
const bodyText = await res.text().catch(() => "");
|
|
8214
8903
|
const { type, message: message2 } = parseSesError(bodyText, res.headers.get("x-amzn-ErrorType"));
|
|
8215
8904
|
const error = sesErrorText(res.status, type, message2);
|
|
8216
|
-
const halt = classifySesError(
|
|
8217
|
-
|
|
8218
|
-
type,
|
|
8219
|
-
message2,
|
|
8220
|
-
type === "MessageRejected" && this.namesSender(message2)
|
|
8221
|
-
);
|
|
8222
|
-
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 };
|
|
8223
8907
|
}
|
|
8224
8908
|
/** One SendEmail with the inline template, for a one-off message to one recipient. */
|
|
8225
8909
|
async sendSingle(content, r) {
|
|
@@ -8264,14 +8948,14 @@ var SesProvider = class {
|
|
|
8264
8948
|
}))
|
|
8265
8949
|
});
|
|
8266
8950
|
if (!res.ok) {
|
|
8267
|
-
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
|
+
}
|
|
8268
8955
|
if (halt2) {
|
|
8269
8956
|
return { kind: "halted", halt: halt2 };
|
|
8270
8957
|
}
|
|
8271
|
-
return
|
|
8272
|
-
kind: "answered",
|
|
8273
|
-
results: batch.map((r) => ({ email: r.email, accepted: false, retryable: false, error }))
|
|
8274
|
-
};
|
|
8958
|
+
return this.invalidBatch(batch, error);
|
|
8275
8959
|
}
|
|
8276
8960
|
let entries = [];
|
|
8277
8961
|
try {
|
|
@@ -8282,6 +8966,8 @@ var SesProvider = class {
|
|
|
8282
8966
|
}
|
|
8283
8967
|
const results = [];
|
|
8284
8968
|
const heldBack = [];
|
|
8969
|
+
const rejected = [];
|
|
8970
|
+
const own = [];
|
|
8285
8971
|
let halt = null;
|
|
8286
8972
|
let short = false;
|
|
8287
8973
|
for (const [i, r] of batch.entries()) {
|
|
@@ -8296,16 +8982,45 @@ var SesProvider = class {
|
|
|
8296
8982
|
continue;
|
|
8297
8983
|
}
|
|
8298
8984
|
const words = entry2.Error ?? "";
|
|
8299
|
-
const error = sesEntryErrorText(status || "
|
|
8300
|
-
const
|
|
8301
|
-
|
|
8302
|
-
|
|
8303
|
-
|
|
8304
|
-
if (
|
|
8305
|
-
halt = worse(halt, { ...
|
|
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 });
|
|
8306
8992
|
heldBack.push({ email: r.email, error });
|
|
8307
|
-
} else {
|
|
8993
|
+
} else if (namesAddress(words, r.email)) {
|
|
8308
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 });
|
|
8309
9024
|
}
|
|
8310
9025
|
}
|
|
8311
9026
|
if (halt && !short && !results.some((r) => r.accepted)) {
|
|
@@ -8325,11 +9040,32 @@ var SesProvider = class {
|
|
|
8325
9040
|
}
|
|
8326
9041
|
return { kind: "answered", results, halt };
|
|
8327
9042
|
}
|
|
8328
|
-
/**
|
|
8329
|
-
*
|
|
8330
|
-
*
|
|
8331
|
-
|
|
8332
|
-
|
|
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
|
+
};
|
|
8333
9069
|
}
|
|
8334
9070
|
async parseWebhook(req, env) {
|
|
8335
9071
|
const expectedTopic = env.SNS_TOPIC_ARN?.trim();
|
|
@@ -8342,11 +9078,27 @@ var SesProvider = class {
|
|
|
8342
9078
|
} catch {
|
|
8343
9079
|
return { events: [], response: textResponse("invalid json", 400) };
|
|
8344
9080
|
}
|
|
8345
|
-
if (
|
|
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") {
|
|
8346
9095
|
return { events: [], response: textResponse("invalid signature", 403) };
|
|
8347
9096
|
}
|
|
9097
|
+
if (signature !== "valid") {
|
|
9098
|
+
return refuse2("signature", "invalid signature");
|
|
9099
|
+
}
|
|
8348
9100
|
if (msg.TopicArn !== expectedTopic) {
|
|
8349
|
-
return
|
|
9101
|
+
return refuse2("topic", "unexpected topic");
|
|
8350
9102
|
}
|
|
8351
9103
|
switch (msg.Type) {
|
|
8352
9104
|
case "SubscriptionConfirmation": {
|
|
@@ -8407,6 +9159,20 @@ function bareAddress(from) {
|
|
|
8407
9159
|
const angle = /<([^>]+)>/.exec(from);
|
|
8408
9160
|
return (angle?.[1] ?? from).trim().toLowerCase();
|
|
8409
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
|
+
}
|
|
8410
9176
|
|
|
8411
9177
|
// src/services/webhook_events.ts
|
|
8412
9178
|
async function applyDeliveryEvents(db, events) {
|
|
@@ -8864,6 +9630,13 @@ async function requestSubscription(c, email2) {
|
|
|
8864
9630
|
created
|
|
8865
9631
|
);
|
|
8866
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
|
+
}
|
|
8867
9640
|
return { kind: "failed", delivered: "no", error: sent.error };
|
|
8868
9641
|
}
|
|
8869
9642
|
const armed = await armConfirmation(db, subscriber.id, token2, now, subscriber.status);
|
|
@@ -8907,7 +9680,7 @@ async function sendConfirmation(c, message2, subscriber, attemptAt) {
|
|
|
8907
9680
|
return { delivered: "unknown", error: err instanceof Error ? err.message : String(err) };
|
|
8908
9681
|
}
|
|
8909
9682
|
if (result.kind === "halted") {
|
|
8910
|
-
return { delivered: "no", error: result.halt.error };
|
|
9683
|
+
return { delivered: "no", error: result.halt.error, cause: result.halt.cause };
|
|
8911
9684
|
}
|
|
8912
9685
|
const [answer] = result.results;
|
|
8913
9686
|
if (!answer) {
|
|
@@ -9266,6 +10039,81 @@ var FakeNotifier = class {
|
|
|
9266
10039
|
}
|
|
9267
10040
|
};
|
|
9268
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
|
+
|
|
9269
10117
|
// src/routes/render_actions.ts
|
|
9270
10118
|
async function loadRenderInput(c) {
|
|
9271
10119
|
const post = await getPost(c.env.DB, param(c, "id"));
|
|
@@ -9289,7 +10137,9 @@ async function loadPostEmail(c) {
|
|
|
9289
10137
|
return {
|
|
9290
10138
|
input,
|
|
9291
10139
|
email: { subject: send.subject, html: send.rendered_html, text: send.rendered_text },
|
|
9292
|
-
|
|
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),
|
|
9293
10143
|
frozen: { id: send.id },
|
|
9294
10144
|
dateMs: send.started_at ?? send.fire_at
|
|
9295
10145
|
};
|
|
@@ -9297,7 +10147,7 @@ async function loadPostEmail(c) {
|
|
|
9297
10147
|
const result = await render(input, c.config, await loadBranding(c));
|
|
9298
10148
|
return { input, email: result, warnings: result.warnings, frozen: null, dateMs: Date.now() };
|
|
9299
10149
|
}
|
|
9300
|
-
function
|
|
10150
|
+
function postPage2(html) {
|
|
9301
10151
|
return new Response(html, {
|
|
9302
10152
|
headers: {
|
|
9303
10153
|
...POST_PAGE_SECURITY_HEADERS,
|
|
@@ -9324,11 +10174,11 @@ async function previewPage(c) {
|
|
|
9324
10174
|
".Email.UnsubscribeURL": testUnsubscribeUrl(c.config),
|
|
9325
10175
|
".Email.SentTo": ""
|
|
9326
10176
|
}).html;
|
|
9327
|
-
return
|
|
10177
|
+
return postPage2(html);
|
|
9328
10178
|
}
|
|
9329
10179
|
async function webPage(c) {
|
|
9330
10180
|
const { email: email2, dateMs } = await loadPostEmail(c);
|
|
9331
|
-
return
|
|
10181
|
+
return postPage2(
|
|
9332
10182
|
webVersion(email2.html, {
|
|
9333
10183
|
config: c.config,
|
|
9334
10184
|
identity: await readerIdentity(c, c.config),
|
|
@@ -9351,19 +10201,24 @@ async function test(c) {
|
|
|
9351
10201
|
const provider = getProvider(c.config, c.env);
|
|
9352
10202
|
const unsubscribeUrl = testUnsubscribeUrl(c.config);
|
|
9353
10203
|
const recipients = [{ email: to, unsubscribeUrl }];
|
|
9354
|
-
const [res] =
|
|
9355
|
-
|
|
10204
|
+
const [res] = await sendTest(
|
|
10205
|
+
provider,
|
|
10206
|
+
email2,
|
|
10207
|
+
recipients,
|
|
10208
|
+
{
|
|
9356
10209
|
purpose: "test",
|
|
9357
10210
|
tag: `test-${input.post.id}`,
|
|
9358
10211
|
batchKey: `test-${input.post.id}-${crypto.randomUUID()}`
|
|
9359
|
-
}
|
|
9360
|
-
|
|
10212
|
+
},
|
|
10213
|
+
{ test: "post", postId: input.post.id }
|
|
9361
10214
|
);
|
|
9362
|
-
|
|
10215
|
+
const sent = res?.sent === true;
|
|
10216
|
+
if (frozen && sent) {
|
|
9363
10217
|
await markTested(c.env.DB, frozen.id, readAt, Date.now());
|
|
9364
10218
|
}
|
|
9365
10219
|
const result = {
|
|
9366
|
-
sent
|
|
10220
|
+
sent,
|
|
10221
|
+
not_sent: res && !res.sent ? res.notSent : null,
|
|
9367
10222
|
provider: provider.name,
|
|
9368
10223
|
to,
|
|
9369
10224
|
subject: email2.subject,
|
|
@@ -9395,8 +10250,11 @@ function sampleRenderInput(subject = TEMPLATE_TEST_SUBJECT) {
|
|
|
9395
10250
|
id: "sample",
|
|
9396
10251
|
slug: "sample-post",
|
|
9397
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",
|
|
9398
10255
|
subject,
|
|
9399
10256
|
status: "draft",
|
|
10257
|
+
email_only: false,
|
|
9400
10258
|
current_revision: "sample-rev",
|
|
9401
10259
|
created_at: now,
|
|
9402
10260
|
updated_at: now
|
|
@@ -9464,19 +10322,19 @@ async function templateTest(c) {
|
|
|
9464
10322
|
const unsubscribeUrl = testUnsubscribeUrl(c.config);
|
|
9465
10323
|
const testId = `template-test-${crypto.randomUUID()}`;
|
|
9466
10324
|
const batch = recipients.map((email2) => ({ email: email2, unsubscribeUrl }));
|
|
9467
|
-
const
|
|
9468
|
-
|
|
9469
|
-
|
|
9470
|
-
|
|
9471
|
-
|
|
9472
|
-
}
|
|
9473
|
-
batch
|
|
10325
|
+
const answers = await sendTest(
|
|
10326
|
+
provider,
|
|
10327
|
+
result,
|
|
10328
|
+
batch,
|
|
10329
|
+
{ purpose: "test", tag: testId, batchKey: testId },
|
|
10330
|
+
{ test: "template" }
|
|
9474
10331
|
);
|
|
9475
|
-
const
|
|
10332
|
+
const notSent = answers.flatMap((a) => a.sent ? [] : [{ email: a.email, ...a.notSent }]);
|
|
9476
10333
|
const report = {
|
|
9477
|
-
sent:
|
|
10334
|
+
sent: answers.length - notSent.length,
|
|
9478
10335
|
total: recipients.length,
|
|
9479
|
-
failed,
|
|
10336
|
+
failed: notSent.map((n) => n.email),
|
|
10337
|
+
not_sent: notSent,
|
|
9480
10338
|
provider: provider.name,
|
|
9481
10339
|
recipients,
|
|
9482
10340
|
subject: result.subject,
|
|
@@ -9646,45 +10504,56 @@ async function schedule(c) {
|
|
|
9646
10504
|
}
|
|
9647
10505
|
const body = await readJsonObject(c);
|
|
9648
10506
|
rejectStrayTemplateChoice(body);
|
|
10507
|
+
const fields = readSettingsBody(body);
|
|
9649
10508
|
requireConnectedProvider(c.config);
|
|
9650
10509
|
const fireAt = parseFutureFireAt(body.fire_at, c.config.minLeadMs);
|
|
9651
|
-
const
|
|
9652
|
-
const
|
|
9653
|
-
return json(
|
|
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);
|
|
9654
10513
|
}
|
|
9655
10514
|
async function sendNow(c) {
|
|
9656
10515
|
const post = await getPost(c.env.DB, param(c, "id"));
|
|
9657
10516
|
if (!post) {
|
|
9658
10517
|
throw notFound("post");
|
|
9659
10518
|
}
|
|
9660
|
-
|
|
10519
|
+
const body = await readJsonObject(c, { optional: true });
|
|
10520
|
+
rejectStrayTemplateChoice(body);
|
|
10521
|
+
const fields = readSettingsBody(body);
|
|
9661
10522
|
const active = await getActiveSendForPost(c.env.DB, post.id);
|
|
9662
10523
|
if (active) {
|
|
9663
|
-
return repeatAnswer(c, active
|
|
10524
|
+
return repeatAnswer(c, active, fields);
|
|
9664
10525
|
}
|
|
9665
10526
|
requireConnectedProvider(c.config);
|
|
9666
10527
|
const now = Date.now();
|
|
9667
10528
|
const fireAt = acceptFireAt(now + c.config.minLeadMs, c.config.minLeadMs, { now });
|
|
10529
|
+
const settings = await resolveSettings(c.env.DB, post, fields);
|
|
9668
10530
|
let send;
|
|
9669
10531
|
try {
|
|
9670
|
-
send = await freeze(c.env, c.config, post, fireAt);
|
|
10532
|
+
send = await freeze(c.env, c.config, post, fireAt, settings);
|
|
9671
10533
|
} catch (err) {
|
|
9672
10534
|
if (err instanceof HttpError && err.code === "active_send_exists") {
|
|
9673
10535
|
const winner = await getActiveSendForPost(c.env.DB, post.id);
|
|
9674
10536
|
if (winner) {
|
|
9675
|
-
return repeatAnswer(c, winner
|
|
10537
|
+
return repeatAnswer(c, winner, fields);
|
|
9676
10538
|
}
|
|
9677
10539
|
}
|
|
9678
10540
|
throw err;
|
|
9679
10541
|
}
|
|
9680
|
-
|
|
9681
|
-
return json(frozen, 201);
|
|
10542
|
+
return json(await frozenAnswer(c, send), 201);
|
|
9682
10543
|
}
|
|
9683
|
-
async function
|
|
9684
|
-
|
|
9685
|
-
...unwrap(await viewWithCursor(c.env,
|
|
9686
|
-
|
|
10544
|
+
async function frozenAnswer(c, send) {
|
|
10545
|
+
return {
|
|
10546
|
+
...unwrap(await viewWithCursor(c.env, send.id), "send"),
|
|
10547
|
+
warnings: renderWarnings(send)
|
|
9687
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 };
|
|
9688
10557
|
return json(repeat);
|
|
9689
10558
|
}
|
|
9690
10559
|
|
|
@@ -9852,7 +10721,11 @@ async function feed(c) {
|
|
|
9852
10721
|
now,
|
|
9853
10722
|
since,
|
|
9854
10723
|
now - SETTLE_FOLLOW_MS,
|
|
9855
|
-
{
|
|
10724
|
+
{
|
|
10725
|
+
missedMs: MISSED_THRESHOLD_MS,
|
|
10726
|
+
stuckMs: STUCK_THRESHOLD_MS,
|
|
10727
|
+
noReceiptsMs: NO_RECEIPTS_AFTER_MS
|
|
10728
|
+
},
|
|
9856
10729
|
limit,
|
|
9857
10730
|
now - BOUNCE_SPIKE_RECENT_MS
|
|
9858
10731
|
);
|
|
@@ -9968,16 +10841,28 @@ async function cancel2(c) {
|
|
|
9968
10841
|
}
|
|
9969
10842
|
async function reschedule2(c) {
|
|
9970
10843
|
const body = await readJsonObject(c);
|
|
10844
|
+
rejectSettingsFields(body);
|
|
9971
10845
|
const fireAt = parseFireAt(body.fire_at);
|
|
9972
10846
|
const id = param(c, "id");
|
|
9973
10847
|
const { changed } = await reschedule(c.env, id, fireAt, c.config.minLeadMs);
|
|
9974
10848
|
const response = { ...await answerWith(c, id), changed };
|
|
9975
10849
|
return json(response);
|
|
9976
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
|
+
}
|
|
9977
10861
|
async function resolve(c) {
|
|
9978
10862
|
const request = await readJsonObject(c);
|
|
9979
10863
|
const outcome = oneOf(request, "resolution", ["unsent", "accepted"]);
|
|
9980
|
-
const
|
|
10864
|
+
const principal = unwrap(c.principal, "principal");
|
|
10865
|
+
const actor = principal.email ?? (principal.tokenId ? `service ${principal.tokenId}` : "service");
|
|
9981
10866
|
const id = param(c, "id");
|
|
9982
10867
|
const { resolved, completed } = await resolveStuckSend(c.env, id, outcome, actor);
|
|
9983
10868
|
const body = { ...await answerWith(c, id), resolved, completed };
|
|
@@ -10093,7 +10978,18 @@ function composeNotification(n, config) {
|
|
|
10093
10978
|
[
|
|
10094
10979
|
`No email provider is connected to this deployment, so ${title} is held where it is.`,
|
|
10095
10980
|
refusalAdvice(n.halt_cause),
|
|
10096
|
-
"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."
|
|
10097
10993
|
],
|
|
10098
10994
|
link
|
|
10099
10995
|
);
|
|
@@ -10103,16 +10999,16 @@ function composeNotification(n, config) {
|
|
|
10103
10999
|
[
|
|
10104
11000
|
`The email provider is refusing this account, so ${title} is paused where it is. The provider said: ${n.halt_error || "no detail given"}`,
|
|
10105
11001
|
refusalAdvice(n.halt_cause),
|
|
10106
|
-
"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."
|
|
10107
11003
|
],
|
|
10108
11004
|
link
|
|
10109
11005
|
);
|
|
10110
11006
|
case "stuck":
|
|
10111
11007
|
return message(
|
|
10112
|
-
`Problem with ${title}:
|
|
11008
|
+
`Problem with ${title}: nothing has gone out for ${minutes2(STUCK_THRESHOLD_MS)} minutes`,
|
|
10113
11009
|
[
|
|
10114
|
-
`${title}
|
|
10115
|
-
"
|
|
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."
|
|
10116
11012
|
],
|
|
10117
11013
|
link
|
|
10118
11014
|
);
|
|
@@ -10165,6 +11061,17 @@ function sampleNotification(config) {
|
|
|
10165
11061
|
dashboardLink(config.appOrigin, "/settings")
|
|
10166
11062
|
);
|
|
10167
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
|
+
}
|
|
10168
11075
|
|
|
10169
11076
|
// src/send/remake.ts
|
|
10170
11077
|
function insideLead(scheduled, minLeadMs, now) {
|
|
@@ -10467,9 +11374,9 @@ async function deleteLogo(c) {
|
|
|
10467
11374
|
}
|
|
10468
11375
|
function readPatch(o) {
|
|
10469
11376
|
const patch = {};
|
|
10470
|
-
const
|
|
10471
|
-
if (
|
|
10472
|
-
patch.testRecipients =
|
|
11377
|
+
const testRecipients2 = optStringList(o, "testRecipients");
|
|
11378
|
+
if (testRecipients2 !== void 0) {
|
|
11379
|
+
patch.testRecipients = testRecipients2;
|
|
10473
11380
|
}
|
|
10474
11381
|
const p = optObject(o, "publication");
|
|
10475
11382
|
if (p !== void 0) {
|
|
@@ -10714,7 +11621,7 @@ function createRouter({
|
|
|
10714
11621
|
}) {
|
|
10715
11622
|
const devOnly = (...defs) => devMode ? defs : [];
|
|
10716
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)}`;
|
|
10717
|
-
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\` (
|
|
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.`;
|
|
10718
11625
|
const notConnected = "Refused with 409 `provider_not_connected` while no email provider is connected (SPEC \xA79), since nothing can be sent.";
|
|
10719
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.";
|
|
10720
11627
|
const sendExample = (over = {}) => ({
|
|
@@ -10722,6 +11629,7 @@ function createRouter({
|
|
|
10722
11629
|
post_id: "p_abc123",
|
|
10723
11630
|
subject: "Spring migration",
|
|
10724
11631
|
status: "sending",
|
|
11632
|
+
email_only: false,
|
|
10725
11633
|
rev: 57,
|
|
10726
11634
|
as_of: 176846761e4,
|
|
10727
11635
|
fire_at: 17684676e5,
|
|
@@ -10803,10 +11711,10 @@ function createRouter({
|
|
|
10803
11711
|
path: "/api/whoami",
|
|
10804
11712
|
access: "admin",
|
|
10805
11713
|
resource: "system",
|
|
10806
|
-
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`.",
|
|
10807
11715
|
handler: (c) => {
|
|
10808
|
-
const { kind, email: email2, via } = unwrap(c.principal, "principal");
|
|
10809
|
-
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 } });
|
|
10810
11718
|
}
|
|
10811
11719
|
},
|
|
10812
11720
|
{
|
|
@@ -10979,13 +11887,14 @@ function createRouter({
|
|
|
10979
11887
|
accepts: JSON_BODY,
|
|
10980
11888
|
resource: "settings",
|
|
10981
11889
|
summary: "Send a sample post through the saved email template, to `to` or the default recipients (I5).",
|
|
10982
|
-
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}`,
|
|
10983
11891
|
example: {
|
|
10984
11892
|
request: { to: "you@example.com" },
|
|
10985
11893
|
response: {
|
|
10986
11894
|
sent: 1,
|
|
10987
11895
|
total: 1,
|
|
10988
11896
|
failed: [],
|
|
11897
|
+
not_sent: [],
|
|
10989
11898
|
provider: "fake",
|
|
10990
11899
|
subject: "Template test \u2014 \u2026"
|
|
10991
11900
|
}
|
|
@@ -11019,10 +11928,22 @@ function createRouter({
|
|
|
11019
11928
|
accepts: JSON_BODY,
|
|
11020
11929
|
resource: "posts",
|
|
11021
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.",
|
|
11022
11932
|
example: {
|
|
11023
11933
|
request: { subject: "Hello, world", markdown: "# Hello\n\nWelcome." },
|
|
11024
11934
|
response: {
|
|
11025
|
-
post: {
|
|
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,
|
|
11026
11947
|
revision_id: "r_1"
|
|
11027
11948
|
}
|
|
11028
11949
|
},
|
|
@@ -11062,6 +11983,7 @@ function createRouter({
|
|
|
11062
11983
|
access: "admin",
|
|
11063
11984
|
resource: "posts",
|
|
11064
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.",
|
|
11065
11987
|
handler: getPost2
|
|
11066
11988
|
},
|
|
11067
11989
|
{
|
|
@@ -11071,7 +11993,7 @@ function createRouter({
|
|
|
11071
11993
|
accepts: JSON_BODY,
|
|
11072
11994
|
resource: "posts",
|
|
11073
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`.",
|
|
11074
|
-
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
|
|
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.",
|
|
11075
11997
|
example: {
|
|
11076
11998
|
request: {
|
|
11077
11999
|
subject: "Hello again, world",
|
|
@@ -11098,6 +12020,33 @@ function createRouter({
|
|
|
11098
12020
|
summary: "Delete a draft and its revisions (drafts only; any other post is a 409 `post_not_draft`).",
|
|
11099
12021
|
handler: deletePost2
|
|
11100
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
|
+
},
|
|
11101
12050
|
{
|
|
11102
12051
|
method: "GET",
|
|
11103
12052
|
path: "/api/posts/:id/revisions",
|
|
@@ -11122,6 +12071,7 @@ function createRouter({
|
|
|
11122
12071
|
accepts: [...FORM_UPLOAD, ...RASTER_IMAGE_TYPES],
|
|
11123
12072
|
resource: "posts",
|
|
11124
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, ``, and the render resolves that name to the stored image.",
|
|
11125
12075
|
handler: uploadImage
|
|
11126
12076
|
},
|
|
11127
12077
|
{
|
|
@@ -11147,7 +12097,7 @@ function createRouter({
|
|
|
11147
12097
|
access: "admin",
|
|
11148
12098
|
resource: "posts",
|
|
11149
12099
|
summary: "Render the post to its email (returns the hosted URL, subject, warnings, and `frozen`).",
|
|
11150
|
-
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.",
|
|
11151
12101
|
handler: preview
|
|
11152
12102
|
},
|
|
11153
12103
|
{
|
|
@@ -11175,11 +12125,12 @@ function createRouter({
|
|
|
11175
12125
|
accepts: JSON_BODY,
|
|
11176
12126
|
resource: "posts",
|
|
11177
12127
|
summary: "Send a test to one address through the same per-recipient path as a real send (I5).",
|
|
11178
|
-
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,
|
|
11179
12129
|
example: {
|
|
11180
12130
|
request: { to: "you@example.com" },
|
|
11181
12131
|
response: {
|
|
11182
12132
|
sent: true,
|
|
12133
|
+
not_sent: null,
|
|
11183
12134
|
provider: "ses",
|
|
11184
12135
|
to: "you@example.com",
|
|
11185
12136
|
subject: "Hello, world",
|
|
@@ -11224,10 +12175,10 @@ function createRouter({
|
|
|
11224
12175
|
accepts: JSON_BODY,
|
|
11225
12176
|
resource: "posts",
|
|
11226
12177
|
summary: `Freeze the render and schedule the send for a future time (at least ${formatLead(minLeadMs)} out).`,
|
|
11227
|
-
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)
|
|
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).",
|
|
11228
12179
|
example: {
|
|
11229
12180
|
request: { fire_at: "2026-01-16T09:00:00Z" },
|
|
11230
|
-
response: { send: scheduledExample, cursor: "57.1768467610000" }
|
|
12181
|
+
response: { send: scheduledExample, cursor: "57.1768467610000", warnings: [] }
|
|
11231
12182
|
},
|
|
11232
12183
|
handler: schedule
|
|
11233
12184
|
},
|
|
@@ -11238,9 +12189,10 @@ function createRouter({
|
|
|
11238
12189
|
accepts: JSON_BODY,
|
|
11239
12190
|
resource: "posts",
|
|
11240
12191
|
summary: "Send now: freeze and schedule one minimum lead out, on the minute, cancelable until then. Idempotent per post.",
|
|
11241
|
-
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
|
|
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.",
|
|
11242
12193
|
example: {
|
|
11243
|
-
|
|
12194
|
+
request: { email_only: true },
|
|
12195
|
+
response: { send: scheduledExample, cursor: "57.1768467610000", warnings: [] }
|
|
11244
12196
|
},
|
|
11245
12197
|
handler: sendNow
|
|
11246
12198
|
},
|
|
@@ -11250,7 +12202,7 @@ function createRouter({
|
|
|
11250
12202
|
access: "admin",
|
|
11251
12203
|
resource: "sends",
|
|
11252
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.",
|
|
11253
|
-
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
|
|
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.",
|
|
11254
12206
|
query: [
|
|
11255
12207
|
{
|
|
11256
12208
|
name: "status",
|
|
@@ -11284,7 +12236,7 @@ function createRouter({
|
|
|
11284
12236
|
access: "admin",
|
|
11285
12237
|
resource: "sends",
|
|
11286
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.",
|
|
11287
|
-
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
|
|
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.`,
|
|
11288
12240
|
query: [
|
|
11289
12241
|
{
|
|
11290
12242
|
name: "since",
|
|
@@ -11344,7 +12296,7 @@ function createRouter({
|
|
|
11344
12296
|
access: "admin",
|
|
11345
12297
|
resource: "sends",
|
|
11346
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).",
|
|
11347
|
-
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.",
|
|
11348
12300
|
example: {
|
|
11349
12301
|
response: {
|
|
11350
12302
|
send: sendExample(),
|
|
@@ -11452,7 +12404,7 @@ function createRouter({
|
|
|
11452
12404
|
accepts: JSON_BODY,
|
|
11453
12405
|
resource: "sends",
|
|
11454
12406
|
summary: "Move a scheduled send's fire time without re-freezing the render (I3, I6).",
|
|
11455
|
-
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,
|
|
11456
12408
|
example: {
|
|
11457
12409
|
request: { fire_at: "2026-01-16T09:00:00Z" },
|
|
11458
12410
|
response: { send: scheduledExample, cursor: "58.1768467620000", changed: true }
|
|
@@ -11842,7 +12794,19 @@ function createRouter({
|
|
|
11842
12794
|
handler: unsubscribe
|
|
11843
12795
|
},
|
|
11844
12796
|
// --- delivery webhooks (public; provider-signature-verified, not requireAuth) ---
|
|
11845
|
-
// ---
|
|
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) ---
|
|
11846
12810
|
// Registered at ARCHIVE_BASE_PATH (default /archive) so the index, the post
|
|
11847
12811
|
// pages, and the emitted archive URLs always share one source. Self-contained by
|
|
11848
12812
|
// default; an apex zone can additionally route <base>/* to this Worker (SPEC §11).
|
|
@@ -11862,7 +12826,8 @@ function createRouter({
|
|
|
11862
12826
|
path: `${archiveBasePath}/:slug`,
|
|
11863
12827
|
access: "public",
|
|
11864
12828
|
resource: "archive",
|
|
11865
|
-
summary: "A published post's archive page
|
|
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.",
|
|
11866
12831
|
handler: archivePage
|
|
11867
12832
|
},
|
|
11868
12833
|
// --- media bytes (public; readers + archive load these unauthenticated) ---
|
|
@@ -11888,7 +12853,12 @@ var SHIPPED_MIGRATIONS = [
|
|
|
11888
12853
|
"0003_post_slug_auto.sql",
|
|
11889
12854
|
"0004_keyed_pending_to_resolve.sql",
|
|
11890
12855
|
"0005_imports.sql",
|
|
11891
|
-
"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"
|
|
11892
12862
|
];
|
|
11893
12863
|
async function missingMigrations(db) {
|
|
11894
12864
|
let applied;
|
|
@@ -12027,13 +12997,14 @@ var MAX_PER_TICK = 10;
|
|
|
12027
12997
|
function stillHolds(n, now) {
|
|
12028
12998
|
switch (n.kind) {
|
|
12029
12999
|
case "import":
|
|
13000
|
+
case "sandbox":
|
|
12030
13001
|
return true;
|
|
12031
13002
|
case "finished":
|
|
12032
13003
|
return true;
|
|
12033
13004
|
case "refused":
|
|
12034
13005
|
return n.send_status === "sending" && n.halt_reason === "account" && n.halted_at === n.episode;
|
|
12035
13006
|
case "stuck":
|
|
12036
|
-
return n.send_status ===
|
|
13007
|
+
return idleSince({ ...n, status: n.send_status }) === n.episode;
|
|
12037
13008
|
case "wedged":
|
|
12038
13009
|
return isWedged({ ...n, status: n.send_status });
|
|
12039
13010
|
case "missed":
|
|
@@ -12069,11 +13040,11 @@ async function notifyPublisher(env, budget) {
|
|
|
12069
13040
|
await recordNotificationOutcome(db, n, { status: "cleared" }, Date.now());
|
|
12070
13041
|
continue;
|
|
12071
13042
|
}
|
|
12072
|
-
const about = n.kind === "import" ? { importId: n.import_id } : { sendId: n.send_id };
|
|
12073
|
-
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}`;
|
|
12074
13045
|
budget.request();
|
|
12075
13046
|
try {
|
|
12076
|
-
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);
|
|
12077
13048
|
await notifier.send(to, email2, key);
|
|
12078
13049
|
await recordNotificationOutcome(db, n, { status: "sent" }, Date.now());
|
|
12079
13050
|
log.info("notify.sent", { ...about, kind: n.kind, channel: notifier.channel });
|
|
@@ -12138,13 +13109,13 @@ var SendWindow = class {
|
|
|
12138
13109
|
// src/send/loop.ts
|
|
12139
13110
|
var MAX_CHUNK = 500;
|
|
12140
13111
|
var GROUP_RECIPIENTS = 10;
|
|
12141
|
-
var OPEN_COST2 =
|
|
13112
|
+
var OPEN_COST2 = 6;
|
|
12142
13113
|
var GROUP_COST = 8;
|
|
12143
13114
|
var GROUP_TYPICAL_COST = 5;
|
|
12144
13115
|
var CLOSE_COST = 4;
|
|
12145
13116
|
var MIN_RUN_COST = OPEN_COST2 + GROUP_COST + 1 + CLOSE_COST;
|
|
12146
13117
|
function worseHalt(current2, next) {
|
|
12147
|
-
return !current2 ||
|
|
13118
|
+
return !current2 || haltRank(next) > haltRank(current2) ? next : current2;
|
|
12148
13119
|
}
|
|
12149
13120
|
function chunk2(items, size) {
|
|
12150
13121
|
const out = [];
|
|
@@ -12199,6 +13170,20 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
|
|
|
12199
13170
|
renewAt = Date.now() + LEASE_TTL_MS / 2;
|
|
12200
13171
|
return renewLease(db, sendId, lease, Date.now() + LEASE_TTL_MS);
|
|
12201
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
|
+
}
|
|
12202
13187
|
if (send.audience_resolved_at === null) {
|
|
12203
13188
|
await resolveAudience(db, sendId, now);
|
|
12204
13189
|
}
|
|
@@ -12214,7 +13199,7 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
|
|
|
12214
13199
|
const held = [];
|
|
12215
13200
|
let halt = null;
|
|
12216
13201
|
let requeued = 0;
|
|
12217
|
-
let
|
|
13202
|
+
let progressed = false;
|
|
12218
13203
|
let complete = true;
|
|
12219
13204
|
let latencyMs = 0;
|
|
12220
13205
|
let heldCount = 0;
|
|
@@ -12282,7 +13267,7 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
|
|
|
12282
13267
|
batchHalt = answer.halt;
|
|
12283
13268
|
} else {
|
|
12284
13269
|
if (answer.results.some((r) => r.accepted || r.held !== true)) {
|
|
12285
|
-
|
|
13270
|
+
progressed = true;
|
|
12286
13271
|
}
|
|
12287
13272
|
if (answer.halt) {
|
|
12288
13273
|
halt = worseHalt(halt, answer.halt);
|
|
@@ -12320,7 +13305,11 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
|
|
|
12320
13305
|
halt = worseHalt(halt, batchHalt);
|
|
12321
13306
|
}
|
|
12322
13307
|
}
|
|
12323
|
-
|
|
13308
|
+
const cleared = progressed && halt?.reason !== "account";
|
|
13309
|
+
await settleDeliveries(db, sendId, lease, "dispatched", outcomes, Date.now(), {
|
|
13310
|
+
progressed,
|
|
13311
|
+
clears: cleared
|
|
13312
|
+
});
|
|
12324
13313
|
const heldAt = Date.now();
|
|
12325
13314
|
await holdBatch(
|
|
12326
13315
|
db,
|
|
@@ -12348,7 +13337,7 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
|
|
|
12348
13337
|
unknown,
|
|
12349
13338
|
latencyMs
|
|
12350
13339
|
});
|
|
12351
|
-
if (
|
|
13340
|
+
if (cleared && halted) {
|
|
12352
13341
|
log.info("send.resumed", {
|
|
12353
13342
|
...tags,
|
|
12354
13343
|
reason: halted.reason,
|
|
@@ -12478,6 +13467,20 @@ async function runTick(env, now, tick) {
|
|
|
12478
13467
|
const budget = new Budget(whole.limit - held, whole.queryLimit - held);
|
|
12479
13468
|
const db = metered(env.DB, budget);
|
|
12480
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
|
+
}
|
|
12481
13484
|
for (const s of await dueSends(db, now)) {
|
|
12482
13485
|
const lag = now - s.fire_at;
|
|
12483
13486
|
if (lag > MISSED_THRESHOLD_MS) {
|
|
@@ -12488,17 +13491,16 @@ async function runTick(env, now, tick) {
|
|
|
12488
13491
|
tick.due += 1;
|
|
12489
13492
|
await safeRun(env, s.id, budget, window);
|
|
12490
13493
|
}
|
|
12491
|
-
const resumable = budget.affords(1 + MIN_RUN_COST) ? await resumableSends(db, now, now + HALT_RETRY_SLACK_MS) : [];
|
|
12492
13494
|
for (const s of resumable) {
|
|
12493
|
-
|
|
12494
|
-
continue;
|
|
12495
|
-
}
|
|
12496
|
-
handled.add(s.id);
|
|
12497
|
-
tick.resumable += 1;
|
|
12498
|
-
await safeRun(env, s.id, budget, window);
|
|
13495
|
+
await resume(s);
|
|
12499
13496
|
}
|
|
12500
13497
|
for (const s of await stuckSends(env.DB, now - STUCK_THRESHOLD_MS)) {
|
|
12501
|
-
log.error("send.stuck", {
|
|
13498
|
+
log.error("send.stuck", {
|
|
13499
|
+
sendId: s.id,
|
|
13500
|
+
postId: s.post_id,
|
|
13501
|
+
startedAt: s.started_at,
|
|
13502
|
+
idleSince: idleSince(s)
|
|
13503
|
+
});
|
|
12502
13504
|
tick.stuck += 1;
|
|
12503
13505
|
}
|
|
12504
13506
|
for (const s of await wedgedSends(env.DB)) {
|