@kurtbruns/kestrel 1.9.0 → 1.11.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/README.md +19 -27
  3. package/cli/context.mjs +5 -1
  4. package/dist/public/dashboard/app.js +367 -256
  5. package/dist/public/dashboard/app.js.map +4 -4
  6. package/dist/public/dashboard/index.html +2 -2
  7. package/dist/public/dashboard/styles.css +493 -24
  8. package/dist/worker/index.js +2658 -2065
  9. package/docs/README.md +4 -5
  10. package/docs/get-started/02-deploy.md +12 -5
  11. package/docs/get-started/03-access.md +2 -0
  12. package/docs/get-started/04-ses.md +57 -17
  13. package/docs/get-started/05-verify.md +22 -8
  14. package/docs/get-started/06-go-live.md +13 -53
  15. package/docs/guides/01-connect-claude.md +7 -3
  16. package/docs/guides/03-notifications.md +11 -11
  17. package/docs/guides/09-as-your-list-grows.md +62 -0
  18. package/docs/publishing/01-your-list.md +157 -0
  19. package/docs/publishing/02-sending-rules.md +35 -0
  20. package/docs/publishing/03-name-your-publication.md +20 -0
  21. package/docs/publishing/04-write-a-post.md +55 -0
  22. package/docs/publishing/06-troubleshooting.md +74 -0
  23. package/docs/publishing/07-working-with-claude.md +29 -0
  24. package/docs/publishing/09-share-your-subscribe-link.md +25 -0
  25. package/docs/publishing/10-check-your-setup.md +30 -0
  26. package/docs/publishing/11-email-template.md +34 -0
  27. package/docs/publishing/README.md +27 -0
  28. package/docs/reference/01-configuration.md +6 -6
  29. package/docs/reference/02-sending-domain-dns.md +4 -2
  30. package/package.json +2 -1
  31. package/template/claude/CLAUDE.md +5 -2
  32. package/template/claude/skills/migrate-list/SKILL.md +56 -0
  33. package/docs/guides/08-try-locally.md +0 -108
  34. package/docs/reference/03-import-format.md +0 -103
