Build FeedBlitz

KINDAback to the verdict

0%0 of 18 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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-daythe files for this build are in the project pack

RuntimeNext.js 15 with TypeScript plus one worker for sending and webhooksStoragePostgreSQL, with suppression checked at send time in the same transactionSendingOne reputable provider with a verified domain, never your own mail serverUnsubscribeA one-click endpoint built to the specification, plus a visible linkFeedbackProvider webhooks for bounces and complaints, deduplicated by event id

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the application, the poller and the send worker.

    Get it nodejs.org, or nvm install 22. open ↗

    Verify node --version prints v22 or higher

  2. installfree 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

  3. have readythe 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

  4. API keyfree 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

  5. have readyfree

    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. open ↗

    Verify A note records the thresholds used and the date read

  6. 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

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- 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

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://user:redacted@localhost:5432/newsSubscribers, suppressions, campaigns and sends.
ESP_API_KEYsecretrequiredre_...redactedSending only. Rotate it if it reaches a log.
ESP_WEBHOOK_SECRETsecretrequiredredactedVerifies bounce and complaint callbacks before they are trusted.
SENDING_DOMAINrequiredmail.example.comThe preflight resolves SPF, DKIM and DMARC for this name.
PUBLIC_BASE_URLrequiredhttps://mail.example.comThe one-click unsubscribe URI must be HTTPS on this host.
COMPLAINT_RATE_BLOCKrequired0.0015Your own line, set below the published threshold, which blocks a send.
SOFT_BOUNCE_SUPPRESS_AFTERrequired4Consecutive soft bounces before the address is suppressed.
FOOTER_POSTAL_ADDRESSrequiredA real postal addressRequired in commercial mail in most jurisdictions. Check yours.

The build, in order

  1. Consent recorded, suppression obeyed

    Every subscriber carries a recorded consent, and no send can reach a suppressed address.

    1. A constraint, not a convention, because imports are written in a hurry.

      Files prisma/schema.prismasrc/lib/import.ts

    2. And report the number dropped, so it is visible rather than silent.

      Files src/lib/import.tstests/import-suppression.test.ts

    3. Checked at send time, because the list is older than the decision.

      Files src/lib/send.tstests/no-send-to-suppressed.test.ts

    done when · tick each as it passes
    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.
  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.

    1. Record everything, send nothing. This is the single most expensive mistake here.

      Files src/lib/feeds.tstests/first-connect.test.ts

    2. Identifier, then canonical URL, then content hash.

      Files src/lib/dedupe.ts

    3. Some clients only read it, and filters notice when it is missing.

      Files src/lib/render.ts

    done when · tick each as it passes
    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.
  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.

    1. The value of the post header is one pair, and it is not free text.

      Files src/lib/headers.tstests/list-unsubscribe.test.ts

    2. No cookie, no authorisation, no redirect, no confirmation page.

      Files src/app/u/[token]/route.tstests/one-click.test.ts

    3. One subscriber, one list, unguessable from any other.

      Files src/lib/tokens.ts

    done when · tick each as it passes
    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.
  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.

    1. Providers retry, and a double-processed bounce is only harmless by luck.

      Files src/app/api/webhooks/route.tstests/webhook-replay.test.ts

    2. The distinction is the provider's; store which one it told you.

      Files src/lib/feedback.ts

    3. The number matters most at the moment you are about to send.

      Files src/app/campaigns/[id]/page.tsx

    done when · tick each as it passes
    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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more newsletters.