Build FeedBlitz
KINDAback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- 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 - 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 - 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 - 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 - 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 - accountfree
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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://user:redacted@localhost:5432/news | Subscribers, suppressions, campaigns and sends. |
ESP_API_KEYsecret | required | re_...redacted | Sending only. Rotate it if it reaches a log. |
ESP_WEBHOOK_SECRETsecret | required | redacted | Verifies bounce and complaint callbacks before they are trusted. |
SENDING_DOMAIN | required | mail.example.com | The preflight resolves SPF, DKIM and DMARC for this name. |
PUBLIC_BASE_URL | required | https://mail.example.com | The one-click unsubscribe URI must be HTTPS on this host. |
COMPLAINT_RATE_BLOCK | required | 0.0015 | Your own line, set below the published threshold, which blocks a send. |
SOFT_BOUNCE_SUPPRESS_AFTER | required | 4 | Consecutive soft bounces before the address is suppressed. |
FOOTER_POSTAL_ADDRESS | required | A real postal address | Required in commercial mail in most jurisdictions. Check yours. |
The build, in order
Consent recorded, suppression obeyed
Every subscriber carries a recorded consent, and no send can reach a suppressed address.
A constraint, not a convention, because imports are written in a hurry.
Files
prisma/schema.prismasrc/lib/import.tsAnd report the number dropped, so it is visible rather than silent.
Files
src/lib/import.tstests/import-suppression.test.tsChecked 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 passeswatch 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.
A feed becomes an issue, and the archive stays where it is
Turning on a feed sends nothing until the next new item appears.
Record everything, send nothing. This is the single most expensive mistake here.
Files
src/lib/feeds.tstests/first-connect.test.tsIdentifier, then canonical URL, then content hash.
Files
src/lib/dedupe.tsSome clients only read it, and filters notice when it is missing.
Files
src/lib/render.ts
done when · tick each as it passeswatch 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.
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.
The value of the post header is one pair, and it is not free text.
Files
src/lib/headers.tstests/list-unsubscribe.test.tsNo cookie, no authorisation, no redirect, no confirmation page.
Files
src/app/u/[token]/route.tstests/one-click.test.tsOne subscriber, one list, unguessable from any other.
Files
src/lib/tokens.ts
done when · tick each as it passeswatch 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.
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.
Providers retry, and a double-processed bounce is only harmless by luck.
Files
src/app/api/webhooks/route.tstests/webhook-replay.test.tsThe distinction is the provider's; store which one it told you.
Files
src/lib/feedback.tsThe number matters most at the moment you are about to send.
Files
src/app/campaigns/[id]/page.tsx
done when · tick each as it passeswatch 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.
Proving you are allowed to sendproduct builder
A preflight checks the things the mailbox providers actually check, and the list leaves whenever you want.
Not from your notes. The record either exists or it does not.
Files
src/lib/preflight.tstests/preflight-dns.test.tsA list without its suppressions is worse than no list.
Files
src/lib/export.tstests/roundtrip.test.tsErasure must not turn into accidental re-subscription.
Files
src/lib/erase.ts
done when · tick each as it passeswatch 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.
That is the whole plan for FeedBlitz. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- 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
- 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
Need the files? The project pack on the verdict page hands your agent the whole brief · more newsletters.