@kurtbruns/kestrel 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +154 -1
  2. package/README.md +6 -7
  3. package/cli/dev-api.mjs +1 -1
  4. package/cli/reset.mjs +2 -0
  5. package/cli/seed.mjs +3 -1
  6. package/demo/README.md +4 -2
  7. package/demo/posts/0-test-email/index.md +13 -0
  8. package/demo/posts/5-a-quick-note/index.md +2 -0
  9. package/dist/public/dashboard/app.js +383 -324
  10. package/dist/public/dashboard/app.js.map +4 -4
  11. package/dist/public/dashboard/index.html +2 -2
  12. package/dist/public/dashboard/styles.css +850 -191
  13. package/dist/worker/index.js +11327 -6891
  14. package/docs/get-started/02-deploy.md +1 -1
  15. package/docs/get-started/03-access.md +3 -3
  16. package/docs/get-started/04-ses.md +24 -4
  17. package/docs/get-started/05-verify.md +21 -5
  18. package/docs/get-started/06-go-live.md +2 -2
  19. package/docs/guides/01-connect-claude.md +8 -3
  20. package/docs/guides/09-as-your-list-grows.md +6 -2
  21. package/docs/publishing/01-your-list.md +35 -30
  22. package/docs/publishing/02-sending-rules.md +3 -3
  23. package/docs/publishing/04-write-a-post.md +20 -6
  24. package/docs/publishing/06-troubleshooting.md +46 -12
  25. package/docs/publishing/07-working-with-claude.md +4 -4
  26. package/docs/publishing/09-share-your-subscribe-link.md +2 -2
  27. package/docs/publishing/10-check-your-setup.md +2 -2
  28. package/docs/publishing/11-email-template.md +3 -1
  29. package/docs/publishing/README.md +1 -1
  30. package/docs/reference/01-configuration.md +3 -3
  31. package/migrations/0007_sends_progress_at.sql +12 -0
  32. package/migrations/0008_notifications_sandbox.sql +35 -0
  33. package/migrations/0009_post_email_only.sql +12 -0
  34. package/migrations/0010_unlisted_links.sql +43 -0
  35. package/migrations/0011_sends_render_warnings.sql +13 -0
  36. package/migrations/0012_unsubscribe_cause.sql +139 -0
  37. package/package.json +1 -1
  38. package/template/claude/CLAUDE.md +13 -9
  39. package/template/claude/skills/migrate-list/SKILL.md +5 -4
