Build Loops

KINDAreplaces $49/mosaves $588/yrback 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 small lifecycle email sender whose hard problems are proving consent for every address, making the suppression list impossible to bypass, and resuming a campaign that crashed halfway without mailing the first half twice.

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

SendingOne reputable provider's API from a domain you authenticateStoragePostgreSQL, with consent and suppression as first-class tablesTemplatesOne source compiled to both HTML and a plain text alternativeEventsAn ingest endpoint idempotent on the sender's own event identifierFeedbackThe provider's bounce and complaint webhook, signature verified, feeding suppressionQueueOne message row per recipient with an idempotency key, never an in-memory loop

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, the send loop and the tests.

    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 Consent, suppression and message rows all live here, and the resumability of a campaign is a property of the schema.

    Get it Your package manager, or a small managed instance.

    Verify A suppressed address is rejected by every send path in the test suite

  3. free at small volume with most providers

    Why Deliverability is the part you are not building, so it is bought rather than written, and the domain authentication is a prerequisite rather than a step.

    Get it Any reputable sending provider, then complete their domain authentication and enable their bounce and complaint webhook. open ↗

    Verify A message sent through them passes authentication at a third-party inbox and a bounce webhook arrives for a known bad address

  4. have readyfree if you own the domain

    Why SPF, DKIM and DMARC are records you publish, and without DNS access this build cannot reach its first done-when condition.

    Get it Access to the zone for the domain you will send from, ideally a subdomain reserved for mail.

    Verify All three records resolve and alignment is confirmed from a received header

  5. have readyfree

    Why Authentication results and folder placement differ by receiver, and a local test tells you nothing about either.

    Get it An address at three large consumer providers, kept separate from your real mail.

    Verify A test campaign arrives at all three and the headers are readable

  6. decidefree

    Why Each is asserted somewhere in the build, and deciding them after a reputation problem means deciding them too late.

    Get it Write down the volume ramp for the first fortnight, how many soft bounces suppress an address, and how long consent and message history are kept.

    Verify All three appear in configuration and are named in the tests or the README

Data model

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

- contacts(id, email_normalised, email_original, created_at, source, properties_json, state)
- consent(contact_id, at, method, ip, user_agent, wording_shown, list_id) · the evidence, kept as long as you mail them and a while after
- suppressions(email_normalised, reason, at, source, permanent) · unsubscribe, hard bounce, complaint or manual, checked by every send path
- lists(id, name, description_shown_at_signup) · what they were told they were joining, because that is what consent was given to
- workflows(id, name, trigger_event, steps_json, active) and workflow_state(contact_id, workflow_id, step_index, next_due_at, entered_at, exited_at, exit_reason)
- messages(id, contact_id, workflow_id, campaign_id, provider_message_id, state, idempotency_key, queued_at, sent_at, delivered_at, bounced_at, complained_at)
- events(id, external_id, contact_id, name, at, properties_json) · unique on external_id, because the same event will arrive twice
- The suppression check is a join every send path performs, and a test asserts that no recipient query exists without it.

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/mailContacts, consent, suppressions, messages. The consent table is the one you cannot lose.
EMAIL_PROVIDER_API_KEYsecretrequiredsk-...redactedScoped to sending only if the provider allows it.
SENDING_DOMAINrequiredmail.example.comA subdomain reserved for this, so a reputation problem does not touch your main domain.
FROM_ADDRESSrequiredhello@mail.example.comMust align with the DKIM signing domain for DMARC to pass.
POSTAL_ADDRESSrequired1 Example Street, Example Town, EX1 1EXRendered in every marketing footer. Required in several jurisdictions.
UNSUBSCRIBE_BASE_URLrequiredhttps://mail.example.com/uLinks in old messages resolve here, so it must outlive any campaign.
WEBHOOK_SIGNING_SECRETsecretrequiredsk-...redactedVerified before the bounce or complaint payload is parsed.
COMPLAINT_RATE_ALERTrequired0.001Alert well under the published threshold, because by the time you reach it delivery has already suffered.

The build, in order

  1. An authenticated domain and a message that passes the checks

    Mail is authenticated, unsubscribable in one action, and proved against real inboxes before a list sees it.

    1. Alignment, not merely presence, is what a receiver checks.

      Files docs/dns-setup.mdscripts/check-auth.ts

    2. Two sources means two messages and one of them is wrong.

      Files src/templates/compile.tstests/text-part.test.ts

    3. A seed at three providers tells you more than any local test.

      Files docs/seed-list.md

    done when · tick each as it passes
    watch out
    • Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening.
  2. Consent you can produce, suppression you cannot bypass

    No address is mailed without an evidence row, and no suppressed address is mailed at all.

    1. Consent is to something specific, and that something changes.

      Files migrations/002_consent.sqlsrc/signup/record.ts

    2. No login, no confirmation step, no second request.

      Files src/unsubscribe/route.tstests/oneclick.test.ts

    3. One untested path is the one that will mail them.

      Files tests/suppression.test.ts

    done when · tick each as it passes
    watch out
    • An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after.
  3. Events in, workflow out, once per contact

    A redelivered event does not restart a workflow or send a second message.

    1. The cheapest protection against a redelivered webhook.

      Files migrations/003_events.sqlsrc/ingest/route.ts

    2. So a restart resumes rather than replays.

      Files src/workflow/state.ts

    3. Not at entry, where it will be hours or days out of date.

      Files src/send/guard.tstests/mid-workflow-unsub.test.ts

    done when · tick each as it passes
    watch out
    • Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time.
  4. Sending a campaign that can crash

    A campaign restarted after a crash sends only what it had not sent.

    1. The campaign becomes resumable by construction.

      Files src/campaign/materialise.ts

    2. One claim, one send, no ambiguity after a crash.

      Files src/campaign/send.tstests/resume.test.ts

    3. A fixed sleep is either too slow or a rate limit ban.

      Files src/provider/client.ts

    done when · tick each as it passes
    watch out
    • Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction.
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.