@kurtbruns/kestrel 1.11.0 → 1.13.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 (39) hide show
  1. package/CHANGELOG.md +154 -1
  2. package/README.md +6 -7
  3. package/cli/dev-api.mjs +1 -1
  4. package/cli/reset.mjs +2 -0
  5. package/cli/seed.mjs +3 -1
  6. package/demo/README.md +4 -2
  7. package/demo/posts/0-test-email/index.md +13 -0
  8. package/demo/posts/5-a-quick-note/index.md +2 -0
  9. package/dist/public/dashboard/app.js +383 -324
  10. package/dist/public/dashboard/app.js.map +4 -4
  11. package/dist/public/dashboard/index.html +2 -2
  12. package/dist/public/dashboard/styles.css +850 -191
  13. package/dist/worker/index.js +11327 -6891
  14. package/docs/get-started/02-deploy.md +1 -1
  15. package/docs/get-started/03-access.md +3 -3
  16. package/docs/get-started/04-ses.md +24 -4
  17. package/docs/get-started/05-verify.md +21 -5
  18. package/docs/get-started/06-go-live.md +2 -2
  19. package/docs/guides/01-connect-claude.md +8 -3
  20. package/docs/guides/09-as-your-list-grows.md +6 -2
  21. package/docs/publishing/01-your-list.md +35 -30
  22. package/docs/publishing/02-sending-rules.md +3 -3
  23. package/docs/publishing/04-write-a-post.md +20 -6
  24. package/docs/publishing/06-troubleshooting.md +46 -12
  25. package/docs/publishing/07-working-with-claude.md +4 -4
  26. package/docs/publishing/09-share-your-subscribe-link.md +2 -2
  27. package/docs/publishing/10-check-your-setup.md +2 -2
  28. package/docs/publishing/11-email-template.md +3 -1
  29. package/docs/publishing/README.md +1 -1
  30. package/docs/reference/01-configuration.md +3 -3
  31. package/migrations/0007_sends_progress_at.sql +12 -0
  32. package/migrations/0008_notifications_sandbox.sql +35 -0
  33. package/migrations/0009_post_email_only.sql +12 -0
  34. package/migrations/0010_unlisted_links.sql +43 -0
  35. package/migrations/0011_sends_render_warnings.sql +13 -0
  36. package/migrations/0012_unsubscribe_cause.sql +139 -0
  37. package/package.json +1 -1
  38. package/template/claude/CLAUDE.md +13 -9
  39. package/template/claude/skills/migrate-list/SKILL.md +5 -4
@@ -202,7 +202,7 @@ This deploys the release your instance runs, with your settings. The output ends
202
202
 
203
203
  In a browser, `https://newsletter.example.com/dashboard/` says the editor is locked until you set up Access. That's expected.
204
204
 
205
- 1. In a browser, `https://newsletter.example.com/` shows the newsletter's public landing page. Its subscribe page, `/subscribe`, says signups aren't open yet, until Connect Amazon SES.
205
+ 1. In a browser, `https://newsletter.example.com/` shows the newsletter's public home page. Its subscribe page, `/subscribe`, says signups aren't open yet, until Connect Amazon SES.
206
206
 
207
207
  If the public pages instead say the newsletter is temporarily unavailable, and `/health` answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",…}`, the setting it names is missing, malformed, or still on `example.com`. Fix it in `wrangler.jsonc`, commit, and deploy again.
208
208
 
@@ -35,7 +35,7 @@ The editor lives under `/dashboard`, and the API under `/api`. One application c
35
35
  | `dashboard` | the editor |
36
36
  | `api` | everything the editor and Claude read and change: posts, sends, subscribers, settings, and the in-app docs |
37
37
 
38
- 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.
38
+ Each path covers everything under it. Leave every other path out. The home page, the archive, subscribing, unsubscribing, images, and your email provider's webhooks must stay public.
39
39
 
40
40
  1. **(Optional)** Choose how long a sign-in lasts. The application's session duration defaults to 24 hours, so everyone signs in again each day. You can set anything from immediately to one month, as Cloudflare's [session management](https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/session-management/) describes. A longer session suits a publisher on their own computer, and a shorter one is safer on a shared one.
41
41
 
@@ -49,7 +49,7 @@ Every Access application denies everyone until a policy allows them.
49
49
  - **Action:** Allow
50
50
  - **Include:** the **Emails** selector, with your email address
51
51
 
