Email ticketing fixes, comms polish, and .env cleanup
Inbound: - Gmail poll query is:unread in:inbox (was category:primary, which matched nothing on a no-tabs Workspace inbox) Outbound email: - Closed/escalation auto-emails editable via TICKET_CLOSE_MESSAGE and new TICKET_ESCALATION_EMAIL_MESSAGE; drop the staff signature from closing emails - Replies quote the customer's latest message (gmail_quote markup so clients collapse it), embed custom emoji inline via CID attachment, and strip Discord role mentions - Tagline spacing fix in the company signature Discord side: - Suppress all mentions in log + transcript posts (no more pinging on close) - Drop the staff-role ping from new-ticket and follow-up notifications - Ticket channels inherit category permissions instead of setting per-channel overwrites (removes the Manage Roles requirement) Gmail folders: - Folder/label routing (gmailLabels.js) with /folder; close files to Complete Config: - Remove ~56 stale .env keys for long-removed features; refresh stale copy Docs: - Design specs for folder routing, email-flow toggle, and per-staff metrics
This commit is contained in:
152
services/gmailLabels.js
Normal file
152
services/gmailLabels.js
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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()
|
||||
};
|
||||
Reference in New Issue
Block a user