Vibecode Groove
track this build4 phases, 12 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Groove, turn one support mailbox into an assigned team inbox with a knowledge base. The hard boundary is email reliability, reporting, rules, integrations, and mature simplicity, plus channel integrations and support operations.
You are building a lean indie version of Groove. 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 ===== # Groove · indie build A small shared inbox whose hard problems are threading a reply chain when clients strip the headers you rely on, letting two people work one queue without replying to the same customer twice, and sending automatic mail without joining a loop. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and a separate worker process | receiving mail and serving an interface have different failure modes and should not share a crash | | Storage | PostgreSQL with the raw message source stored alongside the parsed fields | parsers improve and the original bytes are the only thing you can reparse from | | Receiving | One mailbox polled or delivered by webhook, deduplicated by message identifier | every delivery path redelivers, and a duplicate ticket is a customer answered twice | | Threading | Reply-chain headers first, a signed token in the reply address as the fallback | subject matching merges different customers and is the classic way this breaks | | Sending | One outbound identity with the auto-response rules enforced in code | the loop protection belongs where it cannot be configured away | ## 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 interface and the worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free - Why: Holds conversations, messages and the append-only event log, and the event log wants a database that will not lose a write. - Get it: Your package manager, or a container image. - Verify: psql connects and the migrations apply cleanly - [ ] **A dedicated mailbox with programmatic access and a catch-all or plus-addressing** · a few pounds a month - Why: The signed reply token needs to arrive back at an address you control, and that requires either a catch-all domain or an address scheme the provider will route to you. - Get it: A mailbox on a domain you control, with either catch-all delivery or reliable plus-addressing. - Verify: Mail sent to an address containing a generated token arrives in the mailbox - [ ] **An authenticated sending path for the domain you reply from** · free to a few pounds a month - Why: Replies that fail authentication land in spam and the customer thinks you ignored them, which is the failure this whole product exists to avoid. - Get it: Configure the authentication records for your domain with whichever sending service you use and check them from a receiving account. - Verify: A reply arrives in an inbox at a major provider and passes its authentication checks - [ ] **The automatic response policy** · free - Why: The loop protection has to be decided rather than inherited, because every value in it is a trade between being responsive and mailing a robot for eight hours. - Get it: Write down the suppression window, the hourly cap, and which inbound conditions skip a response entirely. - Verify: All three appear in the configuration and each is asserted in a test ## 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: - Guaranteed deliverability to any inbox. - Social and messaging channels. - A live chat widget. - An assistant that answers tickets from history. - email reliability, reporting, rules, integrations, and mature simplicity - omnichannel integrations - large-scale deliverability - AI resolution tuning - enterprise routing and compliance If one of those is essential to you, that is the reason to keep paying for Groove, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Groove 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 shared support mailbox that threads by the reply chain, assigns without collisions, and never mails a robot back to replace Groove. 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 A support inbox fails in two ways, and the second one is louder than the first. It loses messages. So every message that arrives is written to the database with its raw source intact before any rule, assignment, notification or auto-reply runs. Nothing is deleted on receipt: spam is a flag with a reason, not a deletion, because a filter that silently eats real customers is undetectable from the inside. And it mails robots. RFC 3834 sets the rules and they are not optional if you intend to send any automatic response: a responder MUST NOT reply to a message carrying an Auto-Submitted header field with any value other than 'no', MUST NOT generate a response whose destination would be a null return path such as an empty SMTP MAIL FROM, SHOULD include Auto-Submitted with the value 'auto-replied' on the responses it does send, and SHOULD NOT send the same response to the same sender more than once within a period of several days, with seven days recommended as the default. Two responders that ignore any of this will mail each other through the night, and the first person to notice will be the postmaster of whoever you are looping with. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and a separate worker process · receiving mail and serving an interface have different failure modes and should not share a crash - Storage: PostgreSQL with the raw message source stored alongside the parsed fields · parsers improve and the original bytes are the only thing you can reparse from - Receiving: One mailbox polled or delivered by webhook, deduplicated by message identifier · every delivery path redelivers, and a duplicate ticket is a customer answered twice - Threading: Reply-chain headers first, a signed token in the reply address as the fallback · subject matching merges different customers and is the classic way this breaks - Sending: One outbound identity with the auto-response rules enforced in code · the loop protection belongs where it cannot be configured away ### Data model (create this before Phase 1) - conversations(id, subject, contact_id, mailbox_id, state, assignee_id, assigned_at, last_activity_at, token) · token is the signed value that appears in the reply address - messages(id, conversation_id, direction, message_id_header, in_reply_to, references_json, from_addr, to_json, sent_at, received_at, body_text, body_html, raw_path, spam_reason, auto_submitted) - contacts(id, email, name, first_seen_at, deleted_at) - events(id, conversation_id, actor_id, kind, payload_json, at) · append-only: assigned, replied, noted, state changed, flagged - notes(id, conversation_id, author_id, body, at) · internal, never sent, and rendered so it cannot be mistaken for a reply - attachments(id, message_id, filename_original, stored_name, sha256, bytes, mime) - suppressions(address, reason, until) · the seven-day auto-response record and anything you refuse to mail - raw_path is not optional. The parsed fields are a view of the original message and are regenerable from it; the original is not regenerable from them. ### Phase 1 · Receive it, store it, then do anything else Build: Fetch or receive into a worker, write the raw bytes to disk and the row in one transaction, then return. Deduplicate on the message identifier header plus the mailbox. Run parsing, rules and notifications afterwards, and let them fail without losing the message. Flag spam with a reason rather than dropping it. Trap: Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. Done when: A message whose body cannot be parsed still creates a conversation containing its raw source and a visible parse error, tested with a deliberately malformed message. Delivering the same message twice produces one conversation and one message row, tested with a duplicate identifier. Every message row has a raw file on disk whose hash matches, asserted across the whole table by a check command. A message flagged as spam is visible in a spam view with its reason and can be restored to the inbox in one action. Do not build yet: No threading, assignment, replies or attachments yet. ### Phase 2 · Threading by the reply chain, never by subject Build: Thread on In-Reply-To and the References chain first. Add a signed token to the reply address you send from, and use it when the chain is missing. Only if both are absent, start a new conversation. Never merge on subject, and never merge two conversations automatically. Trap: Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. Done when: A reply carrying only References and no In-Reply-To attaches correctly, tested with a fixture from a real client. A reply with all threading headers stripped but sent to the token address attaches correctly, tested. Two messages from different addresses with identical subjects create two conversations, tested. A tampered or expired token is rejected and starts a new conversation rather than attaching to the one it names, tested with a forged value. Do not build yet: No assignment, replies, notifications or attachments yet. ### Phase 3 · Two people, one queue, no double replies Build: Record every assignment, state change, note and reply as an event row. Show who else has the conversation open and whether they are typing a reply, using a short-lived presence record. Warn before sending when someone else has replied since you opened it, and show what they sent. Trap: Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. Done when: Sending a reply when another reply arrived after your view loaded is blocked with the newer reply shown and requires an explicit confirmation, tested by racing two sessions. Every assignment, state change, note and reply produces an event row and no event row is ever updated, enforced in the schema. Presence expires on its own so a crashed browser does not leave a conversation looking permanently occupied, tested by advancing the clock. The conversation history renders events and messages in one timeline with internal notes visibly distinct from sent replies. Do not build yet: No outbound sending rules, attachments, retention or export yet. ### Phase 4 · Outbound mail that does not join a loop Build: Before any automatic response, check the inbound message for an Auto-Submitted header with a value other than 'no', a null return path, and list headers, and skip the response if any is present with the reason recorded. Stamp your own automatic responses as auto-replied. Record a suppression per recipient and do not repeat within the configured window. Trap: An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. Done when: A message carrying an Auto-Submitted header with any value other than 'no' receives no automatic response and the skip is recorded with its reason, tested for several values. A message with a null return path receives no automatic response, tested. Every automatic response this system sends carries Auto-Submitted with the value 'auto-replied', asserted on the outbound source. A second automatic response to the same address inside the configured window is suppressed, tested at the boundary, and an hourly cap exists and raises a visible alert when reached. Do not build yet: No attachments, retention, export or search yet. ### Phase 5 · Attachments, deletion and taking the mailbox with you Build: Store attachments under generated names outside the web root, keeping the supplied filename as a label only, and serve downloads through a handler that sets the type itself. Delete a contact with every message, attachment and event, including the raw files. Export conversations as standard mailbox files plus a JSON sidecar, and take automatic backups with a tested restore. Trap: Saving an attachment under the filename the sender supplied puts a sender-chosen path on your disk and a sender-chosen extension in front of your web server, which is a traversal and an execution problem in one line of convenience. Generate the stored name from the hash, keep the original as a display label, and never build a filesystem path from anything that arrived in a message. Done when: An attachment with a crafted filename containing path separators is stored under a generated name in the expected directory, tested with a traversal attempt. Downloads are served by a handler that sets the content type from your own detection rather than from the message, verified by review. Deleting a contact removes their messages, attachments, events and raw files, verified by checking every table and the disk afterwards. Export produces standard mailbox files that open in an ordinary mail client, and a restore from backup is exercised in the test suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Guaranteed deliverability to any inbox, which is an operational commitment involving reputation, authentication and years of sending history that a self-hosted inbox cannot honestly make. - Social and messaging channels, each of which is its own integration with its own retention rules and its own idea of what a conversation is. - A live chat widget, which is a second real-time product with presence, reconnection and offline handling of its own. - An assistant that answers tickets from your history, which needs the knowledge base and the threading correct first or it will confidently answer with the wrong customer's context. ### README must contain - That every message is written with its raw source before any rule or notification runs, and that spam is flagged rather than deleted - That threading uses the reply chain and a signed token, never the subject, with the reason - The exact automatic response rules implemented, naming the specification and the suppression window - That attachments are stored under generated names and served with a type your own code decided - How to export the mailbox as standard mailbox files and how the restore is tested ===== AGENTS.md ===== # Agent instructions · Groove indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript and a separate worker process, PostgreSQL with the raw message source stored alongside the parsed fields, One mailbox polled or delivered by webhook, deduplicated by message identifier, Reply-chain headers first, a signed token in the reply address as the fallback, One outbound identity with the auto-response rules enforced in code. 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 - Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. - Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. - Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. - An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. ===== BUILD_PLAN.md ===== # Build plan · Groove A small shared inbox whose hard problems are threading a reply chain when clients strip the headers you rely on, letting two people work one queue without replying to the same customer twice, and sending automatic mail without joining a loop. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Receive it, store it, then do anything else Every inbound message is persisted with its raw source before any processing, and redelivery creates nothing new. ### Steps 1. Write raw bytes and the row before anything else One transaction, then return, then process. Files: `worker/receive.ts`, `tests/receive.test.ts` 2. Deduplicate on the message identifier Because every delivery mechanism redelivers eventually. Files: `worker/dedupe.ts` 3. Make spam a flag with a reason A silent filter eating real mail cannot be discovered. Files: `src/spam/flag.ts` ### Done when - [ ] An unparseable message still creates a conversation - [ ] Redelivery creates one conversation and one message - [ ] Every message row has a matching raw file - [ ] Spam is flagged with a reason and restorable ### Watch out - Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. ## Phase 2 · Threading by the reply chain, never by subject Replies attach to the right conversation even when a client rewrites the headers you hoped for. ### Steps 1. Follow In-Reply-To and the References chain In that order, and handle a chain that has been truncated. Files: `src/threading/chain.ts`, `tests/threading.test.ts` 2. Sign the token in the reply address Verified on the way back in, and rejected if it does not verify. Files: `src/threading/token.ts`, `tests/token.test.ts` 3. Never merge on subject, never merge automatically A wrongly merged conversation leaks one customer to another. Files: `src/threading/index.ts` ### Done when - [ ] References-only replies attach correctly - [ ] A stripped-header reply attaches by token - [ ] Identical subjects from different senders stay separate - [ ] A forged token starts a new conversation ### Watch out - Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. ## Phase 3 · Two people, one queue, no double replies Assignment and activity are an append-only event log, and a second agent opening a conversation sees the first one there. ### Steps 1. Append an event for everything that happens The current state is a projection; the events are the record. Files: `src/events/append.ts`, `src/db/schema.sql` 2. Check for newer replies at send time Not at page load, which is where the race lives. Files: `src/replies/send.ts`, `tests/collision.test.ts` 3. Expire presence on a timer A crashed tab should not lock a customer out of an answer. Files: `src/presence/index.ts` ### Done when - [ ] A racing reply is blocked with the newer one shown - [ ] Events are append-only at the database level - [ ] Presence expires without a clean disconnect - [ ] Notes are visibly distinct from replies in the timeline ### Watch out - Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. ## Phase 4 · Outbound mail that does not join a loop Automatic responses obey the published rules and the same address is never auto-answered twice in a week. ### Steps 1. Check Auto-Submitted, return path and list headers first Three cheap checks that prevent the expensive failure. Files: `src/outbound/autoreply.ts`, `tests/autoreply.test.ts` 2. Stamp your own automatic responses So the responder on the other side can do the same for you. Files: `src/outbound/headers.ts` 3. Suppress per recipient and cap per hour The suppression window plus a cap is what breaks a novel loop. Files: `src/outbound/suppress.ts`, `tests/suppress.test.ts` ### Done when - [ ] Auto-Submitted messages receive no automatic response - [ ] A null return path receives no automatic response - [ ] Outbound automatic responses are stamped auto-replied - [ ] Repeat responses are suppressed and an hourly cap alerts ### Watch out - An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. ## Not in this build - Guaranteed deliverability to any inbox. - Social and messaging channels. - A live chat widget. - An assistant that answers tickets from history. ## After v1, if you want it - Saved replies with variables, once the threading and collision work is settled and boring - A first-response time report computed from the event log rather than from conversation timestamps - A public knowledge base fed from the replies agents keep retyping, which is where the repetition already is ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Conversations, messages, events and suppressions. DATABASE_URL=postgres://user:pass@localhost:5432/inbox # Required. Original message bytes. Everything parsed is regenerable from here. RAW_MAIL_DIR=./data/raw # Required. Outside the web root, generated names only. ATTACHMENT_DIR=./data/attachments # Required · secret. Signs the token in the reply address. Rotating it invalidates outstanding threads, so plan for overlap. REPLY_TOKEN_SECRET=redacted # Required. Where token addresses are routed. Needs catch-all or plus-addressing. REPLY_DOMAIN=reply.example.com # Required. No second automatic response to the same address inside this window. AUTOREPLY_SUPPRESS_DAYS=7 # Required. Total automatic responses per hour. Hitting it raises a visible alert. AUTOREPLY_HOURLY_CAP=50 # Required. Database dumps and raw mail. The restore is exercised in the suite. BACKUP_DIR=./data/backups
You are building a lean indie version of Groove. 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 ===== # Groove · indie build A small shared inbox whose hard problems are threading a reply chain when clients strip the headers you rely on, letting two people work one queue without replying to the same customer twice, and sending automatic mail without joining a loop. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and a separate worker process | receiving mail and serving an interface have different failure modes and should not share a crash | | Storage | PostgreSQL with the raw message source stored alongside the parsed fields | parsers improve and the original bytes are the only thing you can reparse from | | Receiving | One mailbox polled or delivered by webhook, deduplicated by message identifier | every delivery path redelivers, and a duplicate ticket is a customer answered twice | | Threading | Reply-chain headers first, a signed token in the reply address as the fallback | subject matching merges different customers and is the classic way this breaks | | Sending | One outbound identity with the auto-response rules enforced in code | the loop protection belongs where it cannot be configured away | ## 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 interface and the worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free - Why: Holds conversations, messages and the append-only event log, and the event log wants a database that will not lose a write. - Get it: Your package manager, or a container image. - Verify: psql connects and the migrations apply cleanly - [ ] **A dedicated mailbox with programmatic access and a catch-all or plus-addressing** · a few pounds a month - Why: The signed reply token needs to arrive back at an address you control, and that requires either a catch-all domain or an address scheme the provider will route to you. - Get it: A mailbox on a domain you control, with either catch-all delivery or reliable plus-addressing. - Verify: Mail sent to an address containing a generated token arrives in the mailbox - [ ] **An authenticated sending path for the domain you reply from** · free to a few pounds a month - Why: Replies that fail authentication land in spam and the customer thinks you ignored them, which is the failure this whole product exists to avoid. - Get it: Configure the authentication records for your domain with whichever sending service you use and check them from a receiving account. - Verify: A reply arrives in an inbox at a major provider and passes its authentication checks - [ ] **The automatic response policy** · free - Why: The loop protection has to be decided rather than inherited, because every value in it is a trade between being responsive and mailing a robot for eight hours. - Get it: Write down the suppression window, the hourly cap, and which inbound conditions skip a response entirely. - Verify: All three appear in the configuration and each is asserted in a test ## 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: - Guaranteed deliverability to any inbox. - Social and messaging channels. - A live chat widget. - An assistant that answers tickets from history. - email reliability, reporting, rules, integrations, and mature simplicity - omnichannel integrations - large-scale deliverability - AI resolution tuning - enterprise routing and compliance If one of those is essential to you, that is the reason to keep paying for Groove, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Groove 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 shared support mailbox that threads by the reply chain, assigns without collisions, and never mails a robot back to replace Groove. 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 A support inbox fails in two ways, and the second one is louder than the first. It loses messages. So every message that arrives is written to the database with its raw source intact before any rule, assignment, notification or auto-reply runs. Nothing is deleted on receipt: spam is a flag with a reason, not a deletion, because a filter that silently eats real customers is undetectable from the inside. And it mails robots. RFC 3834 sets the rules and they are not optional if you intend to send any automatic response: a responder MUST NOT reply to a message carrying an Auto-Submitted header field with any value other than 'no', MUST NOT generate a response whose destination would be a null return path such as an empty SMTP MAIL FROM, SHOULD include Auto-Submitted with the value 'auto-replied' on the responses it does send, and SHOULD NOT send the same response to the same sender more than once within a period of several days, with seven days recommended as the default. Two responders that ignore any of this will mail each other through the night, and the first person to notice will be the postmaster of whoever you are looping with. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and a separate worker process · receiving mail and serving an interface have different failure modes and should not share a crash - Storage: PostgreSQL with the raw message source stored alongside the parsed fields · parsers improve and the original bytes are the only thing you can reparse from - Receiving: One mailbox polled or delivered by webhook, deduplicated by message identifier · every delivery path redelivers, and a duplicate ticket is a customer answered twice - Threading: Reply-chain headers first, a signed token in the reply address as the fallback · subject matching merges different customers and is the classic way this breaks - Sending: One outbound identity with the auto-response rules enforced in code · the loop protection belongs where it cannot be configured away ### Data model (create this before Phase 1) - conversations(id, subject, contact_id, mailbox_id, state, assignee_id, assigned_at, last_activity_at, token) · token is the signed value that appears in the reply address - messages(id, conversation_id, direction, message_id_header, in_reply_to, references_json, from_addr, to_json, sent_at, received_at, body_text, body_html, raw_path, spam_reason, auto_submitted) - contacts(id, email, name, first_seen_at, deleted_at) - events(id, conversation_id, actor_id, kind, payload_json, at) · append-only: assigned, replied, noted, state changed, flagged - notes(id, conversation_id, author_id, body, at) · internal, never sent, and rendered so it cannot be mistaken for a reply - attachments(id, message_id, filename_original, stored_name, sha256, bytes, mime) - suppressions(address, reason, until) · the seven-day auto-response record and anything you refuse to mail - raw_path is not optional. The parsed fields are a view of the original message and are regenerable from it; the original is not regenerable from them. ### Phase 1 · Receive it, store it, then do anything else Build: Fetch or receive into a worker, write the raw bytes to disk and the row in one transaction, then return. Deduplicate on the message identifier header plus the mailbox. Run parsing, rules and notifications afterwards, and let them fail without losing the message. Flag spam with a reason rather than dropping it. Trap: Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. Done when: A message whose body cannot be parsed still creates a conversation containing its raw source and a visible parse error, tested with a deliberately malformed message. Delivering the same message twice produces one conversation and one message row, tested with a duplicate identifier. Every message row has a raw file on disk whose hash matches, asserted across the whole table by a check command. A message flagged as spam is visible in a spam view with its reason and can be restored to the inbox in one action. Do not build yet: No threading, assignment, replies or attachments yet. ### Phase 2 · Threading by the reply chain, never by subject Build: Thread on In-Reply-To and the References chain first. Add a signed token to the reply address you send from, and use it when the chain is missing. Only if both are absent, start a new conversation. Never merge on subject, and never merge two conversations automatically. Trap: Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. Done when: A reply carrying only References and no In-Reply-To attaches correctly, tested with a fixture from a real client. A reply with all threading headers stripped but sent to the token address attaches correctly, tested. Two messages from different addresses with identical subjects create two conversations, tested. A tampered or expired token is rejected and starts a new conversation rather than attaching to the one it names, tested with a forged value. Do not build yet: No assignment, replies, notifications or attachments yet. ### Phase 3 · Two people, one queue, no double replies Build: Record every assignment, state change, note and reply as an event row. Show who else has the conversation open and whether they are typing a reply, using a short-lived presence record. Warn before sending when someone else has replied since you opened it, and show what they sent. Trap: Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. Done when: Sending a reply when another reply arrived after your view loaded is blocked with the newer reply shown and requires an explicit confirmation, tested by racing two sessions. Every assignment, state change, note and reply produces an event row and no event row is ever updated, enforced in the schema. Presence expires on its own so a crashed browser does not leave a conversation looking permanently occupied, tested by advancing the clock. The conversation history renders events and messages in one timeline with internal notes visibly distinct from sent replies. Do not build yet: No outbound sending rules, attachments, retention or export yet. ### Phase 4 · Outbound mail that does not join a loop Build: Before any automatic response, check the inbound message for an Auto-Submitted header with a value other than 'no', a null return path, and list headers, and skip the response if any is present with the reason recorded. Stamp your own automatic responses as auto-replied. Record a suppression per recipient and do not repeat within the configured window. Trap: An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. Done when: A message carrying an Auto-Submitted header with any value other than 'no' receives no automatic response and the skip is recorded with its reason, tested for several values. A message with a null return path receives no automatic response, tested. Every automatic response this system sends carries Auto-Submitted with the value 'auto-replied', asserted on the outbound source. A second automatic response to the same address inside the configured window is suppressed, tested at the boundary, and an hourly cap exists and raises a visible alert when reached. Do not build yet: No attachments, retention, export or search yet. ### Phase 5 · Attachments, deletion and taking the mailbox with you Build: Store attachments under generated names outside the web root, keeping the supplied filename as a label only, and serve downloads through a handler that sets the type itself. Delete a contact with every message, attachment and event, including the raw files. Export conversations as standard mailbox files plus a JSON sidecar, and take automatic backups with a tested restore. Trap: Saving an attachment under the filename the sender supplied puts a sender-chosen path on your disk and a sender-chosen extension in front of your web server, which is a traversal and an execution problem in one line of convenience. Generate the stored name from the hash, keep the original as a display label, and never build a filesystem path from anything that arrived in a message. Done when: An attachment with a crafted filename containing path separators is stored under a generated name in the expected directory, tested with a traversal attempt. Downloads are served by a handler that sets the content type from your own detection rather than from the message, verified by review. Deleting a contact removes their messages, attachments, events and raw files, verified by checking every table and the disk afterwards. Export produces standard mailbox files that open in an ordinary mail client, and a restore from backup is exercised in the test suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Guaranteed deliverability to any inbox, which is an operational commitment involving reputation, authentication and years of sending history that a self-hosted inbox cannot honestly make. - Social and messaging channels, each of which is its own integration with its own retention rules and its own idea of what a conversation is. - A live chat widget, which is a second real-time product with presence, reconnection and offline handling of its own. - An assistant that answers tickets from your history, which needs the knowledge base and the threading correct first or it will confidently answer with the wrong customer's context. ### README must contain - That every message is written with its raw source before any rule or notification runs, and that spam is flagged rather than deleted - That threading uses the reply chain and a signed token, never the subject, with the reason - The exact automatic response rules implemented, naming the specification and the suppression window - That attachments are stored under generated names and served with a type your own code decided - How to export the mailbox as standard mailbox files and how the restore is tested ===== AGENTS.md ===== # Agent instructions · Groove indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript and a separate worker process, PostgreSQL with the raw message source stored alongside the parsed fields, One mailbox polled or delivered by webhook, deduplicated by message identifier, Reply-chain headers first, a signed token in the reply address as the fallback, One outbound identity with the auto-response rules enforced in code. 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 - Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. - Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. - Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. - An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. ===== BUILD_PLAN.md ===== # Build plan · Groove A small shared inbox whose hard problems are threading a reply chain when clients strip the headers you rely on, letting two people work one queue without replying to the same customer twice, and sending automatic mail without joining a loop. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Receive it, store it, then do anything else Every inbound message is persisted with its raw source before any processing, and redelivery creates nothing new. ### Steps 1. Write raw bytes and the row before anything else One transaction, then return, then process. Files: `worker/receive.ts`, `tests/receive.test.ts` 2. Deduplicate on the message identifier Because every delivery mechanism redelivers eventually. Files: `worker/dedupe.ts` 3. Make spam a flag with a reason A silent filter eating real mail cannot be discovered. Files: `src/spam/flag.ts` ### Done when - [ ] An unparseable message still creates a conversation - [ ] Redelivery creates one conversation and one message - [ ] Every message row has a matching raw file - [ ] Spam is flagged with a reason and restorable ### Watch out - Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. ## Phase 2 · Threading by the reply chain, never by subject Replies attach to the right conversation even when a client rewrites the headers you hoped for. ### Steps 1. Follow In-Reply-To and the References chain In that order, and handle a chain that has been truncated. Files: `src/threading/chain.ts`, `tests/threading.test.ts` 2. Sign the token in the reply address Verified on the way back in, and rejected if it does not verify. Files: `src/threading/token.ts`, `tests/token.test.ts` 3. Never merge on subject, never merge automatically A wrongly merged conversation leaks one customer to another. Files: `src/threading/index.ts` ### Done when - [ ] References-only replies attach correctly - [ ] A stripped-header reply attaches by token - [ ] Identical subjects from different senders stay separate - [ ] A forged token starts a new conversation ### Watch out - Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. ## Phase 3 · Two people, one queue, no double replies Assignment and activity are an append-only event log, and a second agent opening a conversation sees the first one there. ### Steps 1. Append an event for everything that happens The current state is a projection; the events are the record. Files: `src/events/append.ts`, `src/db/schema.sql` 2. Check for newer replies at send time Not at page load, which is where the race lives. Files: `src/replies/send.ts`, `tests/collision.test.ts` 3. Expire presence on a timer A crashed tab should not lock a customer out of an answer. Files: `src/presence/index.ts` ### Done when - [ ] A racing reply is blocked with the newer one shown - [ ] Events are append-only at the database level - [ ] Presence expires without a clean disconnect - [ ] Notes are visibly distinct from replies in the timeline ### Watch out - Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. ## Phase 4 · Outbound mail that does not join a loop Automatic responses obey the published rules and the same address is never auto-answered twice in a week. ### Steps 1. Check Auto-Submitted, return path and list headers first Three cheap checks that prevent the expensive failure. Files: `src/outbound/autoreply.ts`, `tests/autoreply.test.ts` 2. Stamp your own automatic responses So the responder on the other side can do the same for you. Files: `src/outbound/headers.ts` 3. Suppress per recipient and cap per hour The suppression window plus a cap is what breaks a novel loop. Files: `src/outbound/suppress.ts`, `tests/suppress.test.ts` ### Done when - [ ] Auto-Submitted messages receive no automatic response - [ ] A null return path receives no automatic response - [ ] Outbound automatic responses are stamped auto-replied - [ ] Repeat responses are suppressed and an hourly cap alerts ### Watch out - An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. ## Not in this build - Guaranteed deliverability to any inbox. - Social and messaging channels. - A live chat widget. - An assistant that answers tickets from history. ## After v1, if you want it - Saved replies with variables, once the threading and collision work is settled and boring - A first-response time report computed from the event log rather than from conversation timestamps - A public knowledge base fed from the replies agents keep retyping, which is where the repetition already is ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Conversations, messages, events and suppressions. DATABASE_URL=postgres://user:pass@localhost:5432/inbox # Required. Original message bytes. Everything parsed is regenerable from here. RAW_MAIL_DIR=./data/raw # Required. Outside the web root, generated names only. ATTACHMENT_DIR=./data/attachments # Required · secret. Signs the token in the reply address. Rotating it invalidates outstanding threads, so plan for overlap. REPLY_TOKEN_SECRET=redacted # Required. Where token addresses are routed. Needs catch-all or plus-addressing. REPLY_DOMAIN=reply.example.com # Required. No second automatic response to the same address inside this window. AUTOREPLY_SUPPRESS_DAYS=7 # Required. Total automatic responses per hour. Hitting it raises a visible alert. AUTOREPLY_HOURLY_CAP=50 # Required. Database dumps and raw mail. The restore is exercised in the suite. BACKUP_DIR=./data/backups
You are building a production product version of Groove. 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 ===== # Groove · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Groove, turn one support mailbox into an assigned team inbox with a knowledge base. The hard boundary is email reliability, reporting, rules, integrations, and mature simplicity, plus channel integrations and support operations. ## Product outcome A shared mailbox that keeps the original bytes of every message, attaches replies to the right conversation without ever guessing from a subject line, stops two agents answering the same customer twice, and cannot be talked into a mail loop. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - public HTTPS deployment - email or chat channel credentials ## Explicit non-goals for v1 - Guaranteed deliverability to any inbox. - Social and messaging channels. - A live chat widget. - An assistant that answers tickets from history. - email reliability, reporting, rules, integrations, and mature simplicity - omnichannel integrations - large-scale deliverability - AI resolution tuning - enterprise routing and compliance ## Success criteria - An unparseable message is confirmed to create a conversation with its raw source - Identical subjects from different senders are confirmed to stay separate - A racing reply is confirmed blocked with the newer reply shown - Auto-Submitted messages are confirmed to receive no automatic response - A crafted attachment filename is confirmed stored under a generated name ===== BRIEF.md ===== # Build brief · Groove 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 shared support mailbox that threads by the reply chain, assigns without collisions, and never mails a robot back to replace Groove. 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 A support inbox fails in two ways, and the second one is louder than the first. It loses messages. So every message that arrives is written to the database with its raw source intact before any rule, assignment, notification or auto-reply runs. Nothing is deleted on receipt: spam is a flag with a reason, not a deletion, because a filter that silently eats real customers is undetectable from the inside. And it mails robots. RFC 3834 sets the rules and they are not optional if you intend to send any automatic response: a responder MUST NOT reply to a message carrying an Auto-Submitted header field with any value other than 'no', MUST NOT generate a response whose destination would be a null return path such as an empty SMTP MAIL FROM, SHOULD include Auto-Submitted with the value 'auto-replied' on the responses it does send, and SHOULD NOT send the same response to the same sender more than once within a period of several days, with seven days recommended as the default. Two responders that ignore any of this will mail each other through the night, and the first person to notice will be the postmaster of whoever you are looping with. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and a separate worker process · receiving mail and serving an interface have different failure modes and should not share a crash - Storage: PostgreSQL with the raw message source stored alongside the parsed fields · parsers improve and the original bytes are the only thing you can reparse from - Receiving: One mailbox polled or delivered by webhook, deduplicated by message identifier · every delivery path redelivers, and a duplicate ticket is a customer answered twice - Threading: Reply-chain headers first, a signed token in the reply address as the fallback · subject matching merges different customers and is the classic way this breaks - Sending: One outbound identity with the auto-response rules enforced in code · the loop protection belongs where it cannot be configured away ### Data model (create this before Phase 1) - conversations(id, subject, contact_id, mailbox_id, state, assignee_id, assigned_at, last_activity_at, token) · token is the signed value that appears in the reply address - messages(id, conversation_id, direction, message_id_header, in_reply_to, references_json, from_addr, to_json, sent_at, received_at, body_text, body_html, raw_path, spam_reason, auto_submitted) - contacts(id, email, name, first_seen_at, deleted_at) - events(id, conversation_id, actor_id, kind, payload_json, at) · append-only: assigned, replied, noted, state changed, flagged - notes(id, conversation_id, author_id, body, at) · internal, never sent, and rendered so it cannot be mistaken for a reply - attachments(id, message_id, filename_original, stored_name, sha256, bytes, mime) - suppressions(address, reason, until) · the seven-day auto-response record and anything you refuse to mail - raw_path is not optional. The parsed fields are a view of the original message and are regenerable from it; the original is not regenerable from them. ### Phase 1 · Receive it, store it, then do anything else Build: Fetch or receive into a worker, write the raw bytes to disk and the row in one transaction, then return. Deduplicate on the message identifier header plus the mailbox. Run parsing, rules and notifications afterwards, and let them fail without losing the message. Flag spam with a reason rather than dropping it. Trap: Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. Done when: A message whose body cannot be parsed still creates a conversation containing its raw source and a visible parse error, tested with a deliberately malformed message. Delivering the same message twice produces one conversation and one message row, tested with a duplicate identifier. Every message row has a raw file on disk whose hash matches, asserted across the whole table by a check command. A message flagged as spam is visible in a spam view with its reason and can be restored to the inbox in one action. Do not build yet: No threading, assignment, replies or attachments yet. ### Phase 2 · Threading by the reply chain, never by subject Build: Thread on In-Reply-To and the References chain first. Add a signed token to the reply address you send from, and use it when the chain is missing. Only if both are absent, start a new conversation. Never merge on subject, and never merge two conversations automatically. Trap: Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. Done when: A reply carrying only References and no In-Reply-To attaches correctly, tested with a fixture from a real client. A reply with all threading headers stripped but sent to the token address attaches correctly, tested. Two messages from different addresses with identical subjects create two conversations, tested. A tampered or expired token is rejected and starts a new conversation rather than attaching to the one it names, tested with a forged value. Do not build yet: No assignment, replies, notifications or attachments yet. ### Phase 3 · Two people, one queue, no double replies Build: Record every assignment, state change, note and reply as an event row. Show who else has the conversation open and whether they are typing a reply, using a short-lived presence record. Warn before sending when someone else has replied since you opened it, and show what they sent. Trap: Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. Done when: Sending a reply when another reply arrived after your view loaded is blocked with the newer reply shown and requires an explicit confirmation, tested by racing two sessions. Every assignment, state change, note and reply produces an event row and no event row is ever updated, enforced in the schema. Presence expires on its own so a crashed browser does not leave a conversation looking permanently occupied, tested by advancing the clock. The conversation history renders events and messages in one timeline with internal notes visibly distinct from sent replies. Do not build yet: No outbound sending rules, attachments, retention or export yet. ### Phase 4 · Outbound mail that does not join a loop Build: Before any automatic response, check the inbound message for an Auto-Submitted header with a value other than 'no', a null return path, and list headers, and skip the response if any is present with the reason recorded. Stamp your own automatic responses as auto-replied. Record a suppression per recipient and do not repeat within the configured window. Trap: An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. Done when: A message carrying an Auto-Submitted header with any value other than 'no' receives no automatic response and the skip is recorded with its reason, tested for several values. A message with a null return path receives no automatic response, tested. Every automatic response this system sends carries Auto-Submitted with the value 'auto-replied', asserted on the outbound source. A second automatic response to the same address inside the configured window is suppressed, tested at the boundary, and an hourly cap exists and raises a visible alert when reached. Do not build yet: No attachments, retention, export or search yet. ### Phase 5 · Attachments, deletion and taking the mailbox with you Build: Store attachments under generated names outside the web root, keeping the supplied filename as a label only, and serve downloads through a handler that sets the type itself. Delete a contact with every message, attachment and event, including the raw files. Export conversations as standard mailbox files plus a JSON sidecar, and take automatic backups with a tested restore. Trap: Saving an attachment under the filename the sender supplied puts a sender-chosen path on your disk and a sender-chosen extension in front of your web server, which is a traversal and an execution problem in one line of convenience. Generate the stored name from the hash, keep the original as a display label, and never build a filesystem path from anything that arrived in a message. Done when: An attachment with a crafted filename containing path separators is stored under a generated name in the expected directory, tested with a traversal attempt. Downloads are served by a handler that sets the content type from your own detection rather than from the message, verified by review. Deleting a contact removes their messages, attachments, events and raw files, verified by checking every table and the disk afterwards. Export produces standard mailbox files that open in an ordinary mail client, and a restore from backup is exercised in the test suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Guaranteed deliverability to any inbox, which is an operational commitment involving reputation, authentication and years of sending history that a self-hosted inbox cannot honestly make. - Social and messaging channels, each of which is its own integration with its own retention rules and its own idea of what a conversation is. - A live chat widget, which is a second real-time product with presence, reconnection and offline handling of its own. - An assistant that answers tickets from your history, which needs the knowledge base and the threading correct first or it will confidently answer with the wrong customer's context. ### README must contain - That every message is written with its raw source before any rule or notification runs, and that spam is flagged rather than deleted - That threading uses the reply chain and a signed token, never the subject, with the reason - The exact automatic response rules implemented, naming the specification and the suppression window - That attachments are stored under generated names and served with a type your own code decided - How to export the mailbox as standard mailbox files and how the restore is tested ===== ARCHITECTURE.md ===== # Architecture · Groove ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and a separate worker process | receiving mail and serving an interface have different failure modes and should not share a crash | | Storage | PostgreSQL with the raw message source stored alongside the parsed fields | parsers improve and the original bytes are the only thing you can reparse from | | Receiving | One mailbox polled or delivered by webhook, deduplicated by message identifier | every delivery path redelivers, and a duplicate ticket is a customer answered twice | | Threading | Reply-chain headers first, a signed token in the reply address as the fallback | subject matching merges different customers and is the classic way this breaks | | Sending | One outbound identity with the auto-response rules enforced in code | the loop protection belongs where it cannot be configured away | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Receive | Raw storage, deduplication and spam flagging. | Any transport, provided the bytes land before anything else runs. | | Threading | Reply chain resolution and signed tokens. | Any scheme except subject matching, which is the leak. | | Collaboration | Events, presence and send-time collision checks. | Any presence mechanism, provided the check happens at send. | | Outbound | Automatic response rules, stamping and suppression. | Any sender, provided the header checks stay in code rather than configuration. | | Retention | Attachments, contact deletion and export. | Any storage, provided paths are never built from message content. | ## 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 · Conversations, messages, events and suppressions. - `RAW_MAIL_DIR` · required · Original message bytes. Everything parsed is regenerable from here. - `ATTACHMENT_DIR` · required · Outside the web root, generated names only. - `REPLY_TOKEN_SECRET` · required, secret · Signs the token in the reply address. Rotating it invalidates outstanding threads, so plan for overlap. - `REPLY_DOMAIN` · required · Where token addresses are routed. Needs catch-all or plus-addressing. - `AUTOREPLY_SUPPRESS_DAYS` · required · No second automatic response to the same address inside this window. - `AUTOREPLY_HOURLY_CAP` · required · Total automatic responses per hour. Hitting it raises a visible alert. - `BACKUP_DIR` · required · Database dumps and raw mail. The restore is exercised in the suite. ## 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 · Groove product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript and a separate worker process, PostgreSQL with the raw message source stored alongside the parsed fields, One mailbox polled or delivered by webhook, deduplicated by message identifier, Reply-chain headers first, a signed token in the reply address as the fallback, One outbound identity with the auto-response rules enforced in code. - 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 - Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. - Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. - Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. - An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. - Saving an attachment under the filename the sender supplied puts a sender-chosen path on your disk and a sender-chosen extension in front of your web server, which is a traversal and an execution problem in one line of convenience. Generate the stored name from the hash, keep the original as a display label, and never build a filesystem path from anything that arrived in a message. ===== MILESTONES.md ===== # Delivery milestones · Groove Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Receive it, store it, then do anything else Every inbound message is persisted with its raw source before any processing, and redelivery creates nothing new. ### Steps 1. Write raw bytes and the row before anything else One transaction, then return, then process. Files: `worker/receive.ts`, `tests/receive.test.ts` 2. Deduplicate on the message identifier Because every delivery mechanism redelivers eventually. Files: `worker/dedupe.ts` 3. Make spam a flag with a reason A silent filter eating real mail cannot be discovered. Files: `src/spam/flag.ts` ### Done when - [ ] An unparseable message still creates a conversation - [ ] Redelivery creates one conversation and one message - [ ] Every message row has a matching raw file - [ ] Spam is flagged with a reason and restorable ### Watch out - Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. ## M2 · Threading by the reply chain, never by subject Replies attach to the right conversation even when a client rewrites the headers you hoped for. ### Steps 1. Follow In-Reply-To and the References chain In that order, and handle a chain that has been truncated. Files: `src/threading/chain.ts`, `tests/threading.test.ts` 2. Sign the token in the reply address Verified on the way back in, and rejected if it does not verify. Files: `src/threading/token.ts`, `tests/token.test.ts` 3. Never merge on subject, never merge automatically A wrongly merged conversation leaks one customer to another. Files: `src/threading/index.ts` ### Done when - [ ] References-only replies attach correctly - [ ] A stripped-header reply attaches by token - [ ] Identical subjects from different senders stay separate - [ ] A forged token starts a new conversation ### Watch out - Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. ## M3 · Two people, one queue, no double replies Assignment and activity are an append-only event log, and a second agent opening a conversation sees the first one there. ### Steps 1. Append an event for everything that happens The current state is a projection; the events are the record. Files: `src/events/append.ts`, `src/db/schema.sql` 2. Check for newer replies at send time Not at page load, which is where the race lives. Files: `src/replies/send.ts`, `tests/collision.test.ts` 3. Expire presence on a timer A crashed tab should not lock a customer out of an answer. Files: `src/presence/index.ts` ### Done when - [ ] A racing reply is blocked with the newer one shown - [ ] Events are append-only at the database level - [ ] Presence expires without a clean disconnect - [ ] Notes are visibly distinct from replies in the timeline ### Watch out - Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. ## M4 · Outbound mail that does not join a loop Automatic responses obey the published rules and the same address is never auto-answered twice in a week. ### Steps 1. Check Auto-Submitted, return path and list headers first Three cheap checks that prevent the expensive failure. Files: `src/outbound/autoreply.ts`, `tests/autoreply.test.ts` 2. Stamp your own automatic responses So the responder on the other side can do the same for you. Files: `src/outbound/headers.ts` 3. Suppress per recipient and cap per hour The suppression window plus a cap is what breaks a novel loop. Files: `src/outbound/suppress.ts`, `tests/suppress.test.ts` ### Done when - [ ] Auto-Submitted messages receive no automatic response - [ ] A null return path receives no automatic response - [ ] Outbound automatic responses are stamped auto-replied - [ ] Repeat responses are suppressed and an hourly cap alerts ### Watch out - An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. ## M5 · Attachments, deletion and taking the mailbox with you (production only) Files are stored safely, a contact can be deleted for real, and everything exports in a format that is not yours. ### Steps 1. Generate stored names, keep the original as a label Never build a path from something that arrived in a message. Files: `src/attachments/store.ts`, `tests/traversal.test.ts` 2. Delete a contact completely, files included The raw sources are where the forgotten copies live. Files: `src/contacts/delete.ts`, `tests/delete.test.ts` 3. Export to standard mailbox files and test the restore The format check is whether a normal mail client opens it. Files: `src/export/mbox.ts`, `tests/restore.test.ts` ### Done when - [ ] A crafted filename is stored under a generated name - [ ] Downloads set the type from your own detection - [ ] Contact deletion removes rows and raw files - [ ] Exports open in a mail client and restore is tested ### Watch out - Saving an attachment under the filename the sender supplied puts a sender-chosen path on your disk and a sender-chosen extension in front of your web server, which is a traversal and an execution problem in one line of convenience. Generate the stored name from the hash, keep the original as a display label, and never build a filesystem path from anything that arrived in a message. ===== OPERATIONS.md ===== # Operations · Groove ## Backup The database and the raw mail directory together. The raw directory is the source of truth for content and the database holds the threading, assignment and event history that cannot be reconstructed from the mail alone. ## Restore Restore both, reparse a sample of raw messages and confirm they still thread to the same conversations, then confirm the suppression table survived. A lost suppression table means the first hour after a restore can mail somebody twice. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Receiver lag, parse failure rate, automatic responses per hour against the cap, and the count of conversations with no assignee older than a day. The last one is the number that actually predicts an angry customer. ## Incident checklist The incidents are a lost message, a merged conversation and a mail loop. Raw storage answers the first, the threading decisions in the event log answer the second, and the suppression table plus the hourly cap contain the third. 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 - [ ] An unparseable message is confirmed to create a conversation with its raw source - [ ] Identical subjects from different senders are confirmed to stay separate - [ ] A racing reply is confirmed blocked with the newer reply shown - [ ] Auto-Submitted messages are confirmed to receive no automatic response - [ ] A crafted attachment filename is confirmed stored under a generated name ## Launch constraint Do not market omitted Groove 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. Conversations, messages, events and suppressions. DATABASE_URL=postgres://user:pass@localhost:5432/inbox # Required. Original message bytes. Everything parsed is regenerable from here. RAW_MAIL_DIR=./data/raw # Required. Outside the web root, generated names only. ATTACHMENT_DIR=./data/attachments # Required · secret. Signs the token in the reply address. Rotating it invalidates outstanding threads, so plan for overlap. REPLY_TOKEN_SECRET=redacted # Required. Where token addresses are routed. Needs catch-all or plus-addressing. REPLY_DOMAIN=reply.example.com # Required. No second automatic response to the same address inside this window. AUTOREPLY_SUPPRESS_DAYS=7 # Required. Total automatic responses per hour. Hitting it raises a visible alert. AUTOREPLY_HOURLY_CAP=50 # Required. Database dumps and raw mail. The restore is exercised in the suite. BACKUP_DIR=./data/backups
# Groove · indie build A small shared inbox whose hard problems are threading a reply chain when clients strip the headers you rely on, letting two people work one queue without replying to the same customer twice, and sending automatic mail without joining a loop. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and a separate worker process | receiving mail and serving an interface have different failure modes and should not share a crash | | Storage | PostgreSQL with the raw message source stored alongside the parsed fields | parsers improve and the original bytes are the only thing you can reparse from | | Receiving | One mailbox polled or delivered by webhook, deduplicated by message identifier | every delivery path redelivers, and a duplicate ticket is a customer answered twice | | Threading | Reply-chain headers first, a signed token in the reply address as the fallback | subject matching merges different customers and is the classic way this breaks | | Sending | One outbound identity with the auto-response rules enforced in code | the loop protection belongs where it cannot be configured away | ## 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 interface and the worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free - Why: Holds conversations, messages and the append-only event log, and the event log wants a database that will not lose a write. - Get it: Your package manager, or a container image. - Verify: psql connects and the migrations apply cleanly - [ ] **A dedicated mailbox with programmatic access and a catch-all or plus-addressing** · a few pounds a month - Why: The signed reply token needs to arrive back at an address you control, and that requires either a catch-all domain or an address scheme the provider will route to you. - Get it: A mailbox on a domain you control, with either catch-all delivery or reliable plus-addressing. - Verify: Mail sent to an address containing a generated token arrives in the mailbox - [ ] **An authenticated sending path for the domain you reply from** · free to a few pounds a month - Why: Replies that fail authentication land in spam and the customer thinks you ignored them, which is the failure this whole product exists to avoid. - Get it: Configure the authentication records for your domain with whichever sending service you use and check them from a receiving account. - Verify: A reply arrives in an inbox at a major provider and passes its authentication checks - [ ] **The automatic response policy** · free - Why: The loop protection has to be decided rather than inherited, because every value in it is a trade between being responsive and mailing a robot for eight hours. - Get it: Write down the suppression window, the hourly cap, and which inbound conditions skip a response entirely. - Verify: All three appear in the configuration and each is asserted in a test ## 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: - Guaranteed deliverability to any inbox. - Social and messaging channels. - A live chat widget. - An assistant that answers tickets from history. - email reliability, reporting, rules, integrations, and mature simplicity - omnichannel integrations - large-scale deliverability - AI resolution tuning - enterprise routing and compliance If one of those is essential to you, that is the reason to keep paying for Groove, and the README should say so rather than pretend.
# Build brief · Groove 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 shared support mailbox that threads by the reply chain, assigns without collisions, and never mails a robot back to replace Groove. 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 A support inbox fails in two ways, and the second one is louder than the first. It loses messages. So every message that arrives is written to the database with its raw source intact before any rule, assignment, notification or auto-reply runs. Nothing is deleted on receipt: spam is a flag with a reason, not a deletion, because a filter that silently eats real customers is undetectable from the inside. And it mails robots. RFC 3834 sets the rules and they are not optional if you intend to send any automatic response: a responder MUST NOT reply to a message carrying an Auto-Submitted header field with any value other than 'no', MUST NOT generate a response whose destination would be a null return path such as an empty SMTP MAIL FROM, SHOULD include Auto-Submitted with the value 'auto-replied' on the responses it does send, and SHOULD NOT send the same response to the same sender more than once within a period of several days, with seven days recommended as the default. Two responders that ignore any of this will mail each other through the night, and the first person to notice will be the postmaster of whoever you are looping with. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and a separate worker process · receiving mail and serving an interface have different failure modes and should not share a crash - Storage: PostgreSQL with the raw message source stored alongside the parsed fields · parsers improve and the original bytes are the only thing you can reparse from - Receiving: One mailbox polled or delivered by webhook, deduplicated by message identifier · every delivery path redelivers, and a duplicate ticket is a customer answered twice - Threading: Reply-chain headers first, a signed token in the reply address as the fallback · subject matching merges different customers and is the classic way this breaks - Sending: One outbound identity with the auto-response rules enforced in code · the loop protection belongs where it cannot be configured away ### Data model (create this before Phase 1) - conversations(id, subject, contact_id, mailbox_id, state, assignee_id, assigned_at, last_activity_at, token) · token is the signed value that appears in the reply address - messages(id, conversation_id, direction, message_id_header, in_reply_to, references_json, from_addr, to_json, sent_at, received_at, body_text, body_html, raw_path, spam_reason, auto_submitted) - contacts(id, email, name, first_seen_at, deleted_at) - events(id, conversation_id, actor_id, kind, payload_json, at) · append-only: assigned, replied, noted, state changed, flagged - notes(id, conversation_id, author_id, body, at) · internal, never sent, and rendered so it cannot be mistaken for a reply - attachments(id, message_id, filename_original, stored_name, sha256, bytes, mime) - suppressions(address, reason, until) · the seven-day auto-response record and anything you refuse to mail - raw_path is not optional. The parsed fields are a view of the original message and are regenerable from it; the original is not regenerable from them. ### Phase 1 · Receive it, store it, then do anything else Build: Fetch or receive into a worker, write the raw bytes to disk and the row in one transaction, then return. Deduplicate on the message identifier header plus the mailbox. Run parsing, rules and notifications afterwards, and let them fail without losing the message. Flag spam with a reason rather than dropping it. Trap: Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. Done when: A message whose body cannot be parsed still creates a conversation containing its raw source and a visible parse error, tested with a deliberately malformed message. Delivering the same message twice produces one conversation and one message row, tested with a duplicate identifier. Every message row has a raw file on disk whose hash matches, asserted across the whole table by a check command. A message flagged as spam is visible in a spam view with its reason and can be restored to the inbox in one action. Do not build yet: No threading, assignment, replies or attachments yet. ### Phase 2 · Threading by the reply chain, never by subject Build: Thread on In-Reply-To and the References chain first. Add a signed token to the reply address you send from, and use it when the chain is missing. Only if both are absent, start a new conversation. Never merge on subject, and never merge two conversations automatically. Trap: Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. Done when: A reply carrying only References and no In-Reply-To attaches correctly, tested with a fixture from a real client. A reply with all threading headers stripped but sent to the token address attaches correctly, tested. Two messages from different addresses with identical subjects create two conversations, tested. A tampered or expired token is rejected and starts a new conversation rather than attaching to the one it names, tested with a forged value. Do not build yet: No assignment, replies, notifications or attachments yet. ### Phase 3 · Two people, one queue, no double replies Build: Record every assignment, state change, note and reply as an event row. Show who else has the conversation open and whether they are typing a reply, using a short-lived presence record. Warn before sending when someone else has replied since you opened it, and show what they sent. Trap: Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. Done when: Sending a reply when another reply arrived after your view loaded is blocked with the newer reply shown and requires an explicit confirmation, tested by racing two sessions. Every assignment, state change, note and reply produces an event row and no event row is ever updated, enforced in the schema. Presence expires on its own so a crashed browser does not leave a conversation looking permanently occupied, tested by advancing the clock. The conversation history renders events and messages in one timeline with internal notes visibly distinct from sent replies. Do not build yet: No outbound sending rules, attachments, retention or export yet. ### Phase 4 · Outbound mail that does not join a loop Build: Before any automatic response, check the inbound message for an Auto-Submitted header with a value other than 'no', a null return path, and list headers, and skip the response if any is present with the reason recorded. Stamp your own automatic responses as auto-replied. Record a suppression per recipient and do not repeat within the configured window. Trap: An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. Done when: A message carrying an Auto-Submitted header with any value other than 'no' receives no automatic response and the skip is recorded with its reason, tested for several values. A message with a null return path receives no automatic response, tested. Every automatic response this system sends carries Auto-Submitted with the value 'auto-replied', asserted on the outbound source. A second automatic response to the same address inside the configured window is suppressed, tested at the boundary, and an hourly cap exists and raises a visible alert when reached. Do not build yet: No attachments, retention, export or search yet. ### Phase 5 · Attachments, deletion and taking the mailbox with you Build: Store attachments under generated names outside the web root, keeping the supplied filename as a label only, and serve downloads through a handler that sets the type itself. Delete a contact with every message, attachment and event, including the raw files. Export conversations as standard mailbox files plus a JSON sidecar, and take automatic backups with a tested restore. Trap: Saving an attachment under the filename the sender supplied puts a sender-chosen path on your disk and a sender-chosen extension in front of your web server, which is a traversal and an execution problem in one line of convenience. Generate the stored name from the hash, keep the original as a display label, and never build a filesystem path from anything that arrived in a message. Done when: An attachment with a crafted filename containing path separators is stored under a generated name in the expected directory, tested with a traversal attempt. Downloads are served by a handler that sets the content type from your own detection rather than from the message, verified by review. Deleting a contact removes their messages, attachments, events and raw files, verified by checking every table and the disk afterwards. Export produces standard mailbox files that open in an ordinary mail client, and a restore from backup is exercised in the test suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Guaranteed deliverability to any inbox, which is an operational commitment involving reputation, authentication and years of sending history that a self-hosted inbox cannot honestly make. - Social and messaging channels, each of which is its own integration with its own retention rules and its own idea of what a conversation is. - A live chat widget, which is a second real-time product with presence, reconnection and offline handling of its own. - An assistant that answers tickets from your history, which needs the knowledge base and the threading correct first or it will confidently answer with the wrong customer's context. ### README must contain - That every message is written with its raw source before any rule or notification runs, and that spam is flagged rather than deleted - That threading uses the reply chain and a signed token, never the subject, with the reason - The exact automatic response rules implemented, naming the specification and the suppression window - That attachments are stored under generated names and served with a type your own code decided - How to export the mailbox as standard mailbox files and how the restore is tested
# Agent instructions · Groove indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript and a separate worker process, PostgreSQL with the raw message source stored alongside the parsed fields, One mailbox polled or delivered by webhook, deduplicated by message identifier, Reply-chain headers first, a signed token in the reply address as the fallback, One outbound identity with the auto-response rules enforced in code. 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 - Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. - Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. - Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. - An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit.
# Build plan · Groove A small shared inbox whose hard problems are threading a reply chain when clients strip the headers you rely on, letting two people work one queue without replying to the same customer twice, and sending automatic mail without joining a loop. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Receive it, store it, then do anything else Every inbound message is persisted with its raw source before any processing, and redelivery creates nothing new. ### Steps 1. Write raw bytes and the row before anything else One transaction, then return, then process. Files: `worker/receive.ts`, `tests/receive.test.ts` 2. Deduplicate on the message identifier Because every delivery mechanism redelivers eventually. Files: `worker/dedupe.ts` 3. Make spam a flag with a reason A silent filter eating real mail cannot be discovered. Files: `src/spam/flag.ts` ### Done when - [ ] An unparseable message still creates a conversation - [ ] Redelivery creates one conversation and one message - [ ] Every message row has a matching raw file - [ ] Spam is flagged with a reason and restorable ### Watch out - Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. ## Phase 2 · Threading by the reply chain, never by subject Replies attach to the right conversation even when a client rewrites the headers you hoped for. ### Steps 1. Follow In-Reply-To and the References chain In that order, and handle a chain that has been truncated. Files: `src/threading/chain.ts`, `tests/threading.test.ts` 2. Sign the token in the reply address Verified on the way back in, and rejected if it does not verify. Files: `src/threading/token.ts`, `tests/token.test.ts` 3. Never merge on subject, never merge automatically A wrongly merged conversation leaks one customer to another. Files: `src/threading/index.ts` ### Done when - [ ] References-only replies attach correctly - [ ] A stripped-header reply attaches by token - [ ] Identical subjects from different senders stay separate - [ ] A forged token starts a new conversation ### Watch out - Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. ## Phase 3 · Two people, one queue, no double replies Assignment and activity are an append-only event log, and a second agent opening a conversation sees the first one there. ### Steps 1. Append an event for everything that happens The current state is a projection; the events are the record. Files: `src/events/append.ts`, `src/db/schema.sql` 2. Check for newer replies at send time Not at page load, which is where the race lives. Files: `src/replies/send.ts`, `tests/collision.test.ts` 3. Expire presence on a timer A crashed tab should not lock a customer out of an answer. Files: `src/presence/index.ts` ### Done when - [ ] A racing reply is blocked with the newer one shown - [ ] Events are append-only at the database level - [ ] Presence expires without a clean disconnect - [ ] Notes are visibly distinct from replies in the timeline ### Watch out - Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. ## Phase 4 · Outbound mail that does not join a loop Automatic responses obey the published rules and the same address is never auto-answered twice in a week. ### Steps 1. Check Auto-Submitted, return path and list headers first Three cheap checks that prevent the expensive failure. Files: `src/outbound/autoreply.ts`, `tests/autoreply.test.ts` 2. Stamp your own automatic responses So the responder on the other side can do the same for you. Files: `src/outbound/headers.ts` 3. Suppress per recipient and cap per hour The suppression window plus a cap is what breaks a novel loop. Files: `src/outbound/suppress.ts`, `tests/suppress.test.ts` ### Done when - [ ] Auto-Submitted messages receive no automatic response - [ ] A null return path receives no automatic response - [ ] Outbound automatic responses are stamped auto-replied - [ ] Repeat responses are suppressed and an hourly cap alerts ### Watch out - An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. ## Not in this build - Guaranteed deliverability to any inbox. - Social and messaging channels. - A live chat widget. - An assistant that answers tickets from history. ## After v1, if you want it - Saved replies with variables, once the threading and collision work is settled and boring - A first-response time report computed from the event log rather than from conversation timestamps - A public knowledge base fed from the replies agents keep retyping, which is where the repetition already is
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Conversations, messages, events and suppressions. DATABASE_URL=postgres://user:pass@localhost:5432/inbox # Required. Original message bytes. Everything parsed is regenerable from here. RAW_MAIL_DIR=./data/raw # Required. Outside the web root, generated names only. ATTACHMENT_DIR=./data/attachments # Required · secret. Signs the token in the reply address. Rotating it invalidates outstanding threads, so plan for overlap. REPLY_TOKEN_SECRET=redacted # Required. Where token addresses are routed. Needs catch-all or plus-addressing. REPLY_DOMAIN=reply.example.com # Required. No second automatic response to the same address inside this window. AUTOREPLY_SUPPRESS_DAYS=7 # Required. Total automatic responses per hour. Hitting it raises a visible alert. AUTOREPLY_HOURLY_CAP=50 # Required. Database dumps and raw mail. The restore is exercised in the suite. BACKUP_DIR=./data/backups
# Groove · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Groove, turn one support mailbox into an assigned team inbox with a knowledge base. The hard boundary is email reliability, reporting, rules, integrations, and mature simplicity, plus channel integrations and support operations. ## Product outcome A shared mailbox that keeps the original bytes of every message, attaches replies to the right conversation without ever guessing from a subject line, stops two agents answering the same customer twice, and cannot be talked into a mail loop. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - public HTTPS deployment - email or chat channel credentials ## Explicit non-goals for v1 - Guaranteed deliverability to any inbox. - Social and messaging channels. - A live chat widget. - An assistant that answers tickets from history. - email reliability, reporting, rules, integrations, and mature simplicity - omnichannel integrations - large-scale deliverability - AI resolution tuning - enterprise routing and compliance ## Success criteria - An unparseable message is confirmed to create a conversation with its raw source - Identical subjects from different senders are confirmed to stay separate - A racing reply is confirmed blocked with the newer reply shown - Auto-Submitted messages are confirmed to receive no automatic response - A crafted attachment filename is confirmed stored under a generated name
# Build brief · Groove 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 shared support mailbox that threads by the reply chain, assigns without collisions, and never mails a robot back to replace Groove. 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 A support inbox fails in two ways, and the second one is louder than the first. It loses messages. So every message that arrives is written to the database with its raw source intact before any rule, assignment, notification or auto-reply runs. Nothing is deleted on receipt: spam is a flag with a reason, not a deletion, because a filter that silently eats real customers is undetectable from the inside. And it mails robots. RFC 3834 sets the rules and they are not optional if you intend to send any automatic response: a responder MUST NOT reply to a message carrying an Auto-Submitted header field with any value other than 'no', MUST NOT generate a response whose destination would be a null return path such as an empty SMTP MAIL FROM, SHOULD include Auto-Submitted with the value 'auto-replied' on the responses it does send, and SHOULD NOT send the same response to the same sender more than once within a period of several days, with seven days recommended as the default. Two responders that ignore any of this will mail each other through the night, and the first person to notice will be the postmaster of whoever you are looping with. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and a separate worker process · receiving mail and serving an interface have different failure modes and should not share a crash - Storage: PostgreSQL with the raw message source stored alongside the parsed fields · parsers improve and the original bytes are the only thing you can reparse from - Receiving: One mailbox polled or delivered by webhook, deduplicated by message identifier · every delivery path redelivers, and a duplicate ticket is a customer answered twice - Threading: Reply-chain headers first, a signed token in the reply address as the fallback · subject matching merges different customers and is the classic way this breaks - Sending: One outbound identity with the auto-response rules enforced in code · the loop protection belongs where it cannot be configured away ### Data model (create this before Phase 1) - conversations(id, subject, contact_id, mailbox_id, state, assignee_id, assigned_at, last_activity_at, token) · token is the signed value that appears in the reply address - messages(id, conversation_id, direction, message_id_header, in_reply_to, references_json, from_addr, to_json, sent_at, received_at, body_text, body_html, raw_path, spam_reason, auto_submitted) - contacts(id, email, name, first_seen_at, deleted_at) - events(id, conversation_id, actor_id, kind, payload_json, at) · append-only: assigned, replied, noted, state changed, flagged - notes(id, conversation_id, author_id, body, at) · internal, never sent, and rendered so it cannot be mistaken for a reply - attachments(id, message_id, filename_original, stored_name, sha256, bytes, mime) - suppressions(address, reason, until) · the seven-day auto-response record and anything you refuse to mail - raw_path is not optional. The parsed fields are a view of the original message and are regenerable from it; the original is not regenerable from them. ### Phase 1 · Receive it, store it, then do anything else Build: Fetch or receive into a worker, write the raw bytes to disk and the row in one transaction, then return. Deduplicate on the message identifier header plus the mailbox. Run parsing, rules and notifications afterwards, and let them fail without losing the message. Flag spam with a reason rather than dropping it. Trap: Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. Done when: A message whose body cannot be parsed still creates a conversation containing its raw source and a visible parse error, tested with a deliberately malformed message. Delivering the same message twice produces one conversation and one message row, tested with a duplicate identifier. Every message row has a raw file on disk whose hash matches, asserted across the whole table by a check command. A message flagged as spam is visible in a spam view with its reason and can be restored to the inbox in one action. Do not build yet: No threading, assignment, replies or attachments yet. ### Phase 2 · Threading by the reply chain, never by subject Build: Thread on In-Reply-To and the References chain first. Add a signed token to the reply address you send from, and use it when the chain is missing. Only if both are absent, start a new conversation. Never merge on subject, and never merge two conversations automatically. Trap: Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. Done when: A reply carrying only References and no In-Reply-To attaches correctly, tested with a fixture from a real client. A reply with all threading headers stripped but sent to the token address attaches correctly, tested. Two messages from different addresses with identical subjects create two conversations, tested. A tampered or expired token is rejected and starts a new conversation rather than attaching to the one it names, tested with a forged value. Do not build yet: No assignment, replies, notifications or attachments yet. ### Phase 3 · Two people, one queue, no double replies Build: Record every assignment, state change, note and reply as an event row. Show who else has the conversation open and whether they are typing a reply, using a short-lived presence record. Warn before sending when someone else has replied since you opened it, and show what they sent. Trap: Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. Done when: Sending a reply when another reply arrived after your view loaded is blocked with the newer reply shown and requires an explicit confirmation, tested by racing two sessions. Every assignment, state change, note and reply produces an event row and no event row is ever updated, enforced in the schema. Presence expires on its own so a crashed browser does not leave a conversation looking permanently occupied, tested by advancing the clock. The conversation history renders events and messages in one timeline with internal notes visibly distinct from sent replies. Do not build yet: No outbound sending rules, attachments, retention or export yet. ### Phase 4 · Outbound mail that does not join a loop Build: Before any automatic response, check the inbound message for an Auto-Submitted header with a value other than 'no', a null return path, and list headers, and skip the response if any is present with the reason recorded. Stamp your own automatic responses as auto-replied. Record a suppression per recipient and do not repeat within the configured window. Trap: An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. Done when: A message carrying an Auto-Submitted header with any value other than 'no' receives no automatic response and the skip is recorded with its reason, tested for several values. A message with a null return path receives no automatic response, tested. Every automatic response this system sends carries Auto-Submitted with the value 'auto-replied', asserted on the outbound source. A second automatic response to the same address inside the configured window is suppressed, tested at the boundary, and an hourly cap exists and raises a visible alert when reached. Do not build yet: No attachments, retention, export or search yet. ### Phase 5 · Attachments, deletion and taking the mailbox with you Build: Store attachments under generated names outside the web root, keeping the supplied filename as a label only, and serve downloads through a handler that sets the type itself. Delete a contact with every message, attachment and event, including the raw files. Export conversations as standard mailbox files plus a JSON sidecar, and take automatic backups with a tested restore. Trap: Saving an attachment under the filename the sender supplied puts a sender-chosen path on your disk and a sender-chosen extension in front of your web server, which is a traversal and an execution problem in one line of convenience. Generate the stored name from the hash, keep the original as a display label, and never build a filesystem path from anything that arrived in a message. Done when: An attachment with a crafted filename containing path separators is stored under a generated name in the expected directory, tested with a traversal attempt. Downloads are served by a handler that sets the content type from your own detection rather than from the message, verified by review. Deleting a contact removes their messages, attachments, events and raw files, verified by checking every table and the disk afterwards. Export produces standard mailbox files that open in an ordinary mail client, and a restore from backup is exercised in the test suite. Do not build yet: Nothing after this. ### Out of scope (and why) - Guaranteed deliverability to any inbox, which is an operational commitment involving reputation, authentication and years of sending history that a self-hosted inbox cannot honestly make. - Social and messaging channels, each of which is its own integration with its own retention rules and its own idea of what a conversation is. - A live chat widget, which is a second real-time product with presence, reconnection and offline handling of its own. - An assistant that answers tickets from your history, which needs the knowledge base and the threading correct first or it will confidently answer with the wrong customer's context. ### README must contain - That every message is written with its raw source before any rule or notification runs, and that spam is flagged rather than deleted - That threading uses the reply chain and a signed token, never the subject, with the reason - The exact automatic response rules implemented, naming the specification and the suppression window - That attachments are stored under generated names and served with a type your own code decided - How to export the mailbox as standard mailbox files and how the restore is tested
# Architecture · Groove ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and a separate worker process | receiving mail and serving an interface have different failure modes and should not share a crash | | Storage | PostgreSQL with the raw message source stored alongside the parsed fields | parsers improve and the original bytes are the only thing you can reparse from | | Receiving | One mailbox polled or delivered by webhook, deduplicated by message identifier | every delivery path redelivers, and a duplicate ticket is a customer answered twice | | Threading | Reply-chain headers first, a signed token in the reply address as the fallback | subject matching merges different customers and is the classic way this breaks | | Sending | One outbound identity with the auto-response rules enforced in code | the loop protection belongs where it cannot be configured away | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Receive | Raw storage, deduplication and spam flagging. | Any transport, provided the bytes land before anything else runs. | | Threading | Reply chain resolution and signed tokens. | Any scheme except subject matching, which is the leak. | | Collaboration | Events, presence and send-time collision checks. | Any presence mechanism, provided the check happens at send. | | Outbound | Automatic response rules, stamping and suppression. | Any sender, provided the header checks stay in code rather than configuration. | | Retention | Attachments, contact deletion and export. | Any storage, provided paths are never built from message content. | ## 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 · Conversations, messages, events and suppressions. - `RAW_MAIL_DIR` · required · Original message bytes. Everything parsed is regenerable from here. - `ATTACHMENT_DIR` · required · Outside the web root, generated names only. - `REPLY_TOKEN_SECRET` · required, secret · Signs the token in the reply address. Rotating it invalidates outstanding threads, so plan for overlap. - `REPLY_DOMAIN` · required · Where token addresses are routed. Needs catch-all or plus-addressing. - `AUTOREPLY_SUPPRESS_DAYS` · required · No second automatic response to the same address inside this window. - `AUTOREPLY_HOURLY_CAP` · required · Total automatic responses per hour. Hitting it raises a visible alert. - `BACKUP_DIR` · required · Database dumps and raw mail. The restore is exercised in the suite. ## 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 · Groove product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript and a separate worker process, PostgreSQL with the raw message source stored alongside the parsed fields, One mailbox polled or delivered by webhook, deduplicated by message identifier, Reply-chain headers first, a signed token in the reply address as the fallback, One outbound identity with the auto-response rules enforced in code. - 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 - Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. - Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. - Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. - An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. - Saving an attachment under the filename the sender supplied puts a sender-chosen path on your disk and a sender-chosen extension in front of your web server, which is a traversal and an execution problem in one line of convenience. Generate the stored name from the hash, keep the original as a display label, and never build a filesystem path from anything that arrived in a message.
# Delivery milestones · Groove Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Receive it, store it, then do anything else Every inbound message is persisted with its raw source before any processing, and redelivery creates nothing new. ### Steps 1. Write raw bytes and the row before anything else One transaction, then return, then process. Files: `worker/receive.ts`, `tests/receive.test.ts` 2. Deduplicate on the message identifier Because every delivery mechanism redelivers eventually. Files: `worker/dedupe.ts` 3. Make spam a flag with a reason A silent filter eating real mail cannot be discovered. Files: `src/spam/flag.ts` ### Done when - [ ] An unparseable message still creates a conversation - [ ] Redelivery creates one conversation and one message - [ ] Every message row has a matching raw file - [ ] Spam is flagged with a reason and restorable ### Watch out - Parsing the message first and storing the result means a malformed multipart or an unexpected encoding turns into an exception in the receiver, the delivery is retried, it fails the same way, and the customer's message never appears anywhere you can see it. Store the bytes first, parse second, and let a parse failure produce a conversation containing the raw source with an error on it. ## M2 · Threading by the reply chain, never by subject Replies attach to the right conversation even when a client rewrites the headers you hoped for. ### Steps 1. Follow In-Reply-To and the References chain In that order, and handle a chain that has been truncated. Files: `src/threading/chain.ts`, `tests/threading.test.ts` 2. Sign the token in the reply address Verified on the way back in, and rejected if it does not verify. Files: `src/threading/token.ts`, `tests/token.test.ts` 3. Never merge on subject, never merge automatically A wrongly merged conversation leaks one customer to another. Files: `src/threading/index.ts` ### Done when - [ ] References-only replies attach correctly - [ ] A stripped-header reply attaches by token - [ ] Identical subjects from different senders stay separate - [ ] A forged token starts a new conversation ### Watch out - Threading by subject means two different customers who both write 'Re: my order' land in one conversation, one of them reads the other's address and order details in the quoted history, and you find out from the second customer. Use the reply chain and a signed token in the address, and if both are missing, a new conversation is the correct answer even when it looks like a duplicate. ## M3 · Two people, one queue, no double replies Assignment and activity are an append-only event log, and a second agent opening a conversation sees the first one there. ### Steps 1. Append an event for everything that happens The current state is a projection; the events are the record. Files: `src/events/append.ts`, `src/db/schema.sql` 2. Check for newer replies at send time Not at page load, which is where the race lives. Files: `src/replies/send.ts`, `tests/collision.test.ts` 3. Expire presence on a timer A crashed tab should not lock a customer out of an answer. Files: `src/presence/index.ts` ### Done when - [ ] A racing reply is blocked with the newer one shown - [ ] Events are append-only at the database level - [ ] Presence expires without a clean disconnect - [ ] Notes are visibly distinct from replies in the timeline ### Watch out - Storing only the current assignee means that when two people reply within the same minute the interface shows the second assignment and nothing about the first reply, so the customer receives two different answers and neither agent knows. Append events, and check for a reply newer than your view at the moment of sending, not when the page loaded. ## M4 · Outbound mail that does not join a loop Automatic responses obey the published rules and the same address is never auto-answered twice in a week. ### Steps 1. Check Auto-Submitted, return path and list headers first Three cheap checks that prevent the expensive failure. Files: `src/outbound/autoreply.ts`, `tests/autoreply.test.ts` 2. Stamp your own automatic responses So the responder on the other side can do the same for you. Files: `src/outbound/headers.ts` 3. Suppress per recipient and cap per hour The suppression window plus a cap is what breaks a novel loop. Files: `src/outbound/suppress.ts`, `tests/suppress.test.ts` ### Done when - [ ] Auto-Submitted messages receive no automatic response - [ ] A null return path receives no automatic response - [ ] Outbound automatic responses are stamped auto-replied - [ ] Repeat responses are suppressed and an hourly cap alerts ### Watch out - An autoresponder that answers every inbound message will answer the other side's autoresponder, and because both of you are stamping nothing and checking nothing, the pair will exchange several thousand messages overnight and get your sending domain a reputation. Check the headers, stamp your own, suppress per recipient for a week, and cap total automatic responses per hour with an alert when the cap is hit. ## M5 · Attachments, deletion and taking the mailbox with you (production only) Files are stored safely, a contact can be deleted for real, and everything exports in a format that is not yours. ### Steps 1. Generate stored names, keep the original as a label Never build a path from something that arrived in a message. Files: `src/attachments/store.ts`, `tests/traversal.test.ts` 2. Delete a contact completely, files included The raw sources are where the forgotten copies live. Files: `src/contacts/delete.ts`, `tests/delete.test.ts` 3. Export to standard mailbox files and test the restore The format check is whether a normal mail client opens it. Files: `src/export/mbox.ts`, `tests/restore.test.ts` ### Done when - [ ] A crafted filename is stored under a generated name - [ ] Downloads set the type from your own detection - [ ] Contact deletion removes rows and raw files - [ ] Exports open in a mail client and restore is tested ### Watch out - Saving an attachment under the filename the sender supplied puts a sender-chosen path on your disk and a sender-chosen extension in front of your web server, which is a traversal and an execution problem in one line of convenience. Generate the stored name from the hash, keep the original as a display label, and never build a filesystem path from anything that arrived in a message.
# Operations · Groove ## Backup The database and the raw mail directory together. The raw directory is the source of truth for content and the database holds the threading, assignment and event history that cannot be reconstructed from the mail alone. ## Restore Restore both, reparse a sample of raw messages and confirm they still thread to the same conversations, then confirm the suppression table survived. A lost suppression table means the first hour after a restore can mail somebody twice. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Receiver lag, parse failure rate, automatic responses per hour against the cap, and the count of conversations with no assignee older than a day. The last one is the number that actually predicts an angry customer. ## Incident checklist The incidents are a lost message, a merged conversation and a mail loop. Raw storage answers the first, the threading decisions in the event log answer the second, and the suppression table plus the hourly cap contain the third. 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 - [ ] An unparseable message is confirmed to create a conversation with its raw source - [ ] Identical subjects from different senders are confirmed to stay separate - [ ] A racing reply is confirmed blocked with the newer reply shown - [ ] Auto-Submitted messages are confirmed to receive no automatic response - [ ] A crafted attachment filename is confirmed stored under a generated name ## Launch constraint Do not market omitted Groove 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. Conversations, messages, events and suppressions. DATABASE_URL=postgres://user:pass@localhost:5432/inbox # Required. Original message bytes. Everything parsed is regenerable from here. RAW_MAIL_DIR=./data/raw # Required. Outside the web root, generated names only. ATTACHMENT_DIR=./data/attachments # Required · secret. Signs the token in the reply address. Rotating it invalidates outstanding threads, so plan for overlap. REPLY_TOKEN_SECRET=redacted # Required. Where token addresses are routed. Needs catch-all or plus-addressing. REPLY_DOMAIN=reply.example.com # Required. No second automatic response to the same address inside this window. AUTOREPLY_SUPPRESS_DAYS=7 # Required. Total automatic responses per hour. Hitting it raises a visible alert. AUTOREPLY_HOURLY_CAP=50 # Required. Database dumps and raw mail. The restore is exercised in the suite. BACKUP_DIR=./data/backups
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Groove because support software survives because missing or misrouting one customer message is expensive and every channel behaves differently. The recurring cost buys message delivery, webhooks, threading, identity, permissions, attachments, notifications, spam, search, uptime, and retention, not just the visible interface.
xemail reliability, reporting, rules, integrations, and mature simplicity
xomnichannel integrations
xlarge-scale deliverability
xAI resolution tuning
xenterprise routing and compliance
Don't feel like building it? These folks already made it free.
all 6 free alternatives to Groove →· no votes, no pay-to-list · just what's real
Vibecode Groove
Kinda. The core of Groove is buildable in a weekend with the prompt on this page, but there are real gaps: email reliability, reporting, rules, integrations, and mature simplicity, omnichannel integrations. Read the honest list above before committing.
How much does Groove cost?
Groove costs about $20/month (Standard, checked 2026-07-31), which is $240 per year.
What do I lose by replacing Groove?
Honestly: email reliability, reporting, rules, integrations, and mature simplicity; omnichannel integrations; large-scale deliverability; AI resolution tuning; enterprise routing and compliance. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Groove?
Yes: Chatwoot (A proper support inbox with chat, email and a help center; free software, billable server chores.) Zammad (A serious ticket desk with email, chat, SLAs and a knowledge base; setup is the first ticket.) osTicket (Email and web forms become assigned tickets; dated, dependable and very much your PHP server.) All 6 curated free alternatives are at vibecodeit.com/groove/alternatives. The prompt is for when you want it exactly your way.