---
title: Help Desk email tickets — a plain-English support email becomes a tracked ticket
---

# Help Desk email tickets — a plain-English support email becomes a tracked ticket

## 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](bug-report-intake.md) 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](spam-filter.md), 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.

1. **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.
2. **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.
3. **Become a ticket.** A `help_requests` row plus a cloud thread, so it shows in the
   operator queue, stamped with a synthetic reporter built from the sender address.
4. **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.
5. **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](helpdesk-part-2.md).

**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](bug-report-intake.md) — the `[BUG:]` / `[FR:]` router. It always wins;
  this leg only ever sees mail that router declined.
- [bug-report-intake.md](bug-report-intake.md) — the bug-report intake side.
- [Email inbound prescreen](email-inbound-prescreen.md) — the one LLM call this reuses.
