Build Buttondown
YESreplaces $9/mosaves $108/yrback to the verdict
A list you own, sent through a provider you chose, with authentication verified before the first message, one-click unsubscribe in the headers rather than only in the footer, Markdown composition with a web archive, and delivery and complaint figures you watch rather than assume.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the application and the send loop.
Get it python.org or your package manager, then a virtual environment.
Verify
python3 --version prints 3.12 or higher - installfree
Why Subscribers and sends need a real database, and the send loop depends on a unique constraint.
Get it Install locally or run it in Docker. open ↗
Verify
psql connects and reports 16 or higher - have readyroughly 10 to 15 US dollars a year
Why Authentication records are DNS, and sending from a subdomain keeps a reputation problem away from your ordinary mail.
Get it Any registrar with DNS access. Decide the subdomain before configuring anything, since changing it later means rebuilding reputation.
Verify
Records resolve and a test message authenticates - API keyfree tiers typically cover a few thousand messages a month
Why Reputation, feedback loops and event webhooks all come from here, and building them yourself is the project this avoids.
Get it Choose a provider, verify your sending subdomain with the DNS records they specify, and confirm they deliver complaint and bounce events by webhook.
Verify
A test message sends and a delivery event arrives at your endpoint - have readyfree
Why Authentication and unsubscribe both behave differently per provider, and checking one tells you very little.
Get it Create or use accounts at three major providers. You will inspect headers and check the client unsubscribe control in each.
Verify
A test message has been inspected in all three
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- subscribers(id, email, state, subscribed_at, confirmed_at, unsubscribed_at, source, tags_json) · state is pending, active, unsubscribed or bounced - issues(id, subject, markdown, html, text, status, scheduled_for, sent_at, archive_slug) - sends(id, issue_id, subscriber_id, provider_message_id, state, sent_at, delivered_at, opened_at, complained_at, bounced_at) - suppressions(email, reason, at) · complaints and hard bounces, never sent to again - events(id, provider_event_id, type, payload_json, received_at) · idempotency, because providers redeliver - A complaint or a hard bounce writes a suppression immediately. Sending to a suppressed address again is the single fastest way to lose a sending reputation.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://user:pass@localhost:5432/news | Subscribers and sends. Personal data with consent records attached. |
SENDING_DOMAIN | required | mail.example.com | The verified subdomain. Not the root domain, so reputation is isolated. |
FROM_ADDRESS | required | letters@mail.example.com | Must align with the authenticated domain or DMARC fails. |
PROVIDER_API_KEYsecret | required | replace-with-provider-key | Your sending provider's credential. |
UNSUBSCRIBE_SECRETsecret | required | generate-a-long-random-value | Signs unsubscribe links so they work without a login and cannot be forged. |
COMPLAINT_ALERT_RATE | required | 0.001 | Complaint rate at which an alert fires. Well below the limit, because the limit is where damage has already happened. |
SEND_BATCH_SIZE | required | 200 | Recipients per batch. Smaller batches resume more finely after a failure. |
The build, in order
Authentication, before anything to send
A test message from your sending subdomain passes every check.
So a reputation problem does not affect ordinary mail from your main domain.
Files
docs/sending.mdAlignment is the part people miss: the visible From domain must match what SPF or DKIM authenticated.
Of a real test message. Reading your own DNS records proves only that you wrote them.
done when · tick each as it passeswatch out- Configuring the records and assuming they work is how people discover a misalignment after their first real send, when the complaint has already been registered. Inspect the received headers of an actual test message and confirm each result rather than reading your own DNS.
A list built on confirmation
Everyone on the list asked to be, and you can prove when.
A signed link, expiring. Pending addresses are never sent to.
Files
src/subscribe.pySource, time and confirming address. This is what answers a complaint.
Files
src/subscribe.pyImmediately and permanently. Sending again after a complaint is the fastest way to lose a domain.
Files
src/suppress.py
done when · tick each as it passeswatch out- Adding addresses without confirmation produces complaints from people who do not remember signing up, and complaints are the metric that ends deliverability. Confirmation costs you a fraction of signups and protects the sending reputation that everything else depends on.
Unsubscribe that works in one click
Leaving takes one click with no login, from the header and from the footer.
The list header and the post variant. Clients need both to show their own control.
Files
src/headers.pySo it cannot be forged, and so it needs no login to work.
Files
src/unsubscribe.pyThe header control specifically. It either appears or it does not, and you cannot tell without looking.
done when · tick each as it passeswatch out- A footer link that leads to a login or a preferences page with six checkboxes is what makes people click the spam button instead, and a complaint costs far more than an unsubscribe. Make leaving instant and confirm afterwards rather than asking first.
Composing and sending resumably
An issue sends to the whole list, and a failure halfway does not send it twice.
HTML and plain text from one Markdown source. A missing text part looks worse to filters.
Files
src/render.pyThen mark each. Resumption becomes trivial and duplicates become impossible.
Files
src/send.pyOn issue and subscriber. An application check races under retry.
Files
migrations/
done when · tick each as it passeswatch out- A send loop that crashes partway and restarts from the beginning delivers the issue twice to everyone it already reached, which produces complaints from your most engaged readers. Create the send rows first, mark each as it completes, and make the pair of issue and subscriber unique so a duplicate fails loudly.
Watching the numbers that matter
You see complaints and bounces before they cost you the domain.
Deduplicated by the provider's event identifier, because redelivery is normal.
Files
src/events.pyPer issue and rolling. A single bad issue is a different signal from a slow decline.
Files
src/metrics.pyHard suppresses; soft retries. Suppressing on a soft bounce loses real subscribers.
Files
src/bounces.py
done when · tick each as it passeswatch out- The complaint rate is measured by the receiving providers rather than by you, and by the time you see rejections the reputation damage is done. Watch your own complaint events as a leading indicator, alert well below the limit, and treat a single issue with an elevated rate as a reason to pause rather than to send the next one.
Archive, export and the list being yoursproduct builder
Issues are readable on the web and the list can leave with you.
One page per issue, with metadata so a shared link previews properly.
Files
src/archive.pySource and timestamps. Without them the list is not portable in practice.
Files
src/export.pyInto another tool. An export nobody has imported is an assumption.
done when · tick each as it passeswatch out- Exporting addresses without the consent record makes the list unusable elsewhere, because a responsible provider will ask when and how each person subscribed. Export the provenance with the addresses, since that is what makes the list portable rather than just a file of email addresses.
That is the whole plan for Buttondown. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Inbox placement expertise and provider relationships.
- Behavioural automation and engagement segmentation.
- Running your own mail server.
- Support when a provider starts rejecting your mail.
- excellent deliverability, hosted simplicity, integrations, and creator-friendly support
- inbox-placement expertise
- advanced automations
- commerce integrations
- recommendation and ad networks
- A plain text only option, which some readers prefer and which filters like
- Engagement-based pruning, since sending to people who never open lowers your reputation
- Paid subscriptions, which is a payment integration plus a gated archive
Need the files? The project pack on the verdict page hands your agent the whole brief · more newsletters.