diff --git a/.scratch/folder-lifecycle/design.md b/.scratch/folder-lifecycle/design.md new file mode 100644 index 0000000..8d76891 --- /dev/null +++ b/.scratch/folder-lifecycle/design.md @@ -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). diff --git a/.scratch/forward-command/design.md b/.scratch/forward-command/design.md new file mode 100644 index 0000000..80c0046 --- /dev/null +++ b/.scratch/forward-command/design.md @@ -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:
[note:] +``` +- `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: `. + +## 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: ` (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; `
` 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).