Munchable is a gut health scanner. People write to us about billing, about a barcode that came back wrong, and about symptoms they are trying to manage. That last category is why we did not buy a helpdesk: a support thread on this product routinely contains a health condition, and posting every one of those conversations into a third party's SaaS is a privacy decision, not a procurement decision.
So support is a table in our own database with four doors into it. This is what the email half of that turned out to involve, because email is the door that fights back.
You can walk through the front of it at munchable.app/support. File something, and you get a reference back. Reply to the email it sends you, and the reply lands on the same thread.
Four doors, one service
A ticket can be created by the web form, by the app, by an inbound email, or by an operator in the admin panel. Every one of those goes through a single module.
// apps/web/lib/support/ticket-service.ts
//
// The one data-access layer for support_tickets / support_messages. Every door
// into support goes through here, so ownership checks, status transitions and
// ordering are written once rather than four subtly different times.
It returns DTOs, never rows. That sounds like ceremony until you notice what is on the row and not on the DTO.
The thread token is the whole trick, and it never leaves the server
When we email you about a ticket, the Reply-To is not our support address. It is our support address with a token in the plus part:
export function threadReplyTo(threadToken: string): string {
return `Munchable Support <${SUPPORT_LOCAL}+${threadToken}@${SUPPORT_DOMAIN}>`;
}
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;
}
Your mail client keeps that address on every reply in the thread, which is how a message with no subject, sent from a phone, three weeks later, still knows which conversation it belongs to.
The token is a secret. It is on the ticket row and it is deliberately absent from every DTO the service returns, so no route handler can leak it into a JSON response by accident. Anyone holding it can post into that thread.
The reference people actually see is a different thing, derived rather than stored:
/** MU-XXXXXX, from the random half of the ticket's ULID. Distinctive in an
* email, not a usable handle: reading a ticket still needs the full id. */
export function ticketRef(id: string): string {
return `MU-${id.slice(-6).toUpperCase()}`;
}
The MX record is a catch-all, so the routing is ours
Mail for the whole domain lands on one webhook. What happens next is decided by the recipient: a plus-addressed reply appends to its ticket, mail to the support, bugs and feedback addresses opens a new ticket, and everything else is forwarded on with Reply-To set to the sender.
The rule that took an incident to learn: mail that becomes a ticket is not also forwarded. Forwarding it as well puts the same customer in a personal inbox and in the support queue, and then two replies go out, or the same person answers it twice a day apart. The operator gets an alert email instead, and the alert points at the ticket rather than containing it.
Replies arrive with the whole conversation stapled underneath
An email reply contains the reply, then a quoted copy of everything that came before, then possibly a signature. Store that verbatim and every thread grows quadratically, and you re-store your own outbound mail, thread token and all, once per round trip.
Stripping the quoted tail is a heuristic, so the interesting part is which way it is allowed to be wrong. Cutting too much loses what a customer wrote, which cannot be recovered. Leaving a few quoted lines in is untidy. So the trimming only cuts at unambiguous markers:
const QUOTE_MARKERS: RegExp[] = [
/^On .{5,120}\bwrote:\s*$/i,
/^-{2,}\s*Original Message\s*-{2,}\s*$/i,
/^_{5,}\s*$/,
/^From:\s?.+@.+$/i,
/^Sent from my \w+/i,
/^-{3,}\s*Forwarded message\s*-{3,}\s*$/i,
];
Three rules around those markers do most of the work:
- A marker in the first line is ignored. A reply that opens by quoting us is quoting us on purpose.
- A
>line only starts the quoted tail if the run of quoted lines reaches the bottom of the message. One quoted line in the middle is someone answering inline, and cutting there would delete the rest of what they said. - If the cut would leave nothing, the original text is kept. An over-eager trim must never turn a real message into an empty one.
const trimmed = body.trim();
return trimmed || normalized.trim();
Two failure modes that are specific to email
Robots reply too. Out-of-office autoresponders, bounce notifications and "we received your message" acknowledgements all arrive looking like a customer. If one of those opens a ticket and the ticket sends a confirmation, and their responder answers the confirmation, you have built a loop with somebody else's mail server. Inbound mail is checked for the headers that mark it automated (Auto-Submitted, Precedence and friends) before any of it turns into a conversation.
Webhooks retry. A delivery that times out halfway is redelivered, and the naive handler appends the same customer message twice. Each event is claimed with a short-lived key before processing and confirmed after, so a retry of an in-flight event is dropped rather than duplicated, and a genuinely failed one is released to be tried again.
Neither of these is clever. Both are the kind of thing you only write down after seeing it happen once.
What it bought
Support conversations live in the same Postgres as everything else, subject to the same deletion rules as the rest of a person's data, which matters when the account deletion endpoint has to actually mean it. There is no seat-priced tool in the loop. And the statuses are ours, so the queue can use the operator's vocabulary while the customer sees theirs:
export const TICKET_STATUS_LABELS: Record<TicketStatus, string> = {
open: 'Open',
awaiting_user: 'Waiting on you',
resolved: 'Resolved',
closed: 'Closed',
};
export const TICKET_STATUS_LABELS_ADMIN: Record<TicketStatus, string> = {
open: 'Needs a reply',
awaiting_user: 'Waiting on them',
resolved: 'Resolved',
closed: 'Closed',
};
Same four states, two different sentences, one Record that will not compile if a fifth status appears without both of them being written.
Try the support form if you want to see the round trip. The reply that comes back will have a reference on it, and a Reply-To that is not the address you would guess.
Top comments (0)