Build Mailcoach Cloud

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 proving consent for every address, sending a large campaign exactly once per subscriber through crashes and restarts, and reporting figures that survive being questioned.

estimated effort weekendthe files for this build are in the project pack

RuntimeNext.js 15 with TypeScript, plus a worker process for sendingStoragePostgreSQL, with per-recipient send state as a rowDeliveryOne reputable provider the user supplies, behind one send functionSuppressionA permanent table checked before every single sendReportingEvents recorded per recipient, with opens labelled for what they are

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 sending 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 The per-recipient send state and its transactional locking is the correctness story, and this is where it lives.

    Get it Your package manager, a container, or a managed instance. open ↗

    Verify psql connects and the migrations apply

  3. API keyfree tiers exist, then per thousand messages

    Why You are not running a mail server, and the provider is also the source of the delivery notifications that drive suppression.

    Get it Create an account with a transactional or bulk provider, verify a sending domain, and generate an API key.

    Verify A test send arrives and a delivery notification reaches your endpoint

  4. have readythe cost of a domain

    Why Mail from an unauthenticated domain is filtered before anyone can decide whether they want it, and the records have to exist before the first real send.

    Get it Follow your provider's domain verification steps and publish the records they specify at your DNS host.

    Verify The provider reports the domain verified and a test message passes authentication checks at a major receiver

  5. have readyfree

    Why Rendering and the open-tracking caveat are only real when you have watched them on the clients your subscribers actually use.

    Get it Create an address on each of two or three clients and subscribe them to a test list.

    Verify A preview send arrives and renders on each

  6. decidefree

    Why How many class 4 attempts before suppression, and which address variants count as the same person, are both decisions that change who receives mail and must be written before the first campaign.

    Get it Write both down with the reasoning.

    Verify Both are in configuration and 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, subscribed_at, source, source_detail, consent_ip, consent_user_agent, unsubscribed_at)
- lists(id, name, from_name, from_email, reply_to, postal_address) · the sender identity, per list
- campaigns(id, list_id, subject, html_body, text_body, state, scheduled_for, started_at, finished_at)
- sends(campaign_id, subscriber_id, state, attempts, provider_message_id, accepted_at, last_error, updated_at) · state is queued, sending, accepted, failed or suppressed
- suppressions(email_normalised, reason, enhanced_status, first_seen_at, source_campaign_id, permanent) · reason is bounce, complaint or manual
- events(campaign_id, subscriber_id, kind, at, detail_json) · delivery, bounce, complaint, click, and open recorded separately and labelled
- imports(id, filename, rows_total, rows_accepted, rows_rejected, report_json, imported_at) · with the rejection reason per row
- The sends table is the lock: a row moves to sending in its own transaction before the provider is called, so a restart cannot mail the same person twice.

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/mailSubscribers, campaigns, sends, events and suppressions.
EMAIL_PROVIDER_API_KEYsecretrequiredkey-...redactedThe provider that actually delivers. Scope it to sending only.
WEBHOOK_SIGNING_SECRETsecretrequiredwhsec-...redactedVerifies incoming delivery notifications. An unverified notification must never change suppression.
SEND_BATCH_SIZErequired100Rows claimed per worker pass. Smaller means less to reconcile after a crash.
SEND_RATE_PER_MINUTErequired600Kept under the provider's published limit, which you should check rather than assume.
SENDING_STALE_MINUTESrequired10A row in sending longer than this needs reconciliation, never an automatic retry.
SOFT_BOUNCE_RETRY_CAPrequired4Class 4 attempts before permanent suppression.
TRACK_OPENSrequiredfalseDefault off. Turning it on requires the caveat to appear beside every open figure.

The build, in order

  1. A list you can account for

    Every subscriber row carries provenance, and an import without it is refused rather than cleaned up.

    1. Including imports, especially imports.

      Files src/lib/subscribers.tstests/import-provenance.test.ts

    2. Whatever you choose, write it down, because it decides who is a duplicate.

      Files src/lib/normalise.tsdocs/address-normalisation.md

    3. A deleted row is an address a future import will happily re-add.

      Files src/lib/unsubscribe.ts

    done when · tick each as it passes
    watch out
    • Importing a spreadsheet of four thousand addresses with a default source of 'import' loses the only thing that distinguishes a list from a mailing you will regret, and when a complaint arrives you have no answer to the only question that matters, which is where this address came from. Require provenance per row and refuse the file if it is missing.
  2. An email that renders where it lands

    A campaign has a working plain-text alternative and a layout that does not depend on modern CSS.

    1. Not a stripped copy of the HTML, and never empty.

      Files src/lib/render-text.ts

    2. Mail clients are not browsers and never will be.

      Files src/lib/render-html.ts

    3. The only test that matches reality.

      Files docs/preview-checklist.md

    done when · tick each as it passes
    watch out
    • Building the email with the layout techniques you use on the web produces something that looks right in the browser preview and arrives as an unstyled column of text in several mail clients, which you discover after sending to the whole list. Use a conservative layout, generate the text part, and send yourself previews on real clients before every campaign.
  3. Sending four thousand emails exactly once

    A crash or a restart mid-campaign resumes without mailing anyone a second time.

    1. The campaign becomes a queue you can reason about.

      Files src/lib/campaign-start.ts

    2. In its own transaction. This is the whole protection.

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

    3. At materialisation and again at send, because a complaint can arrive mid-campaign.

      Files src/worker/send.ts

    done when · tick each as it passes
    watch out
    • A send loop that iterates the subscriber list and crashes at address nineteen hundred of four thousand restarts at the top, and nineteen hundred people receive the same newsletter twice, which is the fastest route from a newsletter to a complaint rate that ends your sending. Write a per-recipient row first, lock it before the call, and let a restart pick up only what is still queued.
  4. Bounces and complaints, read properly

    A permanent failure suppresses the address forever and a temporary one does not.

    1. The class digit decides everything; the prose decides nothing.

      Files src/lib/bounces.tstests/bounce-classes.test.ts

    2. With no route back through the interface.

      Files src/lib/suppressions.ts

    3. A mailbox that is full for a month is a mailbox that is gone.

      Files src/worker/retry.ts

    done when · tick each as it passes
    watch out
    • Treating every non-delivery as something to try again means an address that has not existed for a year gets mailed at every campaign forever, and the accumulating permanent failures are exactly the signal that receiving systems use to decide your mail belongs in a spam folder. Parse the class, suppress on 5, cap the retries on 4.
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.