Build Groove

KINDAreplaces $20/mosaves $240/yrback to the verdict

0%0 of 17 items done

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

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

RuntimeNext.js 15 with TypeScript and a separate worker processStoragePostgreSQL with the raw message source stored alongside the parsed fieldsReceivingOne mailbox polled or delivered by webhook, deduplicated by message identifierThreadingReply-chain headers first, a signed token in the reply address as the fallbackSendingOne outbound identity with the auto-response rules enforced in code

Before step 1

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

  1. installfree

    Why Runs the interface and the worker.

    Get it nodejs.org, or your version manager. open ↗

    Verify node --version prints v22 or higher

  2. installfree

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

    Verify psql connects and the migrations apply cleanly

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

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

  5. decidefree

    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

Data model

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

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

Environment variables

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

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://user:pass@localhost:5432/inboxConversations, messages, events and suppressions.
RAW_MAIL_DIRrequired./data/rawOriginal message bytes. Everything parsed is regenerable from here.
ATTACHMENT_DIRrequired./data/attachmentsOutside the web root, generated names only.
REPLY_TOKEN_SECRETsecretrequiredredactedSigns the token in the reply address. Rotating it invalidates outstanding threads, so plan for overlap.
REPLY_DOMAINrequiredreply.example.comWhere token addresses are routed. Needs catch-all or plus-addressing.
AUTOREPLY_SUPPRESS_DAYSrequired7No second automatic response to the same address inside this window.
AUTOREPLY_HOURLY_CAPrequired50Total automatic responses per hour. Hitting it raises a visible alert.
BACKUP_DIRrequired./data/backupsDatabase dumps and raw mail. The restore is exercised in the suite.

The build, in order

  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.

    1. One transaction, then return, then process.

      Files worker/receive.tstests/receive.test.ts

    2. Because every delivery mechanism redelivers eventually.

      Files worker/dedupe.ts

    3. A silent filter eating real mail cannot be discovered.

      Files src/spam/flag.ts

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

    1. In that order, and handle a chain that has been truncated.

      Files src/threading/chain.tstests/threading.test.ts

    2. Verified on the way back in, and rejected if it does not verify.

      Files src/threading/token.tstests/token.test.ts

    3. A wrongly merged conversation leaks one customer to another.

      Files src/threading/index.ts

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

    1. The current state is a projection; the events are the record.

      Files src/events/append.tssrc/db/schema.sql

    2. Not at page load, which is where the race lives.

      Files src/replies/send.tstests/collision.test.ts

    3. A crashed tab should not lock a customer out of an answer.

      Files src/presence/index.ts

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

    1. Three cheap checks that prevent the expensive failure.

      Files src/outbound/autoreply.tstests/autoreply.test.ts

    2. So the responder on the other side can do the same for you.

      Files src/outbound/headers.ts

    3. The suppression window plus a cap is what breaks a novel loop.

      Files src/outbound/suppress.tstests/suppress.test.ts

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

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