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_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_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_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_ESCALATED= # Gmail label for escalated tickets (default "Escalated")
|
||||||
GMAIL_LABEL_RESOLVED= # Gmail label for resolved/closed tickets (default "Resolved")
|
GMAIL_LABEL_RESOLVED= # Gmail label for resolved/closed tickets (default "Resolved")
|
||||||
GMAIL_LABEL_FOR_JAKE= # /folder option (default "For Jake")
|
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()
|
new SlashCommandBuilder()
|
||||||
.setName('cancel-close')
|
.setName('cancel-close')
|
||||||
.setDescription('Cancel a pending force-close countdown')
|
.setDescription('Cancel a pending force-close countdown')
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ const CONFIG = {
|
|||||||
GMAIL_POLL_ENABLED: process.env.GMAIL_POLL_ENABLED !== 'false',
|
GMAIL_POLL_ENABLED: process.env.GMAIL_POLL_ENABLED !== 'false',
|
||||||
// Gmail "folder" (label) names for ticket-lifecycle routing — see services/gmailLabels.js.
|
// Gmail "folder" (label) names for ticket-lifecycle routing — see services/gmailLabels.js.
|
||||||
GMAIL_LABEL_TRIAGE: process.env.GMAIL_LABEL_TRIAGE || 'Triage',
|
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_ESCALATED: process.env.GMAIL_LABEL_ESCALATED || 'Escalated',
|
||||||
GMAIL_LABEL_RESOLVED: process.env.GMAIL_LABEL_RESOLVED || 'Resolved',
|
GMAIL_LABEL_RESOLVED: process.env.GMAIL_LABEL_RESOLVED || 'Resolved',
|
||||||
GMAIL_LABEL_FOR_JAKE: process.env.GMAIL_LABEL_FOR_JAKE || 'For Jake',
|
GMAIL_LABEL_FOR_JAKE: process.env.GMAIL_LABEL_FOR_JAKE || 'For Jake',
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const {
|
|||||||
sanitizeEmbedText
|
sanitizeEmbedText
|
||||||
} = require('./utils');
|
} = require('./utils');
|
||||||
const { getGmailClient } = require('./services/gmail');
|
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 { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
||||||
const { logError } = require('./services/debugLog');
|
const { logError } = require('./services/debugLog');
|
||||||
const { enqueueSend } = require('./services/channelQueue');
|
const { enqueueSend } = require('./services/channelQueue');
|
||||||
@@ -305,10 +305,22 @@ async function poll(client) {
|
|||||||
content: `**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
|
content: `**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
|
||||||
allowedMentions: { parse: [] }
|
allowedMentions: { parse: [] }
|
||||||
});
|
});
|
||||||
// Follow-up on an existing thread: archive the new message only. Leave
|
// Customer responded → advance the thread to Needs Response. A
|
||||||
// whatever managed folder staff filed this thread under untouched.
|
// successful move strips INBOX+UNREAD (archives + marks read like
|
||||||
console.log('Archiving/reading Gmail message', msgRef.id);
|
// markGmailMessageRead did). If the thread is manually filed (For Jake,
|
||||||
await markGmailMessageRead(gmail, msgRef);
|
// 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 {
|
} else {
|
||||||
// Create a new ticket channel.
|
// Create a new ticket channel.
|
||||||
const limitCheck = await checkTicketLimits(parsed.senderEmail);
|
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 { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
|
||||||
const { handleResponse, handleAutocomplete } = require('./response');
|
const { handleResponse, handleAutocomplete } = require('./response');
|
||||||
const { handlePanel, handleSignature } = require('./panel');
|
const { handlePanel, handleSignature } = require('./panel');
|
||||||
|
const { handleForward } = require('./forward');
|
||||||
const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu');
|
const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu');
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
@@ -355,7 +356,7 @@ async function handleHelp(interaction) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Ticket Management',
|
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',
|
name: 'Saved Responses',
|
||||||
@@ -405,6 +406,7 @@ const COMMAND_HANDLERS = {
|
|||||||
email: handleEmail,
|
email: handleEmail,
|
||||||
folder: handleFolder,
|
folder: handleFolder,
|
||||||
closetimer: handleCloseTimer,
|
closetimer: handleCloseTimer,
|
||||||
|
forward: handleForward,
|
||||||
'cancel-close': handleCancelClose,
|
'cancel-close': handleCancelClose,
|
||||||
'force-close': handleForceClose,
|
'force-close': handleForceClose,
|
||||||
topic: handleTopic,
|
topic: handleTopic,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const { mongoose } = require('../db-connection');
|
|||||||
const { CONFIG } = require('../config');
|
const { CONFIG } = require('../config');
|
||||||
const { extractRawEmail, isStaff, getCleanBody } = require('../utils');
|
const { extractRawEmail, isStaff, getCleanBody } = require('../utils');
|
||||||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||||
|
const { autoAdvanceFolder } = require('../services/gmailLabels');
|
||||||
const { getNotifyDm } = require('../services/staffSettings');
|
const { getNotifyDm } = require('../services/staffSettings');
|
||||||
const { logError } = require('../services/debugLog');
|
const { logError } = require('../services/debugLog');
|
||||||
|
|
||||||
@@ -100,6 +101,12 @@ async function handleDiscordReply(m) {
|
|||||||
m.author.id,
|
m.author.id,
|
||||||
quote
|
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) {
|
} catch (e) {
|
||||||
console.error('REPLY ERROR:', e);
|
console.error('REPLY ERROR:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
const { google } = require('googleapis');
|
const { google } = require('googleapis');
|
||||||
const { CONFIG } = require('../config');
|
const { CONFIG } = require('../config');
|
||||||
const { extractRawEmail, escapeHtml } = require('../utils');
|
const { extractRawEmail, escapeHtml, getCleanBody } = require('../utils');
|
||||||
const { getStaffSignatureBlocks } = require('./staffSignature');
|
const { getStaffSignatureBlocks } = require('./staffSignature');
|
||||||
const { logError } = require('./debugLog');
|
const { logError } = require('./debugLog');
|
||||||
const { readEnvFile } = require('./configPersistence');
|
const { readEnvFile } = require('./configPersistence');
|
||||||
@@ -49,8 +49,10 @@ function getGmailClient() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-read REFRESH_TOKEN from .env, update in-memory config, and probe Google.
|
* 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
|
* Used by the internal /gmail/reload endpoint so an occasional re-auth (the
|
||||||
* not require a full container restart.
|
* 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)
|
* 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.
|
* 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 = {
|
module.exports = {
|
||||||
getGmailClient,
|
getGmailClient,
|
||||||
reloadGmailClient,
|
reloadGmailClient,
|
||||||
sendGmailReply,
|
sendGmailReply,
|
||||||
sendTicketClosedEmail,
|
sendTicketClosedEmail,
|
||||||
sendTicketNotificationEmail
|
sendTicketNotificationEmail,
|
||||||
|
forwardThread
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const { getGmailClient } = require('./gmail');
|
|||||||
// name from CONFIG (env-configurable); SPAM is the Gmail system label.
|
// name from CONFIG (env-configurable); SPAM is the Gmail system label.
|
||||||
const FOLDER_DEFS = {
|
const FOLDER_DEFS = {
|
||||||
TRIAGE: { configKey: 'GMAIL_LABEL_TRIAGE' },
|
TRIAGE: { configKey: 'GMAIL_LABEL_TRIAGE' },
|
||||||
|
AWAITING_REPLY: { configKey: 'GMAIL_LABEL_AWAITING_REPLY' },
|
||||||
|
NEEDS_RESPONSE: { configKey: 'GMAIL_LABEL_NEEDS_RESPONSE' },
|
||||||
ESCALATED: { configKey: 'GMAIL_LABEL_ESCALATED' },
|
ESCALATED: { configKey: 'GMAIL_LABEL_ESCALATED' },
|
||||||
RESOLVED: { configKey: 'GMAIL_LABEL_RESOLVED' },
|
RESOLVED: { configKey: 'GMAIL_LABEL_RESOLVED' },
|
||||||
FOR_JAKE: { configKey: 'GMAIL_LABEL_FOR_JAKE' },
|
FOR_JAKE: { configKey: 'GMAIL_LABEL_FOR_JAKE' },
|
||||||
@@ -30,6 +32,12 @@ const FOLDER_DEFS = {
|
|||||||
// User-managed folder keys (everything but the system SPAM label).
|
// User-managed folder keys (everything but the system SPAM label).
|
||||||
const MANAGED_USER_KEYS = Object.keys(FOLDER_DEFS).filter(k => !FOLDER_DEFS[k].system);
|
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.
|
// Always stripped on a move so the thread leaves the inbox and is marked read.
|
||||||
const ALWAYS_REMOVE = ['INBOX', 'UNREAD'];
|
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 = {
|
module.exports = {
|
||||||
FOLDER_DEFS,
|
FOLDER_DEFS,
|
||||||
MANAGED_USER_KEYS,
|
MANAGED_USER_KEYS,
|
||||||
|
MANUAL_KEYS,
|
||||||
ALWAYS_REMOVE,
|
ALWAYS_REMOVE,
|
||||||
folderDisplayName,
|
folderDisplayName,
|
||||||
resolveLabelId,
|
resolveLabelId,
|
||||||
computeLabelMutation,
|
computeLabelMutation,
|
||||||
moveThreadToFolder,
|
moveThreadToFolder,
|
||||||
|
getManagedFolderKey,
|
||||||
|
autoAdvanceFolder,
|
||||||
// test seam: clear the name->id cache between cases
|
// test seam: clear the name->id cache between cases
|
||||||
__clearLabelCache: () => labelIdByName.clear()
|
__clearLabelCache: () => labelIdByName.clear()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
resolveLabelId,
|
resolveLabelId,
|
||||||
moveThreadToFolder,
|
moveThreadToFolder,
|
||||||
folderDisplayName,
|
folderDisplayName,
|
||||||
|
getManagedFolderKey,
|
||||||
|
autoAdvanceFolder,
|
||||||
|
MANAGED_USER_KEYS,
|
||||||
__clearLabelCache
|
__clearLabelCache
|
||||||
} from '../services/gmailLabels.js';
|
} from '../services/gmailLabels.js';
|
||||||
|
|
||||||
@@ -19,8 +22,11 @@ const FULL_IDS = {
|
|||||||
|
|
||||||
const FULL_LABELS = [
|
const FULL_LABELS = [
|
||||||
{ name: 'Triage', id: 'L_TRIAGE' },
|
{ name: 'Triage', id: 'L_TRIAGE' },
|
||||||
|
{ name: 'Awaiting Reply', id: 'L_AR' },
|
||||||
|
{ name: 'Needs Response', id: 'L_NR' },
|
||||||
{ name: 'Escalated', id: 'L_ESC' },
|
{ name: 'Escalated', id: 'L_ESC' },
|
||||||
{ name: 'Resolved', id: 'L_RES' },
|
{ name: 'Resolved', id: 'L_RES' },
|
||||||
|
{ name: 'Complete', id: 'L_RES' },
|
||||||
{ name: 'For Jake', id: 'L_FJ' },
|
{ name: 'For Jake', id: 'L_FJ' },
|
||||||
{ name: 'Dashboard Errors', id: 'L_DE' },
|
{ name: 'Dashboard Errors', id: 'L_DE' },
|
||||||
{ name: 'Partnership Offers', id: 'L_PO' }
|
{ name: 'Partnership Offers', id: 'L_PO' }
|
||||||
@@ -150,3 +156,76 @@ describe('moveThreadToFolder', () => {
|
|||||||
await expect(moveThreadToFolder('t', 'BOGUS', gmail)).rejects.toThrow();
|
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