Museletter is a headless, agent-first newsletter engine. One container, one SQLite database, Amazon SES. 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 via SES to SNS webhooks
- a crash-safe send ledger that respects SES rate limits and resumes mid-blast
- per-campaign delivery stats
museletter doctor, which checks DNS, DKIM, DMARC, SES sandbox/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,/webhooks/sns) must be reachable from the internet, because readers click links in their inbox and Amazon SNS posts delivery events to the 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),
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 SES, DNS, and config are healthy
museletter status # server, reachability, subscriber countsconnect saves a named profile in ~/.config/museletter/config.toml. Manage
several servers with --name on connect and --profile on any command.
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 # sent/delivered/bouncedEvery command accepts --json. The HTTP API is browsable at /docs and
authenticates with Authorization: Bearer <api key>.
Museletter sends through Amazon SES, so SES has to be set up once. 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.
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 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 health
(the running server reports its version at /health).
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 |
MUSELETTER_FROM_EMAIL |
yes | sender address (on an SES-verified domain) |
MUSELETTER_FROM_NAME |
no | sender display name |
MUSELETTER_POSTAL_ADDRESS |
no* | postal address in the footer (*required by CAN-SPAM) |
MUSELETTER_OPT_IN |
no | double (default) or single |
MUSELETTER_SEND_RATE |
no | emails/sec, default 10; keep under your SES rate |
MUSELETTER_SES_CONFIGURATION_SET |
no | configuration set for event feedback |
MUSELETTER_SNS_TOPIC_ARN |
recommended | 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 the public /subscribe endpoint (add subscribers via the admin API instead) |
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 credentials |
The client CLI reads its server from ~/.config/museletter/config.toml
(written by museletter connect), or from MUSELETTER_URL +
MUSELETTER_API_KEY when set.
How you collect subscribers depends on whether your site has a backend, but both
paths feed the same default list (or any slug). Double opt-in is on by default
(MUSELETTER_OPT_IN=single skips the confirmation email), and every email
carries a one-click RFC 8058 unsubscribe.
Add subscribers server-side through the authenticated admin API, so your API key never touches the browser:
curl -X POST https://news.example.com/v1/lists/default/subscribers \
-H "Authorization: Bearer $MUSELETTER_API_KEY" \
-H "content-type: application/json" \
-d '{"email":"reader@example.com","name":"Reader","status":"unconfirmed"}'Use status:"unconfirmed" to trigger the double opt-in email, or "active" to
add them directly (only for people who genuinely opted in). Since you are not
using the public form, you can turn the public endpoint off entirely:
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.
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, reachability, auth, per-list counts
museletter doctor DNS/DKIM/DMARC/SES/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 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 SNS webhook is not
wired. Confirm the configuration set, the HTTPS subscription to
/webhooks/sns, and thatMUSELETTER_SNS_TOPIC_ARNmatches your topic. - 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 SES setup, 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