Files
broccolini-bot/services/gmailLabels.js
indifferentketchup 6bae3e79b1 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
2026-06-05 02:46:50 +00:00

207 lines
7.5 KiB
JavaScript

/**
* Gmail "folder" routing — map a ticket's Gmail thread into a managed set of
* labels with exclusive-folder semantics.
*
* Gmail labels are additive; we synthesize folders by, on every move, adding the
* target label and removing every *other* managed label plus INBOX + UNREAD
* (removing an absent label is a no-op, so this is idempotent). "Spam" maps to
* the built-in system SPAM label, which is never created.
*
* Acyclic require graph: this module depends on services/gmail (getGmailClient);
* gmail.js does not depend back on this file.
*/
'use strict';
const { CONFIG } = require('../config');
const { getGmailClient } = require('./gmail');
// Logical folder key -> how to resolve its label. User folders read their display
// 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' },
DASHBOARD_ERRORS: { configKey: 'GMAIL_LABEL_DASHBOARD_ERRORS' },
PARTNERSHIP_OFFERS: { configKey: 'GMAIL_LABEL_PARTNERSHIP_OFFERS' },
SPAM: { system: 'SPAM' }
};
// 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'];
// Cache: Gmail label display name -> label ID. Populated lazily; cleared on a
// stale-label error so a label recreated in Gmail is re-resolved.
const labelIdByName = new Map();
/** Display name for a user folder key (null for the system SPAM label). */
function folderDisplayName(key) {
const def = FOLDER_DEFS[key];
if (!def) throw new Error(`Unknown folder key: ${key}`);
if (def.system) return null;
return CONFIG[def.configKey];
}
async function ensureLabelCache(gmail) {
if (labelIdByName.size > 0) return;
const res = await gmail.users.labels.list({ userId: 'me' });
for (const label of res.data.labels || []) {
labelIdByName.set(label.name, label.id);
}
}
/**
* Resolve a folder key to a Gmail label ID, creating a missing *user* label.
* SPAM short-circuits to the system id and is never created.
*/
async function resolveLabelId(gmail, key) {
const def = FOLDER_DEFS[key];
if (!def) throw new Error(`Unknown folder key: ${key}`);
if (def.system) return def.system;
const name = folderDisplayName(key);
await ensureLabelCache(gmail);
if (labelIdByName.has(name)) return labelIdByName.get(name);
const created = await gmail.users.labels.create({
userId: 'me',
requestBody: { name, labelListVisibility: 'labelShow', messageListVisibility: 'show' }
});
labelIdByName.set(name, created.data.id);
return created.data.id;
}
/**
* Pure: given the target key and a key->id map of every managed label, build the
* add/remove sets for an exclusive-folder move. The target label is added; every
* other managed label plus INBOX + UNREAD is removed.
*/
function computeLabelMutation(targetKey, idByKey) {
const targetId = idByKey[targetKey];
if (!targetId) throw new Error(`Missing resolved id for target folder: ${targetKey}`);
const removeLabelIds = [];
for (const key of Object.keys(idByKey)) {
if (key === targetKey) continue;
const id = idByKey[key];
if (id) removeLabelIds.push(id);
}
for (const sys of ALWAYS_REMOVE) removeLabelIds.push(sys);
return { addLabelIds: [targetId], removeLabelIds };
}
function isInvalidLabelError(err) {
const status = err && ((err.response && err.response.status) || err.code);
const msg = (err && err.message) || '';
return status === 400 || /invalid label|labelId not found/i.test(msg);
}
/**
* Move a Gmail thread into a managed folder with exclusive-folder semantics.
* Resolves (and creates) every managed label, then issues one threads.modify.
* On a stale cached label id (400 invalid label), clears the cache and retries
* once.
*
* @param {string} threadId Gmail thread id (ticket.gmailThreadId)
* @param {string} targetKey one of FOLDER_DEFS keys
* @param {object} [gmail] optional Gmail client (poll loop passes its own)
*/
async function moveThreadToFolder(threadId, targetKey, gmail = getGmailClient()) {
if (!threadId) throw new Error('moveThreadToFolder: threadId required');
if (!FOLDER_DEFS[targetKey]) throw new Error(`Unknown folder key: ${targetKey}`);
const applyOnce = async () => {
const idByKey = {};
for (const key of Object.keys(FOLDER_DEFS)) {
idByKey[key] = await resolveLabelId(gmail, key);
}
const mutation = computeLabelMutation(targetKey, idByKey);
await gmail.users.threads.modify({
userId: 'me',
id: threadId,
requestBody: mutation
});
};
try {
await applyOnce();
} catch (err) {
if (isInvalidLabelError(err)) {
labelIdByName.clear();
await applyOnce();
} else {
throw err;
}
}
}
/**
* 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()
};