Build Letterdrop
KINDAback to the verdict
A newsletter sender whose hard problems are an unsubscribe that is instant and impossible to bypass, sending exactly once per recipient per issue across crashes, and keeping bounces and complaints flowing back into suppression rather than into a dashboard nobody reads.
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 The worker and the send queue both live in the database, and the unique constraint on sends is the exactly-once guarantee.
Get it nodejs.org and postgresql.org, or containers for both. open ↗
Verify
node --version and psql --version print, and the schema applies cleanly - have readyaround ten pounds a year
Why SPF, DKIM and DMARC are records you have to publish, and without DNS access none of the authentication work in the last phase is possible.
Get it Any registrar, with access to the zone.
Verify
A TXT record you add resolves publicly within an hour - accountfree tier is usually enough below a few thousand messages a month
Why The feedback loop matters more than the send API, because bounces and complaints are what keep the suppression list correct and the domain deliverable.
Get it Any transactional or bulk provider that posts delivery, bounce and complaint events to a URL you own.
Verify
A test send produces a delivered event on your webhook, and a send to a known invalid address produces a bounce - have readyfree
Why The thresholds and header requirements in this build are copied from them rather than invented, and they have changed more than once.
Get it Read the current bulk sender pages, note the spam rate thresholds, the unsubscribe deadline and the authentication requirements, and record the date. open ↗
Verify
A note in the repository records the figures and the date checked - have readyfree
Why Rendering parity and the one-click header are only provable by receiving the message somewhere real, and clients differ more than any test suite suggests.
Get it Accounts at three providers, including at least one that reads plain text.
Verify
A test issue arrives at all three and the unsubscribe control appears in the client - decidefree
Why Both are asserted by tests and both pause or suppress real sends, so choosing them after seeing a bad issue means choosing whatever that issue did.
Get it Write down the complaint rate that pauses sending and how many soft bounces become a suppression.
Verify
Both appear in configuration and are named in tests
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, name, state, consent_source, consent_at, consent_ip, created_at) · state is subscribed, unsubscribed, bounced or complained - suppressions(email_normalised, reason, at, source) · unique on the normalised address, checked inside the send transaction - issues(id, title, markdown, html, text, state, scheduled_at, sent_at) · html and text are generated from the same markdown - sends(issue_id, subscriber_id, state, attempts, provider_message_id, sent_at, last_error) · unique on (issue_id, subscriber_id) - events(provider_message_id, kind, at, payload_excerpt) · kind is delivered, bounced, complained or deferred, from the provider's webhook - unsubscribe_tokens(subscriber_id, token_hash, issue_id, created_at) · hashed, per issue, and valid indefinitely because people unsubscribe from old mail - An address that reaches suppressions is never sent to again by any issue, including one already queued, because the check happens inside the transaction that claims the send row.
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:redacted@localhost:5432/letters | Subscribers, suppressions, issues, sends and events. |
SENDING_DOMAIN | required | mail.example.com | Checked for SPF, DKIM and DMARC at startup. Sending is refused without them. |
PROVIDER_API_KEYsecret | required | key-...redacted | The sending provider. Scoped to sending and webhook management only. |
WEBHOOK_SIGNING_SECRETsecret | required | whsec_...redacted | Verifies bounce and complaint callbacks. An unverified webhook can suppress anyone. |
PUBLIC_BASE_URL | required | https://letters.example.com | Builds the unsubscribe URL that goes in the List-Unsubscribe header. |
SEND_BATCH_SIZE | required | 50 | Rows claimed per transaction. Smaller batches make a crash cheaper. |
SOFT_BOUNCE_MAX | required | 3 | Soft bounces before the address is suppressed. |
COMPLAINT_RATE_PAUSE | required | 0.001 | Complaint rate per issue that pauses the next send until acknowledged. |
The build, in order
Consent, normalisation and a suppression list with teeth
Every address has a recorded consent and a suppressed address cannot be sent to by any path.
Not when queueing. The window between the two is where complaints come from.
Files
src/send/claim.tstests/suppression.test.tsOne subscriber, one address, whatever the CSV looked like.
Files
src/subscribers/normalise.tsA list with no recorded consent is the start of a reputation problem.
Files
src/subscribers/import.ts
done when · tick each as it passeswatch out- Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says.
One-click unsubscribe, done to the letter
A reader can leave from their mail client in one action and it takes effect immediately.
The mailto and the HTTPS URL, plus the One-Click post header.
Files
src/mail/headers.tstests/list-unsubscribe.test.tsPrefetchers follow links; they do not post forms.
Files
src/web/unsubscribe.tstests/prefetch.test.tsThe 48 hour allowance is a ceiling, not a design.
Files
src/subscribers/suppress.ts
done when · tick each as it passeswatch out- Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent.
Mail that renders where mail is read
One source produces an HTML part that survives mail clients and a text part that stands alone.
Two renderers, one truth, no tag stripping.
Files
src/render/html.tssrc/render/text.tstests/parity.test.tsMail clients discard stylesheet elements without telling anyone.
Files
src/render/inline.tsThe cheapest gate against a broken issue going to everyone.
Files
src/issues/schedule.ts
done when · tick each as it passeswatch out- Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has.
One message per person per issue
A send that crashes halfway resumes without sending anyone a second copy.
The unique constraint is what turns a restart into a resume.
Files
src/send/materialise.tstests/exactly-once.test.tsSmall batches so a crash costs seconds, not an issue.
Files
src/send/worker.tsA feedback loop that only writes to a dashboard is decorative.
Files
src/webhooks/provider.tstests/bounce.test.ts
done when · tick each as it passeswatch out- Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume.
Your domain, your reputation, your exitproduct builder
The sending domain is authenticated, the complaint rate is watched, and everything is exportable.
A missing DKIM record is discovered at send time otherwise.
Files
src/startup/dns-check.tstests/dns-check.test.tsA threshold that pauses beats a chart that informs.
Files
src/reputation/rate.tsThe cheapest protection against someone else's address on your list.
Files
src/web/subscribe.ts
done when · tick each as it passeswatch out- Reputation damage arrives as a gradual drop in delivery rather than an error, so an issue that quietly lands in spam for half your readers looks identical to one that landed fine. Compute the complaint rate per issue from the webhook events, compare it to the threshold you wrote down, and pause the next send automatically rather than leaving it to a person to notice.
That is the whole plan for Letterdrop. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Running a mail server.
- Open tracking pixels.
- Behavioural automation and large-scale segmentation.
- Paid subscriptions and recommendation networks.
- large-scale segmentation and automation
- payments, recommendations, and audience network
- sender reputation, deliverability, and suppression handling
- sender reputation and deliverability operations
- bounce, complaint, and suppression handling
- Simple segments over recorded fields, once suppression and exactly-once sending have survived a few issues
- A public archive page per issue, which reuses the rendered HTML and adds no sending risk
- Click tracking as an explicit per-issue choice, with the rewritten links visible in the preview
Need the files? The project pack on the verdict page hands your agent the whole brief · more newsletters.