@kurtbruns/kestrel 1.12.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.
@@ -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
 
@@ -227,7 +227,7 @@ Until now the app has run with no email provider connected, refusing to send. Se
227
227
  }
228
228
  ```
229
229
 
230
- 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.
231
231
 
232
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.
233
233
 
@@ -77,7 +77,7 @@ Every email carries an unsubscribe link, and a header that lets mail clients sho
77
77
 
78
78
  ## 6. Check that bounces and complaints come back
79
79
 
80
- 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.
81
81
 
82
82
  1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:
83
83
 
@@ -86,9 +86,9 @@ When an address bounces or marks your mail as spam, SES tells the app through SN
86
86
 
87
87
  Each one gets a confirmation email, which bounces or is marked as spam.
88
88
 
89
- 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.
90
90
 
91
- 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).
92
92
 
93
93
  ## 7. Read the logs
94
94
 
@@ -122,7 +122,7 @@ The checks leave test addresses on the list. Erase them, so the publisher starts
122
122
 
123
123
  1. Erase your second address too, if it won't be a reader.
124
124
 
125
- Erasing keeps a do-not-contact marker, but the person can subscribe again through the subscribe page. Erasing your own address doesn't lock you out.
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
126
 
127
127
  When every section gives the result it describes, your instance is ready for subscribers.
128
128
 
@@ -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,10 +89,10 @@ 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","tokenId":"e367c0ffee5eb4.access"},"auth":{"mode":"access"}}
92
+ {"principal":{"kind":"service","token_id":"e367c0ffee5eb4.access"},"auth":{"mode":"access"}}
90
93
  ```
91
94
 
92
- The `tokenId` is the Client ID Zero Trust shows beside `kestrel-claude`. If it's a different one, Claude Code is using another token.
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.
93
96
 
94
97
  1. Ask Claude to list the posts. It answers with the posts the editor shows.
95
98
 
@@ -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
@@ -53,14 +53,14 @@ A sent post's page under **Sent** shows its address with **Remove from archive
53
53
 
54
54
  ### Who receives it
55
55
 
56
- 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.
57
57
 
58
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)).
59
59
 
60
60
  ### Warnings when you schedule
61
61
 
62
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.
63
- - **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.
64
64
 
65
65
  ### When it sends
66
66
 
