@kurtbruns/kestrel 1.12.0 → 1.14.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 +137 -1
- package/README.md +1 -1
- package/bin/kestrel.mjs +17 -2
- package/cli/api.mjs +462 -0
- package/cli/credentials.mjs +192 -0
- package/cli/dev-api.mjs +17 -11
- package/cli/login.mjs +155 -0
- package/cli/oauth.mjs +368 -0
- package/cli/openapi.mjs +569 -0
- package/cli/seed.mjs +1 -1
- package/dist/public/dashboard/app.js +350 -295
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +466 -153
- package/dist/worker/index.js +10634 -5773
- package/docs/README.md +3 -2
- package/docs/get-started/02-deploy.md +18 -21
- package/docs/get-started/03-access.md +10 -8
- package/docs/get-started/04-ses.md +2 -2
- package/docs/get-started/05-verify.md +4 -4
- package/docs/get-started/06-go-live.md +3 -2
- package/docs/guides/01-allow-claude.md +68 -0
- package/docs/guides/02-automate.md +88 -0
- package/docs/guides/09-as-your-list-grows.md +8 -4
- package/docs/publishing/01-your-list.md +38 -33
- package/docs/publishing/02-sending-rules.md +3 -3
- package/docs/publishing/03-name-your-publication.md +1 -1
- package/docs/publishing/04-write-a-post.md +15 -52
- package/docs/publishing/06-troubleshooting.md +30 -14
- package/docs/publishing/08-connect-claude.md +60 -0
- package/docs/publishing/09-share-your-subscribe-link.md +3 -3
- package/docs/publishing/10-check-your-setup.md +9 -16
- package/docs/publishing/11-email-template.md +3 -1
- package/docs/publishing/README.md +2 -2
- package/docs/reference/01-configuration.md +132 -85
- package/docs/reference/02-sending-domain-dns.md +2 -2
- package/migrations/0012_unsubscribe_cause.sql +139 -0
- package/migrations/0013_who_acted_client.sql +20 -0
- package/migrations/0014_send_acts.sql +25 -0
- package/package.json +1 -1
- package/template/README.md +2 -0
- package/template/claude/CLAUDE.md +18 -14
- package/template/claude/skills/migrate-list/SKILL.md +5 -4
- package/docs/guides/01-connect-claude.md +0 -98
- package/docs/publishing/07-working-with-claude.md +0 -30
|
@@ -10,6 +10,8 @@ Kestrel won't save a template without `{{ .Post.Body }}` and `{{ .Email.Unsubscr
|
|
|
10
10
|
|
|
11
11
|
Wrap a part in `{{ if .IsEmail }}` and `{{ end }}` to keep it off each post's public page. The starting templates do this with the unsubscribe link, the View in browser link, and the mailing address.
|
|
12
12
|
|
|
13
|
+
A region is a marked part, not a general `if`: `.IsEmail` is its only condition, there's no `else`, and regions don't nest. Kestrel won't save a template with a region left open, an `{{ end }}` with nothing to close, another condition, or `{{ .Post.Body }}` inside a region, which would leave the post's page empty. Keep each region's two tags inside the same element, outside any attribute, and not loose between a table's rows or cells, where the browser would move what's inside. A template that breaks that rule saves, but every email shows the built-in template instead until it's fixed, and the preview and a test say so. An unsubscribe link inside a region is fine, since every email still carries it.
|
|
14
|
+
|
|
13
15
|
## Variables
|
|
14
16
|
|
|
15
17
|
A variable is replaced with its value in each email. Type it exactly as shown, or it renders as empty.
|
|
@@ -31,4 +33,4 @@ A variable is replaced with its value in each email. Type it exactly as shown, o
|
|
|
31
33
|
|
|
32
34
|
**Send test email** on the **Template** page sends a sample post through your saved template, so you see it in a real inbox. With unsaved edits, the button reads **Save & send test**, since a test always uses the saved template.
|
|
33
35
|
|
|
34
|
-
A change to the template re-makes the email of every scheduled post,
|
|
36
|
+
A change to the template, or to a publication detail it shows, re-makes the email of every scheduled post, with the same content and send time. Kestrel asks before it saves, and each re-made post then asks you to send a new test. A change is refused while a post is within its last few minutes before sending. Posts already sending or sent never change.
|
|
@@ -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
|
-
|
|
7
|
+
These are the steps to set up your publication. If you're starting without a list, skip importing one.
|
|
8
8
|
|
|
9
9
|
1. [Name your publication](03-name-your-publication.md)
|
|
10
10
|
1. [Check your setup](10-check-your-setup.md)
|
|
@@ -17,7 +17,7 @@ The steps to set up a new publication, in the order the dashboard's setup checkl
|
|
|
17
17
|
Things you choose to do once you're running.
|
|
18
18
|
|
|
19
19
|
- [Edit your email template](11-email-template.md)
|
|
20
|
-
- [
|
|
20
|
+
- [Connect Claude](08-connect-claude.md)
|
|
21
21
|
|
|
22
22
|
## Rules and troubleshooting
|
|
23
23
|
|
|
@@ -10,142 +10,189 @@ In local development, `npm run dev` reads the top level of `wrangler.jsonc` and
|
|
|
10
10
|
|
|
11
11
|
None of these can be read or changed from inside the app, by the editor or by Claude. Settings in the editor shows the public ones, read-only.
|
|
12
12
|
|
|
13
|
+
This is the `production` block a new instance starts with. [Deploy the app](../get-started/02-deploy.md) fills in its database and hostnames:
|
|
14
|
+
|
|
15
|
+
```jsonc
|
|
16
|
+
"production": {
|
|
17
|
+
"name": "kestrel-production",
|
|
18
|
+
"routes": [{ "pattern": "newsletter.example.com", "custom_domain": true }],
|
|
19
|
+
"workers_dev": false,
|
|
20
|
+
"preview_urls": false,
|
|
21
|
+
"triggers": { "crons": ["* * * * *"] },
|
|
22
|
+
"observability": { "enabled": true },
|
|
23
|
+
"d1_databases": [
|
|
24
|
+
{
|
|
25
|
+
"binding": "DB",
|
|
26
|
+
"database_name": "kestrel-production",
|
|
27
|
+
"database_id": "REPLACE_WITH_PRODUCTION_D1_ID",
|
|
28
|
+
"migrations_dir": "migrations"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"r2_buckets": [{ "binding": "MEDIA", "bucket_name": "kestrel-media-production" }],
|
|
32
|
+
"vars": {
|
|
33
|
+
"PROVIDER": "",
|
|
34
|
+
"APP_ORIGIN": "https://newsletter.example.com",
|
|
35
|
+
"ARCHIVE_BASE_PATH": "/archive",
|
|
36
|
+
"SENDING_DOMAIN": "send.example.com",
|
|
37
|
+
"FROM_ADDRESS": "Newsletter <newsletter@send.example.com>",
|
|
38
|
+
"AWS_REGION": "us-east-1"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
13
43
|
## Vars
|
|
14
44
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
45
|
+
Vars go in the environment block's `vars`. Every value is a string.
|
|
46
|
+
|
|
47
|
+
- `PROVIDER` optional
|
|
48
|
+
|
|
49
|
+
The email provider: `ses`, or `fake` for local development. Default: none, so no provider is connected. Refused unless it's exactly `ses`, `fake`, or `none`: a misspelling, or `SES`, is never taken for another. `resend`, which earlier releases supported, is refused with a pointer to [Connect Amazon SES](../get-started/04-ses.md#switch-an-instance-that-already-sends).
|
|
50
|
+
|
|
51
|
+
Until it's set, or while it's empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.
|
|
52
|
+
|
|
53
|
+
`fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`, since on a real hostname it would open signups whose confirmations go nowhere.
|
|
54
|
+
|
|
55
|
+
- `ALLOW_FAKE_PROVIDER` optional
|
|
56
|
+
|
|
57
|
+
`true` lets `fake` run on a real hostname, for a hosted demo that mails no one on purpose. No other deployment needs it. With `fake`, it must be `true` when set; every other provider ignores it.
|
|
58
|
+
|
|
59
|
+
- `APP_ORIGIN` required
|
|
30
60
|
|
|
31
|
-
|
|
61
|
+
The origin the app is served from: scheme and host, no path. Refused unless it's a plain `http` or `https` origin with no path, and never on `example.com` or a name under it, unless `PROVIDER` is `fake`. A trailing slash is dropped.
|
|
32
62
|
|
|
33
|
-
|
|
63
|
+
- `AWS_REGION` required with SES
|
|
34
64
|
|
|
35
|
-
|
|
65
|
+
The SES region. The template sets `us-east-1`.
|
|
36
66
|
|
|
37
|
-
|
|
67
|
+
- `FROM_ADDRESS` required with a real provider
|
|
38
68
|
|
|
39
|
-
`
|
|
69
|
+
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`.
|
|
40
70
|
|
|
41
|
-
|
|
71
|
+
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.
|
|
42
72
|
|
|
43
|
-
|
|
73
|
+
- `SENDING_DOMAIN` optional
|
|
44
74
|
|
|
45
|
-
|
|
75
|
+
The sending domain, shown in Settings beside the From address. The app never checks that it matches `FROM_ADDRESS`, so set it to the part of `FROM_ADDRESS` after the `@`. Never on `example.com`, unless `PROVIDER` is `fake`.
|
|
46
76
|
|
|
47
|
-
|
|
77
|
+
- `ARCHIVE_BASE_PATH` optional
|
|
48
78
|
|
|
49
|
-
|
|
79
|
+
The path the archive lives under. Default: `/archive`. A missing leading slash is added, and a trailing slash is dropped.
|
|
50
80
|
|
|
51
|
-
|
|
81
|
+
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`.
|
|
52
82
|
|
|
53
|
-
|
|
83
|
+
- `SES_MAX_SEND_RATE` optional
|
|
54
84
|
|
|
55
|
-
|
|
85
|
+
Your SES account's maximum send rate, in messages a second. Default: `14`, a common starting rate, not your account's. Refused unless it's a whole number above zero.
|
|
56
86
|
|
|
57
|
-
|
|
87
|
+
The app sends SES one request a second, the most SES takes, and each request carries one second of this rate, up to 50 recipients. Set it to the maximum send rate on your SES **Account dashboard**: `1` in the sandbox, and whatever AWS grants after that. To send faster, ask AWS to raise that rate to 50 before you move to Workers Paid: on Workers Free, a rate of 50 sends nearly four times as many recipients a minute as 14, and a higher rate adds nothing there. A rate set too high only means SES throttles some requests. Those recipients wait for the next minute, and no one is mailed twice.
|
|
58
88
|
|
|
59
|
-
|
|
89
|
+
- `SUBREQUEST_BUDGET` optional
|
|
60
90
|
|
|
61
|
-
|
|
62
|
-
- **On Workers Paid,** which allows 10,000, set `"SUBREQUEST_BUDGET": "10000"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.
|
|
63
|
-
- **If you raised the Worker's own limit** with `limits.subrequests`, you may set this to match.
|
|
91
|
+
How many subrequests one minute's send work may use, which also tells the app which Workers plan it's on. Default: `50`, the Workers Free limit, and safe on any plan. Refused unless it's a whole number above zero. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.
|
|
64
92
|
|
|
65
|
-
It
|
|
93
|
+
Cloudflare caps how many subrequests one run of a Worker may make. Database queries and outbound requests both count. It also caps database queries alone: 50 a run on Workers Free, and 1,000 on Workers Paid. Each minute's send work is one run, so a large send goes out over several minutes, each stopping short of both caps. This setting is the subrequest cap. The app keeps database queries under theirs on its own.
|
|
66
94
|
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
95
|
+
- At the default, a minute sends four SES requests: 56 recipients at a send rate of 14, and 200 at a rate of 50 or more.
|
|
96
|
+
- On Workers Paid, which allows 10,000, set `"SUBREQUEST_BUDGET": "10000"`. A minute then sends about 50 SES requests, one a second: about 700 recipients at a rate of 14, and about 2,500 at 50 or more.
|
|
97
|
+
- If you raised the Worker's own limit with `limits.subrequests`, you may set this to match.
|
|
70
98
|
|
|
71
|
-
Never set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up.
|
|
99
|
+
Never set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up.
|
|
72
100
|
|
|
73
|
-
|
|
101
|
+
At the default, the app runs on Workers Free's limits. One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more, and an export reads 1,000 rows a page. Above it, both take 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)). Workers Free suits a list of up to about 1,000 subscribers. Past about 5,000, one send can use up the day's database writes and stop the newsletter until midnight UTC. So the Dashboard and the Subscribers screen recommend Workers Paid once the confirmed list passes 1,000, and so does an import plan that would take it past. The app also warns before a send to more than 5,000 recipients ([As your list grows](../guides/09-as-your-list-grows.md)). None of these stops you. Above the default, none appears, so set it on an account already on Workers Paid.
|
|
74
102
|
|
|
75
|
-
|
|
103
|
+
- `MIN_LEAD_SECONDS` optional
|
|
76
104
|
|
|
77
|
-
The
|
|
105
|
+
The least time every send stays cancelable before it goes out, whether it's scheduled, sent now, or moved. Default: `300`, five minutes. Refused unless it's a whole number from 60 to 86400, one day.
|
|
78
106
|
|
|
79
|
-
|
|
107
|
+
It's the window to catch a mistake, including one in a send Claude prepared. The floor is 60, since sends go out from a once-a-minute schedule. It's the least wait before every send, not a limit on how far ahead a post can be scheduled. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.
|
|
80
108
|
|
|
81
|
-
|
|
109
|
+
- `ARCHIVE_ORIGIN` optional
|
|
82
110
|
|
|
83
|
-
|
|
111
|
+
The origin archive links use, to put the archive on your website. Default: `APP_ORIGIN`. Refused unless it's a plain `http` or `https` origin with no path, and never on `example.com`, unless `PROVIDER` is `fake`. A trailing slash is dropped.
|
|
84
112
|
|
|
85
|
-
|
|
113
|
+
The app doesn't answer on that origin on its own. A Worker route on that domain, such as `example.com/archive*` in the environment's `routes`, sends the archive path to the app. Links already mailed keep working, since the app still answers on its own hostname. Crawlers read your website's own `robots.txt` for the archive pages there. The app's `/llms.txt` stays on the app's hostname, and its archive link points at your website.
|
|
86
114
|
|
|
87
|
-
|
|
115
|
+
- `MEDIA_PUBLIC_BASE` optional
|
|
88
116
|
|
|
89
|
-
|
|
117
|
+
The base URL images are served from, such as a custom domain on the image bucket. Default: `APP_ORIGIN` followed by `/media`. Refused unless it's a plain `http` or `https` URL, which may have a path, and never on `example.com`, unless `PROVIDER` is `fake`. A trailing slash is dropped.
|
|
118
|
+
|
|
119
|
+
The app's own `/media` path sends `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` with every file, so a file in the bucket never runs as a page. A bucket's custom domain sends neither, so add both with a Response Header Transform Rule on that hostname.
|
|
120
|
+
|
|
121
|
+
- `NOTIFY_FROM` optional
|
|
122
|
+
|
|
123
|
+
The sender of notifications through Cloudflare's email. Default: `kestrel@` on the app's hostname, named Kestrel, such as `Kestrel <kestrel@newsletter.example.com>`. It's used only with the `NOTIFY` binding, and must be an address on a domain set up for Cloudflare's email.
|
|
90
124
|
|
|
91
125
|
## Secrets
|
|
92
126
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
127
|
+
Store each with `npx wrangler secret put NAME --env production`, which prompts for the value.
|
|
128
|
+
|
|
129
|
+
- `ACCESS_TEAM_DOMAIN` required
|
|
130
|
+
|
|
131
|
+
Your Zero Trust team domain, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in. [Lock the dashboard with Access](../get-started/03-access.md#4-give-the-app-its-two-values) shows where to find it.
|
|
132
|
+
|
|
133
|
+
- `ACCESS_AUD` required
|
|
134
|
+
|
|
135
|
+
The Access application's audience tag. It's set together with `ACCESS_TEAM_DOMAIN` or not at all: with only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one.
|
|
136
|
+
|
|
137
|
+
- `ACCESS_ALLOWED_EMAILS` optional
|
|
101
138
|
|
|
102
|
-
|
|
139
|
+
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.
|
|
103
140
|
|
|
104
|
-
|
|
141
|
+
- `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` required with SES
|
|
142
|
+
|
|
143
|
+
The IAM user's access key, which sends through SES.
|
|
144
|
+
|
|
145
|
+
- `SNS_TOPIC_ARN` required with SES
|
|
146
|
+
|
|
147
|
+
The SNS topic the SES webhook accepts. The webhook refuses a report from every other topic, and logs `webhook.refused` naming both.
|
|
148
|
+
|
|
149
|
+
- `SES_CONFIGURATION_SET` optional, recommended with SES
|
|
150
|
+
|
|
151
|
+
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.
|
|
152
|
+
|
|
153
|
+
- `DEV_AUTH_SECRET` never on a deployed environment
|
|
154
|
+
|
|
155
|
+
Local development's stand-in for Access. A deployed instance ignores it anyway. The app honors it only when the environment looks like local development: `PROVIDER` is `fake`, Access isn't set up, and `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A real provider, Access, or a real hostname each turns it off, and Access becomes the only way in.
|
|
105
156
|
|
|
106
157
|
## Bindings and triggers
|
|
107
158
|
|
|
108
159
|
Wrangler doesn't carry bindings or vars over from the top level of `wrangler.jsonc` to an environment. Each environment's block declares its own.
|
|
109
160
|
|
|
110
|
-
|
|
111
|
-
| --- | --- | --- |
|
|
112
|
-
| `name` | the Worker's name, such as `kestrel-production` | required |
|
|
113
|
-
| `routes` | the app's hostname, attached on deploy, and any route on your website | required |
|
|
114
|
-
| `workers_dev`, `preview_urls` | the Worker's `workers.dev` and preview addresses | `false` in the template |
|
|
115
|
-
| `triggers.crons` | the once-a-minute schedule, `* * * * *` | required |
|
|
116
|
-
| `observability` | Workers Logs, where the app's log lines land | on |
|
|
117
|
-
| `d1_databases`, binding `DB` | the database: posts, subscribers, sends, and settings | required |
|
|
118
|
-
| `r2_buckets`, binding `MEDIA` | the bucket for images and your logo | required |
|
|
119
|
-
| `send_email`, binding `NOTIFY` | Cloudflare's email, for notifications | none |
|
|
161
|
+
- `name` required
|
|
120
162
|
|
|
121
|
-
|
|
163
|
+
The Worker's name, such as `kestrel-production`.
|
|
122
164
|
|
|
123
|
-
|
|
165
|
+
- `routes` required
|
|
124
166
|
|
|
125
|
-
|
|
167
|
+
The app's hostname, attached on deploy, and any route on your website. Each deploy replaces the Worker's routes with the ones in the file, so add a route here, never in the dashboard.
|
|
126
168
|
|
|
127
|
-
|
|
169
|
+
- `workers_dev` and `preview_urls` optional
|
|
128
170
|
|
|
129
|
-
|
|
171
|
+
The Worker's `workers.dev` and preview addresses. The template sets both to `false`, so the app answers only on your hostname. That's where your Access application and any rate-limiting rule apply.
|
|
130
172
|
|
|
131
|
-
|
|
173
|
+
- `triggers.crons` required
|
|
132
174
|
|
|
133
|
-
|
|
175
|
+
The once-a-minute schedule, `* * * * *`. It sends whatever is due, and the notifications about it. Without it, nothing scheduled ever goes out.
|
|
134
176
|
|
|
135
|
-
|
|
177
|
+
- `observability` optional
|
|
136
178
|
|
|
137
|
-
|
|
179
|
+
Workers Logs, where the app's log lines land. On in the template. Every invocation is logged, with no sampling, since sampling could drop the one minute that reports a stuck or missed send.
|
|
138
180
|
|
|
139
|
-
|
|
181
|
+
- `DB` required
|
|
182
|
+
|
|
183
|
+
The `d1_databases` binding for the database: posts, subscribers, sends, and settings.
|
|
184
|
+
|
|
185
|
+
- `MEDIA` required
|
|
186
|
+
|
|
187
|
+
The `r2_buckets` binding for the bucket of images and your logo.
|
|
188
|
+
|
|
189
|
+
- `NOTIFY` optional
|
|
190
|
+
|
|
191
|
+
The `send_email` binding for Cloudflare's email. When declared, notifications go through it instead of your email provider, so they still arrive when your provider refuses your account. Without it, notifications come from `FROM_ADDRESS`, through your provider. Local development ignores it, and sends notifications to a stand-in that delivers nothing.
|
|
192
|
+
|
|
193
|
+
## What the app refuses
|
|
140
194
|
|
|
141
|
-
|
|
142
|
-
- `fake` runs only when `APP_ORIGIN` is on `localhost` or `127.0.0.1`, unless `ALLOW_FAKE_PROVIDER` is `true`. On a real hostname it would open signups whose confirmations go nowhere, and record every send as delivered. With `fake`, `ALLOW_FAKE_PROVIDER`, when set, must be `true`; every other provider ignores it.
|
|
143
|
-
- `APP_ORIGIN` must be a plain `http` or `https` origin, with no path. So must `ARCHIVE_ORIGIN`, when set. `MEDIA_PUBLIC_BASE`, when set, must be a plain `http` or `https` URL, and may have a path. A trailing slash is dropped from each.
|
|
144
|
-
- A real provider needs `FROM_ADDRESS`, and every secret the table marks for it. SES also needs `AWS_REGION`. `FROM_ADDRESS` must then be an email address, optionally with a display name in front, like `Name <address>`.
|
|
145
|
-
- None of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` may still be on `example.com` or a name under it, the template's placeholder. That holds with no provider connected too, since every link the app builds would name it. Only `fake` allows it.
|
|
146
|
-
- `SUBREQUEST_BUDGET`, `MIN_LEAD_SECONDS`, and `SES_MAX_SEND_RATE` must be whole numbers above zero. `MIN_LEAD_SECONDS` must also be within its bounds. Any other value is refused, never adjusted. The one exception is a `SUBREQUEST_BUDGET` below 30, which is raised to 30.
|
|
147
|
-
- In local development, `SIMULATE_SENDS` must be off (`off`, `0`, `false`, `no`, or empty), `not-connected` to run as a deployment with no email provider yet, or the provider a local send models: `ses` or `generic` (also `1`, `on`, `true`, or `yes`), optionally followed by `:none` for no injected failures or `:realistic`, the default. Case doesn't matter. A deployed instance ignores it.
|
|
148
|
-
- `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` are set together or not at all. With only one, the app could verify no sign-in and would let no one into the editor, so it refuses and names the missing one. `ACCESS_TEAM_DOMAIN` must be your team's hostname, such as `your-team.cloudflareaccess.com`. An `https://` or `http://` in front and a trailing slash are dropped, but anything else, such as a path or a port, is refused, since it would fail every sign-in.
|
|
195
|
+
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. Case doesn't matter. A deployed instance ignores it.
|
|
149
196
|
|
|
150
197
|
Until it's fixed, no sends go out, and each place says what its reader can act on:
|
|
151
198
|
|
|
@@ -58,8 +58,8 @@ DMARC tells an inbox what to do with mail that fails both SPF and DKIM, and asks
|
|
|
58
58
|
_dmarc.send.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
-
|
|
62
|
-
-
|
|
61
|
+
- `p=none` is monitor mode. Inboxes report on failing mail, but deliver it as usual.
|
|
62
|
+
- `rua` is where reports go. They arrive as attachments, usually one a day from each inbox provider.
|
|
63
63
|
|
|
64
64
|
Leave alignment relaxed, the default, with no `aspf=s` or `adkim=s`. With a custom MAIL FROM domain, your return path is a subdomain of your sending name, so strict SPF alignment would fail.
|
|
65
65
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
-- 0012_unsubscribe_cause: each mark has one owner (SPEC §7).
|
|
2
|
+
--
|
|
3
|
+
-- The consent state is the person's, and now keeps why they left: their own unsubscribe
|
|
4
|
+
-- (`reader`), the publisher on their request (`publisher`), a complaint about the mail
|
|
5
|
+
-- (`complaint`), an erasure (`erased`), or a list brought from elsewhere that says they left
|
|
6
|
+
-- with no cause (`import`). The suppression is only the sender's side: a bounce, an import's
|
|
7
|
+
-- bounce, or a block. A complaint and an erasure, which were suppressions, become unsubscribes
|
|
8
|
+
-- with their cause, and the suppression reasons that held them go.
|
|
9
|
+
--
|
|
10
|
+
-- `unsubscribe_cause` is set while a subscriber has left and not confirmed since: on every
|
|
11
|
+
-- unsubscribed row, and on a pending row that left and signed up again, where it is kept
|
|
12
|
+
-- until they confirm, as `unsubscribed_at` is. An erased subscriber holds only the address
|
|
13
|
+
-- and when, so its unsubscribe token, origin, and opt-in may now be empty, and only on such
|
|
14
|
+
-- a row. A column can be neither added with those rules nor made nullable in place, so the
|
|
15
|
+
-- table is rebuilt, copying every row once, with its indexes made again.
|
|
16
|
+
--
|
|
17
|
+
-- What can't be recovered:
|
|
18
|
+
-- - Every unsubscribe before this reads as the reader's own. The record never said whether
|
|
19
|
+
-- the reader, the publisher, or an import unsubscribed someone, so `reader` stands for all.
|
|
20
|
+
-- - An erasure that found a bounce, a complaint, or a block already on the address kept that
|
|
21
|
+
-- one and left no trace of itself, so its address migrates as that suppression alone (a
|
|
22
|
+
-- complaint's as unsubscribed by a complaint), with no subscriber saying it was erased. Its
|
|
23
|
+
-- bounce or block keeps the detail it had.
|
|
24
|
+
-- - An erasure's marker on an address with a confirmed or unsubscribed subscriber came from an
|
|
25
|
+
-- import that said the address was erased. The person is now unsubscribed by an erasure and
|
|
26
|
+
-- their record stripped, as an erasure here would. A pending subscriber with the marker
|
|
27
|
+
-- signed up again after it, so they stay pending, keeping that they left by an erasure until
|
|
28
|
+
-- they confirm.
|
|
29
|
+
-- - A complaint suppression becomes an unsubscribe whatever the subscriber's state, as every
|
|
30
|
+
-- complaint now is. An address it held with no subscriber is recorded as one, unsubscribed
|
|
31
|
+
-- by a complaint, so the publisher still can't add it back by hand. Nobody was erased, so it
|
|
32
|
+
-- gets an unsubscribe token, with how they joined recorded as unknown.
|
|
33
|
+
|
|
34
|
+
CREATE TABLE subscribers_next (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
email TEXT NOT NULL UNIQUE CHECK (email = lower(email)),
|
|
37
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
38
|
+
CHECK (status IN ('pending', 'confirmed', 'unsubscribed')),
|
|
39
|
+
confirm_token TEXT UNIQUE,
|
|
40
|
+
confirm_sent_at INTEGER,
|
|
41
|
+
confirm_attempt_at INTEGER,
|
|
42
|
+
unsub_token TEXT UNIQUE,
|
|
43
|
+
created_at INTEGER NOT NULL,
|
|
44
|
+
confirmed_at INTEGER,
|
|
45
|
+
unsubscribed_at INTEGER,
|
|
46
|
+
origin TEXT DEFAULT 'kestrel'
|
|
47
|
+
CHECK (origin = lower(origin) AND length(origin) BETWEEN 1 AND 40),
|
|
48
|
+
opt_in TEXT DEFAULT 'double' CHECK (opt_in IN ('double', 'single', 'unknown')),
|
|
49
|
+
consent_evidence TEXT CHECK (consent_evidence IS NULL OR json_valid(consent_evidence)),
|
|
50
|
+
import_id TEXT REFERENCES imports (id),
|
|
51
|
+
unsubscribe_cause TEXT
|
|
52
|
+
CHECK (unsubscribe_cause IN ('reader', 'publisher', 'complaint', 'erased', 'import')),
|
|
53
|
+
CHECK (status <> 'unsubscribed' OR unsubscribe_cause IS NOT NULL),
|
|
54
|
+
CHECK (status <> 'confirmed' OR unsubscribe_cause IS NULL),
|
|
55
|
+
CHECK ((unsub_token IS NOT NULL AND origin IS NOT NULL AND opt_in IS NOT NULL)
|
|
56
|
+
OR (status = 'unsubscribed' AND unsubscribe_cause = 'erased'))
|
|
57
|
+
) STRICT;
|
|
58
|
+
|
|
59
|
+
-- Every unsubscribe before this reads as the reader own, as does a pending row that left.
|
|
60
|
+
INSERT INTO subscribers_next (id, email, status, confirm_token, confirm_sent_at, confirm_attempt_at,
|
|
61
|
+
unsub_token, created_at, confirmed_at, unsubscribed_at, origin, opt_in,
|
|
62
|
+
consent_evidence, import_id, unsubscribe_cause)
|
|
63
|
+
SELECT id, email, status, confirm_token, confirm_sent_at, confirm_attempt_at,
|
|
64
|
+
unsub_token, created_at, confirmed_at, unsubscribed_at, origin, opt_in,
|
|
65
|
+
consent_evidence, import_id,
|
|
66
|
+
(SELECT 'reader' WHERE status = 'unsubscribed' OR (status = 'pending' AND unsubscribed_at IS NOT NULL))
|
|
67
|
+
FROM subscribers;
|
|
68
|
+
|
|
69
|
+
DROP TABLE subscribers;
|
|
70
|
+
ALTER TABLE subscribers_next RENAME TO subscribers;
|
|
71
|
+
CREATE INDEX idx_subscribers_status ON subscribers (status);
|
|
72
|
+
CREATE INDEX idx_subscribers_created ON subscribers (created_at);
|
|
73
|
+
|
|
74
|
+
-- A complaint is leaving: its subscriber is unsubscribed by it, the stronger cause. One who
|
|
75
|
+
-- had not left leaves when the complaint came, and one who had keeps when they left.
|
|
76
|
+
UPDATE subscribers
|
|
77
|
+
SET unsubscribed_at = (SELECT p.created_at FROM suppressions p WHERE p.email = subscribers.email)
|
|
78
|
+
WHERE status <> 'unsubscribed' AND unsubscribed_at IS NULL
|
|
79
|
+
AND email IN (SELECT email FROM suppressions WHERE reason IN ('complaint', 'import_complaint'));
|
|
80
|
+
|
|
81
|
+
UPDATE subscribers SET status = 'unsubscribed', unsubscribe_cause = 'complaint'
|
|
82
|
+
WHERE email IN (SELECT email FROM suppressions WHERE reason IN ('complaint', 'import_complaint'));
|
|
83
|
+
|
|
84
|
+
INSERT INTO subscribers (id, email, status, unsub_token, created_at, unsubscribed_at, origin, opt_in, unsubscribe_cause)
|
|
85
|
+
SELECT lower(hex(randomblob(16))), p.email, 'unsubscribed', lower(hex(randomblob(32))), p.created_at, p.created_at,
|
|
86
|
+
'unknown', 'unknown', 'complaint'
|
|
87
|
+
FROM suppressions p
|
|
88
|
+
WHERE p.reason IN ('complaint', 'import_complaint')
|
|
89
|
+
AND NOT EXISTS (SELECT 1 FROM subscribers s WHERE s.email = p.email);
|
|
90
|
+
|
|
91
|
+
-- An erasure marker: the person is unsubscribed by the erasure, their record holding only
|
|
92
|
+
-- the address and when, with both tokens and how they joined discarded.
|
|
93
|
+
UPDATE subscribers
|
|
94
|
+
SET status = 'unsubscribed',
|
|
95
|
+
unsubscribe_cause = 'erased',
|
|
96
|
+
created_at = (SELECT p.created_at FROM suppressions p WHERE p.email = subscribers.email),
|
|
97
|
+
unsubscribed_at = (SELECT p.created_at FROM suppressions p WHERE p.email = subscribers.email),
|
|
98
|
+
confirmed_at = NULL,
|
|
99
|
+
confirm_token = NULL,
|
|
100
|
+
confirm_sent_at = NULL,
|
|
101
|
+
confirm_attempt_at = NULL,
|
|
102
|
+
unsub_token = NULL,
|
|
103
|
+
origin = NULL,
|
|
104
|
+
opt_in = NULL,
|
|
105
|
+
consent_evidence = NULL,
|
|
106
|
+
import_id = NULL
|
|
107
|
+
WHERE status IN ('confirmed', 'unsubscribed')
|
|
108
|
+
AND email IN (SELECT email FROM suppressions WHERE reason = 'erased');
|
|
109
|
+
|
|
110
|
+
UPDATE subscribers
|
|
111
|
+
SET unsubscribe_cause = 'erased',
|
|
112
|
+
unsubscribed_at = COALESCE(unsubscribed_at,
|
|
113
|
+
(SELECT p.created_at FROM suppressions p WHERE p.email = subscribers.email))
|
|
114
|
+
WHERE status = 'pending'
|
|
115
|
+
AND email IN (SELECT email FROM suppressions WHERE reason = 'erased');
|
|
116
|
+
|
|
117
|
+
INSERT INTO subscribers (id, email, status, unsub_token, created_at, unsubscribed_at, origin, opt_in, unsubscribe_cause)
|
|
118
|
+
SELECT lower(hex(randomblob(16))), p.email, 'unsubscribed', NULL, p.created_at, p.created_at,
|
|
119
|
+
NULL, NULL, 'erased'
|
|
120
|
+
FROM suppressions p
|
|
121
|
+
WHERE p.reason = 'erased'
|
|
122
|
+
AND NOT EXISTS (SELECT 1 FROM subscribers s WHERE s.email = p.email);
|
|
123
|
+
|
|
124
|
+
-- The suppression keeps only the sender side. A STRICT table CHECK cannot be altered, so
|
|
125
|
+
-- it is rebuilt with the three reasons left, and the rows the others held, now carried by
|
|
126
|
+
-- their subscribers, are left behind.
|
|
127
|
+
CREATE TABLE suppressions_next (
|
|
128
|
+
email TEXT PRIMARY KEY CHECK (email = lower(email)),
|
|
129
|
+
reason TEXT NOT NULL CHECK (reason IN ('bounce', 'manual', 'import_bounce')),
|
|
130
|
+
detail TEXT,
|
|
131
|
+
created_at INTEGER NOT NULL
|
|
132
|
+
) STRICT;
|
|
133
|
+
|
|
134
|
+
INSERT INTO suppressions_next (email, reason, detail, created_at)
|
|
135
|
+
SELECT email, reason, detail, created_at FROM suppressions
|
|
136
|
+
WHERE reason IN ('bounce', 'manual', 'import_bounce');
|
|
137
|
+
|
|
138
|
+
DROP TABLE suppressions;
|
|
139
|
+
ALTER TABLE suppressions_next RENAME TO suppressions;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- 0013_who_acted_client: the client a change came through (SPEC §11, Who acted).
|
|
2
|
+
--
|
|
3
|
+
-- Who acted is a person or a service token, plus the client the request came through, which
|
|
4
|
+
-- names itself on each request in one short lowercase word (`editor`, `claude-code`). A
|
|
5
|
+
-- revision already records its author (a person's email, or `service`) and an import its
|
|
6
|
+
-- principal; each now records the client beside it. It is null when the request named none,
|
|
7
|
+
-- and always on a service token's row, since the token already names the program.
|
|
8
|
+
--
|
|
9
|
+
-- What can't be recovered: no client was recorded before this, and nothing tells a person's
|
|
10
|
+
-- change in the editor from one through another program, so every row a person made is read
|
|
11
|
+
-- as the editor's, the one client they were offered. Those rows are rewritten once; a service
|
|
12
|
+
-- token's are left as they are.
|
|
13
|
+
|
|
14
|
+
ALTER TABLE post_revisions ADD COLUMN client TEXT
|
|
15
|
+
CHECK (client = lower(client) AND length(client) BETWEEN 1 AND 32);
|
|
16
|
+
ALTER TABLE imports ADD COLUMN client TEXT
|
|
17
|
+
CHECK (client = lower(client) AND length(client) BETWEEN 1 AND 32);
|
|
18
|
+
|
|
19
|
+
UPDATE post_revisions SET client = 'editor' WHERE author IS NOT NULL AND author <> 'service';
|
|
20
|
+
UPDATE imports SET client = 'editor' WHERE principal_kind = 'human';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- 0014_send_acts: who acted on a send, and when (SPEC §6, §11, Who acted).
|
|
2
|
+
--
|
|
3
|
+
-- A send's acts are a log: each schedule, Send now, reschedule, cancel, and Resolve adds a row with
|
|
4
|
+
-- who acted (the who-acted shape as JSON: `{"kind":"human","email":…,"client":…}` or
|
|
5
|
+
-- `{"kind":"service","token_id":…,"client":null}`) and when, written in the same batch as the
|
|
6
|
+
-- change it records. A schedule, Send now, or reschedule keeps the fire time it set, and a Resolve
|
|
7
|
+
-- the resolution it chose. The send view's latest actor of each act is read from it.
|
|
8
|
+
--
|
|
9
|
+
-- What can't be recovered: no act was recorded before this, so a send made before it has no
|
|
10
|
+
-- rows, and reads as acted on by no one recorded.
|
|
11
|
+
|
|
12
|
+
CREATE TABLE send_acts (
|
|
13
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
14
|
+
send_id TEXT NOT NULL REFERENCES sends (id),
|
|
15
|
+
act TEXT NOT NULL CHECK (act IN ('schedule', 'send_now', 'reschedule', 'cancel', 'resolve')),
|
|
16
|
+
at INTEGER NOT NULL,
|
|
17
|
+
actor TEXT NOT NULL
|
|
18
|
+
CHECK (json_valid(actor) AND json_extract(actor, '$.kind') IN ('human', 'service')),
|
|
19
|
+
fire_at INTEGER CHECK ((fire_at IS NOT NULL) = (act IN ('schedule', 'send_now', 'reschedule'))),
|
|
20
|
+
resolution TEXT
|
|
21
|
+
CHECK ((resolution IS NOT NULL) = (act = 'resolve')
|
|
22
|
+
AND (resolution IS NULL OR resolution IN ('unsent', 'accepted')))
|
|
23
|
+
) STRICT;
|
|
24
|
+
|
|
25
|
+
CREATE INDEX idx_send_acts_send ON send_acts (send_id, id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kurtbruns/kestrel",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.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",
|
package/template/README.md
CHANGED
|
@@ -22,6 +22,8 @@ Open the editor at http://localhost:8787/dashboard/. Email goes to a stand-in th
|
|
|
22
22
|
| `npm run seed`, `npm run reset` | Loads the demo publication into the local app, or empties it |
|
|
23
23
|
| `npm run sync-migrations` | Copies the installed release's migrations into `migrations/` |
|
|
24
24
|
| `npm run check-context` | Compares the Claude files in `.claude/` with the installed release's |
|
|
25
|
+
| `npx -p @kurtbruns/kestrel kestrel login <url>` | Signs Claude Code in to the deployed instance as you, in your browser |
|
|
26
|
+
| `npx -p @kurtbruns/kestrel kestrel api <method> <path>` | Calls the API as you, via Claude Code (`--local` for the local app); `--list` lists the routes and `help <method> <path>` describes one |
|
|
25
27
|
| `npx wrangler deploy --env production` | Deploys the instance |
|
|
26
28
|
| `npx wrangler d1 migrations apply DB --remote --env production` | Applies new migrations to the production database |
|
|
27
29
|
|