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:
@@ -35,17 +35,17 @@ const ALLOWED_CONFIG_KEYS = new Set([
|
||||
'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
|
||||
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
|
||||
// Branding
|
||||
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
|
||||
'LOGO_URL', 'SUPPORT_NAME', 'GAME_LIST',
|
||||
// Toggles
|
||||
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
|
||||
'ALLOW_CLAIM_OVERWRITE',
|
||||
'ALLOW_CLAIM_OVERWRITE', 'TRANSCRIPT_DM_TO_CREATOR',
|
||||
'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
|
||||
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
|
||||
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
|
||||
// Limits and thresholds
|
||||
'GLOBAL_TICKET_LIMIT',
|
||||
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
|
||||
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
|
||||
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS', 'GMAIL_POLL_ENABLED',
|
||||
// Embed colors
|
||||
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
||||
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
|
||||
@@ -163,6 +163,8 @@ function inferType(key) {
|
||||
if (key.includes('COLOR')) return 'hex_color';
|
||||
// ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it.
|
||||
if (key === 'ROLE_ID_TO_PING') return 'discord_id';
|
||||
// Boolean toggle whose name doesn't match the ENABLED/_ON pattern.
|
||||
if (key === 'TRANSCRIPT_DM_TO_CREATOR') return 'boolean';
|
||||
|
||||
// 2. Name patterns
|
||||
if (/ENABLED$|^USE_|_ON$/.test(key)) return 'boolean';
|
||||
|
||||
@@ -36,7 +36,7 @@ async function sendToChannel(channelId, embed, overrideClient) {
|
||||
if (!c || !channelId) return;
|
||||
try {
|
||||
const channel = await c.channels.fetch(channelId);
|
||||
if (channel) await channel.send({ embeds: [embed] });
|
||||
if (channel) await channel.send({ embeds: [embed], allowedMentions: { parse: [] } });
|
||||
} catch (_) {
|
||||
// ignore send failures
|
||||
}
|
||||
@@ -59,7 +59,8 @@ async function logError(context, error, interaction = null, overrideClient = nul
|
||||
const message = redactPII(error.message || String(error));
|
||||
const stack = redactPII(error.stack || error.message || String(error)).slice(0, 1500);
|
||||
await channel.send({
|
||||
content: `\`[${context}]\` ${message}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``
|
||||
content: `\`[${context}]\` ${message}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``,
|
||||
allowedMentions: { parse: [] }
|
||||
});
|
||||
} catch (_) {
|
||||
// ignore send failures
|
||||
|
||||
@@ -23,7 +23,6 @@ function buildCompanySigHtml() {
|
||||
Indifferent Broccoli Support<br>
|
||||
<a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</a><br>
|
||||
Join us on <a href="https://discord.gg/2vmfrrtvJY">Discord</a><br>
|
||||
<br>
|
||||
<em>Host your own game server. Or not... we don't care.</em>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -35,7 +34,6 @@ function buildCompanySigText() {
|
||||
'Indifferent Broccoli Support',
|
||||
'https://indifferentbroccoli.com/',
|
||||
'Join us on Discord: https://discord.gg/2vmfrrtvJY',
|
||||
'',
|
||||
"Host your own game server. Or not... we don't care."
|
||||
].join('\n');
|
||||
}
|
||||
@@ -96,23 +94,168 @@ function encodeReplySubject(baseSubject) {
|
||||
}
|
||||
|
||||
// Compose and send a multipart/alternative reply on an existing Gmail thread.
|
||||
async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId }) {
|
||||
// Build the "On <date>, <sender> wrote:" attribution line for a quoted reply.
|
||||
function formatQuoteAttribution(quote) {
|
||||
const who = (quote.from || '').trim() || 'the sender';
|
||||
const when = (quote.date || '').trim();
|
||||
return when ? `On ${when}, ${who} wrote:` : `${who} wrote:`;
|
||||
}
|
||||
|
||||
// Plain-text quoted block: attribution + each original line prefixed with "> ".
|
||||
// Returns null when there is nothing to quote.
|
||||
function buildQuoteText(quote) {
|
||||
if (!quote || !(quote.body || '').trim()) return null;
|
||||
const quoted = quote.body.replace(/\r\n/g, '\n').split('\n').map(l => `> ${l}`).join('\n');
|
||||
return `${formatQuoteAttribution(quote)}\n${quoted}`;
|
||||
}
|
||||
|
||||
// HTML quoted block. Mirrors Gmail's own reply markup (gmail_quote / gmail_attr
|
||||
// classes + the standard blockquote styling) so receiving clients recognize it
|
||||
// as quoted content and collapse it behind the "•••" toggle. Body is
|
||||
// attacker-controlled email content — escapeHtml it.
|
||||
function buildQuoteHtml(quote) {
|
||||
if (!quote || !(quote.body || '').trim()) return '';
|
||||
const attribution = escapeHtml(formatQuoteAttribution(quote));
|
||||
const quotedHtml = escapeHtml(quote.body.replace(/\r\n/g, '\n')).replace(/\n/g, '<br>');
|
||||
return `<div class="gmail_quote">` +
|
||||
`<div dir="ltr" class="gmail_attr">${attribution}<br></div>` +
|
||||
`<blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex;">${quotedHtml}</blockquote>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
// Discord custom emoji token: <:name:id> (static) or <a:name:id> (animated).
|
||||
const DISCORD_EMOJI_RE = /<(a?):(\w+):(\d+)>/g;
|
||||
// Same token after escapeHtml has turned the angle brackets into entities.
|
||||
const DISCORD_EMOJI_RE_ESCAPED = /<(a?):(\w+):(\d+)>/g;
|
||||
|
||||
// Plain-text: collapse a custom-emoji token to its :name: shortcode.
|
||||
function discordEmojiToText(s) {
|
||||
return (s || '').replace(DISCORD_EMOJI_RE, (_m, _anim, name) => `:${name}:`);
|
||||
}
|
||||
|
||||
// Collect the distinct custom emoji referenced in a message.
|
||||
function collectDiscordEmojis(s) {
|
||||
const seen = new Map();
|
||||
for (const m of (s || '').matchAll(DISCORD_EMOJI_RE)) {
|
||||
const [, anim, name, id] = m;
|
||||
if (!seen.has(id)) seen.set(id, { id, name, ext: anim ? 'gif' : 'png' });
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
// Fetch one emoji's bytes from Discord's CDN for inline (cid:) embedding.
|
||||
// Returns null on any failure so the caller can fall back to a remote <img>.
|
||||
async function fetchEmojiInline(emoji) {
|
||||
try {
|
||||
const res = await fetch(`https://cdn.discordapp.com/emojis/${emoji.id}.${emoji.ext}`);
|
||||
if (!res.ok) return null;
|
||||
const base64 = Buffer.from(await res.arrayBuffer()).toString('base64');
|
||||
return { ...emoji, base64, cid: `emoji-${emoji.id}@broccolini` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// HTML: escape first (body is staff-authored but treated as untrusted), then
|
||||
// swap the now-escaped emoji tokens for an inline <img>. Prefer a cid: reference
|
||||
// (embedded part, always renders); fall back to Discord's CDN when not embedded.
|
||||
// The id is digits-only and name is \w+, so neither can break out of the attribute.
|
||||
function messageTextToHtml(s, cidById = {}) {
|
||||
return escapeHtml(s || '')
|
||||
.replace(DISCORD_EMOJI_RE_ESCAPED, (_m, anim, name, id) => {
|
||||
const ext = anim ? 'gif' : 'png';
|
||||
const src = cidById[id] ? `cid:${cidById[id]}` : `https://cdn.discordapp.com/emojis/${id}.${ext}`;
|
||||
return `<img src="${src}" alt=":${name}:" ` +
|
||||
`width="20" height="20" style="vertical-align: middle;">`;
|
||||
})
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// Strip Discord role mentions (<@&id>) — internal staff pings like @broccolini
|
||||
// that mean nothing to an email recipient. Collapse the whitespace left behind.
|
||||
function stripRoleMentions(s) {
|
||||
return (s || '')
|
||||
.replace(/<@&\d+>/g, '')
|
||||
.replace(/[^\S\r\n]{2,}/g, ' ')
|
||||
.replace(/[^\S\r\n]+\n/g, '\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId, quote = null }) {
|
||||
const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' };
|
||||
const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '<br>') : '';
|
||||
const safeStaffSigText = sigBlocks.text;
|
||||
|
||||
const cleanText = stripRoleMentions(messageText);
|
||||
|
||||
// Embed any custom emoji inline (cid:) so they render without the recipient
|
||||
// having to load remote images. Failed fetches fall back to a remote <img>.
|
||||
const inlineEmojis = (await Promise.all(collectDiscordEmojis(cleanText).map(fetchEmojiInline))).filter(Boolean);
|
||||
const cidById = {};
|
||||
for (const e of inlineEmojis) cidById[e.id] = e.cid;
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p>${escapeHtml(messageText || '').replace(/\n/g, '<br>')}</p>
|
||||
<p>${messageTextToHtml(cleanText, cidById)}</p>
|
||||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||||
${buildCompanySigHtml()}
|
||||
${buildQuoteHtml(quote)}
|
||||
</div>`;
|
||||
|
||||
const plainBody = [messageText || ''];
|
||||
const plainBody = [discordEmojiToText(cleanText)];
|
||||
if (safeStaffSigText) plainBody.push('', safeStaffSigText);
|
||||
plainBody.push('', ...buildCompanySigText().split('\n'));
|
||||
const quoteText = buildQuoteText(quote);
|
||||
if (quoteText) plainBody.push('', quoteText);
|
||||
|
||||
const stamp = Date.now().toString(16);
|
||||
const altBoundary = 'alt_' + stamp;
|
||||
const altPart = [
|
||||
`--${altBoundary}`,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
'',
|
||||
...plainBody,
|
||||
'',
|
||||
`--${altBoundary}`,
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody,
|
||||
'',
|
||||
`--${altBoundary}--`
|
||||
];
|
||||
|
||||
// With no inline images the message stays a plain multipart/alternative.
|
||||
// With them, wrap the alternative + image parts in a multipart/related.
|
||||
let topContentType;
|
||||
let bodyLines;
|
||||
if (inlineEmojis.length) {
|
||||
const relBoundary = 'rel_' + stamp;
|
||||
topContentType = `multipart/related; boundary="${relBoundary}"`;
|
||||
bodyLines = [
|
||||
`--${relBoundary}`,
|
||||
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
|
||||
'',
|
||||
...altPart,
|
||||
''
|
||||
];
|
||||
for (const e of inlineEmojis) {
|
||||
bodyLines.push(
|
||||
`--${relBoundary}`,
|
||||
`Content-Type: image/${e.ext === 'gif' ? 'gif' : 'png'}`,
|
||||
'Content-Transfer-Encoding: base64',
|
||||
`Content-ID: <${e.cid}>`,
|
||||
`Content-Disposition: inline; filename="${e.name}.${e.ext}"`,
|
||||
'',
|
||||
...(e.base64.match(/.{1,76}/g) || []),
|
||||
''
|
||||
);
|
||||
}
|
||||
bodyLines.push(`--${relBoundary}--`);
|
||||
} else {
|
||||
topContentType = `multipart/alternative; boundary="${altBoundary}"`;
|
||||
bodyLines = altPart;
|
||||
}
|
||||
|
||||
const boundary = '000000000000' + Date.now().toString(16);
|
||||
const headers = [
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${recipient}`,
|
||||
@@ -120,24 +263,10 @@ async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, m
|
||||
msgId && `In-Reply-To: ${msgId}`,
|
||||
msgId && `References: ${msgId}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`
|
||||
`Content-Type: ${topContentType}`
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = Buffer.from([
|
||||
...headers,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
'',
|
||||
...plainBody,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody,
|
||||
'',
|
||||
`--${boundary}--`
|
||||
].join('\r\n'))
|
||||
const raw = Buffer.from([...headers, '', ...bodyLines].join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
@@ -164,15 +293,20 @@ async function sendTicketClosedEmail(ticket, closerName, userId = null) {
|
||||
const gmail = getGmailClient();
|
||||
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
|
||||
const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support');
|
||||
const messageText = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`;
|
||||
// Editable via TICKET_CLOSE_MESSAGE in .env. Supports a {closer_name}
|
||||
// placeholder and \n for line breaks.
|
||||
const messageText = (CONFIG.TICKET_CLOSE_MESSAGE || '')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\{closer_name\}/g, closerName);
|
||||
|
||||
// Closing emails intentionally omit the staff signature (userId left out)
|
||||
// — only the resolution message and the company signature go out.
|
||||
await sendThreadedEmail(gmail, {
|
||||
threadId: ticket.gmailThreadId,
|
||||
recipient,
|
||||
encodedSubject,
|
||||
msgId,
|
||||
messageText,
|
||||
userId
|
||||
messageText
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ticket closed email error:', err);
|
||||
@@ -211,7 +345,7 @@ async function sendTicketNotificationEmail(ticket, messageBody, userId = null) {
|
||||
* Send a Gmail reply on an existing thread. Caller supplies subject + messageId
|
||||
* (typically pulled from the latest non-self message in the thread).
|
||||
*/
|
||||
async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null) {
|
||||
async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null, quote = null) {
|
||||
const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase();
|
||||
if (!EMAIL_RE.test(safeRecipient)) {
|
||||
logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {});
|
||||
@@ -225,7 +359,8 @@ async function sendGmailReply(threadId, replyText, recipientEmail, subject, mess
|
||||
encodedSubject: encodeReplySubject(subject || 'Support'),
|
||||
msgId: sanitizeHeaderValue(messageId) || null,
|
||||
messageText: replyText,
|
||||
userId
|
||||
userId,
|
||||
quote
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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