52
- 1. 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 → Integrations → Identity providers** first.
52
+ 1. Keep the login method a new Zero Trust organization starts with, Cloudflare's own: you enter your email, and confirm the sign-in from the message Cloudflare sends to it. By default it admits only members of your Cloudflare account, so the email in your `Publishers` policy should be one. To let someone sign in another way, such as a one-time code by email, add it under **Zero Trust → Integrations → Identity providers** first.
53
53
 
54
54
  1. Select **Create**.
55
55
 
@@ -57,7 +57,7 @@ Every Access application denies everyone until a policy allows them.
57
57
 
58
58
  The app verifies each login against your team domain, and checks that it was issued for this application.
59
59
 
60
- 1. Find your team domain under **Zero Trust → Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`.
60
+ 1. Find your team domain under **Zero Trust → Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`. It belongs to your Zero Trust organization, not to this application, so every application in your account shares it. It's also where a signed-out visit to `https://newsletter.example.com/dashboard/` now takes you.
61
61
 
62
62
  1. Find the application's audience tag. Under **Access controls → Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.
63
63
 
@@ -1,6 +1,6 @@
1
1
  # Connect Amazon SES
2
2
 
3
- Kestrel sends the newsletter's posts through Amazon SES, Amazon's email service. SES only delivers the mail: Kestrel keeps the list, each subscriber's consent, and the record of every send. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.
3
+ Kestrel sends the newsletter's posts through Amazon SES, Amazon's email service. SES only delivers the mail: Kestrel keeps the list, each subscriber's consent, and the record of every send. SES prices every email alike, with no split between transactional and marketing mail, and costs the least at every list size above a tiny one. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.
4
4
 
5
5
  In this guide, you verify your sending hostname with SES and add a DMARC record. You forward readers' replies to the publisher's inbox, and ask AWS for production access. You also route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, set the provider, and deploy.
6
6
 
@@ -107,7 +107,11 @@ If AWS asks for more detail, reply in the support case. If it denies the request
107
107
  - Bounces and complaints reach the app through SNS, and it stops mailing those addresses automatically.
108
108
  - How many subscribers the newsletter expects, and how often it sends.
109
109
 
110
- Your account stays in the sandbox until AWS grants access, so don't go live before then. In the sandbox, a signup from any address you haven't verified fails, and a post reaches only your verified addresses.
110
+ Your account stays in the sandbox until AWS grants access, so don't go live before then. In the sandbox, SES refuses mail to any address you haven't verified:
111
+
112
+ - A signup from an unverified address gets no confirmation email. The app emails the publisher about it, at most once a day.
113
+ - A post goes to your verified addresses and then waits, with the line "The provider is refusing the account" and SES's words. No reader is marked unsent. Once AWS grants access, the post goes to the rest at its next retry, mailing no one twice.
114
+ - A daily quota refusal in the sandbox (200 emails a day) means the same thing: request production access rather than a higher quota.
111
115
 
112
116
  When AWS grants access, the **Account dashboard** shows your daily quota and your maximum send rate. You set the rate in the app in section 8. The rate decides how fast a send goes out, and [As your list grows](../guides/09-as-your-list-grows.md) says when to ask AWS to raise it.
113
117
 
@@ -223,7 +227,7 @@ Until now the app has run with no email provider connected, refusing to send. Se
223
227
  }
