Vibecode Loops
track this build4 phases, 12 steps, beginner friendly0%The visible SaaS email platform loop is buildable, but a credible replacement needs more than the first screen. Loops 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 Loops. 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 ===== # Loops · indie build A small lifecycle email sender whose hard problems are proving consent for every address, making the suppression list impossible to bypass, and resuming a campaign that crashed halfway without mailing the first half twice. Estimated effort: **multi-day**. 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 | | --- | --- | --- | | Sending | One reputable provider's API from a domain you authenticate | a fresh address with no sending history lands in spam and no amount of code fixes that | | Storage | PostgreSQL, with consent and suppression as first-class tables | those two tables are the product, and everything else here is a mail merge | | Templates | One source compiled to both HTML and a plain text alternative | a text part written separately drifts until it contradicts the HTML | | Events | An ingest endpoint idempotent on the sender's own event identifier | webhooks are redelivered, and a redelivery must not restart a workflow | | Feedback | The provider's bounce and complaint webhook, signature verified, feeding suppression | continuing to mail a hard bounce is how a domain's reputation dies | | Queue | One message row per recipient with an idempotency key, never an in-memory loop | a campaign that crashed at contact four thousand must not restart at one | ## 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, the send loop and the tests. - 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: Consent, suppression and message rows all live here, and the resumability of a campaign is a property of the schema. - Get it: Your package manager, or a small managed instance. - Verify: A suppressed address is rejected by every send path in the test suite - [ ] **An email provider account with a verified sending domain** · free at small volume with most providers - Why: Deliverability is the part you are not building, so it is bought rather than written, and the domain authentication is a prerequisite rather than a step. - Get it: Any reputable sending provider, then complete their domain authentication and enable their bounce and complaint webhook. - Verify: A message sent through them passes authentication at a third-party inbox and a bounce webhook arrives for a known bad address - [ ] **Control of the sending domain's DNS** · free if you own the domain - Why: SPF, DKIM and DMARC are records you publish, and without DNS access this build cannot reach its first done-when condition. - Get it: Access to the zone for the domain you will send from, ideally a subdomain reserved for mail. - Verify: All three records resolve and alignment is confirmed from a received header - [ ] **Seed inboxes at three different providers** · free - Why: Authentication results and folder placement differ by receiver, and a local test tells you nothing about either. - Get it: An address at three large consumer providers, kept separate from your real mail. - Verify: A test campaign arrives at all three and the headers are readable - [ ] **Your warm-up plan, soft bounce threshold and retention** · free - Why: Each is asserted somewhere in the build, and deciding them after a reputation problem means deciding them too late. - Get it: Write down the volume ramp for the first fortnight, how many soft bounces suppress an address, and how long consent and message history are kept. - Verify: All three appear in configuration and are named in the tests or the README ## 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: - Purchased or appended lists. - An audience network and cross-promotion. - Your own mail server. - Behavioural segmentation at scale. - bounce, complaint, and suppression handling - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations If one of those is essential to you, that is the reason to keep paying for Loops, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Loops 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 lifecycle email sender that only mails people who asked and can prove it to replace Loops. 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 things, and the law is only the second reason for either. Consent is evidence, not a boolean. Every contact carries when they agreed, by what method, from what address, and the exact wording they were shown. A contact with no evidence row is never sent to, including every row of the spreadsheet somebody assures you is fine. When a complaint arrives, 'they signed up' is not an answer and the evidence row is. The unsubscribe is one action that works with no login. Gmail's published requirements for senders of five thousand or more messages a day are the floor rather than the target: authenticated mail with SPF, DKIM and DMARC, one-click unsubscribe using List-Unsubscribe together with List-Unsubscribe-Post as described in RFC 8058, and a spam complaint rate held below 0.30 percent. Read the current published requirements and your own jurisdiction's rules before the first send, because both move. The suppression list is checked by every send path, and no query in the codebase selects recipients without it. ### Stack (fixed, do not substitute) - Sending: One reputable provider's API from a domain you authenticate · a fresh address with no sending history lands in spam and no amount of code fixes that - Storage: PostgreSQL, with consent and suppression as first-class tables · those two tables are the product, and everything else here is a mail merge - Templates: One source compiled to both HTML and a plain text alternative · a text part written separately drifts until it contradicts the HTML - Events: An ingest endpoint idempotent on the sender's own event identifier · webhooks are redelivered, and a redelivery must not restart a workflow - Feedback: The provider's bounce and complaint webhook, signature verified, feeding suppression · continuing to mail a hard bounce is how a domain's reputation dies - Queue: One message row per recipient with an idempotency key, never an in-memory loop · a campaign that crashed at contact four thousand must not restart at one ### Data model (create this before Phase 1) - contacts(id, email_normalised, email_original, created_at, source, properties_json, state) - consent(contact_id, at, method, ip, user_agent, wording_shown, list_id) · the evidence, kept as long as you mail them and a while after - suppressions(email_normalised, reason, at, source, permanent) · unsubscribe, hard bounce, complaint or manual, checked by every send path - lists(id, name, description_shown_at_signup) · what they were told they were joining, because that is what consent was given to - workflows(id, name, trigger_event, steps_json, active) and workflow_state(contact_id, workflow_id, step_index, next_due_at, entered_at, exited_at, exit_reason) - messages(id, contact_id, workflow_id, campaign_id, provider_message_id, state, idempotency_key, queued_at, sent_at, delivered_at, bounced_at, complained_at) - events(id, external_id, contact_id, name, at, properties_json) · unique on external_id, because the same event will arrive twice - The suppression check is a join every send path performs, and a test asserts that no recipient query exists without it. ### Phase 1 · An authenticated domain and a message that passes the checks Build: Publish SPF, DKIM and DMARC for the sending domain and confirm alignment. Put List-Unsubscribe and List-Unsubscribe-Post on every marketing message and on none of the transactional ones. Generate the plain text alternative from the same source as the HTML. Put a postal address and the list name in the footer. Send to seed addresses at several providers and read the authentication results out of the received headers. Trap: Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. Done when: A test message passes SPF, DKIM and DMARC read from the received headers at three different providers. The one-click header pair is present on every marketing message and absent from transactional ones, asserted in the suite. The plain text alternative is generated from the same source and contains the same links, tested. The footer carries a postal address and names the list the contact is on. Do not build yet: No contacts, no workflows, no sending to anybody but seeds. ### Phase 2 · Consent you can produce, suppression you cannot bypass Build: Record on signup the time, method, address, agent and the exact wording shown. Refuse an import row that carries no evidence. Implement the unsubscribe as a single POST that suppresses immediately with no login, and treat any page shown afterwards as a courtesy. Honour an opt-out at once rather than at the legal deadline: the United States rule allows ten business days and requires the mechanism to keep working at least thirty days after the message, and your own jurisdiction may be stricter, so read it. Trap: An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. Done when: An import row with no evidence column is refused with its reason, tested row by row. A one-click POST suppresses the address in a single request with no login, tested. Every send path has a test that inserts a suppressed address and asserts zero messages leave. An unsubscribe link from an old message still works after the period you documented, checked with a dated fixture. Do not build yet: No workflows, campaigns or feedback handling yet. ### Phase 3 · Events in, workflow out, once per contact Build: Ingest events with a unique constraint on the sender's identifier. Enter a contact into a workflow on a trigger event, advance per-contact state with a due time per step, and record an exit with its reason. Check suppression in the send path itself, immediately before the message is handed to the provider, never at the moment of entry. Trap: Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. Done when: The same event delivered twice enters the workflow once, tested by replaying the payload. A contact who unsubscribes mid-workflow receives no further steps, tested at each step boundary. A delay step fires within a stated tolerance of its due time and never before it. Exiting records a reason, and the same event does not cause a re-entry. Do not build yet: No campaign sending, bounces or complaints yet. ### Phase 4 · Sending a campaign that can crash Build: Materialise one message row per recipient, each with an idempotency key, before the first send. The send loop claims a row in its own transaction, sends, then records the provider's identifier. Respect the provider's published rate limits with backoff rather than a fixed sleep. Provide a stop control that halts within one claim and shows sent, remaining and failed counts. Trap: Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. Done when: Killing the process halfway through a ten thousand row campaign and restarting sends exactly the remainder, asserted with a counting fake provider. Each message carries an idempotency key and a confirmed send is never retried. The stop control halts within one claim and the counts shown add up to the row total. Rate limiting uses the provider's response rather than a guessed interval. Do not build yet: No bounce, complaint or deletion handling yet. ### Phase 5 · Bounces, complaints and the number that decides everything Build: Verify the webhook signature before parsing anything. A hard bounce suppresses permanently; a repeated soft bounce suppresses at a stated threshold; a complaint suppresses and alerts. Show the complaint rate for a stated period against the published threshold. Export contacts, consent, suppressions and message history. Delete a contact's personal data on request while keeping the suppression. Trap: Honouring a deletion request by removing the suppression row along with the contact means the next import puts that address straight back on the list and mails somebody who told you to stop, which is worse than not honouring the deletion at all. Keep the suppression keyed on the normalised address after the personal fields are gone, and say so plainly in your privacy notice. Done when: An unsigned or wrongly signed webhook is rejected before parsing, tested. A hard bounce suppresses permanently within one delivery, and a repeated soft bounce suppresses at the stated threshold. The complaint rate is shown with the period it covers and compared against the published threshold. Deleting a contact removes personal fields, keeps the suppression, and reimporting the address results in zero sends, tested end to end. Do not build yet: Nothing after this. ### Out of scope (and why) - Purchased, scraped or appended lists, which destroy a sending domain faster than anything else and are unlawful in several jurisdictions. - An audience network, cross-promotion and payments, which are a large part of what the paid product actually sells. - Open rate as a headline metric, because a growing share of recorded opens are privacy proxies fetching images nobody looked at. - Running your own mail server, where a new address with no reputation goes to spam regardless of how well you configure it. - Segmentation over behavioural history at scale, which needs an analytics store rather than a mailing tool. ### README must contain - That no address is mailed without an evidence row, and what the evidence row contains - That the one-click unsubscribe suppresses in a single request with no login, and which RFC describes the header pair - Which authentication records the sending domain needs, and how to confirm alignment rather than presence - That a campaign is resumable because every message row is written before the first send - That a deletion request removes personal data but keeps the suppression, and why ===== AGENTS.md ===== # Agent instructions · Loops indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One reputable provider's API from a domain you authenticate, PostgreSQL, with consent and suppression as first-class tables, One source compiled to both HTML and a plain text alternative, An ingest endpoint idempotent on the sender's own event identifier, The provider's bounce and complaint webhook, signature verified, feeding suppression, One message row per recipient with an idempotency key, never an in-memory loop. 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 - Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. - An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. - Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. - Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. ===== BUILD_PLAN.md ===== # Build plan · Loops A small lifecycle email sender whose hard problems are proving consent for every address, making the suppression list impossible to bypass, and resuming a campaign that crashed halfway without mailing the first half twice. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An authenticated domain and a message that passes the checks Mail is authenticated, unsubscribable in one action, and proved against real inboxes before a list sees it. ### Steps 1. Publish and verify the three records Alignment, not merely presence, is what a receiver checks. Files: `docs/dns-setup.md`, `scripts/check-auth.ts` 2. Generate the text part from the same source Two sources means two messages and one of them is wrong. Files: `src/templates/compile.ts`, `tests/text-part.test.ts` 3. Read the headers at real inboxes A seed at three providers tells you more than any local test. Files: `docs/seed-list.md` ### Done when - [ ] SPF, DKIM and DMARC pass at three providers - [ ] One-click headers on marketing mail only - [ ] Text and HTML come from one source - [ ] Footer carries address and list name ### Watch out - Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. ## Phase 2 · Consent you can produce, suppression you cannot bypass No address is mailed without an evidence row, and no suppressed address is mailed at all. ### Steps 1. Store the wording, not just the timestamp Consent is to something specific, and that something changes. Files: `migrations/002_consent.sql`, `src/signup/record.ts` 2. Make the one-click POST do the whole job No login, no confirmation step, no second request. Files: `src/unsubscribe/route.ts`, `tests/oneclick.test.ts` 3. Test every send path against a suppressed address One untested path is the one that will mail them. Files: `tests/suppression.test.ts` ### Done when - [ ] Imports without evidence are refused - [ ] One request unsubscribes with no login - [ ] Every send path is tested against suppression - [ ] Old unsubscribe links still work ### Watch out - An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. ## Phase 3 · Events in, workflow out, once per contact A redelivered event does not restart a workflow or send a second message. ### Steps 1. Constrain on the sender's event identifier The cheapest protection against a redelivered webhook. Files: `migrations/003_events.sql`, `src/ingest/route.ts` 2. Keep per-contact workflow state with a due time So a restart resumes rather than replays. Files: `src/workflow/state.ts` 3. Check suppression in the send path Not at entry, where it will be hours or days out of date. Files: `src/send/guard.ts`, `tests/mid-workflow-unsub.test.ts` ### Done when - [ ] A duplicate event enters the workflow once - [ ] Unsubscribing mid-workflow stops the next step - [ ] Delays fire within tolerance and never early - [ ] Exits record a reason and prevent re-entry ### Watch out - Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. ## Phase 4 · Sending a campaign that can crash A campaign restarted after a crash sends only what it had not sent. ### Steps 1. Write every row before the first send The campaign becomes resumable by construction. Files: `src/campaign/materialise.ts` 2. Claim a row in its own transaction One claim, one send, no ambiguity after a crash. Files: `src/campaign/send.ts`, `tests/resume.test.ts` 3. Back off on the provider's signal A fixed sleep is either too slow or a rate limit ban. Files: `src/provider/client.ts` ### Done when - [ ] A restart sends exactly the remainder - [ ] Confirmed sends are never retried - [ ] Stop halts within one claim with correct counts - [ ] Backoff follows the provider's response ### Watch out - Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. ## Not in this build - Purchased or appended lists. - An audience network and cross-promotion. - Your own mail server. - Behavioural segmentation at scale. ## After v1, if you want it - A second workflow trigger from a product event, which the idempotent ingest already supports - Link click recording with the caveat that opens will not be reported, once the complaint rate has been stable for a month - A plain digest of list health by consent source, which the tables already contain everything for ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Contacts, consent, suppressions, messages. The consent table is the one you cannot lose. DATABASE_URL=postgres://localhost:5432/mail # Required · secret. Scoped to sending only if the provider allows it. EMAIL_PROVIDER_API_KEY=sk-...redacted # Required. A subdomain reserved for this, so a reputation problem does not touch your main domain. SENDING_DOMAIN=mail.example.com # Required. Must align with the DKIM signing domain for DMARC to pass. FROM_ADDRESS=hello@mail.example.com # Required. Rendered in every marketing footer. Required in several jurisdictions. POSTAL_ADDRESS=1 Example Street, Example Town, EX1 1EX # Required. Links in old messages resolve here, so it must outlive any campaign. UNSUBSCRIBE_BASE_URL=https://mail.example.com/u # Required · secret. Verified before the bounce or complaint payload is parsed. WEBHOOK_SIGNING_SECRET=sk-...redacted # Required. Alert well under the published threshold, because by the time you reach it delivery has already suffered. COMPLAINT_RATE_ALERT=0.001
You are building a lean indie version of Loops. 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 ===== # Loops · indie build A small lifecycle email sender whose hard problems are proving consent for every address, making the suppression list impossible to bypass, and resuming a campaign that crashed halfway without mailing the first half twice. Estimated effort: **multi-day**. 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 | | --- | --- | --- | | Sending | One reputable provider's API from a domain you authenticate | a fresh address with no sending history lands in spam and no amount of code fixes that | | Storage | PostgreSQL, with consent and suppression as first-class tables | those two tables are the product, and everything else here is a mail merge | | Templates | One source compiled to both HTML and a plain text alternative | a text part written separately drifts until it contradicts the HTML | | Events | An ingest endpoint idempotent on the sender's own event identifier | webhooks are redelivered, and a redelivery must not restart a workflow | | Feedback | The provider's bounce and complaint webhook, signature verified, feeding suppression | continuing to mail a hard bounce is how a domain's reputation dies | | Queue | One message row per recipient with an idempotency key, never an in-memory loop | a campaign that crashed at contact four thousand must not restart at one | ## 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, the send loop and the tests. - 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: Consent, suppression and message rows all live here, and the resumability of a campaign is a property of the schema. - Get it: Your package manager, or a small managed instance. - Verify: A suppressed address is rejected by every send path in the test suite - [ ] **An email provider account with a verified sending domain** · free at small volume with most providers - Why: Deliverability is the part you are not building, so it is bought rather than written, and the domain authentication is a prerequisite rather than a step. - Get it: Any reputable sending provider, then complete their domain authentication and enable their bounce and complaint webhook. - Verify: A message sent through them passes authentication at a third-party inbox and a bounce webhook arrives for a known bad address - [ ] **Control of the sending domain's DNS** · free if you own the domain - Why: SPF, DKIM and DMARC are records you publish, and without DNS access this build cannot reach its first done-when condition. - Get it: Access to the zone for the domain you will send from, ideally a subdomain reserved for mail. - Verify: All three records resolve and alignment is confirmed from a received header - [ ] **Seed inboxes at three different providers** · free - Why: Authentication results and folder placement differ by receiver, and a local test tells you nothing about either. - Get it: An address at three large consumer providers, kept separate from your real mail. - Verify: A test campaign arrives at all three and the headers are readable - [ ] **Your warm-up plan, soft bounce threshold and retention** · free - Why: Each is asserted somewhere in the build, and deciding them after a reputation problem means deciding them too late. - Get it: Write down the volume ramp for the first fortnight, how many soft bounces suppress an address, and how long consent and message history are kept. - Verify: All three appear in configuration and are named in the tests or the README ## 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: - Purchased or appended lists. - An audience network and cross-promotion. - Your own mail server. - Behavioural segmentation at scale. - bounce, complaint, and suppression handling - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations If one of those is essential to you, that is the reason to keep paying for Loops, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Loops 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 lifecycle email sender that only mails people who asked and can prove it to replace Loops. 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 things, and the law is only the second reason for either. Consent is evidence, not a boolean. Every contact carries when they agreed, by what method, from what address, and the exact wording they were shown. A contact with no evidence row is never sent to, including every row of the spreadsheet somebody assures you is fine. When a complaint arrives, 'they signed up' is not an answer and the evidence row is. The unsubscribe is one action that works with no login. Gmail's published requirements for senders of five thousand or more messages a day are the floor rather than the target: authenticated mail with SPF, DKIM and DMARC, one-click unsubscribe using List-Unsubscribe together with List-Unsubscribe-Post as described in RFC 8058, and a spam complaint rate held below 0.30 percent. Read the current published requirements and your own jurisdiction's rules before the first send, because both move. The suppression list is checked by every send path, and no query in the codebase selects recipients without it. ### Stack (fixed, do not substitute) - Sending: One reputable provider's API from a domain you authenticate · a fresh address with no sending history lands in spam and no amount of code fixes that - Storage: PostgreSQL, with consent and suppression as first-class tables · those two tables are the product, and everything else here is a mail merge - Templates: One source compiled to both HTML and a plain text alternative · a text part written separately drifts until it contradicts the HTML - Events: An ingest endpoint idempotent on the sender's own event identifier · webhooks are redelivered, and a redelivery must not restart a workflow - Feedback: The provider's bounce and complaint webhook, signature verified, feeding suppression · continuing to mail a hard bounce is how a domain's reputation dies - Queue: One message row per recipient with an idempotency key, never an in-memory loop · a campaign that crashed at contact four thousand must not restart at one ### Data model (create this before Phase 1) - contacts(id, email_normalised, email_original, created_at, source, properties_json, state) - consent(contact_id, at, method, ip, user_agent, wording_shown, list_id) · the evidence, kept as long as you mail them and a while after - suppressions(email_normalised, reason, at, source, permanent) · unsubscribe, hard bounce, complaint or manual, checked by every send path - lists(id, name, description_shown_at_signup) · what they were told they were joining, because that is what consent was given to - workflows(id, name, trigger_event, steps_json, active) and workflow_state(contact_id, workflow_id, step_index, next_due_at, entered_at, exited_at, exit_reason) - messages(id, contact_id, workflow_id, campaign_id, provider_message_id, state, idempotency_key, queued_at, sent_at, delivered_at, bounced_at, complained_at) - events(id, external_id, contact_id, name, at, properties_json) · unique on external_id, because the same event will arrive twice - The suppression check is a join every send path performs, and a test asserts that no recipient query exists without it. ### Phase 1 · An authenticated domain and a message that passes the checks Build: Publish SPF, DKIM and DMARC for the sending domain and confirm alignment. Put List-Unsubscribe and List-Unsubscribe-Post on every marketing message and on none of the transactional ones. Generate the plain text alternative from the same source as the HTML. Put a postal address and the list name in the footer. Send to seed addresses at several providers and read the authentication results out of the received headers. Trap: Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. Done when: A test message passes SPF, DKIM and DMARC read from the received headers at three different providers. The one-click header pair is present on every marketing message and absent from transactional ones, asserted in the suite. The plain text alternative is generated from the same source and contains the same links, tested. The footer carries a postal address and names the list the contact is on. Do not build yet: No contacts, no workflows, no sending to anybody but seeds. ### Phase 2 · Consent you can produce, suppression you cannot bypass Build: Record on signup the time, method, address, agent and the exact wording shown. Refuse an import row that carries no evidence. Implement the unsubscribe as a single POST that suppresses immediately with no login, and treat any page shown afterwards as a courtesy. Honour an opt-out at once rather than at the legal deadline: the United States rule allows ten business days and requires the mechanism to keep working at least thirty days after the message, and your own jurisdiction may be stricter, so read it. Trap: An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. Done when: An import row with no evidence column is refused with its reason, tested row by row. A one-click POST suppresses the address in a single request with no login, tested. Every send path has a test that inserts a suppressed address and asserts zero messages leave. An unsubscribe link from an old message still works after the period you documented, checked with a dated fixture. Do not build yet: No workflows, campaigns or feedback handling yet. ### Phase 3 · Events in, workflow out, once per contact Build: Ingest events with a unique constraint on the sender's identifier. Enter a contact into a workflow on a trigger event, advance per-contact state with a due time per step, and record an exit with its reason. Check suppression in the send path itself, immediately before the message is handed to the provider, never at the moment of entry. Trap: Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. Done when: The same event delivered twice enters the workflow once, tested by replaying the payload. A contact who unsubscribes mid-workflow receives no further steps, tested at each step boundary. A delay step fires within a stated tolerance of its due time and never before it. Exiting records a reason, and the same event does not cause a re-entry. Do not build yet: No campaign sending, bounces or complaints yet. ### Phase 4 · Sending a campaign that can crash Build: Materialise one message row per recipient, each with an idempotency key, before the first send. The send loop claims a row in its own transaction, sends, then records the provider's identifier. Respect the provider's published rate limits with backoff rather than a fixed sleep. Provide a stop control that halts within one claim and shows sent, remaining and failed counts. Trap: Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. Done when: Killing the process halfway through a ten thousand row campaign and restarting sends exactly the remainder, asserted with a counting fake provider. Each message carries an idempotency key and a confirmed send is never retried. The stop control halts within one claim and the counts shown add up to the row total. Rate limiting uses the provider's response rather than a guessed interval. Do not build yet: No bounce, complaint or deletion handling yet. ### Phase 5 · Bounces, complaints and the number that decides everything Build: Verify the webhook signature before parsing anything. A hard bounce suppresses permanently; a repeated soft bounce suppresses at a stated threshold; a complaint suppresses and alerts. Show the complaint rate for a stated period against the published threshold. Export contacts, consent, suppressions and message history. Delete a contact's personal data on request while keeping the suppression. Trap: Honouring a deletion request by removing the suppression row along with the contact means the next import puts that address straight back on the list and mails somebody who told you to stop, which is worse than not honouring the deletion at all. Keep the suppression keyed on the normalised address after the personal fields are gone, and say so plainly in your privacy notice. Done when: An unsigned or wrongly signed webhook is rejected before parsing, tested. A hard bounce suppresses permanently within one delivery, and a repeated soft bounce suppresses at the stated threshold. The complaint rate is shown with the period it covers and compared against the published threshold. Deleting a contact removes personal fields, keeps the suppression, and reimporting the address results in zero sends, tested end to end. Do not build yet: Nothing after this. ### Out of scope (and why) - Purchased, scraped or appended lists, which destroy a sending domain faster than anything else and are unlawful in several jurisdictions. - An audience network, cross-promotion and payments, which are a large part of what the paid product actually sells. - Open rate as a headline metric, because a growing share of recorded opens are privacy proxies fetching images nobody looked at. - Running your own mail server, where a new address with no reputation goes to spam regardless of how well you configure it. - Segmentation over behavioural history at scale, which needs an analytics store rather than a mailing tool. ### README must contain - That no address is mailed without an evidence row, and what the evidence row contains - That the one-click unsubscribe suppresses in a single request with no login, and which RFC describes the header pair - Which authentication records the sending domain needs, and how to confirm alignment rather than presence - That a campaign is resumable because every message row is written before the first send - That a deletion request removes personal data but keeps the suppression, and why ===== AGENTS.md ===== # Agent instructions · Loops indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One reputable provider's API from a domain you authenticate, PostgreSQL, with consent and suppression as first-class tables, One source compiled to both HTML and a plain text alternative, An ingest endpoint idempotent on the sender's own event identifier, The provider's bounce and complaint webhook, signature verified, feeding suppression, One message row per recipient with an idempotency key, never an in-memory loop. 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 - Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. - An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. - Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. - Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. ===== BUILD_PLAN.md ===== # Build plan · Loops A small lifecycle email sender whose hard problems are proving consent for every address, making the suppression list impossible to bypass, and resuming a campaign that crashed halfway without mailing the first half twice. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An authenticated domain and a message that passes the checks Mail is authenticated, unsubscribable in one action, and proved against real inboxes before a list sees it. ### Steps 1. Publish and verify the three records Alignment, not merely presence, is what a receiver checks. Files: `docs/dns-setup.md`, `scripts/check-auth.ts` 2. Generate the text part from the same source Two sources means two messages and one of them is wrong. Files: `src/templates/compile.ts`, `tests/text-part.test.ts` 3. Read the headers at real inboxes A seed at three providers tells you more than any local test. Files: `docs/seed-list.md` ### Done when - [ ] SPF, DKIM and DMARC pass at three providers - [ ] One-click headers on marketing mail only - [ ] Text and HTML come from one source - [ ] Footer carries address and list name ### Watch out - Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. ## Phase 2 · Consent you can produce, suppression you cannot bypass No address is mailed without an evidence row, and no suppressed address is mailed at all. ### Steps 1. Store the wording, not just the timestamp Consent is to something specific, and that something changes. Files: `migrations/002_consent.sql`, `src/signup/record.ts` 2. Make the one-click POST do the whole job No login, no confirmation step, no second request. Files: `src/unsubscribe/route.ts`, `tests/oneclick.test.ts` 3. Test every send path against a suppressed address One untested path is the one that will mail them. Files: `tests/suppression.test.ts` ### Done when - [ ] Imports without evidence are refused - [ ] One request unsubscribes with no login - [ ] Every send path is tested against suppression - [ ] Old unsubscribe links still work ### Watch out - An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. ## Phase 3 · Events in, workflow out, once per contact A redelivered event does not restart a workflow or send a second message. ### Steps 1. Constrain on the sender's event identifier The cheapest protection against a redelivered webhook. Files: `migrations/003_events.sql`, `src/ingest/route.ts` 2. Keep per-contact workflow state with a due time So a restart resumes rather than replays. Files: `src/workflow/state.ts` 3. Check suppression in the send path Not at entry, where it will be hours or days out of date. Files: `src/send/guard.ts`, `tests/mid-workflow-unsub.test.ts` ### Done when - [ ] A duplicate event enters the workflow once - [ ] Unsubscribing mid-workflow stops the next step - [ ] Delays fire within tolerance and never early - [ ] Exits record a reason and prevent re-entry ### Watch out - Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. ## Phase 4 · Sending a campaign that can crash A campaign restarted after a crash sends only what it had not sent. ### Steps 1. Write every row before the first send The campaign becomes resumable by construction. Files: `src/campaign/materialise.ts` 2. Claim a row in its own transaction One claim, one send, no ambiguity after a crash. Files: `src/campaign/send.ts`, `tests/resume.test.ts` 3. Back off on the provider's signal A fixed sleep is either too slow or a rate limit ban. Files: `src/provider/client.ts` ### Done when - [ ] A restart sends exactly the remainder - [ ] Confirmed sends are never retried - [ ] Stop halts within one claim with correct counts - [ ] Backoff follows the provider's response ### Watch out - Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. ## Not in this build - Purchased or appended lists. - An audience network and cross-promotion. - Your own mail server. - Behavioural segmentation at scale. ## After v1, if you want it - A second workflow trigger from a product event, which the idempotent ingest already supports - Link click recording with the caveat that opens will not be reported, once the complaint rate has been stable for a month - A plain digest of list health by consent source, which the tables already contain everything for ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Contacts, consent, suppressions, messages. The consent table is the one you cannot lose. DATABASE_URL=postgres://localhost:5432/mail # Required · secret. Scoped to sending only if the provider allows it. EMAIL_PROVIDER_API_KEY=sk-...redacted # Required. A subdomain reserved for this, so a reputation problem does not touch your main domain. SENDING_DOMAIN=mail.example.com # Required. Must align with the DKIM signing domain for DMARC to pass. FROM_ADDRESS=hello@mail.example.com # Required. Rendered in every marketing footer. Required in several jurisdictions. POSTAL_ADDRESS=1 Example Street, Example Town, EX1 1EX # Required. Links in old messages resolve here, so it must outlive any campaign. UNSUBSCRIBE_BASE_URL=https://mail.example.com/u # Required · secret. Verified before the bounce or complaint payload is parsed. WEBHOOK_SIGNING_SECRET=sk-...redacted # Required. Alert well under the published threshold, because by the time you reach it delivery has already suffered. COMPLAINT_RATE_ALERT=0.001
You are building a production product version of Loops. 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 ===== # Loops · product brief ## Problem The visible SaaS email platform loop is buildable, but a credible replacement needs more than the first screen. Loops earns its keep through deliverability, reputation, network, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A sender that can produce the evidence for every address it mails, stops within one request when somebody unsubscribes, resumes a crashed campaign without duplicating a single message, and reacts to bounces and complaints without anybody reading a log. ## 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 - Purchased or appended lists. - An audience network and cross-promotion. - Your own mail server. - Behavioural segmentation at scale. - bounce, complaint, and suppression handling - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations ## Success criteria - Authentication is confirmed passing at three providers - A one-click POST is confirmed to suppress in a single request - Every send path is confirmed tested against a suppressed address - A crashed campaign is confirmed to resume without duplicates - A deletion is confirmed to keep the suppression ===== BRIEF.md ===== # Build brief · Loops 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 lifecycle email sender that only mails people who asked and can prove it to replace Loops. 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 things, and the law is only the second reason for either. Consent is evidence, not a boolean. Every contact carries when they agreed, by what method, from what address, and the exact wording they were shown. A contact with no evidence row is never sent to, including every row of the spreadsheet somebody assures you is fine. When a complaint arrives, 'they signed up' is not an answer and the evidence row is. The unsubscribe is one action that works with no login. Gmail's published requirements for senders of five thousand or more messages a day are the floor rather than the target: authenticated mail with SPF, DKIM and DMARC, one-click unsubscribe using List-Unsubscribe together with List-Unsubscribe-Post as described in RFC 8058, and a spam complaint rate held below 0.30 percent. Read the current published requirements and your own jurisdiction's rules before the first send, because both move. The suppression list is checked by every send path, and no query in the codebase selects recipients without it. ### Stack (fixed, do not substitute) - Sending: One reputable provider's API from a domain you authenticate · a fresh address with no sending history lands in spam and no amount of code fixes that - Storage: PostgreSQL, with consent and suppression as first-class tables · those two tables are the product, and everything else here is a mail merge - Templates: One source compiled to both HTML and a plain text alternative · a text part written separately drifts until it contradicts the HTML - Events: An ingest endpoint idempotent on the sender's own event identifier · webhooks are redelivered, and a redelivery must not restart a workflow - Feedback: The provider's bounce and complaint webhook, signature verified, feeding suppression · continuing to mail a hard bounce is how a domain's reputation dies - Queue: One message row per recipient with an idempotency key, never an in-memory loop · a campaign that crashed at contact four thousand must not restart at one ### Data model (create this before Phase 1) - contacts(id, email_normalised, email_original, created_at, source, properties_json, state) - consent(contact_id, at, method, ip, user_agent, wording_shown, list_id) · the evidence, kept as long as you mail them and a while after - suppressions(email_normalised, reason, at, source, permanent) · unsubscribe, hard bounce, complaint or manual, checked by every send path - lists(id, name, description_shown_at_signup) · what they were told they were joining, because that is what consent was given to - workflows(id, name, trigger_event, steps_json, active) and workflow_state(contact_id, workflow_id, step_index, next_due_at, entered_at, exited_at, exit_reason) - messages(id, contact_id, workflow_id, campaign_id, provider_message_id, state, idempotency_key, queued_at, sent_at, delivered_at, bounced_at, complained_at) - events(id, external_id, contact_id, name, at, properties_json) · unique on external_id, because the same event will arrive twice - The suppression check is a join every send path performs, and a test asserts that no recipient query exists without it. ### Phase 1 · An authenticated domain and a message that passes the checks Build: Publish SPF, DKIM and DMARC for the sending domain and confirm alignment. Put List-Unsubscribe and List-Unsubscribe-Post on every marketing message and on none of the transactional ones. Generate the plain text alternative from the same source as the HTML. Put a postal address and the list name in the footer. Send to seed addresses at several providers and read the authentication results out of the received headers. Trap: Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. Done when: A test message passes SPF, DKIM and DMARC read from the received headers at three different providers. The one-click header pair is present on every marketing message and absent from transactional ones, asserted in the suite. The plain text alternative is generated from the same source and contains the same links, tested. The footer carries a postal address and names the list the contact is on. Do not build yet: No contacts, no workflows, no sending to anybody but seeds. ### Phase 2 · Consent you can produce, suppression you cannot bypass Build: Record on signup the time, method, address, agent and the exact wording shown. Refuse an import row that carries no evidence. Implement the unsubscribe as a single POST that suppresses immediately with no login, and treat any page shown afterwards as a courtesy. Honour an opt-out at once rather than at the legal deadline: the United States rule allows ten business days and requires the mechanism to keep working at least thirty days after the message, and your own jurisdiction may be stricter, so read it. Trap: An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. Done when: An import row with no evidence column is refused with its reason, tested row by row. A one-click POST suppresses the address in a single request with no login, tested. Every send path has a test that inserts a suppressed address and asserts zero messages leave. An unsubscribe link from an old message still works after the period you documented, checked with a dated fixture. Do not build yet: No workflows, campaigns or feedback handling yet. ### Phase 3 · Events in, workflow out, once per contact Build: Ingest events with a unique constraint on the sender's identifier. Enter a contact into a workflow on a trigger event, advance per-contact state with a due time per step, and record an exit with its reason. Check suppression in the send path itself, immediately before the message is handed to the provider, never at the moment of entry. Trap: Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. Done when: The same event delivered twice enters the workflow once, tested by replaying the payload. A contact who unsubscribes mid-workflow receives no further steps, tested at each step boundary. A delay step fires within a stated tolerance of its due time and never before it. Exiting records a reason, and the same event does not cause a re-entry. Do not build yet: No campaign sending, bounces or complaints yet. ### Phase 4 · Sending a campaign that can crash Build: Materialise one message row per recipient, each with an idempotency key, before the first send. The send loop claims a row in its own transaction, sends, then records the provider's identifier. Respect the provider's published rate limits with backoff rather than a fixed sleep. Provide a stop control that halts within one claim and shows sent, remaining and failed counts. Trap: Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. Done when: Killing the process halfway through a ten thousand row campaign and restarting sends exactly the remainder, asserted with a counting fake provider. Each message carries an idempotency key and a confirmed send is never retried. The stop control halts within one claim and the counts shown add up to the row total. Rate limiting uses the provider's response rather than a guessed interval. Do not build yet: No bounce, complaint or deletion handling yet. ### Phase 5 · Bounces, complaints and the number that decides everything Build: Verify the webhook signature before parsing anything. A hard bounce suppresses permanently; a repeated soft bounce suppresses at a stated threshold; a complaint suppresses and alerts. Show the complaint rate for a stated period against the published threshold. Export contacts, consent, suppressions and message history. Delete a contact's personal data on request while keeping the suppression. Trap: Honouring a deletion request by removing the suppression row along with the contact means the next import puts that address straight back on the list and mails somebody who told you to stop, which is worse than not honouring the deletion at all. Keep the suppression keyed on the normalised address after the personal fields are gone, and say so plainly in your privacy notice. Done when: An unsigned or wrongly signed webhook is rejected before parsing, tested. A hard bounce suppresses permanently within one delivery, and a repeated soft bounce suppresses at the stated threshold. The complaint rate is shown with the period it covers and compared against the published threshold. Deleting a contact removes personal fields, keeps the suppression, and reimporting the address results in zero sends, tested end to end. Do not build yet: Nothing after this. ### Out of scope (and why) - Purchased, scraped or appended lists, which destroy a sending domain faster than anything else and are unlawful in several jurisdictions. - An audience network, cross-promotion and payments, which are a large part of what the paid product actually sells. - Open rate as a headline metric, because a growing share of recorded opens are privacy proxies fetching images nobody looked at. - Running your own mail server, where a new address with no reputation goes to spam regardless of how well you configure it. - Segmentation over behavioural history at scale, which needs an analytics store rather than a mailing tool. ### README must contain - That no address is mailed without an evidence row, and what the evidence row contains - That the one-click unsubscribe suppresses in a single request with no login, and which RFC describes the header pair - Which authentication records the sending domain needs, and how to confirm alignment rather than presence - That a campaign is resumable because every message row is written before the first send - That a deletion request removes personal data but keeps the suppression, and why ===== ARCHITECTURE.md ===== # Architecture · Loops ## Stack | Part | Choice | Why | | --- | --- | --- | | Sending | One reputable provider's API from a domain you authenticate | a fresh address with no sending history lands in spam and no amount of code fixes that | | Storage | PostgreSQL, with consent and suppression as first-class tables | those two tables are the product, and everything else here is a mail merge | | Templates | One source compiled to both HTML and a plain text alternative | a text part written separately drifts until it contradicts the HTML | | Events | An ingest endpoint idempotent on the sender's own event identifier | webhooks are redelivered, and a redelivery must not restart a workflow | | Feedback | The provider's bounce and complaint webhook, signature verified, feeding suppression | continuing to mail a hard bounce is how a domain's reputation dies | | Queue | One message row per recipient with an idempotency key, never an in-memory loop | a campaign that crashed at contact four thousand must not restart at one | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Consent | Evidence rows and import refusal. | Any signup surface, provided the wording shown is recorded with the row. | | Suppression | The list every send path joins against. | Not swappable for a flag on the contact, which an import will overwrite. | | Workflows | Idempotent events and per-contact state. | Any engine, provided suppression is checked in the send path itself. | | Campaigns | Materialised rows, claims and resumability. | Any queue, provided every row exists before the first send. | | Feedback | Verified webhooks, bounces, complaints and rates. | Any provider, provided a hard bounce suppresses permanently. | ## 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 · Contacts, consent, suppressions, messages. The consent table is the one you cannot lose. - `EMAIL_PROVIDER_API_KEY` · required, secret · Scoped to sending only if the provider allows it. - `SENDING_DOMAIN` · required · A subdomain reserved for this, so a reputation problem does not touch your main domain. - `FROM_ADDRESS` · required · Must align with the DKIM signing domain for DMARC to pass. - `POSTAL_ADDRESS` · required · Rendered in every marketing footer. Required in several jurisdictions. - `UNSUBSCRIBE_BASE_URL` · required · Links in old messages resolve here, so it must outlive any campaign. - `WEBHOOK_SIGNING_SECRET` · required, secret · Verified before the bounce or complaint payload is parsed. - `COMPLAINT_RATE_ALERT` · required · Alert well under the published threshold, because by the time you reach it delivery has already suffered. ## 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 · Loops product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One reputable provider's API from a domain you authenticate, PostgreSQL, with consent and suppression as first-class tables, One source compiled to both HTML and a plain text alternative, An ingest endpoint idempotent on the sender's own event identifier, The provider's bounce and complaint webhook, signature verified, feeding suppression, One message row per recipient with an idempotency key, never an in-memory loop. - 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 - Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. - An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. - Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. - Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. - Honouring a deletion request by removing the suppression row along with the contact means the next import puts that address straight back on the list and mails somebody who told you to stop, which is worse than not honouring the deletion at all. Keep the suppression keyed on the normalised address after the personal fields are gone, and say so plainly in your privacy notice. ===== MILESTONES.md ===== # Delivery milestones · Loops Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · An authenticated domain and a message that passes the checks Mail is authenticated, unsubscribable in one action, and proved against real inboxes before a list sees it. ### Steps 1. Publish and verify the three records Alignment, not merely presence, is what a receiver checks. Files: `docs/dns-setup.md`, `scripts/check-auth.ts` 2. Generate the text part from the same source Two sources means two messages and one of them is wrong. Files: `src/templates/compile.ts`, `tests/text-part.test.ts` 3. Read the headers at real inboxes A seed at three providers tells you more than any local test. Files: `docs/seed-list.md` ### Done when - [ ] SPF, DKIM and DMARC pass at three providers - [ ] One-click headers on marketing mail only - [ ] Text and HTML come from one source - [ ] Footer carries address and list name ### Watch out - Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. ## M2 · Consent you can produce, suppression you cannot bypass No address is mailed without an evidence row, and no suppressed address is mailed at all. ### Steps 1. Store the wording, not just the timestamp Consent is to something specific, and that something changes. Files: `migrations/002_consent.sql`, `src/signup/record.ts` 2. Make the one-click POST do the whole job No login, no confirmation step, no second request. Files: `src/unsubscribe/route.ts`, `tests/oneclick.test.ts` 3. Test every send path against a suppressed address One untested path is the one that will mail them. Files: `tests/suppression.test.ts` ### Done when - [ ] Imports without evidence are refused - [ ] One request unsubscribes with no login - [ ] Every send path is tested against suppression - [ ] Old unsubscribe links still work ### Watch out - An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. ## M3 · Events in, workflow out, once per contact A redelivered event does not restart a workflow or send a second message. ### Steps 1. Constrain on the sender's event identifier The cheapest protection against a redelivered webhook. Files: `migrations/003_events.sql`, `src/ingest/route.ts` 2. Keep per-contact workflow state with a due time So a restart resumes rather than replays. Files: `src/workflow/state.ts` 3. Check suppression in the send path Not at entry, where it will be hours or days out of date. Files: `src/send/guard.ts`, `tests/mid-workflow-unsub.test.ts` ### Done when - [ ] A duplicate event enters the workflow once - [ ] Unsubscribing mid-workflow stops the next step - [ ] Delays fire within tolerance and never early - [ ] Exits record a reason and prevent re-entry ### Watch out - Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. ## M4 · Sending a campaign that can crash A campaign restarted after a crash sends only what it had not sent. ### Steps 1. Write every row before the first send The campaign becomes resumable by construction. Files: `src/campaign/materialise.ts` 2. Claim a row in its own transaction One claim, one send, no ambiguity after a crash. Files: `src/campaign/send.ts`, `tests/resume.test.ts` 3. Back off on the provider's signal A fixed sleep is either too slow or a rate limit ban. Files: `src/provider/client.ts` ### Done when - [ ] A restart sends exactly the remainder - [ ] Confirmed sends are never retried - [ ] Stop halts within one claim with correct counts - [ ] Backoff follows the provider's response ### Watch out - Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. ## M5 · Bounces, complaints and the number that decides everything (production only) Feedback from the receiving world changes what you send next, without anybody reading a log. ### Steps 1. Verify the signature before parsing An unverified webhook is an open write endpoint. Files: `src/webhooks/verify.ts`, `tests/webhook-sig.test.ts` 2. Suppress on hard bounce and complaint Immediately, permanently, and visibly on the contact. Files: `src/feedback/apply.ts` 3. Keep the suppression through a deletion The address stays suppressed after the person is gone. Files: `src/privacy/erase.ts`, `tests/erase-keeps-suppression.test.ts` ### Done when - [ ] Bad signatures are rejected before parsing - [ ] Hard bounces suppress permanently - [ ] Complaint rate is shown against the threshold - [ ] Deletion keeps the suppression ### Watch out - Honouring a deletion request by removing the suppression row along with the contact means the next import puts that address straight back on the list and mails somebody who told you to stop, which is worse than not honouring the deletion at all. Keep the suppression keyed on the normalised address after the personal fields are gone, and say so plainly in your privacy notice. ===== OPERATIONS.md ===== # Operations · Loops ## Backup The database, with the consent and suppression tables treated as the part that matters. A restore that loses consent rows leaves you mailing addresses you can no longer justify, and a restore that loses suppressions mails people who asked you to stop. ## Restore Restore, then reconcile in-flight campaigns by asking the provider about every message row that was claimed but has no provider identifier, before the send loop is started again. Never restart a campaign without that reconciliation. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Complaint rate over a rolling period, hard bounce rate, suppression list growth, and the count of claimed messages with no provider identifier. The last of those is what a crash leaves behind. ## Incident checklist A duplicate send means a row was claimed twice or the claim happened after the send, and the message rows show which. A sudden complaint rate rise nearly always means a list segment with weak consent, and the consent table identifies it by source. 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 - [ ] Authentication is confirmed passing at three providers - [ ] A one-click POST is confirmed to suppress in a single request - [ ] Every send path is confirmed tested against a suppressed address - [ ] A crashed campaign is confirmed to resume without duplicates - [ ] A deletion is confirmed to keep the suppression ## Launch constraint Do not market omitted Loops 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. Contacts, consent, suppressions, messages. The consent table is the one you cannot lose. DATABASE_URL=postgres://localhost:5432/mail # Required · secret. Scoped to sending only if the provider allows it. EMAIL_PROVIDER_API_KEY=sk-...redacted # Required. A subdomain reserved for this, so a reputation problem does not touch your main domain. SENDING_DOMAIN=mail.example.com # Required. Must align with the DKIM signing domain for DMARC to pass. FROM_ADDRESS=hello@mail.example.com # Required. Rendered in every marketing footer. Required in several jurisdictions. POSTAL_ADDRESS=1 Example Street, Example Town, EX1 1EX # Required. Links in old messages resolve here, so it must outlive any campaign. UNSUBSCRIBE_BASE_URL=https://mail.example.com/u # Required · secret. Verified before the bounce or complaint payload is parsed. WEBHOOK_SIGNING_SECRET=sk-...redacted # Required. Alert well under the published threshold, because by the time you reach it delivery has already suffered. COMPLAINT_RATE_ALERT=0.001
# Loops · indie build A small lifecycle email sender whose hard problems are proving consent for every address, making the suppression list impossible to bypass, and resuming a campaign that crashed halfway without mailing the first half twice. Estimated effort: **multi-day**. 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 | | --- | --- | --- | | Sending | One reputable provider's API from a domain you authenticate | a fresh address with no sending history lands in spam and no amount of code fixes that | | Storage | PostgreSQL, with consent and suppression as first-class tables | those two tables are the product, and everything else here is a mail merge | | Templates | One source compiled to both HTML and a plain text alternative | a text part written separately drifts until it contradicts the HTML | | Events | An ingest endpoint idempotent on the sender's own event identifier | webhooks are redelivered, and a redelivery must not restart a workflow | | Feedback | The provider's bounce and complaint webhook, signature verified, feeding suppression | continuing to mail a hard bounce is how a domain's reputation dies | | Queue | One message row per recipient with an idempotency key, never an in-memory loop | a campaign that crashed at contact four thousand must not restart at one | ## 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, the send loop and the tests. - 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: Consent, suppression and message rows all live here, and the resumability of a campaign is a property of the schema. - Get it: Your package manager, or a small managed instance. - Verify: A suppressed address is rejected by every send path in the test suite - [ ] **An email provider account with a verified sending domain** · free at small volume with most providers - Why: Deliverability is the part you are not building, so it is bought rather than written, and the domain authentication is a prerequisite rather than a step. - Get it: Any reputable sending provider, then complete their domain authentication and enable their bounce and complaint webhook. - Verify: A message sent through them passes authentication at a third-party inbox and a bounce webhook arrives for a known bad address - [ ] **Control of the sending domain's DNS** · free if you own the domain - Why: SPF, DKIM and DMARC are records you publish, and without DNS access this build cannot reach its first done-when condition. - Get it: Access to the zone for the domain you will send from, ideally a subdomain reserved for mail. - Verify: All three records resolve and alignment is confirmed from a received header - [ ] **Seed inboxes at three different providers** · free - Why: Authentication results and folder placement differ by receiver, and a local test tells you nothing about either. - Get it: An address at three large consumer providers, kept separate from your real mail. - Verify: A test campaign arrives at all three and the headers are readable - [ ] **Your warm-up plan, soft bounce threshold and retention** · free - Why: Each is asserted somewhere in the build, and deciding them after a reputation problem means deciding them too late. - Get it: Write down the volume ramp for the first fortnight, how many soft bounces suppress an address, and how long consent and message history are kept. - Verify: All three appear in configuration and are named in the tests or the README ## 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: - Purchased or appended lists. - An audience network and cross-promotion. - Your own mail server. - Behavioural segmentation at scale. - bounce, complaint, and suppression handling - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations If one of those is essential to you, that is the reason to keep paying for Loops, and the README should say so rather than pretend.
# Build brief · Loops 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 lifecycle email sender that only mails people who asked and can prove it to replace Loops. 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 things, and the law is only the second reason for either. Consent is evidence, not a boolean. Every contact carries when they agreed, by what method, from what address, and the exact wording they were shown. A contact with no evidence row is never sent to, including every row of the spreadsheet somebody assures you is fine. When a complaint arrives, 'they signed up' is not an answer and the evidence row is. The unsubscribe is one action that works with no login. Gmail's published requirements for senders of five thousand or more messages a day are the floor rather than the target: authenticated mail with SPF, DKIM and DMARC, one-click unsubscribe using List-Unsubscribe together with List-Unsubscribe-Post as described in RFC 8058, and a spam complaint rate held below 0.30 percent. Read the current published requirements and your own jurisdiction's rules before the first send, because both move. The suppression list is checked by every send path, and no query in the codebase selects recipients without it. ### Stack (fixed, do not substitute) - Sending: One reputable provider's API from a domain you authenticate · a fresh address with no sending history lands in spam and no amount of code fixes that - Storage: PostgreSQL, with consent and suppression as first-class tables · those two tables are the product, and everything else here is a mail merge - Templates: One source compiled to both HTML and a plain text alternative · a text part written separately drifts until it contradicts the HTML - Events: An ingest endpoint idempotent on the sender's own event identifier · webhooks are redelivered, and a redelivery must not restart a workflow - Feedback: The provider's bounce and complaint webhook, signature verified, feeding suppression · continuing to mail a hard bounce is how a domain's reputation dies - Queue: One message row per recipient with an idempotency key, never an in-memory loop · a campaign that crashed at contact four thousand must not restart at one ### Data model (create this before Phase 1) - contacts(id, email_normalised, email_original, created_at, source, properties_json, state) - consent(contact_id, at, method, ip, user_agent, wording_shown, list_id) · the evidence, kept as long as you mail them and a while after - suppressions(email_normalised, reason, at, source, permanent) · unsubscribe, hard bounce, complaint or manual, checked by every send path - lists(id, name, description_shown_at_signup) · what they were told they were joining, because that is what consent was given to - workflows(id, name, trigger_event, steps_json, active) and workflow_state(contact_id, workflow_id, step_index, next_due_at, entered_at, exited_at, exit_reason) - messages(id, contact_id, workflow_id, campaign_id, provider_message_id, state, idempotency_key, queued_at, sent_at, delivered_at, bounced_at, complained_at) - events(id, external_id, contact_id, name, at, properties_json) · unique on external_id, because the same event will arrive twice - The suppression check is a join every send path performs, and a test asserts that no recipient query exists without it. ### Phase 1 · An authenticated domain and a message that passes the checks Build: Publish SPF, DKIM and DMARC for the sending domain and confirm alignment. Put List-Unsubscribe and List-Unsubscribe-Post on every marketing message and on none of the transactional ones. Generate the plain text alternative from the same source as the HTML. Put a postal address and the list name in the footer. Send to seed addresses at several providers and read the authentication results out of the received headers. Trap: Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. Done when: A test message passes SPF, DKIM and DMARC read from the received headers at three different providers. The one-click header pair is present on every marketing message and absent from transactional ones, asserted in the suite. The plain text alternative is generated from the same source and contains the same links, tested. The footer carries a postal address and names the list the contact is on. Do not build yet: No contacts, no workflows, no sending to anybody but seeds. ### Phase 2 · Consent you can produce, suppression you cannot bypass Build: Record on signup the time, method, address, agent and the exact wording shown. Refuse an import row that carries no evidence. Implement the unsubscribe as a single POST that suppresses immediately with no login, and treat any page shown afterwards as a courtesy. Honour an opt-out at once rather than at the legal deadline: the United States rule allows ten business days and requires the mechanism to keep working at least thirty days after the message, and your own jurisdiction may be stricter, so read it. Trap: An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. Done when: An import row with no evidence column is refused with its reason, tested row by row. A one-click POST suppresses the address in a single request with no login, tested. Every send path has a test that inserts a suppressed address and asserts zero messages leave. An unsubscribe link from an old message still works after the period you documented, checked with a dated fixture. Do not build yet: No workflows, campaigns or feedback handling yet. ### Phase 3 · Events in, workflow out, once per contact Build: Ingest events with a unique constraint on the sender's identifier. Enter a contact into a workflow on a trigger event, advance per-contact state with a due time per step, and record an exit with its reason. Check suppression in the send path itself, immediately before the message is handed to the provider, never at the moment of entry. Trap: Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. Done when: The same event delivered twice enters the workflow once, tested by replaying the payload. A contact who unsubscribes mid-workflow receives no further steps, tested at each step boundary. A delay step fires within a stated tolerance of its due time and never before it. Exiting records a reason, and the same event does not cause a re-entry. Do not build yet: No campaign sending, bounces or complaints yet. ### Phase 4 · Sending a campaign that can crash Build: Materialise one message row per recipient, each with an idempotency key, before the first send. The send loop claims a row in its own transaction, sends, then records the provider's identifier. Respect the provider's published rate limits with backoff rather than a fixed sleep. Provide a stop control that halts within one claim and shows sent, remaining and failed counts. Trap: Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. Done when: Killing the process halfway through a ten thousand row campaign and restarting sends exactly the remainder, asserted with a counting fake provider. Each message carries an idempotency key and a confirmed send is never retried. The stop control halts within one claim and the counts shown add up to the row total. Rate limiting uses the provider's response rather than a guessed interval. Do not build yet: No bounce, complaint or deletion handling yet. ### Phase 5 · Bounces, complaints and the number that decides everything Build: Verify the webhook signature before parsing anything. A hard bounce suppresses permanently; a repeated soft bounce suppresses at a stated threshold; a complaint suppresses and alerts. Show the complaint rate for a stated period against the published threshold. Export contacts, consent, suppressions and message history. Delete a contact's personal data on request while keeping the suppression. Trap: Honouring a deletion request by removing the suppression row along with the contact means the next import puts that address straight back on the list and mails somebody who told you to stop, which is worse than not honouring the deletion at all. Keep the suppression keyed on the normalised address after the personal fields are gone, and say so plainly in your privacy notice. Done when: An unsigned or wrongly signed webhook is rejected before parsing, tested. A hard bounce suppresses permanently within one delivery, and a repeated soft bounce suppresses at the stated threshold. The complaint rate is shown with the period it covers and compared against the published threshold. Deleting a contact removes personal fields, keeps the suppression, and reimporting the address results in zero sends, tested end to end. Do not build yet: Nothing after this. ### Out of scope (and why) - Purchased, scraped or appended lists, which destroy a sending domain faster than anything else and are unlawful in several jurisdictions. - An audience network, cross-promotion and payments, which are a large part of what the paid product actually sells. - Open rate as a headline metric, because a growing share of recorded opens are privacy proxies fetching images nobody looked at. - Running your own mail server, where a new address with no reputation goes to spam regardless of how well you configure it. - Segmentation over behavioural history at scale, which needs an analytics store rather than a mailing tool. ### README must contain - That no address is mailed without an evidence row, and what the evidence row contains - That the one-click unsubscribe suppresses in a single request with no login, and which RFC describes the header pair - Which authentication records the sending domain needs, and how to confirm alignment rather than presence - That a campaign is resumable because every message row is written before the first send - That a deletion request removes personal data but keeps the suppression, and why
# Agent instructions · Loops indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One reputable provider's API from a domain you authenticate, PostgreSQL, with consent and suppression as first-class tables, One source compiled to both HTML and a plain text alternative, An ingest endpoint idempotent on the sender's own event identifier, The provider's bounce and complaint webhook, signature verified, feeding suppression, One message row per recipient with an idempotency key, never an in-memory loop. 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 - Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. - An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. - Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. - Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction.
# Build plan · Loops A small lifecycle email sender whose hard problems are proving consent for every address, making the suppression list impossible to bypass, and resuming a campaign that crashed halfway without mailing the first half twice. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An authenticated domain and a message that passes the checks Mail is authenticated, unsubscribable in one action, and proved against real inboxes before a list sees it. ### Steps 1. Publish and verify the three records Alignment, not merely presence, is what a receiver checks. Files: `docs/dns-setup.md`, `scripts/check-auth.ts` 2. Generate the text part from the same source Two sources means two messages and one of them is wrong. Files: `src/templates/compile.ts`, `tests/text-part.test.ts` 3. Read the headers at real inboxes A seed at three providers tells you more than any local test. Files: `docs/seed-list.md` ### Done when - [ ] SPF, DKIM and DMARC pass at three providers - [ ] One-click headers on marketing mail only - [ ] Text and HTML come from one source - [ ] Footer carries address and list name ### Watch out - Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. ## Phase 2 · Consent you can produce, suppression you cannot bypass No address is mailed without an evidence row, and no suppressed address is mailed at all. ### Steps 1. Store the wording, not just the timestamp Consent is to something specific, and that something changes. Files: `migrations/002_consent.sql`, `src/signup/record.ts` 2. Make the one-click POST do the whole job No login, no confirmation step, no second request. Files: `src/unsubscribe/route.ts`, `tests/oneclick.test.ts` 3. Test every send path against a suppressed address One untested path is the one that will mail them. Files: `tests/suppression.test.ts` ### Done when - [ ] Imports without evidence are refused - [ ] One request unsubscribes with no login - [ ] Every send path is tested against suppression - [ ] Old unsubscribe links still work ### Watch out - An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. ## Phase 3 · Events in, workflow out, once per contact A redelivered event does not restart a workflow or send a second message. ### Steps 1. Constrain on the sender's event identifier The cheapest protection against a redelivered webhook. Files: `migrations/003_events.sql`, `src/ingest/route.ts` 2. Keep per-contact workflow state with a due time So a restart resumes rather than replays. Files: `src/workflow/state.ts` 3. Check suppression in the send path Not at entry, where it will be hours or days out of date. Files: `src/send/guard.ts`, `tests/mid-workflow-unsub.test.ts` ### Done when - [ ] A duplicate event enters the workflow once - [ ] Unsubscribing mid-workflow stops the next step - [ ] Delays fire within tolerance and never early - [ ] Exits record a reason and prevent re-entry ### Watch out - Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. ## Phase 4 · Sending a campaign that can crash A campaign restarted after a crash sends only what it had not sent. ### Steps 1. Write every row before the first send The campaign becomes resumable by construction. Files: `src/campaign/materialise.ts` 2. Claim a row in its own transaction One claim, one send, no ambiguity after a crash. Files: `src/campaign/send.ts`, `tests/resume.test.ts` 3. Back off on the provider's signal A fixed sleep is either too slow or a rate limit ban. Files: `src/provider/client.ts` ### Done when - [ ] A restart sends exactly the remainder - [ ] Confirmed sends are never retried - [ ] Stop halts within one claim with correct counts - [ ] Backoff follows the provider's response ### Watch out - Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. ## Not in this build - Purchased or appended lists. - An audience network and cross-promotion. - Your own mail server. - Behavioural segmentation at scale. ## After v1, if you want it - A second workflow trigger from a product event, which the idempotent ingest already supports - Link click recording with the caveat that opens will not be reported, once the complaint rate has been stable for a month - A plain digest of list health by consent source, which the tables already contain everything for
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Contacts, consent, suppressions, messages. The consent table is the one you cannot lose. DATABASE_URL=postgres://localhost:5432/mail # Required · secret. Scoped to sending only if the provider allows it. EMAIL_PROVIDER_API_KEY=sk-...redacted # Required. A subdomain reserved for this, so a reputation problem does not touch your main domain. SENDING_DOMAIN=mail.example.com # Required. Must align with the DKIM signing domain for DMARC to pass. FROM_ADDRESS=hello@mail.example.com # Required. Rendered in every marketing footer. Required in several jurisdictions. POSTAL_ADDRESS=1 Example Street, Example Town, EX1 1EX # Required. Links in old messages resolve here, so it must outlive any campaign. UNSUBSCRIBE_BASE_URL=https://mail.example.com/u # Required · secret. Verified before the bounce or complaint payload is parsed. WEBHOOK_SIGNING_SECRET=sk-...redacted # Required. Alert well under the published threshold, because by the time you reach it delivery has already suffered. COMPLAINT_RATE_ALERT=0.001
# Loops · product brief ## Problem The visible SaaS email platform loop is buildable, but a credible replacement needs more than the first screen. Loops earns its keep through deliverability, reputation, network, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A sender that can produce the evidence for every address it mails, stops within one request when somebody unsubscribes, resumes a crashed campaign without duplicating a single message, and reacts to bounces and complaints without anybody reading a log. ## 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 - Purchased or appended lists. - An audience network and cross-promotion. - Your own mail server. - Behavioural segmentation at scale. - bounce, complaint, and suppression handling - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations ## Success criteria - Authentication is confirmed passing at three providers - A one-click POST is confirmed to suppress in a single request - Every send path is confirmed tested against a suppressed address - A crashed campaign is confirmed to resume without duplicates - A deletion is confirmed to keep the suppression
# Build brief · Loops 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 lifecycle email sender that only mails people who asked and can prove it to replace Loops. 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 things, and the law is only the second reason for either. Consent is evidence, not a boolean. Every contact carries when they agreed, by what method, from what address, and the exact wording they were shown. A contact with no evidence row is never sent to, including every row of the spreadsheet somebody assures you is fine. When a complaint arrives, 'they signed up' is not an answer and the evidence row is. The unsubscribe is one action that works with no login. Gmail's published requirements for senders of five thousand or more messages a day are the floor rather than the target: authenticated mail with SPF, DKIM and DMARC, one-click unsubscribe using List-Unsubscribe together with List-Unsubscribe-Post as described in RFC 8058, and a spam complaint rate held below 0.30 percent. Read the current published requirements and your own jurisdiction's rules before the first send, because both move. The suppression list is checked by every send path, and no query in the codebase selects recipients without it. ### Stack (fixed, do not substitute) - Sending: One reputable provider's API from a domain you authenticate · a fresh address with no sending history lands in spam and no amount of code fixes that - Storage: PostgreSQL, with consent and suppression as first-class tables · those two tables are the product, and everything else here is a mail merge - Templates: One source compiled to both HTML and a plain text alternative · a text part written separately drifts until it contradicts the HTML - Events: An ingest endpoint idempotent on the sender's own event identifier · webhooks are redelivered, and a redelivery must not restart a workflow - Feedback: The provider's bounce and complaint webhook, signature verified, feeding suppression · continuing to mail a hard bounce is how a domain's reputation dies - Queue: One message row per recipient with an idempotency key, never an in-memory loop · a campaign that crashed at contact four thousand must not restart at one ### Data model (create this before Phase 1) - contacts(id, email_normalised, email_original, created_at, source, properties_json, state) - consent(contact_id, at, method, ip, user_agent, wording_shown, list_id) · the evidence, kept as long as you mail them and a while after - suppressions(email_normalised, reason, at, source, permanent) · unsubscribe, hard bounce, complaint or manual, checked by every send path - lists(id, name, description_shown_at_signup) · what they were told they were joining, because that is what consent was given to - workflows(id, name, trigger_event, steps_json, active) and workflow_state(contact_id, workflow_id, step_index, next_due_at, entered_at, exited_at, exit_reason) - messages(id, contact_id, workflow_id, campaign_id, provider_message_id, state, idempotency_key, queued_at, sent_at, delivered_at, bounced_at, complained_at) - events(id, external_id, contact_id, name, at, properties_json) · unique on external_id, because the same event will arrive twice - The suppression check is a join every send path performs, and a test asserts that no recipient query exists without it. ### Phase 1 · An authenticated domain and a message that passes the checks Build: Publish SPF, DKIM and DMARC for the sending domain and confirm alignment. Put List-Unsubscribe and List-Unsubscribe-Post on every marketing message and on none of the transactional ones. Generate the plain text alternative from the same source as the HTML. Put a postal address and the list name in the footer. Send to seed addresses at several providers and read the authentication results out of the received headers. Trap: Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. Done when: A test message passes SPF, DKIM and DMARC read from the received headers at three different providers. The one-click header pair is present on every marketing message and absent from transactional ones, asserted in the suite. The plain text alternative is generated from the same source and contains the same links, tested. The footer carries a postal address and names the list the contact is on. Do not build yet: No contacts, no workflows, no sending to anybody but seeds. ### Phase 2 · Consent you can produce, suppression you cannot bypass Build: Record on signup the time, method, address, agent and the exact wording shown. Refuse an import row that carries no evidence. Implement the unsubscribe as a single POST that suppresses immediately with no login, and treat any page shown afterwards as a courtesy. Honour an opt-out at once rather than at the legal deadline: the United States rule allows ten business days and requires the mechanism to keep working at least thirty days after the message, and your own jurisdiction may be stricter, so read it. Trap: An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. Done when: An import row with no evidence column is refused with its reason, tested row by row. A one-click POST suppresses the address in a single request with no login, tested. Every send path has a test that inserts a suppressed address and asserts zero messages leave. An unsubscribe link from an old message still works after the period you documented, checked with a dated fixture. Do not build yet: No workflows, campaigns or feedback handling yet. ### Phase 3 · Events in, workflow out, once per contact Build: Ingest events with a unique constraint on the sender's identifier. Enter a contact into a workflow on a trigger event, advance per-contact state with a due time per step, and record an exit with its reason. Check suppression in the send path itself, immediately before the message is handed to the provider, never at the moment of entry. Trap: Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. Done when: The same event delivered twice enters the workflow once, tested by replaying the payload. A contact who unsubscribes mid-workflow receives no further steps, tested at each step boundary. A delay step fires within a stated tolerance of its due time and never before it. Exiting records a reason, and the same event does not cause a re-entry. Do not build yet: No campaign sending, bounces or complaints yet. ### Phase 4 · Sending a campaign that can crash Build: Materialise one message row per recipient, each with an idempotency key, before the first send. The send loop claims a row in its own transaction, sends, then records the provider's identifier. Respect the provider's published rate limits with backoff rather than a fixed sleep. Provide a stop control that halts within one claim and shows sent, remaining and failed counts. Trap: Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. Done when: Killing the process halfway through a ten thousand row campaign and restarting sends exactly the remainder, asserted with a counting fake provider. Each message carries an idempotency key and a confirmed send is never retried. The stop control halts within one claim and the counts shown add up to the row total. Rate limiting uses the provider's response rather than a guessed interval. Do not build yet: No bounce, complaint or deletion handling yet. ### Phase 5 · Bounces, complaints and the number that decides everything Build: Verify the webhook signature before parsing anything. A hard bounce suppresses permanently; a repeated soft bounce suppresses at a stated threshold; a complaint suppresses and alerts. Show the complaint rate for a stated period against the published threshold. Export contacts, consent, suppressions and message history. Delete a contact's personal data on request while keeping the suppression. Trap: Honouring a deletion request by removing the suppression row along with the contact means the next import puts that address straight back on the list and mails somebody who told you to stop, which is worse than not honouring the deletion at all. Keep the suppression keyed on the normalised address after the personal fields are gone, and say so plainly in your privacy notice. Done when: An unsigned or wrongly signed webhook is rejected before parsing, tested. A hard bounce suppresses permanently within one delivery, and a repeated soft bounce suppresses at the stated threshold. The complaint rate is shown with the period it covers and compared against the published threshold. Deleting a contact removes personal fields, keeps the suppression, and reimporting the address results in zero sends, tested end to end. Do not build yet: Nothing after this. ### Out of scope (and why) - Purchased, scraped or appended lists, which destroy a sending domain faster than anything else and are unlawful in several jurisdictions. - An audience network, cross-promotion and payments, which are a large part of what the paid product actually sells. - Open rate as a headline metric, because a growing share of recorded opens are privacy proxies fetching images nobody looked at. - Running your own mail server, where a new address with no reputation goes to spam regardless of how well you configure it. - Segmentation over behavioural history at scale, which needs an analytics store rather than a mailing tool. ### README must contain - That no address is mailed without an evidence row, and what the evidence row contains - That the one-click unsubscribe suppresses in a single request with no login, and which RFC describes the header pair - Which authentication records the sending domain needs, and how to confirm alignment rather than presence - That a campaign is resumable because every message row is written before the first send - That a deletion request removes personal data but keeps the suppression, and why
# Architecture · Loops ## Stack | Part | Choice | Why | | --- | --- | --- | | Sending | One reputable provider's API from a domain you authenticate | a fresh address with no sending history lands in spam and no amount of code fixes that | | Storage | PostgreSQL, with consent and suppression as first-class tables | those two tables are the product, and everything else here is a mail merge | | Templates | One source compiled to both HTML and a plain text alternative | a text part written separately drifts until it contradicts the HTML | | Events | An ingest endpoint idempotent on the sender's own event identifier | webhooks are redelivered, and a redelivery must not restart a workflow | | Feedback | The provider's bounce and complaint webhook, signature verified, feeding suppression | continuing to mail a hard bounce is how a domain's reputation dies | | Queue | One message row per recipient with an idempotency key, never an in-memory loop | a campaign that crashed at contact four thousand must not restart at one | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Consent | Evidence rows and import refusal. | Any signup surface, provided the wording shown is recorded with the row. | | Suppression | The list every send path joins against. | Not swappable for a flag on the contact, which an import will overwrite. | | Workflows | Idempotent events and per-contact state. | Any engine, provided suppression is checked in the send path itself. | | Campaigns | Materialised rows, claims and resumability. | Any queue, provided every row exists before the first send. | | Feedback | Verified webhooks, bounces, complaints and rates. | Any provider, provided a hard bounce suppresses permanently. | ## 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 · Contacts, consent, suppressions, messages. The consent table is the one you cannot lose. - `EMAIL_PROVIDER_API_KEY` · required, secret · Scoped to sending only if the provider allows it. - `SENDING_DOMAIN` · required · A subdomain reserved for this, so a reputation problem does not touch your main domain. - `FROM_ADDRESS` · required · Must align with the DKIM signing domain for DMARC to pass. - `POSTAL_ADDRESS` · required · Rendered in every marketing footer. Required in several jurisdictions. - `UNSUBSCRIBE_BASE_URL` · required · Links in old messages resolve here, so it must outlive any campaign. - `WEBHOOK_SIGNING_SECRET` · required, secret · Verified before the bounce or complaint payload is parsed. - `COMPLAINT_RATE_ALERT` · required · Alert well under the published threshold, because by the time you reach it delivery has already suffered. ## 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 · Loops product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One reputable provider's API from a domain you authenticate, PostgreSQL, with consent and suppression as first-class tables, One source compiled to both HTML and a plain text alternative, An ingest endpoint idempotent on the sender's own event identifier, The provider's bounce and complaint webhook, signature verified, feeding suppression, One message row per recipient with an idempotency key, never an in-memory loop. - 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 - Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. - An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. - Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. - Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. - Honouring a deletion request by removing the suppression row along with the contact means the next import puts that address straight back on the list and mails somebody who told you to stop, which is worse than not honouring the deletion at all. Keep the suppression keyed on the normalised address after the personal fields are gone, and say so plainly in your privacy notice.
# Delivery milestones · Loops Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · An authenticated domain and a message that passes the checks Mail is authenticated, unsubscribable in one action, and proved against real inboxes before a list sees it. ### Steps 1. Publish and verify the three records Alignment, not merely presence, is what a receiver checks. Files: `docs/dns-setup.md`, `scripts/check-auth.ts` 2. Generate the text part from the same source Two sources means two messages and one of them is wrong. Files: `src/templates/compile.ts`, `tests/text-part.test.ts` 3. Read the headers at real inboxes A seed at three providers tells you more than any local test. Files: `docs/seed-list.md` ### Done when - [ ] SPF, DKIM and DMARC pass at three providers - [ ] One-click headers on marketing mail only - [ ] Text and HTML come from one source - [ ] Footer carries address and list name ### Watch out - Sending the first campaign from a newly authenticated domain to the whole list looks to a receiving provider exactly like a compromised account: volume out of nowhere, no history, no engagement. Most of it lands in spam and the domain begins its life with a reputation you then spend months repairing. Start with the most engaged contacts, increase volume gradually, and watch the complaint rate before widening. ## M2 · Consent you can produce, suppression you cannot bypass No address is mailed without an evidence row, and no suppressed address is mailed at all. ### Steps 1. Store the wording, not just the timestamp Consent is to something specific, and that something changes. Files: `migrations/002_consent.sql`, `src/signup/record.ts` 2. Make the one-click POST do the whole job No login, no confirmation step, no second request. Files: `src/unsubscribe/route.ts`, `tests/oneclick.test.ts` 3. Test every send path against a suppressed address One untested path is the one that will mail them. Files: `tests/suppression.test.ts` ### Done when - [ ] Imports without evidence are refused - [ ] One request unsubscribes with no login - [ ] Every send path is tested against suppression - [ ] Old unsubscribe links still work ### Watch out - An unsubscribe link that leads to a preference centre asking someone to log in makes them press the spam button instead, which costs far more than the subscriber did, because the complaint rate is the number that decides whether the rest of your mail is delivered at all. The one-click POST must suppress in that single request, and anything else you want to show them comes after. ## M3 · Events in, workflow out, once per contact A redelivered event does not restart a workflow or send a second message. ### Steps 1. Constrain on the sender's event identifier The cheapest protection against a redelivered webhook. Files: `migrations/003_events.sql`, `src/ingest/route.ts` 2. Keep per-contact workflow state with a due time So a restart resumes rather than replays. Files: `src/workflow/state.ts` 3. Check suppression in the send path Not at entry, where it will be hours or days out of date. Files: `src/send/guard.ts`, `tests/mid-workflow-unsub.test.ts` ### Done when - [ ] A duplicate event enters the workflow once - [ ] Unsubscribing mid-workflow stops the next step - [ ] Delays fire within tolerance and never early - [ ] Exits record a reason and prevent re-entry ### Watch out - Checking suppression when a contact enters a workflow rather than when each message is about to leave means somebody who unsubscribed on Tuesday still receives Thursday's step, and that is precisely the message that becomes a spam complaint rather than another unsubscribe. Check immediately before handing the message over, every time. ## M4 · Sending a campaign that can crash A campaign restarted after a crash sends only what it had not sent. ### Steps 1. Write every row before the first send The campaign becomes resumable by construction. Files: `src/campaign/materialise.ts` 2. Claim a row in its own transaction One claim, one send, no ambiguity after a crash. Files: `src/campaign/send.ts`, `tests/resume.test.ts` 3. Back off on the provider's signal A fixed sleep is either too slow or a rate limit ban. Files: `src/provider/client.ts` ### Done when - [ ] A restart sends exactly the remainder - [ ] Confirmed sends are never retried - [ ] Stop halts within one claim with correct counts - [ ] Backoff follows the provider's response ### Watch out - Iterating the recipient list in memory and sending as you go leaves nothing that says where you were when the process died, and the obvious recovery, running it again, sends the first four thousand people a second copy of the same email on the same morning. Write every message row before the first send, claim them one at a time, and make the campaign resumable by construction. ## M5 · Bounces, complaints and the number that decides everything (production only) Feedback from the receiving world changes what you send next, without anybody reading a log. ### Steps 1. Verify the signature before parsing An unverified webhook is an open write endpoint. Files: `src/webhooks/verify.ts`, `tests/webhook-sig.test.ts` 2. Suppress on hard bounce and complaint Immediately, permanently, and visibly on the contact. Files: `src/feedback/apply.ts` 3. Keep the suppression through a deletion The address stays suppressed after the person is gone. Files: `src/privacy/erase.ts`, `tests/erase-keeps-suppression.test.ts` ### Done when - [ ] Bad signatures are rejected before parsing - [ ] Hard bounces suppress permanently - [ ] Complaint rate is shown against the threshold - [ ] Deletion keeps the suppression ### Watch out - Honouring a deletion request by removing the suppression row along with the contact means the next import puts that address straight back on the list and mails somebody who told you to stop, which is worse than not honouring the deletion at all. Keep the suppression keyed on the normalised address after the personal fields are gone, and say so plainly in your privacy notice.
# Operations · Loops ## Backup The database, with the consent and suppression tables treated as the part that matters. A restore that loses consent rows leaves you mailing addresses you can no longer justify, and a restore that loses suppressions mails people who asked you to stop. ## Restore Restore, then reconcile in-flight campaigns by asking the provider about every message row that was claimed but has no provider identifier, before the send loop is started again. Never restart a campaign without that reconciliation. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Complaint rate over a rolling period, hard bounce rate, suppression list growth, and the count of claimed messages with no provider identifier. The last of those is what a crash leaves behind. ## Incident checklist A duplicate send means a row was claimed twice or the claim happened after the send, and the message rows show which. A sudden complaint rate rise nearly always means a list segment with weak consent, and the consent table identifies it by source. 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 - [ ] Authentication is confirmed passing at three providers - [ ] A one-click POST is confirmed to suppress in a single request - [ ] Every send path is confirmed tested against a suppressed address - [ ] A crashed campaign is confirmed to resume without duplicates - [ ] A deletion is confirmed to keep the suppression ## Launch constraint Do not market omitted Loops 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. Contacts, consent, suppressions, messages. The consent table is the one you cannot lose. DATABASE_URL=postgres://localhost:5432/mail # Required · secret. Scoped to sending only if the provider allows it. EMAIL_PROVIDER_API_KEY=sk-...redacted # Required. A subdomain reserved for this, so a reputation problem does not touch your main domain. SENDING_DOMAIN=mail.example.com # Required. Must align with the DKIM signing domain for DMARC to pass. FROM_ADDRESS=hello@mail.example.com # Required. Rendered in every marketing footer. Required in several jurisdictions. POSTAL_ADDRESS=1 Example Street, Example Town, EX1 1EX # Required. Links in old messages resolve here, so it must outlive any campaign. UNSUBSCRIBE_BASE_URL=https://mail.example.com/u # Required · secret. Verified before the bounce or complaint payload is parsed. WEBHOOK_SIGNING_SECRET=sk-...redacted # Required. Alert well under the published threshold, because by the time you reach it delivery has already suffered. COMPLAINT_RATE_ALERT=0.001
$ choose a build depth, inspect the files, then open the complete pack in your agent
Loops: The editor is trivial compared with inbox placement, compliance, reputation, unsubscribe handling, analytics, and audience growth infrastructure.
xbounce, complaint, and suppression handling
xlarge-scale segmentation and automation
xpayments, recommendations, and audience network
xsender reputation, deliverability, and suppression handling
xsender reputation and deliverability operations
Don't feel like building it? These folks already made it free.
all 6 free alternatives to Loops →· no votes, no pay-to-list · just what's real
Loops pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | Up to 1,000 subscribers, 4,000 sends/month, all features included, powered-by footer. |
| 1k-5k subscribers | $49/workspace | — | 1,001-5,000 subscribers, unlimited sends, transactional email included. |
| 5k-10k subscribers | $99/workspace | — | 5,001-10,000 subscribers, unlimited sends, transactional email included. |
| 10k-15k subscribers | $149/workspace | — | 10,001-15,000 subscribers, unlimited sends, transactional email included. |
| 15k-25k subscribers | $199/workspace | — | 15,001-25,000 subscribers, unlimited sends, transactional email included. |
| 25k-50k subscribers | $249/workspace | — | 25,001-50,000 subscribers, unlimited sends, transactional email included. |
| 50k-100k subscribers | $399/workspace | — | 50,001-100,000 subscribers, unlimited sends, transactional email included. |
| 100k-150k subscribers | $599/workspace | — | 100,001-150,000 subscribers, unlimited sends, transactional email included. |
| 150k-200k subscribers | $799/workspace | — | 150,001-200,000 subscribers, unlimited sends, transactional email included. |
| 200k-300k subscribers | $1199/workspace | — | 200,001-300,000 subscribers, unlimited sends, transactional email included. |
| 300k-400k subscribers | $1599/workspace | — | 300,001-400,000 subscribers, unlimited sends, transactional email included. |
| 400k-500k subscribers | $1999/workspace | — | 400,001-500,000 subscribers, unlimited sends, transactional email included. |
| 500k-600k subscribers | $2399/workspace | — | 500,001-600,000 subscribers, unlimited sends, transactional email included. |
| 600k-700k subscribers | $2799/workspace | — | 600,001-700,000 subscribers, unlimited sends, transactional email included. |
| 700k-800k subscribers | $3199/workspace | — | 700,001-800,000 subscribers, unlimited sends, transactional email included. |
| 800k-900k subscribers | $3599/workspace | — | 800,001-900,000 subscribers, unlimited sends, transactional email included. |
| 900k-1m subscribers | $3999/workspace | — | 900,001-1,000,000 subscribers, unlimited sends, transactional email included. |
| enterprise | custom | — | Custom volume above published tiers. |
free tier$0; up to 1,000 subscribers and 4,000 sends/month; all features included with powered-by footer.
billingmonthly only; no annual plan verified
hidden costsHard subscriber step-ups; Free includes powered-by footer.
verified 2026-08-10 · source ↗
Vibecode Loops
Kinda. The core of Loops is buildable in a weekend with the prompt on this page, but there are real gaps: bounce, complaint, and suppression handling, large-scale segmentation and automation. Read the honest list above before committing.
How much does Loops cost?
Loops costs about $49/month (Starter, checked 2026-07-31), which is $588 per year.
What do I lose by replacing Loops?
Honestly: bounce, complaint, and suppression handling; large-scale segmentation and automation; payments, recommendations, and audience network; sender reputation, deliverability, and suppression handling; sender reputation and deliverability operations. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Loops?
Yes: Mautic (A complete lifecycle automation engine with no contact ceiling; simplicity left the building.) Plunk (Marketing, transactional email and workflows from one self-hosted app; SaaS-specific templates are fewer.) Dittofeed (Lifecycle journeys driven by product events, with richer branching and much heavier operations.) All 6 curated free alternatives are at vibecodeit.com/loops/alternatives. The prompt is for when you want it exactly your way.