@kurtbruns/kestrel 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +154 -1
  2. package/README.md +6 -7
  3. package/cli/dev-api.mjs +1 -1
  4. package/cli/reset.mjs +2 -0
  5. package/cli/seed.mjs +3 -1
  6. package/demo/README.md +4 -2
  7. package/demo/posts/0-test-email/index.md +13 -0
  8. package/demo/posts/5-a-quick-note/index.md +2 -0
  9. package/dist/public/dashboard/app.js +383 -324
  10. package/dist/public/dashboard/app.js.map +4 -4
  11. package/dist/public/dashboard/index.html +2 -2
  12. package/dist/public/dashboard/styles.css +850 -191
  13. package/dist/worker/index.js +11327 -6891
  14. package/docs/get-started/02-deploy.md +1 -1
  15. package/docs/get-started/03-access.md +3 -3
  16. package/docs/get-started/04-ses.md +24 -4
  17. package/docs/get-started/05-verify.md +21 -5
  18. package/docs/get-started/06-go-live.md +2 -2
  19. package/docs/guides/01-connect-claude.md +8 -3
  20. package/docs/guides/09-as-your-list-grows.md +6 -2
  21. package/docs/publishing/01-your-list.md +35 -30
  22. package/docs/publishing/02-sending-rules.md +3 -3
  23. package/docs/publishing/04-write-a-post.md +20 -6
  24. package/docs/publishing/06-troubleshooting.md +46 -12
  25. package/docs/publishing/07-working-with-claude.md +4 -4
  26. package/docs/publishing/09-share-your-subscribe-link.md +2 -2
  27. package/docs/publishing/10-check-your-setup.md +2 -2
  28. package/docs/publishing/11-email-template.md +3 -1
  29. package/docs/publishing/README.md +1 -1
  30. package/docs/reference/01-configuration.md +3 -3
  31. package/migrations/0007_sends_progress_at.sql +12 -0
  32. package/migrations/0008_notifications_sandbox.sql +35 -0
  33. package/migrations/0009_post_email_only.sql +12 -0
  34. package/migrations/0010_unlisted_links.sql +43 -0
  35. package/migrations/0011_sends_render_warnings.sql +13 -0
  36. package/migrations/0012_unsubscribe_cause.sql +139 -0
  37. package/package.json +1 -1
  38. package/template/claude/CLAUDE.md +13 -9
  39. package/template/claude/skills/migrate-list/SKILL.md +5 -4
@@ -12,7 +12,7 @@ The authority on the import format is the "The file format" section of the publi
12
12
 
13
13
  ## Before you start
14
14
 
15
- - **Read the truth from the instance, every time.** Call `GET /api/imports/format` and `GET /api/reference` at the start of each migration. They give the columns, the values each takes, the outcomes, the attestation, and this deployment's `limits`. Never write the format from memory, or from this file.
15
+ - **Read the truth from the instance, every time.** Call `GET /api/imports/format` and `GET /api/reference/imports.md` at the start of each migration. They give the columns, the values each takes, the outcomes, the attestation, and this deployment's `limits`. Never write the format from memory, or from this file.
16
16
  - **Explain it in Kestrel's words.** For what to tell the publisher about the format, the row limit, and the Workers Free advice, read the rest of that page, above "The file format", with this deployment's numbers from `GET /api/imports/format`. Use them rather than your own account of the format.
17
17
  - **Convert to the format the route names as `preferred`,** the full CSV. Fall back to a plain list only when the export holds nothing but addresses, and tell the publisher what a plain list loses, from the route's own `loses`.
18
18
  - **Work outside the repository.** Make a working folder outside this repository, such as `~/kestrel-migration/`, for the export, the converter, its tests, and the converted pieces. They hold real addresses, so nothing from the list is ever committed here. A fixture that must be committed uses made-up rows only.
@@ -20,6 +20,7 @@ The authority on the import format is the "The file format" section of the publi
20
20
  ## Rules that never change
21
21
 
22
22
  - **`opt_in` is `unknown` unless the file states how consent was collected.** A column that says double or single opt-in sets it; a service's reputation or default doesn't, and a code (`1`, `2`) counts only once the publisher confirms what it means.
23
+ - **Why someone left is a cause, not a suppression.** A source's unsubscribe, a complaint or spam report, and a deletion or erasure request are each an `unsubscribed` row, with `unsubscribe_cause` `reader`, `complaint`, or `erased` when the source says which (`publisher` when the publisher removed them on request), and empty when it doesn't. `suppression` takes only a bounce or a block. A row whose cause is `erased` is recorded with its address alone, so keep nothing else of that person in it.
23
24
  - **Never invent a time.** A date with no time of day isn't an instant: leave the column empty and keep the date in `evidence`.
24
25
  - **A time without a zone needs the publisher's answer.** Ask which zone the service wrote it in. With no answer, leave the column empty and keep the value in `evidence`. A Unix timestamp already is an instant, but what it records (a signup, a last change) is still the publisher's to say.
25
26
  - **`evidence` is verbatim.** The source's consent-relevant columns, under their own names, with their values as written, as strings: a signup IP, a form, a join date, the source's own status flags. A blank value is left out; nothing is reformatted or added. A row's evidence fits in `limits.max_evidence_bytes`; when it wouldn't, the publisher chooses which columns to keep.