224
228
  ```
225
229
 
226
- On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan's limit, so each minute sends more. Leave it out on Workers Free. [As your list grows](../guides/09-as-your-list-grows.md) says when the plan is worth it.
230
+ On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan's limit, so each minute sends more. Leave it out on Workers Free, which suits a list of up to about 1,000 subscribers. [As your list grows](../guides/09-as-your-list-grows.md) says why.
227
231
 
228
232
  When AWS grants production access, set `SES_MAX_SEND_RATE` to the maximum send rate on the **Account dashboard**, and deploy again. The app keeps to that rate, so SES doesn't throttle your send.
229
233
 
@@ -256,10 +260,26 @@ SNS delivers events only to a subscriber that has confirmed. The app confirms on
256
260
 
257
261
  1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.
258
262
 
263
+ 1. Give SNS longer to retry. By default it retries a failed delivery 3 times over about a minute, then discards the event, so a bounce that arrives while the app is briefly down or mid-deploy is lost, and the address gets your next post. Open the topic, choose **Edit**, and expand **Delivery policy (HTTP/S)**. Clear **Use the default delivery policy**, set these, and choose **Save changes**:
264
+
265
+ | Field | Value |
266
+ | --- | --- |
267
+ | Number of retries | `13` |
268
+ | Retries without delay | `0` |
269
+ | Minimum delay | `20` |
270
+ | Minimum delay retries | `2` |
271
+ | Maximum delay | `300` |
272
+ | Maximum delay retries | `6` |
273
+ | Retry-backoff function | Exponential |
274
+
275
+ SNS then keeps trying for most of an hour, the longest it allows. It never retries an event the app refused. The app refuses only an event it can't verify came from your topic, and logs an error when one claims to be from SNS.
276
+
277
+ 1. **(Optional)** Keep what SNS still can't deliver. On the subscription, choose **Edit**, and set a **Redrive policy (dead-letter queue)** to an SQS queue of your own. SNS then moves an event there instead of discarding it, where you can read it later. [Amazon SNS dead-letter queues](https://docs.aws.amazon.com/sns/latest/dg/sns-dead-letter-queues.html) covers the queue and its permissions.
278
+
259
279
  If it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:
260
280
 
261
281
  - **Access covers `/webhooks/ses`.** If your Access application covers more than `/dashboard` and `/api`, SNS gets the login page instead of the app. Remove that path from your Access application, so only `dashboard` and `api` remain.
262
- - **`SNS_TOPIC_ARN` matches the topic's ARN.**
282
+ - **`SNS_TOPIC_ARN` matches the topic's ARN.** If it doesn't, the app's logs show a `webhook.refused` error naming both.
263
283
  - **The deploy in section 8 finished.**
264
284
 
265
285
  Once you've fixed the cause, select the subscription, and choose **Request confirmation**. SNS sends the request again, and the app confirms it. SNS doesn't let you delete a subscription while it's pending, and removes it on its own after 48 hours.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Some things only show up with real email: whether inboxes trust your mail, how it looks in a real mail client, and whether bounces and unsubscribes come back to the app. Check them now, while the list is empty and every email goes only to you.
4
4
 
5
- In this guide, you send yourself a test email, reply to it, and check that it passes authentication. Then you set up notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs, and check that SES can send to anyone.
5
+ In this guide, you send yourself a test email, reply to it, and check that it passes authentication. Then you set up notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs, check that SES can send to anyone, and clean up the test subscribers.
6
6
 
7
7
  ## Before you begin
8
8
 
@@ -59,10 +59,14 @@ This is a real send, to a list of one. It proves the whole path: subscribing, co
59
59
 
60
60
  1. Open the post, select **Schedule**, and then **Send now**. Every send waits at least five minutes before it goes out, so you can cancel a mistake.
61
61
 
62
- 1. Cancel the send, to see that canceling stops it. Then select **Send now** again, and let it go.
62
+ 1. Uncheck **Include in archive**. The post still goes to subscribers, but it's sent as email only: it isn't in the public archive or on the home page. That way the test proves the send without becoming the publication's first archived post, and the dashboard keeps its **Finish setting up** steps for the publisher.
63
+
64
+ 1. Cancel the send, to see that canceling stops it. Then select **Schedule** and **Send now** again, check that **Include in archive** is still unchecked, and let it go.
63
65
 
64
66
  1. 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.
65
67
 
68
+ If you verified this instance before Email only existed, the test post is in the archive. Open it under **Sent**, and select **Remove from archive…**.
69
+
66
70
  ## 5. Unsubscribe
67
71
 
68
72
  Every 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.
@@ -73,7 +77,7 @@ Every email carries an unsubscribe link, and a header that lets mail clients sho
73
77
 
74
78
  ## 6. Check that bounces and complaints come back
75
79
 
76
- When an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.
80
+ When an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address: a bounce suppresses it, and a complaint unsubscribes it. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.
77
81
 
78
82
  1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:
79
83
 
@@ -82,9 +86,9 @@ When an address bounces or marks your mail as spam, SES tells the app through SN
82
86
 
83
87
  Each one gets a confirmation email, which bounces or is marked as spam.
84
88
 
85
- 1. In the editor, **Subscribers** lists both addresses as **Pending**, each with a **Suppressed** flag. The app never mails a suppressed address again.
89
+ 1. In the editor, **Subscribers** lists the bounce address as **Pending** with a **bounced** flag, and the complaint address as **Unsubscribed**. Open its row: its page says it left by a complaint. The app mails neither again.
86
90
 
87
- If an address has no flag, SES's events aren't reaching the app. Check the configuration set and the SNS subscription in [Connect Amazon SES](04-ses.md#6-send-bounces-and-complaints-to-sns).
91
+ If the bounce address has no flag, or the complaint address is still pending, SES's events aren't reaching the app. Check the configuration set and the SNS subscription in [Connect Amazon SES](04-ses.md#6-send-bounces-and-complaints-to-sns).
88
92
 
89
93
  ## 7. Read the logs
90
94
 
@@ -108,6 +112,18 @@ The app writes a log line for each thing it does, and Cloudflare keeps them for
108
112
 
109
113
  If the dashboard still says sandbox, AWS hasn't answered yet. Every other section works in the sandbox, so come back to this one when it does.
110
114
 
115
+ ## 9. Clean up the test subscribers
116
+
117
+ The checks leave test addresses on the list. Erase them, so the publisher starts with only real readers.
118
+
119
+ 1. In the editor, open **Subscribers**.
120
+
121
+ 1. Erase `bounce@simulator.amazonses.com` and `complaint@simulator.amazonses.com`. Open each one's menu (⋯), and select **Erase**.
122
+
123
+ 1. Erase your second address too, if it won't be a reader.
124
+
125
+ Erasing keeps only the address, so it's never mailed again, but the person can subscribe again through the subscribe page. Erasing your own address doesn't lock you out.
126
+
111
127
  When every section gives the result it describes, your instance is ready for subscribers.
112
128
 
113
129
  Next, [go live](06-go-live.md).
@@ -4,7 +4,7 @@ At this point, your instance should be deployed, verified, and out of the SES sa
4
4
 
5
5
  ## Start publishing
6
6
 
7
- The rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md). The checklist itself shows only until the first post exists, so the test post from [Verify it works](05-verify.md) has already hidden it.
7
+ The rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md).
8
8
 
9
9
  If someone else is the publisher, let them sign in first:
10
10
 
@@ -21,5 +21,5 @@ Each of these guides adds one thing, and you can come back to any of them later.
21
21
  - [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.
22
22
  - [Notifications through Cloudflare's email](../guides/03-notifications.md), so problems are reported even when the email provider has stopped sending.
23
23
  - [Rate-limit the subscribe form](../guides/05-rate-limit.md), before the subscribe link is shared widely.
24
- - [As your list grows](../guides/09-as-your-list-grows.md), from about 5,000 subscribers.
24
+ - [As your list grows](../guides/09-as-your-list-grows.md), from about 1,000 subscribers.
25
25
  - [Upgrade to a new release](../guides/07-upgrade.md), when one comes out.
@@ -72,10 +72,13 @@ Your instance's `.claude/CLAUDE.md` already tells Claude Code how to call the AP
72
72
  ```
