Build Buttondown

YESreplaces $9/mosaves $108/yrback to the verdict

0%0 of 20 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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-daythe files for this build are in the project pack

RuntimePython 3.12 with PostgreSQLSendingA transactional or bulk provider with a verified sending subdomainCompositionMarkdown rendered to HTML and plain text togetherUnsubscribeHeaders plus a signed link that needs no loginArchiveStatic pages generated per issue

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the application and the send loop.

    Get it python.org or your package manager, then a virtual environment.

    Verify python3 --version prints 3.12 or higher

  2. installfree

    Why Subscribers and sends need a real database, and the send loop depends on a unique constraint.

    Get it Install locally or run it in Docker. open ↗

    Verify psql connects and reports 16 or higher

  3. have readyroughly 10 to 15 US dollars a year

    Why Authentication records are DNS, and sending from a subdomain keeps a reputation problem away from your ordinary mail.

    Get it Any registrar with DNS access. Decide the subdomain before configuring anything, since changing it later means rebuilding reputation.

    Verify Records resolve and a test message authenticates

  4. API keyfree tiers typically cover a few thousand messages a month

    Why Reputation, feedback loops and event webhooks all come from here, and building them yourself is the project this avoids.

    Get it Choose a provider, verify your sending subdomain with the DNS records they specify, and confirm they deliver complaint and bounce events by webhook.

    Verify A test message sends and a delivery event arrives at your endpoint

  5. have readyfree

    Why Authentication and unsubscribe both behave differently per provider, and checking one tells you very little.

    Get it Create or use accounts at three major providers. You will inspect headers and check the client unsubscribe control in each.

    Verify A test message has been inspected in all three

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- subscribers(id, email, state, subscribed_at, confirmed_at, unsubscribed_at, source, tags_json) · state is pending, active, unsubscribed or bounced
- issues(id, subject, markdown, html, text, status, scheduled_for, sent_at, archive_slug)
- sends(id, issue_id, subscriber_id, provider_message_id, state, sent_at, delivered_at, opened_at, complained_at, bounced_at)
- suppressions(email, reason, at) · complaints and hard bounces, never sent to again
- events(id, provider_event_id, type, payload_json, received_at) · idempotency, because providers redeliver
- A complaint or a hard bounce writes a suppression immediately. Sending to a suppressed address again is the single fastest way to lose a sending reputation.

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://user:pass@localhost:5432/newsSubscribers and sends. Personal data with consent records attached.
SENDING_DOMAINrequiredmail.example.comThe verified subdomain. Not the root domain, so reputation is isolated.
FROM_ADDRESSrequiredletters@mail.example.comMust align with the authenticated domain or DMARC fails.
PROVIDER_API_KEYsecretrequiredreplace-with-provider-keyYour sending provider's credential.
UNSUBSCRIBE_SECRETsecretrequiredgenerate-a-long-random-valueSigns unsubscribe links so they work without a login and cannot be forged.
COMPLAINT_ALERT_RATErequired0.001Complaint rate at which an alert fires. Well below the limit, because the limit is where damage has already happened.
SEND_BATCH_SIZErequired200Recipients per batch. Smaller batches resume more finely after a failure.

The build, in order

  1. Authentication, before anything to send

    A test message from your sending subdomain passes every check.

    1. So a reputation problem does not affect ordinary mail from your main domain.

      Files docs/sending.md

    2. Alignment is the part people miss: the visible From domain must match what SPF or DKIM authenticated.

    3. Of a real test message. Reading your own DNS records proves only that you wrote them.

    done when · tick each as it passes
    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.
  2. A list built on confirmation

    Everyone on the list asked to be, and you can prove when.

    1. A signed link, expiring. Pending addresses are never sent to.

      Files src/subscribe.py

    2. Source, time and confirming address. This is what answers a complaint.

      Files src/subscribe.py

    3. Immediately and permanently. Sending again after a complaint is the fastest way to lose a domain.

      Files src/suppress.py

    done when · tick each as it passes
    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.
  3. Unsubscribe that works in one click

    Leaving takes one click with no login, from the header and from the footer.

    1. The list header and the post variant. Clients need both to show their own control.

      Files src/headers.py

    2. So it cannot be forged, and so it needs no login to work.

      Files src/unsubscribe.py

    3. The header control specifically. It either appears or it does not, and you cannot tell without looking.

    done when · tick each as it passes
    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.
  4. Composing and sending resumably

    An issue sends to the whole list, and a failure halfway does not send it twice.

    1. HTML and plain text from one Markdown source. A missing text part looks worse to filters.

      Files src/render.py

    2. Then mark each. Resumption becomes trivial and duplicates become impossible.

      Files src/send.py

    3. On issue and subscriber. An application check races under retry.

      Files migrations/

    done when · tick each as it passes
    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.
  5. Watching the numbers that matter

    You see complaints and bounces before they cost you the domain.

    1. Deduplicated by the provider's event identifier, because redelivery is normal.

      Files src/events.py

    2. Per issue and rolling. A single bad issue is a different signal from a slow decline.

      Files src/metrics.py

    3. Hard suppresses; soft retries. Suppressing on a soft bounce loses real subscribers.

      Files src/bounces.py

    done when · tick each as it passes
    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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more newsletters.