Build SendFox

KINDAback to the verdict

0%0 of 18 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 broadcast sender whose hard problems are holding consent as evidence rather than a flag, making one-click unsubscribe work the way the mail clients actually call it, sending each person exactly once through a crash, and suppressing a bounce before it costs you the domain.

estimated effort multi-daythe files for this build are in the project pack

RuntimeTypeScript on Node 22, a web process and a sending workerStoragePostgreSQL with a unique constraint per broadcast and subscriberDeliveryOne reputable provider you supply a key for, never your own mail serverConsentAn append-only events table with evidence per eventSuppressionChecked inside the send transaction, never only at list build time

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 web process and the sending worker.

    Get it nodejs.org, or nvm install 22. open ↗

    Verify node --version prints v22 or higher

  2. installfree locally, a few pounds a month managed

    Why The unique constraint on broadcast and subscriber is what makes exactly-once real rather than hoped for.

    Get it Docker, your package manager, or a managed instance. open ↗

    Verify psql connects and the migration applies

  3. free tier at low volume, then per thousand messages

    Why Reputation, feedback loops and blocklist handling are their job, and taking them on yourself is a permanent second project.

    Get it Sign up with a provider that publishes delivery webhooks and a rate limit, and create a key scoped to sending.

    Verify A single test message is delivered and a webhook arrives

  4. have readythe price of a domain you probably already own

    Why SPF, DKIM and DMARC all live in DNS, and sending from a subdomain keeps the main domain's reputation separate.

    Get it Add the provider's DKIM records on a subdomain such as mail.example.com and publish a DMARC record.

    Verify The provider reports the domain verified and a delivered message passes all three

  5. have readyfree

    Why One-click unsubscribe can only be verified by pressing the button in a real client, and the headers render differently between them.

    Get it A free mailbox at each of two large consumer providers.

    Verify Both receive a test broadcast and both show the client's own unsubscribe control

  6. decidefree

    Why The thresholds and required headers are set by the receiving providers and they change, so the version you built against needs recording.

    Get it Read the sender guidelines of the providers your list actually uses and note the date. open ↗

    Verify The README names the requirements and the date they were read

Data model

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

- subscribers(id, email_normalised, email_display, state, created_at, confirmed_at, unsubscribed_at, source_form_id) · state is unconfirmed, confirmed, unsubscribed or suppressed
- consent_events(id, subscriber_id, at, kind, evidence_json) · kind is signup, confirm, unsubscribe or complaint, and evidence holds the form, the page and the confirmation token used
- suppressions(email_normalised, reason, at, source) · permanent, keyed on the normalised address, checked at send
- broadcasts(id, subject, html_body, text_body, audience_json, state, scheduled_at, sent_count, created_at)
- sends(broadcast_id, subscriber_id, state, provider_message_id, sent_at, last_error) · unique on the pair, which is the exactly-once guarantee
- delivery_events(provider_message_id, kind, at, payload_excerpt) · delivered, bounced, complained, keyed for idempotent webhook handling
- unsubscribe_tokens(token, subscriber_id, broadcast_id, created_at) · unguessable, scoped to a person and a broadcast, never expiring

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_URLrequiredpostgres://localhost:5432/newsletterSubscribers, consent events, suppressions and sends.
EMAIL_PROVIDER_KEYsecretrequiredsk-...redactedSending scope only. Rotate it without touching the database.
WEBHOOK_SIGNING_SECRETsecretrequiredwhsec-...redactedVerifies delivery webhooks. An unverified endpoint is an open suppression list.
SENDING_DOMAINrequiredmail.example.comThe subdomain carrying DKIM, checked at startup.
FROM_ADDRESSrequiredletters@mail.example.comMust align with SPF or DKIM for DMARC to pass.
SEND_RATE_PER_SECONDrequired10Taken from the provider's published limit for your account, with the date recorded.
COMPLAINT_RATE_CEILINGrequired0.003Blocks a new broadcast above this recent rate. Chosen against the published threshold.
SOFT_BOUNCE_THRESHOLDrequired4Consecutive soft bounces before an address is suppressed.

The build, in order

  1. Consent you could show someone

    Every confirmed subscriber has a trail explaining how they got there.

    1. The question later is not whether they consented but how you know.

      Files src/consent/events.tsmigrations/002_consent.sql

    2. A replayable confirmation link is a confirmation anyone can perform.

      Files src/consent/confirm.tstests/token-replay.test.ts

    3. Aggressive normalisation merges two real people into one.

      Files src/email/normalise.tstests/normalise.test.ts

    done when · tick each as it passes
    watch out
    • Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified.
  2. Unsubscribe the way mail clients actually do it

    One click in the mail client removes the person, and nothing else can.

    1. RFC 8058 needs the POST header as well as the address, and clients check for it.

      Files src/mail/headers.ts

    2. Prefetchers issue GET requests, and a list can empty overnight.

      Files src/web/routes/unsubscribe.tstests/get-is-safe.test.ts

    3. Unsubscribes arrive during a send, not politely before it.

      Files src/send/worker.ts

    done when · tick each as it passes
    watch out
    • Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both.
  3. Sending each person exactly once

    A crash, a retry or a double click produces one message per subscriber.

    1. The database is the thing that makes exactly-once true.

      Files migrations/003_sends.sqlsrc/send/materialise.ts

    2. Not after, and not in the same transaction as the call.

      Files src/send/worker.tstests/exactly-once.test.ts

    3. Resending on a timeout is the duplicate-send bug wearing a different hat.

      Files src/send/reconcile.ts

    done when · tick each as it passes
    watch out
    • Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again.
  4. Bounces and complaints, handled before they cost you

    A hard bounce or a complaint suppresses the address without being told twice.

    1. Providers redeliver, and an unsigned endpoint is an open suppression list.

      Files src/web/routes/webhook.tstests/webhook-idempotent.test.ts

    2. Retrying a dead mailbox forever is a reputation cost with no upside.

      Files src/delivery/classify.ts

    3. The number that matters is the one the receiving provider is watching.

      Files src/send/gate.ts

    done when · tick each as it passes
    watch out
    • Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send.
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.