@kurtbruns/kestrel 1.18.0 → 1.20.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 (35) hide show
  1. package/.dev.vars.example +16 -4
  2. package/CHANGELOG.md +76 -1
  3. package/dist/public/dashboard/app.js +289 -290
  4. package/dist/public/dashboard/app.js.map +4 -4
  5. package/dist/public/dashboard/index.html +2 -2
  6. package/dist/public/dashboard/styles.css +127 -21
  7. package/dist/worker/index.js +4135 -2973
  8. package/docs/get-started/02-deploy.md +2 -2
  9. package/docs/get-started/03-access.md +17 -5
  10. package/docs/get-started/04-aws-account.md +2 -0
  11. package/docs/get-started/05-ses.md +49 -28
  12. package/docs/get-started/06-production-access.md +6 -3
  13. package/docs/get-started/07-verify.md +9 -7
  14. package/docs/get-started/08-go-live.md +12 -16
  15. package/docs/guides/01-allow-claude.md +7 -2
  16. package/docs/guides/03-notifications.md +10 -10
  17. package/docs/publishing/04-write-a-post.md +2 -2
  18. package/docs/publishing/06-troubleshooting.md +70 -21
  19. package/docs/publishing/08-connect-claude.md +1 -1
  20. package/docs/publishing/09-share-your-subscribe-link.md +3 -1
  21. package/docs/publishing/10-check-your-setup.md +13 -9
  22. package/docs/publishing/11-email-template.md +1 -1
  23. package/docs/publishing/README.md +8 -3
  24. package/docs/reference/01-configuration.md +5 -5
  25. package/migrations/0016_held_receipts.sql +26 -0
  26. package/migrations/0017_provider_account_rejected.sql +24 -0
  27. package/migrations/0018_notify_addresses.sql +13 -0
  28. package/migrations/0019_sending.sql +67 -0
  29. package/migrations/0020_notifications_not_sending.sql +40 -0
  30. package/migrations/0021_notifications_outage_receipts.sql +46 -0
  31. package/package.json +1 -1
  32. package/packages/kestrel/lib/api.mjs +28 -3
  33. package/packages/kestrel/lib/oauth.mjs +5 -3
  34. package/template/claude/CLAUDE.md +4 -2
  35. package/template/wrangler.jsonc +5 -5
@@ -195,7 +195,7 @@ npx wrangler delete --name newsletter-dev
195
195
  {"status":"ok","service":"kestrel"}
196
196
  ```
197
197
 
198
- If `curl` reports a certificate error, or says it could not resolve the host, Cloudflare is still setting up the new hostname. Try again in a few minutes.
198
+ If `curl` reports a certificate error or can't resolve the host, Cloudflare is still setting up the hostname. Or your computer remembers it from before it existed. Try again over the next 30 minutes.
199
199
 
200
200
  1. The editor is locked. Until you set up Access in the next step, the app can't tell who is asking, so it lets no one in:
201
201
 
@@ -209,7 +209,7 @@ npx wrangler delete --name newsletter-dev
209
209
 
210
210
  In a browser, `https://newsletter.example.com/dashboard/` says the editor is locked until you set up Access. That's expected.
211
211
 
212
- 1. In a browser, `https://newsletter.example.com/` shows the newsletter's public home page. Its subscribe page, `/subscribe`, says signups aren't open yet, until Connect Amazon SES.
212
+ 1. In a browser, `https://newsletter.example.com/` shows the newsletter's public home page. Its subscribe page, `/subscribe`, says signups aren't open yet, until Connect Amazon SES has sent its first test email.
213
213
 
214
214
  If the public pages instead say the newsletter is temporarily unavailable, and `/health` answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",…}`, the setting it names is missing, malformed, or still on `example.com`. Fix it in `wrangler.jsonc`, commit, and deploy again.
215
215
 
@@ -37,7 +37,7 @@ The editor lives under `/dashboard`, and the API under `/api`. One application c
37
37
 
38
38
  Each path covers everything under it. Leave every other path out. The home page, the archive, subscribing, unsubscribing, images, and your email provider's webhooks must stay public.
39
39
 
40
- 1. Add only these two public hostnames. If the form also shows an empty private hostname or IP row, remove it, or saving fails with an error naming `use_clientless_isolation_app_launcher_url`.
40
+ 1. Add only these two public hostnames. If the form also shows an empty private hostname or IP row, remove it. Otherwise saving fails with an error naming `use_clientless_isolation_app_launcher_url`, saying it needs private destinations.
41
41
 
42
42
  1. Name the application after your app's hostname, `newsletter.example.com`. Cloudflare suggests a name made from the subdomain, which won't tell two newsletters apart.
43
43
 
@@ -55,7 +55,7 @@ Every Access application denies everyone until a policy allows them.
55
55
 
56
56
  1. Select **Create**.
57
57
 
58
- Cloudflare's own login is on by default, and admits members of your Cloudflare account, so the email in the policy you made should be one. To let someone sign in another way, such as a one-time code by email, add it under **Zero Trust → Integrations → Identity providers**.
58
+ Cloudflare's own login is on by default, and admits members of your Cloudflare account, so the email in the policy you made should be one.
59
59
 
60
60
  ## 4. Give the app its two values
61
61
 
@@ -79,13 +79,19 @@ The app verifies each login against your team domain, and checks that it was iss
79
79
 
80
80
  A secret takes effect as soon as you store it, with no redeploy.
81
81
 
