Vibecode Mailcoach Cloud
track this build4 phases, 12 steps, beginner friendly0%The visible email marketing loop is buildable, but a credible replacement needs more than the first screen. Mailcoach Cloud earns its keep through deliverability, reputation, network, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Mailcoach Cloud. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Mailcoach Cloud · indie build 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: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, plus a worker process for sending | a send loop inside a request handler dies with the request | | Storage | PostgreSQL, with per-recipient send state as a row | the duplicate-send bug is a database bug | | Delivery | One reputable provider the user supplies, behind one send function | you are not going to run a mail server, and should not | | Suppression | A permanent table checked before every single send | one table stands between you and a dead domain | | Reporting | Events recorded per recipient, with opens labelled for what they are | a number without its caveat becomes a slide | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application and the sending worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free 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. - Verify: psql connects and the migrations apply - [ ] **An email provider account and API key** · free 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 - [ ] **A sending domain with authentication records published** · the 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 - [ ] **Accounts on the mail clients you care about** · free - 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 - [ ] **Your retry cap and normalisation rule** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Mailcoach Cloud, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Mailcoach Cloud The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter tool that sends each campaign exactly once per subscriber and reports numbers it can actually defend to replace Mailcoach Cloud. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both are about not lying: to your subscribers, and to yourself. Nobody receives mail they did not ask for, and nobody receives it twice. Every subscriber row records when, where and how they subscribed, an import without that provenance is refused, and an address that permanently failed or complained is suppressed forever with no route back except the person subscribing again themselves. Sending to a list you cannot account for is how a sending domain dies, and the domain is the only asset here. Second, do not report a number you cannot defend. Open tracking in particular is not a measurement any more: Apple's own description of Mail Privacy Protection says it downloads remote content in the background by default, regardless of whether you engage with the email (checked against Apple's page), so a pixel fires for people who never looked. If you show an open rate at all, show it beside what it actually counts. Deliveries, permanent failures, complaints and clicks are real; opens are not. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, plus a worker process for sending · a send loop inside a request handler dies with the request - Storage: PostgreSQL, with per-recipient send state as a row · the duplicate-send bug is a database bug - Delivery: One reputable provider the user supplies, behind one send function · you are not going to run a mail server, and should not - Suppression: A permanent table checked before every single send · one table stands between you and a dead domain - Reporting: Events recorded per recipient, with opens labelled for what they are · a number without its caveat becomes a slide ### Data model (create this before Phase 1) - 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. ### Phase 1 · A list you can account for Build: Normalise addresses on the way in. Require source, a timestamp and a detail field for every subscriber, including imports. Reject an import lacking them and produce a per-row report saying which rows failed and why. Record unsubscribes as a state change, never a delete. Trap: 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. Done when: An import missing source or subscription date is refused with a per-row report and nothing is written. Addresses differing only in case or in dot placement where the provider ignores it are detected as duplicates according to a documented normalisation rule, tested. An unsubscribe sets a state and keeps the row, so the address cannot be re-added by a later import, tested. Every subscriber page shows where that address came from and when. Do not build yet: No composing, sending, bounces or reporting yet. ### Phase 2 · An email that renders where it lands Build: Compose in a restricted block editor. Render HTML with inline styles and a simple, wide-support layout, and generate the plain-text part from the same blocks rather than leaving it empty. Send a preview to real addresses on the clients you care about before believing any of it. Trap: 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. Done when: Every campaign has a non-empty plain-text part generated from its content, and sending is refused if it is empty. A preview send reaches a real inbox on each client the README names, and the check is part of the release routine rather than a suggestion. Images have alt text and the email is readable with images disabled, verified by looking at it that way. The from name, address, reply-to and postal address come from the list configuration and are present in every send. Do not build yet: No sending to the list, bounces or reporting yet. ### Phase 3 · Sending four thousand emails exactly once Build: Materialise one sends row per subscriber when the campaign starts, minus suppressions. The worker takes rows in batches, moves each to sending in its own transaction before calling the provider, and records the provider message id on acceptance. A row whose outcome is unknown is never retried blindly. Respect the provider's rate limit with backoff. Trap: 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. Done when: Killing the worker mid-campaign and restarting sends to no address twice, verified by counting rows and by a fake provider that records every call. Two workers running at once do not double-send, tested with concurrent runs. A row left in sending past a stale threshold becomes a row needing reconciliation rather than an automatic retry. Suppressed addresses are excluded at materialisation and again immediately before each call. Do not build yet: No bounce handling or reporting yet. ### Phase 4 · Bounces and complaints, read properly Build: Ingest the provider's delivery notifications. Read the enhanced status code rather than the prose: RFC 3463 defines class 5 as a permanent failure, one 'not likely to be resolved by resending the message in the current form', and class 4 as a persistent transient failure, with X.1.1 meaning the mailbox does not exist (checked against the RFC). Suppress permanently on class 5 and on every complaint; retry class 4 with a cap and then suppress. Trap: 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. Done when: A simulated 5.1.1 notification suppresses the address permanently with the enhanced status recorded, and a later campaign excludes it, tested. A class 4 notification retries up to the configured cap and then suppresses, with each attempt visible. A complaint suppresses immediately regardless of class. The suppression list can be exported and shows reason, code and first-seen date for every entry, and there is no interface route that removes a permanent suppression. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers you can defend, and data that can leave Build: Report deliveries, permanent failures, complaints and clicks as counts with denominators. If you record opens at all, label them as remote image loads and state on the same screen that mail privacy features load images without a person reading. Export subscribers, campaigns, sends, events and suppressions as CSV. Implement deletion of a subscriber and everything derived from them. Trap: 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. Done when: The campaign report shows delivered, failed, complained and clicked with denominators, and any open figure carries its caveat in the same view and in every export. CSV exports of all five tables open cleanly and contain the enhanced status code for every failure. Deleting a subscriber removes their events and send rows while keeping the suppression entry, which is the one thing that must outlive them, and this is tested. A campaign that has not started can be cancelled and one that has started can be paused and resumed without duplicate sends. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which is a reputation project measured in months and is the reason the provider exists. - Deliverability consulting and inbox placement work, which is the actual product at the paid end and is people rather than code. - Paid subscriptions, recommendations and audience networks, each of which is a payments or marketplace product bolted to a mailer. - Automations and behavioural segmentation at scale, which multiply every one of these correctness problems by a state machine per subscriber. ### README must contain - That every subscriber row carries provenance and imports without it are refused - That an unsubscribe is a state, not a deletion, so a later import cannot resurrect the address - How the sends table makes a campaign exactly once, and what happens on a restart mid-campaign - That bounce handling reads the RFC 3463 enhanced status class, suppressing permanently on class 5 and on complaints - That open tracking counts remote image loads, not readers, because mail privacy features load images automatically ===== AGENTS.md ===== # Agent instructions · Mailcoach Cloud indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, plus a worker process for sending, PostgreSQL, with per-recipient send state as a row, One reputable provider the user supplies, behind one send function, A permanent table checked before every single send, Events recorded per recipient, with opens labelled for what they are. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Mailcoach Cloud 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A list you can account for Every subscriber row carries provenance, and an import without it is refused rather than cleaned up. ### Steps 1. Require provenance on every row Including imports, especially imports. Files: `src/lib/subscribers.ts`, `tests/import-provenance.test.ts` 2. Document the normalisation rule Whatever you choose, write it down, because it decides who is a duplicate. Files: `src/lib/normalise.ts`, `docs/address-normalisation.md` 3. Unsubscribe is a state, never a delete A deleted row is an address a future import will happily re-add. Files: `src/lib/unsubscribe.ts` ### Done when - [ ] An import without provenance is refused with a report - [ ] Duplicates are detected by the documented rule - [ ] Unsubscribed addresses survive a re-import - [ ] Every subscriber shows source and date ### 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. ## Phase 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. ### Steps 1. Generate the text part from the blocks Not a stripped copy of the HTML, and never empty. Files: `src/lib/render-text.ts` 2. Keep the layout conservative Mail clients are not browsers and never will be. Files: `src/lib/render-html.ts` 3. Preview to real clients before every send The only test that matches reality. Files: `docs/preview-checklist.md` ### Done when - [ ] An empty text part blocks sending - [ ] Previews reach each named client - [ ] The email reads with images disabled - [ ] Sender identity comes from the list configuration ### 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. ## Phase 3 · Sending four thousand emails exactly once A crash or a restart mid-campaign resumes without mailing anyone a second time. ### Steps 1. Materialise a row per recipient up front The campaign becomes a queue you can reason about. Files: `src/lib/campaign-start.ts` 2. Lock the row before the provider call In its own transaction. This is the whole protection. Files: `src/worker/send.ts`, `tests/exactly-once.test.ts` 3. Check suppression twice At materialisation and again at send, because a complaint can arrive mid-campaign. Files: `src/worker/send.ts` ### Done when - [ ] A kill and restart sends to nobody twice - [ ] Two concurrent workers do not double-send - [ ] Stale sending rows need reconciliation, not retry - [ ] Suppression is checked at materialisation and at send ### 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. ## Phase 4 · Bounces and complaints, read properly A permanent failure suppresses the address forever and a temporary one does not. ### Steps 1. Parse the enhanced status code The class digit decides everything; the prose decides nothing. Files: `src/lib/bounces.ts`, `tests/bounce-classes.test.ts` 2. Suppress permanently on class 5 and complaints With no route back through the interface. Files: `src/lib/suppressions.ts` 3. Cap the retries on class 4 A mailbox that is full for a month is a mailbox that is gone. Files: `src/worker/retry.ts` ### Done when - [ ] A 5.1.1 suppresses permanently and excludes later campaigns - [ ] Class 4 retries to the cap then suppresses - [ ] A complaint suppresses immediately - [ ] Suppressions export with reason, code and date ### 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. ## Not in this build - Running your own mail server. - Deliverability consulting. - Paid subscriptions and recommendation networks. - Behavioural automations at scale. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers, campaigns, sends, events and suppressions. DATABASE_URL=postgres://user:pass@localhost:5432/mail # Required · secret. The provider that actually delivers. Scope it to sending only. EMAIL_PROVIDER_API_KEY=key-...redacted # Required · secret. Verifies incoming delivery notifications. An unverified notification must never change suppression. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. Rows claimed per worker pass. Smaller means less to reconcile after a crash. SEND_BATCH_SIZE=100 # Required. Kept under the provider's published limit, which you should check rather than assume. SEND_RATE_PER_MINUTE=600 # Required. A row in sending longer than this needs reconciliation, never an automatic retry. SENDING_STALE_MINUTES=10 # Required. Class 4 attempts before permanent suppression. SOFT_BOUNCE_RETRY_CAP=4 # Required. Default off. Turning it on requires the caveat to appear beside every open figure. TRACK_OPENS=false
You are building a lean indie version of Mailcoach Cloud. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Mailcoach Cloud · indie build 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: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, plus a worker process for sending | a send loop inside a request handler dies with the request | | Storage | PostgreSQL, with per-recipient send state as a row | the duplicate-send bug is a database bug | | Delivery | One reputable provider the user supplies, behind one send function | you are not going to run a mail server, and should not | | Suppression | A permanent table checked before every single send | one table stands between you and a dead domain | | Reporting | Events recorded per recipient, with opens labelled for what they are | a number without its caveat becomes a slide | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application and the sending worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free 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. - Verify: psql connects and the migrations apply - [ ] **An email provider account and API key** · free 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 - [ ] **A sending domain with authentication records published** · the 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 - [ ] **Accounts on the mail clients you care about** · free - 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 - [ ] **Your retry cap and normalisation rule** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Mailcoach Cloud, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Mailcoach Cloud The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter tool that sends each campaign exactly once per subscriber and reports numbers it can actually defend to replace Mailcoach Cloud. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both are about not lying: to your subscribers, and to yourself. Nobody receives mail they did not ask for, and nobody receives it twice. Every subscriber row records when, where and how they subscribed, an import without that provenance is refused, and an address that permanently failed or complained is suppressed forever with no route back except the person subscribing again themselves. Sending to a list you cannot account for is how a sending domain dies, and the domain is the only asset here. Second, do not report a number you cannot defend. Open tracking in particular is not a measurement any more: Apple's own description of Mail Privacy Protection says it downloads remote content in the background by default, regardless of whether you engage with the email (checked against Apple's page), so a pixel fires for people who never looked. If you show an open rate at all, show it beside what it actually counts. Deliveries, permanent failures, complaints and clicks are real; opens are not. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, plus a worker process for sending · a send loop inside a request handler dies with the request - Storage: PostgreSQL, with per-recipient send state as a row · the duplicate-send bug is a database bug - Delivery: One reputable provider the user supplies, behind one send function · you are not going to run a mail server, and should not - Suppression: A permanent table checked before every single send · one table stands between you and a dead domain - Reporting: Events recorded per recipient, with opens labelled for what they are · a number without its caveat becomes a slide ### Data model (create this before Phase 1) - 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. ### Phase 1 · A list you can account for Build: Normalise addresses on the way in. Require source, a timestamp and a detail field for every subscriber, including imports. Reject an import lacking them and produce a per-row report saying which rows failed and why. Record unsubscribes as a state change, never a delete. Trap: 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. Done when: An import missing source or subscription date is refused with a per-row report and nothing is written. Addresses differing only in case or in dot placement where the provider ignores it are detected as duplicates according to a documented normalisation rule, tested. An unsubscribe sets a state and keeps the row, so the address cannot be re-added by a later import, tested. Every subscriber page shows where that address came from and when. Do not build yet: No composing, sending, bounces or reporting yet. ### Phase 2 · An email that renders where it lands Build: Compose in a restricted block editor. Render HTML with inline styles and a simple, wide-support layout, and generate the plain-text part from the same blocks rather than leaving it empty. Send a preview to real addresses on the clients you care about before believing any of it. Trap: 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. Done when: Every campaign has a non-empty plain-text part generated from its content, and sending is refused if it is empty. A preview send reaches a real inbox on each client the README names, and the check is part of the release routine rather than a suggestion. Images have alt text and the email is readable with images disabled, verified by looking at it that way. The from name, address, reply-to and postal address come from the list configuration and are present in every send. Do not build yet: No sending to the list, bounces or reporting yet. ### Phase 3 · Sending four thousand emails exactly once Build: Materialise one sends row per subscriber when the campaign starts, minus suppressions. The worker takes rows in batches, moves each to sending in its own transaction before calling the provider, and records the provider message id on acceptance. A row whose outcome is unknown is never retried blindly. Respect the provider's rate limit with backoff. Trap: 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. Done when: Killing the worker mid-campaign and restarting sends to no address twice, verified by counting rows and by a fake provider that records every call. Two workers running at once do not double-send, tested with concurrent runs. A row left in sending past a stale threshold becomes a row needing reconciliation rather than an automatic retry. Suppressed addresses are excluded at materialisation and again immediately before each call. Do not build yet: No bounce handling or reporting yet. ### Phase 4 · Bounces and complaints, read properly Build: Ingest the provider's delivery notifications. Read the enhanced status code rather than the prose: RFC 3463 defines class 5 as a permanent failure, one 'not likely to be resolved by resending the message in the current form', and class 4 as a persistent transient failure, with X.1.1 meaning the mailbox does not exist (checked against the RFC). Suppress permanently on class 5 and on every complaint; retry class 4 with a cap and then suppress. Trap: 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. Done when: A simulated 5.1.1 notification suppresses the address permanently with the enhanced status recorded, and a later campaign excludes it, tested. A class 4 notification retries up to the configured cap and then suppresses, with each attempt visible. A complaint suppresses immediately regardless of class. The suppression list can be exported and shows reason, code and first-seen date for every entry, and there is no interface route that removes a permanent suppression. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers you can defend, and data that can leave Build: Report deliveries, permanent failures, complaints and clicks as counts with denominators. If you record opens at all, label them as remote image loads and state on the same screen that mail privacy features load images without a person reading. Export subscribers, campaigns, sends, events and suppressions as CSV. Implement deletion of a subscriber and everything derived from them. Trap: 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. Done when: The campaign report shows delivered, failed, complained and clicked with denominators, and any open figure carries its caveat in the same view and in every export. CSV exports of all five tables open cleanly and contain the enhanced status code for every failure. Deleting a subscriber removes their events and send rows while keeping the suppression entry, which is the one thing that must outlive them, and this is tested. A campaign that has not started can be cancelled and one that has started can be paused and resumed without duplicate sends. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which is a reputation project measured in months and is the reason the provider exists. - Deliverability consulting and inbox placement work, which is the actual product at the paid end and is people rather than code. - Paid subscriptions, recommendations and audience networks, each of which is a payments or marketplace product bolted to a mailer. - Automations and behavioural segmentation at scale, which multiply every one of these correctness problems by a state machine per subscriber. ### README must contain - That every subscriber row carries provenance and imports without it are refused - That an unsubscribe is a state, not a deletion, so a later import cannot resurrect the address - How the sends table makes a campaign exactly once, and what happens on a restart mid-campaign - That bounce handling reads the RFC 3463 enhanced status class, suppressing permanently on class 5 and on complaints - That open tracking counts remote image loads, not readers, because mail privacy features load images automatically ===== AGENTS.md ===== # Agent instructions · Mailcoach Cloud indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, plus a worker process for sending, PostgreSQL, with per-recipient send state as a row, One reputable provider the user supplies, behind one send function, A permanent table checked before every single send, Events recorded per recipient, with opens labelled for what they are. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Mailcoach Cloud 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A list you can account for Every subscriber row carries provenance, and an import without it is refused rather than cleaned up. ### Steps 1. Require provenance on every row Including imports, especially imports. Files: `src/lib/subscribers.ts`, `tests/import-provenance.test.ts` 2. Document the normalisation rule Whatever you choose, write it down, because it decides who is a duplicate. Files: `src/lib/normalise.ts`, `docs/address-normalisation.md` 3. Unsubscribe is a state, never a delete A deleted row is an address a future import will happily re-add. Files: `src/lib/unsubscribe.ts` ### Done when - [ ] An import without provenance is refused with a report - [ ] Duplicates are detected by the documented rule - [ ] Unsubscribed addresses survive a re-import - [ ] Every subscriber shows source and date ### 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. ## Phase 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. ### Steps 1. Generate the text part from the blocks Not a stripped copy of the HTML, and never empty. Files: `src/lib/render-text.ts` 2. Keep the layout conservative Mail clients are not browsers and never will be. Files: `src/lib/render-html.ts` 3. Preview to real clients before every send The only test that matches reality. Files: `docs/preview-checklist.md` ### Done when - [ ] An empty text part blocks sending - [ ] Previews reach each named client - [ ] The email reads with images disabled - [ ] Sender identity comes from the list configuration ### 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. ## Phase 3 · Sending four thousand emails exactly once A crash or a restart mid-campaign resumes without mailing anyone a second time. ### Steps 1. Materialise a row per recipient up front The campaign becomes a queue you can reason about. Files: `src/lib/campaign-start.ts` 2. Lock the row before the provider call In its own transaction. This is the whole protection. Files: `src/worker/send.ts`, `tests/exactly-once.test.ts` 3. Check suppression twice At materialisation and again at send, because a complaint can arrive mid-campaign. Files: `src/worker/send.ts` ### Done when - [ ] A kill and restart sends to nobody twice - [ ] Two concurrent workers do not double-send - [ ] Stale sending rows need reconciliation, not retry - [ ] Suppression is checked at materialisation and at send ### 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. ## Phase 4 · Bounces and complaints, read properly A permanent failure suppresses the address forever and a temporary one does not. ### Steps 1. Parse the enhanced status code The class digit decides everything; the prose decides nothing. Files: `src/lib/bounces.ts`, `tests/bounce-classes.test.ts` 2. Suppress permanently on class 5 and complaints With no route back through the interface. Files: `src/lib/suppressions.ts` 3. Cap the retries on class 4 A mailbox that is full for a month is a mailbox that is gone. Files: `src/worker/retry.ts` ### Done when - [ ] A 5.1.1 suppresses permanently and excludes later campaigns - [ ] Class 4 retries to the cap then suppresses - [ ] A complaint suppresses immediately - [ ] Suppressions export with reason, code and date ### 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. ## Not in this build - Running your own mail server. - Deliverability consulting. - Paid subscriptions and recommendation networks. - Behavioural automations at scale. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers, campaigns, sends, events and suppressions. DATABASE_URL=postgres://user:pass@localhost:5432/mail # Required · secret. The provider that actually delivers. Scope it to sending only. EMAIL_PROVIDER_API_KEY=key-...redacted # Required · secret. Verifies incoming delivery notifications. An unverified notification must never change suppression. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. Rows claimed per worker pass. Smaller means less to reconcile after a crash. SEND_BATCH_SIZE=100 # Required. Kept under the provider's published limit, which you should check rather than assume. SEND_RATE_PER_MINUTE=600 # Required. A row in sending longer than this needs reconciliation, never an automatic retry. SENDING_STALE_MINUTES=10 # Required. Class 4 attempts before permanent suppression. SOFT_BOUNCE_RETRY_CAP=4 # Required. Default off. Turning it on requires the caveat to appear beside every open figure. TRACK_OPENS=false
You are building a production product version of Mailcoach Cloud. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Mailcoach Cloud · product brief ## Problem The visible email marketing loop is buildable, but a credible replacement needs more than the first screen. Mailcoach Cloud earns its keep through deliverability, reputation, network, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A mailer that can tell you where every address came from, sends a four thousand person campaign exactly once through a crash, suppresses the addresses that will hurt your domain, and reports figures that survive a question. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - PostgreSQL - transactional or bulk email provider key in .env - verified sending domain ## Explicit non-goals for v1 - 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 ## Success criteria - An import without provenance is confirmed refused - A kill mid-campaign is confirmed to send to nobody twice - A 5.1.1 is confirmed to suppress permanently - Deleting a subscriber is confirmed to keep the suppression - Any open figure is confirmed to carry its caveat in views and exports ===== BRIEF.md ===== # Build brief · Mailcoach Cloud The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter tool that sends each campaign exactly once per subscriber and reports numbers it can actually defend to replace Mailcoach Cloud. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both are about not lying: to your subscribers, and to yourself. Nobody receives mail they did not ask for, and nobody receives it twice. Every subscriber row records when, where and how they subscribed, an import without that provenance is refused, and an address that permanently failed or complained is suppressed forever with no route back except the person subscribing again themselves. Sending to a list you cannot account for is how a sending domain dies, and the domain is the only asset here. Second, do not report a number you cannot defend. Open tracking in particular is not a measurement any more: Apple's own description of Mail Privacy Protection says it downloads remote content in the background by default, regardless of whether you engage with the email (checked against Apple's page), so a pixel fires for people who never looked. If you show an open rate at all, show it beside what it actually counts. Deliveries, permanent failures, complaints and clicks are real; opens are not. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, plus a worker process for sending · a send loop inside a request handler dies with the request - Storage: PostgreSQL, with per-recipient send state as a row · the duplicate-send bug is a database bug - Delivery: One reputable provider the user supplies, behind one send function · you are not going to run a mail server, and should not - Suppression: A permanent table checked before every single send · one table stands between you and a dead domain - Reporting: Events recorded per recipient, with opens labelled for what they are · a number without its caveat becomes a slide ### Data model (create this before Phase 1) - 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. ### Phase 1 · A list you can account for Build: Normalise addresses on the way in. Require source, a timestamp and a detail field for every subscriber, including imports. Reject an import lacking them and produce a per-row report saying which rows failed and why. Record unsubscribes as a state change, never a delete. Trap: 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. Done when: An import missing source or subscription date is refused with a per-row report and nothing is written. Addresses differing only in case or in dot placement where the provider ignores it are detected as duplicates according to a documented normalisation rule, tested. An unsubscribe sets a state and keeps the row, so the address cannot be re-added by a later import, tested. Every subscriber page shows where that address came from and when. Do not build yet: No composing, sending, bounces or reporting yet. ### Phase 2 · An email that renders where it lands Build: Compose in a restricted block editor. Render HTML with inline styles and a simple, wide-support layout, and generate the plain-text part from the same blocks rather than leaving it empty. Send a preview to real addresses on the clients you care about before believing any of it. Trap: 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. Done when: Every campaign has a non-empty plain-text part generated from its content, and sending is refused if it is empty. A preview send reaches a real inbox on each client the README names, and the check is part of the release routine rather than a suggestion. Images have alt text and the email is readable with images disabled, verified by looking at it that way. The from name, address, reply-to and postal address come from the list configuration and are present in every send. Do not build yet: No sending to the list, bounces or reporting yet. ### Phase 3 · Sending four thousand emails exactly once Build: Materialise one sends row per subscriber when the campaign starts, minus suppressions. The worker takes rows in batches, moves each to sending in its own transaction before calling the provider, and records the provider message id on acceptance. A row whose outcome is unknown is never retried blindly. Respect the provider's rate limit with backoff. Trap: 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. Done when: Killing the worker mid-campaign and restarting sends to no address twice, verified by counting rows and by a fake provider that records every call. Two workers running at once do not double-send, tested with concurrent runs. A row left in sending past a stale threshold becomes a row needing reconciliation rather than an automatic retry. Suppressed addresses are excluded at materialisation and again immediately before each call. Do not build yet: No bounce handling or reporting yet. ### Phase 4 · Bounces and complaints, read properly Build: Ingest the provider's delivery notifications. Read the enhanced status code rather than the prose: RFC 3463 defines class 5 as a permanent failure, one 'not likely to be resolved by resending the message in the current form', and class 4 as a persistent transient failure, with X.1.1 meaning the mailbox does not exist (checked against the RFC). Suppress permanently on class 5 and on every complaint; retry class 4 with a cap and then suppress. Trap: 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. Done when: A simulated 5.1.1 notification suppresses the address permanently with the enhanced status recorded, and a later campaign excludes it, tested. A class 4 notification retries up to the configured cap and then suppresses, with each attempt visible. A complaint suppresses immediately regardless of class. The suppression list can be exported and shows reason, code and first-seen date for every entry, and there is no interface route that removes a permanent suppression. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers you can defend, and data that can leave Build: Report deliveries, permanent failures, complaints and clicks as counts with denominators. If you record opens at all, label them as remote image loads and state on the same screen that mail privacy features load images without a person reading. Export subscribers, campaigns, sends, events and suppressions as CSV. Implement deletion of a subscriber and everything derived from them. Trap: 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. Done when: The campaign report shows delivered, failed, complained and clicked with denominators, and any open figure carries its caveat in the same view and in every export. CSV exports of all five tables open cleanly and contain the enhanced status code for every failure. Deleting a subscriber removes their events and send rows while keeping the suppression entry, which is the one thing that must outlive them, and this is tested. A campaign that has not started can be cancelled and one that has started can be paused and resumed without duplicate sends. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which is a reputation project measured in months and is the reason the provider exists. - Deliverability consulting and inbox placement work, which is the actual product at the paid end and is people rather than code. - Paid subscriptions, recommendations and audience networks, each of which is a payments or marketplace product bolted to a mailer. - Automations and behavioural segmentation at scale, which multiply every one of these correctness problems by a state machine per subscriber. ### README must contain - That every subscriber row carries provenance and imports without it are refused - That an unsubscribe is a state, not a deletion, so a later import cannot resurrect the address - How the sends table makes a campaign exactly once, and what happens on a restart mid-campaign - That bounce handling reads the RFC 3463 enhanced status class, suppressing permanently on class 5 and on complaints - That open tracking counts remote image loads, not readers, because mail privacy features load images automatically ===== ARCHITECTURE.md ===== # Architecture · Mailcoach Cloud ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, plus a worker process for sending | a send loop inside a request handler dies with the request | | Storage | PostgreSQL, with per-recipient send state as a row | the duplicate-send bug is a database bug | | Delivery | One reputable provider the user supplies, behind one send function | you are not going to run a mail server, and should not | | Suppression | A permanent table checked before every single send | one table stands between you and a dead domain | | Reporting | Events recorded per recipient, with opens labelled for what they are | a number without its caveat becomes a slide | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Subscribers | Provenance, normalisation and unsubscribe state. | Any store, provided unsubscribed addresses survive a re-import. | | Composer | Restricted blocks, HTML and text rendering. | Any editor, provided the text part is generated and non-empty. | | Sender | Per-recipient rows and the transactional lock. | Not swappable for a loop over the subscriber list. | | Suppression | Status class parsing and permanent entries. | Any notification source, provided class 5 and complaints are permanent. | | Reporting | Counts with denominators and the open caveat. | Any presentation, provided the caveat travels with the number. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Subscribers, campaigns, sends, events and suppressions. - `EMAIL_PROVIDER_API_KEY` · required, secret · The provider that actually delivers. Scope it to sending only. - `WEBHOOK_SIGNING_SECRET` · required, secret · Verifies incoming delivery notifications. An unverified notification must never change suppression. - `SEND_BATCH_SIZE` · required · Rows claimed per worker pass. Smaller means less to reconcile after a crash. - `SEND_RATE_PER_MINUTE` · required · Kept under the provider's published limit, which you should check rather than assume. - `SENDING_STALE_MINUTES` · required · A row in sending longer than this needs reconciliation, never an automatic retry. - `SOFT_BOUNCE_RETRY_CAP` · required · Class 4 attempts before permanent suppression. - `TRACK_OPENS` · required · Default off. Turning it on requires the caveat to appear beside every open figure. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Mailcoach Cloud product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, plus a worker process for sending, PostgreSQL, with per-recipient send state as a row, One reputable provider the user supplies, behind one send function, A permanent table checked before every single send, Events recorded per recipient, with opens labelled for what they are. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. ===== MILESTONES.md ===== # Delivery milestones · Mailcoach Cloud Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A list you can account for Every subscriber row carries provenance, and an import without it is refused rather than cleaned up. ### Steps 1. Require provenance on every row Including imports, especially imports. Files: `src/lib/subscribers.ts`, `tests/import-provenance.test.ts` 2. Document the normalisation rule Whatever you choose, write it down, because it decides who is a duplicate. Files: `src/lib/normalise.ts`, `docs/address-normalisation.md` 3. Unsubscribe is a state, never a delete A deleted row is an address a future import will happily re-add. Files: `src/lib/unsubscribe.ts` ### Done when - [ ] An import without provenance is refused with a report - [ ] Duplicates are detected by the documented rule - [ ] Unsubscribed addresses survive a re-import - [ ] Every subscriber shows source and date ### 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. ## M2 · 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. ### Steps 1. Generate the text part from the blocks Not a stripped copy of the HTML, and never empty. Files: `src/lib/render-text.ts` 2. Keep the layout conservative Mail clients are not browsers and never will be. Files: `src/lib/render-html.ts` 3. Preview to real clients before every send The only test that matches reality. Files: `docs/preview-checklist.md` ### Done when - [ ] An empty text part blocks sending - [ ] Previews reach each named client - [ ] The email reads with images disabled - [ ] Sender identity comes from the list configuration ### 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. ## M3 · Sending four thousand emails exactly once A crash or a restart mid-campaign resumes without mailing anyone a second time. ### Steps 1. Materialise a row per recipient up front The campaign becomes a queue you can reason about. Files: `src/lib/campaign-start.ts` 2. Lock the row before the provider call In its own transaction. This is the whole protection. Files: `src/worker/send.ts`, `tests/exactly-once.test.ts` 3. Check suppression twice At materialisation and again at send, because a complaint can arrive mid-campaign. Files: `src/worker/send.ts` ### Done when - [ ] A kill and restart sends to nobody twice - [ ] Two concurrent workers do not double-send - [ ] Stale sending rows need reconciliation, not retry - [ ] Suppression is checked at materialisation and at send ### 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. ## M4 · Bounces and complaints, read properly A permanent failure suppresses the address forever and a temporary one does not. ### Steps 1. Parse the enhanced status code The class digit decides everything; the prose decides nothing. Files: `src/lib/bounces.ts`, `tests/bounce-classes.test.ts` 2. Suppress permanently on class 5 and complaints With no route back through the interface. Files: `src/lib/suppressions.ts` 3. Cap the retries on class 4 A mailbox that is full for a month is a mailbox that is gone. Files: `src/worker/retry.ts` ### Done when - [ ] A 5.1.1 suppresses permanently and excludes later campaigns - [ ] Class 4 retries to the cap then suppresses - [ ] A complaint suppresses immediately - [ ] Suppressions export with reason, code and date ### 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. ## M5 · Numbers you can defend, and data that can leave (production only) The report says what it counted, and everything exports. ### Steps 1. Lead with deliveries and clicks The two figures that mean what people think they mean. Files: `src/app/campaigns/[id]/report.tsx` 2. Attach the open caveat to the number In the view and in the export header, not only in the README. Files: `src/lib/export.ts` 3. Keep the suppression when deleting a person Deleting the record must not make it possible to mail them again. Files: `src/lib/delete-subscriber.ts`, `tests/delete-keeps-suppression.test.ts` ### Done when - [ ] Reports show counts with denominators - [ ] Any open figure carries its caveat everywhere - [ ] All five tables export cleanly with status codes - [ ] Deleting a subscriber keeps their suppression ### Watch 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. ===== OPERATIONS.md ===== # Operations · Mailcoach Cloud ## Backup The database, nightly, off the machine. The suppression table is the single most important thing in it: losing subscribers is painful, losing the suppression list means mailing addresses that already complained, which is how a domain stops delivering. ## Restore Restore, then move every row left in sending to reconciliation before starting a worker, so a restore does not resend a campaign. Confirm the suppression table came back complete by row count before any send. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Permanent failure rate and complaint rate per campaign, rows stuck in sending, notification endpoint failures, and the gap between accepted and delivered. A rising complaint rate is the only metric here that is an emergency. ## Incident checklist A duplicate send means the lock was bypassed, and the sends table shows exactly which rows were called twice. A campaign that stopped delivering is diagnosed from the notification log and the status codes, not from the provider's dashboard. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] An import without provenance is confirmed refused - [ ] A kill mid-campaign is confirmed to send to nobody twice - [ ] A 5.1.1 is confirmed to suppress permanently - [ ] Deleting a subscriber is confirmed to keep the suppression - [ ] Any open figure is confirmed to carry its caveat in views and exports ## Launch constraint Do not market omitted Mailcoach Cloud capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers, campaigns, sends, events and suppressions. DATABASE_URL=postgres://user:pass@localhost:5432/mail # Required · secret. The provider that actually delivers. Scope it to sending only. EMAIL_PROVIDER_API_KEY=key-...redacted # Required · secret. Verifies incoming delivery notifications. An unverified notification must never change suppression. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. Rows claimed per worker pass. Smaller means less to reconcile after a crash. SEND_BATCH_SIZE=100 # Required. Kept under the provider's published limit, which you should check rather than assume. SEND_RATE_PER_MINUTE=600 # Required. A row in sending longer than this needs reconciliation, never an automatic retry. SENDING_STALE_MINUTES=10 # Required. Class 4 attempts before permanent suppression. SOFT_BOUNCE_RETRY_CAP=4 # Required. Default off. Turning it on requires the caveat to appear beside every open figure. TRACK_OPENS=false
# Mailcoach Cloud · indie build 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: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, plus a worker process for sending | a send loop inside a request handler dies with the request | | Storage | PostgreSQL, with per-recipient send state as a row | the duplicate-send bug is a database bug | | Delivery | One reputable provider the user supplies, behind one send function | you are not going to run a mail server, and should not | | Suppression | A permanent table checked before every single send | one table stands between you and a dead domain | | Reporting | Events recorded per recipient, with opens labelled for what they are | a number without its caveat becomes a slide | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application and the sending worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free 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. - Verify: psql connects and the migrations apply - [ ] **An email provider account and API key** · free 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 - [ ] **A sending domain with authentication records published** · the 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 - [ ] **Accounts on the mail clients you care about** · free - 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 - [ ] **Your retry cap and normalisation rule** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Mailcoach Cloud, and the README should say so rather than pretend.
# Build brief · Mailcoach Cloud The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter tool that sends each campaign exactly once per subscriber and reports numbers it can actually defend to replace Mailcoach Cloud. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both are about not lying: to your subscribers, and to yourself. Nobody receives mail they did not ask for, and nobody receives it twice. Every subscriber row records when, where and how they subscribed, an import without that provenance is refused, and an address that permanently failed or complained is suppressed forever with no route back except the person subscribing again themselves. Sending to a list you cannot account for is how a sending domain dies, and the domain is the only asset here. Second, do not report a number you cannot defend. Open tracking in particular is not a measurement any more: Apple's own description of Mail Privacy Protection says it downloads remote content in the background by default, regardless of whether you engage with the email (checked against Apple's page), so a pixel fires for people who never looked. If you show an open rate at all, show it beside what it actually counts. Deliveries, permanent failures, complaints and clicks are real; opens are not. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, plus a worker process for sending · a send loop inside a request handler dies with the request - Storage: PostgreSQL, with per-recipient send state as a row · the duplicate-send bug is a database bug - Delivery: One reputable provider the user supplies, behind one send function · you are not going to run a mail server, and should not - Suppression: A permanent table checked before every single send · one table stands between you and a dead domain - Reporting: Events recorded per recipient, with opens labelled for what they are · a number without its caveat becomes a slide ### Data model (create this before Phase 1) - 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. ### Phase 1 · A list you can account for Build: Normalise addresses on the way in. Require source, a timestamp and a detail field for every subscriber, including imports. Reject an import lacking them and produce a per-row report saying which rows failed and why. Record unsubscribes as a state change, never a delete. Trap: 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. Done when: An import missing source or subscription date is refused with a per-row report and nothing is written. Addresses differing only in case or in dot placement where the provider ignores it are detected as duplicates according to a documented normalisation rule, tested. An unsubscribe sets a state and keeps the row, so the address cannot be re-added by a later import, tested. Every subscriber page shows where that address came from and when. Do not build yet: No composing, sending, bounces or reporting yet. ### Phase 2 · An email that renders where it lands Build: Compose in a restricted block editor. Render HTML with inline styles and a simple, wide-support layout, and generate the plain-text part from the same blocks rather than leaving it empty. Send a preview to real addresses on the clients you care about before believing any of it. Trap: 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. Done when: Every campaign has a non-empty plain-text part generated from its content, and sending is refused if it is empty. A preview send reaches a real inbox on each client the README names, and the check is part of the release routine rather than a suggestion. Images have alt text and the email is readable with images disabled, verified by looking at it that way. The from name, address, reply-to and postal address come from the list configuration and are present in every send. Do not build yet: No sending to the list, bounces or reporting yet. ### Phase 3 · Sending four thousand emails exactly once Build: Materialise one sends row per subscriber when the campaign starts, minus suppressions. The worker takes rows in batches, moves each to sending in its own transaction before calling the provider, and records the provider message id on acceptance. A row whose outcome is unknown is never retried blindly. Respect the provider's rate limit with backoff. Trap: 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. Done when: Killing the worker mid-campaign and restarting sends to no address twice, verified by counting rows and by a fake provider that records every call. Two workers running at once do not double-send, tested with concurrent runs. A row left in sending past a stale threshold becomes a row needing reconciliation rather than an automatic retry. Suppressed addresses are excluded at materialisation and again immediately before each call. Do not build yet: No bounce handling or reporting yet. ### Phase 4 · Bounces and complaints, read properly Build: Ingest the provider's delivery notifications. Read the enhanced status code rather than the prose: RFC 3463 defines class 5 as a permanent failure, one 'not likely to be resolved by resending the message in the current form', and class 4 as a persistent transient failure, with X.1.1 meaning the mailbox does not exist (checked against the RFC). Suppress permanently on class 5 and on every complaint; retry class 4 with a cap and then suppress. Trap: 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. Done when: A simulated 5.1.1 notification suppresses the address permanently with the enhanced status recorded, and a later campaign excludes it, tested. A class 4 notification retries up to the configured cap and then suppresses, with each attempt visible. A complaint suppresses immediately regardless of class. The suppression list can be exported and shows reason, code and first-seen date for every entry, and there is no interface route that removes a permanent suppression. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers you can defend, and data that can leave Build: Report deliveries, permanent failures, complaints and clicks as counts with denominators. If you record opens at all, label them as remote image loads and state on the same screen that mail privacy features load images without a person reading. Export subscribers, campaigns, sends, events and suppressions as CSV. Implement deletion of a subscriber and everything derived from them. Trap: 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. Done when: The campaign report shows delivered, failed, complained and clicked with denominators, and any open figure carries its caveat in the same view and in every export. CSV exports of all five tables open cleanly and contain the enhanced status code for every failure. Deleting a subscriber removes their events and send rows while keeping the suppression entry, which is the one thing that must outlive them, and this is tested. A campaign that has not started can be cancelled and one that has started can be paused and resumed without duplicate sends. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which is a reputation project measured in months and is the reason the provider exists. - Deliverability consulting and inbox placement work, which is the actual product at the paid end and is people rather than code. - Paid subscriptions, recommendations and audience networks, each of which is a payments or marketplace product bolted to a mailer. - Automations and behavioural segmentation at scale, which multiply every one of these correctness problems by a state machine per subscriber. ### README must contain - That every subscriber row carries provenance and imports without it are refused - That an unsubscribe is a state, not a deletion, so a later import cannot resurrect the address - How the sends table makes a campaign exactly once, and what happens on a restart mid-campaign - That bounce handling reads the RFC 3463 enhanced status class, suppressing permanently on class 5 and on complaints - That open tracking counts remote image loads, not readers, because mail privacy features load images automatically
# Agent instructions · Mailcoach Cloud indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, plus a worker process for sending, PostgreSQL, with per-recipient send state as a row, One reputable provider the user supplies, behind one send function, A permanent table checked before every single send, Events recorded per recipient, with opens labelled for what they are. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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.
# Build plan · Mailcoach Cloud 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A list you can account for Every subscriber row carries provenance, and an import without it is refused rather than cleaned up. ### Steps 1. Require provenance on every row Including imports, especially imports. Files: `src/lib/subscribers.ts`, `tests/import-provenance.test.ts` 2. Document the normalisation rule Whatever you choose, write it down, because it decides who is a duplicate. Files: `src/lib/normalise.ts`, `docs/address-normalisation.md` 3. Unsubscribe is a state, never a delete A deleted row is an address a future import will happily re-add. Files: `src/lib/unsubscribe.ts` ### Done when - [ ] An import without provenance is refused with a report - [ ] Duplicates are detected by the documented rule - [ ] Unsubscribed addresses survive a re-import - [ ] Every subscriber shows source and date ### 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. ## Phase 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. ### Steps 1. Generate the text part from the blocks Not a stripped copy of the HTML, and never empty. Files: `src/lib/render-text.ts` 2. Keep the layout conservative Mail clients are not browsers and never will be. Files: `src/lib/render-html.ts` 3. Preview to real clients before every send The only test that matches reality. Files: `docs/preview-checklist.md` ### Done when - [ ] An empty text part blocks sending - [ ] Previews reach each named client - [ ] The email reads with images disabled - [ ] Sender identity comes from the list configuration ### 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. ## Phase 3 · Sending four thousand emails exactly once A crash or a restart mid-campaign resumes without mailing anyone a second time. ### Steps 1. Materialise a row per recipient up front The campaign becomes a queue you can reason about. Files: `src/lib/campaign-start.ts` 2. Lock the row before the provider call In its own transaction. This is the whole protection. Files: `src/worker/send.ts`, `tests/exactly-once.test.ts` 3. Check suppression twice At materialisation and again at send, because a complaint can arrive mid-campaign. Files: `src/worker/send.ts` ### Done when - [ ] A kill and restart sends to nobody twice - [ ] Two concurrent workers do not double-send - [ ] Stale sending rows need reconciliation, not retry - [ ] Suppression is checked at materialisation and at send ### 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. ## Phase 4 · Bounces and complaints, read properly A permanent failure suppresses the address forever and a temporary one does not. ### Steps 1. Parse the enhanced status code The class digit decides everything; the prose decides nothing. Files: `src/lib/bounces.ts`, `tests/bounce-classes.test.ts` 2. Suppress permanently on class 5 and complaints With no route back through the interface. Files: `src/lib/suppressions.ts` 3. Cap the retries on class 4 A mailbox that is full for a month is a mailbox that is gone. Files: `src/worker/retry.ts` ### Done when - [ ] A 5.1.1 suppresses permanently and excludes later campaigns - [ ] Class 4 retries to the cap then suppresses - [ ] A complaint suppresses immediately - [ ] Suppressions export with reason, code and date ### 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. ## Not in this build - Running your own mail server. - Deliverability consulting. - Paid subscriptions and recommendation networks. - Behavioural automations at scale. ## After v1, if you want it - 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
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers, campaigns, sends, events and suppressions. DATABASE_URL=postgres://user:pass@localhost:5432/mail # Required · secret. The provider that actually delivers. Scope it to sending only. EMAIL_PROVIDER_API_KEY=key-...redacted # Required · secret. Verifies incoming delivery notifications. An unverified notification must never change suppression. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. Rows claimed per worker pass. Smaller means less to reconcile after a crash. SEND_BATCH_SIZE=100 # Required. Kept under the provider's published limit, which you should check rather than assume. SEND_RATE_PER_MINUTE=600 # Required. A row in sending longer than this needs reconciliation, never an automatic retry. SENDING_STALE_MINUTES=10 # Required. Class 4 attempts before permanent suppression. SOFT_BOUNCE_RETRY_CAP=4 # Required. Default off. Turning it on requires the caveat to appear beside every open figure. TRACK_OPENS=false
# Mailcoach Cloud · product brief ## Problem The visible email marketing loop is buildable, but a credible replacement needs more than the first screen. Mailcoach Cloud earns its keep through deliverability, reputation, network, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A mailer that can tell you where every address came from, sends a four thousand person campaign exactly once through a crash, suppresses the addresses that will hurt your domain, and reports figures that survive a question. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - PostgreSQL - transactional or bulk email provider key in .env - verified sending domain ## Explicit non-goals for v1 - 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 ## Success criteria - An import without provenance is confirmed refused - A kill mid-campaign is confirmed to send to nobody twice - A 5.1.1 is confirmed to suppress permanently - Deleting a subscriber is confirmed to keep the suppression - Any open figure is confirmed to carry its caveat in views and exports
# Build brief · Mailcoach Cloud The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter tool that sends each campaign exactly once per subscriber and reports numbers it can actually defend to replace Mailcoach Cloud. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both are about not lying: to your subscribers, and to yourself. Nobody receives mail they did not ask for, and nobody receives it twice. Every subscriber row records when, where and how they subscribed, an import without that provenance is refused, and an address that permanently failed or complained is suppressed forever with no route back except the person subscribing again themselves. Sending to a list you cannot account for is how a sending domain dies, and the domain is the only asset here. Second, do not report a number you cannot defend. Open tracking in particular is not a measurement any more: Apple's own description of Mail Privacy Protection says it downloads remote content in the background by default, regardless of whether you engage with the email (checked against Apple's page), so a pixel fires for people who never looked. If you show an open rate at all, show it beside what it actually counts. Deliveries, permanent failures, complaints and clicks are real; opens are not. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, plus a worker process for sending · a send loop inside a request handler dies with the request - Storage: PostgreSQL, with per-recipient send state as a row · the duplicate-send bug is a database bug - Delivery: One reputable provider the user supplies, behind one send function · you are not going to run a mail server, and should not - Suppression: A permanent table checked before every single send · one table stands between you and a dead domain - Reporting: Events recorded per recipient, with opens labelled for what they are · a number without its caveat becomes a slide ### Data model (create this before Phase 1) - 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. ### Phase 1 · A list you can account for Build: Normalise addresses on the way in. Require source, a timestamp and a detail field for every subscriber, including imports. Reject an import lacking them and produce a per-row report saying which rows failed and why. Record unsubscribes as a state change, never a delete. Trap: 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. Done when: An import missing source or subscription date is refused with a per-row report and nothing is written. Addresses differing only in case or in dot placement where the provider ignores it are detected as duplicates according to a documented normalisation rule, tested. An unsubscribe sets a state and keeps the row, so the address cannot be re-added by a later import, tested. Every subscriber page shows where that address came from and when. Do not build yet: No composing, sending, bounces or reporting yet. ### Phase 2 · An email that renders where it lands Build: Compose in a restricted block editor. Render HTML with inline styles and a simple, wide-support layout, and generate the plain-text part from the same blocks rather than leaving it empty. Send a preview to real addresses on the clients you care about before believing any of it. Trap: 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. Done when: Every campaign has a non-empty plain-text part generated from its content, and sending is refused if it is empty. A preview send reaches a real inbox on each client the README names, and the check is part of the release routine rather than a suggestion. Images have alt text and the email is readable with images disabled, verified by looking at it that way. The from name, address, reply-to and postal address come from the list configuration and are present in every send. Do not build yet: No sending to the list, bounces or reporting yet. ### Phase 3 · Sending four thousand emails exactly once Build: Materialise one sends row per subscriber when the campaign starts, minus suppressions. The worker takes rows in batches, moves each to sending in its own transaction before calling the provider, and records the provider message id on acceptance. A row whose outcome is unknown is never retried blindly. Respect the provider's rate limit with backoff. Trap: 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. Done when: Killing the worker mid-campaign and restarting sends to no address twice, verified by counting rows and by a fake provider that records every call. Two workers running at once do not double-send, tested with concurrent runs. A row left in sending past a stale threshold becomes a row needing reconciliation rather than an automatic retry. Suppressed addresses are excluded at materialisation and again immediately before each call. Do not build yet: No bounce handling or reporting yet. ### Phase 4 · Bounces and complaints, read properly Build: Ingest the provider's delivery notifications. Read the enhanced status code rather than the prose: RFC 3463 defines class 5 as a permanent failure, one 'not likely to be resolved by resending the message in the current form', and class 4 as a persistent transient failure, with X.1.1 meaning the mailbox does not exist (checked against the RFC). Suppress permanently on class 5 and on every complaint; retry class 4 with a cap and then suppress. Trap: 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. Done when: A simulated 5.1.1 notification suppresses the address permanently with the enhanced status recorded, and a later campaign excludes it, tested. A class 4 notification retries up to the configured cap and then suppresses, with each attempt visible. A complaint suppresses immediately regardless of class. The suppression list can be exported and shows reason, code and first-seen date for every entry, and there is no interface route that removes a permanent suppression. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers you can defend, and data that can leave Build: Report deliveries, permanent failures, complaints and clicks as counts with denominators. If you record opens at all, label them as remote image loads and state on the same screen that mail privacy features load images without a person reading. Export subscribers, campaigns, sends, events and suppressions as CSV. Implement deletion of a subscriber and everything derived from them. Trap: 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. Done when: The campaign report shows delivered, failed, complained and clicked with denominators, and any open figure carries its caveat in the same view and in every export. CSV exports of all five tables open cleanly and contain the enhanced status code for every failure. Deleting a subscriber removes their events and send rows while keeping the suppression entry, which is the one thing that must outlive them, and this is tested. A campaign that has not started can be cancelled and one that has started can be paused and resumed without duplicate sends. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which is a reputation project measured in months and is the reason the provider exists. - Deliverability consulting and inbox placement work, which is the actual product at the paid end and is people rather than code. - Paid subscriptions, recommendations and audience networks, each of which is a payments or marketplace product bolted to a mailer. - Automations and behavioural segmentation at scale, which multiply every one of these correctness problems by a state machine per subscriber. ### README must contain - That every subscriber row carries provenance and imports without it are refused - That an unsubscribe is a state, not a deletion, so a later import cannot resurrect the address - How the sends table makes a campaign exactly once, and what happens on a restart mid-campaign - That bounce handling reads the RFC 3463 enhanced status class, suppressing permanently on class 5 and on complaints - That open tracking counts remote image loads, not readers, because mail privacy features load images automatically
# Architecture · Mailcoach Cloud ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, plus a worker process for sending | a send loop inside a request handler dies with the request | | Storage | PostgreSQL, with per-recipient send state as a row | the duplicate-send bug is a database bug | | Delivery | One reputable provider the user supplies, behind one send function | you are not going to run a mail server, and should not | | Suppression | A permanent table checked before every single send | one table stands between you and a dead domain | | Reporting | Events recorded per recipient, with opens labelled for what they are | a number without its caveat becomes a slide | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Subscribers | Provenance, normalisation and unsubscribe state. | Any store, provided unsubscribed addresses survive a re-import. | | Composer | Restricted blocks, HTML and text rendering. | Any editor, provided the text part is generated and non-empty. | | Sender | Per-recipient rows and the transactional lock. | Not swappable for a loop over the subscriber list. | | Suppression | Status class parsing and permanent entries. | Any notification source, provided class 5 and complaints are permanent. | | Reporting | Counts with denominators and the open caveat. | Any presentation, provided the caveat travels with the number. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Subscribers, campaigns, sends, events and suppressions. - `EMAIL_PROVIDER_API_KEY` · required, secret · The provider that actually delivers. Scope it to sending only. - `WEBHOOK_SIGNING_SECRET` · required, secret · Verifies incoming delivery notifications. An unverified notification must never change suppression. - `SEND_BATCH_SIZE` · required · Rows claimed per worker pass. Smaller means less to reconcile after a crash. - `SEND_RATE_PER_MINUTE` · required · Kept under the provider's published limit, which you should check rather than assume. - `SENDING_STALE_MINUTES` · required · A row in sending longer than this needs reconciliation, never an automatic retry. - `SOFT_BOUNCE_RETRY_CAP` · required · Class 4 attempts before permanent suppression. - `TRACK_OPENS` · required · Default off. Turning it on requires the caveat to appear beside every open figure. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Mailcoach Cloud product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, plus a worker process for sending, PostgreSQL, with per-recipient send state as a row, One reputable provider the user supplies, behind one send function, A permanent table checked before every single send, Events recorded per recipient, with opens labelled for what they are. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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.
# Delivery milestones · Mailcoach Cloud Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A list you can account for Every subscriber row carries provenance, and an import without it is refused rather than cleaned up. ### Steps 1. Require provenance on every row Including imports, especially imports. Files: `src/lib/subscribers.ts`, `tests/import-provenance.test.ts` 2. Document the normalisation rule Whatever you choose, write it down, because it decides who is a duplicate. Files: `src/lib/normalise.ts`, `docs/address-normalisation.md` 3. Unsubscribe is a state, never a delete A deleted row is an address a future import will happily re-add. Files: `src/lib/unsubscribe.ts` ### Done when - [ ] An import without provenance is refused with a report - [ ] Duplicates are detected by the documented rule - [ ] Unsubscribed addresses survive a re-import - [ ] Every subscriber shows source and date ### 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. ## M2 · 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. ### Steps 1. Generate the text part from the blocks Not a stripped copy of the HTML, and never empty. Files: `src/lib/render-text.ts` 2. Keep the layout conservative Mail clients are not browsers and never will be. Files: `src/lib/render-html.ts` 3. Preview to real clients before every send The only test that matches reality. Files: `docs/preview-checklist.md` ### Done when - [ ] An empty text part blocks sending - [ ] Previews reach each named client - [ ] The email reads with images disabled - [ ] Sender identity comes from the list configuration ### 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. ## M3 · Sending four thousand emails exactly once A crash or a restart mid-campaign resumes without mailing anyone a second time. ### Steps 1. Materialise a row per recipient up front The campaign becomes a queue you can reason about. Files: `src/lib/campaign-start.ts` 2. Lock the row before the provider call In its own transaction. This is the whole protection. Files: `src/worker/send.ts`, `tests/exactly-once.test.ts` 3. Check suppression twice At materialisation and again at send, because a complaint can arrive mid-campaign. Files: `src/worker/send.ts` ### Done when - [ ] A kill and restart sends to nobody twice - [ ] Two concurrent workers do not double-send - [ ] Stale sending rows need reconciliation, not retry - [ ] Suppression is checked at materialisation and at send ### 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. ## M4 · Bounces and complaints, read properly A permanent failure suppresses the address forever and a temporary one does not. ### Steps 1. Parse the enhanced status code The class digit decides everything; the prose decides nothing. Files: `src/lib/bounces.ts`, `tests/bounce-classes.test.ts` 2. Suppress permanently on class 5 and complaints With no route back through the interface. Files: `src/lib/suppressions.ts` 3. Cap the retries on class 4 A mailbox that is full for a month is a mailbox that is gone. Files: `src/worker/retry.ts` ### Done when - [ ] A 5.1.1 suppresses permanently and excludes later campaigns - [ ] Class 4 retries to the cap then suppresses - [ ] A complaint suppresses immediately - [ ] Suppressions export with reason, code and date ### 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. ## M5 · Numbers you can defend, and data that can leave (production only) The report says what it counted, and everything exports. ### Steps 1. Lead with deliveries and clicks The two figures that mean what people think they mean. Files: `src/app/campaigns/[id]/report.tsx` 2. Attach the open caveat to the number In the view and in the export header, not only in the README. Files: `src/lib/export.ts` 3. Keep the suppression when deleting a person Deleting the record must not make it possible to mail them again. Files: `src/lib/delete-subscriber.ts`, `tests/delete-keeps-suppression.test.ts` ### Done when - [ ] Reports show counts with denominators - [ ] Any open figure carries its caveat everywhere - [ ] All five tables export cleanly with status codes - [ ] Deleting a subscriber keeps their suppression ### Watch 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.
# Operations · Mailcoach Cloud ## Backup The database, nightly, off the machine. The suppression table is the single most important thing in it: losing subscribers is painful, losing the suppression list means mailing addresses that already complained, which is how a domain stops delivering. ## Restore Restore, then move every row left in sending to reconciliation before starting a worker, so a restore does not resend a campaign. Confirm the suppression table came back complete by row count before any send. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Permanent failure rate and complaint rate per campaign, rows stuck in sending, notification endpoint failures, and the gap between accepted and delivered. A rising complaint rate is the only metric here that is an emergency. ## Incident checklist A duplicate send means the lock was bypassed, and the sends table shows exactly which rows were called twice. A campaign that stopped delivering is diagnosed from the notification log and the status codes, not from the provider's dashboard. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] An import without provenance is confirmed refused - [ ] A kill mid-campaign is confirmed to send to nobody twice - [ ] A 5.1.1 is confirmed to suppress permanently - [ ] Deleting a subscriber is confirmed to keep the suppression - [ ] Any open figure is confirmed to carry its caveat in views and exports ## Launch constraint Do not market omitted Mailcoach Cloud capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers, campaigns, sends, events and suppressions. DATABASE_URL=postgres://user:pass@localhost:5432/mail # Required · secret. The provider that actually delivers. Scope it to sending only. EMAIL_PROVIDER_API_KEY=key-...redacted # Required · secret. Verifies incoming delivery notifications. An unverified notification must never change suppression. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. Rows claimed per worker pass. Smaller means less to reconcile after a crash. SEND_BATCH_SIZE=100 # Required. Kept under the provider's published limit, which you should check rather than assume. SEND_RATE_PER_MINUTE=600 # Required. A row in sending longer than this needs reconciliation, never an automatic retry. SENDING_STALE_MINUTES=10 # Required. Class 4 attempts before permanent suppression. SOFT_BOUNCE_RETRY_CAP=4 # Required. Default off. Turning it on requires the caveat to appear beside every open figure. TRACK_OPENS=false
$ choose a build depth, inspect the files, then open the complete pack in your agent
Mailcoach Cloud: The editor is trivial compared with inbox placement, compliance, reputation, unsubscribe handling, analytics, and audience growth infrastructure.
xpayments, recommendations, and audience network
xsender reputation, deliverability, and suppression handling
xsender reputation and deliverability operations
xbounce, complaint, and suppression handling
xlarge-scale segmentation and automation
Don't feel like building it? These folks already made it free.
all 6 free alternatives to Mailcoach Cloud →· no votes, no pay-to-list · just what's real
Mailcoach Cloud pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| cloud | $11.54/workspace | — | 2,000 emails included, unlimited contacts, unlimited lists, unlimited automations. |
free tierno free tier; 14-day trial with 100 emails.
billingmonthly only; usage-based monthly invoice; annual prepayment not available
hidden costsExtra emails are usage-based but the numeric per-email fee did not render; VAT excluded; BYO provider can save 0.001/email; final invoice issued after cancellation.
verified 2026-08-10 · source ↗
Vibecode Mailcoach Cloud
Kinda. The core of Mailcoach Cloud is buildable in a weekend with the prompt on this page, but there are real gaps: payments, recommendations, and audience network, sender reputation, deliverability, and suppression handling. Read the honest list above before committing.
How much does Mailcoach Cloud cost?
Mailcoach Cloud's pricing is usage-based or varies by plan · The pricing model is not safely comparable as one fixed monthly USD amount. Review the official source before publication..
What do I lose by replacing Mailcoach Cloud?
Honestly: 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. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Mailcoach Cloud?
Yes: Mautic (Campaigns, automations, transactional sends and analytics without contact pricing; the stack is anything but lightweight.) Plunk (The same campaign-plus-transactional shape with workflow automation; Docker is the subscription.) Notifuse (Campaigns, transactional mail, visual automations and analytics in one current self-hosted product.) All 6 curated free alternatives are at vibecodeit.com/mailcoach-cloud/alternatives. The prompt is for when you want it exactly your way.