Files
broccolini-bot/docs/superpowers/specs/2026-06-03-gmail-folder-routing-design.md
indifferentketchup 2ccdbf72aa 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
2026-06-04 22:05:20 +00:00

10 KiB

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:

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.jsfinalizeForceClose
  • handlers/buttons.jsrunFinalClose

In each, for non-Discord tickets (!ticket.gmailThreadId.startsWith('discord-')), after the status update, add a non-fatal:

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 JakeFOR_JAKE
    • SpamSPAM
    • Dashboard ErrorsDASHBOARD_ERRORS
    • Partnership OffersPARTNERSHIP_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 ."
    5. logTicketEvent('Email thread filed', [...], interaction).catch(() => {}).
    6. On error, ephemeral "Failed to move the email thread: ."

6. Config & docs

  • config.js: add the six GMAIL_LABEL_* keys with the default names above.
  • .env.example: document the six vars (default-on naming).
  • Not added to ALLOWED_CONFIG_KEYS — settings-site contract unchanged.

Data flow

inbound email (poll, flow ON)
  └─ new ticket  ──> moveThreadToFolder(thread, TRIAGE)   [add Triage; remove others+INBOX+UNREAD]
  └─ follow-up   ──> markGmailMessageRead(msg)            [remove INBOX+UNREAD on the new msg only]

/escalate or tier button ──> runEscalation ──> moveThreadToFolder(thread, ESCALATED)
close (slash or button)  ──> finalize       ──> moveThreadToFolder(thread, RESOLVED)
/folder <dest>           ──> handleFolder    ──> moveThreadToFolder(thread, <dest|SPAM>)

Every moveThreadToFolder resolves IDs (creating missing user labels), then one threads.modify enforcing exclusive-folder semantics.

Error handling

  • Lifecycle hooks (Triage/Escalated/Resolved) are non-fatal .catch — Gmail problems never block ticket flow. Errors logged via logError.
  • /folder surfaces failures to the invoking staffer ephemerally.
  • Stale cached label ID → one cache-clear + retry inside moveThreadToFolder.
  • Label operations are independent of CONFIG.GMAIL_POLL_ENABLED (the /email toggle): they are explicit staff/lifecycle actions, not polling. Triage-on-create only fires during polling, so it is naturally inert while the flow is off.

Files touched

File Change
services/gmailLabels.js New — folder defs, moveThreadToFolder, resolveLabelId, computeLabelMutation, cache
tests/gmailLabels.test.js New — unit tests for mutation logic + label resolution
config.js Add six GMAIL_LABEL_* config keys (defaults)
.env.example Document the six label-name vars
gmail-poll.js Triage on create/reopen; keep plain archive for follow-ups & non-ticket paths
handlers/commands/escalation.js runEscalation: move thread to Escalated (non-fatal)
handlers/commands/close.js finalizeForceClose: move thread to Resolved (non-fatal)
handlers/buttons.js runFinalClose: move thread to Resolved (non-fatal)
commands/register.js Register /folder with 4 choices
handlers/commands/index.js handleFolder + dispatch entry + /help line

No DB schema changes. No destructive data ops — threads.modify only relabels; nothing is deleted or trashed. (Moving to SPAM is reversible from Gmail.)

Verification

  • npm test — existing suite plus new tests/gmailLabels.test.js (computeLabelMutation exclusivity; resolveLabelId cache-hit / create-on-miss / SPAM short-circuit, with a fake gmail client).
  • node --check on every edited file.
  • Manual (post-deploy): create an email ticket → its thread lands in Triage and leaves the inbox; /escalate → Escalated; /folder For Jake → For Jake (and out of Escalated); close → Resolved. Discord ticket → /folder reports no email thread.