buildyourown.software

Shared inbox · by Help Scout PBC

Build your own Help Scout.

A shared inbox for support@ with assignment, statuses, tags, and saved replies. Help Scout charges $25 to $75 per user per month for it.

What you're paying now

$1,500 per year

Help Scout Standard for a 5-person team: $25 × 5 users × 12 months billed monthly. About $1,260 on annual billing.

Build it now

Build it in any tool

Paste into Claude Code, ChatGPT, Codex, Replit, Lovable, or Cursor. The agent fetches everything it needs.

Paste anywhere

Build my own Help Scout. Fetch https://buildyourown.software/like/helpscout/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals.

Claude Code

claude "Build my own Help Scout. Fetch https://buildyourown.software/like/helpscout/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."

Codex CLI

codex "Build my own Help Scout. Fetch https://buildyourown.software/like/helpscout/llms.md and follow it: build the app from the build prompt, then write and run the tests from the test plan. Ask me before changing the data model or the non-goals."
Raw files for agents:llms.mdbuild-prompt.mdtest-prompt.mdIf your tool can't open links, copy the full prompt in section 05 instead.

01

What it does

Help Scout turns a support address like support@yourcompany.com into a shared inbox. Every inbound email becomes a conversation. Your team assigns it to someone, sets a status (active, pending, closed), adds tags, and replies from the shared address. Customers only ever see a normal email.

Around that inbox it sells a help center (Docs), a website widget (Beacon) with live chat, workflows that auto-tag and auto-assign, an AI assistant, and reports on volume and response time. It also has customer profiles that show every past conversation with a person.

The part small teams actually use is the inbox: threading, assignment, a few tags, saved replies, private notes, and a warning when two people open the same email. That's a webhook, a few tables, and one tricky piece (email threading headers).

02

What it costs

List prices from the vendor's own pricing page. The fine print is where the money goes.

Free

$0

up to 5 users

  • 1 inbox
  • 1 Docs site
  • 100 contacts per month
  • 10 saved replies, 10 tags

A contact is someone who got a reply from your team or was resolved by the AI assistant in that month.

Standard

$25

per user / month billed monthly (about $21 on annual billing)

  • Up to 25 users
  • 2 inboxes
  • 2 Docs sites
  • Unlimited contacts
  • Live chat, SMS, social channels
  • 150 workflows, 1 SLA policy

Plus

$45

per user / month billed monthly (about $38 on annual billing)

  • Up to 50 users
  • 5 inboxes
  • 3 Docs sites
  • 25 light users
  • 500 workflows, 2 SLA policies
  • Salesforce, Jira, HubSpot integrations

Pro

$75

per user / month, minimum 10 users

  • 10 inboxes
  • 5 Docs sites
  • 50 light users
  • Unlimited workflows and SLAs
  • SSO/SAML, HIPAA
  • Dedicated onboarding

Contact sales. Annual price for Pro is not shown on the page. Verify before you buy.

The fine print

  • Pro has a 10-user minimum, so the floor is $750 per month whether or not you have 10 people.
  • Help Scout changed its pricing model twice recently: per user, then per contact in 2025, then back to per user. Quotes you saw last year probably don't match today's page.
  • The Free plan caps you at 100 contacts per month and one inbox. A second address like billing@ pushes you to a paid plan.
  • Extra inboxes cost $10 per month on annual billing ($12 monthly) and extra Docs sites cost $20 per month ($24 monthly), on top of seats.
  • AI Answers is metered at $0.75 per resolution after a 3-month trial. You need to set a monthly spending cap yourself.
  • SSO/SAML and HIPAA are paid add-ons on Standard and Plus. Light users, the cheap read-only seats, only exist on Plus and Pro.

Prices checked September 10, 2026 at www.helpscout.com. They change. Check before you quote them.

03

What you actually use

The headline features, and an honest call on whether a typical team needs each one.

9 of 13 headline features are worth building for a typical user. The rest is what you're paying for.

  • Build it

    Shared inbox with threading

    Inbound email to support@ becomes a conversation. Replies thread correctly in both directions.

    Why: This is the product. Get Message-ID, In-Reply-To, and References right and the rest is CRUD.

  • Build it

    Assignment and status

    Assign a conversation to a person. Mark it active, pending, or closed.

    Why: Two columns on the conversations table. Without them a shared inbox is just Gmail with more people in it.

  • Build it

    Tags

    Label conversations (billing, bug, refund) and filter by them.

    Why: A join table. Tags are how you find out what customers keep asking about.

  • Build it

    Saved replies with variables

    Canned answers with placeholders like the customer's first name.

    Why: Most support answers repeat. A template table and a string substitution function cover it.

  • Build it

    Private notes

    Team-only messages inside a conversation that the customer never sees.

    Why: It's a message with a flag that says 'don't email this'. Cheap, and it replaces a Slack thread per ticket.

  • Build it

    Collision detection

    Warn when a teammate is already viewing or replying to the same conversation.

    Why: Stops two people from answering the same customer. A heartbeat every 15 seconds and a banner.

  • Build it

    Customer profiles

    A record per email address with name, notes, and every past conversation.

    Why: You need to know if this is the third time someone has emailed about the same thing.

  • Build it

    Help center (Docs)

    A public knowledge base with articles and search.

    Why: Markdown files in a table, rendered on a public route, with Postgres full text search. It cuts inbound volume.

  • Build it

    Reports

    Volume, first response time, resolution time, replies per agent.

    Why: One daily aggregation query. Keep the report to the numbers you'll actually look at.

  • Maybe

    Workflows

    Rules like 'if subject contains refund, tag billing and assign to Sam'.

    Why: Two or three hardcoded rules in the inbound handler do the job. Build a rule editor only if the rules keep changing.

  • Maybe

    AI drafts and summaries

    Draft a reply from the thread and past articles. Summarize a long conversation.

    Why: One OpenRouter call with the thread and your help center articles as context. Worth adding once the inbox works.

  • Skip

    Beacon widget and live chat

    A chat bubble on your site that creates conversations in real time.

    Why: Chat means presence, typing indicators, and someone online to answer. A contact form that emails support@ covers the small-team case.

  • Skip

    SMS, WhatsApp, social channels, phone

    Pull messages from other channels into the same inbox.

    Why: Each channel is a separate integration with its own rules. Email first.

