Build Bento

KINDAback to the verdict

0%0 of 17 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 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.

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

RuntimeNext.js 15 and TypeScript with a separate send worker processStoragePostgreSQL with suppression enforced in the send querySendingOne user-supplied provider behind one interface, with a per-recipient idempotency keyEventsProvider webhooks with signature verification and replay toleranceAudienceFrozen to rows at send time, never recomputed mid-send

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 and the send 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 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

  3. free 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

  4. 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

  5. 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.

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://user:pass@localhost:5432/newsletterContacts, suppression, campaigns and recipients.
EMAIL_PROVIDER_API_KEYsecretrequiredkey-...redactedThe only credential that can send. Rotate it if it ever reaches a log.
EMAIL_WEBHOOK_SECRETsecretrequiredwhsec-...redactedEvery webhook is verified against this before it is parsed.
SENDING_DOMAINrequiredmail.example.comVerified with the provider, with authentication records in place.
UNSUBSCRIBE_BASE_URLrequiredhttps://mail.example.com/uPer-recipient tokens are appended here.
SOFT_BOUNCE_MAX_RETRIESrequired3After this a soft bounce stops being retried and is recorded.
SEND_RATE_PER_MINUTErequired200Below your provider's limit, checked against their current documentation.

The build, in order

  1. Contacts that remember what they agreed to

    Every address carries where it came from and the exact wording it consented to.

    1. Not as a later field to fill in, which nobody does.

      Files src/lib/import.tstests/import.test.ts

    2. Two rows for one person is two copies of every campaign.

      Files src/lib/email.ts

    3. A not-null column outlives a code review.

      Files migrations/001_contacts.sql

    done when · tick each as it passes
    watch 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.
  2. Suppression that the sender cannot route around

    A suppressed address cannot be sent to by any code path in the application.

    1. The check has to happen at the last possible moment.

      Files src/worker/claim.sqltests/suppression.test.ts

    2. A form that asks why is a form that fails to unsubscribe people.

      Files src/app/u/[token]/route.ts

    3. The bypass is always a script somebody wrote in a hurry.

      Files tests/suppression-bypass.test.ts

    done when · tick each as it passes
    watch 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.
  3. An audience frozen the moment you press send

    Recipient rows are written once, so a campaign cannot double-send or skip.

    1. This is the campaign's audience, forever.

      Files src/lib/freeze.ts

    2. The cheapest protection against the double send.

      Files migrations/003_recipients.sql

    3. A worker restart mid-campaign should be boring.

      Files tests/resume.test.ts

    done when · tick each as it passes
    watch 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.
  4. Handing messages to a provider, and hearing back

    Each message is sent once, and bounces and complaints flow into suppression automatically.

    1. An unverified webhook is an open write endpoint.

      Files src/app/api/webhook/route.ts

    2. Providers redeliver, sometimes for days.

      Files src/lib/events.ts

    3. Never by sending again.

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

    done when · tick each as it passes
    watch 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.
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.