@kurtbruns/kestrel 1.12.0 → 1.14.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 (45) hide show
  1. package/CHANGELOG.md +137 -1
  2. package/README.md +1 -1
  3. package/bin/kestrel.mjs +17 -2
  4. package/cli/api.mjs +462 -0
  5. package/cli/credentials.mjs +192 -0
  6. package/cli/dev-api.mjs +17 -11
  7. package/cli/login.mjs +155 -0
  8. package/cli/oauth.mjs +368 -0
  9. package/cli/openapi.mjs +569 -0
  10. package/cli/seed.mjs +1 -1
  11. package/dist/public/dashboard/app.js +350 -295
  12. package/dist/public/dashboard/app.js.map +4 -4
  13. package/dist/public/dashboard/index.html +2 -2
  14. package/dist/public/dashboard/styles.css +466 -153
  15. package/dist/worker/index.js +10634 -5773
  16. package/docs/README.md +3 -2
  17. package/docs/get-started/02-deploy.md +18 -21
  18. package/docs/get-started/03-access.md +10 -8
  19. package/docs/get-started/04-ses.md +2 -2
  20. package/docs/get-started/05-verify.md +4 -4
  21. package/docs/get-started/06-go-live.md +3 -2
  22. package/docs/guides/01-allow-claude.md +68 -0
  23. package/docs/guides/02-automate.md +88 -0
  24. package/docs/guides/09-as-your-list-grows.md +8 -4
  25. package/docs/publishing/01-your-list.md +38 -33
  26. package/docs/publishing/02-sending-rules.md +3 -3
  27. package/docs/publishing/03-name-your-publication.md +1 -1
  28. package/docs/publishing/04-write-a-post.md +15 -52
  29. package/docs/publishing/06-troubleshooting.md +30 -14
  30. package/docs/publishing/08-connect-claude.md +60 -0
  31. package/docs/publishing/09-share-your-subscribe-link.md +3 -3
  32. package/docs/publishing/10-check-your-setup.md +9 -16
  33. package/docs/publishing/11-email-template.md +3 -1
  34. package/docs/publishing/README.md +2 -2
  35. package/docs/reference/01-configuration.md +132 -85
  36. package/docs/reference/02-sending-domain-dns.md +2 -2
  37. package/migrations/0012_unsubscribe_cause.sql +139 -0
  38. package/migrations/0013_who_acted_client.sql +20 -0
  39. package/migrations/0014_send_acts.sql +25 -0
  40. package/package.json +1 -1
  41. package/template/README.md +2 -0
  42. package/template/claude/CLAUDE.md +18 -14
  43. package/template/claude/skills/migrate-list/SKILL.md +5 -4
  44. package/docs/guides/01-connect-claude.md +0 -98
  45. package/docs/publishing/07-working-with-claude.md +0 -30
@@ -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:
@@ -26,7 +26,7 @@ grace@example.com,subscribed,single,mailchimp,
26
26
  edsger@example.com,unsubscribed,unknown,unknown,
