Build SendFox
KINDAback to the verdict
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.
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 web process and the sending worker.
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 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 - accountfree 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 - 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 - 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 - 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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URL | required | postgres://localhost:5432/newsletter | Subscribers, consent events, suppressions and sends. |
EMAIL_PROVIDER_KEYsecret | required | sk-...redacted | Sending scope only. Rotate it without touching the database. |
WEBHOOK_SIGNING_SECRETsecret | required | whsec-...redacted | Verifies delivery webhooks. An unverified endpoint is an open suppression list. |
SENDING_DOMAIN | required | mail.example.com | The subdomain carrying DKIM, checked at startup. |
FROM_ADDRESS | required | letters@mail.example.com | Must align with SPF or DKIM for DMARC to pass. |
SEND_RATE_PER_SECOND | required | 10 | Taken from the provider's published limit for your account, with the date recorded. |
COMPLAINT_RATE_CEILING | required | 0.003 | Blocks a new broadcast above this recent rate. Chosen against the published threshold. |
SOFT_BOUNCE_THRESHOLD | required | 4 | Consecutive soft bounces before an address is suppressed. |
The build, in order
Consent you could show someone
Every confirmed subscriber has a trail explaining how they got there.
The question later is not whether they consented but how you know.
Files
src/consent/events.tsmigrations/002_consent.sqlA replayable confirmation link is a confirmation anyone can perform.
Files
src/consent/confirm.tstests/token-replay.test.tsAggressive normalisation merges two real people into one.
Files
src/email/normalise.tstests/normalise.test.ts
done when · tick each as it passeswatch 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.
Unsubscribe the way mail clients actually do it
One click in the mail client removes the person, and nothing else can.
RFC 8058 needs the POST header as well as the address, and clients check for it.
Files
src/mail/headers.tsPrefetchers issue GET requests, and a list can empty overnight.
Files
src/web/routes/unsubscribe.tstests/get-is-safe.test.tsUnsubscribes arrive during a send, not politely before it.
Files
src/send/worker.ts
done when · tick each as it passeswatch 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.
Sending each person exactly once
A crash, a retry or a double click produces one message per subscriber.
The database is the thing that makes exactly-once true.
Files
migrations/003_sends.sqlsrc/send/materialise.tsNot after, and not in the same transaction as the call.
Files
src/send/worker.tstests/exactly-once.test.tsResending on a timeout is the duplicate-send bug wearing a different hat.
Files
src/send/reconcile.ts
done when · tick each as it passeswatch 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.
Bounces and complaints, handled before they cost you
A hard bounce or a complaint suppresses the address without being told twice.
Providers redeliver, and an unsigned endpoint is an open suppression list.
Files
src/web/routes/webhook.tstests/webhook-idempotent.test.tsRetrying a dead mailbox forever is a reputation cost with no upside.
Files
src/delivery/classify.tsThe number that matters is the one the receiving provider is watching.
Files
src/send/gate.ts
done when · tick each as it passeswatch 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.
Authentication, and a list that is not trappedproduct builder
The domain is set up correctly and everything can be taken out with its consent trail.
Sending without DKIM is the fastest route to the spam folder.
Files
src/dns/check.tstests/auth-records.test.tsA list without its consent trail is a list you cannot lawfully use elsewhere.
Files
src/export/subscribers.tsOtherwise the next import undoes someone's decision to leave.
Files
src/privacy/delete.tstests/delete-keeps-suppression.test.ts
done when · tick each as it passeswatch out- Deleting a subscriber on request also deletes the record that they unsubscribed, so the next import quietly adds them back and they receive the newsletter they explicitly left, which is the complaint that damages a domain most. Delete the person and keep a suppression keyed on a hash of the address, and say so in the privacy note.
That is the whole plan for SendFox. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Your own mail server or IP reputation.
- Paid subscriptions and recommendation networks.
- Recipient-side behavioural tracking.
- Any deliverability score you invented.
- payments, recommendations, and audience network
- sender reputation, deliverability, and suppression handling
- sender reputation and deliverability operations
- bounce, complaint, and suppression handling
- large-scale segmentation and automation
- A simple automation of two or three timed messages, reusing the send rows and the suppression check unchanged
- A plain-text archive page per broadcast, which also gives the unsubscribe link a second home
- Seed testing across a handful of mailboxes before each send, reported as rendering rather than as a placement score
Need the files? The project pack on the verdict page hands your agent the whole brief · more newsletters.