@kurtbruns/kestrel 1.10.0 → 1.12.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 +64 -1
  2. package/README.md +17 -28
  3. package/cli/reset.mjs +2 -0
  4. package/cli/seed.mjs +2 -0
  5. package/demo/README.md +4 -2
  6. package/demo/posts/0-test-email/index.md +13 -0
  7. package/demo/posts/5-a-quick-note/index.md +2 -0
  8. package/dist/public/dashboard/app.js +302 -293
  9. package/dist/public/dashboard/app.js.map +4 -4
  10. package/dist/public/dashboard/index.html +2 -2
  11. package/dist/public/dashboard/styles.css +434 -54
  12. package/dist/worker/index.js +1472 -470
  13. package/docs/README.md +2 -2
  14. package/docs/get-started/02-deploy.md +12 -5
  15. package/docs/get-started/03-access.md +2 -0
  16. package/docs/get-started/04-ses.md +36 -16
  17. package/docs/get-started/05-verify.md +36 -8
  18. package/docs/get-started/06-go-live.md +13 -55
  19. package/docs/guides/01-connect-claude.md +7 -5
  20. package/docs/guides/03-notifications.md +10 -10
  21. package/docs/guides/09-as-your-list-grows.md +62 -0
  22. package/docs/publishing/01-your-list.md +118 -47
  23. package/docs/publishing/02-sending-rules.md +5 -5
  24. package/docs/publishing/03-name-your-publication.md +7 -93
  25. package/docs/publishing/04-write-a-post.md +37 -66
  26. package/docs/publishing/06-troubleshooting.md +103 -0
  27. package/docs/publishing/07-working-with-claude.md +19 -37
  28. package/docs/publishing/09-share-your-subscribe-link.md +11 -59
  29. package/docs/publishing/10-check-your-setup.md +12 -30
  30. package/docs/publishing/11-email-template.md +34 -0
  31. package/docs/publishing/README.md +12 -11
  32. package/docs/reference/01-configuration.md +6 -6
  33. package/docs/reference/02-sending-domain-dns.md +1 -1
  34. package/migrations/0007_sends_progress_at.sql +12 -0
  35. package/migrations/0008_notifications_sandbox.sql +35 -0
  36. package/migrations/0009_post_email_only.sql +12 -0
  37. package/migrations/0010_unlisted_links.sql +43 -0
  38. package/migrations/0011_sends_render_warnings.sql +13 -0
  39. package/package.json +1 -1
  40. package/template/claude/CLAUDE.md +5 -2
  41. package/template/claude/skills/migrate-list/SKILL.md +2 -2
  42. package/docs/guides/08-try-locally.md +0 -108
  43. package/docs/publishing/05-scheduling-and-sending.md +0 -72
  44. package/docs/publishing/06-after-a-send.md +0 -109
  45. package/docs/publishing/08-import-format.md +0 -103
@@ -0,0 +1,13 @@
1
+ -- 0011_sends_render_warnings: what the render said about a send's frozen copy (SPEC §5, §6).
2
+ --
3
+ -- The render answers its email with warnings, such as an image the post references but never
4
+ -- uploaded. A draft's preview and test show them from a live render, but once the post was
5
+ -- scheduled the instruments read the frozen copy and had nothing to say, so a test of a copy
6
+ -- missing its image answered clean. The freeze and the re-make now store the warnings their
7
+ -- render gave, as a JSON array of strings, beside the copy they describe.
8
+ --
9
+ -- A send frozen before this column existed reads as `[]`: its render's warnings were never
10
+ -- kept, and rendering it again could disagree with the bytes it holds. Adding a column with a
11
+ -- constant default rewrites no rows.
12
+
13
+ ALTER TABLE sends ADD COLUMN render_warnings TEXT NOT NULL DEFAULT '[]';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurtbruns/kestrel",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "type": "module",
5
5
  "description": "An open-source, self-hosted newsletter app where you own the list and the archive. Work in the editor, or just ask Claude. Runs on Cloudflare Workers.",
6
6
  "license": "MIT",
@@ -28,8 +28,11 @@ Deploying, migrating the production database, and anything that sends mail or ch
28
28
  - **Read `GET /api/reference` first,** and use it for every call: each route's method, path, body types, and an example.
29
29
  - **Save a post against the revision you read.** Send its `current_revision` back as `base_revision`, or as `If-Match` with the post's `ETag`. On `stale_revision`, someone else changed the post (the refusal's `author` says who): read it again and re-apply your change to the newer revision, or ask. Never overwrite their edit.
30
30
  - **Act on a send from its `actions`,** which list only what the server would accept on it now. An act is safe to repeat, and every answer and every refusal carries the send as it now stands: read that before telling the publisher what happened or deciding again.