27
27
  ```
28
28
 
29
- [The file format](01-your-list.md#the-file-format), below, describes every column. If you use Claude Code with your instance, it can do the conversion for you, as [Working with Claude](07-working-with-claude.md) describes.
29
+ [The file format](01-your-list.md#the-file-format), below, describes every column. Claude can do the conversion for you, once you [connect it](08-connect-claude.md). In your instance's repository, Claude Code's `/migrate-list` skill asks what anything ambiguous in your old service's export means, and converts a few rows for you to check.
30
30
 
31
31
  ## 3. Import it
32
32
 
@@ -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
 
@@ -108,7 +111,7 @@ An import happens in two steps. Planning shows what the import would do to every
108
111
  The commit needs two things from you when they apply:
109
112
 
110
113
  - **Your attestation,** when anyone would be confirmed: "Everyone I'm importing as subscribed opted in to receive [your publication]. This isn't a purchased, rented, or scraped list." It is kept with the import, with who gave it and when. An import that only unsubscribes or suppresses needs none.
111
- - **Each send that would grow,** acknowledged by a person. A post goes to the list as it stands when it fires. So an import can add to the audience of a scheduled send, or of one that has just fired and not yet fixed its audience. Claude can attest for you, but can't acknowledge a growing send: you finish that import in the dashboard.
114
+ - **Each send that would grow,** acknowledged by a person. A post goes to the list as it stands when it fires. So an import can add to the audience of a scheduled send, or of one that has just fired and not yet fixed its audience. Claude can attest for you, but only you in the editor can acknowledge a growing send, and Claude never speaks as the editor, so you finish that import in the dashboard.
112
115
 
113
116
  If another import is committed after you plan, plan again. If your list changes between the plan and the commit in any other way, such as a reader unsubscribing, the commit still goes ahead. Every write checks the list as it stands, and the answer reports any row that turned out differently.
114
117
 
@@ -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
- - **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.
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. A setting on your instance tells Kestrel which plan it's on, as [As your list grows](../guides/09-as-your-list-grows.md) describes.
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
@@ -1,6 +1,6 @@
1
1
  # Name your publication
2
2
 
3
- Give your newsletter a name and a look before your first post. Set the name, tagline, and logo under **Settings → Publication identity**. They appear in every email your template prints them in, on the public pages readers see, and in the confirmation email new subscribers get.
3
+ Give your publication a name and a look before your first post. Set the name, tagline, and logo under **Settings → Publication identity**. They appear in every email your template prints them in, on the public pages readers see, and in the confirmation email new subscribers get.
4
4
 
5
5
  All three are optional, and you can change them at any time. How they appear in each email comes from your email template, which you can [edit](11-email-template.md).
6
6
 
@@ -1,69 +1,32 @@
1
1
  # Write and send a post
2
2
 
3
- You write a post in Markdown, test it, and schedule it. Scheduling locks the post and makes the exact email that will go out. Then you send yourself a test of that copy, so the email you tested is the one your list receives.
3
+ Write in Markdown, schedule it, then send yourself a test. Scheduling makes the exact email your list will get, so the test you send is what goes out.
4
4
 
5
5
  **New post**, on the Dashboard or under **Drafts**, opens a draft.
6
6
 
7
- ## Writing
7
+ ## Write
8
8
 
9
- Kestrel's editor is a Markdown editor. You write a post in Markdown, the way you would for a static site generator or with an AI assistant. Kestrel places it in your [email template](11-email-template.md). The same Markdown makes the email and the post's archive page, so you never format a post twice. **Preview** shows the email as it will look, and Kestrel saves as you type.
9
+ The editor takes Markdown, and Kestrel saves as you type. The same Markdown becomes the email, in your [email template](11-email-template.md), and the post's page in your archive. **Preview** shows the email.
10
10
 
11
- A few things to know as you write:
12
-
13
- - **A blank line starts a new paragraph.** In an email, a single line break doesn't.
14
- - **The opening lines** are the preview a mail client shows beside the subject.
15
- - **Images** go in by paste or drop, and Kestrel inserts a reference by file name, such as `![cover.jpg](cover.jpg)`. Replace the words in brackets with a short description. PNG, JPEG, WebP, or GIF, up to 5 MB each.
16
- - **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
-
18
- ## Test it
19
-
20
- **Send test email** sends the real email, through the same template and code as the send, to the addresses you name. **Settings → Default test recipients** fills them in for you. Nobody on your list gets anything, nothing appears in your archive, and a test's unsubscribe link unsubscribes no one.
21
-
22
- Mail clients each draw email a little differently, so trust a test over **Preview**, especially for tables, code, and HTML. A test needs an email provider, which [Connect Amazon SES](../get-started/04-ses.md) sets up.
11
+ - A blank line starts a new paragraph.
12
+ - The opening lines are the preview a mail client shows beside the subject.
13
+ - Paste or drop an image to add it: PNG, JPEG, WebP, or GIF, up to 5 MB.
23
14
 
24
15
  ## Schedule and send
25
16
 
26
- Choose **Schedule** at the foot of the editor, pick a time under **Send at**, and confirm. The time must be at least the minimum lead away: five minutes, unless your deployment sets a different [`MIN_LEAD_SECONDS`](../reference/01-configuration.md#min_lead_seconds). Neither the dashboard nor Claude can shorten it. Times are to the minute, and one with seconds rounds up.
27
-
28
- Scheduling makes the email once, from the post, your template, and your publication's details as they are at that moment. That frozen copy is what goes out, and it's also the post's page in your archive. So the post is locked, and **Preview** and **Send test email** show the frozen copy.
29
-
30
- **Send yourself a test now.** It's the copy your list receives, and the time until the send is your window to catch a mistake, including one Claude made.
31
-
32
- Changing your template, or a publication detail it shows, re-makes the email of every scheduled post, with the same content and send time. Kestrel asks before it saves, and each re-made post then asks you to send a new test. A change is refused while a post is within the minimum lead of its send time, and Kestrel says when to try again. Posts already sending or sent never change.
33
-
34
- **Send now →**, under the time picker, is for a post you've already reviewed. It still waits out the window, scheduling the post for the minimum lead from now.
35
-
36
- ### 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
-
50
- ### Changing your mind
51
-
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.
53
-
54
- ### Who receives it
17
+ Choose **Schedule**, pick a time under **Send at**, and confirm. The time must be at least five minutes away by default, or the lead whoever runs your instance set. That's your window to catch a mistake, and you can cancel or move the send until then. **Send now** sends after the same wait.
55
18
 
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.
19
+ **Then send yourself a test.** Scheduling locks the post and makes its email, so **Send test email** now sends exactly what your list receives. **Settings → Default test recipients** fills in the addresses. A test needs an email provider, which [Connect Amazon SES](../get-started/04-ses.md) sets up.
57
20
 
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)).
21
+ To change the post, choose **Cancel** on its banner, and it's a draft again. **Edit schedule** moves the time without unlocking it.
59
22
 
60
- ### Warnings when you schedule
23
+ At the send time, the post goes to your confirmed subscribers, and its page goes up in your archive. Watch it go out under **Sent**. If a send needs you, its page says so, and [Troubleshooting](06-troubleshooting.md) covers each case.
61
24
 
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.
25
+ ## Post settings
64
26
 
65
- ### When it sends
27
+ **Post settings**, at the top right of the editor, holds two choices you can change until the post sends:
66
28
 
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.
29
+ - **Include in archive.** Uncheck it to send the post as email only, for a correction or a short notice. It stays off your archive and home page.
30
+ - **Archive address.** It follows the subject until you type your own. It's fixed once the post sends.
68
31
 
69
- If a send needs your attention, the send page and the dashboard say so. [Troubleshooting](06-troubleshooting.md) covers each case.
32
+ After a post sends, its page under **Sent** can take it out of your archive or put it back.
@@ -8,13 +8,13 @@ When a send needs you, its page and the dashboard's health block say so in a red
8
8
 
9
9
  A problem with your email provider never marks anyone unsent. The send waits where it is, and carries on from there once the problem clears. "No one has been marked unsent because of this" means this problem: readers whose own email failed earlier stay unsent.
10
10
 
11
- - **"The provider is refusing the account".** Your provider won't take mail from your account, and its own words follow, with the fix and who makes it. The fix is almost always in your Amazon SES account or Kestrel's deployment, made by whoever runs it. [What SES refuses, and the fix](#what-ses-refuses-and-the-fix), below, lists each one. Any other post that comes due meanwhile waits with it, without asking SES, and you're told once. While a quota frees up a few at a time, or while the SES sandbox takes only your verified addresses, SES takes some readers on each retry and refuses the rest: those it takes are sent, and the line stays, with the time it began, until SES takes everyone it's asked to.
12
- - **"The provider is refusing this send".** SES refuses this post's email itself, or a batch of it as invalid, rather than your account. Its words follow. Other posts go out as usual. If SES's words name a reader's address, remove that subscriber: the send skips them and carries on at its next retry. If SES refuses the email itself, the email can't change once its send has started, so whoever runs the deployment asks AWS Support why, quoting SES's words.
11
+ - **"The provider is refusing the account".** Your provider won't take mail from your account, and its own words follow, with the fix and who makes it. The fix is almost always in your Amazon SES account or your instance, made by whoever runs it. [What SES refuses, and the fix](#what-ses-refuses-and-the-fix), below, lists each one. Any other post that comes due meanwhile waits with it, without asking SES, and you're told once. While a quota frees up a few at a time, or while the SES sandbox takes only your verified addresses, SES takes some readers on each retry and refuses the rest: those it takes are sent, and the line stays, with the time it began, until SES takes everyone it's asked to.
12
+ - **"The provider is refusing this send".** SES refuses this post's email itself, or a batch of it as invalid, rather than your account. Its words follow. Other posts go out as usual. If SES's words name a reader's address, remove that subscriber: the send skips them and carries on at its next retry. If SES refuses the email itself, the email can't change once its send has started, so whoever runs your instance asks AWS Support why, quoting SES's words.
13
13
  - **"No email provider is connected".** The send is held until one is connected ([Connect Amazon SES](../get-started/04-ses.md)), and goes out once it is.
14
14
  - **"The provider is unavailable".** An outage or a rate limit on the provider's side. The send reads **Backing off** and says when it next retries, spacing its retries out up to an hour apart while the outage lasts, so there's nothing to do. Any other post that comes due meanwhile waits with it.
15
- - **"The provider keeps refusing for its sending rate".** An amber line. SES has refused the first request of several retries in a row for its sending rate, which a short burst never does, so Kestrel is set to send faster than your SES account allows. Whoever runs the deployment sets `SES_MAX_SEND_RATE` to the **Maximum send rate** on the SES console's **Account dashboard** (1 a second in the sandbox), and deploys, as [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy) describes. The send retries on its own meanwhile.
16
- - **"Nothing has gone out for …".** A send has handed off no one for half an hour, with no retry or provider problem to explain it. The part of Kestrel that sends has stopped or keeps failing on this send, or another send going out at the same time is taking all of its turn. A large send going out slowly never shows this. If another send is going out, this one picks up once it finishes. Read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes, and check the Worker's trigger, as [`triggers.crons`](../reference/01-configuration.md#triggerscrons) describes. The send carries on from where it stopped once it runs again, mailing no one twice.
17
- - **"The fire time passed … and the send has not started".** The Cloudflare Cron Trigger that starts sends on time isn't running. The send goes out as soon as it runs again. Check the Worker's trigger, as [`triggers.crons`](../reference/01-configuration.md#triggerscrons) describes.
15
+ - **"The provider keeps refusing for its sending rate".** An amber line. SES has refused the first request of several retries in a row for its sending rate, which a short burst never does, so Kestrel is set to send faster than your SES account allows. Whoever runs your instance sets `SES_MAX_SEND_RATE` to the **Maximum send rate** on the SES console's **Account dashboard** (1 a second in the sandbox), and deploys, as [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy) describes. The send retries on its own meanwhile.
16
+ - **"Nothing has gone out for …".** A send has handed off no one for half an hour, with no retry or provider problem to explain it. The part of Kestrel that sends has stopped or keeps failing on this send, or another send going out at the same time is taking all of its turn. A large send going out slowly never shows this. If another send is going out, this one picks up once it finishes. Read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes, and check the Worker's trigger, as [Configuration](../reference/01-configuration.md#bindings-and-triggers) describes. The send carries on from where it stopped once it runs again, mailing no one twice.
17
+ - **"The fire time passed … and the send has not started".** The Cloudflare Cron Trigger that starts sends on time isn't running. The send goes out as soon as it runs again. Check the Worker's trigger, as [Configuration](../reference/01-configuration.md#bindings-and-triggers) describes.
18
18
  - **"The provider never answered for …".** Rarely, Kestrel can't tell whether your provider took some emails, and sending again could mail those readers twice. **Resolve…** on the send's page asks you to decide. **Assume not sent** is the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it.
19
19
 
20
20
  ## Before a send
@@ -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,24 +38,24 @@ 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
 
44
45
  ### What SES refuses, and the fix
45
46
 
46
- When SES refuses your account or a send, the red line carries SES's words and the fix. Almost every fix is made by whoever runs the deployment, in the SES console or the deployment's settings.
47
+ When SES refuses your account or a send, the red line carries SES's words and the fix. Almost every fix is made by whoever runs your instance, in the SES console or its settings.
47
48
 
48
49
  | SES says | What it means | The fix |
49
50
  | --- | --- | --- |
50
51
  | Email address is not verified, naming a reader | The SES account is still in the sandbox, which mails only verified addresses. | Request production access on the SES console's **Account dashboard**, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Signups fail until then, and you're emailed about it once a day. |
51
- | Email address is not verified, naming your From address or its domain | The sending identity isn't verified in this SES region. | Verify the From address or its domain in SES, in the region the deployment sends from. |
52
+ | Email address is not verified, naming your From address or its domain | The sending identity isn't verified in this SES region. | Verify the From address or its domain in SES, in the region your instance sends from. |
52
53
  | Mail FROM domain is not verified | SES can't read the custom MAIL FROM domain's MX record. | Check that record in DNS, or set the identity's **Behavior on MX failure** to use the default MAIL FROM domain. |
53
54
  | Daily message quota exceeded | The 24-hour sending quota is spent. It frees up on its own. | In the sandbox (200 a day), request production access. Otherwise, ask AWS for a higher quota, as [As your list grows](../guides/09-as-your-list-grows.md) describes. |
54
55
  | Sending paused (account) | AWS paused the account under review, or sending was turned off by hand. | Follow AWS's email to the AWS account's address, or turn sending back on. |
55
56
  | Sending paused (configuration set) | Sending is turned off on the configuration set, by hand or by an alarm. | Turn sending back on for that set. |
56
57
  | Account suspended | AWS shut down the account's sending. | Only AWS can lift it: open a case with AWS Support. |
57
- | An access key, signature, or permission error | The deployment's SES key is wrong, expired, or lacks permission. | Replace the key, or give it `ses:SendEmail` and `ses:SendBulkEmail`. |
58
+ | An access key, signature, or permission error | Your instance's SES key is wrong, expired, or lacks permission. | Replace the key, or give it `ses:SendEmail` and `ses:SendBulkEmail`. |
58
59
  | A subscription is needed (OptInRequired) | The AWS account isn't signed up for SES. | Enable SES for the account, or settle its activation or billing with AWS. |
59
60
  | Configuration set does not exist | `SES_CONFIGURATION_SET` names a set that isn't in this region. | Fix the name, or create the set. |
60
61
  | Sending pool not found | The configuration set uses a dedicated IP pool that doesn't exist. | Pick an existing pool in the set's sending options, or remove it. |
@@ -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
 
@@ -88,6 +93,17 @@ Kestrel refuses a file it can't read with certainty, and says what's wrong and w
88
93
 
89
94
  [The file format](01-your-list.md#the-file-format) describes what each column takes.
90
95
 
96
+ ## Claude can't connect
97
+
98
+ [Connect Claude](08-connect-claude.md) covers connecting it. When that fails, find what Claude or the dashboard says:
99
+
100
+ - **Claude says it couldn't connect, or the sign-in failed.** Your instance's Access application needs Managed OAuth set up as [Turn on Managed OAuth](../guides/01-allow-claude.md#1-turn-on-managed-oauth) describes, including Claude's sign-in address for Claude Desktop and claude.ai. Check that the connector's address is your publication's address followed by `/api/mcp`, and that your instance is deployed, not running on your own computer.
101
+ - `kestrel login` says your instance "doesn't offer a sign-in for programs". Managed OAuth is off. Turn it on, as the same section describes.
102
+ - `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in. If you set `ACCESS_ALLOWED_EMAILS`, check that it includes the address you signed in with. If `kestrel login` names `access_unidentified` after the `401`, or `kestrel api` prints `reason: access_unidentified`, it's the next problem: both then say that Access sent neither an email address nor a service token. If `kestrel api` says your sign-in has ended, sign in again as [Connect Claude](08-connect-claude.md#claude-code) describes.
103
+ - **"Access let you in, but didn't say who you are".** The editor shows this, and the API refuses with a `401` whose `reason` is `access_unidentified`, when your login carried neither an email nor a service token. Some other kind of Access policy let it through, such as a certificate or device rule, or a login method that gives no email. Kestrel accepts only an Allow policy on people's emails and a Service Auth policy on a service token. Sign out, then sign in through the `Publishers` policy. Whoever runs your instance removes the other policy from its Access application, or changes it to one of those two kinds.
104
+ - **Claude's changes read "via the API".** A request made with your sign-in named no client, such as one from Claude calling the API some way other than `kestrel api`, which names Claude Code. Your instance's instructions for Claude Code have it use `kestrel api`, once they're brought up to date, as [Install the release](../guides/07-upgrade.md#2-install-the-release) describes. Changes reading "a service token" mean Claude still uses one, and [Connect Claude](08-connect-claude.md#claude-code) signs it in as you instead.
105
+ - A script gets a `401` whose `reason` is `access_unidentified`. Access let the script in through a policy Kestrel doesn't accept. It needs a service token under a Service Auth policy, as [Allow the token in](../guides/02-automate.md#2-allow-the-token-in) describes.
106
+
91
107
  ## Notifications
