@kurtbruns/kestrel 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -1
- package/README.md +6 -7
- 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 +1413 -307
- package/docs/get-started/04-ses.md +22 -2
- package/docs/get-started/05-verify.md +18 -2
- package/docs/get-started/06-go-live.md +1 -1
- package/docs/guides/01-connect-claude.md +3 -1
- package/docs/publishing/04-write-a-post.md +18 -4
- package/docs/publishing/06-troubleshooting.md +37 -8
- package/docs/publishing/07-working-with-claude.md +3 -2
- package/docs/publishing/11-email-template.md +1 -1
- package/docs/publishing/README.md +1 -1
- package/docs/reference/01-configuration.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/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,21 +731,63 @@ 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.";
|
|
731
765
|
}
|
|
732
766
|
}
|
|
767
|
+
var SELF_THROTTLE_HALTS = 3;
|
|
768
|
+
function isSelfThrottled(send) {
|
|
769
|
+
return send.halt_reason === "unavailable" && send.halt_cause === "rate_limit" && send.halt_retries >= SELF_THROTTLE_HALTS;
|
|
770
|
+
}
|
|
771
|
+
function sentence(error) {
|
|
772
|
+
const text = error?.trim() || "no detail given";
|
|
773
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
774
|
+
}
|
|
775
|
+
function refusedMessage(cause, error) {
|
|
776
|
+
const advice = refusalAdvice(cause);
|
|
777
|
+
if (cause === "not_connected") {
|
|
778
|
+
return `No email provider is connected, so this send is held where it is. ${advice} No one has been marked unsent because of this, and the send goes out at its first retry after a provider is connected.`;
|
|
779
|
+
}
|
|
780
|
+
if (isSendOwnHalt(cause)) {
|
|
781
|
+
return `The provider is refusing this send: ${sentence(error)} ${advice} No one has been marked unsent because of this, and the send tries again on its own, spaced out the longer this lasts.`;
|
|
782
|
+
}
|
|
783
|
+
return `The provider is refusing the account: ${sentence(error)} ${advice} No one has been marked unsent because of this, and the send resumes on its own at its next retry once the account is fixed.`;
|
|
784
|
+
}
|
|
785
|
+
function unavailableMessage(send) {
|
|
786
|
+
if (isSelfThrottled(send)) {
|
|
787
|
+
return `The provider keeps refusing for its sending rate: ${sentence(send.halt_error)} A burst clears within a second, and this has refused the first request of ${send.halt_retries} retries in a row, minutes apart, so the deployment is asking for more a second than the account allows. Whoever runs the deployment sets SES_MAX_SEND_RATE to the maximum send rate on the SES console\u2019s account dashboard (1 a second in the sandbox), and deploys. The send retries on its own meanwhile, and no one has been marked unsent because of this.`;
|
|
788
|
+
}
|
|
789
|
+
return `The provider is unavailable: ${sentence(send.halt_error)} The send retries on its own, spaced out the longer this lasts, and no one has been marked unsent because of this.`;
|
|
790
|
+
}
|
|
733
791
|
var STUCK_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
734
792
|
var DEFAULT_MIN_LEAD_MS = 5 * 60 * 1e3;
|
|
735
793
|
var MIN_LEAD_FLOOR_MS = 60 * 1e3;
|
|
@@ -748,6 +806,7 @@ function tooSoonMessage(minLeadMs) {
|
|
|
748
806
|
var BOUNCE_SPIKE_RATE = 0.05;
|
|
749
807
|
var BOUNCE_SPIKE_MIN = 3;
|
|
750
808
|
var BOUNCE_SPIKE_RECENT_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
809
|
+
var NO_RECEIPTS_AFTER_MS = 60 * 60 * 1e3;
|
|
751
810
|
var DAILY_WRITES_SEND_RECIPIENTS = Math.floor(
|
|
752
811
|
DAILY_WRITES_BUDGET / SEND_WRITES_PER_RECIPIENT
|
|
753
812
|
);
|
|
@@ -808,10 +867,10 @@ var SUBSCRIBER_ACTIONS = {
|
|
|
808
867
|
|
|
809
868
|
// src/generated/version.ts
|
|
810
869
|
var BUILD_INFO = {
|
|
811
|
-
version: "1.
|
|
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 {
|
|
@@ -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;
|
|
@@ -6243,16 +6747,16 @@ var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker,
|
|
|
6243
6747
|
var access_default = '# Lock the dashboard with Access\n\nCloudflare Access puts a login in front of the editor and the API. Kestrel checks every request against that login as well, so a path left out of Access stays locked. The public pages readers see stay public.\n\nIn this guide, you set up Cloudflare Zero Trust, create one Access application that covers the editor and the API, and allow yourself in. Then you give the app two values from that application, and check that the login works.\n\n## 1. Set up Zero Trust\n\nAccess is part of Cloudflare Zero Trust. Your account needs a Zero Trust organization before it can create an Access application. Skip this section if your account already has one.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), select **Zero Trust**.\n\n1. Choose a team name. It becomes your team domain, `YOUR_TEAM.cloudflareaccess.com`, where your login page lives.\n\n1. Choose a plan. The Free plan covers a newsletter\'s editors. Cloudflare asks for payment details even on the Free plan, and doesn\'t charge them.\n\n## 2. Create the Access application\n\nThe editor lives under `/dashboard`, and the API under `/api`. One application covers both, so they share one login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Applications**, and select **Create new application**.\n\n1. Select **Self-hosted and private**.\n\n1. Select **Add public hostname**, and enter your app\'s hostname with the first path:\n\n - **Subdomain:** `newsletter`\n - **Domain:** `example.com`\n - **Path:** `dashboard`\n\n1. Add a second public hostname with the same subdomain and domain, and the path `api`:\n\n | Path | What it covers |\n | --- | --- |\n | `dashboard` | the editor |\n | `api` | everything the editor and Claude read and change: posts, sends, subscribers, settings, and the in-app docs |\n\n Each path covers everything under it. Leave every other path out. The landing page, the archive, subscribing, unsubscribing, images, and your email provider\'s webhooks must stay public.\n\n1. **(Optional)** Choose how long a sign-in lasts. The application\'s session duration defaults to 24 hours, so everyone signs in again each day. You can set anything from immediately to one month, as Cloudflare\'s [session management](https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/session-management/) describes. A longer session suits a publisher on their own computer, and a shorter one is safer on a shared one.\n\n## 3. Allow yourself in\n\nEvery Access application denies everyone until a policy allows them.\n\n1. Under **Access policies**, create a new policy:\n\n - **Policy name:** `Publishers`\n - **Action:** Allow\n - **Include:** the **Emails** selector, with your email address\n\n1. Keep the login method a new Zero Trust organization starts with: you sign in with your Cloudflare account. To sign in another way, such as a one-time code by email, add it under **Zero Trust \u2192 Integrations \u2192 Identity providers** first.\n\n1. Select **Create**.\n\n## 4. Give the app its two values\n\nThe app verifies each login against your team domain, and checks that it was issued for this application.\n\n1. Find your team domain under **Zero Trust \u2192 Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`.\n\n1. Find the application\'s audience tag. Under **Access controls \u2192 Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.\n\n1. Store the team domain as a secret. The command prompts you for the value. Enter the hostname alone, without `https://`. If you paste it with `https://` or a trailing slash, the app drops them. Anything else that isn\'t a hostname is refused, naming `ACCESS_TEAM_DOMAIN`:\n\n ```bash\n npx wrangler secret put ACCESS_TEAM_DOMAIN --env production\n ```\n\n1. Store the audience tag the same way:\n\n ```bash\n npx wrangler secret put ACCESS_AUD --env production\n ```\n\n A secret takes effect as soon as you store it, with no redeploy. The app needs both values. While only one is stored, it refuses its configuration and names the missing one: the editor says so, `/health` answers `500` with `"variable":"ACCESS_AUD"`, and the public pages say the newsletter is temporarily unavailable. Storing the second clears it.\n\n1. **(Optional, recommended)** Limit the editor to named people. The Access policy decides who can sign in. This list keeps that true if the policy is widened later, for another app or by mistake. Enter a comma-separated list of email addresses at the prompt:\n\n ```bash\n npx wrangler secret put ACCESS_ALLOWED_EMAILS --env production\n ```\n\n Without it, the app lets in anyone your Access policy allows.\n\n## Check it\n\n1. In a private browser window, open `https://newsletter.example.com/dashboard/`. Cloudflare asks you to sign in.\n\n1. Sign in. The editor loads, and its sidebar shows the email address you signed in with.\n\n1. In the same window, open `https://newsletter.example.com/api/whoami`. It shows you as a signed-in person, verified through Access:\n\n ```\n {"principal":{"kind":"human","email":"you@example.com"},"auth":{"mode":"access"}}\n ```\n\n1. The public pages still need no login:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\nIf the editor says the configuration needs a fix, store the secret it names. If Cloudflare lets you in but the editor answers `401`, check the two secrets for a typo, or `ACCESS_ALLOWED_EMAILS` may leave out your address.\n\nNext, [connect Amazon SES](04-ses.md).\n';
|
|
6244
6748
|
|
|
6245
6749
|
// docs/get-started/04-ses.md
|
|
6246
|
-
var ses_default = '# Connect Amazon SES\n\nKestrel sends the newsletter\'s posts through Amazon SES, Amazon\'s email service. SES only delivers the mail: Kestrel keeps the list, each subscriber\'s consent, and the record of every send. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.\n\nIn this guide, you verify your sending hostname with SES and add a DMARC record. You forward readers\' replies to the publisher\'s inbox, and ask AWS for production access. You also route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, set the provider, and deploy.\n\n## Before you begin\n\n- An [AWS account](https://portal.aws.amazon.com/billing/signup).\n\nTo move an instance that already sends through another provider or another SES account, read [Switch an instance that already sends](#switch-an-instance-that-already-sends) first.\n\n## 1. Verify your sending hostname\n\nSES sends only from an identity you\'ve verified. Verifying the whole hostname, rather than one address, lets any address on it send. It also sets up DKIM, which lets inboxes trust your mail.\n\n1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose the region you send from in the menu at the top right. Any region that offers SES works, so pick one near you. A few newer regions must be [enabled for your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html) before you can use them. SES keeps identities, quotas, and the sandbox separately in each region, so do every step in this one.\n\n1. Note the region\'s code. The menu shows a name, such as Europe (Ireland), but later steps need its code, such as `eu-west-1`. [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each region\'s code beside its name. The MAIL FROM record, the SNS access policy, and `AWS_REGION` all use this one code.\n\n1. Under **Configuration**, choose **Identities**, then **Create identity**.\n\n1. Select **Domain**, and enter `send.example.com`. Keep the default DKIM settings: **Easy DKIM**, with a 2048-bit key. Choose **Create identity**.\n\n1. On the identity\'s **Authentication** tab, expand **Publish DNS records**. It lists three CNAME records.\n\n1. In the Cloudflare dashboard, add each record to your domain\'s DNS records, exactly as SES lists it. Set each one\'s proxy status to **DNS only**. Cloudflare adds your domain to the name, so a name ends in `._domainkey.send`.\n\n1. Wait until SES shows **DKIM configuration** as **Successful** and **Identity status** as **Verified**.\n\n## 2. Add a DMARC record\n\nDMARC tells inboxes what to do with mail that fails its checks, and sends you reports about it. SES doesn\'t add this record, so you add it yourself. It starts in monitor mode, which reports without blocking anything.\n\n1. In the Cloudflare dashboard, open your domain\'s DNS records, and add a record:\n\n - **Type:** `TXT`\n - **Name:** `_dmarc.send`\n - **Content:** `v=DMARC1; p=none; rua=mailto:dmarc@example.com`\n\n1. Replace `dmarc@example.com` with an address that receives mail. Reports arrive there as attachments.\n\n[Sending-domain DNS](../reference/02-sending-domain-dns.md#dmarc-publish-a-policy-and-collect-reports) explains each record, and when to tighten DMARC.\n\n## 3. Receive replies at your From address\n\nWhen a reader replies to a post, or to their confirmation email, the reply goes to your From address, `newsletter@send.example.com`. Nothing receives mail there yet, so the reply bounces. Kestrel only sends mail, so you give the address an inbox yourself. Here you forward it to the publisher\'s inbox, with Cloudflare Email Routing, which is free on every plan. The publisher is whoever writes and sends the newsletter, which may be you.\n\n`send.example.com` receives no other mail, so turning on Email Routing there changes nothing else. Your SES records sit on other names under it. Inboxes check SES\'s mail for SPF at its return path, not here, so sending is unaffected.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), go to **Compute \u2192 Email Service \u2192 Email Routing**.\n\n1. Add `send.example.com` to Email Routing. Cloudflare adds subdomains from your domain\'s own settings: select `example.com`, then **Settings**, and add `send` under **Subdomains**.\n\n1. Cloudflare adds MX, SPF, and DKIM records for `send.example.com`, and locks them. Wait until they show as added.\n\n1. Under **Destination Addresses**, add the publisher\'s inbox. Open the email Cloudflare sends to it, and select **Verify email address**. If it isn\'t your inbox, ask the publisher to do this step. An address you\'ve already verified with Cloudflare, such as one for [notifications](../guides/03-notifications.md), is listed already.\n\n1. Under **Routing Rules**, select **Create routing rule**. For **Email pattern**, enter `newsletter`, the part of your From address before the `@`, and select `send.example.com`. Set **Action** to **Send to an email**, select the publisher\'s inbox as the **Destination**, and save.\n\nOn a subdomain, Email Routing forwards only the addresses you add a rule for, with no catch-all. That\'s all a From address needs.\n\n- **Automatic replies arrive too.** Out-of-office and other automatic replies go to your From address, so they reach the publisher\'s inbox. Bounces don\'t: they go to SES\'s return path, and the app handles them as before.\n- **Your DMARC reports need an inbox too.** Your DMARC record\'s report address may not receive mail. If so, change the record\'s `rua` to `mailto:dmarc@send.example.com`, and add a second rule that forwards `dmarc` the same way.\n- **Answers come from the publisher\'s inbox.** A forwarded reply comes from the reader\'s own address. An answer to it goes out from the inbox it was forwarded to, not from `newsletter@send.example.com`.\n\nTo answer from the newsletter\'s own address, give it a real mailbox instead of forwarding it. Point `send.example.com`\'s MX records at a mail host you use, such as [Fastmail](https://www.fastmail.help/hc/en-us/articles/360060591133) or [Google Workspace](https://support.google.com/a/answer/140034). Then create `newsletter@send.example.com` there, as the host\'s docs describe. If you send from it, the host\'s SPF and DKIM records go on `send.example.com` beside SES\'s. SES\'s mail is unaffected, since its SPF is checked at its return path. And if your From address is on a domain that already receives mail, replies already reach it, and you can skip this section.\n\nSome replies ask the publisher to do something, and Kestrel can\'t see them, so the publisher acts on them, as [Sending rules](../publishing/02-sending-rules.md#when-a-reader-asks-to-leave-or-to-be-deleted) describes.\n\n## 4. (Optional) Set a custom MAIL FROM domain\n\nSES sends with its own return path by default, so SPF passes for `amazonses.com` rather than for your hostname. DMARC still passes, through DKIM. A custom MAIL FROM domain makes SPF pass for your hostname too, a second check in your favor.\n\n1. In SES, open the `send.example.com` identity. Under **Custom MAIL FROM domain**, choose **Edit**.\n\n1. Select **Use a custom MAIL FROM domain**, and enter `bounce.send.example.com`. For **Behavior on MX failure**, keep **Use default MAIL FROM domain**. Choose **Save changes**.\n\n1. In Cloudflare, add the two records SES lists. Replace `us-east-1` with your region\'s code from section 1:\n\n - **Type:** `MX`, **Name:** `bounce.send`, **Mail server:** `feedback-smtp.us-east-1.amazonses.com`, **Priority:** `10`\n - **Type:** `TXT`, **Name:** `bounce.send`, **Content:** `v=spf1 include:amazonses.com ~all`\n\n1. Wait until SES shows the MAIL FROM domain as **Successful**.\n\n## 5. Request production access\n\nA new SES account starts in the sandbox. It sends only to addresses you\'ve verified in SES, up to 200 emails a day and one a second. Ask AWS to lift those limits now, and test while you wait.\n\n1. Name the publication first, or ask the publisher for its name, since AWS may visit its website to review the request. In the editor, open **Settings \u2192 Publication identity**, and enter the name and tagline. Until then, the public pages show the name in your From address. The subscribe form stays closed until section 8 sets the provider.\n\n1. In SES, open the **Account dashboard**, select **View Get set up page**, and then **Request production access**.\n\n1. Fill in the request:\n\n - **Mail type:** Marketing\n - **Website URL:** `https://newsletter.example.com/`\n\n1. Check the acknowledgement, and select **Submit request**. AWS Support answers by email, and may ask how you collect addresses and handle bounces.\n\n1. While you wait, verify the two addresses you test with in [Verify it works](05-verify.md#before-you-begin), so the sandbox lets you mail them. For each, under **Identities**, choose **Create identity**, select **Email address**, and follow the link in the email AWS sends.\n\nIf AWS asks for more detail, reply in the support case. If it denies the request, reply there, or open a new request from the **Account dashboard**. Either way, say how Kestrel handles the list:\n\n- Signups use double opt-in: each subscriber confirms by email before joining the list.\n- Every post offers one-click unsubscribe.\n- Bounces and complaints reach the app through SNS, and it stops mailing those addresses automatically.\n- How many subscribers the newsletter expects, and how often it sends.\n\nYour account stays in the sandbox until AWS grants access, so don\'t go live before then. In the sandbox, a signup from any address you haven\'t verified fails, and a post reaches only your verified addresses.\n\nWhen AWS grants access, the **Account dashboard** shows your daily quota and your maximum send rate. You set the rate in the app in section 8. The rate decides how fast a send goes out, and [As your list grows](../guides/09-as-your-list-grows.md) says when to ask AWS to raise it.\n\n## 6. Send bounces and complaints to SNS\n\nSES reports what happens to each email through SNS, the AWS notification service. A configuration set says which events to report, and an SNS topic carries them to the app.\n\n1. In SES, under **Configuration**, choose **Configuration sets**, then **Create set**. Name it `kestrel-mail`, and choose **Create set**.\n\n1. On the set\'s **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, **Deliveries**, and **Rendering failures**, and choose **Next**. A rendering failure is an email SES took but couldn\'t build. It shouldn\'t happen, and the app logs it as an error if it does.\n\n1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled. Choose **Next**.\n\n1. For **SNS topic**, choose **Create SNS topic**. Make it a **Standard** topic named `kestrel-events`. Choose **Next**, then **Add destination**.\n\n1. Make the set your hostname\'s default, so SES reports on every email sent from it. Open the `send.example.com` identity, and choose the **Configuration set** tab. Under **Default configuration set**, choose **Edit**, select `kestrel-mail`, and save.\n\n1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), and copy its ARN. It looks like `arn:aws:sns:us-east-1:111122223333:kestrel-events`. After `sns` comes your region\'s code, then your 12-digit account ID. The account menu at the top right of the console also shows the account ID.\n\n1. Let SES publish to the topic. Choose **Edit**, expand **Access policy**, and add this statement to the `Statement` list:\n\n ```json\n {\n "Effect": "Allow",\n "Principal": { "Service": "ses.amazonaws.com" },\n "Action": "sns:Publish",\n "Resource": "REPLACE_WITH_TOPIC_ARN",\n "Condition": {\n "StringEquals": {\n "AWS:SourceAccount": "REPLACE_WITH_ACCOUNT_ID",\n "AWS:SourceArn": "arn:aws:ses:us-east-1:REPLACE_WITH_ACCOUNT_ID:configuration-set/kestrel-mail"\n }\n }\n }\n ```\n\n Replace the topic ARN, and both copies of the account ID, with the values from your ARN. Replace `us-east-1` with your region\'s code, the same one the ARN shows. Choose **Save changes**.\n\nYou subscribe the app to the topic in section 9, once it runs on SES.\n\n## 7. Create the app\'s AWS key\n\nThe app sends through an IAM user whose key can only send email. That limits the harm if the key ever leaks.\n\n1. In the [IAM console](https://console.aws.amazon.com/iam/), go to **Users**, and choose **Create user**. Name it `kestrel-sender`, and leave console access off.\n\n1. When asked for permissions, choose **Attach policies directly**, then **Create policy**. The policy editor opens in a new browser tab.\n\n1. In the new tab, choose the **JSON** editor, and replace its contents with this policy:\n\n ```json\n {\n "Version": "2012-10-17",\n "Statement": [\n {\n "Effect": "Allow",\n "Action": ["ses:SendEmail", "ses:SendBulkEmail"],\n "Resource": "*"\n }\n ]\n }\n ```\n\n Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. You can close this tab.\n\n1. Go back to the first tab, where you\'re creating the user. Refresh the policy list, search for `kestrel-send-email`, and select it.\n\n1. Choose **Next**, then **Create user**.\n\n1. Open the user, and on the **Security credentials** tab, choose **Create access key**. For the use case, choose **Application running outside AWS**.\n\n1. Copy the access key ID and the secret access key. AWS shows the secret only once.\n\n## 8. Set the provider and deploy\n\nUntil now the app has run with no email provider connected, refusing to send. Setting `PROVIDER` connects SES. Store the secrets before you set it. With `PROVIDER` set to `ses` and a required secret missing, the app refuses to run, and sends nothing, until you store it. `/health` and the editor\'s **A setting needs fixing** page name the missing secret, and reader pages say the newsletter is temporarily unavailable. `SES_CONFIGURATION_SET` is the one optional secret.\n\n1. Store the access key ID, and paste it at the prompt:\n\n ```bash\n npx wrangler secret put AWS_ACCESS_KEY_ID --env production\n ```\n\n1. Store the secret access key:\n\n ```bash\n npx wrangler secret put AWS_SECRET_ACCESS_KEY --env production\n ```\n\n1. Store the topic ARN from section 6. The app accepts events only from this topic:\n\n ```bash\n npx wrangler secret put SNS_TOPIC_ARN --env production\n ```\n\n1. Store the configuration set\'s name, `kestrel-mail`. It\'s optional but recommended: the app names it on every email, so SES reports on each one even without the default. Type it exactly as the set is named in this region: if SES can\'t find the set, it refuses every email, and a list send is held until the name is fixed or the set created:\n\n ```bash\n npx wrangler secret put SES_CONFIGURATION_SET --env production\n ```\n\n1. In `wrangler.jsonc`, edit the `production` block\'s `vars`. Change `PROVIDER` to `ses` (it\'s `""` on a new instance; add it if your block has none), and add `SES_MAX_SEND_RATE`. Change `AWS_REGION` to your region\'s code from section 1. Your other values stay as they are. The block ends up like this:\n\n ```jsonc\n "vars": {\n "PROVIDER": "ses", // \u2190 set to ses\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1", // \u2190 change: your region\'s code\n "SES_MAX_SEND_RATE": "1" // \u2190 new: your maximum send rate; 1 in the sandbox\n }\n ```\n\n On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan\'s limit, so each minute sends more. Leave it out on Workers Free. [As your list grows](../guides/09-as-your-list-grows.md) says when the plan is worth it.\n\n When AWS grants production access, set `SES_MAX_SEND_RATE` to the maximum send rate on the **Account dashboard**, and deploy again. The app keeps to that rate, so SES doesn\'t throttle your send.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Send through Amazon SES"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## 9. Subscribe the app to the topic\n\nSNS delivers events only to a subscriber that has confirmed. The app confirms on its own, once it checks that the request came from your topic.\n\n1. In the SNS console, open your topic, and choose **Create subscription**.\n\n1. Set **Protocol** to **HTTPS**, and enter the endpoint:\n\n ```\n https://newsletter.example.com/webhooks/ses\n ```\n\n1. Leave raw message delivery off, and choose **Create subscription**.\n\n1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.\n\nIf it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:\n\n- **Access covers `/webhooks/ses`.** If your Access application covers more than `/dashboard` and `/api`, SNS gets the login page instead of the app. Remove that path from your Access application, so only `dashboard` and `api` remain.\n- **`SNS_TOPIC_ARN` matches the topic\'s ARN.**\n- **The deploy in section 8 finished.**\n\nOnce you\'ve fixed the cause, select the subscription, and choose **Request confirmation**. SNS sends the request again, and the app confirms it. SNS doesn\'t let you delete a subscription while it\'s pending, and removes it on its own after 48 hours.\n\n## Switch an instance that already sends\n\nChange `PROVIDER`, or move to another account with the same provider, only while no send is sending. The dashboard lists any send in progress.\n\nWhen a request to a provider gets no answer, Kestrel can\'t know whether its recipients were mailed. Moving to SES, from another provider or another SES account, mails none of them twice. Kestrel never repeats an unanswered request to SES, so those recipients wait for you to decide whether they were mailed. Use **Resolve** on the send\'s page.\n\nKestrel can\'t tell whether those emails went out. SES\'s console shows sent messages only if you\'ve turned on Virtual Deliverability Manager, which this guide doesn\'t set up. So **Assume not sent** is the safe default. Those readers may miss this post, but they get your next one. No one is mailed twice. Choose **Assume sent** only when you know otherwise, such as a reader who says they got it.\n\n## Check it\n\n1. The app answers on your hostname:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The webhook is public, and refuses a request that isn\'t from SNS:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" -X POST https://newsletter.example.com/webhooks/ses\n ```\n\n ```\n 400\n ```\n\n Any other answer, such as a redirect to your Cloudflare login, means Access covers the webhook. Remove that path from your Access application.\n\n1. The SNS subscription shows **Confirmed**.\n\n1. Your From address has somewhere to go. In Cloudflare, your domain\'s DNS records show the ones Email Routing added for `send`, beside section 1\'s DKIM records:\n\n - **Type:** `MX`, **Name:** `send`, three of them, for `route1.mx.cloudflare.net`, `route2.mx.cloudflare.net`, and `route3.mx.cloudflare.net`\n - **Type:** `TXT`, **Name:** `send`, **Content:** `v=spf1 include:_spf.mx.cloudflare.net ~all`\n - **Type:** `TXT`, **Name:** `cf2024-1._domainkey.send`, Cloudflare\'s DKIM key\n\n The world sees the MX records too:\n\n ```bash\n dig +short MX send.example.com\n ```\n\n It lists the three `route` servers. Email Routing\'s **Routing Rules** shows your rule for `newsletter@send.example.com`, and **Destination Addresses** shows the publisher\'s inbox as verified. A reply to your test email in the next step proves the forwarding works.\n\n1. In the editor, **Settings \u2192 Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before.\n\n1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren\'t open yet.\n\nThe test sends in the next step prove the rest. In the sandbox, send them only to the addresses you verified in section 5, and to the SES test addresses that page lists.\n\nSES charges for each email it sends, test emails included. [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists the rates.\n\nNext, [verify it works](05-verify.md).\n';
|
|
6750
|
+
var ses_default = '# Connect Amazon SES\n\nKestrel sends the newsletter\'s posts through Amazon SES, Amazon\'s email service. SES only delivers the mail: Kestrel keeps the list, each subscriber\'s consent, and the record of every send. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.\n\nIn this guide, you verify your sending hostname with SES and add a DMARC record. You forward readers\' replies to the publisher\'s inbox, and ask AWS for production access. You also route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, set the provider, and deploy.\n\n## Before you begin\n\n- An [AWS account](https://portal.aws.amazon.com/billing/signup).\n\nTo move an instance that already sends through another provider or another SES account, read [Switch an instance that already sends](#switch-an-instance-that-already-sends) first.\n\n## 1. Verify your sending hostname\n\nSES sends only from an identity you\'ve verified. Verifying the whole hostname, rather than one address, lets any address on it send. It also sets up DKIM, which lets inboxes trust your mail.\n\n1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose the region you send from in the menu at the top right. Any region that offers SES works, so pick one near you. A few newer regions must be [enabled for your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html) before you can use them. SES keeps identities, quotas, and the sandbox separately in each region, so do every step in this one.\n\n1. Note the region\'s code. The menu shows a name, such as Europe (Ireland), but later steps need its code, such as `eu-west-1`. [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each region\'s code beside its name. The MAIL FROM record, the SNS access policy, and `AWS_REGION` all use this one code.\n\n1. Under **Configuration**, choose **Identities**, then **Create identity**.\n\n1. Select **Domain**, and enter `send.example.com`. Keep the default DKIM settings: **Easy DKIM**, with a 2048-bit key. Choose **Create identity**.\n\n1. On the identity\'s **Authentication** tab, expand **Publish DNS records**. It lists three CNAME records.\n\n1. In the Cloudflare dashboard, add each record to your domain\'s DNS records, exactly as SES lists it. Set each one\'s proxy status to **DNS only**. Cloudflare adds your domain to the name, so a name ends in `._domainkey.send`.\n\n1. Wait until SES shows **DKIM configuration** as **Successful** and **Identity status** as **Verified**.\n\n## 2. Add a DMARC record\n\nDMARC tells inboxes what to do with mail that fails its checks, and sends you reports about it. SES doesn\'t add this record, so you add it yourself. It starts in monitor mode, which reports without blocking anything.\n\n1. In the Cloudflare dashboard, open your domain\'s DNS records, and add a record:\n\n - **Type:** `TXT`\n - **Name:** `_dmarc.send`\n - **Content:** `v=DMARC1; p=none; rua=mailto:dmarc@example.com`\n\n1. Replace `dmarc@example.com` with an address that receives mail. Reports arrive there as attachments.\n\n[Sending-domain DNS](../reference/02-sending-domain-dns.md#dmarc-publish-a-policy-and-collect-reports) explains each record, and when to tighten DMARC.\n\n## 3. Receive replies at your From address\n\nWhen a reader replies to a post, or to their confirmation email, the reply goes to your From address, `newsletter@send.example.com`. Nothing receives mail there yet, so the reply bounces. Kestrel only sends mail, so you give the address an inbox yourself. Here you forward it to the publisher\'s inbox, with Cloudflare Email Routing, which is free on every plan. The publisher is whoever writes and sends the newsletter, which may be you.\n\n`send.example.com` receives no other mail, so turning on Email Routing there changes nothing else. Your SES records sit on other names under it. Inboxes check SES\'s mail for SPF at its return path, not here, so sending is unaffected.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), go to **Compute \u2192 Email Service \u2192 Email Routing**.\n\n1. Add `send.example.com` to Email Routing. Cloudflare adds subdomains from your domain\'s own settings: select `example.com`, then **Settings**, and add `send` under **Subdomains**.\n\n1. Cloudflare adds MX, SPF, and DKIM records for `send.example.com`, and locks them. Wait until they show as added.\n\n1. Under **Destination Addresses**, add the publisher\'s inbox. Open the email Cloudflare sends to it, and select **Verify email address**. If it isn\'t your inbox, ask the publisher to do this step. An address you\'ve already verified with Cloudflare, such as one for [notifications](../guides/03-notifications.md), is listed already.\n\n1. Under **Routing Rules**, select **Create routing rule**. For **Email pattern**, enter `newsletter`, the part of your From address before the `@`, and select `send.example.com`. Set **Action** to **Send to an email**, select the publisher\'s inbox as the **Destination**, and save.\n\nOn a subdomain, Email Routing forwards only the addresses you add a rule for, with no catch-all. That\'s all a From address needs.\n\n- **Automatic replies arrive too.** Out-of-office and other automatic replies go to your From address, so they reach the publisher\'s inbox. Bounces don\'t: they go to SES\'s return path, and the app handles them as before.\n- **Your DMARC reports need an inbox too.** Your DMARC record\'s report address may not receive mail. If so, change the record\'s `rua` to `mailto:dmarc@send.example.com`, and add a second rule that forwards `dmarc` the same way.\n- **Answers come from the publisher\'s inbox.** A forwarded reply comes from the reader\'s own address. An answer to it goes out from the inbox it was forwarded to, not from `newsletter@send.example.com`.\n\nTo answer from the newsletter\'s own address, give it a real mailbox instead of forwarding it. Point `send.example.com`\'s MX records at a mail host you use, such as [Fastmail](https://www.fastmail.help/hc/en-us/articles/360060591133) or [Google Workspace](https://support.google.com/a/answer/140034). Then create `newsletter@send.example.com` there, as the host\'s docs describe. If you send from it, the host\'s SPF and DKIM records go on `send.example.com` beside SES\'s. SES\'s mail is unaffected, since its SPF is checked at its return path. And if your From address is on a domain that already receives mail, replies already reach it, and you can skip this section.\n\nSome replies ask the publisher to do something, and Kestrel can\'t see them, so the publisher acts on them, as [Sending rules](../publishing/02-sending-rules.md#when-a-reader-asks-to-leave-or-to-be-deleted) describes.\n\n## 4. (Optional) Set a custom MAIL FROM domain\n\nSES sends with its own return path by default, so SPF passes for `amazonses.com` rather than for your hostname. DMARC still passes, through DKIM. A custom MAIL FROM domain makes SPF pass for your hostname too, a second check in your favor.\n\n1. In SES, open the `send.example.com` identity. Under **Custom MAIL FROM domain**, choose **Edit**.\n\n1. Select **Use a custom MAIL FROM domain**, and enter `bounce.send.example.com`. For **Behavior on MX failure**, keep **Use default MAIL FROM domain**. Choose **Save changes**.\n\n1. In Cloudflare, add the two records SES lists. Replace `us-east-1` with your region\'s code from section 1:\n\n - **Type:** `MX`, **Name:** `bounce.send`, **Mail server:** `feedback-smtp.us-east-1.amazonses.com`, **Priority:** `10`\n - **Type:** `TXT`, **Name:** `bounce.send`, **Content:** `v=spf1 include:amazonses.com ~all`\n\n1. Wait until SES shows the MAIL FROM domain as **Successful**.\n\n## 5. Request production access\n\nA new SES account starts in the sandbox. It sends only to addresses you\'ve verified in SES, up to 200 emails a day and one a second. Ask AWS to lift those limits now, and test while you wait.\n\n1. Name the publication first, or ask the publisher for its name, since AWS may visit its website to review the request. In the editor, open **Settings \u2192 Publication identity**, and enter the name and tagline. Until then, the public pages show the name in your From address. The subscribe form stays closed until section 8 sets the provider.\n\n1. In SES, open the **Account dashboard**, select **View Get set up page**, and then **Request production access**.\n\n1. Fill in the request:\n\n - **Mail type:** Marketing\n - **Website URL:** `https://newsletter.example.com/`\n\n1. Check the acknowledgement, and select **Submit request**. AWS Support answers by email, and may ask how you collect addresses and handle bounces.\n\n1. While you wait, verify the two addresses you test with in [Verify it works](05-verify.md#before-you-begin), so the sandbox lets you mail them. For each, under **Identities**, choose **Create identity**, select **Email address**, and follow the link in the email AWS sends.\n\nIf AWS asks for more detail, reply in the support case. If it denies the request, reply there, or open a new request from the **Account dashboard**. Either way, say how Kestrel handles the list:\n\n- Signups use double opt-in: each subscriber confirms by email before joining the list.\n- Every post offers one-click unsubscribe.\n- Bounces and complaints reach the app through SNS, and it stops mailing those addresses automatically.\n- How many subscribers the newsletter expects, and how often it sends.\n\nYour account stays in the sandbox until AWS grants access, so don\'t go live before then. In the sandbox, SES refuses mail to any address you haven\'t verified:\n\n- A signup from an unverified address gets no confirmation email. The app emails the publisher about it, at most once a day.\n- A post goes to your verified addresses and then waits, with the line "The provider is refusing the account" and SES\'s words. No reader is marked unsent. Once AWS grants access, the post goes to the rest at its next retry, mailing no one twice.\n- A daily quota refusal in the sandbox (200 emails a day) means the same thing: request production access rather than a higher quota.\n\nWhen AWS grants access, the **Account dashboard** shows your daily quota and your maximum send rate. You set the rate in the app in section 8. The rate decides how fast a send goes out, and [As your list grows](../guides/09-as-your-list-grows.md) says when to ask AWS to raise it.\n\n## 6. Send bounces and complaints to SNS\n\nSES reports what happens to each email through SNS, the AWS notification service. A configuration set says which events to report, and an SNS topic carries them to the app.\n\n1. In SES, under **Configuration**, choose **Configuration sets**, then **Create set**. Name it `kestrel-mail`, and choose **Create set**.\n\n1. On the set\'s **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, **Deliveries**, and **Rendering failures**, and choose **Next**. A rendering failure is an email SES took but couldn\'t build. It shouldn\'t happen, and the app logs it as an error if it does.\n\n1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled. Choose **Next**.\n\n1. For **SNS topic**, choose **Create SNS topic**. Make it a **Standard** topic named `kestrel-events`. Choose **Next**, then **Add destination**.\n\n1. Make the set your hostname\'s default, so SES reports on every email sent from it. Open the `send.example.com` identity, and choose the **Configuration set** tab. Under **Default configuration set**, choose **Edit**, select `kestrel-mail`, and save.\n\n1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), and copy its ARN. It looks like `arn:aws:sns:us-east-1:111122223333:kestrel-events`. After `sns` comes your region\'s code, then your 12-digit account ID. The account menu at the top right of the console also shows the account ID.\n\n1. Let SES publish to the topic. Choose **Edit**, expand **Access policy**, and add this statement to the `Statement` list:\n\n ```json\n {\n "Effect": "Allow",\n "Principal": { "Service": "ses.amazonaws.com" },\n "Action": "sns:Publish",\n "Resource": "REPLACE_WITH_TOPIC_ARN",\n "Condition": {\n "StringEquals": {\n "AWS:SourceAccount": "REPLACE_WITH_ACCOUNT_ID",\n "AWS:SourceArn": "arn:aws:ses:us-east-1:REPLACE_WITH_ACCOUNT_ID:configuration-set/kestrel-mail"\n }\n }\n }\n ```\n\n Replace the topic ARN, and both copies of the account ID, with the values from your ARN. Replace `us-east-1` with your region\'s code, the same one the ARN shows. Choose **Save changes**.\n\nYou subscribe the app to the topic in section 9, once it runs on SES.\n\n## 7. Create the app\'s AWS key\n\nThe app sends through an IAM user whose key can only send email. That limits the harm if the key ever leaks.\n\n1. In the [IAM console](https://console.aws.amazon.com/iam/), go to **Users**, and choose **Create user**. Name it `kestrel-sender`, and leave console access off.\n\n1. When asked for permissions, choose **Attach policies directly**, then **Create policy**. The policy editor opens in a new browser tab.\n\n1. In the new tab, choose the **JSON** editor, and replace its contents with this policy:\n\n ```json\n {\n "Version": "2012-10-17",\n "Statement": [\n {\n "Effect": "Allow",\n "Action": ["ses:SendEmail", "ses:SendBulkEmail"],\n "Resource": "*"\n }\n ]\n }\n ```\n\n Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. You can close this tab.\n\n1. Go back to the first tab, where you\'re creating the user. Refresh the policy list, search for `kestrel-send-email`, and select it.\n\n1. Choose **Next**, then **Create user**.\n\n1. Open the user, and on the **Security credentials** tab, choose **Create access key**. For the use case, choose **Application running outside AWS**.\n\n1. Copy the access key ID and the secret access key. AWS shows the secret only once.\n\n## 8. Set the provider and deploy\n\nUntil now the app has run with no email provider connected, refusing to send. Setting `PROVIDER` connects SES. Store the secrets before you set it. With `PROVIDER` set to `ses` and a required secret missing, the app refuses to run, and sends nothing, until you store it. `/health` and the editor\'s **A setting needs fixing** page name the missing secret, and reader pages say the newsletter is temporarily unavailable. `SES_CONFIGURATION_SET` is the one optional secret.\n\n1. Store the access key ID, and paste it at the prompt:\n\n ```bash\n npx wrangler secret put AWS_ACCESS_KEY_ID --env production\n ```\n\n1. Store the secret access key:\n\n ```bash\n npx wrangler secret put AWS_SECRET_ACCESS_KEY --env production\n ```\n\n1. Store the topic ARN from section 6. The app accepts events only from this topic:\n\n ```bash\n npx wrangler secret put SNS_TOPIC_ARN --env production\n ```\n\n1. Store the configuration set\'s name, `kestrel-mail`. It\'s optional but recommended: the app names it on every email, so SES reports on each one even without the default. Type it exactly as the set is named in this region: if SES can\'t find the set, it refuses every email, and a list send is held until the name is fixed or the set created:\n\n ```bash\n npx wrangler secret put SES_CONFIGURATION_SET --env production\n ```\n\n1. In `wrangler.jsonc`, edit the `production` block\'s `vars`. Change `PROVIDER` to `ses` (it\'s `""` on a new instance; add it if your block has none), and add `SES_MAX_SEND_RATE`. Change `AWS_REGION` to your region\'s code from section 1. Your other values stay as they are. The block ends up like this:\n\n ```jsonc\n "vars": {\n "PROVIDER": "ses", // \u2190 set to ses\n "APP_ORIGIN": "https://newsletter.example.com",\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com",\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",\n "AWS_REGION": "us-east-1", // \u2190 change: your region\'s code\n "SES_MAX_SEND_RATE": "1" // \u2190 new: your maximum send rate; 1 in the sandbox\n }\n ```\n\n On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan\'s limit, so each minute sends more. Leave it out on Workers Free. [As your list grows](../guides/09-as-your-list-grows.md) says when the plan is worth it.\n\n When AWS grants production access, set `SES_MAX_SEND_RATE` to the maximum send rate on the **Account dashboard**, and deploy again. The app keeps to that rate, so SES doesn\'t throttle your send.\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Send through Amazon SES"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## 9. Subscribe the app to the topic\n\nSNS delivers events only to a subscriber that has confirmed. The app confirms on its own, once it checks that the request came from your topic.\n\n1. In the SNS console, open your topic, and choose **Create subscription**.\n\n1. Set **Protocol** to **HTTPS**, and enter the endpoint:\n\n ```\n https://newsletter.example.com/webhooks/ses\n ```\n\n1. Leave raw message delivery off, and choose **Create subscription**.\n\n1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.\n\n1. Give SNS longer to retry. By default it retries a failed delivery 3 times over about a minute, then discards the event, so a bounce that arrives while the app is briefly down or mid-deploy is lost, and the address gets your next post. Open the topic, choose **Edit**, and expand **Delivery policy (HTTP/S)**. Clear **Use the default delivery policy**, set these, and choose **Save changes**:\n\n | Field | Value |\n | --- | --- |\n | Number of retries | `13` |\n | Retries without delay | `0` |\n | Minimum delay | `20` |\n | Minimum delay retries | `2` |\n | Maximum delay | `300` |\n | Maximum delay retries | `6` |\n | Retry-backoff function | Exponential |\n\n SNS then keeps trying for most of an hour, the longest it allows. It never retries an event the app refused. The app refuses only an event it can\'t verify came from your topic, and logs an error when one claims to be from SNS.\n\n1. **(Optional)** Keep what SNS still can\'t deliver. On the subscription, choose **Edit**, and set a **Redrive policy (dead-letter queue)** to an SQS queue of your own. SNS then moves an event there instead of discarding it, where you can read it later. [Amazon SNS dead-letter queues](https://docs.aws.amazon.com/sns/latest/dg/sns-dead-letter-queues.html) covers the queue and its permissions.\n\nIf it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:\n\n- **Access covers `/webhooks/ses`.** If your Access application covers more than `/dashboard` and `/api`, SNS gets the login page instead of the app. Remove that path from your Access application, so only `dashboard` and `api` remain.\n- **`SNS_TOPIC_ARN` matches the topic\'s ARN.** If it doesn\'t, the app\'s logs show a `webhook.refused` error naming both.\n- **The deploy in section 8 finished.**\n\nOnce you\'ve fixed the cause, select the subscription, and choose **Request confirmation**. SNS sends the request again, and the app confirms it. SNS doesn\'t let you delete a subscription while it\'s pending, and removes it on its own after 48 hours.\n\n## Switch an instance that already sends\n\nChange `PROVIDER`, or move to another account with the same provider, only while no send is sending. The dashboard lists any send in progress.\n\nWhen a request to a provider gets no answer, Kestrel can\'t know whether its recipients were mailed. Moving to SES, from another provider or another SES account, mails none of them twice. Kestrel never repeats an unanswered request to SES, so those recipients wait for you to decide whether they were mailed. Use **Resolve** on the send\'s page.\n\nKestrel can\'t tell whether those emails went out. SES\'s console shows sent messages only if you\'ve turned on Virtual Deliverability Manager, which this guide doesn\'t set up. So **Assume not sent** is the safe default. Those readers may miss this post, but they get your next one. No one is mailed twice. Choose **Assume sent** only when you know otherwise, such as a reader who says they got it.\n\n## Check it\n\n1. The app answers on your hostname:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The webhook is public, and refuses a request that isn\'t from SNS:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" -X POST https://newsletter.example.com/webhooks/ses\n ```\n\n ```\n 400\n ```\n\n Any other answer, such as a redirect to your Cloudflare login, means Access covers the webhook. Remove that path from your Access application.\n\n1. The SNS subscription shows **Confirmed**.\n\n1. Your From address has somewhere to go. In Cloudflare, your domain\'s DNS records show the ones Email Routing added for `send`, beside section 1\'s DKIM records:\n\n - **Type:** `MX`, **Name:** `send`, three of them, for `route1.mx.cloudflare.net`, `route2.mx.cloudflare.net`, and `route3.mx.cloudflare.net`\n - **Type:** `TXT`, **Name:** `send`, **Content:** `v=spf1 include:_spf.mx.cloudflare.net ~all`\n - **Type:** `TXT`, **Name:** `cf2024-1._domainkey.send`, Cloudflare\'s DKIM key\n\n The world sees the MX records too:\n\n ```bash\n dig +short MX send.example.com\n ```\n\n It lists the three `route` servers. Email Routing\'s **Routing Rules** shows your rule for `newsletter@send.example.com`, and **Destination Addresses** shows the publisher\'s inbox as verified. A reply to your test email in the next step proves the forwarding works.\n\n1. In the editor, **Settings \u2192 Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before.\n\n1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren\'t open yet.\n\nThe test sends in the next step prove the rest. In the sandbox, send them only to the addresses you verified in section 5, and to the SES test addresses that page lists.\n\nSES charges for each email it sends, test emails included. [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists the rates.\n\nNext, [verify it works](05-verify.md).\n';
|
|
6247
6751
|
|
|
6248
6752
|
// docs/get-started/05-verify.md
|
|
6249
|
-
var verify_default = "# Verify it works\n\nSome things only show up with real email: whether inboxes trust your mail, how it looks in a real mail client, and whether bounces and unsubscribes come back to the app. Check them now, while the list is empty and every email goes only to you.\n\nIn this guide, you send yourself a test email, reply to it, and check that it passes authentication. Then you set up notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs,
|
|
6753
|
+
var verify_default = "# Verify it works\n\nSome things only show up with real email: whether inboxes trust your mail, how it looks in a real mail client, and whether bounces and unsubscribes come back to the app. Check them now, while the list is empty and every email goes only to you.\n\nIn this guide, you send yourself a test email, reply to it, and check that it passes authentication. Then you set up notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs, check that SES can send to anyone, and clean up the test subscribers.\n\n## Before you begin\n\n- Two email addresses you own, such as a personal address and a work address. Some steps use both. While your SES account is in the sandbox, verify both in SES first, as [Connect Amazon SES](04-ses.md#5-request-production-access) describes.\n- A Gmail address, or another mail client that shows a message's original source and an **Unsubscribe** button.\n\n## 1. Send yourself a test email\n\nA test email goes through the same code as a real send. If the test looks right, the real send looks the same.\n\n1. In the editor at `https://newsletter.example.com/dashboard/`, create a post with a subject, a short body, and an image.\n\n1. Select **Send test email**, enter your address, and send it.\n\n1. Open the email. It should arrive in your inbox, not in spam. The image loads and the subject reads as you wrote it. Its **View in browser** link opens \"Nothing here yet\" until the post goes out, and its unsubscribe link opens the page a subscriber sees, marked as a test and showing a sample address. Its button walks through to the confirmation, but a test email unsubscribes no one. Section 5 unsubscribes for real.\n\n1. Reply to it, as a reader would. Reply from an address other than the inbox your From address forwards to, such as your second address. Gmail doesn't show you a message you sent when it comes back to you. Send that address a test email too if you need one. The reply arrives in the inbox you chose in [Connect Amazon SES](04-ses.md#3-receive-replies-at-your-from-address). The app can't see your forwarding, so this is the only proof that readers can reach the publisher. If the reply bounces, check the routing rule and the destination address there.\n\n## 2. Check that it passes authentication\n\nInboxes trust mail that passes DKIM and DMARC for the domain it's from. Passing both is the best single sign that your mail reaches the inbox.\n\n1. Open the test email's original source. In Gmail, open the message's menu and select **Show original**.\n\n1. Find the `Authentication-Results` header. It shows:\n\n - `dkim=pass` for `send.example.com`\n - `dmarc=pass` for `send.example.com`\n - `spf=pass`, for a hostname under `send.example.com`\n\n With no custom MAIL FROM domain, SPF passes for `amazonses.com` instead. That's expected, and DMARC still passes.\n\nIf a check fails, compare your DNS records with the ones in [Connect Amazon SES](04-ses.md#1-verify-your-sending-hostname). [Sending-domain DNS](../reference/02-sending-domain-dns.md#verify-alignment) explains each result.\n\n## 3. Set up notifications\n\nKestrel emails the notifications address when a send finishes, and right away when one runs into a problem. Notifications go through your email provider. To send them through Cloudflare instead, see [Notifications through Cloudflare's email](../guides/03-notifications.md).\n\n1. In the editor, open **Settings \u2192 Notifications**.\n\n1. Enter your address, and select **Save**.\n\n1. Select **Send a test notification**. It arrives at that address.\n\nUse your own address to test. If you're not the publisher, they set theirs in [Check your setup](../publishing/10-check-your-setup.md).\n\n## 4. Send a real post to yourself\n\nThis is a real send, to a list of one. It proves the whole path: subscribing, confirming, scheduling, and sending.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe with your second address.\n\n1. Open the confirmation email, follow its link, and select **Confirm**. In the editor, **Subscribers** shows the address as **Confirmed**.\n\n1. Open the post, select **Schedule**, and then **Send now**. Every send waits at least five minutes before it goes out, so you can cancel a mistake.\n\n1. Uncheck **Include in archive**. The post still goes to subscribers, but it's sent as email only: it isn't in the public archive or on the home page. That way the test proves the send without becoming the publication's first archived post, and the dashboard keeps its **Finish setting up** steps for the publisher.\n\n1. Cancel the send, to see that canceling stops it. Then select **Schedule** and **Send now** again, check that **Include in archive** is still unchecked, and let it go.\n\n1. When the send finishes, the post arrives at your second address. A notification arrives at your first, with the subject `Sent:` and the post's title.\n\nIf you verified this instance before Email only existed, the test post is in the archive. Open it under **Sent**, and select **Remove from archive\u2026**.\n\n## 5. Unsubscribe\n\nEvery email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. Both work in one step, with no login.\n\n1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender.\n\n1. Select it. In the editor, **Subscribers** shows the address as **Unsubscribed**, and later sends leave it out.\n\n## 6. Check that bounces and complaints come back\n\nWhen an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:\n\n - `bounce@simulator.amazonses.com`, which bounces\n - `complaint@simulator.amazonses.com`, which marks your mail as spam\n\n Each one gets a confirmation email, which bounces or is marked as spam.\n\n1. In the editor, **Subscribers** lists both addresses as **Pending**, each with a **Suppressed** flag. The app never mails a suppressed address again.\n\nIf an address has no flag, SES's events aren't reaching the app. Check the configuration set and the SNS subscription in [Connect Amazon SES](04-ses.md#6-send-bounces-and-complaints-to-sns).\n\n## 7. Read the logs\n\nThe app writes a log line for each thing it does, and Cloudflare keeps them for a few days. Nothing else tells you if the once-a-minute schedule that sends posts stops, so it's worth knowing where to look.\n\n1. In the Cloudflare dashboard, go to **Workers & Pages**, select `kestrel-production`, and select **Observability**.\n\n1. Filter on `event` equal to `sweep.tick`. A line arrives every minute.\n\n1. Filter on `event` equal to `send.completed`. Your send from section 4 is there.\n\n1. Filter on `level` equal to `error`. A healthy instance shows nothing.\n\n## 8. Check that SES can send to anyone\n\n[Connect Amazon SES](04-ses.md#5-request-production-access) asked AWS for production access. Until AWS grants it, the sandbox mails only your verified addresses, so check it before you go live.\n\n1. In SES, open the **Account dashboard** in the region the app sends from. It no longer says your account is in the sandbox, and it shows your daily quota and maximum send rate.\n\n1. Check that `SES_MAX_SEND_RATE` in `wrangler.jsonc` matches that rate, and that you deployed after setting it, as [Connect Amazon SES](04-ses.md#8-set-the-provider-and-deploy) describes.\n\nIf the dashboard still says sandbox, AWS hasn't answered yet. Every other section works in the sandbox, so come back to this one when it does.\n\n## 9. Clean up the test subscribers\n\nThe checks leave test addresses on the list. Erase them, so the publisher starts with only real readers.\n\n1. In the editor, open **Subscribers**.\n\n1. Erase `bounce@simulator.amazonses.com` and `complaint@simulator.amazonses.com`. Open each one's menu (\u22EF), and select **Erase**.\n\n1. Erase your second address too, if it won't be a reader.\n\nErasing keeps a do-not-contact marker, but the person can subscribe again through the subscribe page. Erasing your own address doesn't lock you out.\n\nWhen every section gives the result it describes, your instance is ready for subscribers.\n\nNext, [go live](06-go-live.md).\n";
|
|
6250
6754
|
|
|
6251
6755
|
// docs/get-started/06-go-live.md
|
|
6252
|
-
var go_live_default = "# Go live\n\nAt this point, your instance should be deployed, verified, and out of the SES sandbox, so it can send to anyone. From here, the publisher can set up the publication, and you can continue with the guides below.\n\n## Start publishing\n\nThe rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md)
|
|
6756
|
+
var go_live_default = "# Go live\n\nAt this point, your instance should be deployed, verified, and out of the SES sandbox, so it can send to anyone. From here, the publisher can set up the publication, and you can continue with the guides below.\n\n## Start publishing\n\nThe rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md).\n\nIf someone else is the publisher, let them sign in first:\n\n1. Add their email address to the `Publishers` policy on the Access application, as in [Allow yourself in](03-access.md#3-allow-yourself-in). If you set `ACCESS_ALLOWED_EMAILS`, store the list again with their address added.\n\n1. If they don't have a Cloudflare account, add another way to sign in, such as a one-time code by email. It's under **Zero Trust \u2192 Integrations \u2192 Identity providers**.\n\n1. Send them the editor's address and a link to [Name your publication](../publishing/03-name-your-publication.md).\n\n## Optional next steps\n\nEach of these guides adds one thing, and you can come back to any of them later.\n\n- [Connect Claude to the API](../guides/01-connect-claude.md), so Claude can draft, proofread, and schedule posts through the same API the editor uses.\n- [Notifications through Cloudflare's email](../guides/03-notifications.md), so problems are reported even when the email provider has stopped sending.\n- [Rate-limit the subscribe form](../guides/05-rate-limit.md), before the subscribe link is shared widely.\n- [As your list grows](../guides/09-as-your-list-grows.md), from about 5,000 subscribers.\n- [Upgrade to a new release](../guides/07-upgrade.md), when one comes out.\n";
|
|
6253
6757
|
|
|
6254
6758
|
// docs/guides/01-connect-claude.md
|
|
6255
|
-
var connect_claude_default = '# Connect Claude to the API\n\nClaude Code can draft, edit, proofread, and schedule posts through the same API the editor uses. It has its own Cloudflare Access credential, so its changes show as Claude\'s, not a person\'s. Anything it schedules waits out the review window, where the publisher can still cancel it.\n\nIn this guide, you create a service token for Claude Code, and add a policy for it to your Access application. Then you store the token where Claude Code reads it, check that it works, and point Claude at the API reference.\n\n## Before you begin\n\nYou need:\n\n- The dashboard locked with Access, from [Lock the dashboard with Access](../get-started/03-access.md).\n- [Claude Code](https://code.claude.com/docs/en/overview), run from your instance\'s repository.\n\n## 1. Create a service token\n\nA service token is a Client ID and a Client Secret that a program sends with each request. It carries no email, so the app tells it apart from your own login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Service credentials \u2192 Service Tokens**, and select **Create Service Token**.\n\n1. Name it `kestrel-claude`, and choose a **Service Token Duration**. A year is a good default.\n\n1. Select **Generate token**.\n\n1. Copy the **Client ID** and the **Client Secret**. Cloudflare shows the secret only once.\n\n## 2. Add a policy for the token\n\nYour application lets in only the people its `Publishers` policy names. A token needs a policy of its own, on the same application, because the app accepts only logins issued for that application.\n\n1. Under **Access controls \u2192 Applications**, select **Configure** on the application from [Lock the dashboard with Access](../get-started/03-access.md#2-create-the-access-application).\n\n1. Add a policy:\n\n - **Policy name:** `Claude`\n - **Action:** Service Auth\n - **Include:** the **Service Token** selector, with `kestrel-claude`\n\n1. Save the application.\n\n`ACCESS_ALLOWED_EMAILS` doesn\'t apply to the token, since it has no email. This policy is the only thing that lets it in.\n\n## 3. Store the token for Claude Code\n\nClaude Code loads the `env` block of `.claude/settings.local.json` into every session. That file stays on your computer: your repository\'s `.gitignore` keeps it out of git.\n\n1. Open `.claude/settings.local.json` in your instance\'s repository. Create it if it doesn\'t exist.\n\n1. Add an `env` block, beside anything already in the file:\n\n ```json\n {\n "env": {\n "KESTREL_URL": "https://newsletter.example.com",\n "CF_ACCESS_CLIENT_ID": "REPLACE_WITH_CLIENT_ID",\n "CF_ACCESS_CLIENT_SECRET": "REPLACE_WITH_CLIENT_SECRET"\n }\n }\n ```\n\n `KESTREL_URL` is your app\'s address. The other two are the Client ID and Client Secret from section 1.\n\n1. Start a new Claude Code session, so it loads the values.\n\nNever put the secret in `.claude/settings.json`. That file is committed.\n\n## 4. Point Claude at the API\n\nKestrel has no plugin or connector for Claude. Claude calls the HTTP API directly, and the API reference describes every route: its method and path, the body it takes, and an example. The editor\'s **API** tab shows the same reference.\n\nYour instance\'s `.claude/CLAUDE.md` already tells Claude Code how to call the API, so a session in your instance\'s repository reads the reference before its first call. If it doesn\'t, a prompt like this one points it there:\n\n```\nKestrel\'s API is at $KESTREL_URL. Send the CF-Access-Client-Id and\nCF-Access-Client-Secret headers from your environment with every request.\nRead GET /api/reference first, and use it for every call.\n```\n\nThe reference is also served as Markdown, `GET /api/reference.md`, which reads more easily for Claude than the JSON. So are the docs: `GET /api/docs/publishing.md` lists the publisher\'s pages, `GET /api/docs/self-hosting.md` this guide\'s, and each page is at `GET /api/docs/<slug>.md`.\n\nClaude reads the reference, and from there can list the posts, write a draft, and schedule it. The reference lists the body types each route takes. Most take JSON, sent with `Content-Type: application/json`. An image upload takes a multipart form, or the image file itself.\n\nTo bring subscribers over from another service, ask Claude to import the list, as [Bring your existing list](../publishing/01-your-list.md) describes. Its [file format](../publishing/01-your-list.md#the-file-format) section describes every column.\n\n## Check it\n\n1. Ask Claude to call `GET /api/whoami`. The app answers with a program\'s identity, not yours:\n\n ```\n {"principal":{"kind":"service"},"auth":{"mode":"access"}}\n ```\n\n1. Ask Claude to list the posts. It answers with the posts the editor shows.\n\n1. Open a draft in the editor, and ask Claude to change a word in it. The editor shows that the draft was changed elsewhere, last edited by **Claude**.\n\nIf Claude gets a `401`, a `403`, or a login page, check the policy from section 2, and the token\'s two values in `.claude/settings.local.json`. Check that the token hasn\'t expired, too.\n';
|
|
6759
|
+
var connect_claude_default = '# Connect Claude to the API\n\nClaude Code can draft, edit, proofread, and schedule posts through the same API the editor uses. It has its own Cloudflare Access credential, so its changes show as Claude\'s, not a person\'s. Anything it schedules waits out the review window, where the publisher can still cancel it.\n\nIn this guide, you create a service token for Claude Code, and add a policy for it to your Access application. Then you store the token where Claude Code reads it, check that it works, and point Claude at the API reference.\n\n## Before you begin\n\nYou need:\n\n- The dashboard locked with Access, from [Lock the dashboard with Access](../get-started/03-access.md).\n- [Claude Code](https://code.claude.com/docs/en/overview), run from your instance\'s repository.\n\n## 1. Create a service token\n\nA service token is a Client ID and a Client Secret that a program sends with each request. It carries no email, so the app tells it apart from your own login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Service credentials \u2192 Service Tokens**, and select **Create Service Token**.\n\n1. Name it `kestrel-claude`, and choose a **Service Token Duration**. A year is a good default.\n\n1. Select **Generate token**.\n\n1. Copy the **Client ID** and the **Client Secret**. Cloudflare shows the secret only once.\n\n## 2. Add a policy for the token\n\nYour application lets in only the people its `Publishers` policy names. A token needs a policy of its own, on the same application, because the app accepts only logins issued for that application.\n\n1. Under **Access controls \u2192 Applications**, select **Configure** on the application from [Lock the dashboard with Access](../get-started/03-access.md#2-create-the-access-application).\n\n1. Add a policy:\n\n - **Policy name:** `Claude`\n - **Action:** Service Auth\n - **Include:** the **Service Token** selector, with `kestrel-claude`\n\n1. Save the application.\n\n`ACCESS_ALLOWED_EMAILS` doesn\'t apply to the token, since it has no email. This policy is the only thing that lets it in.\n\n## 3. Store the token for Claude Code\n\nClaude Code loads the `env` block of `.claude/settings.local.json` into every session. That file stays on your computer: your repository\'s `.gitignore` keeps it out of git.\n\n1. Open `.claude/settings.local.json` in your instance\'s repository. Create it if it doesn\'t exist.\n\n1. Add an `env` block, beside anything already in the file:\n\n ```json\n {\n "env": {\n "KESTREL_URL": "https://newsletter.example.com",\n "CF_ACCESS_CLIENT_ID": "REPLACE_WITH_CLIENT_ID",\n "CF_ACCESS_CLIENT_SECRET": "REPLACE_WITH_CLIENT_SECRET"\n }\n }\n ```\n\n `KESTREL_URL` is your app\'s address. The other two are the Client ID and Client Secret from section 1.\n\n1. Start a new Claude Code session, so it loads the values.\n\nNever put the secret in `.claude/settings.json`. That file is committed.\n\n## 4. Point Claude at the API\n\nKestrel has no plugin or connector for Claude. Claude calls the HTTP API directly, and the API reference describes every route: its method and path, the body it takes, and an example. The editor\'s **API** tab shows the same reference.\n\nYour instance\'s `.claude/CLAUDE.md` already tells Claude Code how to call the API, so a session in your instance\'s repository reads the reference before its first call. If it doesn\'t, a prompt like this one points it there:\n\n```\nKestrel\'s API is at $KESTREL_URL. Send the CF-Access-Client-Id and\nCF-Access-Client-Secret headers from your environment with every request.\nRead GET /api/reference first, and use it for every call.\n```\n\nThe reference is also served as Markdown, `GET /api/reference.md`, which reads more easily for Claude than the JSON. So are the docs: `GET /api/docs/publishing.md` lists the publisher\'s pages, `GET /api/docs/self-hosting.md` this guide\'s, and each page is at `GET /api/docs/<slug>.md`.\n\nClaude reads the reference, and from there can list the posts, write a draft, and schedule it. The reference lists the body types each route takes. Most take JSON, sent with `Content-Type: application/json`. An image upload takes a multipart form, or the image file itself.\n\nTo bring subscribers over from another service, ask Claude to import the list, as [Bring your existing list](../publishing/01-your-list.md) describes. Its [file format](../publishing/01-your-list.md#the-file-format) section describes every column.\n\n## Check it\n\n1. Ask Claude to call `GET /api/whoami`. The app answers with a program\'s identity, not yours:\n\n ```\n {"principal":{"kind":"service","tokenId":"e367c0ffee5eb4.access"},"auth":{"mode":"access"}}\n ```\n\n The `tokenId` is the Client ID Zero Trust shows beside `kestrel-claude`. If it\'s a different one, Claude Code is using another token.\n\n1. Ask Claude to list the posts. It answers with the posts the editor shows.\n\n1. Open a draft in the editor, and ask Claude to change a word in it. The editor shows that the draft was changed elsewhere, last edited by **Claude**.\n\nIf Claude gets a `401`, a `403`, or a login page, check the policy from section 2, and the token\'s two values in `.claude/settings.local.json`. Check that the token hasn\'t expired, too.\n';
|
|
6256
6760
|
|
|
6257
6761
|
// docs/guides/03-notifications.md
|
|
6258
6762
|
var notifications_default = "# Notifications through Cloudflare's email\n\nKestrel emails the publisher when a send finishes, and right away when one runs into a problem. By default, these notifications go through the email provider. The one that matters most, about the provider refusing your account, is the one the provider would refuse too. Cloudflare's email doesn't depend on the provider, so that notification still reaches the publisher.\n\nIn this guide, you turn on Cloudflare's email for your app's hostname, and verify the notifications address with it. Then you add the binding that sends notifications through it, deploy, and send yourself a test.\n\n## Before you begin\n\nYou need:\n\n- The notifications address saved, from [Verify it works](../get-started/05-verify.md#3-set-up-notifications).\n- Your domain's DNS on Cloudflare, as for the rest of the app.\n\n## 1. Turn on Email Routing for your app's hostname\n\nCloudflare's email sends only from a domain set up with Email Routing. Use your app's hostname, `newsletter.example.com`. It receives no mail today, so turning Email Routing on there changes nothing else.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), go to **Compute \u2192 Email Service \u2192 Email Routing**.\n\n1. Add `newsletter.example.com` to Email Routing. Cloudflare adds subdomains from your domain's own settings: select `example.com`, then **Settings**, and add `newsletter` under **Subdomains**.\n\n1. Cloudflare adds MX, SPF, and DKIM records for `newsletter.example.com`, and locks them. Wait until they show as added.\n\nAvoid turning on Email Routing for a domain that already receives mail elsewhere, such as `example.com` with Google Workspace. Email Routing takes over that domain's MX records. Don't send notifications from `send.example.com` either, since your email provider sends from it. Email Routing there to forward readers' replies, as [Connect Amazon SES](../get-started/04-ses.md#3-receive-replies-at-your-from-address) sets up, is fine, since it only receives.\n\n## 2. Verify the notifications address\n\nCloudflare's email delivers only to addresses you've verified with it. Sending to a verified address is free on any plan.\n\n1. Under **Email Routing**, go to **Destination Addresses**, and add the address you saved for notifications.\n\n1. Open the email Cloudflare sends to that address, and select **Verify email address**. If it's the publisher's address, ask them to do this step.\n\nA verified address belongs to your Cloudflare account, so every environment in it can use it.\n\n## 3. Add the binding and deploy\n\nA `send_email` binding named `NOTIFY` moves notifications to Cloudflare's email. Without it, they go through your provider.\n\n1. In `wrangler.jsonc`, add the binding to the `production` block:\n\n ```jsonc\n \"send_email\": [\n { \"name\": \"NOTIFY\", \"allowed_destination_addresses\": [\"you@example.com\"] } // \u2190 the notifications address\n ],\n ```\n\n `allowed_destination_addresses` is optional. It limits the binding to the addresses you list, so a changed setting in the editor can't send notifications anywhere else. Leave it out to allow any address you've verified. If the publisher changes the notifications address later, verify the new one and add it here too.\n\n1. **(Optional)** Notifications come from `Kestrel <kestrel@newsletter.example.com>`. To use another address on that hostname, set `NOTIFY_FROM` in the block's `vars`:\n\n ```jsonc\n \"NOTIFY_FROM\": \"Kestrel <alerts@newsletter.example.com>\",\n ```\n\n1. Check the file. This regenerates the binding types:\n\n ```bash\n npm run typecheck\n ```\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am \"Send notifications through Cloudflare's email\"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## Check it\n\n1. In the editor, open **Settings \u2192 Notifications**. **Sent through** reads \"Cloudflare Email, separate from your newsletter's provider\", and **From address** shows `kestrel@newsletter.example.com`.\n\n1. Select **Send a test notification**. It arrives at the notifications address, and **Last notification** shows it as delivered.\n\nIf the test isn't delivered, **Last notification** shows Cloudflare's reason, such as an address that isn't verified. Cloudflare's Email Routing summary lists notifications as dropped even when they're delivered, so check the notifications inbox rather than the summary.\n\n## What notifications don't cover\n\n- **The schedule stopping.** The same once-a-minute schedule that sends posts also notices problems. If the schedule itself stops, nothing tells you. [Read the logs](../get-started/05-verify.md#7-read-the-logs) to check that `sweep.tick` still arrives every minute.\n- **A notification that can't get through.** The app tries each one up to five times, a minute apart. A failure shows under **Settings \u2192 Notifications**, and in the logs as `notify.failed`.\n- **Problems while no address is set.** With the address blank, the app sends nothing, and saves nothing up for later.\n\nA notification never changes a send. One that fails never delays, pauses, or stops it.\n";
|
|
@@ -6276,7 +6780,7 @@ var sending_rules_default = "# Sending rules\n\nEmail law asks a few things of e
|
|
|
6276
6780
|
var name_your_publication_default = "# Name your publication\n\nGive your newsletter a name and a look before your first post. Set the name, tagline, and logo under **Settings \u2192 Publication identity**. They appear in every email your template prints them in, on the public pages readers see, and in the confirmation email new subscribers get.\n\nAll three are optional, and you can change them at any time. How they appear in each email comes from your email template, which you can [edit](11-email-template.md).\n\n## When to provide a mailing address\n\n**If your newsletter is mostly your own writing, you probably don't need one. If an email is mainly selling something, add one.**\n\nUS law (CAN-SPAM) asks for a postal address in commercial email. The FTC [treats a newsletter people subscribed to](https://www.ftc.gov/sites/default/files/documents/federal_register_notices/definitions-and-implementation-under-can-spam-act-16-cfr-part-316/080521canspamact.pdf) as a relationship message instead, even with a few ads in it. An email whose main purpose is to sell, such as a launch, a sale, or a sponsor's promotion, is commercial. Canada's law (CASL) asks for an address more often ([Government of Canada guidance](https://ised-isde.canada.ca/site/canada-anti-spam-legislation/en)).\n\nA P.O. box or a registered private mailbox counts, so you never have to publish your home address. To add one, choose **Add a mailing address** under **Settings \u2192 Publication identity**. The built-in templates print it in the email footer and keep it off your public pages.\n\nThis is general information, not legal advice. The FTC's [CAN-SPAM compliance guide](https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business) has the detail, and [Sending rules](02-sending-rules.md) covers the rest of what the law asks of a newsletter.\n\n## Who sets the From name and address\n\nThe From name and address your emails come from are set when Kestrel is deployed, so **Settings \u2192 Email sender** shows them read-only. To change them, follow [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy).\n\n";
|
|
6277
6781
|
|
|
6278
6782
|
// docs/publishing/04-write-a-post.md
|
|
6279
|
-
var write_a_post_default = "# Write and send a post\n\nYou write a post in Markdown, test it, and schedule it. Scheduling locks the post and makes the exact email that will go out. Then you send yourself a test of that copy, so the email you tested is the one your list receives.\n\n**New post**, on the Dashboard or under **Drafts**, opens a draft.\n\n## Writing\n\nKestrel's editor is a Markdown editor. You write a post in Markdown, the way you would for a static site generator or with an AI assistant. Kestrel places it in your [email template](11-email-template.md). The same Markdown makes the email and the post's archive page, so you never format a post twice. **Preview** shows the email as it will look, and Kestrel saves as you type.\n\nA few things to know as you write:\n\n- **A blank line starts a new paragraph.** In an email, a single line break doesn't.\n- **The opening lines** are the preview a mail client shows beside the subject.\n- **Images** go in by paste or drop, and Kestrel inserts a reference by file name, such as ``. Replace the words in brackets with a short description. PNG, JPEG, WebP, or GIF, up to 5 MB each.\n- **
|
|
6783
|
+
var write_a_post_default = "# Write and send a post\n\nYou write a post in Markdown, test it, and schedule it. Scheduling locks the post and makes the exact email that will go out. Then you send yourself a test of that copy, so the email you tested is the one your list receives.\n\n**New post**, on the Dashboard or under **Drafts**, opens a draft.\n\n## Writing\n\nKestrel's editor is a Markdown editor. You write a post in Markdown, the way you would for a static site generator or with an AI assistant. Kestrel places it in your [email template](11-email-template.md). The same Markdown makes the email and the post's archive page, so you never format a post twice. **Preview** shows the email as it will look, and Kestrel saves as you type.\n\nA few things to know as you write:\n\n- **A blank line starts a new paragraph.** In an email, a single line break doesn't.\n- **The opening lines** are the preview a mail client shows beside the subject.\n- **Images** go in by paste or drop, and Kestrel inserts a reference by file name, such as ``. Replace the words in brackets with a short description. PNG, JPEG, WebP, or GIF, up to 5 MB each.\n- **Where it will go** is in **Post settings**, at the top right: whether it's in your archive, and its address, such as `newsletter.example.com/archive/spring-update`, whose slug follows the subject (below).\n\n## Test it\n\n**Send test email** sends the real email, through the same template and code as the send, to the addresses you name. **Settings \u2192 Default test recipients** fills them in for you. Nobody on your list gets anything, nothing appears in your archive, and a test's unsubscribe link unsubscribes no one.\n\nMail clients each draw email a little differently, so trust a test over **Preview**, especially for tables, code, and HTML. A test needs an email provider, which [Connect Amazon SES](../get-started/04-ses.md) sets up.\n\n## Schedule and send\n\nChoose **Schedule** at the foot of the editor, pick a time under **Send at**, and confirm. The time must be at least the minimum lead away: five minutes, unless your deployment sets a different [`MIN_LEAD_SECONDS`](../reference/01-configuration.md#min_lead_seconds). Neither the dashboard nor Claude can shorten it. Times are to the minute, and one with seconds rounds up.\n\nScheduling makes the email once, from the post, your template, and your publication's details as they are at that moment. That frozen copy is what goes out, and it's also the post's page in your archive. So the post is locked, and **Preview** and **Send test email** show the frozen copy.\n\n**Send yourself a test now.** It's the copy your list receives, and the time until the send is your window to catch a mistake, including one Claude made.\n\nChanging your template, or a publication detail it shows, re-makes the email of every scheduled post, with the same content and send time. Kestrel asks before it saves, and each re-made post then asks you to send a new test. A change is refused while a post is within the minimum lead of its send time, and Kestrel says when to try again. Posts already sending or sent never change.\n\n**Send now \u2192**, under the time picker, is for a post you've already reviewed. It still waits out the window, scheduling the post for the minimum lead from now.\n\n### Post settings\n\n**Post settings**, at the top right of the editor, holds two choices. Neither is part of the email, so both stay open while the post is scheduled. Nothing changes until you select **Save**.\n\n**Include in archive** is checked unless you uncheck it. Unchecked, the post goes to your list as usual but is sent as email only: it stays off your public archive and your home page, and its archive address shows nothing. Use that for a post meant for the inbox: a correction to an earlier email, a short-lived notice, or a test. The editor marks such a post **Email only**.\n\n**Archive address** is where the post's page will be in your archive. Only the last part, the slug, is yours to type; it follows the subject until you do. Each post's address is its own: if you type one another post has, or one with spaces or capitals, Kestrel says so and keeps the address as it was. If another post already has the address your subject would give, Kestrel adds a number (`spring-update-2`) and tells you, so you can choose another. The address is fixed once the post starts sending.\n\nThe Schedule, Send now, and Edit schedule dialogs show the same settings under the time, so you can still change them there. A change made in a dialog applies when you schedule, send, or save, and canceling leaves the post as it was. Once a post is scheduled, its banner names where it's going.\n\nEvery email links its web version through the post's own **unlisted link**, which never changes. It opens the archive page while the post is in your archive, and the page itself while it's email only. Anyone with the email can open it, but nothing lists it, and search engines are asked not to.\n\nA sent post's page under **Sent** shows its address with **Remove from archive\u2026** or **Add to archive\u2026**. Both ask first. Removing a post takes its archive address down, so links people shared stop working, but the address stays the post's: add it back and those links work again. Neither changes what was sent.\n\n### Changing your mind\n\n**Cancel**, on the post's banner or its card under **Sent**, makes the post a draft again at once. Edit it, test again, and schedule it again. **Edit schedule** changes the send time, whether the post is in your archive, or both, without unlocking the post, and your tests still count. You can do either until the send time.\n\n### Who receives it\n\nA post goes to your list as it stands when it sends: every confirmed subscriber, except addresses that bounced, complained, or were blocked. Someone who confirms while it's scheduled receives it. Someone who unsubscribes before their email is handed to your provider doesn't, even partway through the send.\n\nSo a recipient count shown before the send is a snapshot. An import that adds subscribers while a post is scheduled adds them to its audience too, and asks you first ([Bring your existing list](01-your-list.md#3-import-it)).\n\n### Warnings when you schedule\n\n- **No email provider connected.** The Schedule dialog can't schedule until one is connected ([Connect Amazon SES](../get-started/04-ses.md)). If the provider goes after a post is scheduled, the post is held, stays cancelable, and goes out once one is connected.\n- **Daily database writes.** On Cloudflare's free Workers plan, a large send may use up the day's writes. The dialog warns you without stopping you, as [Bring your existing list](01-your-list.md#limits) explains.\n\n### When it sends\n\nAt the send time, the post moves to its send page under **Sent**, where you can watch it go out. From then it can't be edited, canceled, or moved. Its page goes up at the same moment, showing exactly the copy that was sent, so every email's \"view in browser\" link works. Whatever interrupts a send, such as your provider's daily quota running out, it waits and resumes where it stopped. No reader gets the same post twice.\n\nIf a send needs your attention, the send page and the dashboard say so. [Troubleshooting](06-troubleshooting.md) covers each case.\n";
|
|
6280
6784
|
|
|
6281
6785
|
// docs/publishing/06-troubleshooting.md
|
|
6282
6786
|
var troubleshooting_default = `# Troubleshooting
|
|
@@ -6287,17 +6791,21 @@ When a send needs you, its page and the dashboard's health block say so in a red
|
|
|
6287
6791
|
|
|
6288
6792
|
## A send is waiting or held
|
|
6289
6793
|
|
|
6290
|
-
A problem with your email provider never marks anyone unsent. The send waits where it is, and carries on from there once the problem clears.
|
|
6794
|
+
A problem with your email provider never marks anyone unsent. The send waits where it is, and carries on from there once the problem clears. "No one has been marked unsent because of this" means this problem: readers whose own email failed earlier stay unsent.
|
|
6291
6795
|
|
|
6292
|
-
- **"The provider is refusing the account".** Your provider won't take mail from your account, and its own words follow, with the fix
|
|
6796
|
+
- **"The provider is refusing the account".** Your provider won't take mail from your account, and its own words follow, with the fix and who makes it. The fix is almost always in your Amazon SES account or Kestrel's deployment, made by whoever runs it. [What SES refuses, and the fix](#what-ses-refuses-and-the-fix), below, lists each one. Any other post that comes due meanwhile waits with it, without asking SES, and you're told once. While a quota frees up a few at a time, or while the SES sandbox takes only your verified addresses, SES takes some readers on each retry and refuses the rest: those it takes are sent, and the line stays, with the time it began, until SES takes everyone it's asked to.
|
|
6797
|
+
- **"The provider is refusing this send".** SES refuses this post's email itself, or a batch of it as invalid, rather than your account. Its words follow. Other posts go out as usual. If SES's words name a reader's address, remove that subscriber: the send skips them and carries on at its next retry. If SES refuses the email itself, the email can't change once its send has started, so whoever runs the deployment asks AWS Support why, quoting SES's words.
|
|
6293
6798
|
- **"No email provider is connected".** The send is held until one is connected ([Connect Amazon SES](../get-started/04-ses.md)), and goes out once it is.
|
|
6294
|
-
- **"The provider is unavailable".** An outage or a rate limit on the provider's side. The send retries
|
|
6295
|
-
- **"
|
|
6799
|
+
- **"The provider is unavailable".** An outage or a rate limit on the provider's side. The send reads **Backing off** and says when it next retries, spacing its retries out up to an hour apart while the outage lasts, so there's nothing to do. Any other post that comes due meanwhile waits with it.
|
|
6800
|
+
- **"The provider keeps refusing for its sending rate".** An amber line. SES has refused the first request of several retries in a row for its sending rate, which a short burst never does, so Kestrel is set to send faster than your SES account allows. Whoever runs the deployment sets \`SES_MAX_SEND_RATE\` to the **Maximum send rate** on the SES console's **Account dashboard** (1 a second in the sandbox), and deploys, as [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy) describes. The send retries on its own meanwhile.
|
|
6801
|
+
- **"Nothing has gone out for \u2026".** A send has handed off no one for half an hour, with no retry or provider problem to explain it. The part of Kestrel that sends has stopped or keeps failing on this send, or another send going out at the same time is taking all of its turn. A large send going out slowly never shows this. If another send is going out, this one picks up once it finishes. Read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes, and check the Worker's trigger, as [\`triggers.crons\`](../reference/01-configuration.md#triggerscrons) describes. The send carries on from where it stopped once it runs again, mailing no one twice.
|
|
6296
6802
|
- **"The fire time passed \u2026 and the send has not started".** The Cloudflare Cron Trigger that starts sends on time isn't running. The send goes out as soon as it runs again. Check the Worker's trigger, as [\`triggers.crons\`](../reference/01-configuration.md#triggerscrons) describes.
|
|
6297
6803
|
- **"The provider never answered for \u2026".** Rarely, Kestrel can't tell whether your provider took some emails, and sending again could mail those readers twice. **Resolve\u2026** on the send's page asks you to decide. **Assume not sent** is the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it.
|
|
6298
6804
|
|
|
6299
6805
|
## Before a send
|
|
6300
6806
|
|
|
6807
|
+
- **A test says "The provider refused it".** Your provider didn't take the test, and its own words follow, with the fix where Kestrel knows it. Sending again gets the same answer until that's fixed. While Amazon SES is in its sandbox, it sends only to addresses verified in SES, so verify the address you test with in the SES console, or request production access, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Words about the account, such as a key or the sending domain, point at the same fix as [a send the provider refuses](06-troubleshooting.md#a-send-is-waiting-or-held).
|
|
6808
|
+
- **A test says "The provider didn't answer, so the test may have arrived".** Kestrel can't tell whether your provider took it. Check the inbox before you send again, since a second test may arrive twice.
|
|
6301
6809
|
- **"The template or publication details changed this email's look after your last test".** Send a new test of the post, since your last one no longer shows what goes out.
|
|
6302
6810
|
- **The daily database writes warning.** On Cloudflare's free Workers plan, a large send may use up the day's writes, and then the app stops working until midnight UTC. [Bring your existing list](01-your-list.md#limits) explains the limit, and when to upgrade to Workers Paid.
|
|
6303
6811
|
|
|
@@ -6312,9 +6820,33 @@ A send's page becomes its permanent record once it finishes. Its counts cover ev
|
|
|
6312
6820
|
| **Complained** | The reader marked the email as spam. |
|
|
6313
6821
|
| **Unsent** | Kestrel couldn't hand the email to your provider. |
|
|
6314
6822
|
|
|
6315
|
-
Delivered always means confirmed, not just accepted by your provider. Confirmations can lag by days, so counts keep rising after a send finishes. Some emails are never confirmed at all, so a send can read **Settling** for good, which is normal.
|
|
6823
|
+
Delivered always means confirmed, not just accepted by your provider. Confirmations can lag by days, so counts keep rising after a send finishes. Some emails are never confirmed at all, so a send can read **Settling** for good, which is normal, as long as some receipts have come.
|
|
6824
|
+
|
|
6825
|
+
**"No delivery receipt has arrived for any of \u2026".** An hour after a send went out, Kestrel has heard back about none of its readers. Your provider's reports probably aren't reaching Kestrel, so its bounces and complaints suppress no one, and your next post mails those addresses again. Look for \`webhook.refused\` errors in the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes. It names the SNS topic that reached Kestrel and the one it expects. Then check the subscription in [Connect Amazon SES](../get-started/04-ses.md#9-subscribe-the-app-to-the-topic). The line goes as soon as a receipt arrives.
|
|
6826
|
+
|
|
6827
|
+
**Unsent recipients** are still on your list, and get your next post. A recipient is unsent when their own email kept failing, or their address isn't one SES accepts, with the provider's words on the row, or when you chose **Assume not sent**.
|
|
6828
|
+
|
|
6829
|
+
### What SES refuses, and the fix
|
|
6316
6830
|
|
|
6317
|
-
|
|
6831
|
+
When SES refuses your account or a send, the red line carries SES's words and the fix. Almost every fix is made by whoever runs the deployment, in the SES console or the deployment's settings.
|
|
6832
|
+
|
|
6833
|
+
| SES says | What it means | The fix |
|
|
6834
|
+
| --- | --- | --- |
|
|
6835
|
+
| Email address is not verified, naming a reader | The SES account is still in the sandbox, which mails only verified addresses. | Request production access on the SES console's **Account dashboard**, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Signups fail until then, and you're emailed about it once a day. |
|
|
6836
|
+
| Email address is not verified, naming your From address or its domain | The sending identity isn't verified in this SES region. | Verify the From address or its domain in SES, in the region the deployment sends from. |
|
|
6837
|
+
| Mail FROM domain is not verified | SES can't read the custom MAIL FROM domain's MX record. | Check that record in DNS, or set the identity's **Behavior on MX failure** to use the default MAIL FROM domain. |
|
|
6838
|
+
| Daily message quota exceeded | The 24-hour sending quota is spent. It frees up on its own. | In the sandbox (200 a day), request production access. Otherwise, ask AWS for a higher quota, as [As your list grows](../guides/09-as-your-list-grows.md) describes. |
|
|
6839
|
+
| Sending paused (account) | AWS paused the account under review, or sending was turned off by hand. | Follow AWS's email to the AWS account's address, or turn sending back on. |
|
|
6840
|
+
| Sending paused (configuration set) | Sending is turned off on the configuration set, by hand or by an alarm. | Turn sending back on for that set. |
|
|
6841
|
+
| Account suspended | AWS shut down the account's sending. | Only AWS can lift it: open a case with AWS Support. |
|
|
6842
|
+
| An access key, signature, or permission error | The deployment's SES key is wrong, expired, or lacks permission. | Replace the key, or give it \`ses:SendEmail\` and \`ses:SendBulkEmail\`. |
|
|
6843
|
+
| A subscription is needed (OptInRequired) | The AWS account isn't signed up for SES. | Enable SES for the account, or settle its activation or billing with AWS. |
|
|
6844
|
+
| Configuration set does not exist | \`SES_CONFIGURATION_SET\` names a set that isn't in this region. | Fix the name, or create the set. |
|
|
6845
|
+
| Sending pool not found | The configuration set uses a dedicated IP pool that doesn't exist. | Pick an existing pool in the set's sending options, or remove it. |
|
|
6846
|
+
| The message was rejected | SES refuses this email itself. | Ask AWS Support why. Other posts go out as usual. |
|
|
6847
|
+
| The request is invalid | SES refused a batch of this send. | If SES's words name a reader's address, remove that subscriber. |
|
|
6848
|
+
|
|
6849
|
+
A server error from SES may come after SES already took the emails, so Kestrel never sends that batch again: its readers wait for **Resolve\u2026**, as for "The provider never answered for \u2026".
|
|
6318
6850
|
|
|
6319
6851
|
## Bounces and complaints
|
|
6320
6852
|
|
|
@@ -6346,8 +6878,9 @@ Kestrel refuses a file it can't read with certainty, and says what's wrong and w
|
|
|
6346
6878
|
With an address set, Kestrel emails you:
|
|
6347
6879
|
|
|
6348
6880
|
- when a send finishes, with how many emails were accepted, unsent, and skipped
|
|
6349
|
-
- when your provider refuses your account, or no provider is connected
|
|
6350
|
-
- when
|
|
6881
|
+
- when your provider refuses your account or a send, or no provider is connected
|
|
6882
|
+
- when the SES sandbox refuses a signup's confirmation, at most once a day
|
|
6883
|
+
- when a send stops going out with nothing to explain it, waits for you to resolve it, or misses its time
|
|
6351
6884
|
- when Claude imports a list for you
|
|
6352
6885
|
|
|
6353
6886
|
Each links to the page it's about, and is sent once while the problem lasts. A bounce spike shows on the dashboard but isn't emailed. **Send a test notification** checks that one arrives.
|
|
@@ -6356,7 +6889,7 @@ By default, notifications go through your newsletter's own email provider. So wh
|
|
|
6356
6889
|
`;
|
|
6357
6890
|
|
|
6358
6891
|
// docs/publishing/07-working-with-claude.md
|
|
6359
|
-
var working_with_claude_default = "# Working with Claude\n\nAsk Claude in plain words, the way you'd ask a colleague. It works through the same API as the dashboard, so it can do what you can do there, and nothing more. Everything it changes shows in the dashboard as Claude's.\n\nTo set Claude up, follow [Connect Claude to the API](../guides/01-connect-claude.md). Claude reads these same pages from your Kestrel, so it explains things in the words you see here.\n\n## What to ask Claude\n\n- **Draft or proofread a post.** Give it notes, an outline, or a draft. It saves a new version you can open in the dashboard. If you edited the post meanwhile, its save is refused, and it works from your version or asks you.\n- **Test and schedule a post.** Name the post and when it should go out. Claude schedules it
|
|
6892
|
+
var working_with_claude_default = "# Working with Claude\n\nAsk Claude in plain words, the way you'd ask a colleague. It works through the same API as the dashboard, so it can do what you can do there, and nothing more. Everything it changes shows in the dashboard as Claude's.\n\nTo set Claude up, follow [Connect Claude to the API](../guides/01-connect-claude.md). Claude reads these same pages from your Kestrel, so it explains things in the words you see here.\n\n## What to ask Claude\n\n- **Draft or proofread a post.** Give it notes, an outline, or a draft. It saves a new version you can open in the dashboard. If you edited the post meanwhile, its save is refused, and it works from your version or asks you.\n- **Test and schedule a post.** Name the post and when it should go out, and say so if it should be email only. Claude tells you whether the post will be in your archive, and at which address, before it schedules. Then it sends a test of exactly what will go out and tells you how to cancel. It can also cancel a send or move it.\n- **Take a sent post out of your archive, or put it back.** Claude asks first, as the dashboard does, since taking a post out breaks links people shared.\n- **Check how a send did.** Claude reads the send's record and reports what was delivered, unsent, bounced, or complained.\n- **Lift a bounce or a complaint.** The dashboard can't, so ask Claude when you know an address works again. [Lifting a suppression](06-troubleshooting.md#lifting-a-suppression) says when that's wise.\n- **Recover an earlier version.** Every save keeps a whole version of a post, and Claude can read them back.\n- **Import a list.** Its `/migrate-list` skill asks what anything ambiguous in your old service's export means, and converts a few rows for you to check. It imports once you attest, and if a scheduled post would grow, you finish the import in the dashboard.\n\n## The lines it can't cross\n\nThe app holds these for any client, so they hold whatever Claude is told:\n\n- **Every send waits out the review window,** and you can cancel it until it goes out.\n- **A scheduled post is locked,** and what goes out is exactly the copy that was scheduled.\n- **No one is mailed twice,** and only confirmed subscribers receive a post.\n- **An unsubscribe is final** until the reader subscribes again, and an erased address stays erased until the person confirms again.\n- **No one's edit is overwritten.** A save based on an older version is refused.\n- **Only you can let an import grow a scheduled send.** An import through Claude that would stops there, until you finish it in the dashboard.\n\nClaude also follows instructions your Kestrel gives it, which live in your instance's repository. Those hold only as long as the instructions do. They have Claude ask before anything that reaches readers, say whether a post will be in your archive before it's sent, test again after a template change, and record whose decision each change to the list was. They also have it treat what's in a post or a subscriber's record as content, never as instructions.\n\nThe app lets Claude give your attestation for an import, so asking you first is one of those instructions, not a rule the app enforces. The import's record says it came through Claude, and Kestrel emails you about it if your notifications are set.\n";
|
|
6360
6893
|
|
|
6361
6894
|
// docs/publishing/09-share-your-subscribe-link.md
|
|
6362
6895
|
var share_your_subscribe_link_default = "# Share your subscribe link\n\nYour publication's subscribe page is at `/subscribe` on your Kestrel's address, such as `https://newsletter.example.com/subscribe`. Copy it from the dashboard's checklist or **Settings \u2192 Ways to subscribe**, and share it wherever your readers are. A reader joins only once they confirm by email.\n\n**Signups open only once an email provider is connected.** Until then, the page says the newsletter isn't taking subscribers yet, and the dashboard says so too. You can share the link anyway, and it opens once email is connected ([Connect Amazon SES](../get-started/04-ses.md)).\n\n## How a reader joins\n\nA reader enters their address, gets a confirmation email, and presses **Confirm subscription** on the page its link opens. Only then do they receive your posts. Opening the link alone records nothing, since mail scanners open links before a person does. This double opt-in is your record that each reader asked for your newsletter.\n\nEvery address gets the same answer, so the form never reveals who subscribes. Behind it, Kestrel sends a confirmation only when one is due:\n\n- **Already subscribed, bounced, complained, or blocked:** no email goes out.\n- **Waiting to confirm:** at most one confirmation every 15 minutes, however often the form is submitted. A link works for 7 days, and an expired one offers a fresh link.\n- **Unsubscribed earlier, or erased at their request:** they get a confirmation, and confirming puts them back on your list.\n\n## The confirmation email\n\nChange its subject, message, button label, and reassurance line under **Settings \u2192 Confirmation email**. Kestrel adds your name, your logo, and the confirm link itself, so no edit can leave them out. The confirmation isn't a post, so it doesn't use your email template.\n\n## Put the form on your own site\n\n**Settings \u2192 Ways to subscribe \u2192 Embed a form** gives you HTML to paste into any page you control. **Styled** looks like your subscribe page, and **Plain** takes your site's own styles. A reader who submits it confirms by email, as on the subscribe page.\n\n**Settings \u2192 Reader pages** shows every page a reader can land on, at desktop and phone widths, without walking through the steps. You can also limit how often anyone submits the form, which slows down abuse ([Rate-limit the subscribe form](../guides/05-rate-limit.md)).\n";
|
|
@@ -6365,16 +6898,16 @@ var share_your_subscribe_link_default = "# Share your subscribe link\n\nYour pub
|
|
|
6365
6898
|
var check_your_setup_default = "# Check your setup\n\nBefore your readers get anything, see it arrive yourself, and make sure a problem will reach you without your watching for it. Send yourself a test and reply to it, subscribe yourself, and set where Kestrel emails you.\n\nIf you're bringing a list of more than about 5,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import it. On the free plan, a large import and a send can use up the day's database writes. Then the app stops working until midnight UTC. [As your list grows](../guides/09-as-your-list-grows.md) walks through the upgrade.\n\n## Send yourself a test\n\nChoose **Template** in the sidebar, then **Send test email**, and send a sample post to your own inboxes. When it arrives, check:\n\n- **The From name and address,** which readers see first. They're set when Kestrel is deployed ([Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy)).\n- **Where it lands:** the inbox, Promotions, or spam. If it lands in spam, check your sending domain's records, as [Sending-domain DNS](../reference/02-sending-domain-dns.md) describes.\n- **A reply.** Reply to the test, and check that it reaches your inbox. Kestrel never sees replies, so this depends on [forwarding your From address](../get-started/04-ses.md#3-receive-replies-at-your-from-address).\n\n## Subscribe yourself\n\nOpen your subscribe page, from the link under **Settings \u2192 Ways to subscribe**, and subscribe with a personal address. Then confirm. You see what a new reader sees, and you then appear under **Subscribers** as **Confirmed**. [Share your subscribe link](09-share-your-subscribe-link.md) covers the page and the confirmation email.\n\n## Set your notifications address\n\nUnder **Settings \u2192 Notifications**, fill in **Send notifications to**, save, and choose **Send a test notification**. Kestrel then emails you when a send finishes or needs you. [Notifications](06-troubleshooting.md#notifications) lists what you're told, and why a second way to send them is worth asking for.\n\n## If email isn't connected\n\nUntil an email provider is connected, nothing can be sent, and the dashboard says so. The checklist shows **Connect Amazon SES** in place of this step, as [Connect Amazon SES](../get-started/04-ses.md) describes. **Settings \u2192 Email sender** shows your From address, sending domain, and provider, read-only.\n\n## As your list grows\n\nA growing list meets your Amazon SES daily sending quota, longer sends, and on Cloudflare's free Workers plan, a cap on daily database writes. Kestrel tells you when you reach each one, and [As your list grows](../guides/09-as-your-list-grows.md) covers raising each.\n\n";
|
|
6366
6899
|
|
|
6367
6900
|
// docs/publishing/11-email-template.md
|
|
6368
|
-
var email_template_default = "# Edit your email template\n\nYou write each post in Markdown, and the template is the HTML frame it's sent in, like a layout in a static site generator. `{{ .Post.Body }}` marks where your post goes, and the rest is plain HTML around it. The built-in template works as it comes, so changing it is optional.\n\nTo change it, choose **Template** in the sidebar, edit the HTML, and choose **Save changes**. **Start from example** offers two starting points. **Signed** ends each post with your logo, name, and tagline, and **Plain** has just your post and the footer links.\n\n## What every template needs\n\nKestrel won't save a template without `{{ .Post.Body }}` and `{{ .Email.UnsubscribeURL }}`. Every email has to carry the post and a way to leave. The two **Required** pills above the editor turn green or red as your template gains or loses them.\n\nWrap a part in `{{ if .IsEmail }}` and `{{ end }}` to keep it off each post's public page. The starting templates do this with the unsubscribe link, the View in browser link, and the mailing address.\n\n## Variables\n\nA variable is replaced with its value in each email. Type it exactly as shown, or it renders as empty.\n\n| Variable | What it holds |\n| --- | --- |\n| `{{ .Post.Body }}` | Your post, rendered from Markdown. Required. |\n| `{{ .Post.Subject }}` | The post's subject line. |\n| `{{ .Publication.Name }}` | Your publication's name. |\n| `{{ .Publication.Tagline }}` | Your tagline. |\n| `{{ .Publication.Logo }}` | Your logo as an image, or nothing when no logo is set. |\n| `{{ .Publication.LogoURL }}` | Your logo's address, or empty. |\n| `{{ .Publication.Address }}` | Your mailing address, or empty. |\n| `{{ .Email.SentTo }}` | The address this email was sent to. |\n| `{{ .Email.UnsubscribeURL }}` | This reader's one-click unsubscribe link. Required. |\n| `{{ .Email.ViewInBrowserURL }}` | The post's
|
|
6901
|
+
var email_template_default = "# Edit your email template\n\nYou write each post in Markdown, and the template is the HTML frame it's sent in, like a layout in a static site generator. `{{ .Post.Body }}` marks where your post goes, and the rest is plain HTML around it. The built-in template works as it comes, so changing it is optional.\n\nTo change it, choose **Template** in the sidebar, edit the HTML, and choose **Save changes**. **Start from example** offers two starting points. **Signed** ends each post with your logo, name, and tagline, and **Plain** has just your post and the footer links.\n\n## What every template needs\n\nKestrel won't save a template without `{{ .Post.Body }}` and `{{ .Email.UnsubscribeURL }}`. Every email has to carry the post and a way to leave. The two **Required** pills above the editor turn green or red as your template gains or loses them.\n\nWrap a part in `{{ if .IsEmail }}` and `{{ end }}` to keep it off each post's public page. The starting templates do this with the unsubscribe link, the View in browser link, and the mailing address.\n\n## Variables\n\nA variable is replaced with its value in each email. Type it exactly as shown, or it renders as empty.\n\n| Variable | What it holds |\n| --- | --- |\n| `{{ .Post.Body }}` | Your post, rendered from Markdown. Required. |\n| `{{ .Post.Subject }}` | The post's subject line. |\n| `{{ .Publication.Name }}` | Your publication's name. |\n| `{{ .Publication.Tagline }}` | Your tagline. |\n| `{{ .Publication.Logo }}` | Your logo as an image, or nothing when no logo is set. |\n| `{{ .Publication.LogoURL }}` | Your logo's address, or empty. |\n| `{{ .Publication.Address }}` | Your mailing address, or empty. |\n| `{{ .Email.SentTo }}` | The address this email was sent to. |\n| `{{ .Email.UnsubscribeURL }}` | This reader's one-click unsubscribe link. Required. |\n| `{{ .Email.ViewInBrowserURL }}` | The post's own web link, which opens its page once it's sent, in your archive or not. |\n\n## Test it\n\n**Send test email** on the **Template** page sends a sample post through your saved template, so you see it in a real inbox. With unsaved edits, the button reads **Save & send test**, since a test always uses the saved template.\n\nA change to the template re-makes the email of every scheduled post, as [Schedule and send](04-write-a-post.md#schedule-and-send) describes.\n";
|
|
6369
6902
|
|
|
6370
6903
|
// docs/publishing/README.md
|
|
6371
|
-
var README_default = "# Publishing with Kestrel\n\nThese pages get you publishing in the dashboard, and cover the decisions to make before they matter. Deploying and running Kestrel itself is covered in [Self-hosting](../README.md).\n\n## Set up your publication\n\nThe steps to set up a new publication, in the order the dashboard's setup checklist lists them.\n\n1. [Name your publication](03-name-your-publication.md)\n1. [Check your setup](10-check-your-setup.md)\n1. [Bring your existing list](01-your-list.md)\n1. [Write and send a post](04-write-a-post.md)\n1. [Share your subscribe link](09-share-your-subscribe-link.md)\n\n## Go further\n\nThings you choose to do once you're running.\n\n- [Edit your email template](11-email-template.md)\n- [Working with Claude](07-working-with-claude.md)\n\n## Rules and troubleshooting\n\nWhat the law asks of every send, and what to do when something goes wrong.\n\n- [Sending rules](02-sending-rules.md)\n- [Troubleshooting](06-troubleshooting.md)\n";
|
|
6904
|
+
var README_default = "# Publishing with Kestrel\n\nThese pages get you publishing in the dashboard, and cover the decisions to make before they matter. Deploying and running Kestrel itself is covered in [Self-hosting](../README.md).\n\n## Set up your publication\n\nThe steps to set up a new publication, in the order the dashboard's setup checklist lists them. Once you've started a post, they stay on the dashboard as **Finish setting up** until you publish your first post, one that goes to your subscribers and onto your site. That's the fourth step.\n\n1. [Name your publication](03-name-your-publication.md)\n1. [Check your setup](10-check-your-setup.md)\n1. [Bring your existing list](01-your-list.md)\n1. [Write and send a post](04-write-a-post.md)\n1. [Share your subscribe link](09-share-your-subscribe-link.md)\n\n## Go further\n\nThings you choose to do once you're running.\n\n- [Edit your email template](11-email-template.md)\n- [Working with Claude](07-working-with-claude.md)\n\n## Rules and troubleshooting\n\nWhat the law asks of every send, and what to do when something goes wrong.\n\n- [Sending rules](02-sending-rules.md)\n- [Troubleshooting](06-troubleshooting.md)\n";
|
|
6372
6905
|
|
|
6373
6906
|
// docs/README.md
|
|
6374
6907
|
var README_default2 = "# Self-hosting Kestrel\n\nThese pages cover deploying and running your own Kestrel, from an empty repository to a live newsletter on your Cloudflare account. Writing and sending the newsletter is covered in [Publishing](publishing/README.md). These pages call whoever writes and sends it the publisher, which may be you.\n\n## Get started\n\nFollow these steps, from an empty repository to a live newsletter. Each one ends with a check, so you know it worked before you move on.\n\n1. [Deploy the app](get-started/02-deploy.md)\n1. [Lock the dashboard with Access](get-started/03-access.md)\n1. [Connect Amazon SES](get-started/04-ses.md)\n1. [Verify it works](get-started/05-verify.md)\n1. [Go live](get-started/06-go-live.md)\n\n## Guides\n\nEach one stands on its own. Pick the ones you need.\n\n- [Connect Claude to the API](guides/01-connect-claude.md)\n- [Notifications through Cloudflare's email](guides/03-notifications.md)\n- [Rate-limit the subscribe form](guides/05-rate-limit.md)\n- [As your list grows](guides/09-as-your-list-grows.md)\n- [Upgrade to a new release](guides/07-upgrade.md)\n\n## Reference\n\nThe detail behind the steps, for when you need it.\n\n- [Configuration](reference/01-configuration.md)\n- [Sending-domain DNS: SPF, DKIM, and DMARC](reference/02-sending-domain-dns.md)\n";
|
|
6375
6908
|
|
|
6376
6909
|
// docs/reference/01-configuration.md
|
|
6377
|
-
var configuration_default = "# Configuration\n\nEvery deploy-time setting: what it does, its default, and what the app refuses. Each environment's settings live in its block under `env` in `wrangler.jsonc`, and its secrets in Cloudflare.\n\n- **Vars** are public values in the block's `vars`, safe to commit. A change takes effect when you deploy.\n- **Secrets** are stored with `npx wrangler secret put NAME --env production`, and never committed. A secret takes effect as soon as you store it, with no redeploy.\n- **Bindings and triggers** connect the Worker to its database, its image bucket, and its schedule.\n\nIn local development, `npm run dev` reads the top level of `wrangler.jsonc` and `.dev.vars`, which you copy from `.dev.vars.example`. Restart it after you change either.\n\nNone of these can be read or changed from inside the app, by the editor or by Claude. Settings in the editor shows the public ones, read-only.\n\n## Vars\n\n| Var | What it is | Default |\n| --- | --- | --- |\n| `PROVIDER` | the email provider: `ses`, or `fake` for local development | none: no provider connected |\n| `APP_ORIGIN` | the origin the app is served from: scheme and host, no path | required |\n| `ARCHIVE_BASE_PATH` | the path the archive lives under | `/archive` |\n| `FROM_ADDRESS` | the `From:` of every email | required with a real provider |\n| `SENDING_DOMAIN` | the sending domain, shown in Settings | none |\n| `AWS_REGION` | the SES region | `us-east-1` in the template, required with SES |\n| `SES_MAX_SEND_RATE` | your SES account's maximum send rate, in messages a second | `14` |\n| `SUBREQUEST_BUDGET` | how many subrequests one minute's send work may use | `50` |\n| `MIN_LEAD_SECONDS` | the least time every send stays cancelable before it goes out | `300` |\n| `ARCHIVE_ORIGIN` | the origin archive links use, to put the archive on your website | `APP_ORIGIN` |\n| `MEDIA_PUBLIC_BASE` | the base URL images are served from | `APP_ORIGIN` + `/media` |\n| `NOTIFY_FROM` | the sender of notifications through Cloudflare's email | `Kestrel <kestrel@` + the app's hostname + `>` |\n| `ALLOW_FAKE_PROVIDER` | `true` lets `fake` run on a real hostname, for a demo that mails no one on purpose | unset |\n\n### `PROVIDER`\n\nUntil it's set, or while it's empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, `\"PROVIDER\": \"\"`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.\n\n`fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A hosted demo that runs `fake` on a real hostname on purpose sets `ALLOW_FAKE_PROVIDER` to `true`, which no other deployment needs, and which no other provider reads.\n\n### `FROM_ADDRESS` and `SENDING_DOMAIN`\n\n`FROM_ADDRESS` is the only address mail is sent from, and the identity your provider authenticates. `SENDING_DOMAIN` is for display: Settings shows it beside the From address. The app never checks that the two match, so set `SENDING_DOMAIN` to the part of `FROM_ADDRESS` after the `@`.\n\nNeither one is the publication's name, tagline, or logo. You set those in the editor, under **Settings \u2192 Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, the emails and reader pages show no name at all.\n\n### `ARCHIVE_BASE_PATH`\n\nEvery post sent carries its archive link, `ARCHIVE_BASE_PATH` followed by the post's slug, and those links are permanent. Changing the prefix later breaks every link already mailed, so pick it before the first send. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\nA missing leading slash is added, and a trailing slash is dropped.\n\n### `SES_MAX_SEND_RATE`\n\nThe app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. That's `1` in the sandbox, and whatever AWS grants after that. The default, 14, is a common starting rate, not your account's.\n\nA rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.\n\n### `SUBREQUEST_BUDGET`\n\nCloudflare caps how many subrequests one run of a Worker may make. Database queries and outbound requests both count. It also caps database queries alone: 50 a run on Workers Free, and 1,000 on Workers Paid. Each minute's send work is one run, so a large send goes out over several minutes, each stopping short of both caps.\n\nThis setting is the subrequest cap. The app keeps database queries under theirs on its own.\n\n- **The default, 50,** is the Workers Free limit, and safe on any plan. A minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.\n- **On Workers Paid,** which allows 10,000, set `\"SUBREQUEST_BUDGET\": \"10000\"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.\n- **If you raised the Worker's own limit** with `limits.subrequests`, you may set this to match.\n\nIt's also how the app tells Workers Free from Workers Paid. At the default, the app runs on Free's limits:\n\n- One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more. Above it, up to 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)).\n- An export reads 1,000 rows a page. Above it, 10,000.\n- The app warns before a send to more than 5,000 recipients (fewer on a day with imports), and on an import plan that, with the day's earlier imports, would use most of a day's writes. Either may use up D1's daily allowance of 100,000 rows written, which stops the app until midnight UTC ([As your list grows](../guides/09-as-your-list-grows.md)). Neither warning stops you. Above the default, neither appears.\n\nNever set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n### `MIN_LEAD_SECONDS`\n\nEvery send stays visible and cancelable for at least this long before it goes out, whether it's scheduled, sent now, or moved. It's the window to catch a mistake, including one in a send Claude prepared.\n\nThe floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead a post can be scheduled. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.\n\n### `ARCHIVE_ORIGIN` and `MEDIA_PUBLIC_BASE`\n\nLeave them unset to keep everything on the app's own hostname.\n\n`ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname.\n\n`MEDIA_PUBLIC_BASE` serves images from another base URL, such as a custom domain on the image bucket. The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.\n\n### `NOTIFY_FROM`\n\nUsed only with the `NOTIFY` binding. It must be an address on a domain set up for Cloudflare's email. Without the binding, notifications come from `FROM_ADDRESS`, through your email provider.\n\n## Secrets\n\n| Secret | What it is | Needed for |\n| --- | --- | --- |\n| `ACCESS_TEAM_DOMAIN` | your Zero Trust team domain, such as `your-team.cloudflareaccess.com`; an `https://` or trailing slash is dropped | every deployment |\n| `ACCESS_AUD` | the Access application's audience tag | every deployment |\n| `ACCESS_ALLOWED_EMAILS` | a comma-separated list of the people allowed to sign in | optional |\n| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | the IAM user's access key | SES |\n| `SNS_TOPIC_ARN` | the SNS topic the SES webhook accepts; it refuses every other topic | SES |\n| `SES_CONFIGURATION_SET` | the configuration set named on every email, so SES reports bounces and complaints; a name SES can't find in the region holds every list send until it's fixed | SES, recommended |\n\n`ACCESS_ALLOWED_EMAILS` applies to people only. A service token, such as the one Claude uses, carries no email, so the Access policy alone decides whether it gets in.\n\nNever set `DEV_AUTH_SECRET` on a deployed environment. It's local development's stand-in for Access, and a deployed instance ignores it anyway. The app honors it only when the environment looks like local development: `PROVIDER` is `fake`, Access isn't set up, and `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A real provider, Access, or a real hostname each turns it off, and Access becomes the only way in.\n\n## Bindings and triggers\n\nWrangler doesn't carry bindings or vars over from the top level of `wrangler.jsonc` to an environment. Each environment's block declares its own.\n\n| Setting | What it is | Default |\n| --- | --- | --- |\n| `name` | the Worker's name, such as `kestrel-production` | required |\n| `routes` | the app's hostname, attached on deploy, and any route on your website | required |\n| `workers_dev`, `preview_urls` | the Worker's `workers.dev` and preview addresses | `false` in the template |\n| `triggers.crons` | the once-a-minute schedule, `* * * * *` | required |\n| `observability` | Workers Logs, where the app's log lines land | on |\n| `d1_databases`, binding `DB` | the database: posts, subscribers, sends, and settings | required |\n| `r2_buckets`, binding `MEDIA` | the bucket for images and your logo | required |\n| `send_email`, binding `NOTIFY` | Cloudflare's email, for notifications | none |\n\n### `routes`, `workers_dev`, and `preview_urls`\n\nThe template turns off the `workers.dev` addresses, so the app answers only on your hostname. That's where your Access application and any rate-limiting rule apply. Each deploy replaces the Worker's routes with the ones in the file, so add a route here, never in the dashboard.\n\n### `triggers.crons`\n\nThe schedule runs every minute. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.\n\n### `observability`\n\nEvery invocation is logged, with no sampling. Sampling could drop the one minute that reports a stuck or missed send.\n\n### `NOTIFY`\n\nWhen declared, notifications go through Cloudflare's email instead of your provider, so they still arrive when your provider refuses your account. Local development ignores it, and sends notifications to a stand-in that delivers nothing.\n\n## What the app refuses\n\nThe app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly:\n\n- `PROVIDER`, when set, must be exactly `ses`, `fake`, or `none`. A misspelling, or `SES`, is refused, never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).\n- `fake` runs only when `APP_ORIGIN` is on `localhost` or `127.0.0.1`, unless `ALLOW_FAKE_PROVIDER` is `true`. On a real hostname it would open signups whose confirmations go nowhere, and record every send as delivered. With `fake`, `ALLOW_FAKE_PROVIDER`, when set, must be `true`; every other provider ignores it.\n- `APP_ORIGIN` must be a plain `http` or `https` origin, with no path. So must `ARCHIVE_ORIGIN`, when set. `MEDIA_PUBLIC_BASE`, when set, must be a plain `http` or `https` URL, and may have a path. A trailing slash is dropped from each.\n- A real provider needs `FROM_ADDRESS`, and every secret the table marks for it. SES also needs `AWS_REGION`. `FROM_ADDRESS` must then be an email address, optionally with a display name in front, like `Name <address>`.\n- None of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com` or a name under it, the template's placeholder. That holds with no provider connected too, since every link the app builds would name it. Only `fake` allows it.\n- `SUBREQUEST_BUDGET`, `MIN_LEAD_SECONDS`, and `SES_MAX_SEND_RATE` must be whole numbers above zero. `MIN_LEAD_SECONDS` must also be within its bounds. Any other value is refused, never adjusted. The one exception is a `SUBREQUEST_BUDGET` below 30, which is raised to 30.\n- In local development, `SIMULATE_SENDS` must be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn't matter. A deployed instance ignores it.\n- `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are set together or not at all. With only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one. `ACCESS_TEAM_DOMAIN` must be your team's hostname, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in.\n\nUntil it's fixed, no sends go out, and each place says what its reader can act on:\n\n- **The editor** shows a page naming the setting, what's wrong with it, and how to fix it, with a link to its section here. Its own Docs tab can't load until then, so the link opens the project's site.\n- **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{\"error\":\"invalid_config\",\"message\":\u2026,\"variable\":\"APP_ORIGIN\"}`.\n- **The public pages readers see** answer `503` with a page saying the newsletter is temporarily unavailable. It names no setting.\n- **The Worker's log** records it as `config.invalid`.\n";
|
|
6910
|
+
var configuration_default = "# Configuration\n\nEvery deploy-time setting: what it does, its default, and what the app refuses. Each environment's settings live in its block under `env` in `wrangler.jsonc`, and its secrets in Cloudflare.\n\n- **Vars** are public values in the block's `vars`, safe to commit. A change takes effect when you deploy.\n- **Secrets** are stored with `npx wrangler secret put NAME --env production`, and never committed. A secret takes effect as soon as you store it, with no redeploy.\n- **Bindings and triggers** connect the Worker to its database, its image bucket, and its schedule.\n\nIn local development, `npm run dev` reads the top level of `wrangler.jsonc` and `.dev.vars`, which you copy from `.dev.vars.example`. Restart it after you change either.\n\nNone of these can be read or changed from inside the app, by the editor or by Claude. Settings in the editor shows the public ones, read-only.\n\n## Vars\n\n| Var | What it is | Default |\n| --- | --- | --- |\n| `PROVIDER` | the email provider: `ses`, or `fake` for local development | none: no provider connected |\n| `APP_ORIGIN` | the origin the app is served from: scheme and host, no path | required |\n| `ARCHIVE_BASE_PATH` | the path the archive lives under | `/archive` |\n| `FROM_ADDRESS` | the `From:` of every email | required with a real provider |\n| `SENDING_DOMAIN` | the sending domain, shown in Settings | none |\n| `AWS_REGION` | the SES region | `us-east-1` in the template, required with SES |\n| `SES_MAX_SEND_RATE` | your SES account's maximum send rate, in messages a second | `14` |\n| `SUBREQUEST_BUDGET` | how many subrequests one minute's send work may use | `50` |\n| `MIN_LEAD_SECONDS` | the least time every send stays cancelable before it goes out | `300` |\n| `ARCHIVE_ORIGIN` | the origin archive links use, to put the archive on your website | `APP_ORIGIN` |\n| `MEDIA_PUBLIC_BASE` | the base URL images are served from | `APP_ORIGIN` + `/media` |\n| `NOTIFY_FROM` | the sender of notifications through Cloudflare's email | `Kestrel <kestrel@` + the app's hostname + `>` |\n| `ALLOW_FAKE_PROVIDER` | `true` lets `fake` run on a real hostname, for a demo that mails no one on purpose | unset |\n\n### `PROVIDER`\n\nUntil it's set, or while it's empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, `\"PROVIDER\": \"\"`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.\n\n`fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A hosted demo that runs `fake` on a real hostname on purpose sets `ALLOW_FAKE_PROVIDER` to `true`, which no other deployment needs, and which no other provider reads.\n\n### `FROM_ADDRESS` and `SENDING_DOMAIN`\n\n`FROM_ADDRESS` is the only address mail is sent from, and the identity your provider authenticates. `SENDING_DOMAIN` is for display: Settings shows it beside the From address. The app never checks that the two match, so set `SENDING_DOMAIN` to the part of `FROM_ADDRESS` after the `@`.\n\nNeither one is the publication's name, tagline, or logo. You set those in the editor, under **Settings \u2192 Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, the emails and reader pages show no name at all.\n\n### `ARCHIVE_BASE_PATH`\n\nA listed post's archive address is `ARCHIVE_BASE_PATH` followed by the post's slug. Emails link through each post's unlisted link, `/email/<token>` on `APP_ORIGIN`, which forwards to the archive address, so changing the prefix later breaks no email that carries one, only archive addresses people have shared. Emails sent by 1.11 or earlier carry the archive address itself, and changing the prefix breaks those. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\nA missing leading slash is added, and a trailing slash is dropped.\n\n### `SES_MAX_SEND_RATE`\n\nThe app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. That's `1` in the sandbox, and whatever AWS grants after that. The default, 14, is a common starting rate, not your account's.\n\nA rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.\n\n### `SUBREQUEST_BUDGET`\n\nCloudflare caps how many subrequests one run of a Worker may make. Database queries and outbound requests both count. It also caps database queries alone: 50 a run on Workers Free, and 1,000 on Workers Paid. Each minute's send work is one run, so a large send goes out over several minutes, each stopping short of both caps.\n\nThis setting is the subrequest cap. The app keeps database queries under theirs on its own.\n\n- **The default, 50,** is the Workers Free limit, and safe on any plan. A minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.\n- **On Workers Paid,** which allows 10,000, set `\"SUBREQUEST_BUDGET\": \"10000\"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.\n- **If you raised the Worker's own limit** with `limits.subrequests`, you may set this to match.\n\nIt's also how the app tells Workers Free from Workers Paid. At the default, the app runs on Free's limits:\n\n- One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more. Above it, up to 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)).\n- An export reads 1,000 rows a page. Above it, 10,000.\n- The app warns before a send to more than 5,000 recipients (fewer on a day with imports), and on an import plan that, with the day's earlier imports, would use most of a day's writes. Either may use up D1's daily allowance of 100,000 rows written, which stops the app until midnight UTC ([As your list grows](../guides/09-as-your-list-grows.md)). Neither warning stops you. Above the default, neither appears.\n\nNever set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n### `MIN_LEAD_SECONDS`\n\nEvery send stays visible and cancelable for at least this long before it goes out, whether it's scheduled, sent now, or moved. It's the window to catch a mistake, including one in a send Claude prepared.\n\nThe floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead a post can be scheduled. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.\n\n### `ARCHIVE_ORIGIN` and `MEDIA_PUBLIC_BASE`\n\nLeave them unset to keep everything on the app's own hostname.\n\n`ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname.\n\n`MEDIA_PUBLIC_BASE` serves images from another base URL, such as a custom domain on the image bucket. The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.\n\n### `NOTIFY_FROM`\n\nUsed only with the `NOTIFY` binding. It must be an address on a domain set up for Cloudflare's email. Without the binding, notifications come from `FROM_ADDRESS`, through your email provider.\n\n## Secrets\n\n| Secret | What it is | Needed for |\n| --- | --- | --- |\n| `ACCESS_TEAM_DOMAIN` | your Zero Trust team domain, such as `your-team.cloudflareaccess.com`; an `https://` or trailing slash is dropped | every deployment |\n| `ACCESS_AUD` | the Access application's audience tag | every deployment |\n| `ACCESS_ALLOWED_EMAILS` | a comma-separated list of the people allowed to sign in | optional |\n| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | the IAM user's access key | SES |\n| `SNS_TOPIC_ARN` | the SNS topic the SES webhook accepts; it refuses every other topic | SES |\n| `SES_CONFIGURATION_SET` | the configuration set named on every email, so SES reports bounces and complaints; a name SES can't find in the region holds every list send until it's fixed | SES, recommended |\n\n`ACCESS_ALLOWED_EMAILS` applies to people only. A service token, such as the one Claude uses, carries no email, so the Access policy alone decides whether it gets in.\n\nNever set `DEV_AUTH_SECRET` on a deployed environment. It's local development's stand-in for Access, and a deployed instance ignores it anyway. The app honors it only when the environment looks like local development: `PROVIDER` is `fake`, Access isn't set up, and `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A real provider, Access, or a real hostname each turns it off, and Access becomes the only way in.\n\n## Bindings and triggers\n\nWrangler doesn't carry bindings or vars over from the top level of `wrangler.jsonc` to an environment. Each environment's block declares its own.\n\n| Setting | What it is | Default |\n| --- | --- | --- |\n| `name` | the Worker's name, such as `kestrel-production` | required |\n| `routes` | the app's hostname, attached on deploy, and any route on your website | required |\n| `workers_dev`, `preview_urls` | the Worker's `workers.dev` and preview addresses | `false` in the template |\n| `triggers.crons` | the once-a-minute schedule, `* * * * *` | required |\n| `observability` | Workers Logs, where the app's log lines land | on |\n| `d1_databases`, binding `DB` | the database: posts, subscribers, sends, and settings | required |\n| `r2_buckets`, binding `MEDIA` | the bucket for images and your logo | required |\n| `send_email`, binding `NOTIFY` | Cloudflare's email, for notifications | none |\n\n### `routes`, `workers_dev`, and `preview_urls`\n\nThe template turns off the `workers.dev` addresses, so the app answers only on your hostname. That's where your Access application and any rate-limiting rule apply. Each deploy replaces the Worker's routes with the ones in the file, so add a route here, never in the dashboard.\n\n### `triggers.crons`\n\nThe schedule runs every minute. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.\n\n### `observability`\n\nEvery invocation is logged, with no sampling. Sampling could drop the one minute that reports a stuck or missed send.\n\n### `NOTIFY`\n\nWhen declared, notifications go through Cloudflare's email instead of your provider, so they still arrive when your provider refuses your account. Local development ignores it, and sends notifications to a stand-in that delivers nothing.\n\n## What the app refuses\n\nThe app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly:\n\n- `PROVIDER`, when set, must be exactly `ses`, `fake`, or `none`. A misspelling, or `SES`, is refused, never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).\n- `fake` runs only when `APP_ORIGIN` is on `localhost` or `127.0.0.1`, unless `ALLOW_FAKE_PROVIDER` is `true`. On a real hostname it would open signups whose confirmations go nowhere, and record every send as delivered. With `fake`, `ALLOW_FAKE_PROVIDER`, when set, must be `true`; every other provider ignores it.\n- `APP_ORIGIN` must be a plain `http` or `https` origin, with no path. So must `ARCHIVE_ORIGIN`, when set. `MEDIA_PUBLIC_BASE`, when set, must be a plain `http` or `https` URL, and may have a path. A trailing slash is dropped from each.\n- A real provider needs `FROM_ADDRESS`, and every secret the table marks for it. SES also needs `AWS_REGION`. `FROM_ADDRESS` must then be an email address, optionally with a display name in front, like `Name <address>`.\n- None of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com` or a name under it, the template's placeholder. That holds with no provider connected too, since every link the app builds would name it. Only `fake` allows it.\n- `SUBREQUEST_BUDGET`, `MIN_LEAD_SECONDS`, and `SES_MAX_SEND_RATE` must be whole numbers above zero. `MIN_LEAD_SECONDS` must also be within its bounds. Any other value is refused, never adjusted. The one exception is a `SUBREQUEST_BUDGET` below 30, which is raised to 30.\n- In local development, `SIMULATE_SENDS` must be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn't matter. A deployed instance ignores it.\n- `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are set together or not at all. With only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one. `ACCESS_TEAM_DOMAIN` must be your team's hostname, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in.\n\nUntil it's fixed, no sends go out, and each place says what its reader can act on:\n\n- **The editor** shows a page naming the setting, what's wrong with it, and how to fix it, with a link to its section here. Its own Docs tab can't load until then, so the link opens the project's site.\n- **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{\"error\":\"invalid_config\",\"message\":\u2026,\"variable\":\"APP_ORIGIN\"}`.\n- **The public pages readers see** answer `503` with a page saying the newsletter is temporarily unavailable. It names no setting.\n- **The Worker's log** records it as `config.invalid`.\n";
|
|
6378
6911
|
|
|
6379
6912
|
// docs/reference/02-sending-domain-dns.md
|
|
6380
6913
|
var sending_domain_dns_default = "# Sending-domain DNS: SPF, DKIM, and DMARC\n\nInboxes decide whether to trust your mail by checking records in the DNS of the domain it comes from. This page explains the three checks they run: SPF, DKIM, and DMARC. It covers the records [Connect Amazon SES](../get-started/04-ses.md) has you add, what each proves, how to check them, and when to tighten DMARC.\n\n## Why all three\n\nGmail requires every sender to pass SPF or DKIM. A sender of more than 5,000 messages a day to Gmail must have all three:\n\n- SPF and DKIM, both passing.\n- A DMARC record, with a policy of at least `p=none`.\n- Alignment: the domain in the `From:` header matches the SPF domain or the DKIM domain.\n\nGmail also requires one-click unsubscribe for such senders. Kestrel adds the `List-Unsubscribe` and `List-Unsubscribe-Post` headers to every email, so that part needs nothing from you. [Google's sender guidelines](https://support.google.com/a/answer/81126) list the rest.\n\nA small list may never reach 5,000 a day, but the same checks decide whether mail lands in the inbox. Make sure all three pass from the start.\n\n## Choose your sending name\n\n- **Send from a subdomain.** It's recommended to send from a subdomain such as `send.example.com`, rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn't put that at risk.\n- **Keep the app's name and the mail's name distinct.** The app and its public pages live at `newsletter.example.com`, and mail comes from `send.example.com`. Avoid `mail.` as the sending name, since mail servers read `mail.example.com` as a host that receives mail.\n\n## The records\n\nEvery record lives in your domain's DNS, `example.com`, under the sending name. In Cloudflare, a record's name leaves off your domain, so `_dmarc.send.example.com` is entered as `_dmarc.send`.\n\nSES generates the DKIM values. Copy them exactly as SES lists them, since they differ by account and region. You write the DMARC record yourself.\n\nSPF needs no record of yours on SES's default path, since it passes for a subdomain of `amazonses.com`. An SPF record, at `bounce.send`, comes only with the optional custom MAIL FROM domain.\n\nEmail Routing adds MX, SPF, and DKIM records at `send` itself, the DKIM one under the selector `cf2024-1`. They're for mail sent *to* your From address, so readers' replies reach the publisher ([Connect Amazon SES](../get-started/04-ses.md#3-receive-replies-at-your-from-address)). They don't affect your sending: inboxes check SES's mail for SPF at its return path, and for DKIM against SES's own keys.\n\n### SPF: authorize the return path\n\nSPF checks the message's return path, the address bounces go back to, not its `From:` header. DMARC passes when SPF or DKIM passes and aligns with the `From:` domain. DKIM aligns, so an aligned SPF is a second check in your favor, not a requirement.\n\nSES uses a subdomain of its own domain, `amazonses.com`, as the return path by default. SPF then passes for it but doesn't align, and DMARC passes through DKIM alone. A custom MAIL FROM domain, such as `bounce.send.example.com`, aligns SPF too. [Connect Amazon SES](../get-started/04-ses.md#4-optional-set-a-custom-mail-from-domain) sets one up, with an MX record that names your region and an SPF record:\n\n```\nbounce.send.example.com. MX 10 feedback-smtp.us-east-1.amazonses.com.\nbounce.send.example.com. TXT \"v=spf1 include:amazonses.com ~all\"\n```\n\nA name holds one SPF record. If one already exists there, add `include:amazonses.com` to it rather than adding a second.\n\n### DKIM: let the provider sign\n\nDKIM signs each message with a key your DNS publishes. An inbox checks the signature to tell the message came from you, unchanged.\n\nSES lists three `CNAME` records, each at `TOKEN._domainkey.send.example.com`. Their targets depend on your region and account, so copy them as SES lists them.\n\nIn Cloudflare, set every `CNAME` for DKIM to **DNS only**, not proxied. SES reports the domain as verified only once the records resolve.\n\n### DMARC: publish a policy and collect reports\n\nDMARC tells an inbox what to do with mail that fails both SPF and DKIM, and asks it to send you reports. It's a `TXT` record at `_dmarc.send.example.com`:\n\n```\n_dmarc.send.example.com. TXT \"v=DMARC1; p=none; rua=mailto:dmarc@example.com\"\n```\n\n- **`p=none`** is monitor mode. Inboxes report on failing mail, but deliver it as usual.\n- **`rua`** is where reports go. They arrive as attachments, usually one a day from each inbox provider.\n\nLeave alignment relaxed, the default, with no `aspf=s` or `adkim=s`. With a custom MAIL FROM domain, your return path is a subdomain of your sending name, so strict SPF alignment would fail.\n\nOnce the reports show your sends passing and aligned, tighten the policy to `p=quarantine`, which sends failing mail to spam. Later, tighten it to `p=reject`, which refuses it.\n\n## Verify alignment\n\nA test email proves alignment better than any DNS lookup. Send yourself one, as in [Verify it works](../get-started/05-verify.md#2-check-that-it-passes-authentication), and read its `Authentication-Results` header. In Gmail, open the message's menu, and select **Show original**. Expect:\n\n- `dkim=pass` for `send.example.com`.\n- `dmarc=pass` for `send.example.com`.\n- `spf=pass`. With a custom MAIL FROM domain, its domain is under `send.example.com`. Without one, it's a subdomain of `amazonses.com`. That's expected, and DMARC still passes.\n";
|
|
@@ -6588,6 +7121,74 @@ function markdownResponse(body) {
|
|
|
6588
7121
|
return new Response(body, { headers: { "content-type": "text/markdown; charset=utf-8" } });
|
|
6589
7122
|
}
|
|
6590
7123
|
|
|
7124
|
+
// src/routes/post_settings.ts
|
|
7125
|
+
var SLUG_ATTEMPTS = 3;
|
|
7126
|
+
function readSettingsBody(body) {
|
|
7127
|
+
const emailOnly = optBoolean(body, "email_only");
|
|
7128
|
+
const slug = optString(body, "slug");
|
|
7129
|
+
const slugAuto = optBoolean(body, "slug_auto");
|
|
7130
|
+
if (slug !== void 0 && slugAuto === true) {
|
|
7131
|
+
throw fieldError("slug", "slug cannot be set while slug_auto is true");
|
|
7132
|
+
}
|
|
7133
|
+
return { emailOnly, slug, slugAuto };
|
|
7134
|
+
}
|
|
7135
|
+
async function resolveSettings(db, post, body) {
|
|
7136
|
+
const input = { emailOnly: body.emailOnly };
|
|
7137
|
+
if (body.slug === void 0 && body.slugAuto === void 0) {
|
|
7138
|
+
return input;
|
|
7139
|
+
}
|
|
7140
|
+
const fixed = await addressFixed(db, post);
|
|
7141
|
+
if (fixed) {
|
|
7142
|
+
const moves = body.slug !== void 0 && body.slug !== post.slug || body.slugAuto === true;
|
|
7143
|
+
if (moves) {
|
|
7144
|
+
throw slugPermanent(body.slug !== void 0 ? "slug" : "slug_auto", fixed);
|
|
7145
|
+
}
|
|
7146
|
+
return input;
|
|
7147
|
+
}
|
|
7148
|
+
const resolved = await resolveSlug(
|
|
7149
|
+
db,
|
|
7150
|
+
{ postId: post.id, slug: post.slug, slugAuto: post.slug_auto, subject: post.subject },
|
|
7151
|
+
{ slug: body.slug, slugAuto: body.slugAuto }
|
|
7152
|
+
);
|
|
7153
|
+
if ("refused" in resolved) {
|
|
7154
|
+
throw slugRefusal(resolved.refused);
|
|
7155
|
+
}
|
|
7156
|
+
input.slug = resolved;
|
|
7157
|
+
return input;
|
|
7158
|
+
}
|
|
7159
|
+
async function applySettings(db, post, body, resolved) {
|
|
7160
|
+
let input = resolved;
|
|
7161
|
+
let outcome;
|
|
7162
|
+
for (let attempt = 1; ; attempt++) {
|
|
7163
|
+
try {
|
|
7164
|
+
outcome = await setPublishing(db, post, input);
|
|
7165
|
+
break;
|
|
7166
|
+
} catch (err) {
|
|
7167
|
+
if (!isSlugCollision(err)) {
|
|
7168
|
+
throw err;
|
|
7169
|
+
}
|
|
7170
|
+
if (body.slug !== void 0 || attempt >= SLUG_ATTEMPTS) {
|
|
7171
|
+
throw slugRefusal("slug_taken");
|
|
7172
|
+
}
|
|
7173
|
+
input = await resolveSettings(db, post, body);
|
|
7174
|
+
}
|
|
7175
|
+
}
|
|
7176
|
+
if (outcome === "missing") {
|
|
7177
|
+
throw notFound("post");
|
|
7178
|
+
}
|
|
7179
|
+
if (outcome === "address_fixed") {
|
|
7180
|
+
throw slugPermanent(body.slug !== void 0 ? "slug" : "slug_auto", "sent");
|
|
7181
|
+
}
|
|
7182
|
+
return outcome === "changed";
|
|
7183
|
+
}
|
|
7184
|
+
async function addressFixed(db, post) {
|
|
7185
|
+
if (await publishedSendForPost(db, post.id)) {
|
|
7186
|
+
return "sent";
|
|
7187
|
+
}
|
|
7188
|
+
const active = await getActiveSendForPost(db, post.id);
|
|
7189
|
+
return active?.web_link === "archive" ? "linked" : null;
|
|
7190
|
+
}
|
|
7191
|
+
|
|
6591
7192
|
// src/routes/posts.ts
|
|
6592
7193
|
function author(c) {
|
|
6593
7194
|
return c.principal?.email ?? c.principal?.kind ?? null;
|
|
@@ -6616,6 +7217,11 @@ function baseRevision(c, body) {
|
|
|
6616
7217
|
}
|
|
6617
7218
|
return body.base_revision;
|
|
6618
7219
|
}
|
|
7220
|
+
var UNPUBLISHED = null;
|
|
7221
|
+
async function answered(c, post) {
|
|
7222
|
+
const published = await publicationsFor(c.env.DB, [post.id]);
|
|
7223
|
+
return withAddresses(c.config, post, published.get(post.id) ?? null);
|
|
7224
|
+
}
|
|
6619
7225
|
function revisionHeaders(post) {
|
|
6620
7226
|
return post.current_revision ? { ETag: `"${post.current_revision}"` } : void 0;
|
|
6621
7227
|
}
|
|
@@ -6639,8 +7245,15 @@ async function requireDraft(c) {
|
|
|
6639
7245
|
}
|
|
6640
7246
|
async function createPost2(c) {
|
|
6641
7247
|
const { input } = await readBody(c, { optional: true });
|
|
6642
|
-
const { post, revision } = await
|
|
6643
|
-
|
|
7248
|
+
const { post, revision } = await slugGuarded(
|
|
7249
|
+
input,
|
|
7250
|
+
() => createPost(c.env.DB, input, author(c))
|
|
7251
|
+
);
|
|
7252
|
+
const body = {
|
|
7253
|
+
post: withAddresses(c.config, post, UNPUBLISHED),
|
|
7254
|
+
subject_slug_held: await subjectSlugHeld(c.env.DB, post),
|
|
7255
|
+
revision_id: revision.id
|
|
7256
|
+
};
|
|
6644
7257
|
return json(body, 201, revisionHeaders(post));
|
|
6645
7258
|
}
|
|
6646
7259
|
var POST_STATUSES = ["draft", "scheduled", "sent"];
|
|
@@ -6669,8 +7282,12 @@ async function listPosts2(c) {
|
|
|
6669
7282
|
countPosts(c.env.DB, filter),
|
|
6670
7283
|
listPosts(c.env.DB, filter, page)
|
|
6671
7284
|
]);
|
|
7285
|
+
const published = await publicationsFor(
|
|
7286
|
+
c.env.DB,
|
|
7287
|
+
rows.map((row) => row.id)
|
|
7288
|
+
);
|
|
6672
7289
|
const body = {
|
|
6673
|
-
posts: rows,
|
|
7290
|
+
posts: rows.map((row) => withAddresses(c.config, row, published.get(row.id) ?? null)),
|
|
6674
7291
|
page: listPage(total, page),
|
|
6675
7292
|
cursor: encodeSendCursor({ seq, at: now })
|
|
6676
7293
|
};
|
|
@@ -6685,7 +7302,8 @@ async function getPost2(c) {
|
|
|
6685
7302
|
const active = post.status === "scheduled" ? await getActiveSendForPost(c.env.DB, post.id) : null;
|
|
6686
7303
|
const sent = post.status === "sent" ? await latestSentSendForPost(c.env.DB, post.id) : null;
|
|
6687
7304
|
const body = {
|
|
6688
|
-
post,
|
|
7305
|
+
post: await answered(c, post),
|
|
7306
|
+
subject_slug_held: await subjectSlugHeld(c.env.DB, post),
|
|
6689
7307
|
markdown: revision?.markdown ?? "",
|
|
6690
7308
|
author: revision?.author ?? null,
|
|
6691
7309
|
// who wrote the current revision — the freshness poll names them
|
|
@@ -6706,7 +7324,7 @@ async function updatePost2(c) {
|
|
|
6706
7324
|
error: "base_required",
|
|
6707
7325
|
message: "A save must name the revision it was based on. Read the post, then save with its current_revision as base_revision or If-Match.",
|
|
6708
7326
|
field: "base_revision",
|
|
6709
|
-
post
|
|
7327
|
+
post: withAddresses(c.config, post, UNPUBLISHED)
|
|
6710
7328
|
};
|
|
6711
7329
|
return json(required, 428, revisionHeaders(post));
|
|
6712
7330
|
}
|
|
@@ -6717,12 +7335,21 @@ async function updatePost2(c) {
|
|
|
6717
7335
|
message: "This draft changed since you loaded it. Read it again before saving.",
|
|
6718
7336
|
current_revision: post.current_revision,
|
|
6719
7337
|
updated_at: post.updated_at,
|
|
6720
|
-
author: current2?.author ?? null
|
|
7338
|
+
author: current2?.author ?? null,
|
|
7339
|
+
post: withAddresses(c.config, post, UNPUBLISHED),
|
|
7340
|
+
markdown: current2?.markdown ?? ""
|
|
6721
7341
|
};
|
|
6722
7342
|
return json(stale, 409, revisionHeaders(post));
|
|
6723
7343
|
}
|
|
6724
|
-
const { post: updated, revision } = await
|
|
6725
|
-
|
|
7344
|
+
const { post: updated, revision } = await slugGuarded(
|
|
7345
|
+
body.input,
|
|
7346
|
+
() => updatePost(c.env.DB, post, body.input, author(c))
|
|
7347
|
+
);
|
|
7348
|
+
const saved = {
|
|
7349
|
+
post: withAddresses(c.config, updated, UNPUBLISHED),
|
|
7350
|
+
subject_slug_held: await subjectSlugHeld(c.env.DB, updated),
|
|
7351
|
+
revision_id: revision.id
|
|
7352
|
+
};
|
|
6726
7353
|
return json(saved, 200, revisionHeaders(updated));
|
|
6727
7354
|
}
|
|
6728
7355
|
async function deletePost2(c) {
|
|
@@ -6771,6 +7398,47 @@ async function getRevision(c) {
|
|
|
6771
7398
|
metadata: JSON.parse(rev.metadata)
|
|
6772
7399
|
});
|
|
6773
7400
|
}
|
|
7401
|
+
var SLUG_ATTEMPTS2 = 3;
|
|
7402
|
+
async function slugGuarded(input, write) {
|
|
7403
|
+
for (let attempt = 1; ; attempt++) {
|
|
7404
|
+
try {
|
|
7405
|
+
return await write();
|
|
7406
|
+
} catch (err) {
|
|
7407
|
+
if (err instanceof SlugRefused) {
|
|
7408
|
+
throw slugRefusal(err.code);
|
|
7409
|
+
}
|
|
7410
|
+
if (isSlugCollision(err)) {
|
|
7411
|
+
if (input.slug === void 0 && attempt < SLUG_ATTEMPTS2) {
|
|
7412
|
+
continue;
|
|
7413
|
+
}
|
|
7414
|
+
throw slugRefusal("slug_taken");
|
|
7415
|
+
}
|
|
7416
|
+
throw err;
|
|
7417
|
+
}
|
|
7418
|
+
}
|
|
7419
|
+
}
|
|
7420
|
+
async function setSettings(c) {
|
|
7421
|
+
const fields = readSettingsBody(await readJsonObject(c));
|
|
7422
|
+
if (fields.emailOnly === void 0 && fields.slug === void 0 && fields.slugAuto === void 0) {
|
|
7423
|
+
throw fieldError(
|
|
7424
|
+
"email_only",
|
|
7425
|
+
"Name what to change: email_only (true for email only, false to list the post in the archive), slug, or slug_auto"
|
|
7426
|
+
);
|
|
7427
|
+
}
|
|
7428
|
+
const post = await getPost(c.env.DB, param(c, "id"));
|
|
7429
|
+
if (!post) {
|
|
7430
|
+
throw notFound("post");
|
|
7431
|
+
}
|
|
7432
|
+
const input = await resolveSettings(c.env.DB, post, fields);
|
|
7433
|
+
const changed = await applySettings(c.env.DB, post, fields, input);
|
|
7434
|
+
const now = changed ? unwrap(await getPost(c.env.DB, post.id), "post") : post;
|
|
7435
|
+
const answer = {
|
|
7436
|
+
post: await answered(c, now),
|
|
7437
|
+
subject_slug_held: await subjectSlugHeld(c.env.DB, now),
|
|
7438
|
+
changed
|
|
7439
|
+
};
|
|
7440
|
+
return json(answer);
|
|
7441
|
+
}
|
|
6774
7442
|
|
|
6775
7443
|
// src/routes/images.ts
|
|
6776
7444
|
var MAX_POST_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
@@ -7654,6 +8322,20 @@ function sesFromAddress(from) {
|
|
|
7654
8322
|
function sesCanAddress(email2) {
|
|
7655
8323
|
return ASCII_ONLY.test(email2);
|
|
7656
8324
|
}
|
|
8325
|
+
var LOCAL_PART = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/i;
|
|
8326
|
+
var DOMAIN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i;
|
|
8327
|
+
function sesAddressProblem(email2) {
|
|
8328
|
+
if (!sesCanAddress(email2)) {
|
|
8329
|
+
return "ses can't send to an address with characters outside ASCII";
|
|
8330
|
+
}
|
|
8331
|
+
const at = email2.lastIndexOf("@");
|
|
8332
|
+
const local = email2.slice(0, at);
|
|
8333
|
+
const domain = email2.slice(at + 1);
|
|
8334
|
+
if (at <= 0 || !LOCAL_PART.test(local) || !DOMAIN.test(domain)) {
|
|
8335
|
+
return "ses can't send to this address: it isn't a well-formed address";
|
|
8336
|
+
}
|
|
8337
|
+
return null;
|
|
8338
|
+
}
|
|
7657
8339
|
|
|
7658
8340
|
// src/providers/sns.ts
|
|
7659
8341
|
var SNS_HOST = /^sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$/;
|
|
@@ -7773,6 +8455,8 @@ function spkiFromPem(pem) {
|
|
|
7773
8455
|
}
|
|
7774
8456
|
throw new Error("PEM has neither a PUBLIC KEY nor a CERTIFICATE");
|
|
7775
8457
|
}
|
|
8458
|
+
var CertUnavailable = class extends Error {
|
|
8459
|
+
};
|
|
7776
8460
|
async function fetchVerifyKey(certUrl, hash) {
|
|
7777
8461
|
const cacheKey = `${hash}:${certUrl}`;
|
|
7778
8462
|
const cached = keyCache.get(cacheKey);
|
|
@@ -7788,11 +8472,22 @@ async function fetchVerifyKey(certUrl, hash) {
|
|
|
7788
8472
|
if (u.protocol !== "https:" || !isSnsHost(u.hostname)) {
|
|
7789
8473
|
throw new Error(`untrusted SigningCertURL host: ${u.hostname}`);
|
|
7790
8474
|
}
|
|
7791
|
-
|
|
8475
|
+
let res;
|
|
8476
|
+
let pem;
|
|
8477
|
+
try {
|
|
8478
|
+
res = await fetch(certUrl);
|
|
8479
|
+
pem = res.ok ? await res.text() : "";
|
|
8480
|
+
} catch (err) {
|
|
8481
|
+
throw new CertUnavailable(
|
|
8482
|
+
`signing cert fetch failed: ${err instanceof Error ? err.message : String(err)}`
|
|
8483
|
+
);
|
|
8484
|
+
}
|
|
8485
|
+
if (res.status >= 500 || res.status === 429) {
|
|
8486
|
+
throw new CertUnavailable(`signing cert fetch failed: ${res.status}`);
|
|
8487
|
+
}
|
|
7792
8488
|
if (!res.ok) {
|
|
7793
8489
|
throw new Error(`signing cert fetch failed: ${res.status}`);
|
|
7794
8490
|
}
|
|
7795
|
-
const pem = await res.text();
|
|
7796
8491
|
const key = await crypto.subtle.importKey(
|
|
7797
8492
|
"spki",
|
|
7798
8493
|
spkiFromPem(pem),
|
|
@@ -7803,25 +8498,39 @@ async function fetchVerifyKey(certUrl, hash) {
|
|
|
7803
8498
|
keyCache.set(cacheKey, key);
|
|
7804
8499
|
return key;
|
|
7805
8500
|
}
|
|
7806
|
-
|
|
7807
|
-
|
|
8501
|
+
function isSnsCertUrl(certUrl) {
|
|
8502
|
+
try {
|
|
8503
|
+
const u = new URL(certUrl);
|
|
8504
|
+
return u.protocol === "https:" && isSnsHost(u.hostname);
|
|
8505
|
+
} catch {
|
|
7808
8506
|
return false;
|
|
7809
8507
|
}
|
|
8508
|
+
}
|
|
8509
|
+
async function verifySnsSignature(msg) {
|
|
8510
|
+
if (typeof msg.Signature !== "string" || !msg.Signature || typeof msg.SigningCertURL !== "string" || !isSnsCertUrl(msg.SigningCertURL)) {
|
|
8511
|
+
return "unsigned";
|
|
8512
|
+
}
|
|
7810
8513
|
const hash = msg.SignatureVersion === "2" ? "SHA-256" : msg.SignatureVersion === "1" ? "SHA-1" : null;
|
|
7811
8514
|
if (!hash) {
|
|
7812
|
-
return
|
|
8515
|
+
return "unsigned";
|
|
7813
8516
|
}
|
|
7814
8517
|
let key;
|
|
7815
8518
|
try {
|
|
7816
8519
|
key = await fetchVerifyKey(msg.SigningCertURL, hash);
|
|
7817
|
-
} catch {
|
|
7818
|
-
return
|
|
8520
|
+
} catch (err) {
|
|
8521
|
+
return err instanceof CertUnavailable ? "unavailable" : "invalid";
|
|
7819
8522
|
}
|
|
7820
8523
|
try {
|
|
7821
8524
|
const data = new TextEncoder().encode(canonicalString(msg));
|
|
7822
|
-
|
|
8525
|
+
const ok = await crypto.subtle.verify(
|
|
8526
|
+
"RSASSA-PKCS1-v1_5",
|
|
8527
|
+
key,
|
|
8528
|
+
base64ToBytes(msg.Signature),
|
|
8529
|
+
data
|
|
8530
|
+
);
|
|
8531
|
+
return ok ? "valid" : "invalid";
|
|
7823
8532
|
} catch {
|
|
7824
|
-
return
|
|
8533
|
+
return "invalid";
|
|
7825
8534
|
}
|
|
7826
8535
|
}
|
|
7827
8536
|
function sesRenderingFailure(messageJson) {
|
|
@@ -7892,20 +8601,23 @@ function sesTraits(config) {
|
|
|
7892
8601
|
}
|
|
7893
8602
|
function sesErrorText(status, type, message2) {
|
|
7894
8603
|
const detail = [type, message2].filter(Boolean).join(": ");
|
|
7895
|
-
return `ses ${status}${detail ? ` ${detail}` : ""}
|
|
8604
|
+
return redactCredentials(`ses ${status}${detail ? ` ${detail}` : ""}`);
|
|
7896
8605
|
}
|
|
7897
8606
|
function sesEntryErrorText(status, error) {
|
|
7898
|
-
return `ses ${status}${error ? `: ${error}` : ""}
|
|
8607
|
+
return redactCredentials(`ses ${status}${error ? `: ${error}` : ""}`);
|
|
7899
8608
|
}
|
|
7900
8609
|
function textResponse(body, status) {
|
|
7901
8610
|
return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
7902
8611
|
}
|
|
7903
8612
|
var ACCOUNT_ERROR_TYPES = /* @__PURE__ */ new Map([
|
|
7904
|
-
// AWS
|
|
7905
|
-
// restricted it for good; either way, until the operator acts in the SES console.
|
|
7906
|
-
["SendingPausedException", "suspended"],
|
|
8613
|
+
// AWS restricted the account's sending for good, over its sending practices.
|
|
7907
8614
|
["AccountSuspendedException", "suspended"],
|
|
7908
|
-
|
|
8615
|
+
// Sending is paused: by AWS under review, or turned off by hand (`sendingPaused` tells a
|
|
8616
|
+
// configuration set's pause apart by SES's words).
|
|
8617
|
+
["SendingPausedException", "paused"],
|
|
8618
|
+
// SES's API reference says the sending domain isn't verified, its troubleshooting page that
|
|
8619
|
+
// it can't read the custom MAIL FROM domain's MX record; the advice names both.
|
|
8620
|
+
["MailFromDomainNotVerifiedException", "mail_from"],
|
|
7909
8621
|
// A resource the request names doesn't exist. Kestrel names only its configuration set
|
|
7910
8622
|
// (SES_CONFIGURATION_SET), so this is a typo, a set deleted after setup, or the wrong
|
|
7911
8623
|
// region: the deployment's to fix, and the same for every recipient, so it holds the send
|
|
@@ -7913,55 +8625,132 @@ var ACCOUNT_ERROR_TYPES = /* @__PURE__ */ new Map([
|
|
|
7913
8625
|
// resource that could be missing, so a 404 then isn't expected, and the advice still points
|
|
7914
8626
|
// at the one setting that would cause it.
|
|
7915
8627
|
["NotFoundException", "configuration"],
|
|
7916
|
-
//
|
|
7917
|
-
|
|
8628
|
+
// An action SES doesn't know: the request and the endpoint disagree.
|
|
8629
|
+
["UnknownOperationException", "unsupported"],
|
|
8630
|
+
// A request SES couldn't parse as HTTP is Kestrel's to fix, never a recipient's.
|
|
8631
|
+
["MalformedHttpRequestException", "unsupported"],
|
|
8632
|
+
// The AWS account isn't signed up for SES, which the key can't change.
|
|
8633
|
+
["OptInRequired", "opt_in"],
|
|
8634
|
+
// The credentials themselves: unknown, missing, wrongly or incompletely signed, expired, or
|
|
8635
|
+
// not permitted (a key whose policy lacks `ses:SendBulkEmail` among them). AWS's common
|
|
8636
|
+
// errors carry some of these with a 400, which is why the type, not the status, decides; it
|
|
8637
|
+
// names some both with and without the `Exception` suffix, depending on the front end that
|
|
8638
|
+
// answered, so both spellings are here.
|
|
7918
8639
|
["UnrecognizedClientException", "credentials"],
|
|
7919
8640
|
["InvalidClientTokenId", "credentials"],
|
|
8641
|
+
["MissingAuthenticationToken", "credentials"],
|
|
8642
|
+
["MissingAuthenticationTokenException", "credentials"],
|
|
7920
8643
|
["SignatureDoesNotMatch", "credentials"],
|
|
8644
|
+
["IncompleteSignature", "credentials"],
|
|
8645
|
+
["IncompleteSignatureException", "credentials"],
|
|
8646
|
+
["InvalidSignatureException", "credentials"],
|
|
8647
|
+
["RequestExpired", "credentials"],
|
|
7921
8648
|
["ExpiredTokenException", "credentials"],
|
|
7922
|
-
["AccessDeniedException", "credentials"]
|
|
8649
|
+
["AccessDeniedException", "credentials"],
|
|
8650
|
+
["NotAuthorized", "credentials"],
|
|
8651
|
+
// The request as a whole is too large: the email itself, the same for every batch of it.
|
|
8652
|
+
["RequestEntityTooLargeException", "content"]
|
|
7923
8653
|
]);
|
|
7924
|
-
|
|
8654
|
+
var UNAVAILABLE_ERROR_TYPES = /* @__PURE__ */ new Set(["RequestTimeoutException", "LimitExceededException"]);
|
|
8655
|
+
var UNKNOWN_FATE_ERROR_TYPES = /* @__PURE__ */ new Set(["RequestAbortedException"]);
|
|
8656
|
+
function sesFateUnknown(status, type) {
|
|
8657
|
+
return status >= 500 || UNKNOWN_FATE_ERROR_TYPES.has(type);
|
|
8658
|
+
}
|
|
8659
|
+
function unverifiedIdentities(message2) {
|
|
8660
|
+
if (!/not verified/i.test(message2)) {
|
|
8661
|
+
return null;
|
|
8662
|
+
}
|
|
8663
|
+
const list5 = /failed the check in region[^:]*:(.*)$/is.exec(message2)?.[1] ?? "";
|
|
8664
|
+
return list5.split(",").map(
|
|
8665
|
+
(id) => id.trim().replace(/(\.\.\.|…|\.)$/, "").trim().toLowerCase()
|
|
8666
|
+
).filter(Boolean);
|
|
8667
|
+
}
|
|
8668
|
+
function isSenderIdentity(identity, from) {
|
|
8669
|
+
const address = bareAddress(from);
|
|
8670
|
+
if (identity.includes("@")) {
|
|
8671
|
+
return identity === address;
|
|
8672
|
+
}
|
|
8673
|
+
const domain = address.slice(address.lastIndexOf("@") + 1);
|
|
8674
|
+
return identity === domain || domain.endsWith(`.${identity}`);
|
|
8675
|
+
}
|
|
8676
|
+
function verificationCause(message2, from) {
|
|
8677
|
+
const ids = unverifiedIdentities(message2);
|
|
8678
|
+
if (ids === null) {
|
|
8679
|
+
return null;
|
|
8680
|
+
}
|
|
8681
|
+
if (ids.length === 0 || ids.some((id) => isSenderIdentity(id, from))) {
|
|
8682
|
+
return "sender";
|
|
8683
|
+
}
|
|
8684
|
+
return "sandbox";
|
|
8685
|
+
}
|
|
8686
|
+
function classifySesError(status, type, message2 = "", from = "") {
|
|
7925
8687
|
const throttled = status === 429 || /throttl|toomany/i.test(type);
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
8688
|
+
if ((throttled || type === "LimitExceededException") && /daily (message|sending) quota/i.test(message2)) {
|
|
8689
|
+
return account("quota");
|
|
8690
|
+
}
|
|
8691
|
+
const named = ACCOUNT_ERROR_TYPES.get(type);
|
|
8692
|
+
if (named) {
|
|
8693
|
+
return account(named === "paused" && /configuration set/i.test(message2) ? "set_paused" : named);
|
|
8694
|
+
}
|
|
8695
|
+
if (type === "MessageRejected") {
|
|
8696
|
+
return account(verificationCause(message2, from) ?? "content");
|
|
8697
|
+
}
|
|
8698
|
+
if (status === 404) {
|
|
8699
|
+
return account("configuration");
|
|
8700
|
+
}
|
|
8701
|
+
if (status === 401 || status === 403) {
|
|
8702
|
+
return account("credentials");
|
|
7929
8703
|
}
|
|
7930
8704
|
if (throttled) {
|
|
7931
|
-
return
|
|
8705
|
+
return unavailable("rate_limit");
|
|
7932
8706
|
}
|
|
7933
|
-
if (status >= 500) {
|
|
7934
|
-
return
|
|
8707
|
+
if (status === 408 || UNAVAILABLE_ERROR_TYPES.has(type) || status >= 500) {
|
|
8708
|
+
return unavailable("outage");
|
|
7935
8709
|
}
|
|
7936
8710
|
return null;
|
|
7937
8711
|
}
|
|
7938
|
-
|
|
7939
|
-
reason: "account",
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
cause
|
|
7945
|
-
});
|
|
8712
|
+
function account(cause) {
|
|
8713
|
+
return { reason: "account", cause };
|
|
8714
|
+
}
|
|
8715
|
+
function unavailable(cause) {
|
|
8716
|
+
return { reason: "unavailable", cause };
|
|
8717
|
+
}
|
|
7946
8718
|
var ENTRY_HALTS = /* @__PURE__ */ new Map([
|
|
7947
8719
|
["ACCOUNT_SUSPENDED", account("suspended")],
|
|
7948
|
-
["ACCOUNT_SENDING_PAUSED", account("
|
|
7949
|
-
["CONFIGURATION_SET_SENDING_PAUSED", account("
|
|
7950
|
-
["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")],
|
|
7951
8723
|
["ACCOUNT_DAILY_QUOTA_EXCEEDED", account("quota")],
|
|
7952
8724
|
["CONFIGURATION_SET_DOES_NOT_EXIST", account("configuration")],
|
|
7953
8725
|
["CONFIGURATION_SET_NOT_FOUND", account("configuration")],
|
|
7954
|
-
["INVALID_SENDING_POOL_NAME", account("
|
|
7955
|
-
["TEMPLATE_DOES_NOT_EXIST", account("
|
|
7956
|
-
["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")],
|
|
7957
8729
|
["ACCOUNT_THROTTLED", unavailable("rate_limit")],
|
|
7958
8730
|
["TRANSIENT_FAILURE", unavailable("outage")]
|
|
7959
8731
|
]);
|
|
7960
|
-
|
|
7961
|
-
|
|
8732
|
+
var ENTRY_RECIPIENT_FAILURES = /* @__PURE__ */ new Map([
|
|
8733
|
+
["INVALID_PARAMETER", { retryable: false }],
|
|
8734
|
+
["INVALID_PARAMETER_VALUE", { retryable: false }],
|
|
8735
|
+
["FAILED", { retryable: true }]
|
|
8736
|
+
]);
|
|
8737
|
+
function classifySesEntry(status, words = "", from = "") {
|
|
8738
|
+
const halt = ENTRY_HALTS.get(status);
|
|
8739
|
+
if (halt) {
|
|
8740
|
+
return { kind: "halt", halt };
|
|
8741
|
+
}
|
|
8742
|
+
if (status === "MESSAGE_REJECTED") {
|
|
8743
|
+
const verification = verificationCause(words, from);
|
|
8744
|
+
return verification ? { kind: "halt", halt: account(verification) } : { kind: "rejected" };
|
|
8745
|
+
}
|
|
8746
|
+
const own = ENTRY_RECIPIENT_FAILURES.get(status);
|
|
8747
|
+
if (own) {
|
|
8748
|
+
return { kind: "recipient", retryable: own.retryable };
|
|
8749
|
+
}
|
|
8750
|
+
return { kind: "unknown" };
|
|
7962
8751
|
}
|
|
7963
8752
|
function worse(a, b) {
|
|
7964
|
-
return !a ||
|
|
8753
|
+
return !a || haltRank(b) > haltRank(a) ? b : a;
|
|
7965
8754
|
}
|
|
7966
8755
|
var SesProvider = class {
|
|
7967
8756
|
name = "ses";
|
|
@@ -8001,15 +8790,11 @@ var SesProvider = class {
|
|
|
8001
8790
|
const out = [];
|
|
8002
8791
|
const sendable = [];
|
|
8003
8792
|
for (const r of recipients) {
|
|
8004
|
-
|
|
8793
|
+
const problem = sesAddressProblem(r.email);
|
|
8794
|
+
if (problem === null) {
|
|
8005
8795
|
sendable.push(r);
|
|
8006
8796
|
} else {
|
|
8007
|
-
out.push({
|
|
8008
|
-
email: r.email,
|
|
8009
|
-
accepted: false,
|
|
8010
|
-
retryable: false,
|
|
8011
|
-
error: "ses can't send to an address with characters outside ASCII"
|
|
8012
|
-
});
|
|
8797
|
+
out.push({ email: r.email, accepted: false, retryable: false, error: problem });
|
|
8013
8798
|
}
|
|
8014
8799
|
}
|
|
8015
8800
|
const content = sesTemplateContent(rendered);
|
|
@@ -8021,7 +8806,7 @@ var SesProvider = class {
|
|
|
8021
8806
|
try {
|
|
8022
8807
|
answer = opts.purpose === "list" ? await this.sendBulk(content, batch) : await this.sendSingle(content, unwrap(batch[0], "a recipient"));
|
|
8023
8808
|
} catch (err) {
|
|
8024
|
-
if (opts.purpose === "list" ||
|
|
8809
|
+
if (opts.purpose === "list" || out.length === 0 && sendable.length === batch.length) {
|
|
8025
8810
|
throw err;
|
|
8026
8811
|
}
|
|
8027
8812
|
const words = err instanceof Error ? err.message : String(err);
|
|
@@ -8030,11 +8815,17 @@ var SesProvider = class {
|
|
|
8030
8815
|
email: r.email,
|
|
8031
8816
|
accepted: false,
|
|
8032
8817
|
retryable: false,
|
|
8033
|
-
error: `ses gave no answer (${words})
|
|
8818
|
+
error: `ses gave no answer (${words})`,
|
|
8819
|
+
unknown: true
|
|
8034
8820
|
});
|
|
8035
8821
|
}
|
|
8036
8822
|
for (const rest of sendable.slice(at + size)) {
|
|
8037
|
-
out.push({
|
|
8823
|
+
out.push({
|
|
8824
|
+
email: rest.email,
|
|
8825
|
+
accepted: false,
|
|
8826
|
+
retryable: true,
|
|
8827
|
+
error: "not tried, since an earlier request got no answer"
|
|
8828
|
+
});
|
|
8038
8829
|
}
|
|
8039
8830
|
break;
|
|
8040
8831
|
}
|
|
@@ -8090,8 +8881,9 @@ var SesProvider = class {
|
|
|
8090
8881
|
* (that risks a duplicate, violating I4): it throws, and the send loop leaves its
|
|
8091
8882
|
* recipients `dispatched` for a human. A request SES never answers is ended the same
|
|
8092
8883
|
* way: the timeout rejects the fetch, so it throws down that same path. aws4fetch
|
|
8093
|
-
* carries `signal` onto the signed Request.
|
|
8094
|
-
*
|
|
8884
|
+
* carries `signal` onto the signed Request. An HTTP error response says SES did not
|
|
8885
|
+
* accept, except a 5xx or an aborted request (`sesFateUnknown`), which a list send treats
|
|
8886
|
+
* as no answer.
|
|
8095
8887
|
*/
|
|
8096
8888
|
post(path2, payload) {
|
|
8097
8889
|
if (this.configurationSet) {
|
|
@@ -8104,18 +8896,14 @@ var SesProvider = class {
|
|
|
8104
8896
|
signal: AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS)
|
|
8105
8897
|
});
|
|
8106
8898
|
}
|
|
8107
|
-
/** An SES error response
|
|
8899
|
+
/** An SES error response: its type, its halt, or SES's words when it may be about a
|
|
8900
|
+
* recipient. */
|
|
8108
8901
|
async refusal(res) {
|
|
8109
8902
|
const bodyText = await res.text().catch(() => "");
|
|
8110
8903
|
const { type, message: message2 } = parseSesError(bodyText, res.headers.get("x-amzn-ErrorType"));
|
|
8111
8904
|
const error = sesErrorText(res.status, type, message2);
|
|
8112
|
-
const halt = classifySesError(
|
|
8113
|
-
|
|
8114
|
-
type,
|
|
8115
|
-
message2,
|
|
8116
|
-
type === "MessageRejected" && this.namesSender(message2)
|
|
8117
|
-
);
|
|
8118
|
-
return { halt: halt ? { ...halt, error } : null, error };
|
|
8905
|
+
const halt = classifySesError(res.status, type, message2, this.from);
|
|
8906
|
+
return { type, halt: halt ? { ...halt, error } : null, error };
|
|
8119
8907
|
}
|
|
8120
8908
|
/** One SendEmail with the inline template, for a one-off message to one recipient. */
|
|
8121
8909
|
async sendSingle(content, r) {
|
|
@@ -8160,14 +8948,14 @@ var SesProvider = class {
|
|
|
8160
8948
|
}))
|
|
8161
8949
|
});
|
|
8162
8950
|
if (!res.ok) {
|
|
8163
|
-
const { halt: halt2, error } = await this.refusal(res);
|
|
8951
|
+
const { type, halt: halt2, error } = await this.refusal(res);
|
|
8952
|
+
if (sesFateUnknown(res.status, type)) {
|
|
8953
|
+
throw new Error(`${error} (SES may have taken the batch before answering)`);
|
|
8954
|
+
}
|
|
8164
8955
|
if (halt2) {
|
|
8165
8956
|
return { kind: "halted", halt: halt2 };
|
|
8166
8957
|
}
|
|
8167
|
-
return
|
|
8168
|
-
kind: "answered",
|
|
8169
|
-
results: batch.map((r) => ({ email: r.email, accepted: false, retryable: false, error }))
|
|
8170
|
-
};
|
|
8958
|
+
return this.invalidBatch(batch, error);
|
|
8171
8959
|
}
|
|
8172
8960
|
let entries = [];
|
|
8173
8961
|
try {
|
|
@@ -8178,6 +8966,8 @@ var SesProvider = class {
|
|
|
8178
8966
|
}
|
|
8179
8967
|
const results = [];
|
|
8180
8968
|
const heldBack = [];
|
|
8969
|
+
const rejected = [];
|
|
8970
|
+
const own = [];
|
|
8181
8971
|
let halt = null;
|
|
8182
8972
|
let short = false;
|
|
8183
8973
|
for (const [i, r] of batch.entries()) {
|
|
@@ -8192,16 +8982,45 @@ var SesProvider = class {
|
|
|
8192
8982
|
continue;
|
|
8193
8983
|
}
|
|
8194
8984
|
const words = entry2.Error ?? "";
|
|
8195
|
-
const error = sesEntryErrorText(status || "
|
|
8196
|
-
const
|
|
8197
|
-
|
|
8198
|
-
|
|
8199
|
-
|
|
8200
|
-
if (
|
|
8201
|
-
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 });
|
|
8202
8992
|
heldBack.push({ email: r.email, error });
|
|
8203
|
-
} else {
|
|
8993
|
+
} else if (namesAddress(words, r.email)) {
|
|
8204
8994
|
results.push({ email: r.email, accepted: false, retryable: false, error });
|
|
8995
|
+
} else if (verdict.kind === "rejected") {
|
|
8996
|
+
rejected.push({ email: r.email, error });
|
|
8997
|
+
} else {
|
|
8998
|
+
own.push({ email: r.email, error, status, retryable: verdict.retryable });
|
|
8999
|
+
}
|
|
9000
|
+
}
|
|
9001
|
+
const tookAny = results.some((r) => r.accepted);
|
|
9002
|
+
if (rejected.length > 0 && !tookAny) {
|
|
9003
|
+
halt = worse(halt, {
|
|
9004
|
+
reason: "account",
|
|
9005
|
+
cause: "content",
|
|
9006
|
+
error: unwrap(rejected[0], "a rejection").error
|
|
9007
|
+
});
|
|
9008
|
+
heldBack.push(...rejected);
|
|
9009
|
+
} else {
|
|
9010
|
+
for (const r of rejected) {
|
|
9011
|
+
results.push({ email: r.email, accepted: false, retryable: false, error: r.error });
|
|
9012
|
+
}
|
|
9013
|
+
}
|
|
9014
|
+
for (const r of own) {
|
|
9015
|
+
const alike = own.filter((o) => o.status === r.status).length;
|
|
9016
|
+
if (alike > 1 && !tookAny) {
|
|
9017
|
+
halt = worse(halt, {
|
|
9018
|
+
...r.status === "FAILED" ? unavailable("outage") : account("invalid_request"),
|
|
9019
|
+
error: r.error
|
|
9020
|
+
});
|
|
9021
|
+
heldBack.push({ email: r.email, error: r.error });
|
|
9022
|
+
} else {
|
|
9023
|
+
results.push({ email: r.email, accepted: false, retryable: r.retryable, error: r.error });
|
|
8205
9024
|
}
|
|
8206
9025
|
}
|
|
8207
9026
|
if (halt && !short && !results.some((r) => r.accepted)) {
|
|
@@ -8221,11 +9040,32 @@ var SesProvider = class {
|
|
|
8221
9040
|
}
|
|
8222
9041
|
return { kind: "answered", results, halt };
|
|
8223
9042
|
}
|
|
8224
|
-
/**
|
|
8225
|
-
*
|
|
8226
|
-
*
|
|
8227
|
-
|
|
8228
|
-
|
|
9043
|
+
/**
|
|
9044
|
+
* A list batch SES refused as invalid, for a reason that may be one recipient's (a bad
|
|
9045
|
+
* address SES's syntax check refuses) rather than the email's. A batch of one is that
|
|
9046
|
+
* recipient's own, permanently. In a larger batch, a recipient SES's words name is
|
|
9047
|
+
* refused alone and the rest go back to the queue, untouched, for the next request; with
|
|
9048
|
+
* no one named, no one can be blamed, so the send holds for a person to read SES's words
|
|
9049
|
+
* (`invalid_request`), rather than mark a whole batch unsent for one address.
|
|
9050
|
+
*/
|
|
9051
|
+
invalidBatch(batch, error) {
|
|
9052
|
+
if (batch.length === 1) {
|
|
9053
|
+
const only = unwrap(batch[0], "a recipient");
|
|
9054
|
+
return {
|
|
9055
|
+
kind: "answered",
|
|
9056
|
+
results: [{ email: only.email, accepted: false, retryable: false, error }]
|
|
9057
|
+
};
|
|
9058
|
+
}
|
|
9059
|
+
const named = batch.filter((r) => namesAddress(error, r.email));
|
|
9060
|
+
if (named.length === 0) {
|
|
9061
|
+
return { kind: "halted", halt: { reason: "account", cause: "invalid_request", error } };
|
|
9062
|
+
}
|
|
9063
|
+
return {
|
|
9064
|
+
kind: "answered",
|
|
9065
|
+
results: batch.map(
|
|
9066
|
+
(r) => named.includes(r) ? { email: r.email, accepted: false, retryable: false, error } : { email: r.email, accepted: false, retryable: true, error: "not sent", held: true }
|
|
9067
|
+
)
|
|
9068
|
+
};
|
|
8229
9069
|
}
|
|
8230
9070
|
async parseWebhook(req, env) {
|
|
8231
9071
|
const expectedTopic = env.SNS_TOPIC_ARN?.trim();
|
|
@@ -8238,11 +9078,27 @@ var SesProvider = class {
|
|
|
8238
9078
|
} catch {
|
|
8239
9079
|
return { events: [], response: textResponse("invalid json", 400) };
|
|
8240
9080
|
}
|
|
8241
|
-
if (
|
|
9081
|
+
if (typeof msg !== "object" || msg === null) {
|
|
9082
|
+
return { events: [], response: textResponse("invalid json", 400) };
|
|
9083
|
+
}
|
|
9084
|
+
const topic = typeof msg.TopicArn === "string" ? msg.TopicArn.slice(0, 256) : null;
|
|
9085
|
+
const refuse2 = (reason, body) => {
|
|
9086
|
+
log.error("webhook.refused", { provider: "ses", reason, topic, expected: expectedTopic });
|
|
9087
|
+
return { events: [], response: textResponse(body, 403) };
|
|
9088
|
+
};
|
|
9089
|
+
const signature = await verifySnsSignature(msg);
|
|
9090
|
+
if (signature === "unavailable") {
|
|
9091
|
+
log.warn("webhook.cert_unavailable", { provider: "ses", topic });
|
|
9092
|
+
return { events: [], response: textResponse("signing certificate unavailable", 503) };
|
|
9093
|
+
}
|
|
9094
|
+
if (signature === "unsigned") {
|
|
8242
9095
|
return { events: [], response: textResponse("invalid signature", 403) };
|
|
8243
9096
|
}
|
|
9097
|
+
if (signature !== "valid") {
|
|
9098
|
+
return refuse2("signature", "invalid signature");
|
|
9099
|
+
}
|
|
8244
9100
|
if (msg.TopicArn !== expectedTopic) {
|
|
8245
|
-
return
|
|
9101
|
+
return refuse2("topic", "unexpected topic");
|
|
8246
9102
|
}
|
|
8247
9103
|
switch (msg.Type) {
|
|
8248
9104
|
case "SubscriptionConfirmation": {
|
|
@@ -8303,6 +9159,20 @@ function bareAddress(from) {
|
|
|
8303
9159
|
const angle = /<([^>]+)>/.exec(from);
|
|
8304
9160
|
return (angle?.[1] ?? from).trim().toLowerCase();
|
|
8305
9161
|
}
|
|
9162
|
+
var ADDRESS_CHAR = /[a-z0-9.!#$%&'*+/=?^_`{|}~@-]/i;
|
|
9163
|
+
function namesAddress(words, email2) {
|
|
9164
|
+
const text = words.toLowerCase();
|
|
9165
|
+
const target = email2.toLowerCase();
|
|
9166
|
+
for (let at = text.indexOf(target); at >= 0; at = text.indexOf(target, at + 1)) {
|
|
9167
|
+
const before = text[at - 1];
|
|
9168
|
+
const after = text.slice(at + target.length, at + target.length + 2);
|
|
9169
|
+
const endsHere = after === "" || !ADDRESS_CHAR.test(after[0] ?? "") || /^\.(\s|$)/.test(after);
|
|
9170
|
+
if ((before === void 0 || !ADDRESS_CHAR.test(before)) && endsHere) {
|
|
9171
|
+
return true;
|
|
9172
|
+
}
|
|
9173
|
+
}
|
|
9174
|
+
return false;
|
|
9175
|
+
}
|
|
8306
9176
|
|
|
8307
9177
|
// src/services/webhook_events.ts
|
|
8308
9178
|
async function applyDeliveryEvents(db, events) {
|
|
@@ -8760,6 +9630,13 @@ async function requestSubscription(c, email2) {
|
|
|
8760
9630
|
created
|
|
8761
9631
|
);
|
|
8762
9632
|
log.warn("subscribe.confirmation_refused", { provider: c.config.provider, error: sent.error });
|
|
9633
|
+
if (sent.cause === "sandbox") {
|
|
9634
|
+
try {
|
|
9635
|
+
await sandboxNotice(db, now);
|
|
9636
|
+
} catch (err) {
|
|
9637
|
+
log.error("subscribe.failed", { provider: c.config.provider, error: errorText(err) });
|
|
9638
|
+
}
|
|
9639
|
+
}
|
|
8763
9640
|
return { kind: "failed", delivered: "no", error: sent.error };
|
|
8764
9641
|
}
|
|
8765
9642
|
const armed = await armConfirmation(db, subscriber.id, token2, now, subscriber.status);
|
|
@@ -8803,7 +9680,7 @@ async function sendConfirmation(c, message2, subscriber, attemptAt) {
|
|
|
8803
9680
|
return { delivered: "unknown", error: err instanceof Error ? err.message : String(err) };
|
|
8804
9681
|
}
|
|
8805
9682
|
if (result.kind === "halted") {
|
|
8806
|
-
return { delivered: "no", error: result.halt.error };
|
|
9683
|
+
return { delivered: "no", error: result.halt.error, cause: result.halt.cause };
|
|
8807
9684
|
}
|
|
8808
9685
|
const [answer] = result.results;
|
|
8809
9686
|
if (!answer) {
|
|
@@ -9162,6 +10039,81 @@ var FakeNotifier = class {
|
|
|
9162
10039
|
}
|
|
9163
10040
|
};
|
|
9164
10041
|
|
|
10042
|
+
// src/services/test_send.ts
|
|
10043
|
+
async function sendTest(provider, email2, recipients, opts, kind) {
|
|
10044
|
+
let answer;
|
|
10045
|
+
try {
|
|
10046
|
+
answer = await provider.sendBatch(email2, recipients, opts);
|
|
10047
|
+
} catch (err) {
|
|
10048
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
10049
|
+
const notSent = noAnswer(error);
|
|
10050
|
+
logNotSent(provider, kind, recipients.length, notSent);
|
|
10051
|
+
return recipients.map((r) => ({ email: r.email, sent: false, notSent }));
|
|
10052
|
+
}
|
|
10053
|
+
const halt = answer.halt;
|
|
10054
|
+
const unknown = [];
|
|
10055
|
+
const refused = [];
|
|
10056
|
+
const answers = perRecipient(answer, recipients).map((r) => {
|
|
10057
|
+
if (r.accepted) {
|
|
10058
|
+
return { email: r.email, sent: true };
|
|
10059
|
+
}
|
|
10060
|
+
const covering = answer.kind === "halted" || r.held ? halt : void 0;
|
|
10061
|
+
const notSent = why(r, covering);
|
|
10062
|
+
if (notSent.may_have_arrived) {
|
|
10063
|
+
unknown.push(notSent);
|
|
10064
|
+
} else if (covering || !r.retryable) {
|
|
10065
|
+
refused.push(notSent);
|
|
10066
|
+
}
|
|
10067
|
+
return { email: r.email, sent: false, notSent };
|
|
10068
|
+
});
|
|
10069
|
+
if (unknown[0]) {
|
|
10070
|
+
logNotSent(provider, kind, unknown.length, unknown[0]);
|
|
10071
|
+
}
|
|
10072
|
+
if (refused[0]) {
|
|
10073
|
+
logNotSent(provider, kind, refused.length, refused[0]);
|
|
10074
|
+
}
|
|
10075
|
+
return answers;
|
|
10076
|
+
}
|
|
10077
|
+
function noAnswer(error) {
|
|
10078
|
+
return {
|
|
10079
|
+
may_have_arrived: true,
|
|
10080
|
+
error,
|
|
10081
|
+
message: `The provider didn\u2019t answer, so the test may have arrived: ${sentence(error)} Check the inbox before sending it again.`
|
|
10082
|
+
};
|
|
10083
|
+
}
|
|
10084
|
+
function why(r, halt) {
|
|
10085
|
+
if (r.unknown) {
|
|
10086
|
+
return noAnswer(r.error);
|
|
10087
|
+
}
|
|
10088
|
+
const error = halt?.error ?? r.error;
|
|
10089
|
+
const refused = (message2) => ({ may_have_arrived: false, error, message: message2 });
|
|
10090
|
+
if (halt?.reason === "account") {
|
|
10091
|
+
return refused(
|
|
10092
|
+
`The provider refused it: ${sentence(error)} ${refusalAdvice(halt.cause)} Sending the test again gets the same answer until that is fixed.`
|
|
10093
|
+
);
|
|
10094
|
+
}
|
|
10095
|
+
if (halt) {
|
|
10096
|
+
return refused(
|
|
10097
|
+
`The provider can\u2019t take mail right now: ${sentence(error)} Send the test again in a few minutes.`
|
|
10098
|
+
);
|
|
10099
|
+
}
|
|
10100
|
+
if (r.retryable) {
|
|
10101
|
+
return refused(`It wasn\u2019t sent: ${sentence(error)} Send the test again to try it.`);
|
|
10102
|
+
}
|
|
10103
|
+
return refused(
|
|
10104
|
+
`The provider refused it: ${sentence(error)} Sending to this address again gets the same answer until what the provider names is fixed.`
|
|
10105
|
+
);
|
|
10106
|
+
}
|
|
10107
|
+
function logNotSent(provider, kind, count, first) {
|
|
10108
|
+
log.warn(first.may_have_arrived ? "test.unknown" : "test.refused", {
|
|
10109
|
+
provider: provider.name,
|
|
10110
|
+
test: kind.test,
|
|
10111
|
+
postId: kind.postId,
|
|
10112
|
+
count,
|
|
10113
|
+
error: first.error
|
|
10114
|
+
});
|
|
10115
|
+
}
|
|
10116
|
+
|
|
9165
10117
|
// src/routes/render_actions.ts
|
|
9166
10118
|
async function loadRenderInput(c) {
|
|
9167
10119
|
const post = await getPost(c.env.DB, param(c, "id"));
|
|
@@ -9185,7 +10137,9 @@ async function loadPostEmail(c) {
|
|
|
9185
10137
|
return {
|
|
9186
10138
|
input,
|
|
9187
10139
|
email: { subject: send.subject, html: send.rendered_html, text: send.rendered_text },
|
|
9188
|
-
|
|
10140
|
+
// What the render said when it froze this copy, kept with it: the copy is what goes
|
|
10141
|
+
// out, so a test of it reports what it found (SPEC §5).
|
|
10142
|
+
warnings: renderWarnings(send),
|
|
9189
10143
|
frozen: { id: send.id },
|
|
9190
10144
|
dateMs: send.started_at ?? send.fire_at
|
|
9191
10145
|
};
|
|
@@ -9193,7 +10147,7 @@ async function loadPostEmail(c) {
|
|
|
9193
10147
|
const result = await render(input, c.config, await loadBranding(c));
|
|
9194
10148
|
return { input, email: result, warnings: result.warnings, frozen: null, dateMs: Date.now() };
|
|
9195
10149
|
}
|
|
9196
|
-
function
|
|
10150
|
+
function postPage2(html) {
|
|
9197
10151
|
return new Response(html, {
|
|
9198
10152
|
headers: {
|
|
9199
10153
|
...POST_PAGE_SECURITY_HEADERS,
|
|
@@ -9220,11 +10174,11 @@ async function previewPage(c) {
|
|
|
9220
10174
|
".Email.UnsubscribeURL": testUnsubscribeUrl(c.config),
|
|
9221
10175
|
".Email.SentTo": ""
|
|
9222
10176
|
}).html;
|
|
9223
|
-
return
|
|
10177
|
+
return postPage2(html);
|
|
9224
10178
|
}
|
|
9225
10179
|
async function webPage(c) {
|
|
9226
10180
|
const { email: email2, dateMs } = await loadPostEmail(c);
|
|
9227
|
-
return
|
|
10181
|
+
return postPage2(
|
|
9228
10182
|
webVersion(email2.html, {
|
|
9229
10183
|
config: c.config,
|
|
9230
10184
|
identity: await readerIdentity(c, c.config),
|
|
@@ -9247,19 +10201,24 @@ async function test(c) {
|
|
|
9247
10201
|
const provider = getProvider(c.config, c.env);
|
|
9248
10202
|
const unsubscribeUrl = testUnsubscribeUrl(c.config);
|
|
9249
10203
|
const recipients = [{ email: to, unsubscribeUrl }];
|
|
9250
|
-
const [res] =
|
|
9251
|
-
|
|
10204
|
+
const [res] = await sendTest(
|
|
10205
|
+
provider,
|
|
10206
|
+
email2,
|
|
10207
|
+
recipients,
|
|
10208
|
+
{
|
|
9252
10209
|
purpose: "test",
|
|
9253
10210
|
tag: `test-${input.post.id}`,
|
|
9254
10211
|
batchKey: `test-${input.post.id}-${crypto.randomUUID()}`
|
|
9255
|
-
}
|
|
9256
|
-
|
|
10212
|
+
},
|
|
10213
|
+
{ test: "post", postId: input.post.id }
|
|
9257
10214
|
);
|
|
9258
|
-
|
|
10215
|
+
const sent = res?.sent === true;
|
|
10216
|
+
if (frozen && sent) {
|
|
9259
10217
|
await markTested(c.env.DB, frozen.id, readAt, Date.now());
|
|
9260
10218
|
}
|
|
9261
10219
|
const result = {
|
|
9262
|
-
sent
|
|
10220
|
+
sent,
|
|
10221
|
+
not_sent: res && !res.sent ? res.notSent : null,
|
|
9263
10222
|
provider: provider.name,
|
|
9264
10223
|
to,
|
|
9265
10224
|
subject: email2.subject,
|
|
@@ -9291,8 +10250,11 @@ function sampleRenderInput(subject = TEMPLATE_TEST_SUBJECT) {
|
|
|
9291
10250
|
id: "sample",
|
|
9292
10251
|
slug: "sample-post",
|
|
9293
10252
|
slug_auto: false,
|
|
10253
|
+
// No post has this token, so the sample's "view in browser" opens "Nothing here yet".
|
|
10254
|
+
unlisted_token: "sample",
|
|
9294
10255
|
subject,
|
|
9295
10256
|
status: "draft",
|
|
10257
|
+
email_only: false,
|
|
9296
10258
|
current_revision: "sample-rev",
|
|
9297
10259
|
created_at: now,
|
|
9298
10260
|
updated_at: now
|
|
@@ -9360,19 +10322,19 @@ async function templateTest(c) {
|
|
|
9360
10322
|
const unsubscribeUrl = testUnsubscribeUrl(c.config);
|
|
9361
10323
|
const testId = `template-test-${crypto.randomUUID()}`;
|
|
9362
10324
|
const batch = recipients.map((email2) => ({ email: email2, unsubscribeUrl }));
|
|
9363
|
-
const
|
|
9364
|
-
|
|
9365
|
-
|
|
9366
|
-
|
|
9367
|
-
|
|
9368
|
-
}
|
|
9369
|
-
batch
|
|
10325
|
+
const answers = await sendTest(
|
|
10326
|
+
provider,
|
|
10327
|
+
result,
|
|
10328
|
+
batch,
|
|
10329
|
+
{ purpose: "test", tag: testId, batchKey: testId },
|
|
10330
|
+
{ test: "template" }
|
|
9370
10331
|
);
|
|
9371
|
-
const
|
|
10332
|
+
const notSent = answers.flatMap((a) => a.sent ? [] : [{ email: a.email, ...a.notSent }]);
|
|
9372
10333
|
const report = {
|
|
9373
|
-
sent:
|
|
10334
|
+
sent: answers.length - notSent.length,
|
|
9374
10335
|
total: recipients.length,
|
|
9375
|
-
failed,
|
|
10336
|
+
failed: notSent.map((n) => n.email),
|
|
10337
|
+
not_sent: notSent,
|
|
9376
10338
|
provider: provider.name,
|
|
9377
10339
|
recipients,
|
|
9378
10340
|
subject: result.subject,
|
|
@@ -9542,45 +10504,56 @@ async function schedule(c) {
|
|
|
9542
10504
|
}
|
|
9543
10505
|
const body = await readJsonObject(c);
|
|
9544
10506
|
rejectStrayTemplateChoice(body);
|
|
10507
|
+
const fields = readSettingsBody(body);
|
|
9545
10508
|
requireConnectedProvider(c.config);
|
|
9546
10509
|
const fireAt = parseFutureFireAt(body.fire_at, c.config.minLeadMs);
|
|
9547
|
-
const
|
|
9548
|
-
const
|
|
9549
|
-
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);
|
|
9550
10513
|
}
|
|
9551
10514
|
async function sendNow(c) {
|
|
9552
10515
|
const post = await getPost(c.env.DB, param(c, "id"));
|
|
9553
10516
|
if (!post) {
|
|
9554
10517
|
throw notFound("post");
|
|
9555
10518
|
}
|
|
9556
|
-
|
|
10519
|
+
const body = await readJsonObject(c, { optional: true });
|
|
10520
|
+
rejectStrayTemplateChoice(body);
|
|
10521
|
+
const fields = readSettingsBody(body);
|
|
9557
10522
|
const active = await getActiveSendForPost(c.env.DB, post.id);
|
|
9558
10523
|
if (active) {
|
|
9559
|
-
return repeatAnswer(c, active
|
|
10524
|
+
return repeatAnswer(c, active, fields);
|
|
9560
10525
|
}
|
|
9561
10526
|
requireConnectedProvider(c.config);
|
|
9562
10527
|
const now = Date.now();
|
|
9563
10528
|
const fireAt = acceptFireAt(now + c.config.minLeadMs, c.config.minLeadMs, { now });
|
|
10529
|
+
const settings = await resolveSettings(c.env.DB, post, fields);
|
|
9564
10530
|
let send;
|
|
9565
10531
|
try {
|
|
9566
|
-
send = await freeze(c.env, c.config, post, fireAt);
|
|
10532
|
+
send = await freeze(c.env, c.config, post, fireAt, settings);
|
|
9567
10533
|
} catch (err) {
|
|
9568
10534
|
if (err instanceof HttpError && err.code === "active_send_exists") {
|
|
9569
10535
|
const winner = await getActiveSendForPost(c.env.DB, post.id);
|
|
9570
10536
|
if (winner) {
|
|
9571
|
-
return repeatAnswer(c, winner
|
|
10537
|
+
return repeatAnswer(c, winner, fields);
|
|
9572
10538
|
}
|
|
9573
10539
|
}
|
|
9574
10540
|
throw err;
|
|
9575
10541
|
}
|
|
9576
|
-
|
|
9577
|
-
return json(frozen, 201);
|
|
10542
|
+
return json(await frozenAnswer(c, send), 201);
|
|
9578
10543
|
}
|
|
9579
|
-
async function
|
|
9580
|
-
|
|
9581
|
-
...unwrap(await viewWithCursor(c.env,
|
|
9582
|
-
|
|
10544
|
+
async function frozenAnswer(c, send) {
|
|
10545
|
+
return {
|
|
10546
|
+
...unwrap(await viewWithCursor(c.env, send.id), "send"),
|
|
10547
|
+
warnings: renderWarnings(send)
|
|
9583
10548
|
};
|
|
10549
|
+
}
|
|
10550
|
+
async function repeatAnswer(c, send, fields) {
|
|
10551
|
+
const post = unwrap(await getPost(c.env.DB, param(c, "id")), "post");
|
|
10552
|
+
const differs = fields.emailOnly !== void 0 && fields.emailOnly !== post.email_only || fields.slug !== void 0 && fields.slug !== post.slug || fields.slugAuto !== void 0 && fields.slugAuto !== post.slug_auto;
|
|
10553
|
+
if (differs) {
|
|
10554
|
+
throw await activeSendExists(c.env, send);
|
|
10555
|
+
}
|
|
10556
|
+
const repeat = { ...await frozenAnswer(c, send), idempotent: true };
|
|
9584
10557
|
return json(repeat);
|
|
9585
10558
|
}
|
|
9586
10559
|
|
|
@@ -9748,7 +10721,11 @@ async function feed(c) {
|
|
|
9748
10721
|
now,
|
|
9749
10722
|
since,
|
|
9750
10723
|
now - SETTLE_FOLLOW_MS,
|
|
9751
|
-
{
|
|
10724
|
+
{
|
|
10725
|
+
missedMs: MISSED_THRESHOLD_MS,
|
|
10726
|
+
stuckMs: STUCK_THRESHOLD_MS,
|
|
10727
|
+
noReceiptsMs: NO_RECEIPTS_AFTER_MS
|
|
10728
|
+
},
|
|
9752
10729
|
limit,
|
|
9753
10730
|
now - BOUNCE_SPIKE_RECENT_MS
|
|
9754
10731
|
);
|
|
@@ -9864,16 +10841,28 @@ async function cancel2(c) {
|
|
|
9864
10841
|
}
|
|
9865
10842
|
async function reschedule2(c) {
|
|
9866
10843
|
const body = await readJsonObject(c);
|
|
10844
|
+
rejectSettingsFields(body);
|
|
9867
10845
|
const fireAt = parseFireAt(body.fire_at);
|
|
9868
10846
|
const id = param(c, "id");
|
|
9869
10847
|
const { changed } = await reschedule(c.env, id, fireAt, c.config.minLeadMs);
|
|
9870
10848
|
const response = { ...await answerWith(c, id), changed };
|
|
9871
10849
|
return json(response);
|
|
9872
10850
|
}
|
|
10851
|
+
function rejectSettingsFields(body) {
|
|
10852
|
+
for (const field of ["email_only", "slug", "slug_auto"]) {
|
|
10853
|
+
if (field in body) {
|
|
10854
|
+
throw fieldError(
|
|
10855
|
+
field,
|
|
10856
|
+
`${field} is not a field of a reschedule, which moves only the send time: change a post\u2019s settings with PUT /api/posts/:id/settings`
|
|
10857
|
+
);
|
|
10858
|
+
}
|
|
10859
|
+
}
|
|
10860
|
+
}
|
|
9873
10861
|
async function resolve(c) {
|
|
9874
10862
|
const request = await readJsonObject(c);
|
|
9875
10863
|
const outcome = oneOf(request, "resolution", ["unsent", "accepted"]);
|
|
9876
|
-
const
|
|
10864
|
+
const principal = unwrap(c.principal, "principal");
|
|
10865
|
+
const actor = principal.email ?? (principal.tokenId ? `service ${principal.tokenId}` : "service");
|
|
9877
10866
|
const id = param(c, "id");
|
|
9878
10867
|
const { resolved, completed } = await resolveStuckSend(c.env, id, outcome, actor);
|
|
9879
10868
|
const body = { ...await answerWith(c, id), resolved, completed };
|
|
@@ -9989,7 +10978,18 @@ function composeNotification(n, config) {
|
|
|
9989
10978
|
[
|
|
9990
10979
|
`No email provider is connected to this deployment, so ${title} is held where it is.`,
|
|
9991
10980
|
refusalAdvice(n.halt_cause),
|
|
9992
|
-
"No one has been marked unsent. The send keeps trying, at most an hour apart, and goes out at the first try after a provider is connected, mailing no one twice."
|
|
10981
|
+
"No one has been marked unsent because of this. The send keeps trying, at most an hour apart, and goes out at the first try after a provider is connected, mailing no one twice. Any other post that comes due meanwhile waits with it, and you are not told again."
|
|
10982
|
+
],
|
|
10983
|
+
link
|
|
10984
|
+
);
|
|
10985
|
+
}
|
|
10986
|
+
if (isSendOwnHalt(n.halt_cause)) {
|
|
10987
|
+
return message(
|
|
10988
|
+
`Problem with ${title}: the provider is refusing it`,
|
|
10989
|
+
[
|
|
10990
|
+
`The email provider is refusing ${title} itself, so it is paused where it is. The provider said: ${n.halt_error || "no detail given"}`,
|
|
10991
|
+
refusalAdvice(n.halt_cause),
|
|
10992
|
+
"No one has been marked unsent because of this. The send keeps trying, at most an hour apart, mailing no one twice. Other posts go out as usual."
|
|
9993
10993
|
],
|
|
9994
10994
|
link
|
|
9995
10995
|
);
|
|
@@ -9999,16 +10999,16 @@ function composeNotification(n, config) {
|
|
|
9999
10999
|
[
|
|
10000
11000
|
`The email provider is refusing this account, so ${title} is paused where it is. The provider said: ${n.halt_error || "no detail given"}`,
|
|
10001
11001
|
refusalAdvice(n.halt_cause),
|
|
10002
|
-
"No one has been marked unsent. The send keeps trying, at most an hour apart, and resumes on its own at the next try after the account is fixed, mailing no one twice."
|
|
11002
|
+
"No one has been marked unsent because of this. The send keeps trying, at most an hour apart, and resumes on its own at the next try after the account is fixed, mailing no one twice. Any other post that comes due meanwhile waits with it, and you are not told again."
|
|
10003
11003
|
],
|
|
10004
11004
|
link
|
|
10005
11005
|
);
|
|
10006
11006
|
case "stuck":
|
|
10007
11007
|
return message(
|
|
10008
|
-
`Problem with ${title}:
|
|
11008
|
+
`Problem with ${title}: nothing has gone out for ${minutes2(STUCK_THRESHOLD_MS)} minutes`,
|
|
10009
11009
|
[
|
|
10010
|
-
`${title}
|
|
10011
|
-
"
|
|
11010
|
+
`${title} has handed off no one since ${when(n.progress_at ?? n.started_at ?? n.episode)}, with no retry or provider problem to explain it: ${n.c_pending + n.c_in_flight} recipients have not been sent yet.`,
|
|
11011
|
+
"The sweep that sends may have stopped or keep failing on this send, or another send may be taking every minute's sending allowance. Check the send's page and the app's logs. It picks up where it stopped, mailing no one twice."
|
|
10012
11012
|
],
|
|
10013
11013
|
link
|
|
10014
11014
|
);
|
|
@@ -10061,6 +11061,17 @@ function sampleNotification(config) {
|
|
|
10061
11061
|
dashboardLink(config.appOrigin, "/settings")
|
|
10062
11062
|
);
|
|
10063
11063
|
}
|
|
11064
|
+
function composeSandboxNotification(config) {
|
|
11065
|
+
return message(
|
|
11066
|
+
"A signup's confirmation was refused: SES is still in the sandbox",
|
|
11067
|
+
[
|
|
11068
|
+
"Someone signed up, and Amazon SES refused to send their confirmation email, because the SES account is still in the sandbox, which mails only addresses verified in SES. They were told a link is on its way, so they may try again.",
|
|
11069
|
+
refusalAdvice("sandbox"),
|
|
11070
|
+
"Until then, every signup from an address not verified in SES is refused the same way, and a post reaches only verified addresses. You are told at most once a day."
|
|
11071
|
+
],
|
|
11072
|
+
dashboardLink(config.appOrigin, "/subscribers")
|
|
11073
|
+
);
|
|
11074
|
+
}
|
|
10064
11075
|
|
|
10065
11076
|
// src/send/remake.ts
|
|
10066
11077
|
function insideLead(scheduled, minLeadMs, now) {
|
|
@@ -10363,9 +11374,9 @@ async function deleteLogo(c) {
|
|
|
10363
11374
|
}
|
|
10364
11375
|
function readPatch(o) {
|
|
10365
11376
|
const patch = {};
|
|
10366
|
-
const
|
|
10367
|
-
if (
|
|
10368
|
-
patch.testRecipients =
|
|
11377
|
+
const testRecipients2 = optStringList(o, "testRecipients");
|
|
11378
|
+
if (testRecipients2 !== void 0) {
|
|
11379
|
+
patch.testRecipients = testRecipients2;
|
|
10369
11380
|
}
|
|
10370
11381
|
const p = optObject(o, "publication");
|
|
10371
11382
|
if (p !== void 0) {
|
|
@@ -10610,7 +11621,7 @@ function createRouter({
|
|
|
10610
11621
|
}) {
|
|
10611
11622
|
const devOnly = (...defs) => devMode ? defs : [];
|
|
10612
11623
|
const lead = `${formatLead(minLeadMs)} on this deployment (\`deployment.minLeadMs\` in \`GET /api/settings\`); a deployment sets it with \`MIN_LEAD_SECONDS\`, never below ${formatLead(MIN_LEAD_FLOOR_MS)}`;
|
|
10613
|
-
const conditionsText = `\`conditions\` is what is wrong with the send, or worth knowing, now (SPEC \xA78, \xA712), derived by the server from the send and the same on every route, most severe first: \`missed\` (still scheduled ${MISSED_THRESHOLD_MS / 6e4} minutes past its fire time: the sweep is not running), \`stuck\` (
|
|
11624
|
+
const conditionsText = `\`conditions\` is what is wrong with the send, or worth knowing, now (SPEC \xA78, \xA712), derived by the server from the send and the same on every route, most severe first: \`missed\` (still scheduled ${MISSED_THRESHOLD_MS / 6e4} minutes past its fire time: the sweep is not running), \`stuck\` (in flight too long: no recipient handed off for ${STUCK_THRESHOLD_MS / 6e4} minutes and nothing to explain it, since a halt explains the wait until its \`retry_at\` and a wedged send waits for Resolve; a send that keeps handing off is never stuck, however long it takes), \`wedged\` (\`count\` recipients whose delivery is unknown, settled by Resolve), \`refused\` (the provider refusing the account, or this send's own email, with its \`cause\`, its own words as \`error\`, the \`advice\` for the fix, which is outside this API, and \`retry_at\`; the send resumes on its own once the account is fixed, having consumed no one), \`provider_unavailable\` (an outage or a rate limit, retried on its own at \`retry_at\`; \`warn\` rather than \`info\` for a rate limit that has refused the first request of ${SELF_THROTTLE_HALTS} retries in a row, which the deployment's SES_MAX_SEND_RATE causes, and its message says so), \`bounce_spike\` (a send finished within ${BOUNCE_SPIKE_RECENT_MS / 864e5} days whose confirmed bounces reached ${BOUNCE_SPIKE_RATE * 100}% of its audience at fire, at least ${BOUNCE_SPIKE_MIN}; \`bounced\` and \`rate\`), \`no_receipts\` (a send finished within ${BOUNCE_SPIKE_RECENT_MS / 864e5} days, ${NO_RECEIPTS_AFTER_MS / 6e4} minutes or more after its dispatch finished, with not one delivery, bounce, or complaint receipt for the \`accepted\` recipients the provider took: the provider's events may not be reaching the webhook, so bounces and complaints suppress no one), \`remade\` (a template or identity change re-made the scheduled email after its last test, \`tested_at\`; a test of the post clears it), and \`daily_writes\` (on the Workers Free plan's limits, a scheduled send to more than ${DAILY_WRITES_SEND_RECIPIENTS.toLocaleString("en-US")} recipients, or to fewer when imports already wrote on the UTC day it fires, which may spend D1's daily allowance of written rows and stop the instance until midnight UTC; \`recipients\`; a warning, never a refusal). Each has a \`severity\` (\`action\`: a person must act; \`warn\`: worth a look; \`info\`), \`since\` (null when the record does not say), a \`message\` to show or relay as it stands, and the \`action\` that settles it, if any. \`actions\` is exactly what the server would accept on the send now, each \`{name, method, path}\`: \`cancel\` and \`reschedule\` while it is scheduled and its fire time is still ahead, \`resolve\` while it is wedged.`;
|
|
10614
11625
|
const notConnected = "Refused with 409 `provider_not_connected` while no email provider is connected (SPEC \xA79), since nothing can be sent.";
|
|
10615
11626
|
const actionText = "An act carries no precondition on the send you read: it applies to the send as it stands, within the rules above, and the answer shows the result. An `If-Match` header is ignored. Every refusal carries the send as it stands in `send`, in the `GET /api/sends` row shape.";
|
|
10616
11627
|
const sendExample = (over = {}) => ({
|
|
@@ -10618,6 +11629,7 @@ function createRouter({
|
|
|
10618
11629
|
post_id: "p_abc123",
|
|
10619
11630
|
subject: "Spring migration",
|
|
10620
11631
|
status: "sending",
|
|
11632
|
+
email_only: false,
|
|
10621
11633
|
rev: 57,
|
|
10622
11634
|
as_of: 176846761e4,
|
|
10623
11635
|
fire_at: 17684676e5,
|
|
@@ -10699,10 +11711,10 @@ function createRouter({
|
|
|
10699
11711
|
path: "/api/whoami",
|
|
10700
11712
|
access: "admin",
|
|
10701
11713
|
resource: "system",
|
|
10702
|
-
summary: "The authenticated principal and the auth mode: how this request signed in (access or dev).",
|
|
11714
|
+
summary: "The authenticated principal and the auth mode: how this request signed in (access or dev). A service token's principal carries its Client ID as `tokenId`.",
|
|
10703
11715
|
handler: (c) => {
|
|
10704
|
-
const { kind, email: email2, via } = unwrap(c.principal, "principal");
|
|
10705
|
-
return json({ principal: { kind, email: email2 }, auth: { mode: via } });
|
|
11716
|
+
const { kind, email: email2, tokenId, via } = unwrap(c.principal, "principal");
|
|
11717
|
+
return json({ principal: { kind, email: email2, tokenId }, auth: { mode: via } });
|
|
10706
11718
|
}
|
|
10707
11719
|
},
|
|
10708
11720
|
{
|
|
@@ -10875,13 +11887,14 @@ function createRouter({
|
|
|
10875
11887
|
accepts: JSON_BODY,
|
|
10876
11888
|
resource: "settings",
|
|
10877
11889
|
summary: "Send a sample post through the saved email template, to `to` or the default recipients (I5).",
|
|
10878
|
-
description: `Renders the stored template, never unsaved editor content; saving first is the client's job, and that save may need the re-make acknowledgement (see \`PUT /api/settings\`). The answer names the recipients the provider didn't accept in \`failed\`, so a client can offer exactly those again and no one gets a second copy. ${notConnected}`,
|
|
11890
|
+
description: `Renders the stored template, never unsaved editor content; saving first is the client's job, and that save may need the re-make acknowledgement (see \`PUT /api/settings\`). The answer names the recipients the provider didn't accept in \`failed\`, so a client can offer exactly those again and no one gets a second copy, and says why for each in \`not_sent\`, in the same order (see \`POST /api/posts/:id/test\`). ${notConnected}`,
|
|
10879
11891
|
example: {
|
|
10880
11892
|
request: { to: "you@example.com" },
|
|
10881
11893
|
response: {
|
|
10882
11894
|
sent: 1,
|
|
10883
11895
|
total: 1,
|
|
10884
11896
|
failed: [],
|
|
11897
|
+
not_sent: [],
|
|
10885
11898
|
provider: "fake",
|
|
10886
11899
|
subject: "Template test \u2014 \u2026"
|
|
10887
11900
|
}
|
|
@@ -10915,10 +11928,22 @@ function createRouter({
|
|
|
10915
11928
|
accepts: JSON_BODY,
|
|
10916
11929
|
resource: "posts",
|
|
10917
11930
|
summary: "Create a draft post. Its slug follows the subject (`slug_auto: true`) unless you send a `slug`, which sets it by hand.",
|
|
11931
|
+
description: "The post is made with its unlisted link, `unlisted_url` (`<app origin>/email/<unlisted_token>`), which every test and send of it carries as view-in-browser and which never changes (SPEC \xA75). Every post a route answers carries both its addresses, absolute: `unlisted_url`, and `archive_url`, where a listed post is published once its send starts, whether or not it shows anything yet. Beside them, `published_at` is when the post's send started (null before), and `archive_live` whether `archive_url` shows the post now. A reader can see the post at `archive_url` when `archive_live`, otherwise at `unlisted_url` once `published_at` is set; which to share is the publisher's call. `status` stays `scheduled` while the send is going out, so read `published_at`, not `status`, for whether a post is public. The slug is the post's archive address, unique among all posts, drafts included (SPEC \xA74). A slug sent by hand must be lowercase letters and numbers with single hyphens, a 400 `slug_invalid` otherwise, and no other post's, a 409 `slug_taken` otherwise. A slug that follows the subject takes the subject's slug, or the first free number (`-2`) when another post holds it, and `subject_slug_held` in the answer names the slug it couldn't take. That field is not a refusal: the save succeeded, with the numbered slug.",
|
|
10918
11932
|
example: {
|
|
10919
11933
|
request: { subject: "Hello, world", markdown: "# Hello\n\nWelcome." },
|
|
10920
11934
|
response: {
|
|
10921
|
-
post: {
|
|
11935
|
+
post: {
|
|
11936
|
+
id: "p_abc123",
|
|
11937
|
+
status: "draft",
|
|
11938
|
+
slug: "hello-world",
|
|
11939
|
+
slug_auto: true,
|
|
11940
|
+
unlisted_token: "9f2c41d0-7ab35e8c-6d1f04b2-a7e9c350",
|
|
11941
|
+
archive_url: "https://newsletter.example.com/archive/hello-world",
|
|
11942
|
+
unlisted_url: "https://newsletter.example.com/email/9f2c41d0-7ab35e8c-6d1f04b2-a7e9c350",
|
|
11943
|
+
published_at: null,
|
|
11944
|
+
archive_live: false
|
|
11945
|
+
},
|
|
11946
|
+
subject_slug_held: null,
|
|
10922
11947
|
revision_id: "r_1"
|
|
10923
11948
|
}
|
|
10924
11949
|
},
|
|
@@ -10958,6 +11983,7 @@ function createRouter({
|
|
|
10958
11983
|
access: "admin",
|
|
10959
11984
|
resource: "posts",
|
|
10960
11985
|
summary: "One post with its current markdown and any active schedule; `scheduled.remade_at` says when a template or identity change last re-made its email.",
|
|
11986
|
+
description: "`subject_slug_held` is the slug the post's subject gives it when another post holds it, so its address didn't follow the subject; null otherwise, and once the post is published, since its address is fixed (SPEC \xA74). It reports a numbered slug, not a refusal.",
|
|
10961
11987
|
handler: getPost2
|
|
10962
11988
|
},
|
|
10963
11989
|
{
|
|
@@ -10967,7 +11993,7 @@ function createRouter({
|
|
|
10967
11993
|
accepts: JSON_BODY,
|
|
10968
11994
|
resource: "posts",
|
|
10969
11995
|
summary: "Update a draft. Send the revision you read as `base_revision` (or If-Match): a save without one is a 428 `base_required` carrying the post as it stands, and one whose base is no longer current is a 409 `stale_revision`.",
|
|
10970
|
-
description: "Only a draft is editable; a scheduled post is soft-locked until its schedule is canceled, and a save to a post that is not a draft is a 409 `post_not_draft` (its message says whether canceling the schedule would unlock it). While the post's slug follows the subject (`slug_auto: true`), every save derives it from the subject again
|
|
11996
|
+
description: "Only a draft is editable; a scheduled post is soft-locked until its schedule is canceled, and a save to a post that is not a draft is a 409 `post_not_draft` (its message says whether canceling the schedule would unlock it). While the post's slug follows the subject (`slug_auto: true`), every save derives it from the subject again, taking the first free number after the subject's slug (`-2`) when another post holds it, and `subject_slug_held` names the one it couldn't take. Take the slug from the answer. Sending `slug` sets it by hand and turns `slug_auto` off; `slug_auto: true` has it follow the subject again. Sending both is a 400 naming `slug`. A slug set by hand that is malformed is a 400 `slug_invalid`, and one another post holds a 409 `slug_taken`; neither saves anything.",
|
|
10971
11997
|
example: {
|
|
10972
11998
|
request: {
|
|
10973
11999
|
subject: "Hello again, world",
|
|
@@ -10994,6 +12020,33 @@ function createRouter({
|
|
|
10994
12020
|
summary: "Delete a draft and its revisions (drafts only; any other post is a 409 `post_not_draft`).",
|
|
10995
12021
|
handler: deletePost2
|
|
10996
12022
|
},
|
|
12023
|
+
{
|
|
12024
|
+
method: "PUT",
|
|
12025
|
+
path: "/api/posts/:id/settings",
|
|
12026
|
+
access: "admin",
|
|
12027
|
+
accepts: JSON_BODY,
|
|
12028
|
+
resource: "posts",
|
|
12029
|
+
summary: "Set a post's publishing options: where it is listed (`email_only`) and its archive address (`slug`, `slug_auto`) (SPEC \xA74, \xA75).",
|
|
12030
|
+
description: "Any subset of the three. Allowed in any state, a scheduled or sent post's included: none is part of the frozen email, which carries the post's unlisted link, so a change never re-makes a scheduled send or alters a sent one (I3). `email_only: true` mails the post as usual but leaves it out of the archive index and the landing page, and its archive address stops answering, though it stays the post's; its unlisted link opens the page. `false` lists it. Schedule and Send now take the same three fields; a reschedule takes none, since it moves only the time. `slug` sets the archive address by hand and `slug_auto: true` has it follow the subject again, as for a save: a malformed slug is a 400 `slug_invalid` and another post's a 409 `slug_taken`. Once the post's send has started, or while its scheduled email (frozen before unlisted links) links its archive address, its address is fixed: a change to it is a 409 `slug_permanent`, while naming the address it has is fine. Each of the post's sends carries the listing and the address in its view, so they take a new `rev` and the send feed reports them. Answers with the post as it now stands (its `archive_live` says at once whether its archive address shows it), its `subject_slug_held`, and `changed: false` when nothing changed. A body naming none of the three, or a field of the wrong type, is a 400 naming it.",
|
|
12031
|
+
example: {
|
|
12032
|
+
request: { email_only: false, slug: "hello-world" },
|
|
12033
|
+
response: {
|
|
12034
|
+
post: {
|
|
12035
|
+
id: "p_abc123",
|
|
12036
|
+
status: "sent",
|
|
12037
|
+
slug: "hello-world",
|
|
12038
|
+
email_only: false,
|
|
12039
|
+
archive_url: "https://newsletter.example.com/archive/hello-world",
|
|
12040
|
+
unlisted_url: "https://newsletter.example.com/email/9f2c41d0-7ab35e8c-6d1f04b2-a7e9c350",
|
|
12041
|
+
published_at: 17684676e5,
|
|
12042
|
+
archive_live: true
|
|
12043
|
+
},
|
|
12044
|
+
subject_slug_held: null,
|
|
12045
|
+
changed: true
|
|
12046
|
+
}
|
|
12047
|
+
},
|
|
12048
|
+
handler: setSettings
|
|
12049
|
+
},
|
|
10997
12050
|
{
|
|
10998
12051
|
method: "GET",
|
|
10999
12052
|
path: "/api/posts/:id/revisions",
|
|
@@ -11018,6 +12071,7 @@ function createRouter({
|
|
|
11018
12071
|
accepts: [...FORM_UPLOAD, ...RASTER_IMAGE_TYPES],
|
|
11019
12072
|
resource: "posts",
|
|
11020
12073
|
summary: "Upload an image to a draft (multipart form field `file`); a post that is not a draft is a 409 `post_not_draft`.",
|
|
12074
|
+
description: "The post's Markdown references the image by the `filename` the response gives, ``, and the render resolves that name to the stored image.",
|
|
11021
12075
|
handler: uploadImage
|
|
11022
12076
|
},
|
|
11023
12077
|
{
|
|
@@ -11043,7 +12097,7 @@ function createRouter({
|
|
|
11043
12097
|
access: "admin",
|
|
11044
12098
|
resource: "posts",
|
|
11045
12099
|
summary: "Render the post to its email (returns the hosted URL, subject, warnings, and `frozen`).",
|
|
11046
|
-
description: "Once the post is scheduled this is its frozen copy, exactly as it will fire, and once sent the record's; a draft renders live (SPEC \xA75).",
|
|
12100
|
+
description: "Once the post is scheduled this is its frozen copy, exactly as it will fire, and once sent the record's; a draft renders live (SPEC \xA75). A frozen copy's `warnings` are the ones its render gave when it was frozen or last re-made.",
|
|
11047
12101
|
handler: preview
|
|
11048
12102
|
},
|
|
11049
12103
|
{
|
|
@@ -11071,11 +12125,12 @@ function createRouter({
|
|
|
11071
12125
|
accepts: JSON_BODY,
|
|
11072
12126
|
resource: "posts",
|
|
11073
12127
|
summary: "Send a test to one address through the same per-recipient path as a real send (I5).",
|
|
11074
|
-
description: "Once the post is scheduled this sends its frozen copy, exactly as it will fire, and once sent the record's; a draft's test is a live render (SPEC \xA75). The response's `frozen` says which. A test of a scheduled send's frozen copy is recorded as its `tested_at`, which clears the send's `remade` condition. " + notConnected,
|
|
12128
|
+
description: "Once the post is scheduled this sends its frozen copy, exactly as it will fire, and once sent the record's; a draft's test is a live render (SPEC \xA75). The response's `frozen` says which, and its `warnings` are that copy's: a frozen copy's are the ones its render gave when it was frozen or last re-made. A test of a scheduled send's frozen copy is recorded as its `tested_at`, which clears the send's `remade` condition. `sent` is true only when the provider took the test. When it didn't, `not_sent` says why: the provider's own words as `error` (never a credential), a `message` to show or relay as it stands, with the fix where it is known (the same advice as a send's `refused` condition, so a test refused for the account or the address isn't simply tried again), and `may_have_arrived`, true when the provider never answered: then the test may be in the inbox, and the answer is still a 200, never a server error. " + notConnected,
|
|
11075
12129
|
example: {
|
|
11076
12130
|
request: { to: "you@example.com" },
|
|
11077
12131
|
response: {
|
|
11078
12132
|
sent: true,
|
|
12133
|
+
not_sent: null,
|
|
11079
12134
|
provider: "ses",
|
|
11080
12135
|
to: "you@example.com",
|
|
11081
12136
|
subject: "Hello, world",
|
|
@@ -11120,10 +12175,10 @@ function createRouter({
|
|
|
11120
12175
|
accepts: JSON_BODY,
|
|
11121
12176
|
resource: "posts",
|
|
11122
12177
|
summary: `Freeze the render and schedule the send for a future time (at least ${formatLead(minLeadMs)} out).`,
|
|
11123
|
-
description: "Freezes the current draft, with the template and identity as they stand, onto a send row and soft-locks the post; cancelable until its fire time. A later template or identity change re-makes that frozen email after the publisher confirms it (SPEC \xA76); there is no per-send template to name, and a `template_revision` field is a 400. `fire_at` is epoch milliseconds or an ISO-8601 timestamp with a `Z` or `\xB1hh:mm` offset; a timestamp without one is a 400, since the Worker cannot know which local time was meant. `fire_at` must be at least the minimum lead out, a 400 `fire_at_too_soon` otherwise (for the soonest send, use Send now, `POST /api/posts/:id/send`): " + lead + ". The time is then rounded up to the next whole minute (one already on the minute stays), since the sweep fires sends on the minute; the lead is checked on the time as requested, so rounding only lengthens the window. Answers with the new send's view (as `GET /api/sends/:id` describes it)
|
|
12178
|
+
description: "Freezes the current draft, with the template and identity as they stand, onto a send row and soft-locks the post; cancelable until its fire time. A later template or identity change re-makes that frozen email after the publisher confirms it (SPEC \xA76); there is no per-send template to name, and a `template_revision` field is a 400. `fire_at` is epoch milliseconds or an ISO-8601 timestamp with a `Z` or `\xB1hh:mm` offset; a timestamp without one is a 400, since the Worker cannot know which local time was meant. `fire_at` must be at least the minimum lead out, a 400 `fire_at_too_soon` otherwise (for the soonest send, use Send now, `POST /api/posts/:id/send`): " + lead + ". The time is then rounded up to the next whole minute (one already on the minute stays), since the sweep fires sends on the minute; the lead is checked on the time as requested, so rounding only lengthens the window. `email_only` (optional) sets the post's listing as it is scheduled (SPEC \xA75): `true` mails it as usual but leaves it out of the archive index and the landing page, `false` lists it; absent, the post keeps the listing it has, which is in the archive unless it was set otherwise. `slug` and `slug_auto` (optional) set its archive address as `PUT /api/posts/:id/settings` does, checked before anything is frozen, so a 400 `slug_invalid` or 409 `slug_taken` schedules nothing, and a refused schedule changes neither. None is part of the frozen email. Answers with the new send's view (as `GET /api/sends/:id` describes it), a `cursor` to follow it from, and `warnings`, what the render found wrong with the frozen copy (an image the post never uploaded, say), which preview and test of the copy report too (SPEC \xA75). Refusals: 409 `active_send_exists` when the post already has its one active send (carried as `send`), 409 `post_not_draft`, 400 `subject_required` for an empty subject, 409 `settings_changed` when a template or identity save kept landing while it froze (try again), and 409 `provider_not_connected` while no email provider is connected (SPEC \xA79).",
|
|
11124
12179
|
example: {
|
|
11125
12180
|
request: { fire_at: "2026-01-16T09:00:00Z" },
|
|
11126
|
-
response: { send: scheduledExample, cursor: "57.1768467610000" }
|
|
12181
|
+
response: { send: scheduledExample, cursor: "57.1768467610000", warnings: [] }
|
|
11127
12182
|
},
|
|
11128
12183
|
handler: schedule
|
|
11129
12184
|
},
|
|
@@ -11134,9 +12189,10 @@ function createRouter({
|
|
|
11134
12189
|
accepts: JSON_BODY,
|
|
11135
12190
|
resource: "posts",
|
|
11136
12191
|
summary: "Send now: freeze and schedule one minimum lead out, on the minute, cancelable until then. Idempotent per post.",
|
|
11137
|
-
description: "The same freeze as scheduling, with the template and identity as they stand, and `fire_at` set to now plus the minimum lead, rounded up to the next whole minute as every fire time is: " + lead + ". For nearly all of that window the send is inside the minimum lead, so a template or identity save that would re-make it is refused until it has fired. A second call for a post already in its window answers with that send and `idempotent: true
|
|
12192
|
+
description: "The same freeze as scheduling, with the template and identity as they stand, and `fire_at` set to now plus the minimum lead, rounded up to the next whole minute as every fire time is: " + lead + ". For nearly all of that window the send is inside the minimum lead, so a template or identity save that would re-make it is refused until it has fired. An optional JSON body may carry `email_only`, `slug`, and `slug_auto`, the post's settings, as for scheduling. A second call for a post already in its window answers with that send and `idempotent: true`, and changes nothing, its listing included; one that names settings the post doesn't have is a 409 `active_send_exists` carrying the send, so a caller that asked for email only never reads success for a post going out listed (change them with `PUT /api/posts/:id/settings`). Its answer carries `warnings` as scheduling's does, a repeat's included. The other refusals are as for scheduling.",
|
|
11138
12193
|
example: {
|
|
11139
|
-
|
|
12194
|
+
request: { email_only: true },
|
|
12195
|
+
response: { send: scheduledExample, cursor: "57.1768467610000", warnings: [] }
|
|
11140
12196
|
},
|
|
11141
12197
|
handler: sendNow
|
|
11142
12198
|
},
|
|
@@ -11146,7 +12202,7 @@ function createRouter({
|
|
|
11146
12202
|
access: "admin",
|
|
11147
12203
|
resource: "sends",
|
|
11148
12204
|
summary: "List sends with delivery progress. Filter, sort, and paginate via query params; returns a `page` envelope. Each row is the send's view, the same shape `GET /api/sends/:id`, the feed, and every action answer with (see `GET /api/sends/:id`), read at the same moment, so a list row and the watch never read a send differently. A row's `rev` rises with every change a reader could see (a cancel, a move, a re-make, dispatch progress, a receipt), ordered across all sends. The response's `cursor` marks where this read stood among those changes: `<seq>.<at>`, the change sequence at the read and the server's time of it (epoch milliseconds), both decimal; pass it to `GET /api/sends/feed` as `since`. `audience.count` is a snapshot of the audience while a send is scheduled; once it fires (`audience.fixed`), it is the audience fixed at fire, which never grows.",
|
|
11149
|
-
description: "`provider.halt` describes a `sending` send whose provider refused its last batch as a whole (SPEC \xA712), and is null otherwise: its `reason`, `cause`, the provider's own words as `error`, and `since`. The send retries on its own, spaced out the longer the halt lasts: `retries` counts the halted attempts in a row and `retry_at` is when the next is due, up to an hour apart, and both reset the moment a
|
|
12205
|
+
description: "`provider.halt` describes a `sending` send whose provider refused its last batch as a whole (SPEC \xA712), and is null otherwise: its `reason`, `cause`, the provider's own words as `error`, and `since`. A halt is the account's or the provider's, so a send that fires or resumes while another send waits one out joins it without asking the provider, unless the halt is the provider refusing that one send (`content` or `invalid_request`, below): it carries the same halt and retries with it, and fixes its audience when it first hands off (`audience.fixed` stays false until then). The send retries on its own, spaced out the longer the halt lasts: `retries` counts the halted attempts in a row and `retry_at` is when the next is due, up to an hour apart, and both reset the moment the provider takes anyone, except that an answer that takes some and refuses the rest for the account (a quota freeing up a few at a time) continues an `account` halt, its `since` and its spacing. `unavailable` is an outage or a rate limit, retried " + retrySchedule(HALT_BACKOFF_MS.unavailable) + ", shown as the `provider_unavailable` condition and the `backing-off` phase while it lasts; it clears on its own, so it is never also `stuck` until its retry is long overdue. `account` is the provider refusing the account itself, retried " + retrySchedule(HALT_BACKOFF_MS.account) + "; `cause` names what (`credentials`, `opt_in`, `configuration`, `sending_pool`, `unsupported`, `sender`, `mail_from`, `sandbox`, `quota`, `suspended`, `paused`, `set_paused`, or `not_connected`, a deployment with no provider connected; or this send's own `content`, an email the provider refuses, or `invalid_request`, a batch it refused as invalid without naming a recipient), and the condition's `advice` says the fix and who makes it. An `unavailable` halt's `cause` is `rate_limit` or `outage`. Either way no recipient has been consumed. The fix for `account` is outside this API (the provider's dashboard, or the deployment's secrets, which the API never exposes, SPEC \xA79), and once it lands the send resumes by itself at its next retry, so it is never rescheduled or sent again. There is no API action to retry sooner. " + conditionsText + " An unknown `status`, `failures`, `sort`, or `dir`, or a `limit` or `offset` that is not a whole number, is a 400 naming the field.",
|
|
11150
12206
|
query: [
|
|
11151
12207
|
{
|
|
11152
12208
|
name: "status",
|
|
@@ -11180,7 +12236,7 @@ function createRouter({
|
|
|
11180
12236
|
access: "admin",
|
|
11181
12237
|
resource: "sends",
|
|
11182
12238
|
summary: "What changed among sends since a cursor, each send with its phase, counters, conditions, and actions, every open condition across the sends, and when to read again: what a client follows to keep up with sends, whichever client changed them, without polling each one.",
|
|
11183
|
-
description: `With \`since\` (the \`cursor\` from \`GET /api/sends\`, \`GET /api/sends/:id\`, or this route's last read), \`sends\` is every send that changed after that read, whatever its state: a cancel, a move, a re-make, a new schedule, dispatch progress, a receipt, a completion. It also holds each send the clock changed with no write since then: one whose fire time passed (\`due\`), one past the ${MISSED_THRESHOLD_MS / 6e4}-minute missed tolerance (the \`missed\` condition), and one
|
|
12239
|
+
description: `With \`since\` (the \`cursor\` from \`GET /api/sends\`, \`GET /api/sends/:id\`, or this route's last read), \`sends\` is every send that changed after that read, whatever its state: a cancel, a move, a re-make, a new schedule, dispatch progress, a receipt, a completion. It also holds each send the clock changed with no write since then: one whose fire time passed (\`due\`), one past the ${MISSED_THRESHOLD_MS / 6e4}-minute missed tolerance (the \`missed\` condition), and one idle past the ${STUCK_THRESHOLD_MS / 6e4}-minute stuck threshold (\`stuck\`). A send turns wedged (\`wedged\`) by a write, when the run that left recipients with an unknown fate hands it back, so it is reported like any other change. \`removed\` is every send removed after the cursor (a canceled send deleted with its post), each \`{id, rev}\`, so a client drops it rather than keeping a row for a send that is gone. Without \`since\`, \`sends\` is every send that can change on its own: due, \`sending\`, or settling (\`sent\` within the last ${SETTLE_FOLLOW_MS / 6e4} minutes with a recipient still awaiting a receipt), and \`removed\` is empty. Either way each send is its view, the shape \`GET /api/sends/:id\` describes, read off the send's counters, never its delivery rows, so a send reads the same here as on its page; soonest fire first. \`cursor\` is where this read stood, to pass as \`since\` next time: \`<seq>.<at>\`, the change sequence at the read and the server's time of it (\`now\`), both decimal. The format is part of this contract, so a client holding cursors from several reads (a list, and one send's page) may compare them and follow everything from one read: the cursor with the smaller of each number answers for both. A read reports at most \`limit\` changes after the cursor, in the order they were made, and never splits the changes one write made; the sends the clock changed are always all reported. When more are waiting, \`more\` is true, \`cursor\` stands where the read stopped, and \`read_again_at\` is \`now\`: read again at once from it. A cursor ahead of this database (a sequence above the current one, or a read time more than a minute after \`now\`, as after a reset or a restore of the database) is a 409 \`cursor_ahead\` naming \`since\`: nothing after it would ever be reported, so read the sends again (\`GET /api/sends\` or \`GET /api/sends/:id\`) and follow from that read's cursor. \`read_again_at\` is when to read again, by the server's clock (\`now\`), from each unfinished send's \`next_change_at\`, the earliest it can change with no one acting: about 3 seconds while any send can move now (due, short of the missed tolerance, or sending with a run in hand or work queued for the next tick); while sends are only settling, 3 seconds, then 15, then 60, by how long ago the youngest finished dispatch; otherwise about once a minute (one sweep tick); and never later than just past the next change the clock or the sweep will make (a fire time, a halted send's next retry). A send waiting on a person (the provider refusing the account, a wedged send awaiting Resolve, a missed fire time) does not quicken it. A client that reads again then is never more than a few seconds behind a send that is moving, behind a settling send's receipts by at most that pace, and behind any other change (the other client's cancel or move, a Resolve, a late receipt) by about a minute. \`read_again_at\` is advisory: reading sooner, such as right after acting on a send, is always fine, and reading late or skipping a read loses nothing, since the next read from the last cursor reports every send that changed in between. \`GET /api/sends\` and \`GET /api/sends/:id\` carry no pace of their own: a client following from one of them reads this route at once, then keeps to its \`read_again_at\`. \`conditions\` repeats every open condition across the sends that can have one (scheduled, sending, and sent within the bounce-spike window), each with its \`send_id\` and \`subject\`, whether or not the send changed, so one read shows every open problem; most severe first. ${conditionsText} Reading it changes nothing.`,
|
|
11184
12240
|
query: [
|
|
11185
12241
|
{
|
|
11186
12242
|
name: "since",
|
|
@@ -11240,7 +12296,7 @@ function createRouter({
|
|
|
11240
12296
|
access: "admin",
|
|
11241
12297
|
resource: "sends",
|
|
11242
12298
|
summary: "One send: its view, the delivery-outcome breakdown of its record, and the `cursor` marking where this read stood among the changes to sends, to follow it from with `GET /api/sends/feed` (it carries no pace, so read the feed at once from it).",
|
|
11243
|
-
description: "`send` is the send's view, the one shape every send route carries (the list, the feed, every action's answer and refusal), so a client keeps one copy current from any of them and tells two apart by `rev`, the higher the newer. It holds the stored facts: `status` (`scheduled`, `sending`, `sent`, `canceled`), the times, `remade_at` (a template or identity change re-made it while scheduled) and `tested_at`, and `audience` (`count`, an estimate until the send fires and the audience at fire once `fixed`, from `fixed_at`). Beside them, what the server derives as of `as_of`, the server's clock at the read: `counts` (every recipient in one bucket), `dispatch` (hand-off progress; `rate_per_min` is what a sweep tick carries, one tick a minute, and `eta_ms` counts the ticks the rest needs, the wait between them included, given only while it is handing off and once a tick has finished, never while paused), `delivery` (receipts over accepted), `provider` (its `name`, and `halt` while the provider refuses its batches), `phase` (`scheduled`, `due`, `progressing`, `retrying`, `backing-off`, `needs-attention`, `settling`, `complete`, `canceled`), `conditions`, `actions`, and `next_change_at` (see `GET /api/sends/feed`). `links` names the send's other resources: the frozen email (`email_html`, `email_text`), the recipient rows and their CSV, the post, and `archive`, the published page, absolute, once the send is sent. " + conditionsText + " `outcomes` counts the record's recipients by outcome from its rows, summing to the audience at fire. The answer carries `ETag`: the send's `rev` and a tag of what the clock derives from it, so a request with `If-None-Match` answers 304 while nothing about the send has changed, written or derived.",
|
|
12299
|
+
description: "`send` is the send's view, the one shape every send route carries (the list, the feed, every action's answer and refusal), so a client keeps one copy current from any of them and tells two apart by `rev`, the higher the newer. It holds the stored facts: `status` (`scheduled`, `sending`, `sent`, `canceled`), the times, `remade_at` (a template or identity change re-made it while scheduled) and `tested_at`, and `audience` (`count`, an estimate until the send fires and the audience at fire once `fixed`, from `fixed_at`). Beside them, what the server derives as of `as_of`, the server's clock at the read: `counts` (every recipient in one bucket), `dispatch` (hand-off progress; `rate_per_min` is what a sweep tick carries, one tick a minute, and `eta_ms` counts the ticks the rest needs, the wait between them included, given only while it is handing off and once a tick has finished, never while paused or in flight too long), `delivery` (receipts over accepted), `provider` (its `name`, and `halt` while the provider refuses its batches), `phase` (`scheduled`, `due`, `progressing`, `retrying`, `backing-off`, `needs-attention`, `settling`, `complete`, `canceled`), `conditions`, `actions`, and `next_change_at` (see `GET /api/sends/feed`). `links` names the send's other resources: the frozen email (`email_html`, `email_text`), the recipient rows and their CSV, the post, and `archive`, the published page, absolute, once the send is sent. " + conditionsText + " `outcomes` counts the record's recipients by outcome from its rows, summing to the audience at fire. The answer carries `ETag`: the send's `rev` and a tag of what the clock derives from it, so a request with `If-None-Match` answers 304 while nothing about the send has changed, written or derived.",
|
|
11244
12300
|
example: {
|
|
11245
12301
|
response: {
|
|
11246
12302
|
send: sendExample(),
|
|
@@ -11348,7 +12404,7 @@ function createRouter({
|
|
|
11348
12404
|
accepts: JSON_BODY,
|
|
11349
12405
|
resource: "sends",
|
|
11350
12406
|
summary: "Move a scheduled send's fire time without re-freezing the render (I3, I6).",
|
|
11351
|
-
description: "Updates only `fire_at` on a send still in its review window: the frozen render is untouched (the audience is resolved when the send fires) and the review window is preserved; a re-made send keeps its `remade_at`. Distinct from cancel \u2192 edit \u2192 schedule again, which is for content changes. The same `fire_at` form as scheduling (epoch milliseconds or an ISO-8601 timestamp with a `Z` or `\xB1hh:mm` offset), and the same minimum lead, a 400 `fire_at_too_soon` otherwise: " + lead + ". As when scheduling, the new time is rounded up to the next whole minute, after the lead is checked on the time as requested. A move to the time the send already has (after that rounding) answers 200 with `changed: false` before the lead is checked, so a retried move is safe. Once the fire time has passed it is a 409 `window_closed`, and a canceled send is a 409 `send_canceled` (schedule the post again instead). " + actionText,
|
|
12407
|
+
description: "Updates only `fire_at` on a send still in its review window: the frozen render is untouched (the audience is resolved when the send fires) and the review window is preserved; a re-made send keeps its `remade_at`. Distinct from cancel \u2192 edit \u2192 schedule again, which is for content changes. The same `fire_at` form as scheduling (epoch milliseconds or an ISO-8601 timestamp with a `Z` or `\xB1hh:mm` offset), and the same minimum lead, a 400 `fire_at_too_soon` otherwise: " + lead + ". As when scheduling, the new time is rounded up to the next whole minute, after the lead is checked on the time as requested. A move to the time the send already has (after that rounding) answers 200 with `changed: false` before the lead is checked, so a retried move is safe. Once the fire time has passed it is a 409 `window_closed`, and a canceled send is a 409 `send_canceled` (schedule the post again instead). The post's settings are not a reschedule's: `email_only`, `slug`, or `slug_auto` is a 400 naming the field, which moves nothing; change them with `PUT /api/posts/:id/settings`. " + actionText,
|
|
11352
12408
|
example: {
|
|
11353
12409
|
request: { fire_at: "2026-01-16T09:00:00Z" },
|
|
11354
12410
|
response: { send: scheduledExample, cursor: "58.1768467620000", changed: true }
|
|
@@ -11738,7 +12794,19 @@ function createRouter({
|
|
|
11738
12794
|
handler: unsubscribe
|
|
11739
12795
|
},
|
|
11740
12796
|
// --- delivery webhooks (public; provider-signature-verified, not requireAuth) ---
|
|
11741
|
-
// ---
|
|
12797
|
+
// --- unlisted links (public; the "view in browser" every email carries, SPEC §5) ---
|
|
12798
|
+
// On the app's own origin and independent of ARCHIVE_BASE_PATH, so moving the archive
|
|
12799
|
+
// breaks no email already sent.
|
|
12800
|
+
{
|
|
12801
|
+
method: "GET",
|
|
12802
|
+
path: `${UNLISTED_PATH}/:token`,
|
|
12803
|
+
access: "public",
|
|
12804
|
+
resource: "archive",
|
|
12805
|
+
summary: "A post's unlisted link, the view-in-browser link every test and send carries (SPEC \xA75).",
|
|
12806
|
+
description: "Before the post's send starts, the same `Nothing here yet` page (404) as any unknown token. Once it has: while the post is listed, a temporary redirect (302, `Cache-Control: no-store`, `X-Robots-Tag: noindex`) to its own archive address, since the listing can change, and never to anything the request names; while it is email only, the post's page itself, with `X-Robots-Tag: noindex` and `Cache-Control: no-cache`. Public, but never listed or linked from any page. A GET only reads, so a mail client's link scanner following it changes nothing.",
|
|
12807
|
+
handler: unlistedPage
|
|
12808
|
+
},
|
|
12809
|
+
// --- archive (public; serves the frozen record, I3) ---
|
|
11742
12810
|
// Registered at ARCHIVE_BASE_PATH (default /archive) so the index, the post
|
|
11743
12811
|
// pages, and the emitted archive URLs always share one source. Self-contained by
|
|
11744
12812
|
// default; an apex zone can additionally route <base>/* to this Worker (SPEC §11).
|
|
@@ -11758,7 +12826,8 @@ function createRouter({
|
|
|
11758
12826
|
path: `${archiveBasePath}/:slug`,
|
|
11759
12827
|
access: "public",
|
|
11760
12828
|
resource: "archive",
|
|
11761
|
-
summary: "A published post's archive page
|
|
12829
|
+
summary: "A published post's archive page (I3), from the moment a listed post's send starts, while it stays listed.",
|
|
12830
|
+
description: "An email-only post's address answers the `Nothing here yet` page (404) but stays the post's, so listing it again brings it back. The exception is a post whose email carries this address as its view-in-browser link (one sent before unlisted links): its address answers whatever its listing, with `X-Robots-Tag: noindex` while it is email only. The page is sent with `Cache-Control: no-cache`, so a listing change takes effect at once.",
|
|
11762
12831
|
handler: archivePage
|
|
11763
12832
|
},
|
|
11764
12833
|
// --- media bytes (public; readers + archive load these unauthenticated) ---
|
|
@@ -11784,7 +12853,12 @@ var SHIPPED_MIGRATIONS = [
|
|
|
11784
12853
|
"0003_post_slug_auto.sql",
|
|
11785
12854
|
"0004_keyed_pending_to_resolve.sql",
|
|
11786
12855
|
"0005_imports.sql",
|
|
11787
|
-
"0006_deliveries_fewer_indexes.sql"
|
|
12856
|
+
"0006_deliveries_fewer_indexes.sql",
|
|
12857
|
+
"0007_sends_progress_at.sql",
|
|
12858
|
+
"0008_notifications_sandbox.sql",
|
|
12859
|
+
"0009_post_email_only.sql",
|
|
12860
|
+
"0010_unlisted_links.sql",
|
|
12861
|
+
"0011_sends_render_warnings.sql"
|
|
11788
12862
|
];
|
|
11789
12863
|
async function missingMigrations(db) {
|
|
11790
12864
|
let applied;
|
|
@@ -11923,13 +12997,14 @@ var MAX_PER_TICK = 10;
|
|
|
11923
12997
|
function stillHolds(n, now) {
|
|
11924
12998
|
switch (n.kind) {
|
|
11925
12999
|
case "import":
|
|
13000
|
+
case "sandbox":
|
|
11926
13001
|
return true;
|
|
11927
13002
|
case "finished":
|
|
11928
13003
|
return true;
|
|
11929
13004
|
case "refused":
|
|
11930
13005
|
return n.send_status === "sending" && n.halt_reason === "account" && n.halted_at === n.episode;
|
|
11931
13006
|
case "stuck":
|
|
11932
|
-
return n.send_status ===
|
|
13007
|
+
return idleSince({ ...n, status: n.send_status }) === n.episode;
|
|
11933
13008
|
case "wedged":
|
|
11934
13009
|
return isWedged({ ...n, status: n.send_status });
|
|
11935
13010
|
case "missed":
|
|
@@ -11965,11 +13040,11 @@ async function notifyPublisher(env, budget) {
|
|
|
11965
13040
|
await recordNotificationOutcome(db, n, { status: "cleared" }, Date.now());
|
|
11966
13041
|
continue;
|
|
11967
13042
|
}
|
|
11968
|
-
const about = n.kind === "import" ? { importId: n.import_id } : { sendId: n.send_id };
|
|
11969
|
-
const key = `${n.send_id ?? n.import_id}-${n.kind}-${n.episode}`;
|
|
13043
|
+
const about = n.kind === "import" ? { importId: n.import_id } : n.kind === "sandbox" ? {} : { sendId: n.send_id };
|
|
13044
|
+
const key = `${n.send_id ?? n.import_id ?? "account"}-${n.kind}-${n.episode}`;
|
|
11970
13045
|
budget.request();
|
|
11971
13046
|
try {
|
|
11972
|
-
const email2 = n.kind === "import" ? composeImportNotification(n, config) : composeNotification(n, config);
|
|
13047
|
+
const email2 = n.kind === "import" ? composeImportNotification(n, config) : n.kind === "sandbox" ? composeSandboxNotification(config) : composeNotification(n, config);
|
|
11973
13048
|
await notifier.send(to, email2, key);
|
|
11974
13049
|
await recordNotificationOutcome(db, n, { status: "sent" }, Date.now());
|
|
11975
13050
|
log.info("notify.sent", { ...about, kind: n.kind, channel: notifier.channel });
|
|
@@ -12034,13 +13109,13 @@ var SendWindow = class {
|
|
|
12034
13109
|
// src/send/loop.ts
|
|
12035
13110
|
var MAX_CHUNK = 500;
|
|
12036
13111
|
var GROUP_RECIPIENTS = 10;
|
|
12037
|
-
var OPEN_COST2 =
|
|
13112
|
+
var OPEN_COST2 = 6;
|
|
12038
13113
|
var GROUP_COST = 8;
|
|
12039
13114
|
var GROUP_TYPICAL_COST = 5;
|
|
12040
13115
|
var CLOSE_COST = 4;
|
|
12041
13116
|
var MIN_RUN_COST = OPEN_COST2 + GROUP_COST + 1 + CLOSE_COST;
|
|
12042
13117
|
function worseHalt(current2, next) {
|
|
12043
|
-
return !current2 ||
|
|
13118
|
+
return !current2 || haltRank(next) > haltRank(current2) ? next : current2;
|
|
12044
13119
|
}
|
|
12045
13120
|
function chunk2(items, size) {
|
|
12046
13121
|
const out = [];
|
|
@@ -12095,6 +13170,20 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
|
|
|
12095
13170
|
renewAt = Date.now() + LEASE_TTL_MS / 2;
|
|
12096
13171
|
return renewLease(db, sendId, lease, Date.now() + LEASE_TTL_MS);
|
|
12097
13172
|
};
|
|
13173
|
+
const standing = await accountHalt(db, sendId, Date.now() + HALT_RETRY_SLACK_MS);
|
|
13174
|
+
if (standing) {
|
|
13175
|
+
await joinHalt(db, sendId, lease, standing);
|
|
13176
|
+
log.warn("send.halted", {
|
|
13177
|
+
...tags,
|
|
13178
|
+
reason: standing.reason,
|
|
13179
|
+
cause: standing.cause,
|
|
13180
|
+
error: standing.error,
|
|
13181
|
+
retryAt: new Date(standing.retryAt).toISOString(),
|
|
13182
|
+
heldBy: standing.sendId
|
|
13183
|
+
});
|
|
13184
|
+
await releaseLease(db, sendId, lease);
|
|
13185
|
+
return { ...empty, leased: true, halt: standing.reason };
|
|
13186
|
+
}
|
|
12098
13187
|
if (send.audience_resolved_at === null) {
|
|
12099
13188
|
await resolveAudience(db, sendId, now);
|
|
12100
13189
|
}
|
|
@@ -12110,7 +13199,7 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
|
|
|
12110
13199
|
const held = [];
|
|
12111
13200
|
let halt = null;
|
|
12112
13201
|
let requeued = 0;
|
|
12113
|
-
let
|
|
13202
|
+
let progressed = false;
|
|
12114
13203
|
let complete = true;
|
|
12115
13204
|
let latencyMs = 0;
|
|
12116
13205
|
let heldCount = 0;
|
|
@@ -12178,7 +13267,7 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
|
|
|
12178
13267
|
batchHalt = answer.halt;
|
|
12179
13268
|
} else {
|
|
12180
13269
|
if (answer.results.some((r) => r.accepted || r.held !== true)) {
|
|
12181
|
-
|
|
13270
|
+
progressed = true;
|
|
12182
13271
|
}
|
|
12183
13272
|
if (answer.halt) {
|
|
12184
13273
|
halt = worseHalt(halt, answer.halt);
|
|
@@ -12216,7 +13305,11 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
|
|
|
12216
13305
|
halt = worseHalt(halt, batchHalt);
|
|
12217
13306
|
}
|
|
12218
13307
|
}
|
|
12219
|
-
|
|
13308
|
+
const cleared = progressed && halt?.reason !== "account";
|
|
13309
|
+
await settleDeliveries(db, sendId, lease, "dispatched", outcomes, Date.now(), {
|
|
13310
|
+
progressed,
|
|
13311
|
+
clears: cleared
|
|
13312
|
+
});
|
|
12220
13313
|
const heldAt = Date.now();
|
|
12221
13314
|
await holdBatch(
|
|
12222
13315
|
db,
|
|
@@ -12244,7 +13337,7 @@ async function runSend(env, sendId, budget = new Budget(getConfig(env).subreques
|
|
|
12244
13337
|
unknown,
|
|
12245
13338
|
latencyMs
|
|
12246
13339
|
});
|
|
12247
|
-
if (
|
|
13340
|
+
if (cleared && halted) {
|
|
12248
13341
|
log.info("send.resumed", {
|
|
12249
13342
|
...tags,
|
|
12250
13343
|
reason: halted.reason,
|
|
@@ -12374,6 +13467,20 @@ async function runTick(env, now, tick) {
|
|
|
12374
13467
|
const budget = new Budget(whole.limit - held, whole.queryLimit - held);
|
|
12375
13468
|
const db = metered(env.DB, budget);
|
|
12376
13469
|
const window = new SendWindow(getProvider(config, env).maxRequestRate);
|
|
13470
|
+
const resumable = await resumableSends(db, now, now + HALT_RETRY_SLACK_MS);
|
|
13471
|
+
const resume = async (s) => {
|
|
13472
|
+
if (handled.has(s.id) || !budget.affords(MIN_RUN_COST)) {
|
|
13473
|
+
return;
|
|
13474
|
+
}
|
|
13475
|
+
handled.add(s.id);
|
|
13476
|
+
tick.resumable += 1;
|
|
13477
|
+
await safeRun(env, s.id, budget, window);
|
|
13478
|
+
};
|
|
13479
|
+
for (const s of resumable) {
|
|
13480
|
+
if (s.halt_reason !== null) {
|
|
13481
|
+
await resume(s);
|
|
13482
|
+
}
|
|
13483
|
+
}
|
|
12377
13484
|
for (const s of await dueSends(db, now)) {
|
|
12378
13485
|
const lag = now - s.fire_at;
|
|
12379
13486
|
if (lag > MISSED_THRESHOLD_MS) {
|
|
@@ -12384,17 +13491,16 @@ async function runTick(env, now, tick) {
|
|
|
12384
13491
|
tick.due += 1;
|
|
12385
13492
|
await safeRun(env, s.id, budget, window);
|
|
12386
13493
|
}
|
|
12387
|
-
const resumable = budget.affords(1 + MIN_RUN_COST) ? await resumableSends(db, now, now + HALT_RETRY_SLACK_MS) : [];
|
|
12388
13494
|
for (const s of resumable) {
|
|
12389
|
-
|
|
12390
|
-
continue;
|
|
12391
|
-
}
|
|
12392
|
-
handled.add(s.id);
|
|
12393
|
-
tick.resumable += 1;
|
|
12394
|
-
await safeRun(env, s.id, budget, window);
|
|
13495
|
+
await resume(s);
|
|
12395
13496
|
}
|
|
12396
13497
|
for (const s of await stuckSends(env.DB, now - STUCK_THRESHOLD_MS)) {
|
|
12397
|
-
log.error("send.stuck", {
|
|
13498
|
+
log.error("send.stuck", {
|
|
13499
|
+
sendId: s.id,
|
|
13500
|
+
postId: s.post_id,
|
|
13501
|
+
startedAt: s.started_at,
|
|
13502
|
+
idleSince: idleSince(s)
|
|
13503
|
+
});
|
|
12398
13504
|
tick.stuck += 1;
|
|
12399
13505
|
}
|
|
12400
13506
|
for (const s of await wedgedSends(env.DB)) {
|