31
- - **Schedule only when the publisher asks, then send a test.** Once a post is scheduled, a test sends the frozen copy, exactly what will fire, to one address per call: one to each of `testRecipients` from `GET /api/settings`, or ask. Tell them when the send fires and how to cancel it. A template or identity change re-makes scheduled sends, so test again after one.
32
- - **Until AWS has granted SES production access, never help share the subscribe link or send to anyone but the addresses verified in SES.** The sandbox refuses every other recipient, so their signups fail and a send marks them unsent. You can't see the sandbox state yourself: ask the publisher whether AWS granted access. Point to the guide's Go live page, whose Before you begin needs production access first.
31
+ - **Schedule only when the publisher asks, then send a test.** Once a post is scheduled, a test sends the frozen copy, exactly what will fire, to one address per call: one to each of `testRecipients` from `GET /api/settings`, or ask. Tell them when the send fires, where the post goes, and how to cancel it.
32
+ - **Re-make a scheduled send only on the publisher's word.** Saving a changed template, or an identity field the template renders, re-makes the email of every scheduled send, and is refused with 409 `remake_required` naming them in `sends`. Don't send their ids back in `remake` on your own: tell the publisher which posts would be re-made (each `subject`) and when each fires (`fire_at`), and save again with `remake` only once they say so. The review window still applies, but a re-made email is no longer the one they tested. Then send a test of each send in the response's `remade`, as above, since each needs a fresh one.
33
+ - **Say where a post goes before it's scheduled or sent.** A post is listed in the public archive unless it is **email only**, mailed as usual but left off the archive and the home page. Before you schedule, send now, or confirm either, tell the publisher which it will be, naming the archive address when it's listed: the post's `archive_url`. Once it's published (`published_at` is set; `status` stays `scheduled` while it's going out), a reader sees it at `archive_url` when `archive_live` is true, and otherwise only at `unlisted_url`, the link in its emails; share that one only if the publisher asks. Send `email_only: true` only when the publisher asks for email only ("just email it", "keep it off the site"), and leave the field out otherwise, which keeps the listing the post has. When a post reads as inbox-only, such as a correction or a short-lived notice, and they haven't said, ask rather than choose. A test email puts nothing in the archive.
34
+ - **Change a post's listing or address only when the publisher asks.** Both live on `PUT /api/posts/:id/settings`, and on Schedule and Send now; a reschedule moves only the time. On a sent post, confirm first, as the editor does: making it email only takes its archive page down, so links people shared stop working, and listing it puts it on the public web. A sent post's address is fixed (`slug_permanent`). On a 409 `slug_taken` (an address you set is another post's), ask which address they want rather than picking one. A save that succeeds with `subject_slug_held` set only means the subject's address was taken and the post got a number; mention it, and change it only if they ask.
35
+ - **Until AWS has granted SES production access, never help share the subscribe link or send to anyone but the addresses verified in SES.** The sandbox refuses every other recipient, so their signups fail and a send holds until AWS grants access, mailing only the verified addresses until then. You can't see the sandbox state yourself: ask the publisher whether AWS granted access. Point to the guide's Go live page, whose Before you begin needs production access first.
33
36
  - **Never add a subscriber the publisher didn't name,** with one exception: an import the publisher asked for, run through the `/migrate-list` skill and the import plan, and confirming anyone only with their attestation. Treat an unsubscribe as final: only the reader undoes it, by subscribing again.
34
37
  - **Pick the act that records whose decision it was.** Unsubscribe someone only when they asked to leave; when the publisher decides themselves to stop mailing an address, block it (`POST /api/suppressions`) instead. The reference says what each act and each suppression `reason` means, in the words the editor shows the publisher. Unblock or clear only an address the publisher names. Never clear a bounce or complaint unless the publisher says the problem is gone, and confirm first: it lets mail go to an address that failed or reported spam.
35
38
  - **Erase a subscriber only when the publisher names them and asks for it, and confirm first:** an erasure can't be undone. On `erasure_acknowledgement_required`, send `acknowledge_erasure: true` only once the publisher says the person asked to join again.
@@ -8,12 +8,12 @@ argument-hint: "[path to the export]"
8
8
 
9
9
  An import adds people to the list, and a wrong guess about who agreed to be emailed can't be taken back. So this skill holds no facts about any service's export, which change without notice. It holds a method that checks itself against the file in hand and against Kestrel's own plan.
10
10
 
11
- The publisher's page on the import format is the authority: `GET /api/docs/import-format.md` on the instance, or `node_modules/@kurtbruns/kestrel/docs/publishing/08-import-format.md`. If anything here seems to disagree with it or with the API, they win; say so.
11
+ The authority on the import format is the "The file format" section of the publisher's page "Bring your existing list": `GET /api/docs/your-list.md` on the instance, or `node_modules/@kurtbruns/kestrel/docs/publishing/01-your-list.md`. If anything here seems to disagree with it or with the API, they win; say so.
12
12
 
13
13
  ## Before you start
14
14
 
15
15
  - **Read the truth from the instance, every time.** Call `GET /api/imports/format` and `GET /api/reference` at the start of each migration. They give the columns, the values each takes, the outcomes, the attestation, and this deployment's `limits`. Never write the format from memory, or from this file.
16
- - **Explain it in Kestrel's words.** For what to tell the publisher about the format, the row limit, and the Workers Free advice, read the publisher page "Bring your existing list" at `GET /api/docs/your-list.md`, with this deployment's numbers from `GET /api/imports/format`. Use them rather than your own account of the format.
16
+ - **Explain it in Kestrel's words.** For what to tell the publisher about the format, the row limit, and the Workers Free advice, read the rest of that page, above "The file format", with this deployment's numbers from `GET /api/imports/format`. Use them rather than your own account of the format.
17
17
  - **Convert to the format the route names as `preferred`,** the full CSV. Fall back to a plain list only when the export holds nothing but addresses, and tell the publisher what a plain list loses, from the route's own `loses`.
18
18
  - **Work outside the repository.** Make a working folder outside this repository, such as `~/kestrel-migration/`, for the export, the converter, its tests, and the converted pieces. They hold real addresses, so nothing from the list is ever committed here. A fixture that must be committed uses made-up rows only.
19
19
 
@@ -1,108 +0,0 @@
1
- # Try Kestrel locally first
2
-
3
- You can run Kestrel on your own computer to look around before you deploy it. It needs no Cloudflare account and no Git host. Email goes to a stand-in on your computer, so nothing reaches a real inbox.
4
-
5
- In this guide, you write a new instance into an empty folder, start the app, and open the editor. Then you fill it with a demo publication, and empty it again. The folder can become your instance's repository later.
6
-
7
- ## Before you begin
8
-
9
- - [Node.js](https://nodejs.org/) 22 or later
10
-
11
- ## 1. Write a new instance
12
-
13
- An instance is a small folder of your own. It holds the instance's settings and names the release of Kestrel it runs, which installs from npm as `@kurtbruns/kestrel`.
14
-
15
- 1. Make an empty folder, and go into it:
16
-
17
- ```bash
18
- mkdir newsletter
19
- cd newsletter
20
- ```
21
-
22
- 1. Write a new instance into it:
23
-
24
- ```bash
25
- npx @kurtbruns/kestrel init
26
- ```
27
-
28
- It writes the instance's files, and lists the commands to run next. The folder doesn't need to be a Git repository.
29
-
30
- 1. Install the release and its tools:
31
-
32
- ```bash
33
- npm install
34
- ```
35
-
36
- ## 2. Start the app
37
-
38
- 1. Copy the local settings:
39
-
40
- ```bash
41
- cp .dev.vars.example .dev.vars
42
- ```
43
-
44
- They're for your computer only, and send email to the stand-in.
45
-
46
- 1. Start the app:
47
-
48
- ```bash
49
- npm run dev
50
- ```
51
-
52
- The first time, it creates the local database and asks you to confirm its migrations. Answer yes. The server keeps running in this terminal, and its output ends with the address it's ready on.
53
-
54
- 1. Open http://localhost:8787/dashboard/ to see the editor. There's nothing to sign in with on your computer. If another app already uses port 8787, Kestrel picks a free port instead, and the `Ready on` line shows it.
55
-
56
- ## 3. Load the demo publication
57
-
58
- The demo fills the editor with posts, subscribers, and sends that already went out, so you can see the app in use.
59
-
60
- 1. In a second terminal, in the same folder, load the demo:
61
-
62
- ```bash
63
- npm run seed
64
- ```
65
-
66
- It replaces everything in the local database, and lists links to the editor and the demo's published posts.
67
-
68
- 1. To empty the local database again, and see the app as a new instance starts:
69
-
70
- ```bash
71
- npm run reset
72
- ```
73
-
74
- 1. To stop the server, press <kbd>Ctrl</kbd>+<kbd>C</kbd> in its terminal. Your local content stays for the next `npm run dev`.
75
-
76
- ## Check it
77
-
78
- 1. The app answers. If `npm run dev` picked another port, use the one on its `Ready on` line here and below:
79
-
80
- ```bash
81
- curl http://localhost:8787/health
82
- ```
83
-
84
- ```
85
- {"status":"ok","service":"kestrel"}
86
- ```
87
-
88
- 1. After `npm run seed`, http://localhost:8787/ shows the demo publication's public landing page, with its posts.
89
-
90
- ## Keep the folder for your instance
91
-
92
- If you came here from Deploy the app, your folder is already a repository: go back to [Sign in to Cloudflare](../get-started/02-deploy.md#2-sign-in-to-cloudflare).
93
-
94
- When you're ready to deploy, this folder can be your instance's repository. You need [Git](https://git-scm.com/) and a GitHub account for this part. The folder's `.gitignore` already keeps your local settings, the local database, and the installed packages out of it.
95
-
96
- 1. On GitHub, [create a new repository](https://github.com/new) named `newsletter`. It can be private. Leave it empty, with no README, license, or `.gitignore`.
97
-
98
- 1. Make the folder a repository, and commit the instance. Replace `YOUR_USERNAME` with your GitHub username:
99
-
100
- ```bash
101
- git init
102
- git add -A
103
- git commit -m "Start a Kestrel instance"
104
- git remote add origin https://github.com/YOUR_USERNAME/newsletter.git
105
- git push -u origin HEAD
106
- ```
107
-
108
- Then check [what deploying needs](../get-started/02-deploy.md#before-you-begin) and [choose your hostnames](../get-started/02-deploy.md#choose-your-hostnames). Then [deploy the app](../get-started/02-deploy.md#2-sign-in-to-cloudflare) from signing in to Cloudflare onward.
@@ -1,72 +0,0 @@
1
- # Scheduling and sending
2
-
3
- Every post goes out on a schedule. Scheduling makes the email and locks the post, and the send stays visible and cancelable until its time comes. That window is your review, and nothing can skip it, not even **Send now**.
4
-
5
- This page covers scheduling, what it freezes, canceling, moving the send time, and sending now. It also covers who receives a post, and what happens when it goes out.
6
-
7
- ## Scheduling a post
8
-
9
- **Schedule** at the foot of the editor opens a dialog where you pick a time under **Send at**, then confirm with **Schedule**. The post goes out at that time, and until then you can cancel it or move it.
10
-
11
- The time you pick must be at least the minimum lead away. That's five minutes, unless whoever runs your Kestrel set it differently ([Configuration](../reference/01-configuration.md#min_lead_seconds)). A time closer than that is refused under the picker, with the reason. The lead isn't a setting in the dashboard, and Claude can't change it either. So every send gets a window to catch a mistake, including one Claude prepared.
12
-
13
- Send times are to the minute. A time with seconds is rounded up to the next whole minute, which only lengthens the window.
14
-
15
- A post needs a subject to schedule. If the subject is empty, pressing **Schedule** marks the **Subject** field with "Add a subject before you schedule." An empty body gets a warning, not a refusal.
16
-
17
- After you schedule, you stay on the post. A toast names the send time and suggests you send yourself a test, since the window is where you review. The post now shows the scheduled banner: "Scheduled for" the time, with a countdown ("Sends in …"), and **Reschedule** and **Cancel** beside it.
18
-
19
- You manage a scheduled post from that banner, or from the **Scheduled** card on **Sent**. The dashboard's **Scheduled** card shows the countdown and opens the post.
20
-
21
- ## What scheduling freezes
22
-
23
- Scheduling makes the email once, from the post, your email 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.
24
-
25
- So the post is locked while it's scheduled. It opens on **Preview**, which shows the frozen copy. On **Edit**, you can still read, select, and copy the text, and the composer's foot says "Cancel the schedule to edit". **Send test email** still works, and sends you the frozen copy that will go out.
26
-
27
- To change the post's content, cancel the schedule, edit, test again, and schedule it again. That resets your review, so your last test is always of the copy that sends.
28
-
29
- A change to your template, or to a publication detail the template shows, is different. It re-makes the email of every scheduled post, at the same send times, with the same content. Kestrel asks before it saves, in a dialog confirmed with **Save and apply**. Afterwards each re-made post's banner asks you to send a new test. The dashboard's card reads "Look changed since your last test" until you do. A save like this is refused while any scheduled post is within the minimum lead of its send time. The refusal says when to try again. [Name your publication](03-name-your-publication.md) covers the template and those details.
30
-
31
- ## Canceling
32
-
33
- **Cancel** on the scheduled banner, or on the **Scheduled** card on **Sent**, stops the send at once. No confirmation is asked, since nothing is lost: the post is a draft again, editable, and you can schedule it again whenever you like.
34
-
35
- You can cancel until the send time itself. From then the banner reads "Preparing to send…" and its buttons are unavailable, because the email is going out. A cancel that arrives just after the send time is refused with "Too late to cancel: the send time has passed, so this email is going out."
36
-
37
- ## Moving the send time
38
-
39
- **Reschedule** moves when a scheduled post sends, without canceling it. It opens the same time picker, and **Reschedule** confirms it. The content stays frozen, your tests still count, and the post stays cancelable until the new time.
40
-
41
- The new time follows the same rule as scheduling: at least the minimum lead away. Like canceling, you can move a send only before its send time.
42
-
43
- ## Sending now
44
-
45
- Under the time picker in the Schedule dialog, "Or send as soon as possible." leads to **Send now →**. It's for a post you've already reviewed and want out without picking a time.
46
-
47
- Sending now still goes through the review window. The **Send now?** view says how many recipients it reaches and when, such as "in 5 to 6 minutes". The range is the minimum lead, plus rounding up to the next minute. Confirm with **Send now**, and the post is locked and scheduled for that time, cancelable until then like any other.
48
-
49
- ## Who receives it
50
-
51
- 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 the post is scheduled receives it. Someone who unsubscribes before their email is handed to your email provider doesn't, even partway through the send.
52
-
53
- So the recipient count the **Send now?** view shows is a snapshot, not a promise. An import that adds subscribers while a post is scheduled adds them to its audience too. The import asks you to acknowledge that first ([Bring your existing list](01-your-list.md#what-an-import-does)).
54
-
55
- ## Warnings in the Schedule dialog
56
-
57
- The Schedule dialog warns you about two things before you schedule.
58
-
59
- - **No email provider connected.** The dialog says the post can't be scheduled yet, and its buttons are unavailable. Connecting a provider is for whoever runs your Kestrel, as [Connect Amazon SES](../get-started/04-ses.md) describes. If the provider goes after a post is scheduled, its banner reads "Will be held: no email provider is connected." The post stays cancelable, and goes out once a provider is connected.
60
- - **Daily database writes.** On the Workers Free plan's limits, a large send may use up the day's database writes, which stops your newsletter until midnight UTC. The dialog warns you, and the post's banner repeats the warning for as long as it's scheduled. It never stops you from scheduling. [Bring your existing list](01-your-list.md#limits) explains the limit and when it's worth moving to Workers Paid.
61
-
62
- ## When it sends
63
-
64
- At the send time, the post moves from the editor to its send page under **Sent**, where you can watch it go out. If you have the post open, the page switches over with a toast saying why. From here the post can't be edited, canceled, or moved.
65
-
66
- A send goes out a slice at a time. A large list takes several slices, and between them the send still reads **Sending**, with "Paused between ticks. It resumes on the next sweep."
67
-
68
- If your email provider refuses to take more, for example because your account's daily sending quota is spent, the send waits. Nobody is marked unsent, and it tries again on its own, picking up where it stopped once the quota frees up. Raising the quota is for whoever runs your Kestrel ([As your list grows](../get-started/06-go-live.md#as-your-list-grows)).
69
-
70
- Whatever interrupts a send, it resumes rather than starting over. No reader is ever mailed the same post twice.
71
-
72
- [After a send](06-after-a-send.md) covers what the send page shows once it's out: the record, bounces, complaints, unsent recipients, and your archive page.
@@ -1,109 +0,0 @@
1
- # After a send
2
-
3
- Once a send starts, Kestrel keeps a permanent record of it: who it went to, and what happened to each email. The post goes up on its public page in your archive at the same moment, exactly as it was sent. Nothing about either can be edited, and you don't have to watch: Kestrel emails you when the send finishes, and when something needs you.
4
-
5
- This page covers a send's record, unsent recipients, bounces and complaints, your notifications, the dashboard's views, and the post's public page.
6
-
7
- ## The send's record
8
-
9
- Every send has its own page, which you open from **Sent** or from the dashboard's **Sent** list. While the send goes out, the page is a live view of its progress. Once it finishes, the same page becomes its record, in place.
10
-
11
- The record opens with five counts, over everyone the send went to:
12
-
13
- | Count | What it means |
14
- | --- | --- |
15
- | **Recipients** | Everyone the send went to: your confirmed subscribers who weren't suppressed when it started. |
16
- | **Delivered** | Emails your provider confirmed reached the reader's mail server. |
17
- | **Bounced** | Emails the receiving server turned away. |
18
- | **Complained** | Readers who marked the email as spam. |
19
- | **Unsent** | Recipients Kestrel couldn't hand to your email provider. [Unsent recipients](#unsent-recipients), below, says why. |
20
-
21
- A line under the counts accounts for every recipient by name, so the numbers always add up. It also names two kinds the counts leave out. A recipient **accepted, awaiting a delivery receipt** was taken by your email provider, with no word yet on what happened next. A **skipped** recipient unsubscribed or was suppressed before their turn, so they were never mailed.
22
-
23
- Below that is the full list of recipients. It opens on **Failures** (bounced, complained, and unsent), and **Delivered** and **All** are one click away. You can search it by address. Each row shows the outcome, the provider's own words about it, and when it happened. A person you erased reads as **Erased recipient** in place of their address. **Export recipients (CSV)** downloads the whole list.
24
-
25
- ### Accepted, then settled
26
-
27
- Delivery happens in two stages, and the record keeps them apart. First Kestrel hands each email to your email provider, which accepts it, usually within minutes. Later, the provider reports what happened to each email: delivered, bounced, or complained. Those reports can lag by seconds or by days.
28
-
29
- So a send is **sent** once every recipient has been handed off. Its record then keeps updating as reports arrive. The page says **Settling** while it waits, and **Complete** once every accepted email is confirmed. Some accepted emails are never confirmed at all, so a send can stay **Settling** for good. That's normal, not a fault.
30
-
31
- "Delivered" in Kestrel always means confirmed, never just accepted. So a count can rise for days after a send finishes, and an email can be accepted without ever showing as delivered.
32
-
33
- ## Unsent recipients
34
-
35
- An unsent recipient never got this post, and is still on your list. They get your next post as usual.
36
-
37
- A recipient is recorded unsent in two cases:
38
-
39
- - **Their own email kept failing.** When handing off one address hits a passing error, Kestrel tries again a few times, then records it unsent. The row's detail gives the provider's words.
40
- - **You chose Assume not sent.** Rarely, Kestrel can't tell whether your provider took some emails. The send waits for you, and **Resolve…** on its page asks you to decide. **Assume not sent** is the safe default: those readers may miss this post, but no one is mailed twice.
41
-
42
- A problem with your email provider itself never marks anyone unsent. While the provider is down, or refusing your account, the send waits and tries again. Once the problem is fixed, it carries on from where it stopped.
43
-
44
- ## Bounces and complaints
45
-
46
- A bounce or a complaint usually arrives after the send has finished, and lands on the record when it does. Kestrel acts on it for you, so a bad address can't keep hurting your sending reputation:
47
-
48
- - **A hard bounce** means the address can't receive mail, such as one that doesn't exist. Kestrel suppresses it, so it's never mailed again.
49
- - **A soft bounce** is temporary, such as a full mailbox. It's counted on the record, and the address stays on your list.
50
- - **A complaint** means the reader marked your email as spam. Kestrel suppresses the address, so it's never mailed again.
51
-
52
- The record's list says **Hard bounce** or **Soft bounce** on each row. A suppression doesn't change anyone's subscription. Under **Subscribers**, a suppressed address keeps its status and carries a label saying why: **bounced**, **complaint**, **blocked**, or **erased**. **Legend**, beside the counts, says what each label means. Choose the **suppressed** count to list only those subscribers. [Sending rules](02-sending-rules.md#what-kestrel-handles) says why this matters to mailbox providers.
53
-
54
- 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 it's worth finding out why.
55
-
56
- ### Lifting a suppression
57
-
58
- Kestrel never lifts a suppression on its own, and neither does an import. Only you can, deliberately. Do it only when you know the address works again, such as a reader who tells you their mailbox is fixed. Mailing an address that keeps bouncing, or a reader who marked you as spam, can hurt every send after it.
59
-
60
- A block is yours to lift: **Unblock** in the subscriber's ⋯ menu, which asks you to confirm. It's offered for a block you made, or one an import brought. The dashboard has no control for lifting a bounce or a complaint, but Claude can do it for you through the API, as [Working with Claude](07-working-with-claude.md) describes. Lifting a suppression changes nothing on past records: the bounce or complaint stays where it happened.
61
-
62
- ## Notifications
63
-
64
- Kestrel emails you when a send finishes, and when a send runs into a problem it can't settle on its own. So you only need to open the dashboard to act.
65
-
66
- Set the address under **Settings → Notifications**, in **Send notifications to**. **Send a test notification** checks that one arrives. With no address set, Kestrel sends nothing, and doesn't save anything up for later.
67
-
68
- You're told:
69
-
70
- - **When a send finishes,** with how many emails your provider accepted, how many were unsent, and how many were skipped.
71
- - **When your provider refuses your account,** with the provider's own words and what they point to. Amazon SES reports a spent daily quota this way too.
72
- - **When a send is held because no email provider is connected,** and that it goes out once one is.
73
- - **When a send stays in progress too long.**
74
- - **When a send waits for you to resolve it.**
75
- - **When a send missed its time,** going out late or not at all.
76
- - **When Claude imports a list for you,** with what the import changed.
77
-
78
- Each notification links straight to the page it's about. Each event is told once, not again while it lasts, and the dashboard keeps showing it until it clears. A bounce spike is shown on the dashboard, but isn't emailed.
79
-
80
- The **Last notification** row in the same section says whether the latest one was **Delivered** or **Not delivered**. Notifications are separate from your newsletter: one that fails never delays or changes a send.
81
-
82
- Whoever runs your Kestrel chooses how notifications are sent. By default they go through your newsletter's own email provider. Then the one about your provider refusing your account can't reach you, since that provider would refuse it too. Whoever runs your Kestrel can send them another way that avoids this, as [Notifications through Cloudflare's email](../guides/03-notifications.md) describes.
83
-
84
- ## Seeing what's happening
85
-
86
- The **Dashboard** shows what needs you first, then what's going on:
87
-
88
- - **A health block,** only when something needs your attention, such as a send waiting for you to resolve it. Red needs you to act; amber is worth a look.
89
- - **Active sends,** a card for each send going out now, with its progress.
90
- - **Subscribers,** counts of **Confirmed**, **Pending**, **Unsubscribed**, and **Suppressed**. Each opens the list filtered to that group.
91
- - **Sent,** your five latest sends, each with its **Delivered** count and a link to its public page.
92
-
93
- **Sent** in the sidebar has the rest: anything that needs attention, your scheduled sends, sends in progress, and every send so far. Search them by subject, or turn on **With delivery failures** to list only sends with a bounce, a complaint, or an unsent recipient.
94
-
95
- Wherever a send is listed, its number is **Delivered**, with any complaints, bounces, or unsent recipients named beside it. Every page keeps up on its own, so you never need to reload.
96
-
97
- While a send goes out, its page shows two bars. The first fills as your provider accepts each email, and the second fills behind it as deliveries are confirmed. A label beside the subject says how it's going, such as **Sending**, **Retrying**, or **Backing off**.
98
-
99
- ## The post's public page
100
-
101
- Your post goes up in your archive the moment its send starts. So the "view in browser" link in every email works as soon as it lands. Its publish date is the day its send started.
102
-
103
- The page shows the exact copy that was sent, never a fresh rendering of it. Kestrel adds a light frame for the browser: your publication's name, the publish date, a link back to the archive, and a way to subscribe. It also leaves out the parts that only make sense in an inbox, such as the unsubscribe link and your mailing address. No reader's address ever appears on it.
104
-
105
- A sent post can't be changed, and neither can its page. The sign-off shows your publication as it was when you sent the post, while the frame shows it as it is now. The page's address is permanent, so links to it keep working.
106
-
107
- Open it from **View published post ↗** on the send's record, or **Archive ↗** in the dashboard's **Sent** list. **View publication ↗** in the sidebar opens your home page, which links into your archive. [Name your publication](03-name-your-publication.md) says where your name, logo, and address appear.
108
-
109
- Next, see what Claude can do for you through the same API in [Working with Claude](07-working-with-claude.md).
@@ -1,103 +0,0 @@
1
- # Subscriber import format
2
-
3
- Kestrel imports a subscriber list in one documented format, `kestrel-subscribers/1`, and exports the whole list in the same format. This page describes the format, what an import does to each address, and the limits.
4
-
5
- Kestrel never guesses what another service's export means, because a wrong guess about who agreed to be emailed can't be taken back. An export from Sendy, Mailchimp, or anywhere else is converted to this format first, by Claude or by hand. The API serves everything on this page at `GET /api/imports/format`, generated from the same definition the importer reads with, along with an example of each kind of file, `kestrel-import-example.csv` at `GET /api/imports/template.csv` and `kestrel-import-example.txt` at `GET /api/imports/template.txt`. The import screen offers both as **Download example file**.
6
-
7
- ## Two kinds of file
8
-
9
- - **The full CSV (preferred).** It carries unsubscribes, bounces, and how each person joined. It's what the export writes, and you can open it in a spreadsheet to check it before importing.
10
- - **A plain list (`.txt`).** One address per line, for a list that is only addresses. You say what they all are: subscribed (the default), unsubscribed, or bounced. Nothing else carries over: not how or when each person joined, when anyone left, or any evidence of their consent. And since every address is the one kind you choose, unsubscribes and bounces each need a file of their own.
11
-
12
- The file's name decides which it is: `.csv` or `.txt`. Anything else is refused.
13
-
14
- ## The CSV
15
-
16
- A CSV file saved as UTF-8 (in a spreadsheet, **CSV UTF-8**), with a header row of the column names below, in any order, then one row per address. A value with a comma, a quote, or a line break in it is wrapped in double quotes, with each quote inside written twice, as spreadsheets write it (RFC 4180). Column names are written exactly, in lowercase. A row records what the source claims, never what Kestrel will do: the import decides that, under the rules further down.
17
-
18
- | Column | Required | Values | Meaning |
19
- | --- | --- | --- | --- |
20
- | `email` | yes | an email address | The address, trimmed and lowercased before it is matched. An invalid one is still a row, counted as invalid and reported, never dropped. |
21
- | `status` | yes | `subscribed`, `pending`, `unsubscribed`, `none` | The consent claim. `none` is a known address with no consent claim, present only to carry a suppression. |
22
- | `opt_in` | yes | `double`, `single`, `unknown` | How consent was collected. `unknown` unless the source states it. |
23
- | `origin` | yes | a short lowercase name | Where consent was first collected, as the file claims it: `sendy`, `mailchimp`, `kestrel`, or `unknown`. |
24
- | `suppression` | no | `bounce`, `complaint`, `manual`, `erased` | A suppression on top of `status`: the address bounced, complained, was suppressed by hand, or was erased. |
25
- | `subscribed_at` | no | an instant with its zone | When the person subscribed, if the source states it. |
26
- | `confirmed_at` | no | an instant with its zone | When the person confirmed, if the source states it. |
27
- | `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. |
28
- | `suppressed_at` | no | an instant with its zone | When the address was suppressed, if the source states it. |
29
- | `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. |
30
- | `format` | no | `kestrel-subscribers/1` | The version the file is written in. A file without it is read as version 1. |
31
-
32
- 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.
33
-
34
- 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.
35
-
36
- 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:
37
-
38
- ```
39
- email,status,opt_in,origin,suppression,unsubscribed_at,evidence
40
- ada@example.com,subscribed,double,sendy,,,"{""signup_ip"":""203.0.113.7""}"
41
- grace@example.com,subscribed,single,mailchimp,,,
42
- edsger@example.com,unsubscribed,unknown,unknown,,2025-01-20T08:00:00Z,
43
- bounced@example.com,none,unknown,unknown,bounce,,
44
- ```
45
-
46
- **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.
47
-
48
- ## The plain list
49
-
50
- - One address per line. Each line is trimmed, blank lines are skipped, and CRLF line endings and a UTF-8 byte-order mark are accepted.
51
- - A line that isn't a single valid address, such as `Name <ada@example.com>` or a typo, is counted as invalid and listed among the rows left out. One bad line doesn't sink a list.
52
- - The whole file is refused when it isn't a plain list at all: not UTF-8 text, a line with a comma, tab, or semicolon (it's a table, so a `.csv`), or not one valid address in it.
53
- - Its rows arrive with `origin: unknown` and `opt_in: unknown`.
54
-
55
- ## Plan, then commit
56
-
57
- An import happens in two steps. Planning shows what the import would do to every row, against your list as it stands, and changes nothing, so you can plan a file as often as you need. Committing applies the plan, all of it or none of it, and sends no email.
58
-
59
- The commit needs two things from you when they apply:
60
-
61
- - **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.
62
- - **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.
63
-
64
- 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.
65
-
66
- ## What an import does to each address
67
-
68
- Each row gets exactly one outcome. A row that both changes its status and adds a suppression reports its status outcome, and says it added a suppression too.
69
-
70
- | Outcome | When |
71
- | --- | --- |
72
- | `create_confirmed` | `subscribed`, with no subscriber and no suppression in Kestrel, and not erased in the file: added as confirmed. |
73
- | `confirm_pending` | `subscribed`, pending in Kestrel (never confirmed), and not erased in the file: confirmed, and its confirmation link no longer works. |
74
- | `unchanged` | Already confirmed in Kestrel, or already in the state the row claims. A subscriber who joined through Kestrel keeps their own record. |
75
- | `mark_unsubscribed` | `unsubscribed`, and confirmed or pending in Kestrel, with no confirmation in Kestrel newer than the row's `unsubscribed_at`: unsubscribed. |
76
- | `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. |
77
- | `kept_confirmed_newer` | `unsubscribed`, but the reader confirmed in Kestrel after the source's `unsubscribed_at`: their newer action stands. |
78
- | `kept_unsubscribed` | `subscribed`, but unsubscribed in Kestrel, or pending again after leaving until they confirm in Kestrel themselves. An unsubscribe is never reversed. |
79
- | `blocked_by_suppression` | `subscribed`, but the address carries a suppression in Kestrel, a do-not-contact marker included: never added or confirmed. Also `unsubscribed` for an address whose suppression in Kestrel is the do-not-contact marker an erasure left, and, for an address erased in the file (its `suppression` is `erased`, which is still added), `subscribed` or `unsubscribed` with no subscriber in Kestrel, or `subscribed` and pending in Kestrel, which stays pending: no subscriber is recorded or confirmed, so an import never brings back an erased person's data. |
80
- | `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`. |
81
- | `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. |
82
- | `invalid` | The address isn't valid. |
83
- | `merged` | A repeat of an address earlier in the file, folded into it. |
84
-
85
- 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.
86
-
87
- 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.
88
-
89
- **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, and its suppression 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.
90
-
91
- ## How imported subscribers read
92
-
93
- 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.
94
-
95
- ## Limits
96
-
97
- - **Rows:** up to 10,000 in one import. On the Workers Free plan's limits (`SUBREQUEST_BUDGET` unset), up to 1,000, since Free's 10 ms of CPU a request can't plan and commit more. Set [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) on Workers Paid to take 10,000. Split a larger list into several files.
98
- - **Size:** a request of at most 10 MB, and `evidence` of at most 500 bytes a row.
99
- - **Database writes on Workers Free:** D1's free plan allows 100,000 rows written a day, and refuses every query past that until midnight UTC. Each new subscriber an import brings in writes 7 rows, and a send writes about 14 a recipient. So one import of 1,000 new subscribers uses about 7% of a day's writes, and about 10 of them use most of a day. On Workers Free, the plan warns when it and the day's earlier imports, by UTC, would use more than 70% of the day, and advises importing the file the next day. A send scheduled for a day imports have written warns in the same way, and advises sending the next day. Neither warning refuses.
100
-
101
- ## Exporting
102
-
103
- The export reads your whole list out in this format, a page at a time, at `GET /api/subscribers/export`. Each page holds up to 1,000 rows on the Workers Free plan's limits, or 10,000 otherwise, and its `next` value asks for the page after it. Following `next` until it's `null` gives every address exactly once, even if the list changes while you read. An address added or removed partway through may or may not appear. 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, so a spreadsheet shows it as text rather than running it as a formula; an import takes the apostrophe off again. It includes every subscriber with how they joined, their evidence, and their suppression, and every address suppressed with no subscriber behind it, written as `status: none`. A confirmed subscriber is written as `subscribed`. 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.