@kurtbruns/kestrel 1.17.0 → 1.19.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/.dev.vars.example +24 -2
  2. package/CHANGELOG.md +73 -1
  3. package/bin/kestrel.mjs +1 -1
  4. package/cli/dev.mjs +3 -3
  5. package/cli/init.mjs +98 -4
  6. package/dist/public/dashboard/app.js +327 -327
  7. package/dist/public/dashboard/app.js.map +4 -4
  8. package/dist/public/dashboard/index.html +6 -3
  9. package/dist/public/dashboard/styles.css +133 -21
  10. package/dist/worker/index.js +5940 -4434
  11. package/docs/README.md +5 -3
  12. package/docs/get-started/02-deploy.md +25 -21
  13. package/docs/get-started/03-access.md +20 -8
  14. package/docs/get-started/04-aws-account.md +68 -0
  15. package/docs/get-started/05-ses.md +326 -0
  16. package/docs/get-started/06-production-access.md +67 -0
  17. package/docs/get-started/{05-verify.md → 07-verify.md} +16 -26
  18. package/docs/get-started/{06-go-live.md → 08-go-live.md} +4 -2
  19. package/docs/guides/01-allow-claude.md +2 -2
  20. package/docs/guides/02-automate.md +1 -1
  21. package/docs/guides/03-notifications.md +12 -12
  22. package/docs/guides/07-upgrade.md +4 -4
  23. package/docs/guides/09-as-your-list-grows.md +8 -18
  24. package/docs/publishing/02-sending-rules.md +2 -2
  25. package/docs/publishing/03-name-your-publication.md +2 -0
  26. package/docs/publishing/04-write-a-post.md +1 -1
  27. package/docs/publishing/06-troubleshooting.md +37 -9
  28. package/docs/publishing/08-connect-claude.md +1 -1
  29. package/docs/publishing/09-share-your-subscribe-link.md +3 -1
  30. package/docs/publishing/10-check-your-setup.md +15 -9
  31. package/docs/publishing/README.md +8 -3
  32. package/docs/reference/01-configuration.md +18 -16
  33. package/docs/reference/02-sending-domain-dns.md +4 -4
  34. package/migrations/0015_provider_account.sql +18 -0
  35. package/migrations/0016_held_receipts.sql +26 -0
  36. package/migrations/0017_provider_account_rejected.sql +24 -0
  37. package/migrations/0018_notify_addresses.sql +13 -0
  38. package/migrations/0019_sending.sql +67 -0
  39. package/migrations/0020_notifications_not_sending.sql +40 -0
  40. package/package.json +1 -1
  41. package/packages/kestrel/lib/api.mjs +28 -3
  42. package/packages/kestrel/lib/oauth.mjs +5 -3
  43. package/template/claude/CLAUDE.md +5 -3
  44. package/template/wrangler.jsonc +6 -6
  45. package/docs/get-started/04-ses.md +0 -310
@@ -1,23 +1,29 @@
1
1
  # Check your setup
2
2
 
3
- Before your readers get anything, check three things yourself: your email arrives and replies reach you, subscribing works, and problems will reach you.
3
+ Three checks before your readers get anything: is email getting out, will you hear about problems, and what will your readers get. The dashboard's setup steps tick each one off from what Kestrel sees, so you, and Claude, can tell what's left.
4
4
 
5
- **Email has to be connected first.** Until it is, nothing can be sent, and the dashboard's checklist shows **Connect Amazon SES** in place of this step, as [Connect Amazon SES](../get-started/04-ses.md) describes.
5
+ **Email has to be connected first.** Until it is, nothing can be sent, and **Send a test email** waits on the dashboard's **Connect Amazon SES** step, which [Connect Amazon SES](../get-started/05-ses.md) covers.
6
6
 
7
- ## 1. Send yourself a test
7
+ **Until AWS grants production access, Amazon SES mails only addresses verified in SES.** So test with a verified address, and hold the subscribe link until then. The dashboard's **Request production access** step says where AWS's review stands, and [Request production access](../get-started/06-production-access.md) covers the request.
8
8
 
9
- Choose **Template** in the sidebar, then **Send test email**, and send a sample post to your own inboxes. When it arrives, check:
9
+ ## 1. Send a test email
10
+
11
+ Choose **Template** in the sidebar, then **Send test email**, and send a sample post to your own inboxes. This is the test that proves email gets out: signups stay closed until SES takes an email, and the step is checked off once it does. When it arrives, check:
10
12
 
11
13
  - **The From name and address,** which readers see first. They're set when your instance is deployed.
12
14
  - **Where it lands:** the inbox, Promotions, or spam. If it lands in spam, check your sending domain's records, as [Sending-domain DNS](../reference/02-sending-domain-dns.md) describes.
