Build Loops
KINDAreplaces $49/mosaves $588/yrback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the application, the send loop and the tests.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - 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 - accountfree 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 - 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 - 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 - 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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URL | required | postgres://localhost:5432/mail | Contacts, consent, suppressions, messages. The consent table is the one you cannot lose. |
EMAIL_PROVIDER_API_KEYsecret | required | sk-...redacted | Scoped to sending only if the provider allows it. |
SENDING_DOMAIN | required | mail.example.com | A subdomain reserved for this, so a reputation problem does not touch your main domain. |
FROM_ADDRESS | required | hello@mail.example.com | Must align with the DKIM signing domain for DMARC to pass. |
POSTAL_ADDRESS | required | 1 Example Street, Example Town, EX1 1EX | Rendered in every marketing footer. Required in several jurisdictions. |
UNSUBSCRIBE_BASE_URL | required | https://mail.example.com/u | Links in old messages resolve here, so it must outlive any campaign. |
WEBHOOK_SIGNING_SECRETsecret | required | sk-...redacted | Verified before the bounce or complaint payload is parsed. |
COMPLAINT_RATE_ALERT | required | 0.001 | Alert well under the published threshold, because by the time you reach it delivery has already suffered. |
The build, in order
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.
Alignment, not merely presence, is what a receiver checks.
Files
docs/dns-setup.mdscripts/check-auth.tsTwo sources means two messages and one of them is wrong.
Files
src/templates/compile.tstests/text-part.test.tsA seed at three providers tells you more than any local test.
Files
docs/seed-list.md
done when · tick each as it passeswatch 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.
Consent you can produce, suppression you cannot bypass
No address is mailed without an evidence row, and no suppressed address is mailed at all.
Consent is to something specific, and that something changes.
Files
migrations/002_consent.sqlsrc/signup/record.tsNo login, no confirmation step, no second request.
Files
src/unsubscribe/route.tstests/oneclick.test.tsOne untested path is the one that will mail them.
Files
tests/suppression.test.ts
done when · tick each as it passeswatch 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.
Events in, workflow out, once per contact
A redelivered event does not restart a workflow or send a second message.
The cheapest protection against a redelivered webhook.
Files
migrations/003_events.sqlsrc/ingest/route.tsSo a restart resumes rather than replays.
Files
src/workflow/state.tsNot 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 passeswatch 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.
Sending a campaign that can crash
A campaign restarted after a crash sends only what it had not sent.
The campaign becomes resumable by construction.
Files
src/campaign/materialise.tsOne claim, one send, no ambiguity after a crash.
Files
src/campaign/send.tstests/resume.test.tsA fixed sleep is either too slow or a rate limit ban.
Files
src/provider/client.ts
done when · tick each as it passeswatch 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.
Bounces, complaints and the number that decides everythingproduct builder
Feedback from the receiving world changes what you send next, without anybody reading a log.
An unverified webhook is an open write endpoint.
Files
src/webhooks/verify.tstests/webhook-sig.test.tsImmediately, permanently, and visibly on the contact.
Files
src/feedback/apply.tsThe address stays suppressed after the person is gone.
Files
src/privacy/erase.tstests/erase-keeps-suppression.test.ts
done when · tick each as it passeswatch out- Honouring a deletion request by removing the suppression row along with the contact means the next import puts that address straight back on the list and mails somebody who told you to stop, which is worse than not honouring the deletion at all. Keep the suppression keyed on the normalised address after the personal fields are gone, and say so plainly in your privacy notice.
That is the whole plan for Loops. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Purchased or appended lists.
- An audience network and cross-promotion.
- Your own mail server.
- Behavioural segmentation at scale.
- bounce, complaint, and suppression handling
- large-scale segmentation and automation
- payments, recommendations, and audience network
- sender reputation, deliverability, and suppression handling
- sender reputation and deliverability operations
- A second workflow trigger from a product event, which the idempotent ingest already supports
- Link click recording with the caveat that opens will not be reported, once the complaint rate has been stable for a month
- A plain digest of list health by consent source, which the tables already contain everything for
Need the files? The project pack on the verdict page hands your agent the whole brief · more newsletters.