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
This commit is contained in:
@@ -107,6 +107,8 @@ FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing
|
||||
GMAIL_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30)
|
||||
GMAIL_POLL_ENABLED= # Inbound email flow master switch; "false" disables polling (default on). Toggle at runtime with /email on|off
|
||||
GMAIL_LABEL_TRIAGE= # Gmail label for newly created tickets (default "Triage"); auto-created if missing
|
||||
GMAIL_LABEL_AWAITING_REPLY= # Gmail label set when staff reply emails the customer (default "Awaiting Reply")
|
||||
GMAIL_LABEL_NEEDS_RESPONSE= # Gmail label set when the customer responds (default "Needs Response")
|
||||
GMAIL_LABEL_ESCALATED= # Gmail label for escalated tickets (default "Escalated")
|
||||
GMAIL_LABEL_RESOLVED= # Gmail label for resolved/closed tickets (default "Resolved")
|
||||
GMAIL_LABEL_FOR_JAKE= # /folder option (default "For Jake")
|
||||
|
||||
@@ -403,6 +403,26 @@ async function registerCommands() {
|
||||
)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('forward')
|
||||
.setDescription("Forward this ticket's email thread to another address")
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||
.addStringOption(opt =>
|
||||
opt
|
||||
.setName('email')
|
||||
.setDescription('Destination email address')
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption(opt =>
|
||||
opt
|
||||
.setName('note')
|
||||
.setDescription('Optional message to include at the top of the forward')
|
||||
.setMaxLength(1000)
|
||||
.setRequired(false)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('cancel-close')
|
||||
.setDescription('Cancel a pending force-close countdown')
|
||||
|
||||
@@ -82,6 +82,8 @@ const CONFIG = {
|
||||
GMAIL_POLL_ENABLED: process.env.GMAIL_POLL_ENABLED !== 'false',
|
||||
// Gmail "folder" (label) names for ticket-lifecycle routing — see services/gmailLabels.js.
|
||||
GMAIL_LABEL_TRIAGE: process.env.GMAIL_LABEL_TRIAGE || 'Triage',
|
||||
GMAIL_LABEL_AWAITING_REPLY: process.env.GMAIL_LABEL_AWAITING_REPLY || 'Awaiting Reply',
|
||||
GMAIL_LABEL_NEEDS_RESPONSE: process.env.GMAIL_LABEL_NEEDS_RESPONSE || 'Needs Response',
|
||||
GMAIL_LABEL_ESCALATED: process.env.GMAIL_LABEL_ESCALATED || 'Escalated',
|
||||
GMAIL_LABEL_RESOLVED: process.env.GMAIL_LABEL_RESOLVED || 'Resolved',
|
||||
GMAIL_LABEL_FOR_JAKE: process.env.GMAIL_LABEL_FOR_JAKE || 'For Jake',
|
||||
|
||||
@@ -21,7 +21,7 @@ const {
|
||||
sanitizeEmbedText
|
||||
} = require('./utils');
|
||||
const { getGmailClient } = require('./services/gmail');
|
||||
const { moveThreadToFolder } = require('./services/gmailLabels');
|
||||
const { moveThreadToFolder, autoAdvanceFolder } = require('./services/gmailLabels');
|
||||
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
||||
const { logError } = require('./services/debugLog');
|
||||
const { enqueueSend } = require('./services/channelQueue');
|
||||
@@ -305,10 +305,22 @@ async function poll(client) {
|
||||
content: `**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
|
||||
allowedMentions: { parse: [] }
|
||||
});
|
||||
// Follow-up on an existing thread: archive the new message only. Leave
|
||||
// whatever managed folder staff filed this thread under untouched.
|
||||
console.log('Archiving/reading Gmail message', msgRef.id);
|
||||
await markGmailMessageRead(gmail, msgRef);
|
||||
// Customer responded → advance the thread to Needs Response. A
|
||||
// successful move strips INBOX+UNREAD (archives + marks read like
|
||||
// markGmailMessageRead did). If the thread is manually filed (For Jake,
|
||||
// Spam, …) autoAdvanceFolder leaves it put and returns false — or the
|
||||
// move may fail — so in either case fall back to marking just the new
|
||||
// message read, preserving the manual filing and avoiding reprocessing.
|
||||
let advanced = false;
|
||||
try {
|
||||
advanced = await autoAdvanceFolder(parsed.threadId, 'NEEDS_RESPONSE', gmail);
|
||||
} catch (err) {
|
||||
logError('autoAdvanceFolder(NEEDS_RESPONSE)', err, null, client).catch(() => {});
|
||||
}
|
||||
if (!advanced) {
|
||||
console.log('Archiving/reading Gmail message', msgRef.id);
|
||||
await markGmailMessageRead(gmail, msgRef);
|
||||
}
|
||||
} else {
|
||||
// Create a new ticket channel.
|
||||
const limitCheck = await checkTicketLimits(parsed.senderEmail);
|
||||
|
||||
59
handlers/commands/forward.js
Normal file
59
handlers/commands/forward.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* /forward — forward this ticket's email thread to a third-party address.
|
||||
*
|
||||
* Builds a fresh outbound email to the target only; the original customer is
|
||||
* never looped in (see services/gmail.js forwardThread).
|
||||
*/
|
||||
const { MessageFlags } = require('discord.js');
|
||||
const { findTicketForChannel } = require('../sharedHelpers');
|
||||
const { forwardThread } = require('../../services/gmail');
|
||||
const { logError, logTicketEvent } = require('../../services/debugLog');
|
||||
|
||||
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
||||
|
||||
async function handleForward(interaction) {
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
// Discord-origin tickets have no Gmail thread to forward.
|
||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||
return interaction.reply({
|
||||
content: "This ticket has no email thread, so there's nothing to forward.",
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
const target = interaction.options.getString('email');
|
||||
const note = interaction.options.getString('note') || '';
|
||||
|
||||
// Defer: fetching the thread + downloading attachments can exceed the 3s window.
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const { messageCount, attachmentCount, skipped } = await forwardThread(
|
||||
ticket.gmailThreadId, target, note
|
||||
);
|
||||
|
||||
logTicketEvent('Email thread forwarded', [
|
||||
{ name: 'To', value: target },
|
||||
{ name: 'Messages', value: String(messageCount) },
|
||||
{ name: 'Forwarded by', value: interaction.user.tag }
|
||||
], interaction).catch(() => {});
|
||||
|
||||
const skippedNote = skipped ? ` (${plural(skipped, 'attachment')} skipped — over the size limit)` : '';
|
||||
return interaction.editReply({
|
||||
content: `Forwarded ${plural(messageCount, 'message')} (${plural(attachmentCount, 'attachment')}) to **${target}**.${skippedNote}`
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.code === 'EBADRECIPIENT') {
|
||||
return interaction.editReply({ content: "That doesn't look like a valid email address." });
|
||||
}
|
||||
if (err.code === 'EEMPTY') {
|
||||
return interaction.editReply({ content: 'This thread has no messages to forward.' });
|
||||
}
|
||||
logError('handleForward', err, interaction).catch(() => {});
|
||||
return interaction.editReply({ content: `Failed to forward: ${err.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleForward };
|
||||
@@ -31,6 +31,7 @@ const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolv
|
||||
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
|
||||
const { handleResponse, handleAutocomplete } = require('./response');
|
||||
const { handlePanel, handleSignature } = require('./panel');
|
||||
const { handleForward } = require('./forward');
|
||||
const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
@@ -355,7 +356,7 @@ async function handleHelp(interaction) {
|
||||
},
|
||||
{
|
||||
name: 'Ticket Management',
|
||||
value: '`/transfer @staff [reason]` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/cancel-close` - Abort a pending force-close countdown\n`/topic <text>` - Set ticket topic/description\n`/folder <destination>` - File this ticket\'s email into a Gmail folder'
|
||||
value: '`/transfer @staff [reason]` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/cancel-close` - Abort a pending force-close countdown\n`/topic <text>` - Set ticket topic/description\n`/folder <destination>` - File this ticket\'s email into a Gmail folder\n`/forward <email> [note]` - Forward this ticket\'s email thread to another address'
|
||||
},
|
||||
{
|
||||
name: 'Saved Responses',
|
||||
@@ -405,6 +406,7 @@ const COMMAND_HANDLERS = {
|
||||
email: handleEmail,
|
||||
folder: handleFolder,
|
||||
closetimer: handleCloseTimer,
|
||||
forward: handleForward,
|
||||
'cancel-close': handleCancelClose,
|
||||
'force-close': handleForceClose,
|
||||
topic: handleTopic,
|
||||
|
||||
@@ -5,6 +5,7 @@ const { mongoose } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
const { extractRawEmail, isStaff, getCleanBody } = require('../utils');
|
||||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||
const { autoAdvanceFolder } = require('../services/gmailLabels');
|
||||
const { getNotifyDm } = require('../services/staffSettings');
|
||||
const { logError } = require('../services/debugLog');
|
||||
|
||||
@@ -100,6 +101,12 @@ async function handleDiscordReply(m) {
|
||||
m.author.id,
|
||||
quote
|
||||
);
|
||||
|
||||
// Staff just replied to the customer → advance to Awaiting Reply (unless the
|
||||
// thread is manually filed). Fire-and-forget: a label failure must not break
|
||||
// the reply that already went out.
|
||||
autoAdvanceFolder(ticket.gmailThreadId, 'AWAITING_REPLY')
|
||||
.catch(err => logError('autoAdvanceFolder(AWAITING_REPLY)', err).catch(() => {}));
|
||||
} catch (e) {
|
||||
console.error('REPLY ERROR:', e);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
const { google } = require('googleapis');
|
||||
const { CONFIG } = require('../config');
|
||||
const { extractRawEmail, escapeHtml } = require('../utils');
|
||||
const { extractRawEmail, escapeHtml, getCleanBody } = require('../utils');
|
||||
const { getStaffSignatureBlocks } = require('./staffSignature');
|
||||
const { logError } = require('./debugLog');
|
||||
const { readEnvFile } = require('./configPersistence');
|
||||
@@ -49,8 +49,10 @@ function getGmailClient() {
|
||||
|
||||
/**
|
||||
* Re-read REFRESH_TOKEN from .env, update in-memory config, and probe Google.
|
||||
* Used by the internal /gmail/reload endpoint so the weekly reauth chore does
|
||||
* not require a full container restart.
|
||||
* Used by the internal /gmail/reload endpoint so an occasional re-auth (the
|
||||
* OAuth app is published, so the token is long-lived — re-auth is only needed
|
||||
* on revoke/password-change, not on a schedule) does not require a full
|
||||
* container restart.
|
||||
*
|
||||
* Throws if the env file is missing the token, or if the probe call (getProfile)
|
||||
* fails — the caller surfaces the error so the UI can see why.
|
||||
@@ -365,10 +367,171 @@ async function sendGmailReply(threadId, replyText, recipientEmail, subject, mess
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively collect attachment parts (those with a filename + attachmentId)
|
||||
// from a Gmail message payload, at any nesting depth.
|
||||
function collectAttachmentParts(payload) {
|
||||
const out = [];
|
||||
const walk = part => {
|
||||
if (!part) return;
|
||||
if (part.filename && part.body?.attachmentId) {
|
||||
out.push({
|
||||
filename: part.filename,
|
||||
mimeType: part.mimeType || 'application/octet-stream',
|
||||
attachmentId: part.body.attachmentId,
|
||||
size: part.body.size || 0
|
||||
});
|
||||
}
|
||||
if (part.parts) for (const p of part.parts) walk(p);
|
||||
};
|
||||
if (payload?.parts) for (const p of payload.parts) walk(p);
|
||||
else walk(payload);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Forward an entire ticket thread to a third party as a BRAND-NEW email.
|
||||
// The original customer is never looped in: To = target only, no Cc/Bcc, no
|
||||
// threadId, no In-Reply-To/References. Returns counts for the confirmation reply.
|
||||
const FORWARD_MAX_TOTAL_BYTES = 20 * 1024 * 1024; // ~20 MB attachment ceiling
|
||||
const FORWARD_DIVIDER = '-'.repeat(40);
|
||||
|
||||
async function forwardThread(threadId, targetEmail, note = '') {
|
||||
const safeTarget = sanitizeHeaderValue(extractRawEmail(targetEmail || '')).toLowerCase();
|
||||
if (!EMAIL_RE.test(safeTarget)) {
|
||||
const err = new Error(`Invalid forward recipient: ${safeTarget || '(empty)'}`);
|
||||
err.code = 'EBADRECIPIENT';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const gmail = getGmailClient();
|
||||
const thread = await gmail.users.threads.get({ userId: 'me', id: threadId, format: 'full' });
|
||||
const messages = thread.data.messages || [];
|
||||
if (!messages.length) {
|
||||
const err = new Error('Thread has no messages to forward.');
|
||||
err.code = 'EEMPTY';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const firstHeaders = messages[0]?.payload?.headers || [];
|
||||
const baseSubject = firstHeaders.find(h => h.name === 'Subject')?.value || 'No subject';
|
||||
const fwdSubject = sanitizeHeaderValue(`Fwd: ${String(baseSubject).replace(/^(?:\s*Fwd\s*:\s*)+/i, '')}`);
|
||||
const encodedSubject = `=?utf-8?B?${Buffer.from(fwdSubject).toString('base64')}?=`;
|
||||
|
||||
const textBlocks = [];
|
||||
const htmlBlocks = [];
|
||||
const attachments = [];
|
||||
let skipped = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const msg of messages) {
|
||||
const h = msg.payload?.headers || [];
|
||||
const from = h.find(x => x.name === 'From')?.value || 'Unknown';
|
||||
const date = h.find(x => x.name === 'Date')?.value || '';
|
||||
const body = (getCleanBody(msg.payload) || '').replace(/\r\n/g, '\n').trim();
|
||||
|
||||
textBlocks.push(`From: ${from}\nDate: ${date}\n\n${body}`);
|
||||
htmlBlocks.push(
|
||||
`<div style="margin-bottom:8px;color:#555;font-size:13px;">` +
|
||||
`<strong>From:</strong> ${escapeHtml(from)}<br>` +
|
||||
`<strong>Date:</strong> ${escapeHtml(date)}</div>` +
|
||||
`<div>${escapeHtml(body).replace(/\n/g, '<br>')}</div>`
|
||||
);
|
||||
|
||||
for (const att of collectAttachmentParts(msg.payload)) {
|
||||
if (totalBytes + (att.size || 0) > FORWARD_MAX_TOTAL_BYTES) { skipped++; continue; }
|
||||
try {
|
||||
const res = await gmail.users.messages.attachments.get({
|
||||
userId: 'me', messageId: msg.id, id: att.attachmentId
|
||||
});
|
||||
const std = (res.data.data || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||
const buf = Buffer.from(std, 'base64');
|
||||
totalBytes += buf.length;
|
||||
attachments.push({
|
||||
filename: sanitizeHeaderValue(att.filename).replace(/"/g, ''),
|
||||
mimeType: att.mimeType,
|
||||
base64: buf.toString('base64')
|
||||
});
|
||||
} catch (_) {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transcriptText = textBlocks.join(`\n\n${FORWARD_DIVIDER}\n\n`);
|
||||
const transcriptHtml = htmlBlocks.join('<hr style="border:none;border-top:1px solid #ddd;margin:16px 0;">');
|
||||
const noteText = note ? `${note}\n\n${FORWARD_DIVIDER}\n\n` : '';
|
||||
const noteHtml = note
|
||||
? `<p>${escapeHtml(note).replace(/\n/g, '<br>')}</p><hr style="border:none;border-top:1px solid #ddd;margin:16px 0;">`
|
||||
: '';
|
||||
|
||||
const stamp = Date.now().toString(16);
|
||||
const altBoundary = 'alt_' + stamp;
|
||||
const altPart = [
|
||||
`--${altBoundary}`,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
'',
|
||||
noteText + transcriptText,
|
||||
'',
|
||||
`--${altBoundary}`,
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
`<div style="font-family: sans-serif; font-size: 14px; color: #333;">${noteHtml}${transcriptHtml}</div>`,
|
||||
'',
|
||||
`--${altBoundary}--`
|
||||
];
|
||||
|
||||
let topContentType;
|
||||
let bodyLines;
|
||||
if (attachments.length) {
|
||||
const mixBoundary = 'mix_' + stamp;
|
||||
topContentType = `multipart/mixed; boundary="${mixBoundary}"`;
|
||||
bodyLines = [
|
||||
`--${mixBoundary}`,
|
||||
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
|
||||
'',
|
||||
...altPart,
|
||||
''
|
||||
];
|
||||
for (const a of attachments) {
|
||||
bodyLines.push(
|
||||
`--${mixBoundary}`,
|
||||
`Content-Type: ${a.mimeType}; name="${a.filename}"`,
|
||||
'Content-Transfer-Encoding: base64',
|
||||
`Content-Disposition: attachment; filename="${a.filename}"`,
|
||||
'',
|
||||
...(a.base64.match(/.{1,76}/g) || []),
|
||||
''
|
||||
);
|
||||
}
|
||||
bodyLines.push(`--${mixBoundary}--`);
|
||||
} else {
|
||||
topContentType = `multipart/alternative; boundary="${altBoundary}"`;
|
||||
bodyLines = altPart;
|
||||
}
|
||||
|
||||
// Deliberately omit threadId / In-Reply-To / References so this is a fresh
|
||||
// conversation to the target only — the original sender is never in the loop.
|
||||
const headers = [
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${safeTarget}`,
|
||||
`Subject: ${encodedSubject}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: ${topContentType}`
|
||||
];
|
||||
|
||||
const raw = Buffer.from([...headers, '', ...bodyLines].join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({ userId: 'me', requestBody: { raw } });
|
||||
|
||||
return { messageCount: messages.length, attachmentCount: attachments.length, skipped };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getGmailClient,
|
||||
reloadGmailClient,
|
||||
sendGmailReply,
|
||||
sendTicketClosedEmail,
|
||||
sendTicketNotificationEmail
|
||||
sendTicketNotificationEmail,
|
||||
forwardThread
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ const { getGmailClient } = require('./gmail');
|
||||
// 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' },
|
||||
@@ -30,6 +32,12 @@ const FOLDER_DEFS = {
|
||||
// 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'];
|
||||
|
||||
@@ -139,14 +147,60 @@ async function moveThreadToFolder(threadId, targetKey, gmail = getGmailClient())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
};
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
resolveLabelId,
|
||||
moveThreadToFolder,
|
||||
folderDisplayName,
|
||||
getManagedFolderKey,
|
||||
autoAdvanceFolder,
|
||||
MANAGED_USER_KEYS,
|
||||
__clearLabelCache
|
||||
} from '../services/gmailLabels.js';
|
||||
|
||||
@@ -19,8 +22,11 @@ const FULL_IDS = {
|
||||
|
||||
const FULL_LABELS = [
|
||||
{ name: 'Triage', id: 'L_TRIAGE' },
|
||||
{ name: 'Awaiting Reply', id: 'L_AR' },
|
||||
{ name: 'Needs Response', id: 'L_NR' },
|
||||
{ name: 'Escalated', id: 'L_ESC' },
|
||||
{ name: 'Resolved', id: 'L_RES' },
|
||||
{ name: 'Complete', id: 'L_RES' },
|
||||
{ name: 'For Jake', id: 'L_FJ' },
|
||||
{ name: 'Dashboard Errors', id: 'L_DE' },
|
||||
{ name: 'Partnership Offers', id: 'L_PO' }
|
||||
@@ -150,3 +156,76 @@ describe('moveThreadToFolder', () => {
|
||||
await expect(moveThreadToFolder('t', 'BOGUS', gmail)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// Derive label name↔id from the live config so these tests don't depend on the
|
||||
// actual GMAIL_LABEL_* names in .env (e.g. RESOLVED may be customized to "Complete").
|
||||
const idForKey = key => `LID_${key}`;
|
||||
const CYCLE_LABELS = MANAGED_USER_KEYS.map(k => ({ name: folderDisplayName(k), id: idForKey(k) }));
|
||||
|
||||
// Mock Gmail whose thread carries the given label ids; records threads.modify.
|
||||
function makeGmail({ threadLabelIds = [], onModify } = {}) {
|
||||
return {
|
||||
users: {
|
||||
labels: {
|
||||
list: async () => ({ data: { labels: CYCLE_LABELS } }),
|
||||
create: async () => { throw new Error('no create expected'); }
|
||||
},
|
||||
threads: {
|
||||
get: async () => ({ data: { messages: [{ labelIds: threadLabelIds }] } }),
|
||||
modify: async (args) => { if (onModify) onModify(args); return { data: {} }; }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe('getManagedFolderKey', () => {
|
||||
beforeEach(() => __clearLabelCache());
|
||||
|
||||
it('maps a thread label id to its managed folder key', async () => {
|
||||
expect(await getManagedFolderKey('t', makeGmail({ threadLabelIds: [idForKey('FOR_JAKE'), 'INBOX'] }))).toBe('FOR_JAKE');
|
||||
});
|
||||
|
||||
it('detects the system SPAM label', async () => {
|
||||
expect(await getManagedFolderKey('t', makeGmail({ threadLabelIds: ['SPAM'] }))).toBe('SPAM');
|
||||
});
|
||||
|
||||
it('returns null when no managed label is present', async () => {
|
||||
expect(await getManagedFolderKey('t', makeGmail({ threadLabelIds: ['INBOX', 'UNREAD'] }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoAdvanceFolder', () => {
|
||||
beforeEach(() => __clearLabelCache());
|
||||
|
||||
it('advances an auto-cycle thread (Awaiting Reply → Needs Response)', async () => {
|
||||
let modifyArgs = null;
|
||||
const gmail = makeGmail({ threadLabelIds: [idForKey('AWAITING_REPLY')], onModify: a => { modifyArgs = a; } });
|
||||
expect(await autoAdvanceFolder('t', 'NEEDS_RESPONSE', gmail)).toBe(true);
|
||||
expect(modifyArgs.requestBody.addLabelIds).toEqual([idForKey('NEEDS_RESPONSE')]);
|
||||
});
|
||||
|
||||
it('advances a thread with no managed label', async () => {
|
||||
let called = false;
|
||||
const gmail = makeGmail({ threadLabelIds: ['INBOX'], onModify: () => { called = true; } });
|
||||
expect(await autoAdvanceFolder('t', 'AWAITING_REPLY', gmail)).toBe(true);
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a manually-filed thread (For Jake) untouched', async () => {
|
||||
let called = false;
|
||||
const gmail = makeGmail({ threadLabelIds: [idForKey('FOR_JAKE')], onModify: () => { called = true; } });
|
||||
expect(await autoAdvanceFolder('t', 'NEEDS_RESPONSE', gmail)).toBe(false);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a SPAM-filed thread untouched', async () => {
|
||||
let called = false;
|
||||
const gmail = makeGmail({ threadLabelIds: ['SPAM'], onModify: () => { called = true; } });
|
||||
expect(await autoAdvanceFolder('t', 'NEEDS_RESPONSE', gmail)).toBe(false);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unknown target key before touching the API', async () => {
|
||||
await expect(autoAdvanceFolder('t', 'BOGUS', makeGmail())).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user