@kurtbruns/kestrel 1.18.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dev.vars.example +16 -4
- package/CHANGELOG.md +76 -1
- package/dist/public/dashboard/app.js +289 -290
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +127 -21
- package/dist/worker/index.js +4135 -2973
- package/docs/get-started/02-deploy.md +2 -2
- package/docs/get-started/03-access.md +17 -5
- package/docs/get-started/04-aws-account.md +2 -0
- package/docs/get-started/05-ses.md +49 -28
- package/docs/get-started/06-production-access.md +6 -3
- package/docs/get-started/07-verify.md +9 -7
- package/docs/get-started/08-go-live.md +12 -16
- package/docs/guides/01-allow-claude.md +7 -2
- package/docs/guides/03-notifications.md +10 -10
- package/docs/publishing/04-write-a-post.md +2 -2
- package/docs/publishing/06-troubleshooting.md +70 -21
- package/docs/publishing/08-connect-claude.md +1 -1
- package/docs/publishing/09-share-your-subscribe-link.md +3 -1
- package/docs/publishing/10-check-your-setup.md +13 -9
- package/docs/publishing/11-email-template.md +1 -1
- package/docs/publishing/README.md +8 -3
- package/docs/reference/01-configuration.md +5 -5
- package/migrations/0016_held_receipts.sql +26 -0
- package/migrations/0017_provider_account_rejected.sql +24 -0
- package/migrations/0018_notify_addresses.sql +13 -0
- package/migrations/0019_sending.sql +67 -0
- package/migrations/0020_notifications_not_sending.sql +40 -0
- package/migrations/0021_notifications_outage_receipts.sql +46 -0
- package/package.json +1 -1
- package/packages/kestrel/lib/api.mjs +28 -3
- package/packages/kestrel/lib/oauth.mjs +5 -3
- package/template/claude/CLAUDE.md +4 -2
- package/template/wrangler.jsonc +5 -5
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
# Troubleshooting
|
|
2
2
|
|
|
3
|
-
When something needs you, Kestrel says so in a line on the send's page, under **Sent**. The top of the dashboard shows it too. Red needs you to act, and amber is worth a look. The line says what happened
|
|
4
|
-
|
|
5
|
-
**Set a notifications address, so a problem reaches you without your watching.** Set it under **Settings → Notifications**, in **Send notifications to**. With none set, Kestrel emails you nothing. [Notifications](
|
|
3
|
+
When something needs you, Kestrel says so in a line on the send's page, under **Sent**. The top of the dashboard shows it too. Red needs you to act, and amber is worth a look. The line says what happened and what to do. This page covers what the screen can't: the fixes made outside Kestrel, and the decisions that are yours.
|
|
4
|
+
|
|
5
|
+
**Set a notifications address, so a problem reaches you without your watching.** Set it under **Settings → Notifications**, in **Send notifications to**: type an address and choose **Add inbox**. Add the address of whoever runs your instance too, and both of you are told. With none set, Kestrel emails you nothing. [Notifications](#notifications), below, lists what you're told.
|
|
6
|
+
|
|
7
|
+
| Symptom | Where the fix is |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| [A send is held or waiting](#a-send-is-held-or-waiting) | Usually outside Kestrel |
|
|
10
|
+
| [Someone was mailed twice, or not at all](#someone-was-mailed-twice-or-not-at-all) | In Kestrel |
|
|
11
|
+
| [A reader says they never got a post](#a-reader-says-they-never-got-a-post) | In Kestrel |
|
|
12
|
+
| [A send still reads Settling](#a-send-still-reads-settling) | Nothing to fix, unless no receipts come at all |
|
|
13
|
+
| [A test doesn't arrive](#a-test-doesnt-arrive) | Outside Kestrel |
|
|
14
|
+
| [Signups aren't open yet, or are paused](#signups-arent-open-yet-or-are-paused) | In Kestrel, or outside it |
|
|
15
|
+
| [The dashboard names a problem with your Amazon SES account](#a-problem-with-amazon-ses) | Outside Kestrel |
|
|
16
|
+
| [An address bounced or complained](#an-address-bounced-or-complained) | Nothing to fix, unless you lift a suppression |
|
|
17
|
+
| [A warning says to move to Workers Paid](../guides/09-as-your-list-grows.md) | Outside Kestrel |
|
|
18
|
+
| [An import is refused](#an-import-is-refused) | In your file |
|
|
19
|
+
| [Claude can't connect](#claude-cant-connect) | Usually outside Kestrel |
|
|
6
20
|
|
|
7
21
|
## A send is held or waiting
|
|
8
22
|
|
|
9
|
-
Nothing is lost. A problem with your email provider never marks anyone unsent or mails anyone twice. The send waits where it is, and carries on once the problem is fixed. The send's line names the fix. When that fix is outside Kestrel,
|
|
23
|
+
Nothing is lost. A problem with your email provider never marks anyone unsent or mails anyone twice. The send waits where it is, and carries on once the problem is fixed. The send's line names the fix. When that fix is outside Kestrel, it's one of these:
|
|
10
24
|
|
|
11
25
|
- **Amazon SES refuses your account or the send.** The line carries SES's words and the fix, almost always in the SES console. [Connect Amazon SES](../get-started/05-ses.md) sets up each piece SES checks: the sending domain, the AWS key, and the configuration set. [Request production access](../get-started/06-production-access.md) lifts the sandbox. When SES refuses your account, any other post that comes due meanwhile waits with it. When it refuses only this send's email, other posts go out as usual. While SES takes some readers and refuses the rest, as while a daily quota frees up, those it takes are sent. The line stays until SES takes everyone.
|
|
12
26
|
- **SES keeps refusing for its sending rate.** Kestrel is sending faster than your SES account allows. **Settings → Email sender** shows the **Send rate** and where it comes from. If `SES_MAX_SEND_RATE` sets it, lower it to the maximum send rate on the SES console's account dashboard, or remove it, then deploy, as [Configuration](../reference/01-configuration.md#vars) describes. If it's Kestrel's default, the app can't read your account: allow its key `ses:GetAccount`, as [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) shows.
|
|
13
27
|
- **No email provider is connected.** Connect one, as [Connect Amazon SES](../get-started/05-ses.md) describes, and the send goes out at its next retry.
|
|
14
28
|
- **A send missed its time, or nothing has gone out for a while.** The part of Kestrel that sends has stopped, or keeps failing on this send. Check the Worker's Cron Trigger, as [Configuration](../reference/01-configuration.md#bindings-and-triggers) describes, and read the app's logs, as [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes. If another send is going out at the same time, this one may only be waiting its turn.
|
|
15
29
|
|
|
30
|
+
If someone else set up Kestrel for you, pass this along to them.
|
|
31
|
+
|
|
16
32
|
## Someone was mailed twice, or not at all
|
|
17
33
|
|
|
18
34
|
Kestrel never mails a reader twice on its own. When it can't tell whether your provider took some emails, it stops and asks you: the send's page shows **Resolve…**. Choose **Assume not sent** unless you know otherwise, since it's the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it, such as from a reader who tells you so. Either choice mails no one.
|
|
@@ -27,17 +43,38 @@ Under **Subscribers**, search for their address and open their row. Their page l
|
|
|
27
43
|
|
|
28
44
|
That's normal. **Delivered** counts only the emails your provider confirmed reached the reader's mail server. Confirmations can lag by days, so the counts keep rising after a send finishes. Some are never confirmed at all, so a send can read **Settling** for good, as long as some receipts have come.
|
|
29
45
|
|
|
30
|
-
When none come, the send's page says so an hour after it went out. Your provider's reports aren't reaching Kestrel, so its bounces suppress no one and its complaints unsubscribe no one. Your next post mails those addresses again. Look for `webhook.refused` errors in the app's logs, as [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes. Each names the SNS topic that reached Kestrel and the one it expects. Then check the subscription, as [Connect Amazon SES](../get-started/05-ses.md#7-subscribe-the-app-to-the-topic) describes.
|
|
46
|
+
When none come, the send's page says so an hour after it went out. Your provider's reports aren't reaching Kestrel, so its bounces suppress no one and its complaints unsubscribe no one. Your next post mails those addresses again. Look for `webhook.refused` errors in the app's logs, as [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes. Each names the SNS topic that reached Kestrel and the one it expects. Then check the subscription, as [Connect Amazon SES](../get-started/05-ses.md#7-subscribe-the-app-to-the-topic) describes. If someone else set up Kestrel for you, pass this along to them.
|
|
31
47
|
|
|
32
48
|
## A test doesn't arrive
|
|
33
49
|
|
|
34
|
-
While Amazon SES is in its sandbox, it mails only addresses verified in SES. Verify the address you test with in the SES console, or [request production access](../get-started/06-production-access.md). When the test's line says the provider didn't answer, check the inbox before you send it again, since the first may have arrived. A test SES refuses for your account has the same fix as [a send it refuses](
|
|
50
|
+
While Amazon SES is in its sandbox, it mails only addresses verified in SES. Verify the address you test with in the SES console, or [request production access](../get-started/06-production-access.md). When the test's line says the provider didn't answer, check the inbox before you send it again, since the first may have arrived. A test SES refuses for your account has the same fix as [a send it refuses](#a-send-is-held-or-waiting). If someone else set up Kestrel for you, pass this along to them.
|
|
51
|
+
|
|
52
|
+
## Signups aren't open yet, or are paused
|
|
53
|
+
|
|
54
|
+
Your subscribe page takes signups only while email is working, so a reader is never told a link is coming when none can go. Every visitor gets the same page, whatever address they enter.
|
|
55
|
+
|
|
56
|
+
- "Signups aren’t open yet" means SES hasn't sent an email for your publication yet. Send one from **Template → Send test email**, to an address verified in SES while it's in the sandbox. Once SES takes it, signups open. Before an email provider is connected, the page says the same, and [Connect Amazon SES](../get-started/05-ses.md) is the fix.
|
|
57
|
+
- "Signups are paused" means SES is refusing your email, every email including confirmations and tests. The dashboard's **Email isn’t going out** line gives SES's words and the fix, as for [a problem with Amazon SES](#a-problem-with-amazon-ses). Then send a test email: once SES takes one, signups reopen.
|
|
58
|
+
|
|
59
|
+
Kestrel notices a refusal when an email meets it, or within about ten minutes from its check of your SES account. A refusal the check found lifts on its own within about ten minutes of the fix; one an email met lifts when a test email goes through. A reader who signed up just as it started may have been told a link was coming, and can sign up again once signups reopen.
|
|
35
60
|
|
|
36
|
-
|
|
61
|
+
If someone else set up Kestrel for you, pass this along to them.
|
|
37
62
|
|
|
38
|
-
|
|
63
|
+
## A problem with Amazon SES
|
|
39
64
|
|
|
40
|
-
|
|
65
|
+
**Settings → Email sender** and the top of the dashboard name a problem with your Amazon SES account within about ten minutes, without sending anything. Red means no email can go out until it's fixed, tests and confirmations included. Amber is worth a look. Each line links the step that fixes it:
|
|
66
|
+
|
|
67
|
+
- **The configuration set is missing, or its sending is off.** The set Kestrel sends with, the one `SES_CONFIGURATION_SET` names or the sending identity's default if that secret isn't set, isn't in your region, or its sending is turned off. Correct the name, or create the set, as [Connect Amazon SES](../get-started/05-ses.md#4-send-bounces-and-complaints-to-the-app) shows. Turn a set's sending back on in the SES console, under its configuration sets.
|
|
68
|
+
- **SES rejects the AWS key.** The key ID is unknown, the secret doesn't match it, or the AWS account isn't signed up for SES. Store the key's ID and secret again, or create a new key, as [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) shows.
|
|
69
|
+
- **The sender isn't verified.** SES has no verified identity for the From address in your region. Verify the sending hostname, as [Connect Amazon SES](../get-started/05-ses.md#1-verify-your-sending-hostname) shows.
|
|
70
|
+
- **Sending is turned off for the account.** AWS documents turning it back on only with the AWS CLI, naming your region: `aws sesv2 put-account-sending-attributes --sending-enabled --region us-east-1`.
|
|
71
|
+
- **AWS paused the account, or is reviewing it.** AWS does this when the account's bounce or complaint rate runs high. While it reviews, SES still sends, and the line is amber. Once it pauses sending, the line is red. Either way, AWS's email to the AWS account says what it needs: answer it in AWS Support, and check the addresses on your recent sends' records.
|
|
72
|
+
|
|
73
|
+
The app needs to read each of these. If its key isn't allowed to, the line says nothing rather than guessing. The policy in [Connect Amazon SES](../get-started/05-ses.md#5-create-the-apps-aws-key) allows all of it.
|
|
74
|
+
|
|
75
|
+
If someone else set up Kestrel for you, pass this along to them.
|
|
76
|
+
|
|
77
|
+
## An address bounced or complained
|
|
41
78
|
|
|
42
79
|
Kestrel acts on these for you, so a bad address can't keep hurting your sending reputation:
|
|
43
80
|
|
|
@@ -57,28 +94,40 @@ Nothing on your list changes. Kestrel refuses a file it can't read with certaint
|
|
|
57
94
|
|
|
58
95
|
## Claude can't connect
|
|
59
96
|
|
|
60
|
-
Most failures are in your instance's Cloudflare Access setup,
|
|
97
|
+
[Connect Claude](08-connect-claude.md) covers connecting it. Most failures are in your instance's Cloudflare Access setup, and [Allow Claude to connect](../guides/01-allow-claude.md#check-it) lists their fixes. When connecting fails:
|
|
61
98
|
|
|
62
|
-
- **Claude says it couldn't connect, or the sign-in failed.**
|
|
63
|
-
- `kestrel login` says your instance "doesn't offer a sign-in for programs".
|
|
64
|
-
- `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in
|
|
65
|
-
-
|
|
99
|
+
- **Claude says it couldn't connect, or the sign-in failed.** Check that the connector's address is your publication's address followed by `/api/mcp`, and that your instance is deployed, not running on your own computer. If both are right, your instance's Access setup needs the fix.
|
|
100
|
+
- Signing in shows `invalid_target`, or `kestrel login` says your instance "doesn't offer a sign-in for programs". Your instance's Access setup needs the fix.
|
|
101
|
+
- `kestrel login` or `kestrel api` reports a `401`. Access signed you in, but Kestrel refused the sign-in, and your instance's Access setup needs the fix. If either names `access_unidentified`, it's the "Access let you in" problem below. If `kestrel api` says your sign-in has ended, sign in again as [Connect Claude](08-connect-claude.md#claude-code) describes.
|
|
102
|
+
- `kestrel api` says it couldn't renew your sign-in because its Access team answered 404 (not found). First check which instance it called: when you don't name one, it prints a line naming it before "the only instance you're signed in to", which may not be the one you meant. Name yours with `--url`. If it is yours, the Access team domain was most likely renamed or removed since you signed in. Sign in again with `npx kestrel login` and your instance's address, which finds the team it uses now.
|
|
103
|
+
- **"Access let you in, but didn't say who you are".** Your login carried neither an email nor a service token. Sign out, then sign in through the policy that lets you in by email. If you have no such policy, your instance's Access setup needs the fix.
|
|
66
104
|
- **Claude's changes read "via the API".** Claude called the API some way other than `kestrel api`, which names Claude Code. Your instance's instructions for Claude Code have it use `kestrel api` once they're brought up to date, as [Install the release](../guides/07-upgrade.md#2-install-the-release) describes. Changes reading "a service token" mean Claude still uses one, and [Connect Claude](08-connect-claude.md#claude-code) signs it in as you instead.
|
|
67
105
|
|
|
68
|
-
|
|
106
|
+
If someone else set up Kestrel for you, pass this along to them.
|
|
107
|
+
|
|
108
|
+
## Where to look
|
|
109
|
+
|
|
110
|
+
When a line doesn't settle it, these say the most about what happened:
|
|
69
111
|
|
|
70
|
-
|
|
112
|
+
- The top of the dashboard: what needs someone now, in Kestrel's own words.
|
|
113
|
+
- The send's page, under **Sent**: what happened on that send, when it next retries, and its record of each reader.
|
|
114
|
+
- **Settings → Email sender**: the state of your Amazon SES account, and your **Send rate**.
|
|
115
|
+
- **Settings → Notifications**: **Last notification** says whether the last one was delivered, or why not.
|
|
116
|
+
- A subscriber's page, under **Subscribers**: every send they were in, and what happened on each.
|
|
117
|
+
- The app's logs, in your instance's Cloudflare account: [Read the logs](../get-started/07-verify.md#7-read-the-logs) describes them. If someone else set up Kestrel for you, pass this along to them.
|
|
71
118
|
|
|
72
119
|
## Notifications
|
|
73
120
|
|
|
74
121
|
With an address set, Kestrel emails you:
|
|
75
122
|
|
|
76
|
-
- when a send finishes, with how many emails
|
|
77
|
-
- when
|
|
123
|
+
- when a send finishes, with how many emails your provider accepted, and how late it went out if it did
|
|
124
|
+
- when no one's email was accepted for a finished send, since it can't be sent again
|
|
125
|
+
- when email isn't going out and signups are paused: once when it starts, and once more if a post is held after an email that named none
|
|
126
|
+
- when your provider refuses a send or hits a limit, or no provider is connected
|
|
78
127
|
- when the SES sandbox refuses a signup's confirmation, at most once a day
|
|
79
|
-
- when a send stops going out with nothing to explain it, waits for you to resolve it, or
|
|
80
|
-
- when
|
|
128
|
+
- when a send stops going out with nothing to explain it, waits for you to resolve it, or doesn't go out on time
|
|
129
|
+
- when your provider has sent no receipts for six hours after a send, once until a receipt arrives
|
|
81
130
|
|
|
82
|
-
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.
|
|
131
|
+
Each says whether anyone has to act, and how. If the fix needs whoever set up your instance, you can forward it as it stands. Each goes to every address set, links to the page it's about, and is sent once while the problem lasts. A reader's address in your provider's words shows only as its domain, and in full on the send's page. If one address can't be reached, the others still get it, and **Last notification** names the one that failed. A bounce spike shows on the dashboard but isn't emailed. **Send a test notification** checks that one arrives.
|
|
83
132
|
|
|
84
133
|
By default, notifications go through your newsletter's own email provider. So when that provider refuses your account, the notification about it can't reach you either. You can send them another way, as [Notifications through Cloudflare's email](../guides/03-notifications.md) describes.
|
|
@@ -14,7 +14,7 @@ How you connect depends on where you talk to Claude:
|
|
|
14
14
|
1. In Claude, go to **Customize → Connectors**, select **Add**, then **Add custom connector**.
|
|
15
15
|
1. Name it, and enter your publication's address followed by `/api/mcp`, such as `https://newsletter.example.com/api/mcp`.
|
|
16
16
|
1. Under **OAuth client**, choose **Register automatically**.
|
|
17
|
-
1. Select **Add**, then
|
|
17
|
+
1. Select **Add**, then **Connect**. Sign in the way you do for the dashboard if asked, and choose **Allow**.
|
|
18
18
|
|
|
19
19
|
On a Team or Enterprise plan, an Owner of your Claude organization adds the connector under **Organization settings → Connectors** instead, and you then select **Connect** on it. Anthropic's [custom connectors guide](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) has the details.
|
|
20
20
|
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Your publication's subscribe page is at `/subscribe` on your publication's address, such as `https://newsletter.example.com/subscribe`. Copy it from the dashboard's checklist or **Settings → Ways to subscribe**, and share it wherever your readers are. A reader joins only once they confirm by email.
|
|
4
4
|
|
|
5
|
-
**Signups open only once an email provider is connected
|
|
5
|
+
**Signups open only once email is working.** Until an email provider is connected and has sent an email, such as a test from **Template → Send test email**, the page says the newsletter isn't taking subscribers yet. If your provider starts refusing your email, the page says signups are paused, and the dashboard says what to fix. Either way every visitor gets the same page and nothing is saved. You can share the link anyway, and it opens once email works ([Connect Amazon SES](../get-started/05-ses.md)).
|
|
6
|
+
|
|
7
|
+
**On Amazon SES, share it once AWS grants production access.** Until then, SES mails only addresses verified in it, so a reader who signs up never gets their confirmation email. The dashboard's checklist and an empty **Subscribers** list say so, and link [Request production access](../get-started/06-production-access.md). Importing your list is safe meanwhile, since an import sends no email.
|
|
6
8
|
|
|
7
9
|
## How a reader joins
|
|
8
10
|
|
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
# Check your setup
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Three checks before your readers get anything: is email getting out, will you hear about problems, and what will your readers get. The dashboard's setup steps tick each one off from what Kestrel sees, so you, and Claude, can tell what's left.
|
|
4
4
|
|
|
5
|
-
**Email has to be connected first.** Until it is, nothing can be sent, and the dashboard's
|
|
5
|
+
**Email has to be connected first.** Until it is, nothing can be sent, and **Send a test email** waits on the dashboard's **Connect Amazon SES** step, which [Connect Amazon SES](../get-started/05-ses.md) covers.
|
|
6
6
|
|
|
7
|
-
**Until AWS grants production access, Amazon SES mails only addresses verified in SES.** So test with a verified address, and hold the subscribe link until then. The
|
|
7
|
+
**Until AWS grants production access, Amazon SES mails only addresses verified in SES.** So test with a verified address, and hold the subscribe link until then. The dashboard's **Request production access** step says where AWS's review stands, and [Request production access](../get-started/06-production-access.md) covers the request.
|
|
8
8
|
|
|
9
|
-
## 1. Send
|
|
9
|
+
## 1. Send a test email
|
|
10
10
|
|
|
11
|
-
Choose **Template** in the sidebar, then **Send test email**, and send a sample post to your own inboxes. When it arrives, check:
|
|
11
|
+
Choose **Template** in the sidebar, then **Send test email**, and send a sample post to your own inboxes. This is the test that proves email gets out: signups stay closed until SES takes an email, and the step is checked off once it does. When it arrives, check:
|
|
12
12
|
|
|
13
13
|
- **The From name and address,** which readers see first. They're set when your instance is deployed.
|
|
14
14
|
- **Where it lands:** the inbox, Promotions, or spam. If it lands in spam, check your sending domain's records, as [Sending-domain DNS](../reference/02-sending-domain-dns.md) describes.
|
|
15
15
|
- **A reply.** Reply to the test, and check that it reaches your inbox. Kestrel never sees replies, so this depends on [forwarding your From address](../get-started/05-ses.md#8-receive-replies-at-your-from-address).
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
If SES refuses the test, the dashboard says why in red above the steps, with what to check. Send another test once it's fixed.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
## 2. Get notified
|
|
20
|
+
|
|
21
|
+
Under **Settings → Notifications**, add your address under **Send notifications to** with **Add inbox**, save, and choose **Send a test notification**. It goes to each address. If someone else runs your instance, add their address too, so a problem reaches both of you. Kestrel then emails you when a send finishes or needs you, as [Notifications](06-troubleshooting.md#notifications) lists. The step is checked off once an address is saved and the last notification arrived. If the last notification didn't arrive, the dashboard says so above the steps, with the channel's words.
|
|
20
22
|
|
|
21
|
-
## 3.
|
|
23
|
+
## 3. See what your readers get
|
|
24
|
+
|
|
25
|
+
Open your subscribe page from **Settings → Ways to subscribe**, subscribe with a personal address, and confirm. You see what a new reader sees, and you appear under **Subscribers** as **Confirmed**.
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
Then, once your first post is scheduled, **Send a test email for your first post** from its page. Scheduling makes the exact email your list will get, so this test is what goes out, and the step is checked off once it's sent. A test sent from a draft is a preview and doesn't count, and a change to your template or publication identity re-makes the scheduled email, so the step opens again until you test the new one. [Write and send a post](04-write-a-post.md) covers scheduling.
|
|
24
28
|
|
|
25
29
|
**If you're bringing more than about 1,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import.** [Bring your existing list](01-your-list.md#limits) explains why.
|
|
@@ -26,7 +26,7 @@ A variable is replaced with its value in each email. Type it exactly as shown, o
|
|
|
26
26
|
| `{{ .Publication.LogoURL }}` | Your logo's address, or empty. |
|
|
27
27
|
| `{{ .Publication.Address }}` | Your mailing address, or empty. |
|
|
28
28
|
| `{{ .Email.SentTo }}` | The address this email was sent to. |
|
|
29
|
-
| `{{ .Email.UnsubscribeURL }}` | This reader's
|
|
29
|
+
| `{{ .Email.UnsubscribeURL }}` | This reader's unsubscribe link. Required. |
|
|
30
30
|
| `{{ .Email.ViewInBrowserURL }}` | The post's own web link, which opens its page once it's sent, in your archive or not. |
|
|
31
31
|
|
|
32
32
|
## Test it
|
|
@@ -4,12 +4,17 @@ 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
|
-
These are the steps
|
|
7
|
+
These are the dashboard's setup steps, in its order. Each is checked off from what Kestrel can see, which Claude reads too, except sharing your link, which Kestrel can't see. Connecting Amazon SES and requesting production access are for whoever runs your Kestrel instance, in [Connect Amazon SES](../get-started/05-ses.md) and [Request production access](../get-started/06-production-access.md). If you're starting without a list, skip importing one. Setup ends when your first post goes out to the archive, and an email-only send doesn't count.
|
|
8
8
|
|
|
9
|
+
1. Connect Amazon SES
|
|
10
|
+
1. [Send a test email](10-check-your-setup.md)
|
|
9
11
|
1. [Name your publication](03-name-your-publication.md)
|
|
10
|
-
1.
|
|
12
|
+
1. Get notified
|
|
13
|
+
1. Request production access
|
|
11
14
|
1. [Bring your existing list](01-your-list.md)
|
|
12
|
-
1. [Write
|
|
15
|
+
1. [Write your first post](04-write-a-post.md)
|
|
16
|
+
1. Schedule your first post
|
|
17
|
+
1. Send a test email for your first post
|
|
13
18
|
1. [Share your subscribe link](09-share-your-subscribe-link.md)
|
|
14
19
|
|
|
15
20
|
## Go further
|
|
@@ -68,7 +68,7 @@ Vars go in the environment block's `vars`. Every value is a string.
|
|
|
68
68
|
|
|
69
69
|
- `FROM_ADDRESS` required with a real provider
|
|
70
70
|
|
|
71
|
-
The `From:` of every email, and the identity your provider authenticates. Refused unless it's an email address, optionally with a display name in front, like `Name <address>`, and never on `example.com`, unless `PROVIDER` is `fake`.
|
|
71
|
+
The `From:` of every email, and the identity your provider authenticates. Refused unless it's an email address, optionally with a display name in front, like `Name <address>`, and never on `example.com`, unless `PROVIDER` is `fake`. With Amazon SES, when no identity covering it is verified for sending in `AWS_REGION`, **Settings → Email sender** says so in red within about ten minutes, without sending anything.
|
|
72
72
|
|
|
73
73
|
It isn't the publication's name, tagline, or logo. You set those in the editor, under **Settings → Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, the emails and reader pages show no name at all.
|
|
74
74
|
|
|
@@ -138,11 +138,11 @@ Store each with `npx wrangler secret put NAME --env production`, which prompts f
|
|
|
138
138
|
|
|
139
139
|
- `ACCESS_ALLOWED_EMAILS` optional
|
|
140
140
|
|
|
141
|
-
A comma-separated list of the people allowed to sign in. Without it, the app lets in anyone the Access policy allows, so it keeps the editor limited to named people if the policy is widened later. It applies to people only. A service token, the credential a script uses, carries no email, so the Access policy alone decides whether it gets in.
|
|
141
|
+
A comma-separated list of the people allowed to sign in, such as `you@example.com, publisher@example.com`. Spaces and capital letters don't matter. Without it, the app lets in anyone the Access policy allows, so it keeps the editor limited to named people if the policy is widened later. It applies to people only. A service token, the credential a script uses, carries no email, so the Access policy alone decides whether it gets in.
|
|
142
142
|
|
|
143
143
|
- `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` required with SES
|
|
144
144
|
|
|
145
|
-
The IAM user's access key, which sends through SES.
|
|
145
|
+
The IAM user's access key, which sends through SES. When SES rejects it, because the key ID is unknown, the secret doesn't match it, or the AWS account isn't signed up for SES, **Settings → Email sender** says so in red within about ten minutes, without sending anything.
|
|
146
146
|
|
|
147
147
|
- `SNS_TOPIC_ARN` required with SES
|
|
148
148
|
|
|
@@ -150,7 +150,7 @@ Store each with `npx wrangler secret put NAME --env production`, which prompts f
|
|
|
150
150
|
|
|
151
151
|
- `SES_CONFIGURATION_SET` optional, recommended with SES
|
|
152
152
|
|
|
153
|
-
The configuration set named on every email, so SES reports bounces and complaints. A name SES can't find in the region holds every list send until it's fixed.
|
|
153
|
+
The configuration set named on every email, so SES reports bounces and complaints. A name SES can't find in the region holds every list send until it's fixed. **Settings → Email sender** says so in red within about ten minutes, without sending anything, and does the same when the set's sending is turned off, as long as the app's key may call `ses:GetConfigurationSet`.
|
|
154
154
|
|
|
155
155
|
- `DEV_AUTH_SECRET` never on a deployed environment
|
|
156
156
|
|
|
@@ -196,7 +196,7 @@ Wrangler doesn't carry bindings or vars over from the top level of `wrangler.jso
|
|
|
196
196
|
|
|
197
197
|
## What the app refuses
|
|
198
198
|
|
|
199
|
-
The app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly, and each setting above says what it refuses. In local development, `SIMULATE_SENDS` must also 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
|
|
199
|
+
The app checks its configuration on every request. It refuses to run on one that would do the wrong thing quietly, and each setting above says what it refuses. In local development, `SIMULATE_SENDS` must also 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, and, for `ses`, by `:refuse=` and a cause SES refuses every email for (`credentials`, `opt_in`, `configuration`, `sender`, `mail_from`, `paused`, `set_paused`, `suspended`, or `unsupported`), as in `ses:refuse=configuration`. `SIMULATE_ACCOUNT` must be off, or, beside `SIMULATE_SENDS=ses`, the SES account state local development stands in for: `sandbox`, `pending`, `denied`, `production`, or `permission`, optionally followed by `:unrouted` or by one account problem, which shows its warning: `:set_missing`, `:set_paused`, `:key_rejected`, `:sending_disabled`, `:shutdown`, `:sender_unverified`, or `:probation`, as in `production:set_missing`. Case doesn't matter. A deployed instance ignores both.
|
|
200
200
|
|
|
201
201
|
Until it's fixed, no sends go out, and each place says what its reader can act on:
|
|
202
202
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- 0016_held_receipts: receipts that name a message the record has not yet learned (SPEC §10).
|
|
2
|
+
--
|
|
3
|
+
-- The send loop writes a recipient's provider message id when the provider answers the batch,
|
|
4
|
+
-- and the provider can report a bounce, complaint, or delivery for that message before then. A
|
|
5
|
+
-- receipt that names a message no delivery row holds yet, for an address with a recipient still
|
|
6
|
+
-- in flight, is kept here (`provider_id`, the address, the event and its words, when it arrived,
|
|
7
|
+
-- and `delivery_id`, the in-flight recipient it waits for) and applied to the record by the
|
|
8
|
+
-- sweep once a row holds that id, or once the publisher resolves that recipient as sent; it
|
|
9
|
+
-- lands on no other row. One that has waited a day is discarded. The list
|
|
10
|
+
-- already acted on it when it arrived, so nothing here suppresses or unsubscribes anyone.
|
|
11
|
+
--
|
|
12
|
+
-- What can't be recovered: nothing. The table starts empty.
|
|
13
|
+
|
|
14
|
+
CREATE TABLE held_receipts (
|
|
15
|
+
id INTEGER PRIMARY KEY,
|
|
16
|
+
provider_id TEXT NOT NULL,
|
|
17
|
+
email TEXT NOT NULL,
|
|
18
|
+
event TEXT NOT NULL CHECK (event IN ('delivered', 'bounced', 'complained')),
|
|
19
|
+
detail TEXT,
|
|
20
|
+
hard INTEGER CHECK (hard IN (0, 1)),
|
|
21
|
+
at INTEGER NOT NULL,
|
|
22
|
+
delivery_id INTEGER NOT NULL
|
|
23
|
+
) STRICT;
|
|
24
|
+
|
|
25
|
+
-- Erasure discards an address's held receipts by address.
|
|
26
|
+
CREATE INDEX idx_held_receipts_email ON held_receipts (email);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- 0017_provider_account_rejected: an account check whose key SES rejects (SPEC §13).
|
|
2
|
+
--
|
|
3
|
+
-- The account check used to file a key SES doesn't know, a secret that doesn't match, and an
|
|
4
|
+
-- account not signed up for SES as `unreachable`, the same as no answer, so a dead key read
|
|
5
|
+
-- as "can't tell" and was found only by sending. It is now its own outcome, `rejected`.
|
|
6
|
+
-- `provider_account.outcome`'s CHECK doesn't admit it and a STRICT table's CHECK can't be
|
|
7
|
+
-- altered, so the table is rebuilt with `rejected` added, its one row carried over.
|
|
8
|
+
--
|
|
9
|
+
-- What can't be recovered: nothing. The row is copied as it was.
|
|
10
|
+
|
|
11
|
+
CREATE TABLE provider_account_next (
|
|
12
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
13
|
+
attempted_at INTEGER NOT NULL,
|
|
14
|
+
outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'permission', 'rejected', 'unreachable')),
|
|
15
|
+
checked_at INTEGER,
|
|
16
|
+
answer TEXT CHECK (answer IS NULL OR json_valid(answer)),
|
|
17
|
+
CHECK ((checked_at IS NULL) = (answer IS NULL))
|
|
18
|
+
) STRICT;
|
|
19
|
+
|
|
20
|
+
INSERT INTO provider_account_next (id, attempted_at, outcome, checked_at, answer)
|
|
21
|
+
SELECT id, attempted_at, outcome, checked_at, answer FROM provider_account;
|
|
22
|
+
|
|
23
|
+
DROP TABLE provider_account;
|
|
24
|
+
ALTER TABLE provider_account_next RENAME TO provider_account;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- 0018_notify_addresses: notifications go to one or more addresses (SPEC §8).
|
|
2
|
+
--
|
|
3
|
+
-- Each address is its own delivery: `sent_to` holds the addresses a notification has reached,
|
|
4
|
+
-- as a JSON array, so a try after one address failed goes only to the ones still missing it,
|
|
5
|
+
-- never again to one that got it. Every row starts with none: one still pending reached no
|
|
6
|
+
-- address, since it had only the one, and one that is closed is never tried again.
|
|
7
|
+
--
|
|
8
|
+
-- The saved address itself needs no migration: the settings blob is read through code, which
|
|
9
|
+
-- takes a stored single address as a list of that one (`src/db/settings.ts`).
|
|
10
|
+
--
|
|
11
|
+
-- What can't be recovered: nothing. The column is added to every row.
|
|
12
|
+
|
|
13
|
+
ALTER TABLE notifications ADD COLUMN sent_to TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(sent_to));
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
-- 0019_sending: whether email is getting out (SPEC §13).
|
|
2
|
+
--
|
|
3
|
+
-- One row, written whenever the provider answers a test, a confirmation, a list send, or a
|
|
4
|
+
-- notification, and by the account check: `unproven` until the provider takes an email,
|
|
5
|
+
-- `working` while the last one it was asked to take it took, `refusing` while it refuses the
|
|
6
|
+
-- account for a reason that won't clear on its own. `since` is when it entered that state.
|
|
7
|
+
-- The last email taken (`last_accepted_at`, and what it was, `last_accepted_by`) is kept
|
|
8
|
+
-- across a refusal, so the editor can say when the provider last took one. A refusal keeps
|
|
9
|
+
-- its cause, what found it, and the provider's words, never a reader's address. Every write
|
|
10
|
+
-- carries when the provider gave its answer (`observed_at` keeps the newest applied), and none
|
|
11
|
+
-- replaces a newer one: a sweep that met a refusal before the operator's fix can't undo the
|
|
12
|
+
-- test that proved it.
|
|
13
|
+
--
|
|
14
|
+
-- `check_contradicted` lists the causes the account check found that an email the provider
|
|
15
|
+
-- took has since contradicted: a reading that stays wrong while email goes out, such as a
|
|
16
|
+
-- sender verified through another account's sending authorization, pauses signups once and
|
|
17
|
+
-- not again. A clean reading empties it.
|
|
18
|
+
--
|
|
19
|
+
-- The row starts as `working` when the app's own records show email went out: a send the
|
|
20
|
+
-- provider took a recipient of, or a subscriber who confirmed through a link this instance
|
|
21
|
+
-- mailed. So an instance that was live before this release keeps its signups open. A test
|
|
22
|
+
-- leaves no record and so counts for nothing here; the next one sets it. A delivered
|
|
23
|
+
-- notification counts for nothing either, since it may have gone through Cloudflare's email
|
|
24
|
+
-- rather than the provider. The provider's own 24-hour count is never read: it is zero on a
|
|
25
|
+
-- quiet day.
|
|
26
|
+
--
|
|
27
|
+
-- What can't be recovered: nothing. The table is new.
|
|
28
|
+
|
|
29
|
+
CREATE TABLE sending (
|
|
30
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
31
|
+
state TEXT NOT NULL CHECK (state IN ('unproven', 'working', 'refusing')),
|
|
32
|
+
since INTEGER NOT NULL,
|
|
33
|
+
observed_at INTEGER NOT NULL,
|
|
34
|
+
last_accepted_at INTEGER,
|
|
35
|
+
last_accepted_by TEXT CHECK (last_accepted_by IN ('test', 'confirmation', 'list', 'notification')),
|
|
36
|
+
refusal_cause TEXT
|
|
37
|
+
CHECK (refusal_cause IN ('credentials', 'opt_in', 'configuration', 'sending_pool', 'sender',
|
|
38
|
+
'mail_from', 'paused', 'set_paused', 'suspended', 'unsupported')),
|
|
39
|
+
refusal_found_by TEXT
|
|
40
|
+
CHECK (refusal_found_by IN ('test', 'confirmation', 'list', 'notification', 'account_check')),
|
|
41
|
+
refusal_error TEXT,
|
|
42
|
+
refusal_at INTEGER,
|
|
43
|
+
check_contradicted TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(check_contradicted)),
|
|
44
|
+
CHECK ((last_accepted_at IS NULL) = (last_accepted_by IS NULL)),
|
|
45
|
+
CHECK ((state = 'refusing') = (refusal_cause IS NOT NULL)),
|
|
46
|
+
CHECK ((refusal_cause IS NULL) = (refusal_found_by IS NULL)),
|
|
47
|
+
CHECK ((refusal_cause IS NULL) = (refusal_at IS NULL)),
|
|
48
|
+
CHECK (state <> 'working' OR last_accepted_at IS NOT NULL)
|
|
49
|
+
) STRICT;
|
|
50
|
+
|
|
51
|
+
INSERT INTO sending (id, state, since, observed_at, last_accepted_at, last_accepted_by)
|
|
52
|
+
SELECT 1,
|
|
53
|
+
CASE WHEN at IS NULL THEN 'unproven' ELSE 'working' END,
|
|
54
|
+
COALESCE(at, CAST(unixepoch('subsec') * 1000 AS INTEGER)),
|
|
55
|
+
COALESCE(at, 0),
|
|
56
|
+
at,
|
|
57
|
+
CASE WHEN at IS NULL THEN NULL ELSE by END
|
|
58
|
+
FROM (
|
|
59
|
+
SELECT at, by FROM (
|
|
60
|
+
SELECT MAX(updated_at) AS at, 'list' AS by FROM deliveries WHERE status = 'accepted'
|
|
61
|
+
UNION ALL
|
|
62
|
+
SELECT MAX(confirm_sent_at), 'confirmation' FROM subscribers
|
|
63
|
+
WHERE status = 'confirmed' AND confirm_sent_at IS NOT NULL AND import_id IS NULL
|
|
64
|
+
)
|
|
65
|
+
ORDER BY at IS NULL, at DESC
|
|
66
|
+
LIMIT 1
|
|
67
|
+
);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- 0020_notifications_not_sending: tell the publisher when email isn't going out (SPEC §8).
|
|
2
|
+
--
|
|
3
|
+
-- When the provider refuses the account outside a send, or the account check finds a refusal
|
|
4
|
+
-- before anything is sent, nobody is there to see it, so the publisher hears of it through the
|
|
5
|
+
-- notifications they already get, as a `not_sending` notice with no send and no import, at
|
|
6
|
+
-- most one a UTC day: its episode is the day's start, and the partial unique index keeps one
|
|
7
|
+
-- row a day, as the `sandbox` notice does. `notifications.kind`'s CHECK doesn't admit it and a
|
|
8
|
+
-- STRICT table's CHECK can't be altered, so the table is rebuilt with `not_sending` added,
|
|
9
|
+
-- every row and column carried over, `sent_to` and its check included, so a notification that
|
|
10
|
+
-- has reached some of its addresses never mails them again.
|
|
11
|
+
--
|
|
12
|
+
-- What can't be recovered: nothing. Every row is copied as it was.
|
|
13
|
+
|
|
14
|
+
CREATE TABLE notifications_next (
|
|
15
|
+
send_id TEXT REFERENCES sends (id), -- null for a test, an import notice, and an account notice
|
|
16
|
+
import_id TEXT REFERENCES imports (id), -- set only for an import notice
|
|
17
|
+
kind TEXT NOT NULL
|
|
18
|
+
CHECK (kind IN ('finished', 'refused', 'stuck', 'wedged', 'missed', 'test', 'import', 'sandbox', 'not_sending')),
|
|
19
|
+
episode INTEGER NOT NULL DEFAULT 0,
|
|
20
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
21
|
+
CHECK (status IN ('pending', 'sent', 'failed', 'unaddressed', 'cleared')),
|
|
22
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
error TEXT,
|
|
24
|
+
created_at INTEGER NOT NULL,
|
|
25
|
+
updated_at INTEGER NOT NULL,
|
|
26
|
+
sent_to TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(sent_to)),
|
|
27
|
+
UNIQUE (send_id, kind, episode),
|
|
28
|
+
UNIQUE (import_id),
|
|
29
|
+
CHECK ((send_id IS NULL) = (kind IN ('test', 'import', 'sandbox', 'not_sending'))),
|
|
30
|
+
CHECK ((import_id IS NULL) = (kind <> 'import'))
|
|
31
|
+
) STRICT;
|
|
32
|
+
|
|
33
|
+
INSERT INTO notifications_next (send_id, import_id, kind, episode, status, attempts, error, created_at, updated_at, sent_to)
|
|
34
|
+
SELECT send_id, import_id, kind, episode, status, attempts, error, created_at, updated_at, sent_to FROM notifications;
|
|
35
|
+
|
|
36
|
+
DROP TABLE notifications;
|
|
37
|
+
ALTER TABLE notifications_next RENAME TO notifications;
|
|
38
|
+
CREATE INDEX idx_notifications_status ON notifications (status, created_at);
|
|
39
|
+
CREATE UNIQUE INDEX idx_notifications_sandbox_day ON notifications (episode) WHERE kind = 'sandbox';
|
|
40
|
+
CREATE UNIQUE INDEX idx_notifications_not_sending_day ON notifications (episode) WHERE kind = 'not_sending';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- 0021_notifications_outage_receipts: one outage, at most two emails, and a notice of silence (SPEC §8).
|
|
2
|
+
--
|
|
3
|
+
-- `not_sending` is now one notice for each stretch email is refusing rather than one a UTC day:
|
|
4
|
+
-- its episode is when the refusal began. A second kind, `not_sending_held`, keyed the same way,
|
|
5
|
+
-- is the one further email when a post is first held after that notice named none, so a unique
|
|
6
|
+
-- index on each kind's episode is what holds an outage to two. `no_receipts` is a send's notice
|
|
7
|
+
-- that the provider's receipts may not be reaching the app. The publisher is no longer told of
|
|
8
|
+
-- an import, so a pending `import` notice is closed as cleared rather than sent; the kind stays
|
|
9
|
+
-- admitted, with the column it reads, since the rows already recorded keep their history and the
|
|
10
|
+
-- settings surface may still show the latest. A STRICT table's CHECK can't be altered, so the
|
|
11
|
+
-- table is rebuilt, every row and column carried over.
|
|
12
|
+
--
|
|
13
|
+
-- What can't be recovered: nothing but the pending import notices, which were only ever going
|
|
14
|
+
-- to tell of an import already on the record.
|
|
15
|
+
|
|
16
|
+
CREATE TABLE notifications_next (
|
|
17
|
+
send_id TEXT REFERENCES sends (id), -- null for a test, an import notice, and an account notice
|
|
18
|
+
import_id TEXT REFERENCES imports (id), -- set only for an import notice
|
|
19
|
+
kind TEXT NOT NULL
|
|
20
|
+
CHECK (kind IN ('finished', 'refused', 'stuck', 'wedged', 'missed', 'no_receipts', 'test', 'import', 'sandbox', 'not_sending', 'not_sending_held')),
|
|
21
|
+
episode INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
23
|
+
CHECK (status IN ('pending', 'sent', 'failed', 'unaddressed', 'cleared')),
|
|
24
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
error TEXT,
|
|
26
|
+
created_at INTEGER NOT NULL,
|
|
27
|
+
updated_at INTEGER NOT NULL,
|
|
28
|
+
sent_to TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(sent_to)),
|
|
29
|
+
UNIQUE (send_id, kind, episode),
|
|
30
|
+
UNIQUE (import_id),
|
|
31
|
+
CHECK ((send_id IS NULL) = (kind IN ('test', 'import', 'sandbox', 'not_sending', 'not_sending_held'))),
|
|
32
|
+
CHECK ((import_id IS NULL) = (kind <> 'import'))
|
|
33
|
+
) STRICT;
|
|
34
|
+
|
|
35
|
+
INSERT INTO notifications_next (send_id, import_id, kind, episode, status, attempts, error, created_at, updated_at, sent_to)
|
|
36
|
+
SELECT send_id, import_id, kind, episode,
|
|
37
|
+
CASE WHEN kind = 'import' AND status = 'pending' THEN 'cleared' ELSE status END,
|
|
38
|
+
attempts, error, created_at, updated_at, sent_to
|
|
39
|
+
FROM notifications;
|
|
40
|
+
|
|
41
|
+
DROP TABLE notifications;
|
|
42
|
+
ALTER TABLE notifications_next RENAME TO notifications;
|
|
43
|
+
CREATE INDEX idx_notifications_status ON notifications (status, created_at);
|
|
44
|
+
CREATE UNIQUE INDEX idx_notifications_sandbox_day ON notifications (episode) WHERE kind = 'sandbox';
|
|
45
|
+
CREATE UNIQUE INDEX idx_notifications_not_sending ON notifications (episode) WHERE kind = 'not_sending';
|
|
46
|
+
CREATE UNIQUE INDEX idx_notifications_not_sending_held ON notifications (episode) WHERE kind = 'not_sending_held';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kurtbruns/kestrel",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.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",
|
|
@@ -52,7 +52,7 @@ const USAGE = `Usage: kestrel api <method> <path> [options]
|
|
|
52
52
|
--data <json|@file|-> the request body: inline, from a file, or from stdin
|
|
53
53
|
--type <media type> the body's type (default application/json)
|
|
54
54
|
--form <name=value> a multipart field, repeatable; name=@file attaches a file
|
|
55
|
-
--url <url> the instance (default: KESTREL_URL, else
|
|
55
|
+
--url <url> the instance (default: KESTREL_URL, else the only one you're signed in to)
|
|
56
56
|
--local this directory's dev server, started by kestrel dev
|
|
57
57
|
--client <name> your own script's Kestrel-Client name (default ${DEFAULT_CLIENT});
|
|
58
58
|
never editor, which only the editor names itself
|
|
@@ -182,8 +182,15 @@ export function parseApiArgs(argv) {
|
|
|
182
182
|
/**
|
|
183
183
|
* Which instance a call goes to: `--url`, then `KESTREL_URL`, then the one instance this
|
|
184
184
|
* person is signed in to. Several sign-ins and nothing naming one is a refusal, not a guess.
|
|
185
|
+
* The one sign-in is named on stderr through `say`, since nothing in the command named it and
|
|
186
|
+
* a person in another instance's folder should see the mismatch; stdout stays the answer's.
|
|
185
187
|
*/
|
|
186
|
-
export function chooseInstance({
|
|
188
|
+
export function chooseInstance({
|
|
189
|
+
url,
|
|
190
|
+
env = process.env,
|
|
191
|
+
signIns,
|
|
192
|
+
say = (line) => process.stderr.write(`${line}\n`),
|
|
193
|
+
}) {
|
|
187
194
|
if (url) {
|
|
188
195
|
return instanceOrigin(url);
|
|
189
196
|
}
|
|
@@ -192,6 +199,9 @@ export function chooseInstance({ url, env = process.env, signIns }) {
|
|
|
192
199
|
}
|
|
193
200
|
const origins = Object.keys(signIns);
|
|
194
201
|
if (origins.length === 1) {
|
|
202
|
+
say(
|
|
203
|
+
`kestrel api: calling ${origins[0]}, the only instance you're signed in to. Pass --url to call a different one.`,
|
|
204
|
+
);
|
|
195
205
|
return origins[0];
|
|
196
206
|
}
|
|
197
207
|
if (origins.length === 0) {
|
|
@@ -280,9 +290,24 @@ export async function accessToken(origin, { force = false, env = process.env } =
|
|
|
280
290
|
writeSignInHeld(origin, { ...now, ...fields }, env);
|
|
281
291
|
return fields.access_token;
|
|
282
292
|
} catch (err) {
|
|
283
|
-
if (err instanceof SignInError
|
|
293
|
+
if (!(err instanceof SignInError)) {
|
|
294
|
+
throw err;
|
|
295
|
+
}
|
|
296
|
+
if (err.oauthError === "invalid_grant") {
|
|
284
297
|
throw new SignInError(ended);
|
|
285
298
|
}
|
|
299
|
+
// The token endpoint saved at sign-in wasn't found; signing in again discovers where the
|
|
300
|
+
// instance signs in now. It says what happened, not why: a renamed team domain and a
|
|
301
|
+
// moved endpoint both answer 404.
|
|
302
|
+
if (err.status === 404) {
|
|
303
|
+
const team = new URL(now.token_endpoint).host;
|
|
304
|
+
throw new SignInError(
|
|
305
|
+
`Couldn't renew your sign-in to ${origin}: its Access team, ${team}, answered 404 (not found). Run \`npx kestrel login ${origin}\` again.`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (err.status) {
|
|
309
|
+
throw new SignInError(`Access refused the token request for ${origin} (${err.detail}).`);
|
|
310
|
+
}
|
|
286
311
|
throw err;
|
|
287
312
|
}
|
|
288
313
|
}, env);
|