82
- 1. **(Optional, recommended)** Limit the editor to named people. The Access policy decides who can sign in. This list keeps that true if the policy is widened later, for another app or by mistake. Enter a comma-separated list of email addresses at the prompt:
82
+ 1. **(Optional, recommended)** Limit the editor to named people. The Access policy decides who can sign in. This list keeps that true if the policy is widened later, for another app or by mistake. Store the list of addresses allowed in:
83
83
 
84
84
  ```bash
85
85
  npx wrangler secret put ACCESS_ALLOWED_EMAILS --env production
86
86
  ```
87
87
 
88
- Without it, the app lets in anyone your Access policy allows.
88
+ At the prompt, enter the addresses separated by commas:
89
+
90
+ ```
91
+ you@example.com, publisher@example.com
92
+ ```
93
+
94
+ Spaces and capital letters don't matter. Without the list, the app lets in anyone your Access policy allows.
89
95
 
90
96
  ## Check it
91
97
 
@@ -112,7 +118,13 @@ The app verifies each login against your team domain, and checks that it was iss
112
118
  If a check fails:
113
119
 
114
120
  - **The editor says the configuration needs a fix.** Store the secret it names. While only one of the two is stored, `/health` answers `500` naming the other, and the public pages say the newsletter is temporarily unavailable.
115
- - **Cloudflare lets you in, but the editor answers `401`.** Check the two secrets for a typo, and that `ACCESS_ALLOWED_EMAILS`, if you set it, includes your address.
121
+ - **Cloudflare lets you in, but the editor answers `401`.** Check the two secrets for a typo, and that `ACCESS_ALLOWED_EMAILS`, if you set it, includes your address. The same goes for anyone else you let in.
116
122
  - **The editor says Access let you in but didn't say who you are.** Your login carried no email, which happens through a kind of policy the app doesn't accept, such as a device check. Sign out, and sign in through the policy you made above.
117
123
 
