Build Bento
KINDAback to the verdict
A newsletter sender whose hard problems are making suppression impossible to bypass, freezing the audience at send time so a running campaign cannot double-send or skip people, and reporting numbers that are honest about what modern mail clients actually do.
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 and the send 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 Suppression and the recipient freeze rely on transactions and unique constraints doing their job.
Get it Your package manager, a container, or a small managed instance. open ↗
Verify
psql connects and the migrations apply - accountfree tiers exist at low volume
Why Sending, bounce and complaint events all come from here, and suppression is only current because the webhooks arrive.
Get it Create an account, verify a sending domain, and configure the webhook endpoint with a signing secret.
Verify
A test send delivers and its delivery event arrives signed - have readyfree beyond the domain
Why Mailbox providers treat unauthenticated bulk mail badly, and the current requirements are published by each provider and change.
Get it Set up the authentication records your provider documents, then read the current bulk sender requirements from the major mailbox providers and confirm you meet them.
Verify
Your provider reports the domain verified and a test message passes authentication checks at a mailbox you control - have readyfree
Why Suppression and reconciliation are testable locally, but rendering and delivery are only observable in a real inbox.
Get it Create a mailbox at two or three providers and add them to a test segment.
Verify
A campaign to the test segment arrives and its events appear
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, created_at, source, consent_text, consent_at, consent_ip_note) - suppressions(email_normalised primary key, reason, at, detail) · unsubscribed, hard bounced or complained - events(id, contact_id, kind, at, properties_json) · behaviour, for segmentation only - segments(id, name, definition_json, updated_at) - campaigns(id, name, subject, body_html, body_text, segment_id, state, frozen_at, sent_at) - recipients(campaign_id, contact_id, email_normalised, state, idempotency_key, provider_message_id, attempted_at, outcome) · written once when the audience freezes - deliveries(id, recipient_row, kind, at, provider_payload_excerpt) · delivered, bounced, complained, and opens kept separately and labelled
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_URLsecret | required | postgres://user:pass@localhost:5432/newsletter | Contacts, suppression, campaigns and recipients. |
EMAIL_PROVIDER_API_KEYsecret | required | key-...redacted | The only credential that can send. Rotate it if it ever reaches a log. |
EMAIL_WEBHOOK_SECRETsecret | required | whsec-...redacted | Every webhook is verified against this before it is parsed. |
SENDING_DOMAIN | required | mail.example.com | Verified with the provider, with authentication records in place. |
UNSUBSCRIBE_BASE_URL | required | https://mail.example.com/u | Per-recipient tokens are appended here. |
SOFT_BOUNCE_MAX_RETRIES | required | 3 | After this a soft bounce stops being retried and is recorded. |
SEND_RATE_PER_MINUTE | required | 200 | Below your provider's limit, checked against their current documentation. |
The build, in order
Contacts that remember what they agreed to
Every address carries where it came from and the exact wording it consented to.
Not as a later field to fill in, which nobody does.
Files
src/lib/import.tstests/import.test.tsTwo rows for one person is two copies of every campaign.
Files
src/lib/email.tsA not-null column outlives a code review.
Files
migrations/001_contacts.sql
done when · tick each as it passeswatch out- Importing a spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import.
Suppression that the sender cannot route around
A suppressed address cannot be sent to by any code path in the application.
The check has to happen at the last possible moment.
Files
src/worker/claim.sqltests/suppression.test.tsA form that asks why is a form that fails to unsubscribe people.
Files
src/app/u/[token]/route.tsThe bypass is always a script somebody wrote in a hurry.
Files
tests/suppression-bypass.test.ts
done when · tick each as it passeswatch out- Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled.
An audience frozen the moment you press send
Recipient rows are written once, so a campaign cannot double-send or skip.
This is the campaign's audience, forever.
Files
src/lib/freeze.tsThe cheapest protection against the double send.
Files
migrations/003_recipients.sqlA worker restart mid-campaign should be boring.
Files
tests/resume.test.ts
done when · tick each as it passeswatch out- Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once.
Handing messages to a provider, and hearing back
Each message is sent once, and bounces and complaints flow into suppression automatically.
An unverified webhook is an open write endpoint.
Files
src/app/api/webhook/route.tsProviders redeliver, sometimes for days.
Files
src/lib/events.tsNever by sending again.
Files
src/worker/reconcile.tstests/exactly-once.test.ts
done when · tick each as it passeswatch out- Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status.
Numbers that admit what they do not knowproduct builder
Reporting distinguishes what was delivered from what a mail client fetched.
With the reason printed next to the estimate.
Files
src/app/campaigns/[id]/report.tsxIn the validator, so it stays true when someone adds a feature.
Files
src/lib/segments.tstests/no-open-rules.test.tsDeleting the person must not un-suppress the address.
Files
src/lib/deletion.ts
done when · tick each as it passeswatch out- Building a re-engagement rule that deletes contacts who have not opened in six months quietly removes people whose mail client blocks the pixel entirely and keeps people whose client fetches it automatically, so the list gets worse while the dashboard says it got cleaner. Base rules on clicks, replies or purchases, and keep opens as a weak signal with a caption.
That is the whole plan for Bento. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Your own sending infrastructure.
- A drag and drop email designer.
- Branching automations and journeys.
- Payments, recommendations and an audience network.
- sender reputation and deliverability operations
- bounce, complaint, and suppression handling
- large-scale segmentation and automation
- payments, recommendations, and audience network
- sender reputation, deliverability, and suppression handling
- Click tracking on an opt-in basis, which is a better signal than opens and still not a perfect one
- A double opt-in flow, once consent storage has been used in anger for a while
- Scheduled sends, which reuse the freeze exactly and add only a clock
Need the files? The project pack on the verdict page hands your agent the whole brief · more newsletters.