@kurtbruns/kestrel 1.7.0 → 1.9.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/.dev.vars.example +13 -8
- package/CHANGELOG.md +72 -2
- package/README.md +2 -2
- package/cli/dev-api.mjs +7 -1
- package/cli/dev.mjs +46 -1
- package/cli/init.mjs +6 -1
- package/dist/public/dashboard/app.js +218 -223
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +6 -6
- package/dist/public/dashboard/styles.css +173 -180
- package/dist/worker/index.js +4793 -3097
- package/docs/README.md +2 -2
- package/docs/get-started/02-deploy.md +23 -8
- package/docs/get-started/03-access.md +2 -2
- package/docs/{guides/02-ses.md → get-started/04-ses.md} +75 -52
- package/docs/get-started/05-verify.md +8 -10
- package/docs/get-started/06-go-live.md +22 -6
- package/docs/guides/07-upgrade.md +2 -0
- package/docs/reference/01-configuration.md +22 -16
- package/docs/reference/02-sending-domain-dns.md +19 -19
- package/docs/reference/03-import-format.md +103 -0
- package/migrations/0004_keyed_pending_to_resolve.sql +27 -0
- package/migrations/0005_imports.sql +74 -0
- package/migrations/0006_deliveries_fewer_indexes.sql +19 -0
- package/package.json +1 -1
- package/template/claude/CLAUDE.md +3 -1
- package/template/package.json +1 -0
- package/template/wrangler.jsonc +6 -3
- package/docs/get-started/04-resend.md +0 -136
package/.dev.vars.example
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
# nothing in it is ever deployed. It holds only what local development uses. A deployed
|
|
3
3
|
# environment's secrets (Access, and the email provider's keys) are stored with
|
|
4
4
|
# `npx wrangler secret put NAME --env production` instead; the Configuration reference
|
|
5
|
-
#
|
|
5
|
+
# lists them: https://getkestrel.dev/docs/configuration/#secrets
|
|
6
|
+
# Restart `npm run dev` after a change.
|
|
6
7
|
|
|
7
8
|
# --- Admin API auth ---
|
|
8
9
|
# Local dev authenticates with a JWT signed by DEV_AUTH_SECRET (the app verifies it
|
|
@@ -14,7 +15,8 @@
|
|
|
14
15
|
DEV_AUTH_SECRET="dev-insecure-do-not-deploy"
|
|
15
16
|
|
|
16
17
|
# --- Minimum lead ---
|
|
17
|
-
# How long every send stays visible and cancelable before it fires
|
|
18
|
+
# How long every send stays visible and cancelable before it fires
|
|
19
|
+
# (https://getkestrel.dev/docs/configuration/#min_lead_seconds).
|
|
18
20
|
# Deployed environments default to 300 (five minutes); local dev uses the one-minute floor
|
|
19
21
|
# that every environment shares, so a send scheduled a minute out behaves exactly as it
|
|
20
22
|
# would deployed. Not a secret: a deployed environment sets it in its `vars` in
|
|
@@ -27,12 +29,15 @@ MIN_LEAD_SECONDS="60"
|
|
|
27
29
|
# arrive after the send (delivered, bounced, complained), all through the real send loop.
|
|
28
30
|
# Test sends and confirmations go straight to the dev outbox (GET /api/dev/outbox),
|
|
29
31
|
# unsimulated. The profile names the provider modeled:
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
32
|
+
# ses Amazon SES: a second of its send rate a request (up to 50), one request a
|
|
33
|
+
# second, no key (a lost request waits for Resolve), and its daily quota running
|
|
34
|
+
# out once a send; a slice a minute on the default SUBREQUEST_BUDGET, as SES is
|
|
35
|
+
# on Workers Free
|
|
34
36
|
# 1 a generic provider paced to be watched: small, slow batches
|
|
35
37
|
# Add ":none" (e.g. "ses:none") for a clean run with no failures, or set "off" for the
|
|
36
|
-
# plain instant fake.
|
|
38
|
+
# plain instant fake. "not-connected" runs the app as a deployment with no email provider
|
|
39
|
+
# yet: every send refused, signups closed, a due send held. Switched back, the held send
|
|
40
|
+
# goes out at its next retry, a few minutes later.
|
|
41
|
+
# For one run, set it in the shell instead: SIMULATE_SENDS=ses npm run dev
|
|
37
42
|
# Only honored in a dev-shaped env (fake transport, no Access, APP_ORIGIN on localhost).
|
|
38
|
-
SIMULATE_SENDS="
|
|
43
|
+
SIMULATE_SENDS="ses"
|
package/CHANGELOG.md
CHANGED
|
@@ -10,9 +10,77 @@ To move a running instance from one version to another, follow [Upgrade to a new
|
|
|
10
10
|
|
|
11
11
|
<!-- Add entries under Added / Changed / Fixed / Breaking. One operator-facing line each; see .claude/rules/changelog.md. Upgrade steps (a migration, a new setting) go once, in the Upgrading paragraph, never on an entry. -->
|
|
12
12
|
|
|
13
|
+
## [1.9.0] - 2026-10-13
|
|
14
|
+
|
|
15
|
+
**Upgrading from 1.8.0.** Two database migrations: run `npm run sync-migrations`, then `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development) before deploying. The first (`0005_imports`) adds the record of each import and how each subscriber joined, reading every existing subscriber as joined through Kestrel's double opt-in, and rebuilds the notifications table so an import can be told to you. The second (`0006_deliveries_fewer_indexes`) drops two indexes no query reads; it writes no rows, so it costs nothing against D1's daily allowance at any size.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Bring an existing list in, and take the whole list out, through the API, in one documented format, `kestrel-subscribers/1` (a CSV, or a plain `.txt` list of addresses). A plan shows what the import would do to every address before anything changes; the commit applies it whole, with your attestation that everyone imported as subscribed opted in, and imports them as confirmed with how and where they joined, never reversing an unsubscribe (even for a reader the subscribe form has put back to pending) or lifting a suppression here, and sending no email. Each subscriber now records how they joined beside their state; Claude may attest for you, and you're emailed when it does, but only a person can let an import grow a scheduled send. The export reads the list out in the same format, a page at a time (1,000 rows on the Workers Free plan's limits, 10,000 otherwise), with every address on the list for the whole export appearing exactly once, so another instance imports it back; in the CSV written from its pages, a value a spreadsheet would run as a formula is written as text. One import takes up to 10,000 rows, or 1,000 on the Workers Free plan's limits (`SUBREQUEST_BUDGET` unset). The format's reference is in the Docs tab and at `GET /api/imports/format` (SPEC §7, §8).
|
|
20
|
+
- On the Workers Free plan's limits (`SUBREQUEST_BUDGET` unset), where D1 refuses every query once a day's 100,000 written rows are spent and the instance stops until midnight UTC, the app now warns before it gets there. A send to more than 5,000 recipients says so in the Schedule dialog, on the scheduled post, and to Claude as the send's `daily_writes` condition. An import plan that would leave more than 3,000 confirmed subscribers advises sending the next day (UTC). Neither warning ever refuses. The setup guide now recommends Workers Paid from about 5,000 subscribers, for the database's writes as well as send speed (SPEC §7, §8).
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- A send uses fewer of D1's rows: about 14 written and 41 read per recipient, receipts included, down from about 16 and 118. On the Workers Free plan, whose D1 refuses every query once a day's 100,000 written rows are spent, one send can now reach about 7,000 subscribers in a day instead of about 6,200. On Workers Paid, a send bills fewer writes and leaves a smaller database.
|
|
25
|
+
- The API's refusals and render warnings that the editor shows read as sentences that name the next step, with typographic apostrophes and no em dashes: a save or image upload on a scheduled post, a test with no recipients or too many, an address Settings won't take, a scheduling or Resolve refusal, a warning such as an image with no alt text. A template test's refusals about its recipients now name `to` as their `field`, and a save, delete, or image change on a post that isn't a draft is refused as 409 `post_not_draft` instead of `conflict`, saying whether canceling the schedule would unlock it or its send has already started.
|
|
26
|
+
- A send time inside the minimum lead is refused in one plain sentence, "Pick a time at least 5 minutes from now, so there’s time to cancel.", which is now the API's own message on scheduling and rescheduling alike, so Claude reads what the publisher reads where it used to read the route to call; Send now remains the soonest send (SPEC §6).
|
|
27
|
+
- The Schedule dialog's Send now row reads "Or send as soon as possible.", and the Send now view says when the send goes out (one lead, up to the next minute), that the post locks until then, and that it can be canceled before it starts sending, counting recipients as the scheduled card does rather than "confirmed subscribers". The Schedule and Reschedule hints no longer name the lead, which the refusal states when a time is too soon, and the post's scheduled banner drops "cancelable until then", which its Cancel button and countdown already say.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- A list send whose `SES_CONFIGURATION_SET` names a configuration set SES can't find in its region is held with nobody consumed, as a new `configuration` cause whose advice names the setting, where before SES's `NotFoundException` marked every recipient unsent, and its per-recipient form advised replacing the access key (SPEC §12).
|
|
32
|
+
|
|
33
|
+
## [1.8.0] - 2026-10-12
|
|
34
|
+
|
|
35
|
+
**Upgrading from 1.7.0.** Do these in order. First, if your `production` block sets `PROVIDER` to `resend`, switch it to SES on the release you run now, while no send is sending, as [Connect Amazon SES](docs/get-started/04-ses.md#switch-an-instance-that-already-sends) describes, and upgrade only after that: this release refuses `resend`, and until the switch the public pages say the newsletter is temporarily unavailable and nothing is sent. From the switch on, the app no longer records the bounces and complaints Resend reports, so if you can, let a few days pass after your last Resend send before you switch. Then delete the `RESEND_API_KEY` and `RESEND_WEBHOOK_SECRET` secrets and the Resend webhook. Second, if you send through SES, make the `Action` of your sending key's IAM policy (Connect Amazon SES's `kestrel-send-email`) `["ses:SendEmail", "ses:SendBulkEmail"]` before you deploy, where 1.7.0's guide gave only `ses:SendEmail`: this release sends through SES's bulk API, and until the key may call it every list send is held as SES refusing the credentials, while a test email still goes out, so a test won't show it's missing. You may also add **Rendering failures** to your configuration set's event destination, so the app can log one. Third, before you commit the upgrade, as they apply to you: if your `production` block sets `PROVIDER` to `fake` on a real hostname, this release refuses it: set your email provider, or make it `""` until you [connect one](docs/get-started/04-ses.md). A hosted demo that runs `fake` on a real hostname on purpose adds `ALLOW_FAKE_PROVIDER: "true"` before it upgrades, to the env it hands Kestrel: its `vars`, or the object it builds if it builds one, and that object's type. If your `production` block has no `PROVIDER` line, you may add `"PROVIDER": ""` to its `vars` to quiet wrangler's warning that it's missing there; it means the same as leaving it out. If sign-in to a deployed instance has been failing, store `ACCESS_TEAM_DOMAIN` again as the bare hostname (like `yourteam.cloudflareaccess.com`): a value this release still can't read as a hostname, such as one with a path or a port, is now refused, and until it's fixed the public pages say the newsletter is temporarily unavailable and nothing is sent, where before only sign-in failed. A local `.dev.vars` with `SIMULATE_SENDS` set to `resend` (or `resend:none`) changes it to `ses` (or `ses:none`). Fourth, one database migration, which `npm run sync-migrations` brings in before you commit the upgrade: apply it after noting a restore point and before you deploy, as [Upgrade to a new release](docs/guides/07-upgrade.md) does, with `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development). It moves any recipients a send left queued to be re-sent under an earlier provider's key into flight, where **Resolve** settles them, rather than leaving the send open and silent; on a database with none it changes nothing.
|
|
36
|
+
|
|
37
|
+
### Breaking
|
|
38
|
+
|
|
39
|
+
- Resend is no longer supported, so Amazon SES is the one email provider. `PROVIDER=resend` is refused, naming the step that switches to SES; `/webhooks/resend` is gone; and `SIMULATE_SENDS=resend` is refused in local development. Resend sells newsletters as Broadcasts, which would hand it the list and the record of who was mailed (SPEC §10). With no provider left that dedupes a repeated request, a batch whose answer never came always waits for Resolve; before, on Resend, it was sent again under its key (SPEC §12).
|
|
40
|
+
- Sending through SES needs `ses:SendBulkEmail` in the sending key's IAM policy, where `ses:SendEmail` was enough before. On a key without it, SES refuses every list send, and each send is held with nobody consumed, as a credentials refusal, until the policy allows it; the refusal's advice says to give the key both permissions or replace it (SPEC §12). Tests and confirmations still go out, so a test email won't reveal a missing permission.
|
|
41
|
+
- `PROVIDER` set to `fake` is now refused unless `APP_ORIGIN` is on `localhost` or `127.0.0.1`, naming `PROVIDER`, so the development value copied into production, which wrangler's warning about a var missing from `production` invites, shows at Deploy's check instead of opening signups whose confirmations go nowhere and recording sends no one receives. A hosted demo that runs `fake` on purpose opts in with `ALLOW_FAKE_PROVIDER` set to `true`, which no other provider reads (SPEC §9).
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Local development can run as a deployment with no email provider connected: `SIMULATE_SENDS=not-connected` refuses every send, closes signups, and holds a due send, which goes out at its next retry once the switch is off, with local sign-in still on, so the editor's not-connected state can be seen without deploying. A deployed instance ignores it (SPEC §10).
|
|
46
|
+
- While no email provider is connected, the editor says so where sending would otherwise look possible: a draft's page notes it can be written and previewed but not sent, and a scheduled send's dashboard card and editor banner say it will be held when it fires. `kestrel dev` prints a line naming what `SIMULATE_SENDS` makes of the run, `not-connected` or a simulated provider, since wrangler shows the variable only as "(hidden)".
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- The setup guide walks through Amazon SES. Get started's step 3 is now Connect Amazon SES, which replaces Connect an email provider (Resend) and takes over the Use Amazon SES instead of Resend guide. The editor's links to the setup step, the dashboard's setup checklist, and the notification email name it and open it, and local development simulates SES by default. Connect Amazon SES says where to read your region's code and your account ID, walks through creating the sending policy in its own browser tab, shows the `production` vars as they end up, and gives the IAM user, configuration set, and SNS topic distinct names. It says to name the publication before requesting production access, since AWS may visit the site, and what to tell AWS if it asks for more detail. For an SNS subscription stuck pending, it puts Access covering the webhook first among the reasons, and has you choose **Request confirmation**, since SNS won't let you delete it. Go live first checks that SES production access came through and the send rate is raised from the sandbox's `1`, and says how fast a send goes out and when to raise the send rate or the Workers plan. The Sending-domain DNS reference speaks of SES's three checks, where SPF needs a record only with a custom MAIL FROM domain.
|
|
51
|
+
- SES now takes a batch a request through its bulk API: one second of your account's send rate, up to 50 recipients, one request a second. On Workers Free a minute sends 56 recipients at a send rate of 14 and 200 at 50 or more, where it sent about 18, so SES no longer needs Workers Paid, and the setup guide says to raise the account's send rate to 50 before changing plans. Each reader's email, unsubscribe link, and one-click headers are as before, and a test, a confirmation, or a notification goes through SES's single-send API with the same template, so it is the same email without queuing behind the bulk API's one request a second. A request SES never answers now holds the send for an outage's backoff, so an unreachable SES leaves one batch for Resolve rather than one every minute. Recipients SES holds back for a throttle or a spent quota go back to the queue without spending one of their attempts, and the send says why. A request lost in flight now leaves its whole batch for Resolve rather than one recipient, and an email SES took but couldn't build is logged as `webhook.render_failed` (SPEC §10, §12).
|
|
52
|
+
- A new instance's `production` settings declare `"PROVIDER": ""`, which means no provider connected, so wrangler no longer warns on every production command that `PROVIDER` is missing there. Connect Amazon SES now says to change it rather than add it.
|
|
53
|
+
- Resolve, the wedged-send notification, and Connect Amazon SES say that Kestrel can't tell whether a lost request's emails went out, so unless you know, Assume not sent is the safe default, and Assume sent is for when you know otherwise, such as a reader who says they got it. Before, they said to check the provider's console, which shows sent messages only with SES's Virtual Deliverability Manager turned on. The Resolve dialog also says Assume sent records the recipients as accepted by your provider, with delivery still waiting on a receipt, rather than as delivered (SPEC §12).
|
|
54
|
+
- The operator's Claude context (`.claude/CLAUDE.md` in a new instance) carries a new safety caution: `PROVIDER` stays `""` in production until Connect Amazon SES sets it to `ses`, and is never `fake`, whatever a wrangler warning suggests, so an instance's Claude doesn't "fix" the warning by copying the development value into production. `npm run check-context` reports the change; if you edited your `.claude/CLAUDE.md`, merge the line by hand.
|
|
55
|
+
- The operator's Claude context (`.claude/CLAUDE.md` in a new instance) carries a second new safety caution: until the publisher says AWS granted SES production access, don't help share the subscribe link or send to anyone but the addresses verified in SES, since the sandbox refuses every other recipient. `npm run check-context` reports the change; if you edited your `.claude/CLAUDE.md`, merge the line by hand.
|
|
56
|
+
- Deploy the app's step 3 creates the database and the image bucket with `--env production --binding … --update-config`, so Wrangler writes the new database's id into the `production` block itself, with no id to copy and no prompt to decline. The prompt's default, Yes, added a second binding to the production database in the development settings.
|
|
57
|
+
- A new instance installs `@types/node` as a dev dependency, so `npm run typecheck` no longer ends with Wrangler's "Action required: Install @types/node". The Node types stay out of the type check, so the Worker's own types still apply. An existing instance can silence the notice with `npm i -D @types/node`.
|
|
58
|
+
- The operator's Claude context now names `--env production` on every Wrangler command that creates or binds a resource, not only deploying and migrating, since without it Wrangler offers to bind the new resource in the development settings. It bears on safety: it keeps a binding to a production resource out of the development settings. `npm run check-context` reports the change; if you edited your `.claude/CLAUDE.md`, replace that line by hand.
|
|
59
|
+
- A send held because no email provider is connected reads once on the dashboard and the Sent page, one red message with the not-connected state instead of two, and its advice, there and on the send's page, links to Connect Amazon SES and says "then deploy" rather than "then deploy again". In the local not-connected preview it says to restart `npm run dev` without `SIMULATE_SENDS=not-connected`, which the settings API now reports as `deployment.notConnectedPreview`.
|
|
60
|
+
- The editor offers one Primary action per page: the dashboard's Quick actions New post and Settings' Edit template are now ordinary buttons, beside the page's own New post and the save bar's Save.
|
|
61
|
+
- The "Read-only · set at deploy" tag in Settings, and the note under it, are neutral grey rather than amber, since a value set at deploy is not a warning.
|
|
62
|
+
- The editor's wording uses typographic apostrophes and no em dashes, so the embeddable form's "We’ll email you a link" now matches the hosted subscribe page.
|
|
63
|
+
- When a template or publication change re-makes a scheduled email, the post's scheduled banner says its look changed after your last test while its content and send time stay the same, and the dashboard's scheduled card reads "Look changed since your last test", until you send a test of it. It is the same fact Claude sees, the `remade` condition, whose message now reads the same way, in place of a notice you could dismiss without testing (SPEC §8).
|
|
64
|
+
- A failed editor save stays on the save cue, in red with the reason, until a save succeeds, and the editor tries again on its own after a dropped connection or a server error (a refusal the server would repeat waits for your next edit), so it no longer disappears with a toast while the cue reads like ordinary typing. Errors read in plain words rather than codes like "internal_error" or the browser's "Failed to fetch", a test sent to several addresses names the ones that didn't get it and leaves only those in the dialog to try again, and a failed test notification is said once, in its status row.
|
|
65
|
+
- The Template test's answer names the addresses the provider didn't accept, as `failed`, beside `sent` and `total`, so a client can offer exactly those again and no one gets a second copy.
|
|
66
|
+
- The dashboard's Publication card names only the publication's home page, its address with Copy and View publication ↗, instead of the app and archive addresses side by side.
|
|
67
|
+
- Picking a send time inside the minimum lead says so under the picker, in plain words with Send now as the way to the soonest send, rather than showing the API's message; the Schedule dialog's Send now prompt says the soonest send is one lead out and still cancelable, instead of calling it skipping the review window. When the server refuses a value in a form (Settings, the editor's subject or slug, a send time, a subscriber's address), the field it names is marked and focused, and a refused Settings value names it in the error body's `field`, dotted for a nested one such as `notifications.to`.
|
|
68
|
+
- A spent SES quota's advice says to wait for SES's 24-hour quota to free up or ask AWS to raise it in the Service Quotas console, Settings → Email sender says the sender is set in `wrangler.jsonc` with only the AWS keys stored as secrets, and local development's stand-in reads "Local stand-in: nothing is delivered" rather than "No email provider configured", so it no longer reads like the not-connected state.
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
|
|
72
|
+
- An `ACCESS_TEAM_DOMAIN` stored with `https://` or a trailing slash no longer fails every sign-in with a `401` that says nothing of why: the app reads it as the bare hostname. A value that still isn't a hostname, such as one with a path or a port, is refused, naming `ACCESS_TEAM_DOMAIN`, like any other setting the app can't run on (SPEC §9).
|
|
73
|
+
- Before Access is set up, the deployed editor says it is locked until you set up Access, with a link to the guide, instead of saying your session expired; the API's 401 carries `reason: "access_not_configured"` in that state.
|
|
74
|
+
- A dialog or row-action menu no longer stays open over the next page after you navigate away, such as Add subscriber after pressing Back, where its confirm could act on something no longer on screen. Leaving a page closes what it opened; a write already started from a dialog still completes, and a template or identity save left waiting on its confirmation says it wasn't saved.
|
|
75
|
+
- `/health` no longer answers ok on a database that hasn't run every migration the release ships, as after a skipped Apply the schema step or a deploy made before its migration. It answers `500` `schema_behind` naming the missing migrations and the command that applies them, the editor says the same in place of the app, the public pages say the newsletter is temporarily unavailable, and nothing is sent until they're applied; your own `local_` migrations don't count. An error nothing handled on a public page now shows a plain error page rather than a raw JSON body (SPEC §5, §9).
|
|
76
|
+
- The Send now dialog counts who the send will reach, confirmed subscribers less suppressed addresses, rather than every confirmed subscriber (SPEC §6).
|
|
77
|
+
- `kestrel init` ends by pointing to the setup guide on getkestrel.dev, and a new instance's `.dev.vars.example` links the Configuration reference there, instead of paths that don't exist in an instance.
|
|
78
|
+
- Dialogs keep keyboard focus inside them, start it on their first control, and return it to whatever opened them when they close; the ⋯ row menus on Drafts and Subscribers and the Template page's Start from example menu open with focus on their first item and move with the arrow keys, Home, and End, and Escape closes them. The session-expired wall moves focus to its heading like the other walls, and stays put when more requests fail behind it. With one dialog over another, only the top one answers Tab and Escape, and a dialog closed by a click outside it hands focus back too.
|
|
79
|
+
- An error in a dialog, such as a test with no recipients, an address that isn't one, or a reschedule the server refused, shows inside the dialog, under the field at fault, instead of in a toast that sat dimmed behind it, and a toast raised while a dialog is open shows above it. Settings shows any failed save in the save bar, a logo that couldn't be saved says why under its tile, and a default test recipient that isn't an address, or is already in the list, says so under its field.
|
|
80
|
+
|
|
13
81
|
## [1.7.0] - 2026-10-12
|
|
14
82
|
|
|
15
|
-
**Upgrading from 1.6.0.** If your `production` block in `wrangler.jsonc` sets `PROVIDER` to `resend` or `ses`, nothing changes. If it still sets `fake` (as Get started's Deploy step set it until you connected a provider), delete that line before you deploy, so the instance runs with no provider connected rather than recording sends it never delivers, and [connect an email provider](docs/get-started/04-
|
|
83
|
+
**Upgrading from 1.6.0.** If your `production` block in `wrangler.jsonc` sets `PROVIDER` to `resend` or `ses`, nothing changes. If it still sets `fake` (as Get started's Deploy step set it until you connected a provider), delete that line before you deploy, so the instance runs with no provider connected rather than recording sends it never delivers, and [connect an email provider](docs/get-started/04-ses.md) when you're ready. If it sets no `PROVIDER` at all, the instance used to run on `fake` and now refuses to send; connect a provider first if it has readers. A send already scheduled on such an instance is held, with no one marked sent, until a provider is connected; it then goes out at its first retry after that, which may be long after its fire time, and it can't be canceled meanwhile, since it is already sending. A local development config that leaves `PROVIDER` unset needs `"PROVIDER": "fake"` in its top-level `vars`, as a new instance's has. If only one of the `ACCESS_TEAM_DOMAIN` and `ACCESS_AUD` secrets is stored (`npx wrangler secret list --env production` shows which), store the other before you deploy: this release refuses that configuration, and until both are stored its public pages say the newsletter is temporarily unavailable and nothing is sent. If any of `APP_ORIGIN`, `ARCHIVE_ORIGIN`, `MEDIA_PUBLIC_BASE`, `SENDING_DOMAIN`, or `FROM_ADDRESS` in your `production` block is still on `example.com` (the template's placeholder), replace it with your own before you deploy, whatever `PROVIDER` says: this release refuses it on every provider but `fake`, and until it's fixed the public pages say the newsletter is temporarily unavailable.
|
|
16
84
|
|
|
17
85
|
### Breaking
|
|
18
86
|
|
|
@@ -311,7 +379,9 @@ The first tagged release: a self-contained newsletter app on a Cloudflare Worker
|
|
|
311
379
|
- A build-version stamp: every instance reports its version, commit, and build time, read-only in the editor and at `GET /api/version`.
|
|
312
380
|
- This changelog and a semantic-versioning release process.
|
|
313
381
|
|
|
314
|
-
[Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.
|
|
382
|
+
[Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.9.0...HEAD
|
|
383
|
+
[1.9.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.9.0
|
|
384
|
+
[1.8.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.8.0
|
|
315
385
|
[1.7.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.7.0
|
|
316
386
|
[1.6.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.6.0
|
|
317
387
|
[1.5.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.5.0
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Kestrel
|
|
2
2
|
|
|
3
|
-
Kestrel is a newsletter app you run yourself on Cloudflare. You write a post in Markdown, preview it exactly as the email, and send it to a double-opt-in list. The list, the consent, the record of every send, and a permanent archive of every post stay with you. Write in the editor by hand, or with Claude through the same API.
|
|
3
|
+
Kestrel is a newsletter app you run yourself on Cloudflare. You write a post in Markdown, preview it exactly as the email, and send it to a double-opt-in list. The list, the consent, the record of every send, and a permanent archive of every post stay with you. Bring an existing list in, and take the whole list out, in one documented format. Write in the editor by hand, or with Claude through the same API.
|
|
4
4
|
|
|
5
5
|
See [getkestrel.dev](https://getkestrel.dev) to learn more about Kestrel.
|
|
6
6
|
|
|
@@ -24,7 +24,7 @@ cp .dev.vars.example .dev.vars
|
|
|
24
24
|
npm run dev
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Open the editor at http://localhost:8787/dashboard/. There's nothing to sign in with locally, and email goes to a stand-in that never reaches a real inbox. `.dev.vars.example` explains each local setting, including how the stand-in mimics
|
|
27
|
+
Open the editor at http://localhost:8787/dashboard/. There's nothing to sign in with locally, and email goes to a stand-in that never reaches a real inbox. `.dev.vars.example` explains each local setting, including how the stand-in mimics SES on a send.
|
|
28
28
|
|
|
29
29
|
### Demo data
|
|
30
30
|
|
package/cli/dev-api.mjs
CHANGED
|
@@ -137,7 +137,13 @@ async function devToken(base, tag) {
|
|
|
137
137
|
);
|
|
138
138
|
}
|
|
139
139
|
if (!res.ok) {
|
|
140
|
-
|
|
140
|
+
// A refused deployment (a setting, or a database behind the release) says what to do.
|
|
141
|
+
const { message } = await res.json().catch(() => ({}));
|
|
142
|
+
fail(
|
|
143
|
+
tag,
|
|
144
|
+
`could not mint a dev token: ${res.status} ${res.statusText}`,
|
|
145
|
+
...(typeof message === "string" ? [message] : []),
|
|
146
|
+
);
|
|
141
147
|
}
|
|
142
148
|
const { token, runId } = await res.json();
|
|
143
149
|
return { token, runId: typeof runId === "string" && runId ? runId : undefined };
|
package/cli/dev.mjs
CHANGED
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
* Per-run settings. `SIMULATE_SENDS`, `MIN_LEAD_SECONDS`, and `SUBREQUEST_BUDGET` set in the
|
|
26
26
|
* shell are forwarded over `.dev.vars`, so `SIMULATE_SENDS=ses kestrel dev` picks a
|
|
27
27
|
* simulation profile for one run without editing a file. A `.dev.vars` missing a setting that
|
|
28
|
-
* `.dev.vars.example` gives a value is named, rather than left to differ quietly.
|
|
28
|
+
* `.dev.vars.example` gives a value is named, rather than left to differ quietly. What
|
|
29
|
+
* `SIMULATE_SENDS` makes of this run (a simulated provider, or not connected) is said in one
|
|
30
|
+
* line, since wrangler prints the variable only as "(hidden)".
|
|
29
31
|
*
|
|
30
32
|
* Arguments after the command go to `wrangler dev` (`--remote`, for instance), except `--port`,
|
|
31
33
|
* which is refused in favor of PORT. This repository's `npm run dev` (scripts/dev.mjs) adds
|
|
@@ -119,6 +121,45 @@ function checkDevVars(fromShell) {
|
|
|
119
121
|
}
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The line that says what stands in for a provider this run, from `SIMULATE_SENDS` as the
|
|
126
|
+
* Worker will read it (the shell's value over `.dev.vars`'s), or null for the plain fake.
|
|
127
|
+
* Wrangler prints the variable as "(hidden)", so without this a forgotten `not-connected`
|
|
128
|
+
* in the shell looks like a broken app. The values mirror the Worker's reading of the
|
|
129
|
+
* variable (src/env.ts), which stays the authority: a value it would refuse is named as such
|
|
130
|
+
* here rather than called a simulation.
|
|
131
|
+
*/
|
|
132
|
+
export function simulationLine(value) {
|
|
133
|
+
const v = (value ?? "").trim().toLowerCase();
|
|
134
|
+
if (["", "0", "off", "false", "no"].includes(v)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (v === "not-connected") {
|
|
138
|
+
return "[dev] running not connected: sends, tests and signups are refused (unset SIMULATE_SENDS to send)";
|
|
139
|
+
}
|
|
140
|
+
const [name = "", faults = "realistic", ...rest] = v.split(":");
|
|
141
|
+
const known =
|
|
142
|
+
["generic", "ses", "1", "on", "true", "yes"].includes(name) &&
|
|
143
|
+
["realistic", "none"].includes(faults) &&
|
|
144
|
+
rest.length === 0;
|
|
145
|
+
return known
|
|
146
|
+
? `[dev] simulating a provider for list sends (SIMULATE_SENDS=${v}); nothing is delivered`
|
|
147
|
+
: `[dev] SIMULATE_SENDS=${value.trim()} isn't a value the app accepts, so it will refuse to run: use off, ses, or generic (optionally with :none), or not-connected`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* SIMULATE_SENDS as this run will have it: the shell's value when it sets a non-empty one
|
|
152
|
+
* (`off` included, which overrides `.dev.vars` like any other), else `.dev.vars`'s in `dir`.
|
|
153
|
+
* An empty shell value falls through, as it is not forwarded to wrangler (see `devCommand`).
|
|
154
|
+
*/
|
|
155
|
+
export function effectiveSimulateSends(shell, dir) {
|
|
156
|
+
if (shell?.trim()) {
|
|
157
|
+
return shell;
|
|
158
|
+
}
|
|
159
|
+
const local = join(dir, ".dev.vars");
|
|
160
|
+
return existsSync(local) ? parseEnv(readFileSync(local, "utf8")).SIMULATE_SENDS : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
122
163
|
/**
|
|
123
164
|
* `kestrel dev [wrangler dev args]`. `onStop` runs when the server stops, for whatever the
|
|
124
165
|
* caller started beside it.
|
|
@@ -175,6 +216,10 @@ export async function devCommand(argv, { onStop = () => {} } = {}) {
|
|
|
175
216
|
|
|
176
217
|
if (!isRemote) {
|
|
177
218
|
checkDevVars(fromShell);
|
|
219
|
+
const line = simulationLine(effectiveSimulateSends(process.env.SIMULATE_SENDS, process.cwd()));
|
|
220
|
+
if (line) {
|
|
221
|
+
console.log(line);
|
|
222
|
+
}
|
|
178
223
|
}
|
|
179
224
|
|
|
180
225
|
// The Worker reports this ID beside the dev token, and seed and reset with no target given
|
package/cli/init.mjs
CHANGED
|
@@ -88,9 +88,13 @@ export function initCommand(argv) {
|
|
|
88
88
|
copyFileSync(from, join(root, to));
|
|
89
89
|
}
|
|
90
90
|
// The instance pins this exact release, and the tools at the versions it was tested with.
|
|
91
|
+
// @types/node is there only so `wrangler types` finds Node types installed (the Worker has
|
|
92
|
+
// nodejs_compat) and stops ending the typecheck with "Action required"; the scaffold's
|
|
93
|
+
// tsconfig lists no `types`, so they never enter the program and the Worker's types win.
|
|
91
94
|
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
92
95
|
pkg.name = packageName(root);
|
|
93
96
|
pkg.dependencies[PACKAGE.name] = PACKAGE.version;
|
|
97
|
+
pkg.devDependencies["@types/node"] = PACKAGE.devDependencies["@types/node"];
|
|
94
98
|
pkg.devDependencies.typescript = PACKAGE.devDependencies.typescript;
|
|
95
99
|
pkg.devDependencies.wrangler = PACKAGE.peerDependencies.wrangler;
|
|
96
100
|
writeFileSync(join(root, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
|
|
@@ -109,7 +113,8 @@ export function initCommand(argv) {
|
|
|
109
113
|
" npm install",
|
|
110
114
|
" cp .dev.vars.example .dev.vars",
|
|
111
115
|
" npm run dev",
|
|
112
|
-
"Then commit, and follow the setup guide
|
|
116
|
+
"Then commit, and follow the setup guide to deploy: https://getkestrel.dev/docs/",
|
|
117
|
+
"(offline: node_modules/@kurtbruns/kestrel/docs/README.md)",
|
|
113
118
|
].join("\n"),
|
|
114
119
|
);
|
|
115
120
|
}
|