# Gmail Folder Routing — Design **Date:** 2026-06-03 **Status:** Approved (design); pending implementation ## Goal Route a ticket's Gmail thread into Gmail "folders" (labels) as the ticket moves through its lifecycle, plus a manual `/folder` command for ad-hoc filing. - On **ticket creation**, the source email thread goes into a **Triage** folder (instead of the current plain archive). - On **escalation**, the thread moves to an **Escalated** folder. - On **resolution** (close), the thread moves to a **Resolved** folder. - A **`/folder`** slash command lets staff move the current ticket's thread to one of four manual folders: **For Jake**, **Spam**, **Dashboard Errors**, **Partnership Offers**. Discord-originated tickets (`gmailThreadId` prefixed `discord-`) have no Gmail thread and are untouched by all of the above. ## Decisions (locked) | Decision | Choice | |----------|--------| | Folder semantics | **Exclusive** — moving to a folder removes every other managed label and drops the thread out of the Inbox. A thread lives in exactly one managed folder. | | "Spam" target | Gmail's **built-in system `SPAM`** label (trains the filter, hides from normal views). | | Label names | **Configurable via `.env`**, defaulting to the names above. | | Missing labels | **Auto-created** on first use (idempotent, cached). The system `SPAM` label is never created. | | `/folder` options | Exactly the **4 manual folders**. Triage/Escalated/Resolved are lifecycle-driven only, not manually selectable. | | De-escalation | **Leaves the folder as Escalated** — no auto-move back. | Gmail labels are additive by nature; "exclusive folder" behavior is synthesized by always removing the other managed labels on every move (removing an absent label is a no-op, so this is safe and idempotent). ## Architecture ### 1. New module — `services/gmailLabels.js` Single home for all label logic. Folders defined by logical key: | Key | Source | Default name | |-----|--------|--------------| | `TRIAGE` | `CONFIG.GMAIL_LABEL_TRIAGE` (`.env GMAIL_LABEL_TRIAGE`) | `Triage` | | `ESCALATED` | `CONFIG.GMAIL_LABEL_ESCALATED` | `Escalated` | | `RESOLVED` | `CONFIG.GMAIL_LABEL_RESOLVED` | `Resolved` | | `FOR_JAKE` | `CONFIG.GMAIL_LABEL_FOR_JAKE` | `For Jake` | | `DASHBOARD_ERRORS` | `CONFIG.GMAIL_LABEL_DASHBOARD_ERRORS` | `Dashboard Errors` | | `PARTNERSHIP_OFFERS` | `CONFIG.GMAIL_LABEL_PARTNERSHIP_OFFERS` | `Partnership Offers` | | `SPAM` | built-in system label `SPAM` | (not configurable) | `MANAGED_USER_KEYS` = all keys except `SPAM` (these are the user labels whose IDs get resolved/created and which participate in the remove-others set). **Exports:** - `moveThreadToFolder(threadId, folderKey, gmail = getGmailClient())` — the one operation everything calls. 1. Resolve the target label ID (`resolveLabelId`), and the IDs of all managed user labels (to build the remove set). 2. `addLabelIds = [targetId]`. 3. `removeLabelIds = [all managed user-label IDs except target] + ['INBOX', 'UNREAD']` (computed by `computeLabelMutation`). For `SPAM` target, the user labels are all removed and `SPAM` is added; `INBOX`/`UNREAD` removed as usual. 4. `await gmail.users.threads.modify({ userId: 'me', id: threadId, requestBody: { addLabelIds, removeLabelIds } })`. - On a `400` "invalid label" (stale cached ID for a label deleted in Gmail), clear the cache and retry once. - `resolveLabelId(gmail, key)` — returns the Gmail label ID for a key. - `SPAM` short-circuits to `'SPAM'`. - Otherwise: check the module-scoped name→ID cache; on miss, `users.labels.list` and match by name (case-sensitive, Gmail's behavior); if still absent, `users.labels.create` it (`labelListVisibility: 'labelShow'`, `messageListVisibility: 'show'`) and cache the new ID. - `computeLabelMutation(targetKey, idByKey)` — **pure** function returning `{ addLabelIds, removeLabelIds }`. Split out for unit testing without the network. **Caching:** module-scoped `Map` of label-name → ID, populated lazily. Cleared and re-fetched on a stale-label error. **Client:** `getGmailClient` is required from `services/gmail.js` (acyclic — `gmail.js` does not depend on `gmailLabels.js`). Callers that already hold a client (the poll loop) pass it in; others let the default create one. ### 2. Triage on ticket creation — `gmail-poll.js` Today every processed message hits `markGmailMessageRead` (strips `INBOX`+`UNREAD`) at the shared bottom of the per-message loop (~line 397). Restructure so the archive action is branch-specific: - **New ticket created** (and the **reopened** closed→open case, which runs in the create branch) → `await moveThreadToFolder(parsed.threadId, 'TRIAGE', gmail)`. - **Follow-up to an existing open ticket** (the `if (ticketChan)` append branch) → keep `markGmailMessageRead(gmail, msgRef)`. A reply on a thread already filed under "For Jake"/"Resolved" should not be dragged back to Triage automatically. - **Self / limit-exceeded / create-failure** early-`continue` paths → unchanged plain archive (they already call `markGmailMessageRead` before `continue`). The shared bottom `markGmailMessageRead` call is removed; the two surviving paths (append, create) each archive/move explicitly. `moveThreadToFolder` on creation is awaited inside the existing try/catch; a failure is logged via the poll's existing error handling and does not abort the loop. ### 3. Escalated hook — `handlers/commands/escalation.js` `runEscalation` is shared by the `/escalate` slash command and the tier-pick buttons (single hook site). Inside the existing `if (!isDiscordTicket && ticket.gmailThreadId)` block (where the escalation notification email is already sent), add: ```js moveThreadToFolder(ticket.gmailThreadId, 'ESCALATED') .catch(err => logError('gmailLabels: escalate move', err).catch(() => {})); ``` Non-fatal — a label failure never blocks the escalation. De-escalation (`runDeescalation`) is **not** modified. ### 4. Resolved hook — two close finalizers Both finalizers set `status: 'closed'` and remain separate: - `handlers/commands/close.js` → `finalizeForceClose` - `handlers/buttons.js` → `runFinalClose` In each, for non-Discord tickets (`!ticket.gmailThreadId.startsWith('discord-')`), after the status update, add a non-fatal: ```js moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED') .catch(err => logError('gmailLabels: resolved move', err).catch(() => {})); ``` One added line per finalizer. The move runs regardless of whether a close email is sent (so close-without-email still files the thread under Resolved). ### 5. `/folder` command - **Registration** (`commands/register.js`): `SlashCommandBuilder` named `folder`, `setDefaultMemberPermissions(ManageMessages)`, Guild context / GuildInstall, with a required string option `destination` and choices: - `For Jake` → `FOR_JAKE` - `Spam` → `SPAM` - `Dashboard Errors` → `DASHBOARD_ERRORS` - `Partnership Offers` → `PARTNERSHIP_OFFERS` - **Dispatch** (`handlers/commands/index.js`): add `folder: handleFolder` to `COMMAND_HANDLERS`; add a `/folder` line to `/help`. - **Handler** `handleFolder(interaction)`: 1. `findTicketForChannel(interaction)`; bail if none. 2. If `ticket.gmailThreadId.startsWith('discord-')` → ephemeral "This ticket has no email thread, so it can't be moved to a Gmail folder." 3. Otherwise `await moveThreadToFolder(ticket.gmailThreadId, folderKey)`. 4. Ephemeral reply: "Moved this ticket's email thread to **