@@ -22,7 +22,8 @@ A problem with your email provider never marks anyone unsent. The send waits whe
22
22
  - **A test says "The provider refused it".** Your provider didn't take the test, and its own words follow, with the fix where Kestrel knows it. Sending again gets the same answer until that's fixed. While Amazon SES is in its sandbox, it sends only to addresses verified in SES, so verify the address you test with in the SES console, or request production access, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Words about the account, such as a key or the sending domain, point at the same fix as [a send the provider refuses](06-troubleshooting.md#a-send-is-waiting-or-held).
23
23
  - **A test says "The provider didn't answer, so the test may have arrived".** Kestrel can't tell whether your provider took it. Check the inbox before you send again, since a second test may arrive twice.
24
24
  - **"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.
25
- - **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.
25
+ - **The daily database writes warning.** On Cloudflare's free Workers plan, a send to more than about 5,000 recipients 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.
26
+ - **"Workers Free suits a list of up to about 1,000 subscribers".** Your list is past what the free plan suits. Upgrade to Workers Paid ([As your list grows](../guides/09-as-your-list-grows.md)). If the account is on Workers Paid already, set `SUBREQUEST_BUDGET` so Kestrel knows, and the notice goes.
26
27
 
27
28
  ## Reading a send's record
28
29
 
@@ -37,7 +38,7 @@ A send's page becomes its permanent record once it finishes. Its counts cover ev
37
38
 
38
39
  Delivered always means confirmed, not just accepted by your provider. Confirmations can lag by days, so counts keep rising after a send finishes. Some emails are never confirmed at all, so a send can read **Settling** for good, which is normal, as long as some receipts have come.
39
40
 
40
- **"No delivery receipt has arrived for any of …".** An hour after a send went out, Kestrel has heard back about none of its readers. Your provider's reports probably aren't reaching Kestrel, so its bounces and complaints suppress no one, and your next post mails those addresses again. Look for `webhook.refused` errors in the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes. It names the SNS topic that reached Kestrel and the one it expects. Then check the subscription in [Connect Amazon SES](../get-started/04-ses.md#9-subscribe-the-app-to-the-topic). The line goes as soon as a receipt arrives.
41
+ **"No delivery receipt has arrived for any of …".** An hour after a send went out, Kestrel has heard back about none of its readers. Your provider's reports probably aren't reaching Kestrel, so its bounces suppress no one and its complaints unsubscribe no one, and your next post mails those addresses again. Look for `webhook.refused` errors in the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes. It names the SNS topic that reached Kestrel and the one it expects. Then check the subscription in [Connect Amazon SES](../get-started/04-ses.md#9-subscribe-the-app-to-the-topic). The line goes as soon as a receipt arrives.
41
42
 
42
43
  **Unsent recipients** are still on your list, and get your next post. A recipient is unsent when their own email kept failing, or their address isn't one SES accepts, with the provider's words on the row, or when you chose **Assume not sent**.
43
44
 
@@ -63,19 +64,23 @@ When SES refuses your account or a send, the red line carries SES's words and th
63
64
 
64
65
  A server error from SES may come after SES already took the emails, so Kestrel never sends that batch again: its readers wait for **Resolve…**, as for "The provider never answered for …".
65
66
 
67
+ ## A reader says they never got a post
68
+
69
+ Under **Subscribers**, search for their address and open their row. Their page lists every send they were in, newest first, with what happened on each: **Delivered**, a bounce, a complaint, **Unsent** with your email provider's words, or **Skipped** because they had unsubscribed or were suppressed by then. Each send links to its record. The page also shows how they joined, any bounce or block on the address with the provider's words or yours, and why they left, if they did. If they aren't on the list, or are still **pending**, they never confirmed, and only confirmed subscribers are sent your posts.
70
+
66
71
  ## Bounces and complaints
67
72
 
68
73
  Kestrel acts on these for you, so a bad address can't keep hurting your sending reputation:
69
74
 
70
75
  - **A hard bounce** means the address can't receive mail. Kestrel never mails it again.
71
76
  - **A soft bounce** is temporary, such as a full mailbox. The address stays on your list.
72
- - **A complaint** means the reader marked your email as spam. Kestrel never mails them again.
77
+ - **A complaint** means the reader marked your email as spam. Kestrel unsubscribes them, whichever email they reported, and their page, opened from their row, says they left by a complaint.
73
78
 
74
- 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.
79
+ Under **Subscribers**, a bounced address keeps its status and carries a label saying why, and its page, opened from its row, gives your email provider's words for the bounce and when it happened. 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.
75
80
 
76
81
  ### Lifting a suppression
77
82
 
78
- 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.
83
+ 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. In a subscriber's ⋯ menu, **Unblock** lifts a block and **Clear** lifts a bounce, after asking you to confirm. A complaint isn't a suppression but the reader leaving, so nothing lifts it: they come back only by subscribing again themselves, and **Add subscriber** refuses them. Past records keep the bounce or complaint where it happened.
79
84
 
80
85
  ## An import is refused
81
86
 
@@ -10,7 +10,6 @@ To set Claude up, follow [Connect Claude to the API](../guides/01-connect-claude
10
10
  - **Test and schedule a post.** Name the post and when it should go out, and say so if it should be email only. Claude tells you whether the post will be in your archive, and at which address, before it schedules. Then it sends a test of exactly what will go out and tells you how to cancel. It can also cancel a send or move it.
11
11
  - **Take a sent post out of your archive, or put it back.** Claude asks first, as the dashboard does, since taking a post out breaks links people shared.
12
12
  - **Check how a send did.** Claude reads the send's record and reports what was delivered, unsent, bounced, or complained.
13
- - **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.
14
13
  - **Recover an earlier version.** Every save keeps a whole version of a post, and Claude can read them back.
15
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.
16
15
 
@@ -21,7 +20,7 @@ The app holds these for any client, so they hold whatever Claude is told:
21
20
  - **Every send waits out the review window,** and you can cancel it until it goes out.
22
21
  - **A scheduled post is locked,** and what goes out is exactly the copy that was scheduled.
23
22
  - **No one is mailed twice,** and only confirmed subscribers receive a post.
24
- - **An unsubscribe is final** until the reader subscribes again, and an erased address stays erased until the person confirms again.
23
+ - **An unsubscribe is final** until the reader subscribes again. Someone who complained or was erased comes back only by subscribing themselves: neither you nor Claude can add them.
25
24
  - **No one's edit is overwritten.** A save based on an older version is refused.
26
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.
27
26
 
@@ -10,9 +10,9 @@ A reader enters their address, gets a confirmation email, and presses **Confirm
10
10
 
11
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
12
 
13
- - **Already subscribed, bounced, complained, or blocked:** no email goes out.
13
+ - **Already subscribed, bounced, or blocked:** no email goes out.
14
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.
15
+ - **Unsubscribed earlier, for any reason, a complaint or an erasure included:** they get a confirmation, and confirming puts them back on your list. This form is the only way back for someone who complained or was erased.
16
16
 
17
17
  ## The confirmation email
18
18
 
@@ -2,7 +2,7 @@
2
2
 
3
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
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.
5
+ If you're bringing a list of more than about 1,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import it. 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. [As your list grows](../guides/09-as-your-list-grows.md) walks through the upgrade.
6
6
 
7
7
  ## Send yourself a test
8
8
 
@@ -26,5 +26,5 @@ Until an email provider is connected, nothing can be sent, and the dashboard say
26
26
 
27
27
  ## As your list grows
28
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.
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 (on the free plan, from about 1,000 subscribers), and [As your list grows](../guides/09-as-your-list-grows.md) covers raising each.
30
30
 
@@ -10,6 +10,8 @@ Kestrel won't save a template without `{{ .Post.Body }}` and `{{ .Email.Unsubscr
10
10
 
11
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
12
 
13
+ A region is a marked part, not a general `if`: `.IsEmail` is its only condition, there's no `else`, and regions don't nest. Kestrel won't save a template with a region left open, an `{{ end }}` with nothing to close, another condition, or `{{ .Post.Body }}` inside a region, which would leave the post's page empty. Keep each region's two tags inside the same element, outside any attribute, and not loose between a table's rows or cells, where the browser would move what's inside. A template that breaks that rule saves, but every email shows the built-in template instead until it's fixed, and the preview and a test say so. An unsubscribe link inside a region is fine, since every email still carries it.
14
+
13
15
  ## Variables
14
16
 
15
17
  A variable is replaced with its value in each email. Type it exactly as shown, or it renders as empty.
@@ -66,7 +66,7 @@ It's also how the app tells Workers Free from Workers Paid. At the default, the
66
66
 
67
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 (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.
69
+ - 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. So the Dashboard and the Subscribers screen recommend Workers Paid once the confirmed list passes 1,000, and so does an import plan that would take it past. The app also warns before a send to more than 5,000 recipients ([As your list grows](../guides/09-as-your-list-grows.md)). None of these stops you. Above the default, none appears, so set it on an account already on Workers Paid.
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
 
@@ -80,7 +80,7 @@ The floor is 60, since sends go out from a once-a-minute schedule. The ceiling i
80
80
 
81
81
  Leave them unset to keep everything on the app's own hostname.
82
82
 
83
- `ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname.
83
+ `ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname. Crawlers read your website's own `robots.txt` for the archive pages there. The app's `/llms.txt` stays on the app's hostname, and its archive link points at your website.
84
84
 
85
85
  `MEDIA_PUBLIC_BASE` serves images from another base URL, such as a custom domain on the image bucket. The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.
86
86