Skip to main content
Museletter

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

How it works

Three layers:

  1. 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.
  2. The CLI (museletter): the same binary runs the server and is the admin client for a running server, local or remote.
  3. The skill: a bundled set of recipes so an agent can drive the CLI. See Agent-first design.

Install

pip install museletter          # the CLI and server

Or run the server as a container (see Deployment):

docker run ghcr.io/sanketsaurav/museletter:latest

Quickstart

Museletter 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.

1. Bootstrap and run the server

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.com

Add 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:latest

Or run it directly without a container (--env-file loads the .env init just wrote; no shell sourcing needed):

museletter serve --env-file .env

museletter init prints a connect token: one ml_... blob that encodes the server URL and admin API key. Copy it.

2. Point your CLI at the server

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 counts

connect saves a named profile in ~/.config/museletter/config.toml. Manage several servers with --name on connect and --profile on any command.

3. Send your first issue

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/bounced

Every command accepts --json. The HTTP API is browsable at /docs and authenticates with Authorization: Bearer <api key>.

AWS SES setup (once)

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).

  1. 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.

  2. Add a DMARC record. Gmail and Yahoo require one for bulk senders. A minimal TXT record on _dmarc.example.com: v=DMARC1; p=none.

  3. 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=museletter and MUSELETTER_SNS_TOPIC_ARN=<TopicArn> in your environment. Setting the topic ARN is important: it makes the webhook reject events from any other topic.

  4. Request production access. New SES accounts are sandboxed (only verified recipients, 200/day). This is a support form in the SES console.

  5. 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.

Deployment

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 (primary)

docker run -d --name museletter --restart unless-stopped \
  --env-file .env \
  -v museletter-data:/data \
  -p 8000:8000 \
  ghcr.io/sanketsaurav/museletter:latest

Build 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.

Backups and durability

Everything lives in one SQLite file at /data/museletter.db, so backing it up comes down to where you keep /data:

  • put /data on 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.

Render (or any PaaS with a Dockerfile)

Create a service from this repo; Render detects the Dockerfile. Then:

  1. Attach a persistent disk mounted at /data. Without it, a redeploy wipes your subscribers.
  2. Set the environment variables from the table below.
  3. Disable scale-to-zero / sleeping. The send loop runs in-process, so a sleeping instance pauses mid-campaign (it resumes safely, just late).

A plain VPS

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
}

Self-host (Mac mini or any always-on machine)

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 uninstall

Drive it from the same machine over localhost: museletter connect http://127.0.0.1:8000 --api-key <key>.

Updating

There is no auto-update; you update the two pieces yourself.

The CLI on your machine:

pip install -U museletter          # or: pipx upgrade museletter

The server:

  • Docker: pull the new image and recreate the container. The database lives on the /data volume, 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:latest
    

    Every release also publishes :X.Y.Z and :X.Y tags; pin to one for controlled upgrades instead of :latest.

  • pip or a service install: pip install -U museletter, then museletter 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).

Configuration

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 directory of custom email/page templates to use instead of the built-ins
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.

Connect your website

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.

If you have a backend (recommended when you can)

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=false

If you have a static site (no backend)

Point 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.

Sending a campaign

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-run reports 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 (--yes for automation).
  • The send is idempotent: re-running campaigns send for 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).

Look and feel

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 browser

The 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, eject the templates and point the server at your copies:

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=./templates

museletter preview re-renders from your ejected copies, so you can iterate on the look without sending a single email.

CLI reference

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 suppressions <cmd>    list|add|rm

Run any command with --help for its flags, or --json for machine output.

Multiple newsletters

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>.

Troubleshooting

Start with museletter doctor (server health) and museletter status (can the CLI reach and authenticate). Common cases:

  • doctor says 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; doctor reports 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 that MUSELETTER_SNS_TOPIC_ARN matches 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 send refuses with 412: do a test send first, or pass --skip-test.
  • connect reports "API key was rejected": the token or key is stale; regenerate one on the server with museletter print-token.
  • The database: everything is in one SQLite file (MUSELETTER_DB_PATH, or /data/museletter.db in the image). Copy it to back up; delete it to reset.

Agent-first design

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.

Development

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          # typecheck

All four must pass before a PR. See AGENTS.md for architecture and conventions.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

museletter-1.0.0.tar.gz (141.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

museletter-1.0.0-py3-none-any.whl (81.7 kB view details)

Uploaded Python 3

File details

Details for the file museletter-1.0.0.tar.gz.

File metadata

  • Download URL: museletter-1.0.0.tar.gz
  • Upload date:
  • Size: 141.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for museletter-1.0.0.tar.gz
Algorithm Hash digest
SHA256 1d9d3a5fb6d046a48dd190dbc0b157f0ef4c270953d5a2c3984fa80373e0181e
MD5 5b77c2845e613dcb0170f1d37d4ac11a
BLAKE2b-256 9f30733a49d34b40bb6a9a23dfd5eb192f1524715870d8f518d76c22a7c69bad

See more details on using hashes here.

Provenance

The following attestation bundles were made for museletter-1.0.0.tar.gz:

Publisher: release.yml on sanketsaurav/museletter

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file museletter-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: museletter-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 81.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for museletter-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 cacaa5c586579ffc8cd22198bdb8b7de2ab12b55a5d54f90903014d420c4d46b
MD5 746194e25c72c7f68c2d5d6c33aa2ff6
BLAKE2b-256 76c44031576a8f5d14421c6d1edaa9c96a8cc4c1a62285ac09b71f487b1d3235

See more details on using hashes here.

Provenance

The following attestation bundles were made for museletter-1.0.0-py3-none-any.whl:

Publisher: release.yml on sanketsaurav/museletter

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page