73
73
  Kestrel's API is at $KESTREL_URL. Send the CF-Access-Client-Id and
74
74
  CF-Access-Client-Secret headers from your environment with every request.
75
- Read GET /api/reference first, and use it for every call.
75
+ Read GET /api/reference.md first, then the reference of the resource
76
+ you're working with, such as GET /api/reference/sends.md, before calling it.
76
77
  ```
77
78
 
78
- The reference is also served as Markdown, `GET /api/reference.md`, which reads more easily for Claude than the JSON. So are the docs: `GET /api/docs/publishing.md` lists the publisher's pages, `GET /api/docs/self-hosting.md` this guide's, and each page is at `GET /api/docs/<slug>.md`.
79
+ For Claude the reference is Markdown in two levels: `GET /api/reference.md` is the index, with what every route shares and each route on one line, and `GET /api/reference/<resource>.md` holds one resource's routes in full, so Claude reads only what its work needs. The JSON at `GET /api/reference` is the whole reference in one answer, as the editor reads it, and `GET /api/openapi.json` is the same reference as an OpenAPI 3.1 document, for a tool that imports one. The docs are served as Markdown too: `GET /api/docs/publishing.md` lists the publisher's pages, `GET /api/docs/self-hosting.md` this guide's, and each page is at `GET /api/docs/<slug>.md`.
80
+
81
+ An agent given only your app's address finds these routes too. `GET /llms.txt` is public. It names the publication, its archive and subscribe page, and the reference and docs indexes, and links this guide for the token.
79
82
 
80
83
  Claude reads the reference, and from there can list the posts, write a draft, and schedule it. The reference lists the body types each route takes. Most take JSON, sent with `Content-Type: application/json`. An image upload takes a multipart form, or the image file itself.
81
84
 
@@ -86,9 +89,11 @@ To bring subscribers over from another service, ask Claude to import the list, a
86
89
  1. Ask Claude to call `GET /api/whoami`. The app answers with a program's identity, not yours:
87
90
 
88
91
  ```
