Vibecode FeedBlitz
track this build4 phases, 12 steps, beginner friendly0%The visible email + RSS publishing loop is buildable, but a credible replacement needs more than the first screen. FeedBlitz 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 FeedBlitz. 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 ===== # FeedBlitz · indie build A feed-to-email publisher whose hard problems are never sending to someone who left, implementing one-click unsubscribe exactly as the specification requires, and processing bounces and complaints before they cost you the inbox. 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 | Next.js 15 with TypeScript plus one worker for sending and webhooks | sending from a request handler is how a campaign half happens | | Storage | PostgreSQL, with suppression checked at send time in the same transaction | the list is stale the moment it is built | | Sending | One reputable provider with a verified domain, never your own mail server | reputation is the product you are borrowing | | Unsubscribe | A one-click endpoint built to the specification, plus a visible link | a confirmation page breaks the automated unsubscribe and earns a complaint instead | | Feedback | Provider webhooks for bounces and complaints, deduplicated by event id | an unprocessed complaint is a complaint that still counts against you | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application, the poller and the send worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally, a few pounds a month managed - Why: Suppression is joined inside the send transaction, and that is a job for a real database. - Get it: Your package manager, a container, or any managed instance. - Verify: psql connects and a migration applies - [ ] **A sending domain you control the DNS for** · the cost of a domain - Why: SPF, DKIM and DMARC records are the entry requirement, and you cannot add them to a domain someone else owns. - Get it: A domain with DNS you can edit, ideally a subdomain used only for this mail. - Verify: The preflight resolves all three records from the running application - [ ] **An email provider account with webhooks** · free tier at small volume on most providers - Why: You are borrowing their reputation and their bounce and complaint reporting, which is the part you cannot build. - Get it: Create an account, verify the domain, issue a sending key, and enable bounce and complaint webhooks. - Verify: A test send arrives, and a forced bounce produces a webhook you receive - [ ] **The current bulk sender guidelines** · free - Why: The thresholds are copied from the published text rather than chosen, and they have changed before. - Get it: Read the current sender guidelines for the mailbox providers your list actually uses, and note the date. - Verify: A note records the thresholds used and the date read - [ ] **Postmaster or reputation tooling for your domain** · free - Why: The complaint rate you must stay under is measured by the mailbox provider, not by you, and the only honest number comes from their tools. - Get it: Register the sending domain with the provider's postmaster tools. - Verify: The dashboard shows data for your domain after the first send ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Your own mail server. - Paid subscriptions and an audience network. - Behavioural automations and deep segmentation. - Open and click tracking by default. - large-scale segmentation and automation - payments, recommendations, and audience network - resilient polling of malformed and rate-limited feeds - 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 FeedBlitz, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · FeedBlitz 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 turns a feed into email and treats the unsubscribe list as sacred to replace FeedBlitz. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both are about other people's inboxes rather than your features. The suppression list wins over everything. An address that unsubscribed, hard bounced or marked a message as spam is never sent to again by any campaign, any automation, any import or any re-engagement idea. The check runs at send time against the database, not when the recipient list was assembled, because the list was assembled before the person left. Second, the mailbox providers publish the rules and they are not suggestions. Google's sender guidelines require SPF and DKIM for every sender, add DMARC for anyone sending more than 5,000 messages a day to Gmail, require a TLS connection, require one-click unsubscribe on marketing mail, and require spam complaint rates to stay below 0.30 percent as reported in Postmaster Tools, with 0.10 percent named as the level to aim for (checked September 2026). Read the current text before your first send; these thresholds have moved before. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript plus one worker for sending and webhooks · sending from a request handler is how a campaign half happens - Storage: PostgreSQL, with suppression checked at send time in the same transaction · the list is stale the moment it is built - Sending: One reputable provider with a verified domain, never your own mail server · reputation is the product you are borrowing - Unsubscribe: A one-click endpoint built to the specification, plus a visible link · a confirmation page breaks the automated unsubscribe and earns a complaint instead - Feedback: Provider webhooks for bounces and complaints, deduplicated by event id · an unprocessed complaint is a complaint that still counts against you ### Data model (create this before Phase 1) - subscribers(id, email, email_hash, name, state, consent_source, consent_at, consent_ip_hash, created_at) - suppressions(email_hash, reason, at, source) · reason is unsubscribed, bounced, complained or manual, and this table outranks every list - lists(id, name, double_opt_in, from_name, from_email, reply_to, footer_postal_address) - feeds(id, url, list_id, etag, last_modified, watermark_item_id, watermark_at, template_id) - items(id, feed_id, guid, canonical_url, content_hash, title, body_html, published_at, first_seen_at) - campaigns(id, list_id, subject, body_html, source_item_id, state, scheduled_at, sent_at) - sends(campaign_id, subscriber_id, idempotency_key, provider_message_id, state, delivered_at, bounced_at, complained_at) - events(id, provider_event_id, kind, email_hash, at, raw_json) · webhook deliveries deduplicated by the provider's own event id ### Phase 1 · Consent recorded, suppression obeyed Build: Store a consent source, timestamp and address hash with every subscriber, and refuse an import row that has none. Import intersects the incoming addresses with the suppression table and reports how many were dropped and why. The send path joins against suppression inside the same transaction that claims the send row. Trap: Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. Done when: An import containing a previously unsubscribed address adds nothing for that address and reports it in the summary, tested. A send to a suppressed address is impossible through any code path, asserted by a test that tries all of them. Every subscriber has a consent source and timestamp, enforced by a constraint rather than a convention. Unsubscribing writes to suppression rather than only changing a list membership. Do not build yet: No feed polling, templates, sending or webhooks. ### Phase 2 · A feed becomes an issue, and the archive stays where it is Build: Poll with conditional requests. On first connection, record every existing item and set the watermark, sending none of them. Match new items on identifier, then canonical URL, then a content hash. Render into a template with a plain text alternative and the required footer. Trap: Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. Done when: Connecting a feed with a large archive creates zero campaigns, tested with a recorded feed. An item whose identifier changes but whose URL is unchanged does not create a second campaign, tested. Every campaign renders a plain text alternative alongside the HTML, asserted. An item edited after its campaign was sent does not resend, and the change is visible in the interface. Do not build yet: No sending, unsubscribe endpoint or webhooks yet. ### Phase 3 · One-click unsubscribe, built to the specification Build: Send both headers: a List-Unsubscribe carrying an HTTPS URI, and a List-Unsubscribe-Post whose value is the single pair specified for one-click. The endpoint accepts a POST with no cookies and no authorisation, suppresses immediately, and returns success without redirecting. Keep a visible unsubscribe link in the footer as well. Trap: Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. Done when: A POST to the one-click URI with no cookies and no authorisation suppresses the address and returns success without a redirect, tested with a bare HTTP client. Both headers appear on every marketing message and their values match the specification exactly, asserted against a sent message. The token in the URI identifies one subscriber and one list and cannot be guessed from another, tested. A visible unsubscribe link is present in the footer of every message. Do not build yet: No bounce or complaint processing yet. ### Phase 4 · Bounces and complaints, processed the same day Build: Receive the provider's webhooks, verify their signature, and deduplicate on the provider's own event identifier. A hard bounce suppresses immediately; soft bounces count and suppress after a stated run. A complaint suppresses and increments a rate you display against the published threshold. Block the next send when the rate is above your own lower line. Trap: Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. Done when: A replayed webhook is processed once, tested by delivering the same event identifier twice. A hard bounce suppresses the address before the next campaign, tested end to end. The rolling complaint rate is displayed beside the send action with the window it covers. A campaign is blocked when the rate exceeds the configured line, and the block names the rate and the threshold. Do not build yet: No authentication preflight or export yet. ### Phase 5 · Proving you are allowed to send Build: A preflight that resolves your sending domain's SPF and DKIM records and your DMARC policy and refuses the first send of the day if any is missing. Register with the provider postmaster tools and record where the complaint rate is read from. Export subscribers, suppressions, campaigns and sends as CSV, and support deleting a subscriber's data on request. Trap: A preflight that only checks your own configuration table passes while the DNS record is missing, because you wrote the configuration and nobody wrote the record. Resolve the actual DNS from the running application, and fail the send with the name of the record that is absent. Done when: The preflight resolves SPF, DKIM and DMARC from DNS and fails a send naming the missing record, tested by pointing at a domain without one. The export re-imports into an empty instance with suppressions intact, tested, because a list restored without its suppressions is a complaint incident. A deletion request removes the subscriber and leaves a suppression entry keyed by hash, so they are not silently re-subscribed later. The README states which sender guidelines were read and on what date. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server. Reputation takes months to build and one bad send to destroy, and a provider is renting you theirs for less than the time you would spend. - Paid subscriptions, recommendation networks and an audience marketplace, which are the business model rather than the publishing loop. - Behavioural automations and deep segmentation, which multiply every send path by a condition tree and make suppression harder to guarantee. - Open and click tracking by default. It requires rewriting every link and embedding a pixel, both of which carry their own consent questions; if you add it, make it opt-in and say so in the footer. ### README must contain - That the suppression table outranks every list and is checked at send time, not at list build time - Which sender guidelines were read, on what date, and what the complaint rate threshold was then - That one-click unsubscribe uses an HTTPS URI, accepts a POST with no cookies or authorisation, and never redirects - That connecting a feed sends nothing until the next new item, because an archive send is a complaint incident - That bounces and complaints are processed on arrival and the rolling rate blocks a send above your line ===== AGENTS.md ===== # Agent instructions · FeedBlitz indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript plus one worker for sending and webhooks, PostgreSQL, with suppression checked at send time in the same transaction, One reputable provider with a verified domain, never your own mail server, A one-click endpoint built to the specification, plus a visible link, Provider webhooks for bounces and complaints, deduplicated by event id. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. - Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. - Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. - Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. ===== BUILD_PLAN.md ===== # Build plan · FeedBlitz A feed-to-email publisher whose hard problems are never sending to someone who left, implementing one-click unsubscribe exactly as the specification requires, and processing bounces and complaints before they cost you the inbox. 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 recorded, suppression obeyed Every subscriber carries a recorded consent, and no send can reach a suppressed address. ### Steps 1. Make consent a column you cannot skip A constraint, not a convention, because imports are written in a hurry. Files: `prisma/schema.prisma`, `src/lib/import.ts` 2. Intersect every import with suppression And report the number dropped, so it is visible rather than silent. Files: `src/lib/import.ts`, `tests/import-suppression.test.ts` 3. Join suppression inside the send transaction Checked at send time, because the list is older than the decision. Files: `src/lib/send.ts`, `tests/no-send-to-suppressed.test.ts` ### Done when - [ ] An import cannot resurrect a suppressed address - [ ] No code path can send to a suppressed address - [ ] Consent source and time are required by the schema - [ ] Unsubscribe writes to suppression, not just to a list ### Watch out - Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. ## Phase 2 · A feed becomes an issue, and the archive stays where it is Turning on a feed sends nothing until the next new item appears. ### Steps 1. Watermark on first connection Record everything, send nothing. This is the single most expensive mistake here. Files: `src/lib/feeds.ts`, `tests/first-connect.test.ts` 2. Match down a ladder, not on one key Identifier, then canonical URL, then content hash. Files: `src/lib/dedupe.ts` 3. Render a plain text alternative every time Some clients only read it, and filters notice when it is missing. Files: `src/lib/render.ts` ### Done when - [ ] Connecting a feed sends nothing - [ ] A rewritten identifier creates no duplicate campaign - [ ] Every campaign has a plain text alternative - [ ] An edited item does not resend ### Watch out - Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. ## Phase 3 · One-click unsubscribe, built to the specification The mailbox provider's automated unsubscribe works without a page, a cookie or a click from the reader. ### Steps 1. Emit both headers with the exact specified values The value of the post header is one pair, and it is not free text. Files: `src/lib/headers.ts`, `tests/list-unsubscribe.test.ts` 2. Act on the POST, do not ask No cookie, no authorisation, no redirect, no confirmation page. Files: `src/app/u/[token]/route.ts`, `tests/one-click.test.ts` 3. Scope and randomise the token One subscriber, one list, unguessable from any other. Files: `src/lib/tokens.ts` ### Done when - [ ] A bare POST suppresses and returns success with no redirect - [ ] Both headers match the specification exactly - [ ] Tokens are scoped and unguessable - [ ] Every message carries a visible unsubscribe link ### Watch out - Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. ## Phase 4 · Bounces and complaints, processed the same day A hard bounce or a complaint suppresses the address before the next send, and the rate is visible. ### Steps 1. Verify the signature and deduplicate on event id Providers retry, and a double-processed bounce is only harmless by luck. Files: `src/app/api/webhooks/route.ts`, `tests/webhook-replay.test.ts` 2. Suppress hard bounces immediately, soft ones on a run The distinction is the provider's; store which one it told you. Files: `src/lib/feedback.ts` 3. Show the rolling complaint rate next to the send button The number matters most at the moment you are about to send. Files: `src/app/campaigns/[id]/page.tsx` ### Done when - [ ] A replayed webhook is processed once - [ ] A hard bounce suppresses before the next send - [ ] The complaint rate is shown with its window - [ ] An over-threshold rate blocks the send ### Watch out - Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. ## Not in this build - Your own mail server. - Paid subscriptions and an audience network. - Behavioural automations and deep segmentation. - Open and click tracking by default. ## After v1, if you want it - A double opt-in flow per list, once suppression and one-click have run clean for a month - Scheduled digests that batch several feed items into one issue, reusing the watermark - A seed list check before large sends, which is measurement rather than a new send path ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers, suppressions, campaigns and sends. DATABASE_URL=postgres://user:redacted@localhost:5432/news # Required · secret. Sending only. Rotate it if it reaches a log. ESP_API_KEY=re_...redacted # Required · secret. Verifies bounce and complaint callbacks before they are trusted. ESP_WEBHOOK_SECRET=redacted # Required. The preflight resolves SPF, DKIM and DMARC for this name. SENDING_DOMAIN=mail.example.com # Required. The one-click unsubscribe URI must be HTTPS on this host. PUBLIC_BASE_URL=https://mail.example.com # Required. Your own line, set below the published threshold, which blocks a send. COMPLAINT_RATE_BLOCK=0.0015 # Required. Consecutive soft bounces before the address is suppressed. SOFT_BOUNCE_SUPPRESS_AFTER=4 # Required. Required in commercial mail in most jurisdictions. Check yours. FOOTER_POSTAL_ADDRESS=A real postal address
You are building a lean indie version of FeedBlitz. 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 ===== # FeedBlitz · indie build A feed-to-email publisher whose hard problems are never sending to someone who left, implementing one-click unsubscribe exactly as the specification requires, and processing bounces and complaints before they cost you the inbox. 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 | Next.js 15 with TypeScript plus one worker for sending and webhooks | sending from a request handler is how a campaign half happens | | Storage | PostgreSQL, with suppression checked at send time in the same transaction | the list is stale the moment it is built | | Sending | One reputable provider with a verified domain, never your own mail server | reputation is the product you are borrowing | | Unsubscribe | A one-click endpoint built to the specification, plus a visible link | a confirmation page breaks the automated unsubscribe and earns a complaint instead | | Feedback | Provider webhooks for bounces and complaints, deduplicated by event id | an unprocessed complaint is a complaint that still counts against you | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application, the poller and the send worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally, a few pounds a month managed - Why: Suppression is joined inside the send transaction, and that is a job for a real database. - Get it: Your package manager, a container, or any managed instance. - Verify: psql connects and a migration applies - [ ] **A sending domain you control the DNS for** · the cost of a domain - Why: SPF, DKIM and DMARC records are the entry requirement, and you cannot add them to a domain someone else owns. - Get it: A domain with DNS you can edit, ideally a subdomain used only for this mail. - Verify: The preflight resolves all three records from the running application - [ ] **An email provider account with webhooks** · free tier at small volume on most providers - Why: You are borrowing their reputation and their bounce and complaint reporting, which is the part you cannot build. - Get it: Create an account, verify the domain, issue a sending key, and enable bounce and complaint webhooks. - Verify: A test send arrives, and a forced bounce produces a webhook you receive - [ ] **The current bulk sender guidelines** · free - Why: The thresholds are copied from the published text rather than chosen, and they have changed before. - Get it: Read the current sender guidelines for the mailbox providers your list actually uses, and note the date. - Verify: A note records the thresholds used and the date read - [ ] **Postmaster or reputation tooling for your domain** · free - Why: The complaint rate you must stay under is measured by the mailbox provider, not by you, and the only honest number comes from their tools. - Get it: Register the sending domain with the provider's postmaster tools. - Verify: The dashboard shows data for your domain after the first send ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Your own mail server. - Paid subscriptions and an audience network. - Behavioural automations and deep segmentation. - Open and click tracking by default. - large-scale segmentation and automation - payments, recommendations, and audience network - resilient polling of malformed and rate-limited feeds - 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 FeedBlitz, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · FeedBlitz 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 turns a feed into email and treats the unsubscribe list as sacred to replace FeedBlitz. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both are about other people's inboxes rather than your features. The suppression list wins over everything. An address that unsubscribed, hard bounced or marked a message as spam is never sent to again by any campaign, any automation, any import or any re-engagement idea. The check runs at send time against the database, not when the recipient list was assembled, because the list was assembled before the person left. Second, the mailbox providers publish the rules and they are not suggestions. Google's sender guidelines require SPF and DKIM for every sender, add DMARC for anyone sending more than 5,000 messages a day to Gmail, require a TLS connection, require one-click unsubscribe on marketing mail, and require spam complaint rates to stay below 0.30 percent as reported in Postmaster Tools, with 0.10 percent named as the level to aim for (checked September 2026). Read the current text before your first send; these thresholds have moved before. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript plus one worker for sending and webhooks · sending from a request handler is how a campaign half happens - Storage: PostgreSQL, with suppression checked at send time in the same transaction · the list is stale the moment it is built - Sending: One reputable provider with a verified domain, never your own mail server · reputation is the product you are borrowing - Unsubscribe: A one-click endpoint built to the specification, plus a visible link · a confirmation page breaks the automated unsubscribe and earns a complaint instead - Feedback: Provider webhooks for bounces and complaints, deduplicated by event id · an unprocessed complaint is a complaint that still counts against you ### Data model (create this before Phase 1) - subscribers(id, email, email_hash, name, state, consent_source, consent_at, consent_ip_hash, created_at) - suppressions(email_hash, reason, at, source) · reason is unsubscribed, bounced, complained or manual, and this table outranks every list - lists(id, name, double_opt_in, from_name, from_email, reply_to, footer_postal_address) - feeds(id, url, list_id, etag, last_modified, watermark_item_id, watermark_at, template_id) - items(id, feed_id, guid, canonical_url, content_hash, title, body_html, published_at, first_seen_at) - campaigns(id, list_id, subject, body_html, source_item_id, state, scheduled_at, sent_at) - sends(campaign_id, subscriber_id, idempotency_key, provider_message_id, state, delivered_at, bounced_at, complained_at) - events(id, provider_event_id, kind, email_hash, at, raw_json) · webhook deliveries deduplicated by the provider's own event id ### Phase 1 · Consent recorded, suppression obeyed Build: Store a consent source, timestamp and address hash with every subscriber, and refuse an import row that has none. Import intersects the incoming addresses with the suppression table and reports how many were dropped and why. The send path joins against suppression inside the same transaction that claims the send row. Trap: Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. Done when: An import containing a previously unsubscribed address adds nothing for that address and reports it in the summary, tested. A send to a suppressed address is impossible through any code path, asserted by a test that tries all of them. Every subscriber has a consent source and timestamp, enforced by a constraint rather than a convention. Unsubscribing writes to suppression rather than only changing a list membership. Do not build yet: No feed polling, templates, sending or webhooks. ### Phase 2 · A feed becomes an issue, and the archive stays where it is Build: Poll with conditional requests. On first connection, record every existing item and set the watermark, sending none of them. Match new items on identifier, then canonical URL, then a content hash. Render into a template with a plain text alternative and the required footer. Trap: Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. Done when: Connecting a feed with a large archive creates zero campaigns, tested with a recorded feed. An item whose identifier changes but whose URL is unchanged does not create a second campaign, tested. Every campaign renders a plain text alternative alongside the HTML, asserted. An item edited after its campaign was sent does not resend, and the change is visible in the interface. Do not build yet: No sending, unsubscribe endpoint or webhooks yet. ### Phase 3 · One-click unsubscribe, built to the specification Build: Send both headers: a List-Unsubscribe carrying an HTTPS URI, and a List-Unsubscribe-Post whose value is the single pair specified for one-click. The endpoint accepts a POST with no cookies and no authorisation, suppresses immediately, and returns success without redirecting. Keep a visible unsubscribe link in the footer as well. Trap: Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. Done when: A POST to the one-click URI with no cookies and no authorisation suppresses the address and returns success without a redirect, tested with a bare HTTP client. Both headers appear on every marketing message and their values match the specification exactly, asserted against a sent message. The token in the URI identifies one subscriber and one list and cannot be guessed from another, tested. A visible unsubscribe link is present in the footer of every message. Do not build yet: No bounce or complaint processing yet. ### Phase 4 · Bounces and complaints, processed the same day Build: Receive the provider's webhooks, verify their signature, and deduplicate on the provider's own event identifier. A hard bounce suppresses immediately; soft bounces count and suppress after a stated run. A complaint suppresses and increments a rate you display against the published threshold. Block the next send when the rate is above your own lower line. Trap: Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. Done when: A replayed webhook is processed once, tested by delivering the same event identifier twice. A hard bounce suppresses the address before the next campaign, tested end to end. The rolling complaint rate is displayed beside the send action with the window it covers. A campaign is blocked when the rate exceeds the configured line, and the block names the rate and the threshold. Do not build yet: No authentication preflight or export yet. ### Phase 5 · Proving you are allowed to send Build: A preflight that resolves your sending domain's SPF and DKIM records and your DMARC policy and refuses the first send of the day if any is missing. Register with the provider postmaster tools and record where the complaint rate is read from. Export subscribers, suppressions, campaigns and sends as CSV, and support deleting a subscriber's data on request. Trap: A preflight that only checks your own configuration table passes while the DNS record is missing, because you wrote the configuration and nobody wrote the record. Resolve the actual DNS from the running application, and fail the send with the name of the record that is absent. Done when: The preflight resolves SPF, DKIM and DMARC from DNS and fails a send naming the missing record, tested by pointing at a domain without one. The export re-imports into an empty instance with suppressions intact, tested, because a list restored without its suppressions is a complaint incident. A deletion request removes the subscriber and leaves a suppression entry keyed by hash, so they are not silently re-subscribed later. The README states which sender guidelines were read and on what date. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server. Reputation takes months to build and one bad send to destroy, and a provider is renting you theirs for less than the time you would spend. - Paid subscriptions, recommendation networks and an audience marketplace, which are the business model rather than the publishing loop. - Behavioural automations and deep segmentation, which multiply every send path by a condition tree and make suppression harder to guarantee. - Open and click tracking by default. It requires rewriting every link and embedding a pixel, both of which carry their own consent questions; if you add it, make it opt-in and say so in the footer. ### README must contain - That the suppression table outranks every list and is checked at send time, not at list build time - Which sender guidelines were read, on what date, and what the complaint rate threshold was then - That one-click unsubscribe uses an HTTPS URI, accepts a POST with no cookies or authorisation, and never redirects - That connecting a feed sends nothing until the next new item, because an archive send is a complaint incident - That bounces and complaints are processed on arrival and the rolling rate blocks a send above your line ===== AGENTS.md ===== # Agent instructions · FeedBlitz indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript plus one worker for sending and webhooks, PostgreSQL, with suppression checked at send time in the same transaction, One reputable provider with a verified domain, never your own mail server, A one-click endpoint built to the specification, plus a visible link, Provider webhooks for bounces and complaints, deduplicated by event id. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. - Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. - Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. - Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. ===== BUILD_PLAN.md ===== # Build plan · FeedBlitz A feed-to-email publisher whose hard problems are never sending to someone who left, implementing one-click unsubscribe exactly as the specification requires, and processing bounces and complaints before they cost you the inbox. 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 recorded, suppression obeyed Every subscriber carries a recorded consent, and no send can reach a suppressed address. ### Steps 1. Make consent a column you cannot skip A constraint, not a convention, because imports are written in a hurry. Files: `prisma/schema.prisma`, `src/lib/import.ts` 2. Intersect every import with suppression And report the number dropped, so it is visible rather than silent. Files: `src/lib/import.ts`, `tests/import-suppression.test.ts` 3. Join suppression inside the send transaction Checked at send time, because the list is older than the decision. Files: `src/lib/send.ts`, `tests/no-send-to-suppressed.test.ts` ### Done when - [ ] An import cannot resurrect a suppressed address - [ ] No code path can send to a suppressed address - [ ] Consent source and time are required by the schema - [ ] Unsubscribe writes to suppression, not just to a list ### Watch out - Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. ## Phase 2 · A feed becomes an issue, and the archive stays where it is Turning on a feed sends nothing until the next new item appears. ### Steps 1. Watermark on first connection Record everything, send nothing. This is the single most expensive mistake here. Files: `src/lib/feeds.ts`, `tests/first-connect.test.ts` 2. Match down a ladder, not on one key Identifier, then canonical URL, then content hash. Files: `src/lib/dedupe.ts` 3. Render a plain text alternative every time Some clients only read it, and filters notice when it is missing. Files: `src/lib/render.ts` ### Done when - [ ] Connecting a feed sends nothing - [ ] A rewritten identifier creates no duplicate campaign - [ ] Every campaign has a plain text alternative - [ ] An edited item does not resend ### Watch out - Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. ## Phase 3 · One-click unsubscribe, built to the specification The mailbox provider's automated unsubscribe works without a page, a cookie or a click from the reader. ### Steps 1. Emit both headers with the exact specified values The value of the post header is one pair, and it is not free text. Files: `src/lib/headers.ts`, `tests/list-unsubscribe.test.ts` 2. Act on the POST, do not ask No cookie, no authorisation, no redirect, no confirmation page. Files: `src/app/u/[token]/route.ts`, `tests/one-click.test.ts` 3. Scope and randomise the token One subscriber, one list, unguessable from any other. Files: `src/lib/tokens.ts` ### Done when - [ ] A bare POST suppresses and returns success with no redirect - [ ] Both headers match the specification exactly - [ ] Tokens are scoped and unguessable - [ ] Every message carries a visible unsubscribe link ### Watch out - Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. ## Phase 4 · Bounces and complaints, processed the same day A hard bounce or a complaint suppresses the address before the next send, and the rate is visible. ### Steps 1. Verify the signature and deduplicate on event id Providers retry, and a double-processed bounce is only harmless by luck. Files: `src/app/api/webhooks/route.ts`, `tests/webhook-replay.test.ts` 2. Suppress hard bounces immediately, soft ones on a run The distinction is the provider's; store which one it told you. Files: `src/lib/feedback.ts` 3. Show the rolling complaint rate next to the send button The number matters most at the moment you are about to send. Files: `src/app/campaigns/[id]/page.tsx` ### Done when - [ ] A replayed webhook is processed once - [ ] A hard bounce suppresses before the next send - [ ] The complaint rate is shown with its window - [ ] An over-threshold rate blocks the send ### Watch out - Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. ## Not in this build - Your own mail server. - Paid subscriptions and an audience network. - Behavioural automations and deep segmentation. - Open and click tracking by default. ## After v1, if you want it - A double opt-in flow per list, once suppression and one-click have run clean for a month - Scheduled digests that batch several feed items into one issue, reusing the watermark - A seed list check before large sends, which is measurement rather than a new send path ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers, suppressions, campaigns and sends. DATABASE_URL=postgres://user:redacted@localhost:5432/news # Required · secret. Sending only. Rotate it if it reaches a log. ESP_API_KEY=re_...redacted # Required · secret. Verifies bounce and complaint callbacks before they are trusted. ESP_WEBHOOK_SECRET=redacted # Required. The preflight resolves SPF, DKIM and DMARC for this name. SENDING_DOMAIN=mail.example.com # Required. The one-click unsubscribe URI must be HTTPS on this host. PUBLIC_BASE_URL=https://mail.example.com # Required. Your own line, set below the published threshold, which blocks a send. COMPLAINT_RATE_BLOCK=0.0015 # Required. Consecutive soft bounces before the address is suppressed. SOFT_BOUNCE_SUPPRESS_AFTER=4 # Required. Required in commercial mail in most jurisdictions. Check yours. FOOTER_POSTAL_ADDRESS=A real postal address
You are building a production product version of FeedBlitz. 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 ===== # FeedBlitz · product brief ## Problem The visible email + RSS publishing loop is buildable, but a credible replacement needs more than the first screen. FeedBlitz earns its keep through deliverability, reputation, network, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A publisher that never mails someone who left, unsubscribes people the way the mailbox providers expect, notices complaints before they cost you the inbox, and hands you the whole list back whenever you ask. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - PostgreSQL - transactional or bulk email provider key in .env - verified sending domain ## Explicit non-goals for v1 - Your own mail server. - Paid subscriptions and an audience network. - Behavioural automations and deep segmentation. - Open and click tracking by default. - large-scale segmentation and automation - payments, recommendations, and audience network - resilient polling of malformed and rate-limited feeds - sender reputation and deliverability operations - bounce, complaint, and suppression handling ## Success criteria - No code path is confirmed able to send to a suppressed address - Connecting a feed is confirmed to send nothing - A bare one-click POST is confirmed to suppress without redirecting - A replayed webhook is confirmed processed once - Preflight is confirmed to fail naming a missing DNS record ===== BRIEF.md ===== # Build brief · FeedBlitz 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 turns a feed into email and treats the unsubscribe list as sacred to replace FeedBlitz. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both are about other people's inboxes rather than your features. The suppression list wins over everything. An address that unsubscribed, hard bounced or marked a message as spam is never sent to again by any campaign, any automation, any import or any re-engagement idea. The check runs at send time against the database, not when the recipient list was assembled, because the list was assembled before the person left. Second, the mailbox providers publish the rules and they are not suggestions. Google's sender guidelines require SPF and DKIM for every sender, add DMARC for anyone sending more than 5,000 messages a day to Gmail, require a TLS connection, require one-click unsubscribe on marketing mail, and require spam complaint rates to stay below 0.30 percent as reported in Postmaster Tools, with 0.10 percent named as the level to aim for (checked September 2026). Read the current text before your first send; these thresholds have moved before. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript plus one worker for sending and webhooks · sending from a request handler is how a campaign half happens - Storage: PostgreSQL, with suppression checked at send time in the same transaction · the list is stale the moment it is built - Sending: One reputable provider with a verified domain, never your own mail server · reputation is the product you are borrowing - Unsubscribe: A one-click endpoint built to the specification, plus a visible link · a confirmation page breaks the automated unsubscribe and earns a complaint instead - Feedback: Provider webhooks for bounces and complaints, deduplicated by event id · an unprocessed complaint is a complaint that still counts against you ### Data model (create this before Phase 1) - subscribers(id, email, email_hash, name, state, consent_source, consent_at, consent_ip_hash, created_at) - suppressions(email_hash, reason, at, source) · reason is unsubscribed, bounced, complained or manual, and this table outranks every list - lists(id, name, double_opt_in, from_name, from_email, reply_to, footer_postal_address) - feeds(id, url, list_id, etag, last_modified, watermark_item_id, watermark_at, template_id) - items(id, feed_id, guid, canonical_url, content_hash, title, body_html, published_at, first_seen_at) - campaigns(id, list_id, subject, body_html, source_item_id, state, scheduled_at, sent_at) - sends(campaign_id, subscriber_id, idempotency_key, provider_message_id, state, delivered_at, bounced_at, complained_at) - events(id, provider_event_id, kind, email_hash, at, raw_json) · webhook deliveries deduplicated by the provider's own event id ### Phase 1 · Consent recorded, suppression obeyed Build: Store a consent source, timestamp and address hash with every subscriber, and refuse an import row that has none. Import intersects the incoming addresses with the suppression table and reports how many were dropped and why. The send path joins against suppression inside the same transaction that claims the send row. Trap: Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. Done when: An import containing a previously unsubscribed address adds nothing for that address and reports it in the summary, tested. A send to a suppressed address is impossible through any code path, asserted by a test that tries all of them. Every subscriber has a consent source and timestamp, enforced by a constraint rather than a convention. Unsubscribing writes to suppression rather than only changing a list membership. Do not build yet: No feed polling, templates, sending or webhooks. ### Phase 2 · A feed becomes an issue, and the archive stays where it is Build: Poll with conditional requests. On first connection, record every existing item and set the watermark, sending none of them. Match new items on identifier, then canonical URL, then a content hash. Render into a template with a plain text alternative and the required footer. Trap: Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. Done when: Connecting a feed with a large archive creates zero campaigns, tested with a recorded feed. An item whose identifier changes but whose URL is unchanged does not create a second campaign, tested. Every campaign renders a plain text alternative alongside the HTML, asserted. An item edited after its campaign was sent does not resend, and the change is visible in the interface. Do not build yet: No sending, unsubscribe endpoint or webhooks yet. ### Phase 3 · One-click unsubscribe, built to the specification Build: Send both headers: a List-Unsubscribe carrying an HTTPS URI, and a List-Unsubscribe-Post whose value is the single pair specified for one-click. The endpoint accepts a POST with no cookies and no authorisation, suppresses immediately, and returns success without redirecting. Keep a visible unsubscribe link in the footer as well. Trap: Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. Done when: A POST to the one-click URI with no cookies and no authorisation suppresses the address and returns success without a redirect, tested with a bare HTTP client. Both headers appear on every marketing message and their values match the specification exactly, asserted against a sent message. The token in the URI identifies one subscriber and one list and cannot be guessed from another, tested. A visible unsubscribe link is present in the footer of every message. Do not build yet: No bounce or complaint processing yet. ### Phase 4 · Bounces and complaints, processed the same day Build: Receive the provider's webhooks, verify their signature, and deduplicate on the provider's own event identifier. A hard bounce suppresses immediately; soft bounces count and suppress after a stated run. A complaint suppresses and increments a rate you display against the published threshold. Block the next send when the rate is above your own lower line. Trap: Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. Done when: A replayed webhook is processed once, tested by delivering the same event identifier twice. A hard bounce suppresses the address before the next campaign, tested end to end. The rolling complaint rate is displayed beside the send action with the window it covers. A campaign is blocked when the rate exceeds the configured line, and the block names the rate and the threshold. Do not build yet: No authentication preflight or export yet. ### Phase 5 · Proving you are allowed to send Build: A preflight that resolves your sending domain's SPF and DKIM records and your DMARC policy and refuses the first send of the day if any is missing. Register with the provider postmaster tools and record where the complaint rate is read from. Export subscribers, suppressions, campaigns and sends as CSV, and support deleting a subscriber's data on request. Trap: A preflight that only checks your own configuration table passes while the DNS record is missing, because you wrote the configuration and nobody wrote the record. Resolve the actual DNS from the running application, and fail the send with the name of the record that is absent. Done when: The preflight resolves SPF, DKIM and DMARC from DNS and fails a send naming the missing record, tested by pointing at a domain without one. The export re-imports into an empty instance with suppressions intact, tested, because a list restored without its suppressions is a complaint incident. A deletion request removes the subscriber and leaves a suppression entry keyed by hash, so they are not silently re-subscribed later. The README states which sender guidelines were read and on what date. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server. Reputation takes months to build and one bad send to destroy, and a provider is renting you theirs for less than the time you would spend. - Paid subscriptions, recommendation networks and an audience marketplace, which are the business model rather than the publishing loop. - Behavioural automations and deep segmentation, which multiply every send path by a condition tree and make suppression harder to guarantee. - Open and click tracking by default. It requires rewriting every link and embedding a pixel, both of which carry their own consent questions; if you add it, make it opt-in and say so in the footer. ### README must contain - That the suppression table outranks every list and is checked at send time, not at list build time - Which sender guidelines were read, on what date, and what the complaint rate threshold was then - That one-click unsubscribe uses an HTTPS URI, accepts a POST with no cookies or authorisation, and never redirects - That connecting a feed sends nothing until the next new item, because an archive send is a complaint incident - That bounces and complaints are processed on arrival and the rolling rate blocks a send above your line ===== ARCHITECTURE.md ===== # Architecture · FeedBlitz ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript plus one worker for sending and webhooks | sending from a request handler is how a campaign half happens | | Storage | PostgreSQL, with suppression checked at send time in the same transaction | the list is stale the moment it is built | | Sending | One reputable provider with a verified domain, never your own mail server | reputation is the product you are borrowing | | Unsubscribe | A one-click endpoint built to the specification, plus a visible link | a confirmation page breaks the automated unsubscribe and earns a complaint instead | | Feedback | Provider webhooks for bounces and complaints, deduplicated by event id | an unprocessed complaint is a complaint that still counts against you | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Subscribers | Consent records and imports. | Any importer, provided it intersects with suppression and reports the drop. | | Suppression | The table that outranks every list. | Not swappable, and no override may exist. | | Feeds | Watermarks and item matching. | Any parser, provided first connection still sends nothing. | | Unsubscribe | The one-click endpoint and tokens. | Any implementation that acts on a bare POST without redirecting. | | Feedback | Signed webhooks, deduplication and rates. | Any provider, provided events are deduplicated by their own id. | ## 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, campaigns and sends. - `ESP_API_KEY` · required, secret · Sending only. Rotate it if it reaches a log. - `ESP_WEBHOOK_SECRET` · required, secret · Verifies bounce and complaint callbacks before they are trusted. - `SENDING_DOMAIN` · required · The preflight resolves SPF, DKIM and DMARC for this name. - `PUBLIC_BASE_URL` · required · The one-click unsubscribe URI must be HTTPS on this host. - `COMPLAINT_RATE_BLOCK` · required · Your own line, set below the published threshold, which blocks a send. - `SOFT_BOUNCE_SUPPRESS_AFTER` · required · Consecutive soft bounces before the address is suppressed. - `FOOTER_POSTAL_ADDRESS` · required · Required in commercial mail in most jurisdictions. Check yours. ## 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 · FeedBlitz product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript plus one worker for sending and webhooks, PostgreSQL, with suppression checked at send time in the same transaction, One reputable provider with a verified domain, never your own mail server, A one-click endpoint built to the specification, plus a visible link, Provider webhooks for bounces and complaints, deduplicated by event id. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. - Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. - Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. - Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. - A preflight that only checks your own configuration table passes while the DNS record is missing, because you wrote the configuration and nobody wrote the record. Resolve the actual DNS from the running application, and fail the send with the name of the record that is absent. ===== MILESTONES.md ===== # Delivery milestones · FeedBlitz Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Consent recorded, suppression obeyed Every subscriber carries a recorded consent, and no send can reach a suppressed address. ### Steps 1. Make consent a column you cannot skip A constraint, not a convention, because imports are written in a hurry. Files: `prisma/schema.prisma`, `src/lib/import.ts` 2. Intersect every import with suppression And report the number dropped, so it is visible rather than silent. Files: `src/lib/import.ts`, `tests/import-suppression.test.ts` 3. Join suppression inside the send transaction Checked at send time, because the list is older than the decision. Files: `src/lib/send.ts`, `tests/no-send-to-suppressed.test.ts` ### Done when - [ ] An import cannot resurrect a suppressed address - [ ] No code path can send to a suppressed address - [ ] Consent source and time are required by the schema - [ ] Unsubscribe writes to suppression, not just to a list ### Watch out - Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. ## M2 · A feed becomes an issue, and the archive stays where it is Turning on a feed sends nothing until the next new item appears. ### Steps 1. Watermark on first connection Record everything, send nothing. This is the single most expensive mistake here. Files: `src/lib/feeds.ts`, `tests/first-connect.test.ts` 2. Match down a ladder, not on one key Identifier, then canonical URL, then content hash. Files: `src/lib/dedupe.ts` 3. Render a plain text alternative every time Some clients only read it, and filters notice when it is missing. Files: `src/lib/render.ts` ### Done when - [ ] Connecting a feed sends nothing - [ ] A rewritten identifier creates no duplicate campaign - [ ] Every campaign has a plain text alternative - [ ] An edited item does not resend ### Watch out - Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. ## M3 · One-click unsubscribe, built to the specification The mailbox provider's automated unsubscribe works without a page, a cookie or a click from the reader. ### Steps 1. Emit both headers with the exact specified values The value of the post header is one pair, and it is not free text. Files: `src/lib/headers.ts`, `tests/list-unsubscribe.test.ts` 2. Act on the POST, do not ask No cookie, no authorisation, no redirect, no confirmation page. Files: `src/app/u/[token]/route.ts`, `tests/one-click.test.ts` 3. Scope and randomise the token One subscriber, one list, unguessable from any other. Files: `src/lib/tokens.ts` ### Done when - [ ] A bare POST suppresses and returns success with no redirect - [ ] Both headers match the specification exactly - [ ] Tokens are scoped and unguessable - [ ] Every message carries a visible unsubscribe link ### Watch out - Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. ## M4 · Bounces and complaints, processed the same day A hard bounce or a complaint suppresses the address before the next send, and the rate is visible. ### Steps 1. Verify the signature and deduplicate on event id Providers retry, and a double-processed bounce is only harmless by luck. Files: `src/app/api/webhooks/route.ts`, `tests/webhook-replay.test.ts` 2. Suppress hard bounces immediately, soft ones on a run The distinction is the provider's; store which one it told you. Files: `src/lib/feedback.ts` 3. Show the rolling complaint rate next to the send button The number matters most at the moment you are about to send. Files: `src/app/campaigns/[id]/page.tsx` ### Done when - [ ] A replayed webhook is processed once - [ ] A hard bounce suppresses before the next send - [ ] The complaint rate is shown with its window - [ ] An over-threshold rate blocks the send ### Watch out - Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. ## M5 · Proving you are allowed to send (production only) A preflight checks the things the mailbox providers actually check, and the list leaves whenever you want. ### Steps 1. Resolve the DNS records from the application Not from your notes. The record either exists or it does not. Files: `src/lib/preflight.ts`, `tests/preflight-dns.test.ts` 2. Export with suppressions, and test the re-import A list without its suppressions is worse than no list. Files: `src/lib/export.ts`, `tests/roundtrip.test.ts` 3. Delete the person, keep the hash Erasure must not turn into accidental re-subscription. Files: `src/lib/erase.ts` ### Done when - [ ] Preflight fails naming a missing DNS record - [ ] The export re-imports with suppressions intact - [ ] Deletion leaves a hashed suppression behind - [ ] The guidelines read and the date are in the README ### Watch out - A preflight that only checks your own configuration table passes while the DNS record is missing, because you wrote the configuration and nobody wrote the record. Resolve the actual DNS from the running application, and fail the send with the name of the record that is absent. ===== OPERATIONS.md ===== # Operations · FeedBlitz ## Backup The database, with the suppression table treated as the critical part. Restoring subscribers without suppressions re-subscribes everyone who left, which is a complaint incident rather than an inconvenience, so verify the suppression row count after every restore. ## Restore Restore, confirm the suppression count matches the backup, run the preflight, and send one campaign to a test list before anything real. A restore that silently loses suppressions is the worst failure this system has. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Rolling complaint rate against the published threshold, hard bounce rate, unprocessed webhook events, campaigns blocked by the rate check, and the age of the last DNS preflight. The unprocessed event count is the quiet one. ## Incident checklist A sudden complaint spike usually traces to an import or a feed backfill, and the campaign and import tables will say which. Sudden non-delivery usually traces to a DNS record that changed, which the preflight names. Neither is diagnosed by re-sending. 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 - [ ] No code path is confirmed able to send to a suppressed address - [ ] Connecting a feed is confirmed to send nothing - [ ] A bare one-click POST is confirmed to suppress without redirecting - [ ] A replayed webhook is confirmed processed once - [ ] Preflight is confirmed to fail naming a missing DNS record ## Launch constraint Do not market omitted FeedBlitz 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, campaigns and sends. DATABASE_URL=postgres://user:redacted@localhost:5432/news # Required · secret. Sending only. Rotate it if it reaches a log. ESP_API_KEY=re_...redacted # Required · secret. Verifies bounce and complaint callbacks before they are trusted. ESP_WEBHOOK_SECRET=redacted # Required. The preflight resolves SPF, DKIM and DMARC for this name. SENDING_DOMAIN=mail.example.com # Required. The one-click unsubscribe URI must be HTTPS on this host. PUBLIC_BASE_URL=https://mail.example.com # Required. Your own line, set below the published threshold, which blocks a send. COMPLAINT_RATE_BLOCK=0.0015 # Required. Consecutive soft bounces before the address is suppressed. SOFT_BOUNCE_SUPPRESS_AFTER=4 # Required. Required in commercial mail in most jurisdictions. Check yours. FOOTER_POSTAL_ADDRESS=A real postal address
# FeedBlitz · indie build A feed-to-email publisher whose hard problems are never sending to someone who left, implementing one-click unsubscribe exactly as the specification requires, and processing bounces and complaints before they cost you the inbox. 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 | Next.js 15 with TypeScript plus one worker for sending and webhooks | sending from a request handler is how a campaign half happens | | Storage | PostgreSQL, with suppression checked at send time in the same transaction | the list is stale the moment it is built | | Sending | One reputable provider with a verified domain, never your own mail server | reputation is the product you are borrowing | | Unsubscribe | A one-click endpoint built to the specification, plus a visible link | a confirmation page breaks the automated unsubscribe and earns a complaint instead | | Feedback | Provider webhooks for bounces and complaints, deduplicated by event id | an unprocessed complaint is a complaint that still counts against you | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application, the poller and the send worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally, a few pounds a month managed - Why: Suppression is joined inside the send transaction, and that is a job for a real database. - Get it: Your package manager, a container, or any managed instance. - Verify: psql connects and a migration applies - [ ] **A sending domain you control the DNS for** · the cost of a domain - Why: SPF, DKIM and DMARC records are the entry requirement, and you cannot add them to a domain someone else owns. - Get it: A domain with DNS you can edit, ideally a subdomain used only for this mail. - Verify: The preflight resolves all three records from the running application - [ ] **An email provider account with webhooks** · free tier at small volume on most providers - Why: You are borrowing their reputation and their bounce and complaint reporting, which is the part you cannot build. - Get it: Create an account, verify the domain, issue a sending key, and enable bounce and complaint webhooks. - Verify: A test send arrives, and a forced bounce produces a webhook you receive - [ ] **The current bulk sender guidelines** · free - Why: The thresholds are copied from the published text rather than chosen, and they have changed before. - Get it: Read the current sender guidelines for the mailbox providers your list actually uses, and note the date. - Verify: A note records the thresholds used and the date read - [ ] **Postmaster or reputation tooling for your domain** · free - Why: The complaint rate you must stay under is measured by the mailbox provider, not by you, and the only honest number comes from their tools. - Get it: Register the sending domain with the provider's postmaster tools. - Verify: The dashboard shows data for your domain after the first send ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Your own mail server. - Paid subscriptions and an audience network. - Behavioural automations and deep segmentation. - Open and click tracking by default. - large-scale segmentation and automation - payments, recommendations, and audience network - resilient polling of malformed and rate-limited feeds - 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 FeedBlitz, and the README should say so rather than pretend.
# Build brief · FeedBlitz 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 turns a feed into email and treats the unsubscribe list as sacred to replace FeedBlitz. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both are about other people's inboxes rather than your features. The suppression list wins over everything. An address that unsubscribed, hard bounced or marked a message as spam is never sent to again by any campaign, any automation, any import or any re-engagement idea. The check runs at send time against the database, not when the recipient list was assembled, because the list was assembled before the person left. Second, the mailbox providers publish the rules and they are not suggestions. Google's sender guidelines require SPF and DKIM for every sender, add DMARC for anyone sending more than 5,000 messages a day to Gmail, require a TLS connection, require one-click unsubscribe on marketing mail, and require spam complaint rates to stay below 0.30 percent as reported in Postmaster Tools, with 0.10 percent named as the level to aim for (checked September 2026). Read the current text before your first send; these thresholds have moved before. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript plus one worker for sending and webhooks · sending from a request handler is how a campaign half happens - Storage: PostgreSQL, with suppression checked at send time in the same transaction · the list is stale the moment it is built - Sending: One reputable provider with a verified domain, never your own mail server · reputation is the product you are borrowing - Unsubscribe: A one-click endpoint built to the specification, plus a visible link · a confirmation page breaks the automated unsubscribe and earns a complaint instead - Feedback: Provider webhooks for bounces and complaints, deduplicated by event id · an unprocessed complaint is a complaint that still counts against you ### Data model (create this before Phase 1) - subscribers(id, email, email_hash, name, state, consent_source, consent_at, consent_ip_hash, created_at) - suppressions(email_hash, reason, at, source) · reason is unsubscribed, bounced, complained or manual, and this table outranks every list - lists(id, name, double_opt_in, from_name, from_email, reply_to, footer_postal_address) - feeds(id, url, list_id, etag, last_modified, watermark_item_id, watermark_at, template_id) - items(id, feed_id, guid, canonical_url, content_hash, title, body_html, published_at, first_seen_at) - campaigns(id, list_id, subject, body_html, source_item_id, state, scheduled_at, sent_at) - sends(campaign_id, subscriber_id, idempotency_key, provider_message_id, state, delivered_at, bounced_at, complained_at) - events(id, provider_event_id, kind, email_hash, at, raw_json) · webhook deliveries deduplicated by the provider's own event id ### Phase 1 · Consent recorded, suppression obeyed Build: Store a consent source, timestamp and address hash with every subscriber, and refuse an import row that has none. Import intersects the incoming addresses with the suppression table and reports how many were dropped and why. The send path joins against suppression inside the same transaction that claims the send row. Trap: Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. Done when: An import containing a previously unsubscribed address adds nothing for that address and reports it in the summary, tested. A send to a suppressed address is impossible through any code path, asserted by a test that tries all of them. Every subscriber has a consent source and timestamp, enforced by a constraint rather than a convention. Unsubscribing writes to suppression rather than only changing a list membership. Do not build yet: No feed polling, templates, sending or webhooks. ### Phase 2 · A feed becomes an issue, and the archive stays where it is Build: Poll with conditional requests. On first connection, record every existing item and set the watermark, sending none of them. Match new items on identifier, then canonical URL, then a content hash. Render into a template with a plain text alternative and the required footer. Trap: Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. Done when: Connecting a feed with a large archive creates zero campaigns, tested with a recorded feed. An item whose identifier changes but whose URL is unchanged does not create a second campaign, tested. Every campaign renders a plain text alternative alongside the HTML, asserted. An item edited after its campaign was sent does not resend, and the change is visible in the interface. Do not build yet: No sending, unsubscribe endpoint or webhooks yet. ### Phase 3 · One-click unsubscribe, built to the specification Build: Send both headers: a List-Unsubscribe carrying an HTTPS URI, and a List-Unsubscribe-Post whose value is the single pair specified for one-click. The endpoint accepts a POST with no cookies and no authorisation, suppresses immediately, and returns success without redirecting. Keep a visible unsubscribe link in the footer as well. Trap: Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. Done when: A POST to the one-click URI with no cookies and no authorisation suppresses the address and returns success without a redirect, tested with a bare HTTP client. Both headers appear on every marketing message and their values match the specification exactly, asserted against a sent message. The token in the URI identifies one subscriber and one list and cannot be guessed from another, tested. A visible unsubscribe link is present in the footer of every message. Do not build yet: No bounce or complaint processing yet. ### Phase 4 · Bounces and complaints, processed the same day Build: Receive the provider's webhooks, verify their signature, and deduplicate on the provider's own event identifier. A hard bounce suppresses immediately; soft bounces count and suppress after a stated run. A complaint suppresses and increments a rate you display against the published threshold. Block the next send when the rate is above your own lower line. Trap: Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. Done when: A replayed webhook is processed once, tested by delivering the same event identifier twice. A hard bounce suppresses the address before the next campaign, tested end to end. The rolling complaint rate is displayed beside the send action with the window it covers. A campaign is blocked when the rate exceeds the configured line, and the block names the rate and the threshold. Do not build yet: No authentication preflight or export yet. ### Phase 5 · Proving you are allowed to send Build: A preflight that resolves your sending domain's SPF and DKIM records and your DMARC policy and refuses the first send of the day if any is missing. Register with the provider postmaster tools and record where the complaint rate is read from. Export subscribers, suppressions, campaigns and sends as CSV, and support deleting a subscriber's data on request. Trap: A preflight that only checks your own configuration table passes while the DNS record is missing, because you wrote the configuration and nobody wrote the record. Resolve the actual DNS from the running application, and fail the send with the name of the record that is absent. Done when: The preflight resolves SPF, DKIM and DMARC from DNS and fails a send naming the missing record, tested by pointing at a domain without one. The export re-imports into an empty instance with suppressions intact, tested, because a list restored without its suppressions is a complaint incident. A deletion request removes the subscriber and leaves a suppression entry keyed by hash, so they are not silently re-subscribed later. The README states which sender guidelines were read and on what date. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server. Reputation takes months to build and one bad send to destroy, and a provider is renting you theirs for less than the time you would spend. - Paid subscriptions, recommendation networks and an audience marketplace, which are the business model rather than the publishing loop. - Behavioural automations and deep segmentation, which multiply every send path by a condition tree and make suppression harder to guarantee. - Open and click tracking by default. It requires rewriting every link and embedding a pixel, both of which carry their own consent questions; if you add it, make it opt-in and say so in the footer. ### README must contain - That the suppression table outranks every list and is checked at send time, not at list build time - Which sender guidelines were read, on what date, and what the complaint rate threshold was then - That one-click unsubscribe uses an HTTPS URI, accepts a POST with no cookies or authorisation, and never redirects - That connecting a feed sends nothing until the next new item, because an archive send is a complaint incident - That bounces and complaints are processed on arrival and the rolling rate blocks a send above your line
# Agent instructions · FeedBlitz indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript plus one worker for sending and webhooks, PostgreSQL, with suppression checked at send time in the same transaction, One reputable provider with a verified domain, never your own mail server, A one-click endpoint built to the specification, plus a visible link, Provider webhooks for bounces and complaints, deduplicated by event id. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. - Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. - Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. - Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button.
# Build plan · FeedBlitz A feed-to-email publisher whose hard problems are never sending to someone who left, implementing one-click unsubscribe exactly as the specification requires, and processing bounces and complaints before they cost you the inbox. 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 recorded, suppression obeyed Every subscriber carries a recorded consent, and no send can reach a suppressed address. ### Steps 1. Make consent a column you cannot skip A constraint, not a convention, because imports are written in a hurry. Files: `prisma/schema.prisma`, `src/lib/import.ts` 2. Intersect every import with suppression And report the number dropped, so it is visible rather than silent. Files: `src/lib/import.ts`, `tests/import-suppression.test.ts` 3. Join suppression inside the send transaction Checked at send time, because the list is older than the decision. Files: `src/lib/send.ts`, `tests/no-send-to-suppressed.test.ts` ### Done when - [ ] An import cannot resurrect a suppressed address - [ ] No code path can send to a suppressed address - [ ] Consent source and time are required by the schema - [ ] Unsubscribe writes to suppression, not just to a list ### Watch out - Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. ## Phase 2 · A feed becomes an issue, and the archive stays where it is Turning on a feed sends nothing until the next new item appears. ### Steps 1. Watermark on first connection Record everything, send nothing. This is the single most expensive mistake here. Files: `src/lib/feeds.ts`, `tests/first-connect.test.ts` 2. Match down a ladder, not on one key Identifier, then canonical URL, then content hash. Files: `src/lib/dedupe.ts` 3. Render a plain text alternative every time Some clients only read it, and filters notice when it is missing. Files: `src/lib/render.ts` ### Done when - [ ] Connecting a feed sends nothing - [ ] A rewritten identifier creates no duplicate campaign - [ ] Every campaign has a plain text alternative - [ ] An edited item does not resend ### Watch out - Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. ## Phase 3 · One-click unsubscribe, built to the specification The mailbox provider's automated unsubscribe works without a page, a cookie or a click from the reader. ### Steps 1. Emit both headers with the exact specified values The value of the post header is one pair, and it is not free text. Files: `src/lib/headers.ts`, `tests/list-unsubscribe.test.ts` 2. Act on the POST, do not ask No cookie, no authorisation, no redirect, no confirmation page. Files: `src/app/u/[token]/route.ts`, `tests/one-click.test.ts` 3. Scope and randomise the token One subscriber, one list, unguessable from any other. Files: `src/lib/tokens.ts` ### Done when - [ ] A bare POST suppresses and returns success with no redirect - [ ] Both headers match the specification exactly - [ ] Tokens are scoped and unguessable - [ ] Every message carries a visible unsubscribe link ### Watch out - Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. ## Phase 4 · Bounces and complaints, processed the same day A hard bounce or a complaint suppresses the address before the next send, and the rate is visible. ### Steps 1. Verify the signature and deduplicate on event id Providers retry, and a double-processed bounce is only harmless by luck. Files: `src/app/api/webhooks/route.ts`, `tests/webhook-replay.test.ts` 2. Suppress hard bounces immediately, soft ones on a run The distinction is the provider's; store which one it told you. Files: `src/lib/feedback.ts` 3. Show the rolling complaint rate next to the send button The number matters most at the moment you are about to send. Files: `src/app/campaigns/[id]/page.tsx` ### Done when - [ ] A replayed webhook is processed once - [ ] A hard bounce suppresses before the next send - [ ] The complaint rate is shown with its window - [ ] An over-threshold rate blocks the send ### Watch out - Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. ## Not in this build - Your own mail server. - Paid subscriptions and an audience network. - Behavioural automations and deep segmentation. - Open and click tracking by default. ## After v1, if you want it - A double opt-in flow per list, once suppression and one-click have run clean for a month - Scheduled digests that batch several feed items into one issue, reusing the watermark - A seed list check before large sends, which is measurement rather than a new send path
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Subscribers, suppressions, campaigns and sends. DATABASE_URL=postgres://user:redacted@localhost:5432/news # Required · secret. Sending only. Rotate it if it reaches a log. ESP_API_KEY=re_...redacted # Required · secret. Verifies bounce and complaint callbacks before they are trusted. ESP_WEBHOOK_SECRET=redacted # Required. The preflight resolves SPF, DKIM and DMARC for this name. SENDING_DOMAIN=mail.example.com # Required. The one-click unsubscribe URI must be HTTPS on this host. PUBLIC_BASE_URL=https://mail.example.com # Required. Your own line, set below the published threshold, which blocks a send. COMPLAINT_RATE_BLOCK=0.0015 # Required. Consecutive soft bounces before the address is suppressed. SOFT_BOUNCE_SUPPRESS_AFTER=4 # Required. Required in commercial mail in most jurisdictions. Check yours. FOOTER_POSTAL_ADDRESS=A real postal address
# FeedBlitz · product brief ## Problem The visible email + RSS publishing loop is buildable, but a credible replacement needs more than the first screen. FeedBlitz earns its keep through deliverability, reputation, network, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A publisher that never mails someone who left, unsubscribes people the way the mailbox providers expect, notices complaints before they cost you the inbox, and hands you the whole list back whenever you ask. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - PostgreSQL - transactional or bulk email provider key in .env - verified sending domain ## Explicit non-goals for v1 - Your own mail server. - Paid subscriptions and an audience network. - Behavioural automations and deep segmentation. - Open and click tracking by default. - large-scale segmentation and automation - payments, recommendations, and audience network - resilient polling of malformed and rate-limited feeds - sender reputation and deliverability operations - bounce, complaint, and suppression handling ## Success criteria - No code path is confirmed able to send to a suppressed address - Connecting a feed is confirmed to send nothing - A bare one-click POST is confirmed to suppress without redirecting - A replayed webhook is confirmed processed once - Preflight is confirmed to fail naming a missing DNS record
# Build brief · FeedBlitz 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 turns a feed into email and treats the unsubscribe list as sacred to replace FeedBlitz. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both are about other people's inboxes rather than your features. The suppression list wins over everything. An address that unsubscribed, hard bounced or marked a message as spam is never sent to again by any campaign, any automation, any import or any re-engagement idea. The check runs at send time against the database, not when the recipient list was assembled, because the list was assembled before the person left. Second, the mailbox providers publish the rules and they are not suggestions. Google's sender guidelines require SPF and DKIM for every sender, add DMARC for anyone sending more than 5,000 messages a day to Gmail, require a TLS connection, require one-click unsubscribe on marketing mail, and require spam complaint rates to stay below 0.30 percent as reported in Postmaster Tools, with 0.10 percent named as the level to aim for (checked September 2026). Read the current text before your first send; these thresholds have moved before. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript plus one worker for sending and webhooks · sending from a request handler is how a campaign half happens - Storage: PostgreSQL, with suppression checked at send time in the same transaction · the list is stale the moment it is built - Sending: One reputable provider with a verified domain, never your own mail server · reputation is the product you are borrowing - Unsubscribe: A one-click endpoint built to the specification, plus a visible link · a confirmation page breaks the automated unsubscribe and earns a complaint instead - Feedback: Provider webhooks for bounces and complaints, deduplicated by event id · an unprocessed complaint is a complaint that still counts against you ### Data model (create this before Phase 1) - subscribers(id, email, email_hash, name, state, consent_source, consent_at, consent_ip_hash, created_at) - suppressions(email_hash, reason, at, source) · reason is unsubscribed, bounced, complained or manual, and this table outranks every list - lists(id, name, double_opt_in, from_name, from_email, reply_to, footer_postal_address) - feeds(id, url, list_id, etag, last_modified, watermark_item_id, watermark_at, template_id) - items(id, feed_id, guid, canonical_url, content_hash, title, body_html, published_at, first_seen_at) - campaigns(id, list_id, subject, body_html, source_item_id, state, scheduled_at, sent_at) - sends(campaign_id, subscriber_id, idempotency_key, provider_message_id, state, delivered_at, bounced_at, complained_at) - events(id, provider_event_id, kind, email_hash, at, raw_json) · webhook deliveries deduplicated by the provider's own event id ### Phase 1 · Consent recorded, suppression obeyed Build: Store a consent source, timestamp and address hash with every subscriber, and refuse an import row that has none. Import intersects the incoming addresses with the suppression table and reports how many were dropped and why. The send path joins against suppression inside the same transaction that claims the send row. Trap: Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. Done when: An import containing a previously unsubscribed address adds nothing for that address and reports it in the summary, tested. A send to a suppressed address is impossible through any code path, asserted by a test that tries all of them. Every subscriber has a consent source and timestamp, enforced by a constraint rather than a convention. Unsubscribing writes to suppression rather than only changing a list membership. Do not build yet: No feed polling, templates, sending or webhooks. ### Phase 2 · A feed becomes an issue, and the archive stays where it is Build: Poll with conditional requests. On first connection, record every existing item and set the watermark, sending none of them. Match new items on identifier, then canonical URL, then a content hash. Render into a template with a plain text alternative and the required footer. Trap: Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. Done when: Connecting a feed with a large archive creates zero campaigns, tested with a recorded feed. An item whose identifier changes but whose URL is unchanged does not create a second campaign, tested. Every campaign renders a plain text alternative alongside the HTML, asserted. An item edited after its campaign was sent does not resend, and the change is visible in the interface. Do not build yet: No sending, unsubscribe endpoint or webhooks yet. ### Phase 3 · One-click unsubscribe, built to the specification Build: Send both headers: a List-Unsubscribe carrying an HTTPS URI, and a List-Unsubscribe-Post whose value is the single pair specified for one-click. The endpoint accepts a POST with no cookies and no authorisation, suppresses immediately, and returns success without redirecting. Keep a visible unsubscribe link in the footer as well. Trap: Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. Done when: A POST to the one-click URI with no cookies and no authorisation suppresses the address and returns success without a redirect, tested with a bare HTTP client. Both headers appear on every marketing message and their values match the specification exactly, asserted against a sent message. The token in the URI identifies one subscriber and one list and cannot be guessed from another, tested. A visible unsubscribe link is present in the footer of every message. Do not build yet: No bounce or complaint processing yet. ### Phase 4 · Bounces and complaints, processed the same day Build: Receive the provider's webhooks, verify their signature, and deduplicate on the provider's own event identifier. A hard bounce suppresses immediately; soft bounces count and suppress after a stated run. A complaint suppresses and increments a rate you display against the published threshold. Block the next send when the rate is above your own lower line. Trap: Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. Done when: A replayed webhook is processed once, tested by delivering the same event identifier twice. A hard bounce suppresses the address before the next campaign, tested end to end. The rolling complaint rate is displayed beside the send action with the window it covers. A campaign is blocked when the rate exceeds the configured line, and the block names the rate and the threshold. Do not build yet: No authentication preflight or export yet. ### Phase 5 · Proving you are allowed to send Build: A preflight that resolves your sending domain's SPF and DKIM records and your DMARC policy and refuses the first send of the day if any is missing. Register with the provider postmaster tools and record where the complaint rate is read from. Export subscribers, suppressions, campaigns and sends as CSV, and support deleting a subscriber's data on request. Trap: A preflight that only checks your own configuration table passes while the DNS record is missing, because you wrote the configuration and nobody wrote the record. Resolve the actual DNS from the running application, and fail the send with the name of the record that is absent. Done when: The preflight resolves SPF, DKIM and DMARC from DNS and fails a send naming the missing record, tested by pointing at a domain without one. The export re-imports into an empty instance with suppressions intact, tested, because a list restored without its suppressions is a complaint incident. A deletion request removes the subscriber and leaves a suppression entry keyed by hash, so they are not silently re-subscribed later. The README states which sender guidelines were read and on what date. Do not build yet: Nothing after this. ### Out of scope (and why) - Running your own mail server. Reputation takes months to build and one bad send to destroy, and a provider is renting you theirs for less than the time you would spend. - Paid subscriptions, recommendation networks and an audience marketplace, which are the business model rather than the publishing loop. - Behavioural automations and deep segmentation, which multiply every send path by a condition tree and make suppression harder to guarantee. - Open and click tracking by default. It requires rewriting every link and embedding a pixel, both of which carry their own consent questions; if you add it, make it opt-in and say so in the footer. ### README must contain - That the suppression table outranks every list and is checked at send time, not at list build time - Which sender guidelines were read, on what date, and what the complaint rate threshold was then - That one-click unsubscribe uses an HTTPS URI, accepts a POST with no cookies or authorisation, and never redirects - That connecting a feed sends nothing until the next new item, because an archive send is a complaint incident - That bounces and complaints are processed on arrival and the rolling rate blocks a send above your line
# Architecture · FeedBlitz ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript plus one worker for sending and webhooks | sending from a request handler is how a campaign half happens | | Storage | PostgreSQL, with suppression checked at send time in the same transaction | the list is stale the moment it is built | | Sending | One reputable provider with a verified domain, never your own mail server | reputation is the product you are borrowing | | Unsubscribe | A one-click endpoint built to the specification, plus a visible link | a confirmation page breaks the automated unsubscribe and earns a complaint instead | | Feedback | Provider webhooks for bounces and complaints, deduplicated by event id | an unprocessed complaint is a complaint that still counts against you | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Subscribers | Consent records and imports. | Any importer, provided it intersects with suppression and reports the drop. | | Suppression | The table that outranks every list. | Not swappable, and no override may exist. | | Feeds | Watermarks and item matching. | Any parser, provided first connection still sends nothing. | | Unsubscribe | The one-click endpoint and tokens. | Any implementation that acts on a bare POST without redirecting. | | Feedback | Signed webhooks, deduplication and rates. | Any provider, provided events are deduplicated by their own id. | ## 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, campaigns and sends. - `ESP_API_KEY` · required, secret · Sending only. Rotate it if it reaches a log. - `ESP_WEBHOOK_SECRET` · required, secret · Verifies bounce and complaint callbacks before they are trusted. - `SENDING_DOMAIN` · required · The preflight resolves SPF, DKIM and DMARC for this name. - `PUBLIC_BASE_URL` · required · The one-click unsubscribe URI must be HTTPS on this host. - `COMPLAINT_RATE_BLOCK` · required · Your own line, set below the published threshold, which blocks a send. - `SOFT_BOUNCE_SUPPRESS_AFTER` · required · Consecutive soft bounces before the address is suppressed. - `FOOTER_POSTAL_ADDRESS` · required · Required in commercial mail in most jurisdictions. Check yours. ## 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 · FeedBlitz product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript plus one worker for sending and webhooks, PostgreSQL, with suppression checked at send time in the same transaction, One reputable provider with a verified domain, never your own mail server, A one-click endpoint built to the specification, plus a visible link, Provider webhooks for bounces and complaints, deduplicated by event id. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. - Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. - Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. - Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. - A preflight that only checks your own configuration table passes while the DNS record is missing, because you wrote the configuration and nobody wrote the record. Resolve the actual DNS from the running application, and fail the send with the name of the record that is absent.
# Delivery milestones · FeedBlitz Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Consent recorded, suppression obeyed Every subscriber carries a recorded consent, and no send can reach a suppressed address. ### Steps 1. Make consent a column you cannot skip A constraint, not a convention, because imports are written in a hurry. Files: `prisma/schema.prisma`, `src/lib/import.ts` 2. Intersect every import with suppression And report the number dropped, so it is visible rather than silent. Files: `src/lib/import.ts`, `tests/import-suppression.test.ts` 3. Join suppression inside the send transaction Checked at send time, because the list is older than the decision. Files: `src/lib/send.ts`, `tests/no-send-to-suppressed.test.ts` ### Done when - [ ] An import cannot resurrect a suppressed address - [ ] No code path can send to a suppressed address - [ ] Consent source and time are required by the schema - [ ] Unsubscribe writes to suppression, not just to a list ### Watch out - Importing a CSV and adding every row launders the suppression list: the addresses that unsubscribed six months ago come straight back in, and the complaint that follows is the one that actually costs you. Intersect every import with suppression, report the count dropped, and never provide a way to override it. ## M2 · A feed becomes an issue, and the archive stays where it is Turning on a feed sends nothing until the next new item appears. ### Steps 1. Watermark on first connection Record everything, send nothing. This is the single most expensive mistake here. Files: `src/lib/feeds.ts`, `tests/first-connect.test.ts` 2. Match down a ladder, not on one key Identifier, then canonical URL, then content hash. Files: `src/lib/dedupe.ts` 3. Render a plain text alternative every time Some clients only read it, and filters notice when it is missing. Files: `src/lib/render.ts` ### Done when - [ ] Connecting a feed sends nothing - [ ] A rewritten identifier creates no duplicate campaign - [ ] Every campaign has a plain text alternative - [ ] An edited item does not resend ### Watch out - Connecting a feed and sending everything it returns mails four years of archive to a list in one evening, and the complaint rate from that single send is the thing that follows your domain around for months. Watermark on first connection, and make the first campaign a preview you approve rather than an automatic send. ## M3 · One-click unsubscribe, built to the specification The mailbox provider's automated unsubscribe works without a page, a cookie or a click from the reader. ### Steps 1. Emit both headers with the exact specified values The value of the post header is one pair, and it is not free text. Files: `src/lib/headers.ts`, `tests/list-unsubscribe.test.ts` 2. Act on the POST, do not ask No cookie, no authorisation, no redirect, no confirmation page. Files: `src/app/u/[token]/route.ts`, `tests/one-click.test.ts` 3. Scope and randomise the token One subscriber, one list, unguessable from any other. Files: `src/lib/tokens.ts` ### Done when - [ ] A bare POST suppresses and returns success with no redirect - [ ] Both headers match the specification exactly - [ ] Tokens are scoped and unguessable - [ ] Every message carries a visible unsubscribe link ### Watch out - Pointing the one-click header at a page with a 'yes, unsubscribe me' button means the provider's automated POST hits a page that does nothing, the reader believes they unsubscribed, and their next move is the spam button, which costs far more than the unsubscribe would have. RFC 8058 is explicit: the URI must be HTTPS, the POST carries no cookies or authorisation, and the sender must not answer with a redirect because redirected POSTs have historically not worked (checked September 2026). Make the endpoint act, not ask. ## M4 · Bounces and complaints, processed the same day A hard bounce or a complaint suppresses the address before the next send, and the rate is visible. ### Steps 1. Verify the signature and deduplicate on event id Providers retry, and a double-processed bounce is only harmless by luck. Files: `src/app/api/webhooks/route.ts`, `tests/webhook-replay.test.ts` 2. Suppress hard bounces immediately, soft ones on a run The distinction is the provider's; store which one it told you. Files: `src/lib/feedback.ts` 3. Show the rolling complaint rate next to the send button The number matters most at the moment you are about to send. Files: `src/app/campaigns/[id]/page.tsx` ### Done when - [ ] A replayed webhook is processed once - [ ] A hard bounce suppresses before the next send - [ ] The complaint rate is shown with its window - [ ] An over-threshold rate blocks the send ### Watch out - Setting up the webhook and never processing the events is the default outcome, because nothing visibly breaks: the sends keep succeeding, the complaint rate climbs past the published threshold, and by the time your open rate collapses the domain reputation has already moved and takes weeks to recover. Process on arrival, suppress on the spot, and put the rolling rate on the same screen as the send button. ## M5 · Proving you are allowed to send (production only) A preflight checks the things the mailbox providers actually check, and the list leaves whenever you want. ### Steps 1. Resolve the DNS records from the application Not from your notes. The record either exists or it does not. Files: `src/lib/preflight.ts`, `tests/preflight-dns.test.ts` 2. Export with suppressions, and test the re-import A list without its suppressions is worse than no list. Files: `src/lib/export.ts`, `tests/roundtrip.test.ts` 3. Delete the person, keep the hash Erasure must not turn into accidental re-subscription. Files: `src/lib/erase.ts` ### Done when - [ ] Preflight fails naming a missing DNS record - [ ] The export re-imports with suppressions intact - [ ] Deletion leaves a hashed suppression behind - [ ] The guidelines read and the date are in the README ### Watch out - A preflight that only checks your own configuration table passes while the DNS record is missing, because you wrote the configuration and nobody wrote the record. Resolve the actual DNS from the running application, and fail the send with the name of the record that is absent.
# Operations · FeedBlitz ## Backup The database, with the suppression table treated as the critical part. Restoring subscribers without suppressions re-subscribes everyone who left, which is a complaint incident rather than an inconvenience, so verify the suppression row count after every restore. ## Restore Restore, confirm the suppression count matches the backup, run the preflight, and send one campaign to a test list before anything real. A restore that silently loses suppressions is the worst failure this system has. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Rolling complaint rate against the published threshold, hard bounce rate, unprocessed webhook events, campaigns blocked by the rate check, and the age of the last DNS preflight. The unprocessed event count is the quiet one. ## Incident checklist A sudden complaint spike usually traces to an import or a feed backfill, and the campaign and import tables will say which. Sudden non-delivery usually traces to a DNS record that changed, which the preflight names. Neither is diagnosed by re-sending. 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 - [ ] No code path is confirmed able to send to a suppressed address - [ ] Connecting a feed is confirmed to send nothing - [ ] A bare one-click POST is confirmed to suppress without redirecting - [ ] A replayed webhook is confirmed processed once - [ ] Preflight is confirmed to fail naming a missing DNS record ## Launch constraint Do not market omitted FeedBlitz 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, campaigns and sends. DATABASE_URL=postgres://user:redacted@localhost:5432/news # Required · secret. Sending only. Rotate it if it reaches a log. ESP_API_KEY=re_...redacted # Required · secret. Verifies bounce and complaint callbacks before they are trusted. ESP_WEBHOOK_SECRET=redacted # Required. The preflight resolves SPF, DKIM and DMARC for this name. SENDING_DOMAIN=mail.example.com # Required. The one-click unsubscribe URI must be HTTPS on this host. PUBLIC_BASE_URL=https://mail.example.com # Required. Your own line, set below the published threshold, which blocks a send. COMPLAINT_RATE_BLOCK=0.0015 # Required. Consecutive soft bounces before the address is suppressed. SOFT_BOUNCE_SUPPRESS_AFTER=4 # Required. Required in commercial mail in most jurisdictions. Check yours. FOOTER_POSTAL_ADDRESS=A real postal address
$ choose a build depth, inspect the files, then open the complete pack in your agent
FeedBlitz: 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
xresilient polling of malformed and rate-limited feeds
xsender reputation and deliverability operations
xbounce, complaint, and suppression handling
FeedBlitz pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| core | $7/workspace | — | Unlimited emails, lists, campaigns, forms and funnels; price changes by unique active subscribers. |
| accel | $8.50/workspace | — | Unlimited emails, lists, campaigns, forms and funnels; price changes by unique active subscribers. |
| enterprise | $10.50/workspace | — | Unlimited emails, lists, campaigns, forms and funnels; price changes by unique active subscribers. |
free tierno free tier; 30-day trial for new clients.
billingmonthly shown; yearly billing available but annual discount not disclosed; 30-day trial
hidden costsMonthly/yearly fee adjusts by unique active subscribers; no email overages and no charge for pending/duplicate/deleted/unsubscribed addresses.
verified 2026-08-10 · source ↗
Vibecode FeedBlitz
Kinda. The core of FeedBlitz 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 FeedBlitz cost?
FeedBlitz'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 FeedBlitz?
Honestly: large-scale segmentation and automation; payments, recommendations, and audience network; resilient polling of malformed and rate-limited feeds; 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 FeedBlitz?
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.