@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/README.md +17 -28
  3. package/cli/reset.mjs +2 -0
  4. package/cli/seed.mjs +2 -0
  5. package/demo/README.md +4 -2
  6. package/demo/posts/0-test-email/index.md +13 -0
  7. package/demo/posts/5-a-quick-note/index.md +2 -0
  8. package/dist/public/dashboard/app.js +302 -293
  9. package/dist/public/dashboard/app.js.map +4 -4
  10. package/dist/public/dashboard/index.html +2 -2
  11. package/dist/public/dashboard/styles.css +434 -54
  12. package/dist/worker/index.js +1472 -470
  13. package/docs/README.md +2 -2
  14. package/docs/get-started/02-deploy.md +12 -5
  15. package/docs/get-started/03-access.md +2 -0
  16. package/docs/get-started/04-ses.md +36 -16
  17. package/docs/get-started/05-verify.md +36 -8
  18. package/docs/get-started/06-go-live.md +13 -55
  19. package/docs/guides/01-connect-claude.md +7 -5
  20. package/docs/guides/03-notifications.md +10 -10
  21. package/docs/guides/09-as-your-list-grows.md +62 -0
  22. package/docs/publishing/01-your-list.md +118 -47
  23. package/docs/publishing/02-sending-rules.md +5 -5
  24. package/docs/publishing/03-name-your-publication.md +7 -93
  25. package/docs/publishing/04-write-a-post.md +37 -66
  26. package/docs/publishing/06-troubleshooting.md +103 -0
  27. package/docs/publishing/07-working-with-claude.md +19 -37
  28. package/docs/publishing/09-share-your-subscribe-link.md +11 -59
  29. package/docs/publishing/10-check-your-setup.md +12 -30
  30. package/docs/publishing/11-email-template.md +34 -0
  31. package/docs/publishing/README.md +12 -11
  32. package/docs/reference/01-configuration.md +6 -6
  33. package/docs/reference/02-sending-domain-dns.md +1 -1
  34. package/migrations/0007_sends_progress_at.sql +12 -0
  35. package/migrations/0008_notifications_sandbox.sql +35 -0
  36. package/migrations/0009_post_email_only.sql +12 -0
  37. package/migrations/0010_unlisted_links.sql +43 -0
  38. package/migrations/0011_sends_render_warnings.sql +13 -0
  39. package/package.json +1 -1
  40. package/template/claude/CLAUDE.md +5 -2
  41. package/template/claude/skills/migrate-list/SKILL.md +2 -2
  42. package/docs/guides/08-try-locally.md +0 -108
  43. package/docs/publishing/05-scheduling-and-sending.md +0 -72
  44. package/docs/publishing/06-after-a-send.md +0 -109
  45. package/docs/publishing/08-import-format.md +0 -103
