@kurtbruns/kestrel 1.11.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 +154 -1
- package/README.md +6 -7
- package/cli/dev-api.mjs +1 -1
- package/cli/reset.mjs +2 -0
- package/cli/seed.mjs +3 -1
- package/demo/README.md +4 -2
- package/demo/posts/0-test-email/index.md +13 -0
- package/demo/posts/5-a-quick-note/index.md +2 -0
- package/dist/public/dashboard/app.js +383 -324
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +850 -191
- package/dist/worker/index.js +11327 -6891
- 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 +24 -4
- package/docs/get-started/05-verify.md +21 -5
- package/docs/get-started/06-go-live.md +2 -2
- package/docs/guides/01-connect-claude.md +8 -3
- 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 +20 -6
- package/docs/publishing/06-troubleshooting.md +46 -12
- package/docs/publishing/07-working-with-claude.md +4 -4
- 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 +3 -1
- package/docs/publishing/README.md +1 -1
- package/docs/reference/01-configuration.md +3 -3
- package/migrations/0007_sends_progress_at.sql +12 -0
- package/migrations/0008_notifications_sandbox.sql +35 -0
- package/migrations/0009_post_email_only.sql +12 -0
- package/migrations/0010_unlisted_links.sql +43 -0
- package/migrations/0011_sends_render_warnings.sql +13 -0
- package/migrations/0012_unsubscribe_cause.sql +139 -0
- package/package.json +1 -1
- package/template/claude/CLAUDE.md +13 -9
- 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
|
|
|
@@ -107,7 +107,11 @@ If AWS asks for more detail, reply in the support case. If it denies the request
|
|
|
107
107
|
- Bounces and complaints reach the app through SNS, and it stops mailing those addresses automatically.
|
|
108
108
|
- How many subscribers the newsletter expects, and how often it sends.
|
|
109
109
|
|
|
110
|
-
Your account stays in the sandbox until AWS grants access, so don't go live before then. In the sandbox,
|
|
110
|
+
Your account stays in the sandbox until AWS grants access, so don't go live before then. In the sandbox, SES refuses mail to any address you haven't verified:
|
|
111
|
+
|
|
112
|
+
- A signup from an unverified address gets no confirmation email. The app emails the publisher about it, at most once a day.
|
|
113
|
+
- A post goes to your verified addresses and then waits, with the line "The provider is refusing the account" and SES's words. No reader is marked unsent. Once AWS grants access, the post goes to the rest at its next retry, mailing no one twice.
|
|
114
|
+
- A daily quota refusal in the sandbox (200 emails a day) means the same thing: request production access rather than a higher quota.
|
|
111
115
|
|
|
112
116
|
When AWS grants access, the **Account dashboard** shows your daily quota and your maximum send rate. You set the rate in the app in section 8. The rate decides how fast a send goes out, and [As your list grows](../guides/09-as-your-list-grows.md) says when to ask AWS to raise it.
|
|
113
117
|
|
|
@@ -223,7 +227,7 @@ Until now the app has run with no email provider connected, refusing to send. Se
|
|
|
223
227
|
}
|
|
224
228
|
```
|
|
225
229
|
|
|
226
|
-
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.
|
|
227
231
|
|
|
228
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.
|
|
229
233
|
|
|
@@ -256,10 +260,26 @@ SNS delivers events only to a subscriber that has confirmed. The app confirms on
|
|
|
256
260
|
|
|
257
261
|
1. Reload the subscription. Its status changes to **Confirmed**, with nothing for you to click.
|
|
258
262
|
|
|
263
|
+
1. Give SNS longer to retry. By default it retries a failed delivery 3 times over about a minute, then discards the event, so a bounce that arrives while the app is briefly down or mid-deploy is lost, and the address gets your next post. Open the topic, choose **Edit**, and expand **Delivery policy (HTTP/S)**. Clear **Use the default delivery policy**, set these, and choose **Save changes**:
|
|
264
|
+
|
|
265
|
+
| Field | Value |
|
|
266
|
+
| --- | --- |
|
|
267
|
+
| Number of retries | `13` |
|
|
268
|
+
| Retries without delay | `0` |
|
|
269
|
+
| Minimum delay | `20` |
|
|
270
|
+
| Minimum delay retries | `2` |
|
|
271
|
+
| Maximum delay | `300` |
|
|
272
|
+
| Maximum delay retries | `6` |
|
|
273
|
+
| Retry-backoff function | Exponential |
|
|
274
|
+
|
|
275
|
+
SNS then keeps trying for most of an hour, the longest it allows. It never retries an event the app refused. The app refuses only an event it can't verify came from your topic, and logs an error when one claims to be from SNS.
|
|
276
|
+
|
|
277
|
+
1. **(Optional)** Keep what SNS still can't deliver. On the subscription, choose **Edit**, and set a **Redrive policy (dead-letter queue)** to an SQS queue of your own. SNS then moves an event there instead of discarding it, where you can read it later. [Amazon SNS dead-letter queues](https://docs.aws.amazon.com/sns/latest/dg/sns-dead-letter-queues.html) covers the queue and its permissions.
|
|
278
|
+
|
|
259
279
|
If it stays **Pending confirmation**, the request never reached the app, or the app refused it. Check, in this order:
|
|
260
280
|
|
|
261
281
|
- **Access covers `/webhooks/ses`.** If your Access application covers more than `/dashboard` and `/api`, SNS gets the login page instead of the app. Remove that path from your Access application, so only `dashboard` and `api` remain.
|
|
262
|
-
- **`SNS_TOPIC_ARN` matches the topic's ARN.**
|
|
282
|
+
- **`SNS_TOPIC_ARN` matches the topic's ARN.** If it doesn't, the app's logs show a `webhook.refused` error naming both.
|
|
263
283
|
- **The deploy in section 8 finished.**
|
|
264
284
|
|
|
265
285
|
Once you've fixed the cause, select the subscription, and choose **Request confirmation**. SNS sends the request again, and the app confirms it. SNS doesn't let you delete a subscription while it's pending, and removes it on its own after 48 hours.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Some things only show up with real email: whether inboxes trust your mail, how it looks in a real mail client, and whether bounces and unsubscribes come back to the app. Check them now, while the list is empty and every email goes only to you.
|
|
4
4
|
|
|
5
|
-
In this guide, you send yourself a test email, reply to it, and check that it passes authentication. Then you set up notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs,
|
|
5
|
+
In this guide, you send yourself a test email, reply to it, and check that it passes authentication. Then you set up notifications, send a real post to yourself as a subscriber, and unsubscribe. Finally, you check that bounces and complaints reach the app, read the app's logs, check that SES can send to anyone, and clean up the test subscribers.
|
|
6
6
|
|
|
7
7
|
## Before you begin
|
|
8
8
|
|
|
@@ -59,10 +59,14 @@ This is a real send, to a list of one. It proves the whole path: subscribing, co
|
|
|
59
59
|
|
|
60
60
|
1. Open the post, select **Schedule**, and then **Send now**. Every send waits at least five minutes before it goes out, so you can cancel a mistake.
|
|
61
61
|
|
|
62
|
-
1.
|
|
62
|
+
1. Uncheck **Include in archive**. The post still goes to subscribers, but it's sent as email only: it isn't in the public archive or on the home page. That way the test proves the send without becoming the publication's first archived post, and the dashboard keeps its **Finish setting up** steps for the publisher.
|
|
63
|
+
|
|
64
|
+
1. Cancel the send, to see that canceling stops it. Then select **Schedule** and **Send now** again, check that **Include in archive** is still unchecked, and let it go.
|
|
63
65
|
|
|
64
66
|
1. When the send finishes, the post arrives at your second address. A notification arrives at your first, with the subject `Sent:` and the post's title.
|
|
65
67
|
|
|
68
|
+
If you verified this instance before Email only existed, the test post is in the archive. Open it under **Sent**, and select **Remove from archive…**.
|
|
69
|
+
|
|
66
70
|
## 5. Unsubscribe
|
|
67
71
|
|
|
68
72
|
Every email carries an unsubscribe link, and a header that lets mail clients show their own **Unsubscribe** button. Both work in one step, with no login.
|
|
@@ -73,7 +77,7 @@ Every email carries an unsubscribe link, and a header that lets mail clients sho
|
|
|
73
77
|
|
|
74
78
|
## 6. Check that bounces and complaints come back
|
|
75
79
|
|
|
76
|
-
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.
|
|
77
81
|
|
|
78
82
|
1. Open `https://newsletter.example.com/subscribe`, and subscribe each test address:
|
|
79
83
|
|
|
@@ -82,9 +86,9 @@ When an address bounces or marks your mail as spam, SES tells the app through SN
|
|
|
82
86
|
|
|
83
87
|
Each one gets a confirmation email, which bounces or is marked as spam.
|
|
84
88
|
|
|
85
|
-
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.
|
|
86
90
|
|
|
87
|
-
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).
|
|
88
92
|
|
|
89
93
|
## 7. Read the logs
|
|
90
94
|
|
|
@@ -108,6 +112,18 @@ The app writes a log line for each thing it does, and Cloudflare keeps them for
|
|
|
108
112
|
|
|
109
113
|
If the dashboard still says sandbox, AWS hasn't answered yet. Every other section works in the sandbox, so come back to this one when it does.
|
|
110
114
|
|
|
115
|
+
## 9. Clean up the test subscribers
|
|
116
|
+
|
|
117
|
+
The checks leave test addresses on the list. Erase them, so the publisher starts with only real readers.
|
|
118
|
+
|
|
119
|
+
1. In the editor, open **Subscribers**.
|
|
120
|
+
|
|
121
|
+
1. Erase `bounce@simulator.amazonses.com` and `complaint@simulator.amazonses.com`. Open each one's menu (⋯), and select **Erase**.
|
|
122
|
+
|
|
123
|
+
1. Erase your second address too, if it won't be a reader.
|
|
124
|
+
|
|
125
|
+
Erasing keeps 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
|
+
|
|
111
127
|
When every section gives the result it describes, your instance is ready for subscribers.
|
|
112
128
|
|
|
113
129
|
Next, [go live](06-go-live.md).
|
|
@@ -4,7 +4,7 @@ At this point, your instance should be deployed, verified, and out of the SES sa
|
|
|
4
4
|
|
|
5
5
|
## Start publishing
|
|
6
6
|
|
|
7
|
-
The rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md).
|
|
7
|
+
The rest happens in the editor, at `https://newsletter.example.com/dashboard/`. The Publishing docs walk through it in the order of the dashboard's setup checklist, starting with [Name your publication](../publishing/03-name-your-publication.md).
|
|
8
8
|
|
|
9
9
|
If someone else is the publisher, let them sign in first:
|
|
10
10
|
|
|
@@ -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,9 +89,11 @@ 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"},"auth":{"mode":"access"}}
|
|
92
|
+
{"principal":{"kind":"service","token_id":"e367c0ffee5eb4.access"},"auth":{"mode":"access"}}
|
|
90
93
|
```
|
|
91
94
|
|
|
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.
|
|
96
|
+
|
|
92
97
|
1. Ask Claude to list the posts. It answers with the posts the editor shows.
|
|
93
98
|
|
|
94
99
|
1. Open a draft in the editor, and ask Claude to change a word in it. The editor shows that the draft was changed elsewhere, last edited by **Claude**.
|
|
@@ -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
|
|
@@ -13,11 +13,11 @@ A few things to know as you write:
|
|
|
13
13
|
- **A blank line starts a new paragraph.** In an email, a single line break doesn't.
|
|
14
14
|
- **The opening lines** are the preview a mail client shows beside the subject.
|
|
15
15
|
- **Images** go in by paste or drop, and Kestrel inserts a reference by file name, such as ``. Replace the words in brackets with a short description. PNG, JPEG, WebP, or GIF, up to 5 MB each.
|
|
16
|
-
- **
|
|
16
|
+
- **Where it will go** is in **Post settings**, at the top right: whether it's in your archive, and its address, such as `newsletter.example.com/archive/spring-update`, whose slug follows the subject (below).
|
|
17
17
|
|
|
18
18
|
## Test it
|
|
19
19
|
|
|
20
|
-
**Send test email** sends the real email, through the same template and code as the send, to the addresses you name. **Settings → Default test recipients** fills them in for you. Nobody on your list gets anything, and a test's unsubscribe link unsubscribes no one.
|
|
20
|
+
**Send test email** sends the real email, through the same template and code as the send, to the addresses you name. **Settings → Default test recipients** fills them in for you. Nobody on your list gets anything, nothing appears in your archive, and a test's unsubscribe link unsubscribes no one.
|
|
21
21
|
|
|
22
22
|
Mail clients each draw email a little differently, so trust a test over **Preview**, especially for tables, code, and HTML. A test needs an email provider, which [Connect Amazon SES](../get-started/04-ses.md) sets up.
|
|
23
23
|
|
|
@@ -33,23 +33,37 @@ Changing your template, or a publication detail it shows, re-makes the email of
|
|
|
33
33
|
|
|
34
34
|
**Send now →**, under the time picker, is for a post you've already reviewed. It still waits out the window, scheduling the post for the minimum lead from now.
|
|
35
35
|
|
|
36
|
+
### Post settings
|
|
37
|
+
|
|
38
|
+
**Post settings**, at the top right of the editor, holds two choices. Neither is part of the email, so both stay open while the post is scheduled. Nothing changes until you select **Save**.
|
|
39
|
+
|
|
40
|
+
**Include in archive** is checked unless you uncheck it. Unchecked, the post goes to your list as usual but is sent as email only: it stays off your public archive and your home page, and its archive address shows nothing. Use that for a post meant for the inbox: a correction to an earlier email, a short-lived notice, or a test. The editor marks such a post **Email only**.
|
|
41
|
+
|
|
42
|
+
**Archive address** is where the post's page will be in your archive. Only the last part, the slug, is yours to type; it follows the subject until you do. Each post's address is its own: if you type one another post has, or one with spaces or capitals, Kestrel says so and keeps the address as it was. If another post already has the address your subject would give, Kestrel adds a number (`spring-update-2`) and tells you, so you can choose another. The address is fixed once the post starts sending.
|
|
43
|
+
|
|
44
|
+
The Schedule, Send now, and Edit schedule dialogs show the same settings under the time, so you can still change them there. A change made in a dialog applies when you schedule, send, or save, and canceling leaves the post as it was. Once a post is scheduled, its banner names where it's going.
|
|
45
|
+
|
|
46
|
+
Every email links its web version through the post's own **unlisted link**, which never changes. It opens the archive page while the post is in your archive, and the page itself while it's email only. Anyone with the email can open it, but nothing lists it, and search engines are asked not to.
|
|
47
|
+
|
|
48
|
+
A sent post's page under **Sent** shows its address with **Remove from archive…** or **Add to archive…**. Both ask first. Removing a post takes its archive address down, so links people shared stop working, but the address stays the post's: add it back and those links work again. Neither changes what was sent.
|
|
49
|
+
|
|
36
50
|
### Changing your mind
|
|
37
51
|
|
|
38
|
-
**Cancel**, on the post's banner or its card under **Sent**, makes the post a draft again at once. Edit it, test again, and schedule it again. **
|
|
52
|
+
**Cancel**, on the post's banner or its card under **Sent**, makes the post a draft again at once. Edit it, test again, and schedule it again. **Edit schedule** changes the send time, whether the post is in your archive, or both, without unlocking the post, and your tests still count. You can do either until the send time.
|
|
39
53
|
|
|
40
54
|
### Who receives it
|
|
41
55
|
|
|
42
|
-
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.
|
|
43
57
|
|
|
44
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)).
|
|
45
59
|
|
|
46
60
|
### Warnings when you schedule
|
|
47
61
|
|
|
48
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.
|
|
49
|
-
- **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.
|
|
50
64
|
|
|
51
65
|
### When it sends
|
|
52
66
|
|
|
53
|
-
At the send time, the post moves to its send page under **Sent**, where you can watch it go out. From then it can't be edited, canceled, or moved. Its
|
|
67
|
+
At the send time, the post moves to its send page under **Sent**, where you can watch it go out. From then it can't be edited, canceled, or moved. Its page goes up at the same moment, showing exactly the copy that was sent, so every email's "view in browser" link works. Whatever interrupts a send, such as your provider's daily quota running out, it waits and resumes where it stopped. No reader gets the same post twice.
|
|
54
68
|
|
|
55
69
|
If a send needs your attention, the send page and the dashboard say so. [Troubleshooting](06-troubleshooting.md) covers each case.
|