Docs

Architecture

How Helpthread is put together, and why it is put together that way. Everything below describes the system as it ships today; where a spec goes deeper, it is linked at the end.

One project, one origin

A Helpthread deployment is one Vercel project and one Supabase project, both in accounts you own. The project is rooted at web/ and serves the operator inbox and the whole API from a single origin, so there is no second service to stand up and no cross-origin hop between the desk and its own API.

One file in the UI — web/src/engine/mount.ts — hands every /api/** request to the engine, and it is the only file in web/ permitted to import it. That is enforced rather than agreed: a test in the repository fails if any other file reaches across the line.

The engine never calls a platform directly

Queueing, scheduled work, blob storage, inbound email and outbound email all sit behind five interfaces the project owns rather than a vendor SDK scattered through the code. Engine code imports the interface; it never imports the thing that implements it.

The implementations are adapters, kept apart from the engine and chosen at startup from your configuration. What ships today: Gmail and IMAP for reading mail, Gmail and SMTP for sending it, Postgres for the queue, Supabase Storage for attachments. Replacing one is a new adapter, not a fork — which is what makes running this on infrastructure you chose a property of the design rather than a claim on a marketing page.

Mail arrives two ways

Gmail is pushed to your deployment as it happens. Any other IMAP mailbox is fetched on a schedule, every two minutes. Both land in the same path after that: parse the message, decide which conversation it belongs to, then store it. Gmail is the proven route today; the IMAP spec is still a draft with an open question about message ids that survive the transport.

Threading is decided on the way out

Email threading usually breaks because it is inferred on the way in, from headers every mail client writes differently. Helpthread does the opposite. Every outbound message carries a token the engine minted into its own Message-ID, signed with HMAC-SHA256 over the conversation and thread it belongs to.

<ht.{keyId}.{conversationId}.{threadId}.{sig}@{mailDomain}>

When a reply comes back, that token is verified offline against the key that signed it. An In-Reply-To or References header that merely looks like a threading reference, but carries no token the engine can verify, has no weight at all — it is inert, not a weaker signal. A reply lands in the right conversation because the engine can prove it sent the message being replied to.

Work that must survive does not live in a request

Anything that has to happen whether or not a browser is still waiting — sending a reply, waking a snoozed conversation, delivering a webhook — goes onto a queue in your own Postgres, and domain events are written to an outbox in the same transaction as the change that caused them. Nothing is announced that did not actually happen.

Postgres cannot push, so scheduled ticks drain both. Six run in a deployment: the queue, the outbox, snooze wake and a reconcile sweep every minute, IMAP fetch every two, and Gmail watch maintenance once a day. Five of those run more than once a day, which is the whole reason a deployment needs a Vercel Pro plan rather than Hobby.

Events are a closed vocabulary

Webhook subscriptions are filtered against a fixed list of eight event types. It is deliberately not a free-text field: a name either is in the list or is rejected, so an integration cannot quietly subscribe to something that will never fire.

  • conversation.created
  • conversation.message_received
  • conversation.reply_sent
  • conversation.status_changed
  • conversation.tags_changed
  • conversation.assignee_changed
  • draft.created
  • draft.resolved

Each delivery is signed with HMAC-SHA256 over the timestamp and the body, under a secret held per endpoint, so a receiver can tell a real delivery from anything else that found the URL. Before connecting, the handler resolves the hostname and pins the connection to the address it resolved — a webhook URL cannot be aimed at something private inside your own network, and cannot be swapped for one between the check and the connection.

Read the specs