124
+ ## Allow someone else in
125
+
126
+ To let someone else into the editor, such as the publisher or a co-editor, add their email address to the policy from [Allow yourself in](#3-allow-yourself-in). If you set `ACCESS_ALLOWED_EMAILS`, run the command again and enter the whole list with their address added, since the new value replaces the old one.
127
+
128
+ If they don't have a Cloudflare account, add another way to sign in, such as a one-time code by email, under **Zero Trust → Integrations → Identity providers**.
129
+
118
130
  Next, [create an AWS account](04-aws-account.md).
@@ -65,4 +65,6 @@ AWS charges for what you use, with no spending limit, so a mistake or a leaked k
65
65
 
66
66
  1. Under **Budgets**, your budget is listed, and its email recipient shows as **Active**.
67
67
 
68
+ If it shows as **Pending**, select **Resend verification** on its row, and verify the address as section 4 describes.
69
+
68
70
  Next, [connect Amazon SES](05-ses.md).
@@ -19,7 +19,7 @@ The same form sets a custom MAIL FROM domain, so SPF also passes for your domain
19
19
 
20
20
  SES keeps identities, quotas, and the sandbox separately in each region, so do every step on this page in the region you chose. [Configuration](../reference/01-configuration.md#vars) says more about choosing one, under `AWS_REGION`.
21
21
 
22
- 1. Note the region's code, such as `eu-west-1`. The menu shows it beside the region's name, such as Europe (Ireland), and [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each one too. Later steps use this code.
22
+ 1. Note the region's code, such as `us-west-2`. The menu shows it beside the region's name, and [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each one too. AWS names a region in more than one way, such as United States (Oregon) and US West (Oregon), so go by the code. Later steps use it.
23
23
 
24
24
  1. Under **Configuration**, choose **Identities**, then **Create identity**.
25
25
 
@@ -47,6 +47,8 @@ The same form sets a custom MAIL FROM domain, so SPF also passes for your domain
47
47
 
48
48
  1. Wait until SES shows **Identity status** as **Verified**, and both **DKIM configuration** and **MAIL FROM configuration** as **Successful**.
49
49
 
50
+ If they stay pending, check that each record's proxy status is **DNS only**.
51
+
50
52
  ## 2. Add a DMARC record
51
53
 
52
54
  DMARC tells inboxes what to do with mail that fails its checks, and sends you reports about it. SES doesn't add this record, so you add it yourself. It starts in monitor mode, which reports without blocking anything.
@@ -73,27 +75,31 @@ A new SES account starts in the sandbox: it sends only to addresses you've verif
73
75
 
74
76
  1. Choose two addresses you own, such as a personal address and a work address. Verify it works uses both.
75
77
 
76
- 1. For each, under **Identities**, choose **Create identity**, select **Email address**, and enter it. Choose **Create identity**.
78
+ 1. For each, under **Identities**, choose **Create identity**, select **Email address**, and enter it. Leave **Assign a default configuration set** and **Assign to a tenant** unchecked. The form's notice about DMARC is about sending from the address, and the app only sends to it. Choose **Create identity**.
77
79
 
78
80
  1. Open the email AWS sends to each address, and follow its link. The identity's status changes to **Verified**.
79
81
 
82
+ AWS's email and confirmation page say you can now send from the address. That's expected: SES has one kind of verified address, and the same verification lets the sandbox send to it.
83
+
80
84
  ## 4. Send bounces and complaints to the app
81
85
 
82
86
  SES reports bounces and complaints through Amazon Simple Notification Service (SNS), which delivers them to the app. The app then stops mailing an address that bounced or complained. A configuration set says which events SES reports, and an SNS topic carries them.
83
87
 
84
- 1. In SES, under **Configuration**, choose **Configuration sets**, then **Create set**. Name it `kestrel-mail`, and choose **Create set**.
88
+ 1. In SES, under **Configuration**, choose **Configuration sets**, then **Create set**. Name it `kestrel-mail`, keep every other option as it is, and choose **Create set**.
85
89
 
86
90
  1. On the set's **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, **Deliveries**, and **Rendering failures**, and choose **Next**. The app logs a rendering failure, an email SES couldn't build, as an error.
87
91
 
88
- 1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled. Choose **Next**.
92
+ Leave the other six off, since the app already knows about them or doesn't act on them. **Sends** repeats what the send records, and **Delivery delays** end as a bounce or a delivery. **Rejects** (a virus) and **Subscriptions** (SES's own unsubscribe) don't arise, since the app's emails carry no attachments and their own unsubscribe link. **Opens** and **Clicks** would add tracking the app doesn't record.
93
+
94
+ 1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled.
89
95
 
90
- 1. For **SNS topic**, choose **Create SNS topic**. Make it a **Standard** topic named `kestrel-events`. Choose **Next**, then **Add destination**.
96
+ 1. On the same screen, under **SNS topic**, choose **Create SNS topic**. Name it `kestrel-events`, leave the display name empty, and choose **Create topic**. Then select `kestrel-events` in the topic menu, if it isn't already. Choose **Next**, then **Add destination**.
91
97
 
92
- 1. Make the set your hostname's default, so SES reports on every email sent from it. Open the `send.example.com` identity, and choose the **Configuration set** tab. Under **Default configuration set**, choose **Edit**, select `kestrel-mail`, and save.
98
+ 1. Make the set your hostname's default, so SES reports on every email sent from it. Under **Identities**, open `send.example.com`, and choose its **Configuration set** tab, below the summary. Edit the **Default configuration set**, select **Assign a default configuration set**, select `kestrel-mail`, and save.
93
99
 
94
100
  1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), in the same region, and find its ARN. It looks like `arn:aws:sns:us-east-1:111122223333:kestrel-events`, where `us-east-1` is your region's code and `111122223333` is your account ID.
95
101
 
96
- 1. Let SES publish to the topic. Choose **Edit**, expand **Access policy**, and add this statement to the `Statement` list:
102
+ 1. Check that SES may publish to the topic, on its **Access policy** tab. A topic created from SES's **Add destination**, as above, already lets SES publish to it from your account. Leave that statement as it is. For a topic you made in the SNS console instead, choose **Edit**, expand **Access policy**, and add this statement to the `Statement` list:
97
103
 
98
104
  ```json
99
105
  {
@@ -126,15 +132,13 @@ SES reports bounces and complaints through Amazon Simple Notification Service (S
126
132
 
127
133
  ## 5. Create the app's AWS key
128
134
 
129
- The app sends through an AWS Identity and Access Management (IAM) user whose key can only send email and read the account's sending status. That limits the harm if the key ever leaks. Reading the status lets the app show whether SES is still in the sandbox, take its send rate from the account, and warn when bounces and complaints can't reach it.
135
+ The app sends through an AWS Identity and Access Management (IAM) user whose key can only send email and read the account's sending status. That limits the harm if the key ever leaks. Reading the status lets the app show whether SES is still in the sandbox and take its send rate from the account. It also warns, without sending anything, when the configuration set is missing or paused, the sender isn't verified, or bounces and complaints can't reach the app.
130
136
 
131
- If `kestrel-sender` already exists, as on an instance set up before Kestrel read the account, don't create a second user or key. In the IAM console, open **Policies**, then `kestrel-send-email`, and choose **Edit**. In the JSON editor, add `"ses:GetAccount"` and `"ses:GetEmailIdentity"` to its `Action` list, as in the policy below, and save. The app's next check, within an hour, reads the account.
137
+ If `kestrel-sender` already exists, from an earlier release, don't create a second user or key. Update its policy instead, as the end of this section shows.
132
138
 
133
- 1. In the [IAM console](https://console.aws.amazon.com/iam/), go to **Users**, and choose **Create user**. Name it `kestrel-sender`, and leave console access off.
139
+ 1. In the [IAM console](https://console.aws.amazon.com/iam/), choose **Policies**, then **Create policy**.
134
140
 
135
- 1. When asked for permissions, choose **Attach policies directly**, then **Create policy**. The policy editor opens in a new browser tab.
136
-
137
- 1. In the new tab, choose the **JSON** editor, and replace its contents with this policy:
141
+ 1. Choose the **JSON** editor, and replace its contents with this policy:
138
142
 
139
143
  ```json
140
144
  {
@@ -145,8 +149,11 @@ If `kestrel-sender` already exists, as on an instance set up before Kestrel read
145
149
  "Action": [
146
150
  "ses:SendEmail",
147
151
  "ses:SendBulkEmail",
152
+ "ses:SendTemplatedEmail",
153
+ "ses:SendBulkTemplatedEmail",
148
154
  "ses:GetAccount",
149
- "ses:GetEmailIdentity"
155
+ "ses:GetEmailIdentity",
156
+ "ses:GetConfigurationSet"
150
157
  ],
151
158
  "Resource": "*"
152
159
  }
@@ -154,13 +161,17 @@ If `kestrel-sender` already exists, as on an instance set up before Kestrel read
154
161
  }
155
162
  ```
156
163
 
157
- Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. You can close this tab.
164
+ 1. Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. The review lists Pinpoint Email, SES, and SES v2, which is how IAM shows these SES actions.
165
+
166
+ 1. Choose **IAM users**, then **Create user**. Name it `kestrel-sender`, leave console access off, and choose **Next**.
158
167
 
159
- 1. Go back to the first tab, where you're creating the user. Refresh the policy list, search for `kestrel-send-email`, and select it.
168
+ 1. Choose **Attach policies directly**, search for `kestrel-send-email`, and select it. AWS recommends groups, which help manage many people, and one app's user doesn't need one. Choose **Next**, then **Create user**.
160
169
 
161
- 1. Choose **Next**, then **Create user**.
170
+ 1. Open `kestrel-sender`, and choose **Create access key**. It's an access key, not an API key. For the use case, choose **Application running outside AWS**, and choose **Next**.
162
171
 
163
- 1. Open the user, and on the **Security credentials** tab, choose **Create access key**. For the use case, choose **Application running outside AWS**.
172
+ AWS suggests IAM Roles Anywhere instead. It needs a certificate authority of your own, so use the access key.
173
+
174
+ 1. For the description tag, enter the app's hostname, such as `newsletter.example.com`, so you can tell its key apart later. Choose **Create access key**.
164
175
 
165
176
  1. Keep the page open: AWS shows the secret access key only once. Store the access key ID in the app, and paste it at the prompt:
166
177
 
@@ -174,6 +185,8 @@ If `kestrel-sender` already exists, as on an instance set up before Kestrel read
174
185
  npx wrangler secret put AWS_SECRET_ACCESS_KEY --env production
175
186
  ```
176
187
 
188
+ To update an existing key's policy, open **Policies** in the IAM console, then `kestrel-send-email`, and choose **Edit**. In the JSON editor, make its `Action` list match the policy above, and save. Without `ses:SendTemplatedEmail`, SES refuses even a test email. Once the key may call `ses:GetAccount`, the app's next check, within an hour, reads the account. Without `ses:GetConfigurationSet`, the app can't tell whether the configuration set exists, so it says nothing about it rather than guessing.
189
+
177
190
  ## 6. Connect the app and deploy
178
191
 
179
192
  Until now the app has run with no email provider connected, refusing to send. Setting `PROVIDER` to `ses` connects SES, with the secrets you stored in sections 4 and 5. If a required one is missing, the app refuses to run until you store it, and `/health` names it.
@@ -222,6 +235,8 @@ Until now the app has run with no email provider connected, refusing to send. Se
222
235
 
223
236
  1. In the editor, **Settings → Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before. Within a couple of minutes, a **Sandbox** pill appears beside it, once the app has read your SES account.
224
237
 
238
+ 1. In the editor, open **Template** and select **Send test email**, to an address you verified in [Verify your test addresses](#3-verify-your-test-addresses). It arrives. Signups stay closed until SES takes an email, so the **Not proven yet** pill beside Amazon SES in **Settings → Email sender** is gone once it has. If SES refuses it, the test's line says why and what fixes it.
239
+
225
240
  1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren't open yet.
226
241
 
227
242
  ## 7. Subscribe the app to the topic
@@ -240,7 +255,7 @@ SNS delivers events only to a subscriber that has confirmed. The app confirms on
240
255
 
241
256
  Any other answer, such as a redirect to your Cloudflare login, means Access covers the webhook. Remove that path from your Access application.
242
257
 
243
- 1. In the SNS console, open your topic, and choose **Create subscription**.
258
+ 1. In the SNS console, open your topic, and choose **Create subscription**. Check that **Topic ARN** shows the `kestrel-events` topic, and choose it if it's empty.
244
259
 
245
260
  1. Set **Protocol** to **HTTPS**, and enter the endpoint:
246
261
 
@@ -248,13 +263,9 @@ SNS delivers events only to a subscriber that has confirmed. The app confirms on
248
263
  https://newsletter.example.com/webhooks/ses
249
264
  ```
250
265
 
251
- 1. Leave raw message delivery off, and choose **Create subscription**.
266
+ 1. Leave raw message delivery off.
252
267
 
253
- 1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.
254
-
255
- If it stays **Pending confirmation**, check that your Access application covers only `/dashboard` and `/api`, that `SNS_TOPIC_ARN` matches the topic's ARN, and that section 6's deploy finished. A mismatched ARN shows in the app's logs as a `webhook.refused` error naming both. Then request confirmation again from the topic's subscriptions. SNS doesn't let you delete a pending subscription, and removes it on its own after 48 hours.
256
-
257
- 1. Give SNS longer to retry. By default it retries a failed delivery 3 times over about a minute, then discards the event, so a bounce that arrives mid-deploy is lost. Open the topic, choose **Edit**, and expand **Delivery retry policy (HTTP/S)**. Turn off the default policy, set these values, and choose **Save changes**:
268
+ 1. Give SNS longer to retry. By default it retries a failed delivery 3 times over about a minute, then discards the event, so a bounce that arrives mid-deploy is lost. Expand **Delivery policy (HTTP/S)**, turn off **Use the default delivery policy**, and set these values:
258
269
 
259
270
  | Field | Value |
260
271
  | --- | --- |
@@ -266,13 +277,19 @@ SNS delivers events only to a subscriber that has confirmed. The app confirms on
266
277
  | Maximum delay retries | `6` |
267
278
  | Retry-backoff function | Exponential |
268
279
 
269
- SNS then keeps trying for about 40 minutes. It allows an hour at most.
280
+ SNS then keeps trying for about 40 minutes. It allows an hour at most. The topic has a delivery policy of its own, under **Edit**. Setting it there instead also covers any subscription you add later.
281
+
282
+ 1. Choose **Create subscription**.
283
+
284
+ 1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.
285
+
286
+ If it stays **Pending confirmation**, check that your Access application covers only `/dashboard` and `/api`. Check that `SNS_TOPIC_ARN` matches the topic's ARN, and that section 6's deploy finished. A mismatched ARN shows in the app's logs as a `webhook.refused` error naming both. Then request confirmation again from the topic's subscriptions. SNS doesn't let you delete a pending subscription, and removes it on its own after 48 hours.
270
287
 
271
288
  1. **(Optional)** Keep what SNS still can't deliver. On the subscription, choose **Edit**, and set a **Redrive policy (dead-letter queue)** to an SQS queue of your own. SNS then moves an event there instead of discarding it. [Amazon SNS dead-letter queues](https://docs.aws.amazon.com/sns/latest/dg/sns-dead-letter-queues.html) covers the queue and its permissions.
272
289
 
273
290
  ## 8. Receive replies at your From address
274
291
 
275
- Replies to a post go to your From address, `newsletter@send.example.com`, which has no inbox yet, so they bounce. This section forwards that address to the publisher's inbox with Cloudflare Email Routing. Forwarding only receives mail, so it doesn't change how SES sends.
292
+ Replies to a post go to your From address, `newsletter@send.example.com`, which has no inbox yet, so they bounce. The reader is told your domain couldn't be found. This section forwards that address to the publisher's inbox with Cloudflare Email Routing. Forwarding only receives mail, so it doesn't change how SES sends.
276
293
 
277
294
  1. Check that your From address has no inbox yet. This prints nothing:
278
295
 
@@ -286,7 +303,7 @@ Replies to a post go to your From address, `newsletter@send.example.com`, which
286
303
 
287
304
  1. Add `send.example.com` to Email Routing: select `example.com`, then **Settings**, and add `send` under **Subdomains**.
288
305
 
289
- 1. Cloudflare adds MX, SPF, and DKIM records for `send.example.com`, and locks them. Wait until they show as added.
306
+ 1. Cloudflare adds MX, SPF, and DKIM records for `send.example.com`, and locks them. Wait until they show as added. Its SPF record is for forwarding: SES's mail passes SPF at `bounce.send.example.com`, from section 1.
290
307
 
291
308
  1. Under **Destination Addresses**, add the publisher's inbox. Open the email Cloudflare sends to it, and select **Verify email address**. If it isn't your inbox, ask the publisher to do this step.
292
309
 
@@ -300,6 +317,10 @@ Replies to a post go to your From address, `newsletter@send.example.com`, which
300
317
  dig +short MX send.example.com
301
318
  ```
302
319
 
320
+ 1. Send an email to `newsletter@send.example.com` from an address whose mail doesn't go through the publisher's inbox. Gmail, for one, hides a forwarded copy of mail you sent yourself. In Email Routing's **Activity log**, the email shows as **Forwarded**.
321
+
322
+ If it bounces saying the domain couldn't be found, the sender's provider is still remembering that `send.example.com` had no mail servers. That happens after an email to it before this section, and lasts up to 30 minutes on Cloudflare's DNS. Wait, and send it again.
323
+
303
324
  Out-of-office and other automatic replies reach the publisher's inbox too. Bounces don't: they go to SES, which reports them to the app. The publisher answers from their own inbox, not from `newsletter@send.example.com`. To answer from the newsletter's address instead, give it a mailbox at a mail host such as [Fastmail](https://www.fastmail.help/hc/en-us/articles/360060591133) or [Google Workspace](https://support.google.com/a/answer/140034), in place of forwarding.
304
325
 
305
326
  SES is now connected in the sandbox. Until AWS grants production access, it sends only to the addresses you verified in section 3. It also sends to the SES test addresses Verify it works uses. SES charges for each email it sends, test emails included, as [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists.
@@ -34,11 +34,13 @@ AWS reviews each request by hand, for the region the app sends from.
34
34
 
35
35
  1. Check the acknowledgement, and select **Submit request**. AWS Support answers by email.
36
36
 
37
- 1. Within about ten minutes, the editor's **Settings → Email sender** says AWS is reviewing your request for production access. It also says when AWS grants or denies it.
37
+ 1. Within about ten minutes, the editor's **Settings → Email sender** says AWS is reviewing your request for production access, and later whether AWS has granted it.
38
+
39
+ Within seconds of every request, AWS sends an automated email asking for more information, in a support case. The SES console then shows **More information needed**, and the editor says AWS hasn't granted production access yet and has replied in AWS Support, with the case number. That's expected, and not a refusal: answer it as the next section describes.
38
40
 
39
41
  ## 3. Answer AWS's questions
40
42
 
41
- AWS Support may ask how you collect addresses and handle bounces before it decides. A newsletter may be asked for more detail, or denied the first time, sometimes without a reason. A reply that explains how the list is built usually gets further than a new request. If it asks for more detail, or denies the request, reply in the support case, or open a new request from the **Account dashboard**.
43
+ AWS asks how you collect addresses and handle bounces before it decides, every time. A newsletter may be asked for more detail again, or turned down the first time, sometimes without a reason. A reply that explains how the list is built usually gets further than a new request. Reply in the support case, whichever it is, or open a new request from the **Account dashboard**. The editor reads the same until AWS grants access, since AWS's account reports a request for more information and a refusal alike.
42
44
 
43
45
  In the sandbox, the subscribe form's confirmation email reaches only addresses verified in SES. If AWS's reviewer tries the form, say that this is why no confirmation arrives. In your reply, cover:
44
46
 
@@ -48,6 +50,7 @@ In the sandbox, the subscribe form's confirmation email reaches only addresses v
48
50
  - That the publication's website shows what the newsletter is, and how readers subscribe.
49
51
  - That an imported list was gathered with opt-in on the previous service.
50
52
  - How many subscribers the newsletter expects, and how often it sends.
53
+ - A sample email. Send yourself a test of a post, as [Verify it works](07-verify.md#1-send-yourself-a-test-email) shows, and attach it to your reply or paste its text.
51
54
 
52
55
  ## 4. Check that AWS granted it
53
56
 
@@ -55,7 +58,7 @@ Until AWS grants production access, SES sends only to your verified addresses, s
55
58
 
56
59
  1. In the editor, open **Settings → Email sender**. The email provider reads **Production**. From the API, `npx kestrel api GET /api/provider` shows `state` as `production`.
57
60
 
58
- 1. If it reads **Sandbox**, AWS hasn't granted access yet. Meanwhile, continue with [Verify it works](07-verify.md), which runs in the sandbox. Come back to this section when AWS answers, and check the email AWS Support sends for questions.
61
+ 1. If it reads **Sandbox**, AWS hasn't granted access yet. If it also says AWS has replied, with a case number, answer in that case, as section 3 describes. Meanwhile, continue with [Verify it works](07-verify.md), which runs in the sandbox. Come back to this section when AWS answers.
59
62
 
60
63
  1. If it says Kestrel's AWS key needs permission to read your account, select **Add the permission** and follow the step it opens. The app checks again within an hour.
61
64
 
@@ -17,7 +17,7 @@ A test email goes through the same code as a real send. If the test looks right,
17
17
 
18
18
  1. In the editor at `https://newsletter.example.com/dashboard/`, create a post with a subject, a short body, and an image.
19
19
 
20
- 1. Select **Send test email**, enter your address, and send it.
20
+ 1. Select **Send test email**, enter your address, and send it. If SES refuses it, the test's line says why and what fixes it.
21
21
 
22
22
  1. Open the email. It arrives in your inbox, not in spam, the image loads, and the subject reads as you wrote it. Its links work, but a test email unsubscribes no one; section 5 unsubscribes for real.
23
23
 
@@ -41,11 +41,11 @@ If a check fails, compare your DNS records with the ones in [Connect Amazon SES]
41
41
 
42
42
  ## 3. Check that notifications arrive
43
43
 
44
- Kestrel emails a notifications address when a send finishes, and right away when one runs into a problem. Check that they reach you, using one of the addresses you verified in SES.
44
+ Kestrel emails each notifications address when a send finishes, and right away when one runs into a problem. Check that they reach you, using one of the addresses you verified in SES. While SES is in the sandbox, every notifications address has to be verified there too, unless notifications go through [Cloudflare's email](../guides/03-notifications.md), where each is verified with Cloudflare instead. If you're setting Kestrel up for someone else, you can add their address beside yours later.
45
45
 
46
- 1. In the editor, open **Settings → Notifications**, enter that address, and select **Save**.
46
+ 1. In the editor, open **Settings → Notifications**, type that address under **Send notifications to**, select **Add inbox**, and then **Save**.
47
47
 
48
- 1. Select **Send a test notification**. It arrives at that address.
48
+ 1. Select **Send a test notification**. It arrives at that address. If it doesn't, **Last notification**, on the same page, says what happened.
49
49
 
50
50
  The publisher sets their own address in [Check your setup](../publishing/10-check-your-setup.md).
51
51
 
@@ -65,11 +65,13 @@ This is a real send, to a list of one. It proves the whole path: subscribing, co
65
65
 
66
66
  1. When the send finishes, the post arrives at your second address. A notification arrives at your first, with the subject `Sent:` and the post's title.
67
67
 
68
+ If the send shows in red under **Sent**, its line names the fix. Once that's fixed, the send resumes on its own.
69
+
68
70
  ## 5. Unsubscribe
69
71
 
70
- Every email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. Both work in one step, with no login.
72
+ Every email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. The button works in one step, and the link asks you to confirm first. Neither needs a login.
71
73
 
72
- 1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender.
74
+ 1. Open the post from section 4 in your mail client. It shows an **Unsubscribe** button next to the sender. If your client shows none, use the **Unsubscribe** link in the email's footer, which asks you to confirm on a page.
73
75
 
74
76
  1. Select it. In the editor, **Subscribers** shows the address as **Unsubscribed**, and later sends leave it out.
75
77
 
@@ -94,7 +96,7 @@ The app writes a log line for each thing it does, and Cloudflare keeps them for
94
96
 
95
97
  1. In the Cloudflare dashboard, go to **Workers & Pages**, select your Worker, the `name` in the `production` block of `wrangler.jsonc`, and select **Observability**.
96
98
 
97
- 1. Filter on `event` equal to `sweep.tick`. A line arrives every minute.
99
+ 1. Filter on `event` equal to `sweep.tick`. A line arrives every minute. If none arrive, the schedule probably isn't running, and deploying again with `--env production` registers it.
98
100
 
99
101
  1. Filter on `event` equal to `send.completed`. Your send from section 4 is there.
100
102
 
@@ -1,26 +1,22 @@
1
1
  # Go live
2
2
 
3
- At this point, your instance should be deployed and verified. Before anyone subscribes, check that AWS granted production access, as [Check that AWS granted it](06-production-access.md#4-check-that-aws-granted-it) describes, so SES can send to anyone. From here, the publisher can set up the publication, and you can continue with the guides below.
3
+ At this point, your instance should be deployed and verified. Before anyone subscribes, [check that AWS granted production access](06-production-access.md#4-check-that-aws-granted-it), so SES can send to anyone. From here, where you go next depends on who's writing the newsletter.
4
4
 
5
5
  ## Start publishing
6
6
 
7
- The rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md).
7
+ Open the editor at `https://newsletter.example.com/dashboard/`. The dashboard's setup steps show what's left, and [Publishing](../publishing/README.md) covers each one.
8
8
 
9
- If someone else is the publisher, let them sign in first:
9
+ ## Hand off to someone else
10
10
 
11
- 1. Add their email address to the policy on the Access application that lets you in, as in [Allow yourself in](03-access.md#3-allow-yourself-in). If you set `ACCESS_ALLOWED_EMAILS`, store the list again with their address added.
11
+ [Let them into the editor](03-access.md#allow-someone-else-in), and add their address beside yours under **Settings → Notifications**, so a problem reaches both of you. Then send them the editor's address and a link to [Publishing](../publishing/README.md). The dashboard's setup steps show them what's left.
12
12
 
13
- 1. If they don't have a Cloudflare account, add another way to sign in, such as a one-time code by email. It's under **Zero Trust → Integrations → Identity providers**.
13
+ ## Optional guides
14
14
 
15
- 1. Send them the editor's address and a link to [Name your publication](../publishing/03-name-your-publication.md).
15
+ Each one adds one thing, and you can come back to any of them later.
16
16
 
17
- ## Optional next steps
18
-
19
- Each of these guides adds one thing, and you can come back to any of them later.
20
-
21
- - [Allow Claude to connect](../guides/01-allow-claude.md), so the publisher can connect Claude Desktop, claude.ai, or Claude Code, and have it draft, proofread, and schedule posts as them, through the same API the editor uses.
22
- - [Automate with a service token](../guides/02-automate.md), so a script can call the API with a service token, when no one is signed in.
23
- - [Notifications through Cloudflare's email](../guides/03-notifications.md), so problems are reported even when the email provider has stopped sending.
24
- - [Rate-limit the subscribe form](../guides/05-rate-limit.md), before the subscribe link is shared widely.
25
- - [As your list grows](../guides/09-as-your-list-grows.md), from about 1,000 subscribers.
26
- - [Upgrade to a new release](../guides/07-upgrade.md), when one comes out.
17
+ - Before you share your subscribe link: [Rate-limit the subscribe form](../guides/05-rate-limit.md)
18
+ - To hear about a problem even when SES has stopped sending: [Notifications through Cloudflare's email](../guides/03-notifications.md)
19
+ - To have Claude draft, proofread, and schedule posts: [Allow Claude to connect](../guides/01-allow-claude.md)
20
+ - To run scripts with no one signed in: [Automate with a service token](../guides/02-automate.md)
21
+ - To see when your list needs Workers Paid or higher SES limits: [As your list grows](../guides/09-as-your-list-grows.md)
22
+ - To move to a new release: [Upgrade to a new release](../guides/07-upgrade.md)
@@ -43,7 +43,7 @@ Managed OAuth lets an app such as Claude act with a person's account, the way th
43
43
  curl -si https://newsletter.example.com/api/whoami
44
44
  ```
45
45
 
46
- It answers `401`, and its `www-authenticate` line starts with `Bearer` and names `resource_metadata`. Without Managed OAuth, Cloudflare answers with a redirect to its login page instead. A browser still gets the login page either way.
46
+ You should see `401`, with a `www-authenticate` line that starts with `Bearer`. Access can take a few minutes more to start accepting sign-ins. Until it does, signing in shows `invalid_target` (`OAuth not enabled for the targeted resource`); wait, then try again.
47
47
 
48
48
  ## 2. Connect Claude, or send the publisher the address
49
49
 
@@ -65,4 +65,9 @@ Anyone the Access application's Allow policy lets into the dashboard can connect
65
65
 
66
66
  1. Ask Claude what the publication is called. It answers with the name the dashboard shows.
67
67
 
68
- If Claude can't connect, [Claude can't connect](../publishing/06-troubleshooting.md#claude-cant-connect) lists what it reports and the fix.
68
+ If a check fails:
69
+
70
+ - **Claude can't connect, or the sign-in fails.** Check section 1's settings, and that the connector's address is the instance's address followed by `/api/mcp`.
71
+ - `kestrel login` says the instance "doesn't offer a sign-in for programs". Managed OAuth is off. Turn it on, as section 1 describes.
72
+ - `kestrel login` or `kestrel api` reports a `401`. If you set `ACCESS_ALLOWED_EMAILS`, check that it includes the person's address.
73
+ - **The sign-in says Access didn't say who the person is.** The same goes for a `401` naming `access_unidentified`. The app accepts only an Allow policy on people's emails and a Service Auth policy on a service token. Remove or change any other kind.
@@ -2,13 +2,13 @@
2
2
 
3
3
  Kestrel emails the publisher when a send finishes, and right away when one runs into a problem. By default, these notifications go through the email provider. The one that matters most, about the provider refusing your account, is the one the provider would refuse too. Cloudflare's email doesn't depend on the provider, so that notification still reaches the publisher.
4
4
 
5
- In this guide, you turn on Cloudflare's email for your app's hostname, and verify the notifications address with it. Then you add the binding that sends notifications through it, deploy, and send yourself a test.
5
+ In this guide, you turn on Cloudflare's email for your app's hostname, and verify the notifications addresses with it. Then you add the binding that sends notifications through it, deploy, and send yourself a test.
6
6
 
7
7
  ## Before you begin
8
8
 
9
9
  You need:
10
10
 
11
- - The notifications address saved, from [Verify it works](../get-started/07-verify.md#3-check-that-notifications-arrive).
11
+ - The notifications addresses saved, from [Verify it works](../get-started/07-verify.md#3-check-that-notifications-arrive).
12
12
  - Your domain's DNS on Cloudflare, as for the rest of the app.
13
13
 
14
14
  ## 1. Turn on Email Routing for your app's hostname
@@ -23,13 +23,13 @@ Cloudflare's email sends only from a domain set up with Email Routing. Use your
23
23
 
24
24
  Avoid turning on Email Routing for a domain that already receives mail elsewhere, such as `example.com` with Google Workspace. Email Routing takes over that domain's MX records. Don't send notifications from `send.example.com` either, since your email provider sends from it. Email Routing there to forward readers' replies, as [Connect Amazon SES](../get-started/05-ses.md#8-receive-replies-at-your-from-address) sets up, is fine, since it only receives.
25
25
 
26
- ## 2. Verify the notifications address
26
+ ## 2. Verify the notifications addresses
27
27
 
28
- Cloudflare's email delivers only to addresses you've verified with it. Sending to a verified address is free on any plan.
28
+ Cloudflare's email delivers only to addresses you've verified with it. Sending to a verified address is free on any plan. Notifications can go to several addresses, such as the publisher's and yours, and each one needs verifying.
29
29
 
30
- 1. Under **Email Routing**, go to **Destination Addresses**, and add the address you saved for notifications.
30
+ 1. Under **Email Routing**, go to **Destination Addresses**, and add each address saved for notifications.
31
31
 
32
- 1. Open the email Cloudflare sends to that address, and select **Verify email address**. If it's the publisher's address, ask them to do this step.
32
+ 1. Open the email Cloudflare sends to each address, and select **Verify email address**. If it's the publisher's address, ask them to do this step.
33
33
 
34
34
  A verified address belongs to your Cloudflare account, so every environment in it can use it.
35
35
 
@@ -41,11 +41,11 @@ A `send_email` binding named `NOTIFY` moves notifications to Cloudflare's email.
41
41
 
42
42
  ```jsonc
43
43
  "send_email": [
44
- { "name": "NOTIFY", "allowed_destination_addresses": ["you@example.com"] } // ← the notifications address
44
+ { "name": "NOTIFY", "allowed_destination_addresses": ["you@example.com"] } // ← the notifications addresses
45
45
  ],
46
46
  ```
47
47
 
48
- `allowed_destination_addresses` is optional. It limits the binding to the addresses you list, so a changed setting in the editor can't send notifications anywhere else. Leave it out to allow any address you've verified. If the publisher changes the notifications address later, verify the new one and add it here too.
48
+ `allowed_destination_addresses` is optional. It limits the binding to the addresses you list, so a changed setting in the editor can't send notifications anywhere else. Leave it out to allow any address you've verified. If the publisher adds or changes a notifications address later, verify the new one and add it here too.
49
49
 
50
50
  1. **(Optional)** Notifications come from `Kestrel <kestrel@newsletter.example.com>`. To use another address on that hostname, set `NOTIFY_FROM` in the block's `vars`:
51
51
 
@@ -76,9 +76,9 @@ A `send_email` binding named `NOTIFY` moves notifications to Cloudflare's email.
76
76
 
77
77
  1. In the editor, open **Settings → Notifications**. **Sent through** reads "Cloudflare Email, separate from your newsletter's provider", and **From address** shows `Kestrel <kestrel@newsletter.example.com>`.
78
78
 
79
- 1. Select **Send a test notification**. It arrives at the notifications address, and **Last notification** shows it as delivered.
79
+ 1. Select **Send a test notification**. It arrives at each notifications address, and **Last notification** shows it as delivered.
80
80
 
81
- If the test isn't delivered, **Last notification** shows Cloudflare's reason, such as an address that isn't verified. Cloudflare's Email Routing summary lists notifications as dropped even when they're delivered, so check the notifications inbox rather than the summary.
81
+ If the test isn't delivered to an address, **Last notification** shows Cloudflare's reason, such as an address that isn't verified, after the address when there are several. The other addresses still get it. Cloudflare's Email Routing summary lists notifications as dropped even when they're delivered, so check the notifications inbox rather than the summary.
82
82
 
83
83
  ## What notifications don't cover
84
84
 
@@ -6,11 +6,11 @@ Write in Markdown, schedule it, then send yourself a test. Scheduling makes the
6
6
 
7
7
  ## Write
8
8
 
9
- The editor takes Markdown, and Kestrel saves as you type. The same Markdown becomes the email, in your [email template](11-email-template.md), and the post's page in your archive. **Preview** shows the email.
9
+ The editor takes Markdown, and Kestrel saves as you type. The same Markdown becomes the email, in your [email template](11-email-template.md), and the post's page in your archive. **Preview** shows the email, and warns under it about anything it found wrong.
10
10
 
11
11
  - A blank line starts a new paragraph.
12
12
  - The opening lines are the preview a mail client shows beside the subject.
13
- - Paste or drop an image to add it: PNG, JPEG, WebP, or GIF, up to 5 MB.
13
+ - Paste or drop an image to add it: PNG, JPEG, WebP, or GIF, up to 5 MB. Kestrel may change its file name, turning spaces into hyphens and numbering a name the post already has, and inserts the new name for you.
14
14
 
15
15
  ## Schedule and send
16
16