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
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
/folderslash 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.- Resolve the target label ID (
resolveLabelId), and the IDs of all managed user labels (to build the remove set). addLabelIds = [targetId].removeLabelIds = [all managed user-label IDs except target] + ['INBOX', 'UNREAD'](computed bycomputeLabelMutation). ForSPAMtarget, the user labels are all removed andSPAMis added;INBOX/UNREADremoved as usual.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.
- Resolve the target label ID (
-
resolveLabelId(gmail, key)— returns the Gmail label ID for a key.SPAMshort-circuits to'SPAM'.- Otherwise: check the module-scoped name→ID cache; on miss,
users.labels.listand match by name (case-sensitive, Gmail's behavior); if still absent,users.labels.createit (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) → keepmarkGmailMessageRead(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-
continuepaths → unchanged plain archive (they already callmarkGmailMessageReadbeforecontinue).
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.js→finalizeForceClosehandlers/buttons.js→runFinalClose
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):SlashCommandBuildernamedfolder,setDefaultMemberPermissions(ManageMessages), Guild context / GuildInstall, with a required string optiondestinationand choices:For Jake→FOR_JAKESpam→SPAMDashboard Errors→DASHBOARD_ERRORSPartnership Offers→PARTNERSHIP_OFFERS
- Dispatch (
handlers/commands/index.js): addfolder: handleFoldertoCOMMAND_HANDLERS; add a/folderline to/help. - Handler
handleFolder(interaction):findTicketForChannel(interaction); bail if none.- If
ticket.gmailThreadId.startsWith('discord-')→ ephemeral "This ticket has no email thread, so it can't be moved to a Gmail folder." - Otherwise
await moveThreadToFolder(ticket.gmailThreadId, folderKey). - Ephemeral reply: "Moved this ticket's email thread to ."
logTicketEvent('Email thread filed', [...], interaction).catch(() => {}).- On error, ephemeral "Failed to move the email thread: ."
6. Config & docs
config.js: add the sixGMAIL_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 vialogError. /foldersurfaces 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/emailtoggle): 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 newtests/gmailLabels.test.js(computeLabelMutationexclusivity;resolveLabelIdcache-hit / create-on-miss / SPAM short-circuit, with a fake gmail client).node --checkon 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 →/folderreports no email thread.