From 2ccdbf72aa3d471134824d5c09a19a8d43bd5cf3 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Thu, 4 Jun 2026 22:05:20 +0000 Subject: [PATCH] Email ticketing fixes, comms polish, and .env cleanup Inbound: - Gmail poll query is:unread in:inbox (was category:primary, which matched nothing on a no-tabs Workspace inbox) Outbound email: - Closed/escalation auto-emails editable via TICKET_CLOSE_MESSAGE and new TICKET_ESCALATION_EMAIL_MESSAGE; drop the staff signature from closing emails - Replies quote the customer's latest message (gmail_quote markup so clients collapse it), embed custom emoji inline via CID attachment, and strip Discord role mentions - Tagline spacing fix in the company signature Discord side: - Suppress all mentions in log + transcript posts (no more pinging on close) - Drop the staff-role ping from new-ticket and follow-up notifications - Ticket channels inherit category permissions instead of setting per-channel overwrites (removes the Manage Roles requirement) Gmail folders: - Folder/label routing (gmailLabels.js) with /folder; close files to Complete Config: - Remove ~56 stale .env keys for long-removed features; refresh stale copy Docs: - Design specs for folder routing, email-flow toggle, and per-staff metrics --- .env.example | 11 +- HOWITWORKS.md | 4 +- broccolini-discord.js | 8 +- commands/register.js | 35 +++ config.js | 14 +- .../2026-06-03-email-flow-toggle-design.md | 124 ++++++++++ .../2026-06-03-gmail-folder-routing-design.md | 217 ++++++++++++++++++ .../2026-06-04-per-staff-metrics-design.md | 176 ++++++++++++++ gmail-poll.js | 46 ++-- handlers/buttons.js | 14 +- handlers/commands/close.js | 11 +- handlers/commands/escalation.js | 26 ++- handlers/commands/index.js | 103 ++++++++- handlers/messages.js | 12 +- services/configSchema.js | 8 +- services/debugLog.js | 5 +- services/gmail.js | 189 ++++++++++++--- services/gmailLabels.js | 152 ++++++++++++ tests/gmailLabels.test.js | 152 ++++++++++++ 19 files changed, 1224 insertions(+), 83 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-03-email-flow-toggle-design.md create mode 100644 docs/superpowers/specs/2026-06-03-gmail-folder-routing-design.md create mode 100644 docs/superpowers/specs/2026-06-04-per-staff-metrics-design.md create mode 100644 services/gmailLabels.js create mode 100644 tests/gmailLabels.test.js diff --git a/.env.example b/.env.example index fc7622a..5aa01c0 100644 --- a/.env.example +++ b/.env.example @@ -60,8 +60,10 @@ SUPPORT_NAME=Support LOGO_URL= # URL of logo shown in embeds (optional) EMAIL_SIGNATURE= # HTML signature for outgoing emails (use \n for line breaks) TICKET_CLOSE_SUBJECT_PREFIX=[Resolved] -# Email tickets only (closure email body): +# Email tickets only (closure email body). Placeholders: {closer_name}; \n for line breaks. TICKET_CLOSE_MESSAGE= # Body of closure email to customer +# Email tickets only (escalation notification email body). Placeholders: {escalator_name}, {tier}; \n for line breaks. +TICKET_ESCALATION_EMAIL_MESSAGE= # Body of escalation email to customer TICKET_CLOSE_SIGNATURE= # Signature on closure email # Discord ticket closure (in-channel before transcript, transcript post, and auto-close): DISCORD_CLOSE_MESSAGE= # Message in ticket channel before transcript (e.g. ... If you still need assistance, please open a new ticket.) @@ -103,6 +105,13 @@ ALLOW_CLAIM_OVERWRITE=false ADMIN_ID= # Discord user ID of the bot admin (for Gmail OAuth failure DMs) FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing a ticket (default 60) GMAIL_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30) +GMAIL_POLL_ENABLED= # Inbound email flow master switch; "false" disables polling (default on). Toggle at runtime with /email on|off +GMAIL_LABEL_TRIAGE= # Gmail label for newly created tickets (default "Triage"); auto-created if missing +GMAIL_LABEL_ESCALATED= # Gmail label for escalated tickets (default "Escalated") +GMAIL_LABEL_RESOLVED= # Gmail label for resolved/closed tickets (default "Resolved") +GMAIL_LABEL_FOR_JAKE= # /folder option (default "For Jake") +GMAIL_LABEL_DASHBOARD_ERRORS= # /folder option (default "Dashboard Errors") +GMAIL_LABEL_PARTNERSHIP_OFFERS= # /folder option (default "Partnership Offers") GMAIL_LOG_CHANNEL_ID= # Channel for Gmail poll activity logs AUTOMATION_LOG_CHANNEL_ID= # Channel for auto-close/auto-unclaim/reminder logs RENAME_LOG_CHANNEL_ID= # Channel for channel rename queue logs diff --git a/HOWITWORKS.md b/HOWITWORKS.md index deb82ca..3bcf2e3 100644 --- a/HOWITWORKS.md +++ b/HOWITWORKS.md @@ -3,7 +3,7 @@ Broccolini Bot is a single Node.js process. It does three things at once: 1. **Listens to Discord** — slash commands, button clicks, modals, ticket-channel messages. -2. **Polls Gmail** — every N seconds, pulls unread `category:primary` mail and turns each thread into a Discord ticket channel. +2. **Polls Gmail** — every N seconds, pulls unread `in:inbox` mail and turns each thread into a Discord ticket channel. 3. **Serves a couple of HTTP endpoints** — a public healthcheck and an internal config/control API. State lives in MongoDB via Mongoose. There is no queue/worker tier and no public REST API. @@ -27,7 +27,7 @@ Every `setInterval` in the `ready` block is wrapped through `trackInterval(...)` `gmail-poll.js`: -1. Lists unread messages in `category:primary`. +1. Lists unread messages in `in:inbox` (excludes Spam, Trash, and anything filter-skipped from the inbox). 2. For each thread, looks up an existing `Ticket` by `gmailThreadId`. If none, creates a Discord channel under `TICKET_CATEGORY_ID` (or an overflow category if the main is full at Discord's 50-channel limit) and inserts a `Ticket` document. 3. Posts a welcome embed + action row (Close / Claim / Escalate) into the channel and pings `ROLE_ID_TO_PING`. 4. On subsequent emails in the same thread, just appends the new message to the existing channel. diff --git a/broccolini-discord.js b/broccolini-discord.js index c103764..c5628e4 100644 --- a/broccolini-discord.js +++ b/broccolini-discord.js @@ -207,8 +207,12 @@ client.once('ready', async () => { registerCommands().catch(console.error); - gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS)); - poll(client); + if (CONFIG.GMAIL_POLL_ENABLED) { + gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS)); + poll(client); + } else { + console.log('Gmail poll disabled by config (GMAIL_POLL_ENABLED=false) — inbox will not be polled. Enable with /email on.'); + } if (CONFIG.AUTO_CLOSE_ENABLED) { trackInterval(setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000)); diff --git a/commands/register.js b/commands/register.js index 8b7f726..c03ae67 100644 --- a/commands/register.js +++ b/commands/register.js @@ -368,6 +368,41 @@ async function registerCommands() { ) ), + new SlashCommandBuilder() + .setName('email') + .setDescription('Turn the inbound email flow (Gmail polling) on or off, or check its status') + .setContexts([InteractionContextType.Guild]) + .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(sub => + sub.setName('on').setDescription('Start polling the inbox and creating tickets from email') + ) + .addSubcommand(sub => + sub.setName('off').setDescription('Stop polling the inbox (outbound emails still send)') + ) + .addSubcommand(sub => + sub.setName('status').setDescription('Show whether the inbound email flow is on or off') + ), + + new SlashCommandBuilder() + .setName('folder') + .setDescription("Move this ticket's email thread into a Gmail folder") + .setContexts([InteractionContextType.Guild]) + .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .addStringOption(opt => + opt + .setName('destination') + .setDescription('Target folder') + .setRequired(true) + .addChoices( + { name: 'For Jake', value: 'FOR_JAKE' }, + { name: 'Spam', value: 'SPAM' }, + { name: 'Dashboard Errors', value: 'DASHBOARD_ERRORS' }, + { name: 'Partnership Offers', value: 'PARTNERSHIP_OFFERS' } + ) + ), + new SlashCommandBuilder() .setName('cancel-close') .setDescription('Cancel a pending force-close countdown') diff --git a/config.js b/config.js index b3e9d9f..640a0e4 100644 --- a/config.js +++ b/config.js @@ -36,9 +36,11 @@ const CONFIG = { EMAIL_ESCALATED3_CHANNEL_ID: process.env.EMAIL_ESCALATED3_CHANNEL_ID || null, DISCORD_ESCALATED3_CHANNEL_ID: process.env.DISCORD_ESCALATED3_CHANNEL_ID || null, ESCALATION_MESSAGE: process.env.ESCALATION_MESSAGE || 'Your ticket has been escalated.\n\nA senior {support_name} will be here to assist as soon as possible.', + // Email tickets only (escalation notification email body). Placeholders: {escalator_name}, {tier}. + TICKET_ESCALATION_EMAIL_MESSAGE: process.env.TICKET_ESCALATION_EMAIL_MESSAGE || '{escalator_name} escalated this ticket to {tier}.', TICKET_CLOSE_SUBJECT_PREFIX: process.env.TICKET_CLOSE_SUBJECT_PREFIX || '[Resolved]', // Email tickets only (closure email body): - TICKET_CLOSE_MESSAGE: process.env.TICKET_CLOSE_MESSAGE || 'This ticket has been marked as resolved. If you would like to re-open this issue, please reply to this email.', + TICKET_CLOSE_MESSAGE: process.env.TICKET_CLOSE_MESSAGE || '{closer_name} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.', TICKET_CLOSE_SIGNATURE: process.env.TICKET_CLOSE_SIGNATURE || 'Thank you for using Indifferent Broccoli.', // Discord ticket closure (in-channel and transcript): DISCORD_CLOSE_MESSAGE: process.env.DISCORD_CLOSE_MESSAGE || 'This ticket has been closed. A transcript has been saved. If you still need assistance, please open a new ticket.', @@ -75,6 +77,16 @@ const CONFIG = { ADMIN_ID: process.env.ADMIN_ID || null, FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60), GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000, + // Inbound email flow master switch. Absent/anything-but-"false" => on, so + // existing deployments keep polling with no .env change. Toggle via /email. + GMAIL_POLL_ENABLED: process.env.GMAIL_POLL_ENABLED !== 'false', + // Gmail "folder" (label) names for ticket-lifecycle routing — see services/gmailLabels.js. + GMAIL_LABEL_TRIAGE: process.env.GMAIL_LABEL_TRIAGE || 'Triage', + GMAIL_LABEL_ESCALATED: process.env.GMAIL_LABEL_ESCALATED || 'Escalated', + GMAIL_LABEL_RESOLVED: process.env.GMAIL_LABEL_RESOLVED || 'Resolved', + GMAIL_LABEL_FOR_JAKE: process.env.GMAIL_LABEL_FOR_JAKE || 'For Jake', + GMAIL_LABEL_DASHBOARD_ERRORS: process.env.GMAIL_LABEL_DASHBOARD_ERRORS || 'Dashboard Errors', + GMAIL_LABEL_PARTNERSHIP_OFFERS: process.env.GMAIL_LABEL_PARTNERSHIP_OFFERS || 'Partnership Offers', STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true', STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion', STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true', diff --git a/docs/superpowers/specs/2026-06-03-email-flow-toggle-design.md b/docs/superpowers/specs/2026-06-03-email-flow-toggle-design.md new file mode 100644 index 0000000..725a5f1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-email-flow-toggle-design.md @@ -0,0 +1,124 @@ +# Email Flow Toggle — Design + +**Date:** 2026-06-03 +**Status:** Approved (design); pending implementation plan + +## Goal + +A staff slash command to turn the **inbound** email flow on and off at runtime, with the state surviving container restarts. + +- **ON** — Gmail polling runs as today: reads the inbox, creates/append ticket channels, runs all downstream features. +- **OFF** — Polling stops entirely. The mailbox is **never read** (inbox untouched). No new tickets are created from email. +- **Outbound is unaffected** in both states — ticket-close emails, Gmail replies, and notification emails still send when staff act on existing tickets. (Decision: "off" scopes to inbound polling only.) +- **Persists across restarts** — the off-state is honored on the next boot. (Decision: persisted, not runtime-only.) + +Discord-originated tickets are independent of email polling and are unaffected by this toggle. + +## Decisions (locked) + +| Decision | Choice | +|----------|--------| +| Scope of OFF | Inbound polling only; outbound email still sends | +| Persistence | Persist to `.env` via existing config-persistence path; honored on boot | +| Command shape | New dedicated `/email on\|off\|status` command (Approach A) | +| Existing `/gmailpoll` | Guarded so it cannot silently re-enable polling while flow is OFF | + +Rejected: folding into `/gmailpoll` subcommands (needless churn to a working command); sentinel interval `/gmailpoll 0` (poor discoverability). + +## Architecture + +Single source of truth: a new boolean config flag `GMAIL_POLL_ENABLED` (default **true**). The live poll timer (`gmailPollInterval` in `broccolini-discord.js`) is started/stopped to match the flag. + +### 1. Config flag — `GMAIL_POLL_ENABLED` + +- **`config.js`** — add: + ```js + GMAIL_POLL_ENABLED: process.env.GMAIL_POLL_ENABLED !== 'false', + ``` + Undefined/absent → `true`, so existing deployments keep polling with no `.env` change required. +- **`services/configSchema.js`** — add `'GMAIL_POLL_ENABLED'` to `ALLOWED_CONFIG_KEYS`. The existing `/ENABLED$/` rule in `inferType()` already classifies it as a boolean validator, so the settings site can also toggle it (bonus, no extra work). + +### 2. Boot gate + +In `broccolini-discord.js` (`client.once('ready')`, currently ~lines 210-211), only start the poll when enabled: + +```js +if (CONFIG.GMAIL_POLL_ENABLED) { + gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS)); + poll(client); +} else { + console.log('Gmail poll disabled by config (GMAIL_POLL_ENABLED=false)'); +} +``` + +When disabled, `gmailPollInterval` stays `null` — no timer is registered in `activeIntervals`, nothing reads the inbox. + +### 3. `/email` command + +- **Registration** — `commands/register.js`: a `SlashCommandBuilder` named `email` with three subcommands (`on`, `off`, `status`), `setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)` to match sibling commands. +- **Dispatch** — add `email: handleEmail` to `COMMAND_HANDLERS` in `handlers/commands/index.js`. Staff-gated automatically via `requireStaffRole()` at the dispatcher entry. +- **Handler** — `handleEmail(interaction)`: + - `on`: + 1. `applyConfigUpdates({ GMAIL_POLL_ENABLED: true })` (updates runtime `CONFIG` **and** writes `.env`). + 2. Clear the auth-suspend latch via `require('../../gmail-poll').setPollSuspended(false)` so a prior `invalid_grant` suspend doesn't keep polling dead. If auth is still broken, the next cycle re-suspends and DMs admin, exactly as today. + 3. `setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS)` to start the live timer. + 4. Reply (ephemeral): "Email flow is now **on**." + - `off`: + 1. `applyConfigUpdates({ GMAIL_POLL_ENABLED: false })`. + 2. `clearGmailPollInterval()`. + 3. Reply (ephemeral): "Email flow is now **off** — the inbox will not be polled. Outbound emails still send." + - `status`: + - Report `CONFIG.GMAIL_POLL_ENABLED`, the current interval (`CONFIG.GMAIL_POLL_INTERVAL_MS / 1000`s), and whether polling is currently suspended by an auth error. + - On `on`/`off`, fire `logTicketEvent('Email flow toggled', [...], interaction).catch(() => {})` — fire-and-forget, matching `/gmailpoll`. + +`applyConfigUpdates` is called in-process (the command runs inside the bot), reusing the same path the internal API uses — no HTTP round-trip. + +### 4. Guard `/gmailpoll` against silent re-enable + +`handleGmailPoll` currently calls `setGmailPollInterval(ms)`, which *starts* the timer. While flow is OFF that would silently re-enable polling. Change it so that when `CONFIG.GMAIL_POLL_ENABLED` is false: +- update the interval in memory only (`CONFIG.GMAIL_POLL_INTERVAL_MS = ms`) — matching `/gmailpoll`'s existing runtime-only model (it has never persisted to `.env`), but +- do **not** start the live timer, and +- reply: "Interval saved (``s), but the inbound email flow is currently **off** — it will apply when you run `/email on`." + +When flow is ON, `/gmailpoll` behaves exactly as today. + +## Data flow + +``` +/email off ──> applyConfigUpdates({GMAIL_POLL_ENABLED:false}) ──> CONFIG updated + .env written + └─> clearGmailPollInterval() ──> live timer stopped, gmailPollInterval=null + (no inbox reads) + +restart ──> config.js reads GMAIL_POLL_ENABLED=false ──> ready gate skips poll start ──> stays off + +/email on ──> applyConfigUpdates({GMAIL_POLL_ENABLED:true}) ──> CONFIG updated + .env written + ├─> setPollSuspended(false) ──> clear prior auth-suspend latch + └─> setGmailPollInterval(interval) ──> live timer started, immediate poll +``` + +## Error handling + +- Command runs through `runHandler`, so any throw is logged and the user gets an ephemeral "Something went wrong." +- `applyConfigUpdates` returns `{ applied, errors }`; if `GMAIL_POLL_ENABLED` lands in `errors` (should not, given the boolean validator), reply with the error rather than claiming success, and do not flip the live timer. +- Logging stays fire-and-forget (`.catch(() => {})`), per Hard Rule 4. + +## Files touched + +| File | Change | +|------|--------| +| `config.js` | Add `GMAIL_POLL_ENABLED` (default true) | +| `services/configSchema.js` | Add `'GMAIL_POLL_ENABLED'` to `ALLOWED_CONFIG_KEYS` | +| `broccolini-discord.js` | Gate poll start in `ready` on the flag | +| `commands/register.js` | Register `/email on\|off\|status` | +| `handlers/commands/index.js` | Add `handleEmail`; guard `handleGmailPoll` | +| `.env.example` | Document `GMAIL_POLL_ENABLED` (optional, default true) | + +No DB schema changes. No destructive data ops. The mailbox is never read while OFF and is never written by this feature. + +## Verification + +- `/email off` → logs show no further poll cycles; `.env` contains `GMAIL_POLL_ENABLED=false`. +- Restart container → no polling on boot; `/email status` reports off. +- `/email on` → poll resumes (immediate cycle), `.env` flips to `true`. +- While OFF, `/gmailpoll 60` → interval saved, no polling starts. +- `npm test` (covers `services/configSchema.js`); `node --check` on every edited file. diff --git a/docs/superpowers/specs/2026-06-03-gmail-folder-routing-design.md b/docs/superpowers/specs/2026-06-03-gmail-folder-routing-design.md new file mode 100644 index 0000000..f1eee12 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-gmail-folder-routing-design.md @@ -0,0 +1,217 @@ +# 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 **