Build Letterdrop

KINDAback to the verdict

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

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

RuntimeTypeScript on Node 22 with one worker and a queue in the databaseStoragePostgreSQL, with suppression enforced by a constraint and checked at send timeSendingOne provider behind one send function, chosen for its bounce and complaint webhooksRenderingInline CSS at build time, with a plain-text alternative generated from the same sourceTrackingOff by default, and open tracking absent entirely

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

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

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

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

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

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

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://user:redacted@localhost:5432/lettersSubscribers, suppressions, issues, sends and events.
SENDING_DOMAINrequiredmail.example.comChecked for SPF, DKIM and DMARC at startup. Sending is refused without them.
PROVIDER_API_KEYsecretrequiredkey-...redactedThe sending provider. Scoped to sending and webhook management only.
WEBHOOK_SIGNING_SECRETsecretrequiredwhsec_...redactedVerifies bounce and complaint callbacks. An unverified webhook can suppress anyone.
PUBLIC_BASE_URLrequiredhttps://letters.example.comBuilds the unsubscribe URL that goes in the List-Unsubscribe header.
SEND_BATCH_SIZErequired50Rows claimed per transaction. Smaller batches make a crash cheaper.
SOFT_BOUNCE_MAXrequired3Soft bounces before the address is suppressed.
COMPLAINT_RATE_PAUSErequired0.001Complaint rate per issue that pauses the next send until acknowledged.

The build, in order

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

    1. Not when queueing. The window between the two is where complaints come from.

      Files src/send/claim.tstests/suppression.test.ts

    2. One subscriber, one address, whatever the CSV looked like.

      Files src/subscribers/normalise.ts

    3. A list with no recorded consent is the start of a reputation problem.

      Files src/subscribers/import.ts

    done when · tick each as it passes
    watch 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.
  2. One-click unsubscribe, done to the letter

    A reader can leave from their mail client in one action and it takes effect immediately.

    1. The mailto and the HTTPS URL, plus the One-Click post header.

      Files src/mail/headers.tstests/list-unsubscribe.test.ts

    2. Prefetchers follow links; they do not post forms.

      Files src/web/unsubscribe.tstests/prefetch.test.ts

    3. The 48 hour allowance is a ceiling, not a design.

      Files src/subscribers/suppress.ts

    done when · tick each as it passes
    watch 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.
  3. Mail that renders where mail is read

    One source produces an HTML part that survives mail clients and a text part that stands alone.

    1. Two renderers, one truth, no tag stripping.

      Files src/render/html.tssrc/render/text.tstests/parity.test.ts

    2. Mail clients discard stylesheet elements without telling anyone.

      Files src/render/inline.ts

    3. The cheapest gate against a broken issue going to everyone.

      Files src/issues/schedule.ts

    done when · tick each as it passes
    watch 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.
  4. One message per person per issue

    A send that crashes halfway resumes without sending anyone a second copy.

    1. The unique constraint is what turns a restart into a resume.

      Files src/send/materialise.tstests/exactly-once.test.ts

    2. Small batches so a crash costs seconds, not an issue.

      Files src/send/worker.ts

    3. A feedback loop that only writes to a dashboard is decorative.

      Files src/webhooks/provider.tstests/bounce.test.ts

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