1 Commits

Author SHA1 Message Date
cdb5db0082 Add per-staff metrics: StaffAction event log + /stats command
Event-sourced tracking of staff/ticket lifecycle actions, plus a /stats
command. Foundation for a future tickets-website analytics dashboard.

Data:
- StaffAction model (event log) + Ticket.game / Ticket.closedAt
- STATS_ADMIN_IDS config (who may view others' stats)

Recording (fire-and-forget, idempotent on real state transitions):
- claim, response (channel reply + /response send), escalate, de-escalate,
  transfer, close (4 sites), reopen — each denormalizes ticketType, tier,
  priority, game, requester (senderEmail / creatorId), guildId
- close events carry closerType / resolverId (claimer credit) / wasClaimed;
  transfer carries fromId / toId; reopen stamps resolverId
- conditional close transition helper (atomic open->closed + closedAt) shared
  by all four close paths

Query + command:
- pure period parser (presets + free-text) and stats shaper (per-metric keys)
- command-aware autocomplete dispatch
- /stats: period (autocomplete) + member (admin-gated) + source (all/email/
  discord), ManageMessages + staff-role gated, ephemeral, tier-labeled embed

288+ unit tests; timing/busiest-times data is collected but displayed later.
2026-06-05 02:02:48 +00:00
15 changed files with 99 additions and 778 deletions

View File

@@ -108,8 +108,6 @@ 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")

3
.gitignore vendored
View File

@@ -48,8 +48,5 @@ cursor.yml
.claude/ .claude/
# Local planning/issue-tracker scratchpad — specs & PRDs stay on disk, not in git
.scratch/
CLAUDE.md CLAUDE.md
*.bak* *.bak*

View File

@@ -403,26 +403,6 @@ 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')

View File

@@ -83,8 +83,6 @@ 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',

View File

@@ -20,8 +20,8 @@ const {
detectGame, detectGame,
sanitizeEmbedText sanitizeEmbedText
} = require('./utils'); } = require('./utils');
const { getGmailClient, fetchMessageAttachments } = require('./services/gmail'); const { getGmailClient } = require('./services/gmail');
const { moveThreadToFolder, autoAdvanceFolder } = require('./services/gmailLabels'); const { moveThreadToFolder } = 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');
@@ -213,34 +213,6 @@ async function linkPreviousTranscripts(ticketChan, threadId, client) {
} }
} }
/**
* Best-effort: fetch the email's attachments and post them to the ticket channel.
* Files go out in one enqueued message (up to Discord's 10-file limit); any part
* that is too large or fails to download is named in a follow-up note so staff
* know to check Gmail. Never throws — attachment delivery must not break the
* ticket flow.
*/
async function postEmailAttachments(channel, gmail, email, client) {
try {
const { files, skipped } = await fetchMessageAttachments(email.data.id, email.data.payload, gmail);
if (files.length) {
await enqueueSend(channel, {
content: '**Email attachments:**',
files,
allowedMentions: { parse: [] }
});
}
if (skipped.length) {
await enqueueSend(channel, {
content: `⚠️ ${skipped.length} attachment(s) could not be posted (too large or failed to download) — check Gmail: ${skipped.map(s => `\`${s}\``).join(', ')}`,
allowedMentions: { parse: [] }
});
}
} catch (err) {
logError('postEmailAttachments', err, null, client).catch(() => {});
}
}
/** Drop UNREAD + INBOX labels from a Gmail message — equivalent to "archive + read". */ /** Drop UNREAD + INBOX labels from a Gmail message — equivalent to "archive + read". */
async function markGmailMessageRead(gmail, msgRef) { async function markGmailMessageRead(gmail, msgRef) {
await gmail.users.messages.batchModify({ await gmail.users.messages.batchModify({
@@ -383,23 +355,10 @@ 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: [] }
}); });
await postEmailAttachments(ticketChan, gmail, email, client); // 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
// 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); console.log('Archiving/reading Gmail message', msgRef.id);
await markGmailMessageRead(gmail, msgRef); 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);
@@ -453,7 +412,6 @@ async function poll(client) {
content: `**Message:**\n${truncated}`, content: `**Message:**\n${truncated}`,
allowedMentions: { parse: [] } allowedMentions: { parse: [] }
}); });
await postEmailAttachments(ticketChan, gmail, email, client);
// Welcome message skipped for email tickets the email body speaks for itself. // Welcome message skipped for email tickets the email body speaks for itself.
// Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js. // Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js.

View File

@@ -22,7 +22,7 @@ const {
} = require('discord.js'); } = require('discord.js');
const { mongoose } = require('../db-connection'); const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName, attemptCloseTransition, scheduleTicketChannelDelete } = require('../services/tickets'); const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName, attemptCloseTransition } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail'); const { sendTicketClosedEmail } = require('../services/gmail');
const { moveThreadToFolder } = require('../services/gmailLabels'); const { moveThreadToFolder } = require('../services/gmailLabels');
const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents'); const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents');
@@ -404,20 +404,8 @@ async function handleDeescalateButton(interaction, ticket) {
* posts to the transcript channel and optionally DMs the creator, sends the * posts to the transcript channel and optionally DMs the creator, sends the
* customer closure email (email tickets only), then deletes the channel. * customer closure email (email tickets only), then deletes the channel.
*/ */
// Run one best-effort close side-effect. A failure is logged but never propagates,
// so it cannot abort the close — the transition and channel delete still happen.
async function closeStep(label, fn) {
try {
await fn();
} catch (e) {
logError(`runFinalClose:${label}`, e).catch(() => {});
}
}
async function runFinalClose(interaction, ticket, sendEmail = true) { async function runFinalClose(interaction, ticket, sendEmail = true) {
const closedAt = new Date(); const closedAt = new Date();
const channel = interaction.channel;
const channelName = channel.name;
try { try {
await interaction.update({ content: 'Archiving and closing...', components: [] }); await interaction.update({ content: 'Archiving and closing...', components: [] });
@@ -426,63 +414,47 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {}); await interaction.editReply({ content: 'Archiving and closing...', components: [] }).catch(() => {});
} }
// Build the transcript up front — it needs the channel's history, before delete. try {
// Best-effort: a failure here must not block the close. const channelName = interaction.channel.name;
let transcriptText = null; const transcriptText = await buildTranscriptText(interaction.channel, ticket);
await closeStep('buildTranscript', async () => { transcriptText = await buildTranscriptText(channel, ticket); }); const file = new AttachmentBuilder(Buffer.from(transcriptText), {
name: `transcript-${channelName}.txt`
// CRITICAL #1 — commit the close and mark pendingDelete (discordThreadId stays
// set for restart recovery). Done BEFORE the fallible side-effects below so none
// of them can leave a "closed"-looking but still-open, undeleted ticket.
let transitioned = false;
let closedTicket = null;
await closeStep('transition', async () => {
({ transitioned, ticket: closedTicket } =
await attemptCloseTransition(ticket.gmailThreadId, { pendingDelete: true }, { welcomeMessageId: '' }));
}); });
// Customer-facing close notice (best-effort).
await closeStep('closeMessage', () => enqueueSend(channel, CONFIG.DISCORD_CLOSE_MESSAGE));
// Archive the transcript to the transcript channel (best-effort — a Missing
// Access here previously aborted the whole close).
let transcriptMsg = null;
if (transcriptText != null) {
const openedStr = formatDateForTranscript(ticket.createdAt); const openedStr = formatDateForTranscript(ticket.createdAt);
const closedStr = formatDateForTranscript(closedAt); const closedStr = formatDateForTranscript(closedAt);
await closeStep('transcriptArchive', async () => {
const transcriptContent = renderTranscriptHeader(channelName, ticket.senderEmail, openedStr, closedStr); const transcriptContent = renderTranscriptHeader(channelName, ticket.senderEmail, openedStr, closedStr);
const file = new AttachmentBuilder(Buffer.from(transcriptText), { name: `transcript-${channelName}.txt` });
const transcriptChan = await interaction.client.channels.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID).catch(() => null); await enqueueSend(interaction.channel, CONFIG.DISCORD_CLOSE_MESSAGE);
let transcriptMsg = null;
const transcriptChan = await interaction.client.channels
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
.catch(() => null);
if (transcriptChan) { if (transcriptChan) {
transcriptMsg = await enqueueSend(transcriptChan, { transcriptMsg = await enqueueSend(transcriptChan, {
content: transcriptContent, files: [file], allowedMentions: { parse: [] } content: transcriptContent,
files: [file],
allowedMentions: { parse: [] }
}); });
} }
});
// DM the transcript to the creator (Discord-origin tickets only). Best-effort — // Optionally DM the transcript to the ticket creator. Many users have
// many users have member DMs disabled (50007). // server-member DMs disabled; gated to avoid 50007 noise. Discord-origin
// tickets only.
if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) { if (CONFIG.TRANSCRIPT_DM_TO_CREATOR && ticket.gmailThreadId?.startsWith('discord-')) {
await closeStep('dmCreator', () => await dmTranscriptToCreator(interaction.client, ticket, channelName, transcriptText, openedStr, closedStr);
dmTranscriptToCreator(interaction.client, ticket, channelName, transcriptText, openedStr, closedStr));
}
} }
await closeStep('closeLog', () => postCloseLogEntry(interaction, ticket, channelName)); await postCloseLogEntry(interaction, ticket, channelName);
if (sendEmail && !ticket.gmailThreadId?.startsWith('discord-')) {
const closerDisplayName = interaction.member?.displayName || interaction.user.username; const closerDisplayName = interaction.member?.displayName || interaction.user.username;
await closeStep('closeEmail', () => sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id)); if (sendEmail && !ticket.gmailThreadId?.startsWith('discord-')) {
await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id);
} }
// File the email thread into the Resolved folder — non-fatal, email tickets only. const { transitioned, ticket: closedTicket } = await attemptCloseTransition(ticket.gmailThreadId, { discordThreadId: null }, { welcomeMessageId: '' });
if (!ticket.gmailThreadId?.startsWith('discord-')) { if (transitioned) {
moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED')
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
}
if (transitioned && closedTicket) {
const closerType = isStaff(interaction.member) ? 'staff' : 'user'; const closerType = isStaff(interaction.member) ? 'staff' : 'user';
recordAction(interaction.user.id, 'close', { recordAction(interaction.user.id, 'close', {
ticket: closedTicket, ticket: closedTicket,
@@ -493,28 +465,34 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
}); });
} }
// File the email thread into the Resolved folder — non-fatal, email tickets only.
if (!ticket.gmailThreadId?.startsWith('discord-')) {
moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED')
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
}
if (transcriptMsg?.id) { if (transcriptMsg?.id) {
await closeStep('transcriptRecord', () => Transcript.create({ await Transcript.create({
gmailThreadId: ticket.gmailThreadId, gmailThreadId: ticket.gmailThreadId,
transcriptMessageId: transcriptMsg.id, transcriptMessageId: transcriptMsg.id,
createdAt: new Date() createdAt: new Date()
})); });
} }
// CRITICAL #2 — schedule the channel delete. Always runs, regardless of any
// side-effect failure above.
scheduleTicketChannelDelete(channel, ticket.gmailThreadId);
// Best-effort overflow-category cleanup after the channel is gone.
const parentCatId = ticket.parentCategoryId; const parentCatId = ticket.parentCategoryId;
const guildRef = interaction.guild; const guildRef = interaction.guild;
// Lazy require — same cycle reason as in handleConfirmCloseRequest above. // Lazy require — same cycle reason as in handleConfirmCloseRequest above.
const { trackTimeout } = require('../broccolini-discord'); const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => interaction.channel.delete().catch(() => {}), 5000));
trackTimeout(setTimeout(() => { trackTimeout(setTimeout(() => {
if (parentCatId && guildRef) { if (parentCatId && guildRef) {
cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {}); cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME).catch(() => {});
} }
}, 6000)); }, 6000));
} catch (e) {
console.error('Close ticket error:', e);
}
} }
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) { async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {

View File

@@ -15,7 +15,7 @@ const { logTicketEvent, logError } = require('../../services/debugLog');
const { moveThreadToFolder } = require('../../services/gmailLabels'); const { moveThreadToFolder } = require('../../services/gmailLabels');
const { pendingCloses } = require('../pendingCloses'); const { pendingCloses } = require('../pendingCloses');
const { findTicketForChannel } = require('../sharedHelpers'); const { findTicketForChannel } = require('../sharedHelpers');
const { attemptCloseTransition, scheduleTicketChannelDelete } = require('../../services/tickets'); const { attemptCloseTransition } = require('../../services/tickets');
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript'); const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript');
const { recordAction } = require('../../services/staffStats'); const { recordAction } = require('../../services/staffStats');
@@ -75,9 +75,7 @@ async function finalizeForceClose(channelRef, clientRef, _TicketModel, _recordAc
if (!freshTicket || freshTicket.status === 'closed') return; if (!freshTicket || freshTicket.status === 'closed') return;
try { try {
// pendingDelete (with discordThreadId left set) lets resumePendingDeletes() const { transitioned, ticket: closedTicket } = await attemptCloseTransition(freshTicket.gmailThreadId, {}, { welcomeMessageId: '' }, T);
// recover the channel delete if a restart interrupts the grace window.
const { transitioned, ticket: closedTicket } = await attemptCloseTransition(freshTicket.gmailThreadId, { pendingDelete: true }, { welcomeMessageId: '' }, T);
if (transitioned) { if (transitioned) {
record(closerId ?? 'system', 'close', { record(closerId ?? 'system', 'close', {
ticket: closedTicket, ticket: closedTicket,
@@ -94,15 +92,16 @@ async function finalizeForceClose(channelRef, clientRef, _TicketModel, _recordAc
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {})); .catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
} }
// Both best-effort — a failure here must not skip the channel delete below. await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...').catch(() => {});
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr => await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
console.error('Transcript error (force-close):', tErr) console.error('Transcript error (force-close):', tErr)
); );
// Queue-routed, pendingDelete-guarded delete (shared with auto-close + button setTimeout(() => {
// close) so a mid-close restart can't orphan the channel. channelRef.delete('Ticket force-closed').catch(e =>
scheduleTicketChannelDelete(channelRef, freshTicket.gmailThreadId); console.error('Failed to delete channel:', e)
);
}, 5000);
} catch (err) { } catch (err) {
console.error('Force close error:', err); console.error('Force close error:', err);
} }

View File

@@ -1,59 +0,0 @@
/**
* /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 };

View File

@@ -32,7 +32,6 @@ const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolv
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close'); const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
const { handleResponse, handleAutocomplete: handleResponseAutocomplete } = require('./response'); const { handleResponse, handleAutocomplete: handleResponseAutocomplete } = 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 { handleStats, handleStatsAutocomplete } = require('./stats'); const { handleStats, handleStatsAutocomplete } = require('./stats');
@@ -377,7 +376,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\n`/forward <email> [note]` - Forward this ticket\'s email thread to another address' 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'
}, },
{ {
name: 'Saved Responses', name: 'Saved Responses',
@@ -427,7 +426,6 @@ 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,

View File

@@ -5,7 +5,6 @@ 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');
const { recordAction } = require('../services/staffStats'); const { recordAction } = require('../services/staffStats');
@@ -110,12 +109,6 @@ async function handleDiscordReply(m, _TicketModel, _recordAction, _isStaff) {
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);
} }

View File

@@ -3,7 +3,7 @@
*/ */
const { google } = require('googleapis'); const { google } = require('googleapis');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { extractRawEmail, escapeHtml, getCleanBody } = require('../utils'); const { extractRawEmail, escapeHtml } = 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,10 +49,8 @@ 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 an occasional re-auth (the * Used by the internal /gmail/reload endpoint so the weekly reauth chore does
* OAuth app is published, so the token is long-lived — re-auth is only needed * not require a full container restart.
* 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.
@@ -367,241 +365,10 @@ async function sendGmailReply(threadId, replyText, recipientEmail, subject, mess
}); });
} }
// Derive a name for an attachment part that has none — typically an embedded
// screenshot carried inline by Content-ID rather than as a named attachment.
// Uses the mime subtype as the extension so the file still opens correctly.
function synthAttachmentName(part, n) {
const subtype = String(part.mimeType || '').split('/')[1] || '';
const ext = (subtype.split(';')[0].replace(/[^a-z0-9]+/gi, '') || 'bin').toLowerCase();
const isImage = /^image\//i.test(part.mimeType || '');
return `${isImage ? 'screenshot' : 'attachment'}-${n}.${ext}`;
}
// Recursively collect downloadable parts (those backed by an attachmentId) from
// a Gmail message payload, at any nesting depth. Named parts are taken as-is;
// nameless non-text parts — embedded/inline screenshots referenced only by
// Content-ID — are kept with a synthesized name. Nameless text/* parts are
// skipped: Gmail serves a large email *body* as an attachmentId-backed text/html
// part with no filename, and that is the message, not an attachment.
function collectAttachmentParts(payload) {
const out = [];
const walk = part => {
if (!part) return;
const isText = /^text\//i.test(part.mimeType || '');
if (part.body?.attachmentId && (part.filename || !isText)) {
out.push({
filename: part.filename || synthAttachmentName(part, out.length + 1),
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;
}
// Discord's default per-message upload ceiling is 25 MB for any guild (boosting
// raises it, but 25 MB is the universal floor). Parts above this are skipped
// rather than risking a failed send. Discord also caps a single message at 10
// files. Both are conservative so a normal customer attachment always lands.
const DISCORD_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
const DISCORD_MAX_FILES_PER_MESSAGE = 10;
// Strip CR/LF and surrounding whitespace from an attachment filename so it is
// safe to use as a Discord file name and inside a backticked status line.
function sanitizeAttachmentName(name) {
return String(name || '').replace(/[\r\n`]+/g, ' ').trim() || 'attachment';
}
/**
* Fetch a single Gmail message's downloadable attachments as discord.js file
* descriptors ({ name, attachment: Buffer }). Skips parts over Discord's size
* ceiling and caps at 10 files. Best-effort: an individual fetch failure is
* recorded in `skipped`, never thrown — attachment delivery must not break the
* ticket flow.
*
* @param {string} messageId - Gmail message id (email.data.id)
* @param {object} payload - email.data.payload
* @param {object} gmail - authenticated gmail client (getGmailClient())
* @returns {Promise<{ files: Array<{name: string, attachment: Buffer}>, skipped: string[] }>}
*/
async function fetchMessageAttachments(messageId, payload, gmail) {
const parts = collectAttachmentParts(payload);
const files = [];
const skipped = [];
for (const att of parts) {
const name = sanitizeAttachmentName(att.filename);
if (files.length >= DISCORD_MAX_FILES_PER_MESSAGE || (att.size || 0) > DISCORD_ATTACHMENT_MAX_BYTES) {
skipped.push(name);
continue;
}
try {
const res = await gmail.users.messages.attachments.get({
userId: 'me', messageId, id: att.attachmentId
});
const std = (res.data.data || '').replace(/-/g, '+').replace(/_/g, '/');
const buf = Buffer.from(std, 'base64');
if (buf.length > DISCORD_ATTACHMENT_MAX_BYTES) {
skipped.push(name);
continue;
}
files.push({ name, attachment: buf });
} catch (_) {
skipped.push(name);
}
}
return { files, skipped };
}
// 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,
collectAttachmentParts,
fetchMessageAttachments
}; };