92
108
 
93
109
  With an address set, Kestrel emails you:
@@ -96,7 +112,7 @@ With an address set, Kestrel emails you:
96
112
  - when your provider refuses your account or a send, or no provider is connected
97
113
  - when the SES sandbox refuses a signup's confirmation, at most once a day
98
114
  - when a send stops going out with nothing to explain it, waits for you to resolve it, or misses its time
99
- - when Claude imports a list for you
115
+ - when a service token imports a list, since no one saw it happen (an import you make yourself, or through Claude, sends nothing)
100
116
 
101
117
  Each links to the page it's about, and is sent once while the problem lasts. A bounce spike shows on the dashboard but isn't emailed. **Send a test notification** checks that one arrives.
102
118
 
@@ -0,0 +1,60 @@
1
+ # Connect Claude
2
+
3
+ Connecting Claude to Kestrel lets you publish by asking Claude. It drafts, tests, and schedules posts as you, and its changes show as yours, via Claude.
4
+
5
+ **Your Kestrel instance has to allow this first,** as [Allow Claude to connect](../guides/01-allow-claude.md) describes.
6
+
7
+ How you connect depends on where you talk to Claude:
8
+
9
+ - **In Claude Desktop or claude.ai,** add Kestrel as a connector.
10
+ - **In Claude Code,** add Kestrel as a connector, or sign in with the Kestrel CLI to use the API directly.
11
+
12
+ ## Claude Desktop or claude.ai
13
+
14
+ 1. In Claude, go to **Customize → Connectors**, select **Add**, then **Add custom connector**.
15
+ 1. Name it, and enter your publication's address followed by `/api/mcp`, such as `https://newsletter.example.com/api/mcp`.
16
+ 1. Under **OAuth client**, choose **Register automatically**.
17
+ 1. Select **Add**, then sign in the way you do for the dashboard, and approve the sign-in.
18
+
19
+ On a Team or Enterprise plan, an Owner of your Claude organization adds the connector under **Organization settings → Connectors** instead, and you then select **Connect** on it. Anthropic's [custom connectors guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) has the details.
20
+
21
+ To check it works, ask Claude what your publication is called.
22
+
23
+ ## Claude Code
24
+
25
+ Claude Code can use the same connector as Claude Desktop and claude.ai. To reach the whole API instead, images included, sign Claude Code in with the Kestrel CLI:
26
+
27
+ 1. Run this, with your publication's address:
28
+
29
+ ```bash
30
+ npx -p @kurtbruns/kestrel kestrel login https://newsletter.example.com
31
+ ```
32
+
33
+ 1. Approve the sign-in in the browser that opens. The terminal then says who you're signed in as.
34
+
35
+ The sign-in stays on your computer. Opened in your instance's repository, Claude Code then calls Kestrel's API as you through `kestrel api`, the way `gh` calls GitHub, following your instance's instructions for Claude. Its changes show as yours, via Claude Code.
36
+
37
+ ## How long a sign-in lasts
38
+
39
+ The sign-in renews itself while you use Claude, until your instance's grant session ends, a week if it follows the setup guide. Then Claude asks you to approve a new one.
40
+
41
+ To disconnect, remove the connector under **Customize → Connectors**. To sign out of the Kestrel CLI, run `npx -p @kurtbruns/kestrel kestrel logout`.
42
+
43
+ ## Working with Claude
44
+
45
+ Ask Claude in plain words, the way you'd ask a colleague, and ask it what it can do: it knows its tools, and reads these pages from Kestrel. For example:
46
+
47
+ - "Proofread my draft titled 'First frost' and schedule it to go out Friday at 7am."
48
+ - "Add jack@example.com to the list."
49
+ - "Import the subscribers in this CSV from my old newsletter."
50
+
51
+ Whatever Claude is asked, Kestrel holds these for any client:
52
+
53
+ - **Every send waits out the review window,** and you can cancel it until it goes out.
54
+ - **What goes out is exactly the copy that was scheduled.** A scheduled post is locked.
55
+ - **No one is mailed twice,** and only confirmed subscribers receive a post.
56
+ - **An unsubscribe is final** until the reader subscribes again.
57
+ - **No one's edit is overwritten.** A save based on an older version is refused.
58
+ - **Only you can let an import grow a scheduled send,** in the dashboard.
59
+
60
+ Changes Claude makes show in the dashboard as yours, such as "Scheduled by you@example.com via Claude".
@@ -1,6 +1,6 @@
1
1
  # Share your subscribe link