package/CHANGELOG.md CHANGED
@@ -10,6 +10,157 @@ 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.13.0] - 2026-10-19
14
+
15
+ **Upgrading from 1.12.0.** One database migration, which `npm run sync-migrations` brings in: apply it before you deploy with `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development). `0012_unsubscribe_cause` records why each subscriber left and leaves the suppressions only bounces and blocks. Every unsubscribe before it reads as the reader's own, since the record never said otherwise. Each complaint becomes an unsubscribe by a complaint, and an address it held with no subscriber is recorded as one, unsubscribed and holding only the address. Each erasure's marker becomes a subscriber unsubscribed by an erasure, holding only the address and when, except that someone who signed up again after it stays pending, read as having left by the erasure until they confirm. An erasure that found a bounce or a block already on an address left no trace of itself, so that address keeps its suppression alone. It rebuilds the subscribers and suppressions tables, copying every row once, which counts against the day's database writes on Workers Free like any other write. Deploy straight after applying it: until this release is serving, the previous one fails to record a reader's unsubscribe, a confirmation from someone who once left, an erasure, and a complaint report, which the new tables refuse. SES retries a refused report, so complaints arrive once the deploy lands; a reader whose unsubscribe failed meanwhile has to unsubscribe again. Run `npm run check-context` in your instance's repository: this release changes the operator's Claude context, and a copy you've edited is merged by hand. Several API changes can break a script of yours, each listed under Breaking; the table gives every renamed route, field, value, and sort key, old to new. A request that still sends an old name is refused with a 400 naming it, never answered as if it had been read: every API route now refuses a body field, a query parameter, or a filter value it doesn't take, so check a script's requests against the API reference before you deploy.
16
+
17
+ | Where | Was | Now |
18
+ | --- | --- | --- |
19
+ | `GET`, `PUT /api/settings` | `settings.testRecipients` | `settings.test_recipients` |
20
+ | | `settings.emailTemplate` | `settings.email_template` |
21
+ | | `settings.confirmationEmail`, `.confirmationEmailDefault` | `settings.confirmation_email`, `.confirmation_email_default` |
22
+ | | `settings.confirmationEmail.buttonLabel` | `settings.confirmation_email.button_label` |
23
+ | | `deployment.fromAddress`, `sendingDomain`, `appOrigin`, `archiveOrigin`, `archiveBasePath`, `mediaPublicBase`, `awsRegion` | `deployment.from_address`, `sending_domain`, `app_origin`, `archive_origin`, `archive_base_path`, `media_public_base`, `aws_region` |
24
+ | | `deployment.accessConfigured`, `authMode`, `notifyChannel`, `notifyFrom`, `minLeadMs`, `notConnectedPreview`, `freePlanLimits` | `deployment.access_configured`, `auth_mode`, `notify_channel`, `notify_from`, `min_lead_ms`, `not_connected_preview`, `free_plan_limits` |
25
+ | | `inUse`, `inUse.identityFields` (value `logoUrl`) | `in_use`, `in_use.identity_fields` (value `logo_url`) |
26
+ | | `notificationStatus.lastSent`, `.lastFailure` | `notification_status.last_sent`, `.last_failure` |
27
+ | | a 400's `field`, such as `confirmationEmail.buttonLabel` | the new path, such as `confirmation_email.button_label` |
28
+ | `GET /api/settings` | `settings.publication.logoUrl` | `settings.publication.logo_url` |
29
+ | `GET /api/version`, `deployment.build` | `buildTime`, `repoUrl`, `commitUrl`, `tagUrl` | `build_time`, `repo_url`, `commit_url`, `tag_url` |
30
+ | `GET /api/whoami` | `principal.tokenId` | `principal.token_id` |
31
+ | `GET /api/dev/token` | `runId` | `run_id` |
32
+ | `GET /api/dev/outbox` | `messages[].providerId`, `.sentAt`, `notifications[].sentAt` | `provider_id`, `sent_at` |
33
+ | A send's `phase` | `backing-off`, `needs-attention` | `backing_off`, `needs_attention` |
34
+ | `GET /api/posts` `sort` | `updated`, `title`, `scheduled` | `updated_at`, `subject`, `send.fire_at` |
35
+ | `GET /api/sends` `sort` | `fire`, `recipients` | `fire_at`, `audience.count` |
36
+ | `GET /api/subscribers` `sort` | `joined`, `confirmed` | `created_at`, `confirmed_at` |
37
+ | `GET /api/sends/:id/deliveries` `sort` | `updated` | `event_at` |
38
+ | A post, every route | a list row's `fire_at`, `active_send_id`, `active_send_status` | `send`, a reference: `{ id, status, fire_at, rev }` (the whole send is `GET /api/sends/:id`) |
39
+ | | one post's `scheduled`, `sending`, `sent` | `send`, with its `status` |
40
+ | | one post's `author`, beside it | `post.author` |
41
+ | A subscriber, every route | a list row's `suppressed`, `suppression_reason`, `suppression_detail` | `suppression: { reason, detail, created_at }` or null |
42
+ | | `suppressed`, beside one subscriber | `subscriber.suppression` |
43
+ | The re-make: `remake_required`, `remake_too_close`, `in_use.sends`, `remade` | `{ id, post_id, subject, fire_at, remade_at }` | the send's full view, which carries those fields |
44
+ | A send's `counts` | `in_flight` | `dispatched` |
45
+ | `GET /api/sends/:id` `outcomes` | `in_flight` (pending and dispatched together) | `pending`, `dispatched` |
46
+ | `GET /api/sends/:id/deliveries` `view` | `in_flight` (pending and dispatched together) | `pending`, `dispatched` |
47
+ | Lifting a suppression | `DELETE /api/suppressions/:email`, answering `{ email, cleared }` | `POST /api/suppressions/lift` with `{ email, reason }`, answering `{ email, lifted }` (see Breaking) |
48
+ | Describing a post's rendered email | `POST /api/posts/:id/preview` | `GET /api/posts/:id/preview.json`, the same answer (see Breaking) |
49
+ | An import plan | `daily_writes_warning` | `workers_plan_advice` (a different meaning: see Breaking) |
50
+ | An import's outcomes: a plan's and a commit's `counts`, `rows`, and `differed`, the format's `outcomes`, a stored record | `create_confirmed`, `confirm_pending`, `mark_unsubscribed`, `create_unsubscribed`, `blocked_by_suppression`, `suppression_added` | `added_confirmed`, `confirmed_pending`, `unsubscribed`, `added_unsubscribed`, `kept_suppressed`, `suppressed` |
51
+ | A suppression's `reason`, everywhere | `complaint`, `import_complaint`, `erased` | none: the subscriber's `unsubscribe_cause`, `complaint` or `erased` (see Breaking) |
52
+ | `POST /api/subscribers` | `acknowledge_erasure`, and 409 `erasure_acknowledgement_required` with `erased_at` | none: an address that left by a complaint or an erasure is refused with 409 `signup_required`, carrying the `subscriber` |
53
+ | `POST /api/subscribers/:id/erase` | `{ erased: { email, deliveries, suppression } }`, and 404 on a repeat | `{ subscriber, deliveries }`, a repeat answering the same subscriber with `deliveries: 0` |
54
+ | `POST /api/suppressions/lift` | 409 `erasure_marker` | none: an erasure isn't a suppression |
55
+ | The import format, and the export | `kestrel-subscribers/1`, with `suppression` `complaint` or `erased` | `kestrel-subscribers/2`, with `unsubscribe_cause`; a version 1 file still reads |
56
+ | `GET /api/suppressions` | every suppression, newest first, cut off silently past 1,000 | 50 a page with `page`, as the other lists (see Breaking) |
57
+ | Every API route | a body field, query parameter, or filter value it doesn't take, ignored (`GET /api/posts?status=bogus` listed every post) | a 400 naming it in `field` (see Breaking) |
58
+
59
+ ### Breaking
60
+
61
+ - One naming rule on the wire: every JSON field and enum value is snake_case, and a list's sort key is the name of the field it sorts by. The settings (its preferences, its deploy reflection, `in_use`, and `notification_status`), the build info, `whoami`'s token id, the dev routes' `run_id` and the dev outbox's `provider_id` and `sent_at`, the `backing_off` and `needs_attention` phases, and the sort keys of posts, sends, subscribers, and deliveries take their new names; the stored settings keep theirs, so no migration runs. The Upgrading paragraph lists every rename.
62
+ - An import's outcomes are renamed into one form, the past tense of what the import does to the address, and none says "blocked" any more, which in the editor and the API means only your own block: `create_confirmed` is now `added_confirmed`, `confirm_pending` is `confirmed_pending`, `mark_unsubscribed` is `unsubscribed`, `create_unsubscribed` is `added_unsubscribed`, `blocked_by_suppression` is `kept_suppressed`, and `suppression_added` (as an outcome, not the row's field of that name) is `suppressed`. They change in an import plan's and a commit's `counts`, `rows`, and `differed`, the format route's `outcomes`, and an import's record, where one committed before this release reads under the new names too. A script that matched an old name matches the new one (SPEC §7).
63
+ - A send's per-recipient buckets have one vocabulary, the statuses and events its delivery rows store: `counts.in_flight` is now `counts.dispatched`, and `outcomes.in_flight` and the deliveries' `view=in_flight`, which meant pending and dispatched together, are split into `pending` and `dispatched`, so one name never counts two things. A delivery row's `status`, `event`, and `bounce_kind` are now typed to their values (SPEC §12).
64
+ - A post reads the same on every route and refers to its send by one fixed reference, `send`: `{ id, status, fire_at, rev }` of its active send, else the one that sent it, or null, with the whole send one read away at `GET /api/sends/:id` and `rev` saying whether a copy of it is current. It carries `actions` too, what the server would take for it now (`edit`, `delete`, and, while an email provider is connected, `schedule` and `send_now`). They replace a list row's `fire_at`, `active_send_id`, and `active_send_status` and one post's `scheduled`, `sending`, and `sent`, and its `author` moves inside the post. The editor offers a post's controls from its `actions`. The posts list's `fire_at` sort is now `send.fire_at`.
65
+ - A subscriber carries its suppression inside it on every route, `suppression: { reason, detail, created_at }` or null, in place of `suppressed`, `suppression_reason`, and `suppression_detail`, which sat in list rows only, and `suppressed` beside one subscriber's answer.
66
+ - A refusal about a resource's state carries it as it stands: `post_not_draft` and `slug_permanent` the `post`, `settings_changed` and `conflict` the `settings`, an erasure's `run_in_progress` and an Add's `signup_required` the `subscriber`, `suppression_changed` the `suppression`, and every send refusal the `cursor` beside the `send`. The re-make's `sends`, `in_use.sends`, and `remade` are full send views, in place of a smaller reference.
67
+ - `DELETE /api/suppressions/:email` is replaced by `POST /api/suppressions/lift`, which takes `{ email, reason }` in the body, so a subscriber's address is no longer in a logged path, and lifts only the reason named: a different one standing now is refused with `suppression_changed`, carrying it. It lifts a block (`manual`) or a bounce (`bounce`, `import_bounce`), the only reasons a suppression has now; any other is a 400. It answers `{ email, lifted }` in place of `{ email, cleared }`. The old route answers 404.
68
+ - Each mark on an address has one owner (SPEC §7). The consent state is the person's and now keeps why they left, as a subscriber's `unsubscribe_cause`: `reader` (their link or one-click), `publisher` (Unsubscribe on request), `complaint`, `erased`, or `import` (a list that says they left, not why). When someone who left leaves again, the stronger cause stands: an erasure, then a complaint, then the first. The suppression is only the sender's side, so its `reason` is `bounce`, `import_bounce`, or `manual`, and `complaint`, `import_complaint`, and `erased` are gone from it: a complaint and an erasure are unsubscribes with that cause. A script that read a complaint or an erasure from a suppression reads the subscriber's `unsubscribe_cause`.
69
+ - Adding an address that left by a complaint or an erasure (`POST /api/subscribers`) is refused with 409 `signup_required`, carrying the `subscriber`, and sends nothing: only the person's own sign-up through the subscribe form brings them back. `acknowledge_erasure` and `erasure_acknowledgement_required` are gone, and so is `erasure_marker` on a lift. Adding anyone else who left sends a confirmation, as before (SPEC §7).
70
+ - Erasing a subscriber (`POST /api/subscribers/:id/erase`) answers `{ subscriber, deliveries }`, the subscriber as the erasure left them, in place of `{ erased: { email, deliveries, suppression } }`. A repeat answers the same subscriber with `deliveries: 0`, where it answered 404. An erased subscriber's `unsub_token`, `origin`, and `opt_in` are null, so a script that reads them handles null (SPEC §7).
71
+ - The import format is `kestrel-subscribers/2`, which the export now writes: an `unsubscribed` row says why the person left in `unsubscribe_cause` (`reader`, `publisher`, `complaint`, or `erased`), a row that doesn't say is recorded as having left elsewhere, and `suppression` takes only `bounce` or `manual`. A version 1 file still reads, its `complaint` or `erased` suppression read as an unsubscribed row with that cause, whatever its status, and a row naming no version may use either version's values. An erasure is recorded with the address alone. A row that erases someone already on the list does what **Erase** does, past sends' records included, and plans as a new outcome, `erased`, so a script that sums outcomes adds it; the commit answers 409 `run_in_progress` while a send is handing such a person to the provider. A cause on a row that isn't `unsubscribed` is refused with `invalid_field` (SPEC §7).
72
+ - An import plan's `daily_writes_warning` is now `workers_plan_advice`, and means something else: on the Workers Free plan's limits, the advice to move to Workers Paid when the import would leave more than 1,000 confirmed subscribers, rather than a count of the day's database writes. A script that read the old field reads the new one (SPEC §7).
73
+ - `GET /api/suppressions` pages like the other lists: it answers 50 suppressions at a time with `page`, takes `limit`, `offset`, `sort` (`created_at`, `email`, or `reason`), and `dir`, and filters by `reason` and an email contains-search (`search`). It answered up to 1,000 at once and silently left out any past them, which a list imported from another provider can reach on its first day. A script that read the whole list in one call follows `page` (SPEC §7).
74
+ - Every API route takes only what the API reference lists for it: a body field it doesn't name, a query parameter it doesn't list, or a value outside the ones a filter takes is refused with a 400 naming it in `field`, where most routes ignored it and answered as if it had been read. `GET /api/posts?status=bogus` listed every post and `GET /api/subscribers?suppressed=maybe` the whole list; both are now refused, and so is a parameter given twice, a `seed` or `size` the dev seed can't read, and a `kind` other than `human` or `service` on the dev token. A reader page, such as an archive link carrying a tracking parameter, still takes what it is sent.
75
+ - Describing a post's rendered email (where its preview is, its `subject`, `warnings`, and `frozen`) writes nothing, so it is a read: `POST /api/posts/:id/preview` is now `GET /api/posts/:id/preview.json`, with the same answer, and a `POST` to the old path is a 404. The rendered page itself stays at `GET /api/posts/:id/preview`.
76
+
77
+ ### Added
78
+
79
+ - Each subscriber has a page of their own, opened from their row, their address, or **View subscriber** in the row's menu, in place of the View subscriber dialog and the list's How they joined column, which moves to it. It answers "did they get my post?": a line under the address says whether they receive your posts and, if not, why, then every send their address was in, newest first, with what the provider did and reported, each linking to its record. It also shows how they joined, with the import that brought them (its file, when and by whom, and the attestation's version), their suppression with the provider's or your words and since when, why they left (for a complaint, which send it was about), and the row menu's acts. The API adds `GET /api/subscribers/:id/deliveries`, a page at a time, `GET /api/imports/:id`, and `receives_posts` on every subscriber (SPEC §7, §8).
80
+ - Each instance serves its API as an OpenAPI 3.1 document at `GET /api/openapi.json`, behind Access like the reference, so a client generator or an API client such as Postman can import it. It is generated from the same registration as the reference, so it lists the same routes, objects, and refusals, and `/llms.txt` and the reference's index name it.
81
+ - Each instance serves a public `/llms.txt`, so an agent given only its address learns what it is and where to start: the publication's name, its archive and subscribe page, and the API reference and docs behind Access, with a link to Connect Claude to the API for a service token. It is generated from the routes the instance registers, so it always matches the release (SPEC §11).
82
+ - A scheduled post that no test has been sent for says so on the editor's scheduled banner until the first test lands, and the API carries it as an `untested` condition (`info`) on the send and in the feed, so Claude can tell whether the email was tested (SPEC §8).
83
+ - **Clear** in a subscriber's ⋯ menu lifts a bounce, after a dialog that says the address failed and to clear it only when you know its cause is gone, which before only the API could do (SPEC §7). The list's legend, under What you can do, and the API reference say when to use it and when to use **Unblock**, in the same words.
84
+ - The operator's Claude context lifts a suppression through `POST /api/suppressions/lift`, naming the reason it shows, and lifts only a block or a bounce. `npm run check-context` offers the change to an existing instance.
85
+
86
+ ### Changed
87
+
88
+ - A reader who marks a post as spam is unsubscribed by it, as their own link would, instead of suppressed while still confirmed, and comes back only by subscribing again themselves. Every complaint does, whichever email it is about, a confirmation email included; one that reaches no subscriber changes nothing. Each is logged as `subscriber.complained` (SPEC §7, §12).
89
+ - **Erase** unsubscribes the person and keeps their subscriber, stripped to the address and when, in place of deleting it and leaving a do-not-contact marker. It works on someone who already unsubscribed, discards the unsubscribe link their old emails carry, and drops the words of a bounce or block kept on the address. A bounce or block no longer has to stand in for the erasure, so lifting one never frees an erased address (SPEC §7).
90
+ - On the Subscribers screen the **complaint** and **erased** flags are gone, since a complaint and an erasure are now ways of leaving: why an unsubscribed subscriber left shows on their page, not beside their status. **Unsubscribe** is offered on pending subscribers too, **Erase** isn't offered on someone already erased, and **Add subscriber** shows the server's refusal for someone who left by a complaint or an erasure, with no way past it, in place of asking whether an erased person asked to join again.
91
+ - The operator's Claude context: Claude relays `signup_required` as the person having to subscribe themselves, never lifts a complaint or an erasure, describes Erase as an unsubscribe that strips the record, and the migrate-list skill writes a source's complaints and erasures as an unsubscribed row's `unsubscribe_cause`, never as a suppression, and puts an import's `erased` rows to the publisher by name before committing, since they can't be undone. `npm run check-context` offers the change to an existing instance.
92
+ - The operator's Claude context names the default test recipients by their new field, `test_recipients`, so Claude reads them from the settings it now gets.
93
+ - The operator's Claude context tells Claude to act on a post from its `actions`, as on a send, so it never offers to schedule or send a post while no provider is connected, and that every refusal about a post, a send, a subscriber, or the settings carries it as it stands.
94
+ - The API reference no longer cites SPEC sections or invariant numbers ("(SPEC §6)", "(I3)"), which its readers, Claude included, couldn't follow; each rule was already said in its own words beside it.
95
+ - A deployment with no email provider connected now refuses scheduling, Send now, a test, a test notification through the provider, and a confirmation with 503 instead of 409, and the public subscribe route answers 503 too, as JSON and as its "not taking subscribers yet" page. The code (`provider_not_connected`) and message are unchanged, so a client that matches the code sees no difference, and one that matched the 409 status now sees 503: the status now says the deployment needs fixing, as the other refusals fixed outside the API do (SPEC §9).
96
+ - The API reference is reshaped so a person and Claude find a route by what it does and learn it without guessing. Each route leads with a name ("Schedule a post"), its method and path beneath, and lists its body fields with their types, what it returns, and the refusals it gives of its own, each with what to do next. Short topics come first (authentication, requests and what each status range means, errors with a summary of every status, the error body, and the refusals any route can give, paging, and acting safely; how to follow changes to sends is told on Follow changes to sends, the one route it concerns), and each resource opens with the object its routes return, described once field by field, so the send's conditions and full example no longer repeat across routes. In the editor the page reads as a reference, laid out as Stripe's and Mux's are: headings step down from tier to resource to route, every route and object is laid out in full with its examples and curl, each on a card of its own, beside its prose on a wide screen, the rail lists the routes of the resource you're in, the address follows you as you scroll, each section is a heading over unboxed rows, refusals and statuses a ledger by status, and the refusal table folds under Errors. The page moves to `#/api-reference`, an old `#/reference` link opening the same place there. Each route has its own address, named for what it does (`#/api-reference/cancel-send`), as do the topics, resources, and objects the contents and a route's returns link to, so those links open in a new tab and a jump moves focus to where it lands; a route's path has a copy beside it, and every heading is a link to its own address; the filter also finds a route by a refusal code it gives, the object it returns, or a URL pasted with its IDs filled in, and a topic by its title; and descriptions render their inline code and links. For Claude, `GET /api/reference.md` is now an index (the topics, and each route on one line), and `GET /api/reference/<resource>.md` holds one resource in full; `GET /api/reference` carries the new `title`, `body`, `returns`, and `errors` on each route, and `concepts`, `statuses`, `refusals`, and `objects`.
97
+ - The operator's Claude context reads the reference's index at the start of a conversation and a resource's reference before calling its routes, a quarter as much up front as the whole JSON reference it read before; it also says Resolve's choice is the publisher's, and `/migrate-list` reads `GET /api/reference/imports.md`. It now says a route's refusals each say what to do next, and the index's Errors holds only the refusals any route can give, and it points to the reference for how a save names its revision and what an act's answer carries rather than restating them, so the two can't drift apart. `npm run check-context` offers the change to an existing instance.
98
+ - Workers Free's line is drawn once and said the same everywhere: it suits a list of up to about 1,000 subscribers, and past about 5,000 one send can use up the day's database writes and stop the newsletter until midnight UTC. On Free's limits, the Dashboard and the Subscribers screen recommend Workers Paid once the confirmed list passes 1,000, and so does an import that would take it past; `GET /api/subscribers` carries the words as `workers_plan_advice`. A send's daily-writes warning now goes by its audience alone, over 5,000, and no longer counts the day's imports, which also no longer change a scheduled send. The docs move "about 5,000" to the pair (SPEC §7, §8).
99
+ - The operator's Claude context: the migrate-list skill relays an import plan's `workers_plan_advice`, recommending Workers Paid, in place of the old daily-writes warning, which had it offer to wait until midnight UTC between pieces. `npm run check-context` offers the change to an existing instance.
100
+ - The API reference says more of what a route answers and how to act on it. A send's conditions and its halt are objects of their own, linked from the send view's fields: the condition object lists each kind in a table (its severity, fields, meaning, and remedy, `missed` and `stuck` included), and the halt object each cause with one line, its fix left to the `advice` its condition carries; `GET /api/imports/format` describes its answer, `limits` included; the logo upload states its 512 KB cap; and the deliveries list, the deletes, `whoami`, the dev routes, the webhook, and media say what they answer. The index lists each resource once, an off-tier route marked on its line, and images and the SES webhook join Archive and Sends.
101
+ - The API reference and the operator's Claude context name the editor's words where the API's differ, so Claude and the publisher talk about the same thing: `email_only` is the editor's **Include in archive**, unchecked; **Edit schedule** is a reschedule and the post's settings together, so a publisher asking to edit the schedule may mean either or both; and Resolve's `unsent` and `accepted` are **Assume not sent** and **Assume sent**, which Claude now uses when it asks the publisher to choose. `npm run check-context` offers the context's change to an existing instance.
102
+ - Three API reference routes are retitled to match the editor: `POST /api/suppressions/lift` is "Unblock or clear an address", since it is both the editor's **Unblock** and its **Clear**, and its description covers both; `PUT /api/posts/:id/settings` is "Change a post's settings"; and `GET /` is "Show the home page", the name the editor and the docs already give the page at `/`, which SPEC, DESIGN, the reference, and the setup guide now use too.
103
+ - The operator's Claude context: the migrate-list skill relays each import outcome by the name the plan gives it, now that the names read as they stand, and says a `kept_…` outcome means the row's status isn't imported because the list's own record or a suppression here wins. `npm run check-context` offers the change to an existing instance.
104
+ - A send that needs your decision on deliveries no one can confirm now says so on its page ("Some deliveries can’t be confirmed either way, and wait on your decision.") instead of calling them stuck, which the API uses for something else. An import plan says an address is already suppressed here, rather than blocked, since in the editor blocked means only your own block.
105
+ - The API reference opens its Requests topic with the rules a client builds on: the reference is the API's whole contract, so what a route doesn't list isn't part of it; a read is a `GET`; an address goes in the body or a read's query, never a path; and the API changes only in a release, each break listed under Breaking, since the version number doesn't say.
106
+
107
+ ### Fixed
108
+
109
+ - A send stuck awaiting Resolve that still carried an earlier refusal of the account is no longer emailed to the publisher as the provider refusing the account. Nothing retries such a send, so the editor and the API showed only that it waits for Resolve, and the email now agrees (SPEC §8).
110
+ - A send's cached answer (`If-None-Match` on `GET /api/sends/:id`) no longer keeps a client on a stale time to finish: the tag now changes when the dispatch rate or the expected finish does.
111
+ - The API reference documents every field a refusal carries, so Claude can act on one without guessing: `cursor_ahead` lists `field` and the `cursor` where the database stands, `slug_taken` and `slug_permanent` their `field`, Schedule and Send now list `slug_permanent`, an import's `unrecognized_format` its `column` and `allowed`, and the Errors topic says once that every refusal to fix the request names the `field` at fault. A refused image or logo upload and a template with no unsubscribe link now name their `field` too. The reference's objects match the wire: a subscriber's `confirm_token` may be null, the settings' `deployment` lists `access_configured`, `aws_region`, `notify_channel`, `notify_from`, and `simulation`, an import plan's `rows` lists `row`, `email`, and `suppression_added`, and List a send's deliveries lists the `page` it answers.
112
+ - The API reference's description of a send's `phase` said `needs_attention` covers a missed or stuck send, which stay `due` and `progressing` (their conditions say so), and that `backing_off` is only an outage or rate limit, when it also covers recipients waiting on a retry. It now also says which phases each `status` has, and that `status` is the one to filter and route on.
113
+
114
+ ## [1.12.0] - 2026-10-16
115
+
116
+ **Upgrading from 1.11.0.** Five database migrations, which `npm run sync-migrations` brings in: apply them before you deploy with `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development). `0007_sends_progress_at` adds a column recording when each send last handed anyone off, and writes no rows. `0008_notifications_sandbox` rebuilds the small notifications table to admit a new kind, copying its rows once. `0009_post_email_only` adds each post's listing, reading every existing post as listed in the archive, so the archive shows what it did before; it adds a column with a default and rewrites no rows. `0010_unlisted_links` gives every post its unlisted link, and records that every send made before it links its archive address, so those addresses keep answering whatever the post's listing; a send scheduled between the migration and the deploy counts as one made before. It writes each post row once. `0011_sends_render_warnings` adds a column keeping what the render found wrong with each scheduled email, reading every earlier send as having found nothing; it adds a column with a default and rewrites no rows. Run `npm run check-context` in your instance's repository: this release changes the operator's Claude context, and a copy you've edited is merged by hand. An instance verified with an earlier release has its test post in the archive: open it under **Sent** and select **Remove from archive…**. Two API changes can break a script of yours, listed under Breaking: one that reads a send's halt `cause` reads new values, and one that sets a slug by hand handles a 409 `slug_taken` by choosing another, where it used to get a numbered slug.
117
+
118
+ ### Breaking
119
+
120
+ - A send's halt `cause`, on the API and in the `refused` condition, takes new values so each names its own fix: `opt_in`, `sending_pool`, `unsupported`, `mail_from`, `sandbox`, `paused`, `set_paused`, and this send's own `content` and `invalid_request`. `suspended` now means only an account AWS shut down (a paused account or configuration set is `paused` or `set_paused`), and an SES MAIL FROM refusal is `mail_from` rather than `sender`.
121
+ - A slug sent by hand (`slug` on `POST /api/posts`, `PUT /api/posts/:id`, `PUT /api/posts/:id/settings`, or a schedule) that another post holds is now refused with a 409 `slug_taken`, and one that isn't lowercase words joined by single hyphens with a 400 `slug_invalid`, where before it was numbered or rewritten silently. A change to a sent post's slug is a 409 `slug_permanent` (SPEC §4).
122
+
123
+ ### Added
124
+
125
+ - `GET /api/whoami` names a service token by its Client ID, as `tokenId`, so you can check which token Claude is using, and Resolve's note on a wedged send names the token that resolved it. Each service call is logged as `auth.service`, and an Access sign-in that is neither a person nor a service token as `auth.unidentified` (warn), with the names of the claims it carried and never their values (SPEC §11, §12).
126
+ - The publisher is emailed, at most once a day and naming no one, when the SES sandbox refuses a signup's confirmation, since the reader was told a link is on its way (SPEC §7, §8).
127
+ - A recent send with no delivery, bounce, or complaint receipt at all an hour after it went out says so, amber, on its record and the dashboard, as `no_receipts` in its conditions: SES's reports are probably not reaching the webhook, so its bounces and complaints suppress no one. Connect Amazon SES now sets a longer SNS retry policy on the topic, and mentions a dead-letter queue as optional (SPEC §8).
128
+ - **Email only**: a post can be sent to subscribers without being listed in the public archive or on the home page, and its archive address answers nothing (SPEC §5). Uncheck **Include in archive** in the editor's new **Post settings**, or in the Schedule, Send now, or Edit schedule dialog, and change it later on a sent post's page (**Remove from archive…**, which takes its archive address down while keeping it the post's, or **Add to archive…**; both ask first). Its page answers at the email's unlisted link and asks search engines not to index it. A draft's head and the Drafts and Sent lists mark it with an envelope and "Email only" in muted text, a scheduled post's banner says where it's going, and a post in the archive carries no mark. The API carries it as `email_only` on posts and sends, on the schedule and Send now bodies, and through `PUT /api/posts/:id/settings`, which also sets the slug; a reschedule still moves only the time. Every post the API answers carries its two addresses, absolute, as `archive_url` and `unlisted_url`, with `published_at` (when its send started) and `archive_live` (whether the archive address shows it now).
129
+ - **Unlisted links**: every email's "view in browser" link is now the post's own permanent link, `/email/<token>` on the app's origin, in place of its archive address (SPEC §5). It forwards to the archive page while the post is listed and is the page while it's email only, so changing `ARCHIVE_BASE_PATH` or moving the archive no longer breaks links in emails sent from now on. Emails sent before keep linking their archive address, which keeps working. The API carries `unlisted_token` on a post and `links.unlisted` on a send.
130
+ - The operator's Claude context tells Claude to say whether a post will be in the archive, and at which address, before scheduling or sending it; to send it as email only only when the publisher asks; and to confirm before taking a sent post out of the archive or putting it in, which the API doesn't ask about the way the dashboard does. `npm run check-context` offers the change to an existing instance.
131
+ - The dashboard keeps setup's steps after the first post, as a **Finish setting up** card, until the first post listed in the archive goes out. Its last step, **Publish your first post** (formerly Write your first post), is the one that clears it, done once a post that goes to subscribers and onto the site starts sending, so a test send that's email only, like Verify it works' own, leaves the publisher their guided start. **Check your setup** is guidance and never shows as done.
132
+
133
+ ### Changed
134
+
135
+ - The operator's Claude context: on `remake_required`, Claude now tells the publisher which scheduled posts a template or identity change would re-make and when each fires, and confirms only on their word, then tests each re-made send. Re-making a frozen email is the publisher's deliberate act (SPEC §2), which the editor asks about and the API leaves to the client.
136
+ - A post save refused as 409 `stale_revision` now carries the post as it stands and its `markdown`, so a client can re-apply its change without reading the post again.
137
+ - A route that takes no body (a post's preview, cancelling a send, and the like) now accepts a request declaring `Content-Type: application/json` with an empty body, which HTTP helpers send by habit, instead of refusing it with 415. A body sent to such a route is still refused.
138
+ - Each SES refusal gets its own advice, checked against AWS's docs, and says who makes the fix: a paused configuration set, a paused or suspended account, a custom MAIL FROM domain's MX record, a missing dedicated IP pool, an account not signed up for SES, and the sandbox are told apart, and a quota spent in the sandbox points to production access. The line now says "No one has been marked unsent because of this", which stays true on a send with readers unsent for their own reasons. Troubleshooting lists each refusal with its fix (SPEC §12).
139
+ - A rate limit that refuses the first request of three retries in a row shows in amber, on the dashboard and the send's page, that `SES_MAX_SEND_RATE` is set above your SES account's rate, rather than reading as an outage with nothing to do (SPEC §12).
140
+ - The operator's Claude context: the sandbox caution says a send holds in the sandbox, mailing only verified addresses, rather than marking the rest unsent.
141
+ - A send is flagged as **in flight too long** only when it has handed off no one for 30 minutes and nothing else explains it (a provider refusing the account, an outage it is waiting out, or a send awaiting Resolve), rather than 30 minutes after it started. A large send going out at your SES rate on Workers Free is no longer flagged, emailed about, or logged as `send.stuck`, however long it takes; one the sweep has stopped moving still is, shows no time to finish, and its email and message say nothing has gone out and why that points at the sweep (SPEC §12).
142
+ - A post's archive address moves from beside the subject into **Post settings** (top right of the editor), which save nothing until **Save**: one field with the base fixed and only the slug to type, following the subject until you do. A slug that's malformed or another post's is now refused with a message rather than numbered behind your back, and a subject whose slug another post has takes the next number (`spring-update-2`) and says so. The Schedule, Send now, and Edit schedule dialogs show the same settings under the time and apply them with the act, and a scheduled post's banner names where it's going ("Sends by email and publishes at <its archive address>" or "Sends by email only"). The web version's strip now says only that the page isn't public yet. The address is fixed once the post starts sending (SPEC §4). The API refuses a bad slug as `slug_invalid` or `slug_taken`, a change to a sent post's as `slug_permanent`, and answers `subject_slug_held` on a post's read, saves, and settings changes when the subject's address is another post's and the post got a number.
143
+ - A post's archive page is now sent with `Cache-Control: no-cache` (it was cached for an hour), so taking a post out of the archive takes effect at once. Behind a CDN, more of those requests reach the Worker.
144
+ - A sent post's page is split into **The post** and **Delivery** (the record, unchanged). The post shows its link as one field with **Copy** and **Open ↗**, and below it whether the post is in your archive or email only, with **Remove from archive…** or **Add to archive…**.
145
+ - A scheduled send's **Reschedule** is now **Edit schedule**, on its card under Sent and on the editor's banner: one dialog for the send time and the post's settings, with **Save**, which saves either or both, the settings first, and a toast that says whether the send was rescheduled or only its settings saved. The settings alone can be saved up to the send time.
146
+ - A sent post's recipient list opens on **All** rather than **Failures**, its switch now reading **All | Delivered | Failures**. The failures are still counted in the tiles above it, one switch away.
147
+ - Verify it works sends its test post as **Email only**, and ends by erasing the SES simulator addresses and any test subscriber that won't be a reader.
148
+ - The demo publication (`npm run seed`) opens with the operator's own test send, made as Verify it works makes one: email only, to one test address, before the list came in. It shows under **Sent** with the **Email only** label, and the archive leaves it out. Its scheduled post, **A quick note**, says how to send a post as email only.
149
+
150
+ ### Fixed
151
+
152
+ - A scheduled post keeps its render warnings, such as an image the post references but never uploaded: scheduling and Send now answer with them, and a preview or test of the scheduled email reports them, where before a test of it always came back clean. A template or identity change that re-makes the email keeps the new render's (SPEC §5).
153
+ - An SES refusal of the key's signature that AWS answers with a 400 (an incomplete or invalid signature, an expired request, a missing token) now holds the send as a credentials refusal with no reader consumed, as a wrong key already did, instead of marking each reader failed for good (SPEC §12).
154
+ - In the SES sandbox, a list send no longer marks every unverified reader unsent and finishes quietly. It mails your verified addresses, holds with "The provider is refusing the account" and advice to request production access, and goes to everyone else once AWS grants it, mailing no one twice. A reader whose address contains your From address is no longer taken for an unverified sender, which held every send (SPEC §12).
155
+ - An SES answer that isn't a reader's fault no longer marks a batch of readers unsent. A request SES didn't receive whole or a limit is retried, a refusal of the email itself holds that send for you with SES's words without holding other posts, an invalid batch that names one reader refuses only that reader, the same failure on several readers of one request with none taken holds the batch, and an answer Kestrel doesn't recognize waits for **Resolve…** rather than being guessed. A malformed address on an imported list is left out of the request and marked unsent alone (SPEC §10, §12).
156
+ - A batch SES answers with a server error, or aborts, is no longer sent again, since AWS says such an answer can follow emails it already took: its readers wait for **Resolve…** (SPEC §10).
157
+ - When SES refuses a badly signed request and echoes back what it was sent, the access key ID, the signature, and any session token are now removed from its words before they reach a held send's page, the email about it, a failed delivery's record, or the log (SPEC §9, §12).
158
+ - SES's bounces and complaints are no longer lost when the webhook can't fetch SNS's signing certificate: it answers 503, so SNS retries, where before it refused the message as forged and SNS discarded it for good. A message whose SNS signature doesn't hold, or from another SNS topic, is still refused, now logged as `webhook.refused` at error with the topic that came and the one expected; a request with no SNS signature at all is refused without one (SPEC §10, §12).
159
+ - A test send that isn't sent says why, in the editor and on the API (`not_sent`): your provider's own words and the fix where it's known, so a test the SES sandbox refuses for an unverified address no longer just says to send again. A test your provider never answers says it may have arrived, rather than reading as a server error, and both are logged as `test.refused` and `test.unknown` (SPEC §5, §12).
160
+ - A send that SES keeps partly refusing, as a daily quota frees up a few at a time, is one refusal until SES takes a batch with nothing refused: you're emailed about it once, its page keeps the time the refusal began, and its retries keep spacing out up to an hour apart instead of starting over every few minutes. The readers SES took are still recorded as sent at once, and the rest wait with no retry spent (SPEC §12).
161
+ - A post that comes due while another send is held by SES refusing the account, or by an outage, now waits with it instead of firing: it asks SES nothing, reads as held with the same next retry, and goes out once SES answers. You're emailed once per refusal rather than once per send, and an outage where SES doesn't answer no longer leaves a batch of each new post waiting for Resolve. A held post goes to your list as it stands when it actually goes out (SPEC §6, §12).
162
+ - A send paused for a provider outage reads **Backing off**, with when it next retries, on its page, the Sent list, and the dashboard, and shows no time to finish, even while a batch the provider never answered waits for Resolve. Before, it read as sending normally for up to an hour (SPEC §12).
163
+
13
164
  ## [1.11.0] - 2026-10-15
14
165
 
15
166
  **Upgrading from 1.10.0.** No database migrations. Run `npm run check-context` in your instance's repository: `/migrate-list` now reads the import format from "Bring your existing list".
@@ -433,7 +584,9 @@ The first tagged release: a self-contained newsletter app on a Cloudflare Worker
433
584
  - A build-version stamp: every instance reports its version, commit, and build time, read-only in the editor and at `GET /api/version`.
434
585
  - This changelog and a semantic-versioning release process.
435
586
 
436
- [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.11.0...HEAD
587
+ [Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.13.0...HEAD
588
+ [1.13.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.13.0
589
+ [1.12.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.12.0
437
590
  [1.11.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.11.0
438
591
  [1.10.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.10.0
439
592
  [1.9.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.9.0
package/README.md CHANGED
@@ -26,20 +26,19 @@ npm run dev
26
26
 
27
27
  The first time, it asks you to confirm the local database's migrations. Then open http://localhost:8787/dashboard/, with nothing to sign in with. If port 8787 is taken, the `Ready on` line shows the one it picked.
28
28
 
29
- In a second terminal, load a demo publication with posts, subscribers, and past sends, or empty it again:
29
+ In a second terminal, load a demo publication with posts, subscribers, and past sends:
30
30
 
31
31
  ```bash
