Build Mailcoach Cloud
KINDAback to the verdict
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.
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 sending 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 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 - 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 - 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 - 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 - 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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://user:pass@localhost:5432/mail | Subscribers, campaigns, sends, events and suppressions. |
EMAIL_PROVIDER_API_KEYsecret | required | key-...redacted | The provider that actually delivers. Scope it to sending only. |
WEBHOOK_SIGNING_SECRETsecret | required | whsec-...redacted | Verifies incoming delivery notifications. An unverified notification must never change suppression. |
SEND_BATCH_SIZE | required | 100 | Rows claimed per worker pass. Smaller means less to reconcile after a crash. |
SEND_RATE_PER_MINUTE | required | 600 | Kept under the provider's published limit, which you should check rather than assume. |
SENDING_STALE_MINUTES | required | 10 | A row in sending longer than this needs reconciliation, never an automatic retry. |
SOFT_BOUNCE_RETRY_CAP | required | 4 | Class 4 attempts before permanent suppression. |
TRACK_OPENS | required | false | Default off. Turning it on requires the caveat to appear beside every open figure. |
The build, in order
A list you can account for
Every subscriber row carries provenance, and an import without it is refused rather than cleaned up.
Including imports, especially imports.
Files
src/lib/subscribers.tstests/import-provenance.test.tsWhatever you choose, write it down, because it decides who is a duplicate.
Files
src/lib/normalise.tsdocs/address-normalisation.mdA deleted row is an address a future import will happily re-add.
Files
src/lib/unsubscribe.ts
done when · tick each as it passeswatch 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.
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.
Not a stripped copy of the HTML, and never empty.
Files
src/lib/render-text.tsMail clients are not browsers and never will be.
Files
src/lib/render-html.tsThe only test that matches reality.
Files
docs/preview-checklist.md
done when · tick each as it passeswatch 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.
Sending four thousand emails exactly once
A crash or a restart mid-campaign resumes without mailing anyone a second time.
The campaign becomes a queue you can reason about.
Files
src/lib/campaign-start.tsIn its own transaction. This is the whole protection.
Files
src/worker/send.tstests/exactly-once.test.tsAt materialisation and again at send, because a complaint can arrive mid-campaign.
Files
src/worker/send.ts
done when · tick each as it passeswatch 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.
Bounces and complaints, read properly
A permanent failure suppresses the address forever and a temporary one does not.
The class digit decides everything; the prose decides nothing.
Files
src/lib/bounces.tstests/bounce-classes.test.tsWith no route back through the interface.
Files
src/lib/suppressions.tsA mailbox that is full for a month is a mailbox that is gone.
Files
src/worker/retry.ts
done when · tick each as it passeswatch 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.
Numbers you can defend, and data that can leaveproduct builder
The report says what it counted, and everything exports.
The two figures that mean what people think they mean.
Files
src/app/campaigns/[id]/report.tsxIn the view and in the export header, not only in the README.
Files
src/lib/export.tsDeleting the record must not make it possible to mail them again.
Files
src/lib/delete-subscriber.tstests/delete-keeps-suppression.test.ts
done when · tick each as it passeswatch out- Putting an open rate at the top of the campaign page turns it into the number everyone judges the newsletter by, and it is inflated by every subscriber whose mail client fetches images automatically, so a redesign that improves nothing will look like a success. Put deliveries and clicks first, and keep the caveat attached to the open figure wherever it appears.
That is the whole plan for Mailcoach Cloud. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Running your own mail server.
- Deliverability consulting.
- Paid subscriptions and recommendation networks.
- Behavioural automations at scale.
- payments, recommendations, and audience network
- sender reputation, deliverability, and suppression handling
- sender reputation and deliverability operations
- bounce, complaint, and suppression handling
- large-scale segmentation and automation
- Simple segments by subscriber field, which reuses materialisation and changes nothing in the sender
- A double opt-in confirmation flow, which strengthens the provenance you are already recording
- Link-level click reporting, since click events are already stored per recipient
Need the files? The project pack on the verdict page hands your agent the whole brief · more newsletters.