ideas.
May 11, 2026 3 min read clicreatorcontent

Self-hosted newsletter sender from markdown files

A CLI tool that turns a folder of markdown files into a sent newsletter — manages your subscriber list in SQLite and delivers via SES or Postmark, no Mailchimp required.

The idea

A command-line tool for writers who want full ownership of their newsletter without paying a platform for the privilege. You write each issue as a markdown file in a local folder, run send with the issue filename, and the tool renders it to HTML using your saved template, resolves the subscriber list from a local SQLite database, and sends it in batches via Amazon SES or Postmark. Subscriber management (subscribe, unsubscribe, export) is done through subcommands, not a web UI.

Why build this

Newsletter platforms — Mailchimp, ConvertKit, Beehiiv — charge based on subscriber count and own your data. A creator with 2,000 subscribers pays $30–50/month for what is essentially: render some HTML, call an SMTP endpoint, store a list of emails. Amazon SES charges $0.10 per thousand emails. Postmark is similarly cheap. The actual cost for a 2,000-subscriber list is under $1/month. The only thing missing is the send infrastructure, and that is not complicated to build.

Writers increasingly want to work in tools they already use — Obsidian, Neovim, VS Code — rather than browser-based WYSIWYG editors. A CLI that speaks markdown fits directly into that workflow.

Stack sketch

  • Language: Python, distributed as a single pipx-installable package
  • Markdown rendering: markdown-it-py with a jinja2 template wrapping the rendered HTML (header, footer, unsubscribe link with HMAC token)
  • Email delivery: boto3 for SES or the Postmark Python SDK, configurable via a config.toml in the project folder
  • Subscriber store: SQLite via sqlite-utils — one table with email, confirmed, created_at, unsubscribe_token
  • CLI framework: typer (type-annotated Click wrapper that generates help text for free)
  • Template format: A single template.html file with {{ content }} and {{ unsubscribe_url }} placeholders; users edit it once and never think about it again
  • Unsubscribe handling: One-click unsubscribe URL points to a minimal Flask endpoint (single file, ~50 lines) the user deploys separately, or a Cloudflare Worker equivalent

Scope for v1

  • init — scaffold a project folder with config.toml, an example template, and an empty subscribers.db
  • subscriber add <email> and subscriber remove <email> subcommands with a confirmation prompt
  • subscriber import <csv> for migrating from another platform
  • subscriber list [--count] to inspect the current list
  • send <issue.md> [--dry-run] — renders to HTML, previews the subject line extracted from the markdown frontmatter (subject: field), then sends to the full list in configurable batch sizes (default 50/s to respect SES burst limits)
  • preview <issue.md> — renders to a local HTML file and opens it in the default browser
  • Deliberately out: double opt-in confirmation flow, open/click tracking, bounce handling, web UI of any kind, scheduling

Where it could go

The unsubscribe handler is the weakest link in v1 — it requires the user to deploy a small web service just to process clicks. The cleanest follow-on is a serve subcommand that runs a local FastAPI instance, designed to sit behind Caddy or Nginx with a public URL, handling both unsubscribes and an optional web archive of past issues. This turns the CLI into a complete self-hosted publishing stack.

Bounce and complaint handling is the second priority. SES and Postmark both push delivery events to webhooks; wiring those events into the subscriber database (marking hard bounces as unsubscribed, flagging complaint addresses) is essential before a list grows past a few hundred subscribers. It is straightforward to implement once the web server exists.

Watch out for

Email deliverability is unforgiving: send from an unverified domain or skip SPF/DKIM setup and everything lands in spam regardless of how good the tool is. The README needs to walk through SES domain identity verification and DKIM signing step-by-step before it touches CLI installation; burying that in "prerequisites" will cause most users to fail silently and blame the tool.