@@ -0,0 +1,157 @@
1
+ # Bring your existing list
2
+
3
+ Moving to Kestrel from another newsletter service means bringing your subscribers with you, along with the consent they gave there. This guide explains how to export your list from the old service, convert it to Kestrel's format, and import it under **Subscribers → Import**. It also covers what carries over, the limits on an import, and taking your list out again. Kestrel shows you what an import will do before anything changes, and an import never emails your subscribers.
4
+
5
+ **The first decision is whether to upgrade to Cloudflare's Workers Paid plan.** With more than about 5,000 subscribers, it's recommended to upgrade before you import. The free plan caps database writes per day, and a large import plus a send can use them up. When that happens, the app stops working until midnight UTC. Workers Paid is $5 a month and has no daily cap, and [As your list grows](../guides/09-as-your-list-grows.md) walks through the upgrade.
6
+
7
+ If you're starting without a list, skip this step and go on to [Write and send a post](04-write-a-post.md).
8
+
9
+ ## 1. Export from your old service
10
+
11
+ Export everyone your old service knows about, not only your current subscribers. Unsubscribes and bounces matter too: once imported, Kestrel never mails someone who left or an address that fails. Include any consent details the export offers, such as signup dates, IP addresses, or form names. Kestrel keeps them with each subscriber as evidence of their consent.
12
+
13
+ ## 2. Convert it to Kestrel's format
14
+
15
+ Every service's export is different. Kestrel never guesses what one means, because a wrong guess about who agreed to be emailed can't be taken back. So you convert the export first, to one of two kinds of file:
16
+
17
+ - **A CSV in Kestrel's format (preferred).** One row per address, with its status, how and where consent was collected, and any bounce or complaint. **Download example file (CSV)** on the import screen shows each case. Save your file as CSV UTF-8.
18
+ - **A plain list (`.txt`).** One address per line, all of one kind: subscribed, unsubscribed, or bounced. Nothing else carries over, so use it only when addresses are all you have.
19
+
20
+ A CSV in Kestrel's format looks like this, with the five columns that decide what happens to each row:
21
+
22
+ ```
23
+ email,status,opt_in,origin,subscribed_at
24
+ ada@example.com,subscribed,double,sendy,2024-03-01T09:30:00Z
25
+ grace@example.com,subscribed,single,mailchimp,
26
+ edsger@example.com,unsubscribed,unknown,unknown,
27
+ ```
28
+
29
+ [The file format](01-your-list.md#the-file-format), below, describes every column. If you use Claude Code with your instance, it can do the conversion for you, as [Working with Claude](07-working-with-claude.md) describes.
30
+
31
+ ## 3. Import it
32
+
33
+ Under **Subscribers → Import**, choose your file. Nothing changes yet: Kestrel plans the import and shows what it would do to every row. You see who is added, who is recorded as having left, who is suppressed, and which rows are left out. You can download the rows that won't be imported as subscribers, to check them against your file.
34
+
35
+ **Import** then applies the plan, all of it or none of it. Before it does, you confirm two things when they apply:
36
+
37
+ - **That everyone you're importing as subscribed opted in** to receive your publication, and the list isn't purchased, rented, or scraped. Kestrel keeps your attestation with the import.
38
+ - **Each scheduled post that would grow.** A post goes to your list as it stands when it sends, so the people you import would receive it.
39
+
40
+ ## What carries over
41
+
42
+ Asking your whole list to confirm again would lose readers who already said yes. So an import carries over the consent each person gave on your old service, and your word for it. Imported subscribers are **Confirmed** and receive your posts, and **How they joined** shows where they came from, such as "Imported · Sendy, single opt-in".
43
+
44
+ A few things hold whatever the file says:
45
+
46
+ - **Someone who unsubscribed here stays unsubscribed,** until they confirm again themselves.
47
+ - **A bounce, a block, or an erasure here always wins.** An import never lifts one.
48
+ - **A reader's newer choice stands.** Someone who confirmed here after your old service says they left stays subscribed.
49
+
50
+ ## Limits
51
+
52
+ - **Rows.** Up to 10,000 in one import, or 1,000 on Cloudflare's free Workers plan. The import screen says which applies. Split a larger list into several files, and import them one at a time.
53
+ - **Daily writes.** On the free Workers plan, an import or a large send warns you when it would use most of the day's database writes. Neither warning stops anything. [Limits in detail](01-your-list.md#limits-in-detail) gives the numbers.
54
+
55
+ ## Taking your list out
56
+
57
+ **Subscribers → Export** downloads your whole list as one CSV in the same format. It holds every subscriber, how they joined, and every bounced, complained, blocked, or erased address. Keep it as a backup, or import it into another Kestrel to carry the list over.
58
+
59
+ ## The file format
60
+
61
+ This section defines `kestrel-subscribers/1` in full, for whoever converts a file, by hand or with Claude. The API serves the same definition at `GET /api/imports/format`, generated from the one the importer reads with. It serves the example files at `GET /api/imports/template.csv` and `GET /api/imports/template.txt`. A file's name decides its kind, `.csv` or `.txt`, and anything else is refused.
62
+
63
+ ### The CSV
64
+
65
+ The file is a CSV saved as UTF-8 (in a spreadsheet, **CSV UTF-8**). A header row names the columns below, in any order, then each row is one address. A value with a comma, a quote, or a line break in it is wrapped in double quotes. Each quote inside it is written twice, as spreadsheets write it (RFC 4180). Column names are written exactly, in lowercase. A row records what the source claims, never what Kestrel will do: the import decides that, under the rules further down.
66
+
67
+ | Column | Required | Values | Meaning |
68
+ | --- | --- | --- | --- |
69
+ | `email` | yes | an email address | The address, trimmed and lowercased before it is matched. An invalid one is still a row, counted as invalid and reported, never dropped. |
70
+ | `status` | yes | `subscribed`, `pending`, `unsubscribed`, `none` | The consent claim. `none` is a known address with no consent claim, present only to carry a suppression. |
71
+ | `opt_in` | yes | `double`, `single`, `unknown` | How consent was collected. `unknown` unless the source states it. |
72
+ | `origin` | yes | a short lowercase name | Where consent was first collected, as the file claims it: `sendy`, `mailchimp`, `kestrel`, or `unknown`. |
73
+ | `suppression` | no | `bounce`, `complaint`, `manual`, `erased` | A suppression on top of `status`: the address bounced, complained, was suppressed by hand, or was erased. |
74
+ | `subscribed_at` | no | an instant with its zone | When the person subscribed, if the source states it. |
75
+ | `confirmed_at` | no | an instant with its zone | When the person confirmed, if the source states it. |
76
+ | `unsubscribed_at` | no | an instant with its zone | When the person left, if the source states it. A reader who confirmed in Kestrel after it stays confirmed. |
77
+ | `suppressed_at` | no | an instant with its zone | When the address was suppressed, if the source states it. |
78
+ | `evidence` | no | a JSON object, at most 500 bytes | The source's consent-relevant columns, verbatim, such as a signup IP or a form name. Nothing reads it to decide anything: it is the proof of consent and the audit trail of a conversion. |
79
+ | `format` | no | `kestrel-subscribers/1` | The version the file is written in. A file without it is read as version 1. |
80
+
81
+ An instant is a UTC ISO-8601 time with its zone, such as `2024-03-01T09:30:00Z` or `2024-03-01T10:30:00+01:00`. A time without a zone is refused, never interpreted. An optional column may be left out, or left empty on a row.
82
+
83
+ Any other column is refused, naming it. A source's other columns belong in `evidence`, as JSON. A file whose header names none of these columns isn't in the format at all, and is refused as such.
84
+
85
+ The example file, `kestrel-import-example.csv`, shows each case: a double opt-in subscriber with evidence, a single opt-in one, someone who left, and a bounced address. Here are the same four with only the columns they use, since an optional column may be left out:
86
+
87
+ ```
88
+ email,status,opt_in,origin,suppression,unsubscribed_at,evidence
89
+ ada@example.com,subscribed,double,sendy,,,"{""signup_ip"":""203.0.113.7""}"
90
+ grace@example.com,subscribed,single,mailchimp,,,
91
+ edsger@example.com,unsubscribed,unknown,unknown,,2025-01-20T08:00:00Z,
92
+ bounced@example.com,none,unknown,unknown,bounce,,
93
+ ```
94
+
95
+ **Versions.** Adding an optional column keeps the version. Changing what a column means makes a new version. Kestrel reads every version it has published, and refuses one it doesn't know, by name.
96
+
97
+ ### The plain list
98
+
99
+ - One address per line. Each line is trimmed, blank lines are skipped, and CRLF line endings and a UTF-8 byte-order mark are accepted.
100
+ - A line that isn't a single valid address, such as `Name <ada@example.com>` or a typo, counts as invalid. It's listed among the rows left out. One bad line doesn't sink a list.
101
+ - The whole file is refused when it isn't a plain list at all. That's a file that isn't UTF-8 text, or has no valid address in it. It's also one with a comma, tab, or semicolon in a line, since that makes it a table, so a `.csv`.
102
+ - Its rows arrive with `origin: unknown` and `opt_in: unknown`.
103
+
104
+ ### Plan, then commit
105
+
106
+ An import happens in two steps. Planning shows what the import would do to every row, against your list as it stands. It changes nothing, so you can plan a file as often as you need. Committing applies the plan, all of it or none of it, and emails no subscriber.
107
+
108
+ The commit needs two things from you when they apply:
109
+
110
+ - **Your attestation,** when anyone would be confirmed: "Everyone I'm importing as subscribed opted in to receive [your publication]. This isn't a purchased, rented, or scraped list." It is kept with the import, with who gave it and when. An import that only unsubscribes or suppresses needs none.
111
+ - **Each send that would grow,** acknowledged by a person. A post goes to the list as it stands when it fires. So an import can add to the audience of a scheduled send, or of one that has just fired and not yet fixed its audience. Claude can attest for you, but can't acknowledge a growing send: you finish that import in the dashboard.
112
+
113
+ If another import is committed after you plan, plan again. If your list changes between the plan and the commit in any other way, such as a reader unsubscribing, the commit still goes ahead. Every write checks the list as it stands, and the answer reports any row that turned out differently.
114
+
115
+ ### What an import does to each address
116
+
117
+ Each row gets exactly one outcome. A row that both changes its status and adds a suppression reports its status outcome, and says it added a suppression too.
118
+
119
+ | Outcome | When |
120
+ | --- | --- |
121
+ | `create_confirmed` | `subscribed`, with no subscriber and no suppression in Kestrel, and not erased in the file: added as confirmed. |
122
+ | `confirm_pending` | `subscribed`, pending in Kestrel (never confirmed), and not erased in the file: confirmed, and its confirmation link no longer works. |
123
+ | `unchanged` | Already confirmed in Kestrel, or already in the state the row claims. A subscriber who joined through Kestrel keeps their own record. |
124
+ | `mark_unsubscribed` | `unsubscribed`, and confirmed or pending in Kestrel, with no confirmation in Kestrel newer than the row's `unsubscribed_at`: unsubscribed. |
125
+ | `create_unsubscribed` | `unsubscribed`, with no subscriber in Kestrel, and not erased in Kestrel or in the file: recorded as unsubscribed, so the list remembers they left. |
126
+ | `kept_confirmed_newer` | `unsubscribed`, but the reader confirmed in Kestrel after the source's `unsubscribed_at`: their newer action stands. |
127
+ | `kept_unsubscribed` | `subscribed`, but unsubscribed in Kestrel, or pending again after leaving until they confirm in Kestrel themselves. An unsubscribe is never reversed. |
128
+ | `blocked_by_suppression` | `subscribed`, but the address carries a suppression in Kestrel, a do-not-contact marker included: never added or confirmed. Also `unsubscribed`, when the address's suppression in Kestrel is the do-not-contact marker an erasure left. An address erased in the file gets this outcome too, when its row is `subscribed` or `unsubscribed` with no subscriber in Kestrel. So does one that's `subscribed` and pending in Kestrel, which stays pending. The file's `erased` suppression is still added. In each case no subscriber is recorded or confirmed, so an import never brings back an erased person's data. |
129
+ | `suppression_added` | The row carries a suppression and the address has none in Kestrel, or only a do-not-contact marker, and nothing else changes. Applied whatever the row's `status`. |
130
+ | `left_out` | `pending` or `none` with nothing to apply: counted, never confirmed. An import sends no email, so it can't start a confirmation either. |
131
+ | `invalid` | The address isn't valid. |
132
+ | `merged` | A repeat of an address earlier in the file, folded into it. |
133
+
134
+ A suppression already in Kestrel is never replaced, with one exception. A bounce, a complaint, or a manual suppression replaces a do-not-contact marker left by an erasure, so a known bad address still gets no confirmation. An imported bounce or complaint is kept apart from one Kestrel saw itself, so the list says where each came from.
135
+
136
+ Anyone can type an address into your subscribe form, which puts a reader who left back to pending until they confirm. An import treats such a reader as unsubscribed, so it never confirms someone who left in Kestrel.
137
+
138
+ **Repeats of an address** are folded into one row first. Its status is the first of unsubscribed, subscribed, pending, and none that any repeat claims. Its suppression is the first of erased, complaint, bounce, and manual. Its opt-in is the weakest claim. It keeps the earliest consent times and the latest unsubscribe and suppression times, and the origin and evidence of the row whose status won.
139
+
140
+ A subscriber an import confirmed is **confirmed**, like anyone who confirmed in Kestrel, and gets your posts. How they joined shows beside their state: imported, with the origin and opt-in method the file claimed, and the evidence. That holds even when the file says they confirmed through Kestrel itself. Only a confirmation Kestrel recorded reads as Kestrel's double opt-in, and a subscriber who later confirms in Kestrel becomes Kestrel's own.
141
+
142
+ ### Limits in detail
143
+
144
+ - **Rows:** up to 10,000 in one import. On the free Workers plan's limits it's 1,000, since that plan's processing time per request can't plan and commit more. [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) tells Kestrel which plan it's on.
145
+ - **Size:** an import request of at most 10 MB, and at most 500 bytes of `evidence` a row.
146
+ - **Daily database writes on the free Workers plan:** 100,000 written rows a day. Once they're spent, the app stops working until midnight UTC. Each new subscriber an import brings in writes 7 rows, and a send writes about 14 a recipient. So an import of 1,000 new subscribers uses about 7% of a day, and about ten of them use most of it.
147
+ - **The warnings:** an import's plan warns when it and the day's earlier imports, by UTC, would use more than 70% of the day's writes. It suggests importing the file the next day. A scheduled post warns when its send and that day's imports would use most of the day. With no imports, that's a send to more than 5,000 recipients. It suggests sending the next day, and its banner repeats the warning while it's scheduled. Neither warning stops anything.
148
+
149
+ ### Exporting
150
+
151
+ The export reads your whole list out in this format, a page at a time, at `GET /api/subscribers/export`. Each page holds up to 1,000 rows on the free Workers plan's limits, or 10,000 otherwise. Its `next` value asks for the page after it. Following `next` until it's `null` gives every address exactly once, even if the list changes while you read. An address added or removed partway through may or may not appear.
152
+
153
+ To make one CSV in this format, write the pages' rows under a single header. The dashboard's **Export** button, on the Subscribers screen, does this for you and downloads the whole list as one CSV. In the CSV, a value that starts with `=`, `+`, `-`, or `@` is written with an apostrophe before it. A spreadsheet then shows it as text rather than running it as a formula, and an import takes the apostrophe off again.
154
+
155
+ The export includes every subscriber with how they joined, their evidence, and their suppression. It also includes every address suppressed with no subscriber behind it, written as `status: none`. A confirmed subscriber is written as `subscribed`.
156
+
157
+ Importing an export into another Kestrel carries the list over: the same states and suppressions, with every confirmation arriving as imported, carrying its original evidence. Pending subscribers are left out on the way in, since an import sends no confirmation.
@@ -0,0 +1,35 @@
1
+ # Sending rules
2
+
3
+ Email law asks a few things of every newsletter. Kestrel handles the ones a sending app can, and the rest are yours, since they depend on what you write and whom you write to.
4
+
5
+ This page covers what Kestrel handles, what you handle, and how to act on a reader's request to leave or be deleted. It describes US law (CAN-SPAM) unless it says otherwise. It's general information, not legal advice. When your situation is unclear, ask a lawyer.
6
+
7
+ ## What Kestrel handles
8
+
9
+ - **Consent.** Kestrel mails only people who confirmed they want your newsletter. Someone who subscribes gets an email with a link, and only once they confirm do they receive posts. People you import come with your old service's record of their consent, and your word for it ([Bring your existing list](01-your-list.md#what-carries-over)).
10
+ - **Unsubscribing.** Every email has an unsubscribe link, and the one-click unsubscribe header that Gmail and Yahoo ask bulk senders for. It takes effect at once. US law allows up to 10 business days.
11
+ - **Bounces and complaints.** An address that bounces, or a reader who marks your email as spam, is never mailed again. So a bad address can't keep hurting your sending reputation.
12
+ - **Sender authentication.** Your emails are signed with DKIM for your own domain, and SPF and DMARC records in its DNS vouch for them. You set them up in [Connect Amazon SES](../get-started/04-ses.md), and mailbox providers now expect them.
13
+
14
+ ## What you handle
15
+
16
+ - **An honest From name and subject line.** The From name should say who the email is from, and the subject should match what's inside. The From name and address are set when Kestrel is deployed ([Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy)).
17
+ - **Marking ads as ads.** When an email is an advertisement, say so clearly. In a newsletter that's mostly writing, labeling a sponsor's section is enough.
18
+ - **A mailing address, when an email is commercial.** Most newsletters don't need one. [When to provide a mailing address](03-name-your-publication.md#when-to-provide-a-mailing-address) says when you do, beside where you add one.
19
+ - **Requests readers send you.** A reader who replies to a post writes to your From address. Replies reach you once that address forwards to an inbox you read, as [Connect Amazon SES](../get-started/04-ses.md#3-receive-replies-at-your-from-address) sets up. Kestrel never sees a reply, so carrying out what a reader asks is up to you.
20
+
21
+ ## When a reader asks to leave or to be deleted
22
+
23
+ Kestrel offers readers no form for deleting their data, so they ask you. In many places the law gives a reader that right. The European Union's GDPR, for example, may apply if you have readers there.
24
+
25
+ - **Say how to reach you.** Your privacy notice, wherever you publish it, should say how readers ask you about their data. Replying to any post works once replies reach your inbox.
26
+ - **To stop the email,** find a confirmed reader under **Subscribers**, and choose **Unsubscribe** from their ⋯ menu. It records that they asked, and they can rejoin by subscribing again. A reader who never confirmed gets no posts, so **Erase** is the choice for them.
27
+ - **To stop mailing someone on your own judgment,** not at their request, choose **Block** instead. It stops every email to the address whatever its status, and **Unblock** undoes it.
28
+ - **To delete their data,** choose **Erase** from the same menu. It removes the subscriber and how they joined, and can't be undone. Past send records show an erased recipient in their place. Kestrel keeps only a do-not-contact marker, or the bounce, complaint, or block the address already had, so it's never mailed again. The reader can still rejoin by subscribing and confirming themselves.
29
+ - **Act promptly.** Under the [GDPR](https://eur-lex.europa.eu/eli/reg/2016/679/oj), you answer within a month in most cases (Article 12). The right to have data deleted is Article 17, and Article 13 asks you to tell readers about it when they sign up.
30
+
31
+ ## Outside the US
32
+
33
+ Other countries have their own rules, and they often ask more. Canada's anti-spam law (CASL) usually counts more email as commercial, and asks for a mailing address in it ([Government of Canada guidance](https://ised-isde.canada.ca/site/canada-anti-spam-legislation/en)). The GDPR covers how you keep readers' data. If many of your readers live outside the US, check the rules where they live.
34
+
35
+ The FTC's [CAN-SPAM compliance guide](https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business) is the plain-language source for the US rules on this page.
@@ -0,0 +1,20 @@
1
+ # Name your publication
2
+
3
+ Give your newsletter a name and a look before your first post. Set the name, tagline, and logo under **Settings → Publication identity**. They appear in every email your template prints them in, on the public pages readers see, and in the confirmation email new subscribers get.
4
+
5
+ All three are optional, and you can change them at any time. How they appear in each email comes from your email template, which you can [edit](11-email-template.md).
6
+
7
+ ## When to provide a mailing address
8
+
9
+ **If your newsletter is mostly your own writing, you probably don't need one. If an email is mainly selling something, add one.**
10
+
11
+ US law (CAN-SPAM) asks for a postal address in commercial email. The FTC [treats a newsletter people subscribed to](https://www.ftc.gov/sites/default/files/documents/federal_register_notices/definitions-and-implementation-under-can-spam-act-16-cfr-part-316/080521canspamact.pdf) as a relationship message instead, even with a few ads in it. An email whose main purpose is to sell, such as a launch, a sale, or a sponsor's promotion, is commercial. Canada's law (CASL) asks for an address more often ([Government of Canada guidance](https://ised-isde.canada.ca/site/canada-anti-spam-legislation/en)).
12
+
13
+ A P.O. box or a registered private mailbox counts, so you never have to publish your home address. To add one, choose **Add a mailing address** under **Settings → Publication identity**. The built-in templates print it in the email footer and keep it off your public pages.
14
+
15
+ This is general information, not legal advice. The FTC's [CAN-SPAM compliance guide](https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business) has the detail, and [Sending rules](02-sending-rules.md) covers the rest of what the law asks of a newsletter.
16
+
17
+ ## Who sets the From name and address
18
+
19
+ The From name and address your emails come from are set when Kestrel is deployed, so **Settings → Email sender** shows them read-only. To change them, follow [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy).
20
+
@@ -0,0 +1,55 @@
1
+ # Write and send a post
2
+
3
+ You write a post in Markdown, test it, and schedule it. Scheduling locks the post and makes the exact email that will go out. Then you send yourself a test of that copy, so the email you tested is the one your list receives.
4
+
5
+ **New post**, on the Dashboard or under **Drafts**, opens a draft.
6
+
7
+ ## Writing
8
+
9
+ Kestrel's editor is a Markdown editor. You write a post in Markdown, the way you would for a static site generator or with an AI assistant. Kestrel places it in your [email template](11-email-template.md). The same Markdown makes the email and the post's archive page, so you never format a post twice. **Preview** shows the email as it will look, and Kestrel saves as you type.
10
+
11
+ A few things to know as you write:
12
+
13
+ - **A blank line starts a new paragraph.** In an email, a single line break doesn't.
14
+ - **The opening lines** are the preview a mail client shows beside the subject.
15
+ - **Images** go in by paste or drop, and Kestrel inserts a reference by file name, such as `![cover.jpg](cover.jpg)`. Replace the words in brackets with a short description. PNG, JPEG, WebP, or GIF, up to 5 MB each.
16
+ - **The subject** sets the post's slug, the end of its permanent archive address, such as `newsletter.example.com/archive/spring-update`. The slug is fixed once you schedule.
17
+
18
+ ## Test it
19
+
20
+ **Send test email** sends the real email, through the same template and code as the send, to the addresses you name. **Settings → Default test recipients** fills them in for you. Nobody on your list gets anything, and a test's unsubscribe link unsubscribes no one.
21
+
22
+ Mail clients each draw email a little differently, so trust a test over **Preview**, especially for tables, code, and HTML. A test needs an email provider, which [Connect Amazon SES](../get-started/04-ses.md) sets up.
23
+
24
+ ## Schedule and send
25
+
26
+ Choose **Schedule** at the foot of the editor, pick a time under **Send at**, and confirm. The time must be at least the minimum lead away: five minutes, unless your deployment sets a different [`MIN_LEAD_SECONDS`](../reference/01-configuration.md#min_lead_seconds). Neither the dashboard nor Claude can shorten it. Times are to the minute, and one with seconds rounds up.
27
+
28
+ Scheduling makes the email once, from the post, your template, and your publication's details as they are at that moment. That frozen copy is what goes out, and it's also the post's page in your archive. So the post is locked, and **Preview** and **Send test email** show the frozen copy.
29
+
30
+ **Send yourself a test now.** It's the copy your list receives, and the time until the send is your window to catch a mistake, including one Claude made.
31
+
32
+ Changing your template, or a publication detail it shows, re-makes the email of every scheduled post, with the same content and send time. Kestrel asks before it saves, and each re-made post then asks you to send a new test. A change is refused while a post is within the minimum lead of its send time, and Kestrel says when to try again. Posts already sending or sent never change.
33
+
34
+ **Send now →**, under the time picker, is for a post you've already reviewed. It still waits out the window, scheduling the post for the minimum lead from now.
35
+
36
+ ### Changing your mind
37
+
38
+ **Cancel**, on the post's banner or its card under **Sent**, makes the post a draft again at once. Edit it, test again, and schedule it again. **Reschedule** moves the send time without unlocking the post, and your tests still count. You can do either until the send time.
39
+
40
+ ### Who receives it
41
+
42
+ A post goes to your list as it stands when it sends: every confirmed subscriber, except addresses that bounced, complained, or were blocked. Someone who confirms while it's scheduled receives it. Someone who unsubscribes before their email is handed to your provider doesn't, even partway through the send.
43
+
44
+ So a recipient count shown before the send is a snapshot. An import that adds subscribers while a post is scheduled adds them to its audience too, and asks you first ([Bring your existing list](01-your-list.md#3-import-it)).
45
+
46
+ ### Warnings when you schedule
47
+
48
+ - **No email provider connected.** The Schedule dialog can't schedule until one is connected ([Connect Amazon SES](../get-started/04-ses.md)). If the provider goes after a post is scheduled, the post is held, stays cancelable, and goes out once one is connected.
49
+ - **Daily database writes.** On Cloudflare's free Workers plan, a large send may use up the day's writes. The dialog warns you without stopping you, as [Bring your existing list](01-your-list.md#limits) explains.
50
+
51
+ ### When it sends
52
+
53
+ At the send time, the post moves to its send page under **Sent**, where you can watch it go out. From then it can't be edited, canceled, or moved. Its archive page goes up at the same moment, showing exactly the copy that was sent, so every email's "view in browser" link works. Whatever interrupts a send, such as your provider's daily quota running out, it waits and resumes where it stopped. No reader gets the same post twice.
54
+
55
+ If a send needs your attention, the send page and the dashboard say so. [Troubleshooting](06-troubleshooting.md) covers each case.
@@ -0,0 +1,74 @@
1
+ # Troubleshooting
2
+
3
+ When a send needs you, its page and the dashboard's health block say so in a red or amber line. Red needs you to act, and amber is worth a look. Find the line's words below for what it means and what to do.
4
+
5
+ **Set a notifications address, so a problem reaches you without your watching.** Set it under **Settings → Notifications**, in **Send notifications to**. With none set, Kestrel emails you nothing. [Notifications](06-troubleshooting.md#notifications), below, lists what you're told.
6
+
7
+ ## A send is waiting or held
8
+
9
+ A problem with your email provider never marks anyone unsent. The send waits where it is, and carries on from there once the problem clears.
10
+
11
+ - **"The provider is refusing the account".** Your provider won't take mail from your account, and its own words follow, with the fix they point to. The fix is almost always in your Amazon SES account or Kestrel's deployment, and the message names it. A spent daily sending quota frees up within a day, and you can ask AWS for a higher one, as [As your list grows](../guides/09-as-your-list-grows.md) describes.
12
+ - **"No email provider is connected".** The send is held until one is connected ([Connect Amazon SES](../get-started/04-ses.md)), and goes out once it is.
13
+ - **"The provider is unavailable".** An outage or a rate limit on the provider's side. The send retries on its own, so there's nothing to do.
14
+ - **"Still sending … after it started".** A send is taking longer than expected, and may be retrying. If the line stays, read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes.
15
+ - **"The fire time passed … and the send has not started".** The Cloudflare Cron Trigger that starts sends on time isn't running. The send goes out as soon as it runs again. Check the Worker's trigger, as [`triggers.crons`](../reference/01-configuration.md#triggerscrons) describes.
16
+ - **"The provider never answered for …".** Rarely, Kestrel can't tell whether your provider took some emails, and sending again could mail those readers twice. **Resolve…** on the send's page asks you to decide. **Assume not sent** is the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it.
17
+
18
+ ## Before a send
19
+
20
+ - **"The template or publication details changed this email's look after your last test".** Send a new test of the post, since your last one no longer shows what goes out.
21
+ - **The daily database writes warning.** On Cloudflare's free Workers plan, a large send may use up the day's writes, and then the app stops working until midnight UTC. [Bring your existing list](01-your-list.md#limits) explains the limit, and when to upgrade to Workers Paid.
22
+
23
+ ## Reading a send's record
24
+
25
+ A send's page becomes its permanent record once it finishes. Its counts cover everyone the send went to:
26
+
27
+ | Count | What it means |
28
+ | --- | --- |
29
+ | **Delivered** | Your provider confirmed the email reached the reader's mail server. |
30
+ | **Bounced** | The receiving server turned the email away. |
31
+ | **Complained** | The reader marked the email as spam. |
32
+ | **Unsent** | Kestrel couldn't hand the email to your provider. |
33
+
34
+ Delivered always means confirmed, not just accepted by your provider. Confirmations can lag by days, so counts keep rising after a send finishes. Some emails are never confirmed at all, so a send can read **Settling** for good, which is normal.
35
+
36
+ **Unsent recipients** are still on your list, and get your next post. A recipient is unsent when their own email kept failing, with the provider's words on the row, or when you chose **Assume not sent**.
37
+
38
+ ## Bounces and complaints
39
+
40
+ Kestrel acts on these for you, so a bad address can't keep hurting your sending reputation:
41
+
42
+ - **A hard bounce** means the address can't receive mail. Kestrel never mails it again.
43
+ - **A soft bounce** is temporary, such as a full mailbox. The address stays on your list.
44
+ - **A complaint** means the reader marked your email as spam. Kestrel never mails them again.
45
+
46
+ Under **Subscribers**, a suppressed address keeps its status and carries a label saying why. When a recent send's bounces reach about 5% of its recipients, the dashboard warns you. That's about the rate at which email providers start to review a sender, so check the addresses on its record.
47
+
48
+ ### Lifting a suppression
49
+
50
+ Lift one only when you know the address works again, such as a reader who tells you their mailbox is fixed. Mailing an address that keeps failing can hurt every send after it. **Unblock**, in a subscriber's ⋯ menu, lifts a block. The dashboard can't lift a bounce or a complaint, but [Claude](07-working-with-claude.md) can, when you ask. Past records keep the bounce or complaint where it happened.
51
+
52
+ ## An import is refused
53
+
54
+ Kestrel refuses a file it can't read with certainty, and says what's wrong and where. Nothing on your list changes.
55
+
56
+ - **Not in the format.** The file isn't a Kestrel CSV or a plain list. Convert it first, with Claude or by hand.
57
+ - **A column or value the format doesn't take,** named with its row and column. Fix it in the file, or move a column of your old service's into `evidence`.
58
+ - **Too many rows** for one import. Split the file.
59
+ - **Too large.** An import takes up to 10 MB.
60
+
61
+ [The file format](01-your-list.md#the-file-format) describes what each column takes.
62
+
63
+ ## Notifications
64
+
65
+ With an address set, Kestrel emails you:
66
+
67
+ - when a send finishes, with how many emails were accepted, unsent, and skipped
68
+ - when your provider refuses your account, or no provider is connected
69
+ - when a send stays in progress too long, waits for you to resolve it, or misses its time
70
+ - when Claude imports a list for you
71
+
72
+ Each links to the page it's about, and is sent once while the problem lasts. A bounce spike shows on the dashboard but isn't emailed. **Send a test notification** checks that one arrives.
73
+
74
+ By default, notifications go through your newsletter's own email provider. So when that provider refuses your account, the notification about it can't reach you either. You can send them another way, as [Notifications through Cloudflare's email](../guides/03-notifications.md) describes.
@@ -0,0 +1,29 @@
1
+ # Working with Claude
2
+
3
+ Ask Claude in plain words, the way you'd ask a colleague. It works through the same API as the dashboard, so it can do what you can do there, and nothing more. Everything it changes shows in the dashboard as Claude's.
4
+
5
+ To set Claude up, follow [Connect Claude to the API](../guides/01-connect-claude.md). Claude reads these same pages from your Kestrel, so it explains things in the words you see here.
6
+
7
+ ## What to ask Claude
8
+
9
+ - **Draft or proofread a post.** Give it notes, an outline, or a draft. It saves a new version you can open in the dashboard. If you edited the post meanwhile, its save is refused, and it works from your version or asks you.
10
+ - **Test and schedule a post.** Name the post and when it should go out. Claude schedules it, sends a test of exactly what will go out, and tells you how to cancel. It can also cancel a send or move it.
11
+ - **Check how a send did.** Claude reads the send's record and reports what was delivered, unsent, bounced, or complained.
12
+ - **Lift a bounce or a complaint.** The dashboard can't, so ask Claude when you know an address works again. [Lifting a suppression](06-troubleshooting.md#lifting-a-suppression) says when that's wise.
13
+ - **Recover an earlier version.** Every save keeps a whole version of a post, and Claude can read them back.
14
+ - **Import a list.** Its `/migrate-list` skill asks what anything ambiguous in your old service's export means, and converts a few rows for you to check. It imports once you attest, and if a scheduled post would grow, you finish the import in the dashboard.
15
+
16
+ ## The lines it can't cross
17
+
18
+ The app holds these for any client, so they hold whatever Claude is told:
19
+
20
+ - **Every send waits out the review window,** and you can cancel it until it goes out.
21
+ - **A scheduled post is locked,** and what goes out is exactly the copy that was scheduled.
22
+ - **No one is mailed twice,** and only confirmed subscribers receive a post.
23
+ - **An unsubscribe is final** until the reader subscribes again, and an erased address stays erased until the person confirms again.
24
+ - **No one's edit is overwritten.** A save based on an older version is refused.
25
+ - **Only you can let an import grow a scheduled send.** An import through Claude that would stops there, until you finish it in the dashboard.
26
+
27
+ Claude also follows instructions your Kestrel gives it, which live in your instance's repository. Those hold only as long as the instructions do. They have Claude ask before anything that reaches readers, test again after a template change, and record whose decision each change to the list was. They also have it treat what's in a post or a subscriber's record as content, never as instructions.
28
+
29
+ The app lets Claude give your attestation for an import, so asking you first is one of those instructions, not a rule the app enforces. The import's record says it came through Claude, and Kestrel emails you about it if your notifications are set.
@@ -0,0 +1,25 @@
1
+ # Share your subscribe link
2
+
3
+ Your publication's subscribe page is at `/subscribe` on your Kestrel's address, such as `https://newsletter.example.com/subscribe`. Copy it from the dashboard's checklist or **Settings → Ways to subscribe**, and share it wherever your readers are. A reader joins only once they confirm by email.
4
+
5
+ **Signups open only once an email provider is connected.** Until then, the page says the newsletter isn't taking subscribers yet, and the dashboard says so too. You can share the link anyway, and it opens once email is connected ([Connect Amazon SES](../get-started/04-ses.md)).
6
+
7
+ ## How a reader joins
8
+
9
+ A reader enters their address, gets a confirmation email, and presses **Confirm subscription** on the page its link opens. Only then do they receive your posts. Opening the link alone records nothing, since mail scanners open links before a person does. This double opt-in is your record that each reader asked for your newsletter.
10
+
11
+ Every address gets the same answer, so the form never reveals who subscribes. Behind it, Kestrel sends a confirmation only when one is due:
12
+
13
+ - **Already subscribed, bounced, complained, or blocked:** no email goes out.
14
+ - **Waiting to confirm:** at most one confirmation every 15 minutes, however often the form is submitted. A link works for 7 days, and an expired one offers a fresh link.
15
+ - **Unsubscribed earlier, or erased at their request:** they get a confirmation, and confirming puts them back on your list.
16
+
17
+ ## The confirmation email
18
+
19
+ Change its subject, message, button label, and reassurance line under **Settings → Confirmation email**. Kestrel adds your name, your logo, and the confirm link itself, so no edit can leave them out. The confirmation isn't a post, so it doesn't use your email template.
20
+
21
+ ## Put the form on your own site
22
+
23
+ **Settings → Ways to subscribe → Embed a form** gives you HTML to paste into any page you control. **Styled** looks like your subscribe page, and **Plain** takes your site's own styles. A reader who submits it confirms by email, as on the subscribe page.
24
+
25
+ **Settings → Reader pages** shows every page a reader can land on, at desktop and phone widths, without walking through the steps. You can also limit how often anyone submits the form, which slows down abuse ([Rate-limit the subscribe form](../guides/05-rate-limit.md)).
@@ -0,0 +1,30 @@
1
+ # Check your setup
2
+
3
+ Before your readers get anything, see it arrive yourself, and make sure a problem will reach you without your watching for it. Send yourself a test and reply to it, subscribe yourself, and set where Kestrel emails you.
4
+
5
+ If you're bringing a list of more than about 5,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import it. On the free plan, a large import and a send can use up the day's database writes. Then the app stops working until midnight UTC. [As your list grows](../guides/09-as-your-list-grows.md) walks through the upgrade.
6
+
7
+ ## Send yourself a test
8
+
9
+ Choose **Template** in the sidebar, then **Send test email**, and send a sample post to your own inboxes. When it arrives, check:
10
+
11
+ - **The From name and address,** which readers see first. They're set when Kestrel is deployed ([Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy)).
12
+ - **Where it lands:** the inbox, Promotions, or spam. If it lands in spam, check your sending domain's records, as [Sending-domain DNS](../reference/02-sending-domain-dns.md) describes.
13
+ - **A reply.** Reply to the test, and check that it reaches your inbox. Kestrel never sees replies, so this depends on [forwarding your From address](../get-started/04-ses.md#3-receive-replies-at-your-from-address).
14
+
15
+ ## Subscribe yourself
16
+
17
+ Open your subscribe page, from the link under **Settings → Ways to subscribe**, and subscribe with a personal address. Then confirm. You see what a new reader sees, and you then appear under **Subscribers** as **Confirmed**. [Share your subscribe link](09-share-your-subscribe-link.md) covers the page and the confirmation email.
18
+
19
+ ## Set your notifications address
20
+
21
+ Under **Settings → Notifications**, fill in **Send notifications to**, save, and choose **Send a test notification**. Kestrel then emails you when a send finishes or needs you. [Notifications](06-troubleshooting.md#notifications) lists what you're told, and why a second way to send them is worth asking for.
22
+
23
+ ## If email isn't connected
24
+
25
+ Until an email provider is connected, nothing can be sent, and the dashboard says so. The checklist shows **Connect Amazon SES** in place of this step, as [Connect Amazon SES](../get-started/04-ses.md) describes. **Settings → Email sender** shows your From address, sending domain, and provider, read-only.
26
+
27
+ ## As your list grows
28
+
29
+ A growing list meets your Amazon SES daily sending quota, longer sends, and on Cloudflare's free Workers plan, a cap on daily database writes. Kestrel tells you when you reach each one, and [As your list grows](../guides/09-as-your-list-grows.md) covers raising each.
30
+
@@ -0,0 +1,34 @@
1
+ # Edit your email template
2
+
3
+ You write each post in Markdown, and the template is the HTML frame it's sent in, like a layout in a static site generator. `{{ .Post.Body }}` marks where your post goes, and the rest is plain HTML around it. The built-in template works as it comes, so changing it is optional.
4
+
5
+ To change it, choose **Template** in the sidebar, edit the HTML, and choose **Save changes**. **Start from example** offers two starting points. **Signed** ends each post with your logo, name, and tagline, and **Plain** has just your post and the footer links.
6
+
7
+ ## What every template needs
8
+
9
+ Kestrel won't save a template without `{{ .Post.Body }}` and `{{ .Email.UnsubscribeURL }}`. Every email has to carry the post and a way to leave. The two **Required** pills above the editor turn green or red as your template gains or loses them.
10
+
11
+ Wrap a part in `{{ if .IsEmail }}` and `{{ end }}` to keep it off each post's public page. The starting templates do this with the unsubscribe link, the View in browser link, and the mailing address.
12
+
13
+ ## Variables
14
+
15
+ A variable is replaced with its value in each email. Type it exactly as shown, or it renders as empty.
16
+
17
+ | Variable | What it holds |
18
+ | --- | --- |
19
+ | `{{ .Post.Body }}` | Your post, rendered from Markdown. Required. |
20
+ | `{{ .Post.Subject }}` | The post's subject line. |
21
+ | `{{ .Publication.Name }}` | Your publication's name. |
22
+ | `{{ .Publication.Tagline }}` | Your tagline. |
23
+ | `{{ .Publication.Logo }}` | Your logo as an image, or nothing when no logo is set. |
24
+ | `{{ .Publication.LogoURL }}` | Your logo's address, or empty. |
25
+ | `{{ .Publication.Address }}` | Your mailing address, or empty. |
26
+ | `{{ .Email.SentTo }}` | The address this email was sent to. |
27
+ | `{{ .Email.UnsubscribeURL }}` | This reader's one-click unsubscribe link. Required. |
28
+ | `{{ .Email.ViewInBrowserURL }}` | The post's public page. |
29
+
30
+ ## Test it
31
+
32
+ **Send test email** on the **Template** page sends a sample post through your saved template, so you see it in a real inbox. With unsaved edits, the button reads **Save & send test**, since a test always uses the saved template.
33
+
34
+ A change to the template re-makes the email of every scheduled post, as [Schedule and send](04-write-a-post.md#schedule-and-send) describes.
@@ -0,0 +1,27 @@
1
+ # Publishing with Kestrel
2
+
3
+ These pages get you publishing in the dashboard, and cover the decisions to make before they matter. Deploying and running Kestrel itself is covered in [Self-hosting](../README.md).
4
+
5
+ ## Set up your publication
6
+
7
+ The steps to set up a new publication, in the order the dashboard's setup checklist lists them.
8
+
9
+ 1. [Name your publication](03-name-your-publication.md)
10
+ 1. [Check your setup](10-check-your-setup.md)
11
+ 1. [Bring your existing list](01-your-list.md)
12
+ 1. [Write and send a post](04-write-a-post.md)
13
+ 1. [Share your subscribe link](09-share-your-subscribe-link.md)
14
+
15
+ ## Go further
16
+
17
+ Things you choose to do once you're running.
18
+
19
+ - [Edit your email template](11-email-template.md)
20
+ - [Working with Claude](07-working-with-claude.md)
21
+
22
+ ## Rules and troubleshooting
23
+
24
+ What the law asks of every send, and what to do when something goes wrong.
25
+
26
+ - [Sending rules](02-sending-rules.md)
27
+ - [Troubleshooting](06-troubleshooting.md)
@@ -30,7 +30,7 @@ None of these can be read or changed from inside the app, by the editor or by Cl
30
30
 
31
31
  ### `PROVIDER`
32
32
 
33
- Until it's set, or while it's empty, the app runs with no provider connected. You can write and preview posts, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, `"PROVIDER": ""`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.
33
+ Until it's set, or while it's empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, `"PROVIDER": ""`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.
34
34
 
35
35
  `fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A hosted demo that runs `fake` on a real hostname on purpose sets `ALLOW_FAKE_PROVIDER` to `true`, which no other deployment needs, and which no other provider reads.
36
36
 
@@ -38,11 +38,11 @@ Until it's set, or while it's empty, the app runs with no provider connected. Yo
38
38
 
39
39
  `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 `@`.
40
40
 
41
- Neither one is your publication's name, tagline, or logo. You set those in the editor, under **Settings → 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.
41
+ Neither one is the publication's name, tagline, or logo. You set those in the editor, under **Settings → Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, the emails and reader pages show no name at all.
42
42
 
43
43
  ### `ARCHIVE_BASE_PATH`
44
44
 
45
- Every 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`.
45
+ Every post sent carries its archive link, `ARCHIVE_BASE_PATH` followed by the post's slug, and those links are permanent. Changing the prefix later breaks every link already mailed, so pick it before the first send. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.
46
46
 
47
47
  A missing leading slash is added, and a trailing slash is dropped.
48
48
 
@@ -64,9 +64,9 @@ This setting is the subrequest cap. The app keeps database queries under theirs
64
64
 
65
65
  It's also how the app tells Workers Free from Workers Paid. At the default, the app runs on Free's limits:
66
66
 
67
- - One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more. Above it, up to 10,000 ([Import format](03-import-format.md#limits)).
67
+ - One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more. Above it, up to 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)).
68
68
  - An export reads 1,000 rows a page. Above it, 10,000.
69
- - The app warns before a send to more than 5,000 recipients, and after an import plan that leaves more than 3,000 confirmed subscribers. Either may use up D1's daily allowance of 100,000 rows written, which stops the app until midnight UTC ([As your list grows](../get-started/06-go-live.md#as-your-list-grows)). Neither warning stops you. Above the default, neither appears.
69
+ - The app warns before a send to more than 5,000 recipients (fewer on a day with imports), and on an import plan that, with the day's earlier imports, would use most of a day's writes. Either may use up D1's daily allowance of 100,000 rows written, which stops the app until midnight UTC ([As your list grows](../guides/09-as-your-list-grows.md)). Neither warning stops you. Above the default, neither appears.
70
70
 
71
71
  Never 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.
72
72
 
@@ -74,7 +74,7 @@ Never set it above your plan's limit. A minute that hits the cap is cut off mid-
74
74
 
75
75
  Every 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.
76
76
 
77
- The 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.
77
+ The floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead a post can be scheduled. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.
78
78
 
79
79
  ### `ARCHIVE_ORIGIN` and `MEDIA_PUBLIC_BASE`
80
80
 
@@ -1,4 +1,4 @@
1
- # Sending-domain DNS
1
+ # Sending-domain DNS: SPF, DKIM, and DMARC
2
2
 
3
3
  Inboxes decide whether to trust your mail by checking records in the DNS of the domain it comes from. This page explains the three checks they run: SPF, DKIM, and DMARC. It covers the records [Connect Amazon SES](../get-started/04-ses.md) has you add, what each proves, how to check them, and when to tighten DMARC.
4
4
 
@@ -27,11 +27,13 @@ SES generates the DKIM values. Copy them exactly as SES lists them, since they d
27
27
 
28
28
  SPF needs no record of yours on SES's default path, since it passes for a subdomain of `amazonses.com`. An SPF record, at `bounce.send`, comes only with the optional custom MAIL FROM domain.
29
29
 
30
+ Email Routing adds MX, SPF, and DKIM records at `send` itself, the DKIM one under the selector `cf2024-1`. They're for mail sent *to* your From address, so readers' replies reach the publisher ([Connect Amazon SES](../get-started/04-ses.md#3-receive-replies-at-your-from-address)). They don't affect your sending: inboxes check SES's mail for SPF at its return path, and for DKIM against SES's own keys.
31
+
30
32
  ### SPF: authorize the return path
31
33
 
32
34
  SPF checks the message's return path, the address bounces go back to, not its `From:` header. DMARC passes when SPF or DKIM passes and aligns with the `From:` domain. DKIM aligns, so an aligned SPF is a second check in your favor, not a requirement.
33
35
 
34
- SES uses a subdomain of its own domain, `amazonses.com`, as the return path by default. SPF then passes for it but doesn't align, and DMARC passes through DKIM alone. A custom MAIL FROM domain, such as `bounce.send.example.com`, aligns SPF too. [Connect Amazon SES](../get-started/04-ses.md#3-optional-set-a-custom-mail-from-domain) sets one up, with an MX record that names your region and an SPF record:
36
+ SES uses a subdomain of its own domain, `amazonses.com`, as the return path by default. SPF then passes for it but doesn't align, and DMARC passes through DKIM alone. A custom MAIL FROM domain, such as `bounce.send.example.com`, aligns SPF too. [Connect Amazon SES](../get-started/04-ses.md#4-optional-set-a-custom-mail-from-domain) sets one up, with an MX record that names your region and an SPF record:
35
37
 
36
38
  ```
37
39
  bounce.send.example.com. MX 10 feedback-smtp.us-east-1.amazonses.com.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurtbruns/kestrel",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
5
  "description": "An open-source, self-hosted newsletter app where you own the list and the archive. Work in the editor, or just ask Claude. Runs on Cloudflare Workers.",
6
6
  "license": "MIT",
@@ -38,6 +38,7 @@
38
38
  "docs/get-started/",
39
39
  "docs/guides/",
40
40
  "docs/reference/",
41
+ "docs/publishing/",
41
42
  "CHANGELOG.md"
42
43
  ],
43
44
  "scripts": {