View File

@@ -19,8 +19,6 @@ 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' },
@@ -32,12 +30,6 @@ 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'];
@@ -147,60 +139,14 @@ 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()
}; };

View File

@@ -293,32 +293,6 @@ async function attemptCloseTransition(gmailThreadId, extraSet = {}, extraUnset =
return { transitioned, ticket }; return { transitioned, ticket };
} }
/**
* Schedule the final ticket-channel delete after a short grace period (so staff
* read the close message first), routed through the channel queue.
*
* The delete is guarded by the `pendingDelete` flag: the caller MUST have already
* set `pendingDelete: true` on the ticket AND left `discordThreadId` populated, so
* that a restart during the grace window is recovered on boot by
* resumePendingDeletes() (which re-fetches the channel and deletes it). The flag
* is cleared once enqueueDelete resolves; if the doc is gone the unset is a no-op.
*
* Shared by all three close paths (auto-close, button, slash) so they behave
* identically and none can orphan a channel on a mid-close restart.
*/
function scheduleTicketChannelDelete(channel, gmailThreadId, delayMs = 5000) {
// Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe at call time.
const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => {
enqueueDelete(channel).then(() => {
withRetry(() => Ticket.updateOne(
{ gmailThreadId },
{ $unset: { pendingDelete: '' } }
)).catch(() => {});
}).catch(() => {});
}, delayMs));
}
// --- SCHEDULED CHECKS --- // --- SCHEDULED CHECKS ---
// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps. // These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps.
@@ -366,7 +340,16 @@ async function checkAutoClose(client, sendTicketClosedEmail, _TicketModel, _reco
if (_deps && _deps.scheduleDelete) { if (_deps && _deps.scheduleDelete) {
_deps.scheduleDelete(channel, ticket); _deps.scheduleDelete(channel, ticket);
} else { } else {
scheduleTicketChannelDelete(channel, ticket.gmailThreadId); // Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe.
const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => {
enqueueDelete(channel).then(() => {
withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } }
)).catch(() => {});
}).catch(() => {});
}, 5000));
} }
} }
} catch (error) { } catch (error) {
@@ -484,7 +467,6 @@ module.exports = {
checkTicketCreationRateLimit, checkTicketCreationRateLimit,
checkTicketLimits, checkTicketLimits,
attemptCloseTransition, attemptCloseTransition,
scheduleTicketChannelDelete,
checkAutoClose, checkAutoClose,
checkAutoUnclaim, checkAutoUnclaim,
reconcileDeletedTicketChannels, reconcileDeletedTicketChannels,

View File

@@ -1,135 +0,0 @@
/**
* fetchMessageAttachments — Gmail attachment → discord.js file descriptor tests.
*
* Uses a fake gmail client (no module mocking) so we exercise the real
* collectAttachmentParts walk, the base64url→Buffer decode, the size ceiling,
* the 10-file cap, and the best-effort skip-on-failure behavior.
*/
import { describe, it, expect, vi } from 'vitest';
import { collectAttachmentParts, fetchMessageAttachments } from '../services/gmail.js';
// Build a payload part carrying a real attachment (filename + attachmentId).
function attPart(filename, attachmentId, size, mimeType = 'image/png') {
return { filename, mimeType, body: { attachmentId, size } };
}
// Fake gmail whose attachments.get returns base64url of the given string per id.
function fakeGmail(dataById) {
return {
users: {
messages: {
attachments: {
get: vi.fn(async ({ id }) => {
if (!(id in dataById)) throw new Error('not found');
const b64url = Buffer.from(dataById[id]).toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return { data: { data: b64url } };
})
}
}
}
};
}
describe('collectAttachmentParts', () => {
it('finds attachment parts at any nesting depth, skips inline text', () => {
const payload = {
parts: [
{ mimeType: 'text/plain', body: { data: 'aGk=' } },
{ mimeType: 'multipart/mixed', parts: [attPart('log.txt', 'A1', 12)] }
]
};
const parts = collectAttachmentParts(payload);
expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({ filename: 'log.txt', attachmentId: 'A1', size: 12 });
});
it('carries over an embedded screenshot that has no filename', () => {
const payload = {
parts: [
{ mimeType: 'image/png', body: { attachmentId: 'CID1', size: 40000 } }
]
};
const parts = collectAttachmentParts(payload);
expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({ filename: 'screenshot-1.png', attachmentId: 'CID1' });
});
it('skips a nameless text/html body served as an attachmentId part', () => {
const payload = {
parts: [
{ mimeType: 'text/html', body: { attachmentId: 'BODY', size: 200000 } }
]
};
expect(collectAttachmentParts(payload)).toEqual([]);
});
it('names a nameless non-image attachment "attachment-N"', () => {
const payload = {
parts: [
{ mimeType: 'application/pdf', body: { attachmentId: 'P1', size: 1000 } }
]
};
expect(collectAttachmentParts(payload)[0].filename).toBe('attachment-1.pdf');
});
});
describe('fetchMessageAttachments', () => {
it('decodes base64url attachment data into Buffers', async () => {
const payload = { parts: [attPart('hello.txt', 'A1', 5)] };
const gmail = fakeGmail({ A1: 'hello' });
const { files, skipped } = await fetchMessageAttachments('msg1', payload, gmail);
expect(skipped).toEqual([]);
expect(files).toHaveLength(1);
expect(files[0].name).toBe('hello.txt');
expect(Buffer.isBuffer(files[0].attachment)).toBe(true);
expect(files[0].attachment.toString()).toBe('hello');
});
it('skips parts over the 25 MB ceiling without fetching them', async () => {
const payload = { parts: [attPart('huge.bin', 'BIG', 26 * 1024 * 1024)] };
const gmail = fakeGmail({ BIG: 'x' });
const { files, skipped } = await fetchMessageAttachments('msg1', payload, gmail);
expect(files).toEqual([]);
expect(skipped).toEqual(['huge.bin']);
expect(gmail.users.messages.attachments.get).not.toHaveBeenCalled();
});
it('records a failed download as skipped, keeps the rest', async () => {
const payload = { parts: [attPart('ok.txt', 'A1', 2), attPart('gone.txt', 'MISSING', 2)] };
const gmail = fakeGmail({ A1: 'ok' });
const { files, skipped } = await fetchMessageAttachments('msg1', payload, gmail);
expect(files.map(f => f.name)).toEqual(['ok.txt']);
expect(skipped).toEqual(['gone.txt']);
});
it('caps at 10 files, skipping the overflow', async () => {
const data = {};
const parts = [];
for (let i = 0; i < 12; i++) {
const id = `A${i}`;
data[id] = `f${i}`;
parts.push(attPart(`file${i}.txt`, id, 2));
}
const { files, skipped } = await fetchMessageAttachments('msg1', { parts }, fakeGmail(data));
expect(files).toHaveLength(10);
expect(skipped).toHaveLength(2);
});
it('sanitizes CRLF and backticks out of filenames', async () => {
const payload = { parts: [attPart('bad\nname`.txt', 'A1', 2)] };
const gmail = fakeGmail({ A1: 'hi' });
const { files } = await fetchMessageAttachments('msg1', payload, gmail);
expect(files[0].name).toBe('bad name .txt');
});
});

View File

@@ -4,9 +4,6 @@ import {
resolveLabelId, resolveLabelId,
moveThreadToFolder, moveThreadToFolder,
folderDisplayName, folderDisplayName,
getManagedFolderKey,
autoAdvanceFolder,
MANAGED_USER_KEYS,
__clearLabelCache __clearLabelCache
} from '../services/gmailLabels.js'; } from '../services/gmailLabels.js';
@@ -22,11 +19,8 @@ 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' }
@@ -156,76 +150,3 @@ 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();
});
});