@kurtbruns/kestrel 1.12.0 → 1.13.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 +103 -1
- package/cli/dev-api.mjs +1 -1
- package/cli/seed.mjs +1 -1
- package/dist/public/dashboard/app.js +341 -291
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +395 -116
- package/dist/worker/index.js +8722 -5392
- package/docs/get-started/02-deploy.md +1 -1
- package/docs/get-started/03-access.md +3 -3
- 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 +1 -1
- package/docs/guides/01-connect-claude.md +7 -4
- package/docs/guides/09-as-your-list-grows.md +6 -2
- package/docs/publishing/01-your-list.md +35 -30
- package/docs/publishing/02-sending-rules.md +3 -3
- package/docs/publishing/04-write-a-post.md +2 -2
- package/docs/publishing/06-troubleshooting.md +10 -5
- package/docs/publishing/07-working-with-claude.md +1 -2
- package/docs/publishing/09-share-your-subscribe-link.md +2 -2
- package/docs/publishing/10-check-your-setup.md +2 -2
- package/docs/publishing/11-email-template.md +2 -0
- package/docs/reference/01-configuration.md +2 -2
- package/migrations/0012_unsubscribe_cause.sql +139 -0
- package/package.json +1 -1
- package/template/claude/CLAUDE.md +11 -10
- package/template/claude/skills/migrate-list/SKILL.md +5 -4
|
@@ -202,7 +202,7 @@ This deploys the release your instance runs, with your settings. The output ends
|
|
|
202
202
|
|
|
203
203
|
In a browser, `https://newsletter.example.com/dashboard/` says the editor is locked until you set up Access. That's expected.
|
|
204
204
|
|
|
205
|
-
1. In a browser, `https://newsletter.example.com/` shows the newsletter's public
|
|
205
|
+
1. In a browser, `https://newsletter.example.com/` shows the newsletter's public home page. Its subscribe page, `/subscribe`, says signups aren't open yet, until Connect Amazon SES.
|
|
206
206
|
|
|
207
207
|
If the public pages instead say the newsletter is temporarily unavailable, and `/health` answers `500` with a body like `{"error":"invalid_config","variable":"APP_ORIGIN",…}`, the setting it names is missing, malformed, or still on `example.com`. Fix it in `wrangler.jsonc`, commit, and deploy again.
|
|
208
208
|
|
|
@@ -35,7 +35,7 @@ The editor lives under `/dashboard`, and the API under `/api`. One application c
|
|
|
35
35
|
| `dashboard` | the editor |
|
|
36
36
|
| `api` | everything the editor and Claude read and change: posts, sends, subscribers, settings, and the in-app docs |
|
|
37
37
|
|
|
38
|
-
Each path covers everything under it. Leave every other path out. The
|
|
38
|
+
Each path covers everything under it. Leave every other path out. The home page, the archive, subscribing, unsubscribing, images, and your email provider's webhooks must stay public.
|
|
39
39
|
|
|
40
40
|
1. **(Optional)** Choose how long a sign-in lasts. The application's session duration defaults to 24 hours, so everyone signs in again each day. You can set anything from immediately to one month, as Cloudflare's [session management](https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/session-management/) describes. A longer session suits a publisher on their own computer, and a shorter one is safer on a shared one.
|
|
41
41
|
|
|
@@ -49,7 +49,7 @@ Every Access application denies everyone until a policy allows them.
|
|
|
49
49
|
- **Action:** Allow
|
|
50
50
|
- **Include:** the **Emails** selector, with your email address
|
|
51
51
|
|
|
52
|
-
1. Keep the login method a new Zero Trust organization starts with: you sign
|
|
52
|
+
1. Keep the login method a new Zero Trust organization starts with, Cloudflare's own: you enter your email, and confirm the sign-in from the message Cloudflare sends to it. By default it admits only members of your Cloudflare account, so the email in your `Publishers` policy should be one. To let someone sign in another way, such as a one-time code by email, add it under **Zero Trust → Integrations → Identity providers** first.
|
|
53
53
|
|
|
54
54
|
1. Select **Create**.
|
|
55
55
|
|
|
@@ -57,7 +57,7 @@ Every Access application denies everyone until a policy allows them.
|
|
|
57
57
|
|
|
58
58
|
The app verifies each login against your team domain, and checks that it was issued for this application.
|
|
59
59
|
|
|
60
|
-
1. Find your team domain under **Zero Trust → Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`.
|
|
60
|
+
1. Find your team domain under **Zero Trust → Settings**. It looks like `YOUR_TEAM.cloudflareaccess.com`. It belongs to your Zero Trust organization, not to this application, so every application in your account shares it. It's also where a signed-out visit to `https://newsletter.example.com/dashboard/` now takes you.
|
|
61
61
|
|
|
62
62
|
1. Find the application's audience tag. Under **Access controls → Applications**, select **Configure** on your application. On the **Additional settings** tab, copy the **Application Audience (AUD) Tag**.
|
|
63
63
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Connect Amazon SES
|
|
2
2
|
|
|
3
|
-
Kestrel sends the newsletter's posts through Amazon SES, Amazon's email service. SES only delivers the mail: Kestrel keeps the list, each subscriber's consent, and the record of every send. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.
|
|
3
|
+
Kestrel sends the newsletter's posts through Amazon SES, Amazon's email service. SES only delivers the mail: Kestrel keeps the list, each subscriber's consent, and the record of every send. SES prices every email alike, with no split between transactional and marketing mail, and costs the least at every list size above a tiny one. AWS reviews your account before it lets you send to anyone but yourself. This page asks for that early, and you test in the sandbox while you wait.
|
|
4
4
|
|
|
5
5
|
In this guide, you verify your sending hostname with SES and add a DMARC record. You forward readers' replies to the publisher's inbox, and ask AWS for production access. You also route bounces and complaints to the app through SNS. Then you give the app its AWS credentials, set the provider, and deploy.
|
|
6
6
|
|
|
@@ -227,7 +227,7 @@ Until now the app has run with no email provider connected, refusing to send. Se
|
|
|
227
227
|
}
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
-
On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan's limit, so each minute sends more. Leave it out on Workers Free. [As your list grows](../guides/09-as-your-list-grows.md) says
|
|
230
|
+
On Workers Paid, also add `"SUBREQUEST_BUDGET": "10000"`, the plan's limit, so each minute sends more. Leave it out on Workers Free, which suits a list of up to about 1,000 subscribers. [As your list grows](../guides/09-as-your-list-grows.md) says why.
|
|
231
231
|
|
|
232
232
|
When AWS grants production access, set `SES_MAX_SEND_RATE` to the maximum send rate on the **Account dashboard**, and deploy again. The app keeps to that rate, so SES doesn't throttle your send.
|
|
233
233
|
|
|
@@ -77,7 +77,7 @@ Every email carries an unsubscribe link, and a header that lets mail clients sho
|
|
|
77
77
|
|
|
78
78
|
## 6. Check that bounces and complaints come back
|
|
79
79
|
|
|
80
|
-
When an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.
|
|
80
|
+
When an address bounces or marks your mail as spam, SES tells the app through SNS. The app then stops mailing that address: a bounce suppresses it, and a complaint unsubscribes it. SES has test addresses that act out each case without reaching a real person, and the sandbox lets you mail them.
|
|
81
81
|
|
|
82
82
|
1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:
|
|
83
83
|
|
|
@@ -86,9 +86,9 @@ When an address bounces or marks your mail as spam, SES tells the app through SN
|
|
|
86
86
|
|
|
87
87
|
Each one gets a confirmation email, which bounces or is marked as spam.
|
|
88
88
|
|
|
89
|
-
1. In the editor, **Subscribers** lists
|
|
89
|
+
1. In the editor, **Subscribers** lists the bounce address as **Pending** with a **bounced** flag, and the complaint address as **Unsubscribed**. Open its row: its page says it left by a complaint. The app mails neither again.
|
|
90
90
|
|
|
91
|
-
If
|
|
91
|
+
If the bounce address has no flag, or the complaint address is still pending, SES's events aren't reaching the app. Check the configuration set and the SNS subscription in [Connect Amazon SES](04-ses.md#6-send-bounces-and-complaints-to-sns).
|
|
92
92
|
|
|
93
93
|
## 7. Read the logs
|
|
94
94
|
|
|
@@ -122,7 +122,7 @@ The checks leave test addresses on the list. Erase them, so the publisher starts
|
|
|
122
122
|
|
|
123
123
|
1. Erase your second address too, if it won't be a reader.
|
|
124
124
|
|
|
125
|
-
Erasing keeps
|
|
125
|
+
Erasing keeps only the address, so it's never mailed again, but the person can subscribe again through the subscribe page. Erasing your own address doesn't lock you out.
|
|
126
126
|
|
|
127
127
|
When every section gives the result it describes, your instance is ready for subscribers.
|
|
128
128
|
|
|
@@ -21,5 +21,5 @@ Each of these guides adds one thing, and you can come back to any of them later.
|
|
|
21
21
|
- [Connect Claude to the API](../guides/01-connect-claude.md), so Claude can draft, proofread, and schedule posts through the same API the editor uses.
|
|
22
22
|
- [Notifications through Cloudflare's email](../guides/03-notifications.md), so problems are reported even when the email provider has stopped sending.
|
|
23
23
|
- [Rate-limit the subscribe form](../guides/05-rate-limit.md), before the subscribe link is shared widely.
|
|
24
|
-
- [As your list grows](../guides/09-as-your-list-grows.md), from about
|
|
24
|
+
- [As your list grows](../guides/09-as-your-list-grows.md), from about 1,000 subscribers.
|
|
25
25
|
- [Upgrade to a new release](../guides/07-upgrade.md), when one comes out.
|
|
@@ -72,10 +72,13 @@ Your instance's `.claude/CLAUDE.md` already tells Claude Code how to call the AP
|
|
|
72
72
|
```
|
|
73
73
|
Kestrel's API is at $KESTREL_URL. Send the CF-Access-Client-Id and
|
|
74
74
|
CF-Access-Client-Secret headers from your environment with every request.
|
|
75
|
-
Read GET /api/reference first,
|
|
75
|
+
Read GET /api/reference.md first, then the reference of the resource
|
|
76
|
+
you're working with, such as GET /api/reference/sends.md, before calling it.
|
|
76
77
|
```
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
For Claude the reference is Markdown in two levels: `GET /api/reference.md` is the index, with what every route shares and each route on one line, and `GET /api/reference/<resource>.md` holds one resource's routes in full, so Claude reads only what its work needs. The JSON at `GET /api/reference` is the whole reference in one answer, as the editor reads it, and `GET /api/openapi.json` is the same reference as an OpenAPI 3.1 document, for a tool that imports one. The docs are served as Markdown too: `GET /api/docs/publishing.md` lists the publisher's pages, `GET /api/docs/self-hosting.md` this guide's, and each page is at `GET /api/docs/<slug>.md`.
|
|
80
|
+
|
|
81
|
+
An agent given only your app's address finds these routes too. `GET /llms.txt` is public. It names the publication, its archive and subscribe page, and the reference and docs indexes, and links this guide for the token.
|
|
79
82
|
|
|
80
83
|
Claude reads the reference, and from there can list the posts, write a draft, and schedule it. The reference lists the body types each route takes. Most take JSON, sent with `Content-Type: application/json`. An image upload takes a multipart form, or the image file itself.
|
|
81
84
|
|
|
@@ -86,10 +89,10 @@ To bring subscribers over from another service, ask Claude to import the list, a
|
|
|
86
89
|
1. Ask Claude to call `GET /api/whoami`. The app answers with a program's identity, not yours:
|
|
87
90
|
|
|
88
91
|
```
|
|
89
|
-
{"principal":{"kind":"service","
|
|
92
|
+
{"principal":{"kind":"service","token_id":"e367c0ffee5eb4.access"},"auth":{"mode":"access"}}
|
|
90
93
|
```
|
|
91
94
|
|
|
92
|
-
The `
|
|
95
|
+
The `token_id` is the Client ID Zero Trust shows beside `kestrel-claude`. If it's a different one, Claude Code is using another token.
|
|
93
96
|
|
|
94
97
|
1. Ask Claude to list the posts. It answers with the posts the editor shows.
|
|
95
98
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# As your list grows
|
|
2
2
|
|
|
3
|
-
A new instance runs on Cloudflare's free Workers plan with your starting Amazon SES limits.
|
|
3
|
+
A new instance runs on Cloudflare's free Workers plan with your starting Amazon SES limits. 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. Two limits matter as the list grows: how fast SES lets you send, and how many database writes the free plan allows a day.
|
|
4
4
|
|
|
5
5
|
In this guide, you raise your SES limits, which helps on either plan, and upgrade to Workers Paid, which a larger list needs. Then you see how long a send takes after each.
|
|
6
6
|
|
|
@@ -27,7 +27,9 @@ SES caps how many emails your account sends a day, and how many a second. A send
|
|
|
27
27
|
|
|
28
28
|
## 2. Upgrade to Workers Paid
|
|
29
29
|
|
|
30
|
-
On the free plan, [D1](https://developers.cloudflare.com/d1/platform/pricing/) allows
|
|
30
|
+
On the free plan, [D1](https://developers.cloudflare.com/d1/platform/pricing/) allows 100,000 written rows a day, reset at midnight UTC. A send writes about 14 rows a recipient, receipts included, and an import about 7 for each new subscriber. So at 1,000 subscribers, an import of the whole list and two sends on one day use about a third of the day. Once the day's rows are spent, the whole app stops until midnight UTC, the SES webhook with it, so that day's bounces and complaints are lost, and suppress or unsubscribe no one.
|
|
31
|
+
|
|
32
|
+
It's recommended to upgrade from about 1,000 subscribers. Past that, the Dashboard and the Subscribers screen say so, and so does an import that would take the list past it. Past about 5,000, a send that could use up the day on its own also warns before it's scheduled. The app doesn't count the day's writes, so between the two, two large sends or several imports and a send on one UTC day can still use it up without a warning at that moment.
|
|
31
33
|
|
|
32
34
|
1. Upgrade your Cloudflare account to the $5 [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/), which has no daily limit on writes.
|
|
33
35
|
|
|
@@ -46,6 +48,8 @@ On the free plan, [D1](https://developers.cloudflare.com/d1/platform/pricing/) a
|
|
|
46
48
|
npx wrangler deploy --env production
|
|
47
49
|
```
|
|
48
50
|
|
|
51
|
+
Over years, one more limit can matter: on the free plan, one [D1 database](https://developers.cloudflare.com/d1/platform/limits/) holds at most 500 MB, and every send keeps a row for each recipient in its record. Workers Paid raises it to 10 GB.
|
|
52
|
+
|
|
49
53
|
## How long a send takes
|
|
50
54
|
|
|
51
55
|
| Setup | 1,000 subscribers | 5,000 subscribers | 10,000 subscribers |
|
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
Moving to Kestrel from another newsletter service means bringing your subscribers with you, along with the consent they gave there. This guide explains how to export your list from the old service, convert it to Kestrel's format, and import it under **Subscribers → Import**. It also covers what carries over, the limits on an import, and taking your list out again. Kestrel shows you what an import will do before anything changes, and an import never emails your subscribers.
|
|
4
4
|
|
|
5
|
-
**The first decision is whether to upgrade to Cloudflare's Workers Paid plan.**
|
|
5
|
+
**The first decision is whether to upgrade to Cloudflare's Workers Paid plan.** 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. With more than about 1,000 subscribers, it's recommended to upgrade before you import. Workers Paid is $5 a month and has no daily cap, and [As your list grows](../guides/09-as-your-list-grows.md) walks through the upgrade.
|
|
6
6
|
|
|
7
7
|
If you're starting without a list, skip this step and go on to [Write and send a post](04-write-a-post.md).
|
|
8
8
|
|
|
9
9
|
## 1. Export from your old service
|
|
10
10
|
|
|
11
|
-
Export everyone your old service knows about, not only your current subscribers. Unsubscribes and bounces matter too: once imported, Kestrel never mails someone who left or an address that fails. Include any consent details the export offers, such as signup dates, IP addresses, or form names. Kestrel keeps them with each subscriber as evidence of their consent.
|
|
11
|
+
Export everyone your old service knows about, not only your current subscribers. Unsubscribes, complaints, and bounces matter too: once imported, Kestrel never mails someone who left or an address that fails. Include any consent details the export offers, such as signup dates, IP addresses, or form names. Kestrel keeps them with each subscriber as evidence of their consent.
|
|
12
12
|
|
|
13
13
|
## 2. Convert it to Kestrel's format
|
|
14
14
|
|
|
15
15
|
Every service's export is different. Kestrel never guesses what one means, because a wrong guess about who agreed to be emailed can't be taken back. So you convert the export first, to one of two kinds of file:
|
|
16
16
|
|
|
17
|
-
- **A CSV in Kestrel's format (preferred).** One row per address, with its status, how and where consent was collected, and any bounce
|
|
17
|
+
- **A CSV in Kestrel's format (preferred).** One row per address, with its status, how and where consent was collected, why anyone who left did, and any bounce. **Download example file (CSV)** on the import screen shows each case. Save your file as CSV UTF-8.
|
|
18
18
|
- **A plain list (`.txt`).** One address per line, all of one kind: subscribed, unsubscribed, or bounced. Nothing else carries over, so use it only when addresses are all you have.
|
|
19
19
|
|
|
20
20
|
A CSV in Kestrel's format looks like this, with the five columns that decide what happens to each row:
|
|
@@ -39,26 +39,28 @@ Under **Subscribers → Import**, choose your file. Nothing changes yet: Kestrel
|
|
|
39
39
|
|
|
40
40
|
## What carries over
|
|
41
41
|
|
|
42
|
-
Asking your whole list to confirm again would lose readers who already said yes. So an import carries over the consent each person gave on your old service, and your word for it. Imported subscribers are **Confirmed** and receive your posts, and
|
|
42
|
+
Asking your whole list to confirm again would lose readers who already said yes. So an import carries over the consent each person gave on your old service, and your word for it. Imported subscribers are **Confirmed** and receive your posts, and each one's page, opened from their row, shows where they came from, such as "Imported · Sendy, single opt-in".
|
|
43
|
+
|
|
44
|
+
**Why someone left carries over too.** Your file can say a person unsubscribed themselves, that you unsubscribed them when they asked, that they reported your email as spam, or that they asked to be erased. Someone who complained or was erased can't be added back by hand, only by subscribing themselves, and an erasure is recorded with the address alone.
|
|
43
45
|
|
|
44
46
|
A few things hold whatever the file says:
|
|
45
47
|
|
|
46
48
|
- **Someone who unsubscribed here stays unsubscribed,** until they confirm again themselves.
|
|
47
|
-
- **A bounce
|
|
49
|
+
- **A bounce or a block here always wins.** An import never lifts one.
|
|
48
50
|
- **A reader's newer choice stands.** Someone who confirmed here after your old service says they left stays subscribed.
|
|
49
51
|
|
|
50
52
|
## Limits
|
|
51
53
|
|
|
52
54
|
- **Rows.** Up to 10,000 in one import, or 1,000 on Cloudflare's free Workers plan. The import screen says which applies. Split a larger list into several files, and import them one at a time.
|
|
53
|
-
- **
|
|
55
|
+
- **The free plan.** On the free Workers plan, an import that would take your list past about 1,000 subscribers recommends Workers Paid, and so do the Dashboard and the Subscribers screen once it's past. A send to more than about 5,000 warns too. None of these stops anything.
|
|
54
56
|
|
|
55
57
|
## Taking your list out
|
|
56
58
|
|
|
57
|
-
**Subscribers → Export** downloads your whole list as one CSV in the same format. It holds every subscriber, how they joined, and every bounced
|
|
59
|
+
**Subscribers → Export** downloads your whole list as one CSV in the same format. It holds every subscriber, how they joined, why anyone who left did, and every bounced or blocked address. Keep it as a backup, or import it into another Kestrel to carry the list over.
|
|
58
60
|
|
|
59
61
|
## The file format
|
|
60
62
|
|
|
61
|
-
This section defines `kestrel-subscribers/
|
|
63
|
+
This section defines `kestrel-subscribers/2` in full, for whoever converts a file, by hand or with Claude. The API serves the same definition at `GET /api/imports/format`, generated from the one the importer reads with. It serves the example files at `GET /api/imports/template.csv` and `GET /api/imports/template.txt`. A file's name decides its kind, `.csv` or `.txt`, and anything else is refused.
|
|
62
64
|
|
|
63
65
|
### The CSV
|
|
64
66
|
|
|
@@ -70,29 +72,30 @@ The file is a CSV saved as UTF-8 (in a spreadsheet, **CSV UTF-8**). A header row
|
|
|
70
72
|
| `status` | yes | `subscribed`, `pending`, `unsubscribed`, `none` | The consent claim. `none` is a known address with no consent claim, present only to carry a suppression. |
|
|
71
73
|
| `opt_in` | yes | `double`, `single`, `unknown` | How consent was collected. `unknown` unless the source states it. |
|
|
72
74
|
| `origin` | yes | a short lowercase name | Where consent was first collected, as the file claims it: `sendy`, `mailchimp`, `kestrel`, or `unknown`. |
|
|
73
|
-
| `
|
|
75
|
+
| `unsubscribe_cause` | no | `reader`, `publisher`, `complaint`, `erased` | Why an `unsubscribed` row left: they unsubscribed themselves, you unsubscribed them on request, they reported your email as spam, or they asked to be erased. Empty when the source doesn't say, and recorded as having left elsewhere. Only on an `unsubscribed` row. |
|
|
76
|
+
| `suppression` | no | `bounce`, `manual` | A suppression on top of `status`: the address bounced, or was blocked by hand. Version 1 also took `complaint` and `erased`, read now as an `unsubscribed` row with that cause. |
|
|
74
77
|
| `subscribed_at` | no | an instant with its zone | When the person subscribed, if the source states it. |
|
|
75
78
|
| `confirmed_at` | no | an instant with its zone | When the person confirmed, if the source states it. |
|
|
76
79
|
| `unsubscribed_at` | no | an instant with its zone | When the person left, if the source states it. A reader who confirmed in Kestrel after it stays confirmed. |
|
|
77
80
|
| `suppressed_at` | no | an instant with its zone | When the address was suppressed, if the source states it. |
|
|
78
81
|
| `evidence` | no | a JSON object, at most 500 bytes | The source's consent-relevant columns, verbatim, such as a signup IP or a form name. Nothing reads it to decide anything: it is the proof of consent and the audit trail of a conversion. |
|
|
79
|
-
| `format` | no | `kestrel-subscribers/1` | The version the
|
|
82
|
+
| `format` | no | `kestrel-subscribers/1`, `kestrel-subscribers/2` | The version the row is written in. A row without it may use either version's values. |
|
|
80
83
|
|
|
81
84
|
An instant is a UTC ISO-8601 time with its zone, such as `2024-03-01T09:30:00Z` or `2024-03-01T10:30:00+01:00`. A time without a zone is refused, never interpreted. An optional column may be left out, or left empty on a row.
|
|
82
85
|
|
|
83
86
|
Any other column is refused, naming it. A source's other columns belong in `evidence`, as JSON. A file whose header names none of these columns isn't in the format at all, and is refused as such.
|
|
84
87
|
|
|
85
|
-
The example file, `kestrel-import-example.csv`, shows each case: a double opt-in subscriber with evidence, a single opt-in one, someone who
|
|
88
|
+
The example file, `kestrel-import-example.csv`, shows each case: a double opt-in subscriber with evidence, a single opt-in one, someone who unsubscribed themselves, and a bounced address. Here are the same four with only the columns they use, since an optional column may be left out:
|
|
86
89
|
|
|
87
90
|
```
|
|
88
|
-
email,status,opt_in,origin,suppression,unsubscribed_at,evidence
|
|
89
|
-
ada@example.com,subscribed,double,sendy
|
|
90
|
-
grace@example.com,subscribed,single,mailchimp
|
|
91
|
-
edsger@example.com,unsubscribed,unknown,unknown,,2025-01-20T08:00:00Z,
|
|
92
|
-
bounced@example.com,none,unknown,unknown
|
|
91
|
+
email,status,opt_in,origin,unsubscribe_cause,suppression,unsubscribed_at,evidence
|
|
92
|
+
ada@example.com,subscribed,double,sendy,,,,"{""signup_ip"":""203.0.113.7""}"
|
|
93
|
+
grace@example.com,subscribed,single,mailchimp,,,,
|
|
94
|
+
edsger@example.com,unsubscribed,unknown,unknown,reader,,2025-01-20T08:00:00Z,
|
|
95
|
+
bounced@example.com,none,unknown,unknown,,bounce,,
|
|
93
96
|
```
|
|
94
97
|
|
|
95
|
-
**Versions.** Adding an optional column keeps the version. Changing what a column means makes a new version. Kestrel reads every version it has published, and refuses one it doesn't know, by name.
|
|
98
|
+
**Versions.** Adding an optional column keeps the version. Changing what a column means makes a new version. Kestrel reads every version it has published, and refuses one it doesn't know, by name. Version 2 added `unsubscribe_cause`, and with it a complaint and an erasure left `suppression`. A row of version 1 that says `complaint` or `erased` there reads as an `unsubscribed` row with that cause, whatever its `status`. A row that names no version may use either version's values, since both read it alike, but not a cause beside a version 1 complaint or erasure.
|
|
96
99
|
|
|
97
100
|
### The plain list
|
|
98
101
|
|
|
@@ -118,33 +121,35 @@ Each row gets exactly one outcome. A row that both changes its status and adds a
|
|
|
118
121
|
|
|
119
122
|
| Outcome | When |
|
|
120
123
|
| --- | --- |
|
|
121
|
-
| `
|
|
122
|
-
| `
|
|
124
|
+
| `added_confirmed` | `subscribed`, with no subscriber and no suppression in Kestrel: added as confirmed. |
|
|
125
|
+
| `confirmed_pending` | `subscribed`, pending in Kestrel (never confirmed): confirmed, and its confirmation link no longer works. |
|
|
123
126
|
| `unchanged` | Already confirmed in Kestrel, or already in the state the row claims. A subscriber who joined through Kestrel keeps their own record. |
|
|
124
|
-
| `
|
|
125
|
-
| `
|
|
127
|
+
| `unsubscribed` | `unsubscribed`, and confirmed or pending in Kestrel, with no confirmation in Kestrel newer than the row's `unsubscribed_at`: unsubscribed, with the row's cause. Also an address already unsubscribed in Kestrel for a weaker reason, which the row's complaint replaces. |
|
|
128
|
+
| `erased` | `unsubscribed` with cause `erased`, for someone in Kestrel it unsubscribes, or who already left for another reason: erased, as **Erase** does. Their record keeps only the address and when, and your past sends show an erased recipient in place of the address. Named apart so you see who on your list the file erases. |
|
|
129
|
+
| `added_unsubscribed` | `unsubscribed`, with no subscriber in Kestrel: recorded as unsubscribed, with the row's cause, so the list remembers they left. |
|
|
126
130
|
| `kept_confirmed_newer` | `unsubscribed`, but the reader confirmed in Kestrel after the source's `unsubscribed_at`: their newer action stands. |
|
|
127
131
|
| `kept_unsubscribed` | `subscribed`, but unsubscribed in Kestrel, or pending again after leaving until they confirm in Kestrel themselves. An unsubscribe is never reversed. |
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
132
|
+
| `kept_suppressed` | `subscribed`, but the address carries a suppression in Kestrel: never added or confirmed. |
|
|
133
|
+
| `suppressed` | The row carries a suppression and the address has none in Kestrel, and nothing else changes. Applied whatever the row's `status`. |
|
|
130
134
|
| `left_out` | `pending` or `none` with nothing to apply: counted, never confirmed. An import sends no email, so it can't start a confirmation either. |
|
|
131
135
|
| `invalid` | The address isn't valid. |
|
|
132
136
|
| `merged` | A repeat of an address earlier in the file, folded into it. |
|
|
133
137
|
|
|
134
|
-
A suppression already in Kestrel is never replaced
|
|
138
|
+
A suppression already in Kestrel is never replaced: the first one an address gets stands. An imported bounce is kept apart from one Kestrel saw itself, so the list says where each came from.
|
|
139
|
+
|
|
140
|
+
**Why someone left.** A row with no `unsubscribe_cause` is recorded as having left elsewhere, which is all an import can know. One whose cause is `erased` is recorded with its address alone, whatever else the row says. A subscriber already in Kestrel whom it unsubscribes is erased, as **Erase** erases them: their consent history, how they joined, and its evidence go, and your past sends show an erased recipient in place of the address. The plan names each such person apart, as `erased`, so you see who on your list the file erases before you commit. A commit that lands while a send is handing one of them to your email provider is refused, and you try again a minute later. Someone already unsubscribed in Kestrel keeps the stronger of the two reasons: an erasure, then a complaint, then the reason Kestrel already has.
|
|
135
141
|
|
|
136
142
|
Anyone can type an address into your subscribe form, which puts a reader who left back to pending until they confirm. An import treats such a reader as unsubscribed, so it never confirms someone who left in Kestrel.
|
|
137
143
|
|
|
138
|
-
**Repeats of an address** are folded into one row first. Its status is the first of unsubscribed, subscribed, pending, and none that any repeat claims. Its
|
|
144
|
+
**Repeats of an address** are folded into one row first. Its status is the first of unsubscribed, subscribed, pending, and none that any repeat claims. Its reason for leaving is the strongest: an erasure, then a complaint, then the first of the rest. Its suppression is the first of bounce and manual. Its opt-in is the weakest claim. It keeps the earliest consent times and the latest unsubscribe and suppression times, and the origin and evidence of the row whose status won.
|
|
139
145
|
|
|
140
|
-
A subscriber an import confirmed is **confirmed**, like anyone who confirmed in Kestrel, and gets your posts.
|
|
146
|
+
A subscriber an import confirmed is **confirmed**, like anyone who confirmed in Kestrel, and gets your posts. Their page, opened from their row, says how they joined: imported, with the origin and opt-in method the file claimed, the evidence, and the import that brought them: the file, when, by whom, and the attestation. That holds even when the file says they confirmed through Kestrel itself. Only a confirmation Kestrel recorded reads as Kestrel's double opt-in, and a subscriber who later confirms in Kestrel becomes Kestrel's own.
|
|
141
147
|
|
|
142
148
|
### Limits in detail
|
|
143
149
|
|
|
144
150
|
- **Rows:** up to 10,000 in one import. On the free Workers plan's limits it's 1,000, since that plan's processing time per request can't plan and commit more. [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) tells Kestrel which plan it's on.
|
|
145
151
|
- **Size:** an import request of at most 10 MB, and at most 500 bytes of `evidence` a row.
|
|
146
|
-
- **
|
|
147
|
-
- **The warnings:** an import's plan warns when it and the day's earlier imports, by UTC, would use more than 70% of the day's writes. It suggests importing the file the next day. A scheduled post warns when its send and that day's imports would use most of the day. With no imports, that's a send to more than 5,000 recipients. It suggests sending the next day, and its banner repeats the warning while it's scheduled. Neither warning stops anything.
|
|
152
|
+
- **The free Workers plan:** it 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. An import's plan recommends Workers Paid when it would leave more than 1,000 confirmed subscribers, and the Dashboard and the Subscribers screen say the same while the list is past it. A scheduled post to more than 5,000 recipients warns before it's scheduled, and its banner repeats the warning while it's scheduled. None of these stops anything. [As your list grows](../guides/09-as-your-list-grows.md) has the numbers behind them.
|
|
148
153
|
|
|
149
154
|
### Exporting
|
|
150
155
|
|
|
@@ -152,6 +157,6 @@ The export reads your whole list out in this format, a page at a time, at `GET /
|
|
|
152
157
|
|
|
153
158
|
To make one CSV in this format, write the pages' rows under a single header. The dashboard's **Export** button, on the Subscribers screen, does this for you and downloads the whole list as one CSV. In the CSV, a value that starts with `=`, `+`, `-`, or `@` is written with an apostrophe before it. A spreadsheet then shows it as text rather than running it as a formula, and an import takes the apostrophe off again.
|
|
154
159
|
|
|
155
|
-
The export includes every subscriber with how they joined, their evidence, and their suppression. It also includes every address suppressed with no subscriber behind it, written as `status: none`. A confirmed subscriber is written as `subscribed`.
|
|
160
|
+
The export includes every subscriber with how they joined, their evidence, why they left when they have, and their suppression, each in a column of its own. It also includes every address suppressed with no subscriber behind it, written as `status: none`. A confirmed subscriber is written as `subscribed`.
|
|
156
161
|
|
|
157
|
-
Importing an export into another Kestrel carries the list over: the same states and suppressions, with every confirmation arriving as imported, carrying its original evidence. Pending subscribers are left out on the way in, since an import sends no confirmation.
|
|
162
|
+
Importing an export into another Kestrel carries the list over: the same states, the same reasons for leaving, an erasure's included, and the same suppressions, with every confirmation arriving as imported, carrying its original evidence. Pending subscribers are left out on the way in, since an import sends no confirmation.
|
|
@@ -8,7 +8,7 @@ This page covers what Kestrel handles, what you handle, and how to act on a read
|
|
|
8
8
|
|
|
9
9
|
- **Consent.** Kestrel mails only people who confirmed they want your newsletter. Someone who subscribes gets an email with a link, and only once they confirm do they receive posts. People you import come with your old service's record of their consent, and your word for it ([Bring your existing list](01-your-list.md#what-carries-over)).
|
|
10
10
|
- **Unsubscribing.** Every email has an unsubscribe link, and the one-click unsubscribe header that Gmail and Yahoo ask bulk senders for. It takes effect at once. US law allows up to 10 business days.
|
|
11
|
-
- **Bounces and complaints.** An address that bounces,
|
|
11
|
+
- **Bounces and complaints.** An address that bounces is never mailed again, so a bad address can't keep hurting your sending reputation. A reader who marks your email as spam is unsubscribed, as if they had used the link, and you can't add them back: only their own sign-up does.
|
|
12
12
|
- **Sender authentication.** Your emails are signed with DKIM for your own domain, and SPF and DMARC records in its DNS vouch for them. You set them up in [Connect Amazon SES](../get-started/04-ses.md), and mailbox providers now expect them.
|
|
13
13
|
|
|
14
14
|
## What you handle
|
|
@@ -23,9 +23,9 @@ This page covers what Kestrel handles, what you handle, and how to act on a read
|
|
|
23
23
|
Kestrel offers readers no form for deleting their data, so they ask you. In many places the law gives a reader that right. The European Union's GDPR, for example, may apply if you have readers there.
|
|
24
24
|
|
|
25
25
|
- **Say how to reach you.** Your privacy notice, wherever you publish it, should say how readers ask you about their data. Replying to any post works once replies reach your inbox.
|
|
26
|
-
- **To stop the email,** find
|
|
26
|
+
- **To stop the email,** find the reader under **Subscribers**, and choose **Unsubscribe** from their ⋯ menu. It works whether or not they've confirmed, records that they asked, and they can rejoin by subscribing again.
|
|
27
27
|
- **To stop mailing someone on your own judgment,** not at their request, choose **Block** instead. It stops every email to the address whatever its status, and **Unblock** undoes it.
|
|
28
|
-
- **To delete their data,** choose **Erase** from the same menu. It
|
|
28
|
+
- **To delete their data,** choose **Erase** from the same menu, even for someone who already unsubscribed. It unsubscribes them and deletes how they joined and the evidence for it, and can't be undone. Past send records show an erased recipient in their place. Kestrel keeps only the address and when you erased it, so it's never mailed again, beside any bounce or block the address already had. The reader can still rejoin by subscribing and confirming themselves, but you can't add them back.
|
|
29
29
|
- **Act promptly.** Under the [GDPR](https://eur-lex.europa.eu/eli/reg/2016/679/oj), you answer within a month in most cases (Article 12). The right to have data deleted is Article 17, and Article 13 asks you to tell readers about it when they sign up.
|
|
30
30
|
|
|
31
31
|
## Outside the US
|
|
@@ -53,14 +53,14 @@ A sent post's page under **Sent** shows its address with **Remove from archive
|
|
|
53
53
|
|
|
54
54
|
### Who receives it
|
|
55
55
|
|
|
56
|
-
A post goes to your list as it stands when it sends: every confirmed subscriber, except addresses that bounced
|
|
56
|
+
A post goes to your list as it stands when it sends: every confirmed subscriber, except addresses that bounced or were blocked. A reader who complained has been unsubscribed, so isn't on it. Someone who confirms while it's scheduled receives it. Someone who unsubscribes before their email is handed to your provider doesn't, even partway through the send.
|
|
57
57
|
|
|
58
58
|
So a recipient count shown before the send is a snapshot. An import that adds subscribers while a post is scheduled adds them to its audience too, and asks you first ([Bring your existing list](01-your-list.md#3-import-it)).
|
|
59
59
|
|
|
60
60
|
### Warnings when you schedule
|
|
61
61
|
|
|
62
62
|
- **No email provider connected.** The Schedule dialog can't schedule until one is connected ([Connect Amazon SES](../get-started/04-ses.md)). If the provider goes after a post is scheduled, the post is held, stays cancelable, and goes out once one is connected.
|
|
63
|
-
- **Daily database writes.** On Cloudflare's free Workers plan, a
|
|
63
|
+
- **Daily database writes.** On Cloudflare's free Workers plan, a send to more than about 5,000 recipients may use up the day's writes. The dialog warns you without stopping you, as [Bring your existing list](01-your-list.md#limits) explains.
|
|
64
64
|
|
|
65
65
|
### When it sends
|
|
66
66
|
|
|
@@ -22,7 +22,8 @@ A problem with your email provider never marks anyone unsent. The send waits whe
|
|
|
22
22
|
- **A test says "The provider refused it".** Your provider didn't take the test, and its own words follow, with the fix where Kestrel knows it. Sending again gets the same answer until that's fixed. While Amazon SES is in its sandbox, it sends only to addresses verified in SES, so verify the address you test with in the SES console, or request production access, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Words about the account, such as a key or the sending domain, point at the same fix as [a send the provider refuses](06-troubleshooting.md#a-send-is-waiting-or-held).
|
|
23
23
|
- **A test says "The provider didn't answer, so the test may have arrived".** Kestrel can't tell whether your provider took it. Check the inbox before you send again, since a second test may arrive twice.
|
|
24
24
|
- **"The template or publication details changed this email's look after your last test".** Send a new test of the post, since your last one no longer shows what goes out.
|
|
25
|
-
- **The daily database writes warning.** On Cloudflare's free Workers plan, a
|
|
25
|
+
- **The daily database writes warning.** On Cloudflare's free Workers plan, a send to more than about 5,000 recipients may use up the day's writes, and then the app stops working until midnight UTC. [Bring your existing list](01-your-list.md#limits) explains the limit, and when to upgrade to Workers Paid.
|
|
26
|
+
- **"Workers Free suits a list of up to about 1,000 subscribers".** Your list is past what the free plan suits. Upgrade to Workers Paid ([As your list grows](../guides/09-as-your-list-grows.md)). If the account is on Workers Paid already, set `SUBREQUEST_BUDGET` so Kestrel knows, and the notice goes.
|
|
26
27
|
|
|
27
28
|
## Reading a send's record
|
|
28
29
|
|
|
@@ -37,7 +38,7 @@ A send's page becomes its permanent record once it finishes. Its counts cover ev
|
|
|
37
38
|
|
|
38
39
|
Delivered always means confirmed, not just accepted by your provider. Confirmations can lag by days, so counts keep rising after a send finishes. Some emails are never confirmed at all, so a send can read **Settling** for good, which is normal, as long as some receipts have come.
|
|
39
40
|
|
|
40
|
-
**"No delivery receipt has arrived for any of …".** An hour after a send went out, Kestrel has heard back about none of its readers. Your provider's reports probably aren't reaching Kestrel, so its bounces and complaints
|
|
41
|
+
**"No delivery receipt has arrived for any of …".** An hour after a send went out, Kestrel has heard back about none of its readers. Your provider's reports probably aren't reaching Kestrel, so its bounces suppress no one and its complaints unsubscribe no one, and your next post mails those addresses again. Look for `webhook.refused` errors in the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes. It names the SNS topic that reached Kestrel and the one it expects. Then check the subscription in [Connect Amazon SES](../get-started/04-ses.md#9-subscribe-the-app-to-the-topic). The line goes as soon as a receipt arrives.
|
|
41
42
|
|
|
42
43
|
**Unsent recipients** are still on your list, and get your next post. A recipient is unsent when their own email kept failing, or their address isn't one SES accepts, with the provider's words on the row, or when you chose **Assume not sent**.
|
|
43
44
|
|
|
@@ -63,19 +64,23 @@ When SES refuses your account or a send, the red line carries SES's words and th
|
|
|
63
64
|
|
|
64
65
|
A server error from SES may come after SES already took the emails, so Kestrel never sends that batch again: its readers wait for **Resolve…**, as for "The provider never answered for …".
|
|
65
66
|
|
|
67
|
+
## A reader says they never got a post
|
|
68
|
+
|
|
69
|
+
Under **Subscribers**, search for their address and open their row. Their page lists every send they were in, newest first, with what happened on each: **Delivered**, a bounce, a complaint, **Unsent** with your email provider's words, or **Skipped** because they had unsubscribed or were suppressed by then. Each send links to its record. The page also shows how they joined, any bounce or block on the address with the provider's words or yours, and why they left, if they did. If they aren't on the list, or are still **pending**, they never confirmed, and only confirmed subscribers are sent your posts.
|
|
70
|
+
|
|
66
71
|
## Bounces and complaints
|
|
67
72
|
|
|
68
73
|
Kestrel acts on these for you, so a bad address can't keep hurting your sending reputation:
|
|
69
74
|
|
|
70
75
|
- **A hard bounce** means the address can't receive mail. Kestrel never mails it again.
|
|
71
76
|
- **A soft bounce** is temporary, such as a full mailbox. The address stays on your list.
|
|
72
|
-
- **A complaint** means the reader marked your email as spam. Kestrel
|
|
77
|
+
- **A complaint** means the reader marked your email as spam. Kestrel unsubscribes them, whichever email they reported, and their page, opened from their row, says they left by a complaint.
|
|
73
78
|
|
|
74
|
-
Under **Subscribers**, a
|
|
79
|
+
Under **Subscribers**, a bounced address keeps its status and carries a label saying why, and its page, opened from its row, gives your email provider's words for the bounce and when it happened. When a recent send's bounces reach about 5% of its recipients, the dashboard warns you. That's about the rate at which email providers start to review a sender, so check the addresses on its record.
|
|
75
80
|
|
|
76
81
|
### Lifting a suppression
|
|
77
82
|
|
|
78
|
-
Lift one only when you know the address works again, such as a reader who tells you their mailbox is fixed. Mailing an address that keeps failing can hurt every send after it.
|
|
83
|
+
Lift one only when you know the address works again, such as a reader who tells you their mailbox is fixed. Mailing an address that keeps failing can hurt every send after it. In a subscriber's ⋯ menu, **Unblock** lifts a block and **Clear** lifts a bounce, after asking you to confirm. A complaint isn't a suppression but the reader leaving, so nothing lifts it: they come back only by subscribing again themselves, and **Add subscriber** refuses them. Past records keep the bounce or complaint where it happened.
|
|
79
84
|
|
|
80
85
|
## An import is refused
|
|
81
86
|
|
|
@@ -10,7 +10,6 @@ To set Claude up, follow [Connect Claude to the API](../guides/01-connect-claude
|
|
|
10
10
|
- **Test and schedule a post.** Name the post and when it should go out, and say so if it should be email only. Claude tells you whether the post will be in your archive, and at which address, before it schedules. Then it sends a test of exactly what will go out and tells you how to cancel. It can also cancel a send or move it.
|
|
11
11
|
- **Take a sent post out of your archive, or put it back.** Claude asks first, as the dashboard does, since taking a post out breaks links people shared.
|
|
12
12
|
- **Check how a send did.** Claude reads the send's record and reports what was delivered, unsent, bounced, or complained.
|
|
13
|
-
- **Lift a bounce or a complaint.** The dashboard can't, so ask Claude when you know an address works again. [Lifting a suppression](06-troubleshooting.md#lifting-a-suppression) says when that's wise.
|
|
14
13
|
- **Recover an earlier version.** Every save keeps a whole version of a post, and Claude can read them back.
|
|
15
14
|
- **Import a list.** Its `/migrate-list` skill asks what anything ambiguous in your old service's export means, and converts a few rows for you to check. It imports once you attest, and if a scheduled post would grow, you finish the import in the dashboard.
|
|
16
15
|
|
|
@@ -21,7 +20,7 @@ The app holds these for any client, so they hold whatever Claude is told:
|
|
|
21
20
|
- **Every send waits out the review window,** and you can cancel it until it goes out.
|
|
22
21
|
- **A scheduled post is locked,** and what goes out is exactly the copy that was scheduled.
|
|
23
22
|
- **No one is mailed twice,** and only confirmed subscribers receive a post.
|
|
24
|
-
- **An unsubscribe is final** until the reader subscribes again
|
|
23
|
+
- **An unsubscribe is final** until the reader subscribes again. Someone who complained or was erased comes back only by subscribing themselves: neither you nor Claude can add them.
|
|
25
24
|
- **No one's edit is overwritten.** A save based on an older version is refused.
|
|
26
25
|
- **Only you can let an import grow a scheduled send.** An import through Claude that would stops there, until you finish it in the dashboard.
|
|
27
26
|
|
|
@@ -10,9 +10,9 @@ A reader enters their address, gets a confirmation email, and presses **Confirm
|
|
|
10
10
|
|
|
11
11
|
Every address gets the same answer, so the form never reveals who subscribes. Behind it, Kestrel sends a confirmation only when one is due:
|
|
12
12
|
|
|
13
|
-
- **Already subscribed, bounced,
|
|
13
|
+
- **Already subscribed, bounced, or blocked:** no email goes out.
|
|
14
14
|
- **Waiting to confirm:** at most one confirmation every 15 minutes, however often the form is submitted. A link works for 7 days, and an expired one offers a fresh link.
|
|
15
|
-
- **Unsubscribed earlier, or
|
|
15
|
+
- **Unsubscribed earlier, for any reason, a complaint or an erasure included:** they get a confirmation, and confirming puts them back on your list. This form is the only way back for someone who complained or was erased.
|
|
16
16
|
|
|
17
17
|
## The confirmation email
|
|
18
18
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Before your readers get anything, see it arrive yourself, and make sure a problem will reach you without your watching for it. Send yourself a test and reply to it, subscribe yourself, and set where Kestrel emails you.
|
|
4
4
|
|
|
5
|
-
If you're bringing a list of more than about
|
|
5
|
+
If you're bringing a list of more than about 1,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import it. 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. [As your list grows](../guides/09-as-your-list-grows.md) walks through the upgrade.
|
|
6
6
|
|
|
7
7
|
## Send yourself a test
|
|
8
8
|
|
|
@@ -26,5 +26,5 @@ Until an email provider is connected, nothing can be sent, and the dashboard say
|
|
|
26
26
|
|
|
27
27
|
## As your list grows
|
|
28
28
|
|
|
29
|
-
A growing list meets your Amazon SES daily sending quota, longer sends, and on Cloudflare's free Workers plan, a cap on daily database writes. Kestrel tells you when you reach each one, and [As your list grows](../guides/09-as-your-list-grows.md) covers raising each.
|
|
29
|
+
A growing list meets your Amazon SES daily sending quota, longer sends, and on Cloudflare's free Workers plan, a cap on daily database writes. Kestrel tells you when you reach each one (on the free plan, from about 1,000 subscribers), and [As your list grows](../guides/09-as-your-list-grows.md) covers raising each.
|
|
30
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.
|
|
@@ -66,7 +66,7 @@ It's also how the app tells Workers Free from Workers Paid. At the default, the
|
|
|
66
66
|
|
|
67
67
|
- One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more. Above it, up to 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)).
|
|
68
68
|
- An export reads 1,000 rows a page. Above it, 10,000.
|
|
69
|
-
-
|
|
69
|
+
- 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.
|
|
70
70
|
|
|
71
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. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.
|
|
72
72
|
|
|
@@ -80,7 +80,7 @@ The floor is 60, since sends go out from a once-a-minute schedule. The ceiling i
|
|
|
80
80
|
|
|
81
81
|
Leave them unset to keep everything on the app's own hostname.
|
|
82
82
|
|
|
83
|
-
`ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there 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.
|
|
83
|
+
`ARCHIVE_ORIGIN` points archive links at another origin, such as your website, `https://example.com`. The app doesn't answer there 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.
|
|
84
84
|
|
|
85
85
|
`MEDIA_PUBLIC_BASE` serves images from another base URL, such as a custom domain on the image bucket. 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.
|
|
86
86
|
|