Gmail folder auto-advance + /forward command

- Reply-cycle auto-advance: staff reply files the thread to "Awaiting Reply",
  a customer response files it to "Needs Response" (new GMAIL_LABEL_AWAITING_REPLY
  / GMAIL_LABEL_NEEDS_RESPONSE labels + autoAdvanceFolder, which only moves
  threads still in the auto-cycle and leaves hand-filed folders alone)
- /forward: forward a ticket's email to another address (handlers/commands/forward.js
  + forward composition in services/gmail.js)
- Tests for the auto-advance cycle; label fixtures updated for the new labels
This commit is contained in:
2026-06-05 02:46:50 +00:00
parent 0fcffe8d33
commit 6bae3e79b1
10 changed files with 410 additions and 10 deletions

View File

@@ -19,6 +19,8 @@ const { getGmailClient } = require('./gmail');
// name from CONFIG (env-configurable); SPAM is the Gmail system label.
const FOLDER_DEFS = {
TRIAGE: { configKey: 'GMAIL_LABEL_TRIAGE' },
AWAITING_REPLY: { configKey: 'GMAIL_LABEL_AWAITING_REPLY' },
NEEDS_RESPONSE: { configKey: 'GMAIL_LABEL_NEEDS_RESPONSE' },
ESCALATED: { configKey: 'GMAIL_LABEL_ESCALATED' },
RESOLVED: { configKey: 'GMAIL_LABEL_RESOLVED' },
FOR_JAKE: { configKey: 'GMAIL_LABEL_FOR_JAKE' },
@@ -30,6 +32,12 @@ const FOLDER_DEFS = {
// User-managed folder keys (everything but the system SPAM label).
const MANAGED_USER_KEYS = Object.keys(FOLDER_DEFS).filter(k => !FOLDER_DEFS[k].system);
// Folders staff file into by hand. The reply-cycle auto-flow (autoAdvanceFolder)
// never moves a thread out of one of these — a customer reply to something filed
// under "For Jake" or "Spam" stays put. Everything else (Triage, Awaiting Reply,
// Needs Response, Escalated, Resolved) is auto-cycle eligible.
const MANUAL_KEYS = ['FOR_JAKE', 'SPAM', 'PARTNERSHIP_OFFERS', 'DASHBOARD_ERRORS'];
// Always stripped on a move so the thread leaves the inbox and is marked read.
const ALWAYS_REMOVE = ['INBOX', 'UNREAD'];
@@ -139,14 +147,60 @@ async function moveThreadToFolder(threadId, targetKey, gmail = getGmailClient())
}
}
/**
* Read-only: which managed folder does this thread currently sit in? Returns a
* FOLDER_DEFS key, or null if none of the managed labels are present.
*
* Unlike resolveLabelId this never *creates* a label — a label that doesn't exist
* yet can't be on the thread, so it's simply skipped.
*/
async function getManagedFolderKey(threadId, gmail = getGmailClient()) {
if (!threadId) throw new Error('getManagedFolderKey: threadId required');
const thread = await gmail.users.threads.get({ userId: 'me', id: threadId, format: 'minimal' });
const threadLabelIds = new Set();
for (const m of thread.data.messages || []) {
for (const id of m.labelIds || []) threadLabelIds.add(id);
}
await ensureLabelCache(gmail);
for (const key of Object.keys(FOLDER_DEFS)) {
const def = FOLDER_DEFS[key];
const id = def.system ? def.system : labelIdByName.get(folderDisplayName(key));
if (id && threadLabelIds.has(id)) return key;
}
return null;
}
/**
* Advance a thread to `targetKey` as part of the reply cycle — UNLESS it is
* currently filed in a manual folder (MANUAL_KEYS), in which case it is left
* untouched. Threads with no managed label (or an auto-cycle label) advance.
*
* @returns {Promise<boolean>} true if the thread was moved, false if left in place.
*/
async function autoAdvanceFolder(threadId, targetKey, gmail = getGmailClient()) {
if (!threadId) throw new Error('autoAdvanceFolder: threadId required');
if (!FOLDER_DEFS[targetKey]) throw new Error(`Unknown folder key: ${targetKey}`);
const current = await getManagedFolderKey(threadId, gmail);
if (current && MANUAL_KEYS.includes(current)) return false;
await moveThreadToFolder(threadId, targetKey, gmail);
return true;
}
module.exports = {
FOLDER_DEFS,
MANAGED_USER_KEYS,
MANUAL_KEYS,
ALWAYS_REMOVE,
folderDisplayName,
resolveLabelId,
computeLabelMutation,
moveThreadToFolder,
getManagedFolderKey,
autoAdvanceFolder,
// test seam: clear the name->id cache between cases
__clearLabelCache: () => labelIdByName.clear()
};