/** * 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} 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() };