Compare commits
1 Commits
main
...
feat-staff
| Author | SHA1 | Date | |
|---|---|---|---|
| cdb5db0082 |
@@ -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
3
.gitignore
vendored
@@ -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*
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user