@kurtbruns/kestrel 1.6.0 → 1.8.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 +17 -24
- package/CHANGELOG.md +75 -1
- package/README.md +2 -2
- package/cli/dev-api.mjs +7 -1
- package/cli/dev.mjs +46 -1
- package/cli/init.mjs +6 -1
- package/dist/public/dashboard/app.js +234 -236
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +6 -6
- package/dist/public/dashboard/styles.css +187 -177
- package/dist/worker/index.js +2118 -1998
- package/docs/README.md +3 -4
- package/docs/get-started/02-deploy.md +49 -18
- package/docs/get-started/03-access.md +4 -4
- package/docs/{guides/02-ses.md → get-started/04-ses.md} +75 -52
- package/docs/get-started/05-verify.md +8 -10
- package/docs/get-started/06-go-live.md +20 -7
- package/docs/guides/07-upgrade.md +2 -0
- package/docs/guides/08-try-locally.md +108 -0
- package/docs/reference/01-configuration.md +25 -14
- package/docs/reference/02-sending-domain-dns.md +19 -19
- package/migrations/0004_keyed_pending_to_resolve.sql +27 -0
- package/package.json +2 -2
- package/template/claude/CLAUDE.md +3 -1
- package/template/package.json +1 -0
- package/template/wrangler.jsonc +10 -6
- package/docs/get-started/01-overview.md +0 -47
- package/docs/get-started/04-resend.md +0 -134
package/docs/README.md
CHANGED
|
@@ -4,12 +4,11 @@ These guides deploy and run your own instance of Kestrel, from an empty reposito
|
|
|
4
4
|
|
|
5
5
|
## Get started
|
|
6
6
|
|
|
7
|
-
Follow these steps, from an empty repository to a live newsletter.
|
|
7
|
+
Follow these steps, from an empty repository to a live newsletter. Each one ends with a check, so you know it worked before you move on.
|
|
8
8
|
|
|
9
|
-
1. [Overview](get-started/01-overview.md)
|
|
10
9
|
1. [Deploy the app](get-started/02-deploy.md)
|
|
11
10
|
1. [Lock the dashboard with Access](get-started/03-access.md)
|
|
12
|
-
1. [Connect
|
|
11
|
+
1. [Connect Amazon SES](get-started/04-ses.md)
|
|
13
12
|
1. [Verify it works](get-started/05-verify.md)
|
|
14
13
|
1. [Go live](get-started/06-go-live.md)
|
|
15
14
|
|
|
@@ -18,7 +17,7 @@ Follow these steps, from an empty repository to a live newsletter.
|
|
|
18
17
|
Each one stands on its own. Pick the ones you need.
|
|
19
18
|
|
|
20
19
|
- [Connect Claude to the API](guides/01-connect-claude.md)
|
|
21
|
-
- [
|
|
20
|
+
- [Try Kestrel locally first](guides/08-try-locally.md)
|
|
22
21
|
- [Notifications through Cloudflare's email](guides/03-notifications.md)
|
|
23
22
|
- [Rate-limit the subscribe form](guides/05-rate-limit.md)
|
|
24
23
|
- [Upgrade to a new release](guides/07-upgrade.md)
|
|
@@ -2,7 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
Kestrel runs as one Cloudflare Worker, with a D1 database for posts and subscribers and an R2 bucket for images.
|
|
4
4
|
|
|
5
|
-
In this guide, you create your instance's repository, create the database and the bucket, and point Kestrel's production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname.
|
|
5
|
+
In this guide, you choose your hostnames, create your instance's repository, create the database and the bucket, and point Kestrel's production settings at them and at your hostname. Then you deploy, and check that the app answers on your hostname.
|
|
6
|
+
|
|
7
|
+
## Before you begin
|
|
8
|
+
|
|
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
|
+
- [Node.js](https://nodejs.org/) 22 or later, and [Git](https://git-scm.com/).
|
|
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.
|
|
13
|
+
|
|
14
|
+
## Choose your hostnames
|
|
15
|
+
|
|
16
|
+
Kestrel uses two hostnames: one for the app, and one your email is sent from.
|
|
17
|
+
|
|
18
|
+
| Hostname | What it's for |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| `newsletter.example.com` | the editor, the API, and the public pages readers see |
|
|
21
|
+
| `send.example.com` | the address your email comes from, and its SPF, DKIM, and DMARC records |
|
|
22
|
+
|
|
23
|
+
These guides put the app on a subdomain, which leaves any website you already have on `example.com` untouched. If `example.com` has no website of its own, the app can live there instead. Use `example.com` wherever these guides say `newsletter.example.com`.
|
|
24
|
+
|
|
25
|
+
It's recommended to send from a subdomain rather than from `example.com` itself. Your apex domain carries the reputation of your regular email, and a newsletter shouldn't put that at risk. Avoid `mail.` as the sending name too, since mail servers read it as an inbound host. [Sending-domain DNS](../reference/02-sending-domain-dns.md#choose-your-sending-name) explains both.
|
|
26
|
+
|
|
27
|
+
Throughout these guides, replace the example names with your own, and any value written as `REPLACE_WITH_…`.
|
|
6
28
|
|
|
7
29
|
## 1. Create your instance's repository
|
|
8
30
|
|
|
@@ -34,14 +56,7 @@ Your instance's repository holds its configuration, its database migrations, and
|
|
|
34
56
|
git push -u origin HEAD
|
|
35
57
|
```
|
|
36
58
|
|
|
37
|
-
1. **(Optional)** Try Kestrel
|
|
38
|
-
|
|
39
|
-
```bash
|
|
40
|
-
cp .dev.vars.example .dev.vars
|
|
41
|
-
npm run dev
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
Open http://localhost:8787/dashboard/ to see the editor. To fill it with demo content, run `npm run seed` in a second terminal in the same folder, and `npm run reset` to empty it again. Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop the server.
|
|
59
|
+
1. **(Optional)** Try Kestrel locally before you deploy it. Your instance is written and installed, so start at [Start the app](../guides/08-try-locally.md#2-start-the-app), and come back here when you're done. Email goes to a stand-in, so nothing reaches a real inbox.
|
|
45
60
|
|
|
46
61
|
## 2. Sign in to Cloudflare
|
|
47
62
|
|
|
@@ -63,25 +78,33 @@ Every command from here on acts on your Cloudflare account through the Wrangler
|
|
|
63
78
|
|
|
64
79
|
## 3. Create the database and the image bucket
|
|
65
80
|
|
|
81
|
+
Each command creates the resource and writes its id or name into the `production` block of `wrangler.jsonc`, under the binding the app already uses, so there's nothing to copy.
|
|
82
|
+
|
|
66
83
|
1. Create the database:
|
|
67
84
|
|
|
68
85
|
```bash
|
|
69
|
-
npx wrangler d1 create kestrel-production
|
|
86
|
+
npx wrangler d1 create kestrel-production --env production --binding DB --update-config
|
|
70
87
|
```
|
|
71
88
|
|
|
72
|
-
It prints the new database's settings,
|
|
89
|
+
It prints the new database's settings, then a snippet it says to add to your configuration. Wrangler has already added it: it fills in the `database_id` of the production `DB` binding with the database's real id, in place of `REPLACE_WITH_PRODUCTION_D1_ID`, so there's nothing to paste.
|
|
73
90
|
|
|
74
91
|
1. Create the bucket for images:
|
|
75
92
|
|
|
76
93
|
```bash
|
|
77
|
-
npx wrangler r2 bucket create kestrel-media-production
|
|
94
|
+
npx wrangler r2 bucket create kestrel-media-production --env production --binding MEDIA --update-config
|
|
78
95
|
```
|
|
79
96
|
|
|
97
|
+
It prints a snippet to add to your configuration too. There's nothing to paste here either: the production `MEDIA` binding already names this bucket, so it stays as it is.
|
|
98
|
+
|
|
99
|
+
Wrangler rewrites the whole of `wrangler.jsonc` in its own layout when it saves: tabs, and one value per line. Your comments and settings stay; only the spacing changes.
|
|
100
|
+
|
|
101
|
+
Name `--env production` on every Wrangler command that creates or binds a resource. Without it, Wrangler writes to the top level, which is local development.
|
|
102
|
+
|
|
80
103
|
## 4. Configure production
|
|
81
104
|
|
|
82
105
|
`wrangler.jsonc` holds two sets of settings. The top level is for local development, so leave it alone. The `production` block under `env` is yours to fill in.
|
|
83
106
|
|
|
84
|
-
1. Open `wrangler.jsonc` and change the values marked here in the `production` block:
|
|
107
|
+
1. Open `wrangler.jsonc` and change the values marked here in the `production` block. It's shown compactly here; in your file, after section 3, each value is on its own line:
|
|
85
108
|
|
|
86
109
|
```jsonc
|
|
87
110
|
"production": {
|
|
@@ -90,12 +113,12 @@ Every command from here on acts on your Cloudflare account through the Wrangler
|
|
|
90
113
|
{
|
|
91
114
|
"binding": "DB",
|
|
92
115
|
"database_name": "kestrel-production",
|
|
93
|
-
"database_id": "
|
|
116
|
+
"database_id": "…", // already filled in by section 3; leave it
|
|
94
117
|
"migrations_dir": "migrations"
|
|
95
118
|
}
|
|
96
119
|
],
|
|
97
120
|
"vars": {
|
|
98
|
-
"PROVIDER": "
|
|
121
|
+
"PROVIDER": "", // ← stays empty until step 3
|
|
99
122
|
"APP_ORIGIN": "https://newsletter.example.com", // ← your app's hostname
|
|
100
123
|
"ARCHIVE_BASE_PATH": "/archive",
|
|
101
124
|
"SENDING_DOMAIN": "send.example.com", // ← your sending hostname
|
|
@@ -107,10 +130,12 @@ Every command from here on acts on your Cloudflare account through the Wrangler
|
|
|
107
130
|
What each one does:
|
|
108
131
|
|
|
109
132
|
- **`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.
|
|
110
|
-
- **`PROVIDER
|
|
133
|
+
- **`PROVIDER` stays empty.** Step 3, [Connect Amazon SES](04-ses.md), sets it. Until then the app runs without an email provider: you can write and preview posts, 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.
|
|
111
134
|
- **`ARCHIVE_BASE_PATH`** is permanent once you send. Every post you send carries its `/archive/…` link, and changing the prefix later breaks the links you've already mailed. Keep `/archive` unless you have a reason not to.
|
|
112
135
|
- **`FROM_ADDRESS`** is the address every email comes from. Keep it on your sending hostname, and set `SENDING_DOMAIN` to the part after the `@`.
|
|
113
136
|
|
|
137
|
+
Change every `example.com` value, the route included, even before you connect a provider. The app checks its settings but can't see the route: it refuses to run while `APP_ORIGIN`, `SENDING_DOMAIN`, or `FROM_ADDRESS`, or `ARCHIVE_ORIGIN` or `MEDIA_PUBLIC_BASE` if you set them, is still on `example.com`, since its links and the address your email comes from would name a domain that isn't yours.
|
|
138
|
+
|
|
114
139
|
Every other setting has a working default. [Configuration](../reference/01-configuration.md) lists them all, and what the app refuses.
|
|
115
140
|
|
|
116
141
|
1. Check the file. This regenerates the binding types, and fails if the configuration doesn't parse:
|
|
@@ -119,6 +144,8 @@ Every command from here on acts on your Cloudflare account through the Wrangler
|
|
|
119
144
|
npm run typecheck
|
|
120
145
|
```
|
|
121
146
|
|
|
147
|
+
Wrangler prints the binding types it generated and ends with a reminder to run it again whenever you change `wrangler.jsonc`. The type check that follows prints nothing when it passes.
|
|
148
|
+
|
|
122
149
|
1. Commit your settings and push them to your repository. An upgrade changes only the release your instance runs, so your settings stay as they are:
|
|
123
150
|
|
|
124
151
|
```bash
|
|
@@ -166,8 +193,12 @@ This deploys the release your instance runs, with your settings. The output ends
|
|
|
166
193
|
401
|
|
167
194
|
```
|
|
168
195
|
|
|
169
|
-
|
|
196
|
+
In a browser, `https://newsletter.example.com/dashboard/` says the editor is locked until you set up Access. That's expected.
|
|
197
|
+
|
|
198
|
+
1. In a browser, `https://newsletter.example.com/` shows your newsletter's public landing page. Its subscribe page, `/subscribe`, says signups aren't open yet, until step 3, Connect Amazon SES.
|
|
199
|
+
|
|
200
|
+
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.
|
|
170
201
|
|
|
171
|
-
If
|
|
202
|
+
If the public pages say the same, but `/health` answers `500` with `{"error":"schema_behind","missing":[…],…}`, the database hasn't run the migrations it lists, most often because step 5 was skipped or its prompt answered no. Run step 5's command, which the body's `message` repeats; the app answers as soon as it finishes, with no new deploy.
|
|
172
203
|
|
|
173
204
|
Next, [lock the dashboard with Access](03-access.md).
|
|
@@ -59,7 +59,7 @@ The app verifies each login against your team domain, and checks that it was iss
|
|
|
59
59
|
|
|
60
60
|
1. Find the application's audience tag. Under **Access controls → Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.
|
|
61
61
|
|
|
62
|
-
1. Store the team domain as a secret. The command prompts you for the value. Enter
|
|
62
|
+
1. Store the team domain as a secret. The command prompts you for the value. Enter the hostname alone, without `https://`. If you paste it with `https://` or a trailing slash, the app drops them. Anything else that isn't a hostname is refused, naming `ACCESS_TEAM_DOMAIN`:
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
65
|
npx wrangler secret put ACCESS_TEAM_DOMAIN --env production
|
|
@@ -71,7 +71,7 @@ The app verifies each login against your team domain, and checks that it was iss
|
|
|
71
71
|
npx wrangler secret put ACCESS_AUD --env production
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
A secret takes effect as soon as you store it, with no redeploy. The app needs both values.
|
|
74
|
+
A secret takes effect as soon as you store it, with no redeploy. The app needs both values. While only one is stored, it refuses its configuration and names the missing one: the editor says so, `/health` answers `500` with `"variable":"ACCESS_AUD"`, and the public pages say the newsletter is temporarily unavailable. Storing the second clears it.
|
|
75
75
|
|
|
76
76
|
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:
|
|
77
77
|
|
|
@@ -103,6 +103,6 @@ The app verifies each login against your team domain, and checks that it was iss
|
|
|
103
103
|
{"status":"ok","service":"kestrel"}
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
If Cloudflare lets you in but the editor answers `401`, check the two secrets
|
|
106
|
+
If the editor says the configuration needs a fix, store the secret it names. If Cloudflare lets you in but the editor answers `401`, check the two secrets for a typo, or `ACCESS_ALLOWED_EMAILS` may leave out your address.
|
|
107
107
|
|
|
108
|
-
Next, [connect
|
|
108
|
+
Next, [connect Amazon SES](04-ses.md).
|
|
@@ -1,33 +1,22 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Connect Amazon SES
|
|
2
2
|
|
|
3
|
-
Amazon SES
|
|
3
|
+
Kestrel sends your posts through Amazon SES, Amazon's email service. SES only delivers the mail: Kestrel keeps your list, each subscriber's consent, and the record of every send. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.
|
|
4
4
|
|
|
5
|
-
In this guide, you verify your sending hostname with SES, ask AWS for production access
|
|
5
|
+
In this guide, you verify your sending hostname with SES, add a DMARC record, and ask AWS for production access. You also route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, set the provider, and deploy.
|
|
6
6
|
|
|
7
7
|
## Before you begin
|
|
8
8
|
|
|
9
|
-
You need:
|
|
10
|
-
|
|
11
9
|
- An [AWS account](https://portal.aws.amazon.com/billing/signup).
|
|
12
|
-
- Cloudflare's [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/).
|
|
13
|
-
- The app deployed and locked, from [Deploy the app](../get-started/02-deploy.md) and [Lock the dashboard with Access](../get-started/03-access.md).
|
|
14
|
-
|
|
15
|
-
To move an instance that already sends through Resend, read [Switch an instance that already sends](#switch-an-instance-that-already-sends) first.
|
|
16
|
-
|
|
17
|
-
## How fast SES sends
|
|
18
|
-
|
|
19
|
-
A send goes out a slice at a time, one slice a minute. Each slice stays inside the subrequests Cloudflare allows one run of the Worker, which [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) sets. Resend takes up to 100 recipients a request. SES takes one, so each recipient costs far more of that allowance.
|
|
20
10
|
|
|
21
|
-
|
|
22
|
-
- **Workers Paid, with `SUBREQUEST_BUDGET` set to `10000`:** up to about 1,900 recipients a minute, within your SES account's sending rate. At 14 messages a second, that's about 700 a minute, and 10,000 subscribers take about fifteen minutes.
|
|
23
|
-
|
|
24
|
-
AWS sets your account's sending rate when it grants production access. To send faster, ask AWS to raise it.
|
|
11
|
+
To move an instance that already sends through another provider or another SES account, read [Switch an instance that already sends](#switch-an-instance-that-already-sends) first.
|
|
25
12
|
|
|
26
13
|
## 1. Verify your sending hostname
|
|
27
14
|
|
|
28
15
|
SES sends only from an identity you've verified. Verifying the whole hostname, rather than one address, lets any address on it send. It also sets up DKIM, which lets inboxes trust your mail.
|
|
29
16
|
|
|
30
|
-
1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose the region you send from in the menu at the top right. SES keeps identities, quotas, and the sandbox separately in each region, so do every step in this one.
|
|
17
|
+
1. Open the [SES console](https://console.aws.amazon.com/ses/), and choose the region you send from in the menu at the top right. Any region that offers SES works, so pick one near you. A few newer regions must be [enabled for your account](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-regions.html) before you can use them. SES keeps identities, quotas, and the sandbox separately in each region, so do every step in this one.
|
|
18
|
+
|
|
19
|
+
1. Note the region's code. The menu shows a name, such as Europe (Ireland), but later steps need its code, such as `eu-west-1`. [Amazon SES endpoints](https://docs.aws.amazon.com/general/latest/gr/ses.html) lists each region's code beside its name. The MAIL FROM record, the SNS access policy, and `AWS_REGION` all use this one code.
|
|
31
20
|
|
|
32
21
|
1. Under **Configuration**, choose **Identities**, then **Create identity**.
|
|
33
22
|
|
|
@@ -61,7 +50,7 @@ SES sends with its own return path by default, so SPF passes for `amazonses.com`
|
|
|
61
50
|
|
|
62
51
|
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**.
|
|
63
52
|
|
|
64
|
-
1. In Cloudflare, add the two records SES lists. Replace `us-east-1` with your region:
|
|
53
|
+
1. In Cloudflare, add the two records SES lists. Replace `us-east-1` with your region's code from section 1:
|
|
65
54
|
|
|
66
55
|
- **Type:** `MX`, **Name:** `bounce.send`, **Mail server:** `feedback-smtp.us-east-1.amazonses.com`, **Priority:** `10`
|
|
67
56
|
- **Type:** `TXT`, **Name:** `bounce.send`, **Content:** `v=spf1 include:amazonses.com ~all`
|
|
@@ -72,6 +61,8 @@ SES sends with its own return path by default, so SPF passes for `amazonses.com`
|
|
|
72
61
|
|
|
73
62
|
A new SES account starts in the sandbox. It sends only to addresses you've verified in SES, up to 200 emails a day and one a second. Ask AWS to lift those limits now, and test while you wait.
|
|
74
63
|
|
|
64
|
+
1. Name your publication first, since AWS may visit your website to review the request. In the editor, open **Settings → Publication identity**, and enter the name and tagline. Until you do, your public pages show the name in your From address. The subscribe form stays closed until section 7 sets the provider.
|
|
65
|
+
|
|
75
66
|
1. In SES, open the **Account dashboard**, select **View Get set up page**, and then **Request production access**.
|
|
76
67
|
|
|
77
68
|
1. Fill in the request:
|
|
@@ -79,27 +70,36 @@ A new SES account starts in the sandbox. It sends only to addresses you've verif
|
|
|
79
70
|
- **Mail type:** Marketing
|
|
80
71
|
- **Website URL:** `https://newsletter.example.com/`
|
|
81
72
|
|
|
82
|
-
1. Check the acknowledgement, and select **Submit request**. AWS Support answers by email, and may ask how you collect addresses and handle bounces.
|
|
73
|
+
1. Check the acknowledgement, and select **Submit request**. AWS Support answers by email, and may ask how you collect addresses and handle bounces.
|
|
74
|
+
|
|
75
|
+
1. While you wait, verify the two addresses you test with in [Verify it works](05-verify.md#before-you-begin), so the sandbox lets you mail them. For each, under **Identities**, choose **Create identity**, select **Email address**, and follow the link in the email AWS sends.
|
|
83
76
|
|
|
84
|
-
|
|
77
|
+
If AWS asks for more detail, reply in the support case. If it denies the request, reply there, or open a new request from the **Account dashboard**. Either way, say how Kestrel handles your list:
|
|
85
78
|
|
|
86
|
-
|
|
79
|
+
- Signups use double opt-in: each subscriber confirms by email before joining the list.
|
|
80
|
+
- Every post offers one-click unsubscribe.
|
|
81
|
+
- Bounces and complaints reach the app through SNS, and it stops mailing those addresses automatically.
|
|
82
|
+
- How many subscribers you expect, and how often you send.
|
|
83
|
+
|
|
84
|
+
Your account stays in the sandbox until AWS grants access, so don't go live before then. In the sandbox, a signup from any address you haven't verified fails, and a post reaches only your verified addresses.
|
|
85
|
+
|
|
86
|
+
When AWS grants access, the **Account dashboard** shows your daily quota and your maximum send rate. You set the rate in the app in section 7. The rate decides how fast a send goes out, and [As your list grows](06-go-live.md#as-your-list-grows) says when to ask AWS to raise it.
|
|
87
87
|
|
|
88
88
|
## 5. Send bounces and complaints to SNS
|
|
89
89
|
|
|
90
90
|
SES reports what happens to each email through SNS, the AWS notification service. A configuration set says which events to report, and an SNS topic carries them to the app.
|
|
91
91
|
|
|
92
|
-
1. In SES, under **Configuration**, choose **Configuration sets**, then **Create set**. Name it `kestrel-
|
|
92
|
+
1. In SES, under **Configuration**, choose **Configuration sets**, then **Create set**. Name it `kestrel-mail`, and choose **Create set**.
|
|
93
93
|
|
|
94
|
-
1. On the set's **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, and **
|
|
94
|
+
1. On the set's **Event destinations** tab, choose **Add destination**. Select **Hard bounces**, **Complaints**, **Deliveries**, and **Rendering failures**, and choose **Next**. A rendering failure is an email SES took but couldn't build. It shouldn't happen, and the app logs it as an error if it does.
|
|
95
95
|
|
|
96
|
-
1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-
|
|
96
|
+
1. For **Destination type**, select **Amazon SNS**. Name it `kestrel-sns`, and keep **Event publishing** enabled. Choose **Next**.
|
|
97
97
|
|
|
98
|
-
1. For **SNS topic**, choose **Create SNS topic**. Make it a **Standard** topic named `kestrel-
|
|
98
|
+
1. For **SNS topic**, choose **Create SNS topic**. Make it a **Standard** topic named `kestrel-events`. Choose **Next**, then **Add destination**.
|
|
99
99
|
|
|
100
|
-
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-
|
|
100
|
+
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.
|
|
101
101
|
|
|
102
|
-
1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), and copy its ARN. It looks like `arn:aws:sns:us-east-1:111122223333:kestrel-
|
|
102
|
+
1. Open the topic in the [SNS console](https://console.aws.amazon.com/sns/), and copy its ARN. It looks like `arn:aws:sns:us-east-1:111122223333:kestrel-events`. After `sns` comes your region's code, then your 12-digit account ID. The account menu at the top right of the console also shows the account ID.
|
|
103
103
|
|
|
104
104
|
1. Let SES publish to the topic. Choose **Edit**, expand **Access policy**, and add this statement to the `Statement` list:
|
|
105
105
|
|
|
@@ -112,13 +112,13 @@ SES reports what happens to each email through SNS, the AWS notification service
|
|
|
112
112
|
"Condition": {
|
|
113
113
|
"StringEquals": {
|
|
114
114
|
"AWS:SourceAccount": "REPLACE_WITH_ACCOUNT_ID",
|
|
115
|
-
"AWS:SourceArn": "arn:aws:ses:us-east-1:REPLACE_WITH_ACCOUNT_ID:configuration-set/kestrel-
|
|
115
|
+
"AWS:SourceArn": "arn:aws:ses:us-east-1:REPLACE_WITH_ACCOUNT_ID:configuration-set/kestrel-mail"
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
-
Replace the topic ARN,
|
|
121
|
+
Replace the topic ARN, and both copies of the account ID, with the values from your ARN. Replace `us-east-1` with your region's code, the same one the ARN shows. Choose **Save changes**.
|
|
122
122
|
|
|
123
123
|
You subscribe the app to the topic in section 8, once it runs on SES.
|
|
124
124
|
|
|
@@ -126,9 +126,11 @@ You subscribe the app to the topic in section 8, once it runs on SES.
|
|
|
126
126
|
|
|
127
127
|
The app sends through an IAM user whose key can only send email. That limits the harm if the key ever leaks.
|
|
128
128
|
|
|
129
|
-
1. In the [IAM console](https://console.aws.amazon.com/iam/), go to **Users**, and choose **Create user**. Name it `kestrel-
|
|
129
|
+
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.
|
|
130
130
|
|
|
131
|
-
1. When asked for permissions, choose **Attach policies directly**,
|
|
131
|
+
1. When asked for permissions, choose **Attach policies directly**, then **Create policy**. The policy editor opens in a new browser tab.
|
|
132
|
+
|
|
133
|
+
1. In the new tab, choose the **JSON** editor, and replace its contents with this policy:
|
|
132
134
|
|
|
133
135
|
```json
|
|
134
136
|
{
|
|
@@ -136,22 +138,26 @@ The app sends through an IAM user whose key can only send email. That limits the
|
|
|
136
138
|
"Statement": [
|
|
137
139
|
{
|
|
138
140
|
"Effect": "Allow",
|
|
139
|
-
"Action": "ses:SendEmail",
|
|
141
|
+
"Action": ["ses:SendEmail", "ses:SendBulkEmail"],
|
|
140
142
|
"Resource": "*"
|
|
141
143
|
}
|
|
142
144
|
]
|
|
143
145
|
}
|
|
144
146
|
```
|
|
145
147
|
|
|
146
|
-
|
|
148
|
+
Choose **Next**, name the policy `kestrel-send-email`, and choose **Create policy**. You can close this tab.
|
|
149
|
+
|
|
150
|
+
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.
|
|
151
|
+
|
|
152
|
+
1. Choose **Next**, then **Create user**.
|
|
147
153
|
|
|
148
154
|
1. Open the user, and on the **Security credentials** tab, choose **Create access key**. For the use case, choose **Application running outside AWS**.
|
|
149
155
|
|
|
150
156
|
1. Copy the access key ID and the secret access key. AWS shows the secret only once.
|
|
151
157
|
|
|
152
|
-
## 7.
|
|
158
|
+
## 7. Set the provider and deploy
|
|
153
159
|
|
|
154
|
-
Store
|
|
160
|
+
Until now the app has run with no email provider connected, refusing to send. Setting `PROVIDER` connects SES. Store the secrets before you set it. With `PROVIDER` set to `ses` and a required secret missing, the app refuses to run, and sends nothing, until you store it. `/health` and the editor's **A setting needs fixing** page name the missing secret, and reader pages say the newsletter is temporarily unavailable. `SES_CONFIGURATION_SET` is the one optional secret.
|
|
155
161
|
|
|
156
162
|
1. Store the access key ID, and paste it at the prompt:
|
|
157
163
|
|
|
@@ -171,21 +177,28 @@ Store every secret before you switch. With `PROVIDER` set to `ses` and a secret
|
|
|
171
177
|
npx wrangler secret put SNS_TOPIC_ARN --env production
|
|
172
178
|
```
|
|
173
179
|
|
|
174
|
-
1. Store the configuration set's name, `kestrel-
|
|
180
|
+
1. Store the configuration set's name, `kestrel-mail`. It's optional but recommended: the app names it on every email, so SES reports on each one even without the default:
|
|
175
181
|
|
|
176
182
|
```bash
|
|
177
183
|
npx wrangler secret put SES_CONFIGURATION_SET --env production
|
|
178
184
|
```
|
|
179
185
|
|
|
180
|
-
1. In `wrangler.jsonc`,
|
|
186
|
+
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
187
|
|
|
182
188
|
```jsonc
|
|
183
|
-
"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
189
|
+
"vars": {
|
|
190
|
+
"PROVIDER": "ses", // ← set to ses
|
|
191
|
+
"APP_ORIGIN": "https://newsletter.example.com",
|
|
192
|
+
"ARCHIVE_BASE_PATH": "/archive",
|
|
193
|
+
"SENDING_DOMAIN": "send.example.com",
|
|
194
|
+
"FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",
|
|
195
|
+
"AWS_REGION": "us-east-1", // ← change: your region's code
|
|
196
|
+
"SES_MAX_SEND_RATE": "1" // ← new: your maximum send rate; 1 in the sandbox
|
|
197
|
+
}
|
|
187
198
|
```
|
|
188
199
|
|
|
200
|
+
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](06-go-live.md#as-your-list-grows) says when the plan is worth it.
|
|
201
|
+
|
|
189
202
|
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.
|
|
190
203
|
|
|
191
204
|
1. Commit the change, and push it:
|
|
@@ -217,7 +230,21 @@ SNS delivers events only to a subscriber that has confirmed. The app confirms on
|
|
|
217
230
|
|
|
218
231
|
1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.
|
|
219
232
|
|
|
220
|
-
If it stays **Pending confirmation**, the
|
|
233
|
+
If it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:
|
|
234
|
+
|
|
235
|
+
- **Access covers `/webhooks/ses`.** If your Access application covers more than `/dashboard` and `/api`, SNS gets the login page instead of the app. Remove that path from your Access application, so only `dashboard` and `api` remain.
|
|
236
|
+
- **`SNS_TOPIC_ARN` matches the topic's ARN.**
|
|
237
|
+
- **The deploy in section 7 finished.**
|
|
238
|
+
|
|
239
|
+
Once you've fixed the cause, select the subscription, and choose **Request confirmation**. SNS sends the request again, and the app confirms it. SNS doesn't let you delete a subscription while it's pending, and removes it on its own after 48 hours.
|
|
240
|
+
|
|
241
|
+
## Switch an instance that already sends
|
|
242
|
+
|
|
243
|
+
Change `PROVIDER`, or move to another account with the same provider, only while no send is sending. The dashboard lists any send in progress.
|
|
244
|
+
|
|
245
|
+
When a request to a provider gets no answer, Kestrel can't know whether its recipients were mailed. Moving to SES, from another provider or another SES account, mails none of them twice. Kestrel never repeats an unanswered request to SES, so those recipients wait for you to decide whether they were mailed. Use **Resolve** on the send's page.
|
|
246
|
+
|
|
247
|
+
Kestrel can't tell whether those emails went out. SES's console shows sent messages only if you've turned on Virtual Deliverability Manager, which this guide doesn't set up. So **Assume not sent** is the safe default. Those readers may miss this post, but they get your next one. No one is mailed twice. Choose **Assume sent** only when you know otherwise, such as a reader who says they got it.
|
|
221
248
|
|
|
222
249
|
## Check it
|
|
223
250
|
|
|
@@ -245,16 +272,12 @@ If it stays **Pending confirmation**, the app refused the request. Check that `S
|
|
|
245
272
|
|
|
246
273
|
1. The SNS subscription shows **Confirmed**.
|
|
247
274
|
|
|
248
|
-
1. In the editor, **Settings → Email sender** shows Amazon SES
|
|
249
|
-
|
|
250
|
-
The test sends in the next step prove the rest. In the sandbox, send them only to the addresses you verified in section 4, and to the SES test addresses that page lists.
|
|
275
|
+
1. In the editor, **Settings → Email sender** shows Amazon SES and your From address, where it said **No email provider connected** before.
|
|
251
276
|
|
|
252
|
-
|
|
277
|
+
1. In a browser, `https://newsletter.example.com/subscribe` shows the subscribe form, where it said signups aren't open yet.
|
|
253
278
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
Change `PROVIDER`, or move to another account with the same provider, only while no send is sending. The dashboard lists any send in progress.
|
|
279
|
+
The test sends in the next step prove the rest. In the sandbox, send them only to the addresses you verified in section 4, and to the SES test addresses that page lists.
|
|
257
280
|
|
|
258
|
-
|
|
281
|
+
SES charges for each email it sends, test emails included. [Amazon SES pricing](https://aws.amazon.com/ses/pricing/) lists the rates.
|
|
259
282
|
|
|
260
|
-
|
|
283
|
+
Next, [verify it works](05-verify.md).
|
|
@@ -6,7 +6,7 @@ In this guide, you send yourself a test email and check that it passes authentic
|
|
|
6
6
|
|
|
7
7
|
## Before you begin
|
|
8
8
|
|
|
9
|
-
- Two email addresses you own, such as a personal address and a work address. Some steps use both.
|
|
9
|
+
- Two email addresses you own, such as a personal address and a work address. Some steps use both. While your SES account is in the sandbox, verify both in SES first, as [Connect Amazon SES](04-ses.md#4-request-production-access) describes.
|
|
10
10
|
- A Gmail address, or another mail client that shows a message's original source and an **Unsubscribe** button.
|
|
11
11
|
|
|
12
12
|
## 1. Send yourself a test email
|
|
@@ -31,9 +31,9 @@ Inboxes trust mail that passes DKIM and DMARC for the domain it's from. Passing
|
|
|
31
31
|
- `dmarc=pass` for `send.example.com`
|
|
32
32
|
- `spf=pass`, for a hostname under `send.example.com`
|
|
33
33
|
|
|
34
|
-
With
|
|
34
|
+
With no custom MAIL FROM domain, SPF passes for `amazonses.com` instead. That's expected, and DMARC still passes.
|
|
35
35
|
|
|
36
|
-
If a check fails, compare your DNS records with the ones in [Connect
|
|
36
|
+
If a check fails, compare your DNS records with the ones in [Connect Amazon SES](04-ses.md#1-verify-your-sending-hostname). [Sending-domain DNS](../reference/02-sending-domain-dns.md#verify-alignment) explains each result.
|
|
37
37
|
|
|
38
38
|
## 3. Set up your notifications
|
|
39
39
|
|
|
@@ -69,20 +69,18 @@ Every email carries an unsubscribe link, and a header that lets mail clients sho
|
|
|
69
69
|
|
|
70
70
|
## 6. Check that bounces and complaints come back
|
|
71
71
|
|
|
72
|
-
When an address bounces or marks your mail as spam,
|
|
72
|
+
When an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.
|
|
73
73
|
|
|
74
|
-
1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address
|
|
74
|
+
1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
| Resend | `bounced@resend.dev` | `complained@resend.dev` |
|
|
79
|
-
| Amazon SES | `bounce@simulator.amazonses.com` | `complaint@simulator.amazonses.com` |
|
|
76
|
+
- `bounce@simulator.amazonses.com`, which bounces
|
|
77
|
+
- `complaint@simulator.amazonses.com`, which marks your mail as spam
|
|
80
78
|
|
|
81
79
|
Each one gets a confirmation email, which bounces or is marked as spam.
|
|
82
80
|
|
|
83
81
|
1. In the editor, **Subscribers** lists both addresses as **Pending**, each with a **Suppressed** flag. The app never mails a suppressed address again.
|
|
84
82
|
|
|
85
|
-
If an address has no flag,
|
|
83
|
+
If an address has no flag, SES's events aren't reaching the app. Check the configuration set and the SNS subscription in [Connect Amazon SES](04-ses.md#5-send-bounces-and-complaints-to-sns).
|
|
86
84
|
|
|
87
85
|
## 7. Read the logs
|
|
88
86
|
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
# Go live
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This last step makes the newsletter yours, and tells people where to subscribe.
|
|
4
4
|
|
|
5
|
-
In this guide, you
|
|
5
|
+
In this guide, you check your publication's name, add a logo, and share your subscribe link. You also see what to change as your list grows.
|
|
6
|
+
|
|
7
|
+
## Before you begin
|
|
8
|
+
|
|
9
|
+
- SES production access, granted in the region the app sends from. [Connect Amazon SES](04-ses.md#4-request-production-access) asks for it.
|
|
10
|
+
- `SES_MAX_SEND_RATE` set to the maximum send rate on the SES **Account dashboard**, and deployed. [Connect Amazon SES](04-ses.md#7-set-the-provider-and-deploy) sets it to `1` for the sandbox.
|
|
11
|
+
|
|
12
|
+
In the sandbox, SES refuses the confirmation email to any address you haven't verified, so those signups fail. A post reaches only the addresses you verified in SES.
|
|
6
13
|
|
|
7
14
|
## 1. Name your publication
|
|
8
15
|
|
|
9
|
-
Your publication's name, tagline, and logo appear on the public pages readers see, and in every email.
|
|
16
|
+
Your publication's name, tagline, and logo appear on the public pages readers see, and in every email. You named it in [Connect Amazon SES](04-ses.md#4-request-production-access). Check those, and add a logo before anyone sees your pages.
|
|
10
17
|
|
|
11
18
|
1. In the editor, open **Settings → Publication identity**.
|
|
12
19
|
|
|
13
|
-
1.
|
|
20
|
+
1. Check the name and tagline, and upload a logo.
|
|
14
21
|
|
|
15
22
|
1. **(Optional)** Select **Add a mailing address**. US law asks for a postal address in promotional email, and a P.O. box works. The built-in email template prints it in every email's footer.
|
|
16
23
|
|
|
@@ -28,13 +35,19 @@ Readers subscribe on a page the app hosts. Every signup confirms by email before
|
|
|
28
35
|
|
|
29
36
|
## As your list grows
|
|
30
37
|
|
|
31
|
-
A new instance runs on free
|
|
38
|
+
A new instance runs on Cloudflare's free Workers plan. As your list grows, two limits come into play.
|
|
32
39
|
|
|
33
|
-
- **Your
|
|
34
|
-
- **How fast a send goes out.**
|
|
40
|
+
- **Your SES quota.** AWS caps how many emails your account sends a day. A send that hits the cap waits, and the app emails you. It resumes on its own once the quota frees up, or after AWS raises it.
|
|
41
|
+
- **How fast a send goes out.** A send goes out a slice at a time, one slice a minute. SES takes one request a second, of up to 50 recipients, and no more recipients a second than your account's maximum send rate. So each request carries one second of that rate, up to 50.
|
|
42
|
+
|
|
43
|
+
The free Workers plan sends four requests a minute. At 14 messages a second, a common starting rate, that's 56 recipients a minute, and 1,000 subscribers take about 18 minutes. At 50 a second or more, it's 200 a minute.
|
|
44
|
+
|
|
45
|
+
To send faster, first ask AWS to raise your maximum send rate to 50, which costs nothing, then set [`SES_MAX_SEND_RATE`](../reference/01-configuration.md#ses_max_send_rate) to match, and deploy again. A rate above 50 adds nothing on the free plan, where a rate of 50 sends a list of about 12,000 in an hour. If a send still takes longer than you want, move to the $5 Workers Paid plan, and set [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) to `10000`. It sends about 50 requests a minute: about 700 recipients at 14 a second, and about 2,500 at 50 or more.
|
|
35
46
|
|
|
36
47
|
## Check it
|
|
37
48
|
|
|
49
|
+
1. The SES **Account dashboard**, in the region the app sends from, no longer says your account is in the sandbox.
|
|
50
|
+
|
|
38
51
|
1. `https://newsletter.example.com/subscribe` shows your publication's name and logo.
|
|
39
52
|
|
|
40
53
|
1. `https://newsletter.example.com/` shows your publication's name, and the post you sent yourself in [Verify it works](05-verify.md#4-send-a-real-post-to-yourself).
|
|
@@ -113,6 +113,8 @@ The output ends with your app's hostname.
|
|
|
113
113
|
{"status":"ok","service":"kestrel"}
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
+
If it answers `500` with `{"error":"schema_behind","missing":[…],…}` instead, the release was deployed before its database changes: the public pages say the newsletter is temporarily unavailable and nothing is sent. Apply them as in section 4; the app answers as soon as they're applied, with no new deploy.
|
|
117
|
+
|
|
116
118
|
1. Reload the editor. The foot of the **Docs** tab's contents list shows the new version, which links to its release page.
|
|
117
119
|
|
|
118
120
|
1. Send yourself a test email, as in [Verify it works](../get-started/05-verify.md#1-send-yourself-a-test-email). Don't send a real post to check an upgrade: on a live instance, it goes to your whole list.
|