89
- {"principal":{"kind":"service"},"auth":{"mode":"access"}}
92
+ {"principal":{"kind":"service","token_id":"e367c0ffee5eb4.access"},"auth":{"mode":"access"}}
90
93
  ```
91
94
 
95
+ The `token_id` is the Client ID Zero Trust shows beside `kestrel-claude`. If it's a different one, Claude Code is using another token.
96
+
92
97
  1. Ask Claude to list the posts. It answers with the posts the editor shows.
93
98
 
94
99
  1. Open a draft in the editor, and ask Claude to change a word in it. The editor shows that the draft was changed elsewhere, last edited by **Claude**.
@@ -1,6 +1,6 @@
1
1
  # As your list grows
2
2
 
3
- A new instance runs on Cloudflare's free Workers plan with your starting Amazon SES limits. That suits a list of up to about 5,000 subscribers. Past that, two limits matter: how fast SES lets you send, and how many database writes the free plan allows a day.
3
+ A new instance runs on Cloudflare's free Workers plan with your starting Amazon SES limits. Workers Free suits a list of up to about 1,000 subscribers. Past about 5,000, one send can use up the day's database writes and stop the newsletter until midnight UTC. Two limits matter as the list grows: how fast SES lets you send, and how many database writes the free plan allows a day.
4
4
 
5
5
  In this guide, you raise your SES limits, which helps on either plan, and upgrade to Workers Paid, which a larger list needs. Then you see how long a send takes after each.
6
6
 
@@ -27,7 +27,9 @@ SES caps how many emails your account sends a day, and how many a second. A send
27
27
 
28
28
  ## 2. Upgrade to Workers Paid
29
29
 
30
- On the free plan, [D1](https://developers.cloudflare.com/d1/platform/pricing/) allows a fixed number of database writes a day. A send to about 7,000 recipients uses them all, and then the whole app stops until midnight UTC. Until you upgrade, the app warns the publisher before a send or an import that could use up the day. It's recommended to upgrade from about 5,000 subscribers.
30
+ On the free plan, [D1](https://developers.cloudflare.com/d1/platform/pricing/) allows 100,000 written rows a day, reset at midnight UTC. A send writes about 14 rows a recipient, receipts included, and an import about 7 for each new subscriber. So at 1,000 subscribers, an import of the whole list and two sends on one day use about a third of the day. Once the day's rows are spent, the whole app stops until midnight UTC, the SES webhook with it, so that day's bounces and complaints are lost, and suppress or unsubscribe no one.
31
+
32
+ It's recommended to upgrade from about 1,000 subscribers. Past that, the Dashboard and the Subscribers screen say so, and so does an import that would take the list past it. Past about 5,000, a send that could use up the day on its own also warns before it's scheduled. The app doesn't count the day's writes, so between the two, two large sends or several imports and a send on one UTC day can still use it up without a warning at that moment.
31
33
 
32
34
  1. Upgrade your Cloudflare account to the $5 [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/), which has no daily limit on writes.
33
35
 
@@ -46,6 +48,8 @@ On the free plan, [D1](https://developers.cloudflare.com/d1/platform/pricing/) a
46
48
  npx wrangler deploy --env production
47
49
  ```
48
50
 
