docs: design notes for /forward and folder-lifecycle features

This commit is contained in:
2026-06-05 03:08:32 +00:00
parent 6ae57af885
commit 988151d337
2 changed files with 127 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
# Folder lifecycle: Triage → Awaiting Reply → Needs Response
## Goal
Auto-advance a ticket's Gmail folder as the conversation moves, so the inbox
reflects who owes a reply:
- New email ticket → **Triage** (unchanged).
- Staff reply reaches the customer → **Awaiting Reply**.
- Customer responds → **Needs Response**.
- Escalate → **Escalated** (unchanged; cycle resumes on the next reply/response).
- Close → **Resolved** (unchanged).
Triage means "new, not yet replied to"; the first staff reply moves Triage→Awaiting Reply.
## Folder taxonomy (new classification)
- **Auto-cycle** (bot moves these): `TRIAGE`, `ESCALATED`, `AWAITING_REPLY`,
`NEEDS_RESPONSE`, `RESOLVED`.
- **Manual / exempt** (bot never auto-moves): `FOR_JAKE`, `SPAM`,
`PARTNERSHIP_OFFERS`, `DASHBOARD_ERRORS`.
A customer reply (or staff reply) to a thread sitting in a manual folder leaves it
there; only auto-cycle (or unlabeled) threads advance.
## Mechanism
New `services/gmailLabels.js` exports:
- `MANUAL_KEYS` — the exempt set above.
- `getManagedFolderKey(threadId, gmail)` — reads the thread's existing labels
(union of message `labelIds`), maps to a managed folder key, returns it or null.
**Read-only**: never creates a label just to check (uses the existing name→id
cache; SPAM maps to the system `SPAM` id).
- `autoAdvanceFolder(threadId, targetKey, gmail)`:
```
current = getManagedFolderKey(threadId, gmail)
if current ∈ MANUAL_KEYS: return false // filed manually → don't touch
await moveThreadToFolder(threadId, targetKey, gmail)
return true
```
## Decisions baked in (from brainstorming)
- "A response given" = a reply that actually emails the customer. Today that's the
typed-reply path (`handleDiscordReply` → `sendGmailReply`). `/response send` does
NOT email today, so it is intentionally NOT wired here; if it's ever wired to
email, it routes through the same send point and gets the move for free.
- Escalation keeps its own distinct `ESCALATED` folder; it is auto-cycle eligible,
so the next staff reply → Awaiting Reply and the next customer reply → Needs Response.
- Manual filings (For Jake / Spam / Partnership / Dashboard Errors) are never
overridden by the auto-flow.
## Hooks
- `handlers/messages.js` — after a successful `sendGmailReply`, fire-and-forget
`autoAdvanceFolder(ticket.gmailThreadId, 'AWAITING_REPLY')`.
- `gmail-poll.js` (~line 308, the follow-up-to-existing-channel branch) — replace
the "leave folder untouched" behavior with fire-and-forget
`autoAdvanceFolder(parsed.threadId, 'NEEDS_RESPONSE', gmail)`.
Both guard Discord-origin tickets (`gmailThreadId` starts `discord-`) and are
fire-and-forget (`.catch(() => {})`) so a label failure (e.g. stale Gmail auth)
never breaks the reply or the poll.
## Files
- `services/gmailLabels.js` — 2 new `FOLDER_DEFS` entries; `MANUAL_KEYS`;
`getManagedFolderKey()`; `autoAdvanceFolder()`; add to exports.
- `config.js` — `GMAIL_LABEL_AWAITING_REPLY` (default `Awaiting Reply`),
`GMAIL_LABEL_NEEDS_RESPONSE` (default `Needs Response`).
- `.env.example` — document the two new vars.
- `handlers/messages.js` — Awaiting Reply hook.
- `gmail-poll.js` — Needs Response hook.
- `tests/gmailLabels.test.js` — cover manual-exemption gating + happy-path advance.
## Out of scope
- Wiring `/response send` to email (separate concern; user is unsure `/response`
is even worth keeping).
- Reopen flow (a customer reply after the Discord channel is deleted creates a new
channel → Triage via the existing path; unchanged).

View File

@@ -0,0 +1,54 @@
# `/forward` — forward a ticket's email thread to a third party
## Goal
Let staff forward a ticket's entire email conversation to any email address from
inside the ticket channel. The original customer must **never** be looped in.
## Command
```
/forward email:<address> [note:<text>]
```
- `email` — required destination address; validated with `EMAIL_RE`, header-sanitized.
- `note` — optional cover message prepended above the transcript (max 1000 chars).
- Staff-only (the command dispatcher gates every command via `requireStaffRole`);
`ManageMessages` default member permission, matching other ticket-mod commands.
## Customer-isolation guarantees (the hard requirement)
1. `To:` = target address **only**. No `Cc`, no `Bcc`.
2. The Gmail `messages.send` call carries **no `threadId`** → a brand-new thread,
not an append to the customer's conversation.
3. **No `In-Reply-To` / `References`** headers.
4. The customer's `senderEmail` is never written into any recipient header.
5. Fresh `Subject: Fwd: <original subject>`.
## Flow
- `handlers/commands/forward.js``handleForward(interaction)`:
1. `findTicketForChannel` — else ephemeral "not a ticket channel".
2. Discord-origin ticket (`gmailThreadId` starts `discord-`) → ephemeral "no email thread".
3. `deferReply` ephemerally (fetch + attachment download can exceed 3s).
4. `forwardThread(gmailThreadId, email, note, userId)`.
5. Ephemeral confirmation: "Forwarded N messages (M attachments) to x@y.com"
(+ note if any attachments were skipped for size). `logTicketEvent`.
- `services/gmail.js` → new `forwardThread(threadId, targetEmail, note, userId)`:
1. Validate target (`EMAIL_RE`); throw `EBADRECIPIENT` if bad.
2. `threads.get(format:'full')`; throw `EEMPTY` if no messages.
3. Subject from first message → `Fwd: <subject>` (strip existing `Fwd:`), RFC2047-encode.
4. Per message oldest→newest: header line (`From:`/`Date:`) + `getCleanBody`; build
parallel text and HTML blocks (HTML body `escapeHtml`-ed — customer content).
5. Collect attachment parts (recursive walk for `filename` + `body.attachmentId`);
download via `messages.attachments.get`. **Cap total ~20 MB**; past it → skip + count.
6. Compose a **new** outbound email: `From: support@`, `To: target only`,
`multipart/mixed` (attachments) wrapping `multipart/alternative` (text+html).
Plain-ASCII divider between messages in text; `<hr>` in HTML.
7. `messages.send` (no `threadId`). Return `{ messageCount, attachmentCount, skipped }`.
## Files
- `commands/register.js``/forward` builder (after `folder`).
- `services/gmail.js``forwardThread()` + `collectAttachmentParts()` helper; export;
import `getCleanBody` from `utils`.
- `handlers/commands/forward.js` — new handler.
- `handlers/commands/index.js` — import + `COMMAND_HANDLERS.forward`; `/help` line.
## Out of scope
- Forwarding a single message or letting staff pick one (whole-thread only).
- Native RFC822 re-send (we build a fresh email instead).