04

How it works under the hood

The data model, the flows that matter, and the integrations you need. This is what the build prompt is based on.

Data model

Conversation

id · number · mailbox_id · subject · customer_id · assignee_id · status (active | pending | closed | spam) · reply_token · last_customer_message_at · last_agent_message_at · first_response_at · closed_at

number is a short human-friendly sequence for the UI. reply_token goes in the Reply-To address as a threading fallback.

Message

id · conversation_id · kind (inbound | outbound | note) · author_user_id · from_email · to_emails · cc_emails · message_id · in_reply_to · references · text_body · html_body · quoted_text · sent_at

message_id is unique. Store both the raw headers you receive and the ones you send.

Customer

id · mailbox_id · email · name · company · notes · custom (json) · created_at

Email is unique per mailbox. Create on first inbound message.

User (agent)

id · email · name · role (admin | agent) · signature

Saved reply

id · mailbox_id · name · body_markdown · created_by

Variables are {{customer.first_name}}, {{agent.first_name}}, {{conversation.subject}}.

Article

id · collection_id · slug · title · body_markdown · published · search_vector · updated_at

Public at /help/<collection>/<slug>. search_vector is a Postgres tsvector column.

Key flows

Inbound email becomes a conversation

  1. 1.Your email provider posts the parsed message to POST /api/inbound with a signature you verify.
  2. 2.Reject duplicates by Message-ID. Drop auto-replies and bounces (Auto-Submitted, Precedence: bulk, mailer-daemon).
  3. 3.Look up In-Reply-To and References against stored message IDs. Then check for a reply_token in the To address. Then match subject plus sender within 30 days.
  4. 4.Append to the matched conversation and set it back to active if it was closed or pending. Otherwise create a customer (if new) and a new conversation.
  5. 5.Split quoted text from the new text so the thread view stays readable.

Reply from the shared address

  1. 1.Agent writes a reply, optionally starting from a saved reply with variables filled in.
  2. 2.Server generates a Message-ID, sets In-Reply-To to the customer's last message, and builds References from the chain.
  3. 3.Send from support@yourcompany.com with Reply-To support+<reply_token>@yourcompany.com and the agent's name as the display name.
  4. 4.Store the outbound message with its Message-ID so the customer's next reply threads back.
  5. 5.Set status to the one the agent picked (closed by default) and stamp first_response_at if empty.

Collision warning

  1. 1.Opening a conversation posts a presence heartbeat every 15 seconds with state viewing or replying.
  2. 2.The conversation view shows a banner when someone else has a heartbeat under 45 seconds old.
  3. 3.Typing in the reply box switches the state to replying, which turns the banner into a stronger warning.

Daily report

  1. 1.Bucket conversations by day in the mailbox timezone.
  2. 2.Count new conversations, replies sent, and conversations closed per day and per agent.
  3. 3.Compute median first response time (first_response_at minus created_at) and median resolution time.
  4. 4.Show the last 30 days as a table with a CSV export.

Integrations

  • Inbound email (Resend, Postmark, or Cloudflare Email Workers)requiredReceive mail sent to support@ and post it to your webhook as parsed JSON.
  • Outbound email (Resend or Postmark)requiredSend replies from the shared address with custom headers.
  • DNS (MX, SPF, DKIM, DMARC)requiredRoute inbound mail to the provider and keep your replies out of spam.
  • Postgres (Supabase or Neon)requiredConversations, messages, customers, articles, full text search.
  • Auth (Supabase Auth, Clerk, or NextAuth)requiredTeam sign-in with magic link or Google.
  • File storage (S3 or Supabase Storage)optionalAttachments on inbound and outbound messages.
  • Realtime (Supabase Realtime or Pusher)optionalPush presence and new-message updates instead of polling.
  • OpenRouteroptionalDraft replies and summarize long threads with any model.
  • CSV exportrequiredGet conversations and customers out of Help Scout and out of your own app later.

05

The build prompt

The full spec the one-line command points at. Paste it directly if your tool can't fetch URLs, or edit the data model and non-goals first to fit your team.

helpscout-build-prompt.md · 290 lines · 25,537 chars
# Build a shared support inbox (replacing Help Scout)

You are building a shared email inbox for a support address like support@yourcompany.com, used by a team of 2 to 10 people. It replaces Help Scout for teams that only use the inbox, saved replies, notes, tags, customer history, a small help center, and a basic report. Build it end to end. Correct email threading matters more than anything else: if a customer's reply lands in the wrong conversation, nothing else counts.

