@kurtbruns/kestrel 1.9.0 → 1.10.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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,48 @@ To move a running instance from one version to another, follow [Upgrade to a new
10
10
 
11
11
  <!-- Add entries under Added / Changed / Fixed / Breaking. One operator-facing line each; see .claude/rules/changelog.md. Upgrade steps (a migration, a new setting) go once, in the Upgrading paragraph, never on an entry. -->
12
12
 
13
+ ## [1.10.0] - 2026-10-14
14
+
15
+ **Upgrading from 1.9.0.** No database migrations. Run `npm run check-context` in your instance's repository: this release changes the operator's Claude context (a new `/migrate-list` skill, and rules for erasing, for choosing between unsubscribing and blocking, and for reading the docs as Markdown), and a copy you've edited is merged by hand. Three API changes can break a script of yours, listed under Breaking. One that adds suppressions sends `reason: "manual"` or none, reads what was stored from the answer's `suppression`, and takes a 200 with `added: false` as already suppressed. One that clears suppressions takes a 409 `erasure_marker` on an erased address as final. One that reads `GET /api/docs` reads `sets` in place of `landing`.
16
+
17
+ ### Added
18
+
19
+ - The docs are now two sets, each with its own index: **Publishing**, for whoever writes and sends the newsletter, and **Self-hosting**, the setup guide. The editor's Docs tab opens on Publishing, under a **Kestrel docs** header, one intro for both sets, and a tab per set, and each page names its set over its title. Publishing opens with **Set up your publication**, which follows the dashboard's first-run checklist of the same name step for step (Name your publication, Check your setup, Bring your existing list, Write your first post, Share your subscribe link), then **Sending** (Scheduling and sending, After a send, Sending rules) and **More** (Working with Claude, the import format). Sending rules says what the law asks of a newsletter, who handles each part, and how to act on a reader's request to leave or be deleted. Name your publication says when to provide a mailing address, and the address's hints and warning in Settings, and the template's save warning, now link that section in a new tab. The import format reference moves from Self-hosting into Publishing, keeping its address in the Docs tab (SPEC §9, §11).
20
+ - The docs and the API reference are served as Markdown for an agent to read: `GET /api/docs/<slug>.md` for a page, `GET /api/docs/publishing.md` and `GET /api/docs/self-hosting.md` for each set's index, and `GET /api/reference.md` for the whole reference, generated from the same registration as the JSON. Links between docs point at each other's Markdown. Connect Claude to the API mentions them (SPEC §11).
21
+ - The operator's Claude context reads the docs as Markdown: its CLAUDE.md names the two sets and the Markdown routes, and `/migrate-list` reads the import format and "Bring your existing list" at `GET /api/docs/import-format.md` and `GET /api/docs/your-list.md`, since the import format reference moved into the publisher's pages. `npm run check-context` offers the change to an existing instance.
22
+ - **Block** and **Unblock** in a subscriber's ⋯ menu on the Subscribers page, for your own decision to stop mailing an address, as opposed to theirs. Unsubscribe is for when they ask to leave, and its dialog now says so; a block keeps their status and can be undone. The Legend lists each act and when it's the one to use (SPEC §7).
23
+ - The operator's Claude context gains a rule for choosing between unsubscribing and blocking: unsubscribe only when the person asked, block when you decide yourself, unblock or clear only an address you name, and never clear a bounce or complaint without your word. It bears on safety, since clearing one lets mail go to an address that failed or reported spam.
24
+ - Bring your list in and take it out from the dashboard. **Subscribers → Import** shows the file Kestrel reads (a full CSV or a plain list, each with an example file), plans a file the moment you choose it, and shows what will happen to every row, with any rows your list keeps as they are set apart, the rows not imported as subscribers to download, and the Workers Free daily-writes warning when it applies. You attest, acknowledge each scheduled post that would grow, and import, and the result says nobody was emailed. **Export** downloads the whole list as one CSV in the same format. The subscriber list gains a **How they joined** column ("Imported · Sendy, single opt-in", or "Double opt-in" for a confirmation Kestrel recorded) and **View subscriber** in each row's menu shows how they joined, with the evidence. "Bring your existing list", in the Docs tab's new **Publishing** docs, explains it all, and Claude reads the same page (SPEC §7, §8).
25
+ - The operator's Claude context gains a `/migrate-list` skill, which `kestrel init` writes and `npm run check-context` offers to an existing instance, so Claude can bring a list over from another service by a method that checks itself against Kestrel's import plan, and explains the format, the row limit, and the Workers Free advice from the publisher page "Bring your existing list" in the in-app docs rather than in its own words. It bears on safety, since it adds subscribers: the rule against adding a subscriber the publisher didn't name now makes one exception, for such an import, confirming anyone only with the publisher's attestation.
26
+ - Erase a subscriber, for a reader who asks you to delete their data: Erase in the Subscribers row menu, or `POST /api/subscribers/:id/erase`. It removes their subscriber record, with how they joined and its evidence, shows an erased recipient in their place on every past send so each record still adds up, and keeps the address only as a do-not-contact suppression, which no import lifts or gets past: while the address carries it, or when the file itself marks the address as erased, an import creates no subscriber for it, whatever status the file gives it, and a file that marks a pending subscriber as erased leaves them pending, never confirmed. It can't be undone. The person can still rejoin by subscribing and confirming themselves; adding the address by hand asks you first (`acknowledge_erasure: true` through the API) (SPEC §7).
27
+ - The operator's Claude context gains a rule for erasing: Claude erases a subscriber only when you name them and ask, confirming first since it can't be undone, and sends a confirmation to an erased address only once you say the person asked to join again. It bears on safety, since an erasure is permanent.
28
+
29
+ ### Changed
30
+
31
+ - The dashboard's first-run checklist speaks to the publisher: its second step, once an email provider is connected, is **Check your setup**, which opens a new Publishing page on sending yourself a test, how Kestrel tells you when something needs attention, and what changes as your list grows, in place of confirming the sending domain. It also gains **Bring your existing list**, before writing the first post, with **Import** for a publisher moving from another service; a fresh publication skips it. The checklist now stays until the first post rather than ending at the first subscriber, so importing a list no longer takes away the steps after it, and the list step shows as done with the count of subscribers on your list.
32
+ - On the Subscribers page, each count in the summary filters the list: choose **confirmed**, **pending**, or **unsubscribed** to see that status, or **suppressed** to see every suppressed subscriber, and choose it again to see everyone. The suppressed count, here and on the dashboard, now counts suppressed subscribers rather than every suppressed address, so it matches the rows its filter shows (SPEC §8).
33
+ - On the Subscribers page, the status and suppression pills no longer explain themselves in a hover tooltip, which keyboard, screen-reader, and touch users couldn't reach. A **Legend** beside the counts opens to show each pill beside what it means: the consent states, what suppressed means, and each red flag (bounced, complaint, blocked, erased). It replaces the counts' info tip, and the provider's words for a bounce or complaint, which the flag's tooltip carried, now show in **View subscriber**. The API reference describes subscriber states, suppression reasons, and each act on a subscriber in the same words, so Claude is told what you are told.
34
+ - The other labels that explained themselves only on hover now say it on the page. On a sent post's record, the line accounting for every recipient says that a hard bounce or a complaint suppresses the address and a soft bounce leaves it on the list, where a row's outcome used to say it in a tooltip. On the Template page and the Settings template section, while a post is about to send, the lede says when it sends and that you can save once it has, which the in-use chip's tooltip used to, and the sentence goes away once that time has passed. Pills and chips across the editor show the ordinary arrow cursor rather than a text cursor.
35
+ - The Subscribers table holds up at mid-sized widths: it switches to stacked rows whenever it's too narrow to give an address room, not only at phone width, the How they joined column is narrower, and an address that has to wrap breaks before its @ rather than mid-word.
36
+ - The import attestation names your publication the way the sidebar and your emails do: until you set a publication name, by the display name of your From address, and only with neither as "this newsletter". The plan's `attestation` carries the `publication` name it used, and the dashboard's import screen takes the name under the attestation from there. The attestation's version is unchanged.
37
+ - The import format's example files download as `kestrel-import-example.csv` and `kestrel-import-example.txt`, so one isn't mistaken for an exported list, and on the Workers Free plan's limits the row-limit reason (`limits.max_rows_reason`, also in a `too_many_rows` refusal) now speaks to the publisher: it's Cloudflare's free plan's limit, and whoever runs their Kestrel can raise it to 10,000 rows by moving to Workers Paid. The import format reference says how a CSV quotes a value in plain words (SPEC §7).
38
+ - On the Subscribers page, a suppressed subscriber's status shows in the unsubscribed grey, so a confirmed address that bounced, complained, or was blocked no longer shows a green "confirmed" while it isn't mailed; the red flag beside the address still says why. The status itself is unchanged, and clearing the suppression puts it back in effect.
39
+ - The demo loaded by `npm run seed` (without `--size`) now has a suppression on every consent state: besides the bounced and complained confirmed readers, an unsubscribed reader and a pending sign-up blocked by hand, and an erased person who subscribed again. The list's audience and past sends are as before.
40
+ - Dates in the editor show their year when it isn't the current year, past or future, so a draft last edited in a previous year, a send from last year, or a send scheduled for next January reads unambiguously ("Jun 15, 2025, 8:06 AM"); this year's dates stay as short as before.
41
+ - Readers can reply. The setup guide's Connect Amazon SES gains a step, "Receive replies at your From address", that forwards the From address to your inbox with Cloudflare Email Routing, where before nothing received mail there and a reply most likely bounced; Verify it works now has you reply to your test email. Kestrel still only sends: it never receives or stores a reply. An existing instance can add the forwarding at any time, with no redeploy. The new step is section 3, so Connect Amazon SES's later sections move down one (Request production access is now 5, Subscribe the app to the topic now 9), and a link to one of them by its old anchor (such as `#4-request-production-access`) now opens the top of the page instead. Settings says under the From address that readers' replies go there, linking the step (SPEC §9).
42
+ - On the Workers Free plan's limits, the daily-writes warnings now count what the day's imports wrote, by UTC. An import plan warns when it and that day's earlier imports would use more than 70% of D1's daily writes, and advises importing the file the next day, so a list brought in over several files is warned before it can stop the newsletter. A scheduled send firing on a day imports have written now warns below 5,000 recipients, and advises sending the next day. The plan no longer warns because the list it leaves passes 3,000 confirmed subscribers: whether a send fits is the send's warning, given when it's scheduled (SPEC §7, §8).
43
+
44
+ ### Fixed
45
+
46
+ - On the Subscribers page, an address whose bounce or complaint came in with an import is flagged **bounced** or **complaint**, like one seen here, rather than a bare **suppressed**.
47
+ - On a list with more than 1,000 suppressions, the Subscribers page now flags every suppressed address: one suppressed before the newest 1,000 showed as not suppressed, with no red flag, a live-coloured status, and Block offered in its row menu. `GET /api/subscribers` now reads each row's suppression for the page's own addresses.
48
+
49
+ ### Breaking
50
+
51
+ - `GET /api/docs` returns `sets` (each set's id, name, title, intro, and sections) in place of the single `landing`, and each doc carries the `set` it belongs to. A section's id is now its heading's anchor rather than its folder (SPEC §11).
52
+ - `counts.suppressed` in `GET /api/subscribers` counts subscribers whose address is suppressed, whatever their status, rather than every suppressed address, so it matches `suppressed=only`; an address suppressed with no subscriber is counted only in `GET /api/suppressions` (SPEC §8).
53
+ - `POST /api/suppressions` adds only a block: `reason` may be omitted or `manual`, and any other is refused 400, since a bounce or complaint is the provider's to report and an erasure's marker is Erase's to leave. An address already suppressed keeps what it has, and the answer is now the suppression that stands with `added`, instead of echoing the request. `DELETE /api/suppressions/:email` refuses an erased address's marker with 409 `erasure_marker`, which only the person's own confirmation lifts (SPEC §7).
54
+
13
55
  ## [1.9.0] - 2026-10-13
14
56
 
15
57
  **Upgrading from 1.8.0.** Two database migrations: run `npm run sync-migrations`, then `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development) before deploying. The first (`0005_imports`) adds the record of each import and how each subscriber joined, reading every existing subscriber as joined through Kestrel's double opt-in, and rebuilds the notifications table so an import can be told to you. The second (`0006_deliveries_fewer_indexes`) drops two indexes no query reads; it writes no rows, so it costs nothing against D1's daily allowance at any size.
@@ -21,6 +63,7 @@ To move a running instance from one version to another, follow [Upgrade to a new
21
63
 
22
64
  ### Changed
23
65
 
66
+ - While no email provider is connected, a scheduled post's banner says "Will be held" in the banner's own violet, led by an amber ⚠️, the same look as its daily-writes line, instead of amber text on violet, which fell short of readable contrast. The dashboard's scheduled card keeps its amber line.
24
67
  - A send uses fewer of D1's rows: about 14 written and 41 read per recipient, receipts included, down from about 16 and 118. On the Workers Free plan, whose D1 refuses every query once a day's 100,000 written rows are spent, one send can now reach about 7,000 subscribers in a day instead of about 6,200. On Workers Paid, a send bills fewer writes and leaves a smaller database.
25
68
  - The API's refusals and render warnings that the editor shows read as sentences that name the next step, with typographic apostrophes and no em dashes: a save or image upload on a scheduled post, a test with no recipients or too many, an address Settings won't take, a scheduling or Resolve refusal, a warning such as an image with no alt text. A template test's refusals about its recipients now name `to` as their `field`, and a save, delete, or image change on a post that isn't a draft is refused as 409 `post_not_draft` instead of `conflict`, saying whether canceling the schedule would unlock it or its send has already started.
26
69
  - A send time inside the minimum lead is refused in one plain sentence, "Pick a time at least 5 minutes from now, so there’s time to cancel.", which is now the API's own message on scheduling and rescheduling alike, so Claude reads what the publisher reads where it used to read the route to call; Send now remains the soonest send (SPEC §6).
@@ -379,7 +422,8 @@ The first tagged release: a self-contained newsletter app on a Cloudflare Worker
379
422
  - A build-version stamp: every instance reports its version, commit, and build time, read-only in the editor and at `GET /api/version`.
380
423
  - This changelog and a semantic-versioning release process.
381
424
 
382
- [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.9.0...HEAD
425
+ [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.10.0...HEAD
426
+ [1.10.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.10.0
383
427
  [1.9.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.9.0
384
428
  [1.8.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.8.0
385
429
  [1.7.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.7.0
package/README.md CHANGED
@@ -12,7 +12,9 @@ Kestrel installs from npm as [`@kurtbruns/kestrel`](https://www.npmjs.com/packag
12
12
  npx @kurtbruns/kestrel init
13
13
  ```
14
14
 
15
- To look around before you deploy, [Try Kestrel locally first](docs/guides/08-try-locally.md) runs it with demo content and no Cloudflare account. [Get started](docs/README.md#get-started) takes you from an empty repository to a live newsletter, and [Upgrade to a new release](docs/guides/07-upgrade.md) moves an instance to a newer one. The same guide is in the editor's **Docs** tab.
15
+ To look around before you deploy, [Try Kestrel locally first](docs/guides/08-try-locally.md) runs it with demo content and no Cloudflare account. [Get started](docs/README.md#get-started) takes you from an empty repository to a live newsletter, and [Upgrade to a new release](docs/guides/07-upgrade.md) moves an instance to a newer one.
16
+
17
+ The docs are two sets, one per job. [Self-hosting](docs/README.md) is for whoever deploys and runs Kestrel. [Publishing](docs/publishing/README.md) is for whoever writes and sends the newsletter: setting up the publication, the list, writing, sending, and the rules a newsletter keeps. Both are in the editor's **Docs** tab, which opens on Publishing, and an instance serves every page as Markdown for Claude to read.
16
18
 
17
19
  ## Work on Kestrel
18
20
 
package/cli/context.mjs CHANGED
@@ -31,7 +31,11 @@ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
31
31
  const PACKAGE = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
32
32
 
33
33
  /** The context files an instance gets, by their path in the instance. */
34
- export const CONTEXT_FILES = [".claude/CLAUDE.md", ".claude/skills/upgrade/SKILL.md"];
34
+ export const CONTEXT_FILES = [
35
+ ".claude/CLAUDE.md",
36
+ ".claude/skills/upgrade/SKILL.md",
37
+ ".claude/skills/migrate-list/SKILL.md",
38
+ ];
35
39
 
36
40
  /** Where an instance's context file ships in the package. */
37
41
  export function shippedPath(file) {