@@ -31,7 +32,7 @@ The authority on the import format is the "The file format" section of the publi
31
32
 
32
33
  1. **Inspect.** Read the header and a sample of every file, including its last rows, and count its rows. List each column with what it seems to hold, and how it maps to the format's columns or to `evidence`, or is left out (a name, a custom field). Ask the publisher about every ambiguity at once, before converting anything: what each distinct status means (or each combination, when the status is spread across flag columns), what each time column records and its zone, what a blank or a code means, whether an address listed twice with different states is one person, which columns to keep as evidence, and the origin. Point to any row that breaks a column's pattern. Say now how many pieces the import will take (step 5).
33
34
 
34
- 2. **Once they've answered, hand-convert one row of each kind,** one per distinct combination of status, opt-in, suppression, and time shape, plus each oddity you found (a repeat, an address with spaces or capitals, an invalid one, a quoted comma). Show each beside its source line and get the publisher's approval before writing code.
35
+ 2. **Once they've answered, hand-convert one row of each kind,** one per distinct combination of status, why they left, opt-in, suppression, and time shape, plus each oddity you found (a repeat, an address with spaces or capitals, an invalid one, a quoted comma). Show each beside its source line and get the publisher's approval before writing code.
35
36
 
36
37
  3. **Write the converter** in the working folder. Its first tests are the approved rows, each source line in and the exact output line expected, plus a test for every edge case found while inspecting. Write the CSV as the format's reference page says (RFC 4180, UTF-8), with a header of every column the format lists, in its order, as the CSV template does. The converter reports, per input file: rows read, rows written, rows dropped with their numbers and why, the invalid rows, the repeats, and the distinct valid addresses by the status their rows claim. Judge an address valid as Kestrel does: once trimmed and lowercased, it has no spaces, one `@`, and a dot after it. Only a valid address's repeats count as repeats.
37
38
 
@@ -43,14 +44,14 @@ The authority on the import format is the "The file format" section of the publi
43
44
 
44
45
  7. **If `sends_growing` isn't empty, stop here,** before asking for an attestation, and handle it as the next section says.
45
46
 
46
- 8. **Put the plan to the publisher:** the counts by outcome, the confirmed rows by opt-in and origin, the invalid and left-out rows, and anything the plan warns about. When the plan returns `attestation`, put its `text` to them exactly as returned, word for word, and ask whether it's true. Commit only on their yes, given for this piece.
47
+ 8. **Put the plan to the publisher:** the counts by outcome, each by its name as the plan gives it (a `kept_…` outcome means the row's status isn't imported: this list's own record wins, or for `kept_suppressed` a suppression here does; `erased` means someone already on this list will be erased, which can't be undone, so name them and make sure the publisher means it), the confirmed rows by opt-in and origin, the invalid and left-out rows, and anything the plan warns about. When the plan returns `attestation`, put its `text` to them exactly as returned, word for word, and ask whether it's true. Commit only on their yes, given for this piece.
47
48
 
48
49
  9. **Commit that piece** with `POST /api/imports`, sending the plan's body, its `plan_hash`, and, when the plan returned an attestation, `attestation` with its `version` and `accepted: true`. Report the answer's counts and any `differed` rows. Then plan the next piece: a plan made before another import commits is refused as `plan_stale`, so plan each piece only after the one before it has committed. On `plan_stale`, plan again and put the new plan to the publisher.
49
50
 
50
51
  ## When the plan or the commit says more
51
52
 
52
53
  - **`sends_growing` isn't empty:** a scheduled send would reach the people this import adds. Never send `acknowledge_sends` yourself, whatever token you hold: only the publisher acknowledges a growing send, in the dashboard. Tell the publisher which send and how many it gains. They can finish this piece in the dashboard, which asks them to acknowledge it, or cancel or wait out the send and import with nothing scheduled, which is simpler.
53
- - **`daily_writes_warning` isn't null:** relay its words as they stand, before asking for the attestation. On the Workers Free plan's limits, this piece and the day's earlier imports would use most of the database's daily writes, and running out stops the newsletter until midnight UTC. Offer to commit this piece and the rest after midnight UTC, or the move to Workers Paid it names. It never refuses the import. Once the list is in, the send's own warning says whether it fits the same day.
54
+ - **`workers_plan_advice` isn't null:** relay its words as they stand, before asking for the attestation. On the Workers Free plan's limits, this piece leaves the list past about 1,000 confirmed subscribers, what the free plan suits, and past about 5,000 one send can use up the day's database writes and stop the newsletter until midnight UTC. Recommend the move to Workers Paid it names, before the next piece if the list is still growing. It never refuses the import, and the pieces needn't wait for another day.
54
55
  - **A refusal:** read its `error` and `message`, and its `row`, `column`, and `allowed` when it has them. A problem in the file is fixed in the converter and its tests, never in the piece by hand; then run the step again.
55
56
 
56
57
  Finish by reporting, across all the pieces: rows read, rows dropped and why, and the counts by outcome as committed. Then tell the publisher where the working folder is and that it holds their readers' addresses, and offer to delete it. Delete it only on their yes, since it may hold their only copy of the export.