2
2
 
3
- Your publication's subscribe page is at `/subscribe` on your Kestrel's address, such as `https://newsletter.example.com/subscribe`. Copy it from the dashboard's checklist or **Settings → Ways to subscribe**, and share it wherever your readers are. A reader joins only once they confirm by email.
3
+ Your publication's subscribe page is at `/subscribe` on your publication's address, such as `https://newsletter.example.com/subscribe`. Copy it from the dashboard's checklist or **Settings → Ways to subscribe**, and share it wherever your readers are. A reader joins only once they confirm by email.
4
4
 
5
5
  **Signups open only once an email provider is connected.** Until then, the page says the newsletter isn't taking subscribers yet, and the dashboard says so too. You can share the link anyway, and it opens once email is connected ([Connect Amazon SES](../get-started/04-ses.md)).
6
6
 
@@ -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
 
@@ -1,30 +1,23 @@
1
1
  # Check your setup
2
2
 
3
- Before your readers get anything, see it arrive yourself, and make sure a problem will reach you without your watching for it. Send yourself a test and reply to it, subscribe yourself, and set where Kestrel emails you.
3
+ Before your readers get anything, check three things yourself: your email arrives and replies reach you, subscribing works, and problems will reach 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
+ **Email has to be connected first.** Until it is, nothing can be sent, and the dashboard's checklist shows **Connect Amazon SES** in place of this step, as [Connect Amazon SES](../get-started/04-ses.md) describes.
6
6
 
