Vibecode Bento
track this build4 phases, 12 steps, beginner friendly0%The visible email + customer data loop is buildable, but a credible replacement needs more than the first screen. Bento 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 Bento. 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 ===== # Bento · indie build A newsletter sender whose hard problems are making suppression impossible to bypass, freezing the audience at send time so a running campaign cannot double-send or skip people, and reporting numbers that are honest about what modern mail clients actually do. 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 and TypeScript with a separate send worker process | a send that dies with the web request is a half-sent campaign | | Storage | PostgreSQL with suppression enforced in the send query | this belongs in the database because application code gets bypassed | | Sending | One user-supplied provider behind one interface, with a per-recipient idempotency key | you are not going to operate your own mail reputation | | Events | Provider webhooks with signature verification and replay tolerance | bounces and complaints are how suppression stays current | | Audience | Frozen to rows at send time, never recomputed mid-send | a moving segment is how people get two copies or none | ## 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 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 and the recipient freeze rely on transactions and unique constraints doing their job. - Get it: Your package manager, a container, or a small managed instance. - Verify: psql connects and the migrations apply - [ ] **An email provider account with webhooks** · free tiers exist at low volume - Why: Sending, bounce and complaint events all come from here, and suppression is only current because the webhooks arrive. - Get it: Create an account, verify a sending domain, and configure the webhook endpoint with a signing secret. - Verify: A test send delivers and its delivery event arrives signed - [ ] **A sending domain with authentication configured** · free beyond the domain - Why: Mailbox providers treat unauthenticated bulk mail badly, and the current requirements are published by each provider and change. - Get it: Set up the authentication records your provider documents, then read the current bulk sender requirements from the major mailbox providers and confirm you meet them. - Verify: Your provider reports the domain verified and a test message passes authentication checks at a mailbox you control - [ ] **Test mailboxes at two or three major providers** · free - Why: Suppression and reconciliation are testable locally, but rendering and delivery are only observable in a real inbox. - Get it: Create a mailbox at two or three providers and add them to a test segment. - Verify: A campaign to the test segment arrives and its events appear ## 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 sending infrastructure. - A drag and drop email designer. - Branching automations and journeys. - Payments, recommendations and an audience network. - sender reputation and deliverability operations - bounce, complaint, and suppression handling - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling If one of those is essential to you, that is the reason to keep paying for Bento, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Bento 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 subscriber database and newsletter sender that cannot mail someone who asked it to stop to replace Bento. 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. The first is enforced by the database, not by your good intentions. An unsubscribe, a hard bounce or a spam complaint makes an address unmailable, permanently and immediately, and that has to be true even when a send is already running, even when a segment was computed an hour ago, and even when someone calls the send function directly in a script. So suppression is a table the send query joins against inside the same transaction that claims each recipient, with a test that calls the sender directly against a suppressed address and asserts that nothing goes out. Mailing someone after they unsubscribed is the one failure that costs you the domain rather than the customer. Second, do not report opens as if they were reads. Apple's Mail Privacy Protection downloads remote content in the background regardless of whether the recipient engaged with the message, through relays that hide their address, so a tracking pixel fires for mail nobody opened. Label the number for what it is, and never build a rule that deletes or re-mails people based on it. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 and TypeScript with a separate send worker process · a send that dies with the web request is a half-sent campaign - Storage: PostgreSQL with suppression enforced in the send query · this belongs in the database because application code gets bypassed - Sending: One user-supplied provider behind one interface, with a per-recipient idempotency key · you are not going to operate your own mail reputation - Events: Provider webhooks with signature verification and replay tolerance · bounces and complaints are how suppression stays current - Audience: Frozen to rows at send time, never recomputed mid-send · a moving segment is how people get two copies or none ### Data model (create this before Phase 1) - contacts(id, email_normalised, created_at, source, consent_text, consent_at, consent_ip_note) - suppressions(email_normalised primary key, reason, at, detail) · unsubscribed, hard bounced or complained - events(id, contact_id, kind, at, properties_json) · behaviour, for segmentation only - segments(id, name, definition_json, updated_at) - campaigns(id, name, subject, body_html, body_text, segment_id, state, frozen_at, sent_at) - recipients(campaign_id, contact_id, email_normalised, state, idempotency_key, provider_message_id, attempted_at, outcome) · written once when the audience freezes - deliveries(id, recipient_row, kind, at, provider_payload_excerpt) · delivered, bounced, complained, and opens kept separately and labelled ### Phase 1 · Contacts that remember what they agreed to Build: Store contacts with a normalised address, the source, the timestamp and the verbatim consent text shown at the time. Import from CSV requires the importer to state the source and paste the consent wording for that list, applied to every row. Reject an import with no source rather than defaulting it to something vague. Trap: Importing a spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. Done when: A CSV import without a stated source and consent wording is refused, tested through the route. Every contact row carries consent text and a timestamp, verified by a not-null constraint. Address normalisation is applied once at write time and the same address in two cases resolves to one contact, tested. Changing the consent wording for new imports leaves existing rows untouched. Do not build yet: No sending, segments, events or reporting. ### Phase 2 · Suppression that the sender cannot route around Build: Create the suppression table keyed on the normalised address. Make the send worker claim each recipient in a transaction that joins against suppression at that moment. Add an unsubscribe link carrying a per-recipient token that works without a login and writes suppression immediately. Write the test that calls the send function directly with a suppressed address. Trap: Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. Done when: A direct call to the send function with a suppressed address sends nothing and records the refusal, tested. Unsubscribing during an in-progress send stops that recipient's message, tested by suppressing mid-run. The unsubscribe link works with no login and is one action rather than a preferences form. Suppression is keyed on the normalised address so a case variant is suppressed too. Do not build yet: No segments, provider integration or reporting yet. ### Phase 3 · An audience frozen the moment you press send Build: On send, evaluate the segment once and write one recipient row per contact with a unique idempotency key, inside a transaction, then mark the campaign frozen. The worker only ever reads recipient rows. A contact added to the segment afterwards is not in this campaign, and the interface says so. Trap: Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. Done when: Recipient rows are written once per campaign and a unique constraint on campaign and contact prevents a second, tested by attempting it. A contact added to the segment during a send does not receive that campaign, tested. Restarting the worker mid-send resumes from unclaimed rows without resending claimed ones. The campaign page shows the frozen audience size and the time it was frozen. Do not build yet: No provider sending or webhooks yet. ### Phase 4 · Handing messages to a provider, and hearing back Build: Send through the provider with the recipient's idempotency key. Verify every webhook signature before parsing, tolerate replays by event identifier, and classify outcomes: a hard bounce or a complaint writes suppression immediately, a soft bounce retries within a stated limit. A send whose outcome is unknown is reconciled by asking the provider, never by resending. Trap: Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. Done when: A webhook with an invalid signature is rejected before parsing, tested with a forged payload. A repeated webhook event changes nothing, tested by delivering the same event twice. A hard bounce or complaint writes suppression within one processing cycle, tested. Killing the worker between the provider accepting and the database recording leaves one message and a row that reconciles to sent, tested with a fake provider. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers that admit what they do not know Build: Report delivered, bounced and complained from provider events. Keep opens in a separate section labelled as unreliable, with a one line explanation that some mail clients fetch remote content automatically whether or not anyone read the message. Do not offer any automation that acts on opens. Export contacts, consent, suppression and campaign results as CSV, and delete a contact fully on request. Trap: Building a re-engagement rule that deletes contacts who have not opened in six months quietly removes people whose mail client blocks the pixel entirely and keeps people whose client fetches it automatically, so the list gets worse while the dashboard says it got cleaner. Base rules on clicks, replies or purchases, and keep opens as a weak signal with a caption. Done when: The reporting page separates provider-confirmed outcomes from open estimates and carries the explanation beside the latter. No automation or segment definition can reference opens, enforced in the segment validator and tested. Export produces contacts with consent, the suppression list and per-campaign results, and reimports without loss. Deleting a contact removes their rows while keeping the suppression entry, which is the one thing that must survive. Do not build yet: Nothing after this. ### Out of scope (and why) - Operating your own sending infrastructure, which is a reputation problem measured in months and the actual reason people pay for this category. - Visual drag and drop email design, because the hard part of email HTML is client rendering rather than the editor. - Branching automations and journeys, which multiply every suppression and freezing question by the number of paths. - Payments, product recommendations and an audience network, which are three separate products wearing one login. ### README must contain - That suppression is checked inside the transaction that claims each recipient, and how to run the bypass test - That an audience is frozen to rows at send time and what that means for contacts added afterwards - That the provider is yours, that your domain's authentication is your responsibility, and to read the current bulk sender requirements of the major mailbox providers before the first send - That open numbers are estimates because some mail clients fetch remote content automatically, and that no rule may act on them - How to export everything and what a contact deletion does and does not remove ===== AGENTS.md ===== # Agent instructions · Bento indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 and TypeScript with a separate send worker process, PostgreSQL with suppression enforced in the send query, One user-supplied provider behind one interface, with a per-recipient idempotency key, Provider webhooks with signature verification and replay tolerance, Frozen to rows at send time, never recomputed mid-send. 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 spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. - Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. - Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. - Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. ===== BUILD_PLAN.md ===== # Build plan · Bento A newsletter sender whose hard problems are making suppression impossible to bypass, freezing the audience at send time so a running campaign cannot double-send or skip people, and reporting numbers that are honest about what modern mail clients actually do. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Contacts that remember what they agreed to Every address carries where it came from and the exact wording it consented to. ### Steps 1. Require source and consent wording at import Not as a later field to fill in, which nobody does. Files: `src/lib/import.ts`, `tests/import.test.ts` 2. Normalise addresses once, at write Two rows for one person is two copies of every campaign. Files: `src/lib/email.ts` 3. Constrain consent in the schema A not-null column outlives a code review. Files: `migrations/001_contacts.sql` ### Done when - [ ] Imports without source and consent are refused - [ ] Every contact carries consent text and time - [ ] Case variants resolve to one contact - [ ] Existing rows keep their original consent wording ### Watch out - Importing a spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. ## Phase 2 · Suppression that the sender cannot route around A suppressed address cannot be sent to by any code path in the application. ### Steps 1. Join suppression inside the claim transaction The check has to happen at the last possible moment. Files: `src/worker/claim.sql`, `tests/suppression.test.ts` 2. Make unsubscribe one click and no login A form that asks why is a form that fails to unsubscribe people. Files: `src/app/u/[token]/route.ts` 3. Test the direct call, not just the interface The bypass is always a script somebody wrote in a hurry. Files: `tests/suppression-bypass.test.ts` ### Done when - [ ] A direct send to a suppressed address sends nothing - [ ] Mid-send unsubscribes take effect immediately - [ ] Unsubscribe needs no login and one action - [ ] Case variants are suppressed too ### Watch out - Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. ## Phase 3 · An audience frozen the moment you press send Recipient rows are written once, so a campaign cannot double-send or skip. ### Steps 1. Write recipient rows in one transaction This is the campaign's audience, forever. Files: `src/lib/freeze.ts` 2. Constrain one row per campaign and contact The cheapest protection against the double send. Files: `migrations/003_recipients.sql` 3. Resume from unclaimed rows A worker restart mid-campaign should be boring. Files: `tests/resume.test.ts` ### Done when - [ ] One recipient row per campaign and contact, enforced - [ ] Contacts added mid-send are excluded - [ ] A worker restart resends nothing - [ ] Frozen audience size and time are shown ### Watch out - Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. ## Phase 4 · Handing messages to a provider, and hearing back Each message is sent once, and bounces and complaints flow into suppression automatically. ### Steps 1. Verify the signature before you parse An unverified webhook is an open write endpoint. Files: `src/app/api/webhook/route.ts` 2. Make replays harmless Providers redeliver, sometimes for days. Files: `src/lib/events.ts` 3. Reconcile unknown outcomes by asking Never by sending again. Files: `src/worker/reconcile.ts`, `tests/exactly-once.test.ts` ### Done when - [ ] Forged webhook signatures are rejected - [ ] Repeated events are idempotent - [ ] Hard bounces and complaints suppress immediately - [ ] An unknown outcome reconciles rather than resends ### Watch out - Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. ## Not in this build - Your own sending infrastructure. - A drag and drop email designer. - Branching automations and journeys. - Payments, recommendations and an audience network. ## After v1, if you want it - Click tracking on an opt-in basis, which is a better signal than opens and still not a perfect one - A double opt-in flow, once consent storage has been used in anger for a while - Scheduled sends, which reuse the freeze exactly and add only a clock ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Contacts, suppression, campaigns and recipients. DATABASE_URL=postgres://user:pass@localhost:5432/newsletter # Required · secret. The only credential that can send. Rotate it if it ever reaches a log. EMAIL_PROVIDER_API_KEY=key-...redacted # Required · secret. Every webhook is verified against this before it is parsed. EMAIL_WEBHOOK_SECRET=whsec-...redacted # Required. Verified with the provider, with authentication records in place. SENDING_DOMAIN=mail.example.com # Required. Per-recipient tokens are appended here. UNSUBSCRIBE_BASE_URL=https://mail.example.com/u # Required. After this a soft bounce stops being retried and is recorded. SOFT_BOUNCE_MAX_RETRIES=3 # Required. Below your provider's limit, checked against their current documentation. SEND_RATE_PER_MINUTE=200
You are building a lean indie version of Bento. 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 ===== # Bento · indie build A newsletter sender whose hard problems are making suppression impossible to bypass, freezing the audience at send time so a running campaign cannot double-send or skip people, and reporting numbers that are honest about what modern mail clients actually do. 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 and TypeScript with a separate send worker process | a send that dies with the web request is a half-sent campaign | | Storage | PostgreSQL with suppression enforced in the send query | this belongs in the database because application code gets bypassed | | Sending | One user-supplied provider behind one interface, with a per-recipient idempotency key | you are not going to operate your own mail reputation | | Events | Provider webhooks with signature verification and replay tolerance | bounces and complaints are how suppression stays current | | Audience | Frozen to rows at send time, never recomputed mid-send | a moving segment is how people get two copies or none | ## 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 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 and the recipient freeze rely on transactions and unique constraints doing their job. - Get it: Your package manager, a container, or a small managed instance. - Verify: psql connects and the migrations apply - [ ] **An email provider account with webhooks** · free tiers exist at low volume - Why: Sending, bounce and complaint events all come from here, and suppression is only current because the webhooks arrive. - Get it: Create an account, verify a sending domain, and configure the webhook endpoint with a signing secret. - Verify: A test send delivers and its delivery event arrives signed - [ ] **A sending domain with authentication configured** · free beyond the domain - Why: Mailbox providers treat unauthenticated bulk mail badly, and the current requirements are published by each provider and change. - Get it: Set up the authentication records your provider documents, then read the current bulk sender requirements from the major mailbox providers and confirm you meet them. - Verify: Your provider reports the domain verified and a test message passes authentication checks at a mailbox you control - [ ] **Test mailboxes at two or three major providers** · free - Why: Suppression and reconciliation are testable locally, but rendering and delivery are only observable in a real inbox. - Get it: Create a mailbox at two or three providers and add them to a test segment. - Verify: A campaign to the test segment arrives and its events appear ## 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 sending infrastructure. - A drag and drop email designer. - Branching automations and journeys. - Payments, recommendations and an audience network. - sender reputation and deliverability operations - bounce, complaint, and suppression handling - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling If one of those is essential to you, that is the reason to keep paying for Bento, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Bento 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 subscriber database and newsletter sender that cannot mail someone who asked it to stop to replace Bento. 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. The first is enforced by the database, not by your good intentions. An unsubscribe, a hard bounce or a spam complaint makes an address unmailable, permanently and immediately, and that has to be true even when a send is already running, even when a segment was computed an hour ago, and even when someone calls the send function directly in a script. So suppression is a table the send query joins against inside the same transaction that claims each recipient, with a test that calls the sender directly against a suppressed address and asserts that nothing goes out. Mailing someone after they unsubscribed is the one failure that costs you the domain rather than the customer. Second, do not report opens as if they were reads. Apple's Mail Privacy Protection downloads remote content in the background regardless of whether the recipient engaged with the message, through relays that hide their address, so a tracking pixel fires for mail nobody opened. Label the number for what it is, and never build a rule that deletes or re-mails people based on it. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 and TypeScript with a separate send worker process · a send that dies with the web request is a half-sent campaign - Storage: PostgreSQL with suppression enforced in the send query · this belongs in the database because application code gets bypassed - Sending: One user-supplied provider behind one interface, with a per-recipient idempotency key · you are not going to operate your own mail reputation - Events: Provider webhooks with signature verification and replay tolerance · bounces and complaints are how suppression stays current - Audience: Frozen to rows at send time, never recomputed mid-send · a moving segment is how people get two copies or none ### Data model (create this before Phase 1) - contacts(id, email_normalised, created_at, source, consent_text, consent_at, consent_ip_note) - suppressions(email_normalised primary key, reason, at, detail) · unsubscribed, hard bounced or complained - events(id, contact_id, kind, at, properties_json) · behaviour, for segmentation only - segments(id, name, definition_json, updated_at) - campaigns(id, name, subject, body_html, body_text, segment_id, state, frozen_at, sent_at) - recipients(campaign_id, contact_id, email_normalised, state, idempotency_key, provider_message_id, attempted_at, outcome) · written once when the audience freezes - deliveries(id, recipient_row, kind, at, provider_payload_excerpt) · delivered, bounced, complained, and opens kept separately and labelled ### Phase 1 · Contacts that remember what they agreed to Build: Store contacts with a normalised address, the source, the timestamp and the verbatim consent text shown at the time. Import from CSV requires the importer to state the source and paste the consent wording for that list, applied to every row. Reject an import with no source rather than defaulting it to something vague. Trap: Importing a spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. Done when: A CSV import without a stated source and consent wording is refused, tested through the route. Every contact row carries consent text and a timestamp, verified by a not-null constraint. Address normalisation is applied once at write time and the same address in two cases resolves to one contact, tested. Changing the consent wording for new imports leaves existing rows untouched. Do not build yet: No sending, segments, events or reporting. ### Phase 2 · Suppression that the sender cannot route around Build: Create the suppression table keyed on the normalised address. Make the send worker claim each recipient in a transaction that joins against suppression at that moment. Add an unsubscribe link carrying a per-recipient token that works without a login and writes suppression immediately. Write the test that calls the send function directly with a suppressed address. Trap: Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. Done when: A direct call to the send function with a suppressed address sends nothing and records the refusal, tested. Unsubscribing during an in-progress send stops that recipient's message, tested by suppressing mid-run. The unsubscribe link works with no login and is one action rather than a preferences form. Suppression is keyed on the normalised address so a case variant is suppressed too. Do not build yet: No segments, provider integration or reporting yet. ### Phase 3 · An audience frozen the moment you press send Build: On send, evaluate the segment once and write one recipient row per contact with a unique idempotency key, inside a transaction, then mark the campaign frozen. The worker only ever reads recipient rows. A contact added to the segment afterwards is not in this campaign, and the interface says so. Trap: Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. Done when: Recipient rows are written once per campaign and a unique constraint on campaign and contact prevents a second, tested by attempting it. A contact added to the segment during a send does not receive that campaign, tested. Restarting the worker mid-send resumes from unclaimed rows without resending claimed ones. The campaign page shows the frozen audience size and the time it was frozen. Do not build yet: No provider sending or webhooks yet. ### Phase 4 · Handing messages to a provider, and hearing back Build: Send through the provider with the recipient's idempotency key. Verify every webhook signature before parsing, tolerate replays by event identifier, and classify outcomes: a hard bounce or a complaint writes suppression immediately, a soft bounce retries within a stated limit. A send whose outcome is unknown is reconciled by asking the provider, never by resending. Trap: Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. Done when: A webhook with an invalid signature is rejected before parsing, tested with a forged payload. A repeated webhook event changes nothing, tested by delivering the same event twice. A hard bounce or complaint writes suppression within one processing cycle, tested. Killing the worker between the provider accepting and the database recording leaves one message and a row that reconciles to sent, tested with a fake provider. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers that admit what they do not know Build: Report delivered, bounced and complained from provider events. Keep opens in a separate section labelled as unreliable, with a one line explanation that some mail clients fetch remote content automatically whether or not anyone read the message. Do not offer any automation that acts on opens. Export contacts, consent, suppression and campaign results as CSV, and delete a contact fully on request. Trap: Building a re-engagement rule that deletes contacts who have not opened in six months quietly removes people whose mail client blocks the pixel entirely and keeps people whose client fetches it automatically, so the list gets worse while the dashboard says it got cleaner. Base rules on clicks, replies or purchases, and keep opens as a weak signal with a caption. Done when: The reporting page separates provider-confirmed outcomes from open estimates and carries the explanation beside the latter. No automation or segment definition can reference opens, enforced in the segment validator and tested. Export produces contacts with consent, the suppression list and per-campaign results, and reimports without loss. Deleting a contact removes their rows while keeping the suppression entry, which is the one thing that must survive. Do not build yet: Nothing after this. ### Out of scope (and why) - Operating your own sending infrastructure, which is a reputation problem measured in months and the actual reason people pay for this category. - Visual drag and drop email design, because the hard part of email HTML is client rendering rather than the editor. - Branching automations and journeys, which multiply every suppression and freezing question by the number of paths. - Payments, product recommendations and an audience network, which are three separate products wearing one login. ### README must contain - That suppression is checked inside the transaction that claims each recipient, and how to run the bypass test - That an audience is frozen to rows at send time and what that means for contacts added afterwards - That the provider is yours, that your domain's authentication is your responsibility, and to read the current bulk sender requirements of the major mailbox providers before the first send - That open numbers are estimates because some mail clients fetch remote content automatically, and that no rule may act on them - How to export everything and what a contact deletion does and does not remove ===== AGENTS.md ===== # Agent instructions · Bento indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 and TypeScript with a separate send worker process, PostgreSQL with suppression enforced in the send query, One user-supplied provider behind one interface, with a per-recipient idempotency key, Provider webhooks with signature verification and replay tolerance, Frozen to rows at send time, never recomputed mid-send. 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 spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. - Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. - Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. - Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. ===== BUILD_PLAN.md ===== # Build plan · Bento A newsletter sender whose hard problems are making suppression impossible to bypass, freezing the audience at send time so a running campaign cannot double-send or skip people, and reporting numbers that are honest about what modern mail clients actually do. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Contacts that remember what they agreed to Every address carries where it came from and the exact wording it consented to. ### Steps 1. Require source and consent wording at import Not as a later field to fill in, which nobody does. Files: `src/lib/import.ts`, `tests/import.test.ts` 2. Normalise addresses once, at write Two rows for one person is two copies of every campaign. Files: `src/lib/email.ts` 3. Constrain consent in the schema A not-null column outlives a code review. Files: `migrations/001_contacts.sql` ### Done when - [ ] Imports without source and consent are refused - [ ] Every contact carries consent text and time - [ ] Case variants resolve to one contact - [ ] Existing rows keep their original consent wording ### Watch out - Importing a spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. ## Phase 2 · Suppression that the sender cannot route around A suppressed address cannot be sent to by any code path in the application. ### Steps 1. Join suppression inside the claim transaction The check has to happen at the last possible moment. Files: `src/worker/claim.sql`, `tests/suppression.test.ts` 2. Make unsubscribe one click and no login A form that asks why is a form that fails to unsubscribe people. Files: `src/app/u/[token]/route.ts` 3. Test the direct call, not just the interface The bypass is always a script somebody wrote in a hurry. Files: `tests/suppression-bypass.test.ts` ### Done when - [ ] A direct send to a suppressed address sends nothing - [ ] Mid-send unsubscribes take effect immediately - [ ] Unsubscribe needs no login and one action - [ ] Case variants are suppressed too ### Watch out - Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. ## Phase 3 · An audience frozen the moment you press send Recipient rows are written once, so a campaign cannot double-send or skip. ### Steps 1. Write recipient rows in one transaction This is the campaign's audience, forever. Files: `src/lib/freeze.ts` 2. Constrain one row per campaign and contact The cheapest protection against the double send. Files: `migrations/003_recipients.sql` 3. Resume from unclaimed rows A worker restart mid-campaign should be boring. Files: `tests/resume.test.ts` ### Done when - [ ] One recipient row per campaign and contact, enforced - [ ] Contacts added mid-send are excluded - [ ] A worker restart resends nothing - [ ] Frozen audience size and time are shown ### Watch out - Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. ## Phase 4 · Handing messages to a provider, and hearing back Each message is sent once, and bounces and complaints flow into suppression automatically. ### Steps 1. Verify the signature before you parse An unverified webhook is an open write endpoint. Files: `src/app/api/webhook/route.ts` 2. Make replays harmless Providers redeliver, sometimes for days. Files: `src/lib/events.ts` 3. Reconcile unknown outcomes by asking Never by sending again. Files: `src/worker/reconcile.ts`, `tests/exactly-once.test.ts` ### Done when - [ ] Forged webhook signatures are rejected - [ ] Repeated events are idempotent - [ ] Hard bounces and complaints suppress immediately - [ ] An unknown outcome reconciles rather than resends ### Watch out - Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. ## Not in this build - Your own sending infrastructure. - A drag and drop email designer. - Branching automations and journeys. - Payments, recommendations and an audience network. ## After v1, if you want it - Click tracking on an opt-in basis, which is a better signal than opens and still not a perfect one - A double opt-in flow, once consent storage has been used in anger for a while - Scheduled sends, which reuse the freeze exactly and add only a clock ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Contacts, suppression, campaigns and recipients. DATABASE_URL=postgres://user:pass@localhost:5432/newsletter # Required · secret. The only credential that can send. Rotate it if it ever reaches a log. EMAIL_PROVIDER_API_KEY=key-...redacted # Required · secret. Every webhook is verified against this before it is parsed. EMAIL_WEBHOOK_SECRET=whsec-...redacted # Required. Verified with the provider, with authentication records in place. SENDING_DOMAIN=mail.example.com # Required. Per-recipient tokens are appended here. UNSUBSCRIBE_BASE_URL=https://mail.example.com/u # Required. After this a soft bounce stops being retried and is recorded. SOFT_BOUNCE_MAX_RETRIES=3 # Required. Below your provider's limit, checked against their current documentation. SEND_RATE_PER_MINUTE=200
You are building a production product version of Bento. 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 ===== # Bento · product brief ## Problem The visible email + customer data loop is buildable, but a credible replacement needs more than the first screen. Bento earns its keep through deliverability, reputation, network, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A sender where an unsubscribe takes effect within the same second even mid-campaign, a running send cannot mail anyone twice, bounces and complaints suppress themselves, and no dashboard pretends an automatic image fetch was a human reading your newsletter. ## 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 sending infrastructure. - A drag and drop email designer. - Branching automations and journeys. - Payments, recommendations and an audience network. - sender reputation and deliverability operations - bounce, complaint, and suppression handling - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling ## Success criteria - A direct send to a suppressed address is confirmed to send nothing - A mid-send unsubscribe is confirmed to take effect immediately - A double send is confirmed prevented by constraint - Forged webhook signatures are confirmed rejected - Segment definitions are confirmed unable to reference opens ===== BRIEF.md ===== # Build brief · Bento 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 subscriber database and newsletter sender that cannot mail someone who asked it to stop to replace Bento. 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. The first is enforced by the database, not by your good intentions. An unsubscribe, a hard bounce or a spam complaint makes an address unmailable, permanently and immediately, and that has to be true even when a send is already running, even when a segment was computed an hour ago, and even when someone calls the send function directly in a script. So suppression is a table the send query joins against inside the same transaction that claims each recipient, with a test that calls the sender directly against a suppressed address and asserts that nothing goes out. Mailing someone after they unsubscribed is the one failure that costs you the domain rather than the customer. Second, do not report opens as if they were reads. Apple's Mail Privacy Protection downloads remote content in the background regardless of whether the recipient engaged with the message, through relays that hide their address, so a tracking pixel fires for mail nobody opened. Label the number for what it is, and never build a rule that deletes or re-mails people based on it. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 and TypeScript with a separate send worker process · a send that dies with the web request is a half-sent campaign - Storage: PostgreSQL with suppression enforced in the send query · this belongs in the database because application code gets bypassed - Sending: One user-supplied provider behind one interface, with a per-recipient idempotency key · you are not going to operate your own mail reputation - Events: Provider webhooks with signature verification and replay tolerance · bounces and complaints are how suppression stays current - Audience: Frozen to rows at send time, never recomputed mid-send · a moving segment is how people get two copies or none ### Data model (create this before Phase 1) - contacts(id, email_normalised, created_at, source, consent_text, consent_at, consent_ip_note) - suppressions(email_normalised primary key, reason, at, detail) · unsubscribed, hard bounced or complained - events(id, contact_id, kind, at, properties_json) · behaviour, for segmentation only - segments(id, name, definition_json, updated_at) - campaigns(id, name, subject, body_html, body_text, segment_id, state, frozen_at, sent_at) - recipients(campaign_id, contact_id, email_normalised, state, idempotency_key, provider_message_id, attempted_at, outcome) · written once when the audience freezes - deliveries(id, recipient_row, kind, at, provider_payload_excerpt) · delivered, bounced, complained, and opens kept separately and labelled ### Phase 1 · Contacts that remember what they agreed to Build: Store contacts with a normalised address, the source, the timestamp and the verbatim consent text shown at the time. Import from CSV requires the importer to state the source and paste the consent wording for that list, applied to every row. Reject an import with no source rather than defaulting it to something vague. Trap: Importing a spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. Done when: A CSV import without a stated source and consent wording is refused, tested through the route. Every contact row carries consent text and a timestamp, verified by a not-null constraint. Address normalisation is applied once at write time and the same address in two cases resolves to one contact, tested. Changing the consent wording for new imports leaves existing rows untouched. Do not build yet: No sending, segments, events or reporting. ### Phase 2 · Suppression that the sender cannot route around Build: Create the suppression table keyed on the normalised address. Make the send worker claim each recipient in a transaction that joins against suppression at that moment. Add an unsubscribe link carrying a per-recipient token that works without a login and writes suppression immediately. Write the test that calls the send function directly with a suppressed address. Trap: Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. Done when: A direct call to the send function with a suppressed address sends nothing and records the refusal, tested. Unsubscribing during an in-progress send stops that recipient's message, tested by suppressing mid-run. The unsubscribe link works with no login and is one action rather than a preferences form. Suppression is keyed on the normalised address so a case variant is suppressed too. Do not build yet: No segments, provider integration or reporting yet. ### Phase 3 · An audience frozen the moment you press send Build: On send, evaluate the segment once and write one recipient row per contact with a unique idempotency key, inside a transaction, then mark the campaign frozen. The worker only ever reads recipient rows. A contact added to the segment afterwards is not in this campaign, and the interface says so. Trap: Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. Done when: Recipient rows are written once per campaign and a unique constraint on campaign and contact prevents a second, tested by attempting it. A contact added to the segment during a send does not receive that campaign, tested. Restarting the worker mid-send resumes from unclaimed rows without resending claimed ones. The campaign page shows the frozen audience size and the time it was frozen. Do not build yet: No provider sending or webhooks yet. ### Phase 4 · Handing messages to a provider, and hearing back Build: Send through the provider with the recipient's idempotency key. Verify every webhook signature before parsing, tolerate replays by event identifier, and classify outcomes: a hard bounce or a complaint writes suppression immediately, a soft bounce retries within a stated limit. A send whose outcome is unknown is reconciled by asking the provider, never by resending. Trap: Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. Done when: A webhook with an invalid signature is rejected before parsing, tested with a forged payload. A repeated webhook event changes nothing, tested by delivering the same event twice. A hard bounce or complaint writes suppression within one processing cycle, tested. Killing the worker between the provider accepting and the database recording leaves one message and a row that reconciles to sent, tested with a fake provider. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers that admit what they do not know Build: Report delivered, bounced and complained from provider events. Keep opens in a separate section labelled as unreliable, with a one line explanation that some mail clients fetch remote content automatically whether or not anyone read the message. Do not offer any automation that acts on opens. Export contacts, consent, suppression and campaign results as CSV, and delete a contact fully on request. Trap: Building a re-engagement rule that deletes contacts who have not opened in six months quietly removes people whose mail client blocks the pixel entirely and keeps people whose client fetches it automatically, so the list gets worse while the dashboard says it got cleaner. Base rules on clicks, replies or purchases, and keep opens as a weak signal with a caption. Done when: The reporting page separates provider-confirmed outcomes from open estimates and carries the explanation beside the latter. No automation or segment definition can reference opens, enforced in the segment validator and tested. Export produces contacts with consent, the suppression list and per-campaign results, and reimports without loss. Deleting a contact removes their rows while keeping the suppression entry, which is the one thing that must survive. Do not build yet: Nothing after this. ### Out of scope (and why) - Operating your own sending infrastructure, which is a reputation problem measured in months and the actual reason people pay for this category. - Visual drag and drop email design, because the hard part of email HTML is client rendering rather than the editor. - Branching automations and journeys, which multiply every suppression and freezing question by the number of paths. - Payments, product recommendations and an audience network, which are three separate products wearing one login. ### README must contain - That suppression is checked inside the transaction that claims each recipient, and how to run the bypass test - That an audience is frozen to rows at send time and what that means for contacts added afterwards - That the provider is yours, that your domain's authentication is your responsibility, and to read the current bulk sender requirements of the major mailbox providers before the first send - That open numbers are estimates because some mail clients fetch remote content automatically, and that no rule may act on them - How to export everything and what a contact deletion does and does not remove ===== ARCHITECTURE.md ===== # Architecture · Bento ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 and TypeScript with a separate send worker process | a send that dies with the web request is a half-sent campaign | | Storage | PostgreSQL with suppression enforced in the send query | this belongs in the database because application code gets bypassed | | Sending | One user-supplied provider behind one interface, with a per-recipient idempotency key | you are not going to operate your own mail reputation | | Events | Provider webhooks with signature verification and replay tolerance | bounces and complaints are how suppression stays current | | Audience | Frozen to rows at send time, never recomputed mid-send | a moving segment is how people get two copies or none | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Contacts | Addresses with provenance and consent wording. | Any store, provided consent stays required at write. | | Suppression | The unmailable list, checked at claim time. | Not swappable for a filter at audience build time, which is the bug. | | Freeze | Recipient rows written once per campaign. | Any queue, provided the audience stops moving at send. | | Provider | Sending, idempotency and signed webhooks. | Any provider behind the same interface, with its own signature scheme. | | Reporting | Confirmed outcomes, labelled estimates and export. | Any presentation, provided opens never drive a rule. | ## 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 · Contacts, suppression, campaigns and recipients. - `EMAIL_PROVIDER_API_KEY` · required, secret · The only credential that can send. Rotate it if it ever reaches a log. - `EMAIL_WEBHOOK_SECRET` · required, secret · Every webhook is verified against this before it is parsed. - `SENDING_DOMAIN` · required · Verified with the provider, with authentication records in place. - `UNSUBSCRIBE_BASE_URL` · required · Per-recipient tokens are appended here. - `SOFT_BOUNCE_MAX_RETRIES` · required · After this a soft bounce stops being retried and is recorded. - `SEND_RATE_PER_MINUTE` · required · Below your provider's limit, checked against their current documentation. ## 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 · Bento product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 and TypeScript with a separate send worker process, PostgreSQL with suppression enforced in the send query, One user-supplied provider behind one interface, with a per-recipient idempotency key, Provider webhooks with signature verification and replay tolerance, Frozen to rows at send time, never recomputed mid-send. - 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 spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. - Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. - Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. - Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. - Building a re-engagement rule that deletes contacts who have not opened in six months quietly removes people whose mail client blocks the pixel entirely and keeps people whose client fetches it automatically, so the list gets worse while the dashboard says it got cleaner. Base rules on clicks, replies or purchases, and keep opens as a weak signal with a caption. ===== MILESTONES.md ===== # Delivery milestones · Bento Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Contacts that remember what they agreed to Every address carries where it came from and the exact wording it consented to. ### Steps 1. Require source and consent wording at import Not as a later field to fill in, which nobody does. Files: `src/lib/import.ts`, `tests/import.test.ts` 2. Normalise addresses once, at write Two rows for one person is two copies of every campaign. Files: `src/lib/email.ts` 3. Constrain consent in the schema A not-null column outlives a code review. Files: `migrations/001_contacts.sql` ### Done when - [ ] Imports without source and consent are refused - [ ] Every contact carries consent text and time - [ ] Case variants resolve to one contact - [ ] Existing rows keep their original consent wording ### Watch out - Importing a spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. ## M2 · Suppression that the sender cannot route around A suppressed address cannot be sent to by any code path in the application. ### Steps 1. Join suppression inside the claim transaction The check has to happen at the last possible moment. Files: `src/worker/claim.sql`, `tests/suppression.test.ts` 2. Make unsubscribe one click and no login A form that asks why is a form that fails to unsubscribe people. Files: `src/app/u/[token]/route.ts` 3. Test the direct call, not just the interface The bypass is always a script somebody wrote in a hurry. Files: `tests/suppression-bypass.test.ts` ### Done when - [ ] A direct send to a suppressed address sends nothing - [ ] Mid-send unsubscribes take effect immediately - [ ] Unsubscribe needs no login and one action - [ ] Case variants are suppressed too ### Watch out - Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. ## M3 · An audience frozen the moment you press send Recipient rows are written once, so a campaign cannot double-send or skip. ### Steps 1. Write recipient rows in one transaction This is the campaign's audience, forever. Files: `src/lib/freeze.ts` 2. Constrain one row per campaign and contact The cheapest protection against the double send. Files: `migrations/003_recipients.sql` 3. Resume from unclaimed rows A worker restart mid-campaign should be boring. Files: `tests/resume.test.ts` ### Done when - [ ] One recipient row per campaign and contact, enforced - [ ] Contacts added mid-send are excluded - [ ] A worker restart resends nothing - [ ] Frozen audience size and time are shown ### Watch out - Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. ## M4 · Handing messages to a provider, and hearing back Each message is sent once, and bounces and complaints flow into suppression automatically. ### Steps 1. Verify the signature before you parse An unverified webhook is an open write endpoint. Files: `src/app/api/webhook/route.ts` 2. Make replays harmless Providers redeliver, sometimes for days. Files: `src/lib/events.ts` 3. Reconcile unknown outcomes by asking Never by sending again. Files: `src/worker/reconcile.ts`, `tests/exactly-once.test.ts` ### Done when - [ ] Forged webhook signatures are rejected - [ ] Repeated events are idempotent - [ ] Hard bounces and complaints suppress immediately - [ ] An unknown outcome reconciles rather than resends ### Watch out - Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. ## M5 · Numbers that admit what they do not know (production only) Reporting distinguishes what was delivered from what a mail client fetched. ### Steps 1. Separate confirmed outcomes from estimates With the reason printed next to the estimate. Files: `src/app/campaigns/[id]/report.tsx` 2. Forbid opens in segment definitions In the validator, so it stays true when someone adds a feature. Files: `src/lib/segments.ts`, `tests/no-open-rules.test.ts` 3. Keep suppression through a deletion Deleting the person must not un-suppress the address. Files: `src/lib/deletion.ts` ### Done when - [ ] Confirmed outcomes and open estimates are separated - [ ] Segments cannot reference opens - [ ] Export reimports without loss - [ ] Deletion keeps the suppression entry ### Watch out - Building a re-engagement rule that deletes contacts who have not opened in six months quietly removes people whose mail client blocks the pixel entirely and keeps people whose client fetches it automatically, so the list gets worse while the dashboard says it got cleaner. Base rules on clicks, replies or purchases, and keep opens as a weak signal with a caption. ===== OPERATIONS.md ===== # Operations · Bento ## Backup The database, with the suppression table treated as the one that must never be restored to an older state. Restoring a backup from before a batch of unsubscribes would re-mail people who left, so suppression is exported continuously and merged forward after any restore. ## Restore Restore, then merge the exported suppression list forward before starting the worker, and confirm that a known unsubscribed address is still suppressed before any campaign runs. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Complaint rate and hard bounce rate per campaign, webhook lag, and recipients stuck in claimed. A rising complaint rate is the signal that matters and it matters before the volume does. ## Incident checklist The incident to rehearse is a campaign sending to the wrong audience. Stop the worker first, since unclaimed rows simply never send, then investigate. The frozen recipient rows are the record of exactly who was going to receive what. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A direct send to a suppressed address is confirmed to send nothing - [ ] A mid-send unsubscribe is confirmed to take effect immediately - [ ] A double send is confirmed prevented by constraint - [ ] Forged webhook signatures are confirmed rejected - [ ] Segment definitions are confirmed unable to reference opens ## Launch constraint Do not market omitted Bento 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. Contacts, suppression, campaigns and recipients. DATABASE_URL=postgres://user:pass@localhost:5432/newsletter # Required · secret. The only credential that can send. Rotate it if it ever reaches a log. EMAIL_PROVIDER_API_KEY=key-...redacted # Required · secret. Every webhook is verified against this before it is parsed. EMAIL_WEBHOOK_SECRET=whsec-...redacted # Required. Verified with the provider, with authentication records in place. SENDING_DOMAIN=mail.example.com # Required. Per-recipient tokens are appended here. UNSUBSCRIBE_BASE_URL=https://mail.example.com/u # Required. After this a soft bounce stops being retried and is recorded. SOFT_BOUNCE_MAX_RETRIES=3 # Required. Below your provider's limit, checked against their current documentation. SEND_RATE_PER_MINUTE=200
# Bento · indie build A newsletter sender whose hard problems are making suppression impossible to bypass, freezing the audience at send time so a running campaign cannot double-send or skip people, and reporting numbers that are honest about what modern mail clients actually do. 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 and TypeScript with a separate send worker process | a send that dies with the web request is a half-sent campaign | | Storage | PostgreSQL with suppression enforced in the send query | this belongs in the database because application code gets bypassed | | Sending | One user-supplied provider behind one interface, with a per-recipient idempotency key | you are not going to operate your own mail reputation | | Events | Provider webhooks with signature verification and replay tolerance | bounces and complaints are how suppression stays current | | Audience | Frozen to rows at send time, never recomputed mid-send | a moving segment is how people get two copies or none | ## 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 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 and the recipient freeze rely on transactions and unique constraints doing their job. - Get it: Your package manager, a container, or a small managed instance. - Verify: psql connects and the migrations apply - [ ] **An email provider account with webhooks** · free tiers exist at low volume - Why: Sending, bounce and complaint events all come from here, and suppression is only current because the webhooks arrive. - Get it: Create an account, verify a sending domain, and configure the webhook endpoint with a signing secret. - Verify: A test send delivers and its delivery event arrives signed - [ ] **A sending domain with authentication configured** · free beyond the domain - Why: Mailbox providers treat unauthenticated bulk mail badly, and the current requirements are published by each provider and change. - Get it: Set up the authentication records your provider documents, then read the current bulk sender requirements from the major mailbox providers and confirm you meet them. - Verify: Your provider reports the domain verified and a test message passes authentication checks at a mailbox you control - [ ] **Test mailboxes at two or three major providers** · free - Why: Suppression and reconciliation are testable locally, but rendering and delivery are only observable in a real inbox. - Get it: Create a mailbox at two or three providers and add them to a test segment. - Verify: A campaign to the test segment arrives and its events appear ## 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 sending infrastructure. - A drag and drop email designer. - Branching automations and journeys. - Payments, recommendations and an audience network. - sender reputation and deliverability operations - bounce, complaint, and suppression handling - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling If one of those is essential to you, that is the reason to keep paying for Bento, and the README should say so rather than pretend.
# Build brief · Bento 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 subscriber database and newsletter sender that cannot mail someone who asked it to stop to replace Bento. 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. The first is enforced by the database, not by your good intentions. An unsubscribe, a hard bounce or a spam complaint makes an address unmailable, permanently and immediately, and that has to be true even when a send is already running, even when a segment was computed an hour ago, and even when someone calls the send function directly in a script. So suppression is a table the send query joins against inside the same transaction that claims each recipient, with a test that calls the sender directly against a suppressed address and asserts that nothing goes out. Mailing someone after they unsubscribed is the one failure that costs you the domain rather than the customer. Second, do not report opens as if they were reads. Apple's Mail Privacy Protection downloads remote content in the background regardless of whether the recipient engaged with the message, through relays that hide their address, so a tracking pixel fires for mail nobody opened. Label the number for what it is, and never build a rule that deletes or re-mails people based on it. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 and TypeScript with a separate send worker process · a send that dies with the web request is a half-sent campaign - Storage: PostgreSQL with suppression enforced in the send query · this belongs in the database because application code gets bypassed - Sending: One user-supplied provider behind one interface, with a per-recipient idempotency key · you are not going to operate your own mail reputation - Events: Provider webhooks with signature verification and replay tolerance · bounces and complaints are how suppression stays current - Audience: Frozen to rows at send time, never recomputed mid-send · a moving segment is how people get two copies or none ### Data model (create this before Phase 1) - contacts(id, email_normalised, created_at, source, consent_text, consent_at, consent_ip_note) - suppressions(email_normalised primary key, reason, at, detail) · unsubscribed, hard bounced or complained - events(id, contact_id, kind, at, properties_json) · behaviour, for segmentation only - segments(id, name, definition_json, updated_at) - campaigns(id, name, subject, body_html, body_text, segment_id, state, frozen_at, sent_at) - recipients(campaign_id, contact_id, email_normalised, state, idempotency_key, provider_message_id, attempted_at, outcome) · written once when the audience freezes - deliveries(id, recipient_row, kind, at, provider_payload_excerpt) · delivered, bounced, complained, and opens kept separately and labelled ### Phase 1 · Contacts that remember what they agreed to Build: Store contacts with a normalised address, the source, the timestamp and the verbatim consent text shown at the time. Import from CSV requires the importer to state the source and paste the consent wording for that list, applied to every row. Reject an import with no source rather than defaulting it to something vague. Trap: Importing a spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. Done when: A CSV import without a stated source and consent wording is refused, tested through the route. Every contact row carries consent text and a timestamp, verified by a not-null constraint. Address normalisation is applied once at write time and the same address in two cases resolves to one contact, tested. Changing the consent wording for new imports leaves existing rows untouched. Do not build yet: No sending, segments, events or reporting. ### Phase 2 · Suppression that the sender cannot route around Build: Create the suppression table keyed on the normalised address. Make the send worker claim each recipient in a transaction that joins against suppression at that moment. Add an unsubscribe link carrying a per-recipient token that works without a login and writes suppression immediately. Write the test that calls the send function directly with a suppressed address. Trap: Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. Done when: A direct call to the send function with a suppressed address sends nothing and records the refusal, tested. Unsubscribing during an in-progress send stops that recipient's message, tested by suppressing mid-run. The unsubscribe link works with no login and is one action rather than a preferences form. Suppression is keyed on the normalised address so a case variant is suppressed too. Do not build yet: No segments, provider integration or reporting yet. ### Phase 3 · An audience frozen the moment you press send Build: On send, evaluate the segment once and write one recipient row per contact with a unique idempotency key, inside a transaction, then mark the campaign frozen. The worker only ever reads recipient rows. A contact added to the segment afterwards is not in this campaign, and the interface says so. Trap: Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. Done when: Recipient rows are written once per campaign and a unique constraint on campaign and contact prevents a second, tested by attempting it. A contact added to the segment during a send does not receive that campaign, tested. Restarting the worker mid-send resumes from unclaimed rows without resending claimed ones. The campaign page shows the frozen audience size and the time it was frozen. Do not build yet: No provider sending or webhooks yet. ### Phase 4 · Handing messages to a provider, and hearing back Build: Send through the provider with the recipient's idempotency key. Verify every webhook signature before parsing, tolerate replays by event identifier, and classify outcomes: a hard bounce or a complaint writes suppression immediately, a soft bounce retries within a stated limit. A send whose outcome is unknown is reconciled by asking the provider, never by resending. Trap: Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. Done when: A webhook with an invalid signature is rejected before parsing, tested with a forged payload. A repeated webhook event changes nothing, tested by delivering the same event twice. A hard bounce or complaint writes suppression within one processing cycle, tested. Killing the worker between the provider accepting and the database recording leaves one message and a row that reconciles to sent, tested with a fake provider. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers that admit what they do not know Build: Report delivered, bounced and complained from provider events. Keep opens in a separate section labelled as unreliable, with a one line explanation that some mail clients fetch remote content automatically whether or not anyone read the message. Do not offer any automation that acts on opens. Export contacts, consent, suppression and campaign results as CSV, and delete a contact fully on request. Trap: Building a re-engagement rule that deletes contacts who have not opened in six months quietly removes people whose mail client blocks the pixel entirely and keeps people whose client fetches it automatically, so the list gets worse while the dashboard says it got cleaner. Base rules on clicks, replies or purchases, and keep opens as a weak signal with a caption. Done when: The reporting page separates provider-confirmed outcomes from open estimates and carries the explanation beside the latter. No automation or segment definition can reference opens, enforced in the segment validator and tested. Export produces contacts with consent, the suppression list and per-campaign results, and reimports without loss. Deleting a contact removes their rows while keeping the suppression entry, which is the one thing that must survive. Do not build yet: Nothing after this. ### Out of scope (and why) - Operating your own sending infrastructure, which is a reputation problem measured in months and the actual reason people pay for this category. - Visual drag and drop email design, because the hard part of email HTML is client rendering rather than the editor. - Branching automations and journeys, which multiply every suppression and freezing question by the number of paths. - Payments, product recommendations and an audience network, which are three separate products wearing one login. ### README must contain - That suppression is checked inside the transaction that claims each recipient, and how to run the bypass test - That an audience is frozen to rows at send time and what that means for contacts added afterwards - That the provider is yours, that your domain's authentication is your responsibility, and to read the current bulk sender requirements of the major mailbox providers before the first send - That open numbers are estimates because some mail clients fetch remote content automatically, and that no rule may act on them - How to export everything and what a contact deletion does and does not remove
# Agent instructions · Bento indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 and TypeScript with a separate send worker process, PostgreSQL with suppression enforced in the send query, One user-supplied provider behind one interface, with a per-recipient idempotency key, Provider webhooks with signature verification and replay tolerance, Frozen to rows at send time, never recomputed mid-send. 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 spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. - Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. - Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. - Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status.
# Build plan · Bento A newsletter sender whose hard problems are making suppression impossible to bypass, freezing the audience at send time so a running campaign cannot double-send or skip people, and reporting numbers that are honest about what modern mail clients actually do. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Contacts that remember what they agreed to Every address carries where it came from and the exact wording it consented to. ### Steps 1. Require source and consent wording at import Not as a later field to fill in, which nobody does. Files: `src/lib/import.ts`, `tests/import.test.ts` 2. Normalise addresses once, at write Two rows for one person is two copies of every campaign. Files: `src/lib/email.ts` 3. Constrain consent in the schema A not-null column outlives a code review. Files: `migrations/001_contacts.sql` ### Done when - [ ] Imports without source and consent are refused - [ ] Every contact carries consent text and time - [ ] Case variants resolve to one contact - [ ] Existing rows keep their original consent wording ### Watch out - Importing a spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. ## Phase 2 · Suppression that the sender cannot route around A suppressed address cannot be sent to by any code path in the application. ### Steps 1. Join suppression inside the claim transaction The check has to happen at the last possible moment. Files: `src/worker/claim.sql`, `tests/suppression.test.ts` 2. Make unsubscribe one click and no login A form that asks why is a form that fails to unsubscribe people. Files: `src/app/u/[token]/route.ts` 3. Test the direct call, not just the interface The bypass is always a script somebody wrote in a hurry. Files: `tests/suppression-bypass.test.ts` ### Done when - [ ] A direct send to a suppressed address sends nothing - [ ] Mid-send unsubscribes take effect immediately - [ ] Unsubscribe needs no login and one action - [ ] Case variants are suppressed too ### Watch out - Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. ## Phase 3 · An audience frozen the moment you press send Recipient rows are written once, so a campaign cannot double-send or skip. ### Steps 1. Write recipient rows in one transaction This is the campaign's audience, forever. Files: `src/lib/freeze.ts` 2. Constrain one row per campaign and contact The cheapest protection against the double send. Files: `migrations/003_recipients.sql` 3. Resume from unclaimed rows A worker restart mid-campaign should be boring. Files: `tests/resume.test.ts` ### Done when - [ ] One recipient row per campaign and contact, enforced - [ ] Contacts added mid-send are excluded - [ ] A worker restart resends nothing - [ ] Frozen audience size and time are shown ### Watch out - Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. ## Phase 4 · Handing messages to a provider, and hearing back Each message is sent once, and bounces and complaints flow into suppression automatically. ### Steps 1. Verify the signature before you parse An unverified webhook is an open write endpoint. Files: `src/app/api/webhook/route.ts` 2. Make replays harmless Providers redeliver, sometimes for days. Files: `src/lib/events.ts` 3. Reconcile unknown outcomes by asking Never by sending again. Files: `src/worker/reconcile.ts`, `tests/exactly-once.test.ts` ### Done when - [ ] Forged webhook signatures are rejected - [ ] Repeated events are idempotent - [ ] Hard bounces and complaints suppress immediately - [ ] An unknown outcome reconciles rather than resends ### Watch out - Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. ## Not in this build - Your own sending infrastructure. - A drag and drop email designer. - Branching automations and journeys. - Payments, recommendations and an audience network. ## After v1, if you want it - Click tracking on an opt-in basis, which is a better signal than opens and still not a perfect one - A double opt-in flow, once consent storage has been used in anger for a while - Scheduled sends, which reuse the freeze exactly and add only a clock
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Contacts, suppression, campaigns and recipients. DATABASE_URL=postgres://user:pass@localhost:5432/newsletter # Required · secret. The only credential that can send. Rotate it if it ever reaches a log. EMAIL_PROVIDER_API_KEY=key-...redacted # Required · secret. Every webhook is verified against this before it is parsed. EMAIL_WEBHOOK_SECRET=whsec-...redacted # Required. Verified with the provider, with authentication records in place. SENDING_DOMAIN=mail.example.com # Required. Per-recipient tokens are appended here. UNSUBSCRIBE_BASE_URL=https://mail.example.com/u # Required. After this a soft bounce stops being retried and is recorded. SOFT_BOUNCE_MAX_RETRIES=3 # Required. Below your provider's limit, checked against their current documentation. SEND_RATE_PER_MINUTE=200
# Bento · product brief ## Problem The visible email + customer data loop is buildable, but a credible replacement needs more than the first screen. Bento earns its keep through deliverability, reputation, network, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A sender where an unsubscribe takes effect within the same second even mid-campaign, a running send cannot mail anyone twice, bounces and complaints suppress themselves, and no dashboard pretends an automatic image fetch was a human reading your newsletter. ## 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 sending infrastructure. - A drag and drop email designer. - Branching automations and journeys. - Payments, recommendations and an audience network. - sender reputation and deliverability operations - bounce, complaint, and suppression handling - large-scale segmentation and automation - payments, recommendations, and audience network - sender reputation, deliverability, and suppression handling ## Success criteria - A direct send to a suppressed address is confirmed to send nothing - A mid-send unsubscribe is confirmed to take effect immediately - A double send is confirmed prevented by constraint - Forged webhook signatures are confirmed rejected - Segment definitions are confirmed unable to reference opens
# Build brief · Bento 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 subscriber database and newsletter sender that cannot mail someone who asked it to stop to replace Bento. 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. The first is enforced by the database, not by your good intentions. An unsubscribe, a hard bounce or a spam complaint makes an address unmailable, permanently and immediately, and that has to be true even when a send is already running, even when a segment was computed an hour ago, and even when someone calls the send function directly in a script. So suppression is a table the send query joins against inside the same transaction that claims each recipient, with a test that calls the sender directly against a suppressed address and asserts that nothing goes out. Mailing someone after they unsubscribed is the one failure that costs you the domain rather than the customer. Second, do not report opens as if they were reads. Apple's Mail Privacy Protection downloads remote content in the background regardless of whether the recipient engaged with the message, through relays that hide their address, so a tracking pixel fires for mail nobody opened. Label the number for what it is, and never build a rule that deletes or re-mails people based on it. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 and TypeScript with a separate send worker process · a send that dies with the web request is a half-sent campaign - Storage: PostgreSQL with suppression enforced in the send query · this belongs in the database because application code gets bypassed - Sending: One user-supplied provider behind one interface, with a per-recipient idempotency key · you are not going to operate your own mail reputation - Events: Provider webhooks with signature verification and replay tolerance · bounces and complaints are how suppression stays current - Audience: Frozen to rows at send time, never recomputed mid-send · a moving segment is how people get two copies or none ### Data model (create this before Phase 1) - contacts(id, email_normalised, created_at, source, consent_text, consent_at, consent_ip_note) - suppressions(email_normalised primary key, reason, at, detail) · unsubscribed, hard bounced or complained - events(id, contact_id, kind, at, properties_json) · behaviour, for segmentation only - segments(id, name, definition_json, updated_at) - campaigns(id, name, subject, body_html, body_text, segment_id, state, frozen_at, sent_at) - recipients(campaign_id, contact_id, email_normalised, state, idempotency_key, provider_message_id, attempted_at, outcome) · written once when the audience freezes - deliveries(id, recipient_row, kind, at, provider_payload_excerpt) · delivered, bounced, complained, and opens kept separately and labelled ### Phase 1 · Contacts that remember what they agreed to Build: Store contacts with a normalised address, the source, the timestamp and the verbatim consent text shown at the time. Import from CSV requires the importer to state the source and paste the consent wording for that list, applied to every row. Reject an import with no source rather than defaulting it to something vague. Trap: Importing a spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. Done when: A CSV import without a stated source and consent wording is refused, tested through the route. Every contact row carries consent text and a timestamp, verified by a not-null constraint. Address normalisation is applied once at write time and the same address in two cases resolves to one contact, tested. Changing the consent wording for new imports leaves existing rows untouched. Do not build yet: No sending, segments, events or reporting. ### Phase 2 · Suppression that the sender cannot route around Build: Create the suppression table keyed on the normalised address. Make the send worker claim each recipient in a transaction that joins against suppression at that moment. Add an unsubscribe link carrying a per-recipient token that works without a login and writes suppression immediately. Write the test that calls the send function directly with a suppressed address. Trap: Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. Done when: A direct call to the send function with a suppressed address sends nothing and records the refusal, tested. Unsubscribing during an in-progress send stops that recipient's message, tested by suppressing mid-run. The unsubscribe link works with no login and is one action rather than a preferences form. Suppression is keyed on the normalised address so a case variant is suppressed too. Do not build yet: No segments, provider integration or reporting yet. ### Phase 3 · An audience frozen the moment you press send Build: On send, evaluate the segment once and write one recipient row per contact with a unique idempotency key, inside a transaction, then mark the campaign frozen. The worker only ever reads recipient rows. A contact added to the segment afterwards is not in this campaign, and the interface says so. Trap: Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. Done when: Recipient rows are written once per campaign and a unique constraint on campaign and contact prevents a second, tested by attempting it. A contact added to the segment during a send does not receive that campaign, tested. Restarting the worker mid-send resumes from unclaimed rows without resending claimed ones. The campaign page shows the frozen audience size and the time it was frozen. Do not build yet: No provider sending or webhooks yet. ### Phase 4 · Handing messages to a provider, and hearing back Build: Send through the provider with the recipient's idempotency key. Verify every webhook signature before parsing, tolerate replays by event identifier, and classify outcomes: a hard bounce or a complaint writes suppression immediately, a soft bounce retries within a stated limit. A send whose outcome is unknown is reconciled by asking the provider, never by resending. Trap: Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. Done when: A webhook with an invalid signature is rejected before parsing, tested with a forged payload. A repeated webhook event changes nothing, tested by delivering the same event twice. A hard bounce or complaint writes suppression within one processing cycle, tested. Killing the worker between the provider accepting and the database recording leaves one message and a row that reconciles to sent, tested with a fake provider. Do not build yet: No reporting or export yet. ### Phase 5 · Numbers that admit what they do not know Build: Report delivered, bounced and complained from provider events. Keep opens in a separate section labelled as unreliable, with a one line explanation that some mail clients fetch remote content automatically whether or not anyone read the message. Do not offer any automation that acts on opens. Export contacts, consent, suppression and campaign results as CSV, and delete a contact fully on request. Trap: Building a re-engagement rule that deletes contacts who have not opened in six months quietly removes people whose mail client blocks the pixel entirely and keeps people whose client fetches it automatically, so the list gets worse while the dashboard says it got cleaner. Base rules on clicks, replies or purchases, and keep opens as a weak signal with a caption. Done when: The reporting page separates provider-confirmed outcomes from open estimates and carries the explanation beside the latter. No automation or segment definition can reference opens, enforced in the segment validator and tested. Export produces contacts with consent, the suppression list and per-campaign results, and reimports without loss. Deleting a contact removes their rows while keeping the suppression entry, which is the one thing that must survive. Do not build yet: Nothing after this. ### Out of scope (and why) - Operating your own sending infrastructure, which is a reputation problem measured in months and the actual reason people pay for this category. - Visual drag and drop email design, because the hard part of email HTML is client rendering rather than the editor. - Branching automations and journeys, which multiply every suppression and freezing question by the number of paths. - Payments, product recommendations and an audience network, which are three separate products wearing one login. ### README must contain - That suppression is checked inside the transaction that claims each recipient, and how to run the bypass test - That an audience is frozen to rows at send time and what that means for contacts added afterwards - That the provider is yours, that your domain's authentication is your responsibility, and to read the current bulk sender requirements of the major mailbox providers before the first send - That open numbers are estimates because some mail clients fetch remote content automatically, and that no rule may act on them - How to export everything and what a contact deletion does and does not remove
# Architecture · Bento ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 and TypeScript with a separate send worker process | a send that dies with the web request is a half-sent campaign | | Storage | PostgreSQL with suppression enforced in the send query | this belongs in the database because application code gets bypassed | | Sending | One user-supplied provider behind one interface, with a per-recipient idempotency key | you are not going to operate your own mail reputation | | Events | Provider webhooks with signature verification and replay tolerance | bounces and complaints are how suppression stays current | | Audience | Frozen to rows at send time, never recomputed mid-send | a moving segment is how people get two copies or none | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Contacts | Addresses with provenance and consent wording. | Any store, provided consent stays required at write. | | Suppression | The unmailable list, checked at claim time. | Not swappable for a filter at audience build time, which is the bug. | | Freeze | Recipient rows written once per campaign. | Any queue, provided the audience stops moving at send. | | Provider | Sending, idempotency and signed webhooks. | Any provider behind the same interface, with its own signature scheme. | | Reporting | Confirmed outcomes, labelled estimates and export. | Any presentation, provided opens never drive a rule. | ## 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 · Contacts, suppression, campaigns and recipients. - `EMAIL_PROVIDER_API_KEY` · required, secret · The only credential that can send. Rotate it if it ever reaches a log. - `EMAIL_WEBHOOK_SECRET` · required, secret · Every webhook is verified against this before it is parsed. - `SENDING_DOMAIN` · required · Verified with the provider, with authentication records in place. - `UNSUBSCRIBE_BASE_URL` · required · Per-recipient tokens are appended here. - `SOFT_BOUNCE_MAX_RETRIES` · required · After this a soft bounce stops being retried and is recorded. - `SEND_RATE_PER_MINUTE` · required · Below your provider's limit, checked against their current documentation. ## 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 · Bento product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 and TypeScript with a separate send worker process, PostgreSQL with suppression enforced in the send query, One user-supplied provider behind one interface, with a per-recipient idempotency key, Provider webhooks with signature verification and replay tolerance, Frozen to rows at send time, never recomputed mid-send. - 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 spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. - Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. - Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. - Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. - Building a re-engagement rule that deletes contacts who have not opened in six months quietly removes people whose mail client blocks the pixel entirely and keeps people whose client fetches it automatically, so the list gets worse while the dashboard says it got cleaner. Base rules on clicks, replies or purchases, and keep opens as a weak signal with a caption.
# Delivery milestones · Bento Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Contacts that remember what they agreed to Every address carries where it came from and the exact wording it consented to. ### Steps 1. Require source and consent wording at import Not as a later field to fill in, which nobody does. Files: `src/lib/import.ts`, `tests/import.test.ts` 2. Normalise addresses once, at write Two rows for one person is two copies of every campaign. Files: `src/lib/email.ts` 3. Constrain consent in the schema A not-null column outlives a code review. Files: `migrations/001_contacts.sql` ### Done when - [ ] Imports without source and consent are refused - [ ] Every contact carries consent text and time - [ ] Case variants resolve to one contact - [ ] Existing rows keep their original consent wording ### Watch out - Importing a spreadsheet of addresses with no provenance gives you a list you cannot honestly mail, and you will not discover which rows were the problem later because nothing recorded it. Make source and consent wording required inputs of the import itself, and store them per contact rather than per import. ## M2 · Suppression that the sender cannot route around A suppressed address cannot be sent to by any code path in the application. ### Steps 1. Join suppression inside the claim transaction The check has to happen at the last possible moment. Files: `src/worker/claim.sql`, `tests/suppression.test.ts` 2. Make unsubscribe one click and no login A form that asks why is a form that fails to unsubscribe people. Files: `src/app/u/[token]/route.ts` 3. Test the direct call, not just the interface The bypass is always a script somebody wrote in a hurry. Files: `tests/suppression-bypass.test.ts` ### Done when - [ ] A direct send to a suppressed address sends nothing - [ ] Mid-send unsubscribes take effect immediately - [ ] Unsubscribe needs no login and one action - [ ] Case variants are suppressed too ### Watch out - Filtering suppressed addresses when the audience is built means anyone who unsubscribes during the hour your campaign takes to send still receives it, and that is precisely the moment they are paying attention. Check suppression when each recipient is claimed, inside the transaction, not when the list was assembled. ## M3 · An audience frozen the moment you press send Recipient rows are written once, so a campaign cannot double-send or skip. ### Steps 1. Write recipient rows in one transaction This is the campaign's audience, forever. Files: `src/lib/freeze.ts` 2. Constrain one row per campaign and contact The cheapest protection against the double send. Files: `migrations/003_recipients.sql` 3. Resume from unclaimed rows A worker restart mid-campaign should be boring. Files: `tests/resume.test.ts` ### Done when - [ ] One recipient row per campaign and contact, enforced - [ ] Contacts added mid-send are excluded - [ ] A worker restart resends nothing - [ ] Frozen audience size and time are shown ### Watch out - Re-evaluating the segment on each batch means a contact whose event fires mid-send gets added to a later batch and possibly mailed twice, while a contact removed mid-send silently disappears from a campaign that reports as complete. Freeze the list to rows, and treat the segment as a query that ran once. ## M4 · Handing messages to a provider, and hearing back Each message is sent once, and bounces and complaints flow into suppression automatically. ### Steps 1. Verify the signature before you parse An unverified webhook is an open write endpoint. Files: `src/app/api/webhook/route.ts` 2. Make replays harmless Providers redeliver, sometimes for days. Files: `src/lib/events.ts` 3. Reconcile unknown outcomes by asking Never by sending again. Files: `src/worker/reconcile.ts`, `tests/exactly-once.test.ts` ### Done when - [ ] Forged webhook signatures are rejected - [ ] Repeated events are idempotent - [ ] Hard bounces and complaints suppress immediately - [ ] An unknown outcome reconciles rather than resends ### Watch out - Treating a request timeout as a failure and retrying sends the newsletter twice to whoever the provider had already accepted, and the duplicate lands within seconds, which is when a subscriber decides you are careless. Record the attempt before the call, and settle an unknown outcome by querying the provider's message status. ## M5 · Numbers that admit what they do not know (production only) Reporting distinguishes what was delivered from what a mail client fetched. ### Steps 1. Separate confirmed outcomes from estimates With the reason printed next to the estimate. Files: `src/app/campaigns/[id]/report.tsx` 2. Forbid opens in segment definitions In the validator, so it stays true when someone adds a feature. Files: `src/lib/segments.ts`, `tests/no-open-rules.test.ts` 3. Keep suppression through a deletion Deleting the person must not un-suppress the address. Files: `src/lib/deletion.ts` ### Done when - [ ] Confirmed outcomes and open estimates are separated - [ ] Segments cannot reference opens - [ ] Export reimports without loss - [ ] Deletion keeps the suppression entry ### Watch out - Building a re-engagement rule that deletes contacts who have not opened in six months quietly removes people whose mail client blocks the pixel entirely and keeps people whose client fetches it automatically, so the list gets worse while the dashboard says it got cleaner. Base rules on clicks, replies or purchases, and keep opens as a weak signal with a caption.
# Operations · Bento ## Backup The database, with the suppression table treated as the one that must never be restored to an older state. Restoring a backup from before a batch of unsubscribes would re-mail people who left, so suppression is exported continuously and merged forward after any restore. ## Restore Restore, then merge the exported suppression list forward before starting the worker, and confirm that a known unsubscribed address is still suppressed before any campaign runs. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Complaint rate and hard bounce rate per campaign, webhook lag, and recipients stuck in claimed. A rising complaint rate is the signal that matters and it matters before the volume does. ## Incident checklist The incident to rehearse is a campaign sending to the wrong audience. Stop the worker first, since unclaimed rows simply never send, then investigate. The frozen recipient rows are the record of exactly who was going to receive what. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A direct send to a suppressed address is confirmed to send nothing - [ ] A mid-send unsubscribe is confirmed to take effect immediately - [ ] A double send is confirmed prevented by constraint - [ ] Forged webhook signatures are confirmed rejected - [ ] Segment definitions are confirmed unable to reference opens ## Launch constraint Do not market omitted Bento 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. Contacts, suppression, campaigns and recipients. DATABASE_URL=postgres://user:pass@localhost:5432/newsletter # Required · secret. The only credential that can send. Rotate it if it ever reaches a log. EMAIL_PROVIDER_API_KEY=key-...redacted # Required · secret. Every webhook is verified against this before it is parsed. EMAIL_WEBHOOK_SECRET=whsec-...redacted # Required. Verified with the provider, with authentication records in place. SENDING_DOMAIN=mail.example.com # Required. Per-recipient tokens are appended here. UNSUBSCRIBE_BASE_URL=https://mail.example.com/u # Required. After this a soft bounce stops being retried and is recorded. SOFT_BOUNCE_MAX_RETRIES=3 # Required. Below your provider's limit, checked against their current documentation. SEND_RATE_PER_MINUTE=200
$ choose a build depth, inspect the files, then open the complete pack in your agent
Bento: The editor is trivial compared with inbox placement, compliance, reputation, unsubscribe handling, analytics, and audience growth infrastructure.
xsender reputation and deliverability operations
xbounce, complaint, and suppression handling
xlarge-scale segmentation and automation
xpayments, recommendations, and audience network
xsender reputation, deliverability, and suppression handling
Don't feel like building it? These folks already made it free.
all 5 free alternatives to Bento →· no votes, no pay-to-list · just what's real
Bento pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| marketing platform | $29/workspace | — | Up to 5,000 active users included; unlimited marketing sends; team seats included. |
| transactional email free | $0/workspace | — | First 100 transactional emails free. |
| transactional email | $5/workspace | — | Up to 12,500 transactional emails/month; then $0.09 per 1,000 emails. |
| bento chat add-on | $30/workspace | — | Chat add-on; numeric usage caps not published in pricing text. |
free tierno free marketing tier; 30-day trial; transactional email includes first 100 emails free.
billingmonthly only public pricing; no annual plan verified
hidden costsMarketing active-user overages: $0.01/user for 5,001-50,000, $0.009 for 50,001-250,000, $0.008 for 250,001+; transactional overage $0.09/1,000 emails.
verified 2026-08-10 · source ↗
Vibecode Bento
Kinda. The core of Bento is buildable in a weekend with the prompt on this page, but there are real gaps: sender reputation and deliverability operations, bounce, complaint, and suppression handling. Read the honest list above before committing.
How much does Bento cost?
Bento's pricing is usage-based or varies by plan · The pricing model is not safely comparable as one fixed monthly USD amount. Review the official source before publication..
What do I lose by replacing Bento?
Honestly: sender reputation and deliverability operations; bounce, complaint, and suppression handling; large-scale segmentation and automation; payments, recommendations, and audience network; sender reputation, deliverability, and suppression handling. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Bento?
Yes: Mautic (Behavior tracking, segments and campaigns with years of battle scars; operating it is also a campaign.) Plunk (Events, conditions and automated email in one open platform; analytics are leaner than Bento's.) Dittofeed (Event-driven segments and multichannel journeys with a marketer-facing UI; the infrastructure diagram is not decorative.) All 5 curated free alternatives are at vibecodeit.com/bento/alternatives. The prompt is for when you want it exactly your way.