Vibecode Buttondown
track this build5 phases, 15 steps, beginner friendly0%The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Buttondown, send Markdown newsletters from a small list with archives and simple automations. The hard boundary is excellent deliverability, hosted simplicity, integrations, and creator-friendly support, plus deliverability, growth network, and integrations.
You are building a lean indie version of Buttondown. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Buttondown · indie build 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. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Python 3.12 with PostgreSQL | subscribers, sends and events are relational and the send loop needs to be resumable | | Sending | A transactional or bulk provider with a verified sending subdomain | running your own mail server means owning reputation from zero, which is a project rather than a step | | Composition | Markdown rendered to HTML and plain text together | a message with no plain text alternative looks worse to filters and to some readers | | Unsubscribe | Headers plus a signed link that needs no login | the header is what the requirements ask for and the link is what people click | | Archive | Static pages generated per issue | an archive is how people find you and it costs nothing to serve | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - 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 - [ ] **PostgreSQL 16 or newer** · free - 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. - Verify: psql connects and reports 16 or higher - [ ] **A domain with DNS you control, and a sending subdomain** · roughly 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 - [ ] **An email sending provider** · free 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 - [ ] **Test inboxes at three different providers** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Buttondown, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Buttondown The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter you send yourself, authenticated properly so it reaches inboxes to replace Buttondown. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Deliverability is the whole product and it is mostly configuration you get right before the first send. As checked in September 2026: every sender needs SPF, DKIM, a reverse record and TLS. Above roughly five thousand messages a day you are classified as a bulk sender, and that classification is permanent for the domain: sending less later does not reverse it. Bulk senders additionally need DMARC with alignment, meaning the domain in the visible From header must match the domain authenticated by SPF or DKIM, and one-click unsubscribe through the List-Unsubscribe and List-Unsubscribe-Post headers, honoured within two days. Spam complaint rates must stay below three in a thousand, and the practical guidance is to stay below one in a thousand. Non-compliant mail is rejected outright rather than filtered. So authentication and unsubscribe are Phase 1, before there is anything to send. And send from a subdomain rather than your main domain, because a reputation problem on the sending domain should not take your ordinary mail down with it. ### Stack (fixed, do not substitute) - Runtime: Python 3.12 with PostgreSQL · subscribers, sends and events are relational and the send loop needs to be resumable - Sending: A transactional or bulk provider with a verified sending subdomain · running your own mail server means owning reputation from zero, which is a project rather than a step - Composition: Markdown rendered to HTML and plain text together · a message with no plain text alternative looks worse to filters and to some readers - Unsubscribe: Headers plus a signed link that needs no login · the header is what the requirements ask for and the link is what people click - Archive: Static pages generated per issue · an archive is how people find you and it costs nothing to serve ### Data model (create this before Phase 1) - 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. ### Phase 1 · Authentication, before anything to send Build: Choose a sending subdomain. Configure SPF, DKIM and DMARC with alignment. Set a reverse record if you control the address. Send a test to an inbox and inspect the headers to confirm each check passed and that the visible From domain aligns. Trap: 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. Done when: A test message arrives and its headers show SPF, DKIM and DMARC all passing. The visible From domain aligns with the authenticated domain. Sending is from a subdomain rather than the root domain. A message sent with the DKIM key deliberately wrong is rejected or marked, proving the checks are live rather than absent. Do not build yet: No subscribers, composition or sending. ### Phase 2 · A list built on confirmation Build: A signup form sending a confirmation link. Nobody becomes active without clicking it. Record the source, the time and the address that confirmed. Suppress an address the moment it complains or hard bounces. Trap: 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. Done when: An address becomes active only after clicking a confirmation link. The signup source and confirmation time are recorded. An address that complains is suppressed immediately and cannot be resubscribed by an import. Importing a list requires confirming that consent exists and records that assertion. Do not build yet: No composition or sending yet. ### Phase 3 · Unsubscribe that works in one click Build: Add the List-Unsubscribe and List-Unsubscribe-Post headers so the mail client offers its own unsubscribe control. Provide a signed footer link that needs no account. Honour both immediately, and confirm the header control works in a real client. Trap: 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. Done when: A mail client shows its own unsubscribe control and using it removes the subscriber, verified in a real client. The footer link unsubscribes in one click with no login. Both take effect immediately rather than within two days. An unsubscribed address is never sent to again, verified by attempting a send. Do not build yet: No composition or archive yet. ### Phase 4 · Composing and sending resumably Build: Compose in Markdown, render HTML and plain text together, and send in batches with a row per recipient created before sending. Resume from where it stopped. Never send twice to one recipient for one issue, enforced by a unique constraint. Trap: 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. Done when: An issue sends to the whole list with one send row per recipient. Killing the process partway and restarting resumes without resending, verified by counting. A duplicate send row insert fails at the database. Both HTML and plain text parts are present in the delivered message. Do not build yet: No archive or reporting yet. ### Phase 5 · Watching the numbers that matter Build: Ingest delivery, bounce and complaint events from the provider, deduplicated by event identifier. Compute complaint rate per issue and rolling. Alert when it approaches the threshold rather than after crossing it. Trap: 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. Done when: Delivery, bounce and complaint events are ingested and deduplicated by event identifier. Complaint rate is computed per issue and as a rolling figure. An alert fires below the threshold rather than at it. A hard bounce suppresses the address and a soft bounce does not. Do not build yet: No archive or growth work yet. ### Phase 6 · Archive, export and the list being yours Build: Generate a static archive page per issue with correct metadata. Export subscribers with their consent provenance, and issues as Markdown. Confirm the export is importable somewhere else. Trap: 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. Done when: Each issue has a static archive page with correct link preview metadata. Subscriber export includes source, subscription and confirmation times. Issue export is Markdown that renders the same content. The export has been test-imported into another tool successfully. Do not build yet: Nothing after this. ### Out of scope (and why) - Inbox placement expertise. A good provider has relationships and reputation data you do not, and that is much of what the hosted price buys. - Advanced automation: behavioural sequences, segmentation on engagement and the branching flows a marketing tool provides. - Running your own mail server, which means building a sending reputation from nothing and is a different project with a worse first year. - Support when a provider starts rejecting your mail, which is the moment you will most want someone whose job it is. ### README must contain - The authentication records configured and how to verify them from received headers - That sending uses a subdomain, and that bulk sender classification is permanent for a domain - That unsubscribe works from the mail client's own control as well as the footer - That sends are resumable and a duplicate is impossible by construction - The complaint rate thresholds and where the alert fires ===== AGENTS.md ===== # Agent instructions · Buttondown indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Python 3.12 with PostgreSQL, A transactional or bulk provider with a verified sending subdomain, Markdown rendered to HTML and plain text together, Headers plus a signed link that needs no login, Static pages generated per issue. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Buttondown 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Authentication, before anything to send A test message from your sending subdomain passes every check. ### Steps 1. Pick a sending subdomain So a reputation problem does not affect ordinary mail from your main domain. Files: `docs/sending.md` 2. Configure SPF, DKIM and DMARC with alignment Alignment is the part people miss: the visible From domain must match what SPF or DKIM authenticated. 3. Verify from received headers Of a real test message. Reading your own DNS records proves only that you wrote them. ### Done when - [ ] Received headers show SPF, DKIM and DMARC passing - [ ] The visible From domain aligns with the authenticated domain - [ ] Sending uses a subdomain rather than the root domain - [ ] A deliberately broken signature is visibly rejected ### Watch 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. ## Phase 2 · A list built on confirmation Everyone on the list asked to be, and you can prove when. ### Steps 1. Require confirmation A signed link, expiring. Pending addresses are never sent to. Files: `src/subscribe.py` 2. Record provenance Source, time and confirming address. This is what answers a complaint. Files: `src/subscribe.py` 3. Suppress on complaint or hard bounce Immediately and permanently. Sending again after a complaint is the fastest way to lose a domain. Files: `src/suppress.py` ### Done when - [ ] An address activates only after confirmation - [ ] Source and confirmation time are recorded - [ ] A complaint suppresses permanently - [ ] An import requires a consent assertion and records it ### Watch 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. ## Phase 3 · Unsubscribe that works in one click Leaving takes one click with no login, from the header and from the footer. ### Steps 1. Add both unsubscribe headers The list header and the post variant. Clients need both to show their own control. Files: `src/headers.py` 2. Sign the footer link So it cannot be forged, and so it needs no login to work. Files: `src/unsubscribe.py` 3. Verify in a real client The header control specifically. It either appears or it does not, and you cannot tell without looking. ### Done when - [ ] A real client shows and honours its own unsubscribe control - [ ] The footer link works in one click with no login - [ ] Both take effect immediately - [ ] An unsubscribed address is never sent to again ### Watch 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. ## Phase 4 · Composing and sending resumably An issue sends to the whole list, and a failure halfway does not send it twice. ### Steps 1. Render both parts HTML and plain text from one Markdown source. A missing text part looks worse to filters. Files: `src/render.py` 2. Create send rows before sending Then mark each. Resumption becomes trivial and duplicates become impossible. Files: `src/send.py` 3. Enforce uniqueness in the database On issue and subscriber. An application check races under retry. Files: `migrations/` ### Done when - [ ] One send row exists per recipient - [ ] Restarting mid-send resumes without resending - [ ] A duplicate send row fails at the database - [ ] Delivered messages carry both HTML and plain text ### Watch 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. ## Phase 5 · Watching the numbers that matter You see complaints and bounces before they cost you the domain. ### Steps 1. Ingest provider events idempotently Deduplicated by the provider's event identifier, because redelivery is normal. Files: `src/events.py` 2. Compute complaint rate two ways Per issue and rolling. A single bad issue is a different signal from a slow decline. Files: `src/metrics.py` 3. Distinguish hard from soft bounces Hard suppresses; soft retries. Suppressing on a soft bounce loses real subscribers. Files: `src/bounces.py` ### Done when - [ ] Events are ingested and deduplicated - [ ] Complaint rate is computed per issue and rolling - [ ] An alert fires below the threshold - [ ] Hard bounces suppress and soft bounces do not ### Watch 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. ## Not in this build - Inbox placement expertise and provider relationships. - Behavioural automation and engagement segmentation. - Running your own mail server. - Support when a provider starts rejecting your mail. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers and sends. Personal data with consent records attached. DATABASE_URL=postgres://user:pass@localhost:5432/news # Required. The verified subdomain. Not the root domain, so reputation is isolated. SENDING_DOMAIN=mail.example.com # Required. Must align with the authenticated domain or DMARC fails. FROM_ADDRESS=letters@mail.example.com # Required · secret. Your sending provider's credential. PROVIDER_API_KEY=replace-with-provider-key # Required · secret. Signs unsubscribe links so they work without a login and cannot be forged. UNSUBSCRIBE_SECRET=generate-a-long-random-value # Required. Complaint rate at which an alert fires. Well below the limit, because the limit is where damage has already happened. COMPLAINT_ALERT_RATE=0.001 # Required. Recipients per batch. Smaller batches resume more finely after a failure. SEND_BATCH_SIZE=200
You are building a lean indie version of Buttondown. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Buttondown · indie build 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. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Python 3.12 with PostgreSQL | subscribers, sends and events are relational and the send loop needs to be resumable | | Sending | A transactional or bulk provider with a verified sending subdomain | running your own mail server means owning reputation from zero, which is a project rather than a step | | Composition | Markdown rendered to HTML and plain text together | a message with no plain text alternative looks worse to filters and to some readers | | Unsubscribe | Headers plus a signed link that needs no login | the header is what the requirements ask for and the link is what people click | | Archive | Static pages generated per issue | an archive is how people find you and it costs nothing to serve | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - 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 - [ ] **PostgreSQL 16 or newer** · free - 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. - Verify: psql connects and reports 16 or higher - [ ] **A domain with DNS you control, and a sending subdomain** · roughly 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 - [ ] **An email sending provider** · free 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 - [ ] **Test inboxes at three different providers** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Buttondown, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Buttondown The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter you send yourself, authenticated properly so it reaches inboxes to replace Buttondown. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Deliverability is the whole product and it is mostly configuration you get right before the first send. As checked in September 2026: every sender needs SPF, DKIM, a reverse record and TLS. Above roughly five thousand messages a day you are classified as a bulk sender, and that classification is permanent for the domain: sending less later does not reverse it. Bulk senders additionally need DMARC with alignment, meaning the domain in the visible From header must match the domain authenticated by SPF or DKIM, and one-click unsubscribe through the List-Unsubscribe and List-Unsubscribe-Post headers, honoured within two days. Spam complaint rates must stay below three in a thousand, and the practical guidance is to stay below one in a thousand. Non-compliant mail is rejected outright rather than filtered. So authentication and unsubscribe are Phase 1, before there is anything to send. And send from a subdomain rather than your main domain, because a reputation problem on the sending domain should not take your ordinary mail down with it. ### Stack (fixed, do not substitute) - Runtime: Python 3.12 with PostgreSQL · subscribers, sends and events are relational and the send loop needs to be resumable - Sending: A transactional or bulk provider with a verified sending subdomain · running your own mail server means owning reputation from zero, which is a project rather than a step - Composition: Markdown rendered to HTML and plain text together · a message with no plain text alternative looks worse to filters and to some readers - Unsubscribe: Headers plus a signed link that needs no login · the header is what the requirements ask for and the link is what people click - Archive: Static pages generated per issue · an archive is how people find you and it costs nothing to serve ### Data model (create this before Phase 1) - 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. ### Phase 1 · Authentication, before anything to send Build: Choose a sending subdomain. Configure SPF, DKIM and DMARC with alignment. Set a reverse record if you control the address. Send a test to an inbox and inspect the headers to confirm each check passed and that the visible From domain aligns. Trap: 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. Done when: A test message arrives and its headers show SPF, DKIM and DMARC all passing. The visible From domain aligns with the authenticated domain. Sending is from a subdomain rather than the root domain. A message sent with the DKIM key deliberately wrong is rejected or marked, proving the checks are live rather than absent. Do not build yet: No subscribers, composition or sending. ### Phase 2 · A list built on confirmation Build: A signup form sending a confirmation link. Nobody becomes active without clicking it. Record the source, the time and the address that confirmed. Suppress an address the moment it complains or hard bounces. Trap: 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. Done when: An address becomes active only after clicking a confirmation link. The signup source and confirmation time are recorded. An address that complains is suppressed immediately and cannot be resubscribed by an import. Importing a list requires confirming that consent exists and records that assertion. Do not build yet: No composition or sending yet. ### Phase 3 · Unsubscribe that works in one click Build: Add the List-Unsubscribe and List-Unsubscribe-Post headers so the mail client offers its own unsubscribe control. Provide a signed footer link that needs no account. Honour both immediately, and confirm the header control works in a real client. Trap: 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. Done when: A mail client shows its own unsubscribe control and using it removes the subscriber, verified in a real client. The footer link unsubscribes in one click with no login. Both take effect immediately rather than within two days. An unsubscribed address is never sent to again, verified by attempting a send. Do not build yet: No composition or archive yet. ### Phase 4 · Composing and sending resumably Build: Compose in Markdown, render HTML and plain text together, and send in batches with a row per recipient created before sending. Resume from where it stopped. Never send twice to one recipient for one issue, enforced by a unique constraint. Trap: 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. Done when: An issue sends to the whole list with one send row per recipient. Killing the process partway and restarting resumes without resending, verified by counting. A duplicate send row insert fails at the database. Both HTML and plain text parts are present in the delivered message. Do not build yet: No archive or reporting yet. ### Phase 5 · Watching the numbers that matter Build: Ingest delivery, bounce and complaint events from the provider, deduplicated by event identifier. Compute complaint rate per issue and rolling. Alert when it approaches the threshold rather than after crossing it. Trap: 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. Done when: Delivery, bounce and complaint events are ingested and deduplicated by event identifier. Complaint rate is computed per issue and as a rolling figure. An alert fires below the threshold rather than at it. A hard bounce suppresses the address and a soft bounce does not. Do not build yet: No archive or growth work yet. ### Phase 6 · Archive, export and the list being yours Build: Generate a static archive page per issue with correct metadata. Export subscribers with their consent provenance, and issues as Markdown. Confirm the export is importable somewhere else. Trap: 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. Done when: Each issue has a static archive page with correct link preview metadata. Subscriber export includes source, subscription and confirmation times. Issue export is Markdown that renders the same content. The export has been test-imported into another tool successfully. Do not build yet: Nothing after this. ### Out of scope (and why) - Inbox placement expertise. A good provider has relationships and reputation data you do not, and that is much of what the hosted price buys. - Advanced automation: behavioural sequences, segmentation on engagement and the branching flows a marketing tool provides. - Running your own mail server, which means building a sending reputation from nothing and is a different project with a worse first year. - Support when a provider starts rejecting your mail, which is the moment you will most want someone whose job it is. ### README must contain - The authentication records configured and how to verify them from received headers - That sending uses a subdomain, and that bulk sender classification is permanent for a domain - That unsubscribe works from the mail client's own control as well as the footer - That sends are resumable and a duplicate is impossible by construction - The complaint rate thresholds and where the alert fires ===== AGENTS.md ===== # Agent instructions · Buttondown indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Python 3.12 with PostgreSQL, A transactional or bulk provider with a verified sending subdomain, Markdown rendered to HTML and plain text together, Headers plus a signed link that needs no login, Static pages generated per issue. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Buttondown 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Authentication, before anything to send A test message from your sending subdomain passes every check. ### Steps 1. Pick a sending subdomain So a reputation problem does not affect ordinary mail from your main domain. Files: `docs/sending.md` 2. Configure SPF, DKIM and DMARC with alignment Alignment is the part people miss: the visible From domain must match what SPF or DKIM authenticated. 3. Verify from received headers Of a real test message. Reading your own DNS records proves only that you wrote them. ### Done when - [ ] Received headers show SPF, DKIM and DMARC passing - [ ] The visible From domain aligns with the authenticated domain - [ ] Sending uses a subdomain rather than the root domain - [ ] A deliberately broken signature is visibly rejected ### Watch 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. ## Phase 2 · A list built on confirmation Everyone on the list asked to be, and you can prove when. ### Steps 1. Require confirmation A signed link, expiring. Pending addresses are never sent to. Files: `src/subscribe.py` 2. Record provenance Source, time and confirming address. This is what answers a complaint. Files: `src/subscribe.py` 3. Suppress on complaint or hard bounce Immediately and permanently. Sending again after a complaint is the fastest way to lose a domain. Files: `src/suppress.py` ### Done when - [ ] An address activates only after confirmation - [ ] Source and confirmation time are recorded - [ ] A complaint suppresses permanently - [ ] An import requires a consent assertion and records it ### Watch 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. ## Phase 3 · Unsubscribe that works in one click Leaving takes one click with no login, from the header and from the footer. ### Steps 1. Add both unsubscribe headers The list header and the post variant. Clients need both to show their own control. Files: `src/headers.py` 2. Sign the footer link So it cannot be forged, and so it needs no login to work. Files: `src/unsubscribe.py` 3. Verify in a real client The header control specifically. It either appears or it does not, and you cannot tell without looking. ### Done when - [ ] A real client shows and honours its own unsubscribe control - [ ] The footer link works in one click with no login - [ ] Both take effect immediately - [ ] An unsubscribed address is never sent to again ### Watch 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. ## Phase 4 · Composing and sending resumably An issue sends to the whole list, and a failure halfway does not send it twice. ### Steps 1. Render both parts HTML and plain text from one Markdown source. A missing text part looks worse to filters. Files: `src/render.py` 2. Create send rows before sending Then mark each. Resumption becomes trivial and duplicates become impossible. Files: `src/send.py` 3. Enforce uniqueness in the database On issue and subscriber. An application check races under retry. Files: `migrations/` ### Done when - [ ] One send row exists per recipient - [ ] Restarting mid-send resumes without resending - [ ] A duplicate send row fails at the database - [ ] Delivered messages carry both HTML and plain text ### Watch 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. ## Phase 5 · Watching the numbers that matter You see complaints and bounces before they cost you the domain. ### Steps 1. Ingest provider events idempotently Deduplicated by the provider's event identifier, because redelivery is normal. Files: `src/events.py` 2. Compute complaint rate two ways Per issue and rolling. A single bad issue is a different signal from a slow decline. Files: `src/metrics.py` 3. Distinguish hard from soft bounces Hard suppresses; soft retries. Suppressing on a soft bounce loses real subscribers. Files: `src/bounces.py` ### Done when - [ ] Events are ingested and deduplicated - [ ] Complaint rate is computed per issue and rolling - [ ] An alert fires below the threshold - [ ] Hard bounces suppress and soft bounces do not ### Watch 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. ## Not in this build - Inbox placement expertise and provider relationships. - Behavioural automation and engagement segmentation. - Running your own mail server. - Support when a provider starts rejecting your mail. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers and sends. Personal data with consent records attached. DATABASE_URL=postgres://user:pass@localhost:5432/news # Required. The verified subdomain. Not the root domain, so reputation is isolated. SENDING_DOMAIN=mail.example.com # Required. Must align with the authenticated domain or DMARC fails. FROM_ADDRESS=letters@mail.example.com # Required · secret. Your sending provider's credential. PROVIDER_API_KEY=replace-with-provider-key # Required · secret. Signs unsubscribe links so they work without a login and cannot be forged. UNSUBSCRIBE_SECRET=generate-a-long-random-value # Required. Complaint rate at which an alert fires. Well below the limit, because the limit is where damage has already happened. COMPLAINT_ALERT_RATE=0.001 # Required. Recipients per batch. Smaller batches resume more finely after a failure. SEND_BATCH_SIZE=200
You are building a production product version of Buttondown. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Buttondown · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Buttondown, send Markdown newsletters from a small list with archives and simple automations. The hard boundary is excellent deliverability, hosted simplicity, integrations, and creator-friendly support, plus deliverability, growth network, and integrations. ## Product outcome A newsletter that authenticates correctly from the first send, can be left in one click from the reader's own mail client, cannot be sent twice by a crash, and whose complaint rate you watch rather than discover. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Amazon SES account - verified sending domain - PostgreSQL - public HTTPS deploy target - DNS access ## Explicit non-goals for v1 - 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 ## Success criteria - Received headers confirm SPF, DKIM and DMARC passing with alignment - The mail client's own unsubscribe control is confirmed working - A killed send is confirmed to resume without resending - Suppression on complaint is confirmed permanent - The subscriber export has been imported into another tool ===== BRIEF.md ===== # Build brief · Buttondown The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter you send yourself, authenticated properly so it reaches inboxes to replace Buttondown. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Deliverability is the whole product and it is mostly configuration you get right before the first send. As checked in September 2026: every sender needs SPF, DKIM, a reverse record and TLS. Above roughly five thousand messages a day you are classified as a bulk sender, and that classification is permanent for the domain: sending less later does not reverse it. Bulk senders additionally need DMARC with alignment, meaning the domain in the visible From header must match the domain authenticated by SPF or DKIM, and one-click unsubscribe through the List-Unsubscribe and List-Unsubscribe-Post headers, honoured within two days. Spam complaint rates must stay below three in a thousand, and the practical guidance is to stay below one in a thousand. Non-compliant mail is rejected outright rather than filtered. So authentication and unsubscribe are Phase 1, before there is anything to send. And send from a subdomain rather than your main domain, because a reputation problem on the sending domain should not take your ordinary mail down with it. ### Stack (fixed, do not substitute) - Runtime: Python 3.12 with PostgreSQL · subscribers, sends and events are relational and the send loop needs to be resumable - Sending: A transactional or bulk provider with a verified sending subdomain · running your own mail server means owning reputation from zero, which is a project rather than a step - Composition: Markdown rendered to HTML and plain text together · a message with no plain text alternative looks worse to filters and to some readers - Unsubscribe: Headers plus a signed link that needs no login · the header is what the requirements ask for and the link is what people click - Archive: Static pages generated per issue · an archive is how people find you and it costs nothing to serve ### Data model (create this before Phase 1) - 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. ### Phase 1 · Authentication, before anything to send Build: Choose a sending subdomain. Configure SPF, DKIM and DMARC with alignment. Set a reverse record if you control the address. Send a test to an inbox and inspect the headers to confirm each check passed and that the visible From domain aligns. Trap: 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. Done when: A test message arrives and its headers show SPF, DKIM and DMARC all passing. The visible From domain aligns with the authenticated domain. Sending is from a subdomain rather than the root domain. A message sent with the DKIM key deliberately wrong is rejected or marked, proving the checks are live rather than absent. Do not build yet: No subscribers, composition or sending. ### Phase 2 · A list built on confirmation Build: A signup form sending a confirmation link. Nobody becomes active without clicking it. Record the source, the time and the address that confirmed. Suppress an address the moment it complains or hard bounces. Trap: 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. Done when: An address becomes active only after clicking a confirmation link. The signup source and confirmation time are recorded. An address that complains is suppressed immediately and cannot be resubscribed by an import. Importing a list requires confirming that consent exists and records that assertion. Do not build yet: No composition or sending yet. ### Phase 3 · Unsubscribe that works in one click Build: Add the List-Unsubscribe and List-Unsubscribe-Post headers so the mail client offers its own unsubscribe control. Provide a signed footer link that needs no account. Honour both immediately, and confirm the header control works in a real client. Trap: 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. Done when: A mail client shows its own unsubscribe control and using it removes the subscriber, verified in a real client. The footer link unsubscribes in one click with no login. Both take effect immediately rather than within two days. An unsubscribed address is never sent to again, verified by attempting a send. Do not build yet: No composition or archive yet. ### Phase 4 · Composing and sending resumably Build: Compose in Markdown, render HTML and plain text together, and send in batches with a row per recipient created before sending. Resume from where it stopped. Never send twice to one recipient for one issue, enforced by a unique constraint. Trap: 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. Done when: An issue sends to the whole list with one send row per recipient. Killing the process partway and restarting resumes without resending, verified by counting. A duplicate send row insert fails at the database. Both HTML and plain text parts are present in the delivered message. Do not build yet: No archive or reporting yet. ### Phase 5 · Watching the numbers that matter Build: Ingest delivery, bounce and complaint events from the provider, deduplicated by event identifier. Compute complaint rate per issue and rolling. Alert when it approaches the threshold rather than after crossing it. Trap: 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. Done when: Delivery, bounce and complaint events are ingested and deduplicated by event identifier. Complaint rate is computed per issue and as a rolling figure. An alert fires below the threshold rather than at it. A hard bounce suppresses the address and a soft bounce does not. Do not build yet: No archive or growth work yet. ### Phase 6 · Archive, export and the list being yours Build: Generate a static archive page per issue with correct metadata. Export subscribers with their consent provenance, and issues as Markdown. Confirm the export is importable somewhere else. Trap: 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. Done when: Each issue has a static archive page with correct link preview metadata. Subscriber export includes source, subscription and confirmation times. Issue export is Markdown that renders the same content. The export has been test-imported into another tool successfully. Do not build yet: Nothing after this. ### Out of scope (and why) - Inbox placement expertise. A good provider has relationships and reputation data you do not, and that is much of what the hosted price buys. - Advanced automation: behavioural sequences, segmentation on engagement and the branching flows a marketing tool provides. - Running your own mail server, which means building a sending reputation from nothing and is a different project with a worse first year. - Support when a provider starts rejecting your mail, which is the moment you will most want someone whose job it is. ### README must contain - The authentication records configured and how to verify them from received headers - That sending uses a subdomain, and that bulk sender classification is permanent for a domain - That unsubscribe works from the mail client's own control as well as the footer - That sends are resumable and a duplicate is impossible by construction - The complaint rate thresholds and where the alert fires ===== ARCHITECTURE.md ===== # Architecture · Buttondown ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Python 3.12 with PostgreSQL | subscribers, sends and events are relational and the send loop needs to be resumable | | Sending | A transactional or bulk provider with a verified sending subdomain | running your own mail server means owning reputation from zero, which is a project rather than a step | | Composition | Markdown rendered to HTML and plain text together | a message with no plain text alternative looks worse to filters and to some readers | | Unsubscribe | Headers plus a signed link that needs no login | the header is what the requirements ask for and the link is what people click | | Archive | Static pages generated per issue | an archive is how people find you and it costs nothing to serve | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Authentication | SPF, DKIM, DMARC and the sending subdomain. | Provider specific in detail, universal in principle. Alignment is the requirement people miss. | | List | Confirmation, provenance and suppression. | Any storage. Confirmed opt-in and immediate suppression are what protect the reputation everything rests on. | | Unsubscribe | Headers and the signed link. | Not swappable for a preferences page. Friction here converts unsubscribes into complaints. | | Send | Resumable batched delivery. | Any queue, provided the uniqueness constraint prevents duplicates rather than an application check. | | Metrics | Complaints, bounces and the early alert. | Any provider's events. The leading indicator matters more than the exact source. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Subscribers and sends. Personal data with consent records attached. - `SENDING_DOMAIN` · required · The verified subdomain. Not the root domain, so reputation is isolated. - `FROM_ADDRESS` · required · Must align with the authenticated domain or DMARC fails. - `PROVIDER_API_KEY` · required, secret · Your sending provider's credential. - `UNSUBSCRIBE_SECRET` · required, secret · Signs unsubscribe links so they work without a login and cannot be forged. - `COMPLAINT_ALERT_RATE` · required · Complaint rate at which an alert fires. Well below the limit, because the limit is where damage has already happened. - `SEND_BATCH_SIZE` · required · Recipients per batch. Smaller batches resume more finely after a failure. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Buttondown product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Python 3.12 with PostgreSQL, A transactional or bulk provider with a verified sending subdomain, Markdown rendered to HTML and plain text together, Headers plus a signed link that needs no login, Static pages generated per issue. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. - 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. ===== MILESTONES.md ===== # Delivery milestones · Buttondown Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Authentication, before anything to send A test message from your sending subdomain passes every check. ### Steps 1. Pick a sending subdomain So a reputation problem does not affect ordinary mail from your main domain. Files: `docs/sending.md` 2. Configure SPF, DKIM and DMARC with alignment Alignment is the part people miss: the visible From domain must match what SPF or DKIM authenticated. 3. Verify from received headers Of a real test message. Reading your own DNS records proves only that you wrote them. ### Done when - [ ] Received headers show SPF, DKIM and DMARC passing - [ ] The visible From domain aligns with the authenticated domain - [ ] Sending uses a subdomain rather than the root domain - [ ] A deliberately broken signature is visibly rejected ### Watch 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. ## M2 · A list built on confirmation Everyone on the list asked to be, and you can prove when. ### Steps 1. Require confirmation A signed link, expiring. Pending addresses are never sent to. Files: `src/subscribe.py` 2. Record provenance Source, time and confirming address. This is what answers a complaint. Files: `src/subscribe.py` 3. Suppress on complaint or hard bounce Immediately and permanently. Sending again after a complaint is the fastest way to lose a domain. Files: `src/suppress.py` ### Done when - [ ] An address activates only after confirmation - [ ] Source and confirmation time are recorded - [ ] A complaint suppresses permanently - [ ] An import requires a consent assertion and records it ### Watch 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. ## M3 · Unsubscribe that works in one click Leaving takes one click with no login, from the header and from the footer. ### Steps 1. Add both unsubscribe headers The list header and the post variant. Clients need both to show their own control. Files: `src/headers.py` 2. Sign the footer link So it cannot be forged, and so it needs no login to work. Files: `src/unsubscribe.py` 3. Verify in a real client The header control specifically. It either appears or it does not, and you cannot tell without looking. ### Done when - [ ] A real client shows and honours its own unsubscribe control - [ ] The footer link works in one click with no login - [ ] Both take effect immediately - [ ] An unsubscribed address is never sent to again ### Watch 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. ## M4 · Composing and sending resumably An issue sends to the whole list, and a failure halfway does not send it twice. ### Steps 1. Render both parts HTML and plain text from one Markdown source. A missing text part looks worse to filters. Files: `src/render.py` 2. Create send rows before sending Then mark each. Resumption becomes trivial and duplicates become impossible. Files: `src/send.py` 3. Enforce uniqueness in the database On issue and subscriber. An application check races under retry. Files: `migrations/` ### Done when - [ ] One send row exists per recipient - [ ] Restarting mid-send resumes without resending - [ ] A duplicate send row fails at the database - [ ] Delivered messages carry both HTML and plain text ### Watch 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. ## M5 · Watching the numbers that matter You see complaints and bounces before they cost you the domain. ### Steps 1. Ingest provider events idempotently Deduplicated by the provider's event identifier, because redelivery is normal. Files: `src/events.py` 2. Compute complaint rate two ways Per issue and rolling. A single bad issue is a different signal from a slow decline. Files: `src/metrics.py` 3. Distinguish hard from soft bounces Hard suppresses; soft retries. Suppressing on a soft bounce loses real subscribers. Files: `src/bounces.py` ### Done when - [ ] Events are ingested and deduplicated - [ ] Complaint rate is computed per issue and rolling - [ ] An alert fires below the threshold - [ ] Hard bounces suppress and soft bounces do not ### Watch 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. ## M6 · Archive, export and the list being yours (production only) Issues are readable on the web and the list can leave with you. ### Steps 1. Generate the archive statically One page per issue, with metadata so a shared link previews properly. Files: `src/archive.py` 2. Export subscribers with provenance Source and timestamps. Without them the list is not portable in practice. Files: `src/export.py` 3. Test the import elsewhere Into another tool. An export nobody has imported is an assumption. ### Done when - [ ] Each issue has an archive page with preview metadata - [ ] Subscriber export includes consent provenance - [ ] Issue export renders the same content - [ ] The export has been imported elsewhere successfully ### Watch 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. ===== OPERATIONS.md ===== # Operations · Buttondown ## Backup Subscribers with their consent provenance are the irreplaceable asset, and the provenance is what makes them portable. Issues are content and belong in a backup too, though they are usually recoverable from the archive. ## Restore Restore and confirm suppressions came back before sending anything, because a restore that loses suppressions will send to people who complained and that is the worst possible first message after an outage. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Complaint rate per issue and rolling, bounce rate, and delivery event lag. A provider that stops sending events looks identical to a newsletter nobody is opening. ## Incident checklist If deliverability drops, check authentication first since a DNS change can break alignment silently, then complaint rate. Pause sending rather than continuing while you investigate, because each send with an elevated complaint rate compounds the problem. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Received headers confirm SPF, DKIM and DMARC passing with alignment - [ ] The mail client's own unsubscribe control is confirmed working - [ ] A killed send is confirmed to resume without resending - [ ] Suppression on complaint is confirmed permanent - [ ] The subscriber export has been imported into another tool ## Launch constraint Do not market omitted Buttondown capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers and sends. Personal data with consent records attached. DATABASE_URL=postgres://user:pass@localhost:5432/news # Required. The verified subdomain. Not the root domain, so reputation is isolated. SENDING_DOMAIN=mail.example.com # Required. Must align with the authenticated domain or DMARC fails. FROM_ADDRESS=letters@mail.example.com # Required · secret. Your sending provider's credential. PROVIDER_API_KEY=replace-with-provider-key # Required · secret. Signs unsubscribe links so they work without a login and cannot be forged. UNSUBSCRIBE_SECRET=generate-a-long-random-value # Required. Complaint rate at which an alert fires. Well below the limit, because the limit is where damage has already happened. COMPLAINT_ALERT_RATE=0.001 # Required. Recipients per batch. Smaller batches resume more finely after a failure. SEND_BATCH_SIZE=200
# Buttondown · indie build 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. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Python 3.12 with PostgreSQL | subscribers, sends and events are relational and the send loop needs to be resumable | | Sending | A transactional or bulk provider with a verified sending subdomain | running your own mail server means owning reputation from zero, which is a project rather than a step | | Composition | Markdown rendered to HTML and plain text together | a message with no plain text alternative looks worse to filters and to some readers | | Unsubscribe | Headers plus a signed link that needs no login | the header is what the requirements ask for and the link is what people click | | Archive | Static pages generated per issue | an archive is how people find you and it costs nothing to serve | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - 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 - [ ] **PostgreSQL 16 or newer** · free - 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. - Verify: psql connects and reports 16 or higher - [ ] **A domain with DNS you control, and a sending subdomain** · roughly 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 - [ ] **An email sending provider** · free 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 - [ ] **Test inboxes at three different providers** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Buttondown, and the README should say so rather than pretend.
# Build brief · Buttondown The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter you send yourself, authenticated properly so it reaches inboxes to replace Buttondown. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Deliverability is the whole product and it is mostly configuration you get right before the first send. As checked in September 2026: every sender needs SPF, DKIM, a reverse record and TLS. Above roughly five thousand messages a day you are classified as a bulk sender, and that classification is permanent for the domain: sending less later does not reverse it. Bulk senders additionally need DMARC with alignment, meaning the domain in the visible From header must match the domain authenticated by SPF or DKIM, and one-click unsubscribe through the List-Unsubscribe and List-Unsubscribe-Post headers, honoured within two days. Spam complaint rates must stay below three in a thousand, and the practical guidance is to stay below one in a thousand. Non-compliant mail is rejected outright rather than filtered. So authentication and unsubscribe are Phase 1, before there is anything to send. And send from a subdomain rather than your main domain, because a reputation problem on the sending domain should not take your ordinary mail down with it. ### Stack (fixed, do not substitute) - Runtime: Python 3.12 with PostgreSQL · subscribers, sends and events are relational and the send loop needs to be resumable - Sending: A transactional or bulk provider with a verified sending subdomain · running your own mail server means owning reputation from zero, which is a project rather than a step - Composition: Markdown rendered to HTML and plain text together · a message with no plain text alternative looks worse to filters and to some readers - Unsubscribe: Headers plus a signed link that needs no login · the header is what the requirements ask for and the link is what people click - Archive: Static pages generated per issue · an archive is how people find you and it costs nothing to serve ### Data model (create this before Phase 1) - 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. ### Phase 1 · Authentication, before anything to send Build: Choose a sending subdomain. Configure SPF, DKIM and DMARC with alignment. Set a reverse record if you control the address. Send a test to an inbox and inspect the headers to confirm each check passed and that the visible From domain aligns. Trap: 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. Done when: A test message arrives and its headers show SPF, DKIM and DMARC all passing. The visible From domain aligns with the authenticated domain. Sending is from a subdomain rather than the root domain. A message sent with the DKIM key deliberately wrong is rejected or marked, proving the checks are live rather than absent. Do not build yet: No subscribers, composition or sending. ### Phase 2 · A list built on confirmation Build: A signup form sending a confirmation link. Nobody becomes active without clicking it. Record the source, the time and the address that confirmed. Suppress an address the moment it complains or hard bounces. Trap: 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. Done when: An address becomes active only after clicking a confirmation link. The signup source and confirmation time are recorded. An address that complains is suppressed immediately and cannot be resubscribed by an import. Importing a list requires confirming that consent exists and records that assertion. Do not build yet: No composition or sending yet. ### Phase 3 · Unsubscribe that works in one click Build: Add the List-Unsubscribe and List-Unsubscribe-Post headers so the mail client offers its own unsubscribe control. Provide a signed footer link that needs no account. Honour both immediately, and confirm the header control works in a real client. Trap: 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. Done when: A mail client shows its own unsubscribe control and using it removes the subscriber, verified in a real client. The footer link unsubscribes in one click with no login. Both take effect immediately rather than within two days. An unsubscribed address is never sent to again, verified by attempting a send. Do not build yet: No composition or archive yet. ### Phase 4 · Composing and sending resumably Build: Compose in Markdown, render HTML and plain text together, and send in batches with a row per recipient created before sending. Resume from where it stopped. Never send twice to one recipient for one issue, enforced by a unique constraint. Trap: 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. Done when: An issue sends to the whole list with one send row per recipient. Killing the process partway and restarting resumes without resending, verified by counting. A duplicate send row insert fails at the database. Both HTML and plain text parts are present in the delivered message. Do not build yet: No archive or reporting yet. ### Phase 5 · Watching the numbers that matter Build: Ingest delivery, bounce and complaint events from the provider, deduplicated by event identifier. Compute complaint rate per issue and rolling. Alert when it approaches the threshold rather than after crossing it. Trap: 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. Done when: Delivery, bounce and complaint events are ingested and deduplicated by event identifier. Complaint rate is computed per issue and as a rolling figure. An alert fires below the threshold rather than at it. A hard bounce suppresses the address and a soft bounce does not. Do not build yet: No archive or growth work yet. ### Phase 6 · Archive, export and the list being yours Build: Generate a static archive page per issue with correct metadata. Export subscribers with their consent provenance, and issues as Markdown. Confirm the export is importable somewhere else. Trap: 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. Done when: Each issue has a static archive page with correct link preview metadata. Subscriber export includes source, subscription and confirmation times. Issue export is Markdown that renders the same content. The export has been test-imported into another tool successfully. Do not build yet: Nothing after this. ### Out of scope (and why) - Inbox placement expertise. A good provider has relationships and reputation data you do not, and that is much of what the hosted price buys. - Advanced automation: behavioural sequences, segmentation on engagement and the branching flows a marketing tool provides. - Running your own mail server, which means building a sending reputation from nothing and is a different project with a worse first year. - Support when a provider starts rejecting your mail, which is the moment you will most want someone whose job it is. ### README must contain - The authentication records configured and how to verify them from received headers - That sending uses a subdomain, and that bulk sender classification is permanent for a domain - That unsubscribe works from the mail client's own control as well as the footer - That sends are resumable and a duplicate is impossible by construction - The complaint rate thresholds and where the alert fires
# Agent instructions · Buttondown indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Python 3.12 with PostgreSQL, A transactional or bulk provider with a verified sending subdomain, Markdown rendered to HTML and plain text together, Headers plus a signed link that needs no login, Static pages generated per issue. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. - 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.
# Build plan · Buttondown 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Authentication, before anything to send A test message from your sending subdomain passes every check. ### Steps 1. Pick a sending subdomain So a reputation problem does not affect ordinary mail from your main domain. Files: `docs/sending.md` 2. Configure SPF, DKIM and DMARC with alignment Alignment is the part people miss: the visible From domain must match what SPF or DKIM authenticated. 3. Verify from received headers Of a real test message. Reading your own DNS records proves only that you wrote them. ### Done when - [ ] Received headers show SPF, DKIM and DMARC passing - [ ] The visible From domain aligns with the authenticated domain - [ ] Sending uses a subdomain rather than the root domain - [ ] A deliberately broken signature is visibly rejected ### Watch 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. ## Phase 2 · A list built on confirmation Everyone on the list asked to be, and you can prove when. ### Steps 1. Require confirmation A signed link, expiring. Pending addresses are never sent to. Files: `src/subscribe.py` 2. Record provenance Source, time and confirming address. This is what answers a complaint. Files: `src/subscribe.py` 3. Suppress on complaint or hard bounce Immediately and permanently. Sending again after a complaint is the fastest way to lose a domain. Files: `src/suppress.py` ### Done when - [ ] An address activates only after confirmation - [ ] Source and confirmation time are recorded - [ ] A complaint suppresses permanently - [ ] An import requires a consent assertion and records it ### Watch 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. ## Phase 3 · Unsubscribe that works in one click Leaving takes one click with no login, from the header and from the footer. ### Steps 1. Add both unsubscribe headers The list header and the post variant. Clients need both to show their own control. Files: `src/headers.py` 2. Sign the footer link So it cannot be forged, and so it needs no login to work. Files: `src/unsubscribe.py` 3. Verify in a real client The header control specifically. It either appears or it does not, and you cannot tell without looking. ### Done when - [ ] A real client shows and honours its own unsubscribe control - [ ] The footer link works in one click with no login - [ ] Both take effect immediately - [ ] An unsubscribed address is never sent to again ### Watch 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. ## Phase 4 · Composing and sending resumably An issue sends to the whole list, and a failure halfway does not send it twice. ### Steps 1. Render both parts HTML and plain text from one Markdown source. A missing text part looks worse to filters. Files: `src/render.py` 2. Create send rows before sending Then mark each. Resumption becomes trivial and duplicates become impossible. Files: `src/send.py` 3. Enforce uniqueness in the database On issue and subscriber. An application check races under retry. Files: `migrations/` ### Done when - [ ] One send row exists per recipient - [ ] Restarting mid-send resumes without resending - [ ] A duplicate send row fails at the database - [ ] Delivered messages carry both HTML and plain text ### Watch 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. ## Phase 5 · Watching the numbers that matter You see complaints and bounces before they cost you the domain. ### Steps 1. Ingest provider events idempotently Deduplicated by the provider's event identifier, because redelivery is normal. Files: `src/events.py` 2. Compute complaint rate two ways Per issue and rolling. A single bad issue is a different signal from a slow decline. Files: `src/metrics.py` 3. Distinguish hard from soft bounces Hard suppresses; soft retries. Suppressing on a soft bounce loses real subscribers. Files: `src/bounces.py` ### Done when - [ ] Events are ingested and deduplicated - [ ] Complaint rate is computed per issue and rolling - [ ] An alert fires below the threshold - [ ] Hard bounces suppress and soft bounces do not ### Watch 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. ## Not in this build - Inbox placement expertise and provider relationships. - Behavioural automation and engagement segmentation. - Running your own mail server. - Support when a provider starts rejecting your mail. ## After v1, if you want it - 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
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers and sends. Personal data with consent records attached. DATABASE_URL=postgres://user:pass@localhost:5432/news # Required. The verified subdomain. Not the root domain, so reputation is isolated. SENDING_DOMAIN=mail.example.com # Required. Must align with the authenticated domain or DMARC fails. FROM_ADDRESS=letters@mail.example.com # Required · secret. Your sending provider's credential. PROVIDER_API_KEY=replace-with-provider-key # Required · secret. Signs unsubscribe links so they work without a login and cannot be forged. UNSUBSCRIBE_SECRET=generate-a-long-random-value # Required. Complaint rate at which an alert fires. Well below the limit, because the limit is where damage has already happened. COMPLAINT_ALERT_RATE=0.001 # Required. Recipients per batch. Smaller batches resume more finely after a failure. SEND_BATCH_SIZE=200
# Buttondown · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Buttondown, send Markdown newsletters from a small list with archives and simple automations. The hard boundary is excellent deliverability, hosted simplicity, integrations, and creator-friendly support, plus deliverability, growth network, and integrations. ## Product outcome A newsletter that authenticates correctly from the first send, can be left in one click from the reader's own mail client, cannot be sent twice by a crash, and whose complaint rate you watch rather than discover. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Amazon SES account - verified sending domain - PostgreSQL - public HTTPS deploy target - DNS access ## Explicit non-goals for v1 - 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 ## Success criteria - Received headers confirm SPF, DKIM and DMARC passing with alignment - The mail client's own unsubscribe control is confirmed working - A killed send is confirmed to resume without resending - Suppression on complaint is confirmed permanent - The subscriber export has been imported into another tool
# Build brief · Buttondown The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter you send yourself, authenticated properly so it reaches inboxes to replace Buttondown. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Deliverability is the whole product and it is mostly configuration you get right before the first send. As checked in September 2026: every sender needs SPF, DKIM, a reverse record and TLS. Above roughly five thousand messages a day you are classified as a bulk sender, and that classification is permanent for the domain: sending less later does not reverse it. Bulk senders additionally need DMARC with alignment, meaning the domain in the visible From header must match the domain authenticated by SPF or DKIM, and one-click unsubscribe through the List-Unsubscribe and List-Unsubscribe-Post headers, honoured within two days. Spam complaint rates must stay below three in a thousand, and the practical guidance is to stay below one in a thousand. Non-compliant mail is rejected outright rather than filtered. So authentication and unsubscribe are Phase 1, before there is anything to send. And send from a subdomain rather than your main domain, because a reputation problem on the sending domain should not take your ordinary mail down with it. ### Stack (fixed, do not substitute) - Runtime: Python 3.12 with PostgreSQL · subscribers, sends and events are relational and the send loop needs to be resumable - Sending: A transactional or bulk provider with a verified sending subdomain · running your own mail server means owning reputation from zero, which is a project rather than a step - Composition: Markdown rendered to HTML and plain text together · a message with no plain text alternative looks worse to filters and to some readers - Unsubscribe: Headers plus a signed link that needs no login · the header is what the requirements ask for and the link is what people click - Archive: Static pages generated per issue · an archive is how people find you and it costs nothing to serve ### Data model (create this before Phase 1) - 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. ### Phase 1 · Authentication, before anything to send Build: Choose a sending subdomain. Configure SPF, DKIM and DMARC with alignment. Set a reverse record if you control the address. Send a test to an inbox and inspect the headers to confirm each check passed and that the visible From domain aligns. Trap: 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. Done when: A test message arrives and its headers show SPF, DKIM and DMARC all passing. The visible From domain aligns with the authenticated domain. Sending is from a subdomain rather than the root domain. A message sent with the DKIM key deliberately wrong is rejected or marked, proving the checks are live rather than absent. Do not build yet: No subscribers, composition or sending. ### Phase 2 · A list built on confirmation Build: A signup form sending a confirmation link. Nobody becomes active without clicking it. Record the source, the time and the address that confirmed. Suppress an address the moment it complains or hard bounces. Trap: 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. Done when: An address becomes active only after clicking a confirmation link. The signup source and confirmation time are recorded. An address that complains is suppressed immediately and cannot be resubscribed by an import. Importing a list requires confirming that consent exists and records that assertion. Do not build yet: No composition or sending yet. ### Phase 3 · Unsubscribe that works in one click Build: Add the List-Unsubscribe and List-Unsubscribe-Post headers so the mail client offers its own unsubscribe control. Provide a signed footer link that needs no account. Honour both immediately, and confirm the header control works in a real client. Trap: 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. Done when: A mail client shows its own unsubscribe control and using it removes the subscriber, verified in a real client. The footer link unsubscribes in one click with no login. Both take effect immediately rather than within two days. An unsubscribed address is never sent to again, verified by attempting a send. Do not build yet: No composition or archive yet. ### Phase 4 · Composing and sending resumably Build: Compose in Markdown, render HTML and plain text together, and send in batches with a row per recipient created before sending. Resume from where it stopped. Never send twice to one recipient for one issue, enforced by a unique constraint. Trap: 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. Done when: An issue sends to the whole list with one send row per recipient. Killing the process partway and restarting resumes without resending, verified by counting. A duplicate send row insert fails at the database. Both HTML and plain text parts are present in the delivered message. Do not build yet: No archive or reporting yet. ### Phase 5 · Watching the numbers that matter Build: Ingest delivery, bounce and complaint events from the provider, deduplicated by event identifier. Compute complaint rate per issue and rolling. Alert when it approaches the threshold rather than after crossing it. Trap: 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. Done when: Delivery, bounce and complaint events are ingested and deduplicated by event identifier. Complaint rate is computed per issue and as a rolling figure. An alert fires below the threshold rather than at it. A hard bounce suppresses the address and a soft bounce does not. Do not build yet: No archive or growth work yet. ### Phase 6 · Archive, export and the list being yours Build: Generate a static archive page per issue with correct metadata. Export subscribers with their consent provenance, and issues as Markdown. Confirm the export is importable somewhere else. Trap: 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. Done when: Each issue has a static archive page with correct link preview metadata. Subscriber export includes source, subscription and confirmation times. Issue export is Markdown that renders the same content. The export has been test-imported into another tool successfully. Do not build yet: Nothing after this. ### Out of scope (and why) - Inbox placement expertise. A good provider has relationships and reputation data you do not, and that is much of what the hosted price buys. - Advanced automation: behavioural sequences, segmentation on engagement and the branching flows a marketing tool provides. - Running your own mail server, which means building a sending reputation from nothing and is a different project with a worse first year. - Support when a provider starts rejecting your mail, which is the moment you will most want someone whose job it is. ### README must contain - The authentication records configured and how to verify them from received headers - That sending uses a subdomain, and that bulk sender classification is permanent for a domain - That unsubscribe works from the mail client's own control as well as the footer - That sends are resumable and a duplicate is impossible by construction - The complaint rate thresholds and where the alert fires
# Architecture · Buttondown ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Python 3.12 with PostgreSQL | subscribers, sends and events are relational and the send loop needs to be resumable | | Sending | A transactional or bulk provider with a verified sending subdomain | running your own mail server means owning reputation from zero, which is a project rather than a step | | Composition | Markdown rendered to HTML and plain text together | a message with no plain text alternative looks worse to filters and to some readers | | Unsubscribe | Headers plus a signed link that needs no login | the header is what the requirements ask for and the link is what people click | | Archive | Static pages generated per issue | an archive is how people find you and it costs nothing to serve | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Authentication | SPF, DKIM, DMARC and the sending subdomain. | Provider specific in detail, universal in principle. Alignment is the requirement people miss. | | List | Confirmation, provenance and suppression. | Any storage. Confirmed opt-in and immediate suppression are what protect the reputation everything rests on. | | Unsubscribe | Headers and the signed link. | Not swappable for a preferences page. Friction here converts unsubscribes into complaints. | | Send | Resumable batched delivery. | Any queue, provided the uniqueness constraint prevents duplicates rather than an application check. | | Metrics | Complaints, bounces and the early alert. | Any provider's events. The leading indicator matters more than the exact source. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Subscribers and sends. Personal data with consent records attached. - `SENDING_DOMAIN` · required · The verified subdomain. Not the root domain, so reputation is isolated. - `FROM_ADDRESS` · required · Must align with the authenticated domain or DMARC fails. - `PROVIDER_API_KEY` · required, secret · Your sending provider's credential. - `UNSUBSCRIBE_SECRET` · required, secret · Signs unsubscribe links so they work without a login and cannot be forged. - `COMPLAINT_ALERT_RATE` · required · Complaint rate at which an alert fires. Well below the limit, because the limit is where damage has already happened. - `SEND_BATCH_SIZE` · required · Recipients per batch. Smaller batches resume more finely after a failure. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Buttondown product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Python 3.12 with PostgreSQL, A transactional or bulk provider with a verified sending subdomain, Markdown rendered to HTML and plain text together, Headers plus a signed link that needs no login, Static pages generated per issue. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. - 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.
# Delivery milestones · Buttondown Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Authentication, before anything to send A test message from your sending subdomain passes every check. ### Steps 1. Pick a sending subdomain So a reputation problem does not affect ordinary mail from your main domain. Files: `docs/sending.md` 2. Configure SPF, DKIM and DMARC with alignment Alignment is the part people miss: the visible From domain must match what SPF or DKIM authenticated. 3. Verify from received headers Of a real test message. Reading your own DNS records proves only that you wrote them. ### Done when - [ ] Received headers show SPF, DKIM and DMARC passing - [ ] The visible From domain aligns with the authenticated domain - [ ] Sending uses a subdomain rather than the root domain - [ ] A deliberately broken signature is visibly rejected ### Watch 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. ## M2 · A list built on confirmation Everyone on the list asked to be, and you can prove when. ### Steps 1. Require confirmation A signed link, expiring. Pending addresses are never sent to. Files: `src/subscribe.py` 2. Record provenance Source, time and confirming address. This is what answers a complaint. Files: `src/subscribe.py` 3. Suppress on complaint or hard bounce Immediately and permanently. Sending again after a complaint is the fastest way to lose a domain. Files: `src/suppress.py` ### Done when - [ ] An address activates only after confirmation - [ ] Source and confirmation time are recorded - [ ] A complaint suppresses permanently - [ ] An import requires a consent assertion and records it ### Watch 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. ## M3 · Unsubscribe that works in one click Leaving takes one click with no login, from the header and from the footer. ### Steps 1. Add both unsubscribe headers The list header and the post variant. Clients need both to show their own control. Files: `src/headers.py` 2. Sign the footer link So it cannot be forged, and so it needs no login to work. Files: `src/unsubscribe.py` 3. Verify in a real client The header control specifically. It either appears or it does not, and you cannot tell without looking. ### Done when - [ ] A real client shows and honours its own unsubscribe control - [ ] The footer link works in one click with no login - [ ] Both take effect immediately - [ ] An unsubscribed address is never sent to again ### Watch 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. ## M4 · Composing and sending resumably An issue sends to the whole list, and a failure halfway does not send it twice. ### Steps 1. Render both parts HTML and plain text from one Markdown source. A missing text part looks worse to filters. Files: `src/render.py` 2. Create send rows before sending Then mark each. Resumption becomes trivial and duplicates become impossible. Files: `src/send.py` 3. Enforce uniqueness in the database On issue and subscriber. An application check races under retry. Files: `migrations/` ### Done when - [ ] One send row exists per recipient - [ ] Restarting mid-send resumes without resending - [ ] A duplicate send row fails at the database - [ ] Delivered messages carry both HTML and plain text ### Watch 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. ## M5 · Watching the numbers that matter You see complaints and bounces before they cost you the domain. ### Steps 1. Ingest provider events idempotently Deduplicated by the provider's event identifier, because redelivery is normal. Files: `src/events.py` 2. Compute complaint rate two ways Per issue and rolling. A single bad issue is a different signal from a slow decline. Files: `src/metrics.py` 3. Distinguish hard from soft bounces Hard suppresses; soft retries. Suppressing on a soft bounce loses real subscribers. Files: `src/bounces.py` ### Done when - [ ] Events are ingested and deduplicated - [ ] Complaint rate is computed per issue and rolling - [ ] An alert fires below the threshold - [ ] Hard bounces suppress and soft bounces do not ### Watch 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. ## M6 · Archive, export and the list being yours (production only) Issues are readable on the web and the list can leave with you. ### Steps 1. Generate the archive statically One page per issue, with metadata so a shared link previews properly. Files: `src/archive.py` 2. Export subscribers with provenance Source and timestamps. Without them the list is not portable in practice. Files: `src/export.py` 3. Test the import elsewhere Into another tool. An export nobody has imported is an assumption. ### Done when - [ ] Each issue has an archive page with preview metadata - [ ] Subscriber export includes consent provenance - [ ] Issue export renders the same content - [ ] The export has been imported elsewhere successfully ### Watch 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.
# Operations · Buttondown ## Backup Subscribers with their consent provenance are the irreplaceable asset, and the provenance is what makes them portable. Issues are content and belong in a backup too, though they are usually recoverable from the archive. ## Restore Restore and confirm suppressions came back before sending anything, because a restore that loses suppressions will send to people who complained and that is the worst possible first message after an outage. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Complaint rate per issue and rolling, bounce rate, and delivery event lag. A provider that stops sending events looks identical to a newsletter nobody is opening. ## Incident checklist If deliverability drops, check authentication first since a DNS change can break alignment silently, then complaint rate. Pause sending rather than continuing while you investigate, because each send with an elevated complaint rate compounds the problem. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Received headers confirm SPF, DKIM and DMARC passing with alignment - [ ] The mail client's own unsubscribe control is confirmed working - [ ] A killed send is confirmed to resume without resending - [ ] Suppression on complaint is confirmed permanent - [ ] The subscriber export has been imported into another tool ## Launch constraint Do not market omitted Buttondown capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers and sends. Personal data with consent records attached. DATABASE_URL=postgres://user:pass@localhost:5432/news # Required. The verified subdomain. Not the root domain, so reputation is isolated. SENDING_DOMAIN=mail.example.com # Required. Must align with the authenticated domain or DMARC fails. FROM_ADDRESS=letters@mail.example.com # Required · secret. Your sending provider's credential. PROVIDER_API_KEY=replace-with-provider-key # Required · secret. Signs unsubscribe links so they work without a login and cannot be forged. UNSUBSCRIBE_SECRET=generate-a-long-random-value # Required. Complaint rate at which an alert fires. Well below the limit, because the limit is where damage has already happened. COMPLAINT_ALERT_RATE=0.001 # Required. Recipients per batch. Smaller batches resume more finely after a failure. SEND_BATCH_SIZE=200
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Buttondown because newsletter operators pay for inbox placement, growth mechanics, and operational confidence on publication day. The recurring cost buys DNS authentication, reputation, bounces, complaints, unsubscribe compliance, templates, queues, analytics, backups, and support, not just the visible interface.
xexcellent deliverability, hosted simplicity, integrations, and creator-friendly support
xinbox-placement expertise
xadvanced automations
xcommerce integrations
xrecommendation and ad networks
Buttondown pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free first 100 subscribers | $0/workspace | $0/workspace | First 100 subscribers cost $0. |
| subscriber-based base plan | custom | — | Base cost is determined by subscriber calculator; exact public numeric schedule was not exposed in crawled text. |
| small add-ons | $9/workspace | — | $9/mo each: tagging/segmentation, paid subscriptions, comments/surveys, analytics, RSS-to-email, sponsorships, attachments. |
| medium add-ons | $29/workspace | — | $29/mo each: custom-domain archives, multiple newsletters, automations. |
| large add-ons | $79/workspace | — | $79/mo each: whitelabeling, teams. |
free tier$0 for the first 100 subscribers.
billingsubscriber-based calculator plus monthly add-ons; annual terms not verified
hidden costsCore capabilities are modular add-ons: $9/mo, $29/mo and $79/mo items can stack on top of subscriber pricing.
verified 2026-08-10 · source ↗
Vibecode Buttondown
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Buttondown replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Buttondown cost?
Buttondown costs about $9/month (Basic, checked 2026-07-31), which is $108 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Buttondown?
Honestly: excellent deliverability, hosted simplicity, integrations, and creator-friendly support; inbox-placement expertise; advanced automations; commerce integrations; recommendation and ad networks. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Buttondown?
Yes: Listmonk (Mature open-source newsletter and mailing-list manager.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.