@kurtbruns/kestrel 1.17.0 → 1.18.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 +10 -0
  2. package/CHANGELOG.md +30 -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 +288 -286
  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 +10 -0
  10. package/dist/worker/index.js +5606 -5095
  11. package/docs/README.md +5 -3
  12. package/docs/get-started/02-deploy.md +24 -20
  13. package/docs/get-started/03-access.md +9 -5
  14. package/docs/get-started/04-aws-account.md +68 -0
  15. package/docs/get-started/{04-ses.md → 05-ses.md} +89 -92
  16. package/docs/get-started/06-production-access.md +64 -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} +2 -2
  19. package/docs/guides/01-allow-claude.md +1 -1
  20. package/docs/guides/02-automate.md +1 -1
  21. package/docs/guides/03-notifications.md +3 -3
  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 +11 -7
  28. package/docs/publishing/09-share-your-subscribe-link.md +1 -1
  29. package/docs/publishing/10-check-your-setup.md +4 -2
  30. package/docs/reference/01-configuration.md +14 -12
  31. package/docs/reference/02-sending-domain-dns.md +4 -4
  32. package/migrations/0015_provider_account.sql +18 -0
  33. package/package.json +1 -1
  34. package/template/claude/CLAUDE.md +3 -3
  35. package/template/wrangler.jsonc +1 -1
package/docs/README.md CHANGED
@@ -8,9 +8,11 @@ These are the steps to deploy a Kestrel instance and get it ready to send. Each
8
8
 
9
9
  1. [Deploy the app](get-started/02-deploy.md)
10
10
  1. [Lock the dashboard with Access](get-started/03-access.md)
11
- 1. [Connect Amazon SES](get-started/04-ses.md)
12
- 1. [Verify it works](get-started/05-verify.md)
13
- 1. [Go live](get-started/06-go-live.md)
11
+ 1. [Create an AWS account](get-started/04-aws-account.md)
12
+ 1. [Connect Amazon SES](get-started/05-ses.md)
13
+ 1. [Request production access](get-started/06-production-access.md)
14
+ 1. [Verify it works](get-started/07-verify.md)
15
+ 1. [Go live](get-started/08-go-live.md)
14
16
 
15
17
  ## Guides
16
18
 