13
- - **A reply.** Reply to the test, and check that it reaches your inbox. Kestrel never sees replies, so this depends on [forwarding your From address](../get-started/04-ses.md#9-receive-replies-at-your-from-address).
15
+ - **A reply.** Reply to the test, and check that it reaches your inbox. Kestrel never sees replies, so this depends on [forwarding your From address](../get-started/05-ses.md#8-receive-replies-at-your-from-address).
14
16
 
15
- ## 2. Subscribe yourself
17
+ If SES refuses the test, the dashboard says why in red above the steps, with what to check. Send another test once it's fixed.
16
18
 
17
- Open your subscribe page from **Settings → Ways to subscribe**, subscribe with a personal address, and confirm. You see what a new reader sees, and you appear under **Subscribers** as **Confirmed**.
19
+ ## 2. Get notified
20
+
21
+ Under **Settings → Notifications**, add your address under **Send notifications to** with **Add inbox**, save, and choose **Send a test notification**. It goes to each address. If someone else runs your instance, add their address too, so a problem reaches both of you. Kestrel then emails you when a send finishes or needs you, as [Notifications](06-troubleshooting.md#notifications) lists. The step is checked off once an address is saved and the last notification arrived. If the last notification didn't arrive, the dashboard says so above the steps, with the channel's words.
18
22
 
19
- ## 3. Set your notifications address
23
+ ## 3. See what your readers get
24
+
25
+ Open your subscribe page from **Settings → Ways to subscribe**, subscribe with a personal address, and confirm. You see what a new reader sees, and you appear under **Subscribers** as **Confirmed**.
20
26
 
21
- Under **Settings → Notifications**, fill in **Send notifications to**, save, and choose **Send a test notification**. Kestrel then emails you when a send finishes or needs you, as [Notifications](06-troubleshooting.md#notifications) lists.
27
+ Then, once your first post is scheduled, **Send a test email for your first post** from its page. Scheduling makes the exact email your list will get, so this test is what goes out, and the step is checked off once it's sent. A test sent from a draft is a preview and doesn't count, and a change to your template or publication identity re-makes the scheduled email, so the step opens again until you test the new one. [Write and send a post](04-write-a-post.md) covers scheduling.
22
28
 
23
29
  **If you're bringing more than about 1,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import.** [Bring your existing list](01-your-list.md#limits) explains why.
@@ -4,12 +4,17 @@ These pages get you publishing in the dashboard, and cover the decisions to make
4
4
 
5
5
  ## Set up your publication
6
6
 
7
- These are the steps to set up your publication. If you're starting without a list, skip importing one.
7
+ These are the dashboard's setup steps, in its order. Each is checked off from what Kestrel can see, which Claude reads too, except sharing your link, which Kestrel can't see. Connecting Amazon SES and requesting production access are for whoever runs your Kestrel instance, in [Connect Amazon SES](../get-started/05-ses.md) and [Request production access](../get-started/06-production-access.md). If you're starting without a list, skip importing one.
8
8
 
9
+ 1. Connect Amazon SES
10
+ 1. [Send a test email](10-check-your-setup.md)
9
11
  1. [Name your publication](03-name-your-publication.md)
10
- 1. [Check your setup](10-check-your-setup.md)
12
+ 1. Get notified
13
+ 1. Request production access
11
14
  1. [Bring your existing list](01-your-list.md)
12
- 1. [Write and send a post](04-write-a-post.md)
15
+ 1. [Write your first post](04-write-a-post.md)
16
+ 1. Schedule your first post
17
+ 1. Send a test email for your first post
13
18
  1. [Share your subscribe link](09-share-your-subscribe-link.md)
14
19
 
15
20
  ## Go further
@@ -10,11 +10,11 @@ In local development, `npm run dev` reads the top level of `wrangler.jsonc` and
10
10
 
11
11
  None of these can be read or changed from inside the app, by the editor or by Claude. Settings in the editor shows the public ones, read-only.
12
12
 
13
- This is the `production` block a new instance starts with. [Deploy the app](../get-started/02-deploy.md) fills in its database and hostnames:
13
+ This is the `production` block a new instance starts with, here for an instance named `newsletter`. [Deploy the app](../get-started/02-deploy.md) fills in its database and hostnames:
14
14
 
15
15
  ```jsonc
16
16
  "production": {
17
- "name": "kestrel-production",
17
+ "name": "newsletter-production",
18
18
  "routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }],
19
19
  "workers_dev": false,
20
20
  "preview_urls": false,
@@ -23,12 +23,12 @@ This is the `production` block a new instance starts with. [Deploy the app](../g
23
23
  "d1_databases": [
24
24
  {
25
25
  "binding": "DB",
26
- "database_name": "kestrel-production",
26
+ "database_name": "newsletter-production",
27
27
  "database_id": "REPLACE_WITH_PRODUCTION_D1_ID",
28
28
  "migrations_dir": "migrations"
29
29
  }
30
30
  ],
31
- "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "kestrel-media-production" }],
31
+ "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "newsletter-media-production" }],
32
32
  "vars": {
33
33
  "PROVIDER": "",
34
34
  "APP_ORIGIN": "https://newsletter.example.com",
@@ -46,9 +46,9 @@ Vars go in the environment block's `vars`. Every value is a string.
46
46
 
47
47
  - `PROVIDER` optional
48
48
 
49
- The email provider: `ses`, or `fake` for local development. Default: none, so no provider is connected. Refused unless it's exactly `ses`, `fake`, or `none`: a misspelling, or `SES`, is never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md).
49
+ The email provider: `ses`, or `fake` for local development. Default: none, so no provider is connected. Refused unless it's exactly `ses`, `fake`, or `none`: a misspelling, or `SES`, is never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/05-ses.md).
50
50
 
51
- Until it's set, or while it's empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.
51
+ Until it's set, or while it's empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/05-ses.md) changes it to `ses`.
52
52
 
53
53
  Change it, or move to another SES account or region, only while no send is sending. The dashboard lists any send in progress.
54
54
 
@@ -64,11 +64,11 @@ Vars go in the environment block's `vars`. Every value is a string.
64
64
 
65
65
  - `AWS_REGION` required with SES
66
66
 
67
- The SES region. The template sets `us-east-1`.
67
+ The SES region. The template sets `us-east-1`. SES keeps identities, quotas, and the sandbox separately in each region, so set the region where you verified your sending hostname. On an AWS page, go by the region menu at the top right, not the page's address. A page for a global service, such as IAM or Billing, has no region to choose, so open SES before you choose. A few newer regions must be [enabled for your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html) first.
68
68
 
69
69
  - `FROM_ADDRESS` required with a real provider
70
70
 
71
- The `From:` of every email, and the identity your provider authenticates. Refused unless it's an email address, optionally with a display name in front, like `Name <address>`, and never on `example.com`, unless `PROVIDER` is `fake`.
71
+ The `From:` of every email, and the identity your provider authenticates. Refused unless it's an email address, optionally with a display name in front, like `Name <address>`, and never on `example.com`, unless `PROVIDER` is `fake`. With Amazon SES, when no identity covering it is verified for sending in `AWS_REGION`, **Settings → Email sender** says so in red within about ten minutes, without sending anything.
72
72
 
73
73
  It isn't the publication's name, tagline, or logo. You set those in the editor, under **Settings → Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, the emails and reader pages show no name at all.
74
74
 
@@ -84,9 +84,9 @@ Vars go in the environment block's `vars`. Every value is a string.
84
84
 
85
85
  - `SES_MAX_SEND_RATE` optional
86
86
 
87
- Your SES account's maximum send rate, in messages a second. Default: `14`, a common starting rate, not your account's. Refused unless it's a whole number above zero.
87
+ A cap on the send rate, in messages a second. Usually left unset: the app reads your SES account's maximum send rate itself, about every ten minutes while nothing is sending, and keeps to the lower of the two when both are known. Without either, it sends at `14`, a common starting rate, not your account's. Refused unless it's a whole number above zero. **Settings → Email sender** shows the rate a send keeps to, and where it comes from.
88
88
 
89
- The app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**: `1` in the sandbox, and whatever AWS grants after that. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. A rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.
89
+ The app sends SES one request a second, the most SES takes, and each request carries one second of the rate, up to 50 recipients. Set this only to send slower than your account allows, or when the app's key can't read the account (it needs `ses:GetAccount`). To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. A rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.
90
90
 
91
91
  - `SUBREQUEST_BUDGET` optional
92
92
 
@@ -138,11 +138,11 @@ Store each with `npx wrangler secret put NAME --env production`, which prompts f
138
138
 
139
139
  - `ACCESS_ALLOWED_EMAILS` optional
140
140
 
141
- A comma-separated list of the people allowed to sign in. Without it, the app lets in anyone the Access policy allows, so it keeps the editor limited to named people if the policy is widened later. It applies to people only. A service token, the credential a script uses, carries no email, so the Access policy alone decides whether it gets in.
141
+ A comma-separated list of the people allowed to sign in, such as `you@example.com, publisher@example.com`. Spaces and capital letters don't matter. Without it, the app lets in anyone the Access policy allows, so it keeps the editor limited to named people if the policy is widened later. It applies to people only. A service token, the credential a script uses, carries no email, so the Access policy alone decides whether it gets in.
142
142
 
143
143
  - `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` required with SES
144
144
 
145
- The IAM user's access key, which sends through SES.
145
+ The IAM user's access key, which sends through SES. When SES rejects it, because the key ID is unknown, the secret doesn't match it, or the AWS account isn't signed up for SES, **Settings → Email sender** says so in red within about ten minutes, without sending anything.
146
146
 
147
147
  - `SNS_TOPIC_ARN` required with SES
148
148
 
@@ -150,7 +150,7 @@ Store each with `npx wrangler secret put NAME --env production`, which prompts f
150
150
 
151
151
  - `SES_CONFIGURATION_SET` optional, recommended with SES
152
152
 
153
- The configuration set named on every email, so SES reports bounces and complaints. A name SES can't find in the region holds every list send until it's fixed.
153
+ The configuration set named on every email, so SES reports bounces and complaints. A name SES can't find in the region holds every list send until it's fixed. **Settings → Email sender** says so in red within about ten minutes, without sending anything, and does the same when the set's sending is turned off, as long as the app's key may call `ses:GetConfigurationSet`.
154
154
 
155
155
  - `DEV_AUTH_SECRET` never on a deployed environment
156
156
 
@@ -162,7 +162,9 @@ Wrangler doesn't carry bindings or vars over from the top level of `wrangler.jso
162
162
 
163
163
  - `name` required
164
164
 
165
- The Worker's name, such as `kestrel-production`.
165
+ The Worker's name. `kestrel init` names the Worker, the database, and the bucket after the instance. The instance's name is its folder's name, made to fit the rule below (`My Newsletter` gives `my-newsletter`, and `newsletter.example.com` gives `newsletter-example-com`), or the name given with `init`'s `--name`. An instance named `newsletter` deploys as `newsletter-production`, with the database of the same name and the bucket `newsletter-media-production`, and the top level's development Worker is `newsletter-dev`. A name takes lowercase letters, digits, and hyphens, starts and ends with a letter or digit, and is at most 46 characters, so every name made from it fits Cloudflare's rules. It can't be `kestrel`, which instances made by earlier releases use.
166
+
167
+ Each instance in a Cloudflare account needs a name of its own, since a deploy under another instance's Worker name replaces that Worker. An instance made by an earlier release keeps its names, `kestrel-production` and `kestrel-media-production`, and needs no change.
166
168
 
167
169
  - `routes` required
168
170
 
@@ -178,7 +180,7 @@ Wrangler doesn't carry bindings or vars over from the top level of `wrangler.jso
178
180
 
179
181
  - `observability` optional
180
182
 
181
- Workers Logs, where the app's log lines land. On in the template. Kestrel sets no sampling, since sampling could drop the one minute that reports a stuck or missed send, so leave `head_sampling_rate` at its default of 1. Cloudflare's own daily limit on the Free plan can still stop new logs for the rest of the day, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes.
183
+ Workers Logs, where the app's log lines land. On in the template. Kestrel sets no sampling, since sampling could drop the one minute that reports a stuck or missed send, so leave `head_sampling_rate` at its default of 1. Cloudflare's own daily limit on the Free plan can still stop new logs for the rest of the day, as [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes.
182
184
 
183
185
  - `DB` required
184
186
 
@@ -194,7 +196,7 @@ Wrangler doesn't carry bindings or vars over from the top level of `wrangler.jso
194
196
 
195
197
  ## What the app refuses
196
198
 
197
- The app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly, and each setting above says what it refuses. In local development, `SIMULATE_SENDS` must also be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn't matter. A deployed instance ignores it.
199
+ The app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly, and each setting above says what it refuses. In local development, `SIMULATE_SENDS` must also be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default, and, for `ses`, by `:refuse=` and a cause SES refuses every email for (`credentials`, `opt_in`, `configuration`, `sender`, `mail_from`, `paused`, `set_paused`, `suspended`, or `unsupported`), as in `ses:refuse=configuration`. `SIMULATE_ACCOUNT` must be off, or, beside `SIMULATE_SENDS=ses`, the SES account state local development stands in for: `sandbox`, `pending`, `denied`, `production`, or `permission`, optionally followed by `:unrouted` or by one account problem, which shows its warning: `:set_missing`, `:set_paused`, `:key_rejected`, `:sending_disabled`, `:shutdown`, `:sender_unverified`, or `:probation`, as in `production:set_missing`. Case doesn't matter. A deployed instance ignores both.
198
200
 
199
201
  Until it's fixed, no sends go out, and each place says what its reader can act on:
200
202
 
@@ -1,6 +1,6 @@
1
1
  # Sending-domain DNS: SPF, DKIM, and DMARC
2
2
 
3
- Inboxes decide whether to trust your mail by checking records in the DNS of the domain it comes from. This page explains the three checks they run: SPF, DKIM, and DMARC. It covers the records [Connect Amazon SES](../get-started/04-ses.md) has you add, what each proves, how to check them, and when to tighten DMARC.
3
+ Inboxes decide whether to trust your mail by checking records in the DNS of the domain it comes from. This page explains the three checks they run: SPF, DKIM, and DMARC. It covers the records [Connect Amazon SES](../get-started/05-ses.md) has you add, what each proves, how to check them, and when to tighten DMARC.
4
4
 
5
5
  ## Why all three
6
6
 
@@ -27,13 +27,13 @@ SES generates the DKIM values. Copy them exactly as SES lists them, since they d
27
27
 
28
28
  SPF needs no record of yours on SES's default path, since it passes for a subdomain of `amazonses.com`. An SPF record, at `bounce.send`, comes with the custom MAIL FROM domain.
29
29
 
30
- Email Routing adds MX, SPF, and DKIM records at `send` itself, the DKIM one under the selector `cf2024-1`. They're for mail sent *to* your From address, so readers' replies reach the publisher ([Connect Amazon SES](../get-started/04-ses.md#9-receive-replies-at-your-from-address)). They don't affect your sending: inboxes check SES's mail for SPF at its return path, and for DKIM against SES's own keys.
30
+ Email Routing adds MX, SPF, and DKIM records at `send` itself, the DKIM one under the selector `cf2024-1`. They're for mail sent *to* your From address, so readers' replies reach the publisher ([Connect Amazon SES](../get-started/05-ses.md#8-receive-replies-at-your-from-address)). They don't affect your sending: inboxes check SES's mail for SPF at its return path, and for DKIM against SES's own keys.
31
31
 
32
32
  ### SPF: authorize the return path
33
33
 
34
34
  SPF checks the message's return path, the address bounces go back to, not its `From:` header. DMARC passes when SPF or DKIM passes and aligns with the `From:` domain. DKIM aligns, so an aligned SPF is a second check in your favor, not a requirement.
35
35
 
36
- SES uses a subdomain of its own domain, `amazonses.com`, as the return path by default. SPF then passes for it but doesn't align, and DMARC passes through DKIM alone. A custom MAIL FROM domain, such as `bounce.send.example.com`, aligns SPF too. [Connect Amazon SES](../get-started/04-ses.md#2-set-a-custom-mail-from-domain) sets one up, with an MX record that names your region and an SPF record:
36
+ SES uses a subdomain of its own domain, `amazonses.com`, as the return path by default. SPF then passes for it but doesn't align, and DMARC passes through DKIM alone. A custom MAIL FROM domain, such as `bounce.send.example.com`, aligns SPF too. [Connect Amazon SES](../get-started/05-ses.md#1-verify-your-sending-hostname) sets one up, with an MX record that names your region and an SPF record:
37
37
 
38
38
  ```
39
39
  bounce.send.example.com. MX 10 feedback-smtp.us-east-1.amazonses.com.
@@ -67,7 +67,7 @@ Once the reports show your sends passing and aligned, tighten the policy to `p=q
67
67
 
68
68
  ## Verify alignment
69
69
 
70
- A test email proves alignment better than any DNS lookup. Send yourself one, as in [Verify it works](../get-started/05-verify.md#2-check-that-it-passes-authentication), and read its `Authentication-Results` header. In Gmail, open the message's menu, and select **Show original**. Expect:
70
+ A test email proves alignment better than any DNS lookup. Send yourself one, as in [Verify it works](../get-started/07-verify.md#2-check-that-it-passes-authentication), and read its `Authentication-Results` header. In Gmail, open the message's menu, and select **Show original**. Expect:
71
71
 
72
72
  - `dkim=pass` for `send.example.com`.
73
73
  - `dmarc=pass` for `send.example.com`.
@@ -0,0 +1,18 @@
1
+ -- 0015_provider_account: what the provider's account last said about itself (SPEC §9).
2
+ --
3
+ -- One row, written by the sweep's account check: when it last asked (`attempted_at`), what came
4
+ -- of it (`outcome`: `ok`, `permission` for a key not allowed to ask, `unreachable` for no usable
5
+ -- answer), and the last answer it got (`answer`, JSON, with `checked_at`), which stands until a
6
+ -- newer one replaces it. It is an observation, not a preference, so it lives apart from the
7
+ -- settings row; nothing that decides whether to send reads it.
8
+ --
9
+ -- What can't be recovered: nothing. The table starts empty and fills on the first idle tick.
10
+
11
+ CREATE TABLE provider_account (
12
+ id INTEGER PRIMARY KEY CHECK (id = 1),
13
+ attempted_at INTEGER NOT NULL,
14
+ outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'permission', 'unreachable')),
15
+ checked_at INTEGER,
16
+ answer TEXT CHECK (answer IS NULL OR json_valid(answer)),
17
+ CHECK ((checked_at IS NULL) = (answer IS NULL))
18
+ ) STRICT;
@@ -0,0 +1,26 @@
1
+ -- 0016_held_receipts: receipts that name a message the record has not yet learned (SPEC §10).
2
+ --
3
+ -- The send loop writes a recipient's provider message id when the provider answers the batch,
4
+ -- and the provider can report a bounce, complaint, or delivery for that message before then. A
5
+ -- receipt that names a message no delivery row holds yet, for an address with a recipient still
6
+ -- in flight, is kept here (`provider_id`, the address, the event and its words, when it arrived,
7
+ -- and `delivery_id`, the in-flight recipient it waits for) and applied to the record by the
8
+ -- sweep once a row holds that id, or once the publisher resolves that recipient as sent; it
9
+ -- lands on no other row. One that has waited a day is discarded. The list
10
+ -- already acted on it when it arrived, so nothing here suppresses or unsubscribes anyone.
11
+ --
12
+ -- What can't be recovered: nothing. The table starts empty.
13
+
14
+ CREATE TABLE held_receipts (
15
+ id INTEGER PRIMARY KEY,
16
+ provider_id TEXT NOT NULL,
17
+ email TEXT NOT NULL,
18
+ event TEXT NOT NULL CHECK (event IN ('delivered', 'bounced', 'complained')),
19
+ detail TEXT,
20
+ hard INTEGER CHECK (hard IN (0, 1)),
21
+ at INTEGER NOT NULL,
22
+ delivery_id INTEGER NOT NULL
23
+ ) STRICT;
24
+
25
+ -- Erasure discards an address's held receipts by address.
26
+ CREATE INDEX idx_held_receipts_email ON held_receipts (email);
@@ -0,0 +1,24 @@
1
+ -- 0017_provider_account_rejected: an account check whose key SES rejects (SPEC §13).
2
+ --
3
+ -- The account check used to file a key SES doesn't know, a secret that doesn't match, and an
4
+ -- account not signed up for SES as `unreachable`, the same as no answer, so a dead key read
5
+ -- as "can't tell" and was found only by sending. It is now its own outcome, `rejected`.
6
+ -- `provider_account.outcome`'s CHECK doesn't admit it and a STRICT table's CHECK can't be
7
+ -- altered, so the table is rebuilt with `rejected` added, its one row carried over.
8
+ --
9
+ -- What can't be recovered: nothing. The row is copied as it was.
10
+
11
+ CREATE TABLE provider_account_next (
12
+ id INTEGER PRIMARY KEY CHECK (id = 1),
13
+ attempted_at INTEGER NOT NULL,
14
+ outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'permission', 'rejected', 'unreachable')),
15
+ checked_at INTEGER,
16
+ answer TEXT CHECK (answer IS NULL OR json_valid(answer)),
17
+ CHECK ((checked_at IS NULL) = (answer IS NULL))
18
+ ) STRICT;
19
+
20
+ INSERT INTO provider_account_next (id, attempted_at, outcome, checked_at, answer)
21
+ SELECT id, attempted_at, outcome, checked_at, answer FROM provider_account;
22
+
23
+ DROP TABLE provider_account;
24
+ ALTER TABLE provider_account_next RENAME TO provider_account;
@@ -0,0 +1,13 @@
1
+ -- 0018_notify_addresses: notifications go to one or more addresses (SPEC §8).
2
+ --
3
+ -- Each address is its own delivery: `sent_to` holds the addresses a notification has reached,
4
+ -- as a JSON array, so a try after one address failed goes only to the ones still missing it,
5
+ -- never again to one that got it. Every row starts with none: one still pending reached no
6
+ -- address, since it had only the one, and one that is closed is never tried again.
7
+ --
8
+ -- The saved address itself needs no migration: the settings blob is read through code, which
9
+ -- takes a stored single address as a list of that one (`src/db/settings.ts`).
10
+ --
11
+ -- What can't be recovered: nothing. The column is added to every row.
12
+
13
+ ALTER TABLE notifications ADD COLUMN sent_to TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(sent_to));
@@ -0,0 +1,67 @@
1
+ -- 0019_sending: whether email is getting out (SPEC §13).
2
+ --
3
+ -- One row, written whenever the provider answers a test, a confirmation, a list send, or a
4
+ -- notification, and by the account check: `unproven` until the provider takes an email,
5
+ -- `working` while the last one it was asked to take it took, `refusing` while it refuses the
6
+ -- account for a reason that won't clear on its own. `since` is when it entered that state.
7
+ -- The last email taken (`last_accepted_at`, and what it was, `last_accepted_by`) is kept
8
+ -- across a refusal, so the editor can say when the provider last took one. A refusal keeps
9
+ -- its cause, what found it, and the provider's words, never a reader's address. Every write
10
+ -- carries when the provider gave its answer (`observed_at` keeps the newest applied), and none
11
+ -- replaces a newer one: a sweep that met a refusal before the operator's fix can't undo the
12
+ -- test that proved it.
13
+ --
14
+ -- `check_contradicted` lists the causes the account check found that an email the provider
15
+ -- took has since contradicted: a reading that stays wrong while email goes out, such as a
16
+ -- sender verified through another account's sending authorization, pauses signups once and
17
+ -- not again. A clean reading empties it.
18
+ --
19
+ -- The row starts as `working` when the app's own records show email went out: a send the
20
+ -- provider took a recipient of, or a subscriber who confirmed through a link this instance
21
+ -- mailed. So an instance that was live before this release keeps its signups open. A test
22
+ -- leaves no record and so counts for nothing here; the next one sets it. A delivered
23
+ -- notification counts for nothing either, since it may have gone through Cloudflare's email
24
+ -- rather than the provider. The provider's own 24-hour count is never read: it is zero on a
25
+ -- quiet day.
26
+ --
27
+ -- What can't be recovered: nothing. The table is new.
28
+
29
+ CREATE TABLE sending (
30
+ id INTEGER PRIMARY KEY CHECK (id = 1),
31
+ state TEXT NOT NULL CHECK (state IN ('unproven', 'working', 'refusing')),
32
+ since INTEGER NOT NULL,
33
+ observed_at INTEGER NOT NULL,
34
+ last_accepted_at INTEGER,
35
+ last_accepted_by TEXT CHECK (last_accepted_by IN ('test', 'confirmation', 'list', 'notification')),
36
+ refusal_cause TEXT
37
+ CHECK (refusal_cause IN ('credentials', 'opt_in', 'configuration', 'sending_pool', 'sender',
38
+ 'mail_from', 'paused', 'set_paused', 'suspended', 'unsupported')),
39
+ refusal_found_by TEXT
40
+ CHECK (refusal_found_by IN ('test', 'confirmation', 'list', 'notification', 'account_check')),
41
+ refusal_error TEXT,
42
+ refusal_at INTEGER,
43
+ check_contradicted TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(check_contradicted)),
44
+ CHECK ((last_accepted_at IS NULL) = (last_accepted_by IS NULL)),
45
+ CHECK ((state = 'refusing') = (refusal_cause IS NOT NULL)),
46
+ CHECK ((refusal_cause IS NULL) = (refusal_found_by IS NULL)),
47
+ CHECK ((refusal_cause IS NULL) = (refusal_at IS NULL)),
48
+ CHECK (state <> 'working' OR last_accepted_at IS NOT NULL)
49
+ ) STRICT;
50
+
51
+ INSERT INTO sending (id, state, since, observed_at, last_accepted_at, last_accepted_by)
52
+ SELECT 1,
53
+ CASE WHEN at IS NULL THEN 'unproven' ELSE 'working' END,
54
+ COALESCE(at, CAST(unixepoch('subsec') * 1000 AS INTEGER)),
55
+ COALESCE(at, 0),
56
+ at,
57
+ CASE WHEN at IS NULL THEN NULL ELSE by END
58
+ FROM (
59
+ SELECT at, by FROM (
60
+ SELECT MAX(updated_at) AS at, 'list' AS by FROM deliveries WHERE status = 'accepted'
61
+ UNION ALL
62
+ SELECT MAX(confirm_sent_at), 'confirmation' FROM subscribers
63
+ WHERE status = 'confirmed' AND confirm_sent_at IS NOT NULL AND import_id IS NULL
64
+ )
65
+ ORDER BY at IS NULL, at DESC
66
+ LIMIT 1
67
+ );
@@ -0,0 +1,40 @@
1
+ -- 0020_notifications_not_sending: tell the publisher when email isn't going out (SPEC §8).
2
+ --
3
+ -- When the provider refuses the account outside a send, or the account check finds a refusal
4
+ -- before anything is sent, nobody is there to see it, so the publisher hears of it through the
5
+ -- notifications they already get, as a `not_sending` notice with no send and no import, at
6
+ -- most one a UTC day: its episode is the day's start, and the partial unique index keeps one
7
+ -- row a day, as the `sandbox` notice does. `notifications.kind`'s CHECK doesn't admit it and a
8
+ -- STRICT table's CHECK can't be altered, so the table is rebuilt with `not_sending` added,
9
+ -- every row and column carried over, `sent_to` and its check included, so a notification that
10
+ -- has reached some of its addresses never mails them again.
11
+ --
12
+ -- What can't be recovered: nothing. Every row is copied as it was.
13
+
14
+ CREATE TABLE notifications_next (
15
+ send_id TEXT REFERENCES sends (id), -- null for a test, an import notice, and an account notice
16
+ import_id TEXT REFERENCES imports (id), -- set only for an import notice
17
+ kind TEXT NOT NULL
18
+ CHECK (kind IN ('finished', 'refused', 'stuck', 'wedged', 'missed', 'test', 'import', 'sandbox', 'not_sending')),
19
+ episode INTEGER NOT NULL DEFAULT 0,
20
+ status TEXT NOT NULL DEFAULT 'pending'
21
+ CHECK (status IN ('pending', 'sent', 'failed', 'unaddressed', 'cleared')),
22
+ attempts INTEGER NOT NULL DEFAULT 0,
23
+ error TEXT,
24
+ created_at INTEGER NOT NULL,
25
+ updated_at INTEGER NOT NULL,
26
+ sent_to TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(sent_to)),
27
+ UNIQUE (send_id, kind, episode),
28
+ UNIQUE (import_id),
29
+ CHECK ((send_id IS NULL) = (kind IN ('test', 'import', 'sandbox', 'not_sending'))),
30
+ CHECK ((import_id IS NULL) = (kind <> 'import'))
31
+ ) STRICT;
32
+
33
+ INSERT INTO notifications_next (send_id, import_id, kind, episode, status, attempts, error, created_at, updated_at, sent_to)
34
+ SELECT send_id, import_id, kind, episode, status, attempts, error, created_at, updated_at, sent_to FROM notifications;
35
+
36
+ DROP TABLE notifications;
37
+ ALTER TABLE notifications_next RENAME TO notifications;
38
+ CREATE INDEX idx_notifications_status ON notifications (status, created_at);
39
+ CREATE UNIQUE INDEX idx_notifications_sandbox_day ON notifications (episode) WHERE kind = 'sandbox';
40
+ CREATE UNIQUE INDEX idx_notifications_not_sending_day ON notifications (episode) WHERE kind = 'not_sending';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurtbruns/kestrel",
3
- "version": "1.17.0",
3
+ "version": "1.19.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",
@@ -52,7 +52,7 @@ const USAGE = `Usage: kestrel api <method> <path> [options]
52
52
  --data <json|@file|-> the request body: inline, from a file, or from stdin
53
53
  --type <media type> the body's type (default application/json)
54
54
  --form <name=value> a multipart field, repeatable; name=@file attaches a file
55
- --url <url> the instance (default: KESTREL_URL, else your one sign-in)
55
+ --url <url> the instance (default: KESTREL_URL, else the only one you're signed in to)
56
56
  --local this directory's dev server, started by kestrel dev
57
57
  --client <name> your own script's Kestrel-Client name (default ${DEFAULT_CLIENT});
58
58
  never editor, which only the editor names itself
@@ -182,8 +182,15 @@ export function parseApiArgs(argv) {
182
182
  /**
183
183
  * Which instance a call goes to: `--url`, then `KESTREL_URL`, then the one instance this
184
184
  * person is signed in to. Several sign-ins and nothing naming one is a refusal, not a guess.
185
+ * The one sign-in is named on stderr through `say`, since nothing in the command named it and
186
+ * a person in another instance's folder should see the mismatch; stdout stays the answer's.
185
187
  */
186
- export function chooseInstance({ url, env = process.env, signIns }) {
188
+ export function chooseInstance({
189
+ url,
190
+ env = process.env,
191
+ signIns,
192
+ say = (line) => process.stderr.write(`${line}\n`),
193
+ }) {
187
194
  if (url) {
188
195
  return instanceOrigin(url);
189
196
  }
@@ -192,6 +199,9 @@ export function chooseInstance({ url, env = process.env, signIns }) {
192
199
  }
193
200
  const origins = Object.keys(signIns);
194
201
  if (origins.length === 1) {
202
+ say(
203
+ `kestrel api: calling ${origins[0]}, the only instance you're signed in to. Pass --url to call a different one.`,
204
+ );
195
205
  return origins[0];
196
206
  }
197
207
  if (origins.length === 0) {
@@ -280,9 +290,24 @@ export async function accessToken(origin, { force = false, env = process.env } =
280
290
  writeSignInHeld(origin, { ...now, ...fields }, env);
281
291
  return fields.access_token;
282
292
  } catch (err) {
283
- if (err instanceof SignInError && err.oauthError === "invalid_grant") {
293
+ if (!(err instanceof SignInError)) {
294
+ throw err;
295
+ }
296
+ if (err.oauthError === "invalid_grant") {
284
297
  throw new SignInError(ended);
285
298
  }
299
+ // The token endpoint saved at sign-in wasn't found; signing in again discovers where the
300
+ // instance signs in now. It says what happened, not why: a renamed team domain and a
301
+ // moved endpoint both answer 404.
302
+ if (err.status === 404) {
303
+ const team = new URL(now.token_endpoint).host;
304
+ throw new SignInError(
305
+ `Couldn't renew your sign-in to ${origin}: its Access team, ${team}, answered 404 (not found). Run \`npx kestrel login ${origin}\` again.`,
306
+ );
307
+ }
308
+ if (err.status) {
309
+ throw new SignInError(`Access refused the token request for ${origin} (${err.detail}).`);
310
+ }
286
311
  throw err;
287
312
  }
288
313
  }, env);
@@ -227,9 +227,11 @@ async function tokenRequest(endpoint, form, timeoutMs = TOKEN_TIMEOUT_MS) {
227
227
  throw new SignInError(`Couldn't reach ${new URL(endpoint).origin}: ${errText(err)}`);
228
228
  }
229
229
  if (!res.ok) {
230
- const error = new SignInError(
231
- `Access refused the token request (${res.status}${body.error ? `: ${body.error}` : ""}${body.error_description ? `, ${body.error_description}` : ""}).`,
232
- );
230
+ const detail = `${res.status}${body.error ? `: ${body.error}` : ""}${body.error_description ? `, ${body.error_description}` : ""}`;
231
+ const error = new SignInError(`Access refused the token request (${detail}).`);
232
+ // What a caller needs to say more: the status, and Access's words.
233
+ error.status = res.status;
234
+ error.detail = detail;
233
235
  error.oauthError = body.error;
234
236
  throw error;
235
237
  }
@@ -14,9 +14,9 @@ Deploying, migrating the production database, and anything that sends mail or ch
14
14
 
15
15
  ## Running the instance
16
16
 
17
- - **Follow the guide page, step by step.** Each page is a complete procedure. Do what a step says, ask the person for what only they can do (a dashboard click, a DNS record at their registrar), and end with the page's **Check it** list, reporting each check's result.
17
+ - **Follow the guide page, step by step.** Each page is a complete procedure. Do what a step says, ask the person for what only they can do (a dashboard click, a DNS record at their registrar), and run each check the page gives, reporting each one's result: the step that ends a section, and the page's **Check it** list where it has one.
18
18
  - **Deploying, migrating, and creating or binding a resource are plain wrangler,** always naming the environment, since the top level is development: `npx wrangler deploy --env production`, `npx wrangler d1 migrations apply DB --remote --env production`, and `npx wrangler d1 create NAME --env production --binding DB --update-config`, which writes the binding into `production` instead of offering to add it to the top level.
19
- - **Never deploy or store a secret without `--env production`.** Without it, wrangler acts on the top level: a deploy creates a second Worker, named `kestrel`, running the stand-in transport and the local sign-in, and a secret lands on that Worker instead of production. The top level turns off every address that Worker could answer on and its send schedule; keep `workers_dev` and `preview_urls` there `false` and its `crons` present and empty (deleting the line leaves an earlier schedule running), and never add a route there. If a deploy went out without `--env`, tell the person, and remove it with `npx wrangler delete --name kestrel` once they agree.
19
+ - **Never deploy or store a secret without `--env production`.** Without it, wrangler acts on the top level: a deploy creates a second Worker, named by the top-level `name` in `wrangler.jsonc`, running the stand-in transport and the local sign-in, and a secret lands on that Worker instead of production. The top level turns off every address that Worker could answer on and its send schedule; keep `workers_dev` and `preview_urls` there `false` and its `crons` present and empty (deleting the line leaves an earlier schedule running), and never add a route there. If a deploy went out without `--env`, tell the person, and remove it with `npx wrangler delete --name` and that top-level name once they agree.
20
20
  - **Upgrade with the `/upgrade` skill.** It follows the guide's upgrade page.
21
21
  - **Bring a list from another service with the `/migrate-list` skill.** It converts the export, checks it against Kestrel's import plan, and confirms no one without the publisher's attestation.
22
22
  - **Secrets never pass through you.** The person enters each one themselves with `npx wrangler secret put NAME --env production`, which prompts for the value; never ask for a secret in chat. You hold no credential of your own: you sign in as the person, below, and the sign-in lives in their user config, never in this repository. Never write a secret into `wrangler.jsonc`, `.claude/settings.json`, or a commit.
@@ -38,7 +38,9 @@ Deploying, migrating the production database, and anything that sends mail or ch
38
38
  - **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 offer a test of each send in the response's `remade`, as above, since each needs a fresh one.
39
39
  - **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: `email_only: true`, which the editor shows as **Include in archive** unchecked. 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.
40
40
  - **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. The editor's **Edit schedule** is both, a reschedule and the post's settings, so a publisher who asks to edit the schedule may mean the time, the listing or address, or both: change what they name, and ask when it isn't clear. 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.
41
- - **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.
41
+ - **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. Read `state` from `GET /api/provider`: `sandbox` means not yet, and `review` says whether AWS is reviewing a request (`pending`) or has replied in its support case (`review_case`) without granting access (`denied`); `production` means granted. `denied` is AWS's word for every reply short of a grant, including the request for more information it sends within seconds of every request, so never tell the publisher AWS turned them down: say AWS has replied, and to read the case and answer there. When it's `unknown` and `unknown_reason` is `rejected`, SES rejects the app's AWS key: relay the `key_rejected` warning and its fix, storing the key again, rather than asking about production access. Otherwise, when it's `unknown`, ask the publisher, and if `unknown_reason` is `permission`, suggest allowing the AWS key `ses:GetAccount`, `ses:GetEmailIdentity`, and `ses:GetConfigurationSet`, as Connect Amazon SES's Create the app's AWS key section shows. Its `warnings` are worth telling the publisher, in their words: one whose `severity` is `error` means no email can go out until it's fixed, so raise it first. To ask for it, or to check the grant, point to the guide's Request production access page: its Check that AWS granted it section covers the grant.
42
+ - **Offer the subscribe link only while `GET /api/provider` reads `signups: "open"` and `state: "production"`.** `signups` is `not_open` until SES has taken an email from the instance (`sending.state` is `unproven`): a template test to a test recipient (`POST /api/settings/template/test`) proves it, and then read `/api/provider` again, since a test's answer doesn't say. It is `paused` while `sending.state` is `refusing`: SES refuses every email, readers are told signups are paused, and a scheduled send is held when it fires. Tell the publisher SES's words and the fix from `sending.refusal` (`error`, `advice`), and once the fix is made, send a template test, which reopens signups when SES takes it. In the sandbox the form is open, since AWS's reviewer looks for it, but it confirms only addresses verified in SES.
43
+ - **When the publisher asks what's left to set up, answer with the dashboard's setup steps, in its order and by its names,** each done from the same fact it reads, problems first in the API's own words: **Connect Amazon SES** (a provider is connected: `GET /api/provider`'s `provider` isn't `none`), **Send a test email** (`sending.state` is `working`), **Name your publication** (`GET /api/settings`'s `settings.publication.name` isn't blank), **Get notified** (`settings.notifications.to` has an address and `notification_status.last_failure` is null), **Request production access** (`state` is `production`; while `review` is `pending` it waits on AWS; left out unless the provider is SES or none is connected yet), **Bring your existing list** (`GET /api/subscribers` counts confirmed or pending), **Write your first post** (`GET /api/posts` has a post, a draft included, that isn't `email_only`), **Schedule your first post** (`GET /api/sends` has a `scheduled` send that isn't `email_only`), **Send a test email for your first post** (the soonest such send's `tested_at` is set and not before its `remade_at`; a draft's test doesn't count), and **Share your subscribe link** (never done; offer it only under the rule above). Setup is done once the first post listed in the archive goes out.
42
44
  - **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. Someone whose `unsubscribe_cause` is `complaint` or `erased` can't be added at all: `POST /api/subscribers` refuses them with `signup_required`, and you relay that they have to subscribe through the subscribe page themselves.
43
45
  - **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, through `POST /api/suppressions/lift` with the `reason` the suppression shows; on `suppression_changed`, tell the publisher what stands now and ask again. Never clear a bounce unless the publisher says the problem is gone, and confirm first: it lets mail go to an address that failed. Only a block or a bounce is lifted; a complaint or an erasure is how someone left, not a suppression, and nothing lifts it.
44
46
  - **Erase a subscriber only when the publisher names them and asks for it, and confirm first:** an erasure can't be undone. It unsubscribes them and strips their record to the address, and only their own sign-up brings them back.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "./node_modules/wrangler/config-schema.json",
3
- "name": "kestrel",
3
+ "name": "kestrel-dev",
4
4
  // The Worker is Kestrel's: src/index.ts re-exports it from the release this repository pins.
5
5
  "main": "src/index.ts",
6
6
  // Matches the release's own config. An upgrade's changelog says when to change it.
@@ -79,11 +79,11 @@
79
79
  }
80
80
  ],
81
81
  "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "kestrel-media-production" }],
82
- // PROVIDER is empty, which means no provider connected: the app runs but sends nothing
83
- // until the setup guide's Connect Amazon SES step sets it to `ses`, once its secrets are
84
- // stored. It is declared so wrangler doesn't warn that the top level's is missing here.
85
- // Never `fake`, which records every send as delivered and mails no one; the app refuses
86
- // it on this hostname.
82
+ // PROVIDER is the email provider: `ses` once the setup guide's Connect Amazon SES step has
83
+ // stored its secrets, and empty, "", until then, which means none connected: the app runs
84
+ // but sends nothing. It is declared so wrangler doesn't warn that the top level's is
85
+ // missing here. Never `fake`, which records every send as delivered and mails no one; the
86
+ // app refuses it on this hostname.
87
87
  "vars": {
88
88
  "PROVIDER": "",
89
89
  "APP_ORIGIN": "https://newsletter.example.com",