@kurtbruns/kestrel 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -1
- package/README.md +17 -28
- package/cli/reset.mjs +2 -0
- package/cli/seed.mjs +2 -0
- package/demo/README.md +4 -2
- package/demo/posts/0-test-email/index.md +13 -0
- package/demo/posts/5-a-quick-note/index.md +2 -0
- package/dist/public/dashboard/app.js +302 -293
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +2 -2
- package/dist/public/dashboard/styles.css +434 -54
- package/dist/worker/index.js +1472 -470
- package/docs/README.md +2 -2
- package/docs/get-started/02-deploy.md +12 -5
- package/docs/get-started/03-access.md +2 -0
- package/docs/get-started/04-ses.md +36 -16
- package/docs/get-started/05-verify.md +36 -8
- package/docs/get-started/06-go-live.md +13 -55
- package/docs/guides/01-connect-claude.md +7 -5
- package/docs/guides/03-notifications.md +10 -10
- package/docs/guides/09-as-your-list-grows.md +62 -0
- package/docs/publishing/01-your-list.md +118 -47
- package/docs/publishing/02-sending-rules.md +5 -5
- package/docs/publishing/03-name-your-publication.md +7 -93
- package/docs/publishing/04-write-a-post.md +37 -66
- package/docs/publishing/06-troubleshooting.md +103 -0
- package/docs/publishing/07-working-with-claude.md +19 -37
- package/docs/publishing/09-share-your-subscribe-link.md +11 -59
- package/docs/publishing/10-check-your-setup.md +12 -30
- package/docs/publishing/11-email-template.md +34 -0
- package/docs/publishing/README.md +12 -11
- package/docs/reference/01-configuration.md +6 -6
- package/docs/reference/02-sending-domain-dns.md +1 -1
- package/migrations/0007_sends_progress_at.sql +12 -0
- package/migrations/0008_notifications_sandbox.sql +35 -0
- package/migrations/0009_post_email_only.sql +12 -0
- package/migrations/0010_unlisted_links.sql +43 -0
- package/migrations/0011_sends_render_warnings.sql +13 -0
- package/package.json +1 -1
- package/template/claude/CLAUDE.md +5 -2
- package/template/claude/skills/migrate-list/SKILL.md +2 -2
- package/docs/guides/08-try-locally.md +0 -108
- package/docs/publishing/05-scheduling-and-sending.md +0 -72
- package/docs/publishing/06-after-a-send.md +0 -109
- package/docs/publishing/08-import-format.md +0 -103
|
@@ -1,86 +1,157 @@
|
|
|
1
1
|
# Bring your existing list
|
|
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
|
-
|
|
5
|
+
**The first decision is whether to upgrade to Cloudflare's Workers Paid plan.** With more than about 5,000 subscribers, it's recommended to upgrade before you import. The free plan caps database writes per day, and a large import plus a send can use them up. When that happens, the app stops working until midnight UTC. 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
12
|
+
|
|
13
|
+
## 2. Convert it to Kestrel's format
|
|
14
|
+
|
|
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
|
+
|
|
17
|
+
- **A CSV in Kestrel's format (preferred).** One row per address, with its status, how and where consent was collected, and any bounce or complaint. **Download example file (CSV)** on the import screen shows each case. Save your file as CSV UTF-8.
|
|
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
|
+
|
|
20
|
+
A CSV in Kestrel's format looks like this, with the five columns that decide what happens to each row:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
email,status,opt_in,origin,subscribed_at
|
|
24
|
+
ada@example.com,subscribed,double,sendy,2024-03-01T09:30:00Z
|
|
25
|
+
grace@example.com,subscribed,single,mailchimp,
|
|
26
|
+
edsger@example.com,unsubscribed,unknown,unknown,
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
[The file format](01-your-list.md#the-file-format), below, describes every column. If you use Claude Code with your instance, it can do the conversion for you, as [Working with Claude](07-working-with-claude.md) describes.
|
|
30
|
+
|
|
31
|
+
## 3. Import it
|
|
32
|
+
|
|
33
|
+
Under **Subscribers → Import**, choose your file. Nothing changes yet: Kestrel plans the import and shows what it would do to every row. You see who is added, who is recorded as having left, who is suppressed, and which rows are left out. You can download the rows that won't be imported as subscribers, to check them against your file.
|
|
34
|
+
|
|
35
|
+
**Import** then applies the plan, all of it or none of it. Before it does, you confirm two things when they apply:
|
|
36
|
+
|
|
37
|
+
- **That everyone you're importing as subscribed opted in** to receive your publication, and the list isn't purchased, rented, or scraped. Kestrel keeps your attestation with the import.
|
|
38
|
+
- **Each scheduled post that would grow.** A post goes to your list as it stands when it sends, so the people you import would receive it.
|
|
18
39
|
|
|
19
|
-
|
|
40
|
+
## What carries over
|
|
20
41
|
|
|
21
|
-
|
|
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 **How they joined** shows where they came from, such as "Imported · Sendy, single opt-in".
|
|
22
43
|
|
|
23
|
-
|
|
44
|
+
A few things hold whatever the file says:
|
|
24
45
|
|
|
25
|
-
|
|
46
|
+
- **Someone who unsubscribed here stays unsubscribed,** until they confirm again themselves.
|
|
47
|
+
- **A bounce, a block, or an erasure here always wins.** An import never lifts one.
|
|
48
|
+
- **A reader's newer choice stands.** Someone who confirmed here after your old service says they left stays subscribed.
|
|
26
49
|
|
|
27
|
-
|
|
50
|
+
## Limits
|
|
51
|
+
|
|
52
|
+
- **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
|
+
- **Daily writes.** On the free Workers plan, an import or a large send warns you when it would use most of the day's database writes. Neither warning stops anything. [Limits in detail](01-your-list.md#limits-in-detail) gives the numbers.
|
|
54
|
+
|
|
55
|
+
## Taking your list out
|
|
56
|
+
|
|
57
|
+
**Subscribers → Export** downloads your whole list as one CSV in the same format. It holds every subscriber, how they joined, and every bounced, complained, blocked, or erased address. Keep it as a backup, or import it into another Kestrel to carry the list over.
|
|
58
|
+
|
|
59
|
+
## The file format
|
|
60
|
+
|
|
61
|
+
This section defines `kestrel-subscribers/1` 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.
|
|
28
62
|
|
|
29
|
-
|
|
63
|
+
### The CSV
|
|
30
64
|
|
|
31
|
-
|
|
65
|
+
The file is a CSV saved as UTF-8 (in a spreadsheet, **CSV UTF-8**). A header row names the columns below, in any order, then each row is one address. A value with a comma, a quote, or a line break in it is wrapped in double quotes. Each quote inside it is written twice, as spreadsheets write it (RFC 4180). Column names are written exactly, in lowercase. A row records what the source claims, never what Kestrel will do: the import decides that, under the rules further down.
|
|
32
66
|
|
|
33
|
-
|
|
67
|
+
| Column | Required | Values | Meaning |
|
|
68
|
+
| --- | --- | --- | --- |
|
|
69
|
+
| `email` | yes | an email address | The address, trimmed and lowercased before it is matched. An invalid one is still a row, counted as invalid and reported, never dropped. |
|
|
70
|
+
| `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
|
+
| `opt_in` | yes | `double`, `single`, `unknown` | How consent was collected. `unknown` unless the source states it. |
|
|
72
|
+
| `origin` | yes | a short lowercase name | Where consent was first collected, as the file claims it: `sendy`, `mailchimp`, `kestrel`, or `unknown`. |
|
|
73
|
+
| `suppression` | no | `bounce`, `complaint`, `manual`, `erased` | A suppression on top of `status`: the address bounced, complained, was suppressed by hand, or was erased. |
|
|
74
|
+
| `subscribed_at` | no | an instant with its zone | When the person subscribed, if the source states it. |
|
|
75
|
+
| `confirmed_at` | no | an instant with its zone | When the person confirmed, if the source states it. |
|
|
76
|
+
| `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
|
+
| `suppressed_at` | no | an instant with its zone | When the address was suppressed, if the source states it. |
|
|
78
|
+
| `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 file is written in. A file without it is read as version 1. |
|
|
80
|
+
|
|
81
|
+
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
|
+
|
|
83
|
+
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
|
+
|
|
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 left, and a bounced address. Here are the same four with only the columns they use, since an optional column may be left out:
|
|
34
86
|
|
|
35
87
|
```
|
|
36
|
-
|
|
88
|
+
email,status,opt_in,origin,suppression,unsubscribed_at,evidence
|
|
89
|
+
ada@example.com,subscribed,double,sendy,,,"{""signup_ip"":""203.0.113.7""}"
|
|
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,bounce,,
|
|
37
93
|
```
|
|
38
94
|
|
|
39
|
-
|
|
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.
|
|
40
96
|
|
|
41
|
-
|
|
97
|
+
### The plain list
|
|
42
98
|
|
|
43
|
-
|
|
99
|
+
- One address per line. Each line is trimmed, blank lines are skipped, and CRLF line endings and a UTF-8 byte-order mark are accepted.
|
|
100
|
+
- A line that isn't a single valid address, such as `Name <ada@example.com>` or a typo, counts as invalid. It's listed among the rows left out. One bad line doesn't sink a list.
|
|
101
|
+
- The whole file is refused when it isn't a plain list at all. That's a file that isn't UTF-8 text, or has no valid address in it. It's also one with a comma, tab, or semicolon in a line, since that makes it a table, so a `.csv`.
|
|
102
|
+
- Its rows arrive with `origin: unknown` and `opt_in: unknown`.
|
|
44
103
|
|
|
45
|
-
|
|
104
|
+
### Plan, then commit
|
|
46
105
|
|
|
47
|
-
|
|
106
|
+
An import happens in two steps. Planning shows what the import would do to every row, against your list as it stands. It changes nothing, so you can plan a file as often as you need. Committing applies the plan, all of it or none of it, and emails no subscriber.
|
|
48
107
|
|
|
49
|
-
|
|
108
|
+
The commit needs two things from you when they apply:
|
|
50
109
|
|
|
51
|
-
|
|
110
|
+
- **Your attestation,** when anyone would be confirmed: "Everyone I'm importing as subscribed opted in to receive [your publication]. This isn't a purchased, rented, or scraped list." It is kept with the import, with who gave it and when. An import that only unsubscribes or suppresses needs none.
|
|
111
|
+
- **Each send that would grow,** acknowledged by a person. A post goes to the list as it stands when it fires. So an import can add to the audience of a scheduled send, or of one that has just fired and not yet fixed its audience. Claude can attest for you, but can't acknowledge a growing send: you finish that import in the dashboard.
|
|
52
112
|
|
|
53
|
-
|
|
113
|
+
If another import is committed after you plan, plan again. If your list changes between the plan and the commit in any other way, such as a reader unsubscribing, the commit still goes ahead. Every write checks the list as it stands, and the answer reports any row that turned out differently.
|
|
54
114
|
|
|
55
|
-
|
|
115
|
+
### What an import does to each address
|
|
56
116
|
|
|
57
|
-
|
|
117
|
+
Each row gets exactly one outcome. A row that both changes its status and adds a suppression reports its status outcome, and says it added a suppression too.
|
|
58
118
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
119
|
+
| Outcome | When |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| `create_confirmed` | `subscribed`, with no subscriber and no suppression in Kestrel, and not erased in the file: added as confirmed. |
|
|
122
|
+
| `confirm_pending` | `subscribed`, pending in Kestrel (never confirmed), and not erased in the file: confirmed, and its confirmation link no longer works. |
|
|
123
|
+
| `unchanged` | Already confirmed in Kestrel, or already in the state the row claims. A subscriber who joined through Kestrel keeps their own record. |
|
|
124
|
+
| `mark_unsubscribed` | `unsubscribed`, and confirmed or pending in Kestrel, with no confirmation in Kestrel newer than the row's `unsubscribed_at`: unsubscribed. |
|
|
125
|
+
| `create_unsubscribed` | `unsubscribed`, with no subscriber in Kestrel, and not erased in Kestrel or in the file: recorded as unsubscribed, so the list remembers they left. |
|
|
126
|
+
| `kept_confirmed_newer` | `unsubscribed`, but the reader confirmed in Kestrel after the source's `unsubscribed_at`: their newer action stands. |
|
|
127
|
+
| `kept_unsubscribed` | `subscribed`, but unsubscribed in Kestrel, or pending again after leaving until they confirm in Kestrel themselves. An unsubscribe is never reversed. |
|
|
128
|
+
| `blocked_by_suppression` | `subscribed`, but the address carries a suppression in Kestrel, a do-not-contact marker included: never added or confirmed. Also `unsubscribed`, when the address's suppression in Kestrel is the do-not-contact marker an erasure left. An address erased in the file gets this outcome too, when its row is `subscribed` or `unsubscribed` with no subscriber in Kestrel. So does one that's `subscribed` and pending in Kestrel, which stays pending. The file's `erased` suppression is still added. In each case no subscriber is recorded or confirmed, so an import never brings back an erased person's data. |
|
|
129
|
+
| `suppression_added` | The row carries a suppression and the address has none in Kestrel, or only a do-not-contact marker, and nothing else changes. Applied whatever the row's `status`. |
|
|
130
|
+
| `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
|
+
| `invalid` | The address isn't valid. |
|
|
132
|
+
| `merged` | A repeat of an address earlier in the file, folded into it. |
|
|
63
133
|
|
|
64
|
-
|
|
134
|
+
A suppression already in Kestrel is never replaced, with one exception. A bounce, a complaint, or a manual suppression replaces a do-not-contact marker left by an erasure, so a known bad address still gets no confirmation. An imported bounce or complaint is kept apart from one Kestrel saw itself, so the list says where each came from.
|
|
65
135
|
|
|
66
|
-
|
|
136
|
+
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.
|
|
67
137
|
|
|
68
|
-
|
|
69
|
-
- **Daily database writes on the free plan.** Cloudflare's free plan allows a fixed number of database writes a day, and stops your newsletter until midnight UTC once they're used up. Bringing in a large list, in one file or several, and sending to it on the same day can use them up. So when the day's imports would use most of the day's writes, the plan warns you and suggests importing the file the next day, by UTC, and a post scheduled for a day imports have written warns you too, suggesting it go out the next day. Neither warning stops anything.
|
|
70
|
-
- **When to move to Workers Paid.** From about 5,000 subscribers, it's recommended to move to the $5 Workers Paid plan, which has no daily limit on writes. Whoever runs your Kestrel makes the change, as [Go live](../get-started/06-go-live.md#as-your-list-grows) describes.
|
|
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 suppression is the first of erased, complaint, 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.
|
|
71
139
|
|
|
72
|
-
|
|
140
|
+
A subscriber an import confirmed is **confirmed**, like anyone who confirmed in Kestrel, and gets your posts. How they joined shows beside their state: imported, with the origin and opt-in method the file claimed, and the evidence. 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.
|
|
73
141
|
|
|
74
|
-
|
|
142
|
+
### Limits in detail
|
|
75
143
|
|
|
76
|
-
|
|
144
|
+
- **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
|
+
- **Size:** an import request of at most 10 MB, and at most 500 bytes of `evidence` a row.
|
|
146
|
+
- **Daily database writes on the free Workers plan:** 100,000 written rows a day. Once they're spent, the app stops working until midnight UTC. Each new subscriber an import brings in writes 7 rows, and a send writes about 14 a recipient. So an import of 1,000 new subscribers uses about 7% of a day, and about ten of them use most of it.
|
|
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.
|
|
77
148
|
|
|
78
|
-
|
|
149
|
+
### Exporting
|
|
79
150
|
|
|
80
|
-
|
|
151
|
+
The export reads your whole list out in this format, a page at a time, at `GET /api/subscribers/export`. Each page holds up to 1,000 rows on the free Workers plan's limits, or 10,000 otherwise. Its `next` value asks for the page after it. Following `next` until it's `null` gives every address exactly once, even if the list changes while you read. An address added or removed partway through may or may not appear.
|
|
81
152
|
|
|
82
|
-
|
|
153
|
+
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.
|
|
83
154
|
|
|
84
|
-
|
|
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`.
|
|
85
156
|
|
|
86
|
-
|
|
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.
|
|
@@ -6,17 +6,17 @@ This page covers what Kestrel handles, what you handle, and how to act on a read
|
|
|
6
6
|
|
|
7
7
|
## What Kestrel handles
|
|
8
8
|
|
|
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#
|
|
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
11
|
- **Bounces and complaints.** An address that bounces, or a reader who marks your email as spam, is never mailed again. So a bad address can't keep hurting your sending reputation.
|
|
12
|
-
- **Sender authentication.** Your emails are signed with DKIM for your own domain, and SPF and DMARC records in its DNS vouch for them.
|
|
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
|
|
15
15
|
|
|
16
|
-
- **An honest From name and subject line.** The From name should say who the email is from, and the subject should match what's inside.
|
|
16
|
+
- **An honest From name and subject line.** The From name should say who the email is from, and the subject should match what's inside. The From name and address are set when Kestrel is deployed ([Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy)).
|
|
17
17
|
- **Marking ads as ads.** When an email is an advertisement, say so clearly. In a newsletter that's mostly writing, labeling a sponsor's section is enough.
|
|
18
18
|
- **A mailing address, when an email is commercial.** Most newsletters don't need one. [When to provide a mailing address](03-name-your-publication.md#when-to-provide-a-mailing-address) says when you do, beside where you add one.
|
|
19
|
-
- **Requests readers send you.** A reader who replies to a post writes to your From address. Replies reach you once that address forwards to an inbox you read,
|
|
19
|
+
- **Requests readers send you.** A reader who replies to a post writes to your From address. Replies reach you once that address forwards to an inbox you read, as [Connect Amazon SES](../get-started/04-ses.md#3-receive-replies-at-your-from-address) sets up. Kestrel never sees a reply, so carrying out what a reader asks is up to you.
|
|
20
20
|
|
|
21
21
|
## When a reader asks to leave or to be deleted
|
|
22
22
|
|
|
@@ -25,7 +25,7 @@ Kestrel offers readers no form for deleting their data, so they ask you. In many
|
|
|
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
26
|
- **To stop the email,** find a confirmed reader under **Subscribers**, and choose **Unsubscribe** from their ⋯ menu. It records that they asked, and they can rejoin by subscribing again. A reader who never confirmed gets no posts, so **Erase** is the choice for them.
|
|
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 removes the subscriber and how they joined, and can't be undone. Past send records show an erased recipient in their place. Kestrel keeps the address
|
|
28
|
+
- **To delete their data,** choose **Erase** from the same menu. It removes the subscriber and how they joined, and can't be undone. Past send records show an erased recipient in their place. Kestrel keeps only a do-not-contact marker, or the bounce, complaint, or block the address already had, so it's never mailed again. The reader can still rejoin by subscribing and confirming themselves.
|
|
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
|
|
@@ -1,106 +1,20 @@
|
|
|
1
1
|
# Name your publication
|
|
2
2
|
|
|
3
|
-
Give your newsletter a name and a look before your first post.
|
|
3
|
+
Give your newsletter a name and a look before your first post. Set the name, tagline, and logo under **Settings → Publication identity**. They appear in every email your template prints them in, on the public pages readers see, and in the confirmation email new subscribers get.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
## Name, tagline, and logo
|
|
8
|
-
|
|
9
|
-
All three are optional, and you can change them at any time under **Settings → Publication identity**. The name and tagline save with **Save changes** in the bar at the bottom of the page. The logo saves the moment you pick it.
|
|
10
|
-
|
|
11
|
-
- **Name.** Left blank, your publication uses the From name of your emails instead. If that has no name either, your publication goes unnamed, and every page reads whole without one.
|
|
12
|
-
- **Tagline.** A short line under the name.
|
|
13
|
-
- **Logo.** A PNG, JPEG, WebP, or GIF image. Kestrel shows it square everywhere, so a wider or taller image is cropped to its center square. Pick one at least 256 × 256 px. **Replace** swaps it, and **Remove** takes it away.
|
|
14
|
-
|
|
15
|
-
Sent emails never change. An email already sent, and its archive page, keep the logo and name they went out with.
|
|
16
|
-
|
|
17
|
-
## Where each part appears
|
|
18
|
-
|
|
19
|
-
Your identity shows in four places:
|
|
20
|
-
|
|
21
|
-
- **In your emails,** wherever your template prints it. The built-in template signs off each post with your logo, name, and tagline. The sample email under **Settings → Email template** shows which parts your template prints.
|
|
22
|
-
- **On the public pages readers see.** Your home page, your archive, and each post's page carry your identity. So do the pages for subscribing, confirming, and unsubscribing. Your logo is the browser tab's icon. **Settings → Reader pages** shows each of these pages as a reader gets it.
|
|
23
|
-
- **In the confirmation email** a new subscriber gets, which opens with your logo, name, and tagline.
|
|
24
|
-
- **In the dashboard.** The top of the sidebar shows your logo, name, and tagline.
|
|
25
|
-
|
|
26
|
-
A post's page shows your publication as it is now at the top, and the sign-off as it was sent.
|
|
5
|
+
All three are optional, and you can change them at any time. How they appear in each email comes from your email template, which you can [edit](11-email-template.md).
|
|
27
6
|
|
|
28
7
|
## When to provide a mailing address
|
|
29
8
|
|
|
30
|
-
**
|
|
31
|
-
|
|
32
|
-
US law (CAN-SPAM) requires a postal address in commercial email. That's email whose primary purpose is to advertise or promote a product or service, at any volume. In its [2008 rule](https://www.ftc.gov/sites/default/files/documents/federal_register_notices/definitions-and-implementation-under-can-spam-act-16-cfr-part-316/080521canspamact.pdf), the FTC treats a newsletter someone subscribed to as a "transactional or relationship" message instead. That holds when it's mainly content, even with ads mixed in. A subscription that's purely advertising, like a catalog, stays commercial.
|
|
33
|
-
|
|
34
|
-
An email whose primary purpose is selling is a different matter. The FTC's [primary-purpose test](https://www.ecfr.gov/current/title-16/chapter-I/subchapter-C/part-316/section-316.3) looks at what the email says and what its subject line suggests. So you probably need an address in:
|
|
35
|
-
|
|
36
|
-
- An email that's mainly selling, such as a launch, a sale, a paid course, or event tickets.
|
|
37
|
-
- A post that's mostly sponsor content or promotions.
|
|
38
|
-
- A mixed email whose subject line reads like an ad.
|
|
39
|
-
|
|
40
|
-
A nonprofit isn't exempt as a kind of sender. What the email says decides it. Canada's anti-spam law (CASL) usually counts more email as commercial, and asks for a mailing address in it too ([Government of Canada guidance](https://ised-isde.canada.ca/site/canada-anti-spam-legislation/en)). Gmail and Yahoo don't ask for one: their [rules for bulk senders](https://support.google.com/a/answer/14229414) are authentication, one-click unsubscribe, and a low spam-complaint rate.
|
|
41
|
-
|
|
42
|
-
**What counts.** A street address, a USPS P.O. box, or a private mailbox registered with the Postal Service. You never have to publish a home address.
|
|
43
|
-
|
|
44
|
-
**Adding it.** Under **Settings → Publication identity**, choose **Add a mailing address**. The built-in template and both starting templates print it in the footer as soon as it's set, and print nothing while it isn't. They keep it off each post's public page, so your address isn't on the web.
|
|
45
|
-
|
|
46
|
-
If you've added an address and your template doesn't include `{{ .Publication.Address }}`, Kestrel saves your change and warns you. A line under the address says so in Settings, and the **Template** page says so when you save. If you typed your address into the template yourself, or don't need one, you can ignore the warning.
|
|
47
|
-
|
|
48
|
-
This is general information, not legal advice. The FTC's [CAN-SPAM compliance guide](https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business) is the plain-language source, and [Sending rules](02-sending-rules.md) covers the rest of what the law asks of a newsletter.
|
|
49
|
-
|
|
50
|
-
## The email template
|
|
51
|
-
|
|
52
|
-
The template is the layout every post is sent in: HTML with a style block, and variables Kestrel fills in when it sends. There is one template, used by every post. Choose **Template** in the sidebar, or **Edit template →** under **Settings → Email template**.
|
|
53
|
-
|
|
54
|
-
You start from a built-in default. **Start from example** offers two starting templates to replace what's in the editor:
|
|
55
|
-
|
|
56
|
-
- **Signed:** your post, then your logo, name, and tagline as a sign-off.
|
|
57
|
-
- **Plain:** just your post and the footer links.
|
|
58
|
-
|
|
59
|
-
Both put the unsubscribe link, a View in browser link, and your mailing address in the footer, and both end with "Powered by Kestrel". From there, edit the HTML as you like, then press **Save changes**. **Discard** throws your edits away.
|
|
60
|
-
|
|
61
|
-
### Variables
|
|
62
|
-
|
|
63
|
-
A variable is replaced with its value in each email. Type it exactly as shown, or it renders as empty. The **Variables** list at the bottom of the **Template** page says what each one holds.
|
|
64
|
-
|
|
65
|
-
| Variable | What it holds |
|
|
66
|
-
| --- | --- |
|
|
67
|
-
| `{{ .Post.Body }}` | Your post, rendered from Markdown. Required. |
|
|
68
|
-
| `{{ .Post.Subject }}` | The post's subject line. |
|
|
69
|
-
| `{{ .Publication.Name }}` | Your publication's name. |
|
|
70
|
-
| `{{ .Publication.Tagline }}` | Your tagline. |
|
|
71
|
-
| `{{ .Publication.Logo }}` | Your logo as an image, or nothing when no logo is set. |
|
|
72
|
-
| `{{ .Publication.LogoURL }}` | Your logo's address, or empty. |
|
|
73
|
-
| `{{ .Publication.Address }}` | Your mailing address, or empty. |
|
|
74
|
-
| `{{ .Email.SentTo }}` | The address this email was sent to. |
|
|
75
|
-
| `{{ .Email.UnsubscribeURL }}` | This reader's one-click unsubscribe link. Required. |
|
|
76
|
-
| `{{ .Email.ViewInBrowserURL }}` | The post's public page. |
|
|
77
|
-
|
|
78
|
-
Kestrel won't save a template without `{{ .Post.Body }}` or `{{ .Email.UnsubscribeURL }}`. Every email has to carry the post and a way to leave. The two **Required** pills above the editor turn green or red as your template gains or loses them.
|
|
79
|
-
|
|
80
|
-
### Email-only parts
|
|
81
|
-
|
|
82
|
-
Wrap part of the template in `{{ if .IsEmail }}` and `{{ end }}` to keep it off each post's public page. The starting templates wrap the unsubscribe link, the View in browser link, and the mailing address this way. To show your address on the web too, move it outside the wrapped part.
|
|
83
|
-
|
|
84
|
-
### Test the template
|
|
85
|
-
|
|
86
|
-
The sample email at the top of the **Template** page shows the layout with sample content. Mail apps each draw email their own way, so send yourself a test to see it in a real inbox.
|
|
87
|
-
|
|
88
|
-
**Send test email** sends a sample post through your saved template, to the addresses you choose. Your default test recipients, from **Settings → Default test recipients**, fill in first. With unsaved edits, the button reads **Save & send test**, since a test always uses the saved template.
|
|
89
|
-
|
|
90
|
-
## Changes and scheduled posts
|
|
91
|
-
|
|
92
|
-
When you schedule a post, Kestrel makes its email then, with your template and identity as they stand. So when you change the template, or a part of your identity the template prints, Kestrel makes each scheduled post's email again. Nothing changes until you confirm.
|
|
9
|
+
**If your newsletter is mostly your own writing, you probably don't need one. If an email is mainly selling something, add one.**
|
|
93
10
|
|
|
94
|
-
|
|
11
|
+
US law (CAN-SPAM) asks for a postal address in commercial email. The FTC [treats a newsletter people subscribed to](https://www.ftc.gov/sites/default/files/documents/federal_register_notices/definitions-and-implementation-under-can-spam-act-16-cfr-part-316/080521canspamact.pdf) as a relationship message instead, even with a few ads in it. An email whose main purpose is to sell, such as a launch, a sale, or a sponsor's promotion, is commercial. Canada's law (CASL) asks for an address more often ([Government of Canada guidance](https://ised-isde.canada.ca/site/canada-anti-spam-legislation/en)).
|
|
95
12
|
|
|
96
|
-
|
|
13
|
+
A P.O. box or a registered private mailbox counts, so you never have to publish your home address. To add one, choose **Add a mailing address** under **Settings → Publication identity**. The built-in templates print it in the email footer and keep it off your public pages.
|
|
97
14
|
|
|
98
|
-
|
|
99
|
-
- **A post about to send** blocks the change. Kestrel says when you can try again, once that post has gone out.
|
|
100
|
-
- **Posts already sending or sent** never change.
|
|
15
|
+
This is general information, not legal advice. The FTC's [CAN-SPAM compliance guide](https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business) has the detail, and [Sending rules](02-sending-rules.md) covers the rest of what the law asks of a newsletter.
|
|
101
16
|
|
|
102
17
|
## Who sets the From name and address
|
|
103
18
|
|
|
104
|
-
|
|
19
|
+
The From name and address your emails come from are set when Kestrel is deployed, so **Settings → Email sender** shows them read-only. To change them, follow [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy).
|
|
105
20
|
|
|
106
|
-
Next, [Check your setup](10-check-your-setup.md).
|
|
@@ -1,98 +1,69 @@
|
|
|
1
|
-
# Write
|
|
1
|
+
# Write and send a post
|
|
2
2
|
|
|
3
|
-
You write a post in Markdown,
|
|
3
|
+
You write a post in Markdown, test it, and schedule it. Scheduling locks the post and makes the exact email that will go out. Then you send yourself a test of that copy, so the email you tested is the one your list receives.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**New post**, on the Dashboard or under **Drafts**, opens a draft.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Writing
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Kestrel's editor is a Markdown editor. You write a post in Markdown, the way you would for a static site generator or with an AI assistant. Kestrel places it in your [email template](11-email-template.md). The same Markdown makes the email and the post's archive page, so you never format a post twice. **Preview** shows the email as it will look, and Kestrel saves as you type.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
A few things to know as you write:
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
- **A blank line starts a new paragraph.** In an email, a single line break doesn't.
|
|
14
|
+
- **The opening lines** are the preview a mail client shows beside the subject.
|
|
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
|
+
- **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).
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
## Test it
|
|
16
19
|
|
|
17
|
-
|
|
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.
|
|
18
21
|
|
|
19
|
-
|
|
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.
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
## Schedule and send
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
Choose **Schedule** at the foot of the editor, pick a time under **Send at**, and confirm. The time must be at least the minimum lead away: five minutes, unless your deployment sets a different [`MIN_LEAD_SECONDS`](../reference/01-configuration.md#min_lead_seconds). Neither the dashboard nor Claude can shorten it. Times are to the minute, and one with seconds rounds up.
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
Scheduling makes the email once, from the post, your template, and your publication's details as they are at that moment. That frozen copy is what goes out, and it's also the post's page in your archive. So the post is locked, and **Preview** and **Send test email** show the frozen copy.
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
**Send yourself a test now.** It's the copy your list receives, and the time until the send is your window to catch a mistake, including one Claude made.
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
Changing your template, or a publication detail it shows, re-makes the email of every scheduled post, with the same content and send time. Kestrel asks before it saves, and each re-made post then asks you to send a new test. A change is refused while a post is within the minimum lead of its send time, and Kestrel says when to try again. Posts already sending or sent never change.
|
|
30
33
|
|
|
31
|
-
|
|
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.
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
- **HTML.** You can write HTML where Markdown falls short. Kestrel removes scripts, `<style>` blocks, forms, and event handlers, since no email needs them. Style a piece with an inline `style` attribute instead.
|
|
35
|
-
- **The inbox preview.** The snippet a mail client shows beside the subject comes from the start of your post. There's no separate field for it, so make the opening lines count.
|
|
36
|
-
- **The plain-text version.** Every email also carries a plain-text version made from the same post. A link becomes its text followed by its address, and an image becomes its alt text.
|
|
36
|
+
### Post settings
|
|
37
37
|
|
|
38
|
-
|
|
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
39
|
|
|
40
|
-
|
|
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
41
|
|
|
42
|
-
|
|
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
43
|
|
|
44
|
-
|
|
45
|
-
- **Size.** Up to 5 MB per image. Kestrel doesn't shrink the file, and every reader downloads it, so a smaller file opens faster. An image wider than the email's column is shown at the column's width.
|
|
46
|
-
- **Warnings.** The preview and each test warn you about an image with no alt text, or a reference to a file the post doesn't have.
|
|
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.
|
|
47
45
|
|
|
48
|
-
|
|
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.
|
|
49
47
|
|
|
50
|
-
|
|
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.
|
|
51
49
|
|
|
52
|
-
|
|
50
|
+
### Changing your mind
|
|
53
51
|
|
|
54
|
-
|
|
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.
|
|
55
53
|
|
|
56
|
-
|
|
54
|
+
### Who receives it
|
|
57
55
|
|
|
58
|
-
|
|
56
|
+
A post goes to your list as it stands when it sends: every confirmed subscriber, except addresses that bounced, complained, or were blocked. 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.
|
|
59
57
|
|
|
60
|
-
|
|
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)).
|
|
61
59
|
|
|
62
|
-
|
|
60
|
+
### Warnings when you schedule
|
|
63
61
|
|
|
64
|
-
- **
|
|
65
|
-
- **
|
|
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 large send 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.
|
|
66
64
|
|
|
67
|
-
|
|
65
|
+
### When it sends
|
|
68
66
|
|
|
69
|
-
|
|
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.
|
|
70
68
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
Three views show the post, each for one purpose:
|
|
74
|
-
|
|
75
|
-
- **Preview** is the email, for a quick look at layout.
|
|
76
|
-
- **Send test email** is the email in a real inbox, where rendering finally counts.
|
|
77
|
-
- **Web version ↗** is the post's public archive page, before it's public.
|
|
78
|
-
|
|
79
|
-
Inside the preview, the email's "view in browser" link opens the web version, since the public page doesn't exist until the post sends. Mail clients each draw email a little differently, so trust a test over the preview.
|
|
80
|
-
|
|
81
|
-
## Test emails
|
|
82
|
-
|
|
83
|
-
**Send test email** opens a dialog that takes one or more addresses, one per line. Each gets its own copy of the email, and nobody on your list receives anything. The addresses in **Settings → Default test recipients** fill the dialog for you.
|
|
84
|
-
|
|
85
|
-
A test is the real email. It saves your draft first, then sends it through the same template and code as the send to your list. So a clean test means a clean send. Once a post is scheduled, a test sends the exact copy that will go out.
|
|
86
|
-
|
|
87
|
-
When some addresses don't get the test, the dialog stays open with only those left, and says why. **Send test** again tries just those, so no one gets a second copy.
|
|
88
|
-
|
|
89
|
-
A few links in a test behave differently from the real send:
|
|
90
|
-
|
|
91
|
-
- **Unsubscribe.** A test carries an unsubscribe link and the one-click unsubscribe header, so you can check both. Neither unsubscribes anyone. The link opens the page a subscriber sees, marked as a test and showing a sample address.
|
|
92
|
-
- **View in browser.** The link points at the post's public address, which shows a page saying the post appears there once it's sent. It works once the send starts.
|
|
93
|
-
|
|
94
|
-
A test needs an email provider. Until whoever runs your Kestrel connects one, the editor lets you write and preview but not send tests. [Connect Amazon SES](../get-started/04-ses.md) is their step.
|
|
95
|
-
|
|
96
|
-
## Next
|
|
97
|
-
|
|
98
|
-
When the test looks right, [Scheduling and sending](05-scheduling-and-sending.md) covers putting the post on the schedule. On a new publication, finish setting up first: [Share your subscribe link](09-share-your-subscribe-link.md).
|
|
69
|
+
If a send needs your attention, the send page and the dashboard say so. [Troubleshooting](06-troubleshooting.md) covers each case.
|