@kurtbruns/kestrel 1.11.0 → 1.12.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/CHANGELOG.md +52 -1
- package/README.md +6 -7
- package/cli/reset.mjs +2 -0
- package/cli/seed.mjs +2 -0
- package/demo/README.md +4 -2
- package/demo/posts/0-test-email/index.md +13 -0
- package/demo/posts/5-a-quick-note/index.md +2 -0
- package/dist/public/dashboard/app.js +302 -293
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +434 -54
- package/dist/worker/index.js +1413 -307
- package/docs/get-started/04-ses.md +22 -2
- package/docs/get-started/05-verify.md +18 -2
- package/docs/get-started/06-go-live.md +1 -1
- package/docs/guides/01-connect-claude.md +3 -1
- package/docs/publishing/04-write-a-post.md +18 -4
- package/docs/publishing/06-troubleshooting.md +37 -8
- package/docs/publishing/07-working-with-claude.md +3 -2
- package/docs/publishing/11-email-template.md +1 -1
- package/docs/publishing/README.md +1 -1
- package/docs/reference/01-configuration.md +1 -1
- package/migrations/0007_sends_progress_at.sql +12 -0
- package/migrations/0008_notifications_sandbox.sql +35 -0
- package/migrations/0009_post_email_only.sql +12 -0
- package/migrations/0010_unlisted_links.sql +43 -0
- package/migrations/0011_sends_render_warnings.sql +13 -0
- package/package.json +1 -1
- package/template/claude/CLAUDE.md +5 -2
|
@@ -107,7 +107,11 @@ If AWS asks for more detail, reply in the support case. If it denies the request
|
|
|
107
107
|
- Bounces and complaints reach the app through SNS, and it stops mailing those addresses automatically.
|
|
108
108
|
- How many subscribers the newsletter expects, and how often it sends.
|
|
109
109
|
|
|
110
|
-
Your account stays in the sandbox until AWS grants access, so don't go live before then. In the sandbox,
|
|
110
|
+
Your account stays in the sandbox until AWS grants access, so don't go live before then. In the sandbox, SES refuses mail to any address you haven't verified:
|
|
111
|
+
|
|
112
|
+
- A signup from an unverified address gets no confirmation email. The app emails the publisher about it, at most once a day.
|
|
113
|
+
- A post goes to your verified addresses and then waits, with the line "The provider is refusing the account" and SES's words. No reader is marked unsent. Once AWS grants access, the post goes to the rest at its next retry, mailing no one twice.
|
|
114
|
+
- A daily quota refusal in the sandbox (200 emails a day) means the same thing: request production access rather than a higher quota.
|
|
111
115
|
|
|
112
116
|
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 8. The rate decides how fast a send goes out, and [As your list grows](../guides/09-as-your-list-grows.md) says when to ask AWS to raise it.
|
|
113
117
|
|
|
@@ -256,10 +260,26 @@ SNS delivers events only to a subscriber that has confirmed. The app confirms on
|
|
|
256
260
|
|
|
257
261
|
1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.
|
|
258
262
|
|
|
263
|
+
1. Give SNS longer to retry. By default it retries a failed delivery 3 times over about a minute, then discards the event, so a bounce that arrives while the app is briefly down or mid-deploy is lost, and the address gets your next post. Open the topic, choose **Edit**, and expand **Delivery policy (HTTP/S)**. Clear **Use the default delivery policy**, set these, and choose **Save changes**:
|
|
264
|
+
|
|
265
|
+
| Field | Value |
|
|
266
|
+
| --- | --- |
|
|
267
|
+
| Number of retries | `13` |
|
|
268
|
+
| Retries without delay | `0` |
|
|
269
|
+
| Minimum delay | `20` |
|
|
270
|
+
| Minimum delay retries | `2` |
|
|
271
|
+
| Maximum delay | `300` |
|
|
272
|
+
| Maximum delay retries | `6` |
|
|
273
|
+
| Retry-backoff function | Exponential |
|
|
274
|
+
|
|
275
|
+
SNS then keeps trying for most of an hour, the longest it allows. It never retries an event the app refused. The app refuses only an event it can't verify came from your topic, and logs an error when one claims to be from SNS.
|
|
276
|
+
|
|
277
|
+
1. **(Optional)** Keep what SNS still can't deliver. On the subscription, choose **Edit**, and set a **Redrive policy (dead-letter queue)** to an SQS queue of your own. SNS then moves an event there instead of discarding it, where you can read it later. [Amazon SNS dead-letter queues](https://docs.aws.amazon.com/sns/latest/dg/sns-dead-letter-queues.html) covers the queue and its permissions.
|
|
278
|
+
|
|
259
279
|
If it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:
|
|
260
280
|
|
|
261
281
|
- **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.
|
|
262
|
-
- **`SNS_TOPIC_ARN` matches the topic's ARN.**
|
|
282
|
+
- **`SNS_TOPIC_ARN` matches the topic's ARN.** If it doesn't, the app's logs show a `webhook.refused` error naming both.
|
|
263
283
|
- **The deploy in section 8 finished.**
|
|
264
284
|
|
|
265
285
|
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.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Some things only show up with real email: whether inboxes trust your mail, how it looks in a real mail client, and whether bounces and unsubscribes come back to the app. Check them now, while the list is empty and every email goes only to you.
|
|
4
4
|
|
|
5
|
-
In this guide, you send yourself a test email, reply to it, and check that it passes authentication. Then you set up notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs,
|
|
5
|
+
In this guide, you send yourself a test email, reply to it, and check that it passes authentication. Then you set up notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs, check that SES can send to anyone, and clean up the test subscribers.
|
|
6
6
|
|
|
7
7
|
## Before you begin
|
|
8
8
|
|
|
@@ -59,10 +59,14 @@ This is a real send, to a list of one. It proves the whole path: subscribing, co
|
|
|
59
59
|
|
|
60
60
|
1. Open the post, select **Schedule**, and then **Send now**. Every send waits at least five minutes before it goes out, so you can cancel a mistake.
|
|
61
61
|
|
|
62
|
-
1.
|
|
62
|
+
1. Uncheck **Include in archive**. The post still goes to subscribers, but it's sent as email only: it isn't in the public archive or on the home page. That way the test proves the send without becoming the publication's first archived post, and the dashboard keeps its **Finish setting up** steps for the publisher.
|
|
63
|
+
|
|
64
|
+
1. Cancel the send, to see that canceling stops it. Then select **Schedule** and **Send now** again, check that **Include in archive** is still unchecked, and let it go.
|
|
63
65
|
|
|
64
66
|
1. When the send finishes, the post arrives at your second address. A notification arrives at your first, with the subject `Sent:` and the post's title.
|
|
65
67
|
|
|
68
|
+
If you verified this instance before Email only existed, the test post is in the archive. Open it under **Sent**, and select **Remove from archive…**.
|
|
69
|
+
|
|
66
70
|
## 5. Unsubscribe
|
|
67
71
|
|
|
68
72
|
Every email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. Both work in one step, with no login.
|
|
@@ -108,6 +112,18 @@ The app writes a log line for each thing it does, and Cloudflare keeps them for
|
|
|
108
112
|
|
|
109
113
|
If the dashboard still says sandbox, AWS hasn't answered yet. Every other section works in the sandbox, so come back to this one when it does.
|
|
110
114
|
|
|
115
|
+
## 9. Clean up the test subscribers
|
|
116
|
+
|
|
117
|
+
The checks leave test addresses on the list. Erase them, so the publisher starts with only real readers.
|
|
118
|
+
|
|
119
|
+
1. In the editor, open **Subscribers**.
|
|
120
|
+
|
|
121
|
+
1. Erase `bounce@simulator.amazonses.com` and `complaint@simulator.amazonses.com`. Open each one's menu (⋯), and select **Erase**.
|
|
122
|
+
|
|
123
|
+
1. Erase your second address too, if it won't be a reader.
|
|
124
|
+
|
|
125
|
+
Erasing keeps a do-not-contact marker, but the person can subscribe again through the subscribe page. Erasing your own address doesn't lock you out.
|
|
126
|
+
|
|
111
127
|
When every section gives the result it describes, your instance is ready for subscribers.
|
|
112
128
|
|
|
113
129
|
Next, [go live](06-go-live.md).
|
|
@@ -4,7 +4,7 @@ At this point, your instance should be deployed, verified, and out of the SES sa
|
|
|
4
4
|
|
|
5
5
|
## Start publishing
|
|
6
6
|
|
|
7
|
-
The rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md).
|
|
7
|
+
The rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md).
|
|
8
8
|
|
|
9
9
|
If someone else is the publisher, let them sign in first:
|
|
10
10
|
|
|
@@ -86,9 +86,11 @@ To bring subscribers over from another service, ask Claude to import the list, a
|
|
|
86
86
|
1. Ask Claude to call `GET /api/whoami`. The app answers with a program's identity, not yours:
|
|
87
87
|
|
|
88
88
|
```
|
|
89
|
-
{"principal":{"kind":"service"},"auth":{"mode":"access"}}
|
|
89
|
+
{"principal":{"kind":"service","tokenId":"e367c0ffee5eb4.access"},"auth":{"mode":"access"}}
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
+
The `tokenId` is the Client ID Zero Trust shows beside `kestrel-claude`. If it's a different one, Claude Code is using another token.
|
|
93
|
+
|
|
92
94
|
1. Ask Claude to list the posts. It answers with the posts the editor shows.
|
|
93
95
|
|
|
94
96
|
1. Open a draft in the editor, and ask Claude to change a word in it. The editor shows that the draft was changed elsewhere, last edited by **Claude**.
|
|
@@ -13,11 +13,11 @@ A few things to know as you write:
|
|
|
13
13
|
- **A blank line starts a new paragraph.** In an email, a single line break doesn't.
|
|
14
14
|
- **The opening lines** are the preview a mail client shows beside the subject.
|
|
15
15
|
- **Images** go in by paste or drop, and Kestrel inserts a reference by file name, such as ``. Replace the words in brackets with a short description. PNG, JPEG, WebP, or GIF, up to 5 MB each.
|
|
16
|
-
- **
|
|
16
|
+
- **Where it will go** is in **Post settings**, at the top right: whether it's in your archive, and its address, such as `newsletter.example.com/archive/spring-update`, whose slug follows the subject (below).
|
|
17
17
|
|
|
18
18
|
## Test it
|
|
19
19
|
|
|
20
|
-
**Send test email** sends the real email, through the same template and code as the send, to the addresses you name. **Settings → Default test recipients** fills them in for you. Nobody on your list gets anything, and a test's unsubscribe link unsubscribes no one.
|
|
20
|
+
**Send test email** sends the real email, through the same template and code as the send, to the addresses you name. **Settings → Default test recipients** fills them in for you. Nobody on your list gets anything, nothing appears in your archive, and a test's unsubscribe link unsubscribes no one.
|
|
21
21
|
|
|
22
22
|
Mail clients each draw email a little differently, so trust a test over **Preview**, especially for tables, code, and HTML. A test needs an email provider, which [Connect Amazon SES](../get-started/04-ses.md) sets up.
|
|
23
23
|
|
|
@@ -33,9 +33,23 @@ Changing your template, or a publication detail it shows, re-makes the email of
|
|
|
33
33
|
|
|
34
34
|
**Send now →**, under the time picker, is for a post you've already reviewed. It still waits out the window, scheduling the post for the minimum lead from now.
|
|
35
35
|
|
|
36
|
+
### Post settings
|
|
37
|
+
|
|
38
|
+
**Post settings**, at the top right of the editor, holds two choices. Neither is part of the email, so both stay open while the post is scheduled. Nothing changes until you select **Save**.
|
|
39
|
+
|
|
40
|
+
**Include in archive** is checked unless you uncheck it. Unchecked, the post goes to your list as usual but is sent as email only: it stays off your public archive and your home page, and its archive address shows nothing. Use that for a post meant for the inbox: a correction to an earlier email, a short-lived notice, or a test. The editor marks such a post **Email only**.
|
|
41
|
+
|
|
42
|
+
**Archive address** is where the post's page will be in your archive. Only the last part, the slug, is yours to type; it follows the subject until you do. Each post's address is its own: if you type one another post has, or one with spaces or capitals, Kestrel says so and keeps the address as it was. If another post already has the address your subject would give, Kestrel adds a number (`spring-update-2`) and tells you, so you can choose another. The address is fixed once the post starts sending.
|
|
43
|
+
|
|
44
|
+
The Schedule, Send now, and Edit schedule dialogs show the same settings under the time, so you can still change them there. A change made in a dialog applies when you schedule, send, or save, and canceling leaves the post as it was. Once a post is scheduled, its banner names where it's going.
|
|
45
|
+
|
|
46
|
+
Every email links its web version through the post's own **unlisted link**, which never changes. It opens the archive page while the post is in your archive, and the page itself while it's email only. Anyone with the email can open it, but nothing lists it, and search engines are asked not to.
|
|
47
|
+
|
|
48
|
+
A sent post's page under **Sent** shows its address with **Remove from archive…** or **Add to archive…**. Both ask first. Removing a post takes its archive address down, so links people shared stop working, but the address stays the post's: add it back and those links work again. Neither changes what was sent.
|
|
49
|
+
|
|
36
50
|
### Changing your mind
|
|
37
51
|
|
|
38
|
-
**Cancel**, on the post's banner or its card under **Sent**, makes the post a draft again at once. Edit it, test again, and schedule it again. **
|
|
52
|
+
**Cancel**, on the post's banner or its card under **Sent**, makes the post a draft again at once. Edit it, test again, and schedule it again. **Edit schedule** changes the send time, whether the post is in your archive, or both, without unlocking the post, and your tests still count. You can do either until the send time.
|
|
39
53
|
|
|
40
54
|
### Who receives it
|
|
41
55
|
|
|
@@ -50,6 +64,6 @@ So a recipient count shown before the send is a snapshot. An import that adds su
|
|
|
50
64
|
|
|
51
65
|
### When it sends
|
|
52
66
|
|
|
53
|
-
At the send time, the post moves to its send page under **Sent**, where you can watch it go out. From then it can't be edited, canceled, or moved. Its
|
|
67
|
+
At the send time, the post moves to its send page under **Sent**, where you can watch it go out. From then it can't be edited, canceled, or moved. Its page goes up at the same moment, showing exactly the copy that was sent, so every email's "view in browser" link works. Whatever interrupts a send, such as your provider's daily quota running out, it waits and resumes where it stopped. No reader gets the same post twice.
|
|
54
68
|
|
|
55
69
|
If a send needs your attention, the send page and the dashboard say so. [Troubleshooting](06-troubleshooting.md) covers each case.
|
|
@@ -6,17 +6,21 @@ When a send needs you, its page and the dashboard's health block say so in a red
|
|
|
6
6
|
|
|
7
7
|
## A send is waiting or held
|
|
8
8
|
|
|
9
|
-
A problem with your email provider never marks anyone unsent. The send waits where it is, and carries on from there once the problem clears.
|
|
9
|
+
A problem with your email provider never marks anyone unsent. The send waits where it is, and carries on from there once the problem clears. "No one has been marked unsent because of this" means this problem: readers whose own email failed earlier stay unsent.
|
|
10
10
|
|
|
11
|
-
- **"The provider is refusing the account".** Your provider won't take mail from your account, and its own words follow, with the fix
|
|
11
|
+
- **"The provider is refusing the account".** Your provider won't take mail from your account, and its own words follow, with the fix and who makes it. The fix is almost always in your Amazon SES account or Kestrel's deployment, made by whoever runs it. [What SES refuses, and the fix](#what-ses-refuses-and-the-fix), below, lists each one. Any other post that comes due meanwhile waits with it, without asking SES, and you're told once. While a quota frees up a few at a time, or while the SES sandbox takes only your verified addresses, SES takes some readers on each retry and refuses the rest: those it takes are sent, and the line stays, with the time it began, until SES takes everyone it's asked to.
|
|
12
|
+
- **"The provider is refusing this send".** SES refuses this post's email itself, or a batch of it as invalid, rather than your account. Its words follow. Other posts go out as usual. If SES's words name a reader's address, remove that subscriber: the send skips them and carries on at its next retry. If SES refuses the email itself, the email can't change once its send has started, so whoever runs the deployment asks AWS Support why, quoting SES's words.
|
|
12
13
|
- **"No email provider is connected".** The send is held until one is connected ([Connect Amazon SES](../get-started/04-ses.md)), and goes out once it is.
|
|
13
|
-
- **"The provider is unavailable".** An outage or a rate limit on the provider's side. The send retries
|
|
14
|
-
- **"
|
|
14
|
+
- **"The provider is unavailable".** An outage or a rate limit on the provider's side. The send reads **Backing off** and says when it next retries, spacing its retries out up to an hour apart while the outage lasts, so there's nothing to do. Any other post that comes due meanwhile waits with it.
|
|
15
|
+
- **"The provider keeps refusing for its sending rate".** An amber line. SES has refused the first request of several retries in a row for its sending rate, which a short burst never does, so Kestrel is set to send faster than your SES account allows. Whoever runs the deployment sets `SES_MAX_SEND_RATE` to the **Maximum send rate** on the SES console's **Account dashboard** (1 a second in the sandbox), and deploys, as [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy) describes. The send retries on its own meanwhile.
|
|
16
|
+
- **"Nothing has gone out for …".** A send has handed off no one for half an hour, with no retry or provider problem to explain it. The part of Kestrel that sends has stopped or keeps failing on this send, or another send going out at the same time is taking all of its turn. A large send going out slowly never shows this. If another send is going out, this one picks up once it finishes. Read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes, and check the Worker's trigger, as [`triggers.crons`](../reference/01-configuration.md#triggerscrons) describes. The send carries on from where it stopped once it runs again, mailing no one twice.
|
|
15
17
|
- **"The fire time passed … and the send has not started".** The Cloudflare Cron Trigger that starts sends on time isn't running. The send goes out as soon as it runs again. Check the Worker's trigger, as [`triggers.crons`](../reference/01-configuration.md#triggerscrons) describes.
|
|
16
18
|
- **"The provider never answered for …".** Rarely, Kestrel can't tell whether your provider took some emails, and sending again could mail those readers twice. **Resolve…** on the send's page asks you to decide. **Assume not sent** is the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it.
|
|
17
19
|
|
|
18
20
|
## Before a send
|
|
19
21
|
|
|
22
|
+
- **A test says "The provider refused it".** Your provider didn't take the test, and its own words follow, with the fix where Kestrel knows it. Sending again gets the same answer until that's fixed. While Amazon SES is in its sandbox, it sends only to addresses verified in SES, so verify the address you test with in the SES console, or request production access, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Words about the account, such as a key or the sending domain, point at the same fix as [a send the provider refuses](06-troubleshooting.md#a-send-is-waiting-or-held).
|
|
23
|
+
- **A test says "The provider didn't answer, so the test may have arrived".** Kestrel can't tell whether your provider took it. Check the inbox before you send again, since a second test may arrive twice.
|
|
20
24
|
- **"The template or publication details changed this email's look after your last test".** Send a new test of the post, since your last one no longer shows what goes out.
|
|
21
25
|
- **The daily database writes warning.** On Cloudflare's free Workers plan, a large send may use up the day's writes, and then the app stops working until midnight UTC. [Bring your existing list](01-your-list.md#limits) explains the limit, and when to upgrade to Workers Paid.
|
|
22
26
|
|
|
@@ -31,9 +35,33 @@ A send's page becomes its permanent record once it finishes. Its counts cover ev
|
|
|
31
35
|
| **Complained** | The reader marked the email as spam. |
|
|
32
36
|
| **Unsent** | Kestrel couldn't hand the email to your provider. |
|
|
33
37
|
|
|
34
|
-
Delivered always means confirmed, not just accepted by your provider. Confirmations can lag by days, so counts keep rising after a send finishes. Some emails are never confirmed at all, so a send can read **Settling** for good, which is normal.
|
|
38
|
+
Delivered always means confirmed, not just accepted by your provider. Confirmations can lag by days, so counts keep rising after a send finishes. Some emails are never confirmed at all, so a send can read **Settling** for good, which is normal, as long as some receipts have come.
|
|
35
39
|
|
|
36
|
-
**
|
|
40
|
+
**"No delivery receipt has arrived for any of …".** An hour after a send went out, Kestrel has heard back about none of its readers. Your provider's reports probably aren't reaching Kestrel, so its bounces and complaints suppress no one, and your next post mails those addresses again. Look for `webhook.refused` errors in the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes. It names the SNS topic that reached Kestrel and the one it expects. Then check the subscription in [Connect Amazon SES](../get-started/04-ses.md#9-subscribe-the-app-to-the-topic). The line goes as soon as a receipt arrives.
|
|
41
|
+
|
|
42
|
+
**Unsent recipients** are still on your list, and get your next post. A recipient is unsent when their own email kept failing, or their address isn't one SES accepts, with the provider's words on the row, or when you chose **Assume not sent**.
|
|
43
|
+
|
|
44
|
+
### What SES refuses, and the fix
|
|
45
|
+
|
|
46
|
+
When SES refuses your account or a send, the red line carries SES's words and the fix. Almost every fix is made by whoever runs the deployment, in the SES console or the deployment's settings.
|
|
47
|
+
|
|
48
|
+
| SES says | What it means | The fix |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| Email address is not verified, naming a reader | The SES account is still in the sandbox, which mails only verified addresses. | Request production access on the SES console's **Account dashboard**, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Signups fail until then, and you're emailed about it once a day. |
|
|
51
|
+
| Email address is not verified, naming your From address or its domain | The sending identity isn't verified in this SES region. | Verify the From address or its domain in SES, in the region the deployment sends from. |
|
|
52
|
+
| Mail FROM domain is not verified | SES can't read the custom MAIL FROM domain's MX record. | Check that record in DNS, or set the identity's **Behavior on MX failure** to use the default MAIL FROM domain. |
|
|
53
|
+
| Daily message quota exceeded | The 24-hour sending quota is spent. It frees up on its own. | In the sandbox (200 a day), request production access. Otherwise, ask AWS for a higher quota, as [As your list grows](../guides/09-as-your-list-grows.md) describes. |
|
|
54
|
+
| Sending paused (account) | AWS paused the account under review, or sending was turned off by hand. | Follow AWS's email to the AWS account's address, or turn sending back on. |
|
|
55
|
+
| Sending paused (configuration set) | Sending is turned off on the configuration set, by hand or by an alarm. | Turn sending back on for that set. |
|
|
56
|
+
| Account suspended | AWS shut down the account's sending. | Only AWS can lift it: open a case with AWS Support. |
|
|
57
|
+
| An access key, signature, or permission error | The deployment's SES key is wrong, expired, or lacks permission. | Replace the key, or give it `ses:SendEmail` and `ses:SendBulkEmail`. |
|
|
58
|
+
| A subscription is needed (OptInRequired) | The AWS account isn't signed up for SES. | Enable SES for the account, or settle its activation or billing with AWS. |
|
|
59
|
+
| Configuration set does not exist | `SES_CONFIGURATION_SET` names a set that isn't in this region. | Fix the name, or create the set. |
|
|
60
|
+
| Sending pool not found | The configuration set uses a dedicated IP pool that doesn't exist. | Pick an existing pool in the set's sending options, or remove it. |
|
|
61
|
+
| The message was rejected | SES refuses this email itself. | Ask AWS Support why. Other posts go out as usual. |
|
|
62
|
+
| The request is invalid | SES refused a batch of this send. | If SES's words name a reader's address, remove that subscriber. |
|
|
63
|
+
|
|
64
|
+
A server error from SES may come after SES already took the emails, so Kestrel never sends that batch again: its readers wait for **Resolve…**, as for "The provider never answered for …".
|
|
37
65
|
|
|
38
66
|
## Bounces and complaints
|
|
39
67
|
|
|
@@ -65,8 +93,9 @@ Kestrel refuses a file it can't read with certainty, and says what's wrong and w
|
|
|
65
93
|
With an address set, Kestrel emails you:
|
|
66
94
|
|
|
67
95
|
- when a send finishes, with how many emails were accepted, unsent, and skipped
|
|
68
|
-
- when your provider refuses your account, or no provider is connected
|
|
69
|
-
- when
|
|
96
|
+
- when your provider refuses your account or a send, or no provider is connected
|
|
97
|
+
- when the SES sandbox refuses a signup's confirmation, at most once a day
|
|
98
|
+
- when a send stops going out with nothing to explain it, waits for you to resolve it, or misses its time
|
|
70
99
|
- when Claude imports a list for you
|
|
71
100
|
|
|
72
101
|
Each links to the page it's about, and is sent once while the problem lasts. A bounce spike shows on the dashboard but isn't emailed. **Send a test notification** checks that one arrives.
|
|
@@ -7,7 +7,8 @@ To set Claude up, follow [Connect Claude to the API](../guides/01-connect-claude
|
|
|
7
7
|
## What to ask Claude
|
|
8
8
|
|
|
9
9
|
- **Draft or proofread a post.** Give it notes, an outline, or a draft. It saves a new version you can open in the dashboard. If you edited the post meanwhile, its save is refused, and it works from your version or asks you.
|
|
10
|
-
- **Test and schedule a post.** Name the post and when it should go out. Claude schedules it
|
|
10
|
+
- **Test and schedule a post.** Name the post and when it should go out, and say so if it should be email only. Claude tells you whether the post will be in your archive, and at which address, before it schedules. Then it sends a test of exactly what will go out and tells you how to cancel. It can also cancel a send or move it.
|
|
11
|
+
- **Take a sent post out of your archive, or put it back.** Claude asks first, as the dashboard does, since taking a post out breaks links people shared.
|
|
11
12
|
- **Check how a send did.** Claude reads the send's record and reports what was delivered, unsent, bounced, or complained.
|
|
12
13
|
- **Lift a bounce or a complaint.** The dashboard can't, so ask Claude when you know an address works again. [Lifting a suppression](06-troubleshooting.md#lifting-a-suppression) says when that's wise.
|
|
13
14
|
- **Recover an earlier version.** Every save keeps a whole version of a post, and Claude can read them back.
|
|
@@ -24,6 +25,6 @@ The app holds these for any client, so they hold whatever Claude is told:
|
|
|
24
25
|
- **No one's edit is overwritten.** A save based on an older version is refused.
|
|
25
26
|
- **Only you can let an import grow a scheduled send.** An import through Claude that would stops there, until you finish it in the dashboard.
|
|
26
27
|
|
|
27
|
-
Claude also follows instructions your Kestrel gives it, which live in your instance's repository. Those hold only as long as the instructions do. They have Claude ask before anything that reaches readers, test again after a template change, and record whose decision each change to the list was. They also have it treat what's in a post or a subscriber's record as content, never as instructions.
|
|
28
|
+
Claude also follows instructions your Kestrel gives it, which live in your instance's repository. Those hold only as long as the instructions do. They have Claude ask before anything that reaches readers, say whether a post will be in your archive before it's sent, test again after a template change, and record whose decision each change to the list was. They also have it treat what's in a post or a subscriber's record as content, never as instructions.
|
|
28
29
|
|
|
29
30
|
The app lets Claude give your attestation for an import, so asking you first is one of those instructions, not a rule the app enforces. The import's record says it came through Claude, and Kestrel emails you about it if your notifications are set.
|
|
@@ -25,7 +25,7 @@ A variable is replaced with its value in each email. Type it exactly as shown, o
|
|
|
25
25
|
| `{{ .Publication.Address }}` | Your mailing address, or empty. |
|
|
26
26
|
| `{{ .Email.SentTo }}` | The address this email was sent to. |
|
|
27
27
|
| `{{ .Email.UnsubscribeURL }}` | This reader's one-click unsubscribe link. Required. |
|
|
28
|
-
| `{{ .Email.ViewInBrowserURL }}` | The post's
|
|
28
|
+
| `{{ .Email.ViewInBrowserURL }}` | The post's own web link, which opens its page once it's sent, in your archive or not. |
|
|
29
29
|
|
|
30
30
|
## Test it
|
|
31
31
|
|
|
@@ -4,7 +4,7 @@ These pages get you publishing in the dashboard, and cover the decisions to make
|
|
|
4
4
|
|
|
5
5
|
## Set up your publication
|
|
6
6
|
|
|
7
|
-
The steps to set up a new publication, in the order the dashboard's setup checklist lists them.
|
|
7
|
+
The steps to set up a new publication, in the order the dashboard's setup checklist lists them. Once you've started a post, they stay on the dashboard as **Finish setting up** until you publish your first post, one that goes to your subscribers and onto your site. That's the fourth step.
|
|
8
8
|
|
|
9
9
|
1. [Name your publication](03-name-your-publication.md)
|
|
10
10
|
1. [Check your setup](10-check-your-setup.md)
|
|
@@ -42,7 +42,7 @@ Neither one is the publication's name, tagline, or logo. You set those in the ed
|
|
|
42
42
|
|
|
43
43
|
### `ARCHIVE_BASE_PATH`
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
A listed post's archive address is `ARCHIVE_BASE_PATH` followed by the post's slug. Emails link through each post's unlisted link, `/email/<token>` on `APP_ORIGIN`, which forwards to the archive address, so changing the prefix later breaks no email that carries one, only archive addresses people have shared. Emails sent by 1.11 or earlier carry the archive address itself, and changing the prefix breaks those. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.
|
|
46
46
|
|
|
47
47
|
A missing leading slash is added, and a trailing slash is dropped.
|
|
48
48
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- 0007_sends_progress_at: when a send last made progress (SPEC §12).
|
|
2
|
+
--
|
|
3
|
+
-- A send is in flight too long when it has handed off no one for a while and nothing else
|
|
4
|
+
-- explains why, not when it has been sending a while: a large send on a slow provider rate
|
|
5
|
+
-- goes out for hours and is fine. `progress_at` is when the provider last answered for any
|
|
6
|
+
-- of the send's recipients, so the app can tell a send that is moving from one that has
|
|
7
|
+
-- stopped. Null until the first answer, when the send's start stands in for it, so a send
|
|
8
|
+
-- already in flight when this runs reads as it did (src/db/sends.ts, SEND_IDLE_SINCE).
|
|
9
|
+
--
|
|
10
|
+
-- Adding a nullable column writes no rows, so this costs nothing to apply at any size.
|
|
11
|
+
|
|
12
|
+
ALTER TABLE sends ADD COLUMN progress_at INTEGER;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- 0008_notifications_sandbox: tell the publisher when the SES sandbox refuses a signup's
|
|
2
|
+
-- confirmation (SPEC §7, §8).
|
|
3
|
+
--
|
|
4
|
+
-- While the SES account is in the sandbox, SES refuses mail to any address not verified in
|
|
5
|
+
-- it, so a reader who signs up is told a link is on its way and none arrives. The publisher
|
|
6
|
+
-- hears of it through the notifications they already get, as a `sandbox` notice with no send
|
|
7
|
+
-- and no import, at most one a UTC day: its episode is the day's start, and the partial
|
|
8
|
+
-- unique index keeps one row a day however many signups SES refuses. `notifications.kind`'s
|
|
9
|
+
-- CHECK doesn't admit it and a STRICT table's CHECK can't be altered, so the table is rebuilt
|
|
10
|
+
-- with `sandbox` added, every row and column carried over.
|
|
11
|
+
CREATE TABLE notifications_next (
|
|
12
|
+
send_id TEXT REFERENCES sends (id), -- null for a test, an import notice, and a sandbox notice
|
|
13
|
+
import_id TEXT REFERENCES imports (id), -- set only for an import notice
|
|
14
|
+
kind TEXT NOT NULL
|
|
15
|
+
CHECK (kind IN ('finished', 'refused', 'stuck', 'wedged', 'missed', 'test', 'import', 'sandbox')),
|
|
16
|
+
episode INTEGER NOT NULL DEFAULT 0,
|
|
17
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
18
|
+
CHECK (status IN ('pending', 'sent', 'failed', 'unaddressed', 'cleared')),
|
|
19
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
20
|
+
error TEXT,
|
|
21
|
+
created_at INTEGER NOT NULL,
|
|
22
|
+
updated_at INTEGER NOT NULL,
|
|
23
|
+
UNIQUE (send_id, kind, episode),
|
|
24
|
+
UNIQUE (import_id),
|
|
25
|
+
CHECK ((send_id IS NULL) = (kind IN ('test', 'import', 'sandbox'))),
|
|
26
|
+
CHECK ((import_id IS NULL) = (kind <> 'import'))
|
|
27
|
+
) STRICT;
|
|
28
|
+
|
|
29
|
+
INSERT INTO notifications_next (send_id, import_id, kind, episode, status, attempts, error, created_at, updated_at)
|
|
30
|
+
SELECT send_id, import_id, kind, episode, status, attempts, error, created_at, updated_at FROM notifications;
|
|
31
|
+
|
|
32
|
+
DROP TABLE notifications;
|
|
33
|
+
ALTER TABLE notifications_next RENAME TO notifications;
|
|
34
|
+
CREATE INDEX idx_notifications_status ON notifications (status, created_at);
|
|
35
|
+
CREATE UNIQUE INDEX idx_notifications_sandbox_day ON notifications (episode) WHERE kind = 'sandbox';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- 0009_post_email_only: whether a post is listed in the public archive (SPEC §5).
|
|
2
|
+
--
|
|
3
|
+
-- A post is in the archive (0, the default) or email only (1): mailed exactly as a listed
|
|
4
|
+
-- post is, but left out of the archive index and the landing page; 0008 decides what its
|
|
5
|
+
-- archive address answers. The listing belongs to the post, not to a send,
|
|
6
|
+
-- because it is not part of the frozen render: changing it never re-makes a scheduled send's
|
|
7
|
+
-- email or touches a sent one (I3), and it can be changed after the post is sent.
|
|
8
|
+
--
|
|
9
|
+
-- Every existing post reads as in the archive, so an upgrade lists exactly what it listed
|
|
10
|
+
-- before. Adding a column with a constant default rewrites no rows.
|
|
11
|
+
|
|
12
|
+
ALTER TABLE posts ADD COLUMN email_only INTEGER NOT NULL DEFAULT 0 CHECK (email_only IN (0, 1));
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- 0010_unlisted_links: every post gets its own unlisted link, and every send records which
|
|
2
|
+
-- address its email carries (SPEC §5).
|
|
3
|
+
--
|
|
4
|
+
-- Every email's "view in browser" link used to be the post's archive address,
|
|
5
|
+
-- `/archive/<slug>`. That made an email-only post's page guessable from its subject, and tied
|
|
6
|
+
-- every email ever sent to the archive's base path. Now each post has an unlisted link,
|
|
7
|
+
-- `/email/<token>` on the app's own origin, which every test and send carries instead, and the
|
|
8
|
+
-- archive address answers only while the post is listed.
|
|
9
|
+
--
|
|
10
|
+
-- posts.unlisted_token: 128 bits of randomness, made with the post and never changed, written as
|
|
11
|
+
-- 32 hex digits in four groups of eight joined by hyphens, so a reader can keep their place in it.
|
|
12
|
+
-- A column added to an existing table can't take a per-row default, so existing posts are
|
|
13
|
+
-- given theirs here, and a trigger gives one to every post inserted without it, so no way of
|
|
14
|
+
-- inserting a post leaves it without a link. The unique index keeps every link a post's own.
|
|
15
|
+
--
|
|
16
|
+
-- sends.web_link: which address the send's email carries as its "view in browser" link,
|
|
17
|
+
-- `unlisted` from this release on, or `archive` for a send frozen before it. The column defaults
|
|
18
|
+
-- to `archive`, which every existing send takes, and this release's Worker names `unlisted` on
|
|
19
|
+
-- every send it freezes: a send frozen by the previous release's Worker while it still serves,
|
|
20
|
+
-- between this migration and the deploy or after a rollback, carries its archive address, and
|
|
21
|
+
-- the default records it so. A post whose
|
|
22
|
+
-- published send carries `archive` keeps its archive address answering whatever its listing,
|
|
23
|
+
-- since every copy of that email links there. A re-make freezes a new copy, so it moves a
|
|
24
|
+
-- scheduled send to `unlisted`.
|
|
25
|
+
--
|
|
26
|
+
-- Slugs keep the rule they have had since 0001: every post's is unique, drafts included.
|
|
27
|
+
|
|
28
|
+
ALTER TABLE posts ADD COLUMN unlisted_token TEXT;
|
|
29
|
+
UPDATE posts SET unlisted_token = lower(hex(randomblob(16)));
|
|
30
|
+
UPDATE posts SET unlisted_token = substr(unlisted_token, 1, 8) || '-' || substr(unlisted_token, 9, 8)
|
|
31
|
+
|| '-' || substr(unlisted_token, 17, 8) || '-' || substr(unlisted_token, 25, 8);
|
|
32
|
+
CREATE UNIQUE INDEX idx_posts_unlisted_token ON posts (unlisted_token);
|
|
33
|
+
|
|
34
|
+
CREATE TRIGGER posts_unlisted_token AFTER INSERT ON posts
|
|
35
|
+
WHEN NEW.unlisted_token IS NULL
|
|
36
|
+
BEGIN
|
|
37
|
+
UPDATE posts SET unlisted_token = lower(hex(randomblob(16))) WHERE id = NEW.id;
|
|
38
|
+
UPDATE posts SET unlisted_token = substr(unlisted_token, 1, 8) || '-' || substr(unlisted_token, 9, 8)
|
|
39
|
+
|| '-' || substr(unlisted_token, 17, 8) || '-' || substr(unlisted_token, 25, 8) WHERE id = NEW.id;
|
|
40
|
+
END;
|
|
41
|
+
|
|
42
|
+
ALTER TABLE sends ADD COLUMN web_link TEXT NOT NULL DEFAULT 'archive'
|
|
43
|
+
CHECK (web_link IN ('unlisted', 'archive'));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- 0011_sends_render_warnings: what the render said about a send's frozen copy (SPEC §5, §6).
|
|
2
|
+
--
|
|
3
|
+
-- The render answers its email with warnings, such as an image the post references but never
|
|
4
|
+
-- uploaded. A draft's preview and test show them from a live render, but once the post was
|
|
5
|
+
-- scheduled the instruments read the frozen copy and had nothing to say, so a test of a copy
|
|
6
|
+
-- missing its image answered clean. The freeze and the re-make now store the warnings their
|
|
7
|
+
-- render gave, as a JSON array of strings, beside the copy they describe.
|
|
8
|
+
--
|
|
9
|
+
-- A send frozen before this column existed reads as `[]`: its render's warnings were never
|
|
10
|
+
-- kept, and rendering it again could disagree with the bytes it holds. Adding a column with a
|
|
11
|
+
-- constant default rewrites no rows.
|
|
12
|
+
|
|
13
|
+
ALTER TABLE sends ADD COLUMN render_warnings TEXT NOT NULL DEFAULT '[]';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kurtbruns/kestrel",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "An open-source, self-hosted newsletter app where you own the list and the archive. Work in the editor, or just ask Claude. Runs on Cloudflare Workers.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,8 +28,11 @@ Deploying, migrating the production database, and anything that sends mail or ch
|
|
|
28
28
|
- **Read `GET /api/reference` first,** and use it for every call: each route's method, path, body types, and an example.
|
|
29
29
|
- **Save a post against the revision you read.** Send its `current_revision` back as `base_revision`, or as `If-Match` with the post's `ETag`. On `stale_revision`, someone else changed the post (the refusal's `author` says who): read it again and re-apply your change to the newer revision, or ask. Never overwrite their edit.
|
|
30
30
|
- **Act on a send from its `actions`,** which list only what the server would accept on it now. An act is safe to repeat, and every answer and every refusal carries the send as it now stands: read that before telling the publisher what happened or deciding again.
|
|
31
|
-
- **Schedule only when the publisher asks, then send a test.** Once a post is scheduled, a test sends the frozen copy, exactly what will fire, to one address per call: one to each of `testRecipients` from `GET /api/settings`, or ask. Tell them when the send fires and how to cancel it.
|
|
32
|
-
- **
|
|
31
|
+
- **Schedule only when the publisher asks, then send a test.** Once a post is scheduled, a test sends the frozen copy, exactly what will fire, to one address per call: one to each of `testRecipients` from `GET /api/settings`, or ask. Tell them when the send fires, where the post goes, and how to cancel it.
|
|
32
|
+
- **Re-make a scheduled send only on the publisher's word.** Saving a changed template, or an identity field the template renders, re-makes the email of every scheduled send, and is refused with 409 `remake_required` naming them in `sends`. Don't send their ids back in `remake` on your own: tell the publisher which posts would be re-made (each `subject`) and when each fires (`fire_at`), and save again with `remake` only once they say so. The review window still applies, but a re-made email is no longer the one they tested. Then send a test of each send in the response's `remade`, as above, since each needs a fresh one.
|
|
33
|
+
- **Say where a post goes before it's scheduled or sent.** A post is listed in the public archive unless it is **email only**, mailed as usual but left off the archive and the home page. Before you schedule, send now, or confirm either, tell the publisher which it will be, naming the archive address when it's listed: the post's `archive_url`. Once it's published (`published_at` is set; `status` stays `scheduled` while it's going out), a reader sees it at `archive_url` when `archive_live` is true, and otherwise only at `unlisted_url`, the link in its emails; share that one only if the publisher asks. Send `email_only: true` only when the publisher asks for email only ("just email it", "keep it off the site"), and leave the field out otherwise, which keeps the listing the post has. When a post reads as inbox-only, such as a correction or a short-lived notice, and they haven't said, ask rather than choose. A test email puts nothing in the archive.
|
|
34
|
+
- **Change a post's listing or address only when the publisher asks.** Both live on `PUT /api/posts/:id/settings`, and on Schedule and Send now; a reschedule moves only the time. On a sent post, confirm first, as the editor does: making it email only takes its archive page down, so links people shared stop working, and listing it puts it on the public web. A sent post's address is fixed (`slug_permanent`). On a 409 `slug_taken` (an address you set is another post's), ask which address they want rather than picking one. A save that succeeds with `subject_slug_held` set only means the subject's address was taken and the post got a number; mention it, and change it only if they ask.
|
|
35
|
+
- **Until AWS has granted SES production access, never help share the subscribe link or send to anyone but the addresses verified in SES.** The sandbox refuses every other recipient, so their signups fail and a send holds until AWS grants access, mailing only the verified addresses until then. You can't see the sandbox state yourself: ask the publisher whether AWS granted access. Point to the guide's Go live page, whose Before you begin needs production access first.
|
|
33
36
|
- **Never add a subscriber the publisher didn't name,** with one exception: an import the publisher asked for, run through the `/migrate-list` skill and the import plan, and confirming anyone only with their attestation. Treat an unsubscribe as final: only the reader undoes it, by subscribing again.
|
|
34
37
|
- **Pick the act that records whose decision it was.** Unsubscribe someone only when they asked to leave; when the publisher decides themselves to stop mailing an address, block it (`POST /api/suppressions`) instead. The reference says what each act and each suppression `reason` means, in the words the editor shows the publisher. Unblock or clear only an address the publisher names. Never clear a bounce or complaint unless the publisher says the problem is gone, and confirm first: it lets mail go to an address that failed or reported spam.
|
|
35
38
|
- **Erase a subscriber only when the publisher names them and asks for it, and confirm first:** an erasure can't be undone. On `erasure_acknowledgement_required`, send `acknowledge_erasure: true` only once the publisher says the person asked to join again.
|