Vibecode SendFox
track this build4 phases, 12 steps, beginner friendly0%The visible creator email marketing loop is buildable, but a credible replacement needs more than the first screen. SendFox 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 SendFox. 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 ===== # SendFox · indie build A broadcast sender whose hard problems are holding consent as evidence rather than a flag, making one-click unsubscribe work the way the mail clients actually call it, sending each person exactly once through a crash, and suppressing a bounce before it costs you the domain. 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 | | --- | --- | --- | | Runtime | TypeScript on Node 22, a web process and a sending worker | the send has to continue when the browser is closed | | Storage | PostgreSQL with a unique constraint per broadcast and subscriber | the duplicate-send bug is a database problem, so the database prevents it | | Delivery | One reputable provider you supply a key for, never your own mail server | reputation is the product, and you are not going to build it in a weekend | | Consent | An append-only events table with evidence per event | a boolean cannot answer the only question that matters later | | Suppression | Checked inside the send transaction, never only at list build time | an unsubscribe during a send must still take effect | ## 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 web process and the sending worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally, a few pounds a month managed - Why: The unique constraint on broadcast and subscriber is what makes exactly-once real rather than hoped for. - Get it: Docker, your package manager, or a managed instance. - Verify: psql connects and the migration applies - [ ] **An email provider account with an API key** · free tier at low volume, then per thousand messages - Why: Reputation, feedback loops and blocklist handling are their job, and taking them on yourself is a permanent second project. - Get it: Sign up with a provider that publishes delivery webhooks and a rate limit, and create a key scoped to sending. - Verify: A single test message is delivered and a webhook arrives - [ ] **A domain with DNS you control, and a sending subdomain** · the price of a domain you probably already own - Why: SPF, DKIM and DMARC all live in DNS, and sending from a subdomain keeps the main domain's reputation separate. - Get it: Add the provider's DKIM records on a subdomain such as mail.example.com and publish a DMARC record. - Verify: The provider reports the domain verified and a delivered message passes all three - [ ] **Test mailboxes at two major providers** · free - Why: One-click unsubscribe can only be verified by pressing the button in a real client, and the headers render differently between them. - Get it: A free mailbox at each of two large consumer providers. - Verify: Both receive a test broadcast and both show the client's own unsubscribe control - [ ] **The current bulk sender requirements, read today** · free - Why: The thresholds and required headers are set by the receiving providers and they change, so the version you built against needs recording. - Get it: Read the sender guidelines of the providers your list actually uses and note the date. - Verify: The README names the requirements and the date they were read ## 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: - Your own mail server or IP reputation. - Paid subscriptions and recommendation networks. - Recipient-side behavioural tracking. - Any deliverability score you invented. - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations - bounce, complaint, and suppression handling - large-scale segmentation and automation If one of those is essential to you, that is the reason to keep paying for SendFox, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · SendFox The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter sender that can prove consent, honours an unsubscribe in one click and never sends a broadcast twice to replace SendFox. 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 Three rules. Consent is recorded as evidence, not as a boolean: when, from where, by which form, and the confirmation the person clicked. A list you cannot evidence is a list you do not send to, and no bulk import creates a confirmed subscriber. Unsubscribing is instant and irreversible in the direction that matters. The address goes to a suppression table checked at send time, not to a flag on a row that a later import can flip back. A person who left and reappears through a form starts again from unconfirmed. And you are a guest in someone else's inbox. Gmail's sender guidelines require bulk senders to authenticate with SPF and DKIM, publish a DMARC record with the From domain aligned to one of them, keep the spam rate reported in Postmaster Tools below 0.30 percent, and support one-click unsubscribe by RFC 8058 alongside a clearly visible unsubscribe link in the body. Those have been mandatory since 1 February 2024. Read the current page yourself before you send anything, because this is the part that changes. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22, a web process and a sending worker · the send has to continue when the browser is closed - Storage: PostgreSQL with a unique constraint per broadcast and subscriber · the duplicate-send bug is a database problem, so the database prevents it - Delivery: One reputable provider you supply a key for, never your own mail server · reputation is the product, and you are not going to build it in a weekend - Consent: An append-only events table with evidence per event · a boolean cannot answer the only question that matters later - Suppression: Checked inside the send transaction, never only at list build time · an unsubscribe during a send must still take effect ### Data model (create this before Phase 1) - subscribers(id, email_normalised, email_display, state, created_at, confirmed_at, unsubscribed_at, source_form_id) · state is unconfirmed, confirmed, unsubscribed or suppressed - consent_events(id, subscriber_id, at, kind, evidence_json) · kind is signup, confirm, unsubscribe or complaint, and evidence holds the form, the page and the confirmation token used - suppressions(email_normalised, reason, at, source) · permanent, keyed on the normalised address, checked at send - broadcasts(id, subject, html_body, text_body, audience_json, state, scheduled_at, sent_count, created_at) - sends(broadcast_id, subscriber_id, state, provider_message_id, sent_at, last_error) · unique on the pair, which is the exactly-once guarantee - delivery_events(provider_message_id, kind, at, payload_excerpt) · delivered, bounced, complained, keyed for idempotent webhook handling - unsubscribe_tokens(token, subscriber_id, broadcast_id, created_at) · unguessable, scoped to a person and a broadcast, never expiring ### Phase 1 · Consent you could show someone Build: A signup form that creates an unconfirmed subscriber and sends a confirmation mail with a single use token. Confirming writes a consent event with the form, the page, the time and the token used. Normalise addresses for comparison but keep the address the person typed for display. Imports land as unconfirmed and must be confirmed the same way as anyone else. Trap: Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. Done when: A confirmed subscriber has at least a signup event and a confirm event, each with its evidence, asserted in the suite. A confirmation token works once and is then useless, tested by replaying it. An imported list arrives as unconfirmed and no import path can create a confirmed subscriber, asserted by inspecting every insert. The address stored for sending is exactly what the person typed. Do not build yet: No broadcasts, no sending, no unsubscribe handling yet. ### Phase 2 · Unsubscribe the way mail clients actually do it Build: Send both headers that one-click unsubscribe needs: a List-Unsubscribe header carrying an https address, and List-Unsubscribe-Post with the value List-Unsubscribe=One-Click, per RFC 8058. Handle the POST without requiring a login or a confirmation page, and make it write a suppression and a consent event. Keep a visible unsubscribe link in the body as well, since the guidelines require both. Verify the whole path by sending to a real mailbox and using the client's own button. Trap: Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. Done when: A GET to the unsubscribe address never changes state, tested by fetching it twice and asserting the subscriber is untouched. A POST with the one-click body suppresses the address immediately and writes a consent event. Both headers appear in the raw source of a delivered message, checked by reading a real received mail. Once suppressed, the address is skipped inside the send transaction even if the audience was built before the unsubscribe. Do not build yet: No broadcast sending, bounce handling or analytics yet. ### Phase 3 · Sending each person exactly once Build: Materialise the audience into send rows when the broadcast starts, with a unique constraint on the broadcast and subscriber pair. The worker claims a row, marks it sending in its own transaction, calls the provider, then records the message id. A call whose outcome is unknown leaves the row in an unresolved state that is settled by asking the provider, never by sending again. Pace the send to the rate the provider publishes for your account. Trap: Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. Done when: Killing the worker mid-broadcast and restarting it sends no duplicate, asserted by counting messages against a fake provider. Two workers running the same broadcast concurrently produce one message per subscriber, tested with real concurrency. An unresolved send is settled by querying the provider and never by resending, asserted in the suite. The send rate stays within the figure configured from the provider's published limit. Do not build yet: No bounce and complaint handling, no authentication setup, no export. ### Phase 4 · Bounces and complaints, handled before they cost you Build: Accept the provider's delivery webhook, verify its signature, and record each event keyed on its identifier so a redelivery is ignored. A hard bounce suppresses permanently; a complaint suppresses permanently and is never followed by another message to that address; a soft bounce counts toward a stated threshold. Show the complaint rate per broadcast against the 0.30 percent figure the guidelines set, and refuse to start a new broadcast while the recent rate is above it. Trap: Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. Done when: A redelivered webhook event changes nothing the second time, tested by posting the same event twice. A hard bounce suppresses the address permanently and the next broadcast skips it, asserted in the suite. A complaint suppresses and is recorded with its broadcast, and the rate per broadcast is displayed. Starting a broadcast while the recent complaint rate exceeds the configured ceiling is refused with the figure shown. Do not build yet: No domain authentication guidance, no export or deletion requests yet. ### Phase 5 · Authentication, and a list that is not trapped Build: Document and verify SPF, DKIM and a DMARC record with the From domain aligned, using a subdomain for sending so the main domain's reputation is separate. Check the records at startup and refuse to send if DKIM is missing. Export subscribers with their full consent events as CSV, handle a deletion request by removing the person while keeping the suppression, and keep an audit line for every export and deletion. Trap: Deleting a subscriber on request also deletes the record that they unsubscribed, so the next import quietly adds them back and they receive the newsletter they explicitly left, which is the complaint that damages a domain most. Delete the person and keep a suppression keyed on a hash of the address, and say so in the privacy note. Done when: Startup checks SPF, DKIM and DMARC for the sending domain and refuses to send with a named missing record, tested against a domain with each one removed. Export produces every subscriber with their consent events in a file that reimports. A deletion request removes the subscriber while leaving a suppression that blocks a later reimport, asserted in the suite. Every export and deletion appends an audit line with the time and the operator. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which means owning IP reputation, feedback loops and blocklist delisting as a permanent job. - Paid subscriptions and audience recommendation networks, which are where the hosted products make their money and need a payment and identity model of their own. - Deliverability consulting dressed up as a feature, such as a score with no basis; the only honest signals here are the provider's own delivery events. - Large-scale behavioural segmentation, which needs event tracking on the recipient's side and a much larger privacy commitment. ### README must contain - That consent is stored as events with evidence and no import path can create a confirmed subscriber - That one-click unsubscribe needs both headers and a POST handler with no authentication, and that GET never changes state - That sends are unique per broadcast and subscriber, and an unknown outcome is reconciled rather than retried - Which sender requirements were read, on what date, and where to recheck them before the next send - That a deletion request removes the person but keeps a suppression, and why ===== AGENTS.md ===== # Agent instructions · SendFox indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node 22, a web process and a sending worker, PostgreSQL with a unique constraint per broadcast and subscriber, One reputable provider you supply a key for, never your own mail server, An append-only events table with evidence per event, Checked inside the send transaction, never only at list build time. 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 - Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. - Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. - Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. - Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. ===== BUILD_PLAN.md ===== # Build plan · SendFox A broadcast sender whose hard problems are holding consent as evidence rather than a flag, making one-click unsubscribe work the way the mail clients actually call it, sending each person exactly once through a crash, and suppressing a bounce before it costs you the domain. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Consent you could show someone Every confirmed subscriber has a trail explaining how they got there. ### Steps 1. Record consent as events with evidence The question later is not whether they consented but how you know. Files: `src/consent/events.ts`, `migrations/002_consent.sql` 2. Make confirmation tokens single use A replayable confirmation link is a confirmation anyone can perform. Files: `src/consent/confirm.ts`, `tests/token-replay.test.ts` 3. Keep the typed address, normalise only for comparison Aggressive normalisation merges two real people into one. Files: `src/email/normalise.ts`, `tests/normalise.test.ts` ### Done when - [ ] Confirmed subscribers carry signup and confirm evidence - [ ] A confirmation token cannot be replayed - [ ] No import path produces a confirmed subscriber - [ ] The typed address is what gets sent to ### Watch out - Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. ## Phase 2 · Unsubscribe the way mail clients actually do it One click in the mail client removes the person, and nothing else can. ### Steps 1. Emit both one-click headers RFC 8058 needs the POST header as well as the address, and clients check for it. Files: `src/mail/headers.ts` 2. Unsubscribe on POST, never on GET Prefetchers issue GET requests, and a list can empty overnight. Files: `src/web/routes/unsubscribe.ts`, `tests/get-is-safe.test.ts` 3. Check suppression inside the send transaction Unsubscribes arrive during a send, not politely before it. Files: `src/send/worker.ts` ### Done when - [ ] A GET to the unsubscribe address changes nothing - [ ] A one-click POST suppresses immediately - [ ] Both headers appear in a real received message - [ ] Suppression is checked inside the send transaction ### Watch out - Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. ## Phase 3 · Sending each person exactly once A crash, a retry or a double click produces one message per subscriber. ### Steps 1. Materialise send rows with a unique pair constraint The database is the thing that makes exactly-once true. Files: `migrations/003_sends.sql`, `src/send/materialise.ts` 2. Claim the row before calling the provider Not after, and not in the same transaction as the call. Files: `src/send/worker.ts`, `tests/exactly-once.test.ts` 3. Settle unknown outcomes by asking Resending on a timeout is the duplicate-send bug wearing a different hat. Files: `src/send/reconcile.ts` ### Done when - [ ] A mid-broadcast crash produces no duplicate - [ ] Concurrent workers send once per subscriber - [ ] Unknown outcomes are reconciled, not resent - [ ] The send rate honours the provider's published limit ### Watch out - Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. ## Phase 4 · Bounces and complaints, handled before they cost you A hard bounce or a complaint suppresses the address without being told twice. ### Steps 1. Verify the webhook signature and key on the event id Providers redeliver, and an unsigned endpoint is an open suppression list. Files: `src/web/routes/webhook.ts`, `tests/webhook-idempotent.test.ts` 2. Separate hard bounces from soft ones Retrying a dead mailbox forever is a reputation cost with no upside. Files: `src/delivery/classify.ts` 3. Block the next send on a high complaint rate The number that matters is the one the receiving provider is watching. Files: `src/send/gate.ts` ### Done when - [ ] A redelivered event is ignored the second time - [ ] Hard bounces suppress permanently - [ ] Complaint rate is shown per broadcast - [ ] A high recent rate refuses the next broadcast ### Watch out - Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. ## Not in this build - Your own mail server or IP reputation. - Paid subscriptions and recommendation networks. - Recipient-side behavioural tracking. - Any deliverability score you invented. ## After v1, if you want it - A simple automation of two or three timed messages, reusing the send rows and the suppression check unchanged - A plain-text archive page per broadcast, which also gives the unsubscribe link a second home - Seed testing across a handful of mailboxes before each send, reported as rendering rather than as a placement score ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Subscribers, consent events, suppressions and sends. DATABASE_URL=postgres://localhost:5432/newsletter # Required · secret. Sending scope only. Rotate it without touching the database. EMAIL_PROVIDER_KEY=sk-...redacted # Required · secret. Verifies delivery webhooks. An unverified endpoint is an open suppression list. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. The subdomain carrying DKIM, checked at startup. SENDING_DOMAIN=mail.example.com # Required. Must align with SPF or DKIM for DMARC to pass. FROM_ADDRESS=letters@mail.example.com # Required. Taken from the provider's published limit for your account, with the date recorded. SEND_RATE_PER_SECOND=10 # Required. Blocks a new broadcast above this recent rate. Chosen against the published threshold. COMPLAINT_RATE_CEILING=0.003 # Required. Consecutive soft bounces before an address is suppressed. SOFT_BOUNCE_THRESHOLD=4
You are building a lean indie version of SendFox. 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 ===== # SendFox · indie build A broadcast sender whose hard problems are holding consent as evidence rather than a flag, making one-click unsubscribe work the way the mail clients actually call it, sending each person exactly once through a crash, and suppressing a bounce before it costs you the domain. 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 | | --- | --- | --- | | Runtime | TypeScript on Node 22, a web process and a sending worker | the send has to continue when the browser is closed | | Storage | PostgreSQL with a unique constraint per broadcast and subscriber | the duplicate-send bug is a database problem, so the database prevents it | | Delivery | One reputable provider you supply a key for, never your own mail server | reputation is the product, and you are not going to build it in a weekend | | Consent | An append-only events table with evidence per event | a boolean cannot answer the only question that matters later | | Suppression | Checked inside the send transaction, never only at list build time | an unsubscribe during a send must still take effect | ## 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 web process and the sending worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally, a few pounds a month managed - Why: The unique constraint on broadcast and subscriber is what makes exactly-once real rather than hoped for. - Get it: Docker, your package manager, or a managed instance. - Verify: psql connects and the migration applies - [ ] **An email provider account with an API key** · free tier at low volume, then per thousand messages - Why: Reputation, feedback loops and blocklist handling are their job, and taking them on yourself is a permanent second project. - Get it: Sign up with a provider that publishes delivery webhooks and a rate limit, and create a key scoped to sending. - Verify: A single test message is delivered and a webhook arrives - [ ] **A domain with DNS you control, and a sending subdomain** · the price of a domain you probably already own - Why: SPF, DKIM and DMARC all live in DNS, and sending from a subdomain keeps the main domain's reputation separate. - Get it: Add the provider's DKIM records on a subdomain such as mail.example.com and publish a DMARC record. - Verify: The provider reports the domain verified and a delivered message passes all three - [ ] **Test mailboxes at two major providers** · free - Why: One-click unsubscribe can only be verified by pressing the button in a real client, and the headers render differently between them. - Get it: A free mailbox at each of two large consumer providers. - Verify: Both receive a test broadcast and both show the client's own unsubscribe control - [ ] **The current bulk sender requirements, read today** · free - Why: The thresholds and required headers are set by the receiving providers and they change, so the version you built against needs recording. - Get it: Read the sender guidelines of the providers your list actually uses and note the date. - Verify: The README names the requirements and the date they were read ## 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: - Your own mail server or IP reputation. - Paid subscriptions and recommendation networks. - Recipient-side behavioural tracking. - Any deliverability score you invented. - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations - bounce, complaint, and suppression handling - large-scale segmentation and automation If one of those is essential to you, that is the reason to keep paying for SendFox, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · SendFox The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter sender that can prove consent, honours an unsubscribe in one click and never sends a broadcast twice to replace SendFox. 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 Three rules. Consent is recorded as evidence, not as a boolean: when, from where, by which form, and the confirmation the person clicked. A list you cannot evidence is a list you do not send to, and no bulk import creates a confirmed subscriber. Unsubscribing is instant and irreversible in the direction that matters. The address goes to a suppression table checked at send time, not to a flag on a row that a later import can flip back. A person who left and reappears through a form starts again from unconfirmed. And you are a guest in someone else's inbox. Gmail's sender guidelines require bulk senders to authenticate with SPF and DKIM, publish a DMARC record with the From domain aligned to one of them, keep the spam rate reported in Postmaster Tools below 0.30 percent, and support one-click unsubscribe by RFC 8058 alongside a clearly visible unsubscribe link in the body. Those have been mandatory since 1 February 2024. Read the current page yourself before you send anything, because this is the part that changes. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22, a web process and a sending worker · the send has to continue when the browser is closed - Storage: PostgreSQL with a unique constraint per broadcast and subscriber · the duplicate-send bug is a database problem, so the database prevents it - Delivery: One reputable provider you supply a key for, never your own mail server · reputation is the product, and you are not going to build it in a weekend - Consent: An append-only events table with evidence per event · a boolean cannot answer the only question that matters later - Suppression: Checked inside the send transaction, never only at list build time · an unsubscribe during a send must still take effect ### Data model (create this before Phase 1) - subscribers(id, email_normalised, email_display, state, created_at, confirmed_at, unsubscribed_at, source_form_id) · state is unconfirmed, confirmed, unsubscribed or suppressed - consent_events(id, subscriber_id, at, kind, evidence_json) · kind is signup, confirm, unsubscribe or complaint, and evidence holds the form, the page and the confirmation token used - suppressions(email_normalised, reason, at, source) · permanent, keyed on the normalised address, checked at send - broadcasts(id, subject, html_body, text_body, audience_json, state, scheduled_at, sent_count, created_at) - sends(broadcast_id, subscriber_id, state, provider_message_id, sent_at, last_error) · unique on the pair, which is the exactly-once guarantee - delivery_events(provider_message_id, kind, at, payload_excerpt) · delivered, bounced, complained, keyed for idempotent webhook handling - unsubscribe_tokens(token, subscriber_id, broadcast_id, created_at) · unguessable, scoped to a person and a broadcast, never expiring ### Phase 1 · Consent you could show someone Build: A signup form that creates an unconfirmed subscriber and sends a confirmation mail with a single use token. Confirming writes a consent event with the form, the page, the time and the token used. Normalise addresses for comparison but keep the address the person typed for display. Imports land as unconfirmed and must be confirmed the same way as anyone else. Trap: Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. Done when: A confirmed subscriber has at least a signup event and a confirm event, each with its evidence, asserted in the suite. A confirmation token works once and is then useless, tested by replaying it. An imported list arrives as unconfirmed and no import path can create a confirmed subscriber, asserted by inspecting every insert. The address stored for sending is exactly what the person typed. Do not build yet: No broadcasts, no sending, no unsubscribe handling yet. ### Phase 2 · Unsubscribe the way mail clients actually do it Build: Send both headers that one-click unsubscribe needs: a List-Unsubscribe header carrying an https address, and List-Unsubscribe-Post with the value List-Unsubscribe=One-Click, per RFC 8058. Handle the POST without requiring a login or a confirmation page, and make it write a suppression and a consent event. Keep a visible unsubscribe link in the body as well, since the guidelines require both. Verify the whole path by sending to a real mailbox and using the client's own button. Trap: Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. Done when: A GET to the unsubscribe address never changes state, tested by fetching it twice and asserting the subscriber is untouched. A POST with the one-click body suppresses the address immediately and writes a consent event. Both headers appear in the raw source of a delivered message, checked by reading a real received mail. Once suppressed, the address is skipped inside the send transaction even if the audience was built before the unsubscribe. Do not build yet: No broadcast sending, bounce handling or analytics yet. ### Phase 3 · Sending each person exactly once Build: Materialise the audience into send rows when the broadcast starts, with a unique constraint on the broadcast and subscriber pair. The worker claims a row, marks it sending in its own transaction, calls the provider, then records the message id. A call whose outcome is unknown leaves the row in an unresolved state that is settled by asking the provider, never by sending again. Pace the send to the rate the provider publishes for your account. Trap: Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. Done when: Killing the worker mid-broadcast and restarting it sends no duplicate, asserted by counting messages against a fake provider. Two workers running the same broadcast concurrently produce one message per subscriber, tested with real concurrency. An unresolved send is settled by querying the provider and never by resending, asserted in the suite. The send rate stays within the figure configured from the provider's published limit. Do not build yet: No bounce and complaint handling, no authentication setup, no export. ### Phase 4 · Bounces and complaints, handled before they cost you Build: Accept the provider's delivery webhook, verify its signature, and record each event keyed on its identifier so a redelivery is ignored. A hard bounce suppresses permanently; a complaint suppresses permanently and is never followed by another message to that address; a soft bounce counts toward a stated threshold. Show the complaint rate per broadcast against the 0.30 percent figure the guidelines set, and refuse to start a new broadcast while the recent rate is above it. Trap: Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. Done when: A redelivered webhook event changes nothing the second time, tested by posting the same event twice. A hard bounce suppresses the address permanently and the next broadcast skips it, asserted in the suite. A complaint suppresses and is recorded with its broadcast, and the rate per broadcast is displayed. Starting a broadcast while the recent complaint rate exceeds the configured ceiling is refused with the figure shown. Do not build yet: No domain authentication guidance, no export or deletion requests yet. ### Phase 5 · Authentication, and a list that is not trapped Build: Document and verify SPF, DKIM and a DMARC record with the From domain aligned, using a subdomain for sending so the main domain's reputation is separate. Check the records at startup and refuse to send if DKIM is missing. Export subscribers with their full consent events as CSV, handle a deletion request by removing the person while keeping the suppression, and keep an audit line for every export and deletion. Trap: Deleting a subscriber on request also deletes the record that they unsubscribed, so the next import quietly adds them back and they receive the newsletter they explicitly left, which is the complaint that damages a domain most. Delete the person and keep a suppression keyed on a hash of the address, and say so in the privacy note. Done when: Startup checks SPF, DKIM and DMARC for the sending domain and refuses to send with a named missing record, tested against a domain with each one removed. Export produces every subscriber with their consent events in a file that reimports. A deletion request removes the subscriber while leaving a suppression that blocks a later reimport, asserted in the suite. Every export and deletion appends an audit line with the time and the operator. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which means owning IP reputation, feedback loops and blocklist delisting as a permanent job. - Paid subscriptions and audience recommendation networks, which are where the hosted products make their money and need a payment and identity model of their own. - Deliverability consulting dressed up as a feature, such as a score with no basis; the only honest signals here are the provider's own delivery events. - Large-scale behavioural segmentation, which needs event tracking on the recipient's side and a much larger privacy commitment. ### README must contain - That consent is stored as events with evidence and no import path can create a confirmed subscriber - That one-click unsubscribe needs both headers and a POST handler with no authentication, and that GET never changes state - That sends are unique per broadcast and subscriber, and an unknown outcome is reconciled rather than retried - Which sender requirements were read, on what date, and where to recheck them before the next send - That a deletion request removes the person but keeps a suppression, and why ===== AGENTS.md ===== # Agent instructions · SendFox indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node 22, a web process and a sending worker, PostgreSQL with a unique constraint per broadcast and subscriber, One reputable provider you supply a key for, never your own mail server, An append-only events table with evidence per event, Checked inside the send transaction, never only at list build time. 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 - Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. - Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. - Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. - Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. ===== BUILD_PLAN.md ===== # Build plan · SendFox A broadcast sender whose hard problems are holding consent as evidence rather than a flag, making one-click unsubscribe work the way the mail clients actually call it, sending each person exactly once through a crash, and suppressing a bounce before it costs you the domain. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Consent you could show someone Every confirmed subscriber has a trail explaining how they got there. ### Steps 1. Record consent as events with evidence The question later is not whether they consented but how you know. Files: `src/consent/events.ts`, `migrations/002_consent.sql` 2. Make confirmation tokens single use A replayable confirmation link is a confirmation anyone can perform. Files: `src/consent/confirm.ts`, `tests/token-replay.test.ts` 3. Keep the typed address, normalise only for comparison Aggressive normalisation merges two real people into one. Files: `src/email/normalise.ts`, `tests/normalise.test.ts` ### Done when - [ ] Confirmed subscribers carry signup and confirm evidence - [ ] A confirmation token cannot be replayed - [ ] No import path produces a confirmed subscriber - [ ] The typed address is what gets sent to ### Watch out - Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. ## Phase 2 · Unsubscribe the way mail clients actually do it One click in the mail client removes the person, and nothing else can. ### Steps 1. Emit both one-click headers RFC 8058 needs the POST header as well as the address, and clients check for it. Files: `src/mail/headers.ts` 2. Unsubscribe on POST, never on GET Prefetchers issue GET requests, and a list can empty overnight. Files: `src/web/routes/unsubscribe.ts`, `tests/get-is-safe.test.ts` 3. Check suppression inside the send transaction Unsubscribes arrive during a send, not politely before it. Files: `src/send/worker.ts` ### Done when - [ ] A GET to the unsubscribe address changes nothing - [ ] A one-click POST suppresses immediately - [ ] Both headers appear in a real received message - [ ] Suppression is checked inside the send transaction ### Watch out - Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. ## Phase 3 · Sending each person exactly once A crash, a retry or a double click produces one message per subscriber. ### Steps 1. Materialise send rows with a unique pair constraint The database is the thing that makes exactly-once true. Files: `migrations/003_sends.sql`, `src/send/materialise.ts` 2. Claim the row before calling the provider Not after, and not in the same transaction as the call. Files: `src/send/worker.ts`, `tests/exactly-once.test.ts` 3. Settle unknown outcomes by asking Resending on a timeout is the duplicate-send bug wearing a different hat. Files: `src/send/reconcile.ts` ### Done when - [ ] A mid-broadcast crash produces no duplicate - [ ] Concurrent workers send once per subscriber - [ ] Unknown outcomes are reconciled, not resent - [ ] The send rate honours the provider's published limit ### Watch out - Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. ## Phase 4 · Bounces and complaints, handled before they cost you A hard bounce or a complaint suppresses the address without being told twice. ### Steps 1. Verify the webhook signature and key on the event id Providers redeliver, and an unsigned endpoint is an open suppression list. Files: `src/web/routes/webhook.ts`, `tests/webhook-idempotent.test.ts` 2. Separate hard bounces from soft ones Retrying a dead mailbox forever is a reputation cost with no upside. Files: `src/delivery/classify.ts` 3. Block the next send on a high complaint rate The number that matters is the one the receiving provider is watching. Files: `src/send/gate.ts` ### Done when - [ ] A redelivered event is ignored the second time - [ ] Hard bounces suppress permanently - [ ] Complaint rate is shown per broadcast - [ ] A high recent rate refuses the next broadcast ### Watch out - Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. ## Not in this build - Your own mail server or IP reputation. - Paid subscriptions and recommendation networks. - Recipient-side behavioural tracking. - Any deliverability score you invented. ## After v1, if you want it - A simple automation of two or three timed messages, reusing the send rows and the suppression check unchanged - A plain-text archive page per broadcast, which also gives the unsubscribe link a second home - Seed testing across a handful of mailboxes before each send, reported as rendering rather than as a placement score ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Subscribers, consent events, suppressions and sends. DATABASE_URL=postgres://localhost:5432/newsletter # Required · secret. Sending scope only. Rotate it without touching the database. EMAIL_PROVIDER_KEY=sk-...redacted # Required · secret. Verifies delivery webhooks. An unverified endpoint is an open suppression list. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. The subdomain carrying DKIM, checked at startup. SENDING_DOMAIN=mail.example.com # Required. Must align with SPF or DKIM for DMARC to pass. FROM_ADDRESS=letters@mail.example.com # Required. Taken from the provider's published limit for your account, with the date recorded. SEND_RATE_PER_SECOND=10 # Required. Blocks a new broadcast above this recent rate. Chosen against the published threshold. COMPLAINT_RATE_CEILING=0.003 # Required. Consecutive soft bounces before an address is suppressed. SOFT_BOUNCE_THRESHOLD=4
You are building a production product version of SendFox. 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 ===== # SendFox · product brief ## Problem The visible creator email marketing loop is buildable, but a credible replacement needs more than the first screen. SendFox 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 show how every subscriber consented, removes someone the instant their mail client asks, never sends the same person a broadcast twice, and stops itself before a complaint rate damages the domain. ## 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 - Your own mail server or IP reputation. - Paid subscriptions and recommendation networks. - Recipient-side behavioural tracking. - Any deliverability score you invented. - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations - bounce, complaint, and suppression handling - large-scale segmentation and automation ## Success criteria - A GET to the unsubscribe address is confirmed to change nothing - Both one-click headers are confirmed present in a real received message - A mid-broadcast crash is confirmed to produce no duplicate - A redelivered webhook event is confirmed idempotent - A missing DKIM record is confirmed to refuse the send ===== BRIEF.md ===== # Build brief · SendFox The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter sender that can prove consent, honours an unsubscribe in one click and never sends a broadcast twice to replace SendFox. 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 Three rules. Consent is recorded as evidence, not as a boolean: when, from where, by which form, and the confirmation the person clicked. A list you cannot evidence is a list you do not send to, and no bulk import creates a confirmed subscriber. Unsubscribing is instant and irreversible in the direction that matters. The address goes to a suppression table checked at send time, not to a flag on a row that a later import can flip back. A person who left and reappears through a form starts again from unconfirmed. And you are a guest in someone else's inbox. Gmail's sender guidelines require bulk senders to authenticate with SPF and DKIM, publish a DMARC record with the From domain aligned to one of them, keep the spam rate reported in Postmaster Tools below 0.30 percent, and support one-click unsubscribe by RFC 8058 alongside a clearly visible unsubscribe link in the body. Those have been mandatory since 1 February 2024. Read the current page yourself before you send anything, because this is the part that changes. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22, a web process and a sending worker · the send has to continue when the browser is closed - Storage: PostgreSQL with a unique constraint per broadcast and subscriber · the duplicate-send bug is a database problem, so the database prevents it - Delivery: One reputable provider you supply a key for, never your own mail server · reputation is the product, and you are not going to build it in a weekend - Consent: An append-only events table with evidence per event · a boolean cannot answer the only question that matters later - Suppression: Checked inside the send transaction, never only at list build time · an unsubscribe during a send must still take effect ### Data model (create this before Phase 1) - subscribers(id, email_normalised, email_display, state, created_at, confirmed_at, unsubscribed_at, source_form_id) · state is unconfirmed, confirmed, unsubscribed or suppressed - consent_events(id, subscriber_id, at, kind, evidence_json) · kind is signup, confirm, unsubscribe or complaint, and evidence holds the form, the page and the confirmation token used - suppressions(email_normalised, reason, at, source) · permanent, keyed on the normalised address, checked at send - broadcasts(id, subject, html_body, text_body, audience_json, state, scheduled_at, sent_count, created_at) - sends(broadcast_id, subscriber_id, state, provider_message_id, sent_at, last_error) · unique on the pair, which is the exactly-once guarantee - delivery_events(provider_message_id, kind, at, payload_excerpt) · delivered, bounced, complained, keyed for idempotent webhook handling - unsubscribe_tokens(token, subscriber_id, broadcast_id, created_at) · unguessable, scoped to a person and a broadcast, never expiring ### Phase 1 · Consent you could show someone Build: A signup form that creates an unconfirmed subscriber and sends a confirmation mail with a single use token. Confirming writes a consent event with the form, the page, the time and the token used. Normalise addresses for comparison but keep the address the person typed for display. Imports land as unconfirmed and must be confirmed the same way as anyone else. Trap: Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. Done when: A confirmed subscriber has at least a signup event and a confirm event, each with its evidence, asserted in the suite. A confirmation token works once and is then useless, tested by replaying it. An imported list arrives as unconfirmed and no import path can create a confirmed subscriber, asserted by inspecting every insert. The address stored for sending is exactly what the person typed. Do not build yet: No broadcasts, no sending, no unsubscribe handling yet. ### Phase 2 · Unsubscribe the way mail clients actually do it Build: Send both headers that one-click unsubscribe needs: a List-Unsubscribe header carrying an https address, and List-Unsubscribe-Post with the value List-Unsubscribe=One-Click, per RFC 8058. Handle the POST without requiring a login or a confirmation page, and make it write a suppression and a consent event. Keep a visible unsubscribe link in the body as well, since the guidelines require both. Verify the whole path by sending to a real mailbox and using the client's own button. Trap: Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. Done when: A GET to the unsubscribe address never changes state, tested by fetching it twice and asserting the subscriber is untouched. A POST with the one-click body suppresses the address immediately and writes a consent event. Both headers appear in the raw source of a delivered message, checked by reading a real received mail. Once suppressed, the address is skipped inside the send transaction even if the audience was built before the unsubscribe. Do not build yet: No broadcast sending, bounce handling or analytics yet. ### Phase 3 · Sending each person exactly once Build: Materialise the audience into send rows when the broadcast starts, with a unique constraint on the broadcast and subscriber pair. The worker claims a row, marks it sending in its own transaction, calls the provider, then records the message id. A call whose outcome is unknown leaves the row in an unresolved state that is settled by asking the provider, never by sending again. Pace the send to the rate the provider publishes for your account. Trap: Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. Done when: Killing the worker mid-broadcast and restarting it sends no duplicate, asserted by counting messages against a fake provider. Two workers running the same broadcast concurrently produce one message per subscriber, tested with real concurrency. An unresolved send is settled by querying the provider and never by resending, asserted in the suite. The send rate stays within the figure configured from the provider's published limit. Do not build yet: No bounce and complaint handling, no authentication setup, no export. ### Phase 4 · Bounces and complaints, handled before they cost you Build: Accept the provider's delivery webhook, verify its signature, and record each event keyed on its identifier so a redelivery is ignored. A hard bounce suppresses permanently; a complaint suppresses permanently and is never followed by another message to that address; a soft bounce counts toward a stated threshold. Show the complaint rate per broadcast against the 0.30 percent figure the guidelines set, and refuse to start a new broadcast while the recent rate is above it. Trap: Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. Done when: A redelivered webhook event changes nothing the second time, tested by posting the same event twice. A hard bounce suppresses the address permanently and the next broadcast skips it, asserted in the suite. A complaint suppresses and is recorded with its broadcast, and the rate per broadcast is displayed. Starting a broadcast while the recent complaint rate exceeds the configured ceiling is refused with the figure shown. Do not build yet: No domain authentication guidance, no export or deletion requests yet. ### Phase 5 · Authentication, and a list that is not trapped Build: Document and verify SPF, DKIM and a DMARC record with the From domain aligned, using a subdomain for sending so the main domain's reputation is separate. Check the records at startup and refuse to send if DKIM is missing. Export subscribers with their full consent events as CSV, handle a deletion request by removing the person while keeping the suppression, and keep an audit line for every export and deletion. Trap: Deleting a subscriber on request also deletes the record that they unsubscribed, so the next import quietly adds them back and they receive the newsletter they explicitly left, which is the complaint that damages a domain most. Delete the person and keep a suppression keyed on a hash of the address, and say so in the privacy note. Done when: Startup checks SPF, DKIM and DMARC for the sending domain and refuses to send with a named missing record, tested against a domain with each one removed. Export produces every subscriber with their consent events in a file that reimports. A deletion request removes the subscriber while leaving a suppression that blocks a later reimport, asserted in the suite. Every export and deletion appends an audit line with the time and the operator. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which means owning IP reputation, feedback loops and blocklist delisting as a permanent job. - Paid subscriptions and audience recommendation networks, which are where the hosted products make their money and need a payment and identity model of their own. - Deliverability consulting dressed up as a feature, such as a score with no basis; the only honest signals here are the provider's own delivery events. - Large-scale behavioural segmentation, which needs event tracking on the recipient's side and a much larger privacy commitment. ### README must contain - That consent is stored as events with evidence and no import path can create a confirmed subscriber - That one-click unsubscribe needs both headers and a POST handler with no authentication, and that GET never changes state - That sends are unique per broadcast and subscriber, and an unknown outcome is reconciled rather than retried - Which sender requirements were read, on what date, and where to recheck them before the next send - That a deletion request removes the person but keeps a suppression, and why ===== ARCHITECTURE.md ===== # Architecture · SendFox ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript on Node 22, a web process and a sending worker | the send has to continue when the browser is closed | | Storage | PostgreSQL with a unique constraint per broadcast and subscriber | the duplicate-send bug is a database problem, so the database prevents it | | Delivery | One reputable provider you supply a key for, never your own mail server | reputation is the product, and you are not going to build it in a weekend | | Consent | An append-only events table with evidence per event | a boolean cannot answer the only question that matters later | | Suppression | Checked inside the send transaction, never only at list build time | an unsubscribe during a send must still take effect | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Consent | Append-only events with evidence. | Any store, provided nothing can create a confirmed subscriber without a confirm event. | | Unsubscribe | Both headers, the POST handler and suppression. | Not swappable for a GET handler, which prefetchers will empty your list through. | | Sender | Claimed rows, pacing and reconciliation. | Any provider, provided the unique pair constraint stays. | | Delivery events | Signed, idempotent webhook handling and classification. | Any provider's format, behind one classifier. | | Authentication | Startup checks of SPF, DKIM and DMARC. | Any DNS, provided a missing record refuses the send. | ## 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 · Subscribers, consent events, suppressions and sends. - `EMAIL_PROVIDER_KEY` · required, secret · Sending scope only. Rotate it without touching the database. - `WEBHOOK_SIGNING_SECRET` · required, secret · Verifies delivery webhooks. An unverified endpoint is an open suppression list. - `SENDING_DOMAIN` · required · The subdomain carrying DKIM, checked at startup. - `FROM_ADDRESS` · required · Must align with SPF or DKIM for DMARC to pass. - `SEND_RATE_PER_SECOND` · required · Taken from the provider's published limit for your account, with the date recorded. - `COMPLAINT_RATE_CEILING` · required · Blocks a new broadcast above this recent rate. Chosen against the published threshold. - `SOFT_BOUNCE_THRESHOLD` · required · Consecutive soft bounces before an address is suppressed. ## 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 · SendFox product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript on Node 22, a web process and a sending worker, PostgreSQL with a unique constraint per broadcast and subscriber, One reputable provider you supply a key for, never your own mail server, An append-only events table with evidence per event, Checked inside the send transaction, never only at list build time. - 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 - Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. - Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. - Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. - Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. - Deleting a subscriber on request also deletes the record that they unsubscribed, so the next import quietly adds them back and they receive the newsletter they explicitly left, which is the complaint that damages a domain most. Delete the person and keep a suppression keyed on a hash of the address, and say so in the privacy note. ===== MILESTONES.md ===== # Delivery milestones · SendFox Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Consent you could show someone Every confirmed subscriber has a trail explaining how they got there. ### Steps 1. Record consent as events with evidence The question later is not whether they consented but how you know. Files: `src/consent/events.ts`, `migrations/002_consent.sql` 2. Make confirmation tokens single use A replayable confirmation link is a confirmation anyone can perform. Files: `src/consent/confirm.ts`, `tests/token-replay.test.ts` 3. Keep the typed address, normalise only for comparison Aggressive normalisation merges two real people into one. Files: `src/email/normalise.ts`, `tests/normalise.test.ts` ### Done when - [ ] Confirmed subscribers carry signup and confirm evidence - [ ] A confirmation token cannot be replayed - [ ] No import path produces a confirmed subscriber - [ ] The typed address is what gets sent to ### Watch out - Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. ## M2 · Unsubscribe the way mail clients actually do it One click in the mail client removes the person, and nothing else can. ### Steps 1. Emit both one-click headers RFC 8058 needs the POST header as well as the address, and clients check for it. Files: `src/mail/headers.ts` 2. Unsubscribe on POST, never on GET Prefetchers issue GET requests, and a list can empty overnight. Files: `src/web/routes/unsubscribe.ts`, `tests/get-is-safe.test.ts` 3. Check suppression inside the send transaction Unsubscribes arrive during a send, not politely before it. Files: `src/send/worker.ts` ### Done when - [ ] A GET to the unsubscribe address changes nothing - [ ] A one-click POST suppresses immediately - [ ] Both headers appear in a real received message - [ ] Suppression is checked inside the send transaction ### Watch out - Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. ## M3 · Sending each person exactly once A crash, a retry or a double click produces one message per subscriber. ### Steps 1. Materialise send rows with a unique pair constraint The database is the thing that makes exactly-once true. Files: `migrations/003_sends.sql`, `src/send/materialise.ts` 2. Claim the row before calling the provider Not after, and not in the same transaction as the call. Files: `src/send/worker.ts`, `tests/exactly-once.test.ts` 3. Settle unknown outcomes by asking Resending on a timeout is the duplicate-send bug wearing a different hat. Files: `src/send/reconcile.ts` ### Done when - [ ] A mid-broadcast crash produces no duplicate - [ ] Concurrent workers send once per subscriber - [ ] Unknown outcomes are reconciled, not resent - [ ] The send rate honours the provider's published limit ### Watch out - Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. ## M4 · Bounces and complaints, handled before they cost you A hard bounce or a complaint suppresses the address without being told twice. ### Steps 1. Verify the webhook signature and key on the event id Providers redeliver, and an unsigned endpoint is an open suppression list. Files: `src/web/routes/webhook.ts`, `tests/webhook-idempotent.test.ts` 2. Separate hard bounces from soft ones Retrying a dead mailbox forever is a reputation cost with no upside. Files: `src/delivery/classify.ts` 3. Block the next send on a high complaint rate The number that matters is the one the receiving provider is watching. Files: `src/send/gate.ts` ### Done when - [ ] A redelivered event is ignored the second time - [ ] Hard bounces suppress permanently - [ ] Complaint rate is shown per broadcast - [ ] A high recent rate refuses the next broadcast ### Watch out - Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. ## M5 · Authentication, and a list that is not trapped (production only) The domain is set up correctly and everything can be taken out with its consent trail. ### Steps 1. Check authentication records at startup Sending without DKIM is the fastest route to the spam folder. Files: `src/dns/check.ts`, `tests/auth-records.test.ts` 2. Export subscribers with their consent events A list without its consent trail is a list you cannot lawfully use elsewhere. Files: `src/export/subscribers.ts` 3. Keep the suppression after a deletion Otherwise the next import undoes someone's decision to leave. Files: `src/privacy/delete.ts`, `tests/delete-keeps-suppression.test.ts` ### Done when - [ ] A missing DKIM record refuses the send - [ ] Export includes consent events and reimports - [ ] Deletion keeps a suppression that blocks reimport - [ ] Exports and deletions are audited ### Watch out - Deleting a subscriber on request also deletes the record that they unsubscribed, so the next import quietly adds them back and they receive the newsletter they explicitly left, which is the complaint that damages a domain most. Delete the person and keep a suppression keyed on a hash of the address, and say so in the privacy note. ===== OPERATIONS.md ===== # Operations · SendFox ## Backup The database, with the consent events and suppressions being the parts you genuinely cannot reconstruct. Back up before every import, because an import is the operation most likely to need undoing. ## Restore Restore and rebuild any broadcast that was sending from its send rows, which record exactly who was reached. Never restart a broadcast from the audience query, because the audience has moved on since. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Complaint and bounce rate per broadcast, unresolved sends, suppression growth, and the DKIM and DMARC records for the sending domain. A DNS change made for an unrelated reason is a common cause of a sudden deliverability drop. ## Incident checklist A duplicate send means the unique constraint or the claim step was bypassed, and the send rows show it. A sudden drop in delivery is a DNS or reputation question first and a code question second, so check the records before reading any application logs. 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 - [ ] A GET to the unsubscribe address is confirmed to change nothing - [ ] Both one-click headers are confirmed present in a real received message - [ ] A mid-broadcast crash is confirmed to produce no duplicate - [ ] A redelivered webhook event is confirmed idempotent - [ ] A missing DKIM record is confirmed to refuse the send ## Launch constraint Do not market omitted SendFox 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. Subscribers, consent events, suppressions and sends. DATABASE_URL=postgres://localhost:5432/newsletter # Required · secret. Sending scope only. Rotate it without touching the database. EMAIL_PROVIDER_KEY=sk-...redacted # Required · secret. Verifies delivery webhooks. An unverified endpoint is an open suppression list. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. The subdomain carrying DKIM, checked at startup. SENDING_DOMAIN=mail.example.com # Required. Must align with SPF or DKIM for DMARC to pass. FROM_ADDRESS=letters@mail.example.com # Required. Taken from the provider's published limit for your account, with the date recorded. SEND_RATE_PER_SECOND=10 # Required. Blocks a new broadcast above this recent rate. Chosen against the published threshold. COMPLAINT_RATE_CEILING=0.003 # Required. Consecutive soft bounces before an address is suppressed. SOFT_BOUNCE_THRESHOLD=4
# SendFox · indie build A broadcast sender whose hard problems are holding consent as evidence rather than a flag, making one-click unsubscribe work the way the mail clients actually call it, sending each person exactly once through a crash, and suppressing a bounce before it costs you the domain. 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 | | --- | --- | --- | | Runtime | TypeScript on Node 22, a web process and a sending worker | the send has to continue when the browser is closed | | Storage | PostgreSQL with a unique constraint per broadcast and subscriber | the duplicate-send bug is a database problem, so the database prevents it | | Delivery | One reputable provider you supply a key for, never your own mail server | reputation is the product, and you are not going to build it in a weekend | | Consent | An append-only events table with evidence per event | a boolean cannot answer the only question that matters later | | Suppression | Checked inside the send transaction, never only at list build time | an unsubscribe during a send must still take effect | ## 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 web process and the sending worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally, a few pounds a month managed - Why: The unique constraint on broadcast and subscriber is what makes exactly-once real rather than hoped for. - Get it: Docker, your package manager, or a managed instance. - Verify: psql connects and the migration applies - [ ] **An email provider account with an API key** · free tier at low volume, then per thousand messages - Why: Reputation, feedback loops and blocklist handling are their job, and taking them on yourself is a permanent second project. - Get it: Sign up with a provider that publishes delivery webhooks and a rate limit, and create a key scoped to sending. - Verify: A single test message is delivered and a webhook arrives - [ ] **A domain with DNS you control, and a sending subdomain** · the price of a domain you probably already own - Why: SPF, DKIM and DMARC all live in DNS, and sending from a subdomain keeps the main domain's reputation separate. - Get it: Add the provider's DKIM records on a subdomain such as mail.example.com and publish a DMARC record. - Verify: The provider reports the domain verified and a delivered message passes all three - [ ] **Test mailboxes at two major providers** · free - Why: One-click unsubscribe can only be verified by pressing the button in a real client, and the headers render differently between them. - Get it: A free mailbox at each of two large consumer providers. - Verify: Both receive a test broadcast and both show the client's own unsubscribe control - [ ] **The current bulk sender requirements, read today** · free - Why: The thresholds and required headers are set by the receiving providers and they change, so the version you built against needs recording. - Get it: Read the sender guidelines of the providers your list actually uses and note the date. - Verify: The README names the requirements and the date they were read ## 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: - Your own mail server or IP reputation. - Paid subscriptions and recommendation networks. - Recipient-side behavioural tracking. - Any deliverability score you invented. - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations - bounce, complaint, and suppression handling - large-scale segmentation and automation If one of those is essential to you, that is the reason to keep paying for SendFox, and the README should say so rather than pretend.
# Build brief · SendFox The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter sender that can prove consent, honours an unsubscribe in one click and never sends a broadcast twice to replace SendFox. 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 Three rules. Consent is recorded as evidence, not as a boolean: when, from where, by which form, and the confirmation the person clicked. A list you cannot evidence is a list you do not send to, and no bulk import creates a confirmed subscriber. Unsubscribing is instant and irreversible in the direction that matters. The address goes to a suppression table checked at send time, not to a flag on a row that a later import can flip back. A person who left and reappears through a form starts again from unconfirmed. And you are a guest in someone else's inbox. Gmail's sender guidelines require bulk senders to authenticate with SPF and DKIM, publish a DMARC record with the From domain aligned to one of them, keep the spam rate reported in Postmaster Tools below 0.30 percent, and support one-click unsubscribe by RFC 8058 alongside a clearly visible unsubscribe link in the body. Those have been mandatory since 1 February 2024. Read the current page yourself before you send anything, because this is the part that changes. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22, a web process and a sending worker · the send has to continue when the browser is closed - Storage: PostgreSQL with a unique constraint per broadcast and subscriber · the duplicate-send bug is a database problem, so the database prevents it - Delivery: One reputable provider you supply a key for, never your own mail server · reputation is the product, and you are not going to build it in a weekend - Consent: An append-only events table with evidence per event · a boolean cannot answer the only question that matters later - Suppression: Checked inside the send transaction, never only at list build time · an unsubscribe during a send must still take effect ### Data model (create this before Phase 1) - subscribers(id, email_normalised, email_display, state, created_at, confirmed_at, unsubscribed_at, source_form_id) · state is unconfirmed, confirmed, unsubscribed or suppressed - consent_events(id, subscriber_id, at, kind, evidence_json) · kind is signup, confirm, unsubscribe or complaint, and evidence holds the form, the page and the confirmation token used - suppressions(email_normalised, reason, at, source) · permanent, keyed on the normalised address, checked at send - broadcasts(id, subject, html_body, text_body, audience_json, state, scheduled_at, sent_count, created_at) - sends(broadcast_id, subscriber_id, state, provider_message_id, sent_at, last_error) · unique on the pair, which is the exactly-once guarantee - delivery_events(provider_message_id, kind, at, payload_excerpt) · delivered, bounced, complained, keyed for idempotent webhook handling - unsubscribe_tokens(token, subscriber_id, broadcast_id, created_at) · unguessable, scoped to a person and a broadcast, never expiring ### Phase 1 · Consent you could show someone Build: A signup form that creates an unconfirmed subscriber and sends a confirmation mail with a single use token. Confirming writes a consent event with the form, the page, the time and the token used. Normalise addresses for comparison but keep the address the person typed for display. Imports land as unconfirmed and must be confirmed the same way as anyone else. Trap: Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. Done when: A confirmed subscriber has at least a signup event and a confirm event, each with its evidence, asserted in the suite. A confirmation token works once and is then useless, tested by replaying it. An imported list arrives as unconfirmed and no import path can create a confirmed subscriber, asserted by inspecting every insert. The address stored for sending is exactly what the person typed. Do not build yet: No broadcasts, no sending, no unsubscribe handling yet. ### Phase 2 · Unsubscribe the way mail clients actually do it Build: Send both headers that one-click unsubscribe needs: a List-Unsubscribe header carrying an https address, and List-Unsubscribe-Post with the value List-Unsubscribe=One-Click, per RFC 8058. Handle the POST without requiring a login or a confirmation page, and make it write a suppression and a consent event. Keep a visible unsubscribe link in the body as well, since the guidelines require both. Verify the whole path by sending to a real mailbox and using the client's own button. Trap: Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. Done when: A GET to the unsubscribe address never changes state, tested by fetching it twice and asserting the subscriber is untouched. A POST with the one-click body suppresses the address immediately and writes a consent event. Both headers appear in the raw source of a delivered message, checked by reading a real received mail. Once suppressed, the address is skipped inside the send transaction even if the audience was built before the unsubscribe. Do not build yet: No broadcast sending, bounce handling or analytics yet. ### Phase 3 · Sending each person exactly once Build: Materialise the audience into send rows when the broadcast starts, with a unique constraint on the broadcast and subscriber pair. The worker claims a row, marks it sending in its own transaction, calls the provider, then records the message id. A call whose outcome is unknown leaves the row in an unresolved state that is settled by asking the provider, never by sending again. Pace the send to the rate the provider publishes for your account. Trap: Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. Done when: Killing the worker mid-broadcast and restarting it sends no duplicate, asserted by counting messages against a fake provider. Two workers running the same broadcast concurrently produce one message per subscriber, tested with real concurrency. An unresolved send is settled by querying the provider and never by resending, asserted in the suite. The send rate stays within the figure configured from the provider's published limit. Do not build yet: No bounce and complaint handling, no authentication setup, no export. ### Phase 4 · Bounces and complaints, handled before they cost you Build: Accept the provider's delivery webhook, verify its signature, and record each event keyed on its identifier so a redelivery is ignored. A hard bounce suppresses permanently; a complaint suppresses permanently and is never followed by another message to that address; a soft bounce counts toward a stated threshold. Show the complaint rate per broadcast against the 0.30 percent figure the guidelines set, and refuse to start a new broadcast while the recent rate is above it. Trap: Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. Done when: A redelivered webhook event changes nothing the second time, tested by posting the same event twice. A hard bounce suppresses the address permanently and the next broadcast skips it, asserted in the suite. A complaint suppresses and is recorded with its broadcast, and the rate per broadcast is displayed. Starting a broadcast while the recent complaint rate exceeds the configured ceiling is refused with the figure shown. Do not build yet: No domain authentication guidance, no export or deletion requests yet. ### Phase 5 · Authentication, and a list that is not trapped Build: Document and verify SPF, DKIM and a DMARC record with the From domain aligned, using a subdomain for sending so the main domain's reputation is separate. Check the records at startup and refuse to send if DKIM is missing. Export subscribers with their full consent events as CSV, handle a deletion request by removing the person while keeping the suppression, and keep an audit line for every export and deletion. Trap: Deleting a subscriber on request also deletes the record that they unsubscribed, so the next import quietly adds them back and they receive the newsletter they explicitly left, which is the complaint that damages a domain most. Delete the person and keep a suppression keyed on a hash of the address, and say so in the privacy note. Done when: Startup checks SPF, DKIM and DMARC for the sending domain and refuses to send with a named missing record, tested against a domain with each one removed. Export produces every subscriber with their consent events in a file that reimports. A deletion request removes the subscriber while leaving a suppression that blocks a later reimport, asserted in the suite. Every export and deletion appends an audit line with the time and the operator. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which means owning IP reputation, feedback loops and blocklist delisting as a permanent job. - Paid subscriptions and audience recommendation networks, which are where the hosted products make their money and need a payment and identity model of their own. - Deliverability consulting dressed up as a feature, such as a score with no basis; the only honest signals here are the provider's own delivery events. - Large-scale behavioural segmentation, which needs event tracking on the recipient's side and a much larger privacy commitment. ### README must contain - That consent is stored as events with evidence and no import path can create a confirmed subscriber - That one-click unsubscribe needs both headers and a POST handler with no authentication, and that GET never changes state - That sends are unique per broadcast and subscriber, and an unknown outcome is reconciled rather than retried - Which sender requirements were read, on what date, and where to recheck them before the next send - That a deletion request removes the person but keeps a suppression, and why
# Agent instructions · SendFox indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node 22, a web process and a sending worker, PostgreSQL with a unique constraint per broadcast and subscriber, One reputable provider you supply a key for, never your own mail server, An append-only events table with evidence per event, Checked inside the send transaction, never only at list build time. 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 - Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. - Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. - Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. - Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send.
# Build plan · SendFox A broadcast sender whose hard problems are holding consent as evidence rather than a flag, making one-click unsubscribe work the way the mail clients actually call it, sending each person exactly once through a crash, and suppressing a bounce before it costs you the domain. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Consent you could show someone Every confirmed subscriber has a trail explaining how they got there. ### Steps 1. Record consent as events with evidence The question later is not whether they consented but how you know. Files: `src/consent/events.ts`, `migrations/002_consent.sql` 2. Make confirmation tokens single use A replayable confirmation link is a confirmation anyone can perform. Files: `src/consent/confirm.ts`, `tests/token-replay.test.ts` 3. Keep the typed address, normalise only for comparison Aggressive normalisation merges two real people into one. Files: `src/email/normalise.ts`, `tests/normalise.test.ts` ### Done when - [ ] Confirmed subscribers carry signup and confirm evidence - [ ] A confirmation token cannot be replayed - [ ] No import path produces a confirmed subscriber - [ ] The typed address is what gets sent to ### Watch out - Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. ## Phase 2 · Unsubscribe the way mail clients actually do it One click in the mail client removes the person, and nothing else can. ### Steps 1. Emit both one-click headers RFC 8058 needs the POST header as well as the address, and clients check for it. Files: `src/mail/headers.ts` 2. Unsubscribe on POST, never on GET Prefetchers issue GET requests, and a list can empty overnight. Files: `src/web/routes/unsubscribe.ts`, `tests/get-is-safe.test.ts` 3. Check suppression inside the send transaction Unsubscribes arrive during a send, not politely before it. Files: `src/send/worker.ts` ### Done when - [ ] A GET to the unsubscribe address changes nothing - [ ] A one-click POST suppresses immediately - [ ] Both headers appear in a real received message - [ ] Suppression is checked inside the send transaction ### Watch out - Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. ## Phase 3 · Sending each person exactly once A crash, a retry or a double click produces one message per subscriber. ### Steps 1. Materialise send rows with a unique pair constraint The database is the thing that makes exactly-once true. Files: `migrations/003_sends.sql`, `src/send/materialise.ts` 2. Claim the row before calling the provider Not after, and not in the same transaction as the call. Files: `src/send/worker.ts`, `tests/exactly-once.test.ts` 3. Settle unknown outcomes by asking Resending on a timeout is the duplicate-send bug wearing a different hat. Files: `src/send/reconcile.ts` ### Done when - [ ] A mid-broadcast crash produces no duplicate - [ ] Concurrent workers send once per subscriber - [ ] Unknown outcomes are reconciled, not resent - [ ] The send rate honours the provider's published limit ### Watch out - Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. ## Phase 4 · Bounces and complaints, handled before they cost you A hard bounce or a complaint suppresses the address without being told twice. ### Steps 1. Verify the webhook signature and key on the event id Providers redeliver, and an unsigned endpoint is an open suppression list. Files: `src/web/routes/webhook.ts`, `tests/webhook-idempotent.test.ts` 2. Separate hard bounces from soft ones Retrying a dead mailbox forever is a reputation cost with no upside. Files: `src/delivery/classify.ts` 3. Block the next send on a high complaint rate The number that matters is the one the receiving provider is watching. Files: `src/send/gate.ts` ### Done when - [ ] A redelivered event is ignored the second time - [ ] Hard bounces suppress permanently - [ ] Complaint rate is shown per broadcast - [ ] A high recent rate refuses the next broadcast ### Watch out - Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. ## Not in this build - Your own mail server or IP reputation. - Paid subscriptions and recommendation networks. - Recipient-side behavioural tracking. - Any deliverability score you invented. ## After v1, if you want it - A simple automation of two or three timed messages, reusing the send rows and the suppression check unchanged - A plain-text archive page per broadcast, which also gives the unsubscribe link a second home - Seed testing across a handful of mailboxes before each send, reported as rendering rather than as a placement score
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Subscribers, consent events, suppressions and sends. DATABASE_URL=postgres://localhost:5432/newsletter # Required · secret. Sending scope only. Rotate it without touching the database. EMAIL_PROVIDER_KEY=sk-...redacted # Required · secret. Verifies delivery webhooks. An unverified endpoint is an open suppression list. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. The subdomain carrying DKIM, checked at startup. SENDING_DOMAIN=mail.example.com # Required. Must align with SPF or DKIM for DMARC to pass. FROM_ADDRESS=letters@mail.example.com # Required. Taken from the provider's published limit for your account, with the date recorded. SEND_RATE_PER_SECOND=10 # Required. Blocks a new broadcast above this recent rate. Chosen against the published threshold. COMPLAINT_RATE_CEILING=0.003 # Required. Consecutive soft bounces before an address is suppressed. SOFT_BOUNCE_THRESHOLD=4
# SendFox · product brief ## Problem The visible creator email marketing loop is buildable, but a credible replacement needs more than the first screen. SendFox 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 show how every subscriber consented, removes someone the instant their mail client asks, never sends the same person a broadcast twice, and stops itself before a complaint rate damages the domain. ## 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 - Your own mail server or IP reputation. - Paid subscriptions and recommendation networks. - Recipient-side behavioural tracking. - Any deliverability score you invented. - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations - bounce, complaint, and suppression handling - large-scale segmentation and automation ## Success criteria - A GET to the unsubscribe address is confirmed to change nothing - Both one-click headers are confirmed present in a real received message - A mid-broadcast crash is confirmed to produce no duplicate - A redelivered webhook event is confirmed idempotent - A missing DKIM record is confirmed to refuse the send
# Build brief · SendFox The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter sender that can prove consent, honours an unsubscribe in one click and never sends a broadcast twice to replace SendFox. 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 Three rules. Consent is recorded as evidence, not as a boolean: when, from where, by which form, and the confirmation the person clicked. A list you cannot evidence is a list you do not send to, and no bulk import creates a confirmed subscriber. Unsubscribing is instant and irreversible in the direction that matters. The address goes to a suppression table checked at send time, not to a flag on a row that a later import can flip back. A person who left and reappears through a form starts again from unconfirmed. And you are a guest in someone else's inbox. Gmail's sender guidelines require bulk senders to authenticate with SPF and DKIM, publish a DMARC record with the From domain aligned to one of them, keep the spam rate reported in Postmaster Tools below 0.30 percent, and support one-click unsubscribe by RFC 8058 alongside a clearly visible unsubscribe link in the body. Those have been mandatory since 1 February 2024. Read the current page yourself before you send anything, because this is the part that changes. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22, a web process and a sending worker · the send has to continue when the browser is closed - Storage: PostgreSQL with a unique constraint per broadcast and subscriber · the duplicate-send bug is a database problem, so the database prevents it - Delivery: One reputable provider you supply a key for, never your own mail server · reputation is the product, and you are not going to build it in a weekend - Consent: An append-only events table with evidence per event · a boolean cannot answer the only question that matters later - Suppression: Checked inside the send transaction, never only at list build time · an unsubscribe during a send must still take effect ### Data model (create this before Phase 1) - subscribers(id, email_normalised, email_display, state, created_at, confirmed_at, unsubscribed_at, source_form_id) · state is unconfirmed, confirmed, unsubscribed or suppressed - consent_events(id, subscriber_id, at, kind, evidence_json) · kind is signup, confirm, unsubscribe or complaint, and evidence holds the form, the page and the confirmation token used - suppressions(email_normalised, reason, at, source) · permanent, keyed on the normalised address, checked at send - broadcasts(id, subject, html_body, text_body, audience_json, state, scheduled_at, sent_count, created_at) - sends(broadcast_id, subscriber_id, state, provider_message_id, sent_at, last_error) · unique on the pair, which is the exactly-once guarantee - delivery_events(provider_message_id, kind, at, payload_excerpt) · delivered, bounced, complained, keyed for idempotent webhook handling - unsubscribe_tokens(token, subscriber_id, broadcast_id, created_at) · unguessable, scoped to a person and a broadcast, never expiring ### Phase 1 · Consent you could show someone Build: A signup form that creates an unconfirmed subscriber and sends a confirmation mail with a single use token. Confirming writes a consent event with the form, the page, the time and the token used. Normalise addresses for comparison but keep the address the person typed for display. Imports land as unconfirmed and must be confirmed the same way as anyone else. Trap: Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. Done when: A confirmed subscriber has at least a signup event and a confirm event, each with its evidence, asserted in the suite. A confirmation token works once and is then useless, tested by replaying it. An imported list arrives as unconfirmed and no import path can create a confirmed subscriber, asserted by inspecting every insert. The address stored for sending is exactly what the person typed. Do not build yet: No broadcasts, no sending, no unsubscribe handling yet. ### Phase 2 · Unsubscribe the way mail clients actually do it Build: Send both headers that one-click unsubscribe needs: a List-Unsubscribe header carrying an https address, and List-Unsubscribe-Post with the value List-Unsubscribe=One-Click, per RFC 8058. Handle the POST without requiring a login or a confirmation page, and make it write a suppression and a consent event. Keep a visible unsubscribe link in the body as well, since the guidelines require both. Verify the whole path by sending to a real mailbox and using the client's own button. Trap: Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. Done when: A GET to the unsubscribe address never changes state, tested by fetching it twice and asserting the subscriber is untouched. A POST with the one-click body suppresses the address immediately and writes a consent event. Both headers appear in the raw source of a delivered message, checked by reading a real received mail. Once suppressed, the address is skipped inside the send transaction even if the audience was built before the unsubscribe. Do not build yet: No broadcast sending, bounce handling or analytics yet. ### Phase 3 · Sending each person exactly once Build: Materialise the audience into send rows when the broadcast starts, with a unique constraint on the broadcast and subscriber pair. The worker claims a row, marks it sending in its own transaction, calls the provider, then records the message id. A call whose outcome is unknown leaves the row in an unresolved state that is settled by asking the provider, never by sending again. Pace the send to the rate the provider publishes for your account. Trap: Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. Done when: Killing the worker mid-broadcast and restarting it sends no duplicate, asserted by counting messages against a fake provider. Two workers running the same broadcast concurrently produce one message per subscriber, tested with real concurrency. An unresolved send is settled by querying the provider and never by resending, asserted in the suite. The send rate stays within the figure configured from the provider's published limit. Do not build yet: No bounce and complaint handling, no authentication setup, no export. ### Phase 4 · Bounces and complaints, handled before they cost you Build: Accept the provider's delivery webhook, verify its signature, and record each event keyed on its identifier so a redelivery is ignored. A hard bounce suppresses permanently; a complaint suppresses permanently and is never followed by another message to that address; a soft bounce counts toward a stated threshold. Show the complaint rate per broadcast against the 0.30 percent figure the guidelines set, and refuse to start a new broadcast while the recent rate is above it. Trap: Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. Done when: A redelivered webhook event changes nothing the second time, tested by posting the same event twice. A hard bounce suppresses the address permanently and the next broadcast skips it, asserted in the suite. A complaint suppresses and is recorded with its broadcast, and the rate per broadcast is displayed. Starting a broadcast while the recent complaint rate exceeds the configured ceiling is refused with the figure shown. Do not build yet: No domain authentication guidance, no export or deletion requests yet. ### Phase 5 · Authentication, and a list that is not trapped Build: Document and verify SPF, DKIM and a DMARC record with the From domain aligned, using a subdomain for sending so the main domain's reputation is separate. Check the records at startup and refuse to send if DKIM is missing. Export subscribers with their full consent events as CSV, handle a deletion request by removing the person while keeping the suppression, and keep an audit line for every export and deletion. Trap: Deleting a subscriber on request also deletes the record that they unsubscribed, so the next import quietly adds them back and they receive the newsletter they explicitly left, which is the complaint that damages a domain most. Delete the person and keep a suppression keyed on a hash of the address, and say so in the privacy note. Done when: Startup checks SPF, DKIM and DMARC for the sending domain and refuses to send with a named missing record, tested against a domain with each one removed. Export produces every subscriber with their consent events in a file that reimports. A deletion request removes the subscriber while leaving a suppression that blocks a later reimport, asserted in the suite. Every export and deletion appends an audit line with the time and the operator. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which means owning IP reputation, feedback loops and blocklist delisting as a permanent job. - Paid subscriptions and audience recommendation networks, which are where the hosted products make their money and need a payment and identity model of their own. - Deliverability consulting dressed up as a feature, such as a score with no basis; the only honest signals here are the provider's own delivery events. - Large-scale behavioural segmentation, which needs event tracking on the recipient's side and a much larger privacy commitment. ### README must contain - That consent is stored as events with evidence and no import path can create a confirmed subscriber - That one-click unsubscribe needs both headers and a POST handler with no authentication, and that GET never changes state - That sends are unique per broadcast and subscriber, and an unknown outcome is reconciled rather than retried - Which sender requirements were read, on what date, and where to recheck them before the next send - That a deletion request removes the person but keeps a suppression, and why
# Architecture · SendFox ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript on Node 22, a web process and a sending worker | the send has to continue when the browser is closed | | Storage | PostgreSQL with a unique constraint per broadcast and subscriber | the duplicate-send bug is a database problem, so the database prevents it | | Delivery | One reputable provider you supply a key for, never your own mail server | reputation is the product, and you are not going to build it in a weekend | | Consent | An append-only events table with evidence per event | a boolean cannot answer the only question that matters later | | Suppression | Checked inside the send transaction, never only at list build time | an unsubscribe during a send must still take effect | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Consent | Append-only events with evidence. | Any store, provided nothing can create a confirmed subscriber without a confirm event. | | Unsubscribe | Both headers, the POST handler and suppression. | Not swappable for a GET handler, which prefetchers will empty your list through. | | Sender | Claimed rows, pacing and reconciliation. | Any provider, provided the unique pair constraint stays. | | Delivery events | Signed, idempotent webhook handling and classification. | Any provider's format, behind one classifier. | | Authentication | Startup checks of SPF, DKIM and DMARC. | Any DNS, provided a missing record refuses the send. | ## 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 · Subscribers, consent events, suppressions and sends. - `EMAIL_PROVIDER_KEY` · required, secret · Sending scope only. Rotate it without touching the database. - `WEBHOOK_SIGNING_SECRET` · required, secret · Verifies delivery webhooks. An unverified endpoint is an open suppression list. - `SENDING_DOMAIN` · required · The subdomain carrying DKIM, checked at startup. - `FROM_ADDRESS` · required · Must align with SPF or DKIM for DMARC to pass. - `SEND_RATE_PER_SECOND` · required · Taken from the provider's published limit for your account, with the date recorded. - `COMPLAINT_RATE_CEILING` · required · Blocks a new broadcast above this recent rate. Chosen against the published threshold. - `SOFT_BOUNCE_THRESHOLD` · required · Consecutive soft bounces before an address is suppressed. ## 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 · SendFox product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript on Node 22, a web process and a sending worker, PostgreSQL with a unique constraint per broadcast and subscriber, One reputable provider you supply a key for, never your own mail server, An append-only events table with evidence per event, Checked inside the send transaction, never only at list build time. - 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 - Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. - Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. - Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. - Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. - Deleting a subscriber on request also deletes the record that they unsubscribed, so the next import quietly adds them back and they receive the newsletter they explicitly left, which is the complaint that damages a domain most. Delete the person and keep a suppression keyed on a hash of the address, and say so in the privacy note.
# Delivery milestones · SendFox Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Consent you could show someone Every confirmed subscriber has a trail explaining how they got there. ### Steps 1. Record consent as events with evidence The question later is not whether they consented but how you know. Files: `src/consent/events.ts`, `migrations/002_consent.sql` 2. Make confirmation tokens single use A replayable confirmation link is a confirmation anyone can perform. Files: `src/consent/confirm.ts`, `tests/token-replay.test.ts` 3. Keep the typed address, normalise only for comparison Aggressive normalisation merges two real people into one. Files: `src/email/normalise.ts`, `tests/normalise.test.ts` ### Done when - [ ] Confirmed subscribers carry signup and confirm evidence - [ ] A confirmation token cannot be replayed - [ ] No import path produces a confirmed subscriber - [ ] The typed address is what gets sent to ### Watch out - Lowercasing and stripping dots from every address to deduplicate silently merges two different people at providers that treat the local part as case sensitive or meaningful, and the wrong person then receives someone else's newsletter and unsubscribes them. Normalise only the domain and case for comparison, keep the original for sending, and never invent provider-specific rules you have not verified. ## M2 · Unsubscribe the way mail clients actually do it One click in the mail client removes the person, and nothing else can. ### Steps 1. Emit both one-click headers RFC 8058 needs the POST header as well as the address, and clients check for it. Files: `src/mail/headers.ts` 2. Unsubscribe on POST, never on GET Prefetchers issue GET requests, and a list can empty overnight. Files: `src/web/routes/unsubscribe.ts`, `tests/get-is-safe.test.ts` 3. Check suppression inside the send transaction Unsubscribes arrive during a send, not politely before it. Files: `src/send/worker.ts` ### Done when - [ ] A GET to the unsubscribe address changes nothing - [ ] A one-click POST suppresses immediately - [ ] Both headers appear in a real received message - [ ] Suppression is checked inside the send transaction ### Watch out - Serving the unsubscribe at a GET address means link scanners and mail client prefetchers remove people who never clicked anything, and a list quietly empties; serving it only at POST behind a login means the mail client's own button fails silently. Unsubscribe on POST with no authentication, use GET only for a page with a button, and test both. ## M3 · Sending each person exactly once A crash, a retry or a double click produces one message per subscriber. ### Steps 1. Materialise send rows with a unique pair constraint The database is the thing that makes exactly-once true. Files: `migrations/003_sends.sql`, `src/send/materialise.ts` 2. Claim the row before calling the provider Not after, and not in the same transaction as the call. Files: `src/send/worker.ts`, `tests/exactly-once.test.ts` 3. Settle unknown outcomes by asking Resending on a timeout is the duplicate-send bug wearing a different hat. Files: `src/send/reconcile.ts` ### Done when - [ ] A mid-broadcast crash produces no duplicate - [ ] Concurrent workers send once per subscriber - [ ] Unknown outcomes are reconciled, not resent - [ ] The send rate honours the provider's published limit ### Watch out - Retrying after a timeout because the request failed sends a second copy every time the provider accepted the first, and on a list of a few thousand that is a visible embarrassment plus a spike in complaints. Claim the row before the call, and treat an unknown outcome as a question for the provider rather than a reason to send again. ## M4 · Bounces and complaints, handled before they cost you A hard bounce or a complaint suppresses the address without being told twice. ### Steps 1. Verify the webhook signature and key on the event id Providers redeliver, and an unsigned endpoint is an open suppression list. Files: `src/web/routes/webhook.ts`, `tests/webhook-idempotent.test.ts` 2. Separate hard bounces from soft ones Retrying a dead mailbox forever is a reputation cost with no upside. Files: `src/delivery/classify.ts` 3. Block the next send on a high complaint rate The number that matters is the one the receiving provider is watching. Files: `src/send/gate.ts` ### Done when - [ ] A redelivered event is ignored the second time - [ ] Hard bounces suppress permanently - [ ] Complaint rate is shown per broadcast - [ ] A high recent rate refuses the next broadcast ### Watch out - Treating every bounce the same retries a mailbox that no longer exists on every broadcast, and each one is another signal to the receiving provider that you do not maintain your list; meanwhile the complaint that should have stopped a send gets processed twice and counted twice. Separate hard from soft, key webhook events on their identifier, and let the complaint rate block the next send. ## M5 · Authentication, and a list that is not trapped (production only) The domain is set up correctly and everything can be taken out with its consent trail. ### Steps 1. Check authentication records at startup Sending without DKIM is the fastest route to the spam folder. Files: `src/dns/check.ts`, `tests/auth-records.test.ts` 2. Export subscribers with their consent events A list without its consent trail is a list you cannot lawfully use elsewhere. Files: `src/export/subscribers.ts` 3. Keep the suppression after a deletion Otherwise the next import undoes someone's decision to leave. Files: `src/privacy/delete.ts`, `tests/delete-keeps-suppression.test.ts` ### Done when - [ ] A missing DKIM record refuses the send - [ ] Export includes consent events and reimports - [ ] Deletion keeps a suppression that blocks reimport - [ ] Exports and deletions are audited ### Watch out - Deleting a subscriber on request also deletes the record that they unsubscribed, so the next import quietly adds them back and they receive the newsletter they explicitly left, which is the complaint that damages a domain most. Delete the person and keep a suppression keyed on a hash of the address, and say so in the privacy note.
# Operations · SendFox ## Backup The database, with the consent events and suppressions being the parts you genuinely cannot reconstruct. Back up before every import, because an import is the operation most likely to need undoing. ## Restore Restore and rebuild any broadcast that was sending from its send rows, which record exactly who was reached. Never restart a broadcast from the audience query, because the audience has moved on since. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Complaint and bounce rate per broadcast, unresolved sends, suppression growth, and the DKIM and DMARC records for the sending domain. A DNS change made for an unrelated reason is a common cause of a sudden deliverability drop. ## Incident checklist A duplicate send means the unique constraint or the claim step was bypassed, and the send rows show it. A sudden drop in delivery is a DNS or reputation question first and a code question second, so check the records before reading any application logs. 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 - [ ] A GET to the unsubscribe address is confirmed to change nothing - [ ] Both one-click headers are confirmed present in a real received message - [ ] A mid-broadcast crash is confirmed to produce no duplicate - [ ] A redelivered webhook event is confirmed idempotent - [ ] A missing DKIM record is confirmed to refuse the send ## Launch constraint Do not market omitted SendFox 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. Subscribers, consent events, suppressions and sends. DATABASE_URL=postgres://localhost:5432/newsletter # Required · secret. Sending scope only. Rotate it without touching the database. EMAIL_PROVIDER_KEY=sk-...redacted # Required · secret. Verifies delivery webhooks. An unverified endpoint is an open suppression list. WEBHOOK_SIGNING_SECRET=whsec-...redacted # Required. The subdomain carrying DKIM, checked at startup. SENDING_DOMAIN=mail.example.com # Required. Must align with SPF or DKIM for DMARC to pass. FROM_ADDRESS=letters@mail.example.com # Required. Taken from the provider's published limit for your account, with the date recorded. SEND_RATE_PER_SECOND=10 # Required. Blocks a new broadcast above this recent rate. Chosen against the published threshold. COMPLAINT_RATE_CEILING=0.003 # Required. Consecutive soft bounces before an address is suppressed. SOFT_BOUNCE_THRESHOLD=4
$ choose a build depth, inspect the files, then open the complete pack in your agent
SendFox: The editor is trivial compared with inbox placement, compliance, reputation, unsubscribe handling, analytics, and audience growth infrastructure.
xpayments, recommendations, and audience network
xsender reputation, deliverability, and suppression handling
xsender reputation and deliverability operations
xbounce, complaint, and suppression handling
xlarge-scale segmentation and automation
Don't feel like building it? These folks already made it free.
all 5 free alternatives to SendFox →· no votes, no pay-to-list · just what's real
SendFox pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 1,000 contacts, 3,000 sends/month, 1 smart page, 1 form, 1 automation, no API, no MCP, branding. |
| lifetime license | custom | — | 5,000 contacts, 300,000 lifetime sends, unlimited automations/smart pages/forms, full API. |
| pro 5k | $19/workspace | $15.83/workspace | 5,000 contacts, 100,000 sends/month, white label, MCP, VIP sending IP, paid newsletters, HTML editor. |
| pro 25k | $79/workspace | $65.83/workspace | 25,000 contacts; higher send/contact tier of Pro. |
| custom | custom | — | Above 250,000 contacts; custom terms. |
free tier$0; 1,000 contacts, 3,000 sends/month, 1 smart page, 1 form, 1 automation, branding, no API/MCP.
billingfree + one-time lifetime license + monthly Pro; annual Pro gives 2 months free
hidden costsLifetime plan has 300,000 lifetime sends and keeps footer; Pro price grows with contact tier.
verified 2026-08-10 · source ↗
Vibecode SendFox
Kinda. The core of SendFox is buildable in a weekend with the prompt on this page, but there are real gaps: payments, recommendations, and audience network, sender reputation, deliverability, and suppression handling. Read the honest list above before committing.
How much does SendFox cost?
SendFox's pricing is usage-based or varies by plan · The pricing model is not safely comparable as one fixed monthly USD amount. Review the official source before publication..
What do I lose by replacing SendFox?
Honestly: payments, recommendations, and audience network; sender reputation, deliverability, and suppression handling; sender reputation and deliverability operations; bounce, complaint, and suppression handling; large-scale segmentation and automation. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to SendFox?
Yes: Mautic (Broadcasts and automations with no contact cap; the server stack is wildly less simple.) Brevo Free (Campaigns, forms and automation with a 300-message daily speed governor.) EmailOctopus Free (Simple broadcasts, forms and three short automations; enough for a modest creator list.) All 5 curated free alternatives are at vibecodeit.com/sendfox/alternatives. The prompt is for when you want it exactly your way.