7
- ## Send yourself a test
7
+ ## 1. Send yourself a test
8
8
 
9
9
  Choose **Template** in the sidebar, then **Send test email**, and send a sample post to your own inboxes. When it arrives, check:
10
10
 
11
- - **The From name and address,** which readers see first. They're set when Kestrel is deployed ([Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy)).
11
+ - **The From name and address,** which readers see first. They're set when your instance is deployed.
12
12
  - **Where it lands:** the inbox, Promotions, or spam. If it lands in spam, check your sending domain's records, as [Sending-domain DNS](../reference/02-sending-domain-dns.md) describes.
13
13
  - **A reply.** Reply to the test, and check that it reaches your inbox. Kestrel never sees replies, so this depends on [forwarding your From address](../get-started/04-ses.md#3-receive-replies-at-your-from-address).
14
14
 
15
- ## Subscribe yourself
15
+ ## 2. Subscribe yourself
16
16
 
17
- Open your subscribe page, from the link under **Settings → Ways to subscribe**, and subscribe with a personal address. Then confirm. You see what a new reader sees, and you then appear under **Subscribers** as **Confirmed**. [Share your subscribe link](09-share-your-subscribe-link.md) covers the page and the confirmation email.
17
+ Open your subscribe page from **Settings → Ways to subscribe**, subscribe with a personal address, and confirm. You see what a new reader sees, and you appear under **Subscribers** as **Confirmed**.
18
18
 
19
- ## Set your notifications address
19
+ ## 3. Set your notifications address
20
20
 
21
- Under **Settings → Notifications**, fill in **Send notifications to**, save, and choose **Send a test notification**. Kestrel then emails you when a send finishes or needs you. [Notifications](06-troubleshooting.md#notifications) lists what you're told, and why a second way to send them is worth asking for.
22
-
23
- ## If email isn't connected
24
-
25
- Until an email provider is connected, nothing can be sent, and the dashboard says so. The checklist shows **Connect Amazon SES** in place of this step, as [Connect Amazon SES](../get-started/04-ses.md) describes. **Settings → Email sender** shows your From address, sending domain, and provider, read-only.
26
-
27
- ## As your list grows
28
-
29
- A growing list meets your Amazon SES daily sending quota, longer sends, and on Cloudflare's free Workers plan, a cap on daily database writes. Kestrel tells you when you reach each one, and [As your list grows](../guides/09-as-your-list-grows.md) covers raising each.
21
+ Under **Settings → Notifications**, fill in **Send notifications to**, save, and choose **Send a test notification**. Kestrel then emails you when a send finishes or needs you, as [Notifications](06-troubleshooting.md#notifications) lists.
30
22
 
23
+ **If you're bringing more than about 1,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import.** [Bring your existing list](01-your-list.md#limits) explains why.