Munchable has a support form, an admin panel, and a mailbox. A person can open a ticket from the web form, from the app, or by emailing support@. The operator answers in the panel. The answer goes out as email. The person hits reply in Gmail, and the reply has to land back on the same ticket as a message in the thread, not in a personal inbox and not as a new ticket.
The whole system turns on one question: when an email arrives, which ticket is it for? Here is how that is answered, in order of confidence, and what each fallback is guarding against.
The token is not the ticket id
Every ticket has a ULID, which appears in URLs the user can see. It also has a second random value that never appears anywhere but the Reply-To address of outbound mail:
/**
* The ticket id is a ULID so the admin queue's keyset tiebreak sorts in
* creation order, and the thread token is a separate random value: the id
* appears in URLs the user can see, so it must not double as the secret that
* authorises threading an inbound email onto the conversation.
*/
export async function createTicket(input: CreateTicketInput): Promise<CreatedTicket> {
const id = ulid();
const threadToken = crypto.randomUUID().replace(/-/g, '');
Every support email sets Reply-To: Munchable Support <support+TOKEN@munchable.app>. The token never reaches a client DTO. It is returned to the route that sends the acknowledgement, and nowhere else.
Extracting it on the way back is a regex with two extra checks:
export function threadTokenFromAddress(address: string): string | null {
const match = /^([a-z0-9._-]+)\+([a-z0-9]{16,64})@(.+)$/i.exec(address.trim().toLowerCase());
if (!match) return null;
const [, local, token, domain] = match;
if (local !== SUPPORT_LOCAL || domain !== SUPPORT_DOMAIN) return null;
return token;
}
The tests for that function say why the domain check exists: an attacker controls the domain half of an address they send from, so support+TOKEN@evil.example must not match. Neither must hello+TOKEN@munchable.app, so a spoofed local part cannot be threaded onto someone's ticket.
Three tiers of confidence
* THREADING, in order of confidence:
* 1. A thread token in the recipient address. Unambiguous: only an email we
* sent carries one.
* 2. Failing that, the sender's most recent still-live ticket. Some clients
* drop plus-addressing when a user hits reply on a forwarded copy, and
* losing the connection would restart the conversation from scratch in
* front of a customer who is mid-sentence.
* 3. Otherwise a new ticket.
Tier two is the one that needed thought. It is bounded two ways: same sender address, and only tickets in an active status. A reply to a months-old resolved thread opens a fresh ticket instead of resurrecting one nobody is watching, and a stranger's email cannot attach to somebody else's thread because the address has to match.
The lookup is one line once those helpers exist:
const existing =
(threadToken ? await findTicketByThreadToken(threadToken) : null) ??
(await findActiveTicketByEmail(senderAddress));
A reply on any existing ticket sets its status back to open. Someone still typing means it is not resolved.
The mailbox is a catch-all, and what becomes a ticket is not forwarded
MX for the domain points at the email provider, so mail to any address arrives at one webhook. support+TOKEN@ is a reply. support@, bugs@ and feedback@ open tickets, typed by the door they came through. Anything else is forwarded to a personal inbox as before.
Mail that becomes a ticket is deliberately not also forwarded. The panel is where it is answered, and a duplicate in a personal inbox is how two people, or the same person twice, end up replying to one customer. The operator gets a short alert email with a link to the ticket instead.
Stripping the quoted tail, conservatively
A reply from a mail client arrives with the entire previous conversation quoted underneath it. Stored verbatim, every thread grows quadratically and re-stores our own outbound email, thread tokens included, once per round trip. So the tail is trimmed, and the trimming errs in one direction:
Cutting too much loses what the customer said, which is unrecoverable; leaving a few quoted lines in is merely untidy.
Concretely: it only cuts at unambiguous markers ("On ... wrote:", "-----Original Message-----", "Sent from my"), it ignores a marker in the first line because a reply that opens with a quote is quoting us on purpose, a run of > lines only counts if it continues to the bottom so point-by-point replies survive, and if the result would be empty it returns the original text. The test names are the spec: "keeps an interleaved quote, which is someone answering point by point" and "never returns empty, even when a marker is the whole message".
The parts that stop it becoming a spam relay
The webhook sends email through our account and fetches content from a URL, so an unauthenticated caller must never reach its side effects. In order:
- Signature verification, failing closed. A missing webhook secret is a 500, not an open endpoint.
- A 256 KB cap on the webhook body and 25 MB on the raw message, checked on the header and again after download.
- The raw message download is pinned to the provider's hosts, so a spoofed upstream response cannot turn the webhook into a request to an internal address.
- Idempotency with a short claim: the delivery id is claimed for 120 seconds and promoted to 24 hours only on success. A hard crash mid-processing lets the provider's retry re-deliver rather than the long key dropping it, because dropping a real email is worse than a duplicate.
- An automated-mail guard reading
Auto-Submitted,Precedenceand the sender, because replying to a bounce is how a mail loop starts. Automated mail falls through to plain forwarding so a human still sees what bounced.
The form side has its own rule: opening a ticket does not require an account, because the people most in need of support are often the ones who cannot sign in. A signed-out request supplies its own contact address and pays for it with a tight per-IP limit, and that limiter fails closed with a 503, since the route sends two emails per call.
Every email is best effort, after the write
The ticket is written first, in one transaction with its first message. Then the acknowledgement and the operator alert go out, and a failure there is logged and swallowed. A ticket that exists with no acknowledgement is recoverable. A lost ticket is not. The admin reply route has the same shape: persist, then send, and a send failure never turns a recorded reply into a request error.
Try the form at munchable.app/support. No account needed. The acknowledgement you get has a plus-addressed Reply-To, and replying to it is the path this post describes.
The pipeline was ported from a sibling product, and an earlier post from that side covers why "instant" notifications are never instant. This one is the inbound direction.
Top comments (0)