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
218 lines
10 KiB
Markdown
218 lines
10 KiB
Markdown
# 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 **<label>**."
|
|
5. `logTicketEvent('Email thread filed', [...], interaction).catch(() => {})`.
|
|
6. On error, ephemeral "Failed to move the email thread: <reason>."
|
|
|
|
### 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.
|