@kurtbruns/kestrel 1.7.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/docs/README.md CHANGED
@@ -8,7 +8,7 @@ Follow these steps, from an empty repository to a live newsletter. Each one ends
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 an email provider](get-started/04-resend.md)
11
+ 1. [Connect Amazon SES](get-started/04-ses.md)
12
12
  1. [Verify it works](get-started/05-verify.md)
13
13
  1. [Go live](get-started/06-go-live.md)
14
14
 
@@ -18,7 +18,6 @@ Each one stands on its own. Pick the ones you need.
18
18
 
19
19
  - [Connect Claude to the API](guides/01-connect-claude.md)
20
20
  - [Try Kestrel locally first](guides/08-try-locally.md)
21
- - [Use Amazon SES instead of Resend](guides/02-ses.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)
@@ -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
- - A provider for sending email. [Resend](https://resend.com/signup) is a good default, and the one these guides walk through.
12
+ - An [AWS account](https://portal.aws.amazon.com/billing/signup), for Amazon SES, which sends your email.
13
13
 
14
14
  ## Choose your hostnames
15
15
 
@@ -78,25 +78,33 @@ Every command from here on acts on your Cloudflare account through the Wrangler
78
78
 
79
79
  ## 3. Create the database and the image bucket
80
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
+
81
83
  1. Create the database:
82
84
 
83
85
  ```bash
84
- npx wrangler d1 create kestrel-production
86
+ npx wrangler d1 create kestrel-production --env production --binding DB --update-config
85
87
  ```
86
88
 
87
- It prints the new database's settings, including a `database_id`. Copy the id for the next section. If Wrangler offers to add the database to your configuration for you, decline: you add it to the production settings yourself, below.
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.
88
90
 
89
91
  1. Create the bucket for images:
90
92
 
91
93
  ```bash
92
- npx wrangler r2 bucket create kestrel-media-production
94
+ npx wrangler r2 bucket create kestrel-media-production --env production --binding MEDIA --update-config
93
95
  ```
94
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
+
95
103
  ## 4. Configure production
96
104
 
97
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.
98
106
 
99
- 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:
100
108
 
101
109
  ```jsonc
102
110
  "production": {
@@ -105,11 +113,12 @@ Every command from here on acts on your Cloudflare account through the Wrangler
105
113
  {
106
114
  "binding": "DB",
107
115
  "database_name": "kestrel-production",
108
- "database_id": "REPLACE_WITH_PRODUCTION_D1_ID", // ← the id from section 3
116
+ "database_id": "…", // already filled in by section 3; leave it
109
117
  "migrations_dir": "migrations"
110
118
  }
111
119
  ],
112
120
  "vars": {
121
+ "PROVIDER": "", // ← stays empty until step 3
113
122
  "APP_ORIGIN": "https://newsletter.example.com", // ← your app's hostname
114
123
  "ARCHIVE_BASE_PATH": "/archive",
115
124
  "SENDING_DOMAIN": "send.example.com", // ← your sending hostname
@@ -121,7 +130,7 @@ Every command from here on acts on your Cloudflare account through the Wrangler
121
130
  What each one does:
122
131
 
123
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.
124
- - **No `PROVIDER` yet.** You add it in step 3, [Connect an email provider](04-resend.md). Until then the app runs without an email provider: you can write and preview posts, but scheduling, test emails, and signups are refused.
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.
125
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.
126
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 `@`.
127
136
 
@@ -135,6 +144,8 @@ Every command from here on acts on your Cloudflare account through the Wrangler
135
144
  npm run typecheck
136
145
  ```
137
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
+
138
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:
139
150
 
140
151
  ```bash
@@ -182,8 +193,12 @@ This deploys the release your instance runs, with your settings. The output ends
182
193
  401
183
194
  ```
184
195
 
185
- 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 an email provider.
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.
186
199
 
187
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.
188
201
 
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.
203
+
189
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 it without `https://`:
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
@@ -105,4 +105,4 @@ The app verifies each login against your team domain, and checks that it was iss
105
105
 
106
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 an email provider](04-resend.md).
108
+ Next, [connect Amazon SES](04-ses.md).
@@ -1,33 +1,22 @@
1
- # Use Amazon SES instead of Resend
1
+ # Connect Amazon SES
2
2
 
3
- Amazon SES costs less than Resend for a large list, but takes more setup. AWS reviews your account before it lets you send to anyone but yourself. Kestrel also needs Cloudflare's Workers Paid plan to send through SES at a useful pace.
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, and route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, switch it to SES, and deploy. Follow it in place of [Connect an email provider](../get-started/04-resend.md).
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
- - **Workers Free:** about 18 recipients a minute. A send to 1,000 subscribers takes about an hour, so use Resend on this plan.
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. Kestrel confirms every signup by email, and stops mailing an address that bounces or complains.
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
- 1. While you wait, verify the two addresses you test with in [Verify it works](../get-started/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.
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
- 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.
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-production`, and choose **Create set**.
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 **Deliveries**, and choose **Next**.
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-events`, and keep **Event publishing** enabled. Choose **Next**.
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-production`. Choose **Next**, then **Add destination**.
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-production`, and save.
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-production`.
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-production"
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, your 12-digit account ID, and the region. Choose **Save changes**.
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-production`, and leave console access off.
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**, and create a policy with this JSON:
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
- Name the policy `kestrel-send-email`, attach it to the user, and create the user.
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. Switch the provider and deploy
158
+ ## 7. Set the provider and deploy
153
159
 
154
- Store every secret before you switch. With `PROVIDER` set to `ses` and a secret missing, every request answers `500` and names the missing secret.
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-production`. The app names it on every email, so SES reports on each one even without the default:
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`, in the `production` block's `vars`, set these values, adding `PROVIDER` if it isn't there yet:
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
- "PROVIDER": "ses",
184
- "AWS_REGION": "us-east-1", // ← your SES region
185
- "SES_MAX_SEND_RATE": "1", // ← your maximum send rate; 1 in the sandbox
186
- "SUBREQUEST_BUDGET": "10000",
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 app refused the request. Check that `SNS_TOPIC_ARN` matches the topic's ARN, and that the deploy in section 7 finished. Then delete the subscription, and create it again.
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 as the email provider, and your From address.
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
- Next, [verify it works](../get-started/05-verify.md).
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
- ## Switch an instance that already sends
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
- When a request to a provider gets no answer, Kestrel can't know whether those recipients were mailed. Resend accepts the same request again without mailing anyone twice, because Kestrel sends it under the same key. A different provider, or another account, has never seen that key, so it delivers the post again. That's at most the recipients of the one unanswered request, but each of them gets the post twice.
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
- To have another provider finish a halted send, accept that risk. Otherwise, fix the old account, and let the send resume there.
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 Amazon SES and no custom MAIL FROM domain, SPF passes for `amazonses.com` instead. That's expected, and DMARC still passes.
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 an email provider](04-resend.md#1-verify-your-sending-hostname). [Sending-domain DNS](../reference/02-sending-domain-dns.md#verify-alignment) explains each result.
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, your provider tells the app through the webhook. The app then stops mailing that address. Your provider has test addresses that act out each case without reaching a real person.
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 for your provider:
74
+ 1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:
75
75
 
76
- | Provider | Bounces | Marks as spam |
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, the webhook isn't reaching the app. Check its URL and signing secret in [Connect an email provider](04-resend.md#4-add-the-webhook).
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
 
@@ -2,15 +2,22 @@
2
2
 
3
3
  This last step makes the newsletter yours, and tells people where to subscribe.
4
4
 
5
- In this guide, you name your publication and share your subscribe link. You also see what to change as your list grows.
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. Set them before anyone sees those pages. Until you do, the name in your From address stands in.
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. Enter the name and tagline, and upload a logo.
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 plans. As your list grows, two limits come into play.
38
+ A new instance runs on Cloudflare's free Workers plan. As your list grows, two limits come into play.
32
39
 
33
- - **Your email provider's limits.** Resend's free plan sends up to 100 emails a day. A send that hits the limit waits, and the app emails you. It resumes on its own once the limit lifts, or after you upgrade your Resend plan. For a large list, [Amazon SES](../guides/02-ses.md) may cost less.
34
- - **How fast a send goes out.** On Cloudflare's free Workers plan, Resend sends about 400 recipients a minute. On the $5 Workers Paid plan, set [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) to send far more each minute.
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.
@@ -14,24 +14,25 @@ None of these can be read or changed from inside the app, by the editor or by Cl
14
14
 
15
15
  | Var | What it is | Default |
16
16
  | --- | --- | --- |
17
- | `PROVIDER` | the email provider: `resend` or `ses`, or `fake` for local development | none: no provider connected |
17
+ | `PROVIDER` | the email provider: `ses`, or `fake` for local development | none: no provider connected |
18
18
  | `APP_ORIGIN` | the origin the app is served from: scheme and host, no path | required |
19
19
  | `ARCHIVE_BASE_PATH` | the path the archive lives under | `/archive` |
20
20
  | `FROM_ADDRESS` | the `From:` of every email | required with a real provider |
21
21
  | `SENDING_DOMAIN` | the sending domain, shown in Settings | none |
22
- | `AWS_REGION` | the SES region; Resend ignores it | `us-east-1` in the template, required with SES |
22
+ | `AWS_REGION` | the SES region | `us-east-1` in the template, required with SES |
23
23
  | `SES_MAX_SEND_RATE` | your SES account's maximum send rate, in messages a second | `14` |
24
24
  | `SUBREQUEST_BUDGET` | how many subrequests one minute's send work may use | `50` |
25
25
  | `MIN_LEAD_SECONDS` | the least time every send stays cancelable before it goes out | `300` |
26
26
  | `ARCHIVE_ORIGIN` | the origin archive links use, to put the archive on your website | `APP_ORIGIN` |
27
27
  | `MEDIA_PUBLIC_BASE` | the base URL images are served from | `APP_ORIGIN` + `/media` |
28
28
  | `NOTIFY_FROM` | the sender of notifications through Cloudflare's email | `Kestrel <kestrel@` + the app's hostname + `>` |
29
+ | `ALLOW_FAKE_PROVIDER` | `true` lets `fake` run on a real hostname, for a demo that mails no one on purpose | unset |
29
30
 
30
31
  ### `PROVIDER`
31
32
 
32
- Until it's set, the app runs with no provider connected. You can write and preview posts, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. [Connect an email provider](../get-started/04-resend.md) sets it.
33
+ Until it's set, or while it's empty, the app runs with no provider connected. You can write and preview posts, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, `"PROVIDER": ""`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.
33
34
 
34
- `fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never set it on a deployment that has readers: every subscriber would be recorded as sent the post, and never get it.
35
+ `fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A hosted demo that runs `fake` on a real hostname on purpose sets `ALLOW_FAKE_PROVIDER` to `true`, which no other deployment needs, and which no other provider reads.
35
36
 
36
37
  ### `FROM_ADDRESS` and `SENDING_DOMAIN`
37
38
 
@@ -47,7 +48,7 @@ A missing leading slash is added, and a trailing slash is dropped.
47
48
 
48
49
  ### `SES_MAX_SEND_RATE`
49
50
 
50
- The app starts SES requests no faster than this rate. Set it to the maximum send rate on your SES **Account dashboard**. That's `1` in the sandbox, and whatever AWS grants after that. The default, 14, is a common starting rate, not your account's.
51
+ The app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. That's `1` in the sandbox, and whatever AWS grants after that. The default, 14, is a common starting rate, not your account's.
51
52
 
52
53
  A rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.
53
54
 
@@ -57,8 +58,8 @@ Cloudflare caps how many subrequests one run of a Worker may make. Database quer
57
58
 
58
59
  This setting is the subrequest cap. The app keeps database queries under theirs on its own.
59
60
 
60
- - **The default, 50,** is the Workers Free limit, and safe on any plan. Through Resend, a minute sends about 400 recipients. Through SES, which takes one recipient a request, it sends about 18.
61
- - **On Workers Paid,** which allows 10,000, set `"SUBREQUEST_BUDGET": "10000"`. Resend then sends about 19,000 recipients a minute. SES sends as fast as your account's rate allows, up to about 1,900.
61
+ - **The default, 50,** is the Workers Free limit, and safe on any plan. A minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.
62
+ - **On Workers Paid,** which allows 10,000, set `"SUBREQUEST_BUDGET": "10000"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.
62
63
  - **If you raised the Worker's own limit** with `limits.subrequests`, you may set this to match.
63
64
 
64
65
  Never set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.
@@ -85,11 +86,9 @@ Used only with the `NOTIFY` binding. It must be an address on a domain set up fo
85
86
 
86
87
  | Secret | What it is | Needed for |
87
88
  | --- | --- | --- |
88
- | `ACCESS_TEAM_DOMAIN` | your Zero Trust team domain, such as `your-team.cloudflareaccess.com` | every deployment |
89
+ | `ACCESS_TEAM_DOMAIN` | your Zero Trust team domain, such as `your-team.cloudflareaccess.com`; an `https://` or trailing slash is dropped | every deployment |
89
90
  | `ACCESS_AUD` | the Access application's audience tag | every deployment |
90
91
  | `ACCESS_ALLOWED_EMAILS` | a comma-separated list of the people allowed to sign in | optional |
91
- | `RESEND_API_KEY` | the Resend API key | Resend |
92
- | `RESEND_WEBHOOK_SECRET` | the Resend webhook's signing secret | Resend |
93
92
  | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | the IAM user's access key | SES |
94
93
  | `SNS_TOPIC_ARN` | the SNS topic the SES webhook accepts; it refuses every other topic | SES |
95
94
  | `SES_CONFIGURATION_SET` | the configuration set named on every email, so SES reports bounces and complaints | SES, recommended |
@@ -133,17 +132,18 @@ When declared, notifications go through Cloudflare's email instead of your provi
133
132
 
134
133
  The app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly:
135
134
 
136
- - `PROVIDER`, when set, must be exactly `resend`, `ses`, `fake`, or `none`. A misspelling, or `SES`, is refused, never taken for another.
135
+ - `PROVIDER`, when set, must be exactly `ses`, `fake`, or `none`. A misspelling, or `SES`, is refused, never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).
136
+ - `fake` runs only when `APP_ORIGIN` is on `localhost` or `127.0.0.1`, unless `ALLOW_FAKE_PROVIDER` is `true`. On a real hostname it would open signups whose confirmations go nowhere, and record every send as delivered. With `fake`, `ALLOW_FAKE_PROVIDER`, when set, must be `true`; every other provider ignores it.
137
137
  - `APP_ORIGIN` must be a plain `http` or `https` origin, with no path. So must `ARCHIVE_ORIGIN`, when set. `MEDIA_PUBLIC_BASE`, when set, must be a plain `http` or `https` URL, and may have a path. A trailing slash is dropped from each.
138
- - A real provider needs `FROM_ADDRESS`, and every secret the table marks for it. SES also needs `AWS_REGION`.
138
+ - A real provider needs `FROM_ADDRESS`, and every secret the table marks for it. SES also needs `AWS_REGION`. `FROM_ADDRESS` must then be an email address, optionally with a display name in front, like `Name <address>`.
139
139
  - None of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com` or a name under it, the template's placeholder. That holds with no provider connected too, since every link the app builds would name it. Only `fake` allows it.
140
140
  - `SUBREQUEST_BUDGET`, `MIN_LEAD_SECONDS`, and `SES_MAX_SEND_RATE` must be whole numbers above zero. `MIN_LEAD_SECONDS` must also be within its bounds. Any other value is refused, never adjusted. The one exception is a `SUBREQUEST_BUDGET` below 30, which is raised to 30.
141
- - In local development, `SIMULATE_SENDS` must be `off`, or the provider a local send models, `resend`, `ses`, or `1` for a generic one, optionally followed by `:none` for no injected failures. A deployed instance ignores it.
142
- - `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are set together or not at all. With only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one.
141
+ - In local development, `SIMULATE_SENDS` must be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn't matter. A deployed instance ignores it.
142
+ - `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are set together or not at all. With only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one. `ACCESS_TEAM_DOMAIN` must be your team's hostname, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in.
143
143
 
144
144
  Until it's fixed, no sends go out, and each place says what its reader can act on:
145
145
 
146
146
  - **The editor** shows a page naming the setting, what's wrong with it, and how to fix it, with a link to its section here. Its own Docs tab can't load until then, so the link opens the project's site.
147
- - **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{"error":"invalid_config","variable":"APP_ORIGIN",…}`.
147
+ - **The API**, `/health`, and the webhooks answer `500` with a body naming the variable, such as `{"error":"invalid_config","message":…,"variable":"APP_ORIGIN"}`.
148
148
  - **The public pages readers see** answer `503` with a page saying the newsletter is temporarily unavailable. It names no setting.
149
149
  - **The Worker's log** records it as `config.invalid`.