@@ -9,7 +9,7 @@ In this guide, you choose your hostnames, create your instance's repository, cre
9
9
  - A [Cloudflare account](https://dash.cloudflare.com/sign-up), with your domain's DNS on Cloudflare. Your app's hostname and its login both live there.
10
10
  - [Node.js](https://nodejs.org/) 22 or later, and [Git](https://git-scm.com/).
11
11
  - A GitHub account, or another Git host, for your instance's repository.
12
- - An [AWS account](https://portal.aws.amazon.com/billing/signup), for Amazon SES, which sends your email.
12
+ - An AWS account for the newsletter, before [Connect Amazon SES](05-ses.md). [Create an AWS account](04-aws-account.md) walks through it. You can start it now. Signing up needs an email address that receives mail, and AWS can take a while to activate a new account.
13
13
 
14
14
  ## Choose your hostnames
15
15
 
@@ -30,9 +30,9 @@ Throughout these guides, replace the example names with your own, and any value
30
30
 
31
31
  Your instance's repository holds its configuration, its database migrations, and the release of Kestrel it runs. Kestrel itself installs from npm as `@kurtbruns/kestrel`, so the repository stays small, and an upgrade changes the version it names.
32
32
 
33
- 1. On GitHub, [create a new repository](https://github.com/new) named `newsletter`. It can be private. Leave it empty, with no README, license, or `.gitignore`.
33
+ 1. On GitHub, [create a new repository](https://github.com/new) named after your publication. These guides use `newsletter`. It can be private. Leave it empty, with no README, license, or `.gitignore`.
34
34
 
35
- 1. Clone it, and write a new instance into it. Replace `YOUR_USERNAME` with your GitHub username:
35
+ 1. Clone it, and write a new instance into it. Replace `YOUR_USERNAME` with your GitHub username, and `newsletter` with your repository's name:
36
36
 
37
37
  ```bash
38
38
  git clone https://github.com/YOUR_USERNAME/newsletter.git
@@ -48,14 +48,6 @@ Your instance's repository holds its configuration, its database migrations, and
48
48
  npm install
49
49
  ```
50
50
 
51
- 1. Commit the new instance, and push it:
52
-
53
- ```bash
54
- git add -A
55
- git commit -m "Start a Kestrel instance"
56
- git push -u origin HEAD
57
- ```
58
-
59
51
  1. **(Optional)** Try Kestrel locally before you deploy it. Copy the local settings, and start the app:
60
52
 
61
53
  ```bash
@@ -63,7 +55,15 @@ Your instance's repository holds its configuration, its database migrations, and
63
55
  npm run dev
64
56
  ```
65
57
 
66
- Open http://localhost:8787/dashboard/, and run `npm run seed` in a second terminal to load a demo publication. Email goes to a stand-in, so nothing reaches a real inbox. Press Ctrl+C to stop the server when you're done.
58
+ Open http://localhost:8787/dashboard/, and run `npm run seed` in a second terminal to load a demo publication. Email goes to a stand-in, so nothing reaches a real inbox. Press Ctrl+C to stop the server when you're done. `init` prints these steps too, in this order.
59
+
60
+ 1. Commit the new instance, and push it. Your local settings and data stay out of the commit, since the instance's `.gitignore` lists them:
61
+
62
+ ```bash
63
+ git add -A
64
+ git commit -m "Initialize Kestrel instance"
65
+ git push -u origin HEAD
66
+ ```
67
67
 
68
68
  ## 2. Sign in to Cloudflare
69
69
 
@@ -87,16 +87,18 @@ Every command from here on acts on your Cloudflare account through the Wrangler
87
87
 
88
88
  Each command creates the resource and writes it into the `production` block of `wrangler.jsonc`, under the binding the app already uses. Name `--env production` on each one, or Wrangler writes to the top level, which is local development.
89
89
 
90
+ The commands below use `newsletter`. Use your instance's names instead: `kestrel init` printed them, and they're in the `production` block of `wrangler.jsonc`.
91
+
90
92
  1. Create the database:
91
93
 
92
94
  ```bash
93
- npx wrangler d1 create kestrel-production --env production --binding DB --update-config
95
+ npx wrangler d1 create newsletter-production --env production --binding DB --update-config
94
96
  ```
95
97
 
96
98
  1. Create the bucket for images:
97
99
 
98
100
  ```bash
99
- npx wrangler r2 bucket create kestrel-media-production --env production --binding MEDIA --update-config
101
+ npx wrangler r2 bucket create newsletter-media-production --env production --binding MEDIA --update-config
100
102
  ```
101
103
 
102
104
  Each prints a snippet to add to your configuration. There's nothing to paste: `--update-config` has filled in the database's id, and the `MEDIA` binding already names the bucket. Wrangler also rewrites `wrangler.jsonc` in its own layout, with tabs and one value per line, and your settings stay.
@@ -118,7 +120,7 @@ Each prints a snippet to add to your configuration. There's nothing to paste: `-
118
120
  "d1_databases": [
119
121
  {
120
122
  "binding": "DB",
121
- "database_name": "kestrel-production",
123
+ "database_name": "newsletter-production", // named by init; leave it
122
124
  "database_id": "…", // already filled in by section 3; leave it
123
125
  "migrations_dir": "migrations"
124
126
  }
@@ -136,9 +138,9 @@ Each prints a snippet to add to your configuration. There's nothing to paste: `-
136
138
  What each one does:
137
139
 
138
140
  - `routes` puts the app on your hostname when you deploy. Cloudflare creates the DNS record and the certificate. Your domain must be in the same Cloudflare account, and the hostname can't already have a CNAME record; delete one if it does. The block also turns off the `workers.dev` addresses, so the app answers only on your hostname.
139
- - `PROVIDER` stays empty until [Connect Amazon SES](04-ses.md) sets it. Until then the app runs without an email provider: posts can be written and previewed, but scheduling, test emails, and signups are refused. Never set it to `fake`, the top level's value, even when a wrangler warning suggests copying it: `fake` delivers nothing, and the app refuses it on your hostname.
141
+ - `PROVIDER` stays empty until [Connect Amazon SES](05-ses.md) sets it. Until then the app runs without an email provider: posts can be written and previewed, but scheduling, test emails, and signups are refused. Never set it to `fake`, the top level's value, even when a wrangler warning suggests copying it: `fake` delivers nothing, and the app refuses it on your hostname.
140
142
  - `ARCHIVE_BASE_PATH` is permanent once the first post goes out. Every post sent carries its `/archive/…` link, and changing the prefix later breaks the links already mailed. Keep `/archive` unless you have a reason not to.
141
- - `FROM_ADDRESS` is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.
143
+ - `FROM_ADDRESS` is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`. The name before the `<` is what readers see as the sender. It also names the publication on its public pages, until the publisher sets a name under **Settings → Publication identity**. You can change the name at any time, and deploy again.
142
144
 
143
145
  Change every `example.com` value, the route included. The app refuses to run while any of its settings still names `example.com`.
144
146
 
@@ -175,15 +177,15 @@ npx wrangler deploy --env production
175
177
 
176
178
  This deploys the release your instance runs, with your settings. The output ends with the hostname the app now answers on. Deploying also registers the once-a-minute schedule that sends posts, which you can see under the Worker's **Triggers** tab in the Cloudflare dashboard.
177
179
 
178
- Always name `--env production`, here and on every wrangler command that stores a secret. Without it, wrangler deploys the top level, local development, as a second Worker named `kestrel` that answers nowhere and never sends. If that happens, remove it:
180
+ Always name `--env production`, here and on every wrangler command that stores a secret. Without it, wrangler deploys the top level, local development, as a second Worker that answers nowhere and never sends. It takes the top level's `name` in `wrangler.jsonc`, such as `newsletter-dev`. If that happens, remove it by that name:
179
181
 
180
182
  ```bash
181
- npx wrangler delete --name kestrel
183
+ npx wrangler delete --name newsletter-dev
182
184
  ```
183
185
 
184
186
  ## Check it
185
187
 
186
- 1. The app answers on your hostname. If `curl` reports a certificate error, Cloudflare is still issuing the new hostname's certificate; try again shortly.
188
+ 1. The app answers on your hostname:
187
189
 
188
190
  ```bash
189
191
  curl https://newsletter.example.com/health
@@ -193,6 +195,8 @@ npx wrangler delete --name kestrel
193
195
  {"status":"ok","service":"kestrel"}
194
196
  ```
195
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.
199
+
196
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:
197
201
 
198
202
  ```bash
@@ -37,7 +37,11 @@ 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. **(Optional)** Choose how long a sign-in lasts. The application's session duration defaults to 24 hours, so everyone signs in again each day. You can set anything from immediately to one month, as Cloudflare's [session management](https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/session-management/) describes. A longer session suits a publisher on their own computer, and a shorter one is safer on a shared one.
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`.
41
+
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
+
44
+ 1. **(Optional)** Choose how long a sign-in lasts. The application's session duration defaults to 24 hours, so everyone signs in again each day. You can set anything from immediately to one month, as Cloudflare's [session management](https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/session-management/) describes. For example, a week may suit a publisher on their own computer.
41
45
 
42
46
  ## 3. Allow yourself in
43
47
 
@@ -45,13 +49,13 @@ Every Access application denies everyone until a policy allows them.
45
49
 
46
50
  1. Under **Access policies**, create a new policy:
47
51
 
48
- - **Policy name:** `Publishers`
52
+ - **Policy name:** the publication's name and `publishers`, such as `Example publishers`
49
53
  - **Action:** Allow
50
54
  - **Include:** the **Emails** selector, with your email address
51
55
 
52
56
  1. Select **Create**.
53
57
 
54
- Cloudflare's own login is on by default, and admits members of your Cloudflare account, so the email in your `Publishers` policy 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. To let someone sign in another way, such as a one-time code by email, add it under **Zero Trust → Integrations → Identity providers**.
55
59
 
56
60
  ## 4. Give the app its two values
57
61
 
@@ -109,6 +113,6 @@ If a check fails:
109
113
 
110
114
  - **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.
111
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.
112
- - **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 `Publishers` policy.
116
+ - **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.
113
117
 
114
- Next, [connect Amazon SES](04-ses.md).
118
+ Next, [create an AWS account](04-aws-account.md).
@@ -0,0 +1,68 @@
1
+ # Create an AWS account
2
+
3
+ Kestrel sends the newsletter's email through Amazon SES, which runs in an Amazon Web Services (AWS) account. This page assumes a brand-new account, made for the newsletter. If you'd rather use one you already have, skip to [Connect Amazon SES](05-ses.md).
4
+
5
+ If you plan to run multiple publications, you may want to consider setting up one account for each publication. With this setup, you can use [AWS Organizations](https://docs.aws.amazon.com/organizations/latest/userguide/orgs_introduction.html) to keep the accounts under one bill. Multiple publications can be run through one account. However, they share its sandbox, sending quotas, and suppression list, and AWS can pause sending for the whole account if one publication's bounces or complaints run high.
6
+
7
+ In this guide, you choose the account's email address, sign up, and secure the account's root user. Then you set a monthly budget, so AWS emails you if the account ever costs more than you expect.
8
+
9
+ ## Before you begin
10
+
11
+ - A phone number, and a credit or debit card for AWS's charges.
12
+ - Your domain's DNS on Cloudflare, if the account's email address will be forwarded with Cloudflare Email Routing.
13
+
14
+ ## 1. Choose the account's email address
15
+
16
+ The address you sign up with belongs to the account's root user, the identity that owns the account and everything in it. You sign in as the root user with it, AWS contacts you there about the account, and it's how you recover the account. AWS recommends an address used for nothing else, which forwards to whoever looks after the account, as its [root user best practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/root-user-best-practices.html) describe.
17
+
18
+ 1. Choose an address named for its purpose, on your own domain, such as `aws@example.com`. Use one you'll keep, since recovering the account depends on it.
19
+
20
+ 1. Make sure it receives mail before you sign up, since AWS emails it a code right away. If `example.com` receives no mail yet, forward the address to your inbox with Cloudflare Email Routing, as [Enable Email Routing](https://developers.cloudflare.com/email-routing/get-started/enable-email-routing/) describes. If `example.com` already receives mail at a mail host, create the address there instead, since Email Routing replaces a domain's mail servers.
21
+
22
+ ## 2. Sign up
23
+
24
+ AWS may offer two ways to sign up. Choose **Sign up for AWS (advanced)**. The other way, **Sign up for AWS (new)**, makes an account with no root user, and chooses its region from your address. Its console differs from the one this guide describes. AWS's [Compare sign-up options](https://docs.aws.amazon.com/accounts/latest/reference/sign-up-for-aws.html) explains both.
25
+
26
+ 1. Open [Sign up for AWS](https://signin.aws.amazon.com/signup?request_type=register). If it offers a choice, select **Sign up for AWS (advanced)**.
27
+
28
+ 1. Enter the address from section 1 as the root user's email address, and an account name, such as your publication's name. Verify the address with the code AWS sends to it, and choose a password for the root user.
29
+
30
+ 1. Choose the paid plan. The free plan closes the account automatically after six months, or sooner once its credits are used, unless you upgrade it before then. The paid plan gets the same sign-up credits, and keeps running once they're used. [AWS Free Tier plans](https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/free-tier-plans.html) describes both.
31
+
32
+ 1. Enter your contact information and payment details, and confirm your phone number if AWS asks.
33
+
34
+ 1. Choose the free Basic support plan. It's enough for everything the setup guide does. Asking AWS to let SES send to anyone is a form in the SES console, with no paid support plan needed.
35
+
36
+ 1. Complete the sign-up. The console opens, signed in as the root user, and AWS emails the account's address when the account is ready.
37
+
38
+ ## 3. Secure the root user
39
+
40
+ The root user can do anything in the account, including close it, so it needs more than a password. AWS requires multi-factor authentication (MFA) for it, and asks you to set it up soon after your first sign-in.
41
+
42
+ 1. In the console, select your account name at the top right, and then **Security credentials**.
43
+
44
+ 1. Under **Multi-factor authentication (MFA)**, choose **Assign MFA device**. Enter a device name, select **Passkey or Security Key**, and follow the prompts. A passkey on your computer or phone works.
45
+
46
+ 1. Leave **Access keys** empty. A root user's key could do anything the root user can. [Connect Amazon SES](05-ses.md#5-create-the-apps-aws-key) makes a separate key that can only send email and read the account's sending status.
47
+
48
+ ## 4. Set a budget alert
49
+
50
+ AWS charges for what you use, with no spending limit, so a mistake or a leaked key could run up a bill before you notice. A budget emails you as the month's costs near an amount you choose. SES charges for each email it sends, as [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists.
51
+
52
+ 1. In the console, open **Billing and Cost Management**, then **Budgets**, and choose **Create budget**.
53
+
54
+ 1. Under **Use a template (simplified)**, select **Monthly cost budget**.
55
+
56
+ 1. Enter a name, and an amount a little above what you expect to spend each month. Add the account's email address, or your own, as an email recipient. Choose **Create budget**.
57
+
58
+ 1. AWS emails each recipient to verify it, and sends alerts only to a verified address. Open that email while you're signed in to the account, and verify the address within 12 hours, as [Budgets email recipients](https://docs.aws.amazon.com/cost-management/latest/userguide/budgets-email-recipients.html) describes.
59
+
60
+ ## Check it
61
+
62
+ 1. Sign out, and sign in again as the root user, with the address from section 1. After the password, AWS asks for your passkey or security key.
63
+
64
+ 1. Under **Security credentials**, the MFA list shows your device, and **Access keys** lists none.
65
+
66
+ 1. Under **Budgets**, your budget is listed, and its email recipient shows as **Active**.
67
+
68
+ Next, [connect Amazon SES](05-ses.md).
@@ -1,48 +1,53 @@
1
1
  # Connect Amazon SES
2
2
 
3
- This page connects the app to Amazon Simple Email Service (Amazon SES), which delivers the newsletter's email. A new SES account starts in a sandbox, where it can send only to addresses you've verified, until AWS reviews it. This page requests that review early, so you can test in the sandbox while you wait.
3
+ This page connects the app to Amazon Simple Email Service (Amazon SES), which delivers the newsletter's email. A new SES account starts in a sandbox, where it can send only to addresses you've verified, until AWS reviews it. See [Request production access](06-production-access.md) to take it out of the sandbox.
4
4
 
5
- In this guide, you verify your sending hostname, set a custom MAIL FROM domain, add a DMARC record, and request production access. You then route bounces and complaints to the app, give it an AWS key, and deploy it with SES connected. Last, you forward readers' replies to the publisher's inbox.
5
+ In this guide, you set up how the app sends mail through SES and receives bounces and complaints, and how readers' replies reach the publisher.
6
6
 
7
7
  ## Before you begin
8
8
 
9
- - An [AWS account](https://portal.aws.amazon.com/billing/signup), if you haven't created one already.
9
+ - An AWS account for the newsletter, from [Create an AWS account](04-aws-account.md).
10
10
  - The publisher's inbox, for readers' replies.
11
11
 
12
12
  ## 1. Verify your sending hostname
13
13
 
14
14
  SES sends only from an identity you've verified. Verifying the whole hostname lets any address on it send, and sets up DKIM, which lets inboxes trust your mail.
15
15
 
16
- 1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose a region near you in the menu at the top right. SES keeps identities, quotas, and the sandbox separately in each region, so do every step on this page in this one. A few newer regions must be [enabled for your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html) first.
16
+ The same form sets a custom MAIL FROM domain, so SPF also passes for your domain. [SPF: authorize the return path](../reference/02-sending-domain-dns.md#spf-authorize-the-return-path) explains why it matters.
17
17
 
18
- 1. Note the region's code, such as `eu-west-1`. The menu shows only its name, such as Europe (Ireland), and [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each code beside its name. Later steps use this code.
18
+ 1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose a region near you in the region menu at the top right.
19
19
 
20
- 1. Under **Configuration**, choose **Identities**, then **Create identity**.
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. Select **Domain**, and enter `send.example.com`. Keep the default DKIM settings, **Easy DKIM** with a 2048-bit key, and choose **Create identity**.
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.
23
23
 
24
- 1. On the identity's **Authentication** tab, expand **Publish DNS records**. It lists three CNAME records.
24
+ 1. Under **Configuration**, choose **Identities**, then **Create identity**.
25
25
 
26
- 1. In the Cloudflare dashboard, add each record to your domain's DNS records, exactly as SES lists it, with its proxy status set to **DNS only**. Cloudflare adds your domain to the name, so each name ends in `._domainkey.send`.
26
+ 1. Select **Domain**, and enter `send.example.com`. Leave **Assign a default configuration set** and **Assign to a tenant** unchecked. Section 4 makes the configuration set, and makes it the default then.
27
27
 
28
- 1. Wait until SES shows **DKIM configuration** as **Successful** and **Identity status** as **Verified**.
28
+ 1. Select **Use a custom MAIL FROM domain**. In **MAIL FROM domain**, enter `bounce`; the form shows `.send.example.com` after it. For **Behavior on MX failure**, keep **Use default MAIL FROM domain**.
29
29
 
30
- ## 2. Set a custom MAIL FROM domain
30
+ 1. Expand **Advanced DKIM settings**, and select **Easy DKIM**. Keep **RSA_2048_BIT** as the key length. Choose **Create identity**.
31
31
 
32
- By default, SES sends with its own return path, so Gmail shows your mail as "mailed-by `amazonses.com`", and only DKIM ties it to your domain. A custom MAIL FROM domain puts your hostname there too, so SPF also passes for your domain. Your mail then still passes DMARC, the check section 3 sets up, if DKIM ever fails. It also meets Gmail's bulk-sender rule to set up SPF for your domain.
32
+ 1. The identity opens on its **Authentication** tab, which lists five DNS records to publish. Under DKIM, three CNAME records. Under **Custom MAIL FROM domain**, expand **Publish DNS records** for an MX record and a TXT record.
33
33
 
34
- 1. In SES, open the `send.example.com` identity. Under **Custom MAIL FROM domain**, choose **Edit**.
34
+ 1. Add all five to your domain's DNS records in Cloudflare, with their proxy status set to **DNS only**. SES can't read a proxied record, so check each one's status. You can import them in one go, or add them by hand:
35
35
 
36
- 1. Select **Use a custom MAIL FROM domain**, and enter `bounce.send.example.com`. For **Behavior on MX failure**, keep **Use default MAIL FROM domain**. Choose **Save changes**.
36
+ - **Import them.** Copy this into a text file. Replace each `REPLACE_WITH_NAME_…` and `REPLACE_WITH_VALUE_…` with a DKIM record's name and value, exactly as SES lists them, and keep the dot after each. Replace `us-east-1` with your region's code. In the Cloudflare dashboard, open your domain's **DNS → Records**, select **Import and Export**, and import the file with **Proxy imported DNS records** unchecked. SES's own **Download .csv record set** is in a format Cloudflare doesn't import.
37
37
 
38
- 1. In Cloudflare, add the two records SES lists, with your region's code in place of `us-east-1`:
38
+ ```
39
+ REPLACE_WITH_NAME_1. 3600 IN CNAME REPLACE_WITH_VALUE_1.
40
+ REPLACE_WITH_NAME_2. 3600 IN CNAME REPLACE_WITH_VALUE_2.
41
+ REPLACE_WITH_NAME_3. 3600 IN CNAME REPLACE_WITH_VALUE_3.
42
+ bounce.send.example.com. 3600 IN MX 10 feedback-smtp.us-east-1.amazonses.com.
43
+ bounce.send.example.com. 3600 IN TXT "v=spf1 include:amazonses.com ~all"
44
+ ```
39
45
 
40
- - **Type:** `MX`, **Name:** `bounce.send`, **Mail server:** `feedback-smtp.us-east-1.amazonses.com`, **Priority:** `10`
41
- - **Type:** `TXT`, **Name:** `bounce.send`, **Content:** `v=spf1 include:amazonses.com ~all`
46
+ - **Add them by hand.** Add each record exactly as SES lists it. Cloudflare adds your domain to the name, so each DKIM name ends in `._domainkey.send`, and the MAIL FROM records are named `bounce.send`.
42
47
 
43
- 1. Wait until SES shows the MAIL FROM domain's status as successful.
48
+ 1. Wait until SES shows **Identity status** as **Verified**, and both **DKIM configuration** and **MAIL FROM configuration** as **Successful**.
44
49
 
45
- ## 3. Add a DMARC record
50
+ ## 2. Add a DMARC record
46
51
 
47
52
  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.
48
53
 
@@ -52,39 +57,27 @@ DMARC tells inboxes what to do with mail that fails its checks, and sends you re
52
57
  - **Name:** `_dmarc.send`
53
58
  - **Content:** `v=DMARC1; p=none; rua=mailto:dmarc@example.com`
54
59
 
55
- 1. Replace `dmarc@example.com` with an address that receives mail. Reports arrive there as attachments.
56
-
57
- [Sending-domain DNS](../reference/02-sending-domain-dns.md#dmarc-publish-a-policy-and-collect-reports) explains each record, and when to tighten DMARC.
58
-
59
- ## 4. Request production access
60
-
61
- A new SES account starts in the sandbox: it sends only to addresses you've verified, up to 200 emails a day and one a second. Ask AWS to lift those limits now, and test while you wait.
62
-
63
- 1. Name the publication, since AWS may visit its website to review the request. In the editor, open **Settings → Publication identity**, and enter the name and tagline, or ask the publisher for them.
64
-
65
- 1. In SES, open the **Account dashboard**, select **View Get set up page**, and then **Request production access**.
60
+ 1. Replace `dmarc@example.com` with an address on your own domain that receives mail, such as one forwarded to your inbox with Cloudflare Email Routing. Reports arrive there as attachments. An address on another domain, such as a Gmail address, gets no reports unless that domain publishes a record allowing them, which Gmail doesn't.
66
61
 
67
- 1. Fill in the request:
62
+ 1. Check that the record is published. This prints it:
68
63
 
69
- - **Mail type:** Marketing
70
- - **Website URL:** `https://newsletter.example.com/`
64
+ ```bash
65
+ dig +short TXT _dmarc.send.example.com
66
+ ```
71
67
 
72
- Leave the other fields as they are.
68
+ [Sending-domain DNS](../reference/02-sending-domain-dns.md#dmarc-publish-a-policy-and-collect-reports) explains each record, and when to tighten DMARC.
73
69
 
74
- 1. Check the acknowledgement, and select **Submit request**. AWS Support answers by email, and may ask how you collect addresses and handle bounces.
70
+ ## 3. Verify your test addresses
75
71
 
76
- 1. Verify two addresses you own, such as a personal address and a work address, so the sandbox lets you mail them. Your test emails go to these. For each, under **Identities**, choose **Create identity**, select **Email address**, and follow the link in the email AWS sends.
72
+ A new SES account starts in the sandbox: it sends only to addresses you've verified, up to 200 emails a day and one a second. The test emails in [Verify it works](07-verify.md) go to two addresses of your own, so verify those now. [Request production access](06-production-access.md) lifts the limit, once AWS grants it.
77
73
 
78
- If AWS asks for more detail, or denies the request, reply in the support case, or open a new request from the **Account dashboard**. Say how Kestrel handles the list:
74
+ 1. Choose two addresses you own, such as a personal address and a work address. Verify it works uses both.
79
75
 
80
- - Signups use double opt-in: each subscriber confirms by email before joining the list.
81
- - Every post offers one-click unsubscribe.
82
- - Bounces and complaints reach the app, and it stops mailing those addresses automatically.
83
- - How many subscribers the newsletter expects, and how often it sends.
76
+ 1. For each, under **Identities**, choose **Create identity**, select **Email address**, and enter it. Choose **Create identity**.
84
77
 
85
- In the meantime, continue with the rest of this guide.
78
+ 1. Open the email AWS sends to each address, and follow its link. The identity's status changes to **Verified**.
86
79
 
87
- ## 5. Send bounces and complaints to the app
80
+ ## 4. Send bounces and complaints to the app
88
81
 
89
82
  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.
90
83
 
@@ -98,7 +91,7 @@ SES reports bounces and complaints through Amazon Simple Notification Service (S
98
91
 
99
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.
100
93
 
101
- 1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), 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.
94
+ 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.
102
95
 
103
96
  1. Let SES publish to the topic. Choose **Edit**, expand **Access policy**, and add this statement to the `Statement` list:
104
97
 
@@ -131,9 +124,11 @@ SES reports bounces and complaints through Amazon Simple Notification Service (S
131
124
  npx wrangler secret put SES_CONFIGURATION_SET --env production
132
125
  ```
133
126
 
134
- ## 6. Create the app's AWS key
127
+ ## 5. Create the app's AWS key
128
+
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
130
 
136
- The app sends through an AWS Identity and Access Management (IAM) user whose key can only send email. That limits the harm if the key ever leaks.
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
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
134
 
@@ -147,7 +142,12 @@ The app sends through an AWS Identity and Access Management (IAM) user whose key
147
142
  "Statement": [
148
143
  {
149
144
  "Effect": "Allow",
150
- "Action": ["ses:SendEmail", "ses:SendBulkEmail"],
145
+ "Action": [
146
+ "ses:SendEmail",
147
+ "ses:SendBulkEmail",
148
+ "ses:GetAccount",
149
+ "ses:GetEmailIdentity"
150
+ ],
151
151
  "Resource": "*"
152
152
  }
153
153
  ]
@@ -174,11 +174,11 @@ The app sends through an AWS Identity and Access Management (IAM) user whose key
174
174
  npx wrangler secret put AWS_SECRET_ACCESS_KEY --env production
175
175
  ```
176
176
 
177
- ## 7. Set the provider and deploy
177
+ ## 6. Connect the app and deploy
178
178
 
179
- 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 5 and 6. If a required one is missing, the app refuses to run until you store it, and `/health` names it.
179
+ 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.
180
180
 
181
- 1. In `wrangler.jsonc`, edit the `production` block's `vars`. Change `PROVIDER` to `ses` (it's `""` on a new instance; add it if your block has none), and add `SES_MAX_SEND_RATE`. Change `AWS_REGION` to your region's code from section 1. Your other values stay as they are. The block ends up like this:
181
+ 1. In `wrangler.jsonc`, edit the `production` block's `vars`. Change `PROVIDER` to `ses` (it's `""` on a new instance; add it if your block has none). Change `AWS_REGION` to your region's code from section 1. Your other values stay as they are. The block ends up like this:
182
182
 
183
183
  ```jsonc
184
184
  "vars": {
@@ -187,14 +187,13 @@ Until now the app has run with no email provider connected, refusing to send. Se
187
187
  "ARCHIVE_BASE_PATH": "/archive",
188
188
  "SENDING_DOMAIN": "send.example.com",
189
189
  "FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",
190
- "AWS_REGION": "us-east-1", // ← change: your region's code
191
- "SES_MAX_SEND_RATE": "1" // ← new: your maximum send rate; 1 in the sandbox
190
+ "AWS_REGION": "us-east-1" // ← change: your region's code
192
191
  }
193
192
  ```
194
193
 
195
- On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan's limit, so each minute sends more. Leave it out on Workers Free, which suits a list of up to about 1,000 subscribers. [As your list grows](../guides/09-as-your-list-grows.md) says why.
194
+ On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan's limit, so each minute sends more. Leave it out on Workers Free. [As your list grows](../guides/09-as-your-list-grows.md) says when Workers Paid is worth it: for faster sends at any size, and for the daily database writes of a list past about 1,000 subscribers.
196
195
 
197
- When AWS grants production access, set `SES_MAX_SEND_RATE` to the maximum send rate on the **Account dashboard**, and deploy again. The app keeps to that rate, so SES doesn't throttle your send.
196
+ The app reads its send rate from your SES account, so there's nothing to change when AWS grants production access. **Settings → Email sender** shows the rate and where it comes from.
198
197
 
199
198
  1. Commit the change, and push it:
200
199
 
@@ -209,10 +208,38 @@ Until now the app has run with no email provider connected, refusing to send. Se
209
208
  npx wrangler deploy --env production
210
209
  ```
211
210
 
212
- ## 8. Subscribe the app to the topic
211
+ 1. Check that the app answers on your hostname:
212
+
213
+ ```bash
214
+ curl https://newsletter.example.com/health
215
+ ```
216
+
217
+ ```
218
+ {"status":"ok","service":"kestrel"}
219
+ ```
220
+
221
+ Any other answer names the setting the app is missing.
222
+
223
+ 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
+
225
+ 1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren't open yet.
226
+
227
+ ## 7. Subscribe the app to the topic
213
228
 
214
229
  SNS delivers events only to a subscriber that has confirmed. The app confirms on its own, once it checks that the request came from your topic.
215
230
 
231
+ 1. Check that the app's webhook is public. It refuses a request that isn't from SNS:
232
+
233
+ ```bash
234
+ curl -s -o /dev/null -w "%{http_code}\n" -X POST https://newsletter.example.com/webhooks/ses
235
+ ```
236
+
237
+ ```
238
+ 400
239
+ ```
240
+
241
+ Any other answer, such as a redirect to your Cloudflare login, means Access covers the webhook. Remove that path from your Access application.
242
+
216
243
  1. In the SNS console, open your topic, and choose **Create subscription**.
217
244
 
218
245
  1. Set **Protocol** to **HTTPS**, and enter the endpoint:
@@ -225,7 +252,7 @@ SNS delivers events only to a subscriber that has confirmed. The app confirms on
225
252
 
226
253
  1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.
227
254
 
228
- 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 7'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.
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.
229
256
 
230
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**:
231
258
 
@@ -243,7 +270,7 @@ SNS delivers events only to a subscriber that has confirmed. The app confirms on
243
270
 
244
271
  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.
245
272
 
246
- ## 9. Receive replies at your From address
273
+ ## 8. Receive replies at your From address
247
274
 
248
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.
249
276
 
@@ -253,7 +280,7 @@ Replies to a post go to your From address, `newsletter@send.example.com`, which
253
280
  dig +short MX send.example.com
254
281
  ```
255
282
 
256
- If it lists mail servers, `send.example.com` already receives mail elsewhere, and Email Routing would replace those servers. Check that your mail host has a mailbox for `newsletter@send.example.com`, and skip to [Check it](#check-it).
283
+ If it lists mail servers, `send.example.com` already receives mail elsewhere, and Email Routing would replace those servers. Check that your mail host has a mailbox for `newsletter@send.example.com`, and skip the rest of this section.
257
284
 
258
285
  1. In the [Cloudflare dashboard](https://dash.cloudflare.com/), go to **Compute → Email Service → Email Routing**.
259
286
 
@@ -267,44 +294,14 @@ Replies to a post go to your From address, `newsletter@send.example.com`, which
267
294
 
268
295
  1. **(Optional)** Give your DMARC reports an inbox too. If the address in your DMARC record doesn't receive mail, add a rule that forwards `dmarc` the same way. Then change the record's `rua` to `mailto:dmarc@send.example.com`.
269
296
 
270
- 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.
271
-
272
- ## Check it
273
-
274
- 1. The app answers on your hostname:
275
-
276
- ```bash
277
- curl https://newsletter.example.com/health
278
- ```
279
-
280
- ```
281
- {"status":"ok","service":"kestrel"}
282
- ```
283
-
284
- 1. The webhook is public, and refuses a request that isn't from SNS:
285
-
286
- ```bash
287
- curl -s -o /dev/null -w "%{http_code}\n" -X POST https://newsletter.example.com/webhooks/ses
288
- ```
289
-
290
- ```
291
- 400
292
- ```
293
-
294
- Any other answer, such as a redirect to your Cloudflare login, means Access covers the webhook. Remove that path from your Access application.
295
-
296
- 1. The SNS subscription shows **Confirmed**.
297
-
298
- 1. Your From address receives mail. This lists Cloudflare's three `route` mail servers, or your own mail host's if you skipped section 9's forwarding:
297
+ 1. Check that your From address receives mail. This now lists Cloudflare's three `route` mail servers:
299
298
 
300
299
  ```bash
301
300
  dig +short MX send.example.com
302
301
  ```
303
302
 
304
- 1. In the editor, **Settings → Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before.
305
-
306
- 1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren't open yet.
303
+ 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.
307
304
 
308
- The test emails in the next step prove the rest, including a reply. In the sandbox, they go only to the addresses you verified in section 4, and to the SES test addresses that page lists. SES charges for each email it sends, test emails included, as [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists.
305
+ 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.
309
306
 
310
- Next, [verify it works](05-verify.md).
307
+ Next, [request production access](06-production-access.md).