## Stack

- Next.js (App Router) with TypeScript, Tailwind
- Postgres via Supabase or Neon, with Drizzle or Prisma
- Auth: magic-link email plus Google sign-in (Supabase Auth, Clerk, or NextAuth, whichever is simplest)
- Inbound email: Resend inbound, Postmark inbound, or a Cloudflare Email Worker, posting parsed JSON to `POST /api/inbound`
- Outbound email: Resend or Postmark, using the custom-headers option so you can set `Message-ID`, `In-Reply-To`, and `References`
- `mailparser` (or the provider's parsed payload) for MIME, `sanitize-html` for rendering HTML bodies, `marked` for markdown
- `date-fns` and `date-fns-tz` for every date calculation. Never do timezone math by hand.
- Realtime: Supabase Realtime or a 10-second poll for presence and new messages. Polling is fine to start.
- Deploy target: Vercel, with Vercel Cron for the daily report digest

If a different stack is already set up in this repo, use it and keep the behavior identical.

## Environment variables

- `DATABASE_URL`
- `AUTH_SECRET` plus whatever the auth provider needs
- `INBOUND_PROVIDER` (`resend` | `postmark` | `cloudflare`) and `INBOUND_WEBHOOK_SECRET`
- `OUTBOUND_PROVIDER` (`resend` | `postmark`), `RESEND_API_KEY` or `POSTMARK_SERVER_TOKEN`
- `STORAGE_BUCKET` and the storage credentials
- `CRON_SECRET` for the report digest
- `OPENROUTER_API_KEY` (optional)

## Data model

Every table has `id` (uuid), `created_at`, `updated_at`.

- `mailboxes`: `name`, `address` (e.g. `support@yourcompany.com`, unique), `display_name` (e.g. `Acme Support`), `timezone` (IANA, e.g. `America/New_York`), `reply_domain` (e.g. `yourcompany.com`), `signature_markdown`.
  One mailbox is enough. Keep `mailbox_id` on everything so a second one can be added later.
- `users`: `email` (unique), `name`, `avatar_url`, `role` (`admin` | `agent`), `signature_markdown` (nullable).
- `mailbox_members`: `mailbox_id`, `user_id`. Primary key on both.
- `customers`: `mailbox_id`, `email` (unique per mailbox, lowercased), `name` (nullable), `company` (nullable), `notes` (text), `custom` (jsonb), `conversation_count` (int, maintained by the app).
- `conversations`: `mailbox_id`, `number` (int, sequence per mailbox, unique with `mailbox_id`), `subject`, `customer_id`, `assignee_id` (nullable FK users), `status` (`active` | `pending` | `closed` | `spam`), `reply_token` (16 random bytes, base32, unique), `last_customer_message_at`, `last_agent_message_at` (nullable), `first_response_at` (nullable), `closed_at` (nullable), `message_count` (int).
- `messages`: `conversation_id`, `kind` (`inbound` | `outbound` | `note`), `author_user_id` (nullable, set for outbound and note), `from_email`, `from_name`, `to_emails` (text[]), `cc_emails` (text[]), `message_id` (text, unique, nullable for notes), `in_reply_to` (nullable), `references` (text[]), `subject`, `text_body`, `html_body` (nullable), `quoted_text` (nullable), `raw_headers` (jsonb), `sent_at` (timestamptz), `delivery_status` (`queued` | `sent` | `failed`, outbound only), `delivery_error` (nullable), `provider_message_id` (nullable).
  Index on `message_id`. Full text index on `text_body`.
- `attachments`: `message_id`, `filename`, `content_type`, `size_bytes`, `storage_key`.
- `tags`: `mailbox_id`, `name` (unique per mailbox, lowercase), `color`.
- `conversation_tags`: `conversation_id`, `tag_id`. Primary key on both.
- `saved_replies`: `mailbox_id`, `name`, `body_markdown`, `created_by`, `use_count` (int).
- `conversation_events`: `conversation_id`, `actor_user_id` (nullable), `type` (`status_changed` | `assigned` | `tagged` | `untagged` | `reopened_by_customer` | `merged`), `data` (jsonb).
  Rendered inline in the thread as small gray lines.
- `presence`: `conversation_id`, `user_id`, `state` (`viewing` | `replying`), `seen_at`. Primary key on (`conversation_id`, `user_id`).
- `collections`: `mailbox_id`, `slug`, `name`, `position`.
- `articles`: `collection_id`, `slug` (unique per collection), `title`, `body_markdown`, `published` (bool), `search_vector` (tsvector generated from title and body, GIN index), `view_count`.

Seed: one mailbox, two users (one admin), tags `billing`, `bug`, `question`, `refund`, three saved replies, one collection with three articles, and 30 conversations across the last 14 days with a mix of statuses, assignees, and reply counts.

## Screens

Left sidebar: Inbox, Customers, Help Center, Reports, Settings. Top bar has a search box that searches conversation subject, message body, and customer email.

### 1. Sign in

- Magic link or Google.
- The first user becomes admin and creates the mailbox (name, address, timezone).
- Admins invite by email. Invited users land in the inbox on first sign-in.

### 2. Inbox (`/inbox`)

- Folders on the left: Unassigned, Mine, All Active, Pending, Closed, Spam, plus one entry per tag with a count.
- Each row shows customer name, subject, a one-line preview of the latest message, assignee avatar, tags, and the relative time of the last message.
- Default sort for Active is oldest `last_customer_message_at` first, so the customer who has waited longest is at the top. Closed sorts newest first.
- Bulk select with checkboxes for assign, tag, and close.
- Keyboard: j/k to move, enter to open, e to close, a to assign to me, s to mark pending.
- Paginate at 50. Server components for the initial render.

### 3. Conversation (`/inbox/[number]`)

- Header: subject, `#number`, status dropdown, assignee dropdown, tag picker.
- Thread: messages oldest first. Inbound on the left, outbound on the right, notes with a yellow background and a lock icon. Events as gray one-liners between messages.
- Each message shows sender, time in the mailbox timezone, and a "Show quoted text" toggle when `quoted_text` is set.
- Attachments listed under the message with filename and size, downloadable through a signed URL.
- Right column: the customer card (name, email, company, notes, editable inline) and a "Previous conversations" list with status and date.
- Presence banner above the composer (see Collision detection).

### 4. Reply composer

- Lives at the bottom of the conversation. Tabs for Reply and Note.
- Toolbar: Insert saved reply (searchable dropdown), attach file, and a send button with a split menu: "Send and close" (default), "Send and keep active", "Send and mark pending".
- Cmd+Enter sends. Esc clears focus.
- Draft auto-saves to localStorage every 2 seconds keyed by conversation id and restores on reload.
- Optional "Draft reply" and "Summarize" buttons when `OPENROUTER_API_KEY` is set.

### 5. Customers (`/customers`, `/customers/[id]`)

- Table with search by name, email, or company. Columns: name, email, company, conversation count, last contact.
- Detail page: editable profile on the left, every conversation for that customer on the right, newest first, with status and assignee.
- Admins can merge two customers (moves conversations, keeps the older record, writes a `merged` event on each moved conversation).

### 6. Help Center admin (`/help-center`)

- Collections list with drag-to-reorder. Articles per collection.
- Article editor: title, slug (auto from title, editable), collection, markdown body with a live preview, published toggle.
- "View public page" link. Unpublished articles show a preview link that only works when signed in.

### 7. Public help center (`/help`, `/help/[collection]`, `/help/[collection]/[slug]`)

- No auth. Home lists collections with article counts.
- Search box hits `GET /api/help/search?q=` and shows the top 10 results with a highlighted snippet.
- Article page renders sanitized markdown with a table of contents from headings, a "Last updated" date, and a "Still stuck? Email us" mailto link to the mailbox address.
- Only `published = true` articles are reachable. Unpublished slugs return 404.

### 8. Reports (`/reports`)

- Date range picker, default last 30 days.
- A table with one row per day and a totals row. Below it, a per-agent table for the range.
- CSV export of both tables.

### 9. Settings (`/settings`)

- Mailbox: name, display name, timezone (searchable IANA list), signature.
- Members and pending invites. Admins can change roles and remove members.
- Tags: rename, recolor, merge into another tag.
- Saved replies: list, create, edit, delete, with a variables cheat sheet.
- Inbound: the webhook URL and secret with a copy button and a "Send test email" instruction.
- Export: conversations, messages, and customers as CSV.

## API endpoints

- `POST /api/inbound` (webhook, signature verified)
- `GET /api/conversations?folder=&tag=&q=&page=`
- `GET /api/conversations/:id`
- `PATCH /api/conversations/:id` (status, assignee, tags)
- `POST /api/conversations/:id/reply`
- `POST /api/conversations/:id/note`
- `POST /api/messages/:id/retry`
- `POST /api/conversations/:id/presence`, `DELETE` same path
- `GET /api/conversations/:id/presence`
- `GET /api/search?q=`
- `GET /api/customers/:id`, `PATCH /api/customers/:id`, `POST /api/customers/:id/merge`
- `GET /api/help/search?q=` (public)
- `GET /api/reports/daily?from=&to=`
- `GET /api/cron/report-digest` (protected by `CRON_SECRET`)
- `POST /api/ai/draft`, `POST /api/ai/summarize` (optional)

Every authenticated route checks that the user is a member of the mailbox that owns the record. Return 404 for records in other mailboxes.

## Behaviors

### Inbound email (`POST /api/inbound`)

1. Verify the provider's signature. Resend and Postmark sign webhooks. For Cloudflare use a shared secret header. Return 401 on failure.
2. Parse into: `message_id`, `in_reply_to`, `references[]`, `from` (email, name), `to[]`, `cc[]`, `subject`, `text`, `html`, `date`, `headers`, `attachments[]`. If the provider gives you raw MIME, run it through `mailparser`.
3. If `message_id` already exists in `messages`, return 200 and do nothing. Providers retry, and customers forward.
4. Drop and return 200 (log it) when any of these is true:
   - `Auto-Submitted` header is present and not `no`
   - `Precedence` is `bulk`, `junk`, or `auto_reply`
   - `X-Autoreply` or `X-Autorespond` is present
   - the from address starts with `mailer-daemon@`, `postmaster@`, `noreply@`, or `no-reply@`
   - the from address equals the mailbox address (your own sent mail looping back)
5. Find the conversation, in this order:
   - Any message whose `message_id` equals `in_reply_to` or any entry in `references`.
   - A `reply_token` in any `to` or `cc` address of the form `support+<token>@<reply_domain>`.
   - A conversation from the same customer whose normalized subject matches and whose `last_customer_message_at` or `last_agent_message_at` is within the last 30 days. Normalize by repeatedly stripping leading `Re:`, `RE:`, `Fwd:`, `FW:`, `AW:`, `SV:`, then trim and lowercase.
   - Otherwise none.
6. If none: find or create the customer by lowercased email. Split the display name into `name`; leave it null if absent. Create the conversation with `status = active`, `subject` (or `(no subject)`), a fresh `reply_token`, and `number` from the sequence.
7. Insert the inbound message. Split the body with a quote detector on `text` that cuts at the first line matching any of:
   - `^On .+ wrote:$` (allow the line to wrap once)
   - `^-----Original Message-----`
   - `^From: .+` followed within 3 lines by `^Sent: ` or `^Date: `
   - a run of 3 or more lines starting with `> `
   - in `html`, a `<div class="gmail_quote">` or `<blockquote type="cite">` boundary
   Everything after the cut goes in `quoted_text`. If the detector leaves `text_body` empty, keep the whole thing in `text_body` and null `quoted_text`.
8. Store attachments in object storage under `attachments/<message_id>/<filename>`. Reject any single file over 10 MB with a log line; keep the message.
9. Update the conversation: `last_customer_message_at = sent_at`, `message_count += 1`. If the status was `closed` or `pending`, set it to `active` and write a `reopened_by_customer` event. Increment the customer's `conversation_count` only on a new conversation.
10. Return 200 with `{ conversation_id, message_id, matched_by: "headers" | "token" | "subject" | "new" }`. Return 200 for every handled case so the provider stops retrying. Return 500 only on a database error.

Run steps 3 through 9 in one transaction so a retry mid-way can't leave a conversation without a message.

### Outbound reply (`POST /api/conversations/:id/reply`)

1. Body: `{ text_markdown, status_after: "closed" | "active" | "pending", attachments?: [] }`. Validate with zod. Notes go to `POST /api/conversations/:id/note` and are never emailed.
2. Render markdown to HTML and to plain text. Append the agent's signature if set, else the mailbox signature.
3. Generate `message_id = <conv-<conversation_id>-<uuid>@<reply_domain>>`. Set `in_reply_to` to the `message_id` of the most recent inbound message. Set `references` to that message's `references` plus its `message_id`, capped at the last 20 entries.
4. Send with:
   - `from: "<agent name> (<mailbox display_name>) <mailbox address>"`
   - `to: customer email`
   - `cc: the cc list from the last inbound message minus the mailbox address`
   - `reply_to: support+<reply_token>@<reply_domain>`
   - `subject: "Re: <conversation subject>"` (don't double the `Re:`)
   - the three threading headers above
5. Insert the outbound message with `delivery_status = queued` before sending. On provider success set `sent` and store `provider_message_id`. On failure set `failed`, store the error, and show a red "Not sent, retry" banner on the message in the thread. `POST /api/messages/:id/retry` reuses the same `message_id`.
6. Update the conversation: `last_agent_message_at`, `message_count`, `status = status_after`, `closed_at` when closing (null it when reopening), `first_response_at` if null. Write a `status_changed` event when the status changed. Bump `use_count` on any saved reply that was inserted.
7. If the conversation has no inbound message yet (an agent started it from a customer page), send without `In-Reply-To` and still set `Reply-To` with the token.

### Saved replies

- Variables: `{{customer.first_name}}`, `{{customer.name}}`, `{{customer.email}}`, `{{agent.first_name}}`, `{{agent.name}}`, `{{conversation.subject}}`, `{{conversation.number}}`, `{{mailbox.name}}`.
- `first_name` is the first whitespace-separated token of `name`.
- Fallbacks when a value is missing: `{{customer.first_name}}` becomes `there`, `{{customer.name}}` becomes the email local part, everything else becomes an empty string.
- Unknown variables stay as literal text so the agent notices.
- Put this in `lib/templates.ts` as a pure function and unit test it.

### Status, assignment, tags (`PATCH /api/conversations/:id`)

- Accepts `{ status?, assignee_id?, add_tags?: string[], remove_tags?: string[] }`. Every change writes a `conversation_events` row.
- Closing sets `closed_at`. Reopening (any status to `active`) nulls it.
- `spam` hides the conversation from every folder except Spam and increments `customers.custom.spam_count`. Future emails from that customer still create conversations; don't auto-drop them.
- Assigning to yourself from the inbox list is one keystroke (`a`).
- Tag names are created on first use, lowercased and trimmed. Merging tags in Settings moves every `conversation_tags` row and deletes the old tag.

### Collision detection

- The conversation page calls `POST /api/conversations/:id/presence` with `{ state: "viewing" | "replying" }` on open, every 15 seconds while open, on focus of the composer (`replying`), and on blur back to `viewing`. It calls `DELETE` on unmount and on `pagehide`.
- The server upserts `presence` with `seen_at = now()`.
- The page fetches (or subscribes to) presence every 10 seconds and shows a banner above the composer for every other user with `seen_at` within the last 45 seconds: "Sam is viewing this conversation" in gray, or "Sam is replying to this conversation" in amber with the composer border turned amber. Never show the current user.
- The presence endpoint deletes rows older than 5 minutes on every write.
- Sending a reply while someone else is `replying` still works, behind a confirm dialog: "Sam is also replying. Send anyway?"

### Reports (`GET /api/reports/daily?from=YYYY-MM-DD&to=YYYY-MM-DD`)

- Day boundaries are in the mailbox timezone. Convert every timestamp with `date-fns-tz` before bucketing. Never bucket on UTC dates.
- Per day: `new_conversations` (created that day), `replies_sent` (outbound messages with `delivery_status = sent` that day), `closed` (conversations with `closed_at` that day), `median_first_response_minutes` (over conversations created that day with `first_response_at` set), `median_resolution_minutes` (over conversations closed that day, `closed_at` minus `created_at`).
- Show medians as `1h 12m` in the UI and as integer minutes in CSV. Show a dash when there are no samples.
- Per agent for the range: `replies_sent`, `closed`, `median_first_response_minutes` over conversations where their reply was the first response.
- Cap the range at 366 days. Default to the last 30 days.
- The `report-digest` cron runs at 08:00 mailbox time and emails yesterday's row to every admin. Skip the send when there was no activity.

### Search

- `GET /api/search?q=` searches conversations by subject `ILIKE`, messages by `text_body` full text, and customers by email or name.
- Return the top 20 conversations with the matching snippet.
- Search inside the inbox respects the current folder.

### Help center

- `GET /api/help/search?q=` runs `websearch_to_tsquery('english', q)` against `articles.search_vector` where `published = true`, ranked with `ts_rank`, with `ts_headline` snippets. Limit 10.
- Markdown renders through `marked` and then `sanitize-html` with a strict allowlist: no scripts, no iframes, no inline event handlers, links get `rel="noopener"`.
- Headings get ids for the table of contents.
- Article pages are statically cached and revalidated on publish with `revalidatePath`.
- Increment `view_count` in the background; never block the page on it.

### Timezones and dates

- Store every timestamp as `timestamptz`.
- Render in the mailbox timezone in the app and the report. Lists show relative times ("14m ago"); hover and the thread show absolute times ("Sep 10, 2026, 3:42 PM EDT").
- The `sent_at` for an inbound message is the email's `Date` header when it parses and is within 24 hours of now. Otherwise use the webhook receipt time. A spoofed or broken `Date` header must not push a conversation to the top or bottom of the inbox.
- Report day buckets use the mailbox timezone at query time. If an admin changes the timezone, historical days re-bucket. That's expected; say so in the UI.

### Edge cases to handle

- A customer emails from two addresses about the same thing. Don't auto-merge. The customer merge tool in the UI handles it.
- A customer CCs a colleague. Replies go to the customer and CC the colleague. If the colleague replies, they join the same conversation by headers and get a customer record of their own.
- An agent replies to a conversation that was already closed by a teammate 10 seconds ago. The reply still sends; the status follows `status_after`.
- The same person emails support@ and billing@ (a future second mailbox). Customers are per mailbox, so they'd be two records. Fine for now.
- An inbound HTML-only email with no `text` part: derive `text_body` from the HTML with `html-to-text` before running the quote detector.
- Subject longer than 998 characters or containing newlines: truncate to 255 and strip control characters before storing.

## Integrations

- **Inbound provider.** Document all three setups in the README: Resend inbound (MX record plus webhook), Postmark inbound (MX record or forwarding plus webhook with basic auth in the URL), and a Cloudflare Email Worker that posts the raw message to the webhook with a shared secret. Provide an adapter per provider in `lib/inbound/<provider>.ts` that normalizes to one `InboundEmail` type. The route picks the adapter from `INBOUND_PROVIDER`.
- **Outbound provider.** Same shape: `lib/outbound/<provider>.ts` exposing `send(email: OutboundEmail): Promise<{ provider_message_id }>`. Both Resend and Postmark accept custom headers; set all three threading headers explicitly. Do not rely on the provider to generate `Message-ID`.
- **DNS.** README lists the exact records: MX for inbound, SPF, DKIM, DMARC (`p=none` to start). Explain that replies from support@ need DKIM on `reply_domain` or Gmail shows "via" and may junk them.
- **Storage.** Supabase Storage or S3 for attachments, private bucket, signed URLs that expire in 1 hour.
- **OpenRouter (optional, behind `OPENROUTER_API_KEY`).** "Draft reply" sends the last 10 messages plus the 3 best-matching published articles (reuse the help center search) and inserts the result into the composer for editing. "Summarize" writes a 3-sentence summary as a private note. Never send anything to the customer automatically.

## Non-goals

Do not build: live chat or a website widget, AI answers sent without a human, phone or SMS, WhatsApp or social channels, multiple brands, SSO, SLAs, a workflow rule editor, customer satisfaction ratings, a mobile app. If you're tempted, add a TODO comment instead. Keep `mailbox_id` on every table so a second inbox is a later feature and not a rewrite.

## Acceptance criteria

1. A new user signs in, creates the mailbox, and invites a teammate who signs in and sees the same inbox.
2. `POST /api/inbound` with a new email from `jane@example.org` creates a customer, a conversation with `status = active` and a `reply_token`, and one inbound message. `matched_by` is `new`.
3. Posting the same payload again (same `Message-ID`) returns 200 and creates nothing.
4. A reply from the app sends one email with `From` set to the mailbox address, `Reply-To` containing the `reply_token`, `In-Reply-To` equal to the customer's last `Message-ID`, and `References` containing every prior id in order. The outbound message is stored with its own `Message-ID`.
5. An inbound email whose `In-Reply-To` is the outbound `Message-ID` from criterion 4 lands in the same conversation with `matched_by = headers`, and a closed conversation flips back to `active` with a `reopened_by_customer` event.
6. An inbound email with no threading headers but addressed to `support+<token>@` lands in the right conversation with `matched_by = token`. One with no headers and no token but a matching normalized subject from the same customer within 30 days matches with `matched_by = subject`. The same subject from a different customer creates a new conversation.
7. An email with `Auto-Submitted: auto-replied` or from `mailer-daemon@` is dropped with a 200 and no rows.
8. A Gmail-style reply with `On Tue, Sep 8, 2026 at 3:41 PM Acme Support wrote:` followed by quoted lines stores only the new text in `text_body` and the rest in `quoted_text`.
9. Inserting a saved reply fills `{{customer.first_name}}` from the customer's name, falls back to `there` when the name is null, and leaves `{{unknown.var}}` untouched.
10. A note is stored with `kind = note`, appears in the thread, and no email is sent (assert the outbound mock was not called).
11. Two agents open the same conversation; within 20 seconds each sees the other's viewing banner, and focusing the composer changes the other agent's banner to "replying".
12. A customer's page lists every conversation for that email, and the inbox search finds a conversation by a word in a message body.
13. A published article is reachable and searchable at `/help`; unpublishing it makes the URL 404 and removes it from search.
14. The daily report for a seeded day matches hand-computed counts and medians, and a conversation created at 11:30 PM in `America/Los_Angeles` is bucketed on that calendar day even though it is the next day in UTC.

## Deliverables

- The app, migrations, and a seed script that creates the demo mailbox described above.
- `fixtures/inbound/` with real-shaped webhook payloads for each provider covering: a new email, a reply with `In-Reply-To`, a reply with only `References`, a reply with a `reply_token` and no headers, a subject-only match, an auto-reply, a bounce, a duplicate `Message-ID`, a Gmail quoted reply, an Outlook quoted reply, an HTML-only email, and an email with two attachments.
- `lib/threading.ts` (conversation matching), `lib/quotes.ts` (quoted text splitter), and `lib/templates.ts` (saved reply variables) as pure, unit-tested modules.
- README covering environment variables, the inbound and outbound provider setup with exact DNS records, how to replay a fixture against a local server with `curl`, and how to deploy to Vercel with the cron.

Build in this order: schema and migrations, then `lib/threading.ts` and `lib/quotes.ts` with their tests against the fixtures, then the inbound route, then the outbound reply, then the inbox and conversation screens, then everything else. Send yourself a real email through the deployed webhook before you build the reports.

06

Where to build it

Any of these will take the prompt. Use the one you already pay for.

  • Claude CodeAgentic coding in your terminal

    Best fit. The threading logic needs tests against real email fixtures, and Claude Code can write the parser, run the fixtures, and fix the edge cases in one loop.

  • ReplitBuild and host in the browser

    Works. Replit gives you a public URL right away, which you need for the inbound webhook. Point Resend or Postmark at it and you can test real email on day one.

  • LovablePrompt-to-app with Supabase built in

    Good for the inbox UI and the help center pages. Put the inbound webhook and the reply sender in Supabase edge functions so the secret keys stay off the client.

  • ChatGPT / CodexPlan, then build with Codex

    Use ChatGPT to decide which statuses and tags your team actually uses and trim the spec, then hand it to Codex.

  • OpenRouterBring your own model

    Optional. Send the thread plus your help center articles to any model to draft a reply or summarize a 20-message conversation.

07

Test it

Walk the checklist by hand once, then let the agent write the automated tests.

Manual checklist

  • Send an email from a personal Gmail to support@ and confirm a conversation appears within a minute with the right subject and sender.
  • Reply from the app and confirm the customer sees it from support@yourcompany.com with the agent's name, and that it lands in the same Gmail thread.
  • Reply to that email from Gmail and confirm it lands in the same conversation and the status flips back to active.
  • Forward the original email to a second personal address and reply from there. Confirm it still threads (Reply-To token) even though the sender changed.
  • Send the same email twice with the same Message-ID (replay the webhook payload) and confirm only one message exists.
  • Set up a vacation auto-reply on a test account, email it from the app, and confirm the auto-reply does not create a new conversation.
  • Add a private note and confirm nothing was emailed and the note is visually distinct in the thread.
  • Insert a saved reply with {{customer.first_name}} and confirm the name is filled in, and that a customer with no name gets a sensible fallback.
  • Open the same conversation in two browsers as two agents and confirm both see the collision banner within 20 seconds.
  • Close a conversation, then email from the customer again, and confirm it reopens in the same thread.
  • Open a customer's profile and confirm every past conversation is listed with status and date.
  • Publish a help article, search for a word from its body on the public help center, and confirm it's found. Unpublish it and confirm it's gone.
  • Check the daily report on a day with known activity and confirm counts and response times match what you can see in the inbox.

Generate automated tests

Paste this into the same tool that built the app. It writes tests against the acceptance criteria from the build prompt.

helpscout-test-prompt.md · 47 lines · 5,696 chars
Write automated tests for the shared inbox app in this repo. Treat the acceptance criteria below as the spec. Use Vitest for the pure modules and the API routes against a real test Postgres, and Playwright for the browser flows. Mock the outbound email provider and object storage at the module boundary so the suite runs offline. Use the webhook payloads in `fixtures/inbound/` as inputs; if a fixture is missing, create a realistic one from a real email's headers.

## Acceptance criteria to cover

1. A new user signs in, creates the mailbox, invites a teammate, and the teammate sees the same inbox.
2. `POST /api/inbound` with a new email creates one customer, one active conversation with a `reply_token`, and one inbound message. Response has `matched_by: "new"`.
3. Posting the same payload twice creates exactly one message and returns 200 both times.
4. A reply sends one email whose `From` is the mailbox address, `Reply-To` contains the `reply_token`, `In-Reply-To` equals the last inbound `Message-ID`, and `References` lists every prior id in order. The stored outbound message has its own `Message-ID` and `delivery_status = sent`.
5. An inbound email whose `In-Reply-To` is that outbound `Message-ID` joins the same conversation with `matched_by: "headers"`. If the conversation was closed, it becomes active and a `reopened_by_customer` event exists.
6. Threading fallbacks:
   - a headerless email to `support+<token>@` matches by token
   - a headerless email with subject `Re: Re: Order 1234` from the same customer within 30 days matches a conversation titled `Order 1234` by subject
   - the same subject from a different customer creates a new conversation
   - the same subject from the same customer 31 days later creates a new conversation
7. Emails with `Auto-Submitted: auto-replied`, `Precedence: bulk`, or from `mailer-daemon@` return 200 and create no rows.
8. The Gmail and Outlook quoted-reply fixtures split into `text_body` (new text only) and `quoted_text`. A message with no quote boundary keeps everything in `text_body` and `quoted_text` is null. The HTML-only fixture produces a non-empty `text_body`.
9. Saved reply variables: `{{customer.first_name}}` fills from the name, falls back to `there` when the name is null, `{{customer.name}}` falls back to the email local part, and `{{unknown.var}}` is left as literal text.
10. A note is stored with `kind = note`, renders in the thread, and the outbound mock is never called.
11. Two agents on the same conversation each see the other's "viewing" banner within 20 seconds; focusing the composer switches the other agent's banner to "replying"; a presence row older than 45 seconds is not shown.
12. The customer page lists every conversation for that email; inbox search finds a conversation by a word that appears only in a message body.
13. A published article is served at `/help/<collection>/<slug>` and appears in `GET /api/help/search`; after unpublishing, the page returns 404 and the search result disappears.
14. The daily report for a seeded day matches hand-computed counts and medians, and a conversation created at 2026-09-09 23:30 `America/Los_Angeles` is counted on 2026-09-09.
15. A failed outbound send stores `delivery_status = failed` with the error, and `POST /api/messages/:id/retry` reuses the same `Message-ID` and sends exactly once.
16. A request to `/api/conversations/:id` for a conversation in another mailbox returns 404, and an inbound webhook with a bad signature returns 401 and creates nothing.

## Test layout

- `tests/unit/threading.test.ts`: criteria 5 and 6 as table-driven cases over `{ headers, to, subject, from, existing, now, expectMatchedBy }`.
- `tests/unit/quotes.test.ts`: criterion 8 against every fixture in `fixtures/inbound/`.
- `tests/unit/templates.test.ts`: criterion 9.
- `tests/unit/reports.test.ts`: the timezone bucketing and median helpers for criterion 14, including a DST boundary day.
- `tests/integration/`: API routes against a real Postgres (Testcontainers or `DATABASE_URL_TEST`), with the outbound provider mocked and every call captured. Cover criteria 2, 3, 4, 5, 6, 7, 10, 12, 13, 14, 15, 16. Replay fixtures with the same signature header the real provider would send.
- `tests/e2e/`: Playwright for criterion 1, criterion 11 with two browser contexts, and the composer flow for criterion 4 (send and close, then confirm the conversation is in the Closed folder and the draft in localStorage is cleared).

## Fixtures

- If `fixtures/inbound/` is incomplete, add payloads for: new email, reply with `In-Reply-To`, reply with only `References`, reply with a `reply_token` and no headers, subject-only match, auto-reply, bounce, duplicate `Message-ID`, Gmail quoted reply, Outlook quoted reply, HTML-only email, and an email with two attachments.
- Keep one fixture per provider shape (Resend, Postmark, Cloudflare) for the new-email case so every adapter gets exercised.

## Rules

- Name every test after its criterion: `test("AC6: headerless reply with reply_token matches by token")`.
- Freeze time with `vi.useFakeTimers()` or an injected `now`. Test the 30-day subject window and the 45-second presence cutoff on both sides of the boundary.
- Never send real email. If `RESEND_API_KEY` or `POSTMARK_SERVER_TOKEN` is set in the test environment, fail fast with a clear message.
- Reset the database between integration tests. Seed one mailbox and two users in a `beforeEach`.
- Add `pnpm test` and a GitHub Actions workflow that runs it with a Postgres service container and no browser UI.
- Run the suite. Fix the app where the app is wrong and the test where the test is wrong. Report per-criterion pass/fail and what you changed.

Help Scout is a trademark of Help Scout PBC. This page is independent research and is not affiliated with or endorsed by Help Scout PBC. Last updated 2026-09-10.