Museletter is a headless, agent-first newsletter engine. One container, one SQLite database, and your email provider: Amazon SES or Cloudflare Email Service. There is no web UI. You (or your AI agent) operate it through a CLI and an HTTP API.
I'm building Museletter to use it on my website sanketsaurav.com. It has all essential primitives for running a professional newsletter:
- subscribers, lists, and tags (with CSV import/export)
- a public subscribe endpoint with double opt-in
- Markdown campaigns rendered into a clean email template
- RFC 8058 one-click unsubscribe, injected automatically on every send
- automatic bounce/complaint suppression, fed by SES+SNS webhooks or Cloudflare queue events
- a crash-safe send ledger that respects provider rate limits and resumes mid-blast
- per-campaign delivery stats and open tracking
museletter doctor, which checks DNS, DKIM, DMARC, provider account/quota, config
Three layers:
- The server (
museletter serve): a FastAPI app over one SQLite file. Its HTTP surface has two audiences. The public endpoints (/subscribe,/confirm,/unsubscribe,/open,/webhooks/sns) must be reachable from the internet, because readers click links in their inbox and Amazon SNS posts delivery events to the webhook (on Cloudflare, delivery events arrive by polling a queue instead: no inbound webhook). The admin API (/v1/*, bearer-authenticated) only needs to be reachable by you. - The CLI (
museletter): the same binary runs the server and is the admin client for a running server, local or remote. - The skill: a bundled set of recipes so an agent can drive the CLI. See Agent-first design.
pip install museletter # the CLI and serverOn macOS, Homebrew works too:
brew install sanketsaurav/tap/museletterOr run the server as a container (see Deployment):
docker run ghcr.io/sanketsaurav/museletter:latestMuseletter is two installs: the server on an always-on host, and the
CLI on your machine pointed at that server. On a single machine (a Mac
mini, say) they are the same install talking to localhost.
On the host, generate config (this writes a .env and prints a connect
token):
museletter init --base-url https://news.example.com --from-email you@example.comAdd your AWS credentials to the .env (see AWS SES setup;
to send through Cloudflare instead, pass --provider cloudflare and see
Cloudflare Email Service setup), then
start the server with that environment. Any container host works; the
simplest is Docker:
docker run -d --env-file .env -v museletter:/data -p 8000:8000 \
ghcr.io/sanketsaurav/museletter:latestOr run it directly without a container (--env-file loads the .env init just
wrote; no shell sourcing needed):
museletter serve --env-file .envmuseletter init prints a connect token: one ml_... blob that encodes
the server URL and admin API key. Copy it.
On your laptop:
pip install museletter
museletter connect ml_... # paste the token; verifies reachability + auth
museletter skill install # drop the agent skill into ~/.claude/skills
museletter doctor # confirm provider, DNS, and config are healthy
museletter status # server version, reachability, subscriber countsconnect saves a named profile in ~/.config/museletter/config.toml. Manage
several servers with --name on connect and --profile (-p) before the
command, for example museletter -p second status. An explicit profile takes
precedence over the server URL and API key in your environment.
museletter subs add reader@example.com --name "First Reader"
museletter campaigns create --subject "Hello" --file issue.md
museletter campaigns preview cmp_xxx # review it (or --html out.html)
museletter campaigns test cmp_xxx --to you@example.com # test send to yourself
museletter campaigns send cmp_xxx --dry-run # show the audience
museletter campaigns send cmp_xxx # asks to confirm
museletter campaigns stats cmp_xxx # delivery stats and opensEvery command accepts --json. The HTTP API is browsable at /docs and
authenticates with Authorization: Bearer <api key>.
Campaign reports group delivered, awaiting confirmation, bounced, and complained
emails under a cumulative sent total. Pending, failed, and suppressed emails
appear separately. Delivery bars and percentages use the total recipient count.
In --json and API responses, sent keeps its raw meaning: emails awaiting a
delivery outcome, excluding those already delivered, bounced, or complained.
New campaigns track opens by default through a signed, per-recipient 1x1 image in the HTML email. This works with both SES and Cloudflare, including custom templates. Preview, test, and confirmation emails do not contain tracking pixels; plain-text emails cannot report opens. Links are left unchanged.
Use campaigns stats <id> or campaigns show <id> to see opens. The stats API
and --json output include:
track_opens: whether tracking is enabled for the campaign.unique_opens: recipients whose pixel has loaded at least once.total_opens: all recorded pixel loads, including repeats.open_rate: the percentage of recipients insent,delivered,bounced, orcomplainedstate whose pixel has loaded, rounded to two decimal places. Pending, failed, and suppressed recipients are excluded from both sides of this rate. The rate is zero until there are sent recipients.
Opens are estimates of image loads, not proof that a person read the email. Apple Mail Privacy Protection can preload images without the reader opening a message. Image blocking can hide opens, while caching can hide repeat opens. Forwarded messages retain the original recipient's pixel. The recipient ledger stores counts and first/last open times, without IP addresses or user agents. Opens never change delivery status or suppressions.
To disable tracking on a new campaign or an existing draft:
museletter campaigns create --subject "Hello" --file issue.md --no-track-opens
museletter campaigns edit cmp_xxx --no-track-opens
museletter campaigns edit cmp_xxx --track-opens # re-enable on a draftThe API accepts track_opens: false on campaign create or update. Changing a
draft clears its test-send flag as usual. Once sending starts, the setting is
fixed. Upgrading preserves existing data: old drafts enable tracking, while
campaigns already queued or sent remain untracked. Previously sent emails
cannot gain tracking retroactively.
Allow public GET requests to /open/<token>.gif under MUSELETTER_BASE_URL
through your reverse proxy or tunnel, and disable caching for this route. The
endpoint requires no admin key and sends cache-prevention headers. HEAD requests
do not record opens.
SES is the default provider (MUSELETTER_EMAIL_PROVIDER=ses) and has to be
set up once; prefer Cloudflare? See
Cloudflare Email Service setup. All of
this is scriptable, and the bundled skill has a copy-paste recipe
(museletter skill install, then see recipes/aws-ses-setup.md).
-
Verify your sending domain (creates DKIM keys):
aws sesv2 create-email-identity --email-identity example.com aws sesv2 get-email-identity --email-identity example.com \ --query 'DkimAttributes.Tokens'Add each returned token as a CNAME:
<token>._domainkey.example.com -> <token>.dkim.amazonses.com. -
Add a DMARC record. Gmail and Yahoo require one for bulk senders. A minimal TXT record on
_dmarc.example.com:v=DMARC1; p=none. -
Wire bounce/complaint events back to Museletter. Without this, bounces are never suppressed and SES will eventually suspend your account.
aws sesv2 create-configuration-set --configuration-set-name museletter aws sns create-topic --name museletter-events # note the TopicArn aws sesv2 create-configuration-set-event-destination \ --configuration-set-name museletter \ --event-destination-name sns \ --event-destination '{"Enabled":true,"MatchingEventTypes":["BOUNCE","COMPLAINT","DELIVERY"],"SnsDestination":{"TopicArn":"<TopicArn>"}}' aws sns subscribe --topic-arn <TopicArn> --protocol https \ --notification-endpoint https://news.example.com/webhooks/sns
Museletter auto-confirms the SNS subscription. Then set
MUSELETTER_SES_CONFIGURATION_SET=museletterandMUSELETTER_SNS_TOPIC_ARN=<TopicArn>in your environment. Setting the topic ARN is important: it makes the webhook reject events from any other topic. -
Request production access. New SES accounts are sandboxed (only verified recipients, 200/day). This is a support form in the SES console.
-
Minimal IAM policy for the server's credentials:
ses:SendEmail,ses:GetAccount,ses:GetEmailIdentity.
Run museletter doctor at any point; it reports exactly which of these is
missing.
Museletter can send through
Cloudflare Email Service
instead of SES. Email Sending is in public beta and requires a Workers Paid
plan; treat the provider as experimental until Cloudflare declares it GA.
Set MUSELETTER_EMAIL_PROVIDER=cloudflare and configure once:
-
Verify your sending domain. In the Cloudflare dashboard, open your zone and enable Email Sending (Email > Email Sending) on the domain or a subdomain such as
news.example.com, then add the DNS records it shows (SPF + DKIM).MUSELETTER_FROM_EMAILmust be on that domain. Add the same DMARC record as with SES:_dmarc.example.com TXT "v=DMARC1; p=none". -
Create an API token (My Profile > API Tokens) with Email Sending write access on the account, plus Queues read + write for step 3. Set:
CLOUDFLARE_API_TOKEN=<token> CLOUDFLARE_ACCOUNT_ID=<account id from the dashboard sidebar>
-
Route delivery events to a queue. Bounces and complaints arrive as queue messages and Museletter polls the queue over HTTPS: no Worker and no inbound webhook to host. Create a queue and register an HTTP pull consumer on it (the pull API rejects queues without one), then subscribe the queue to Email Sending events for your sending domain (
message.delivered,message.bounced,message.complained,message.failed,message.rejected) from the queue's Event Subscriptions settings in the dashboard:npx wrangler queues create museletter-email-events npx wrangler queues consumer http add museletter-email-events
Set
MUSELETTER_CLOUDFLARE_EVENTS_QUEUE_IDto the queue id shown on the queue's page. Without it Museletter still sends, but bounces are only caught when Cloudflare reports them synchronously in the send response, anddoctorwarns. -
Run
museletter doctor. It verifies the token, the Email Sending API, and the events queue.
One provider quirk to know: the Cloudflare REST send API returns no per-message id, so Museletter correlates delivery events by recipient address. Each send goes to exactly one recipient, which keeps that correlation narrow.
The server is a single stateless process plus one SQLite file. Run the
Docker image anywhere and mount a volume at /data. That is the primary
and best-supported path; the platform notes below are thin wrappers around it.
The one hard requirement on every platform: the public endpoints must be
reachable from the internet over HTTPS, and MUSELETTER_BASE_URL must be
that public URL (it goes into every confirm/unsubscribe link, open-tracking
pixel, and the SNS subscription).
docker run -d --name museletter --restart unless-stopped \
--env-file .env \
-v museletter-data:/data \
-p 8000:8000 \
ghcr.io/sanketsaurav/museletter:latestBuild it yourself instead of pulling: docker build -t museletter .. The
database lives at /data/museletter.db (set by the image); back up that one
file and you have backed up everything. Put the container behind a reverse
proxy (Caddy, nginx, your platform's router) for TLS.
Everything lives in one SQLite file at /data/museletter.db, so backing it up
comes down to where you keep /data:
- put
/dataon a persistent or replicated volume (most hosts offer one), or - run Litestream next to the container to stream the file to S3-compatible storage, which gets you point-in-time restore.
There's no remote-database option, by design: one local file is what keeps the send ledger fast (the database is the queue) and the whole thing easy to move.
Create a service from this repo; Render detects the Dockerfile. Then:
- Attach a persistent disk mounted at
/data. Without it, a redeploy wipes your subscribers. - Set the environment variables from the table below.
- Disable scale-to-zero / sleeping. The send loop runs in-process, so a sleeping instance pauses mid-campaign (it resumes safely, just late).
Any small box works. Run the container as above, or pip install museletter
and run museletter serve under systemd. Front it with Caddy for automatic
HTTPS:
news.example.com {
reverse_proxy 127.0.0.1:8000
}
Run the server locally and expose only the public endpoints through a tunnel
(a home LAN is not internet-reachable on its own). A
Cloudflare Tunnel
is the cleanest (no open ports, free, stable hostname); Tailscale Funnel and
ngrok work the same way. Point the tunnel at http://127.0.0.1:8000 and set
MUSELETTER_BASE_URL to the tunnel's public hostname.
Keep it running across reboots with the built-in service installer (launchd on macOS, a systemd user unit on Linux):
museletter service install --env-file .env # starts on boot, restarts on crash
museletter service status
museletter service uninstallDrive it from the same machine over localhost:
museletter connect http://127.0.0.1:8000 --api-key <key>.
There is no auto-update; you update the two pieces yourself.
The CLI on your machine:
pip install -U museletter # or: pipx upgrade museletterThe server:
-
Docker: pull the new image and recreate the container. The database lives on the
/datavolume, so it survives:docker pull ghcr.io/sanketsaurav/museletter:latest docker rm -f museletter && docker run -d --name museletter \ --env-file .env -v museletter-data:/data -p 8000:8000 \ ghcr.io/sanketsaurav/museletter:latestEvery release also publishes
:X.Y.Zand:X.Ytags; pin to one for controlled upgrades instead of:latest. -
pip or a service install:
pip install -U museletter, thenmuseletter service restart(launchd/systemd) or restart your process.
Check versions with museletter --version (the CLI) and museletter status
(the running server). museletter health also reports the server version.
Set these in the server's environment (museletter init writes most of them).
| Variable | Required | Meaning |
|---|---|---|
MUSELETTER_API_KEY |
yes | admin credential (any long random string) |
MUSELETTER_BASE_URL |
yes | public URL used in confirm/unsubscribe links and open-tracking pixels |
MUSELETTER_FROM_EMAIL |
yes | sender address (on a domain verified with your provider) |
MUSELETTER_FROM_NAME |
no | sender display name |
MUSELETTER_REPLY_TO |
no | Reply-To address on all outgoing email; when unset, replies go to the from address |
MUSELETTER_POSTAL_ADDRESS |
no* | postal address in the footer (*required by CAN-SPAM) |
MUSELETTER_ATTRIBUTION |
no | false removes the "Sent with Museletter" line from email footers (default true) |
MUSELETTER_OPT_IN |
no | double (default) or single |
MUSELETTER_EMAIL_PROVIDER |
no | ses (default) or cloudflare |
MUSELETTER_SEND_RATE |
no | emails/sec, default 10; keep under your provider's rate |
MUSELETTER_SES_CONFIGURATION_SET |
no | ses: configuration set for event feedback |
MUSELETTER_SNS_TOPIC_ARN |
recommended | ses: your SNS topic ARN; the webhook rejects events from any other topic |
MUSELETTER_TRUST_PROXY |
no | true when behind a proxy, so rate limiting uses X-Forwarded-For not the proxy IP |
MUSELETTER_PUBLIC_SUBSCRIBE |
no | false disables public signup; backends can still use authenticated /v1/lists/{ref}/subscribe |
MUSELETTER_TURNSTILE_SECRET |
no | Cloudflare Turnstile secret; when set, /subscribe requires a valid Turnstile token |
MUSELETTER_CONFIRMATION_COOLDOWN |
no | min seconds between confirmation emails to one address, default 3600 |
MUSELETTER_TEMPLATE_DIR |
no | server-side directory overriding the packaged templates (issue templates are better managed with museletter templates) |
MUSELETTER_DB_PATH |
no | SQLite path, default museletter.db (the image uses /data/museletter.db) |
AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
yes (ses) | SES credentials |
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID |
yes (cloudflare) | API token (Email Sending + Queues permissions) and account id |
MUSELETTER_CLOUDFLARE_EVENTS_QUEUE_ID |
recommended (cloudflare) | queue holding the Email Sending event subscription; feeds bounce/complaint suppression |
MUSELETTER_CLOUDFLARE_POLL_SECONDS |
no | cloudflare: seconds between event queue polls, default 30 |
The client CLI selects its server in this order:
- An explicit
--profile <name>(-p <name>). MUSELETTER_URLwithMUSELETTER_API_KEYfrom the environment.- The profile named by
MUSELETTER_PROFILE. - The default profile in
~/.config/museletter/config.toml(written bymuseletter connector selected withmuseletter profiles use).
An explicit profile uses its saved URL and API key together, even when server
environment variables are set. An unknown explicit profile is an error.
List selection uses --list, then MUSELETTER_LIST, then the selected
profile's pinned list (museletter lists use), then default. A server
selected through MUSELETTER_URL has no pinned list.
How you collect subscribers depends on whether your site has a backend, but both
paths feed the same default list (or any slug). Authenticated signup always
requires double opt-in. Public signup defaults to double opt-in;
MUSELETTER_OPT_IN=single skips its confirmation email. Campaign emails carry
a one-click RFC 8058 unsubscribe.
Request a confirmation email through the authenticated signup API, so your API key never touches the browser:
curl -X POST https://news.example.com/v1/lists/default/subscribe \
-H "Authorization: Bearer $MUSELETTER_API_KEY" \
-H "content-type: application/json" \
-d '{"email":"reader@example.com","name":"Reader"}'This returns HTTP 202 with status: "pending_confirmation". New readers are
stored as unconfirmed and become active only after clicking the email link,
even when MUSELETTER_OPT_IN=single. An optional name is accepted; a status
override is not. The list reference can be a slug or id.
Active, opted-out, and suppressed addresses receive the same response, with no
new email or status change. Pending readers share the public form's
MUSELETTER_CONFIRMATION_COOLDOWN (default one hour), including concurrent
requests. Provider errors return 502 and allow a retry. Existing opt-outs are
not reopened, so old confirmation links cannot reactivate them.
Validate your site's form and apply bot protection/rate limiting in your backend before forwarding the request. This authenticated endpoint does not require the public form's Turnstile token or share its per-IP rate limit.
The separate POST /v1/lists/{ref}/subscribers endpoint is for admin imports:
it does not send confirmation emails, including with status: "unconfirmed".
Use status: "active" there only for readers whose consent you already have.
Deploy a server exposing the new signup route before switching your website (releases through 1.4.0 do not have it). Once your backend uses it, you can turn the public endpoint off entirely; confirmation and unsubscribe links stay live:
MUSELETTER_PUBLIC_SUBSCRIBE=falsePoint a form at the public /subscribe/{slug} endpoint. CORS is open, so
client-side JavaScript can call it directly from any domain. This keeps the
reader on your page and shows the result inline:
<form id="newsletter">
<input type="email" name="email" placeholder="you@example.com" required>
<!-- honeypot: hidden from humans; bots fill it and are silently dropped -->
<input type="text" name="website" tabindex="-1" autocomplete="off"
style="position:absolute;left:-9999px" aria-hidden="true">
<button type="submit">Subscribe</button>
<p id="newsletter-msg"></p>
</form>
<script>
document.getElementById('newsletter').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const res = await fetch('https://news.example.com/subscribe/default', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: form.email.value, website: form.website.value }),
});
const data = await res.json();
document.getElementById('newsletter-msg').textContent =
res.ok ? data.message : (data.detail || 'Something went wrong.');
if (res.ok) form.reset();
});
</script>A plain <form action="..." method="post"> works too, but without JavaScript
the browser navigates to the endpoint's JSON response, so the reader lands on a
raw JSON page. Use the fetch version above for real visitors.
The public endpoint is hardened against abuse: a honeypot field, a per-IP rate
limit, a per-address cooldown so it cannot be used to flood a victim with
confirmation emails, and a uniform response that does not reveal who is already
subscribed. For a high-traffic or targeted form, turn on
Cloudflare Turnstile (free): set
MUSELETTER_TURNSTILE_SECRET, add the Turnstile widget to your form, and the
widget's cf-turnstile-response token is verified on every submit.
Campaign bodies are Markdown. Personalization tokens are {{name}},
{{first_name}} (the first word of the name), and {{email}}, with fallbacks
like {{first_name|there}}. The unsubscribe footer and postal address are added
automatically; never write your own unsubscribe link. The footer ends with a
small "Sent with Museletter" line; set MUSELETTER_ATTRIBUTION=false on the
server to leave it out.
Preview the rendered result before sending: campaigns preview <id> prints the
plain-text version, and campaigns preview <id> --html out.html writes the full
HTML (self-contained, mark inlined) to open in a browser.
Sending is guarded so an automated caller cannot blast the wrong thing:
--dry-runreports the audience size and a sample without sending.- A test send is required before the real send (or pass
--skip-test). - The real send needs explicit confirmation (
--yesfor automation). - The send is idempotent: re-running
campaigns sendfor a campaign that is already sending does nothing.
Target a subset with tags: campaigns create ... --tag vip sends only to
subscribers carrying that tag. Pick a list with --list <slug> (a default
list exists out of the box).
There are only a few reader-facing surfaces: two emails (the issue and the double opt-in confirmation) and the public pages (subscribed, unsubscribe, invalid link, and friends). Preview them all at once, rendered from the current templates with sample data:
museletter preview # writes them to a temp dir and opens a browserThe gallery has a light/dark toggle and an "open full page" link on each surface, so you can check both themes and inspect any surface on its own.
The publication name on every surface is just the list's name, so rename it
with museletter lists edit <slug> --name "Field Notes". The mark and accent
color are Museletter's brand by default. To change anything else, customize
the templates.
The issue email's shell (layout, colors, logo - everything around your
Markdown) is a template, and templates live on the server and are managed
entirely through the CLI, so a running server never needs touching. Museletter
ships a built-in default; copy it, restyle the copy, and email yourself a
sample issue to judge it in a real inbox:
museletter templates create mine --from default # duplicate the built-in
museletter templates show mine --out mine.html # fetch the HTML to edit
# restyle mine.html (or hand it to your agent), then push it back and test:
museletter templates edit mine --file mine.html
museletter templates test mine --to you@example.comA template is one HTML file with string.Template placeholders: $content
(the rendered issue) and $footer (the unsubscribe link and postal address)
are required; $subject and $header are optional. Every create and edit is
validated - unknown placeholders, a missing $content/$footer, or a size
past Gmail's clip point are rejected - and the send path re-checks the
template as a preflight, so a broken template cannot reach subscribers.
Then pick what renders where; a campaign's own template beats its list's default, which beats the built-in:
museletter lists edit default --template mine # default for the whole list
museletter campaigns create ... --template mine # or pin a single campaignTwo guardrails to know about: the built-in default can be copied but never
edited or deleted, and changing a template's HTML clears the test-send state
of every draft that renders through it - the test you approved is always the
email that goes out.
The confirmation email and the public pages are packaged templates too. Overriding those (or replacing the shipped default issue template itself) happens on the server's filesystem:
museletter preview --eject ./templates # copies email.html, email-system.html, page.html
# edit them (colors, logo, layout), then run the server with:
export MUSELETTER_TEMPLATE_DIR=./templatesmuseletter preview re-renders from your ejected copies, so you can iterate on
the look without sending a single email.
museletter serve run the server
museletter init bootstrap server config (.env + connect token)
museletter print-token print a connect token for a configured server
museletter service <cmd> install|restart|uninstall|status (launchd/systemd)
museletter connect <token|--url> point the CLI at a server, save a profile
museletter profiles <cmd> list|use|rm (switch between servers)
museletter status server version, reachability, auth, per-list counts
museletter doctor DNS/DKIM/DMARC/provider/config health checks
museletter health liveness of the configured server
museletter docs print this README (offline, agent-readable)
museletter preview open every reader-facing surface in a browser
museletter skill install install the agent skill into .claude/skills
museletter lists <cmd> list|use|create|edit|show|rm
museletter subs <cmd> add|show|list|rm|tag|untag|import|export
museletter tags <cmd> list|create|rm
museletter campaigns <cmd> create|show|edit|preview|test|send|stats|rm
museletter templates <cmd> list|create|show|edit|test|rm
museletter suppressions <cmd> list|add|rm
Run museletter help (or museletter --help) to see commands grouped into
client and server commands. Run any command with --help for its flags, or
--json for machine output.
Newsletters that send from the same address are just separate lists on one
server. Pick the one you're working on with museletter lists use <slug>, and
subs, campaigns, and tags all target it until you switch, so you're not
repeating --list everywhere (you can still pass --list for a one-off, or set
MUSELETTER_LIST in a script). museletter status shows which list is active
and how many subscribers each one has.
When newsletters send from different domains, give each its own server, since a
server has a single from_email. Connect to each with museletter connect --name <name> and switch between them with museletter profiles use <name>.
Start with museletter doctor (server health) and museletter status (can
the CLI reach and authenticate). Common cases:
doctorsays the account is in the SES sandbox: you can only email verified addresses until you request production access (SES console).- Emails land in spam / DKIM or DMARC failing: re-check the CNAME and TXT
records from AWS SES setup;
doctorreports each. - Bounces or complaints are not being suppressed: the event feed is not
wired. On SES, confirm the configuration set, the HTTPS subscription to
/webhooks/sns, and thatMUSELETTER_SNS_TOPIC_ARNmatches your topic. On Cloudflare, confirm the queue's Email Sending event subscription exists and thatMUSELETTER_CLOUDFLARE_EVENTS_QUEUE_IDis set;doctorchecks both. - The subscribe form returns 429 under load: you are behind a proxy and
rate limiting sees the proxy IP as one client. Set
MUSELETTER_TRUST_PROXY=true. campaigns sendrefuses with 412: do a test send first, or pass--skip-test.connectreports "API key was rejected": the token or key is stale; regenerate one on the server withmuseletter print-token.- The database: everything is in one SQLite file (
MUSELETTER_DB_PATH, or/data/museletter.dbin the image). Copy it to back up; delete it to reset.
Museletter ships with a ready-made skill for Claude Code and any agent that reads Markdown. Install it into a skills directory with:
museletter skill install # ~/.claude/skills/museletter (all projects)
museletter skill install --project # ./.claude/skills/museletter (this repo)The skill's recipes cover publishing an issue, first-time provider setup (SES
or Cloudflare), migrating from another platform, and a periodic health check. The skill source lives at
src/museletter/skill/.
The whole tool is built for agents: idempotency keys on mutations, dry runs,
mandatory confirm-to-send, test-send-before-send guardrails, machine-readable
JSON on every command, doctor for self-diagnosis, and museletter docs so
the full manual is available offline from the CLI itself.
uv venv && uv pip install -e ".[dev]"
.venv/bin/pytest # tests
.venv/bin/ruff check . # lint (--fix to autofix)
.venv/bin/ruff format . # format
.venv/bin/ty check # typecheckAll four must pass before a PR. See AGENTS.md for architecture and conventions.
MIT