Help Desk email tickets — a plain-English support email becomes a tracked ticket
Someone emails your support address in plain words and it becomes a real Help Desk ticket: it appears in the operator's incoming queue, advances a lifecycle stage on its own, and the reply goes back out as the support address on the reporter's own thread.
What it is
One-line: A person emails your support address in plain English — no [BUG: <slug>]
subject marker — and it becomes a real Help Desk ticket: it appears in the operator
"Incoming" queue with the sender's name on it, carries a lifecycle stage that advances by
itself when an investigation starts, and their replies land back on the same ticket. They
get one "we got it" straight away; Claude drafts the answer; a human presses send, and that
send goes out as the support address on the reporter's own thread.
The gap it closes
Before this, email only became a ticket if the subject literally started with
[BUG: <slug>] or [FR: <slug>] — the Bug Report Intake router.
Real people do not write that. Their mail matched nothing, fell through to a generic
"catchall" session that answered it once, and was never tracked. help_requests held zero
rows on an install with months of real support mail.
Opening the door to the public
A ticket could only ever be started by someone on your approved-senders list, and that list is one setting for the whole install — so opening it to the public would have opened your personal agent address to the world with it. Two things fix that:
- A public support address you claim from the Agent Email panel (the same panel that holds your personal address). It is a second address on your account, and the panel shows it, lets you copy it, and turns it off again with one click. Turning it off is a true rollback: with no support address set, mail is handled exactly as it was before.
- A per-address sender rule. Mail arriving at the support address is judged by the support policy (anyone may write in); every other address on the account keeps exactly the policy it had. Naming a support address never widens anything else — and naming your own address as the support address opens nothing at all.
- Only support mail becomes a ticket. Once a support address is set, a new email to your personal agent address is not turned into a ticket — it goes to your agent as a normal conversation, the way emailing your agent always worked. A customer's reply on a ticket still joins that ticket, whichever of your addresses it arrives at.
A reserved name like support@ is refused for everyone except an owner/admin, and that
refusal is enforced by the server, not just by this app — otherwise any signed-in user
could claim the name by calling the cloud directly.
A per-sender daily limit (10 new tickets from one address) still bounds what one stranger can trigger, because each ticket can start a paid investigation session.
In-development, OFF by default
Reveal via Settings → Features → Helpdesk email ticket intake
(helpdeskEmailTicketIntakeEnabled) or AMC_SHOW_HELPDESK_EMAIL_TICKET_INTAKE=1. It has
its own switch — the helpdeskEnabled toggle above it gates the in-app "Get Help" ask
panel, a different consumer this leg does not touch.
With the switch off, the gate returns before any database read and inbound mail behaves exactly as it did before the feature existed.
What happens to an incoming email
If you have turned on the spam filter, a stranger's new email is checked for junk before any of the steps below. Junk, and newsletters or auto-replies sent to the support address, wait in the Inbox's Spam list instead of becoming a ticket, and Not spam turns one into a ticket. A reply on a ticket you already have is never checked.
- Is it a person? Judged on transport headers only, never on content — a real person
asking an unusual question must never be mistaken for a mailer. Four signals:
List-Unsubscribe,List-Unsubscribe-Post(RFC 8058),Precedence: bulk|list|junk,Auto-Submitted, plus a no-reply-style sender address. - What kind of report is it? bug / feature / feedback / question, classified by the same prescreen LLM call that already runs on every inbound message — no second call, so the cost is flat and the existing daily cap and circuit breaker still govern.
- Become a ticket. A
help_requestsrow plus a cloud thread, so it shows in the operator queue, stamped with a synthetic reporter built from the sender address. - Bind both ways. The inbound tracking row carries the ticket id, so a later reply on that email thread appends to the same ticket (and reopens it if it was resolved) instead of opening a second one — and puts the ticket back under Pending with a fresh alert, even after you answered it. Deleting the ticket from your Get Help history only hides it there: your replies still reach the customer and their answers still join the same ticket.
- Investigate. A session spawns with the report as untrusted, fenced input — the same prompt builder the marked-subject router uses, so the operator's global and per-project appended instructions apply here too, with no extra setting. The model is the account default unless a tier is chosen (below).
The two knobs
| Knob | Where | Default |
|---|---|---|
| Investigation model tier (Genius / Smart / Worker / Basic) | Bug Intake settings | unset — the spawn inherits the account default, exactly as before |
| The investigation prompt | the existing global + per-project appended instructions boxes | empty — the prompt is byte-identical to the marked-subject one |
A tier, never a model id: the id is resolved at spawn time so the choice cannot go stale when a new model version ships.
The acknowledgement
A stranger who writes in hears nothing until an operator replies by hand — the claim that keeps the catchall away is the same thing that stops an automatic reply covering the gap. So a new ticket sends its reporter one short "we got it".
It fires at most once, and only for a brand-new ticket: the marker is written on the message's own row inside the ticket's transaction, and the send happens only for the caller that wins that write. A redelivered message, an overlapping poll, and a reply that appends to an existing ticket can none of them produce a second one. It records before it sends, so a send that fails is never retried — a duplicate "we got your message" to a stranger is worse than a missing one, and a missing one is exactly the behaviour before this existed.
Replying
Claude drafts into the existing console composer; a human presses send — on any operator's
computer. The reply lands in the cloud thread, and the computer that received the
customer's email mails it to them on their original message (In-Reply-To the thread root,
so it lands in their existing conversation). Only that computer can: the link to the email,
its subject and the support sender live on it alone. A reply written there goes out within
seconds; one written on a teammate's computer goes out as soon as the receiving computer hears
of it — and while that computer is off, the reply waits for it.
Every reply is emailed exactly once. Each reply carries its own "don't send twice" key, so the second and later replies on a ticket go out as reliably as the first, and a reply that arrives twice is never mailed twice. A reply only counts as handled once its email has gone (or the recipient's mail server refused it for good). If the send fails — a network hiccup, or no agent address set up yet — it is tried again automatically after 1, 5, 15, 30 and 60 minutes, and after that the next time the app starts. Three failures in a row raise the "can't send your agent's email replies" card in your inbox. Because the customer is the one asking, replies on an email ticket never tell the operator "an agent replied to your question".
In the console, an email ticket shows its sender, an Email tag and the email's own subject at the top, the reply box reads "Send your message to … by email", and the earlier conversation the customer's mail client quoted folds behind Show quoted text — see Get Help part 2.
The reply answers as the address the mail arrived at. A ticket that came in at the support address is answered from support, with Reply-To support, so the reporter's answer comes back to support rather than into your personal agent inbox — otherwise the promise breaks on the second message. A ticket with no recorded recipient (any that predates this) falls back to your single agent address, exactly as before.
Nothing sends by itself
This is structural, not a setting. Claiming the message stops the catchall session from spawning at all, and the inbound tracking row is written already-closed under a sentinel session id, so none of the six "needs a reply" selectors can find it. There is nothing for an automatic replier to act on.
The acknowledgement does not weaken this: it is a one-shot message of its own, sent on a path that never touches the reply marker or a session id, so it cannot re-arm anything an automatic replier looks for.
Their own board
Support-email tickets no longer share a lane with bug reports. The Bug Intake panel carries a Help Desk tab listing them alone — same rows, same stage control, same filters as the general Tracker board behind it, scoped to one origin. The distinction is recorded as a fact (the ticket came from the help-desk email leg) rather than guessed from the source or from the classifier's report type, so the board never shows unrelated reports.
Spend bounds
Its own limit before any paid spawn — unmarked mail exits the marked-subject router before that router's own caps are consulted, so this leg needs its own. The limit is per sender, never per inbox: every different customer always gets their "we got it" acknowledgement and their investigation, however busy the inbox is. What is limited is ONE address opening new ticket after new ticket — 10 a day — which is what a mail loop, a runaway script or an abuser looks like, and almost never a real person. There is no burst throttle: several support emails arriving together (a queue drained after a restart) each get their acknowledgement and their investigation.
Past that sender's limit, the email still becomes a ticket in the Incoming queue — only the "we got it" acknowledgement and the investigation are skipped, so a person answers it by hand. It never falls back to an ordinary email session, which would reply to the sender automatically. The limit counts only that sender's tickets that got the automated treatment, so a follow-up on an existing ticket never uses it up.
There is no limit on the whole inbox. Instead, when Help Desk support email reaches 100 in one day, you get one heads-up in your inbox ("Help Desk is getting a lot of email today") with the day's count. Nothing is blocked or slowed because of it — every email is still handled as usual — and a heads-up you dismiss does not come back that day. You can switch this alert type off like any other alert.
If a ticket gets stuck
Escalation can fail after the local ticket exists, which would leave it invisible in the operator queue. A repair pass rides the existing inbound poll and re-escalates such tickets, carrying the original reporter so a recovered ticket is not anonymous. It ignores tickets younger than 5 minutes (they may still be mid-flight) and gives up after 24 hours rather than retrying a broken one forever. A stuck ticket you deleted from your Get Help history is still repaired — the delete only hides it — and it is escalated exactly once.
Where to find it
This is not a screen you open — it is a path into one. The person emails your support address; the ticket lands in the Help Desk operator's Incoming queue; the answer is written and sent from there.
How it behaves
Deploy dependency
Three of the four bulk-mail signals depend on the Cloudflare email worker forwarding
List-Unsubscribe, List-Unsubscribe-Post, Precedence and Auto-Submitted. Until that
worker is deployed, only the no-reply sender check can fire. Every hop treats the headers
as optional, so the desktop, the inboundEmail function and the worker can be deployed in
any order without rejecting real mail.
Deploy note for the public intake
The reserved-name rule and the two-address lookup live in the agentEmailRelay Cloud
Function, so opening a support address on a real install needs that function deployed to
the shared shares project. The desktop half degrades safely without it: the
address still works, but a reserved name is refused only in-app until the function ships.
For agents
Where it lives
- Contract:
helpdesk-email-ticket-intake-contract.md - Public intake contract:
helpdesk-public-support-intake-contract.md - The claim + repair pass:
src/main/services/helpdesk/helpdesk-email-intake.ts - The one-shot acknowledgement:
src/main/services/helpdesk/helpdesk-acknowledgement.ts - The outbound reply:
src/main/services/helpdesk/helpdesk-email-reply.ts - Who sends it (the reply receive leg — email first, record second):
src/main/services/helpdesk/helpdesk-boot.ts - The quoted-text fold:
src/renderer/src/features/helpdesk/helpdesk-quoted-reply.ts - The reply From / Reply-To rule:
src/main/services/email/agent-email-reply-identity.ts - The per-recipient sender rule:
src/main/services/email/agent-email-sender-policy.ts - The model tier stamp:
src/main/services/helpdesk/helpdesk-investigation-model.ts - The bulk filter:
src/main/services/email/machine-mail-filter.ts - The board:
src/renderer/src/features/bug-intake/BugIntakeTrackerTab.tsx(the Help Desk tab is that board, filtered)
Related
- Bug Report Intake — the
[BUG:]/[FR:]router. It always wins; this leg only ever sees mail that router declined. - bug-report-intake.md — the bug-report intake side.
- Email inbound prescreen — the one LLM call this reuses.
Last verified 2026-09-28