/** * 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' }, 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); // 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; } } } module.exports = { FOLDER_DEFS, MANAGED_USER_KEYS, ALWAYS_REMOVE, folderDisplayName, resolveLabelId, computeLabelMutation, moveThreadToFolder, // test seam: clear the name->id cache between cases __clearLabelCache: () => labelIdByName.clear() };