@kurtbruns/kestrel 1.10.0 → 1.11.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 +13 -1
- package/README.md +19 -29
- package/dist/public/dashboard/app.js +1 -1
- package/dist/public/dashboard/app.js.map +2 -2
- package/dist/public/dashboard/index.html +1 -1
- package/dist/worker/index.js +86 -190
- 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 +14 -14
- package/docs/get-started/05-verify.md +19 -7
- package/docs/get-started/06-go-live.md +13 -55
- package/docs/guides/01-connect-claude.md +4 -4
- 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 +30 -73
- package/docs/publishing/06-troubleshooting.md +74 -0
- package/docs/publishing/07-working-with-claude.md +18 -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/package.json +1 -1
- 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,72 +0,0 @@
|
|
|
1
|
-
# Scheduling and sending
|
|
2
|
-
|
|
3
|
-
Every post goes out on a schedule. Scheduling makes the email and locks the post, and the send stays visible and cancelable until its time comes. That window is your review, and nothing can skip it, not even **Send now**.
|
|
4
|
-
|
|
5
|
-
This page covers scheduling, what it freezes, canceling, moving the send time, and sending now. It also covers who receives a post, and what happens when it goes out.
|
|
6
|
-
|
|
7
|
-
## Scheduling a post
|
|
8
|
-
|
|
9
|
-
**Schedule** at the foot of the editor opens a dialog where you pick a time under **Send at**, then confirm with **Schedule**. The post goes out at that time, and until then you can cancel it or move it.
|
|
10
|
-
|
|
11
|
-
The time you pick must be at least the minimum lead away. That's five minutes, unless whoever runs your Kestrel set it differently ([Configuration](../reference/01-configuration.md#min_lead_seconds)). A time closer than that is refused under the picker, with the reason. The lead isn't a setting in the dashboard, and Claude can't change it either. So every send gets a window to catch a mistake, including one Claude prepared.
|
|
12
|
-
|
|
13
|
-
Send times are to the minute. A time with seconds is rounded up to the next whole minute, which only lengthens the window.
|
|
14
|
-
|
|
15
|
-
A post needs a subject to schedule. If the subject is empty, pressing **Schedule** marks the **Subject** field with "Add a subject before you schedule." An empty body gets a warning, not a refusal.
|
|
16
|
-
|
|
17
|
-
After you schedule, you stay on the post. A toast names the send time and suggests you send yourself a test, since the window is where you review. The post now shows the scheduled banner: "Scheduled for" the time, with a countdown ("Sends in …"), and **Reschedule** and **Cancel** beside it.
|
|
18
|
-
|
|
19
|
-
You manage a scheduled post from that banner, or from the **Scheduled** card on **Sent**. The dashboard's **Scheduled** card shows the countdown and opens the post.
|
|
20
|
-
|
|
21
|
-
## What scheduling freezes
|
|
22
|
-
|
|
23
|
-
Scheduling makes the email once, from the post, your email 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.
|
|
24
|
-
|
|
25
|
-
So the post is locked while it's scheduled. It opens on **Preview**, which shows the frozen copy. On **Edit**, you can still read, select, and copy the text, and the composer's foot says "Cancel the schedule to edit". **Send test email** still works, and sends you the frozen copy that will go out.
|
|
26
|
-
|
|
27
|
-
To change the post's content, cancel the schedule, edit, test again, and schedule it again. That resets your review, so your last test is always of the copy that sends.
|
|
28
|
-
|
|
29
|
-
A change to your template, or to a publication detail the template shows, is different. It re-makes the email of every scheduled post, at the same send times, with the same content. Kestrel asks before it saves, in a dialog confirmed with **Save and apply**. Afterwards each re-made post's banner asks you to send a new test. The dashboard's card reads "Look changed since your last test" until you do. A save like this is refused while any scheduled post is within the minimum lead of its send time. The refusal says when to try again. [Name your publication](03-name-your-publication.md) covers the template and those details.
|
|
30
|
-
|
|
31
|
-
## Canceling
|
|
32
|
-
|
|
33
|
-
**Cancel** on the scheduled banner, or on the **Scheduled** card on **Sent**, stops the send at once. No confirmation is asked, since nothing is lost: the post is a draft again, editable, and you can schedule it again whenever you like.
|
|
34
|
-
|
|
35
|
-
You can cancel until the send time itself. From then the banner reads "Preparing to send…" and its buttons are unavailable, because the email is going out. A cancel that arrives just after the send time is refused with "Too late to cancel: the send time has passed, so this email is going out."
|
|
36
|
-
|
|
37
|
-
## Moving the send time
|
|
38
|
-
|
|
39
|
-
**Reschedule** moves when a scheduled post sends, without canceling it. It opens the same time picker, and **Reschedule** confirms it. The content stays frozen, your tests still count, and the post stays cancelable until the new time.
|
|
40
|
-
|
|
41
|
-
The new time follows the same rule as scheduling: at least the minimum lead away. Like canceling, you can move a send only before its send time.
|
|
42
|
-
|
|
43
|
-
## Sending now
|
|
44
|
-
|
|
45
|
-
Under the time picker in the Schedule dialog, "Or send as soon as possible." leads to **Send now →**. It's for a post you've already reviewed and want out without picking a time.
|
|
46
|
-
|
|
47
|
-
Sending now still goes through the review window. The **Send now?** view says how many recipients it reaches and when, such as "in 5 to 6 minutes". The range is the minimum lead, plus rounding up to the next minute. Confirm with **Send now**, and the post is locked and scheduled for that time, cancelable until then like any other.
|
|
48
|
-
|
|
49
|
-
## Who receives it
|
|
50
|
-
|
|
51
|
-
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 the post is scheduled receives it. Someone who unsubscribes before their email is handed to your email provider doesn't, even partway through the send.
|
|
52
|
-
|
|
53
|
-
So the recipient count the **Send now?** view shows is a snapshot, not a promise. An import that adds subscribers while a post is scheduled adds them to its audience too. The import asks you to acknowledge that first ([Bring your existing list](01-your-list.md#what-an-import-does)).
|
|
54
|
-
|
|
55
|
-
## Warnings in the Schedule dialog
|
|
56
|
-
|
|
57
|
-
The Schedule dialog warns you about two things before you schedule.
|
|
58
|
-
|
|
59
|
-
- **No email provider connected.** The dialog says the post can't be scheduled yet, and its buttons are unavailable. Connecting a provider is for whoever runs your Kestrel, as [Connect Amazon SES](../get-started/04-ses.md) describes. If the provider goes after a post is scheduled, its banner reads "Will be held: no email provider is connected." The post stays cancelable, and goes out once a provider is connected.
|
|
60
|
-
- **Daily database writes.** On the Workers Free plan's limits, a large send may use up the day's database writes, which stops your newsletter until midnight UTC. The dialog warns you, and the post's banner repeats the warning for as long as it's scheduled. It never stops you from scheduling. [Bring your existing list](01-your-list.md#limits) explains the limit and when it's worth moving to Workers Paid.
|
|
61
|
-
|
|
62
|
-
## When it sends
|
|
63
|
-
|
|
64
|
-
At the send time, the post moves from the editor to its send page under **Sent**, where you can watch it go out. If you have the post open, the page switches over with a toast saying why. From here the post can't be edited, canceled, or moved.
|
|
65
|
-
|
|
66
|
-
A send goes out a slice at a time. A large list takes several slices, and between them the send still reads **Sending**, with "Paused between ticks. It resumes on the next sweep."
|
|
67
|
-
|
|
68
|
-
If your email provider refuses to take more, for example because your account's daily sending quota is spent, the send waits. Nobody is marked unsent, and it tries again on its own, picking up where it stopped once the quota frees up. Raising the quota is for whoever runs your Kestrel ([As your list grows](../get-started/06-go-live.md#as-your-list-grows)).
|
|
69
|
-
|
|
70
|
-
Whatever interrupts a send, it resumes rather than starting over. No reader is ever mailed the same post twice.
|
|
71
|
-
|
|
72
|
-
[After a send](06-after-a-send.md) covers what the send page shows once it's out: the record, bounces, complaints, unsent recipients, and your archive page.
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
# After a send
|
|
2
|
-
|
|
3
|
-
Once a send starts, Kestrel keeps a permanent record of it: who it went to, and what happened to each email. The post goes up on its public page in your archive at the same moment, exactly as it was sent. Nothing about either can be edited, and you don't have to watch: Kestrel emails you when the send finishes, and when something needs you.
|
|
4
|
-
|
|
5
|
-
This page covers a send's record, unsent recipients, bounces and complaints, your notifications, the dashboard's views, and the post's public page.
|
|
6
|
-
|
|
7
|
-
## The send's record
|
|
8
|
-
|
|
9
|
-
Every send has its own page, which you open from **Sent** or from the dashboard's **Sent** list. While the send goes out, the page is a live view of its progress. Once it finishes, the same page becomes its record, in place.
|
|
10
|
-
|
|
11
|
-
The record opens with five counts, over everyone the send went to:
|
|
12
|
-
|
|
13
|
-
| Count | What it means |
|
|
14
|
-
| --- | --- |
|
|
15
|
-
| **Recipients** | Everyone the send went to: your confirmed subscribers who weren't suppressed when it started. |
|
|
16
|
-
| **Delivered** | Emails your provider confirmed reached the reader's mail server. |
|
|
17
|
-
| **Bounced** | Emails the receiving server turned away. |
|
|
18
|
-
| **Complained** | Readers who marked the email as spam. |
|
|
19
|
-
| **Unsent** | Recipients Kestrel couldn't hand to your email provider. [Unsent recipients](#unsent-recipients), below, says why. |
|
|
20
|
-
|
|
21
|
-
A line under the counts accounts for every recipient by name, so the numbers always add up. It also names two kinds the counts leave out. A recipient **accepted, awaiting a delivery receipt** was taken by your email provider, with no word yet on what happened next. A **skipped** recipient unsubscribed or was suppressed before their turn, so they were never mailed.
|
|
22
|
-
|
|
23
|
-
Below that is the full list of recipients. It opens on **Failures** (bounced, complained, and unsent), and **Delivered** and **All** are one click away. You can search it by address. Each row shows the outcome, the provider's own words about it, and when it happened. A person you erased reads as **Erased recipient** in place of their address. **Export recipients (CSV)** downloads the whole list.
|
|
24
|
-
|
|
25
|
-
### Accepted, then settled
|
|
26
|
-
|
|
27
|
-
Delivery happens in two stages, and the record keeps them apart. First Kestrel hands each email to your email provider, which accepts it, usually within minutes. Later, the provider reports what happened to each email: delivered, bounced, or complained. Those reports can lag by seconds or by days.
|
|
28
|
-
|
|
29
|
-
So a send is **sent** once every recipient has been handed off. Its record then keeps updating as reports arrive. The page says **Settling** while it waits, and **Complete** once every accepted email is confirmed. Some accepted emails are never confirmed at all, so a send can stay **Settling** for good. That's normal, not a fault.
|
|
30
|
-
|
|
31
|
-
"Delivered" in Kestrel always means confirmed, never just accepted. So a count can rise for days after a send finishes, and an email can be accepted without ever showing as delivered.
|
|
32
|
-
|
|
33
|
-
## Unsent recipients
|
|
34
|
-
|
|
35
|
-
An unsent recipient never got this post, and is still on your list. They get your next post as usual.
|
|
36
|
-
|
|
37
|
-
A recipient is recorded unsent in two cases:
|
|
38
|
-
|
|
39
|
-
- **Their own email kept failing.** When handing off one address hits a passing error, Kestrel tries again a few times, then records it unsent. The row's detail gives the provider's words.
|
|
40
|
-
- **You chose Assume not sent.** Rarely, Kestrel can't tell whether your provider took some emails. The send waits for you, and **Resolve…** on its page asks you to decide. **Assume not sent** is the safe default: those readers may miss this post, but no one is mailed twice.
|
|
41
|
-
|
|
42
|
-
A problem with your email provider itself never marks anyone unsent. While the provider is down, or refusing your account, the send waits and tries again. Once the problem is fixed, it carries on from where it stopped.
|
|
43
|
-
|
|
44
|
-
## Bounces and complaints
|
|
45
|
-
|
|
46
|
-
A bounce or a complaint usually arrives after the send has finished, and lands on the record when it does. Kestrel acts on it for you, so a bad address can't keep hurting your sending reputation:
|
|
47
|
-
|
|
48
|
-
- **A hard bounce** means the address can't receive mail, such as one that doesn't exist. Kestrel suppresses it, so it's never mailed again.
|
|
49
|
-
- **A soft bounce** is temporary, such as a full mailbox. It's counted on the record, and the address stays on your list.
|
|
50
|
-
- **A complaint** means the reader marked your email as spam. Kestrel suppresses the address, so it's never mailed again.
|
|
51
|
-
|
|
52
|
-
The record's list says **Hard bounce** or **Soft bounce** on each row. A suppression doesn't change anyone's subscription. Under **Subscribers**, a suppressed address keeps its status and carries a label saying why: **bounced**, **complaint**, **blocked**, or **erased**. **Legend**, beside the counts, says what each label means. Choose the **suppressed** count to list only those subscribers. [Sending rules](02-sending-rules.md#what-kestrel-handles) says why this matters to mailbox providers.
|
|
53
|
-
|
|
54
|
-
When a recent send's bounces reach about 5% of its recipients, the dashboard warns you. That's about the rate at which email providers start to review a sender, so it's worth finding out why.
|
|
55
|
-
|
|
56
|
-
### Lifting a suppression
|
|
57
|
-
|
|
58
|
-
Kestrel never lifts a suppression on its own, and neither does an import. Only you can, deliberately. Do it only when you know the address works again, such as a reader who tells you their mailbox is fixed. Mailing an address that keeps bouncing, or a reader who marked you as spam, can hurt every send after it.
|
|
59
|
-
|
|
60
|
-
A block is yours to lift: **Unblock** in the subscriber's ⋯ menu, which asks you to confirm. It's offered for a block you made, or one an import brought. The dashboard has no control for lifting a bounce or a complaint, but Claude can do it for you through the API, as [Working with Claude](07-working-with-claude.md) describes. Lifting a suppression changes nothing on past records: the bounce or complaint stays where it happened.
|
|
61
|
-
|
|
62
|
-
## Notifications
|
|
63
|
-
|
|
64
|
-
Kestrel emails you when a send finishes, and when a send runs into a problem it can't settle on its own. So you only need to open the dashboard to act.
|
|
65
|
-
|
|
66
|
-
Set the address under **Settings → Notifications**, in **Send notifications to**. **Send a test notification** checks that one arrives. With no address set, Kestrel sends nothing, and doesn't save anything up for later.
|
|
67
|
-
|
|
68
|
-
You're told:
|
|
69
|
-
|
|
70
|
-
- **When a send finishes,** with how many emails your provider accepted, how many were unsent, and how many were skipped.
|
|
71
|
-
- **When your provider refuses your account,** with the provider's own words and what they point to. Amazon SES reports a spent daily quota this way too.
|
|
72
|
-
- **When a send is held because no email provider is connected,** and that it goes out once one is.
|
|
73
|
-
- **When a send stays in progress too long.**
|
|
74
|
-
- **When a send waits for you to resolve it.**
|
|
75
|
-
- **When a send missed its time,** going out late or not at all.
|
|
76
|
-
- **When Claude imports a list for you,** with what the import changed.
|
|
77
|
-
|
|
78
|
-
Each notification links straight to the page it's about. Each event is told once, not again while it lasts, and the dashboard keeps showing it until it clears. A bounce spike is shown on the dashboard, but isn't emailed.
|
|
79
|
-
|
|
80
|
-
The **Last notification** row in the same section says whether the latest one was **Delivered** or **Not delivered**. Notifications are separate from your newsletter: one that fails never delays or changes a send.
|
|
81
|
-
|
|
82
|
-
Whoever runs your Kestrel chooses how notifications are sent. By default they go through your newsletter's own email provider. Then the one about your provider refusing your account can't reach you, since that provider would refuse it too. Whoever runs your Kestrel can send them another way that avoids this, as [Notifications through Cloudflare's email](../guides/03-notifications.md) describes.
|
|
83
|
-
|
|
84
|
-
## Seeing what's happening
|
|
85
|
-
|
|
86
|
-
The **Dashboard** shows what needs you first, then what's going on:
|
|
87
|
-
|
|
88
|
-
- **A health block,** only when something needs your attention, such as a send waiting for you to resolve it. Red needs you to act; amber is worth a look.
|
|
89
|
-
- **Active sends,** a card for each send going out now, with its progress.
|
|
90
|
-
- **Subscribers,** counts of **Confirmed**, **Pending**, **Unsubscribed**, and **Suppressed**. Each opens the list filtered to that group.
|
|
91
|
-
- **Sent,** your five latest sends, each with its **Delivered** count and a link to its public page.
|
|
92
|
-
|
|
93
|
-
**Sent** in the sidebar has the rest: anything that needs attention, your scheduled sends, sends in progress, and every send so far. Search them by subject, or turn on **With delivery failures** to list only sends with a bounce, a complaint, or an unsent recipient.
|
|
94
|
-
|
|
95
|
-
Wherever a send is listed, its number is **Delivered**, with any complaints, bounces, or unsent recipients named beside it. Every page keeps up on its own, so you never need to reload.
|
|
96
|
-
|
|
97
|
-
While a send goes out, its page shows two bars. The first fills as your provider accepts each email, and the second fills behind it as deliveries are confirmed. A label beside the subject says how it's going, such as **Sending**, **Retrying**, or **Backing off**.
|
|
98
|
-
|
|
99
|
-
## The post's public page
|
|
100
|
-
|
|
101
|
-
Your post goes up in your archive the moment its send starts. So the "view in browser" link in every email works as soon as it lands. Its publish date is the day its send started.
|
|
102
|
-
|
|
103
|
-
The page shows the exact copy that was sent, never a fresh rendering of it. Kestrel adds a light frame for the browser: your publication's name, the publish date, a link back to the archive, and a way to subscribe. It also leaves out the parts that only make sense in an inbox, such as the unsubscribe link and your mailing address. No reader's address ever appears on it.
|
|
104
|
-
|
|
105
|
-
A sent post can't be changed, and neither can its page. The sign-off shows your publication as it was when you sent the post, while the frame shows it as it is now. The page's address is permanent, so links to it keep working.
|
|
106
|
-
|
|
107
|
-
Open it from **View published post ↗** on the send's record, or **Archive ↗** in the dashboard's **Sent** list. **View publication ↗** in the sidebar opens your home page, which links into your archive. [Name your publication](03-name-your-publication.md) says where your name, logo, and address appear.
|
|
108
|
-
|
|
109
|
-
Next, see what Claude can do for you through the same API in [Working with Claude](07-working-with-claude.md).
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
# Subscriber import format
|
|
2
|
-
|
|
3
|
-
Kestrel imports a subscriber list in one documented format, `kestrel-subscribers/1`, and exports the whole list in the same format. This page describes the format, what an import does to each address, and the limits.
|
|
4
|
-
|
|
5
|
-
Kestrel never guesses what another service's export means, because a wrong guess about who agreed to be emailed can't be taken back. An export from Sendy, Mailchimp, or anywhere else is converted to this format first, by Claude or by hand. The API serves everything on this page at `GET /api/imports/format`, generated from the same definition the importer reads with, along with an example of each kind of file, `kestrel-import-example.csv` at `GET /api/imports/template.csv` and `kestrel-import-example.txt` at `GET /api/imports/template.txt`. The import screen offers both as **Download example file**.
|
|
6
|
-
|
|
7
|
-
## Two kinds of file
|
|
8
|
-
|
|
9
|
-
- **The full CSV (preferred).** It carries unsubscribes, bounces, and how each person joined. It's what the export writes, and you can open it in a spreadsheet to check it before importing.
|
|
10
|
-
- **A plain list (`.txt`).** One address per line, for a list that is only addresses. You say what they all are: subscribed (the default), unsubscribed, or bounced. Nothing else carries over: not how or when each person joined, when anyone left, or any evidence of their consent. And since every address is the one kind you choose, unsubscribes and bounces each need a file of their own.
|
|
11
|
-
|
|
12
|
-
The file's name decides which it is: `.csv` or `.txt`. Anything else is refused.
|
|
13
|
-
|
|
14
|
-
## The CSV
|
|
15
|
-
|
|
16
|
-
A CSV file saved as UTF-8 (in a spreadsheet, **CSV UTF-8**), with a header row of the column names below, in any order, then one row per address. A value with a comma, a quote, or a line break in it is wrapped in double quotes, with each quote inside 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.
|
|
17
|
-
|
|
18
|
-
| Column | Required | Values | Meaning |
|
|
19
|
-
| --- | --- | --- | --- |
|
|
20
|
-
| `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. |
|
|
21
|
-
| `status` | yes | `subscribed`, `pending`, `unsubscribed`, `none` | The consent claim. `none` is a known address with no consent claim, present only to carry a suppression. |
|
|
22
|
-
| `opt_in` | yes | `double`, `single`, `unknown` | How consent was collected. `unknown` unless the source states it. |
|
|
23
|
-
| `origin` | yes | a short lowercase name | Where consent was first collected, as the file claims it: `sendy`, `mailchimp`, `kestrel`, or `unknown`. |
|
|
24
|
-
| `suppression` | no | `bounce`, `complaint`, `manual`, `erased` | A suppression on top of `status`: the address bounced, complained, was suppressed by hand, or was erased. |
|
|
25
|
-
| `subscribed_at` | no | an instant with its zone | When the person subscribed, if the source states it. |
|
|
26
|
-
| `confirmed_at` | no | an instant with its zone | When the person confirmed, if the source states it. |
|
|
27
|
-
| `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. |
|
|
28
|
-
| `suppressed_at` | no | an instant with its zone | When the address was suppressed, if the source states it. |
|
|
29
|
-
| `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. |
|
|
30
|
-
| `format` | no | `kestrel-subscribers/1` | The version the file is written in. A file without it is read as version 1. |
|
|
31
|
-
|
|
32
|
-
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.
|
|
33
|
-
|
|
34
|
-
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.
|
|
35
|
-
|
|
36
|
-
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:
|
|
37
|
-
|
|
38
|
-
```
|
|
39
|
-
email,status,opt_in,origin,suppression,unsubscribed_at,evidence
|
|
40
|
-
ada@example.com,subscribed,double,sendy,,,"{""signup_ip"":""203.0.113.7""}"
|
|
41
|
-
grace@example.com,subscribed,single,mailchimp,,,
|
|
42
|
-
edsger@example.com,unsubscribed,unknown,unknown,,2025-01-20T08:00:00Z,
|
|
43
|
-
bounced@example.com,none,unknown,unknown,bounce,,
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
**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.
|
|
47
|
-
|
|
48
|
-
## The plain list
|
|
49
|
-
|
|
50
|
-
- 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.
|
|
51
|
-
- A line that isn't a single valid address, such as `Name <ada@example.com>` or a typo, is counted as invalid and listed among the rows left out. One bad line doesn't sink a list.
|
|
52
|
-
- The whole file is refused when it isn't a plain list at all: not UTF-8 text, a line with a comma, tab, or semicolon (it's a table, so a `.csv`), or not one valid address in it.
|
|
53
|
-
- Its rows arrive with `origin: unknown` and `opt_in: unknown`.
|
|
54
|
-
|
|
55
|
-
## Plan, then commit
|
|
56
|
-
|
|
57
|
-
An import happens in two steps. Planning shows what the import would do to every row, against your list as it stands, and 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 sends no email.
|
|
58
|
-
|
|
59
|
-
The commit needs two things from you when they apply:
|
|
60
|
-
|
|
61
|
-
- **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.
|
|
62
|
-
- **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.
|
|
63
|
-
|
|
64
|
-
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.
|
|
65
|
-
|
|
66
|
-
## What an import does to each address
|
|
67
|
-
|
|
68
|
-
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.
|
|
69
|
-
|
|
70
|
-
| Outcome | When |
|
|
71
|
-
| --- | --- |
|
|
72
|
-
| `create_confirmed` | `subscribed`, with no subscriber and no suppression in Kestrel, and not erased in the file: added as confirmed. |
|
|
73
|
-
| `confirm_pending` | `subscribed`, pending in Kestrel (never confirmed), and not erased in the file: confirmed, and its confirmation link no longer works. |
|
|
74
|
-
| `unchanged` | Already confirmed in Kestrel, or already in the state the row claims. A subscriber who joined through Kestrel keeps their own record. |
|
|
75
|
-
| `mark_unsubscribed` | `unsubscribed`, and confirmed or pending in Kestrel, with no confirmation in Kestrel newer than the row's `unsubscribed_at`: unsubscribed. |
|
|
76
|
-
| `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. |
|
|
77
|
-
| `kept_confirmed_newer` | `unsubscribed`, but the reader confirmed in Kestrel after the source's `unsubscribed_at`: their newer action stands. |
|
|
78
|
-
| `kept_unsubscribed` | `subscribed`, but unsubscribed in Kestrel, or pending again after leaving until they confirm in Kestrel themselves. An unsubscribe is never reversed. |
|
|
79
|
-
| `blocked_by_suppression` | `subscribed`, but the address carries a suppression in Kestrel, a do-not-contact marker included: never added or confirmed. Also `unsubscribed` for an address whose suppression in Kestrel is the do-not-contact marker an erasure left, and, for an address erased in the file (its `suppression` is `erased`, which is still added), `subscribed` or `unsubscribed` with no subscriber in Kestrel, or `subscribed` and pending in Kestrel, which stays pending: no subscriber is recorded or confirmed, so an import never brings back an erased person's data. |
|
|
80
|
-
| `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`. |
|
|
81
|
-
| `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. |
|
|
82
|
-
| `invalid` | The address isn't valid. |
|
|
83
|
-
| `merged` | A repeat of an address earlier in the file, folded into it. |
|
|
84
|
-
|
|
85
|
-
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.
|
|
86
|
-
|
|
87
|
-
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.
|
|
88
|
-
|
|
89
|
-
**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, and its suppression 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.
|
|
90
|
-
|
|
91
|
-
## How imported subscribers read
|
|
92
|
-
|
|
93
|
-
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.
|
|
94
|
-
|
|
95
|
-
## Limits
|
|
96
|
-
|
|
97
|
-
- **Rows:** up to 10,000 in one import. On the Workers Free plan's limits (`SUBREQUEST_BUDGET` unset), up to 1,000, since Free's 10 ms of CPU a request can't plan and commit more. Set [`SUBREQUEST_BUDGET`](../reference/01-configuration.md#subrequest_budget) on Workers Paid to take 10,000. Split a larger list into several files.
|
|
98
|
-
- **Size:** a request of at most 10 MB, and `evidence` of at most 500 bytes a row.
|
|
99
|
-
- **Database writes on Workers Free:** D1's free plan allows 100,000 rows written a day, and refuses every query past that until midnight UTC. Each new subscriber an import brings in writes 7 rows, and a send writes about 14 a recipient. So one import of 1,000 new subscribers uses about 7% of a day's writes, and about 10 of them use most of a day. On Workers Free, the plan warns when it and the day's earlier imports, by UTC, would use more than 70% of the day, and advises importing the file the next day. A send scheduled for a day imports have written warns in the same way, and advises sending the next day. Neither warning refuses.
|
|
100
|
-
|
|
101
|
-
## Exporting
|
|
102
|
-
|
|
103
|
-
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 Workers Free plan's limits, or 10,000 otherwise, and 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. 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, so a spreadsheet shows it as text rather than running it as a formula; an import takes the apostrophe off again. It includes every subscriber with how they joined, their evidence, and their suppression, and every address suppressed with no subscriber behind it, written as `status: none`. A confirmed subscriber is written as `subscribed`. 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.
|