@kurtbruns/kestrel 1.5.0 → 1.6.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 +53 -1
- package/cli/context.mjs +20 -11
- package/cli/dev-api.mjs +73 -9
- package/cli/dev-server.mjs +72 -0
- package/cli/dev.mjs +39 -11
- package/cli/init.mjs +8 -2
- package/cli/reset.mjs +2 -3
- package/cli/seed.mjs +7 -5
- package/cli/sync-migrations.mjs +5 -1
- package/dist/public/dashboard/app.js +362 -324
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +600 -126
- package/dist/worker/index.js +1111 -802
- package/docs/get-started/02-deploy.md +1 -1
- package/docs/get-started/05-verify.md +1 -1
- package/docs/reference/01-configuration.md +1 -1
- package/migrations/0003_post_slug_auto.sql +14 -0
- package/package.json +4 -5
- package/template/claude/CLAUDE.md +2 -2
- package/template/package.json +5 -0
- package/cli/dev-port.mjs +0 -57
package/dist/worker/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @kurtbruns/kestrel 1.
|
|
1
|
+
// @kurtbruns/kestrel 1.6.0: the Worker, built by scripts/build-package.mjs.
|
|
2
2
|
|
|
3
3
|
// shared/sends.ts
|
|
4
4
|
function refusalAdvice(cause) {
|
|
@@ -33,10 +33,10 @@ var BOUNCE_SPIKE_RECENT_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
|
33
33
|
|
|
34
34
|
// src/generated/version.ts
|
|
35
35
|
var BUILD_INFO = {
|
|
36
|
-
version: "1.
|
|
37
|
-
sha: "
|
|
38
|
-
tag: "v1.
|
|
39
|
-
buildTime: "2026-10-
|
|
36
|
+
version: "1.6.0",
|
|
37
|
+
sha: "25b8b69",
|
|
38
|
+
tag: "v1.6.0",
|
|
39
|
+
buildTime: "2026-10-12T03:08:02.808Z",
|
|
40
40
|
repoUrl: "https://github.com/kurtbruns/kestrel"
|
|
41
41
|
};
|
|
42
42
|
|
|
@@ -358,6 +358,7 @@ function getConfig(env) {
|
|
|
358
358
|
const accessTeamDomain = orUndefined(env.ACCESS_TEAM_DOMAIN);
|
|
359
359
|
const devShaped = provider === "fake" && !accessTeamDomain && isLoopbackOrigin(appOrigin);
|
|
360
360
|
const devAuthSecret = devShaped ? orUndefined(env.DEV_AUTH_SECRET) : void 0;
|
|
361
|
+
const devMode = devAuthSecret !== void 0;
|
|
361
362
|
const notifyChannel = devShaped ? "fake" : env.NOTIFY ? "cloudflare" : "provider";
|
|
362
363
|
return {
|
|
363
364
|
provider,
|
|
@@ -375,8 +376,9 @@ function getConfig(env) {
|
|
|
375
376
|
// Show the dev-only dashboard link, and register the dev routes, exactly when the
|
|
376
377
|
// dev credential path is live (dev-shaped AND the secret resolved). Never true in a
|
|
377
378
|
// deployed env, so the reader surface stays clean of any admin link there.
|
|
378
|
-
devMode
|
|
379
|
+
devMode,
|
|
379
380
|
devAuthSecret,
|
|
381
|
+
devRunId: devMode ? orUndefined(env.DEV_RUN_ID) : void 0,
|
|
380
382
|
// Only ever read in a dev-shaped env; a deployed env runs a real provider, so the
|
|
381
383
|
// simulation can never engage there whatever the var says.
|
|
382
384
|
simulation: devShaped ? readSimulation(env.SIMULATE_SENDS) : null,
|
|
@@ -544,7 +546,7 @@ function mediaTypeOf(req) {
|
|
|
544
546
|
return declared ? (declared.split(";")[0] ?? "").trim().toLowerCase() || void 0 : void 0;
|
|
545
547
|
}
|
|
546
548
|
async function readJsonObject(c, opts = {}) {
|
|
547
|
-
const text = await c.req.
|
|
549
|
+
const text = new TextDecoder().decode(await c.req.arrayBuffer());
|
|
548
550
|
if (text.trim() === "") {
|
|
549
551
|
if (opts.optional) {
|
|
550
552
|
return {};
|
|
@@ -579,23 +581,23 @@ function optString(o, key, at) {
|
|
|
579
581
|
}
|
|
580
582
|
return v;
|
|
581
583
|
}
|
|
582
|
-
function
|
|
583
|
-
const v = o[key];
|
|
584
|
-
if (v === void 0 || v === null || typeof v === "string") {
|
|
585
|
-
return v;
|
|
586
|
-
}
|
|
587
|
-
throw fieldError(path(key, at), `${path(key, at)} must be a string or null`);
|
|
588
|
-
}
|
|
589
|
-
function optCount(o, key, at) {
|
|
584
|
+
function optBoolean(o, key, at) {
|
|
590
585
|
const v = o[key];
|
|
591
586
|
if (v === void 0) {
|
|
592
587
|
return void 0;
|
|
593
588
|
}
|
|
594
|
-
if (typeof v !== "
|
|
595
|
-
throw fieldError(path(key, at), `${path(key, at)} must be
|
|
589
|
+
if (typeof v !== "boolean") {
|
|
590
|
+
throw fieldError(path(key, at), `${path(key, at)} must be true or false`);
|
|
596
591
|
}
|
|
597
592
|
return v;
|
|
598
593
|
}
|
|
594
|
+
function optStringOrNull(o, key, at) {
|
|
595
|
+
const v = o[key];
|
|
596
|
+
if (v === void 0 || v === null || typeof v === "string") {
|
|
597
|
+
return v;
|
|
598
|
+
}
|
|
599
|
+
throw fieldError(path(key, at), `${path(key, at)} must be a string or null`);
|
|
600
|
+
}
|
|
599
601
|
function optStringList(o, key, at) {
|
|
600
602
|
const v = o[key];
|
|
601
603
|
if (v === void 0) {
|
|
@@ -901,13 +903,20 @@ function latestSentSendForPost(db, postId) {
|
|
|
901
903
|
"SELECT * FROM sends WHERE post_id = ? AND status = 'sent' ORDER BY completed_at DESC LIMIT 1"
|
|
902
904
|
).bind(postId).first();
|
|
903
905
|
}
|
|
906
|
+
var PUBLISHED_SEND = "s.status IN ('sending', 'sent')";
|
|
907
|
+
var PUBLISHED_AT = "COALESCE(s.started_at, s.fire_at)";
|
|
908
|
+
function publishedSendForPost(db, postId) {
|
|
909
|
+
return db.prepare(
|
|
910
|
+
`SELECT * FROM sends s WHERE s.post_id = ? AND ${PUBLISHED_SEND} ORDER BY ${PUBLISHED_AT} DESC LIMIT 1`
|
|
911
|
+
).bind(postId).first();
|
|
912
|
+
}
|
|
904
913
|
async function listPublishedPosts(db, limit = 200) {
|
|
905
914
|
const { results } = await db.prepare(
|
|
906
|
-
`SELECT p.slug AS slug, s.subject AS subject, MAX(
|
|
915
|
+
`SELECT p.slug AS slug, s.subject AS subject, MAX(${PUBLISHED_AT}) AS published_at
|
|
907
916
|
FROM sends s JOIN posts p ON p.id = s.post_id
|
|
908
|
-
WHERE
|
|
917
|
+
WHERE ${PUBLISHED_SEND}
|
|
909
918
|
GROUP BY s.post_id
|
|
910
|
-
ORDER BY
|
|
919
|
+
ORDER BY published_at DESC
|
|
911
920
|
LIMIT ?`
|
|
912
921
|
).bind(Math.min(limit, 1e3)).all();
|
|
913
922
|
return results;
|
|
@@ -1145,18 +1154,15 @@ function insertScheduledSendStmt(db, send, now, settingsVersion) {
|
|
|
1145
1154
|
...guard2.binds
|
|
1146
1155
|
);
|
|
1147
1156
|
}
|
|
1148
|
-
function inWindowAt(now
|
|
1149
|
-
return {
|
|
1150
|
-
sql: "status = 'scheduled' AND fire_at > ? AND (? IS NULL OR rev = ?)",
|
|
1151
|
-
binds: [now, rev, rev]
|
|
1152
|
-
};
|
|
1157
|
+
function inWindowAt(now) {
|
|
1158
|
+
return { sql: "status = 'scheduled' AND fire_at > ?", binds: [now] };
|
|
1153
1159
|
}
|
|
1154
|
-
function rescheduleStmt(db, sendId, fireAt, now
|
|
1155
|
-
const guard2 = inWindowAt(now
|
|
1160
|
+
function rescheduleStmt(db, sendId, fireAt, now) {
|
|
1161
|
+
const guard2 = inWindowAt(now);
|
|
1156
1162
|
return db.prepare(`UPDATE sends SET fire_at = ?, rev = ${NEXT_REV} WHERE id = ? AND ${guard2.sql}`).bind(fireAt, sendId, ...guard2.binds);
|
|
1157
1163
|
}
|
|
1158
|
-
function cancelStmt(db, sendId, now
|
|
1159
|
-
const guard2 = inWindowAt(now
|
|
1164
|
+
function cancelStmt(db, sendId, now) {
|
|
1165
|
+
const guard2 = inWindowAt(now);
|
|
1160
1166
|
return db.prepare(
|
|
1161
1167
|
`UPDATE sends SET status = 'canceled', completed_at = ?, rev = ${NEXT_REV} WHERE id = ? AND ${guard2.sql}`
|
|
1162
1168
|
).bind(now, sendId, ...guard2.binds);
|
|
@@ -1608,7 +1614,8 @@ async function findReceiptTarget(db, u) {
|
|
|
1608
1614
|
).bind(normalizeEmail(u.email)).first();
|
|
1609
1615
|
}
|
|
1610
1616
|
if (u.email) {
|
|
1611
|
-
|
|
1617
|
+
const row = await db.prepare(`SELECT ${cols} FROM deliveries WHERE email = ? ORDER BY updated_at DESC LIMIT 1`).bind(normalizeEmail(u.email)).first();
|
|
1618
|
+
return row && row.status !== "pending" && row.status !== "dispatched" ? row : null;
|
|
1612
1619
|
}
|
|
1613
1620
|
return null;
|
|
1614
1621
|
}
|
|
@@ -1625,15 +1632,20 @@ async function acceptedAwaitingEvent(db, limit) {
|
|
|
1625
1632
|
}
|
|
1626
1633
|
|
|
1627
1634
|
// src/db/posts.ts
|
|
1635
|
+
function fromStored(row) {
|
|
1636
|
+
return { ...row, slug_auto: row.slug_auto === 1 };
|
|
1637
|
+
}
|
|
1628
1638
|
function setPostStatusStmt(db, postId, from, to, now, guard2) {
|
|
1629
1639
|
const extra = guard2 ? ` AND ${guard2.sql}` : "";
|
|
1630
1640
|
return db.prepare(`UPDATE posts SET status = ?, updated_at = ? WHERE id = ? AND status = ?${extra}`).bind(to, now, postId, from, ...guard2?.binds ?? []);
|
|
1631
1641
|
}
|
|
1632
|
-
function getPost(db, id) {
|
|
1633
|
-
|
|
1642
|
+
async function getPost(db, id) {
|
|
1643
|
+
const row = await db.prepare("SELECT * FROM posts WHERE id = ?").bind(id).first();
|
|
1644
|
+
return row && fromStored(row);
|
|
1634
1645
|
}
|
|
1635
|
-
function getBySlug(db, slug) {
|
|
1636
|
-
|
|
1646
|
+
async function getBySlug(db, slug) {
|
|
1647
|
+
const row = await db.prepare("SELECT * FROM posts WHERE slug = ?").bind(slug).first();
|
|
1648
|
+
return row && fromStored(row);
|
|
1637
1649
|
}
|
|
1638
1650
|
var POST_LIST_SPEC = {
|
|
1639
1651
|
columns: {
|
|
@@ -1677,7 +1689,7 @@ async function listPosts(db, filter = {}, page) {
|
|
|
1677
1689
|
${order}
|
|
1678
1690
|
LIMIT ? OFFSET ?`
|
|
1679
1691
|
).bind(...binds, limit, offset).all();
|
|
1680
|
-
return results;
|
|
1692
|
+
return results.map(fromStored);
|
|
1681
1693
|
}
|
|
1682
1694
|
async function countPosts(db, filter = {}) {
|
|
1683
1695
|
const { clause, binds } = postWhere(filter);
|
|
@@ -1722,12 +1734,13 @@ async function createPost(db, input, author2) {
|
|
|
1722
1734
|
const revId = newId();
|
|
1723
1735
|
const subject = input.subject ?? "";
|
|
1724
1736
|
const markdown = input.markdown ?? "";
|
|
1725
|
-
const
|
|
1737
|
+
const auto = input.slug_auto ?? input.slug === void 0;
|
|
1738
|
+
const slug = await uniqueSlug(db, slugify(auto ? subject : input.slug ?? subject));
|
|
1726
1739
|
const metadata = JSON.stringify({ subject, slug });
|
|
1727
1740
|
await db.batch([
|
|
1728
1741
|
db.prepare(
|
|
1729
|
-
"INSERT INTO posts (id, slug, subject, status, current_revision, created_at, updated_at) VALUES (?, ?, ?, 'draft', ?, ?, ?)"
|
|
1730
|
-
).bind(id, slug, subject, revId, now, now),
|
|
1742
|
+
"INSERT INTO posts (id, slug, slug_auto, subject, status, current_revision, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?)"
|
|
1743
|
+
).bind(id, slug, auto ? 1 : 0, subject, revId, now, now),
|
|
1731
1744
|
db.prepare(
|
|
1732
1745
|
"INSERT INTO post_revisions (id, post_id, markdown, metadata, author, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
1733
1746
|
).bind(revId, id, markdown, metadata, author2, now)
|
|
@@ -1745,8 +1758,11 @@ async function updatePost(db, post, input, author2) {
|
|
|
1745
1758
|
const revId = newId();
|
|
1746
1759
|
const subject = input.subject ?? post.subject;
|
|
1747
1760
|
const markdown = input.markdown ?? current?.markdown ?? "";
|
|
1761
|
+
const auto = input.slug_auto ?? (input.slug === void 0 && post.slug_auto);
|
|
1748
1762
|
let slug = post.slug;
|
|
1749
|
-
if (
|
|
1763
|
+
if (auto) {
|
|
1764
|
+
slug = await uniqueSlug(db, slugify(subject), post.id);
|
|
1765
|
+
} else if (input.slug !== void 0) {
|
|
1750
1766
|
slug = await uniqueSlug(db, slugify(input.slug), post.id);
|
|
1751
1767
|
}
|
|
1752
1768
|
const metadata = JSON.stringify({ subject, slug });
|
|
@@ -1755,8 +1771,8 @@ async function updatePost(db, post, input, author2) {
|
|
|
1755
1771
|
"INSERT INTO post_revisions (id, post_id, markdown, metadata, author, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
1756
1772
|
).bind(revId, post.id, markdown, metadata, author2, now),
|
|
1757
1773
|
db.prepare(
|
|
1758
|
-
"UPDATE posts SET slug = ?, subject = ?, current_revision = ?, updated_at = ? WHERE id = ? AND status = 'draft'"
|
|
1759
|
-
).bind(slug, subject, revId, now, post.id)
|
|
1774
|
+
"UPDATE posts SET slug = ?, slug_auto = ?, subject = ?, current_revision = ?, updated_at = ? WHERE id = ? AND status = 'draft'"
|
|
1775
|
+
).bind(slug, auto ? 1 : 0, subject, revId, now, post.id)
|
|
1760
1776
|
]);
|
|
1761
1777
|
const updated = unwrap(await getPost(db, post.id), "post");
|
|
1762
1778
|
const revision = unwrap(
|
|
@@ -1779,499 +1795,233 @@ async function deletePost(db, id) {
|
|
|
1779
1795
|
]);
|
|
1780
1796
|
}
|
|
1781
1797
|
|
|
1782
|
-
// src/
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
subject: "Confirm your subscription",
|
|
1786
|
-
body: "Thanks for subscribing. Please confirm your email address to start receiving the newsletter.",
|
|
1787
|
-
buttonLabel: "Confirm subscription",
|
|
1788
|
-
reassurance: "If you didn't request this, you can safely ignore this email."
|
|
1789
|
-
};
|
|
1790
|
-
var DEFAULT_SETTINGS = {
|
|
1791
|
-
testRecipients: [],
|
|
1792
|
-
publication: { name: "", tagline: "", address: "", logo: null },
|
|
1793
|
-
emailTemplate: "",
|
|
1794
|
-
confirmationEmail: DEFAULT_CONFIRMATION_EMAIL,
|
|
1795
|
-
notifications: { to: "" }
|
|
1796
|
-
};
|
|
1797
|
-
var MAX_TEST_RECIPIENTS = 20;
|
|
1798
|
-
var MAX_NAME = 120;
|
|
1799
|
-
var MAX_TAGLINE = 200;
|
|
1800
|
-
var MAX_ADDRESS = 300;
|
|
1801
|
-
var MAX_TEMPLATE = 4e4;
|
|
1802
|
-
var MAX_CE_SUBJECT = 200;
|
|
1803
|
-
var MAX_CE_BODY = 1e3;
|
|
1804
|
-
var MAX_CE_BUTTON = 80;
|
|
1805
|
-
var MAX_CE_REASSURANCE = 400;
|
|
1806
|
-
function coercePublication(raw) {
|
|
1807
|
-
const o = raw && typeof raw === "object" ? raw : {};
|
|
1808
|
-
const str = (v) => typeof v === "string" ? v : "";
|
|
1809
|
-
const lr = o.logo;
|
|
1810
|
-
const logo = lr && typeof lr === "object" && typeof lr.version === "number" ? { version: lr.version, contentType: str(lr.contentType) || "image/png" } : null;
|
|
1811
|
-
return {
|
|
1812
|
-
name: str(o.name).slice(0, MAX_NAME),
|
|
1813
|
-
tagline: str(o.tagline).slice(0, MAX_TAGLINE),
|
|
1814
|
-
address: str(o.address).slice(0, MAX_ADDRESS),
|
|
1815
|
-
logo
|
|
1816
|
-
};
|
|
1817
|
-
}
|
|
1818
|
-
function coerceConfirmation(raw) {
|
|
1819
|
-
const o = raw && typeof raw === "object" ? raw : {};
|
|
1820
|
-
const str = (v) => typeof v === "string" ? v : "";
|
|
1821
|
-
return {
|
|
1822
|
-
subject: str(o.subject).slice(0, MAX_CE_SUBJECT),
|
|
1823
|
-
body: str(o.body).slice(0, MAX_CE_BODY),
|
|
1824
|
-
buttonLabel: str(o.buttonLabel).slice(0, MAX_CE_BUTTON),
|
|
1825
|
-
reassurance: str(o.reassurance).slice(0, MAX_CE_REASSURANCE)
|
|
1826
|
-
};
|
|
1798
|
+
// src/lib/html.ts
|
|
1799
|
+
function escapeHtml(s) {
|
|
1800
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1827
1801
|
}
|
|
1828
|
-
function
|
|
1829
|
-
|
|
1830
|
-
const list5 = Array.isArray(o.testRecipients) ? o.testRecipients : [];
|
|
1831
|
-
return {
|
|
1832
|
-
testRecipients: list5.filter((e) => typeof e === "string"),
|
|
1833
|
-
publication: coercePublication(o.publication),
|
|
1834
|
-
emailTemplate: typeof o.emailTemplate === "string" ? o.emailTemplate.slice(0, MAX_TEMPLATE) : "",
|
|
1835
|
-
// A row that predates this feature has no `confirmationEmail` key at all — that's
|
|
1836
|
-
// "never configured", so it gets the full built-in copy (reassurance included), NOT
|
|
1837
|
-
// a blank-reassurance row. Only once the key exists (the operator saved copy) does a
|
|
1838
|
-
// blank reassurance mean "deliberately omit the footer".
|
|
1839
|
-
confirmationEmail: "confirmationEmail" in o ? coerceConfirmation(o.confirmationEmail) : { ...DEFAULT_CONFIRMATION_EMAIL },
|
|
1840
|
-
notifications: coerceNotifications(o.notifications)
|
|
1841
|
-
};
|
|
1802
|
+
function escapeHtmlAttr(s) {
|
|
1803
|
+
return escapeHtml(s).replace(/"/g, """).replace(/'/g, "'");
|
|
1842
1804
|
}
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1805
|
+
|
|
1806
|
+
// shared/page_policy.ts
|
|
1807
|
+
function pagePolicy(formAction, opts = {}) {
|
|
1808
|
+
return [
|
|
1809
|
+
"default-src 'none'",
|
|
1810
|
+
"script-src 'none'",
|
|
1811
|
+
"object-src 'none'",
|
|
1812
|
+
"frame-src 'none'",
|
|
1813
|
+
"base-uri 'none'",
|
|
1814
|
+
`form-action ${formAction}`,
|
|
1815
|
+
opts.framed ? null : "frame-ancestors 'none'",
|
|
1816
|
+
"img-src * data:",
|
|
1817
|
+
"style-src 'unsafe-inline' https://fonts.googleapis.com",
|
|
1818
|
+
"font-src https://fonts.gstatic.com"
|
|
1819
|
+
].filter((d) => d !== null).join("; ");
|
|
1846
1820
|
}
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1821
|
+
|
|
1822
|
+
// src/lib/page_headers.ts
|
|
1823
|
+
function headers(formAction) {
|
|
1850
1824
|
return {
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
reassurance: c.reassurance
|
|
1825
|
+
"content-security-policy": pagePolicy(formAction),
|
|
1826
|
+
"x-frame-options": "DENY",
|
|
1827
|
+
"x-content-type-options": "nosniff"
|
|
1855
1828
|
};
|
|
1856
1829
|
}
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1830
|
+
var PAGE_SECURITY_HEADERS = headers("'self'");
|
|
1831
|
+
var POST_PAGE_SECURITY_HEADERS = headers("'none'");
|
|
1832
|
+
|
|
1833
|
+
// src/lib/page.ts
|
|
1834
|
+
var READER_BG_LIGHT = "#fbfbfa";
|
|
1835
|
+
var READER_BG_DARK = "#12110f";
|
|
1836
|
+
var APP_ACCENT_TOKENS = `
|
|
1837
|
+
:root { --k-accent:#3355cc; --k-accent-contrast:#ffffff; }
|
|
1838
|
+
@media (prefers-color-scheme: dark) { :root { --k-accent:#7d9bff; --k-accent-contrast:#10131f; } }
|
|
1839
|
+
`;
|
|
1840
|
+
var DEV_BADGE_STYLE = `${APP_ACCENT_TOKENS}
|
|
1841
|
+
.r-dev { position:fixed; right:18px; bottom:18px; z-index:50; display:inline-flex; align-items:center; gap:8px;
|
|
1842
|
+
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; line-height:1.6;
|
|
1843
|
+
font-size:13px; font-weight:600; text-decoration:none; padding:9px 15px 9px 10px; border-radius:999px;
|
|
1844
|
+
background:var(--k-accent); color:var(--k-accent-contrast); border:1px solid var(--k-accent);
|
|
1845
|
+
box-shadow:0 6px 20px rgba(0,0,0,.22); }
|
|
1846
|
+
.r-dev:hover { filter:brightness(1.08); }
|
|
1847
|
+
/* Inverted badge \u2014 solid contrast fill, accent-colored text \u2014 so the tiny "DEV"
|
|
1848
|
+
label clears WCAG AA on the accent pill in both themes (a translucent tint left
|
|
1849
|
+
it ~3.6:1). */
|
|
1850
|
+
.r-dev .r-dev-tag { font-size:9.5px; letter-spacing:.09em; text-transform:uppercase; font-weight:700;
|
|
1851
|
+
padding:2px 7px; border-radius:999px;
|
|
1852
|
+
background:var(--k-accent-contrast); color:var(--k-accent); }
|
|
1853
|
+
.r-dev:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
|
1854
|
+
@media (prefers-color-scheme: dark) { .r-dev:focus-visible { outline-color:#60a5fa; } }
|
|
1855
|
+
`;
|
|
1856
|
+
var READER_BANNER_STYLE = `${APP_ACCENT_TOKENS}
|
|
1857
|
+
.r-banner { background:var(--k-accent); color:var(--k-accent-contrast);
|
|
1858
|
+
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
|
1859
|
+
font-size:13.5px; font-weight:500; line-height:1.4; }
|
|
1860
|
+
/* The tag runs in, inline with the sentence, so on a phone the sentence wraps beneath it at
|
|
1861
|
+
the strip's full width rather than in a narrow column beside it. */
|
|
1862
|
+
.r-banner-in { max-width:var(--r-measure); margin:0 auto; padding:10px 24px; }
|
|
1863
|
+
.r-banner-tag { display:inline-block; margin-right:10px; font-size:10px; letter-spacing:.09em;
|
|
1864
|
+
text-transform:uppercase; font-weight:700; padding:1px 7px; border-radius:999px;
|
|
1865
|
+
background:var(--k-accent-contrast); color:var(--k-accent); }
|
|
1866
|
+
.r-banner-text { overflow-wrap:anywhere; }
|
|
1867
|
+
`;
|
|
1868
|
+
var READER_STYLE = `
|
|
1869
|
+
:root {
|
|
1870
|
+
color-scheme: light dark;
|
|
1871
|
+
--r-bg:${READER_BG_LIGHT}; --r-ink:#1b1a17; --r-card:#ffffff; --r-line:#e7e5df; --r-mut:#6b6a63;
|
|
1872
|
+
--r-serif:Fraunces,Georgia,'Iowan Old Style','Times New Roman',serif;
|
|
1873
|
+
--r-sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
|
1874
|
+
--r-measure:820px; /* one reading width shared by the masthead, body, and footer so their edges line up */
|
|
1875
|
+
/* The masthead sits on the card surface with a hairline rule (the footer's top
|
|
1876
|
+
rule, mirrored); the eyebrow/link accent inherits the ink color. */
|
|
1877
|
+
--brand-ink:currentColor;
|
|
1863
1878
|
}
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
let problem = "is not a JSON object";
|
|
1867
|
-
try {
|
|
1868
|
-
parsed = JSON.parse(data);
|
|
1869
|
-
} catch (err) {
|
|
1870
|
-
problem = `is not valid JSON (${err instanceof Error ? err.message : String(err)})`;
|
|
1871
|
-
}
|
|
1872
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1873
|
-
return parsed;
|
|
1874
|
-
}
|
|
1875
|
-
log.error("settings.corrupt", {
|
|
1876
|
-
message: `the settings row (settings.id = 1) ${problem}; reads and writes of settings fail until it is repaired by hand`,
|
|
1877
|
-
length: data.length
|
|
1878
|
-
});
|
|
1879
|
-
throw new HttpError(
|
|
1880
|
-
500,
|
|
1881
|
-
"settings_corrupt",
|
|
1882
|
-
"The stored settings can't be read, so they are left as they are and nothing will save over them. The server log (settings.corrupt) says what's wrong."
|
|
1883
|
-
);
|
|
1879
|
+
@media (prefers-color-scheme: dark) {
|
|
1880
|
+
:root { --r-bg:${READER_BG_DARK}; --r-ink:#ece9e3; --r-card:#1b1a17; --r-line:#2c2a25; --r-mut:#a5a199; }
|
|
1884
1881
|
}
|
|
1885
|
-
|
|
1886
|
-
|
|
1882
|
+
* { box-sizing:border-box; }
|
|
1883
|
+
body { margin:0; font-family:var(--r-sans); background:var(--r-bg); color:var(--r-ink); line-height:1.6; }
|
|
1884
|
+
a { color:inherit; }
|
|
1885
|
+
img { max-width:100%; }
|
|
1886
|
+
|
|
1887
|
+
/* Masthead: identity + a subscribe call to action, on the card surface with a
|
|
1888
|
+
hairline bottom rule \u2014 light with dark text in light mode, and it inverts with
|
|
1889
|
+
the reader tokens in dark mode. */
|
|
1890
|
+
.r-mast { background:var(--r-card); color:var(--r-ink); border-bottom:1px solid var(--r-line); }
|
|
1891
|
+
.r-mast-in { max-width:var(--r-measure); margin:0 auto; padding:24px; display:flex; align-items:center; gap:16px; }
|
|
1892
|
+
.r-brand { display:flex; align-items:center; gap:14px; text-decoration:none; color:inherit; min-width:0; }
|
|
1893
|
+
.r-logo { width:64px; height:64px; border-radius:14px; background:color-mix(in srgb, currentColor 12%, transparent);
|
|
1894
|
+
flex:none; display:grid; place-items:center; overflow:hidden; }
|
|
1895
|
+
.r-logo img { max-width:100%; max-height:100%; display:block; }
|
|
1896
|
+
.r-name { font-family:var(--r-serif); font-size:23px; font-weight:600; letter-spacing:-.01em; line-height:1.15; }
|
|
1897
|
+
.r-tag { font-size:13px; opacity:.85; margin-top:2px; }
|
|
1898
|
+
.r-cta { margin-left:auto; flex:none; }
|
|
1899
|
+
.r-sub { display:inline-flex; align-items:center; gap:6px; font-size:13.5px; font-weight:600; color:inherit;
|
|
1900
|
+
text-decoration:none; border:1px solid color-mix(in srgb, currentColor 45%, transparent);
|
|
1901
|
+
padding:8px 14px; border-radius:999px; white-space:nowrap; }
|
|
1902
|
+
.r-sub:hover { background:color-mix(in srgb, currentColor 14%, transparent); }
|
|
1903
|
+
|
|
1904
|
+
.r-body { max-width:var(--r-measure); margin:0 auto; padding:32px 24px 56px; }
|
|
1905
|
+
.r-ey { font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--brand-ink);
|
|
1906
|
+
font-weight:700; margin:0 0 12px; }
|
|
1907
|
+
.r-feat { border:1px solid var(--r-line); background:var(--r-card); border-radius:12px; padding:22px 24px; margin:0 0 1.5rem; }
|
|
1908
|
+
.r-feat .r-k { font-size:12.5px; color:var(--r-mut); margin:0 0 8px; }
|
|
1909
|
+
.r-feat h2 { font-family:var(--r-serif); font-size:26px; line-height:1.15; font-weight:600; margin:0 0 12px; }
|
|
1910
|
+
.r-feat h2 a { text-decoration:none; }
|
|
1911
|
+
.r-feat h2 a:hover { text-decoration:underline; }
|
|
1912
|
+
.r-link { font-size:14px; font-weight:600; color:var(--brand-ink); text-decoration:none; }
|
|
1913
|
+
.r-link:hover { text-decoration:underline; }
|
|
1914
|
+
ul.r-list { list-style:none; margin:0; padding:0; }
|
|
1915
|
+
li.r-item { display:flex; gap:18px; align-items:baseline; padding:16px 0; border-top:1px solid var(--r-line); }
|
|
1916
|
+
/* Fixed date column so the titles line up. Sized to hold the widest en-US long date
|
|
1917
|
+
("September 30, 2026" \u2248 122px) \u2014 a narrower column lets a long month overflow its
|
|
1918
|
+
box (flex:none + nowrap don't clip) and butt against the title. */
|
|
1919
|
+
li.r-item .r-d { font-size:12.5px; color:var(--r-mut); width:128px; flex:none; white-space:nowrap; }
|
|
1920
|
+
a.r-t { font-family:var(--r-serif); font-size:18px; font-weight:600; text-decoration:none; }
|
|
1921
|
+
a.r-t:hover { text-decoration:underline; }
|
|
1922
|
+
.r-empty { color:var(--r-mut); padding:20px 0; }
|
|
1923
|
+
.r-arch { display:inline-block; margin-top:24px; font-size:14px; font-weight:600; color:var(--brand-ink); text-decoration:none; }
|
|
1924
|
+
.r-arch:hover { text-decoration:underline; }
|
|
1925
|
+
/* A lede already ends in its own margin, so a link straight beneath it sits closer. */
|
|
1926
|
+
.r-lead + .r-arch { margin-top:0; }
|
|
1927
|
+
|
|
1928
|
+
/* Subscribe / message pages: a headline, lede, and a branded form. */
|
|
1929
|
+
.r-h1 { font-family:var(--r-serif); font-size:30px; line-height:1.12; font-weight:600; letter-spacing:-.01em; margin:0 0 12px; }
|
|
1930
|
+
.r-lead { font-size:16px; color:var(--r-mut); margin:0 0 22px; max-width:54ch; }
|
|
1931
|
+
.r-lead strong { color:var(--r-ink); font-weight:600; }
|
|
1932
|
+
.r-form { display:flex; gap:10px; flex-wrap:wrap; max-width:460px; }
|
|
1933
|
+
.r-form input { flex:1 1 220px; font:inherit; font-size:16px; padding:11px 13px; border:1px solid var(--r-line);
|
|
1934
|
+
border-radius:9px; background:var(--r-card); color:var(--r-ink); }
|
|
1935
|
+
.r-form input:focus-visible { outline:2px solid var(--brand-ink); outline-offset:1px; border-color:var(--brand-ink); }
|
|
1936
|
+
.r-btn { font:inherit; font-size:15px; font-weight:600; padding:11px 20px; border-radius:9px; border:1px solid var(--r-ink);
|
|
1937
|
+
background:var(--r-ink); color:var(--r-bg); cursor:pointer; white-space:nowrap; }
|
|
1938
|
+
.r-btn:hover { filter:brightness(1.06); }
|
|
1939
|
+
.r-fine { font-size:12.5px; color:var(--r-mut); margin:16px 0 0; }
|
|
1940
|
+
|
|
1941
|
+
.r-foot { border-top:1px solid var(--r-line); }
|
|
1942
|
+
.r-foot-in { max-width:var(--r-measure); margin:0 auto; padding:22px 24px 40px; font-size:12.5px; color:var(--r-mut); }
|
|
1943
|
+
|
|
1944
|
+
a:focus-visible, .r-sub:focus-visible, .r-btn:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
|
1945
|
+
@media (prefers-color-scheme: dark) {
|
|
1946
|
+
a:focus-visible, .r-sub:focus-visible, .r-btn:focus-visible { outline-color:#60a5fa; }
|
|
1887
1947
|
}
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
return structuredClone(DEFAULT_SETTINGS);
|
|
1894
|
-
}
|
|
1895
|
-
throw err;
|
|
1896
|
-
}
|
|
1948
|
+
@media (max-width:560px) {
|
|
1949
|
+
.r-mast-in { flex-wrap:wrap; gap:12px; }
|
|
1950
|
+
.r-cta { margin-left:0; }
|
|
1951
|
+
li.r-item { flex-wrap:wrap; gap:6px 18px; }
|
|
1952
|
+
li.r-item .r-d { width:100%; }
|
|
1897
1953
|
}
|
|
1898
|
-
|
|
1899
|
-
|
|
1954
|
+
`;
|
|
1955
|
+
var FRAUNCES_FONT_LINKS = `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..600;1,9..144,400..600&display=swap">`;
|
|
1956
|
+
var ARCHIVE_POST_HEAD = FRAUNCES_FONT_LINKS + `<style>body.k-bg,body .k-bg,body .k-card{background:${READER_BG_LIGHT}!important}.k-mast{color:#52525b;border-bottom:1px solid #e4e4e7}.k-mast a{color:#18181b}@media(prefers-color-scheme:dark){body.k-bg,body .k-bg,body .k-card{background:${READER_BG_DARK}!important}.k-mast{color:#a1a1aa;border-bottom-color:#2e2e33}.k-mast a{color:#ededed}}</style>`;
|
|
1957
|
+
function devDashboardStyle(url) {
|
|
1958
|
+
return url ? `<style>${DEV_BADGE_STYLE}</style>` : "";
|
|
1900
1959
|
}
|
|
1901
|
-
function
|
|
1902
|
-
|
|
1903
|
-
return db.prepare(
|
|
1904
|
-
`UPDATE settings SET data = ?, updated_at = MAX(?, updated_at + 1)
|
|
1905
|
-
WHERE id = 1 AND updated_at = ?${extra}`
|
|
1906
|
-
).bind(JSON.stringify(next), Date.now(), expected, ...guard2?.binds ?? []);
|
|
1907
|
-
}
|
|
1908
|
-
var WRITE_RETRIES = 5;
|
|
1909
|
-
var SettingsContention = class extends HttpError {
|
|
1910
|
-
constructor() {
|
|
1911
|
-
super(409, "conflict", "settings changed concurrently; try again");
|
|
1912
|
-
}
|
|
1913
|
-
};
|
|
1914
|
-
async function updateSettingsWith(db, derive) {
|
|
1915
|
-
await ensureSettingsRow(db);
|
|
1916
|
-
for (let attempt = 0; attempt < WRITE_RETRIES; attempt++) {
|
|
1917
|
-
const { settings, version } = await readSettings(db);
|
|
1918
|
-
const next = derive(settings);
|
|
1919
|
-
const res = await persistSettingsStmt(db, next, version ?? 0).run();
|
|
1920
|
-
if ((res.meta.changes ?? 0) > 0) {
|
|
1921
|
-
return next;
|
|
1922
|
-
}
|
|
1923
|
-
}
|
|
1924
|
-
throw new SettingsContention();
|
|
1925
|
-
}
|
|
1926
|
-
async function updateSettings(db, patch) {
|
|
1927
|
-
return updateSettingsWith(db, (current) => applyPatch(current, patch));
|
|
1928
|
-
}
|
|
1929
|
-
function applyPatch(current, patch) {
|
|
1930
|
-
const next = {
|
|
1931
|
-
...current,
|
|
1932
|
-
publication: { ...current.publication },
|
|
1933
|
-
confirmationEmail: { ...current.confirmationEmail },
|
|
1934
|
-
notifications: { ...current.notifications }
|
|
1935
|
-
};
|
|
1936
|
-
if (patch.testRecipients !== void 0) {
|
|
1937
|
-
next.testRecipients = normalizeRecipients(patch.testRecipients);
|
|
1938
|
-
}
|
|
1939
|
-
if (patch.publication !== void 0) {
|
|
1940
|
-
const p = patch.publication;
|
|
1941
|
-
if (p.name !== void 0) {
|
|
1942
|
-
next.publication.name = normalizeText(p.name, "name", MAX_NAME);
|
|
1943
|
-
}
|
|
1944
|
-
if (p.tagline !== void 0) {
|
|
1945
|
-
next.publication.tagline = normalizeText(p.tagline, "tagline", MAX_TAGLINE);
|
|
1946
|
-
}
|
|
1947
|
-
if (p.address !== void 0) {
|
|
1948
|
-
next.publication.address = normalizeText(p.address, "address", MAX_ADDRESS);
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1951
|
-
if (patch.emailTemplate !== void 0) {
|
|
1952
|
-
if (typeof patch.emailTemplate !== "string") {
|
|
1953
|
-
throw new Error("emailTemplate must be a string");
|
|
1954
|
-
}
|
|
1955
|
-
if (patch.emailTemplate.length > MAX_TEMPLATE) {
|
|
1956
|
-
throw new Error(`emailTemplate must be ${MAX_TEMPLATE} characters or fewer`);
|
|
1957
|
-
}
|
|
1958
|
-
next.emailTemplate = patch.emailTemplate;
|
|
1959
|
-
}
|
|
1960
|
-
if (patch.confirmationEmail !== void 0) {
|
|
1961
|
-
const c = patch.confirmationEmail;
|
|
1962
|
-
if (c.subject !== void 0) {
|
|
1963
|
-
next.confirmationEmail.subject = normalizeText(c.subject, "subject", MAX_CE_SUBJECT);
|
|
1964
|
-
}
|
|
1965
|
-
if (c.body !== void 0) {
|
|
1966
|
-
next.confirmationEmail.body = normalizeText(c.body, "message", MAX_CE_BODY);
|
|
1967
|
-
}
|
|
1968
|
-
if (c.buttonLabel !== void 0) {
|
|
1969
|
-
next.confirmationEmail.buttonLabel = normalizeText(
|
|
1970
|
-
c.buttonLabel,
|
|
1971
|
-
"button label",
|
|
1972
|
-
MAX_CE_BUTTON
|
|
1973
|
-
);
|
|
1974
|
-
}
|
|
1975
|
-
if (c.reassurance !== void 0) {
|
|
1976
|
-
next.confirmationEmail.reassurance = normalizeText(
|
|
1977
|
-
c.reassurance,
|
|
1978
|
-
"reassurance line",
|
|
1979
|
-
MAX_CE_REASSURANCE
|
|
1980
|
-
);
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
if (patch.notifications?.to !== void 0) {
|
|
1984
|
-
next.notifications.to = normalizeNotifyTo(patch.notifications.to);
|
|
1985
|
-
}
|
|
1986
|
-
return next;
|
|
1987
|
-
}
|
|
1988
|
-
function normalizeNotifyTo(raw) {
|
|
1989
|
-
if (typeof raw !== "string") {
|
|
1990
|
-
throw new Error("notifications address must be a string");
|
|
1991
|
-
}
|
|
1992
|
-
const email2 = normalizeEmail(raw);
|
|
1993
|
-
if (email2 && !isValidEmail(email2)) {
|
|
1994
|
-
throw new Error(`not a valid email address: ${raw}`);
|
|
1995
|
-
}
|
|
1996
|
-
return email2;
|
|
1997
|
-
}
|
|
1998
|
-
function withPublicationLogo(current, logo) {
|
|
1999
|
-
return { ...current, publication: { ...current.publication, logo } };
|
|
2000
|
-
}
|
|
2001
|
-
async function setPublicationLogo(db, logo) {
|
|
2002
|
-
return updateSettingsWith(db, (current) => withPublicationLogo(current, logo));
|
|
2003
|
-
}
|
|
2004
|
-
function normalizeText(v, what, max) {
|
|
2005
|
-
if (typeof v !== "string") {
|
|
2006
|
-
throw new Error(`${what} must be a string`);
|
|
2007
|
-
}
|
|
2008
|
-
return v.trim().slice(0, max);
|
|
2009
|
-
}
|
|
2010
|
-
function normalizeRecipients(input) {
|
|
2011
|
-
if (!Array.isArray(input)) {
|
|
2012
|
-
throw new Error("testRecipients must be a list of email addresses");
|
|
2013
|
-
}
|
|
2014
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2015
|
-
const out = [];
|
|
2016
|
-
for (const raw of input) {
|
|
2017
|
-
if (typeof raw !== "string") {
|
|
2018
|
-
throw new Error("each test recipient must be a string");
|
|
2019
|
-
}
|
|
2020
|
-
const email2 = normalizeEmail(raw);
|
|
2021
|
-
if (!email2) {
|
|
2022
|
-
continue;
|
|
2023
|
-
}
|
|
2024
|
-
if (!isValidEmail(email2)) {
|
|
2025
|
-
throw new Error(`not a valid email address: ${raw}`);
|
|
2026
|
-
}
|
|
2027
|
-
if (seen.has(email2)) {
|
|
2028
|
-
continue;
|
|
2029
|
-
}
|
|
2030
|
-
seen.add(email2);
|
|
2031
|
-
out.push(email2);
|
|
2032
|
-
}
|
|
2033
|
-
if (out.length > MAX_TEST_RECIPIENTS) {
|
|
2034
|
-
throw new Error(`at most ${MAX_TEST_RECIPIENTS} test recipients`);
|
|
2035
|
-
}
|
|
2036
|
-
return out;
|
|
2037
|
-
}
|
|
2038
|
-
|
|
2039
|
-
// src/lib/html.ts
|
|
2040
|
-
function escapeHtml(s) {
|
|
2041
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2042
|
-
}
|
|
2043
|
-
function escapeHtmlAttr(s) {
|
|
2044
|
-
return escapeHtml(s).replace(/"/g, """).replace(/'/g, "'");
|
|
2045
|
-
}
|
|
2046
|
-
|
|
2047
|
-
// src/lib/page_headers.ts
|
|
2048
|
-
function csp(formAction) {
|
|
2049
|
-
return [
|
|
2050
|
-
"default-src 'none'",
|
|
2051
|
-
"script-src 'none'",
|
|
2052
|
-
"object-src 'none'",
|
|
2053
|
-
"frame-src 'none'",
|
|
2054
|
-
"base-uri 'none'",
|
|
2055
|
-
`form-action ${formAction}`,
|
|
2056
|
-
"frame-ancestors 'none'",
|
|
2057
|
-
"img-src * data:",
|
|
2058
|
-
"style-src 'unsafe-inline' https://fonts.googleapis.com",
|
|
2059
|
-
"font-src https://fonts.gstatic.com"
|
|
2060
|
-
].join("; ");
|
|
2061
|
-
}
|
|
2062
|
-
function headers(formAction) {
|
|
2063
|
-
return {
|
|
2064
|
-
"content-security-policy": csp(formAction),
|
|
2065
|
-
"x-frame-options": "DENY",
|
|
2066
|
-
"x-content-type-options": "nosniff"
|
|
2067
|
-
};
|
|
1960
|
+
function devDashboardBadge(url) {
|
|
1961
|
+
return url ? `<a class="r-dev" href="${escapeHtmlAttr(url)}" title="Shown only on your local dev server"><span class="r-dev-tag">Dev</span>Open dashboard →</a>` : "";
|
|
2068
1962
|
}
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
// src/lib/page.ts
|
|
2073
|
-
var TOKENS = `
|
|
2074
|
-
:root {
|
|
2075
|
-
color-scheme: light dark;
|
|
2076
|
-
--fg:#18181b; --muted:#71717a; --line:#e4e4e7; --bg:#f4f4f5; --card:#fff;
|
|
2077
|
-
--accent:#18181b; --accent-fg:#fff; --danger:#b91c1c; --danger-fg:#fff;
|
|
2078
|
-
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
|
1963
|
+
function archiveDevDashboardChrome(url) {
|
|
1964
|
+
return { head: devDashboardStyle(url), masthead: devDashboardBadge(url) };
|
|
2079
1965
|
}
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
--accent:#e5e7eb; --accent-fg:#18181b; --danger:#f87171; --danger-fg:#18181b; }
|
|
1966
|
+
function readerBannerStyle(banner) {
|
|
1967
|
+
return banner ? `<style>${READER_BANNER_STYLE}</style>` : "";
|
|
2083
1968
|
}
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
.btn:hover { filter: brightness(1.08); }
|
|
2098
|
-
.btn:active { transform: translateY(1px); }
|
|
2099
|
-
.btn-danger { border-color:var(--danger); background:var(--danger); color:var(--danger-fg); }
|
|
2100
|
-
.btn:focus-visible, input:focus-visible, a:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
|
2101
|
-
.wrap p a { text-decoration: underline; }
|
|
2102
|
-
@media (prefers-color-scheme: dark) { .btn:focus-visible, input:focus-visible, a:focus-visible { outline-color:#60a5fa; } }
|
|
1969
|
+
function readerPage(opts) {
|
|
1970
|
+
const { name, tagline, logoUrl } = opts.identity;
|
|
1971
|
+
const logo = logoUrl ? `<span class="r-logo"><img src="${escapeHtmlAttr(logoUrl)}" alt=""></span>` : "";
|
|
1972
|
+
const nameHtml = name ? `<span class="r-name">${escapeHtml(name)}</span>` : "";
|
|
1973
|
+
const tag = tagline ? `<div class="r-tag">${escapeHtml(tagline)}</div>` : "";
|
|
1974
|
+
const text = nameHtml || tag ? `<span>${nameHtml}${tag}</span>` : "";
|
|
1975
|
+
const label = text ? "" : logo ? ' aria-label="Home"' : "";
|
|
1976
|
+
const brand = `<a class="r-brand" href="${escapeHtmlAttr(opts.homeUrl)}"${label}>${logo || text ? `${logo}${text}` : "Archive"}</a>`;
|
|
1977
|
+
const cta = opts.subscribeUrl ? `<span class="r-cta"><a class="r-sub" href="${escapeHtmlAttr(opts.subscribeUrl)}">Subscribe here →</a></span>` : "";
|
|
1978
|
+
const masthead2 = `<header class="r-mast"><div class="r-mast-in">
|
|
1979
|
+
${brand}
|
|
1980
|
+
${cta}
|
|
1981
|
+
</div></header>
|
|
2103
1982
|
`;
|
|
2104
|
-
|
|
1983
|
+
const banner = opts.banner ? `<div class="r-banner" role="note"><div class="r-banner-in"><span class="r-banner-tag">${escapeHtml(opts.banner.tag)}</span><span class="r-banner-text">${escapeHtml(opts.banner.text)}</span></div></div>` : "";
|
|
2105
1984
|
const doc = `<!doctype html>
|
|
2106
1985
|
<html lang="en">
|
|
2107
1986
|
<head>
|
|
2108
1987
|
<meta charset="utf-8">
|
|
2109
1988
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2110
|
-
<title>${escapeHtml(title)}</title>
|
|
2111
|
-
|
|
1989
|
+
<title>${escapeHtml(opts.title)}</title>
|
|
1990
|
+
${FRAUNCES_FONT_LINKS}
|
|
1991
|
+
<style>${READER_STYLE}</style>${readerBannerStyle(opts.banner)}${devDashboardStyle(opts.devDashboardUrl)}
|
|
2112
1992
|
</head>
|
|
2113
|
-
<body
|
|
1993
|
+
<body>
|
|
1994
|
+
${banner}${masthead2}<main class="r-body">${opts.mainHtml}</main>
|
|
1995
|
+
<footer class="r-foot"><div class="r-foot-in">Powered by Kestrel</div></footer>
|
|
1996
|
+
${devDashboardBadge(opts.devDashboardUrl)}
|
|
1997
|
+
</body>
|
|
2114
1998
|
</html>`;
|
|
2115
1999
|
return new Response(doc, {
|
|
2116
|
-
status,
|
|
2000
|
+
status: opts.status ?? 200,
|
|
2117
2001
|
headers: {
|
|
2118
2002
|
...PAGE_SECURITY_HEADERS,
|
|
2119
2003
|
"content-type": "text/html; charset=utf-8",
|
|
2120
|
-
"x-robots-tag": "noindex"
|
|
2004
|
+
...opts.private ? { "cache-control": "no-store", "x-robots-tag": "noindex" } : { "cache-control": "public, max-age=300" }
|
|
2121
2005
|
}
|
|
2122
2006
|
});
|
|
2123
2007
|
}
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
var DEV_BADGE_STYLE = `
|
|
2127
|
-
/* The app's brand accent (DESIGN.md \xA73), mirrored here for the ONE dev-only element
|
|
2128
|
-
the reader surface carries. That badge is app chrome, not the publication's
|
|
2129
|
-
identity, so it wears the app's action color, matching a Primary action, rather
|
|
2130
|
-
than the reader ink; keep these values in sync with styles.css. This is the
|
|
2131
|
-
single, deliberate exception to "the reader surface is not styled from the admin
|
|
2132
|
-
tokens" (DESIGN.md, reader-surface note). */
|
|
2133
|
-
:root { --k-accent:#3355cc; --k-accent-contrast:#ffffff; }
|
|
2134
|
-
@media (prefers-color-scheme: dark) { :root { --k-accent:#7d9bff; --k-accent-contrast:#10131f; } }
|
|
2135
|
-
.r-dev { position:fixed; right:18px; bottom:18px; z-index:50; display:inline-flex; align-items:center; gap:8px;
|
|
2136
|
-
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; line-height:1.6;
|
|
2137
|
-
font-size:13px; font-weight:600; text-decoration:none; padding:9px 15px 9px 10px; border-radius:999px;
|
|
2138
|
-
background:var(--k-accent); color:var(--k-accent-contrast); border:1px solid var(--k-accent);
|
|
2139
|
-
box-shadow:0 6px 20px rgba(0,0,0,.22); }
|
|
2140
|
-
.r-dev:hover { filter:brightness(1.08); }
|
|
2141
|
-
/* Inverted badge \u2014 solid contrast fill, accent-colored text \u2014 so the tiny "DEV"
|
|
2142
|
-
label clears WCAG AA on the accent pill in both themes (a translucent tint left
|
|
2143
|
-
it ~3.6:1). */
|
|
2144
|
-
.r-dev .r-dev-tag { font-size:9.5px; letter-spacing:.09em; text-transform:uppercase; font-weight:700;
|
|
2145
|
-
padding:2px 7px; border-radius:999px;
|
|
2146
|
-
background:var(--k-accent-contrast); color:var(--k-accent); }
|
|
2147
|
-
.r-dev:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
|
2148
|
-
@media (prefers-color-scheme: dark) { .r-dev:focus-visible { outline-color:#60a5fa; } }
|
|
2149
|
-
`;
|
|
2150
|
-
var READER_STYLE = `
|
|
2151
|
-
:root {
|
|
2152
|
-
color-scheme: light dark;
|
|
2153
|
-
--r-bg:${READER_BG_LIGHT}; --r-ink:#1b1a17; --r-card:#ffffff; --r-line:#e7e5df; --r-mut:#6b6a63;
|
|
2154
|
-
--r-serif:Fraunces,Georgia,'Iowan Old Style','Times New Roman',serif;
|
|
2155
|
-
--r-sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
|
2156
|
-
--r-measure:820px; /* one reading width shared by the masthead, body, and footer so their edges line up */
|
|
2157
|
-
/* The masthead sits on the card surface with a hairline rule (the footer's top
|
|
2158
|
-
rule, mirrored); the eyebrow/link accent inherits the ink color. */
|
|
2159
|
-
--brand-ink:currentColor;
|
|
2008
|
+
function pageTitle(page, name) {
|
|
2009
|
+
return name ? `${page} \xB7 ${name}` : page;
|
|
2160
2010
|
}
|
|
2161
|
-
|
|
2162
|
-
|
|
2011
|
+
function messagePage(opts) {
|
|
2012
|
+
return readerPage({
|
|
2013
|
+
identity: opts.identity,
|
|
2014
|
+
homeUrl: opts.homeUrl,
|
|
2015
|
+
title: pageTitle(opts.title, opts.identity.name),
|
|
2016
|
+
mainHtml: `<p class="r-ey">${escapeHtml(opts.eyebrow)}</p><h1 class="r-h1">${escapeHtml(opts.headline)}</h1><p class="r-lead">${opts.ledeHtml}</p>` + (opts.afterHtml ?? ""),
|
|
2017
|
+
status: opts.status,
|
|
2018
|
+
devDashboardUrl: opts.devDashboardUrl,
|
|
2019
|
+
banner: opts.banner,
|
|
2020
|
+
private: true
|
|
2021
|
+
});
|
|
2163
2022
|
}
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
a { color:inherit; }
|
|
2167
|
-
img { max-width:100%; }
|
|
2168
|
-
|
|
2169
|
-
/* Masthead: identity + a subscribe call to action, on the card surface with a
|
|
2170
|
-
hairline bottom rule \u2014 light with dark text in light mode, and it inverts with
|
|
2171
|
-
the reader tokens in dark mode. */
|
|
2172
|
-
.r-mast { background:var(--r-card); color:var(--r-ink); border-bottom:1px solid var(--r-line); }
|
|
2173
|
-
.r-mast-in { max-width:var(--r-measure); margin:0 auto; padding:24px; display:flex; align-items:center; gap:16px; }
|
|
2174
|
-
.r-brand { display:flex; align-items:center; gap:14px; text-decoration:none; color:inherit; min-width:0; }
|
|
2175
|
-
.r-logo { width:64px; height:64px; border-radius:14px; background:color-mix(in srgb, currentColor 12%, transparent);
|
|
2176
|
-
flex:none; display:grid; place-items:center; overflow:hidden; }
|
|
2177
|
-
.r-logo img { max-width:100%; max-height:100%; display:block; }
|
|
2178
|
-
.r-name { font-family:var(--r-serif); font-size:23px; font-weight:600; letter-spacing:-.01em; line-height:1.15; }
|
|
2179
|
-
.r-tag { font-size:13px; opacity:.85; margin-top:2px; }
|
|
2180
|
-
.r-cta { margin-left:auto; flex:none; }
|
|
2181
|
-
.r-sub { display:inline-flex; align-items:center; gap:6px; font-size:13.5px; font-weight:600; color:inherit;
|
|
2182
|
-
text-decoration:none; border:1px solid color-mix(in srgb, currentColor 45%, transparent);
|
|
2183
|
-
padding:8px 14px; border-radius:999px; white-space:nowrap; }
|
|
2184
|
-
.r-sub:hover { background:color-mix(in srgb, currentColor 14%, transparent); }
|
|
2185
|
-
|
|
2186
|
-
.r-body { max-width:var(--r-measure); margin:0 auto; padding:32px 24px 56px; }
|
|
2187
|
-
.r-ey { font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--brand-ink);
|
|
2188
|
-
font-weight:700; margin:0 0 12px; }
|
|
2189
|
-
.r-feat { border:1px solid var(--r-line); background:var(--r-card); border-radius:12px; padding:22px 24px; margin:0 0 1.5rem; }
|
|
2190
|
-
.r-feat .r-k { font-size:12.5px; color:var(--r-mut); margin:0 0 8px; }
|
|
2191
|
-
.r-feat h2 { font-family:var(--r-serif); font-size:26px; line-height:1.15; font-weight:600; margin:0 0 12px; }
|
|
2192
|
-
.r-feat h2 a { text-decoration:none; }
|
|
2193
|
-
.r-feat h2 a:hover { text-decoration:underline; }
|
|
2194
|
-
.r-link { font-size:14px; font-weight:600; color:var(--brand-ink); text-decoration:none; }
|
|
2195
|
-
.r-link:hover { text-decoration:underline; }
|
|
2196
|
-
ul.r-list { list-style:none; margin:0; padding:0; }
|
|
2197
|
-
li.r-item { display:flex; gap:18px; align-items:baseline; padding:16px 0; border-top:1px solid var(--r-line); }
|
|
2198
|
-
/* Fixed date column so the titles line up. Sized to hold the widest en-US long date
|
|
2199
|
-
("September 30, 2026" \u2248 122px) \u2014 a narrower column lets a long month overflow its
|
|
2200
|
-
box (flex:none + nowrap don't clip) and butt against the title. */
|
|
2201
|
-
li.r-item .r-d { font-size:12.5px; color:var(--r-mut); width:128px; flex:none; white-space:nowrap; }
|
|
2202
|
-
a.r-t { font-family:var(--r-serif); font-size:18px; font-weight:600; text-decoration:none; }
|
|
2203
|
-
a.r-t:hover { text-decoration:underline; }
|
|
2204
|
-
.r-empty { color:var(--r-mut); padding:20px 0; }
|
|
2205
|
-
.r-arch { display:inline-block; margin-top:24px; font-size:14px; font-weight:600; color:var(--brand-ink); text-decoration:none; }
|
|
2206
|
-
.r-arch:hover { text-decoration:underline; }
|
|
2207
|
-
|
|
2208
|
-
/* Subscribe / message pages: a headline, lede, and a branded form. */
|
|
2209
|
-
.r-h1 { font-family:var(--r-serif); font-size:30px; line-height:1.12; font-weight:600; letter-spacing:-.01em; margin:0 0 12px; }
|
|
2210
|
-
.r-lead { font-size:16px; color:var(--r-mut); margin:0 0 22px; max-width:54ch; }
|
|
2211
|
-
.r-form { display:flex; gap:10px; flex-wrap:wrap; max-width:460px; }
|
|
2212
|
-
.r-form input { flex:1 1 220px; font:inherit; font-size:16px; padding:11px 13px; border:1px solid var(--r-line);
|
|
2213
|
-
border-radius:9px; background:var(--r-card); color:var(--r-ink); }
|
|
2214
|
-
.r-form input:focus-visible { outline:2px solid var(--brand-ink); outline-offset:1px; border-color:var(--brand-ink); }
|
|
2215
|
-
.r-btn { font:inherit; font-size:15px; font-weight:600; padding:11px 20px; border-radius:9px; border:1px solid var(--r-ink);
|
|
2216
|
-
background:var(--r-ink); color:var(--r-bg); cursor:pointer; white-space:nowrap; }
|
|
2217
|
-
.r-btn:hover { filter:brightness(1.06); }
|
|
2218
|
-
.r-fine { font-size:12.5px; color:var(--r-mut); margin:16px 0 0; }
|
|
2219
|
-
|
|
2220
|
-
.r-foot { border-top:1px solid var(--r-line); }
|
|
2221
|
-
.r-foot-in { max-width:var(--r-measure); margin:0 auto; padding:22px 24px 40px; font-size:12.5px; color:var(--r-mut); }
|
|
2222
|
-
|
|
2223
|
-
a:focus-visible, .r-sub:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
|
2224
|
-
@media (prefers-color-scheme: dark) { a:focus-visible, .r-sub:focus-visible { outline-color:#60a5fa; } }
|
|
2225
|
-
@media (max-width:560px) {
|
|
2226
|
-
.r-mast-in { flex-wrap:wrap; gap:12px; }
|
|
2227
|
-
.r-cta { margin-left:0; }
|
|
2228
|
-
li.r-item { flex-wrap:wrap; gap:6px 18px; }
|
|
2229
|
-
li.r-item .r-d { width:100%; }
|
|
2230
|
-
}
|
|
2231
|
-
`;
|
|
2232
|
-
var FRAUNCES_FONT_LINKS = `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..600;1,9..144,400..600&display=swap">`;
|
|
2233
|
-
var ARCHIVE_POST_HEAD = FRAUNCES_FONT_LINKS + `<style>body.k-bg,body .k-bg,body .k-card{background:${READER_BG_LIGHT}!important}.k-mast{color:#52525b;border-bottom:1px solid #e4e4e7}.k-mast a{color:#18181b}@media(prefers-color-scheme:dark){body.k-bg,body .k-bg,body .k-card{background:${READER_BG_DARK}!important}.k-mast{color:#a1a1aa;border-bottom-color:#2e2e33}.k-mast a{color:#ededed}}</style>`;
|
|
2234
|
-
function devDashboardStyle(url) {
|
|
2235
|
-
return url ? `<style>${DEV_BADGE_STYLE}</style>` : "";
|
|
2236
|
-
}
|
|
2237
|
-
function devDashboardBadge(url) {
|
|
2238
|
-
return url ? `<a class="r-dev" href="${escapeHtmlAttr(url)}" title="Shown only on your local dev server"><span class="r-dev-tag">Dev</span>Open dashboard →</a>` : "";
|
|
2239
|
-
}
|
|
2240
|
-
function archiveDevDashboardChrome(url) {
|
|
2241
|
-
return { head: devDashboardStyle(url), masthead: devDashboardBadge(url) };
|
|
2242
|
-
}
|
|
2243
|
-
function readerPage(opts) {
|
|
2244
|
-
const { name, tagline, logoUrl } = opts.identity;
|
|
2245
|
-
const logo = logoUrl ? `<span class="r-logo"><img src="${escapeHtmlAttr(logoUrl)}" alt=""></span>` : "";
|
|
2246
|
-
const tag = tagline ? `<div class="r-tag">${escapeHtml(tagline)}</div>` : "";
|
|
2247
|
-
const cta = opts.subscribeUrl ? `<span class="r-cta"><a class="r-sub" href="${escapeHtmlAttr(opts.subscribeUrl)}">Subscribe here →</a></span>` : "";
|
|
2248
|
-
const doc = `<!doctype html>
|
|
2249
|
-
<html lang="en">
|
|
2250
|
-
<head>
|
|
2251
|
-
<meta charset="utf-8">
|
|
2252
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2253
|
-
<title>${escapeHtml(opts.title)}</title>
|
|
2254
|
-
${FRAUNCES_FONT_LINKS}
|
|
2255
|
-
<style>${READER_STYLE}</style>${devDashboardStyle(opts.devDashboardUrl)}
|
|
2256
|
-
</head>
|
|
2257
|
-
<body>
|
|
2258
|
-
<header class="r-mast"><div class="r-mast-in">
|
|
2259
|
-
<a class="r-brand" href="${escapeHtmlAttr(opts.homeUrl)}">${logo}<span><span class="r-name">${escapeHtml(name)}</span>${tag}</span></a>
|
|
2260
|
-
${cta}
|
|
2261
|
-
</div></header>
|
|
2262
|
-
<main class="r-body">${opts.mainHtml}</main>
|
|
2263
|
-
<footer class="r-foot"><div class="r-foot-in">Powered by Kestrel</div></footer>
|
|
2264
|
-
${devDashboardBadge(opts.devDashboardUrl)}
|
|
2265
|
-
</body>
|
|
2266
|
-
</html>`;
|
|
2267
|
-
return new Response(doc, {
|
|
2268
|
-
status: opts.status ?? 200,
|
|
2269
|
-
headers: {
|
|
2270
|
-
...PAGE_SECURITY_HEADERS,
|
|
2271
|
-
"content-type": "text/html; charset=utf-8",
|
|
2272
|
-
"cache-control": "public, max-age=300"
|
|
2273
|
-
}
|
|
2274
|
-
});
|
|
2023
|
+
function archiveLink(url, label) {
|
|
2024
|
+
return `<a class="r-arch" href="${escapeHtmlAttr(url)}">${escapeHtml(label)} →</a>`;
|
|
2275
2025
|
}
|
|
2276
2026
|
function postRow(i) {
|
|
2277
2027
|
return `<li class="r-item"><span class="r-d">${escapeHtml(i.dateLabel)}</span><a class="r-t" href="${escapeHtmlAttr(i.url)}">${escapeHtml(i.title)}</a></li>`;
|
|
@@ -2284,14 +2034,14 @@ function landingPage(opts) {
|
|
|
2284
2034
|
const f = opts.featured;
|
|
2285
2035
|
const feature = `<p class="r-ey">Latest post</p><article class="r-feat"><p class="r-k">${escapeHtml(f.dateLabel)}</p><h2><a href="${escapeHtmlAttr(f.url)}">${escapeHtml(f.title)}</a></h2><a class="r-link" href="${escapeHtmlAttr(f.url)}">Read here →</a></article>`;
|
|
2286
2036
|
const list5 = opts.recent.length ? `<ul class="r-list">${opts.recent.map(postRow).join("")}</ul>` : "";
|
|
2287
|
-
|
|
2288
|
-
main = feature + list5 + archive;
|
|
2037
|
+
main = feature + list5 + archiveLink(opts.archiveUrl, "Browse the full archive");
|
|
2289
2038
|
}
|
|
2290
2039
|
return readerPage({
|
|
2291
2040
|
identity: opts.identity,
|
|
2292
2041
|
subscribeUrl: opts.subscribeUrl,
|
|
2293
2042
|
homeUrl: opts.homeUrl,
|
|
2294
|
-
|
|
2043
|
+
// The front door is titled by the publication; with no name, by what it is.
|
|
2044
|
+
title: opts.identity.name || "Home",
|
|
2295
2045
|
mainHtml: main,
|
|
2296
2046
|
devDashboardUrl: opts.devDashboardUrl
|
|
2297
2047
|
});
|
|
@@ -2302,7 +2052,7 @@ function archiveIndexPage(opts) {
|
|
|
2302
2052
|
identity: opts.identity,
|
|
2303
2053
|
subscribeUrl: opts.subscribeUrl,
|
|
2304
2054
|
homeUrl: opts.homeUrl,
|
|
2305
|
-
title:
|
|
2055
|
+
title: pageTitle("Archive", opts.identity.name),
|
|
2306
2056
|
mainHtml: `<p class="r-ey">Archive</p>${list5}`,
|
|
2307
2057
|
devDashboardUrl: opts.devDashboardUrl
|
|
2308
2058
|
});
|
|
@@ -2433,48 +2183,339 @@ function emailOnlyRegionsWhole(html) {
|
|
|
2433
2183
|
open.push(name);
|
|
2434
2184
|
}
|
|
2435
2185
|
}
|
|
2436
|
-
if (open.length > 0 || /[<>]/.test(span.replace(/<\/?[a-zA-Z][\w-]*\b[^<>]*>/g, ""))) {
|
|
2437
|
-
return false;
|
|
2186
|
+
if (open.length > 0 || /[<>]/.test(span.replace(/<\/?[a-zA-Z][\w-]*\b[^<>]*>/g, ""))) {
|
|
2187
|
+
return false;
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
return true;
|
|
2191
|
+
}
|
|
2192
|
+
var HEADING_FONT = "Fraunces, Georgia, 'Times New Roman', serif";
|
|
2193
|
+
var FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
|
|
2194
|
+
function emailLayout(i) {
|
|
2195
|
+
const preheader = i.preheader ? `<span style="display:none!important;opacity:0;color:transparent;height:0;width:0;overflow:hidden;">${escapeHtml(
|
|
2196
|
+
i.preheader
|
|
2197
|
+
)}</span>` : "";
|
|
2198
|
+
return `<!doctype html>
|
|
2199
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
2200
|
+
<meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark">
|
|
2201
|
+
<title>${escapeHtml(i.subject)}</title>${ARCHIVE_HEAD_ANCHOR}
|
|
2202
|
+
<style>@media (prefers-color-scheme: dark) { .k-bg { background: #18181b !important; } }</style>
|
|
2203
|
+
</head>
|
|
2204
|
+
<body class="k-bg" style="margin:0;padding:0;background:#ffffff;">
|
|
2205
|
+
${preheader}
|
|
2206
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="k-bg" style="background:#ffffff;"><tr><td align="center" style="padding:8px 12px 48px;">
|
|
2207
|
+
<table role="presentation" width="664" cellpadding="0" cellspacing="0" class="k-bg" style="max-width:664px;width:100%;background:#ffffff;">
|
|
2208
|
+
<tr><td style="padding:24px 32px 32px;font-family:${FONT};font-size:16px;line-height:1.6;color:#18181b;word-break:break-word;">
|
|
2209
|
+
${ARCHIVE_MASTHEAD_ANCHOR}${i.bodyHtml}
|
|
2210
|
+
</td></tr>
|
|
2211
|
+
</table>
|
|
2212
|
+
</td></tr></table>
|
|
2213
|
+
</body></html>`;
|
|
2214
|
+
}
|
|
2215
|
+
function archiveMasthead(opts) {
|
|
2216
|
+
const name = escapeHtml(opts.name || "Archive");
|
|
2217
|
+
const date = escapeHtml(opts.dateLabel);
|
|
2218
|
+
const url = escapeHtmlAttr(opts.indexUrl);
|
|
2219
|
+
return `<div class="k-mast" style="display:flex;justify-content:space-between;align-items:baseline;gap:16px;font-family:${FONT};font-size:13px;line-height:1.5;padding-bottom:14px;margin-bottom:28px;"><a href="${url}" style="text-decoration:none;font-weight:600;font-size:15px;font-family:${HEADING_FONT};"><span style="font-family:${FONT};font-weight:500;">←</span> ${name}</a><span style="white-space:nowrap;">${date}</span></div>`;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
// src/render/template_engine.ts
|
|
2223
|
+
import { initWasm, inline } from "@css-inline/css-inline-wasm";
|
|
2224
|
+
import wasmModule from "@css-inline/css-inline-wasm/index_bg.wasm";
|
|
2225
|
+
|
|
2226
|
+
// shared/publication.ts
|
|
2227
|
+
function fromDisplayName(fromAddress) {
|
|
2228
|
+
const header = fromAddress.trim();
|
|
2229
|
+
const lt = header.lastIndexOf("<");
|
|
2230
|
+
if (lt < 0 || !header.endsWith(">")) {
|
|
2231
|
+
return "";
|
|
2232
|
+
}
|
|
2233
|
+
const display = header.slice(0, lt).trim();
|
|
2234
|
+
if (display.length >= 2 && display.startsWith('"') && display.endsWith('"')) {
|
|
2235
|
+
return display.slice(1, -1).replace(/\\(.)/g, "$1").trim();
|
|
2236
|
+
}
|
|
2237
|
+
return display;
|
|
2238
|
+
}
|
|
2239
|
+
function publicationName(name, fromAddress) {
|
|
2240
|
+
return name || fromDisplayName(fromAddress);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// src/db/settings.ts
|
|
2244
|
+
var BRANDING_LOGO_KEY = "branding/logo";
|
|
2245
|
+
function newLogoKey(version) {
|
|
2246
|
+
return `${BRANDING_LOGO_KEY}/${version}-${crypto.randomUUID().slice(0, 8)}`;
|
|
2247
|
+
}
|
|
2248
|
+
var LOGO_KEY = /^branding\/logo\/\d+-[0-9a-f]{8}$/;
|
|
2249
|
+
function publicationLogoUrl(mediaBase, logo) {
|
|
2250
|
+
if (!logo) {
|
|
2251
|
+
return "";
|
|
2252
|
+
}
|
|
2253
|
+
return logo.key ? `${mediaBase}/${logo.key}` : `${mediaBase}/${BRANDING_LOGO_KEY}?v=${logo.version}`;
|
|
2254
|
+
}
|
|
2255
|
+
var DEFAULT_CONFIRMATION_EMAIL = {
|
|
2256
|
+
subject: "Confirm your subscription",
|
|
2257
|
+
body: "Thanks for subscribing. Please confirm your email address to start receiving the newsletter.",
|
|
2258
|
+
buttonLabel: "Confirm subscription",
|
|
2259
|
+
reassurance: "If you didn't request this, you can safely ignore this email."
|
|
2260
|
+
};
|
|
2261
|
+
var DEFAULT_SETTINGS = {
|
|
2262
|
+
testRecipients: [],
|
|
2263
|
+
publication: { name: "", tagline: "", address: "", logo: null },
|
|
2264
|
+
emailTemplate: "",
|
|
2265
|
+
confirmationEmail: DEFAULT_CONFIRMATION_EMAIL,
|
|
2266
|
+
notifications: { to: "" }
|
|
2267
|
+
};
|
|
2268
|
+
var MAX_TEST_RECIPIENTS = 20;
|
|
2269
|
+
var MAX_NAME = 120;
|
|
2270
|
+
var MAX_TAGLINE = 200;
|
|
2271
|
+
var MAX_ADDRESS = 300;
|
|
2272
|
+
var MAX_TEMPLATE = 4e4;
|
|
2273
|
+
var MAX_CE_SUBJECT = 200;
|
|
2274
|
+
var MAX_CE_BODY = 1e3;
|
|
2275
|
+
var MAX_CE_BUTTON = 80;
|
|
2276
|
+
var MAX_CE_REASSURANCE = 400;
|
|
2277
|
+
function coercePublication(raw) {
|
|
2278
|
+
const o = raw && typeof raw === "object" ? raw : {};
|
|
2279
|
+
const str = (v) => typeof v === "string" ? v : "";
|
|
2280
|
+
const lr = o.logo;
|
|
2281
|
+
const key = lr && typeof lr.key === "string" && LOGO_KEY.test(lr.key);
|
|
2282
|
+
const logo = lr && typeof lr === "object" && typeof lr.version === "number" ? {
|
|
2283
|
+
version: lr.version,
|
|
2284
|
+
contentType: str(lr.contentType) || "image/png",
|
|
2285
|
+
...key ? { key: lr.key } : {}
|
|
2286
|
+
} : null;
|
|
2287
|
+
return {
|
|
2288
|
+
name: str(o.name).slice(0, MAX_NAME),
|
|
2289
|
+
tagline: str(o.tagline).slice(0, MAX_TAGLINE),
|
|
2290
|
+
address: str(o.address).slice(0, MAX_ADDRESS),
|
|
2291
|
+
logo
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
function coerceConfirmation(raw) {
|
|
2295
|
+
const o = raw && typeof raw === "object" ? raw : {};
|
|
2296
|
+
const str = (v) => typeof v === "string" ? v : "";
|
|
2297
|
+
return {
|
|
2298
|
+
subject: str(o.subject).slice(0, MAX_CE_SUBJECT),
|
|
2299
|
+
body: str(o.body).slice(0, MAX_CE_BODY),
|
|
2300
|
+
buttonLabel: str(o.buttonLabel).slice(0, MAX_CE_BUTTON),
|
|
2301
|
+
reassurance: str(o.reassurance).slice(0, MAX_CE_REASSURANCE)
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
function coerce(raw) {
|
|
2305
|
+
const o = raw && typeof raw === "object" ? raw : {};
|
|
2306
|
+
const list5 = Array.isArray(o.testRecipients) ? o.testRecipients : [];
|
|
2307
|
+
return {
|
|
2308
|
+
testRecipients: list5.filter((e) => typeof e === "string"),
|
|
2309
|
+
publication: coercePublication(o.publication),
|
|
2310
|
+
emailTemplate: typeof o.emailTemplate === "string" ? o.emailTemplate.slice(0, MAX_TEMPLATE) : "",
|
|
2311
|
+
// A row that predates this feature has no `confirmationEmail` key at all — that's
|
|
2312
|
+
// "never configured", so it gets the full built-in copy (reassurance included), NOT
|
|
2313
|
+
// a blank-reassurance row. Only once the key exists (the operator saved copy) does a
|
|
2314
|
+
// blank reassurance mean "deliberately omit the footer".
|
|
2315
|
+
confirmationEmail: "confirmationEmail" in o ? coerceConfirmation(o.confirmationEmail) : { ...DEFAULT_CONFIRMATION_EMAIL },
|
|
2316
|
+
notifications: coerceNotifications(o.notifications)
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
function coerceNotifications(raw) {
|
|
2320
|
+
const o = raw && typeof raw === "object" ? raw : {};
|
|
2321
|
+
return { to: typeof o.to === "string" ? o.to : "" };
|
|
2322
|
+
}
|
|
2323
|
+
function resolveConfirmationEmail(settings) {
|
|
2324
|
+
const c = settings.confirmationEmail;
|
|
2325
|
+
const d = DEFAULT_CONFIRMATION_EMAIL;
|
|
2326
|
+
return {
|
|
2327
|
+
subject: c.subject.trim() || d.subject,
|
|
2328
|
+
body: c.body.trim() || d.body,
|
|
2329
|
+
buttonLabel: c.buttonLabel.trim() || d.buttonLabel,
|
|
2330
|
+
reassurance: c.reassurance
|
|
2331
|
+
};
|
|
2332
|
+
}
|
|
2333
|
+
async function readSettings(db) {
|
|
2334
|
+
const row = await db.prepare("SELECT data, updated_at FROM settings WHERE id = 1").first();
|
|
2335
|
+
if (!row) {
|
|
2336
|
+
return { settings: structuredClone(DEFAULT_SETTINGS), version: null };
|
|
2337
|
+
}
|
|
2338
|
+
return { settings: coerce(parseStored(row.data)), version: row.updated_at };
|
|
2339
|
+
}
|
|
2340
|
+
function parseStored(data) {
|
|
2341
|
+
let parsed;
|
|
2342
|
+
let problem = "is not a JSON object";
|
|
2343
|
+
try {
|
|
2344
|
+
parsed = JSON.parse(data);
|
|
2345
|
+
} catch (err) {
|
|
2346
|
+
problem = `is not valid JSON (${err instanceof Error ? err.message : String(err)})`;
|
|
2347
|
+
}
|
|
2348
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
2349
|
+
return parsed;
|
|
2350
|
+
}
|
|
2351
|
+
log.error("settings.corrupt", {
|
|
2352
|
+
message: `the settings row (settings.id = 1) ${problem}; reads and writes of settings fail until it is repaired by hand`,
|
|
2353
|
+
length: data.length
|
|
2354
|
+
});
|
|
2355
|
+
throw new HttpError(
|
|
2356
|
+
500,
|
|
2357
|
+
"settings_corrupt",
|
|
2358
|
+
"The stored settings can't be read, so they are left as they are and nothing will save over them. The server log (settings.corrupt) says what's wrong."
|
|
2359
|
+
);
|
|
2360
|
+
}
|
|
2361
|
+
async function getSettings(db) {
|
|
2362
|
+
return (await readSettings(db)).settings;
|
|
2363
|
+
}
|
|
2364
|
+
async function getSettingsForDisplay(db) {
|
|
2365
|
+
try {
|
|
2366
|
+
return await getSettings(db);
|
|
2367
|
+
} catch (err) {
|
|
2368
|
+
if (err instanceof HttpError && err.code === "settings_corrupt") {
|
|
2369
|
+
return structuredClone(DEFAULT_SETTINGS);
|
|
2370
|
+
}
|
|
2371
|
+
throw err;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
async function ensureSettingsRow(db) {
|
|
2375
|
+
await db.prepare("INSERT OR IGNORE INTO settings (id, data, updated_at) VALUES (1, '{}', 0)").run();
|
|
2376
|
+
}
|
|
2377
|
+
function persistSettingsStmt(db, next, expected, guard2) {
|
|
2378
|
+
const extra = guard2 ? ` AND ${guard2.sql}` : "";
|
|
2379
|
+
return db.prepare(
|
|
2380
|
+
`UPDATE settings SET data = ?, updated_at = MAX(?, updated_at + 1)
|
|
2381
|
+
WHERE id = 1 AND updated_at = ?${extra}`
|
|
2382
|
+
).bind(JSON.stringify(next), Date.now(), expected, ...guard2?.binds ?? []);
|
|
2383
|
+
}
|
|
2384
|
+
var WRITE_RETRIES = 5;
|
|
2385
|
+
var SettingsContention = class extends HttpError {
|
|
2386
|
+
constructor() {
|
|
2387
|
+
super(409, "conflict", "settings changed concurrently; try again");
|
|
2388
|
+
}
|
|
2389
|
+
};
|
|
2390
|
+
async function updateSettingsWith(db, derive) {
|
|
2391
|
+
await ensureSettingsRow(db);
|
|
2392
|
+
for (let attempt = 0; attempt < WRITE_RETRIES; attempt++) {
|
|
2393
|
+
const { settings, version } = await readSettings(db);
|
|
2394
|
+
const next = derive(settings);
|
|
2395
|
+
const res = await persistSettingsStmt(db, next, version ?? 0).run();
|
|
2396
|
+
if ((res.meta.changes ?? 0) > 0) {
|
|
2397
|
+
return next;
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
throw new SettingsContention();
|
|
2401
|
+
}
|
|
2402
|
+
async function updateSettings(db, patch) {
|
|
2403
|
+
return updateSettingsWith(db, (current) => applyPatch(current, patch));
|
|
2404
|
+
}
|
|
2405
|
+
function applyPatch(current, patch) {
|
|
2406
|
+
const next = {
|
|
2407
|
+
...current,
|
|
2408
|
+
publication: { ...current.publication },
|
|
2409
|
+
confirmationEmail: { ...current.confirmationEmail },
|
|
2410
|
+
notifications: { ...current.notifications }
|
|
2411
|
+
};
|
|
2412
|
+
if (patch.testRecipients !== void 0) {
|
|
2413
|
+
next.testRecipients = normalizeRecipients(patch.testRecipients);
|
|
2414
|
+
}
|
|
2415
|
+
if (patch.publication !== void 0) {
|
|
2416
|
+
const p = patch.publication;
|
|
2417
|
+
if (p.name !== void 0) {
|
|
2418
|
+
next.publication.name = normalizeText(p.name, "name", MAX_NAME);
|
|
2419
|
+
}
|
|
2420
|
+
if (p.tagline !== void 0) {
|
|
2421
|
+
next.publication.tagline = normalizeText(p.tagline, "tagline", MAX_TAGLINE);
|
|
2422
|
+
}
|
|
2423
|
+
if (p.address !== void 0) {
|
|
2424
|
+
next.publication.address = normalizeText(p.address, "address", MAX_ADDRESS);
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
if (patch.emailTemplate !== void 0) {
|
|
2428
|
+
if (typeof patch.emailTemplate !== "string") {
|
|
2429
|
+
throw new Error("emailTemplate must be a string");
|
|
2430
|
+
}
|
|
2431
|
+
if (patch.emailTemplate.length > MAX_TEMPLATE) {
|
|
2432
|
+
throw new Error(`emailTemplate must be ${MAX_TEMPLATE} characters or fewer`);
|
|
2433
|
+
}
|
|
2434
|
+
next.emailTemplate = patch.emailTemplate;
|
|
2435
|
+
}
|
|
2436
|
+
if (patch.confirmationEmail !== void 0) {
|
|
2437
|
+
const c = patch.confirmationEmail;
|
|
2438
|
+
if (c.subject !== void 0) {
|
|
2439
|
+
next.confirmationEmail.subject = normalizeText(c.subject, "subject", MAX_CE_SUBJECT);
|
|
2440
|
+
}
|
|
2441
|
+
if (c.body !== void 0) {
|
|
2442
|
+
next.confirmationEmail.body = normalizeText(c.body, "message", MAX_CE_BODY);
|
|
2443
|
+
}
|
|
2444
|
+
if (c.buttonLabel !== void 0) {
|
|
2445
|
+
next.confirmationEmail.buttonLabel = normalizeText(
|
|
2446
|
+
c.buttonLabel,
|
|
2447
|
+
"button label",
|
|
2448
|
+
MAX_CE_BUTTON
|
|
2449
|
+
);
|
|
2450
|
+
}
|
|
2451
|
+
if (c.reassurance !== void 0) {
|
|
2452
|
+
next.confirmationEmail.reassurance = normalizeText(
|
|
2453
|
+
c.reassurance,
|
|
2454
|
+
"reassurance line",
|
|
2455
|
+
MAX_CE_REASSURANCE
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
if (patch.notifications?.to !== void 0) {
|
|
2460
|
+
next.notifications.to = normalizeNotifyTo(patch.notifications.to);
|
|
2461
|
+
}
|
|
2462
|
+
return next;
|
|
2463
|
+
}
|
|
2464
|
+
function normalizeNotifyTo(raw) {
|
|
2465
|
+
if (typeof raw !== "string") {
|
|
2466
|
+
throw new Error("notifications address must be a string");
|
|
2467
|
+
}
|
|
2468
|
+
const email2 = normalizeEmail(raw);
|
|
2469
|
+
if (email2 && !isValidEmail(email2)) {
|
|
2470
|
+
throw new Error(`not a valid email address: ${raw}`);
|
|
2471
|
+
}
|
|
2472
|
+
return email2;
|
|
2473
|
+
}
|
|
2474
|
+
function withPublicationLogo(current, logo) {
|
|
2475
|
+
return { ...current, publication: { ...current.publication, logo } };
|
|
2476
|
+
}
|
|
2477
|
+
async function setPublicationLogo(db, logo) {
|
|
2478
|
+
return updateSettingsWith(db, (current) => withPublicationLogo(current, logo));
|
|
2479
|
+
}
|
|
2480
|
+
function normalizeText(v, what, max) {
|
|
2481
|
+
if (typeof v !== "string") {
|
|
2482
|
+
throw new Error(`${what} must be a string`);
|
|
2483
|
+
}
|
|
2484
|
+
return v.trim().slice(0, max);
|
|
2485
|
+
}
|
|
2486
|
+
function normalizeRecipients(input) {
|
|
2487
|
+
if (!Array.isArray(input)) {
|
|
2488
|
+
throw new Error("testRecipients must be a list of email addresses");
|
|
2489
|
+
}
|
|
2490
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2491
|
+
const out = [];
|
|
2492
|
+
for (const raw of input) {
|
|
2493
|
+
if (typeof raw !== "string") {
|
|
2494
|
+
throw new Error("each test recipient must be a string");
|
|
2495
|
+
}
|
|
2496
|
+
const email2 = normalizeEmail(raw);
|
|
2497
|
+
if (!email2) {
|
|
2498
|
+
continue;
|
|
2438
2499
|
}
|
|
2500
|
+
if (!isValidEmail(email2)) {
|
|
2501
|
+
throw new Error(`not a valid email address: ${raw}`);
|
|
2502
|
+
}
|
|
2503
|
+
if (seen.has(email2)) {
|
|
2504
|
+
continue;
|
|
2505
|
+
}
|
|
2506
|
+
seen.add(email2);
|
|
2507
|
+
out.push(email2);
|
|
2439
2508
|
}
|
|
2440
|
-
|
|
2441
|
-
}
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
function emailLayout(i) {
|
|
2445
|
-
const preheader = i.preheader ? `<span style="display:none!important;opacity:0;color:transparent;height:0;width:0;overflow:hidden;">${escapeHtml(
|
|
2446
|
-
i.preheader
|
|
2447
|
-
)}</span>` : "";
|
|
2448
|
-
return `<!doctype html>
|
|
2449
|
-
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
2450
|
-
<meta name="color-scheme" content="light dark"><meta name="supported-color-schemes" content="light dark">
|
|
2451
|
-
<title>${escapeHtml(i.subject)}</title>${ARCHIVE_HEAD_ANCHOR}
|
|
2452
|
-
<style>@media (prefers-color-scheme: dark) { .k-bg { background: #18181b !important; } }</style>
|
|
2453
|
-
</head>
|
|
2454
|
-
<body class="k-bg" style="margin:0;padding:0;background:#ffffff;">
|
|
2455
|
-
${preheader}
|
|
2456
|
-
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" class="k-bg" style="background:#ffffff;"><tr><td align="center" style="padding:8px 12px 48px;">
|
|
2457
|
-
<table role="presentation" width="664" cellpadding="0" cellspacing="0" class="k-bg" style="max-width:664px;width:100%;background:#ffffff;">
|
|
2458
|
-
<tr><td style="padding:24px 32px 32px;font-family:${FONT};font-size:16px;line-height:1.6;color:#18181b;word-break:break-word;">
|
|
2459
|
-
${ARCHIVE_MASTHEAD_ANCHOR}${i.bodyHtml}
|
|
2460
|
-
</td></tr>
|
|
2461
|
-
</table>
|
|
2462
|
-
</td></tr></table>
|
|
2463
|
-
</body></html>`;
|
|
2464
|
-
}
|
|
2465
|
-
function archiveMasthead(opts) {
|
|
2466
|
-
const name = escapeHtml(opts.name);
|
|
2467
|
-
const date = escapeHtml(opts.dateLabel);
|
|
2468
|
-
const url = escapeHtmlAttr(opts.indexUrl);
|
|
2469
|
-
return `<div class="k-mast" style="display:flex;justify-content:space-between;align-items:baseline;gap:16px;font-family:${FONT};font-size:13px;line-height:1.5;padding-bottom:14px;margin-bottom:28px;"><a href="${url}" style="text-decoration:none;font-weight:600;font-size:15px;font-family:${HEADING_FONT};"><span style="font-family:${FONT};font-weight:500;">←</span> ${name}</a><span style="white-space:nowrap;">${date}</span></div>`;
|
|
2509
|
+
if (out.length > MAX_TEST_RECIPIENTS) {
|
|
2510
|
+
throw new Error(`at most ${MAX_TEST_RECIPIENTS} test recipients`);
|
|
2511
|
+
}
|
|
2512
|
+
return out;
|
|
2470
2513
|
}
|
|
2471
2514
|
|
|
2472
2515
|
// src/render/template_engine.ts
|
|
2473
|
-
import { initWasm, inline } from "@css-inline/css-inline-wasm";
|
|
2474
|
-
import wasmModule from "@css-inline/css-inline-wasm/index_bg.wasm";
|
|
2475
2516
|
var UNSUB_SENTINEL = "%%UNSUBSCRIBE_URL%%";
|
|
2476
2517
|
var SENTTO_SENTINEL = "%%SENT_TO%%";
|
|
2477
|
-
var
|
|
2518
|
+
var TOKENS = {
|
|
2478
2519
|
".Post.Body": { phase: "render", escaping: "raw" },
|
|
2479
2520
|
".Post.Subject": { phase: "render", escaping: "attr" },
|
|
2480
2521
|
".Publication.Name": { phase: "render", escaping: "attr" },
|
|
@@ -2493,9 +2534,9 @@ var TOKENS2 = {
|
|
|
2493
2534
|
".Email.UnsubscribeURL": { phase: "delivery", escaping: "raw", sentinel: UNSUB_SENTINEL },
|
|
2494
2535
|
".Email.SentTo": { phase: "delivery", escaping: "attr", sentinel: SENTTO_SENTINEL }
|
|
2495
2536
|
};
|
|
2496
|
-
var EMAIL_TEMPLATE_VARIABLES = Object.keys(
|
|
2537
|
+
var EMAIL_TEMPLATE_VARIABLES = Object.keys(TOKENS);
|
|
2497
2538
|
var KNOWN_VARS = new Set(EMAIL_TEMPLATE_VARIABLES);
|
|
2498
|
-
var DELIVERY_TOKENS = Object.entries(
|
|
2539
|
+
var DELIVERY_TOKENS = Object.entries(TOKENS).flatMap(
|
|
2499
2540
|
([key, spec]) => spec.phase === "delivery" ? [{ key, escaping: spec.escaping, sentinel: spec.sentinel }] : []
|
|
2500
2541
|
);
|
|
2501
2542
|
var TOKEN2 = /\{\{\s*([\w.]+)\s*\}\}/g;
|
|
@@ -2511,7 +2552,7 @@ function markEmailOnlyRegions(html) {
|
|
|
2511
2552
|
}
|
|
2512
2553
|
function fillEmailTemplate(html, ctx) {
|
|
2513
2554
|
return markEmailOnlyRegions(html).replace(TOKEN2, (_m, key) => {
|
|
2514
|
-
const spec =
|
|
2555
|
+
const spec = TOKENS[key];
|
|
2515
2556
|
if (!spec) {
|
|
2516
2557
|
return "";
|
|
2517
2558
|
}
|
|
@@ -2648,22 +2689,20 @@ function onceUntilRejected(init) {
|
|
|
2648
2689
|
return ready;
|
|
2649
2690
|
};
|
|
2650
2691
|
}
|
|
2651
|
-
var inlinerReady = onceUntilRejected(
|
|
2692
|
+
var inlinerReady = onceUntilRejected(
|
|
2693
|
+
() => initWasm({ module_or_path: wasmModule })
|
|
2694
|
+
);
|
|
2652
2695
|
async function inlineEmailCss(html) {
|
|
2653
2696
|
await inlinerReady();
|
|
2654
2697
|
return inline(html, { keepAtRules: true });
|
|
2655
2698
|
}
|
|
2656
|
-
function fromDisplayName(fromAddress) {
|
|
2657
|
-
const m = /^\s*"?([^"<]*?)"?\s*<[^>]+>\s*$/.exec(fromAddress);
|
|
2658
|
-
return m?.[1] ? m[1].trim() : "";
|
|
2659
|
-
}
|
|
2660
2699
|
function resolveBranding(settings, config) {
|
|
2661
2700
|
const p = settings.publication;
|
|
2662
2701
|
return {
|
|
2663
2702
|
template: settings.emailTemplate.trim() ? settings.emailTemplate : DEFAULT_EMAIL_TEMPLATE,
|
|
2664
|
-
name: p.name
|
|
2703
|
+
name: publicationName(p.name, config.fromAddress),
|
|
2665
2704
|
tagline: p.tagline,
|
|
2666
|
-
logoUrl:
|
|
2705
|
+
logoUrl: publicationLogoUrl(config.mediaPublicBase, p.logo),
|
|
2667
2706
|
address: p.address
|
|
2668
2707
|
};
|
|
2669
2708
|
}
|
|
@@ -2871,21 +2910,32 @@ function substituteRecipient(r, ctx) {
|
|
|
2871
2910
|
};
|
|
2872
2911
|
}
|
|
2873
2912
|
|
|
2874
|
-
// src/routes/
|
|
2875
|
-
function fromDisplayName2(fromAddress) {
|
|
2876
|
-
const lt = fromAddress.indexOf("<");
|
|
2877
|
-
const display = (lt >= 0 ? fromAddress.slice(0, lt) : "").trim().replace(/^"|"$/g, "").trim();
|
|
2878
|
-
return display || "Newsletter";
|
|
2879
|
-
}
|
|
2913
|
+
// src/routes/reader.ts
|
|
2880
2914
|
async function readerIdentity(c, config) {
|
|
2881
2915
|
const { publication: p } = await getSettingsForDisplay(c.env.DB);
|
|
2882
2916
|
return {
|
|
2883
|
-
name: p.name
|
|
2917
|
+
name: publicationName(p.name, config.fromAddress),
|
|
2884
2918
|
tagline: p.tagline,
|
|
2885
|
-
logoUrl:
|
|
2919
|
+
logoUrl: publicationLogoUrl(config.mediaPublicBase, p.logo)
|
|
2886
2920
|
};
|
|
2887
2921
|
}
|
|
2888
|
-
function
|
|
2922
|
+
function archiveHomeUrl(config) {
|
|
2923
|
+
return `${config.archiveOrigin}${config.archiveBasePath}`;
|
|
2924
|
+
}
|
|
2925
|
+
function devDashboardUrl(config) {
|
|
2926
|
+
return config.devMode ? `${config.appOrigin}/dashboard/` : void 0;
|
|
2927
|
+
}
|
|
2928
|
+
async function readerMessage(c, page, identity) {
|
|
2929
|
+
return messagePage({
|
|
2930
|
+
...page,
|
|
2931
|
+
identity: identity ?? await readerIdentity(c, c.config),
|
|
2932
|
+
homeUrl: `${c.config.appOrigin}/`,
|
|
2933
|
+
devDashboardUrl: devDashboardUrl(c.config)
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
// src/routes/archive.ts
|
|
2938
|
+
function formatPublishedDate(ms) {
|
|
2889
2939
|
return new Date(ms).toLocaleDateString("en-US", {
|
|
2890
2940
|
timeZone: "UTC",
|
|
2891
2941
|
year: "numeric",
|
|
@@ -2893,12 +2943,6 @@ function formatSentDate(ms) {
|
|
|
2893
2943
|
day: "numeric"
|
|
2894
2944
|
});
|
|
2895
2945
|
}
|
|
2896
|
-
function archiveHomeUrl(config) {
|
|
2897
|
-
return `${config.archiveOrigin}${config.archiveBasePath}`;
|
|
2898
|
-
}
|
|
2899
|
-
function devDashboardUrl(config) {
|
|
2900
|
-
return config.devMode ? `${config.appOrigin}/dashboard/` : void 0;
|
|
2901
|
-
}
|
|
2902
2946
|
async function landing(c) {
|
|
2903
2947
|
const [posts, identity] = await Promise.all([
|
|
2904
2948
|
listPublishedPosts(c.env.DB, 6),
|
|
@@ -2907,7 +2951,7 @@ async function landing(c) {
|
|
|
2907
2951
|
const mapped = posts.map((i) => ({
|
|
2908
2952
|
title: i.subject,
|
|
2909
2953
|
url: archiveUrl(c.config, i.slug),
|
|
2910
|
-
dateLabel:
|
|
2954
|
+
dateLabel: formatPublishedDate(i.published_at)
|
|
2911
2955
|
}));
|
|
2912
2956
|
const [featured, ...recent] = mapped;
|
|
2913
2957
|
return landingPage({
|
|
@@ -2932,37 +2976,49 @@ async function archiveIndex(c) {
|
|
|
2932
2976
|
posts: posts.map((i) => ({
|
|
2933
2977
|
title: i.subject,
|
|
2934
2978
|
url: archiveUrl(c.config, i.slug),
|
|
2935
|
-
dateLabel:
|
|
2979
|
+
dateLabel: formatPublishedDate(i.published_at)
|
|
2936
2980
|
})),
|
|
2937
2981
|
devDashboardUrl: devDashboardUrl(c.config)
|
|
2938
2982
|
});
|
|
2939
2983
|
}
|
|
2940
|
-
|
|
2941
|
-
const slug = param(c, "slug");
|
|
2942
|
-
const post = await getBySlug(c.env.DB, slug);
|
|
2943
|
-
const send = post ? await latestSentSendForPost(c.env.DB, post.id) : null;
|
|
2944
|
-
if (!post || !send) {
|
|
2945
|
-
return htmlPage(
|
|
2946
|
-
"Not found",
|
|
2947
|
-
`<h1 style="margin-top:0;">Not found</h1><p>This post isn't available.</p>`,
|
|
2948
|
-
404,
|
|
2949
|
-
devDashboardUrl(c.config)
|
|
2950
|
-
);
|
|
2951
|
-
}
|
|
2952
|
-
const identity = await readerIdentity(c, c.config);
|
|
2984
|
+
function webVersion(html, opts) {
|
|
2953
2985
|
const masthead2 = archiveMasthead({
|
|
2954
|
-
name: identity.name,
|
|
2955
|
-
dateLabel:
|
|
2986
|
+
name: opts.identity.name,
|
|
2987
|
+
dateLabel: formatPublishedDate(opts.dateMs),
|
|
2956
2988
|
// Back to the archive index the post belongs to, on the same (archive) origin —
|
|
2957
2989
|
// so an apex-hosted post stays on the apex instead of jumping to the app subdomain.
|
|
2958
|
-
indexUrl: archiveHomeUrl(
|
|
2990
|
+
indexUrl: archiveHomeUrl(opts.config)
|
|
2959
2991
|
});
|
|
2960
|
-
const dev = archiveDevDashboardChrome(devDashboardUrl
|
|
2961
|
-
|
|
2962
|
-
omitEmailOnly(
|
|
2963
|
-
{ ".Email.UnsubscribeURL": `${
|
|
2992
|
+
const dev = archiveDevDashboardChrome(opts.devDashboardUrl);
|
|
2993
|
+
return fillDeliveryTokens(
|
|
2994
|
+
omitEmailOnly(html),
|
|
2995
|
+
{ ".Email.UnsubscribeURL": `${opts.config.appOrigin}/unsubscribe`, ".Email.SentTo": "" },
|
|
2964
2996
|
"html"
|
|
2965
2997
|
).split(ARCHIVE_MASTHEAD_ANCHOR).join(masthead2 + dev.masthead).split(ARCHIVE_HEAD_ANCHOR).join(ARCHIVE_POST_HEAD + dev.head);
|
|
2998
|
+
}
|
|
2999
|
+
function nothingHereYetPage(c) {
|
|
3000
|
+
return readerMessage(c, {
|
|
3001
|
+
title: "Nothing here yet",
|
|
3002
|
+
eyebrow: "Archive",
|
|
3003
|
+
headline: "Nothing here yet",
|
|
3004
|
+
ledeHtml: "A post appears here as soon as it\u2019s sent.",
|
|
3005
|
+
afterHtml: archiveLink(archiveHomeUrl(c.config), "Browse the archive"),
|
|
3006
|
+
status: 404
|
|
3007
|
+
});
|
|
3008
|
+
}
|
|
3009
|
+
async function archivePage(c) {
|
|
3010
|
+
const slug = param(c, "slug");
|
|
3011
|
+
const post = await getBySlug(c.env.DB, slug);
|
|
3012
|
+
const send = post ? await publishedSendForPost(c.env.DB, post.id) : null;
|
|
3013
|
+
if (!post || !send) {
|
|
3014
|
+
return nothingHereYetPage(c);
|
|
3015
|
+
}
|
|
3016
|
+
const html = webVersion(send.rendered_html, {
|
|
3017
|
+
config: c.config,
|
|
3018
|
+
identity: await readerIdentity(c, c.config),
|
|
3019
|
+
dateMs: send.started_at ?? send.fire_at,
|
|
3020
|
+
devDashboardUrl: devDashboardUrl(c.config)
|
|
3021
|
+
});
|
|
2966
3022
|
return new Response(html, {
|
|
2967
3023
|
headers: {
|
|
2968
3024
|
...POST_PAGE_SECURITY_HEADERS,
|
|
@@ -3029,10 +3085,11 @@ async function insertSuppressions(db, rows) {
|
|
|
3029
3085
|
async function insertPost(db, post, revision) {
|
|
3030
3086
|
await db.batch([
|
|
3031
3087
|
db.prepare(
|
|
3032
|
-
"INSERT INTO posts (id, slug, subject, status, current_revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
3088
|
+
"INSERT INTO posts (id, slug, slug_auto, subject, status, current_revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
3033
3089
|
).bind(
|
|
3034
3090
|
post.id,
|
|
3035
3091
|
post.slug,
|
|
3092
|
+
post.slug_auto ? 1 : 0,
|
|
3036
3093
|
post.subject,
|
|
3037
3094
|
post.status,
|
|
3038
3095
|
post.current_revision,
|
|
@@ -3701,8 +3758,8 @@ function buildSendView(send, config, hasRetries, now) {
|
|
|
3701
3758
|
deliveries: `/api/sends/${id}/deliveries`,
|
|
3702
3759
|
deliveries_csv: `/api/sends/${id}/deliveries.csv`,
|
|
3703
3760
|
post: `/api/posts/${send.post_id}`,
|
|
3704
|
-
// The archive serves a post's page
|
|
3705
|
-
archive: send.status === "sent" && send.post_slug !== null ? archiveUrl(config, send.post_slug) : null
|
|
3761
|
+
// The archive serves a post's page once its send starts: before, the link would 404.
|
|
3762
|
+
archive: (send.status === "sending" || send.status === "sent") && send.post_slug !== null ? archiveUrl(config, send.post_slug) : null
|
|
3706
3763
|
}
|
|
3707
3764
|
};
|
|
3708
3765
|
}
|
|
@@ -3812,19 +3869,11 @@ function isActiveSendConflict(err) {
|
|
|
3812
3869
|
const message2 = err instanceof Error ? err.message : String(err);
|
|
3813
3870
|
return /UNIQUE constraint failed:\s*sends\.post_id/i.test(message2);
|
|
3814
3871
|
}
|
|
3815
|
-
async function readForAction(env, sendId
|
|
3872
|
+
async function readForAction(env, sendId) {
|
|
3816
3873
|
const send = await getSend(env.DB, sendId);
|
|
3817
3874
|
if (!send) {
|
|
3818
3875
|
throw notFound("send");
|
|
3819
3876
|
}
|
|
3820
|
-
if (guard2.ifMatch !== void 0 && send.rev !== guard2.ifMatch) {
|
|
3821
|
-
throw refusal(
|
|
3822
|
-
412,
|
|
3823
|
-
"precondition_failed",
|
|
3824
|
-
`the send has changed since rev ${guard2.ifMatch} (it is at rev ${send.rev}); read it again and decide on what it is now`,
|
|
3825
|
-
{ send: await viewSend(env, send.id) }
|
|
3826
|
-
);
|
|
3827
|
-
}
|
|
3828
3877
|
return send;
|
|
3829
3878
|
}
|
|
3830
3879
|
async function windowClosed(env, send, what) {
|
|
@@ -3833,8 +3882,8 @@ async function windowClosed(env, send, what) {
|
|
|
3833
3882
|
send: await viewSend(env, send.id)
|
|
3834
3883
|
});
|
|
3835
3884
|
}
|
|
3836
|
-
async function reschedule(env, sendId, fireAt, minLeadMs
|
|
3837
|
-
const send = await readForAction(env, sendId
|
|
3885
|
+
async function reschedule(env, sendId, fireAt, minLeadMs) {
|
|
3886
|
+
const send = await readForAction(env, sendId);
|
|
3838
3887
|
const now = Date.now();
|
|
3839
3888
|
if (send.status === "canceled") {
|
|
3840
3889
|
throw refusal(409, "send_canceled", "send is canceled; schedule the post again instead", {
|
|
@@ -3848,9 +3897,9 @@ async function reschedule(env, sendId, fireAt, minLeadMs, guard2 = {}) {
|
|
|
3848
3897
|
return { send, changed: false };
|
|
3849
3898
|
}
|
|
3850
3899
|
const target = acceptFireAt(fireAt, minLeadMs, { now });
|
|
3851
|
-
const res = await rescheduleStmt(env.DB, sendId, target, now
|
|
3900
|
+
const res = await rescheduleStmt(env.DB, sendId, target, now).run();
|
|
3852
3901
|
if ((res.meta.changes ?? 0) === 0) {
|
|
3853
|
-
return reschedule(env, sendId, fireAt, minLeadMs
|
|
3902
|
+
return reschedule(env, sendId, fireAt, minLeadMs);
|
|
3854
3903
|
}
|
|
3855
3904
|
log.info("send.rescheduled", {
|
|
3856
3905
|
sendId,
|
|
@@ -3860,8 +3909,8 @@ async function reschedule(env, sendId, fireAt, minLeadMs, guard2 = {}) {
|
|
|
3860
3909
|
});
|
|
3861
3910
|
return { send: unwrap(await getSend(env.DB, sendId), "send"), changed: true };
|
|
3862
3911
|
}
|
|
3863
|
-
async function cancel(env, sendId
|
|
3864
|
-
const send = await readForAction(env, sendId
|
|
3912
|
+
async function cancel(env, sendId) {
|
|
3913
|
+
const send = await readForAction(env, sendId);
|
|
3865
3914
|
if (send.status === "canceled") {
|
|
3866
3915
|
return { send, changed: false };
|
|
3867
3916
|
}
|
|
@@ -3870,7 +3919,7 @@ async function cancel(env, sendId, guard2 = {}) {
|
|
|
3870
3919
|
throw await windowClosed(env, send, "canceled");
|
|
3871
3920
|
}
|
|
3872
3921
|
const [res] = await env.DB.batch([
|
|
3873
|
-
cancelStmt(env.DB, sendId, now
|
|
3922
|
+
cancelStmt(env.DB, sendId, now),
|
|
3874
3923
|
setPostStatusStmt(
|
|
3875
3924
|
env.DB,
|
|
3876
3925
|
send.post_id,
|
|
@@ -3881,7 +3930,7 @@ async function cancel(env, sendId, guard2 = {}) {
|
|
|
3881
3930
|
)
|
|
3882
3931
|
]);
|
|
3883
3932
|
if ((res?.meta.changes ?? 0) === 0) {
|
|
3884
|
-
return cancel(env, sendId
|
|
3933
|
+
return cancel(env, sendId);
|
|
3885
3934
|
}
|
|
3886
3935
|
log.info("send.canceled", { sendId, postId: send.post_id });
|
|
3887
3936
|
return { send: unwrap(await getSend(env.DB, sendId), "send"), changed: true };
|
|
@@ -4510,6 +4559,8 @@ function renderInputFor(id, demoPost, at, markdown) {
|
|
|
4510
4559
|
const post = {
|
|
4511
4560
|
id,
|
|
4512
4561
|
slug: demoPost.slug,
|
|
4562
|
+
// A demo slug that is just its subject's reads as following it, as a new post's would.
|
|
4563
|
+
slug_auto: demoPost.slug === slugify(demoPost.subject),
|
|
4513
4564
|
subject: demoPost.subject,
|
|
4514
4565
|
status: demoPost.status,
|
|
4515
4566
|
current_revision: `${id}-rev`,
|
|
@@ -4548,10 +4599,11 @@ async function seedDatabase(env, config, imageFiles = [], logoFile, options) {
|
|
|
4548
4599
|
});
|
|
4549
4600
|
let logoWritten = false;
|
|
4550
4601
|
if (logoFile) {
|
|
4551
|
-
|
|
4602
|
+
const key = newLogoKey(now);
|
|
4603
|
+
await env.MEDIA.put(key, logoFile.bytes, {
|
|
4552
4604
|
httpMetadata: { contentType: logoFile.contentType }
|
|
4553
4605
|
});
|
|
4554
|
-
await setPublicationLogo(db, { version: now, contentType: logoFile.contentType });
|
|
4606
|
+
await setPublicationLogo(db, { version: now, contentType: logoFile.contentType, key });
|
|
4555
4607
|
logoWritten = true;
|
|
4556
4608
|
}
|
|
4557
4609
|
const size = options?.size;
|
|
@@ -4695,7 +4747,11 @@ async function token(c) {
|
|
|
4695
4747
|
}
|
|
4696
4748
|
const service = c.url.searchParams.get("kind") === "service";
|
|
4697
4749
|
const jwt = await mintDevToken(c.config.devAuthSecret, service ? {} : { email: "dev@localhost" });
|
|
4698
|
-
return json({
|
|
4750
|
+
return json({
|
|
4751
|
+
token: jwt,
|
|
4752
|
+
kind: service ? "service" : "human",
|
|
4753
|
+
...c.config.devRunId ? { runId: c.config.devRunId } : {}
|
|
4754
|
+
});
|
|
4699
4755
|
}
|
|
4700
4756
|
async function seed(c) {
|
|
4701
4757
|
requireDevMode(c);
|
|
@@ -4734,7 +4790,8 @@ async function reset(c) {
|
|
|
4734
4790
|
requireDevMode(c);
|
|
4735
4791
|
await resetAll(c.env.DB);
|
|
4736
4792
|
try {
|
|
4737
|
-
await c.env.MEDIA.
|
|
4793
|
+
const { objects } = await c.env.MEDIA.list({ prefix: BRANDING_LOGO_KEY });
|
|
4794
|
+
await c.env.MEDIA.delete(objects.map((o) => o.key));
|
|
4738
4795
|
} catch {
|
|
4739
4796
|
}
|
|
4740
4797
|
return json({ reset: true, url: `${c.config.appOrigin}/dashboard/` });
|
|
@@ -4747,7 +4804,7 @@ import { Lexer } from "marked";
|
|
|
4747
4804
|
var overview_default = "# Overview\n\nKestrel is a newsletter app you run yourself. You write a post in Markdown, preview it exactly as the email, and send it to a list you own. It runs on Cloudflare Workers, and an email provider delivers the mail.\n\nIn this guide, you deploy your own instance of Kestrel to your Cloudflare account and connect it to an email provider. You go from an empty repository to a newsletter that's ready for its first subscriber. Each step ends with a check, so you know it worked before you move on.\n\nYou run every step from your instance's repository, a small repository of your own. It holds your instance's configuration and names the release of Kestrel it runs, which installs from npm as `@kurtbruns/kestrel`. You return to it to deploy changes and upgrade to new releases. You don't need to run Kestrel on your computer to deploy it, but you can try it there first, with demo content.\n\n## Before you begin\n\nYou need:\n\n1. A [Cloudflare account](https://dash.cloudflare.com/sign-up), with your domain's DNS on Cloudflare. Your app's hostname and its login both live there.\n\n1. An account with an email provider: [Resend](https://resend.com/signup) or [Amazon SES](https://aws.amazon.com/ses/). These guides use Resend, the quicker to set up. Amazon SES costs less for a large list. It needs AWS to approve your account before it sends to subscribers, and Cloudflare's Workers Paid plan. To use SES, follow [Use Amazon SES instead of Resend](../guides/02-ses.md) in place of step 4.\n\n1. [Node.js](https://nodejs.org/) 22 or later, and [Git](https://git-scm.com/).\n\n1. A GitHub account, or another Git host, for your instance's repository.\n\n## Choose your hostnames\n\nKestrel uses two hostnames, because the app and the mail do different jobs:\n\n| Hostname | What it's for |\n| --- | --- |\n| `newsletter.example.com` | the editor, the API, and the public pages readers see |\n| `send.example.com` | the address your mail comes from, and its SPF, DKIM, and DMARC records |\n\nThese guides put the app on a subdomain, which leaves any website you already have on `example.com` untouched. If `example.com` has no website of its own, the app can live there instead. Use `example.com` wherever these guides say `newsletter.example.com`.\n\nIt's recommended to send from a subdomain rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn't put that at risk. Avoid `mail.` as the sending name too, since mail servers read it as an inbound host. [Sending-domain DNS](../reference/02-sending-domain-dns.md#choose-your-sending-name) explains both.\n\nThroughout these guides, replace `example.com`, `newsletter.example.com`, and `send.example.com` with your own names. Replace any value written as `REPLACE_WITH_\u2026` too.\n\n## The steps\n\n1. **Overview**: what you need, and the hostnames you use.\n1. [Deploy the app](02-deploy.md): create your instance's repository, and put the app on your hostname.\n1. [Lock the dashboard with Access](03-access.md): add the login that keeps the editor and the API to you.\n1. [Connect Resend](04-resend.md): verify your sending domain, and connect Resend to the app.\n1. [Verify it works](05-verify.md): send real email to yourself, and test unsubscribing and bounces.\n1. [Go live](06-go-live.md): name your publication and share the subscribe link.\n\nFurther guides, like [connecting Claude](../guides/01-connect-claude.md) and [upgrading to a new release](../guides/07-upgrade.md), pick up from there.\n\nWhen you're ready, continue to [Deploy the app](02-deploy.md).\n";
|
|
4748
4805
|
|
|
4749
4806
|
// docs/get-started/02-deploy.md
|
|
4750
|
-
var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker, with a D1 database for posts and subscribers and an R2 bucket for images.\n\nIn this guide, you create your instance\'s repository, create the database and the bucket, and point Kestrel\'s production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname. You need what the [Overview](01-overview.md#before-you-begin) lists.\n\n## 1. Create your instance\'s repository\n\nYour instance\'s repository holds its configuration, its database migrations, and the release of Kestrel it runs. Kestrel itself installs from npm as `@kurtbruns/kestrel`, so the repository stays small, and an upgrade changes the version it names.\n\n1. On GitHub, [create a new repository](https://github.com/new) named `newsletter`. It can be private. Leave it empty, with no README, license, or `.gitignore`.\n\n1. Clone it, and write a new instance into it. Replace `YOUR_USERNAME` with your GitHub username:\n\n ```bash\n git clone https://github.com/YOUR_USERNAME/newsletter.git\n cd newsletter\n npx @kurtbruns/kestrel init\n ```\n\n This writes the instance\'s files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release\'s database migrations, and a starting point for Claude Code in `.claude/`. The instance\'s `README.md` lists its commands.\n\n1. Install the release and its tools:\n\n ```bash\n npm install\n ```\n\n1. Commit the new instance, and push it:\n\n ```bash\n git add -A\n git commit -m "Start a Kestrel instance"\n git push -u origin HEAD\n ```\n\n1. **(Optional)** Try Kestrel on your computer before you deploy it. Local development sends email to a stand-in, so nothing reaches a real inbox:\n\n ```bash\n cp .dev.vars.example .dev.vars\n npm run dev\n ```\n\n Open http://localhost:8787/dashboard/ to see the editor. To fill it with demo content, run `npm run seed` in a second terminal, and `npm run reset` to empty it again. Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop the server.\n\n## 2. Sign in to Cloudflare\n\nEvery command from here on acts on your Cloudflare account through the Wrangler CLI.\n\n1. Sign in. A browser window opens for you to approve the login:\n\n ```bash\n npx wrangler login\n ```\n\n1. Confirm which account you\'re signed in to:\n\n ```bash\n npx wrangler whoami\n ```\n\n It prints your email and the account name. If you belong to more than one account, make sure it\'s the one that holds your domain.\n\n## 3. Create the database and the image bucket\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create kestrel-production\n ```\n\n It prints the new database\'s settings, including a `database_id`. Copy the id for the next section. If Wrangler offers to add the database to your configuration for you, decline: you add it to the production settings yourself, below.\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create kestrel-media-production\n ```\n\n## 4. Configure production\n\n`wrangler.jsonc` holds two sets of settings. The top level is for local development, so leave it alone. The `production` block under `env` is yours to fill in.\n\n1. Open `wrangler.jsonc` and change the values marked here in the `production` block:\n\n ```jsonc\n "production": {\n "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }], // \u2190 your app\'s hostname\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "REPLACE_WITH_PRODUCTION_D1_ID", // \u2190 the id from section 3\n "migrations_dir": "migrations"\n }\n ],\n "vars": {\n "PROVIDER": "fake", // \u2190 leave as is for now\n "APP_ORIGIN": "https://newsletter.example.com", // \u2190 your app\'s hostname\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com", // \u2190 your sending hostname\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>" // \u2190 who your mail is from\n }\n }\n ```\n\n What each one does:\n\n - **`routes`** puts the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can\'t already have a CNAME record; delete one if it does. The block also turns off the `workers.dev` addresses, so the app answers only on your hostname.\n - **`PROVIDER`** stays `fake` until you connect Resend in [step 4](04-resend.md). The `fake` transport delivers nothing, so don\'t schedule a post before then.\n - **`ARCHIVE_BASE_PATH`** is permanent once you send. Every post you send carries its `/archive/\u2026` link, and changing the prefix later breaks the links you\'ve already mailed. Keep `/archive` unless you have a reason not to.\n - **`FROM_ADDRESS`** is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.\n\n Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.\n\n1. Check the file. This regenerates the binding types, and fails if the configuration doesn\'t parse:\n\n ```bash\n npm run typecheck\n ```\n\n1. Commit your settings and push them to your repository. An upgrade changes only the release your instance runs, so your settings stay as they are:\n\n ```bash\n git commit -am "Configure production"\n git push\n ```\n\n## 5. Apply the schema\n\nCreate Kestrel\'s tables in the production database:\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it\'s about to apply and asks you to confirm. It applies only the ones the database hasn\'t seen, so running it again is harmless.\n\n## 6. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThis deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker\'s **Triggers** tab in the Cloudflare dashboard.\n\n## Check it\n\n1. The app answers on your hostname. A new hostname can take a few minutes to get its certificate.\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The editor is locked. Until you set up Access in the next step, the app can\'t tell who is asking, so it lets no one in:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" https://newsletter.example.com/api/whoami\n ```\n\n ```\n 401\n ```\n\n1. In a browser, `https://newsletter.example.com/` shows your newsletter\'s public landing page.\n\nIf every request instead answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",\u2026}`, the setting it names is missing or malformed. Fix it in `wrangler.jsonc`, commit, and deploy again.\n\nNext, [lock the dashboard with Access](03-access.md).\n';
|
|
4807
|
+
var deploy_default = '# Deploy the app\n\nKestrel runs as one Cloudflare Worker, with a D1 database for posts and subscribers and an R2 bucket for images.\n\nIn this guide, you create your instance\'s repository, create the database and the bucket, and point Kestrel\'s production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname. You need what the [Overview](01-overview.md#before-you-begin) lists.\n\n## 1. Create your instance\'s repository\n\nYour instance\'s repository holds its configuration, its database migrations, and the release of Kestrel it runs. Kestrel itself installs from npm as `@kurtbruns/kestrel`, so the repository stays small, and an upgrade changes the version it names.\n\n1. On GitHub, [create a new repository](https://github.com/new) named `newsletter`. It can be private. Leave it empty, with no README, license, or `.gitignore`.\n\n1. Clone it, and write a new instance into it. Replace `YOUR_USERNAME` with your GitHub username:\n\n ```bash\n git clone https://github.com/YOUR_USERNAME/newsletter.git\n cd newsletter\n npx @kurtbruns/kestrel init\n ```\n\n This writes the instance\'s files: `wrangler.jsonc` for its settings, `package.json` with the release it runs, the release\'s database migrations, and a starting point for Claude Code in `.claude/`. The instance\'s `README.md` lists its commands.\n\n1. Install the release and its tools:\n\n ```bash\n npm install\n ```\n\n1. Commit the new instance, and push it:\n\n ```bash\n git add -A\n git commit -m "Start a Kestrel instance"\n git push -u origin HEAD\n ```\n\n1. **(Optional)** Try Kestrel on your computer before you deploy it. Local development sends email to a stand-in, so nothing reaches a real inbox:\n\n ```bash\n cp .dev.vars.example .dev.vars\n npm run dev\n ```\n\n Open http://localhost:8787/dashboard/ to see the editor. To fill it with demo content, run `npm run seed` in a second terminal in the same folder, and `npm run reset` to empty it again. Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop the server.\n\n## 2. Sign in to Cloudflare\n\nEvery command from here on acts on your Cloudflare account through the Wrangler CLI.\n\n1. Sign in. A browser window opens for you to approve the login:\n\n ```bash\n npx wrangler login\n ```\n\n1. Confirm which account you\'re signed in to:\n\n ```bash\n npx wrangler whoami\n ```\n\n It prints your email and the account name. If you belong to more than one account, make sure it\'s the one that holds your domain.\n\n## 3. Create the database and the image bucket\n\n1. Create the database:\n\n ```bash\n npx wrangler d1 create kestrel-production\n ```\n\n It prints the new database\'s settings, including a `database_id`. Copy the id for the next section. If Wrangler offers to add the database to your configuration for you, decline: you add it to the production settings yourself, below.\n\n1. Create the bucket for images:\n\n ```bash\n npx wrangler r2 bucket create kestrel-media-production\n ```\n\n## 4. Configure production\n\n`wrangler.jsonc` holds two sets of settings. The top level is for local development, so leave it alone. The `production` block under `env` is yours to fill in.\n\n1. Open `wrangler.jsonc` and change the values marked here in the `production` block:\n\n ```jsonc\n "production": {\n "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }], // \u2190 your app\'s hostname\n "d1_databases": [\n {\n "binding": "DB",\n "database_name": "kestrel-production",\n "database_id": "REPLACE_WITH_PRODUCTION_D1_ID", // \u2190 the id from section 3\n "migrations_dir": "migrations"\n }\n ],\n "vars": {\n "PROVIDER": "fake", // \u2190 leave as is for now\n "APP_ORIGIN": "https://newsletter.example.com", // \u2190 your app\'s hostname\n "ARCHIVE_BASE_PATH": "/archive",\n "SENDING_DOMAIN": "send.example.com", // \u2190 your sending hostname\n "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>" // \u2190 who your mail is from\n }\n }\n ```\n\n What each one does:\n\n - **`routes`** puts the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can\'t already have a CNAME record; delete one if it does. The block also turns off the `workers.dev` addresses, so the app answers only on your hostname.\n - **`PROVIDER`** stays `fake` until you connect Resend in [step 4](04-resend.md). The `fake` transport delivers nothing, so don\'t schedule a post before then.\n - **`ARCHIVE_BASE_PATH`** is permanent once you send. Every post you send carries its `/archive/\u2026` link, and changing the prefix later breaks the links you\'ve already mailed. Keep `/archive` unless you have a reason not to.\n - **`FROM_ADDRESS`** is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.\n\n Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.\n\n1. Check the file. This regenerates the binding types, and fails if the configuration doesn\'t parse:\n\n ```bash\n npm run typecheck\n ```\n\n1. Commit your settings and push them to your repository. An upgrade changes only the release your instance runs, so your settings stay as they are:\n\n ```bash\n git commit -am "Configure production"\n git push\n ```\n\n## 5. Apply the schema\n\nCreate Kestrel\'s tables in the production database:\n\n```bash\nnpx wrangler d1 migrations apply DB --remote --env production\n```\n\nIt lists the migrations it\'s about to apply and asks you to confirm. It applies only the ones the database hasn\'t seen, so running it again is harmless.\n\n## 6. Deploy\n\n```bash\nnpx wrangler deploy --env production\n```\n\nThis deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker\'s **Triggers** tab in the Cloudflare dashboard.\n\n## Check it\n\n1. The app answers on your hostname. A new hostname can take a few minutes to get its certificate.\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The editor is locked. Until you set up Access in the next step, the app can\'t tell who is asking, so it lets no one in:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" https://newsletter.example.com/api/whoami\n ```\n\n ```\n 401\n ```\n\n1. In a browser, `https://newsletter.example.com/` shows your newsletter\'s public landing page.\n\nIf every request instead answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",\u2026}`, the setting it names is missing or malformed. Fix it in `wrangler.jsonc`, commit, and deploy again.\n\nNext, [lock the dashboard with Access](03-access.md).\n';
|
|
4751
4808
|
|
|
4752
4809
|
// docs/get-started/03-access.md
|
|
4753
4810
|
var access_default = '# Lock the dashboard with Access\n\nCloudflare Access puts a login in front of the editor and the API. Kestrel checks every request against that login as well, so a path left out of Access stays locked. The public pages readers see stay public.\n\nIn this guide, you set up Cloudflare Zero Trust, create one Access application that covers the editor and the API, and allow yourself in. Then you give the app two values from that application, and check that the login works.\n\n## 1. Set up Zero Trust\n\nAccess is part of Cloudflare Zero Trust. Your account needs a Zero Trust organization before it can create an Access application. Skip this section if your account already has one.\n\n1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), select **Zero Trust**.\n\n1. Choose a team name. It becomes your team domain, `YOUR_TEAM.cloudflareaccess.com`, where your login page lives.\n\n1. Choose a plan. The Free plan covers a newsletter\'s editors. Cloudflare asks for payment details even on the Free plan, and doesn\'t charge them.\n\n## 2. Create the Access application\n\nThe editor lives under `/dashboard`, and the API under `/api`. One application covers both, so they share one login.\n\n1. In **Zero Trust**, go to **Access controls \u2192 Applications**, and select **Create new application**.\n\n1. Select **Self-hosted and private**.\n\n1. Select **Add public hostname**, and enter your app\'s hostname with the first path:\n\n - **Subdomain:** `newsletter`\n - **Domain:** `example.com`\n - **Path:** `dashboard`\n\n1. Add a second public hostname with the same subdomain and domain, and the path `api`:\n\n | Path | What it covers |\n | --- | --- |\n | `dashboard` | the editor |\n | `api` | everything the editor and Claude read and change: posts, sends, subscribers, settings, and the in-app docs |\n\n Each path covers everything under it. Leave every other path out. The landing page, the archive, subscribing, unsubscribing, images, and your email provider\'s webhooks must stay public.\n\n## 3. Allow yourself in\n\nEvery Access application denies everyone until a policy allows them.\n\n1. Under **Access policies**, create a new policy:\n\n - **Policy name:** `Publishers`\n - **Action:** Allow\n - **Include:** the **Emails** selector, with your email address\n\n1. Keep the login method a new Zero Trust organization starts with: you sign in with your Cloudflare account. To sign in another way, such as a one-time code by email, add it under **Zero Trust \u2192 Integrations \u2192 Identity providers** first.\n\n1. Select **Create**.\n\n## 4. Give the app its two values\n\nThe app verifies each login against your team domain, and checks that it was issued for this application.\n\n1. Find your team domain under **Zero Trust \u2192 Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`.\n\n1. Find the application\'s audience tag. Under **Access controls \u2192 Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.\n\n1. Store the team domain as a secret. The command prompts you for the value. Enter it without `https://`:\n\n ```bash\n npx wrangler secret put ACCESS_TEAM_DOMAIN --env production\n ```\n\n1. Store the audience tag the same way:\n\n ```bash\n npx wrangler secret put ACCESS_AUD --env production\n ```\n\n A secret takes effect as soon as you store it, with no redeploy. The app needs both values. With either one missing, it can\'t verify any login, so it lets no one in.\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 Cloudflare lets you in but the editor answers `401`, check the two secrets. One may be missing or mistyped, or `ACCESS_ALLOWED_EMAILS` may leave out your address.\n\nNext, [connect Resend](04-resend.md).\n';
|
|
@@ -4756,7 +4813,7 @@ var access_default = '# Lock the dashboard with Access\n\nCloudflare Access puts
|
|
|
4756
4813
|
var resend_default = '# Connect Resend\n\nKestrel sends your posts through an email provider. The provider only delivers the mail: Kestrel keeps your list, each subscriber\'s consent, and the record of every send. These guides use Resend. To use Amazon SES instead, follow [Use Amazon SES instead of Resend](../guides/02-ses.md) in place of this page.\n\nIn this guide, you verify your sending hostname with Resend and add a DMARC record. Then you give the app an API key and a webhook for bounces and complaints, switch it to Resend, and deploy.\n\n## 1. Verify your sending hostname\n\nResend sends only from a domain whose DNS proves you own it. These records also let inboxes trust your mail.\n\n1. In Resend, go to [Domains](https://resend.com/domains), select **Add Domain**, and enter `send.example.com`.\n\n1. Select **Sign in to Cloudflare**, and allow Resend to edit your DNS. Resend adds its records to your zone for you.\n\n To add them by hand instead, copy each record from Resend into your zone\'s DNS records in Cloudflare. Because your sending hostname is itself a subdomain, some names look doubled, such as `send.send`. That\'s expected.\n\n1. Leave receiving turned off. Kestrel doesn\'t receive mail.\n\n1. Wait until Resend shows the domain 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. Resend 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. Create an API key\n\nThe app sends through this key. A key that can only send, from only your sending hostname, limits the harm if it ever leaks.\n\n1. In Resend, go to [API Keys](https://resend.com/api-keys), and select **Create API Key**.\n\n1. Fill in the key:\n\n - **Name:** `kestrel-production`\n - **Permission:** Sending access\n - **Domain:** `send.example.com`\n\n1. Copy the key. Resend shows it only once.\n\n1. Store it as a secret, and paste the key at the prompt:\n\n ```bash\n npx wrangler secret put RESEND_API_KEY --env production\n ```\n\n## 4. Add the webhook\n\nThe webhook tells the app when an address bounces or complains, and the app stops mailing it. Without it, you keep mailing those addresses, and your mail starts landing in spam.\n\n1. In Resend, go to [Webhooks](https://resend.com/webhooks), and select **Add Webhook**.\n\n1. Enter the endpoint URL:\n\n ```\n https://newsletter.example.com/webhooks/resend\n ```\n\n1. Select these three events, and create the webhook:\n\n - `email.delivered`\n - `email.bounced`\n - `email.complained`\n\n1. On the webhook\'s page, copy its signing secret. It starts with `whsec_`.\n\n1. Store it as a secret. The app checks every event against it:\n\n ```bash\n npx wrangler secret put RESEND_WEBHOOK_SECRET --env production\n ```\n\n## 5. Switch the provider and deploy\n\nStore both secrets before you switch. With `PROVIDER` set to `resend` and a secret missing, every request answers `500` and names the missing secret.\n\n1. In `wrangler.jsonc`, in the `production` block\'s `vars`, change `PROVIDER` from `fake` to `resend`:\n\n ```jsonc\n "PROVIDER": "resend",\n ```\n\n1. Commit the change, and push it:\n\n ```bash\n git commit -am "Send through Resend"\n git push\n ```\n\n1. Deploy:\n\n ```bash\n npx wrangler deploy --env production\n ```\n\n## Check it\n\n1. The app answers on your hostname:\n\n ```bash\n curl https://newsletter.example.com/health\n ```\n\n ```\n {"status":"ok","service":"kestrel"}\n ```\n\n1. The webhook is public, and refuses a request without Resend\'s signature:\n\n ```bash\n curl -s -o /dev/null -w "%{http_code}\\n" -X POST https://newsletter.example.com/webhooks/resend\n ```\n\n ```\n 400\n ```\n\n Any other answer, such as a redirect to your Cloudflare login, means Access covers the webhook. Remove that path from your Access application.\n\n1. In the editor, **Settings \u2192 Email sender** shows Resend as the email provider, and your From address.\n\nThe test sends in the next step prove the rest.\n\nResend\'s free plan sends up to 100 emails a day and 3,000 a month, test emails included. A list of more than about a hundred subscribers needs a paid plan, listed on [Resend\'s pricing](https://resend.com/pricing) page. For a large list, [Amazon SES](../guides/02-ses.md) may cost less.\n\nNext, [verify it works](05-verify.md).\n';
|
|
4757
4814
|
|
|
4758
4815
|
// docs/get-started/05-verify.md
|
|
4759
|
-
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 your list is empty and every email goes only to you.\n\nIn this guide, you send yourself a test email and check that it passes authentication. Then you set up your notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, and read the app's logs.\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.\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
|
|
4816
|
+
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 your list is empty and every email goes only to you.\n\nIn this guide, you send yourself a test email and check that it passes authentication. Then you set up your notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, and read the app's logs.\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.\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\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 Amazon SES and 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 Resend](04-resend.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 your notifications\n\nKestrel emails you 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\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 your post, select **Schedule**, and then **Send now**. Every send waits at least five minutes before it goes out, so you can cancel a mistake.\n\n1. Cancel the send, to see that canceling stops it. Then select **Send now** again, and let it go.\n\n1. When the send finishes, the post arrives at your second address. A notification arrives at your first, with the subject `Sent:` and the post's title.\n\n## 5. Unsubscribe\n\nEvery email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. Both work in one step, with no login.\n\n1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender.\n\n1. Select it. In the editor, **Subscribers** shows the address as **Unsubscribed**, and later sends leave it out.\n\n## 6. Check that bounces and complaints come back\n\nWhen an address bounces or marks your mail as spam, your provider tells the app through the webhook. The app then stops mailing that address. Your provider has test addresses that act out each case without reaching a real person.\n\n1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address for your provider:\n\n | Provider | Bounces | Marks as spam |\n | --- | --- | --- |\n | Resend | `bounced@resend.dev` | `complained@resend.dev` |\n | Amazon SES | `bounce@simulator.amazonses.com` | `complaint@simulator.amazonses.com` |\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, the webhook isn't reaching the app. Check its URL and signing secret in [Connect Resend](04-resend.md#4-add-the-webhook).\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 your 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\nWhen every section gives the result it describes, your instance is ready for subscribers.\n\nNext, [go live](06-go-live.md).\n";
|
|
4760
4817
|
|
|
4761
4818
|
// docs/get-started/06-go-live.md
|
|
4762
4819
|
var go_live_default = "# Go live\n\nYour instance works. What's left is making it yours, and telling people where to subscribe.\n\nIn this guide, you name your publication and share your subscribe link. You also see what to change as your list grows.\n\n## 1. Name your publication\n\nYour publication's name, tagline, and logo appear on the public pages readers see, and in every email. Set them before anyone sees those pages. Until you do, the name in your From address stands in.\n\n1. In the editor, open **Settings \u2192 Publication identity**.\n\n1. Enter the name and tagline, and upload a logo.\n\n1. **(Optional)** Select **Add a mailing address**. US law asks for a postal address in promotional email, and a P.O. box works. The built-in email template prints it in every email's footer.\n\n## 2. Share your subscribe link\n\nReaders subscribe on a page the app hosts. Every signup confirms by email before it joins your list.\n\n1. In the editor, open **Settings \u2192 Ways to subscribe**.\n\n1. Copy your subscribe page's link, `https://newsletter.example.com/subscribe`, and share it.\n\n1. **(Optional)** To take signups on your own website, copy the embeddable subscribe form from the same page, and paste it into your site.\n\n1. **(Optional)** Cap how often one visitor can submit the form. The app already sends any one address at most one confirmation every 15 minutes. A Cloudflare rule can also limit each visitor, and the Free plan includes one. See [Rate-limit the subscribe form](../guides/05-rate-limit.md).\n\n## As your list grows\n\nA new instance runs on free plans. As your list grows, two limits come into play.\n\n- **Your email provider's limits.** Resend's free plan sends up to 100 emails a day. A send that hits the limit waits, and the app emails you. It resumes on its own once the limit lifts, or after you upgrade your Resend plan, and mails no one twice. For a large list, [Amazon SES](../guides/02-ses.md) may cost less.\n- **How fast a send goes out.** On Cloudflare's free Workers plan, Resend sends about 400 recipients a minute. On the $5 Workers Paid plan, set [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) to send far more each minute.\n\n## Check it\n\n1. `https://newsletter.example.com/subscribe` shows your publication's name and logo.\n\n1. `https://newsletter.example.com/` shows your publication's name, and the post you sent yourself in [Verify it works](05-verify.md#4-send-a-real-post-to-yourself).\n\n## What's next\n\nEach of these guides stands on its own. Pick the ones you want.\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 you hear about problems even when your email provider has stopped sending.\n\nWhen a new release comes out, [Upgrade to a new release](../guides/07-upgrade.md) shows how to move to it.\n";
|
|
@@ -4780,7 +4837,7 @@ var upgrade_default = "# Upgrade to a new release\n\nA new release of Kestrel is
|
|
|
4780
4837
|
var README_default = "# Set up and run Kestrel\n\nThese guides deploy and run your own instance of Kestrel, from an empty repository to a newsletter on your own Cloudflare account.\n\n## Get started\n\nFollow these steps, from an empty repository to a live newsletter.\n\n1. [Overview](get-started/01-overview.md)\n1. [Deploy the app](get-started/02-deploy.md)\n1. [Lock the dashboard with Access](get-started/03-access.md)\n1. [Connect Resend](get-started/04-resend.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- [Use Amazon SES instead of Resend](guides/02-ses.md)\n- [Notifications through Cloudflare's email](guides/03-notifications.md)\n- [Rate-limit the subscribe form](guides/05-rate-limit.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](reference/02-sending-domain-dns.md)\n";
|
|
4781
4838
|
|
|
4782
4839
|
// docs/reference/01-configuration.md
|
|
4783
|
-
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.\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\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: `fake`, `resend`, or `ses` | required |\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; Resend ignores it | `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\n### `PROVIDER`\n\n`fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and so does a new deployment until its provider is connected. Never schedule a real post on it.\n\n### `FROM_ADDRESS` and `SENDING_DOMAIN`\n\n`FROM_ADDRESS` is the only address mail is sent from, and the identity your provider authenticates. `SENDING_DOMAIN` is for display: Settings shows it beside the From address. The app never checks that the two match, so set `SENDING_DOMAIN` to the part of `FROM_ADDRESS` after the `@`.\n\nNeither one is your publication's name, tagline, or logo. You set those in the editor, under **Settings \u2192 Publication identity**. Until you do, the From address's display name stands in for the name.\n\n### `ARCHIVE_BASE_PATH`\n\nEvery post you send carries its archive link, `ARCHIVE_BASE_PATH` followed by the post's slug, and those links are permanent. Changing the prefix later breaks every link already mailed, so pick it before your first send. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\nA missing leading slash is added, and a trailing slash is dropped.\n\n### `SES_MAX_SEND_RATE`\n\nThe app starts SES requests no faster than this rate. Set it to the maximum send rate on your SES **Account dashboard**. 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. Through Resend, a minute sends about 400 recipients. Through SES, which takes one recipient a request, it sends about 18.\n- **On Workers Paid,** which allows 10,000, set `\"SUBREQUEST_BUDGET\": \"10000\"`. Resend then sends about 19,000 recipients a minute. SES sends as fast as your account's rate allows, up to about 1,900.\n- **If you raised the Worker's own limit** with `limits.subrequests`, you may set this to match.\n\nNever set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n### `MIN_LEAD_SECONDS`\n\nEvery send stays visible and cancelable for at least this long before it goes out, whether it's scheduled, sent now, or moved. It's the window to catch a mistake, including one in a send Claude prepared.\n\nThe floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead you can schedule. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.\n\n### `ARCHIVE_ORIGIN` and `MEDIA_PUBLIC_BASE`\n\nLeave them unset to keep everything on the app's own hostname.\n\n`ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname.\n\n`MEDIA_PUBLIC_BASE` serves images from another base URL, such as a custom domain on the image bucket. The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.\n\n### `NOTIFY_FROM`\n\nUsed only with the `NOTIFY` binding. It must be an address on a domain set up for Cloudflare's email. Without the binding, notifications come from `FROM_ADDRESS`, through your email provider.\n\n## Secrets\n\n| Secret | What it is | Needed for |\n| --- | --- | --- |\n| `ACCESS_TEAM_DOMAIN` | your Zero Trust team domain, such as `your-team.cloudflareaccess.com` | 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| `RESEND_API_KEY` | the Resend API key | Resend |\n| `RESEND_WEBHOOK_SECRET` | the Resend webhook's signing secret | Resend |\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 | 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` must be exactly `fake`, `resend`, or `ses`. A misspelling, or `SES`, is refused, never taken for `fake`.\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`.\n- With a real provider, none of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com`, the template's placeholder.\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\nUntil it's fixed, every request answers `500` with a body naming the variable, such as `{\"error\":\"invalid_config\",\"variable\":\"APP_ORIGIN\",\u2026}`. The Worker's log records it, and no sends go out. The editor's page still loads, since it's a static file, but everything it asks the API for fails the same way.\n";
|
|
4840
|
+
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.\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\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: `fake`, `resend`, or `ses` | required |\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; Resend ignores it | `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\n### `PROVIDER`\n\n`fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and so does a new deployment until its provider is connected. Never schedule a real post on it.\n\n### `FROM_ADDRESS` and `SENDING_DOMAIN`\n\n`FROM_ADDRESS` is the only address mail is sent from, and the identity your provider authenticates. `SENDING_DOMAIN` is for display: Settings shows it beside the From address. The app never checks that the two match, so set `SENDING_DOMAIN` to the part of `FROM_ADDRESS` after the `@`.\n\nNeither one is your publication's name, tagline, or logo. You set those in the editor, under **Settings \u2192 Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, your emails and reader pages show no name at all.\n\n### `ARCHIVE_BASE_PATH`\n\nEvery post you send carries its archive link, `ARCHIVE_BASE_PATH` followed by the post's slug, and those links are permanent. Changing the prefix later breaks every link already mailed, so pick it before your first send. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.\n\nA missing leading slash is added, and a trailing slash is dropped.\n\n### `SES_MAX_SEND_RATE`\n\nThe app starts SES requests no faster than this rate. Set it to the maximum send rate on your SES **Account dashboard**. 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. Through Resend, a minute sends about 400 recipients. Through SES, which takes one recipient a request, it sends about 18.\n- **On Workers Paid,** which allows 10,000, set `\"SUBREQUEST_BUDGET\": \"10000\"`. Resend then sends about 19,000 recipients a minute. SES sends as fast as your account's rate allows, up to about 1,900.\n- **If you raised the Worker's own limit** with `limits.subrequests`, you may set this to match.\n\nNever set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.\n\n### `MIN_LEAD_SECONDS`\n\nEvery send stays visible and cancelable for at least this long before it goes out, whether it's scheduled, sent now, or moved. It's the window to catch a mistake, including one in a send Claude prepared.\n\nThe floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead you can schedule. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.\n\n### `ARCHIVE_ORIGIN` and `MEDIA_PUBLIC_BASE`\n\nLeave them unset to keep everything on the app's own hostname.\n\n`ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname.\n\n`MEDIA_PUBLIC_BASE` serves images from another base URL, such as a custom domain on the image bucket. The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.\n\n### `NOTIFY_FROM`\n\nUsed only with the `NOTIFY` binding. It must be an address on a domain set up for Cloudflare's email. Without the binding, notifications come from `FROM_ADDRESS`, through your email provider.\n\n## Secrets\n\n| Secret | What it is | Needed for |\n| --- | --- | --- |\n| `ACCESS_TEAM_DOMAIN` | your Zero Trust team domain, such as `your-team.cloudflareaccess.com` | 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| `RESEND_API_KEY` | the Resend API key | Resend |\n| `RESEND_WEBHOOK_SECRET` | the Resend webhook's signing secret | Resend |\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 | 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` must be exactly `fake`, `resend`, or `ses`. A misspelling, or `SES`, is refused, never taken for `fake`.\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`.\n- With a real provider, none of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com`, the template's placeholder.\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\nUntil it's fixed, every request answers `500` with a body naming the variable, such as `{\"error\":\"invalid_config\",\"variable\":\"APP_ORIGIN\",\u2026}`. The Worker's log records it, and no sends go out. The editor's page still loads, since it's a static file, but everything it asks the API for fails the same way.\n";
|
|
4784
4841
|
|
|
4785
4842
|
// docs/reference/02-sending-domain-dns.md
|
|
4786
4843
|
var sending_domain_dns_default = "# Sending-domain DNS\n\nInboxes decide whether to trust your mail by checking records in the DNS of the domain it comes from. This page explains the records that [Connect Resend](../get-started/04-resend.md) and [Use Amazon SES instead of Resend](../guides/02-ses.md) have you add. It covers what each one 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, through either provider, 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 records decide whether mail lands in the inbox. Set up all three 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 three 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\nYour provider generates the SPF and DKIM values. Copy them exactly as its dashboard lists them, since the values differ by account and region. You write the DMARC record yourself.\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 with either provider, so an aligned SPF is a second check in your favor, not a requirement.\n\n- **Resend** uses a return path on a subdomain of your sending name, `send.send.example.com` by default. Its dashboard lists an MX record and an SPF `TXT` record for it. In Cloudflare, both are named `send.send`. That name looks doubled because your sending name is itself `send`.\n- **SES** uses its own domain, `amazonses.com`, as the return path by default. SPF then passes for `amazonses.com` but doesn't align, and DMARC passes through DKIM alone. A custom MAIL FROM domain, such as `bounce.send.example.com`, aligns SPF too. [Use Amazon SES instead of Resend](../guides/02-ses.md#3-optional-set-a-custom-mail-from-domain) sets one up, with an MX record that names your region and an SPF record:\n\n ```\n bounce.send.example.com. MX 10 feedback-smtp.us-east-1.amazonses.com.\n bounce.send.example.com. TXT \"v=spf1 include:amazonses.com ~all\"\n ```\n\nA name holds one SPF record. If one already exists there, add the provider's `include:` 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\n- **Resend** lists one `TXT` record at `resend._domainkey.send.example.com`. A domain added to Resend after August 2026 may get `CNAME` records instead.\n- **SES** 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. Your provider 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 checks, 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`. 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 Resend, or SES with a custom MAIL FROM domain, its domain is under `send.example.com`. With SES and no custom MAIL FROM domain, it's `amazonses.com`. That's expected, and DMARC still passes.\n";
|
|
@@ -5019,10 +5076,16 @@ function author(c) {
|
|
|
5019
5076
|
}
|
|
5020
5077
|
async function readBody(c, opts = {}) {
|
|
5021
5078
|
const raw = await readJsonObject(c, opts);
|
|
5079
|
+
const slug = optString(raw, "slug");
|
|
5080
|
+
const slugAuto = optBoolean(raw, "slug_auto");
|
|
5081
|
+
if (slug !== void 0 && slugAuto === true) {
|
|
5082
|
+
throw fieldError("slug", "slug cannot be set while slug_auto is true");
|
|
5083
|
+
}
|
|
5022
5084
|
return {
|
|
5023
5085
|
input: {
|
|
5024
5086
|
subject: optString(raw, "subject"),
|
|
5025
|
-
slug
|
|
5087
|
+
slug,
|
|
5088
|
+
slug_auto: slugAuto,
|
|
5026
5089
|
markdown: optString(raw, "markdown")
|
|
5027
5090
|
},
|
|
5028
5091
|
base_revision: optStringOrNull(raw, "base_revision") ?? null
|
|
@@ -6647,25 +6710,38 @@ async function readToken(c) {
|
|
|
6647
6710
|
}
|
|
6648
6711
|
return "";
|
|
6649
6712
|
}
|
|
6713
|
+
function toPublication(action2, name) {
|
|
6714
|
+
return name ? `${action2} to ${name}` : action2;
|
|
6715
|
+
}
|
|
6650
6716
|
function subscribeFormHtml() {
|
|
6651
|
-
return `<form class="r-form" method="post" action="/subscribe"><input type="email" name="email" required placeholder="you@example.com" aria-label="Your email address"><button class="r-btn" type="submit">Subscribe</button></form><p class="r-fine">
|
|
6717
|
+
return `<form class="r-form" method="post" action="/subscribe"><input type="email" name="email" required placeholder="you@example.com" aria-label="Your email address"><button class="r-btn" type="submit">Subscribe</button></form><p class="r-fine">We\u2019ll email you a link to confirm. Unsubscribe anytime.</p>`;
|
|
6652
6718
|
}
|
|
6653
6719
|
function subscribePage(c, identity, mainHtml, status = 200) {
|
|
6654
6720
|
return readerPage({
|
|
6655
6721
|
identity,
|
|
6656
6722
|
homeUrl: `${c.config.appOrigin}/`,
|
|
6657
|
-
title:
|
|
6723
|
+
title: pageTitle("Subscribe", identity.name),
|
|
6658
6724
|
mainHtml,
|
|
6659
6725
|
devDashboardUrl: devDashboardUrl(c.config),
|
|
6660
6726
|
status
|
|
6661
6727
|
});
|
|
6662
6728
|
}
|
|
6663
|
-
|
|
6664
|
-
const identity = await readerIdentity(c, c.config);
|
|
6729
|
+
function subscribeFormPage(c, identity) {
|
|
6665
6730
|
const lede = identity.tagline ? escapeHtml(identity.tagline) : "Get the next post delivered to your inbox.";
|
|
6666
|
-
const main = `<p class="r-ey">Newsletter</p><h1 class="r-h1"
|
|
6731
|
+
const main = `<p class="r-ey">Newsletter</p><h1 class="r-h1">${escapeHtml(toPublication("Subscribe", identity.name))}</h1><p class="r-lead">${lede}</p>` + subscribeFormHtml();
|
|
6732
|
+
return subscribePage(c, identity, main);
|
|
6733
|
+
}
|
|
6734
|
+
function subscribeNotAnEmailPage(c, identity) {
|
|
6735
|
+
const main = `<p class="r-ey">Newsletter</p><h1 class="r-h1">That doesn\u2019t look like an email</h1><p class="r-lead">Please check the address and try again.</p>` + subscribeFormHtml();
|
|
6736
|
+
return subscribePage(c, identity, main, 400);
|
|
6737
|
+
}
|
|
6738
|
+
function subscribeCheckInboxPage(c, identity) {
|
|
6739
|
+
const main = `<p class="r-ey">Newsletter</p><h1 class="r-h1">Almost there</h1><p class="r-lead">If that address isn\u2019t subscribed yet, a confirmation link is on its way. Check your inbox to finish subscribing.</p>`;
|
|
6667
6740
|
return subscribePage(c, identity, main);
|
|
6668
6741
|
}
|
|
6742
|
+
async function subscribeForm(c) {
|
|
6743
|
+
return subscribeFormPage(c, await readerIdentity(c, c.config));
|
|
6744
|
+
}
|
|
6669
6745
|
async function subscribe(c) {
|
|
6670
6746
|
const raw = await readEmail(c);
|
|
6671
6747
|
const email2 = raw ? normalizeEmail(raw) : "";
|
|
@@ -6673,9 +6749,7 @@ async function subscribe(c) {
|
|
|
6673
6749
|
if (!wantsHtml(c)) {
|
|
6674
6750
|
return json({ error: "bad_request", message: "a valid email is required" }, 400);
|
|
6675
6751
|
}
|
|
6676
|
-
|
|
6677
|
-
const main2 = `<p class="r-ey">Newsletter</p><h1 class="r-h1">That doesn\u2019t look like an email</h1><p class="r-lead">Please check the address and try again.</p>` + subscribeFormHtml();
|
|
6678
|
-
return subscribePage(c, identity2, main2, 400);
|
|
6752
|
+
return subscribeNotAnEmailPage(c, await readerIdentity(c, c.config));
|
|
6679
6753
|
}
|
|
6680
6754
|
c.ctx.waitUntil(
|
|
6681
6755
|
requestSubscription(c, email2).catch((err) => {
|
|
@@ -6686,9 +6760,7 @@ async function subscribe(c) {
|
|
|
6686
6760
|
const body = { status: "check_inbox" };
|
|
6687
6761
|
return json(body);
|
|
6688
6762
|
}
|
|
6689
|
-
|
|
6690
|
-
const main = `<p class="r-ey">Newsletter</p><h1 class="r-h1">Almost there</h1><p class="r-lead">If that address isn\u2019t subscribed yet, a confirmation link is on its way. Check your inbox to finish subscribing.</p>`;
|
|
6691
|
-
return subscribePage(c, identity, main);
|
|
6763
|
+
return subscribeCheckInboxPage(c, await readerIdentity(c, c.config));
|
|
6692
6764
|
}
|
|
6693
6765
|
async function confirmLanding(c) {
|
|
6694
6766
|
const token2 = c.url.searchParams.get("token") ?? "";
|
|
@@ -6698,98 +6770,197 @@ async function confirm2(c) {
|
|
|
6698
6770
|
const token2 = await readToken(c);
|
|
6699
6771
|
return confirmStatePage(c, await confirmSubscription2(c.env.DB, token2), token2);
|
|
6700
6772
|
}
|
|
6701
|
-
function confirmStatePage(c, state, token2) {
|
|
6702
|
-
const
|
|
6773
|
+
async function confirmStatePage(c, state, token2) {
|
|
6774
|
+
const eyebrow = "Newsletter";
|
|
6703
6775
|
switch (state.kind) {
|
|
6704
|
-
case "ready":
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
6712
|
-
|
|
6713
|
-
</form
|
|
6714
|
-
|
|
6715
|
-
|
|
6776
|
+
case "ready": {
|
|
6777
|
+
const identity = await readerIdentity(c, c.config);
|
|
6778
|
+
return readerMessage(
|
|
6779
|
+
c,
|
|
6780
|
+
{
|
|
6781
|
+
title: "Confirm your subscription",
|
|
6782
|
+
eyebrow,
|
|
6783
|
+
headline: toPublication("Confirm your subscription", identity.name),
|
|
6784
|
+
ledeHtml: `Start sending the newsletter to <strong>${escapeHtml(state.subscriber.email)}</strong>?`,
|
|
6785
|
+
afterHtml: `<form class="r-form" method="post" action="/confirm"><input type="hidden" name="token" value="${escapeHtmlAttr(token2)}"><button class="r-btn" type="submit">Confirm subscription</button></form>`
|
|
6786
|
+
},
|
|
6787
|
+
identity
|
|
6716
6788
|
);
|
|
6789
|
+
}
|
|
6717
6790
|
case "confirmed":
|
|
6718
|
-
return
|
|
6719
|
-
"Subscribed",
|
|
6720
|
-
|
|
6721
|
-
|
|
6722
|
-
)}</strong>.
|
|
6723
|
-
|
|
6724
|
-
|
|
6725
|
-
);
|
|
6791
|
+
return readerMessage(c, {
|
|
6792
|
+
title: "Subscribed",
|
|
6793
|
+
eyebrow,
|
|
6794
|
+
headline: "You\u2019re subscribed",
|
|
6795
|
+
ledeHtml: `Thanks for confirming <strong>${escapeHtml(state.subscriber.email)}</strong>. The next post will land in your inbox.`,
|
|
6796
|
+
afterHtml: archiveLink(archiveHomeUrl(c.config), "Browse the archive")
|
|
6797
|
+
});
|
|
6726
6798
|
case "expired":
|
|
6727
|
-
return
|
|
6728
|
-
"Link expired",
|
|
6729
|
-
|
|
6730
|
-
|
|
6731
|
-
)}</strong
|
|
6732
|
-
|
|
6733
|
-
|
|
6734
|
-
|
|
6735
|
-
</form>`,
|
|
6736
|
-
410,
|
|
6737
|
-
dev
|
|
6738
|
-
);
|
|
6799
|
+
return readerMessage(c, {
|
|
6800
|
+
title: "Link expired",
|
|
6801
|
+
eyebrow,
|
|
6802
|
+
headline: "This link has expired",
|
|
6803
|
+
ledeHtml: `Confirmation links only work for a limited time. We can send a new one to <strong>${escapeHtml(state.subscriber.email)}</strong>.`,
|
|
6804
|
+
afterHtml: `<form class="r-form" method="post" action="/subscribe"><input type="hidden" name="email" value="${escapeHtmlAttr(state.subscriber.email)}"><button class="r-btn" type="submit">Send a new link</button></form>`,
|
|
6805
|
+
status: 410
|
|
6806
|
+
});
|
|
6739
6807
|
case "invalid":
|
|
6740
|
-
return
|
|
6741
|
-
"Invalid link",
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6808
|
+
return readerMessage(c, {
|
|
6809
|
+
title: "Invalid link",
|
|
6810
|
+
eyebrow,
|
|
6811
|
+
headline: "This link is invalid",
|
|
6812
|
+
ledeHtml: `<a href="/subscribe">Subscribe again</a> to get a new one.`,
|
|
6813
|
+
status: 400
|
|
6814
|
+
});
|
|
6746
6815
|
}
|
|
6747
6816
|
}
|
|
6817
|
+
function unsubscribeInvalidPage(c) {
|
|
6818
|
+
return readerMessage(c, {
|
|
6819
|
+
title: "Invalid link",
|
|
6820
|
+
eyebrow: "Unsubscribe",
|
|
6821
|
+
headline: "This link is invalid",
|
|
6822
|
+
ledeHtml: "It may be incomplete. The unsubscribe link at the bottom of any email from the newsletter will work.",
|
|
6823
|
+
status: 400
|
|
6824
|
+
});
|
|
6825
|
+
}
|
|
6826
|
+
function unsubscribedPage(c, email2, banner) {
|
|
6827
|
+
return readerMessage(c, {
|
|
6828
|
+
title: "Unsubscribed",
|
|
6829
|
+
eyebrow: "Unsubscribe",
|
|
6830
|
+
headline: "You\u2019re unsubscribed",
|
|
6831
|
+
ledeHtml: `<strong>${escapeHtml(email2)}</strong> won\u2019t receive further emails.`,
|
|
6832
|
+
banner
|
|
6833
|
+
});
|
|
6834
|
+
}
|
|
6835
|
+
async function unsubscribeAskPage(c, email2, action2, banner) {
|
|
6836
|
+
const identity = await readerIdentity(c, c.config);
|
|
6837
|
+
return readerMessage(
|
|
6838
|
+
c,
|
|
6839
|
+
{
|
|
6840
|
+
title: "Unsubscribe",
|
|
6841
|
+
eyebrow: "Unsubscribe",
|
|
6842
|
+
headline: identity.name ? `Unsubscribe from ${identity.name}?` : "Unsubscribe?",
|
|
6843
|
+
ledeHtml: `We\u2019ll stop sending the newsletter to <strong>${escapeHtml(email2)}</strong>.`,
|
|
6844
|
+
afterHtml: `<form class="r-form" method="post" action="${escapeHtmlAttr(action2)}"><button class="r-btn" type="submit">Unsubscribe</button></form>`,
|
|
6845
|
+
banner
|
|
6846
|
+
},
|
|
6847
|
+
identity
|
|
6848
|
+
);
|
|
6849
|
+
}
|
|
6850
|
+
var TEST_UNSUBSCRIBE_PARAM = "test";
|
|
6851
|
+
function testUnsubscribeUrl(config) {
|
|
6852
|
+
return `${config.appOrigin}/unsubscribe?${TEST_UNSUBSCRIBE_PARAM}=1`;
|
|
6853
|
+
}
|
|
6854
|
+
function isTestUnsubscribe(c, token2) {
|
|
6855
|
+
return token2 === "" && c.url.searchParams.get(TEST_UNSUBSCRIBE_PARAM) === "1";
|
|
6856
|
+
}
|
|
6857
|
+
var TEST_UNSUBSCRIBE_EMAIL = "reader@example.com";
|
|
6858
|
+
var TEST_UNSUBSCRIBE_BANNER = {
|
|
6859
|
+
tag: "Test",
|
|
6860
|
+
text: "This is the page a subscriber sees. Nothing here changes anyone\u2019s subscription."
|
|
6861
|
+
};
|
|
6748
6862
|
async function unsubscribeLanding(c) {
|
|
6749
6863
|
const token2 = c.url.searchParams.get("token") ?? "";
|
|
6864
|
+
if (isTestUnsubscribe(c, token2)) {
|
|
6865
|
+
return unsubscribeAskPage(
|
|
6866
|
+
c,
|
|
6867
|
+
TEST_UNSUBSCRIBE_EMAIL,
|
|
6868
|
+
`/unsubscribe?${TEST_UNSUBSCRIBE_PARAM}=1`,
|
|
6869
|
+
TEST_UNSUBSCRIBE_BANNER
|
|
6870
|
+
);
|
|
6871
|
+
}
|
|
6750
6872
|
const row = await getByUnsubToken(c.env.DB, token2);
|
|
6751
|
-
const dev = devDashboardUrl(c.config);
|
|
6752
6873
|
if (!row) {
|
|
6753
|
-
return
|
|
6754
|
-
"Invalid link",
|
|
6755
|
-
`<h1 style="margin-top:0;">This link is invalid</h1>`,
|
|
6756
|
-
400,
|
|
6757
|
-
dev
|
|
6758
|
-
);
|
|
6874
|
+
return unsubscribeInvalidPage(c);
|
|
6759
6875
|
}
|
|
6760
6876
|
if (row.status === "unsubscribed") {
|
|
6761
|
-
return
|
|
6762
|
-
"Unsubscribed",
|
|
6763
|
-
`<h1 style="margin-top:0;">You're unsubscribed</h1><p>${escapeHtml(row.email)} won't receive further emails.</p>`,
|
|
6764
|
-
200,
|
|
6765
|
-
dev
|
|
6766
|
-
);
|
|
6877
|
+
return unsubscribedPage(c, row.email);
|
|
6767
6878
|
}
|
|
6768
|
-
return
|
|
6769
|
-
"Unsubscribe",
|
|
6770
|
-
`<h1>Unsubscribe?</h1><p>Stop sending the newsletter to <strong>${escapeHtml(
|
|
6771
|
-
row.email
|
|
6772
|
-
)}</strong>?</p>
|
|
6773
|
-
<form method="post" action="/unsubscribe?token=${encodeURIComponent(token2)}">
|
|
6774
|
-
<button type="submit" class="btn btn-danger">Unsubscribe</button>
|
|
6775
|
-
</form>`,
|
|
6776
|
-
200,
|
|
6777
|
-
dev
|
|
6778
|
-
);
|
|
6879
|
+
return unsubscribeAskPage(c, row.email, `/unsubscribe?token=${encodeURIComponent(token2)}`);
|
|
6779
6880
|
}
|
|
6780
6881
|
async function unsubscribe(c) {
|
|
6781
6882
|
const token2 = await readToken(c);
|
|
6883
|
+
if (isTestUnsubscribe(c, token2)) {
|
|
6884
|
+
return wantsHtml(c) ? unsubscribedPage(c, TEST_UNSUBSCRIBE_EMAIL, TEST_UNSUBSCRIBE_BANNER) : new Response("test email", { status: 200 });
|
|
6885
|
+
}
|
|
6782
6886
|
const row = await unsubscribeByToken(c.env.DB, token2);
|
|
6783
|
-
const dev = devDashboardUrl(c.config);
|
|
6784
6887
|
if (!row) {
|
|
6785
|
-
return wantsHtml(c) ?
|
|
6888
|
+
return wantsHtml(c) ? unsubscribeInvalidPage(c) : new Response("invalid token", { status: 400 });
|
|
6889
|
+
}
|
|
6890
|
+
return wantsHtml(c) ? unsubscribedPage(c, row.email) : new Response("unsubscribed", { status: 200 });
|
|
6891
|
+
}
|
|
6892
|
+
|
|
6893
|
+
// shared/reader_pages.ts
|
|
6894
|
+
var SUBSCRIBE = { base: "app", path: "/subscribe" };
|
|
6895
|
+
var CONFIRM = { base: "app", path: "/confirm?token=\u2026" };
|
|
6896
|
+
var UNSUBSCRIBE = { base: "app", path: "/unsubscribe?token=\u2026" };
|
|
6897
|
+
var READER_JOURNEYS = [
|
|
6898
|
+
{
|
|
6899
|
+
id: "subscribe",
|
|
6900
|
+
label: "Subscribe",
|
|
6901
|
+
flow: "steps",
|
|
6902
|
+
states: [
|
|
6903
|
+
{ id: "form", label: "Form", at: SUBSCRIBE },
|
|
6904
|
+
{ id: "check-inbox", label: "Check your inbox", at: SUBSCRIBE },
|
|
6905
|
+
{ id: "not-an-email", label: "Not an email", offPath: true, at: SUBSCRIBE }
|
|
6906
|
+
]
|
|
6907
|
+
},
|
|
6908
|
+
{
|
|
6909
|
+
id: "confirm",
|
|
6910
|
+
label: "Confirm",
|
|
6911
|
+
flow: "steps",
|
|
6912
|
+
states: [
|
|
6913
|
+
{ id: "ready", label: "Ready", at: CONFIRM },
|
|
6914
|
+
{ id: "confirmed", label: "Confirmed", at: CONFIRM },
|
|
6915
|
+
{ id: "expired", label: "Expired", offPath: true, at: CONFIRM },
|
|
6916
|
+
{ id: "invalid", label: "Invalid link", offPath: true, at: CONFIRM }
|
|
6917
|
+
]
|
|
6918
|
+
},
|
|
6919
|
+
{
|
|
6920
|
+
id: "unsubscribe",
|
|
6921
|
+
label: "Unsubscribe",
|
|
6922
|
+
flow: "steps",
|
|
6923
|
+
states: [
|
|
6924
|
+
{ id: "ask", label: "Ask", at: UNSUBSCRIBE },
|
|
6925
|
+
{ id: "done", label: "Unsubscribed", at: UNSUBSCRIBE },
|
|
6926
|
+
{ id: "invalid", label: "Invalid link", offPath: true, at: UNSUBSCRIBE }
|
|
6927
|
+
]
|
|
6928
|
+
},
|
|
6929
|
+
{
|
|
6930
|
+
id: "reading",
|
|
6931
|
+
label: "Reading",
|
|
6932
|
+
flow: "peers",
|
|
6933
|
+
states: [
|
|
6934
|
+
{ id: "landing", label: "Landing page", at: { base: "app", path: "/" } },
|
|
6935
|
+
{ id: "archive", label: "Archive", at: { base: "archive", path: "" } },
|
|
6936
|
+
// The sample post's slug, as the Worker's preview renders it.
|
|
6937
|
+
{ id: "post", label: "Post page", at: { base: "archive", path: "/sample-post" } },
|
|
6938
|
+
// A publication with nothing sent yet: what its first visitors get.
|
|
6939
|
+
{
|
|
6940
|
+
id: "landing-empty",
|
|
6941
|
+
label: "Landing, no posts yet",
|
|
6942
|
+
offPath: true,
|
|
6943
|
+
at: { base: "app", path: "/" }
|
|
6944
|
+
},
|
|
6945
|
+
{
|
|
6946
|
+
id: "archive-empty",
|
|
6947
|
+
label: "Archive, no posts yet",
|
|
6948
|
+
offPath: true,
|
|
6949
|
+
at: { base: "archive", path: "" }
|
|
6950
|
+
},
|
|
6951
|
+
{
|
|
6952
|
+
id: "not-found",
|
|
6953
|
+
label: "Nothing here yet",
|
|
6954
|
+
offPath: true,
|
|
6955
|
+
at: { base: "archive", path: "/\u2026" }
|
|
6956
|
+
}
|
|
6957
|
+
]
|
|
6786
6958
|
}
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
) : new Response("unsubscribed", { status: 200 });
|
|
6959
|
+
];
|
|
6960
|
+
function findReaderPage(journey, state) {
|
|
6961
|
+
const j = READER_JOURNEYS.find((x) => x.id === journey);
|
|
6962
|
+
const s = j?.states.find((x) => x.id === state);
|
|
6963
|
+
return j && s ? { journey: j, state: s } : null;
|
|
6793
6964
|
}
|
|
6794
6965
|
|
|
6795
6966
|
// src/notify/fake.ts
|
|
@@ -6822,9 +6993,6 @@ async function loadRenderInput(c) {
|
|
|
6822
6993
|
const imgs = await listImages(c.env.DB, post.id);
|
|
6823
6994
|
return { post, revision, images: imgs };
|
|
6824
6995
|
}
|
|
6825
|
-
function genericUnsubscribeUrl(c) {
|
|
6826
|
-
return `${c.config.appOrigin}/unsubscribe`;
|
|
6827
|
-
}
|
|
6828
6996
|
async function loadBranding(c) {
|
|
6829
6997
|
return resolveBranding(await getSettings(c.env.DB), c.config);
|
|
6830
6998
|
}
|
|
@@ -6836,11 +7004,21 @@ async function loadPostEmail(c) {
|
|
|
6836
7004
|
input,
|
|
6837
7005
|
email: { subject: send.subject, html: send.rendered_html, text: send.rendered_text },
|
|
6838
7006
|
warnings: [],
|
|
6839
|
-
frozen: { id: send.id }
|
|
7007
|
+
frozen: { id: send.id },
|
|
7008
|
+
dateMs: send.started_at ?? send.fire_at
|
|
6840
7009
|
};
|
|
6841
7010
|
}
|
|
6842
7011
|
const result = await render(input, c.config, await loadBranding(c));
|
|
6843
|
-
return { input, email: result, warnings: result.warnings, frozen: null };
|
|
7012
|
+
return { input, email: result, warnings: result.warnings, frozen: null, dateMs: Date.now() };
|
|
7013
|
+
}
|
|
7014
|
+
function postPage(html) {
|
|
7015
|
+
return new Response(html, {
|
|
7016
|
+
headers: {
|
|
7017
|
+
...POST_PAGE_SECURITY_HEADERS,
|
|
7018
|
+
"content-type": "text/html; charset=utf-8",
|
|
7019
|
+
"x-robots-tag": "noindex"
|
|
7020
|
+
}
|
|
7021
|
+
});
|
|
6844
7022
|
}
|
|
6845
7023
|
async function preview(c) {
|
|
6846
7024
|
const { input, email: email2, warnings, frozen } = await loadPostEmail(c);
|
|
@@ -6855,16 +7033,22 @@ async function preview(c) {
|
|
|
6855
7033
|
async function previewPage(c) {
|
|
6856
7034
|
const { email: email2 } = await loadPostEmail(c);
|
|
6857
7035
|
const html = substituteRecipient(email2, {
|
|
6858
|
-
|
|
7036
|
+
// The preview is the email shown to the publisher, so its link opens the same page
|
|
7037
|
+
// a test email's does, not one calling the link invalid (SPEC §7).
|
|
7038
|
+
".Email.UnsubscribeURL": testUnsubscribeUrl(c.config),
|
|
6859
7039
|
".Email.SentTo": ""
|
|
6860
7040
|
}).html;
|
|
6861
|
-
return
|
|
6862
|
-
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
6867
|
-
|
|
7041
|
+
return postPage(html);
|
|
7042
|
+
}
|
|
7043
|
+
async function webPage(c) {
|
|
7044
|
+
const { email: email2, dateMs } = await loadPostEmail(c);
|
|
7045
|
+
return postPage(
|
|
7046
|
+
webVersion(email2.html, {
|
|
7047
|
+
config: c.config,
|
|
7048
|
+
identity: await readerIdentity(c, c.config),
|
|
7049
|
+
dateMs
|
|
7050
|
+
})
|
|
7051
|
+
);
|
|
6868
7052
|
}
|
|
6869
7053
|
async function test(c) {
|
|
6870
7054
|
const to = normalizeEmail(optString(await readJsonObject(c), "to") ?? "");
|
|
@@ -6874,7 +7058,7 @@ async function test(c) {
|
|
|
6874
7058
|
const readAt = Date.now();
|
|
6875
7059
|
const { input, email: email2, warnings, frozen } = await loadPostEmail(c);
|
|
6876
7060
|
const provider = getProvider(c.config, c.env);
|
|
6877
|
-
const unsubscribeUrl =
|
|
7061
|
+
const unsubscribeUrl = testUnsubscribeUrl(c.config);
|
|
6878
7062
|
const recipients = [{ email: to, unsubscribeUrl }];
|
|
6879
7063
|
const [res] = perRecipient(
|
|
6880
7064
|
await provider.sendBatch(email2, recipients, {
|
|
@@ -6911,14 +7095,16 @@ var TEMPLATE_TEST_MARKDOWN = [
|
|
|
6911
7095
|
"- The maples on Marsh Lane have finally turned.",
|
|
6912
7096
|
"- Someone left a note in the little free library, addressed to no one.",
|
|
6913
7097
|
"",
|
|
6914
|
-
"This is a **sample post
|
|
7098
|
+
"This is a **sample post**: [links](https://example.com) render like this, and a real post's Markdown fills this space."
|
|
6915
7099
|
].join("\n");
|
|
6916
|
-
|
|
7100
|
+
var SAMPLE_POST_SUBJECT = "The starlings are back";
|
|
7101
|
+
function sampleRenderInput(subject = TEMPLATE_TEST_SUBJECT) {
|
|
6917
7102
|
const now = Date.now();
|
|
6918
7103
|
const post = {
|
|
6919
7104
|
id: "sample",
|
|
6920
7105
|
slug: "sample-post",
|
|
6921
|
-
|
|
7106
|
+
slug_auto: false,
|
|
7107
|
+
subject,
|
|
6922
7108
|
status: "draft",
|
|
6923
7109
|
current_revision: "sample-rev",
|
|
6924
7110
|
created_at: now,
|
|
@@ -6928,7 +7114,7 @@ function sampleRenderInput() {
|
|
|
6928
7114
|
id: "sample-rev",
|
|
6929
7115
|
post_id: "sample",
|
|
6930
7116
|
markdown: TEMPLATE_TEST_MARKDOWN,
|
|
6931
|
-
metadata: JSON.stringify({ subject
|
|
7117
|
+
metadata: JSON.stringify({ subject, slug: post.slug }),
|
|
6932
7118
|
author: null,
|
|
6933
7119
|
created_at: now
|
|
6934
7120
|
};
|
|
@@ -6979,7 +7165,7 @@ async function templateTest(c) {
|
|
|
6979
7165
|
}
|
|
6980
7166
|
const result = await render(sampleRenderInput(), c.config, resolveBranding(settings, c.config));
|
|
6981
7167
|
const provider = getProvider(c.config, c.env);
|
|
6982
|
-
const unsubscribeUrl =
|
|
7168
|
+
const unsubscribeUrl = testUnsubscribeUrl(c.config);
|
|
6983
7169
|
const testId = `template-test-${crypto.randomUUID()}`;
|
|
6984
7170
|
const batch = recipients.map((email2) => ({ email: email2, unsubscribeUrl }));
|
|
6985
7171
|
const results = perRecipient(
|
|
@@ -7008,6 +7194,116 @@ async function devOutbox(c) {
|
|
|
7008
7194
|
return json({ messages: fakeOutbox(), notifications: fakeNotifications() });
|
|
7009
7195
|
}
|
|
7010
7196
|
|
|
7197
|
+
// src/routes/reader_pages.ts
|
|
7198
|
+
var SAMPLE_EMAIL = "reader@example.com";
|
|
7199
|
+
var DAY_MS = 864e5;
|
|
7200
|
+
function samplePosts(c, now) {
|
|
7201
|
+
return [
|
|
7202
|
+
{ title: SAMPLE_POST_SUBJECT, slug: "sample-post", at: now },
|
|
7203
|
+
{ title: "Notes from the first frost", slug: "first-frost", at: now - 7 * DAY_MS },
|
|
7204
|
+
{ title: "What the river kept", slug: "what-the-river-kept", at: now - 14 * DAY_MS }
|
|
7205
|
+
].map((p) => ({
|
|
7206
|
+
title: p.title,
|
|
7207
|
+
url: archiveUrl(c.config, p.slug),
|
|
7208
|
+
dateLabel: formatPublishedDate(p.at)
|
|
7209
|
+
}));
|
|
7210
|
+
}
|
|
7211
|
+
async function previewOf(c, journey, state) {
|
|
7212
|
+
const home = `${c.config.appOrigin}/`;
|
|
7213
|
+
const subscribeUrl = `${c.config.appOrigin}/subscribe`;
|
|
7214
|
+
const sample = { email: SAMPLE_EMAIL };
|
|
7215
|
+
switch (`${journey}/${state}`) {
|
|
7216
|
+
case "subscribe/form":
|
|
7217
|
+
return subscribeFormPage(c, await readerIdentity(c, c.config));
|
|
7218
|
+
case "subscribe/check-inbox":
|
|
7219
|
+
return subscribeCheckInboxPage(c, await readerIdentity(c, c.config));
|
|
7220
|
+
case "subscribe/not-an-email":
|
|
7221
|
+
return subscribeNotAnEmailPage(c, await readerIdentity(c, c.config));
|
|
7222
|
+
case "confirm/ready":
|
|
7223
|
+
return confirmStatePage(c, { kind: "ready", subscriber: sample }, "");
|
|
7224
|
+
case "confirm/confirmed":
|
|
7225
|
+
return confirmStatePage(c, { kind: "confirmed", subscriber: sample }, "");
|
|
7226
|
+
case "confirm/expired":
|
|
7227
|
+
return confirmStatePage(c, { kind: "expired", subscriber: sample }, "");
|
|
7228
|
+
case "confirm/invalid":
|
|
7229
|
+
return confirmStatePage(c, { kind: "invalid" }, "");
|
|
7230
|
+
case "unsubscribe/ask":
|
|
7231
|
+
return unsubscribeAskPage(c, SAMPLE_EMAIL, "/unsubscribe");
|
|
7232
|
+
case "unsubscribe/done":
|
|
7233
|
+
return unsubscribedPage(c, SAMPLE_EMAIL);
|
|
7234
|
+
case "unsubscribe/invalid":
|
|
7235
|
+
return unsubscribeInvalidPage(c);
|
|
7236
|
+
case "reading/landing": {
|
|
7237
|
+
const [featured, ...recent] = samplePosts(c, Date.now());
|
|
7238
|
+
return landingPage({
|
|
7239
|
+
identity: await readerIdentity(c, c.config),
|
|
7240
|
+
subscribeUrl,
|
|
7241
|
+
homeUrl: home,
|
|
7242
|
+
archiveUrl: archiveHomeUrl(c.config),
|
|
7243
|
+
featured,
|
|
7244
|
+
recent
|
|
7245
|
+
});
|
|
7246
|
+
}
|
|
7247
|
+
case "reading/archive":
|
|
7248
|
+
return archiveIndexPage({
|
|
7249
|
+
identity: await readerIdentity(c, c.config),
|
|
7250
|
+
subscribeUrl,
|
|
7251
|
+
homeUrl: home,
|
|
7252
|
+
posts: samplePosts(c, Date.now())
|
|
7253
|
+
});
|
|
7254
|
+
// The same two pages before the first post is sent.
|
|
7255
|
+
case "reading/landing-empty":
|
|
7256
|
+
return landingPage({
|
|
7257
|
+
identity: await readerIdentity(c, c.config),
|
|
7258
|
+
subscribeUrl,
|
|
7259
|
+
homeUrl: home,
|
|
7260
|
+
archiveUrl: archiveHomeUrl(c.config),
|
|
7261
|
+
recent: []
|
|
7262
|
+
});
|
|
7263
|
+
case "reading/archive-empty":
|
|
7264
|
+
return archiveIndexPage({
|
|
7265
|
+
identity: await readerIdentity(c, c.config),
|
|
7266
|
+
subscribeUrl,
|
|
7267
|
+
homeUrl: home,
|
|
7268
|
+
posts: []
|
|
7269
|
+
});
|
|
7270
|
+
case "reading/post": {
|
|
7271
|
+
const settings = await getSettings(c.env.DB);
|
|
7272
|
+
const email2 = await render(
|
|
7273
|
+
sampleRenderInput(SAMPLE_POST_SUBJECT),
|
|
7274
|
+
c.config,
|
|
7275
|
+
resolveBranding(settings, c.config)
|
|
7276
|
+
);
|
|
7277
|
+
const html = webVersion(email2.html, {
|
|
7278
|
+
config: c.config,
|
|
7279
|
+
identity: await readerIdentity(c, c.config),
|
|
7280
|
+
dateMs: Date.now()
|
|
7281
|
+
});
|
|
7282
|
+
return new Response(html);
|
|
7283
|
+
}
|
|
7284
|
+
case "reading/not-found":
|
|
7285
|
+
return nothingHereYetPage(c);
|
|
7286
|
+
}
|
|
7287
|
+
throw notFound("reader page");
|
|
7288
|
+
}
|
|
7289
|
+
async function readerPagePreview(c) {
|
|
7290
|
+
const journey = param(c, "journey");
|
|
7291
|
+
const state = param(c, "state");
|
|
7292
|
+
if (!findReaderPage(journey, state)) {
|
|
7293
|
+
throw notFound("reader page");
|
|
7294
|
+
}
|
|
7295
|
+
const pc = { ...c, config: { ...c.config, devMode: false } };
|
|
7296
|
+
const page = await previewOf(pc, journey, state);
|
|
7297
|
+
return new Response(page.body, {
|
|
7298
|
+
headers: {
|
|
7299
|
+
...POST_PAGE_SECURITY_HEADERS,
|
|
7300
|
+
"content-type": "text/html; charset=utf-8",
|
|
7301
|
+
"cache-control": "no-store",
|
|
7302
|
+
"x-robots-tag": "noindex"
|
|
7303
|
+
}
|
|
7304
|
+
});
|
|
7305
|
+
}
|
|
7306
|
+
|
|
7011
7307
|
// src/routes/schedule.ts
|
|
7012
7308
|
var ISO_WITH_OFFSET = /T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/i;
|
|
7013
7309
|
function parseFireAt(input) {
|
|
@@ -7067,70 +7363,60 @@ async function sendNow(c) {
|
|
|
7067
7363
|
rejectStrayTemplateChoice(await readJsonObject(c, { optional: true }));
|
|
7068
7364
|
const active = await getActiveSendForPost(c.env.DB, post.id);
|
|
7069
7365
|
if (active) {
|
|
7070
|
-
|
|
7071
|
-
...unwrap(await viewWithCursor(c.env, active.id), "send"),
|
|
7072
|
-
idempotent: true
|
|
7073
|
-
};
|
|
7074
|
-
return json(repeat);
|
|
7366
|
+
return repeatAnswer(c, active.id);
|
|
7075
7367
|
}
|
|
7076
7368
|
const now = Date.now();
|
|
7077
7369
|
const fireAt = acceptFireAt(now + c.config.minLeadMs, c.config.minLeadMs, { now });
|
|
7078
|
-
|
|
7370
|
+
let send;
|
|
7371
|
+
try {
|
|
7372
|
+
send = await freeze(c.env, c.config, post, fireAt);
|
|
7373
|
+
} catch (err) {
|
|
7374
|
+
if (err instanceof HttpError && err.code === "active_send_exists") {
|
|
7375
|
+
const winner = await getActiveSendForPost(c.env.DB, post.id);
|
|
7376
|
+
if (winner) {
|
|
7377
|
+
return repeatAnswer(c, winner.id);
|
|
7378
|
+
}
|
|
7379
|
+
}
|
|
7380
|
+
throw err;
|
|
7381
|
+
}
|
|
7079
7382
|
const frozen = unwrap(await viewWithCursor(c.env, send.id), "send");
|
|
7080
7383
|
return json(frozen, 201);
|
|
7081
7384
|
}
|
|
7385
|
+
async function repeatAnswer(c, sendId) {
|
|
7386
|
+
const repeat = {
|
|
7387
|
+
...unwrap(await viewWithCursor(c.env, sendId), "send"),
|
|
7388
|
+
idempotent: true
|
|
7389
|
+
};
|
|
7390
|
+
return json(repeat);
|
|
7391
|
+
}
|
|
7082
7392
|
|
|
7083
7393
|
// src/send/resolve.ts
|
|
7084
|
-
async function resolveStuckSend(env, sendId, outcome, actor
|
|
7394
|
+
async function resolveStuckSend(env, sendId, outcome, actor) {
|
|
7085
7395
|
const send = await getSend(env.DB, sendId);
|
|
7086
7396
|
if (!send) {
|
|
7087
7397
|
throw notFound("send");
|
|
7088
7398
|
}
|
|
7089
|
-
const refuse = async (
|
|
7090
|
-
if (guard2.ifMatch !== void 0 && send.rev !== guard2.ifMatch) {
|
|
7091
|
-
throw await refuse(
|
|
7092
|
-
412,
|
|
7093
|
-
"precondition_failed",
|
|
7094
|
-
`the send has changed since rev ${guard2.ifMatch} (it is at rev ${send.rev}); read it again before resolving`
|
|
7095
|
-
);
|
|
7096
|
-
}
|
|
7399
|
+
const refuse = async (code, message2) => refusal(409, code, message2, { send: await viewSend(env, send.id) });
|
|
7097
7400
|
const now = Date.now();
|
|
7098
7401
|
if (send.status === "sending" && send.locked_until !== null && send.locked_until > now) {
|
|
7099
7402
|
throw await refuse(
|
|
7100
|
-
409,
|
|
7101
7403
|
"run_in_progress",
|
|
7102
7404
|
"this send is being worked on right now; try Resolve again in a moment"
|
|
7103
7405
|
);
|
|
7104
7406
|
}
|
|
7105
7407
|
if (!isWedged(send)) {
|
|
7106
7408
|
throw await refuse(
|
|
7107
|
-
409,
|
|
7108
7409
|
"not_wedged",
|
|
7109
7410
|
send.status === "sending" ? "send is not wedged: it still has recipients to hand off, or a run has yet to look at those in flight" : `send is ${send.status}, with no ambiguous deliveries to resolve`
|
|
7110
7411
|
);
|
|
7111
7412
|
}
|
|
7112
|
-
if (guard2.expectedCount !== void 0 && guard2.expectedCount !== send.c_in_flight) {
|
|
7113
|
-
throw await refuse(
|
|
7114
|
-
409,
|
|
7115
|
-
"count_changed",
|
|
7116
|
-
`the send has ${send.c_in_flight} ambiguous recipients, not the ${guard2.expectedCount} expected; read it again before resolving`
|
|
7117
|
-
);
|
|
7118
|
-
}
|
|
7119
7413
|
const lease = await acquireLease(env.DB, sendId, now, LEASE_TTL_MS);
|
|
7120
7414
|
if (!lease) {
|
|
7121
7415
|
const current = await getSend(env.DB, sendId);
|
|
7122
|
-
if (current && guard2.ifMatch !== void 0 && current.rev !== guard2.ifMatch) {
|
|
7123
|
-
throw await refuse(
|
|
7124
|
-
412,
|
|
7125
|
-
"precondition_failed",
|
|
7126
|
-
`the send has changed since rev ${guard2.ifMatch} (it is at rev ${current.rev}); read it again before resolving`
|
|
7127
|
-
);
|
|
7128
|
-
}
|
|
7129
7416
|
if (!current || !isWedged(current)) {
|
|
7130
|
-
throw await refuse(
|
|
7417
|
+
throw await refuse("not_wedged", "send is no longer wedged");
|
|
7131
7418
|
}
|
|
7132
7419
|
throw await refuse(
|
|
7133
|
-
409,
|
|
7134
7420
|
"run_in_progress",
|
|
7135
7421
|
"this send is being worked on right now; try Resolve again in a moment"
|
|
7136
7422
|
);
|
|
@@ -7363,21 +7649,9 @@ async function answerWith(c, id) {
|
|
|
7363
7649
|
}
|
|
7364
7650
|
return read;
|
|
7365
7651
|
}
|
|
7366
|
-
function parseIfMatch(c) {
|
|
7367
|
-
const raw = c.req.headers.get("if-match");
|
|
7368
|
-
if (raw === null) {
|
|
7369
|
-
return void 0;
|
|
7370
|
-
}
|
|
7371
|
-
const match = /^\s*(?:W\/)?"?(\d+)(?:-[0-9a-z]+)?"?\s*$/.exec(raw);
|
|
7372
|
-
const rev = match?.[1] === void 0 ? Number.NaN : Number(match[1]);
|
|
7373
|
-
if (!Number.isSafeInteger(rev)) {
|
|
7374
|
-
throw badRequest(`If-Match must be a send's rev, as "<rev>"`, { field: "If-Match" });
|
|
7375
|
-
}
|
|
7376
|
-
return rev;
|
|
7377
|
-
}
|
|
7378
7652
|
async function cancel2(c) {
|
|
7379
7653
|
const id = param(c, "id");
|
|
7380
|
-
const { changed } = await cancel(c.env, id
|
|
7654
|
+
const { changed } = await cancel(c.env, id);
|
|
7381
7655
|
const body = { ...await answerWith(c, id), changed };
|
|
7382
7656
|
return json(body);
|
|
7383
7657
|
}
|
|
@@ -7385,22 +7659,16 @@ async function reschedule2(c) {
|
|
|
7385
7659
|
const body = await readJsonObject(c);
|
|
7386
7660
|
const fireAt = parseFireAt(body.fire_at);
|
|
7387
7661
|
const id = param(c, "id");
|
|
7388
|
-
const { changed } = await reschedule(c.env, id, fireAt, c.config.minLeadMs
|
|
7389
|
-
ifMatch: parseIfMatch(c)
|
|
7390
|
-
});
|
|
7662
|
+
const { changed } = await reschedule(c.env, id, fireAt, c.config.minLeadMs);
|
|
7391
7663
|
const response = { ...await answerWith(c, id), changed };
|
|
7392
7664
|
return json(response);
|
|
7393
7665
|
}
|
|
7394
7666
|
async function resolve(c) {
|
|
7395
7667
|
const request = await readJsonObject(c);
|
|
7396
7668
|
const outcome = oneOf(request, "resolution", ["unsent", "accepted"]);
|
|
7397
|
-
const expectedCount = optCount(request, "expected_count");
|
|
7398
7669
|
const actor = c.principal?.email ?? "service";
|
|
7399
7670
|
const id = param(c, "id");
|
|
7400
|
-
const { resolved, completed } = await resolveStuckSend(c.env, id, outcome, actor
|
|
7401
|
-
ifMatch: parseIfMatch(c),
|
|
7402
|
-
expectedCount
|
|
7403
|
-
});
|
|
7671
|
+
const { resolved, completed } = await resolveStuckSend(c.env, id, outcome, actor);
|
|
7404
7672
|
const body = { ...await answerWith(c, id), resolved, completed };
|
|
7405
7673
|
return json(body);
|
|
7406
7674
|
}
|
|
@@ -7554,6 +7822,12 @@ var ProviderNotifier = class {
|
|
|
7554
7822
|
}
|
|
7555
7823
|
};
|
|
7556
7824
|
|
|
7825
|
+
// shared/dashboard_link.ts
|
|
7826
|
+
var PARAM = "to";
|
|
7827
|
+
function dashboardLink(appOrigin, route) {
|
|
7828
|
+
return `${appOrigin}/dashboard/?${PARAM}=${encodeURIComponent(route).replaceAll("%2F", "/")}`;
|
|
7829
|
+
}
|
|
7830
|
+
|
|
7557
7831
|
// src/notify/compose.ts
|
|
7558
7832
|
function when(ms) {
|
|
7559
7833
|
return `${new Date(ms).toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
@@ -7562,7 +7836,7 @@ function minutes2(ms) {
|
|
|
7562
7836
|
return Math.round(ms / 6e4);
|
|
7563
7837
|
}
|
|
7564
7838
|
function sendLink(config, sendId) {
|
|
7565
|
-
return
|
|
7839
|
+
return dashboardLink(config.appOrigin, `/sent/${encodeURIComponent(sendId)}`);
|
|
7566
7840
|
}
|
|
7567
7841
|
function message(subject, paragraphs, link) {
|
|
7568
7842
|
const text = `${paragraphs.join("\n\n")}
|
|
@@ -7640,7 +7914,7 @@ function sampleNotification(config) {
|
|
|
7640
7914
|
"This is a test of the notifications that tell you when a send goes out, and right away if a send runs into a problem.",
|
|
7641
7915
|
"If it arrived, the channel works; nothing else was sent."
|
|
7642
7916
|
],
|
|
7643
|
-
|
|
7917
|
+
dashboardLink(config.appOrigin, "/settings")
|
|
7644
7918
|
);
|
|
7645
7919
|
}
|
|
7646
7920
|
|
|
@@ -7779,7 +8053,7 @@ function publicationView(settings, cfg) {
|
|
|
7779
8053
|
name: p.name,
|
|
7780
8054
|
tagline: p.tagline,
|
|
7781
8055
|
address: p.address,
|
|
7782
|
-
logoUrl:
|
|
8056
|
+
logoUrl: publicationLogoUrl(cfg.mediaPublicBase, p.logo)
|
|
7783
8057
|
};
|
|
7784
8058
|
}
|
|
7785
8059
|
function settingsView(settings, cfg) {
|
|
@@ -7901,14 +8175,15 @@ async function uploadLogo(c) {
|
|
|
7901
8175
|
throw badRequest("logo must be 512 KB or smaller");
|
|
7902
8176
|
}
|
|
7903
8177
|
const version = Date.now();
|
|
8178
|
+
const key = newLogoKey(version);
|
|
7904
8179
|
const ack = readAckQuery(c);
|
|
7905
8180
|
const { settings, remade } = await saveSettingsRemaking(
|
|
7906
8181
|
c.env,
|
|
7907
8182
|
c.config,
|
|
7908
|
-
(current) => withPublicationLogo(current, { version, contentType: type }),
|
|
8183
|
+
(current) => withPublicationLogo(current, { version, contentType: type, key }),
|
|
7909
8184
|
ack,
|
|
7910
8185
|
async () => {
|
|
7911
|
-
await c.env.MEDIA.put(
|
|
8186
|
+
await c.env.MEDIA.put(key, bytes, { httpMetadata: { contentType: type } });
|
|
7912
8187
|
}
|
|
7913
8188
|
);
|
|
7914
8189
|
const body = { settings: settingsView(settings, c.config), remade };
|
|
@@ -7921,7 +8196,10 @@ async function deleteLogo(c) {
|
|
|
7921
8196
|
c.config,
|
|
7922
8197
|
(current) => withPublicationLogo(current, null),
|
|
7923
8198
|
ack,
|
|
7924
|
-
|
|
8199
|
+
// The bytes stay: emails already sent, and their archive pages, still show them (I3).
|
|
8200
|
+
// Removing the logo takes it out of what renders from now on, nothing more.
|
|
8201
|
+
async () => {
|
|
8202
|
+
}
|
|
7925
8203
|
);
|
|
7926
8204
|
const body = { settings: settingsView(settings, c.config), remade };
|
|
7927
8205
|
return json(body);
|
|
@@ -8112,7 +8390,7 @@ function createRouter({
|
|
|
8112
8390
|
const devOnly = (...defs) => devMode ? defs : [];
|
|
8113
8391
|
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)}`;
|
|
8114
8392
|
const conditionsText = `\`conditions\` is what is wrong with the send, or worth knowing, now (SPEC \xA78, \xA712), derived by the server from the send and the same on every route, most severe first: \`missed\` (still scheduled ${MISSED_THRESHOLD_MS / 6e4} minutes past its fire time: the sweep is not running), \`stuck\` (still sending ${STUCK_THRESHOLD_MS / 6e4} minutes after it started, whatever the cause), \`wedged\` (\`count\` recipients whose delivery is unknown, settled by Resolve), \`refused\` (the provider refusing the account, with its \`cause\`, its own words as \`error\`, the \`advice\` for the fix, which is outside this API, and \`retry_at\`; the send resumes on its own once the account is fixed, having consumed no one), \`provider_unavailable\` (an outage or a rate limit, retried on its own at \`retry_at\`), \`bounce_spike\` (a send finished within ${BOUNCE_SPIKE_RECENT_MS / 864e5} days whose confirmed bounces reached ${BOUNCE_SPIKE_RATE * 100}% of its audience at fire, at least ${BOUNCE_SPIKE_MIN}; \`bounced\` and \`rate\`), and \`remade\` (a template or identity change re-made the scheduled email after its last test, \`tested_at\`; a test of the post clears it). 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.`;
|
|
8115
|
-
const actionText =
|
|
8393
|
+
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.";
|
|
8116
8394
|
const sendExample = (over = {}) => ({
|
|
8117
8395
|
id: "s_xyz789",
|
|
8118
8396
|
post_id: "p_abc123",
|
|
@@ -8234,7 +8512,7 @@ function createRouter({
|
|
|
8234
8512
|
access: "public",
|
|
8235
8513
|
resource: "dev",
|
|
8236
8514
|
summary: "Mint an admin token (local dev only).",
|
|
8237
|
-
description: "Public because it's how a local client gets the token every admin route requires. Registered only on a local instance.",
|
|
8515
|
+
description: "Public because it's how a local client gets the token every admin route requires. Registered only on a local instance. When `kestrel dev` started the server, the answer also carries `runId`, the ID it recorded for this run, which `kestrel seed` and `kestrel reset` check before wiping the database.",
|
|
8238
8516
|
query: [
|
|
8239
8517
|
{
|
|
8240
8518
|
name: "kind",
|
|
@@ -8332,7 +8610,7 @@ function createRouter({
|
|
|
8332
8610
|
accepts: FORM_UPLOAD,
|
|
8333
8611
|
resource: "settings",
|
|
8334
8612
|
summary: "Upload the publication logo (multipart `file`); served publicly via /media.",
|
|
8335
|
-
description: "The logo is part of the identity, so when the template renders it and sends are scheduled this re-makes their emails under the same rule as `PUT /api/settings`, acknowledged as `?remake=` (comma-separated send ids) since the request carries no JSON body; the bytes are written only once the refusals are ruled out.",
|
|
8613
|
+
description: "The logo is part of the identity, so when the template renders it and sends are scheduled this re-makes their emails under the same rule as `PUT /api/settings`, acknowledged as `?remake=` (comma-separated send ids) since the request carries no JSON body; the bytes are written only once the refusals are ruled out. The image is stored as sent: every surface shows the logo as a square, and an email stretches one that isn't, so send a square image at least 256 \xD7 256 px (the editor crops and scales to that before uploading).",
|
|
8336
8614
|
handler: uploadLogo
|
|
8337
8615
|
},
|
|
8338
8616
|
{
|
|
@@ -8341,7 +8619,7 @@ function createRouter({
|
|
|
8341
8619
|
access: "admin",
|
|
8342
8620
|
resource: "settings",
|
|
8343
8621
|
summary: "Remove the publication logo.",
|
|
8344
|
-
description: "Under the same re-make rule as `PUT /api/settings` when the template renders the logo and sends are scheduled: acknowledged as `?remake=` (comma-separated send ids), refused inside the minimum lead.",
|
|
8622
|
+
description: "Under the same re-make rule as `PUT /api/settings` when the template renders the logo and sends are scheduled: acknowledged as `?remake=` (comma-separated send ids), refused inside the minimum lead. The image itself stays served, since emails already sent still show it (I3); only what renders from then on loses it.",
|
|
8345
8623
|
handler: deleteLogo
|
|
8346
8624
|
},
|
|
8347
8625
|
{
|
|
@@ -8371,6 +8649,15 @@ function createRouter({
|
|
|
8371
8649
|
example: { response: { to: "you@example.com", channel: "cloudflare" } },
|
|
8372
8650
|
handler: notificationTest
|
|
8373
8651
|
},
|
|
8652
|
+
{
|
|
8653
|
+
method: "GET",
|
|
8654
|
+
path: "/api/reader-pages/:journey/:state",
|
|
8655
|
+
access: "admin",
|
|
8656
|
+
resource: "settings",
|
|
8657
|
+
summary: "A page a reader can land on, as a standalone HTML page: Settings' preview of it (SPEC \xA75).",
|
|
8658
|
+
description: "`journey` is `subscribe` (`form`, `check-inbox`, `not-an-email`), `confirm` (`ready`, `confirmed`, `expired`, `invalid`), `unsubscribe` (`ask`, `done`, `invalid`), or `reading` (`landing`, `archive`, `post`, `landing-empty`, `archive-empty`, `not-found`), naming the `state`. Rendered by the code that serves the real page, with the saved publication identity and a sample address and sample posts in place of a reader's own (the `-empty` pages show the landing page and archive before any post is sent), and nothing marking it as a preview, since Settings frames it as one; the post page renders the sample post through the saved template. Always 200, whatever status the real page carries; an unknown pair is a 404. A preview changes nothing: its forms submit nowhere (`form-action 'none'`).",
|
|
8659
|
+
handler: readerPagePreview
|
|
8660
|
+
},
|
|
8374
8661
|
// --- posts + revisions (authed) ---
|
|
8375
8662
|
{
|
|
8376
8663
|
method: "POST",
|
|
@@ -8378,11 +8665,11 @@ function createRouter({
|
|
|
8378
8665
|
access: "admin",
|
|
8379
8666
|
accepts: JSON_BODY,
|
|
8380
8667
|
resource: "posts",
|
|
8381
|
-
summary: "Create a draft post.",
|
|
8668
|
+
summary: "Create a draft post. Its slug follows the subject (`slug_auto: true`) unless you send a `slug`, which sets it by hand.",
|
|
8382
8669
|
example: {
|
|
8383
8670
|
request: { subject: "Hello, world", markdown: "# Hello\n\nWelcome." },
|
|
8384
8671
|
response: {
|
|
8385
|
-
post: { id: "p_abc123", status: "draft", slug: "hello-world" },
|
|
8672
|
+
post: { id: "p_abc123", status: "draft", slug: "hello-world", slug_auto: true },
|
|
8386
8673
|
revision_id: "r_1"
|
|
8387
8674
|
}
|
|
8388
8675
|
},
|
|
@@ -8431,15 +8718,22 @@ function createRouter({
|
|
|
8431
8718
|
accepts: JSON_BODY,
|
|
8432
8719
|
resource: "posts",
|
|
8433
8720
|
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`.",
|
|
8434
|
-
description: "Only a draft is editable; a scheduled post is soft-locked until its schedule is canceled.",
|
|
8721
|
+
description: "Only a draft is editable; a scheduled post is soft-locked until its schedule is canceled. While the post's slug follows the subject (`slug_auto: true`), every save derives it from the subject again and numbers it if another post holds it, so take the slug from the answer. Sending `slug` sets it by hand and turns `slug_auto` off; `slug_auto: true` has it follow the subject again. Sending both is a 400 naming `slug`.",
|
|
8435
8722
|
example: {
|
|
8436
8723
|
request: {
|
|
8437
|
-
subject: "Hello, world",
|
|
8438
|
-
slug: "hello-world",
|
|
8724
|
+
subject: "Hello again, world",
|
|
8439
8725
|
markdown: "# Hello\n\nEdited.",
|
|
8440
8726
|
base_revision: "r_1"
|
|
8441
8727
|
},
|
|
8442
|
-
response: {
|
|
8728
|
+
response: {
|
|
8729
|
+
post: {
|
|
8730
|
+
id: "p_abc123",
|
|
8731
|
+
current_revision: "r_2",
|
|
8732
|
+
status: "draft",
|
|
8733
|
+
slug: "hello-again-world",
|
|
8734
|
+
slug_auto: true
|
|
8735
|
+
}
|
|
8736
|
+
}
|
|
8443
8737
|
},
|
|
8444
8738
|
handler: updatePost2
|
|
8445
8739
|
},
|
|
@@ -8508,10 +8802,19 @@ function createRouter({
|
|
|
8508
8802
|
path: "/api/posts/:id/preview",
|
|
8509
8803
|
access: "admin",
|
|
8510
8804
|
resource: "posts",
|
|
8511
|
-
summary: "The rendered email as a standalone HTML page (editor preview
|
|
8805
|
+
summary: "The rendered email as a standalone HTML page (the editor's preview).",
|
|
8512
8806
|
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).",
|
|
8513
8807
|
handler: previewPage
|
|
8514
8808
|
},
|
|
8809
|
+
{
|
|
8810
|
+
method: "GET",
|
|
8811
|
+
path: "/api/posts/:id/web",
|
|
8812
|
+
access: "admin",
|
|
8813
|
+
resource: "posts",
|
|
8814
|
+
summary: "The post's web version as a standalone HTML page: what its public post page will show.",
|
|
8815
|
+
description: "Built from the same copy as the preview (a draft's live render, the frozen copy once scheduled, the record once sent) by the post page's own code: the email-only regions left out, the masthead and display font added (SPEC \xA75). Once the send starts it matches the public post page, less the local-dev dashboard link.",
|
|
8816
|
+
handler: webPage
|
|
8817
|
+
},
|
|
8515
8818
|
{
|
|
8516
8819
|
method: "POST",
|
|
8517
8820
|
path: "/api/posts/:id/test",
|
|
@@ -8688,7 +8991,7 @@ function createRouter({
|
|
|
8688
8991
|
access: "admin",
|
|
8689
8992
|
resource: "sends",
|
|
8690
8993
|
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).",
|
|
8691
|
-
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.
|
|
8994
|
+
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.",
|
|
8692
8995
|
example: {
|
|
8693
8996
|
response: {
|
|
8694
8997
|
send: sendExample(),
|
|
@@ -8809,10 +9112,10 @@ function createRouter({
|
|
|
8809
9112
|
access: "admin",
|
|
8810
9113
|
accepts: JSON_BODY,
|
|
8811
9114
|
resource: "sends",
|
|
8812
|
-
summary: "Resolve a send wedged on ambiguous (dispatched) deliveries; body {resolution: 'unsent'|'accepted'
|
|
8813
|
-
description: "On a non-idempotent provider a mid-batch transport error leaves recipients `dispatched` \u2014 the loop won't blind-retry them (I4), so the send can't reach its completion gate. This adjudicates those rows: 'unsent' (assume not sent; the address is picked up by the next post) or 'accepted' (assume sent, operator-confirmed), then completes the send. Never re-mails an already-accepted recipient. Offered in `actions` exactly while the send is wedged. Refusals: 409 `run_in_progress` while a run holds the send (its in-flight rows may still get the provider's answer, so try again a moment later), 409 `not_wedged` when the send is not wedged (nothing ambiguous, recipients still to hand off, or a run yet to look at them)
|
|
9115
|
+
summary: "Resolve a send wedged on ambiguous (dispatched) deliveries; body {resolution: 'unsent'|'accepted'}.",
|
|
9116
|
+
description: "On a non-idempotent provider a mid-batch transport error leaves recipients `dispatched` \u2014 the loop won't blind-retry them (I4), so the send can't reach its completion gate. This adjudicates those rows: 'unsent' (assume not sent; the address is picked up by the next post) or 'accepted' (assume sent, operator-confirmed), then completes the send. Never re-mails an already-accepted recipient. Offered in `actions` exactly while the send is wedged. Refusals: 409 `run_in_progress` while a run holds the send (its in-flight rows may still get the provider's answer, so try again a moment later), 409 `not_wedged` when the send is not wedged (nothing ambiguous, recipients still to hand off, or a run yet to look at them) and when another Resolve has settled it. Once a send is wedged no run touches it, so the recipients Resolve settles are the ones the send showed; an `expected_count` in the body is ignored. " + actionText,
|
|
8814
9117
|
example: {
|
|
8815
|
-
request: { resolution: "unsent"
|
|
9118
|
+
request: { resolution: "unsent" },
|
|
8816
9119
|
response: {
|
|
8817
9120
|
send: sendExample({ status: "sent", phase: "settling", completed_at: 176846762e4 }),
|
|
8818
9121
|
cursor: "58.1768467620000",
|
|
@@ -8984,12 +9287,15 @@ function createRouter({
|
|
|
8984
9287
|
path: "/unsubscribe",
|
|
8985
9288
|
access: "public",
|
|
8986
9289
|
resource: "subscriptions",
|
|
8987
|
-
summary: "Unsubscribe landing page (`?token
|
|
9290
|
+
summary: "Unsubscribe landing page (`?token=`, or `?test=1` on a test email or a preview).",
|
|
8988
9291
|
query: [
|
|
8989
9292
|
{
|
|
8990
9293
|
name: "token",
|
|
8991
|
-
description: "The subscriber's unsubscribe token, from any delivered email's link."
|
|
8992
|
-
|
|
9294
|
+
description: "The subscriber's unsubscribe token, from any delivered email's link."
|
|
9295
|
+
},
|
|
9296
|
+
{
|
|
9297
|
+
name: "test",
|
|
9298
|
+
description: "`1` on the link of a test email or a preview, which carries no token: the page a subscriber sees, marked as a test and showing a sample address, whose button posts back with the marker. It unsubscribes no one. A token, when present, wins."
|
|
8993
9299
|
}
|
|
8994
9300
|
],
|
|
8995
9301
|
handler: unsubscribeLanding
|
|
@@ -8999,12 +9305,15 @@ function createRouter({
|
|
|
8999
9305
|
path: "/unsubscribe",
|
|
9000
9306
|
access: "public",
|
|
9001
9307
|
resource: "subscriptions",
|
|
9002
|
-
summary: "Process a one-click / form unsubscribe (`?token
|
|
9308
|
+
summary: "Process a one-click / form unsubscribe (`?token=`, or `?test=1` on a test email or a preview).",
|
|
9003
9309
|
query: [
|
|
9004
9310
|
{
|
|
9005
9311
|
name: "token",
|
|
9006
|
-
description: "The subscriber's unsubscribe token, from any delivered email's link."
|
|
9007
|
-
|
|
9312
|
+
description: "The subscriber's unsubscribe token, from any delivered email's link."
|
|
9313
|
+
},
|
|
9314
|
+
{
|
|
9315
|
+
name: "test",
|
|
9316
|
+
description: "`1` on the link of a test email or a preview, which carries no token: answers 200 (a form gets the page a subscriber sees once unsubscribed, marked as a test and showing a sample address) and unsubscribes no one. A token, when present, wins."
|
|
9008
9317
|
}
|
|
9009
9318
|
],
|
|
9010
9319
|
handler: unsubscribe
|
|
@@ -9030,7 +9339,7 @@ function createRouter({
|
|
|
9030
9339
|
path: `${archiveBasePath}{/}?`,
|
|
9031
9340
|
access: "public",
|
|
9032
9341
|
resource: "archive",
|
|
9033
|
-
summary: "The public archive index: every
|
|
9342
|
+
summary: "The public archive index: every published post (its send has started), newest first.",
|
|
9034
9343
|
handler: archiveIndex
|
|
9035
9344
|
},
|
|
9036
9345
|
{
|
|
@@ -9038,7 +9347,7 @@ function createRouter({
|
|
|
9038
9347
|
path: `${archiveBasePath}/:slug`,
|
|
9039
9348
|
access: "public",
|
|
9040
9349
|
resource: "archive",
|
|
9041
|
-
summary: "A
|
|
9350
|
+
summary: "A published post's archive page / view-in-browser (I3), from the moment its send starts.",
|
|
9042
9351
|
handler: archivePage
|
|
9043
9352
|
},
|
|
9044
9353
|
// --- media bytes (public; readers + archive load these unauthenticated) ---
|