@@ -0,0 +1,103 @@
1
+ # Troubleshooting
2
+
3
+ When a send needs you, its page and the dashboard's health block say so in a red or amber line. Red needs you to act, and amber is worth a look. Find the line's words below for what it means and what to do.
4
+
5
+ **Set a notifications address, so a problem reaches you without your watching.** Set it under **Settings → Notifications**, in **Send notifications to**. With none set, Kestrel emails you nothing. [Notifications](06-troubleshooting.md#notifications), below, lists what you're told.
6
+
7
+ ## A send is waiting or held
8
+
9
+ A problem with your email provider never marks anyone unsent. The send waits where it is, and carries on from there once the problem clears. "No one has been marked unsent because of this" means this problem: readers whose own email failed earlier stay unsent.
10
+
11
+ - **"The provider is refusing the account".** Your provider won't take mail from your account, and its own words follow, with the fix and who makes it. The fix is almost always in your Amazon SES account or Kestrel's deployment, made by whoever runs it. [What SES refuses, and the fix](#what-ses-refuses-and-the-fix), below, lists each one. Any other post that comes due meanwhile waits with it, without asking SES, and you're told once. While a quota frees up a few at a time, or while the SES sandbox takes only your verified addresses, SES takes some readers on each retry and refuses the rest: those it takes are sent, and the line stays, with the time it began, until SES takes everyone it's asked to.
12
+ - **"The provider is refusing this send".** SES refuses this post's email itself, or a batch of it as invalid, rather than your account. Its words follow. Other posts go out as usual. If SES's words name a reader's address, remove that subscriber: the send skips them and carries on at its next retry. If SES refuses the email itself, the email can't change once its send has started, so whoever runs the deployment asks AWS Support why, quoting SES's words.
13
+ - **"No email provider is connected".** The send is held until one is connected ([Connect Amazon SES](../get-started/04-ses.md)), and goes out once it is.
14
+ - **"The provider is unavailable".** An outage or a rate limit on the provider's side. The send reads **Backing off** and says when it next retries, spacing its retries out up to an hour apart while the outage lasts, so there's nothing to do. Any other post that comes due meanwhile waits with it.
15
+ - **"The provider keeps refusing for its sending rate".** An amber line. SES has refused the first request of several retries in a row for its sending rate, which a short burst never does, so Kestrel is set to send faster than your SES account allows. Whoever runs the deployment sets `SES_MAX_SEND_RATE` to the **Maximum send rate** on the SES console's **Account dashboard** (1 a second in the sandbox), and deploys, as [Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy) describes. The send retries on its own meanwhile.
16
+ - **"Nothing has gone out for …".** A send has handed off no one for half an hour, with no retry or provider problem to explain it. The part of Kestrel that sends has stopped or keeps failing on this send, or another send going out at the same time is taking all of its turn. A large send going out slowly never shows this. If another send is going out, this one picks up once it finishes. Read the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes, and check the Worker's trigger, as [`triggers.crons`](../reference/01-configuration.md#triggerscrons) describes. The send carries on from where it stopped once it runs again, mailing no one twice.
17
+ - **"The fire time passed … and the send has not started".** The Cloudflare Cron Trigger that starts sends on time isn't running. The send goes out as soon as it runs again. Check the Worker's trigger, as [`triggers.crons`](../reference/01-configuration.md#triggerscrons) describes.
18
+ - **"The provider never answered for …".** Rarely, Kestrel can't tell whether your provider took some emails, and sending again could mail those readers twice. **Resolve…** on the send's page asks you to decide. **Assume not sent** is the safe default: those readers may miss this post, but get your next one. Choose **Assume sent** only when you know they got it.
19
+
20
+ ## Before a send
21
+
22
+ - **A test says "The provider refused it".** Your provider didn't take the test, and its own words follow, with the fix where Kestrel knows it. Sending again gets the same answer until that's fixed. While Amazon SES is in its sandbox, it sends only to addresses verified in SES, so verify the address you test with in the SES console, or request production access, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Words about the account, such as a key or the sending domain, point at the same fix as [a send the provider refuses](06-troubleshooting.md#a-send-is-waiting-or-held).
23
+ - **A test says "The provider didn't answer, so the test may have arrived".** Kestrel can't tell whether your provider took it. Check the inbox before you send again, since a second test may arrive twice.
24
+ - **"The template or publication details changed this email's look after your last test".** Send a new test of the post, since your last one no longer shows what goes out.
25
+ - **The daily database writes warning.** On Cloudflare's free Workers plan, a large send may use up the day's writes, and then the app stops working until midnight UTC. [Bring your existing list](01-your-list.md#limits) explains the limit, and when to upgrade to Workers Paid.
26
+
27
+ ## Reading a send's record
28
+
29
+ A send's page becomes its permanent record once it finishes. Its counts cover everyone the send went to:
30
+
31
+ | Count | What it means |
32
+ | --- | --- |
33
+ | **Delivered** | Your provider confirmed the email reached the reader's mail server. |
34
+ | **Bounced** | The receiving server turned the email away. |
35
+ | **Complained** | The reader marked the email as spam. |
36
+ | **Unsent** | Kestrel couldn't hand the email to your provider. |
37
+
38
+ Delivered always means confirmed, not just accepted by your provider. Confirmations can lag by days, so counts keep rising after a send finishes. Some emails are never confirmed at all, so a send can read **Settling** for good, which is normal, as long as some receipts have come.
39
+
40
+ **"No delivery receipt has arrived for any of …".** An hour after a send went out, Kestrel has heard back about none of its readers. Your provider's reports probably aren't reaching Kestrel, so its bounces and complaints suppress no one, and your next post mails those addresses again. Look for `webhook.refused` errors in the app's logs, as [Read the logs](../get-started/05-verify.md#7-read-the-logs) describes. It names the SNS topic that reached Kestrel and the one it expects. Then check the subscription in [Connect Amazon SES](../get-started/04-ses.md#9-subscribe-the-app-to-the-topic). The line goes as soon as a receipt arrives.
41
+
42
+ **Unsent recipients** are still on your list, and get your next post. A recipient is unsent when their own email kept failing, or their address isn't one SES accepts, with the provider's words on the row, or when you chose **Assume not sent**.
43
+
44
+ ### What SES refuses, and the fix
45
+
46
+ When SES refuses your account or a send, the red line carries SES's words and the fix. Almost every fix is made by whoever runs the deployment, in the SES console or the deployment's settings.
47
+
48
+ | SES says | What it means | The fix |
49
+ | --- | --- | --- |
50
+ | Email address is not verified, naming a reader | The SES account is still in the sandbox, which mails only verified addresses. | Request production access on the SES console's **Account dashboard**, as [Connect Amazon SES](../get-started/04-ses.md#5-request-production-access) describes. Signups fail until then, and you're emailed about it once a day. |
51
+ | Email address is not verified, naming your From address or its domain | The sending identity isn't verified in this SES region. | Verify the From address or its domain in SES, in the region the deployment sends from. |
52
+ | Mail FROM domain is not verified | SES can't read the custom MAIL FROM domain's MX record. | Check that record in DNS, or set the identity's **Behavior on MX failure** to use the default MAIL FROM domain. |
53
+ | Daily message quota exceeded | The 24-hour sending quota is spent. It frees up on its own. | In the sandbox (200 a day), request production access. Otherwise, ask AWS for a higher quota, as [As your list grows](../guides/09-as-your-list-grows.md) describes. |
54
+ | Sending paused (account) | AWS paused the account under review, or sending was turned off by hand. | Follow AWS's email to the AWS account's address, or turn sending back on. |
55
+ | Sending paused (configuration set) | Sending is turned off on the configuration set, by hand or by an alarm. | Turn sending back on for that set. |
56
+ | Account suspended | AWS shut down the account's sending. | Only AWS can lift it: open a case with AWS Support. |
57
+ | An access key, signature, or permission error | The deployment's SES key is wrong, expired, or lacks permission. | Replace the key, or give it `ses:SendEmail` and `ses:SendBulkEmail`. |
58
+ | A subscription is needed (OptInRequired) | The AWS account isn't signed up for SES. | Enable SES for the account, or settle its activation or billing with AWS. |
59
+ | Configuration set does not exist | `SES_CONFIGURATION_SET` names a set that isn't in this region. | Fix the name, or create the set. |
60
+ | Sending pool not found | The configuration set uses a dedicated IP pool that doesn't exist. | Pick an existing pool in the set's sending options, or remove it. |
61
+ | The message was rejected | SES refuses this email itself. | Ask AWS Support why. Other posts go out as usual. |
62
+ | The request is invalid | SES refused a batch of this send. | If SES's words name a reader's address, remove that subscriber. |
63
+
64
+ A server error from SES may come after SES already took the emails, so Kestrel never sends that batch again: its readers wait for **Resolve…**, as for "The provider never answered for …".
65
+
66
+ ## Bounces and complaints
67
+
68
+ Kestrel acts on these for you, so a bad address can't keep hurting your sending reputation:
69
+
70
+ - **A hard bounce** means the address can't receive mail. Kestrel never mails it again.
71
+ - **A soft bounce** is temporary, such as a full mailbox. The address stays on your list.
72
+ - **A complaint** means the reader marked your email as spam. Kestrel never mails them again.
73
+
74
+ Under **Subscribers**, a suppressed address keeps its status and carries a label saying why. When a recent send's bounces reach about 5% of its recipients, the dashboard warns you. That's about the rate at which email providers start to review a sender, so check the addresses on its record.
75
+
76
+ ### Lifting a suppression
77
+
78
+ Lift one only when you know the address works again, such as a reader who tells you their mailbox is fixed. Mailing an address that keeps failing can hurt every send after it. **Unblock**, in a subscriber's ⋯ menu, lifts a block. The dashboard can't lift a bounce or a complaint, but [Claude](07-working-with-claude.md) can, when you ask. Past records keep the bounce or complaint where it happened.
79
+
80
+ ## An import is refused
81
+
82
+ Kestrel refuses a file it can't read with certainty, and says what's wrong and where. Nothing on your list changes.
83
+
84
+ - **Not in the format.** The file isn't a Kestrel CSV or a plain list. Convert it first, with Claude or by hand.
85
+ - **A column or value the format doesn't take,** named with its row and column. Fix it in the file, or move a column of your old service's into `evidence`.
86
+ - **Too many rows** for one import. Split the file.
87
+ - **Too large.** An import takes up to 10 MB.
88
+
89
+ [The file format](01-your-list.md#the-file-format) describes what each column takes.
90
+
91
+ ## Notifications
92
+
93
+ With an address set, Kestrel emails you:
94
+
95
+ - when a send finishes, with how many emails were accepted, unsent, and skipped
96
+ - when your provider refuses your account or a send, or no provider is connected
97
+ - when the SES sandbox refuses a signup's confirmation, at most once a day
98
+ - when a send stops going out with nothing to explain it, waits for you to resolve it, or misses its time
99
+ - when Claude imports a list for you
100
+
101
+ Each links to the page it's about, and is sent once while the problem lasts. A bounce spike shows on the dashboard but isn't emailed. **Send a test notification** checks that one arrives.
102
+
103
+ By default, notifications go through your newsletter's own email provider. So when that provider refuses your account, the notification about it can't reach you either. You can send them another way, as [Notifications through Cloudflare's email](../guides/03-notifications.md) describes.
@@ -1,48 +1,30 @@
1
1
  # Working with Claude
2
2
 
3
- Claude works through the same API as the dashboard, so it can do what you do in the dashboard, and nothing more. It drafts, proofreads, tests, schedules, and reports, and everything it changes shows in the dashboard as Claude's.
3
+ Ask Claude in plain words, the way you'd ask a colleague. It works through the same API as the dashboard, so it can do what you can do there, and nothing more. Everything it changes shows in the dashboard as Claude's.
4
4
 
5
- This page covers what to ask Claude for, the lines the app holds for any client, and the lines Claude keeps on its own. Setting Claude up is a job for whoever runs your Kestrel: [Connect Claude to the API](../guides/01-connect-claude.md) walks through it. Claude reads these same pages from your Kestrel, so it explains things in the words you see here.
5
+ To set Claude up, follow [Connect Claude to the API](../guides/01-connect-claude.md). Claude reads these same pages from your Kestrel, so it explains things in the words you see here.
6
6
 
7
7
  ## What to ask Claude
8
8
 
9
- Ask in plain words, the way you'd ask a colleague. Claude reads the API reference, `GET /api/reference`, before its first call, and works out the rest.
9
+ - **Draft or proofread a post.** Give it notes, an outline, or a draft. It saves a new version you can open in the dashboard. If you edited the post meanwhile, its save is refused, and it works from your version or asks you.
10
+ - **Test and schedule a post.** Name the post and when it should go out, and say so if it should be email only. Claude tells you whether the post will be in your archive, and at which address, before it schedules. Then it sends a test of exactly what will go out and tells you how to cancel. It can also cancel a send or move it.
11
+ - **Take a sent post out of your archive, or put it back.** Claude asks first, as the dashboard does, since taking a post out breaks links people shared.
12
+ - **Check how a send did.** Claude reads the send's record and reports what was delivered, unsent, bounced, or complained.
13
+ - **Lift a bounce or a complaint.** The dashboard can't, so ask Claude when you know an address works again. [Lifting a suppression](06-troubleshooting.md#lifting-a-suppression) says when that's wise.
14
+ - **Recover an earlier version.** Every save keeps a whole version of a post, and Claude can read them back.
15
+ - **Import a list.** Its `/migrate-list` skill asks what anything ambiguous in your old service's export means, and converts a few rows for you to check. It imports once you attest, and if a scheduled post would grow, you finish the import in the dashboard.
10
16
 
11
- - **Draft a post.** Give it your notes, an outline, or a topic, and it saves a draft you can open in the dashboard. Images are uploaded to the post and referenced by file name, as [Write your first post](04-write-a-post.md) describes.
12
- - **Edit or proofread a post.** Claude saves a new version against the one it read. If you edited the post meanwhile, its save is refused, and it reads your version and works from that, or asks you.
13
- - **Send a test.** Claude sends the real email to your test addresses, or to one you name, through the same render as the send.
14
- - **Schedule a post.** Name the post and when it should go out. Claude schedules it, sends a test of exactly what will fire, and tells you when it fires and how to cancel. It can also cancel a send or move its fire time, as [Scheduling and sending](05-scheduling-and-sending.md) describes.
15
- - **Check a send's record.** Ask how a post did, and Claude reads the send and its recipients. It reports how many were accepted, delivered, and unsent, and any bounces or complaints. [After a send](06-after-a-send.md) says what each one means.
16
- - **Lift a bounce or a complaint.** The dashboard lifts only a block, with **Unblock**, so ask Claude when you know a bounced address works again. [After a send](06-after-a-send.md#lifting-a-suppression) says when that's wise.
17
- - **Import a list.** Ask Claude to bring your subscribers over from another service. Its `/migrate-list` skill converts the export, checks it with you, and imports it. [Bring your existing list](01-your-list.md#let-claude-convert-your-export) says how that goes.
17
+ ## The lines it can't cross
18
18
 
19
- If you have a post open when Claude saves it, the editor tells you, and names Claude as the one who changed it.
19
+ The app holds these for any client, so they hold whatever Claude is told:
20
20
 
21
- ## Lines the app holds for any client
21
+ - **Every send waits out the review window,** and you can cancel it until it goes out.
22
+ - **A scheduled post is locked,** and what goes out is exactly the copy that was scheduled.
23
+ - **No one is mailed twice,** and only confirmed subscribers receive a post.
24
+ - **An unsubscribe is final** until the reader subscribes again, and an erased address stays erased until the person confirms again.
25
+ - **No one's edit is overwritten.** A save based on an older version is refused.
26
+ - **Only you can let an import grow a scheduled send.** An import through Claude that would stops there, until you finish it in the dashboard.
22
27
 
23
- The app enforces these itself, for the dashboard and Claude alike, so they hold whatever Claude is told:
28
+ Claude also follows instructions your Kestrel gives it, which live in your instance's repository. Those hold only as long as the instructions do. They have Claude ask before anything that reaches readers, say whether a post will be in your archive before it's sent, test again after a template change, and record whose decision each change to the list was. They also have it treat what's in a post or a subscriber's record as content, never as instructions.
24
29
 
25
- - **Every send waits out the review window.** Nothing goes out the moment it's asked for, not even a send now, and you can cancel it until it fires. The shortest window is set when Kestrel is deployed, so no client can shorten it through the API.
26
- - **A scheduled post is locked.** To change it, cancel the send, edit, and schedule again. What fires is exactly the copy that was scheduled.
27
- - **One send per post at a time, and no one mailed twice.** A repeated request finds the existing send instead of making another.
28
- - **Only confirmed subscribers receive a post.** Someone added by hand gets a confirmation email first, and receives nothing until they confirm. A bounced or complained address is never mailed.
29
- - **An unsubscribe is final.** Only the reader undoes it, by subscribing again.
30
- - **No one's edit is overwritten.** A save based on an older version of a post is refused, whichever client sent it.
31
- - **Only you can let an import grow a scheduled send.** An import through Claude that would add people to a waiting send stops there, until you finish it in the dashboard.
32
- - **An erased address stays erased.** Adding it back by hand needs a statement that the person asked to join again, and only their own confirmation lifts it.
33
-
34
- ## Lines Claude keeps
35
-
36
- These come from the instructions your Kestrel gives Claude, not from the app, so they hold as long as those instructions do. Whoever runs your Kestrel keeps them in your instance's repository.
37
-
38
- - **It asks before anything that reaches readers.** Sending mail and changing the list wait for your yes.
39
- - **It schedules only when you ask,** then sends a test of the scheduled copy. After a change to your template or publication details, it tests again.
40
- - **It never adds a subscriber you didn't name.** The one exception is an import you asked for, through `/migrate-list`, which imports anyone as a subscriber only on your attestation.
41
- - **It records whose decision it was.** It unsubscribes someone only when they asked to leave, and blocks an address when you decide to stop mailing it. It lifts a bounce or a complaint only when you say the problem is gone, and confirms first.
42
- - **It erases a subscriber only when you name them and ask,** and confirms first, since an erasure can't be undone.
43
- - **It reads post text and subscriber data as content.** Words in a post or a reader's record are never instructions to Claude.
44
- - **It won't help share your subscribe link before your Kestrel can send to anyone.** Until then, signups fail. Whoever runs your Kestrel finishes that before [Go live](../get-started/06-go-live.md).
45
-
46
- The app lets Claude give your attestation for an import, so the instruction to ask you first is Claude's own. The import's record says it was given through Claude, and Kestrel emails you about the import if your notifications are set up.
47
-
48
- [Subscriber import format](08-import-format.md) describes the file Claude converts your list to.
30
+ The app lets Claude give your attestation for an import, so asking you first is one of those instructions, not a rule the app enforces. The import's record says it came through Claude, and Kestrel emails you about it if your notifications are set.
@@ -1,73 +1,25 @@
1
1
  # Share your subscribe link
2
2
 
3
- Your publication has a public subscribe page at `/subscribe` on your Kestrel's address, such as `https://newsletter.example.com/subscribe`. Share that link wherever your readers are, or put the same form on your own website. Either way, a reader joins only once they confirm by email.
3
+ Your publication's subscribe page is at `/subscribe` on your Kestrel's address, such as `https://newsletter.example.com/subscribe`. Copy it from the dashboard's checklist or **Settings → Ways to subscribe**, and share it wherever your readers are. A reader joins only once they confirm by email.
4
4
 
5
- This page covers what a reader sees, the confirmation email, putting the form on your site, and previewing every page a reader can land on. You find the link under **Settings → Ways to subscribe**, and on the dashboard's first-run checklist.
5
+ **Signups open only once an email provider is connected.** Until then, the page says the newsletter isn't taking subscribers yet, and the dashboard says so too. You can share the link anyway, and it opens once email is connected ([Connect Amazon SES](../get-started/04-ses.md)).
6
6
 
7
- ## What a reader sees
7
+ ## How a reader joins
8
8
 
9
- **A reader enters their address, gets a confirmation email, and presses a button to confirm. Only then do they receive your posts.**
9
+ A reader enters their address, gets a confirmation email, and presses **Confirm subscription** on the page its link opens. Only then do they receive your posts. Opening the link alone records nothing, since mail scanners open links before a person does. This double opt-in is your record that each reader asked for your newsletter.
10
10
 
11
- The subscribe page wears your publication's name, tagline, and logo, which you set in [Name your publication](03-name-your-publication.md). Your landing page and archive carry a subscribe prompt too. After a reader submits their address, the page tells them to check their inbox. The email's link opens a page with a **Confirm subscription** button, and pressing it puts them on your list. This is double opt-in, and it's your record that each reader asked for your newsletter.
11
+ Every address gets the same answer, so the form never reveals who subscribes. Behind it, Kestrel sends a confirmation only when one is due:
12
12
 
13
- Opening the link alone records nothing, since mail scanners open links before a person does. A confirmation link also works only for a limited time. An expired link offers to send a fresh one.
14
-
15
- ## Addresses already on your list, or that left
16
-
17
- **Every address gets the same answer, so the form never reveals who subscribes.**
18
-
19
- The page always says to check the inbox, whatever the address is. Behind it, Kestrel sends a confirmation only when one is due:
20
-
21
- - **Already subscribed:** no email goes out, and they stay subscribed.
22
- - **Waiting to confirm:** they get a confirmation, but at most one every 15 minutes, however often the form is submitted. A confirmation link works for 7 days, and an older one offers to send a fresh link.
23
- - **Unsubscribed earlier:** they get a confirmation, and confirming it puts them back on your list.
24
- - **Bounced, complained, or blocked:** no email goes out. [After a send](06-after-a-send.md) covers those addresses.
25
- - **Erased at their request:** they get a confirmation, and confirming lifts the erasure, so they're back on your list.
13
+ - **Already subscribed, bounced, complained, or blocked:** no email goes out.
14
+ - **Waiting to confirm:** at most one confirmation every 15 minutes, however often the form is submitted. A link works for 7 days, and an expired one offers a fresh link.
15
+ - **Unsubscribed earlier, or erased at their request:** they get a confirmation, and confirming puts them back on your list.
26
16
 
27
17
  ## The confirmation email
28
18
 
29
- **You can change its words in Settings → Confirmation email. Its layout and its link are fixed.**
30
-
31
- The section shows the email as a reader gets it. Choose **Edit** to change four things:
32
-
33
- - **Subject line**
34
- - **Message**, the line above the confirm button
35
- - **Button label**
36
- - **Reassurance line**, a quiet footer for anyone who didn't sign up. Leave it blank to omit it.
37
-
38
- A required field left blank uses Kestrel's default wording, and **Reset to Kestrel default** brings all of it back. Save with the page's save bar.
39
-
40
- What you can't change keeps every confirmation working. The email always opens with your publication's name and logo, so a reader sees who's asking first. Kestrel adds the confirm link itself, so no edit can leave it out. The confirmation isn't a post, so it doesn't use your email template and has no unsubscribe link. The words on the page the link opens are fixed too, so what a reader agrees to can't be edited away.
19
+ Change its subject, message, button label, and reassurance line under **Settings → Confirmation email**. Kestrel adds your name, your logo, and the confirm link itself, so no edit can leave them out. The confirmation isn't a post, so it doesn't use your email template.
41
20
 
42
21
  ## Put the form on your own site
43
22
 
44
- **Copy the form from Settings → Ways to subscribe → Embed a form, and paste it into any page you control.**
45
-
46
- Choose a look first:
47
-
48
- - **Styled** looks like your subscribe page, with its own fonts and colors carried along, so it fits anywhere.
49
- - **Plain** is bare markup that takes your site's styles, or the browser's own controls where your site has none.
50
-
51
- The section shows the form as it will look beside the HTML that makes it. Choose **Copy HTML**, then paste it into your site. The form heads itself "Subscribe to" your publication's name.
52
-
53
- A reader who submits it lands on your Kestrel's own check-your-inbox page, and confirms by email as on the subscribe page.
54
-
55
- ## Preview every reader page
56
-
57
- **Settings → Reader pages shows each page a reader can land on, exactly as they see it, without walking through the steps.**
58
-
59
- Pick a page from the list: the subscribe form and its check-your-inbox page, the confirm and unsubscribe pages, or your landing page, archive, and a post page. Pages a reader rarely meets, such as an expired link or an archive with no posts yet, are there too. **Desktop** and **Phone** show each at either width. These are samples, so nothing on them subscribes or unsubscribes anyone.
60
-
61
- ## When signups aren't open yet
62
-
63
- **The form takes no addresses until whoever runs your Kestrel connects email.**
64
-
65
- Until then, the subscribe page says the newsletter isn't taking subscribers yet, and the dashboard and Settings say so too. You can still share the link, and it opens once email is connected. [Connect Amazon SES](../get-started/04-ses.md) is that step.
66
-
67
- Whoever runs your Kestrel can also limit how often anyone submits the form, which slows down abuse. [Rate-limit the subscribe form](../guides/05-rate-limit.md) covers it.
68
-
69
- ## Already have a list somewhere else
70
-
71
- **Import it instead of asking everyone to sign up again.** Readers who opted in with your old service come with that record of their consent. [Bring your existing list](01-your-list.md) covers importing and exporting.
23
+ **Settings → Ways to subscribe → Embed a form** gives you HTML to paste into any page you control. **Styled** looks like your subscribe page, and **Plain** takes your site's own styles. A reader who submits it confirms by email, as on the subscribe page.
72
24
 
73
- Next, send your first post: [Scheduling and sending](05-scheduling-and-sending.md).
25
+ **Settings → Reader pages** shows every page a reader can land on, at desktop and phone widths, without walking through the steps. You can also limit how often anyone submits the form, which slows down abuse ([Rate-limit the subscribe form](../guides/05-rate-limit.md)).
@@ -1,48 +1,30 @@
1
1
  # Check your setup
2
2
 
3
- Before your readers get anything, see it arrive yourself, and make sure a problem will reach you without your watching for it. Send yourself a test, subscribe yourself, and set where Kestrel emails you. It's the second step of **Set up your publication** on your dashboard.
3
+ Before your readers get anything, see it arrive yourself, and make sure a problem will reach you without your watching for it. Send yourself a test and reply to it, subscribe yourself, and set where Kestrel emails you.
4
4
 
5
- This page covers whether email is connected, what to check in a test, how Kestrel tells you something's wrong, and what changes as your list grows.
6
-
7
- ## Email is connected
8
-
9
- If nothing on your dashboard says otherwise, it is. While no email provider is connected, Kestrel says so wherever a send would fail. Until your first post, the dashboard's checklist shows **Connect Amazon SES** in place of this step. After that, an amber line on the dashboard reads "No email provider connected, so nothing can be sent yet." The Schedule dialog says the post can't be scheduled yet, and its buttons are unavailable.
10
-
11
- **Settings → Email sender** shows your From address, sending domain, and email provider, read-only. Connecting a provider is for whoever runs your Kestrel, in [Connect Amazon SES](../get-started/04-ses.md) and [Verify it works](../get-started/05-verify.md).
5
+ If you're bringing a list of more than about 5,000 subscribers, upgrade to Cloudflare's Workers Paid plan before you import it. On the free plan, a large import and a send can use up the day's database writes. Then the app stops working until midnight UTC. [As your list grows](../guides/09-as-your-list-grows.md) walks through the upgrade.
12
6
 
13
7
  ## Send yourself a test
14
8
 
15
- A test shows you exactly what readers get, and you can send one before you write any post. Choose **Template** in the sidebar, then **Send test email**, and send a sample post to your own inboxes. [Name your publication](03-name-your-publication.md) covers the template, and [Write your first post](04-write-a-post.md#test-emails) covers what a test is.
9
+ Choose **Template** in the sidebar, then **Send test email**, and send a sample post to your own inboxes. When it arrives, check:
16
10
 
17
- When it arrives, look at:
18
-
19
- - **The From name and address.** They're what readers see first.
20
- - **Your logo and name,** as your template prints them.
21
- - **Where it lands:** the inbox, Promotions, or spam. If it lands in spam, tell whoever runs your Kestrel. [Sending-domain DNS](../reference/02-sending-domain-dns.md) covers what they check.
22
- - **A reply.** Reply to the test, and check that it reaches your inbox. Kestrel never sees replies, so this depends on [forwarding set up by whoever runs your Kestrel](../get-started/04-ses.md#3-receive-replies-at-your-from-address).
11
+ - **The From name and address,** which readers see first. They're set when Kestrel is deployed ([Connect Amazon SES](../get-started/04-ses.md#8-set-the-provider-and-deploy)).
12
+ - **Where it lands:** the inbox, Promotions, or spam. If it lands in spam, check your sending domain's records, as [Sending-domain DNS](../reference/02-sending-domain-dns.md) describes.
13
+ - **A reply.** Reply to the test, and check that it reaches your inbox. Kestrel never sees replies, so this depends on [forwarding your From address](../get-started/04-ses.md#3-receive-replies-at-your-from-address).
23
14
 
24
15
  ## Subscribe yourself
25
16
 
26
- Subscribing with a personal address shows you what a new reader sees. Use your public subscribe page, at your publication's address followed by `/subscribe`. The confirmation email arrives with your wording. Once you confirm, you appear under **Subscribers** as **Confirmed**. [Share your subscribe link](09-share-your-subscribe-link.md) covers the page and the confirmation email.
17
+ Open your subscribe page, from the link under **Settings → Ways to subscribe**, and subscribe with a personal address. Then confirm. You see what a new reader sees, and you then appear under **Subscribers** as **Confirmed**. [Share your subscribe link](09-share-your-subscribe-link.md) covers the page and the confirmation email.
27
18
 
28
- ## How Kestrel tells you something's wrong
19
+ ## Set your notifications address
29
20
 
30
- Kestrel tells you in three places: your inbox, the dashboard, and the Schedule dialog. Most of the time, none of them says anything.
21
+ Under **Settings → Notifications**, fill in **Send notifications to**, save, and choose **Send a test notification**. Kestrel then emails you when a send finishes or needs you. [Notifications](06-troubleshooting.md#notifications) lists what you're told, and why a second way to send them is worth asking for.
31
22
 
32
- - **Notifications.** Set your address under **Settings → Notifications**, in **Send notifications to**, and save. **Send a test notification** checks that one arrives. With no address set, nothing is emailed. [After a send](06-after-a-send.md#notifications) lists what you're told.
33
- - **The dashboard's health block.** It appears only when something needs attention, and stays until it clears. Red needs you to act, and amber is worth a look.
34
- - **Warnings when you schedule.** The Schedule dialog warns you before you schedule something that could go wrong. [Scheduling and sending](05-scheduling-and-sending.md#warnings-in-the-schedule-dialog) covers them.
23
+ ## If email isn't connected
35
24
 
36
- Whoever runs your Kestrel chooses how notifications are sent. [Notifications through Cloudflare's email](../guides/03-notifications.md) covers a way that reaches you even when your email provider refuses your account.
25
+ Until an email provider is connected, nothing can be sent, and the dashboard says so. The checklist shows **Connect Amazon SES** in place of this step, as [Connect Amazon SES](../get-started/04-ses.md) describes. **Settings → Email sender** shows your From address, sending domain, and provider, read-only.
37
26
 
38
27
  ## As your list grows
39
28
 
40
- A growing list meets a few limits, and Kestrel tells you before or when it reaches each one. Raising each is a change for whoever runs your Kestrel, on the plans and accounts they chose, as [Go live](../get-started/06-go-live.md#as-your-list-grows) describes.
41
-
42
- - **Your provider's daily sending quota.** A send that hits it waits, and the dashboard shows why. It resumes on its own once the quota frees up, and nobody is mailed twice. Amazon SES reports a spent quota as refusing your account, so its email reaches you only if your notifications go another way ([Notifications](06-after-a-send.md#notifications)).
43
- - **How long a send takes.** It grows with your list, and depends on your provider's sending rate and the hosting plan whoever runs your Kestrel chose. [Go live](../get-started/06-go-live.md#as-your-list-grows) gives the rates.
44
- - **Daily database writes on the free hosting plan.** A large send or import can use up the day's writes. Then the whole app stops until midnight UTC. On that plan, Kestrel warns you before a send to more than 5,000 recipients. It also warns you before an import that would use most of the day's writes. Neither warning stops you. [Bring your existing list](01-your-list.md#limits) explains more.
45
-
46
- From about 5,000 subscribers, it's recommended that whoever runs your Kestrel moves to the paid hosting plan, which has no daily limit on writes.
29
+ A growing list meets your Amazon SES daily sending quota, longer sends, and on Cloudflare's free Workers plan, a cap on daily database writes. Kestrel tells you when you reach each one, and [As your list grows](../guides/09-as-your-list-grows.md) covers raising each.
47
30
 
48
- Next, [Bring your existing list](01-your-list.md).
@@ -0,0 +1,34 @@
1
+ # Edit your email template
2
+
3
+ You write each post in Markdown, and the template is the HTML frame it's sent in, like a layout in a static site generator. `{{ .Post.Body }}` marks where your post goes, and the rest is plain HTML around it. The built-in template works as it comes, so changing it is optional.
4
+
5
+ To change it, choose **Template** in the sidebar, edit the HTML, and choose **Save changes**. **Start from example** offers two starting points. **Signed** ends each post with your logo, name, and tagline, and **Plain** has just your post and the footer links.
6
+
7
+ ## What every template needs
8
+
9
+ Kestrel won't save a template without `{{ .Post.Body }}` and `{{ .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.
10
+
11
+ Wrap a part in `{{ if .IsEmail }}` and `{{ end }}` to keep it off each post's public page. The starting templates do this with the unsubscribe link, the View in browser link, and the mailing address.
12
+
13
+ ## Variables
14
+
15
+ A variable is replaced with its value in each email. Type it exactly as shown, or it renders as empty.
16
+
17
+ | Variable | What it holds |
18
+ | --- | --- |
19
+ | `{{ .Post.Body }}` | Your post, rendered from Markdown. Required. |
20
+ | `{{ .Post.Subject }}` | The post's subject line. |
21
+ | `{{ .Publication.Name }}` | Your publication's name. |
22
+ | `{{ .Publication.Tagline }}` | Your tagline. |
23
+ | `{{ .Publication.Logo }}` | Your logo as an image, or nothing when no logo is set. |
24
+ | `{{ .Publication.LogoURL }}` | Your logo's address, or empty. |
25
+ | `{{ .Publication.Address }}` | Your mailing address, or empty. |
26
+ | `{{ .Email.SentTo }}` | The address this email was sent to. |
27
+ | `{{ .Email.UnsubscribeURL }}` | This reader's one-click unsubscribe link. Required. |
28
+ | `{{ .Email.ViewInBrowserURL }}` | The post's own web link, which opens its page once it's sent, in your archive or not. |
29
+
30
+ ## Test it
31
+
32
+ **Send test email** on the **Template** page sends a sample post through your saved template, so you see it in a real inbox. With unsaved edits, the button reads **Save & send test**, since a test always uses the saved template.
33
+
34
+ A change to the template re-makes the email of every scheduled post, as [Schedule and send](04-write-a-post.md#schedule-and-send) describes.
@@ -1,26 +1,27 @@
1
1
  # Publishing with Kestrel
2
2
 
3
- These pages cover writing and sending your newsletter in the dashboard, from naming your publication to what happens after each send. Deploying and running Kestrel itself is covered in [Self-hosting](../README.md).
3
+ These pages get you publishing in the dashboard, and cover the decisions to make before they matter. Deploying and running Kestrel itself is covered in [Self-hosting](../README.md).
4
4
 
5
5
  ## Set up your publication
6
6
 
7
- The steps your dashboard lists until your first post, in the same order.
7
+ The steps to set up a new publication, in the order the dashboard's setup checklist lists them. Once you've started a post, they stay on the dashboard as **Finish setting up** until you publish your first post, one that goes to your subscribers and onto your site. That's the fourth step.
8
8
 
9
9
  1. [Name your publication](03-name-your-publication.md)
10
10
  1. [Check your setup](10-check-your-setup.md)
11
11
  1. [Bring your existing list](01-your-list.md)
12
- 1. [Write your first post](04-write-a-post.md)
12
+ 1. [Write and send a post](04-write-a-post.md)
13
13
  1. [Share your subscribe link](09-share-your-subscribe-link.md)
14
14
 
15
- ## Sending
15
+ ## Go further
16
16
 
17
- Scheduling a post, what happens when it goes out, and the rules every send keeps.
17
+ Things you choose to do once you're running.
18
18
 
19
- - [Scheduling and sending](05-scheduling-and-sending.md)
20
- - [After a send](06-after-a-send.md)
21
- - [Sending rules](02-sending-rules.md)
19
+ - [Edit your email template](11-email-template.md)
20
+ - [Working with Claude](07-working-with-claude.md)
22
21
 
23
- ## More
22
+ ## Rules and troubleshooting
24
23
 
25
- - [Working with Claude](07-working-with-claude.md)
26
- - [Subscriber import format](08-import-format.md)
24
+ What the law asks of every send, and what to do when something goes wrong.
25
+
26
+ - [Sending rules](02-sending-rules.md)
27
+ - [Troubleshooting](06-troubleshooting.md)
@@ -30,7 +30,7 @@ None of these can be read or changed from inside the app, by the editor or by Cl
30
30
 
31
31
  ### `PROVIDER`
32
32
 
33
- Until it's set, or while it's empty, the app runs with no provider connected. You can write and preview posts, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, `"PROVIDER": ""`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.
33
+ Until it's set, or while it's empty, the app runs with no provider connected. Posts can be written and previewed, but everything that would send email is refused: scheduling, sending now, test emails, and the confirmation emails that signups need. The subscribe page says signups aren't open yet, and Settings shows **No email provider connected**. Setting it to `none` means the same. A new instance's `production` block declares it empty, `"PROVIDER": ""`, so wrangler doesn't warn that the top level's value is missing there. [Connect Amazon SES](../get-started/04-ses.md) changes it to `ses`.
34
34
 
35
35
  `fake` delivers nothing. It records a send as if every recipient accepted it. Local development runs on it, and names it on purpose in the top level of `wrangler.jsonc`. Never copy it into `production`, whatever wrangler suggests: every subscriber would be recorded as sent the post, and never get it. The app refuses `fake` unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`. A hosted demo that runs `fake` on a real hostname on purpose sets `ALLOW_FAKE_PROVIDER` to `true`, which no other deployment needs, and which no other provider reads.
36
36
 
@@ -38,11 +38,11 @@ Until it's set, or while it's empty, the app runs with no provider connected. Yo
38
38
 
39
39
  `FROM_ADDRESS` is the only address mail is sent from, and the identity your provider authenticates. `SENDING_DOMAIN` is for display: Settings shows it beside the From address. The app never checks that the two match, so set `SENDING_DOMAIN` to the part of `FROM_ADDRESS` after the `@`.
40
40
 
41
- Neither one is your publication's name, tagline, or logo. You set those in the editor, under **Settings → Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, your emails and reader pages show no name at all.
41
+ Neither one is the publication's name, tagline, or logo. You set those in the editor, under **Settings → Publication identity**. Until you do, the From address's display name stands in for the name, and with neither, the emails and reader pages show no name at all.
42
42
 
43
43
  ### `ARCHIVE_BASE_PATH`
44
44
 
45
- Every post you send carries its archive link, `ARCHIVE_BASE_PATH` followed by the post's slug, and those links are permanent. Changing the prefix later breaks every link already mailed, so pick it before your first send. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.
45
+ A listed post's archive address is `ARCHIVE_BASE_PATH` followed by the post's slug. Emails link through each post's unlisted link, `/email/<token>` on `APP_ORIGIN`, which forwards to the archive address, so changing the prefix later breaks no email that carries one, only archive addresses people have shared. Emails sent by 1.11 or earlier carry the archive address itself, and changing the prefix breaks those. To keep links from an older install working, set it to the prefix they use, such as `/newsletter`.
46
46
 
47
47
  A missing leading slash is added, and a trailing slash is dropped.
48
48
 
@@ -64,9 +64,9 @@ This setting is the subrequest cap. The app keeps database queries under theirs
64
64
 
65
65
  It's also how the app tells Workers Free from Workers Paid. At the default, the app runs on Free's limits:
66
66
 
67
- - One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more. Above it, up to 10,000 ([Import format](../publishing/08-import-format.md#limits)).
67
+ - One import takes up to 1,000 rows, since Free's 10 ms of CPU a request can't plan and commit more. Above it, up to 10,000 ([Limits in detail](../publishing/01-your-list.md#limits-in-detail)).
68
68
  - An export reads 1,000 rows a page. Above it, 10,000.
69
- - The app warns before a send to more than 5,000 recipients (fewer on a day you've imported), and on an import plan that, with the day's earlier imports, would use most of a day's writes. Either may use up D1's daily allowance of 100,000 rows written, which stops the app until midnight UTC ([As your list grows](../get-started/06-go-live.md#as-your-list-grows)). Neither warning stops you. Above the default, neither appears.
69
+ - The app warns before a send to more than 5,000 recipients (fewer on a day with imports), and on an import plan that, with the day's earlier imports, would use most of a day's writes. Either may use up D1's daily allowance of 100,000 rows written, which stops the app until midnight UTC ([As your list grows](../guides/09-as-your-list-grows.md)). Neither warning stops you. Above the default, neither appears.
70
70
 
71
71
  Never set it above your plan's limit. A minute that hits the cap is cut off mid-batch, and the send stalls until a later minute picks it up. A value below 30 is raised to 30, the least a minute needs to send anything and still send a notification.
72
72
 
@@ -74,7 +74,7 @@ Never set it above your plan's limit. A minute that hits the cap is cut off mid-
74
74
 
75
75
  Every send stays visible and cancelable for at least this long before it goes out, whether it's scheduled, sent now, or moved. It's the window to catch a mistake, including one in a send Claude prepared.
76
76
 
77
- The floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead you can schedule. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.
77
+ The floor is 60, since sends go out from a once-a-minute schedule. The ceiling is 86400, one day. It's the least wait before every send, not a limit on how far ahead a post can be scheduled. It lives in deploy config on purpose, so neither the editor nor Claude can shorten it.
78
78
 
79
79
  ### `ARCHIVE_ORIGIN` and `MEDIA_PUBLIC_BASE`
80
80
 
@@ -27,7 +27,7 @@ SES generates the DKIM values. Copy them exactly as SES lists them, since they d
27
27
 
28
28
  SPF needs no record of yours on SES's default path, since it passes for a subdomain of `amazonses.com`. An SPF record, at `bounce.send`, comes only with the optional custom MAIL FROM domain.
29
29
 
30
- Email Routing adds MX, SPF, and DKIM records at `send` itself, the DKIM one under the selector `cf2024-1`. They're for mail sent *to* your From address, so readers' replies reach you ([Connect Amazon SES](../get-started/04-ses.md#3-receive-replies-at-your-from-address)). They don't affect your sending: inboxes check SES's mail for SPF at its return path, and for DKIM against SES's own keys.
30
+ Email Routing adds MX, SPF, and DKIM records at `send` itself, the DKIM one under the selector `cf2024-1`. They're for mail sent *to* your From address, so readers' replies reach the publisher ([Connect Amazon SES](../get-started/04-ses.md#3-receive-replies-at-your-from-address)). They don't affect your sending: inboxes check SES's mail for SPF at its return path, and for DKIM against SES's own keys.
31
31
 
32
32
  ### SPF: authorize the return path
33
33
 
@@ -0,0 +1,12 @@
1
+ -- 0007_sends_progress_at: when a send last made progress (SPEC §12).
2
+ --
3
+ -- A send is in flight too long when it has handed off no one for a while and nothing else
4
+ -- explains why, not when it has been sending a while: a large send on a slow provider rate
5
+ -- goes out for hours and is fine. `progress_at` is when the provider last answered for any
6
+ -- of the send's recipients, so the app can tell a send that is moving from one that has
7
+ -- stopped. Null until the first answer, when the send's start stands in for it, so a send
8
+ -- already in flight when this runs reads as it did (src/db/sends.ts, SEND_IDLE_SINCE).
9
+ --
10
+ -- Adding a nullable column writes no rows, so this costs nothing to apply at any size.
11
+
12
+ ALTER TABLE sends ADD COLUMN progress_at INTEGER;
@@ -0,0 +1,35 @@
1
+ -- 0008_notifications_sandbox: tell the publisher when the SES sandbox refuses a signup's
2
+ -- confirmation (SPEC §7, §8).
3
+ --
4
+ -- While the SES account is in the sandbox, SES refuses mail to any address not verified in
5
+ -- it, so a reader who signs up is told a link is on its way and none arrives. The publisher
6
+ -- hears of it through the notifications they already get, as a `sandbox` notice with no send
7
+ -- and no import, at most one a UTC day: its episode is the day's start, and the partial
8
+ -- unique index keeps one row a day however many signups SES refuses. `notifications.kind`'s
9
+ -- CHECK doesn't admit it and a STRICT table's CHECK can't be altered, so the table is rebuilt
10
+ -- with `sandbox` added, every row and column carried over.
11
+ CREATE TABLE notifications_next (
12
+ send_id TEXT REFERENCES sends (id), -- null for a test, an import notice, and a sandbox notice
13
+ import_id TEXT REFERENCES imports (id), -- set only for an import notice
14
+ kind TEXT NOT NULL
15
+ CHECK (kind IN ('finished', 'refused', 'stuck', 'wedged', 'missed', 'test', 'import', 'sandbox')),
16
+ episode INTEGER NOT NULL DEFAULT 0,
17
+ status TEXT NOT NULL DEFAULT 'pending'
18
+ CHECK (status IN ('pending', 'sent', 'failed', 'unaddressed', 'cleared')),
19
+ attempts INTEGER NOT NULL DEFAULT 0,
20
+ error TEXT,
21
+ created_at INTEGER NOT NULL,
22
+ updated_at INTEGER NOT NULL,
23
+ UNIQUE (send_id, kind, episode),
24
+ UNIQUE (import_id),
25
+ CHECK ((send_id IS NULL) = (kind IN ('test', 'import', 'sandbox'))),
26
+ CHECK ((import_id IS NULL) = (kind <> 'import'))
27
+ ) STRICT;
28
+
29
+ INSERT INTO notifications_next (send_id, import_id, kind, episode, status, attempts, error, created_at, updated_at)
30
+ SELECT send_id, import_id, kind, episode, status, attempts, error, created_at, updated_at FROM notifications;
31
+
32
+ DROP TABLE notifications;
33
+ ALTER TABLE notifications_next RENAME TO notifications;
34
+ CREATE INDEX idx_notifications_status ON notifications (status, created_at);
35
+ CREATE UNIQUE INDEX idx_notifications_sandbox_day ON notifications (episode) WHERE kind = 'sandbox';
@@ -0,0 +1,12 @@
1
+ -- 0009_post_email_only: whether a post is listed in the public archive (SPEC §5).
2
+ --
3
+ -- A post is in the archive (0, the default) or email only (1): mailed exactly as a listed
4
+ -- post is, but left out of the archive index and the landing page; 0008 decides what its
5
+ -- archive address answers. The listing belongs to the post, not to a send,
6
+ -- because it is not part of the frozen render: changing it never re-makes a scheduled send's
7
+ -- email or touches a sent one (I3), and it can be changed after the post is sent.
8
+ --
9
+ -- Every existing post reads as in the archive, so an upgrade lists exactly what it listed
10
+ -- before. Adding a column with a constant default rewrites no rows.
11
+
12
+ ALTER TABLE posts ADD COLUMN email_only INTEGER NOT NULL DEFAULT 0 CHECK (email_only IN (0, 1));
@@ -0,0 +1,43 @@
1
+ -- 0010_unlisted_links: every post gets its own unlisted link, and every send records which
2
+ -- address its email carries (SPEC §5).
3
+ --
4
+ -- Every email's "view in browser" link used to be the post's archive address,
5
+ -- `/archive/<slug>`. That made an email-only post's page guessable from its subject, and tied
6
+ -- every email ever sent to the archive's base path. Now each post has an unlisted link,
7
+ -- `/email/<token>` on the app's own origin, which every test and send carries instead, and the
8
+ -- archive address answers only while the post is listed.
9
+ --
10
+ -- posts.unlisted_token: 128 bits of randomness, made with the post and never changed, written as
11
+ -- 32 hex digits in four groups of eight joined by hyphens, so a reader can keep their place in it.
12
+ -- A column added to an existing table can't take a per-row default, so existing posts are
13
+ -- given theirs here, and a trigger gives one to every post inserted without it, so no way of
14
+ -- inserting a post leaves it without a link. The unique index keeps every link a post's own.
15
+ --
16
+ -- sends.web_link: which address the send's email carries as its "view in browser" link,
17
+ -- `unlisted` from this release on, or `archive` for a send frozen before it. The column defaults
18
+ -- to `archive`, which every existing send takes, and this release's Worker names `unlisted` on
19
+ -- every send it freezes: a send frozen by the previous release's Worker while it still serves,
20
+ -- between this migration and the deploy or after a rollback, carries its archive address, and
21
+ -- the default records it so. A post whose
22
+ -- published send carries `archive` keeps its archive address answering whatever its listing,
23
+ -- since every copy of that email links there. A re-make freezes a new copy, so it moves a
24
+ -- scheduled send to `unlisted`.
25
+ --
26
+ -- Slugs keep the rule they have had since 0001: every post's is unique, drafts included.
27
+
28
+ ALTER TABLE posts ADD COLUMN unlisted_token TEXT;
29
+ UPDATE posts SET unlisted_token = lower(hex(randomblob(16)));
30
+ UPDATE posts SET unlisted_token = substr(unlisted_token, 1, 8) || '-' || substr(unlisted_token, 9, 8)
31
+ || '-' || substr(unlisted_token, 17, 8) || '-' || substr(unlisted_token, 25, 8);
32
+ CREATE UNIQUE INDEX idx_posts_unlisted_token ON posts (unlisted_token);
33
+
34
+ CREATE TRIGGER posts_unlisted_token AFTER INSERT ON posts
35
+ WHEN NEW.unlisted_token IS NULL
36
+ BEGIN
37
+ UPDATE posts SET unlisted_token = lower(hex(randomblob(16))) WHERE id = NEW.id;
38
+ UPDATE posts SET unlisted_token = substr(unlisted_token, 1, 8) || '-' || substr(unlisted_token, 9, 8)
39
+ || '-' || substr(unlisted_token, 17, 8) || '-' || substr(unlisted_token, 25, 8) WHERE id = NEW.id;
40
+ END;
41
+
42
+ ALTER TABLE sends ADD COLUMN web_link TEXT NOT NULL DEFAULT 'archive'
43
+ CHECK (web_link IN ('unlisted', 'archive'));