Vibecode Letterdrop
track this build4 phases, 12 steps, beginner friendly0%The visible B2B content + distribution loop is buildable, but a credible replacement needs more than the first screen. Letterdrop 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 Letterdrop. 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 ===== # Letterdrop · indie build A newsletter sender whose hard problems are an unsubscribe that is instant and impossible to bypass, sending exactly once per recipient per issue across crashes, and keeping bounces and complaints flowing back into suppression rather than into a dashboard nobody reads. 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 with one worker and a queue in the database | an issue send is a long-running job that must survive a restart | | Storage | PostgreSQL, with suppression enforced by a constraint and checked at send time | an unsubscribe that only the application respects is not an unsubscribe | | Sending | One provider behind one send function, chosen for its bounce and complaint webhooks | the feedback loop matters more than the send API | | Rendering | Inline CSS at build time, with a plain-text alternative generated from the same source | mail clients strip stylesheets and some readers only see text | | Tracking | Off by default, and open tracking absent entirely | an invisible pixel in every message is a choice you should have to make deliberately | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 and PostgreSQL 16** · free - Why: The worker and the send queue both live in the database, and the unique constraint on sends is the exactly-once guarantee. - Get it: nodejs.org and postgresql.org, or containers for both. - Verify: node --version and psql --version print, and the schema applies cleanly - [ ] **A domain you control, with DNS access** · around ten pounds a year - Why: SPF, DKIM and DMARC are records you have to publish, and without DNS access none of the authentication work in the last phase is possible. - Get it: Any registrar, with access to the zone. - Verify: A TXT record you add resolves publicly within an hour - [ ] **A sending provider with bounce and complaint webhooks** · free tier is usually enough below a few thousand messages a month - Why: The feedback loop matters more than the send API, because bounces and complaints are what keep the suppression list correct and the domain deliverable. - Get it: Any transactional or bulk provider that posts delivery, bounce and complaint events to a URL you own. - Verify: A test send produces a delivered event on your webhook, and a send to a known invalid address produces a bounce - [ ] **The current sender guidelines from the major mailbox providers** · free - Why: The thresholds and header requirements in this build are copied from them rather than invented, and they have changed more than once. - Get it: Read the current bulk sender pages, note the spam rate thresholds, the unsubscribe deadline and the authentication requirements, and record the date. - Verify: A note in the repository records the figures and the date checked - [ ] **Test addresses on at least three mail clients** · free - Why: Rendering parity and the one-click header are only provable by receiving the message somewhere real, and clients differ more than any test suite suggests. - Get it: Accounts at three providers, including at least one that reads plain text. - Verify: A test issue arrives at all three and the unsubscribe control appears in the client - [ ] **Your complaint threshold and soft bounce retry count** · free - Why: Both are asserted by tests and both pause or suppress real sends, so choosing them after seeing a bad issue means choosing whatever that issue did. - Get it: Write down the complaint rate that pauses sending and how many soft bounces become a suppression. - Verify: Both appear in configuration and are named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Running a mail server. - Open tracking pixels. - Behavioural automation and large-scale segmentation. - Paid subscriptions and recommendation networks. - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations - bounce, complaint, and suppression handling If one of those is essential to you, that is the reason to keep paying for Letterdrop, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Letterdrop The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter tool that sends through your own provider and treats the unsubscribe path as the most important code in it to replace Letterdrop. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first outranks every feature you will be tempted to add first. The unsubscribe path is the most important code in this build. Google's sender guidelines require marketing and subscribed mail to support one-click unsubscribe using the List-Unsubscribe and List-Unsubscribe-Post headers described in RFC 2369 and RFC 8058, and require unsubscribe requests to be honoured within 48 hours; they ask senders to keep the spam rate reported in Postmaster Tools below 0.10 per cent and never to reach 0.30 per cent, and they require SPF and DKIM for everyone plus DMARC over a TLS connection once you are sending around 5,000 messages a day to personal Gmail accounts, counted across a primary domain and its subdomains. Those figures were checked while this was written and they change, so read the current page before you send anything. Treat 48 hours as a ceiling and make removal take effect on the next send, always, with no batch job in between. Second, you are not running a mail server. Sending goes through a provider you hold an account with, from a domain you control and have authenticated, and the suppression list is consulted at send time rather than when the list was built. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22 with one worker and a queue in the database · an issue send is a long-running job that must survive a restart - Storage: PostgreSQL, with suppression enforced by a constraint and checked at send time · an unsubscribe that only the application respects is not an unsubscribe - Sending: One provider behind one send function, chosen for its bounce and complaint webhooks · the feedback loop matters more than the send API - Rendering: Inline CSS at build time, with a plain-text alternative generated from the same source · mail clients strip stylesheets and some readers only see text - Tracking: Off by default, and open tracking absent entirely · an invisible pixel in every message is a choice you should have to make deliberately ### Data model (create this before Phase 1) - subscribers(id, email_normalised, name, state, consent_source, consent_at, consent_ip, created_at) · state is subscribed, unsubscribed, bounced or complained - suppressions(email_normalised, reason, at, source) · unique on the normalised address, checked inside the send transaction - issues(id, title, markdown, html, text, state, scheduled_at, sent_at) · html and text are generated from the same markdown - sends(issue_id, subscriber_id, state, attempts, provider_message_id, sent_at, last_error) · unique on (issue_id, subscriber_id) - events(provider_message_id, kind, at, payload_excerpt) · kind is delivered, bounced, complained or deferred, from the provider's webhook - unsubscribe_tokens(subscriber_id, token_hash, issue_id, created_at) · hashed, per issue, and valid indefinitely because people unsubscribe from old mail - An address that reaches suppressions is never sent to again by any issue, including one already queued, because the check happens inside the transaction that claims the send row. ### Phase 1 · Consent, normalisation and a suppression list with teeth Build: Store how, when and from where each subscriber signed up. Normalise addresses once on the way in and store the normalised form alongside the original. Import from CSV with a required consent column. Make the suppression check part of the send-row claim rather than a filter on the list. Trap: Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. Done when: An address suppressed after an issue is queued receives nothing, tested by suppressing mid-send. An import containing a suppressed address leaves it suppressed and reports the count of skipped rows. Addresses differing only by case or surrounding whitespace resolve to one subscriber, tested. Every subscriber row has a consent source and timestamp, enforced by a not-null constraint, and the importer refuses a file without one. Do not build yet: No composing, sending, unsubscribe links or webhooks. ### Phase 2 · One-click unsubscribe, done to the letter Build: Add List-Unsubscribe with both a mailto and an HTTPS URL, and List-Unsubscribe-Post set to List-Unsubscribe=One-Click, so a client posts to the URL without a confirmation page. Handle that POST by suppressing immediately and returning success. Keep a human unsubscribe link in the body too. Tokens are random, stored hashed, per issue and never expire. Trap: Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. Done when: A POST to the unsubscribe URL with the one-click body suppresses the address and returns success, tested against the header contract. A GET to the same URL renders a confirmation page and changes nothing, so a prefetcher cannot unsubscribe anyone. Suppression is visible to the next send immediately, with no scheduled job in between, tested. Repeating either request is harmless and returns the same result. Do not build yet: No rendering, sending or webhooks yet. ### Phase 3 · Mail that renders where mail is read Build: Write in Markdown. Generate HTML with CSS inlined at build time and a table-based layout, and generate the plain-text part from the same Markdown rather than by stripping tags. Rewrite links only if click tracking is explicitly enabled. Send a real test to at least three different clients before an issue can be scheduled. Trap: Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. Done when: The text part contains every heading and every link destination the HTML part contains, asserted in the suite. CSS is inlined and no stylesheet element remains, tested against the generated HTML. With tracking disabled the message contains no rewritten links and no image beacon, verified by scanning the output. An issue cannot be scheduled until a test send has been recorded. Do not build yet: No bulk sending, webhooks or authentication work yet. ### Phase 4 · One message per person per issue Build: Materialise one send row per recipient per issue with a unique constraint, then claim rows in small batches, moving each to sending in its own transaction before the provider is called. Store the provider's message id. Consume the bounce and complaint webhooks and write hard bounces and complaints straight into suppressions. Trap: Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. Done when: Killing the worker mid-issue and restarting delivers exactly one message per subscriber, tested with a counting fake provider. A hard bounce arriving on the webhook suppresses that address within one processing cycle and the next issue skips it, tested. A complaint does the same and is recorded separately from a bounce. Soft bounces are retried a stated number of times and then suppressed, with the count in configuration. Do not build yet: No domain authentication or reputation monitoring yet. ### Phase 5 · Your domain, your reputation, your exit Build: Document and verify SPF, DKIM and DMARC for the sending domain, with a startup check that refuses to send if any record is missing. Record the complaint rate per issue from webhook events and pause sending automatically above a stated threshold. Export subscribers, issues, sends and events as files. Rate limit the public subscribe endpoint and require confirmation. Trap: Reputation damage arrives as a gradual drop in delivery rather than an error, so an issue that quietly lands in spam for half your readers looks identical to one that landed fine. Compute the complaint rate per issue from the webhook events, compare it to the threshold you wrote down, and pause the next send automatically rather than leaving it to a person to notice. Done when: Startup refuses to send when SPF, DKIM or DMARC is missing for the configured domain, naming the missing record, tested with a domain that lacks one. The complaint rate per issue is computed from events and shown, and exceeding the configured threshold pauses the next send until acknowledged. Public subscription requires a confirmation click and the endpoint is rate limited per address. Export produces files that reimport into an empty database with consent and suppression intact. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which is a reputation project measured in months rather than a component. - Open tracking pixels, which put an invisible beacon in every message and buy you a number that modern privacy proxies have already ruined. - Large-scale segmentation and behavioural automation, which needs an event store and a rules engine and is most of what the subscription pays for. - Paid subscriptions and audience recommendation networks, which add payments, tax and a second set of webhooks that must also be exactly once. - Buying, scraping or enriching an address list, which is how a domain's reputation is destroyed in one send. ### README must contain - That one-click unsubscribe is implemented with List-Unsubscribe and List-Unsubscribe-Post per RFC 2369 and RFC 8058, and that the POST acts immediately while the GET only confirms - That suppression is checked inside the transaction claiming each send, so an unsubscribe during a send takes effect - The current sender requirements you checked, with the date, including the spam rate thresholds and the authentication records needed - That send rows are materialised with a unique constraint so a crash mid-issue resumes rather than repeats - That open tracking is absent and click tracking is off unless explicitly enabled ===== AGENTS.md ===== # Agent instructions · Letterdrop indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node 22 with one worker and a queue in the database, PostgreSQL, with suppression enforced by a constraint and checked at send time, One provider behind one send function, chosen for its bounce and complaint webhooks, Inline CSS at build time, with a plain-text alternative generated from the same source, Off by default, and open tracking absent entirely. 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 - Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. - Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. - Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. - Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. ===== BUILD_PLAN.md ===== # Build plan · Letterdrop A newsletter sender whose hard problems are an unsubscribe that is instant and impossible to bypass, sending exactly once per recipient per issue across crashes, and keeping bounces and complaints flowing back into suppression rather than into a dashboard nobody reads. 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, normalisation and a suppression list with teeth Every address has a recorded consent and a suppressed address cannot be sent to by any path. ### Steps 1. Check suppression inside the send claim Not when queueing. The window between the two is where complaints come from. Files: `src/send/claim.ts`, `tests/suppression.test.ts` 2. Normalise once and store both forms One subscriber, one address, whatever the CSV looked like. Files: `src/subscribers/normalise.ts` 3. Refuse an import without consent columns A list with no recorded consent is the start of a reputation problem. Files: `src/subscribers/import.ts` ### Done when - [ ] Suppressing mid-send stops that recipient - [ ] An import never resurrects a suppressed address - [ ] Case and whitespace variants resolve to one subscriber - [ ] Consent source and time are required on every row ### Watch out - Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. ## Phase 2 · One-click unsubscribe, done to the letter A reader can leave from their mail client in one action and it takes effect immediately. ### Steps 1. Send both header forms exactly as specified The mailto and the HTTPS URL, plus the One-Click post header. Files: `src/mail/headers.ts`, `tests/list-unsubscribe.test.ts` 2. Act on POST, confirm on GET Prefetchers follow links; they do not post forms. Files: `src/web/unsubscribe.ts`, `tests/prefetch.test.ts` 3. Make suppression take effect with no intermediate job The 48 hour allowance is a ceiling, not a design. Files: `src/subscribers/suppress.ts` ### Done when - [ ] A one-click POST suppresses and returns success - [ ] A GET confirms and changes nothing - [ ] Suppression affects the very next send - [ ] Both requests are idempotent ### Watch out - Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. ## Phase 3 · Mail that renders where mail is read One source produces an HTML part that survives mail clients and a text part that stands alone. ### Steps 1. Generate both parts from one Markdown source Two renderers, one truth, no tag stripping. Files: `src/render/html.ts`, `src/render/text.ts`, `tests/parity.test.ts` 2. Inline CSS at build time Mail clients discard stylesheet elements without telling anyone. Files: `src/render/inline.ts` 3. Require a recorded test send before scheduling The cheapest gate against a broken issue going to everyone. Files: `src/issues/schedule.ts` ### Done when - [ ] The text part matches the HTML on headings and links - [ ] No stylesheet element survives inlining - [ ] Tracking off means no rewritten links and no beacon - [ ] Scheduling is blocked until a test send exists ### Watch out - Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. ## Phase 4 · One message per person per issue A send that crashes halfway resumes without sending anyone a second copy. ### Steps 1. Materialise send rows before the first send The unique constraint is what turns a restart into a resume. Files: `src/send/materialise.ts`, `tests/exactly-once.test.ts` 2. Claim in batches inside transactions Small batches so a crash costs seconds, not an issue. Files: `src/send/worker.ts` 3. Wire bounces and complaints into suppression A feedback loop that only writes to a dashboard is decorative. Files: `src/webhooks/provider.ts`, `tests/bounce.test.ts` ### Done when - [ ] A mid-issue crash and restart yields one message each - [ ] A hard bounce suppresses within one cycle - [ ] Complaints are recorded separately and suppress - [ ] Soft bounce retries are bounded and configured ### Watch out - Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. ## Not in this build - Running a mail server. - Open tracking pixels. - Behavioural automation and large-scale segmentation. - Paid subscriptions and recommendation networks. ## After v1, if you want it - Simple segments over recorded fields, once suppression and exactly-once sending have survived a few issues - A public archive page per issue, which reuses the rendered HTML and adds no sending risk - Click tracking as an explicit per-issue choice, with the rewritten links visible in the preview ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers, suppressions, issues, sends and events. DATABASE_URL=postgres://user:redacted@localhost:5432/letters # Required. Checked for SPF, DKIM and DMARC at startup. Sending is refused without them. SENDING_DOMAIN=mail.example.com # Required · secret. The sending provider. Scoped to sending and webhook management only. PROVIDER_API_KEY=key-...redacted # Required · secret. Verifies bounce and complaint callbacks. An unverified webhook can suppress anyone. WEBHOOK_SIGNING_SECRET=whsec_...redacted # Required. Builds the unsubscribe URL that goes in the List-Unsubscribe header. PUBLIC_BASE_URL=https://letters.example.com # Required. Rows claimed per transaction. Smaller batches make a crash cheaper. SEND_BATCH_SIZE=50 # Required. Soft bounces before the address is suppressed. SOFT_BOUNCE_MAX=3 # Required. Complaint rate per issue that pauses the next send until acknowledged. COMPLAINT_RATE_PAUSE=0.001
You are building a lean indie version of Letterdrop. 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 ===== # Letterdrop · indie build A newsletter sender whose hard problems are an unsubscribe that is instant and impossible to bypass, sending exactly once per recipient per issue across crashes, and keeping bounces and complaints flowing back into suppression rather than into a dashboard nobody reads. 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 with one worker and a queue in the database | an issue send is a long-running job that must survive a restart | | Storage | PostgreSQL, with suppression enforced by a constraint and checked at send time | an unsubscribe that only the application respects is not an unsubscribe | | Sending | One provider behind one send function, chosen for its bounce and complaint webhooks | the feedback loop matters more than the send API | | Rendering | Inline CSS at build time, with a plain-text alternative generated from the same source | mail clients strip stylesheets and some readers only see text | | Tracking | Off by default, and open tracking absent entirely | an invisible pixel in every message is a choice you should have to make deliberately | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 and PostgreSQL 16** · free - Why: The worker and the send queue both live in the database, and the unique constraint on sends is the exactly-once guarantee. - Get it: nodejs.org and postgresql.org, or containers for both. - Verify: node --version and psql --version print, and the schema applies cleanly - [ ] **A domain you control, with DNS access** · around ten pounds a year - Why: SPF, DKIM and DMARC are records you have to publish, and without DNS access none of the authentication work in the last phase is possible. - Get it: Any registrar, with access to the zone. - Verify: A TXT record you add resolves publicly within an hour - [ ] **A sending provider with bounce and complaint webhooks** · free tier is usually enough below a few thousand messages a month - Why: The feedback loop matters more than the send API, because bounces and complaints are what keep the suppression list correct and the domain deliverable. - Get it: Any transactional or bulk provider that posts delivery, bounce and complaint events to a URL you own. - Verify: A test send produces a delivered event on your webhook, and a send to a known invalid address produces a bounce - [ ] **The current sender guidelines from the major mailbox providers** · free - Why: The thresholds and header requirements in this build are copied from them rather than invented, and they have changed more than once. - Get it: Read the current bulk sender pages, note the spam rate thresholds, the unsubscribe deadline and the authentication requirements, and record the date. - Verify: A note in the repository records the figures and the date checked - [ ] **Test addresses on at least three mail clients** · free - Why: Rendering parity and the one-click header are only provable by receiving the message somewhere real, and clients differ more than any test suite suggests. - Get it: Accounts at three providers, including at least one that reads plain text. - Verify: A test issue arrives at all three and the unsubscribe control appears in the client - [ ] **Your complaint threshold and soft bounce retry count** · free - Why: Both are asserted by tests and both pause or suppress real sends, so choosing them after seeing a bad issue means choosing whatever that issue did. - Get it: Write down the complaint rate that pauses sending and how many soft bounces become a suppression. - Verify: Both appear in configuration and are named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Running a mail server. - Open tracking pixels. - Behavioural automation and large-scale segmentation. - Paid subscriptions and recommendation networks. - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations - bounce, complaint, and suppression handling If one of those is essential to you, that is the reason to keep paying for Letterdrop, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Letterdrop The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter tool that sends through your own provider and treats the unsubscribe path as the most important code in it to replace Letterdrop. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first outranks every feature you will be tempted to add first. The unsubscribe path is the most important code in this build. Google's sender guidelines require marketing and subscribed mail to support one-click unsubscribe using the List-Unsubscribe and List-Unsubscribe-Post headers described in RFC 2369 and RFC 8058, and require unsubscribe requests to be honoured within 48 hours; they ask senders to keep the spam rate reported in Postmaster Tools below 0.10 per cent and never to reach 0.30 per cent, and they require SPF and DKIM for everyone plus DMARC over a TLS connection once you are sending around 5,000 messages a day to personal Gmail accounts, counted across a primary domain and its subdomains. Those figures were checked while this was written and they change, so read the current page before you send anything. Treat 48 hours as a ceiling and make removal take effect on the next send, always, with no batch job in between. Second, you are not running a mail server. Sending goes through a provider you hold an account with, from a domain you control and have authenticated, and the suppression list is consulted at send time rather than when the list was built. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22 with one worker and a queue in the database · an issue send is a long-running job that must survive a restart - Storage: PostgreSQL, with suppression enforced by a constraint and checked at send time · an unsubscribe that only the application respects is not an unsubscribe - Sending: One provider behind one send function, chosen for its bounce and complaint webhooks · the feedback loop matters more than the send API - Rendering: Inline CSS at build time, with a plain-text alternative generated from the same source · mail clients strip stylesheets and some readers only see text - Tracking: Off by default, and open tracking absent entirely · an invisible pixel in every message is a choice you should have to make deliberately ### Data model (create this before Phase 1) - subscribers(id, email_normalised, name, state, consent_source, consent_at, consent_ip, created_at) · state is subscribed, unsubscribed, bounced or complained - suppressions(email_normalised, reason, at, source) · unique on the normalised address, checked inside the send transaction - issues(id, title, markdown, html, text, state, scheduled_at, sent_at) · html and text are generated from the same markdown - sends(issue_id, subscriber_id, state, attempts, provider_message_id, sent_at, last_error) · unique on (issue_id, subscriber_id) - events(provider_message_id, kind, at, payload_excerpt) · kind is delivered, bounced, complained or deferred, from the provider's webhook - unsubscribe_tokens(subscriber_id, token_hash, issue_id, created_at) · hashed, per issue, and valid indefinitely because people unsubscribe from old mail - An address that reaches suppressions is never sent to again by any issue, including one already queued, because the check happens inside the transaction that claims the send row. ### Phase 1 · Consent, normalisation and a suppression list with teeth Build: Store how, when and from where each subscriber signed up. Normalise addresses once on the way in and store the normalised form alongside the original. Import from CSV with a required consent column. Make the suppression check part of the send-row claim rather than a filter on the list. Trap: Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. Done when: An address suppressed after an issue is queued receives nothing, tested by suppressing mid-send. An import containing a suppressed address leaves it suppressed and reports the count of skipped rows. Addresses differing only by case or surrounding whitespace resolve to one subscriber, tested. Every subscriber row has a consent source and timestamp, enforced by a not-null constraint, and the importer refuses a file without one. Do not build yet: No composing, sending, unsubscribe links or webhooks. ### Phase 2 · One-click unsubscribe, done to the letter Build: Add List-Unsubscribe with both a mailto and an HTTPS URL, and List-Unsubscribe-Post set to List-Unsubscribe=One-Click, so a client posts to the URL without a confirmation page. Handle that POST by suppressing immediately and returning success. Keep a human unsubscribe link in the body too. Tokens are random, stored hashed, per issue and never expire. Trap: Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. Done when: A POST to the unsubscribe URL with the one-click body suppresses the address and returns success, tested against the header contract. A GET to the same URL renders a confirmation page and changes nothing, so a prefetcher cannot unsubscribe anyone. Suppression is visible to the next send immediately, with no scheduled job in between, tested. Repeating either request is harmless and returns the same result. Do not build yet: No rendering, sending or webhooks yet. ### Phase 3 · Mail that renders where mail is read Build: Write in Markdown. Generate HTML with CSS inlined at build time and a table-based layout, and generate the plain-text part from the same Markdown rather than by stripping tags. Rewrite links only if click tracking is explicitly enabled. Send a real test to at least three different clients before an issue can be scheduled. Trap: Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. Done when: The text part contains every heading and every link destination the HTML part contains, asserted in the suite. CSS is inlined and no stylesheet element remains, tested against the generated HTML. With tracking disabled the message contains no rewritten links and no image beacon, verified by scanning the output. An issue cannot be scheduled until a test send has been recorded. Do not build yet: No bulk sending, webhooks or authentication work yet. ### Phase 4 · One message per person per issue Build: Materialise one send row per recipient per issue with a unique constraint, then claim rows in small batches, moving each to sending in its own transaction before the provider is called. Store the provider's message id. Consume the bounce and complaint webhooks and write hard bounces and complaints straight into suppressions. Trap: Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. Done when: Killing the worker mid-issue and restarting delivers exactly one message per subscriber, tested with a counting fake provider. A hard bounce arriving on the webhook suppresses that address within one processing cycle and the next issue skips it, tested. A complaint does the same and is recorded separately from a bounce. Soft bounces are retried a stated number of times and then suppressed, with the count in configuration. Do not build yet: No domain authentication or reputation monitoring yet. ### Phase 5 · Your domain, your reputation, your exit Build: Document and verify SPF, DKIM and DMARC for the sending domain, with a startup check that refuses to send if any record is missing. Record the complaint rate per issue from webhook events and pause sending automatically above a stated threshold. Export subscribers, issues, sends and events as files. Rate limit the public subscribe endpoint and require confirmation. Trap: Reputation damage arrives as a gradual drop in delivery rather than an error, so an issue that quietly lands in spam for half your readers looks identical to one that landed fine. Compute the complaint rate per issue from the webhook events, compare it to the threshold you wrote down, and pause the next send automatically rather than leaving it to a person to notice. Done when: Startup refuses to send when SPF, DKIM or DMARC is missing for the configured domain, naming the missing record, tested with a domain that lacks one. The complaint rate per issue is computed from events and shown, and exceeding the configured threshold pauses the next send until acknowledged. Public subscription requires a confirmation click and the endpoint is rate limited per address. Export produces files that reimport into an empty database with consent and suppression intact. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which is a reputation project measured in months rather than a component. - Open tracking pixels, which put an invisible beacon in every message and buy you a number that modern privacy proxies have already ruined. - Large-scale segmentation and behavioural automation, which needs an event store and a rules engine and is most of what the subscription pays for. - Paid subscriptions and audience recommendation networks, which add payments, tax and a second set of webhooks that must also be exactly once. - Buying, scraping or enriching an address list, which is how a domain's reputation is destroyed in one send. ### README must contain - That one-click unsubscribe is implemented with List-Unsubscribe and List-Unsubscribe-Post per RFC 2369 and RFC 8058, and that the POST acts immediately while the GET only confirms - That suppression is checked inside the transaction claiming each send, so an unsubscribe during a send takes effect - The current sender requirements you checked, with the date, including the spam rate thresholds and the authentication records needed - That send rows are materialised with a unique constraint so a crash mid-issue resumes rather than repeats - That open tracking is absent and click tracking is off unless explicitly enabled ===== AGENTS.md ===== # Agent instructions · Letterdrop indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node 22 with one worker and a queue in the database, PostgreSQL, with suppression enforced by a constraint and checked at send time, One provider behind one send function, chosen for its bounce and complaint webhooks, Inline CSS at build time, with a plain-text alternative generated from the same source, Off by default, and open tracking absent entirely. 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 - Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. - Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. - Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. - Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. ===== BUILD_PLAN.md ===== # Build plan · Letterdrop A newsletter sender whose hard problems are an unsubscribe that is instant and impossible to bypass, sending exactly once per recipient per issue across crashes, and keeping bounces and complaints flowing back into suppression rather than into a dashboard nobody reads. 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, normalisation and a suppression list with teeth Every address has a recorded consent and a suppressed address cannot be sent to by any path. ### Steps 1. Check suppression inside the send claim Not when queueing. The window between the two is where complaints come from. Files: `src/send/claim.ts`, `tests/suppression.test.ts` 2. Normalise once and store both forms One subscriber, one address, whatever the CSV looked like. Files: `src/subscribers/normalise.ts` 3. Refuse an import without consent columns A list with no recorded consent is the start of a reputation problem. Files: `src/subscribers/import.ts` ### Done when - [ ] Suppressing mid-send stops that recipient - [ ] An import never resurrects a suppressed address - [ ] Case and whitespace variants resolve to one subscriber - [ ] Consent source and time are required on every row ### Watch out - Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. ## Phase 2 · One-click unsubscribe, done to the letter A reader can leave from their mail client in one action and it takes effect immediately. ### Steps 1. Send both header forms exactly as specified The mailto and the HTTPS URL, plus the One-Click post header. Files: `src/mail/headers.ts`, `tests/list-unsubscribe.test.ts` 2. Act on POST, confirm on GET Prefetchers follow links; they do not post forms. Files: `src/web/unsubscribe.ts`, `tests/prefetch.test.ts` 3. Make suppression take effect with no intermediate job The 48 hour allowance is a ceiling, not a design. Files: `src/subscribers/suppress.ts` ### Done when - [ ] A one-click POST suppresses and returns success - [ ] A GET confirms and changes nothing - [ ] Suppression affects the very next send - [ ] Both requests are idempotent ### Watch out - Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. ## Phase 3 · Mail that renders where mail is read One source produces an HTML part that survives mail clients and a text part that stands alone. ### Steps 1. Generate both parts from one Markdown source Two renderers, one truth, no tag stripping. Files: `src/render/html.ts`, `src/render/text.ts`, `tests/parity.test.ts` 2. Inline CSS at build time Mail clients discard stylesheet elements without telling anyone. Files: `src/render/inline.ts` 3. Require a recorded test send before scheduling The cheapest gate against a broken issue going to everyone. Files: `src/issues/schedule.ts` ### Done when - [ ] The text part matches the HTML on headings and links - [ ] No stylesheet element survives inlining - [ ] Tracking off means no rewritten links and no beacon - [ ] Scheduling is blocked until a test send exists ### Watch out - Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. ## Phase 4 · One message per person per issue A send that crashes halfway resumes without sending anyone a second copy. ### Steps 1. Materialise send rows before the first send The unique constraint is what turns a restart into a resume. Files: `src/send/materialise.ts`, `tests/exactly-once.test.ts` 2. Claim in batches inside transactions Small batches so a crash costs seconds, not an issue. Files: `src/send/worker.ts` 3. Wire bounces and complaints into suppression A feedback loop that only writes to a dashboard is decorative. Files: `src/webhooks/provider.ts`, `tests/bounce.test.ts` ### Done when - [ ] A mid-issue crash and restart yields one message each - [ ] A hard bounce suppresses within one cycle - [ ] Complaints are recorded separately and suppress - [ ] Soft bounce retries are bounded and configured ### Watch out - Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. ## Not in this build - Running a mail server. - Open tracking pixels. - Behavioural automation and large-scale segmentation. - Paid subscriptions and recommendation networks. ## After v1, if you want it - Simple segments over recorded fields, once suppression and exactly-once sending have survived a few issues - A public archive page per issue, which reuses the rendered HTML and adds no sending risk - Click tracking as an explicit per-issue choice, with the rewritten links visible in the preview ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers, suppressions, issues, sends and events. DATABASE_URL=postgres://user:redacted@localhost:5432/letters # Required. Checked for SPF, DKIM and DMARC at startup. Sending is refused without them. SENDING_DOMAIN=mail.example.com # Required · secret. The sending provider. Scoped to sending and webhook management only. PROVIDER_API_KEY=key-...redacted # Required · secret. Verifies bounce and complaint callbacks. An unverified webhook can suppress anyone. WEBHOOK_SIGNING_SECRET=whsec_...redacted # Required. Builds the unsubscribe URL that goes in the List-Unsubscribe header. PUBLIC_BASE_URL=https://letters.example.com # Required. Rows claimed per transaction. Smaller batches make a crash cheaper. SEND_BATCH_SIZE=50 # Required. Soft bounces before the address is suppressed. SOFT_BOUNCE_MAX=3 # Required. Complaint rate per issue that pauses the next send until acknowledged. COMPLAINT_RATE_PAUSE=0.001
You are building a production product version of Letterdrop. 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 ===== # Letterdrop · product brief ## Problem The visible B2B content + distribution loop is buildable, but a credible replacement needs more than the first screen. Letterdrop earns its keep through deliverability, reputation, network, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A newsletter sender whose unsubscribe works from inside the mail client and takes effect on the next message, which cannot send anyone two copies of an issue however badly it crashes, and which stops itself when the complaint rate says readers are unhappy. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - PostgreSQL - transactional or bulk email provider key in .env - verified sending domain ## Explicit non-goals for v1 - Running a mail server. - Open tracking pixels. - Behavioural automation and large-scale segmentation. - Paid subscriptions and recommendation networks. - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations - bounce, complaint, and suppression handling ## Success criteria - Suppressing mid-send is confirmed to stop that recipient - A one-click POST is confirmed to suppress while a GET changes nothing - The text part is confirmed to match the HTML on headings and links - A mid-issue crash and restart is confirmed to yield one message each - A missing authentication record is confirmed to block sending ===== BRIEF.md ===== # Build brief · Letterdrop The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter tool that sends through your own provider and treats the unsubscribe path as the most important code in it to replace Letterdrop. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first outranks every feature you will be tempted to add first. The unsubscribe path is the most important code in this build. Google's sender guidelines require marketing and subscribed mail to support one-click unsubscribe using the List-Unsubscribe and List-Unsubscribe-Post headers described in RFC 2369 and RFC 8058, and require unsubscribe requests to be honoured within 48 hours; they ask senders to keep the spam rate reported in Postmaster Tools below 0.10 per cent and never to reach 0.30 per cent, and they require SPF and DKIM for everyone plus DMARC over a TLS connection once you are sending around 5,000 messages a day to personal Gmail accounts, counted across a primary domain and its subdomains. Those figures were checked while this was written and they change, so read the current page before you send anything. Treat 48 hours as a ceiling and make removal take effect on the next send, always, with no batch job in between. Second, you are not running a mail server. Sending goes through a provider you hold an account with, from a domain you control and have authenticated, and the suppression list is consulted at send time rather than when the list was built. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22 with one worker and a queue in the database · an issue send is a long-running job that must survive a restart - Storage: PostgreSQL, with suppression enforced by a constraint and checked at send time · an unsubscribe that only the application respects is not an unsubscribe - Sending: One provider behind one send function, chosen for its bounce and complaint webhooks · the feedback loop matters more than the send API - Rendering: Inline CSS at build time, with a plain-text alternative generated from the same source · mail clients strip stylesheets and some readers only see text - Tracking: Off by default, and open tracking absent entirely · an invisible pixel in every message is a choice you should have to make deliberately ### Data model (create this before Phase 1) - subscribers(id, email_normalised, name, state, consent_source, consent_at, consent_ip, created_at) · state is subscribed, unsubscribed, bounced or complained - suppressions(email_normalised, reason, at, source) · unique on the normalised address, checked inside the send transaction - issues(id, title, markdown, html, text, state, scheduled_at, sent_at) · html and text are generated from the same markdown - sends(issue_id, subscriber_id, state, attempts, provider_message_id, sent_at, last_error) · unique on (issue_id, subscriber_id) - events(provider_message_id, kind, at, payload_excerpt) · kind is delivered, bounced, complained or deferred, from the provider's webhook - unsubscribe_tokens(subscriber_id, token_hash, issue_id, created_at) · hashed, per issue, and valid indefinitely because people unsubscribe from old mail - An address that reaches suppressions is never sent to again by any issue, including one already queued, because the check happens inside the transaction that claims the send row. ### Phase 1 · Consent, normalisation and a suppression list with teeth Build: Store how, when and from where each subscriber signed up. Normalise addresses once on the way in and store the normalised form alongside the original. Import from CSV with a required consent column. Make the suppression check part of the send-row claim rather than a filter on the list. Trap: Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. Done when: An address suppressed after an issue is queued receives nothing, tested by suppressing mid-send. An import containing a suppressed address leaves it suppressed and reports the count of skipped rows. Addresses differing only by case or surrounding whitespace resolve to one subscriber, tested. Every subscriber row has a consent source and timestamp, enforced by a not-null constraint, and the importer refuses a file without one. Do not build yet: No composing, sending, unsubscribe links or webhooks. ### Phase 2 · One-click unsubscribe, done to the letter Build: Add List-Unsubscribe with both a mailto and an HTTPS URL, and List-Unsubscribe-Post set to List-Unsubscribe=One-Click, so a client posts to the URL without a confirmation page. Handle that POST by suppressing immediately and returning success. Keep a human unsubscribe link in the body too. Tokens are random, stored hashed, per issue and never expire. Trap: Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. Done when: A POST to the unsubscribe URL with the one-click body suppresses the address and returns success, tested against the header contract. A GET to the same URL renders a confirmation page and changes nothing, so a prefetcher cannot unsubscribe anyone. Suppression is visible to the next send immediately, with no scheduled job in between, tested. Repeating either request is harmless and returns the same result. Do not build yet: No rendering, sending or webhooks yet. ### Phase 3 · Mail that renders where mail is read Build: Write in Markdown. Generate HTML with CSS inlined at build time and a table-based layout, and generate the plain-text part from the same Markdown rather than by stripping tags. Rewrite links only if click tracking is explicitly enabled. Send a real test to at least three different clients before an issue can be scheduled. Trap: Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. Done when: The text part contains every heading and every link destination the HTML part contains, asserted in the suite. CSS is inlined and no stylesheet element remains, tested against the generated HTML. With tracking disabled the message contains no rewritten links and no image beacon, verified by scanning the output. An issue cannot be scheduled until a test send has been recorded. Do not build yet: No bulk sending, webhooks or authentication work yet. ### Phase 4 · One message per person per issue Build: Materialise one send row per recipient per issue with a unique constraint, then claim rows in small batches, moving each to sending in its own transaction before the provider is called. Store the provider's message id. Consume the bounce and complaint webhooks and write hard bounces and complaints straight into suppressions. Trap: Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. Done when: Killing the worker mid-issue and restarting delivers exactly one message per subscriber, tested with a counting fake provider. A hard bounce arriving on the webhook suppresses that address within one processing cycle and the next issue skips it, tested. A complaint does the same and is recorded separately from a bounce. Soft bounces are retried a stated number of times and then suppressed, with the count in configuration. Do not build yet: No domain authentication or reputation monitoring yet. ### Phase 5 · Your domain, your reputation, your exit Build: Document and verify SPF, DKIM and DMARC for the sending domain, with a startup check that refuses to send if any record is missing. Record the complaint rate per issue from webhook events and pause sending automatically above a stated threshold. Export subscribers, issues, sends and events as files. Rate limit the public subscribe endpoint and require confirmation. Trap: Reputation damage arrives as a gradual drop in delivery rather than an error, so an issue that quietly lands in spam for half your readers looks identical to one that landed fine. Compute the complaint rate per issue from the webhook events, compare it to the threshold you wrote down, and pause the next send automatically rather than leaving it to a person to notice. Done when: Startup refuses to send when SPF, DKIM or DMARC is missing for the configured domain, naming the missing record, tested with a domain that lacks one. The complaint rate per issue is computed from events and shown, and exceeding the configured threshold pauses the next send until acknowledged. Public subscription requires a confirmation click and the endpoint is rate limited per address. Export produces files that reimport into an empty database with consent and suppression intact. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which is a reputation project measured in months rather than a component. - Open tracking pixels, which put an invisible beacon in every message and buy you a number that modern privacy proxies have already ruined. - Large-scale segmentation and behavioural automation, which needs an event store and a rules engine and is most of what the subscription pays for. - Paid subscriptions and audience recommendation networks, which add payments, tax and a second set of webhooks that must also be exactly once. - Buying, scraping or enriching an address list, which is how a domain's reputation is destroyed in one send. ### README must contain - That one-click unsubscribe is implemented with List-Unsubscribe and List-Unsubscribe-Post per RFC 2369 and RFC 8058, and that the POST acts immediately while the GET only confirms - That suppression is checked inside the transaction claiming each send, so an unsubscribe during a send takes effect - The current sender requirements you checked, with the date, including the spam rate thresholds and the authentication records needed - That send rows are materialised with a unique constraint so a crash mid-issue resumes rather than repeats - That open tracking is absent and click tracking is off unless explicitly enabled ===== ARCHITECTURE.md ===== # Architecture · Letterdrop ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript on Node 22 with one worker and a queue in the database | an issue send is a long-running job that must survive a restart | | Storage | PostgreSQL, with suppression enforced by a constraint and checked at send time | an unsubscribe that only the application respects is not an unsubscribe | | Sending | One provider behind one send function, chosen for its bounce and complaint webhooks | the feedback loop matters more than the send API | | Rendering | Inline CSS at build time, with a plain-text alternative generated from the same source | mail clients strip stylesheets and some readers only see text | | Tracking | Off by default, and open tracking absent entirely | an invisible pixel in every message is a choice you should have to make deliberately | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Subscribers | Normalisation, consent and imports. | Any import format, provided consent is required and suppression is never overridden. | | Suppression | The list and the check inside the send claim. | Not swappable for a filter at queue time, which reopens the window. | | Render | HTML and text from one Markdown source. | Any renderer, provided the parity test still passes. | | Sender | Materialised send rows and batched claims. | Any provider, provided the message id is stored and webhooks are wired. | | Reputation | Authentication checks and the complaint rate. | Any source of delivery events, provided the pause threshold still fires. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Subscribers, suppressions, issues, sends and events. - `SENDING_DOMAIN` · required · Checked for SPF, DKIM and DMARC at startup. Sending is refused without them. - `PROVIDER_API_KEY` · required, secret · The sending provider. Scoped to sending and webhook management only. - `WEBHOOK_SIGNING_SECRET` · required, secret · Verifies bounce and complaint callbacks. An unverified webhook can suppress anyone. - `PUBLIC_BASE_URL` · required · Builds the unsubscribe URL that goes in the List-Unsubscribe header. - `SEND_BATCH_SIZE` · required · Rows claimed per transaction. Smaller batches make a crash cheaper. - `SOFT_BOUNCE_MAX` · required · Soft bounces before the address is suppressed. - `COMPLAINT_RATE_PAUSE` · required · Complaint rate per issue that pauses the next send until acknowledged. ## 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 · Letterdrop product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript on Node 22 with one worker and a queue in the database, PostgreSQL, with suppression enforced by a constraint and checked at send time, One provider behind one send function, chosen for its bounce and complaint webhooks, Inline CSS at build time, with a plain-text alternative generated from the same source, Off by default, and open tracking absent entirely. - 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 - Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. - Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. - Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. - Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. - Reputation damage arrives as a gradual drop in delivery rather than an error, so an issue that quietly lands in spam for half your readers looks identical to one that landed fine. Compute the complaint rate per issue from the webhook events, compare it to the threshold you wrote down, and pause the next send automatically rather than leaving it to a person to notice. ===== MILESTONES.md ===== # Delivery milestones · Letterdrop Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Consent, normalisation and a suppression list with teeth Every address has a recorded consent and a suppressed address cannot be sent to by any path. ### Steps 1. Check suppression inside the send claim Not when queueing. The window between the two is where complaints come from. Files: `src/send/claim.ts`, `tests/suppression.test.ts` 2. Normalise once and store both forms One subscriber, one address, whatever the CSV looked like. Files: `src/subscribers/normalise.ts` 3. Refuse an import without consent columns A list with no recorded consent is the start of a reputation problem. Files: `src/subscribers/import.ts` ### Done when - [ ] Suppressing mid-send stops that recipient - [ ] An import never resurrects a suppressed address - [ ] Case and whitespace variants resolve to one subscriber - [ ] Consent source and time are required on every row ### Watch out - Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. ## M2 · One-click unsubscribe, done to the letter A reader can leave from their mail client in one action and it takes effect immediately. ### Steps 1. Send both header forms exactly as specified The mailto and the HTTPS URL, plus the One-Click post header. Files: `src/mail/headers.ts`, `tests/list-unsubscribe.test.ts` 2. Act on POST, confirm on GET Prefetchers follow links; they do not post forms. Files: `src/web/unsubscribe.ts`, `tests/prefetch.test.ts` 3. Make suppression take effect with no intermediate job The 48 hour allowance is a ceiling, not a design. Files: `src/subscribers/suppress.ts` ### Done when - [ ] A one-click POST suppresses and returns success - [ ] A GET confirms and changes nothing - [ ] Suppression affects the very next send - [ ] Both requests are idempotent ### Watch out - Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. ## M3 · Mail that renders where mail is read One source produces an HTML part that survives mail clients and a text part that stands alone. ### Steps 1. Generate both parts from one Markdown source Two renderers, one truth, no tag stripping. Files: `src/render/html.ts`, `src/render/text.ts`, `tests/parity.test.ts` 2. Inline CSS at build time Mail clients discard stylesheet elements without telling anyone. Files: `src/render/inline.ts` 3. Require a recorded test send before scheduling The cheapest gate against a broken issue going to everyone. Files: `src/issues/schedule.ts` ### Done when - [ ] The text part matches the HTML on headings and links - [ ] No stylesheet element survives inlining - [ ] Tracking off means no rewritten links and no beacon - [ ] Scheduling is blocked until a test send exists ### Watch out - Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. ## M4 · One message per person per issue A send that crashes halfway resumes without sending anyone a second copy. ### Steps 1. Materialise send rows before the first send The unique constraint is what turns a restart into a resume. Files: `src/send/materialise.ts`, `tests/exactly-once.test.ts` 2. Claim in batches inside transactions Small batches so a crash costs seconds, not an issue. Files: `src/send/worker.ts` 3. Wire bounces and complaints into suppression A feedback loop that only writes to a dashboard is decorative. Files: `src/webhooks/provider.ts`, `tests/bounce.test.ts` ### Done when - [ ] A mid-issue crash and restart yields one message each - [ ] A hard bounce suppresses within one cycle - [ ] Complaints are recorded separately and suppress - [ ] Soft bounce retries are bounded and configured ### Watch out - Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. ## M5 · Your domain, your reputation, your exit (production only) The sending domain is authenticated, the complaint rate is watched, and everything is exportable. ### Steps 1. Check the authentication records at startup A missing DKIM record is discovered at send time otherwise. Files: `src/startup/dns-check.ts`, `tests/dns-check.test.ts` 2. Compute and act on the complaint rate A threshold that pauses beats a chart that informs. Files: `src/reputation/rate.ts` 3. Require confirmed opt-in on the public form The cheapest protection against someone else's address on your list. Files: `src/web/subscribe.ts` ### Done when - [ ] A missing authentication record blocks sending - [ ] The complaint rate pauses the next send at the threshold - [ ] Public subscription is confirmed and rate limited - [ ] Export round-trips with consent and suppression intact ### Watch out - Reputation damage arrives as a gradual drop in delivery rather than an error, so an issue that quietly lands in spam for half your readers looks identical to one that landed fine. Compute the complaint rate per issue from the webhook events, compare it to the threshold you wrote down, and pause the next send automatically rather than leaving it to a person to notice. ===== OPERATIONS.md ===== # Operations · Letterdrop ## Backup The database is the record of who consented to what and who asked to leave, which is the part with a legal shape. Back it up before every issue, and keep the suppression table in any partial backup even if nothing else fits. ## Restore Restore, then move any send rows left in sending back to claimable only after checking the provider for a message id, since the provider may already have sent them. Verify the suppression table row count against the last backup before sending anything. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Complaint rate per issue, hard bounce rate, sends stuck in the sending state, webhook delivery failures and the authentication records still resolving. A webhook that stopped arriving means suppression has silently stopped updating. ## Incident checklist A duplicate message means the unique constraint was missing or the claim was outside a transaction, and the sends table shows it. A spike in complaints means an issue went to people who did not expect it, and the consent columns on those subscribers are where the answer is. 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 - [ ] Suppressing mid-send is confirmed to stop that recipient - [ ] A one-click POST is confirmed to suppress while a GET changes nothing - [ ] The text part is confirmed to match the HTML on headings and links - [ ] A mid-issue crash and restart is confirmed to yield one message each - [ ] A missing authentication record is confirmed to block sending ## Launch constraint Do not market omitted Letterdrop capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers, suppressions, issues, sends and events. DATABASE_URL=postgres://user:redacted@localhost:5432/letters # Required. Checked for SPF, DKIM and DMARC at startup. Sending is refused without them. SENDING_DOMAIN=mail.example.com # Required · secret. The sending provider. Scoped to sending and webhook management only. PROVIDER_API_KEY=key-...redacted # Required · secret. Verifies bounce and complaint callbacks. An unverified webhook can suppress anyone. WEBHOOK_SIGNING_SECRET=whsec_...redacted # Required. Builds the unsubscribe URL that goes in the List-Unsubscribe header. PUBLIC_BASE_URL=https://letters.example.com # Required. Rows claimed per transaction. Smaller batches make a crash cheaper. SEND_BATCH_SIZE=50 # Required. Soft bounces before the address is suppressed. SOFT_BOUNCE_MAX=3 # Required. Complaint rate per issue that pauses the next send until acknowledged. COMPLAINT_RATE_PAUSE=0.001
# Letterdrop · indie build A newsletter sender whose hard problems are an unsubscribe that is instant and impossible to bypass, sending exactly once per recipient per issue across crashes, and keeping bounces and complaints flowing back into suppression rather than into a dashboard nobody reads. 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 with one worker and a queue in the database | an issue send is a long-running job that must survive a restart | | Storage | PostgreSQL, with suppression enforced by a constraint and checked at send time | an unsubscribe that only the application respects is not an unsubscribe | | Sending | One provider behind one send function, chosen for its bounce and complaint webhooks | the feedback loop matters more than the send API | | Rendering | Inline CSS at build time, with a plain-text alternative generated from the same source | mail clients strip stylesheets and some readers only see text | | Tracking | Off by default, and open tracking absent entirely | an invisible pixel in every message is a choice you should have to make deliberately | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 and PostgreSQL 16** · free - Why: The worker and the send queue both live in the database, and the unique constraint on sends is the exactly-once guarantee. - Get it: nodejs.org and postgresql.org, or containers for both. - Verify: node --version and psql --version print, and the schema applies cleanly - [ ] **A domain you control, with DNS access** · around ten pounds a year - Why: SPF, DKIM and DMARC are records you have to publish, and without DNS access none of the authentication work in the last phase is possible. - Get it: Any registrar, with access to the zone. - Verify: A TXT record you add resolves publicly within an hour - [ ] **A sending provider with bounce and complaint webhooks** · free tier is usually enough below a few thousand messages a month - Why: The feedback loop matters more than the send API, because bounces and complaints are what keep the suppression list correct and the domain deliverable. - Get it: Any transactional or bulk provider that posts delivery, bounce and complaint events to a URL you own. - Verify: A test send produces a delivered event on your webhook, and a send to a known invalid address produces a bounce - [ ] **The current sender guidelines from the major mailbox providers** · free - Why: The thresholds and header requirements in this build are copied from them rather than invented, and they have changed more than once. - Get it: Read the current bulk sender pages, note the spam rate thresholds, the unsubscribe deadline and the authentication requirements, and record the date. - Verify: A note in the repository records the figures and the date checked - [ ] **Test addresses on at least three mail clients** · free - Why: Rendering parity and the one-click header are only provable by receiving the message somewhere real, and clients differ more than any test suite suggests. - Get it: Accounts at three providers, including at least one that reads plain text. - Verify: A test issue arrives at all three and the unsubscribe control appears in the client - [ ] **Your complaint threshold and soft bounce retry count** · free - Why: Both are asserted by tests and both pause or suppress real sends, so choosing them after seeing a bad issue means choosing whatever that issue did. - Get it: Write down the complaint rate that pauses sending and how many soft bounces become a suppression. - Verify: Both appear in configuration and are named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Running a mail server. - Open tracking pixels. - Behavioural automation and large-scale segmentation. - Paid subscriptions and recommendation networks. - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations - bounce, complaint, and suppression handling If one of those is essential to you, that is the reason to keep paying for Letterdrop, and the README should say so rather than pretend.
# Build brief · Letterdrop The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter tool that sends through your own provider and treats the unsubscribe path as the most important code in it to replace Letterdrop. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first outranks every feature you will be tempted to add first. The unsubscribe path is the most important code in this build. Google's sender guidelines require marketing and subscribed mail to support one-click unsubscribe using the List-Unsubscribe and List-Unsubscribe-Post headers described in RFC 2369 and RFC 8058, and require unsubscribe requests to be honoured within 48 hours; they ask senders to keep the spam rate reported in Postmaster Tools below 0.10 per cent and never to reach 0.30 per cent, and they require SPF and DKIM for everyone plus DMARC over a TLS connection once you are sending around 5,000 messages a day to personal Gmail accounts, counted across a primary domain and its subdomains. Those figures were checked while this was written and they change, so read the current page before you send anything. Treat 48 hours as a ceiling and make removal take effect on the next send, always, with no batch job in between. Second, you are not running a mail server. Sending goes through a provider you hold an account with, from a domain you control and have authenticated, and the suppression list is consulted at send time rather than when the list was built. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22 with one worker and a queue in the database · an issue send is a long-running job that must survive a restart - Storage: PostgreSQL, with suppression enforced by a constraint and checked at send time · an unsubscribe that only the application respects is not an unsubscribe - Sending: One provider behind one send function, chosen for its bounce and complaint webhooks · the feedback loop matters more than the send API - Rendering: Inline CSS at build time, with a plain-text alternative generated from the same source · mail clients strip stylesheets and some readers only see text - Tracking: Off by default, and open tracking absent entirely · an invisible pixel in every message is a choice you should have to make deliberately ### Data model (create this before Phase 1) - subscribers(id, email_normalised, name, state, consent_source, consent_at, consent_ip, created_at) · state is subscribed, unsubscribed, bounced or complained - suppressions(email_normalised, reason, at, source) · unique on the normalised address, checked inside the send transaction - issues(id, title, markdown, html, text, state, scheduled_at, sent_at) · html and text are generated from the same markdown - sends(issue_id, subscriber_id, state, attempts, provider_message_id, sent_at, last_error) · unique on (issue_id, subscriber_id) - events(provider_message_id, kind, at, payload_excerpt) · kind is delivered, bounced, complained or deferred, from the provider's webhook - unsubscribe_tokens(subscriber_id, token_hash, issue_id, created_at) · hashed, per issue, and valid indefinitely because people unsubscribe from old mail - An address that reaches suppressions is never sent to again by any issue, including one already queued, because the check happens inside the transaction that claims the send row. ### Phase 1 · Consent, normalisation and a suppression list with teeth Build: Store how, when and from where each subscriber signed up. Normalise addresses once on the way in and store the normalised form alongside the original. Import from CSV with a required consent column. Make the suppression check part of the send-row claim rather than a filter on the list. Trap: Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. Done when: An address suppressed after an issue is queued receives nothing, tested by suppressing mid-send. An import containing a suppressed address leaves it suppressed and reports the count of skipped rows. Addresses differing only by case or surrounding whitespace resolve to one subscriber, tested. Every subscriber row has a consent source and timestamp, enforced by a not-null constraint, and the importer refuses a file without one. Do not build yet: No composing, sending, unsubscribe links or webhooks. ### Phase 2 · One-click unsubscribe, done to the letter Build: Add List-Unsubscribe with both a mailto and an HTTPS URL, and List-Unsubscribe-Post set to List-Unsubscribe=One-Click, so a client posts to the URL without a confirmation page. Handle that POST by suppressing immediately and returning success. Keep a human unsubscribe link in the body too. Tokens are random, stored hashed, per issue and never expire. Trap: Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. Done when: A POST to the unsubscribe URL with the one-click body suppresses the address and returns success, tested against the header contract. A GET to the same URL renders a confirmation page and changes nothing, so a prefetcher cannot unsubscribe anyone. Suppression is visible to the next send immediately, with no scheduled job in between, tested. Repeating either request is harmless and returns the same result. Do not build yet: No rendering, sending or webhooks yet. ### Phase 3 · Mail that renders where mail is read Build: Write in Markdown. Generate HTML with CSS inlined at build time and a table-based layout, and generate the plain-text part from the same Markdown rather than by stripping tags. Rewrite links only if click tracking is explicitly enabled. Send a real test to at least three different clients before an issue can be scheduled. Trap: Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. Done when: The text part contains every heading and every link destination the HTML part contains, asserted in the suite. CSS is inlined and no stylesheet element remains, tested against the generated HTML. With tracking disabled the message contains no rewritten links and no image beacon, verified by scanning the output. An issue cannot be scheduled until a test send has been recorded. Do not build yet: No bulk sending, webhooks or authentication work yet. ### Phase 4 · One message per person per issue Build: Materialise one send row per recipient per issue with a unique constraint, then claim rows in small batches, moving each to sending in its own transaction before the provider is called. Store the provider's message id. Consume the bounce and complaint webhooks and write hard bounces and complaints straight into suppressions. Trap: Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. Done when: Killing the worker mid-issue and restarting delivers exactly one message per subscriber, tested with a counting fake provider. A hard bounce arriving on the webhook suppresses that address within one processing cycle and the next issue skips it, tested. A complaint does the same and is recorded separately from a bounce. Soft bounces are retried a stated number of times and then suppressed, with the count in configuration. Do not build yet: No domain authentication or reputation monitoring yet. ### Phase 5 · Your domain, your reputation, your exit Build: Document and verify SPF, DKIM and DMARC for the sending domain, with a startup check that refuses to send if any record is missing. Record the complaint rate per issue from webhook events and pause sending automatically above a stated threshold. Export subscribers, issues, sends and events as files. Rate limit the public subscribe endpoint and require confirmation. Trap: Reputation damage arrives as a gradual drop in delivery rather than an error, so an issue that quietly lands in spam for half your readers looks identical to one that landed fine. Compute the complaint rate per issue from the webhook events, compare it to the threshold you wrote down, and pause the next send automatically rather than leaving it to a person to notice. Done when: Startup refuses to send when SPF, DKIM or DMARC is missing for the configured domain, naming the missing record, tested with a domain that lacks one. The complaint rate per issue is computed from events and shown, and exceeding the configured threshold pauses the next send until acknowledged. Public subscription requires a confirmation click and the endpoint is rate limited per address. Export produces files that reimport into an empty database with consent and suppression intact. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which is a reputation project measured in months rather than a component. - Open tracking pixels, which put an invisible beacon in every message and buy you a number that modern privacy proxies have already ruined. - Large-scale segmentation and behavioural automation, which needs an event store and a rules engine and is most of what the subscription pays for. - Paid subscriptions and audience recommendation networks, which add payments, tax and a second set of webhooks that must also be exactly once. - Buying, scraping or enriching an address list, which is how a domain's reputation is destroyed in one send. ### README must contain - That one-click unsubscribe is implemented with List-Unsubscribe and List-Unsubscribe-Post per RFC 2369 and RFC 8058, and that the POST acts immediately while the GET only confirms - That suppression is checked inside the transaction claiming each send, so an unsubscribe during a send takes effect - The current sender requirements you checked, with the date, including the spam rate thresholds and the authentication records needed - That send rows are materialised with a unique constraint so a crash mid-issue resumes rather than repeats - That open tracking is absent and click tracking is off unless explicitly enabled
# Agent instructions · Letterdrop indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node 22 with one worker and a queue in the database, PostgreSQL, with suppression enforced by a constraint and checked at send time, One provider behind one send function, chosen for its bounce and complaint webhooks, Inline CSS at build time, with a plain-text alternative generated from the same source, Off by default, and open tracking absent entirely. 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 - Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. - Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. - Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. - Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume.
# Build plan · Letterdrop A newsletter sender whose hard problems are an unsubscribe that is instant and impossible to bypass, sending exactly once per recipient per issue across crashes, and keeping bounces and complaints flowing back into suppression rather than into a dashboard nobody reads. 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, normalisation and a suppression list with teeth Every address has a recorded consent and a suppressed address cannot be sent to by any path. ### Steps 1. Check suppression inside the send claim Not when queueing. The window between the two is where complaints come from. Files: `src/send/claim.ts`, `tests/suppression.test.ts` 2. Normalise once and store both forms One subscriber, one address, whatever the CSV looked like. Files: `src/subscribers/normalise.ts` 3. Refuse an import without consent columns A list with no recorded consent is the start of a reputation problem. Files: `src/subscribers/import.ts` ### Done when - [ ] Suppressing mid-send stops that recipient - [ ] An import never resurrects a suppressed address - [ ] Case and whitespace variants resolve to one subscriber - [ ] Consent source and time are required on every row ### Watch out - Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. ## Phase 2 · One-click unsubscribe, done to the letter A reader can leave from their mail client in one action and it takes effect immediately. ### Steps 1. Send both header forms exactly as specified The mailto and the HTTPS URL, plus the One-Click post header. Files: `src/mail/headers.ts`, `tests/list-unsubscribe.test.ts` 2. Act on POST, confirm on GET Prefetchers follow links; they do not post forms. Files: `src/web/unsubscribe.ts`, `tests/prefetch.test.ts` 3. Make suppression take effect with no intermediate job The 48 hour allowance is a ceiling, not a design. Files: `src/subscribers/suppress.ts` ### Done when - [ ] A one-click POST suppresses and returns success - [ ] A GET confirms and changes nothing - [ ] Suppression affects the very next send - [ ] Both requests are idempotent ### Watch out - Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. ## Phase 3 · Mail that renders where mail is read One source produces an HTML part that survives mail clients and a text part that stands alone. ### Steps 1. Generate both parts from one Markdown source Two renderers, one truth, no tag stripping. Files: `src/render/html.ts`, `src/render/text.ts`, `tests/parity.test.ts` 2. Inline CSS at build time Mail clients discard stylesheet elements without telling anyone. Files: `src/render/inline.ts` 3. Require a recorded test send before scheduling The cheapest gate against a broken issue going to everyone. Files: `src/issues/schedule.ts` ### Done when - [ ] The text part matches the HTML on headings and links - [ ] No stylesheet element survives inlining - [ ] Tracking off means no rewritten links and no beacon - [ ] Scheduling is blocked until a test send exists ### Watch out - Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. ## Phase 4 · One message per person per issue A send that crashes halfway resumes without sending anyone a second copy. ### Steps 1. Materialise send rows before the first send The unique constraint is what turns a restart into a resume. Files: `src/send/materialise.ts`, `tests/exactly-once.test.ts` 2. Claim in batches inside transactions Small batches so a crash costs seconds, not an issue. Files: `src/send/worker.ts` 3. Wire bounces and complaints into suppression A feedback loop that only writes to a dashboard is decorative. Files: `src/webhooks/provider.ts`, `tests/bounce.test.ts` ### Done when - [ ] A mid-issue crash and restart yields one message each - [ ] A hard bounce suppresses within one cycle - [ ] Complaints are recorded separately and suppress - [ ] Soft bounce retries are bounded and configured ### Watch out - Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. ## Not in this build - Running a mail server. - Open tracking pixels. - Behavioural automation and large-scale segmentation. - Paid subscriptions and recommendation networks. ## After v1, if you want it - Simple segments over recorded fields, once suppression and exactly-once sending have survived a few issues - A public archive page per issue, which reuses the rendered HTML and adds no sending risk - Click tracking as an explicit per-issue choice, with the rewritten links visible in the preview
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers, suppressions, issues, sends and events. DATABASE_URL=postgres://user:redacted@localhost:5432/letters # Required. Checked for SPF, DKIM and DMARC at startup. Sending is refused without them. SENDING_DOMAIN=mail.example.com # Required · secret. The sending provider. Scoped to sending and webhook management only. PROVIDER_API_KEY=key-...redacted # Required · secret. Verifies bounce and complaint callbacks. An unverified webhook can suppress anyone. WEBHOOK_SIGNING_SECRET=whsec_...redacted # Required. Builds the unsubscribe URL that goes in the List-Unsubscribe header. PUBLIC_BASE_URL=https://letters.example.com # Required. Rows claimed per transaction. Smaller batches make a crash cheaper. SEND_BATCH_SIZE=50 # Required. Soft bounces before the address is suppressed. SOFT_BOUNCE_MAX=3 # Required. Complaint rate per issue that pauses the next send until acknowledged. COMPLAINT_RATE_PAUSE=0.001
# Letterdrop · product brief ## Problem The visible B2B content + distribution loop is buildable, but a credible replacement needs more than the first screen. Letterdrop earns its keep through deliverability, reputation, network, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A newsletter sender whose unsubscribe works from inside the mail client and takes effect on the next message, which cannot send anyone two copies of an issue however badly it crashes, and which stops itself when the complaint rate says readers are unhappy. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - PostgreSQL - transactional or bulk email provider key in .env - verified sending domain ## Explicit non-goals for v1 - Running a mail server. - Open tracking pixels. - Behavioural automation and large-scale segmentation. - Paid subscriptions and recommendation networks. - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling - sender reputation and deliverability operations - bounce, complaint, and suppression handling ## Success criteria - Suppressing mid-send is confirmed to stop that recipient - A one-click POST is confirmed to suppress while a GET changes nothing - The text part is confirmed to match the HTML on headings and links - A mid-issue crash and restart is confirmed to yield one message each - A missing authentication record is confirmed to block sending
# Build brief · Letterdrop The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a newsletter tool that sends through your own provider and treats the unsubscribe path as the most important code in it to replace Letterdrop. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first outranks every feature you will be tempted to add first. The unsubscribe path is the most important code in this build. Google's sender guidelines require marketing and subscribed mail to support one-click unsubscribe using the List-Unsubscribe and List-Unsubscribe-Post headers described in RFC 2369 and RFC 8058, and require unsubscribe requests to be honoured within 48 hours; they ask senders to keep the spam rate reported in Postmaster Tools below 0.10 per cent and never to reach 0.30 per cent, and they require SPF and DKIM for everyone plus DMARC over a TLS connection once you are sending around 5,000 messages a day to personal Gmail accounts, counted across a primary domain and its subdomains. Those figures were checked while this was written and they change, so read the current page before you send anything. Treat 48 hours as a ceiling and make removal take effect on the next send, always, with no batch job in between. Second, you are not running a mail server. Sending goes through a provider you hold an account with, from a domain you control and have authenticated, and the suppression list is consulted at send time rather than when the list was built. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22 with one worker and a queue in the database · an issue send is a long-running job that must survive a restart - Storage: PostgreSQL, with suppression enforced by a constraint and checked at send time · an unsubscribe that only the application respects is not an unsubscribe - Sending: One provider behind one send function, chosen for its bounce and complaint webhooks · the feedback loop matters more than the send API - Rendering: Inline CSS at build time, with a plain-text alternative generated from the same source · mail clients strip stylesheets and some readers only see text - Tracking: Off by default, and open tracking absent entirely · an invisible pixel in every message is a choice you should have to make deliberately ### Data model (create this before Phase 1) - subscribers(id, email_normalised, name, state, consent_source, consent_at, consent_ip, created_at) · state is subscribed, unsubscribed, bounced or complained - suppressions(email_normalised, reason, at, source) · unique on the normalised address, checked inside the send transaction - issues(id, title, markdown, html, text, state, scheduled_at, sent_at) · html and text are generated from the same markdown - sends(issue_id, subscriber_id, state, attempts, provider_message_id, sent_at, last_error) · unique on (issue_id, subscriber_id) - events(provider_message_id, kind, at, payload_excerpt) · kind is delivered, bounced, complained or deferred, from the provider's webhook - unsubscribe_tokens(subscriber_id, token_hash, issue_id, created_at) · hashed, per issue, and valid indefinitely because people unsubscribe from old mail - An address that reaches suppressions is never sent to again by any issue, including one already queued, because the check happens inside the transaction that claims the send row. ### Phase 1 · Consent, normalisation and a suppression list with teeth Build: Store how, when and from where each subscriber signed up. Normalise addresses once on the way in and store the normalised form alongside the original. Import from CSV with a required consent column. Make the suppression check part of the send-row claim rather than a filter on the list. Trap: Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. Done when: An address suppressed after an issue is queued receives nothing, tested by suppressing mid-send. An import containing a suppressed address leaves it suppressed and reports the count of skipped rows. Addresses differing only by case or surrounding whitespace resolve to one subscriber, tested. Every subscriber row has a consent source and timestamp, enforced by a not-null constraint, and the importer refuses a file without one. Do not build yet: No composing, sending, unsubscribe links or webhooks. ### Phase 2 · One-click unsubscribe, done to the letter Build: Add List-Unsubscribe with both a mailto and an HTTPS URL, and List-Unsubscribe-Post set to List-Unsubscribe=One-Click, so a client posts to the URL without a confirmation page. Handle that POST by suppressing immediately and returning success. Keep a human unsubscribe link in the body too. Tokens are random, stored hashed, per issue and never expire. Trap: Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. Done when: A POST to the unsubscribe URL with the one-click body suppresses the address and returns success, tested against the header contract. A GET to the same URL renders a confirmation page and changes nothing, so a prefetcher cannot unsubscribe anyone. Suppression is visible to the next send immediately, with no scheduled job in between, tested. Repeating either request is harmless and returns the same result. Do not build yet: No rendering, sending or webhooks yet. ### Phase 3 · Mail that renders where mail is read Build: Write in Markdown. Generate HTML with CSS inlined at build time and a table-based layout, and generate the plain-text part from the same Markdown rather than by stripping tags. Rewrite links only if click tracking is explicitly enabled. Send a real test to at least three different clients before an issue can be scheduled. Trap: Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. Done when: The text part contains every heading and every link destination the HTML part contains, asserted in the suite. CSS is inlined and no stylesheet element remains, tested against the generated HTML. With tracking disabled the message contains no rewritten links and no image beacon, verified by scanning the output. An issue cannot be scheduled until a test send has been recorded. Do not build yet: No bulk sending, webhooks or authentication work yet. ### Phase 4 · One message per person per issue Build: Materialise one send row per recipient per issue with a unique constraint, then claim rows in small batches, moving each to sending in its own transaction before the provider is called. Store the provider's message id. Consume the bounce and complaint webhooks and write hard bounces and complaints straight into suppressions. Trap: Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. Done when: Killing the worker mid-issue and restarting delivers exactly one message per subscriber, tested with a counting fake provider. A hard bounce arriving on the webhook suppresses that address within one processing cycle and the next issue skips it, tested. A complaint does the same and is recorded separately from a bounce. Soft bounces are retried a stated number of times and then suppressed, with the count in configuration. Do not build yet: No domain authentication or reputation monitoring yet. ### Phase 5 · Your domain, your reputation, your exit Build: Document and verify SPF, DKIM and DMARC for the sending domain, with a startup check that refuses to send if any record is missing. Record the complaint rate per issue from webhook events and pause sending automatically above a stated threshold. Export subscribers, issues, sends and events as files. Rate limit the public subscribe endpoint and require confirmation. Trap: Reputation damage arrives as a gradual drop in delivery rather than an error, so an issue that quietly lands in spam for half your readers looks identical to one that landed fine. Compute the complaint rate per issue from the webhook events, compare it to the threshold you wrote down, and pause the next send automatically rather than leaving it to a person to notice. Done when: Startup refuses to send when SPF, DKIM or DMARC is missing for the configured domain, naming the missing record, tested with a domain that lacks one. The complaint rate per issue is computed from events and shown, and exceeding the configured threshold pauses the next send until acknowledged. Public subscription requires a confirmation click and the endpoint is rate limited per address. Export produces files that reimport into an empty database with consent and suppression intact. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server, which is a reputation project measured in months rather than a component. - Open tracking pixels, which put an invisible beacon in every message and buy you a number that modern privacy proxies have already ruined. - Large-scale segmentation and behavioural automation, which needs an event store and a rules engine and is most of what the subscription pays for. - Paid subscriptions and audience recommendation networks, which add payments, tax and a second set of webhooks that must also be exactly once. - Buying, scraping or enriching an address list, which is how a domain's reputation is destroyed in one send. ### README must contain - That one-click unsubscribe is implemented with List-Unsubscribe and List-Unsubscribe-Post per RFC 2369 and RFC 8058, and that the POST acts immediately while the GET only confirms - That suppression is checked inside the transaction claiming each send, so an unsubscribe during a send takes effect - The current sender requirements you checked, with the date, including the spam rate thresholds and the authentication records needed - That send rows are materialised with a unique constraint so a crash mid-issue resumes rather than repeats - That open tracking is absent and click tracking is off unless explicitly enabled
# Architecture · Letterdrop ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript on Node 22 with one worker and a queue in the database | an issue send is a long-running job that must survive a restart | | Storage | PostgreSQL, with suppression enforced by a constraint and checked at send time | an unsubscribe that only the application respects is not an unsubscribe | | Sending | One provider behind one send function, chosen for its bounce and complaint webhooks | the feedback loop matters more than the send API | | Rendering | Inline CSS at build time, with a plain-text alternative generated from the same source | mail clients strip stylesheets and some readers only see text | | Tracking | Off by default, and open tracking absent entirely | an invisible pixel in every message is a choice you should have to make deliberately | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Subscribers | Normalisation, consent and imports. | Any import format, provided consent is required and suppression is never overridden. | | Suppression | The list and the check inside the send claim. | Not swappable for a filter at queue time, which reopens the window. | | Render | HTML and text from one Markdown source. | Any renderer, provided the parity test still passes. | | Sender | Materialised send rows and batched claims. | Any provider, provided the message id is stored and webhooks are wired. | | Reputation | Authentication checks and the complaint rate. | Any source of delivery events, provided the pause threshold still fires. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Subscribers, suppressions, issues, sends and events. - `SENDING_DOMAIN` · required · Checked for SPF, DKIM and DMARC at startup. Sending is refused without them. - `PROVIDER_API_KEY` · required, secret · The sending provider. Scoped to sending and webhook management only. - `WEBHOOK_SIGNING_SECRET` · required, secret · Verifies bounce and complaint callbacks. An unverified webhook can suppress anyone. - `PUBLIC_BASE_URL` · required · Builds the unsubscribe URL that goes in the List-Unsubscribe header. - `SEND_BATCH_SIZE` · required · Rows claimed per transaction. Smaller batches make a crash cheaper. - `SOFT_BOUNCE_MAX` · required · Soft bounces before the address is suppressed. - `COMPLAINT_RATE_PAUSE` · required · Complaint rate per issue that pauses the next send until acknowledged. ## 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 · Letterdrop product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript on Node 22 with one worker and a queue in the database, PostgreSQL, with suppression enforced by a constraint and checked at send time, One provider behind one send function, chosen for its bounce and complaint webhooks, Inline CSS at build time, with a plain-text alternative generated from the same source, Off by default, and open tracking absent entirely. - 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 - Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. - Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. - Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. - Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. - Reputation damage arrives as a gradual drop in delivery rather than an error, so an issue that quietly lands in spam for half your readers looks identical to one that landed fine. Compute the complaint rate per issue from the webhook events, compare it to the threshold you wrote down, and pause the next send automatically rather than leaving it to a person to notice.
# Delivery milestones · Letterdrop Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Consent, normalisation and a suppression list with teeth Every address has a recorded consent and a suppressed address cannot be sent to by any path. ### Steps 1. Check suppression inside the send claim Not when queueing. The window between the two is where complaints come from. Files: `src/send/claim.ts`, `tests/suppression.test.ts` 2. Normalise once and store both forms One subscriber, one address, whatever the CSV looked like. Files: `src/subscribers/normalise.ts` 3. Refuse an import without consent columns A list with no recorded consent is the start of a reputation problem. Files: `src/subscribers/import.ts` ### Done when - [ ] Suppressing mid-send stops that recipient - [ ] An import never resurrects a suppressed address - [ ] Case and whitespace variants resolve to one subscriber - [ ] Consent source and time are required on every row ### Watch out - Filtering the suppression list when the send is queued rather than when each message is claimed means anyone unsubscribing during a two hour send still gets the email, and that is exactly the person most likely to report it as spam. Check suppression inside the transaction that claims each send row, and let an import never resurrect a suppressed address whatever the file says. ## M2 · One-click unsubscribe, done to the letter A reader can leave from their mail client in one action and it takes effect immediately. ### Steps 1. Send both header forms exactly as specified The mailto and the HTTPS URL, plus the One-Click post header. Files: `src/mail/headers.ts`, `tests/list-unsubscribe.test.ts` 2. Act on POST, confirm on GET Prefetchers follow links; they do not post forms. Files: `src/web/unsubscribe.ts`, `tests/prefetch.test.ts` 3. Make suppression take effect with no intermediate job The 48 hour allowance is a ceiling, not a design. Files: `src/subscribers/suppress.ts` ### Done when - [ ] A one-click POST suppresses and returns success - [ ] A GET confirms and changes nothing - [ ] Suppression affects the very next send - [ ] Both requests are idempotent ### Watch out - Implementing one-click unsubscribe as a GET to a confirmation page fails in two ways at once: mail clients that post get an error, and link prefetchers that follow every URL in a message unsubscribe people who never touched it. Accept the POST and act on it, make the GET a page with a button, and make both idempotent. ## M3 · Mail that renders where mail is read One source produces an HTML part that survives mail clients and a text part that stands alone. ### Steps 1. Generate both parts from one Markdown source Two renderers, one truth, no tag stripping. Files: `src/render/html.ts`, `src/render/text.ts`, `tests/parity.test.ts` 2. Inline CSS at build time Mail clients discard stylesheet elements without telling anyone. Files: `src/render/inline.ts` 3. Require a recorded test send before scheduling The cheapest gate against a broken issue going to everyone. Files: `src/issues/schedule.ts` ### Done when - [ ] The text part matches the HTML on headings and links - [ ] No stylesheet element survives inlining - [ ] Tracking off means no rewritten links and no beacon - [ ] Scheduling is blocked until a test send exists ### Watch out - Generating the text part by stripping HTML produces a wall of bare URLs and lost list structure, and that part is what the reader on a text-only client, a smartwatch summary or a spam filter sees first. Generate it from the Markdown source, keep link text with its destination, and assert in a test that the text part contains every heading and link the HTML has. ## M4 · One message per person per issue A send that crashes halfway resumes without sending anyone a second copy. ### Steps 1. Materialise send rows before the first send The unique constraint is what turns a restart into a resume. Files: `src/send/materialise.ts`, `tests/exactly-once.test.ts` 2. Claim in batches inside transactions Small batches so a crash costs seconds, not an issue. Files: `src/send/worker.ts` 3. Wire bounces and complaints into suppression A feedback loop that only writes to a dashboard is decorative. Files: `src/webhooks/provider.ts`, `tests/bounce.test.ts` ### Done when - [ ] A mid-issue crash and restart yields one message each - [ ] A hard bounce suppresses within one cycle - [ ] Complaints are recorded separately and suppress - [ ] Soft bounce retries are bounded and configured ### Watch out - Iterating the subscriber list and sending as you go means a crash at recipient four thousand leaves you with no record of who received what, and the safe-looking fix of starting again sends four thousand duplicates. Materialise the send rows first, claim them one batch at a time, and let the unique constraint make a restart a resume. ## M5 · Your domain, your reputation, your exit (production only) The sending domain is authenticated, the complaint rate is watched, and everything is exportable. ### Steps 1. Check the authentication records at startup A missing DKIM record is discovered at send time otherwise. Files: `src/startup/dns-check.ts`, `tests/dns-check.test.ts` 2. Compute and act on the complaint rate A threshold that pauses beats a chart that informs. Files: `src/reputation/rate.ts` 3. Require confirmed opt-in on the public form The cheapest protection against someone else's address on your list. Files: `src/web/subscribe.ts` ### Done when - [ ] A missing authentication record blocks sending - [ ] The complaint rate pauses the next send at the threshold - [ ] Public subscription is confirmed and rate limited - [ ] Export round-trips with consent and suppression intact ### Watch out - Reputation damage arrives as a gradual drop in delivery rather than an error, so an issue that quietly lands in spam for half your readers looks identical to one that landed fine. Compute the complaint rate per issue from the webhook events, compare it to the threshold you wrote down, and pause the next send automatically rather than leaving it to a person to notice.
# Operations · Letterdrop ## Backup The database is the record of who consented to what and who asked to leave, which is the part with a legal shape. Back it up before every issue, and keep the suppression table in any partial backup even if nothing else fits. ## Restore Restore, then move any send rows left in sending back to claimable only after checking the provider for a message id, since the provider may already have sent them. Verify the suppression table row count against the last backup before sending anything. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Complaint rate per issue, hard bounce rate, sends stuck in the sending state, webhook delivery failures and the authentication records still resolving. A webhook that stopped arriving means suppression has silently stopped updating. ## Incident checklist A duplicate message means the unique constraint was missing or the claim was outside a transaction, and the sends table shows it. A spike in complaints means an issue went to people who did not expect it, and the consent columns on those subscribers are where the answer is. 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 - [ ] Suppressing mid-send is confirmed to stop that recipient - [ ] A one-click POST is confirmed to suppress while a GET changes nothing - [ ] The text part is confirmed to match the HTML on headings and links - [ ] A mid-issue crash and restart is confirmed to yield one message each - [ ] A missing authentication record is confirmed to block sending ## Launch constraint Do not market omitted Letterdrop capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers, suppressions, issues, sends and events. DATABASE_URL=postgres://user:redacted@localhost:5432/letters # Required. Checked for SPF, DKIM and DMARC at startup. Sending is refused without them. SENDING_DOMAIN=mail.example.com # Required · secret. The sending provider. Scoped to sending and webhook management only. PROVIDER_API_KEY=key-...redacted # Required · secret. Verifies bounce and complaint callbacks. An unverified webhook can suppress anyone. WEBHOOK_SIGNING_SECRET=whsec_...redacted # Required. Builds the unsubscribe URL that goes in the List-Unsubscribe header. PUBLIC_BASE_URL=https://letters.example.com # Required. Rows claimed per transaction. Smaller batches make a crash cheaper. SEND_BATCH_SIZE=50 # Required. Soft bounces before the address is suppressed. SOFT_BOUNCE_MAX=3 # Required. Complaint rate per issue that pauses the next send until acknowledged. COMPLAINT_RATE_PAUSE=0.001
$ choose a build depth, inspect the files, then open the complete pack in your agent
Letterdrop: The editor is trivial compared with inbox placement, compliance, reputation, unsubscribe handling, analytics, and audience growth infrastructure.
xlarge-scale segmentation and automation
xpayments, recommendations, and audience network
xsender reputation, deliverability, and suppression handling
xsender reputation and deliverability operations
xbounce, complaint, and suppression handling
Letterdrop pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| competitor signal | custom | — | Daily competitor/job-change signals to CRM, Salesforce/HubSpot/Outreach/Apollo/Clay sync, email and phone contact data, onboarding about 10 days to live. |
free tierno free tier.
billingannual custom contract; 30-day pilot; usage-based
hidden costsPricing scales with tracked competitor sales headcount; security/enterprise terms are custom.
verified 2026-08-10 · source ↗
Vibecode Letterdrop
Kinda. The core of Letterdrop is buildable in a weekend with the prompt on this page, but there are real gaps: large-scale segmentation and automation, payments, recommendations, and audience network. Read the honest list above before committing.
How much does Letterdrop cost?
Letterdrop's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Letterdrop?
Honestly: large-scale segmentation and automation; payments, recommendations, and audience network; sender reputation, deliverability, and suppression handling; sender reputation and deliverability operations; bounce, complaint, and suppression handling. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Letterdrop?
Yes: listmonk (High-performance self-hosted newsletter and mailing-list manager.), Mautic (Open-source marketing automation platform.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.