51
+ Over years, one more limit can matter: on the free plan, one [D1 database](https://developers.cloudflare.com/d1/platform/limits/) holds at most 500 MB, and every send keeps a row for each recipient in its record. Workers Paid raises it to 10 GB.
52
+
49
53
  ## How long a send takes
50
54
 
51
55
  | Setup | 1,000 subscribers | 5,000 subscribers | 10,000 subscribers |
@@ -2,19 +2,19 @@
2
2
 
3
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
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.
5
+ **The first decision is whether to upgrade to Cloudflare's Workers Paid plan.** Workers Free suits a list of up to about 1,000 subscribers. Past about 5,000, one send can use up the day's database writes and stop the newsletter until midnight UTC. With more than about 1,000 subscribers, it's recommended to upgrade before you import. 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
6
 
7
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
8
 
9
9
  ## 1. Export from your old service
10
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.
11
+ Export everyone your old service knows about, not only your current subscribers. Unsubscribes, complaints, 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
12
 
13
13
  ## 2. Convert it to Kestrel's format
14
14
 
15
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
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.
17
+ - **A CSV in Kestrel's format (preferred).** One row per address, with its status, how and where consent was collected, why anyone who left did, and any bounce. **Download example file (CSV)** on the import screen shows each case. Save your file as CSV UTF-8.
18
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
19
 
20
20
  A CSV in Kestrel's format looks like this, with the five columns that decide what happens to each row:
@@ -39,26 +39,28 @@ Under **Subscribers → Import**, choose your file. Nothing changes yet: Kestrel
39
39
 
40
40
  ## What carries over
41
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".
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 each one's page, opened from their row, shows where they came from, such as "Imported · Sendy, single opt-in".
43
+
44
+ **Why someone left carries over too.** Your file can say a person unsubscribed themselves, that you unsubscribed them when they asked, that they reported your email as spam, or that they asked to be erased. Someone who complained or was erased can't be added back by hand, only by subscribing themselves, and an erasure is recorded with the address alone.
43
45
 
44
46
  A few things hold whatever the file says:
45
47
 
46
48
  - **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.
49
+ - **A bounce or a block here always wins.** An import never lifts one.
48
50
  - **A reader's newer choice stands.** Someone who confirmed here after your old service says they left stays subscribed.
49
51
 
50
52
  ## Limits
51
53
 
52
54
  - **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.
55
+ - **The free plan.** On the free Workers plan, an import that would take your list past about 1,000 subscribers recommends Workers Paid, and so do the Dashboard and the Subscribers screen once it's past. A send to more than about 5,000 warns too. None of these stops anything.
54
56
 
55
57
  ## Taking your list out
56
58
 
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.
59
+ **Subscribers → Export** downloads your whole list as one CSV in the same format. It holds every subscriber, how they joined, why anyone who left did, and every bounced or blocked address. Keep it as a backup, or import it into another Kestrel to carry the list over.
58
60
 
59
61
  ## The file format
60
62
 
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.
63
+ This section defines `kestrel-subscribers/2` 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
64
 
63
65
  ### The CSV
64
66
 
@@ -70,29 +72,30 @@ The file is a CSV saved as UTF-8 (in a spreadsheet, **CSV UTF-8**). A header row
70
72
  | `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
73
  | `opt_in` | yes | `double`, `single`, `unknown` | How consent was collected. `unknown` unless the source states it. |
72
74
  | `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. |
75
+ | `unsubscribe_cause` | no | `reader`, `publisher`, `complaint`, `erased` | Why an `unsubscribed` row left: they unsubscribed themselves, you unsubscribed them on request, they reported your email as spam, or they asked to be erased. Empty when the source doesn't say, and recorded as having left elsewhere. Only on an `unsubscribed` row. |
76
+ | `suppression` | no | `bounce`, `manual` | A suppression on top of `status`: the address bounced, or was blocked by hand. Version 1 also took `complaint` and `erased`, read now as an `unsubscribed` row with that cause. |
74
77
  | `subscribed_at` | no | an instant with its zone | When the person subscribed, if the source states it. |
75
78
  | `confirmed_at` | no | an instant with its zone | When the person confirmed, if the source states it. |
76
79
  | `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
80
  | `suppressed_at` | no | an instant with its zone | When the address was suppressed, if the source states it. |
78
81
  | `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. |
82
+ | `format` | no | `kestrel-subscribers/1`, `kestrel-subscribers/2` | The version the row is written in. A row without it may use either version's values. |
80
83
 
81
84
  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
85
 
83
86
  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
87
 
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:
88
+ The example file, `kestrel-import-example.csv`, shows each case: a double opt-in subscriber with evidence, a single opt-in one, someone who unsubscribed themselves, and a bounced address. Here are the same four with only the columns they use, since an optional column may be left out:
86
89
 
87
90
  ```
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,,
91
+ email,status,opt_in,origin,unsubscribe_cause,suppression,unsubscribed_at,evidence
92
+ ada@example.com,subscribed,double,sendy,,,,"{""signup_ip"":""203.0.113.7""}"
93
+ grace@example.com,subscribed,single,mailchimp,,,,
94
+ edsger@example.com,unsubscribed,unknown,unknown,reader,,2025-01-20T08:00:00Z,
95
+ bounced@example.com,none,unknown,unknown,,bounce,,
93
96
  ```
94
97
 
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.
98
+ **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. Version 2 added `unsubscribe_cause`, and with it a complaint and an erasure left `suppression`. A row of version 1 that says `complaint` or `erased` there reads as an `unsubscribed` row with that cause, whatever its `status`. A row that names no version may use either version's values, since both read it alike, but not a cause beside a version 1 complaint or erasure.
96
99
 
97
100
  ### The plain list
98
101
 
@@ -118,33 +121,35 @@ Each row gets exactly one outcome. A row that both changes its status and adds a
118
121
 
119
122
  | Outcome | When |
120
123
  | --- | --- |
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. |
124
+ | `added_confirmed` | `subscribed`, with no subscriber and no suppression in Kestrel: added as confirmed. |
125
+ | `confirmed_pending` | `subscribed`, pending in Kestrel (never confirmed): confirmed, and its confirmation link no longer works. |
123
126
  | `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. |
127
+ | `unsubscribed` | `unsubscribed`, and confirmed or pending in Kestrel, with no confirmation in Kestrel newer than the row's `unsubscribed_at`: unsubscribed, with the row's cause. Also an address already unsubscribed in Kestrel for a weaker reason, which the row's complaint replaces. |
128
+ | `erased` | `unsubscribed` with cause `erased`, for someone in Kestrel it unsubscribes, or who already left for another reason: erased, as **Erase** does. Their record keeps only the address and when, and your past sends show an erased recipient in place of the address. Named apart so you see who on your list the file erases. |
129
+ | `added_unsubscribed` | `unsubscribed`, with no subscriber in Kestrel: recorded as unsubscribed, with the row's cause, so the list remembers they left. |
126
130
  | `kept_confirmed_newer` | `unsubscribed`, but the reader confirmed in Kestrel after the source's `unsubscribed_at`: their newer action stands. |
127
131
  | `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`. |
132
+ | `kept_suppressed` | `subscribed`, but the address carries a suppression in Kestrel: never added or confirmed. |
133
+ | `suppressed` | The row carries a suppression and the address has none in Kestrel, and nothing else changes. Applied whatever the row's `status`. |
130
134
  | `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
135
  | `invalid` | The address isn't valid. |
132
136
  | `merged` | A repeat of an address earlier in the file, folded into it. |
133
137
 
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.
138
+ A suppression already in Kestrel is never replaced: the first one an address gets stands. An imported bounce is kept apart from one Kestrel saw itself, so the list says where each came from.
139
+
140
+ **Why someone left.** A row with no `unsubscribe_cause` is recorded as having left elsewhere, which is all an import can know. One whose cause is `erased` is recorded with its address alone, whatever else the row says. A subscriber already in Kestrel whom it unsubscribes is erased, as **Erase** erases them: their consent history, how they joined, and its evidence go, and your past sends show an erased recipient in place of the address. The plan names each such person apart, as `erased`, so you see who on your list the file erases before you commit. A commit that lands while a send is handing one of them to your email provider is refused, and you try again a minute later. Someone already unsubscribed in Kestrel keeps the stronger of the two reasons: an erasure, then a complaint, then the reason Kestrel already has.
135
141
 
136
142
  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
143
 
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.
144
+ **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 reason for leaving is the strongest: an erasure, then a complaint, then the first of the rest. Its suppression is the first of 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
145
 
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.
146
+ A subscriber an import confirmed is **confirmed**, like anyone who confirmed in Kestrel, and gets your posts. Their page, opened from their row, says how they joined: imported, with the origin and opt-in method the file claimed, the evidence, and the import that brought them: the file, when, by whom, and the attestation. 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
147
 
142
148
  ### Limits in detail
143
149
 
144
150
  - **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
151
  - **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.
152
+ - **The free Workers plan:** it suits a list of up to about 1,000 subscribers. Past about 5,000, one send can use up the day's database writes and stop the newsletter until midnight UTC. An import's plan recommends Workers Paid when it would leave more than 1,000 confirmed subscribers, and the Dashboard and the Subscribers screen say the same while the list is past it. A scheduled post to more than 5,000 recipients warns before it's scheduled, and its banner repeats the warning while it's scheduled. None of these stops anything. [As your list grows](../guides/09-as-your-list-grows.md) has the numbers behind them.
148
153
 
149
154
  ### Exporting
150
155
 
@@ -152,6 +157,6 @@ The export reads your whole list out in this format, a page at a time, at `GET /
152
157
 
153
158
  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
159
 
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`.
160
+ The export includes every subscriber with how they joined, their evidence, why they left when they have, and their suppression, each in a column of its own. It also includes every address suppressed with no subscriber behind it, written as `status: none`. A confirmed subscriber is written as `subscribed`.
156
161
 
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.
162
+ Importing an export into another Kestrel carries the list over: the same states, the same reasons for leaving, an erasure's included, and the same 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.
@@ -8,7 +8,7 @@ This page covers what Kestrel handles, what you handle, and how to act on a read
8
8
 
9
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
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.
11
+ - **Bounces and complaints.** An address that bounces is never mailed again, so a bad address can't keep hurting your sending reputation. A reader who marks your email as spam is unsubscribed, as if they had used the link, and you can't add them back: only their own sign-up does.
12
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
13
 
14
14
  ## What you handle
@@ -23,9 +23,9 @@ This page covers what Kestrel handles, what you handle, and how to act on a read
23
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
24
 
25
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.
26
+ - **To stop the email,** find the reader under **Subscribers**, and choose **Unsubscribe** from their ⋯ menu. It works whether or not they've confirmed, records that they asked, and they can rejoin by subscribing again.
27
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.
28
+ - **To delete their data,** choose **Erase** from the same menu, even for someone who already unsubscribed. It unsubscribes them and deletes how they joined and the evidence for it, and can't be undone. Past send records show an erased recipient in their place. Kestrel keeps only the address and when you erased it, so it's never mailed again, beside any bounce or block the address already had. The reader can still rejoin by subscribing and confirming themselves, but you can't add them back.
29
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
30
 
31
31
  ## Outside the US
@@ -13,11 +13,11 @@ A few things to know as you write:
13
13
  - **A blank line starts a new paragraph.** In an email, a single line break doesn't.
14
14
  - **The opening lines** are the preview a mail client shows beside the subject.
15
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.
16
+ - **Where it will go** is in **Post settings**, at the top right: whether it's in your archive, and its address, such as `newsletter.example.com/archive/spring-update`, whose slug follows the subject (below).
17
17
 
18
18
  ## Test it
19
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.
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, nothing appears in your archive, and a test's unsubscribe link unsubscribes no one.
21
21
 
22
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
23
 
@@ -33,23 +33,37 @@ Changing your template, or a publication detail it shows, re-makes the email of
33
33
 
34
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
35
 
36
+ ### Post settings
37
+
38
+ **Post settings**, at the top right of the editor, holds two choices. Neither is part of the email, so both stay open while the post is scheduled. Nothing changes until you select **Save**.
39
+
40
+ **Include in archive** is checked unless you uncheck it. Unchecked, the post goes to your list as usual but is sent as email only: it stays off your public archive and your home page, and its archive address shows nothing. Use that for a post meant for the inbox: a correction to an earlier email, a short-lived notice, or a test. The editor marks such a post **Email only**.
41
+
42
+ **Archive address** is where the post's page will be in your archive. Only the last part, the slug, is yours to type; it follows the subject until you do. Each post's address is its own: if you type one another post has, or one with spaces or capitals, Kestrel says so and keeps the address as it was. If another post already has the address your subject would give, Kestrel adds a number (`spring-update-2`) and tells you, so you can choose another. The address is fixed once the post starts sending.
43
+
44
+ The Schedule, Send now, and Edit schedule dialogs show the same settings under the time, so you can still change them there. A change made in a dialog applies when you schedule, send, or save, and canceling leaves the post as it was. Once a post is scheduled, its banner names where it's going.
45
+
46
+ Every email links its web version through the post's own **unlisted link**, which never changes. It opens the archive page while the post is in your archive, and the page itself while it's email only. Anyone with the email can open it, but nothing lists it, and search engines are asked not to.
47
+
48
+ A sent post's page under **Sent** shows its address with **Remove from archive…** or **Add to archive…**. Both ask first. Removing a post takes its archive address down, so links people shared stop working, but the address stays the post's: add it back and those links work again. Neither changes what was sent.
49
+
36
50
  ### Changing your mind
37
51
 
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.
52
+ **Cancel**, on the post's banner or its card under **Sent**, makes the post a draft again at once. Edit it, test again, and schedule it again. **Edit schedule** changes the send time, whether the post is in your archive, or both, without unlocking the post, and your tests still count. You can do either until the send time.
39
53
 
40
54
  ### Who receives it
41
55
 
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.
56
+ A post goes to your list as it stands when it sends: every confirmed subscriber, except addresses that bounced or were blocked. A reader who complained has been unsubscribed, so isn't on it. 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
57
 
44
58
  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
59
 
46
60
  ### Warnings when you schedule
47
61
 
48
62
  - **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.
63
+ - **Daily database writes.** On Cloudflare's free Workers plan, a send to more than about 5,000 recipients 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
64
 
51
65
  ### When it sends
52
66
 
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.
67
+ 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 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
68
 
55
69
  If a send needs your attention, the send page and the dashboard say so. [Troubleshooting](06-troubleshooting.md) covers each case.