32
32
  npm run seed
33
- npm run reset
34
33
  ```
35
34
 
36
- When you're ready to deploy, this folder can be your instance's repository. Push it to an empty GitHub repository, then follow [Deploy the app](docs/get-started/02-deploy.md#2-sign-in-to-cloudflare) from signing in to Cloudflare.
37
-
38
- ## Run your own
35
+ To start over, reset the app to the state a new instance starts in: no posts, subscribers, or settings, with the first-run dashboard and its setup checklist:
39
36
 
40
- Kestrel installs from npm as [`@kurtbruns/kestrel`](https://www.npmjs.com/package/@kurtbruns/kestrel). Your instance lives in a small repository of your own, which holds its settings and names the release it runs. [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.
37
+ ```bash
38
+ npm run reset
39
+ ```
41
40
 
42
- 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. Both are in the editor's **Docs** tab, and an instance serves every page as Markdown for Claude to read.
41
+ When you're ready to deploy your own instance, see the [setup guide](https://getkestrel.dev/docs/), which contains instructions for deploying and running Kestrel.
43
42
 
44
43
  ## Contributing
45
44
 
package/cli/dev-api.mjs CHANGED
@@ -145,7 +145,7 @@ async function devToken(base, tag) {
145
145
  ...(typeof message === "string" ? [message] : []),
146
146
  );
147
147
  }
148
- const { token, runId } = await res.json();
148
+ const { token, run_id: runId } = await res.json();
149
149
  return { token, runId: typeof runId === "string" && runId ? runId : undefined };
150
150
  }
151
151
 
package/cli/reset.mjs CHANGED
@@ -20,4 +20,6 @@ export async function resetCommand(argv) {
20
20
  "[reset] done — the local database is a fresh install (no posts, subscribers, or settings).",
21
21
  );
22
22
  console.log(` view the first-run dashboard: ${url || `${base}/dashboard/`}`);
23
+ // An open editor tab holds the settings it read when it loaded (the old name and logo).
24
+ console.log(" reload any dashboard tab already open, so it reads the fresh install.");
23
25
  }
package/cli/seed.mjs CHANGED
@@ -134,7 +134,7 @@ export async function seedCommand(argv) {
134
134
  ` posts: ${summary.posts.sent} sent, ${summary.posts.scheduled} scheduled, ${summary.posts.draft} draft`,
135
135
  );
136
136
  console.log(
137
- ` deliveries: ${summary.deliveries} • images written: ${summary.imagesWritten} • logo written: ${summary.logoWritten}`,
137
+ ` deliveries: ${summary.deliveries} • images written: ${summary.images_written} • logo written: ${summary.logo_written}`,
138
138
  );
139
139
  console.log("");
140
140
  console.log(" view it:");
@@ -142,4 +142,6 @@ export async function seedCommand(argv) {
142
142
  for (const a of summary.urls.archive) {
143
143
  console.log(` archived post: ${a}`);
144
144
  }
145
+ // An open editor tab holds the settings it read when it loaded (the old name and logo).
146
+ console.log(" reload any dashboard tab already open, so it reads the new publication.");
145
147
  }
package/demo/README.md CHANGED
@@ -6,7 +6,7 @@ Everything `npm run seed` loads into a local dev server, apart from the subscrib
6
6
  - **`posts/`**: one folder per post, a page bundle: the post is `index.md`, and any image it shows sits beside it (`1-the-hovering-hunter/kestrel.webp`). The post refers to the image by its filename, as in `![…](kestrel.webp)`, so the link resolves when you preview the Markdown, and the seed attaches the file to that post. The number in a folder's name sets its place: sent posts go out oldest first in that order, and a post's id (and so its editor URL) follows its position.
7
7
  - **`field-notes-logo.png`**: the publication logo, named by `publication.md`.
8
8
 
9
- The worker bundles the Markdown files (the `rules` entry in `wrangler.jsonc`) and parses them in `src/dev/demo.ts`, which lists each post's folder; add a post by adding its folder and one line there. `npm run dev` watches this folder, so an edit here is in the next seed. `cli/seed.mjs` uploads the images through the running dev server, which stores them in local R2. The subscribers, the four completed sends, and the scheduled send's fire time come from the timeline in `src/dev/seed.ts`.
9
+ The worker bundles the Markdown files (the `rules` entry in `wrangler.jsonc`) and parses them in `src/dev/demo.ts`, which lists each post's folder; add a post by adding its folder and one line there. `npm run dev` watches this folder, so an edit here is in the next seed. `cli/seed.mjs` uploads the images through the running dev server, which stores them in local R2. The subscribers, the four completed sends, and the scheduled send's fire time come from the timeline in `src/dev/seed.ts`. The first post, `0-test-email`, is the operator's test send, made the way Verify it works has them make one: email only, to their own second address alone, before the list came in. That address subscribed just before it and unsubscribed just after, so it's in no later send.
10
10
 
11
11
  ## Front matter
12
12
 
@@ -28,8 +28,10 @@ A post:
28
28
  |---|---|---|
29
29
  | `subject` | yes | The subject line, which is also the post's title in the list. |
30
30
  | `slug` | yes | The post's path in the archive. |
31
- | `status` | yes | `sent`, `scheduled`, or `draft`. The seed timeline has four completed sends, so at most four posts can be `sent`. |
31
+ | `status` | yes | `sent`, `scheduled`, or `draft`. The seed timeline has four completed sends, so at most four posts can be `sent` without `sent_to`. |
32
32
  | `edited` | no | Drafts only: when the draft was last edited, as `2 days ago`. |
33
+ | `email_only` | no | Sent or scheduled posts: `true` to send it without listing it in the archive or on the home page. |
34
+ | `sent_to` | no | Sent posts only: a test send to just these addresses, comma-separated, before the list is imported, instead of a timeline send. Keep them on `.example`. |
33
35
 
34
36
  ## The images
35
37
 
@@ -0,0 +1,13 @@
1
+ ---
2
+ subject: Test email
3
+ slug: test-email
4
+ status: sent
5
+ email_only: true
6
+ sent_to: proof@fieldnotes.example
7
+ ---
8
+
9
+ # Test email
10
+
11
+ This is the test send that proved Field Notes works end to end, before its list came in: subscribing, confirming, scheduling, and a real send landing in a real inbox.
12
+
13
+ It went out as email only, so it isn't in the archive or on the home page. Its web version still opens from the email, which is how you're reading this.
@@ -8,4 +8,6 @@ status: scheduled
8
8
 
9
9
  Not every issue has to be long. Sometimes the best newsletter is a quick note that there's something new to read, watch, or listen to.
10
10
 
11
+ A note like this one doesn't always belong in your archive. To send it as email only, uncheck **Include in archive** in its Post settings.
12
+
11
13
  Thanks for reading Field Notes.