@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.
- package/.dev.vars.example +10 -0
- package/CHANGELOG.md +30 -1
- package/bin/kestrel.mjs +1 -1
- package/cli/dev.mjs +3 -3
- package/cli/init.mjs +98 -4
- package/dist/public/dashboard/app.js +288 -286
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +6 -3
- package/dist/public/dashboard/styles.css +10 -0
- package/dist/worker/index.js +5606 -5095
- package/docs/README.md +5 -3
- package/docs/get-started/02-deploy.md +24 -20
- package/docs/get-started/03-access.md +9 -5
- package/docs/get-started/04-aws-account.md +68 -0
- package/docs/get-started/{04-ses.md → 05-ses.md} +89 -92
- package/docs/get-started/06-production-access.md +64 -0
- package/docs/get-started/{05-verify.md → 07-verify.md} +16 -26
- package/docs/get-started/{06-go-live.md → 08-go-live.md} +2 -2
- package/docs/guides/01-allow-claude.md +1 -1
- package/docs/guides/02-automate.md +1 -1
- package/docs/guides/03-notifications.md +3 -3
- package/docs/guides/07-upgrade.md +4 -4
- package/docs/guides/09-as-your-list-grows.md +8 -18
- package/docs/publishing/02-sending-rules.md +2 -2
- package/docs/publishing/03-name-your-publication.md +2 -0
- package/docs/publishing/04-write-a-post.md +1 -1
- package/docs/publishing/06-troubleshooting.md +11 -7
- package/docs/publishing/09-share-your-subscribe-link.md +1 -1
- package/docs/publishing/10-check-your-setup.md +4 -2
- package/docs/reference/01-configuration.md +14 -12
- package/docs/reference/02-sending-domain-dns.md +4 -4
- package/migrations/0015_provider_account.sql +18 -0
- package/package.json +1 -1
- package/template/claude/CLAUDE.md +3 -3
- 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. [
|
|
12
|
-
1. [
|
|
13
|
-
1. [
|
|
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](
|
|
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
|
|
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
|
|
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": "
|
|
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](
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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:** `
|
|
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
|
|
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
|
|
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, [
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
24
|
+
1. Under **Configuration**, choose **Identities**, then **Create identity**.
|
|
25
25
|
|
|
26
|
-
1.
|
|
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.
|
|
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
|
-
|
|
30
|
+
1. Expand **Advanced DKIM settings**, and select **Easy DKIM**. Keep **RSA_2048_BIT** as the key length. Choose **Create identity**.
|
|
31
31
|
|
|
32
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- **
|
|
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
|
|
48
|
+
1. Wait until SES shows **Identity status** as **Verified**, and both **DKIM configuration** and **MAIL FROM configuration** as **Successful**.
|
|
44
49
|
|
|
45
|
-
##
|
|
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.
|
|
62
|
+
1. Check that the record is published. This prints it:
|
|
68
63
|
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
```bash
|
|
65
|
+
dig +short TXT _dmarc.send.example.com
|
|
66
|
+
```
|
|
71
67
|
|
|
72
|
-
|
|
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
|
-
|
|
70
|
+
## 3. Verify your test addresses
|
|
75
71
|
|
|
76
|
-
|
|
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
|
-
|
|
74
|
+
1. Choose two addresses you own, such as a personal address and a work address. Verify it works uses both.
|
|
79
75
|
|
|
80
|
-
|
|
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
|
-
|
|
78
|
+
1. Open the email AWS sends to each address, and follow its link. The identity's status changes to **Verified**.
|
|
86
79
|
|
|
87
|
-
##
|
|
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
|
-
##
|
|
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
|
-
|
|
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": [
|
|
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
|
-
##
|
|
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
|
|
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)
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, [
|
|
307
|
+
Next, [request production access](06-production-access.md).
|