diff --git a/config.js b/config.js index 89ec59e..0c4467a 100644 --- a/config.js +++ b/config.js @@ -110,11 +110,7 @@ const CONFIG = { ADMIN_ID: process.env.ADMIN_ID || null, FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60), GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000, - GMAIL_LOG_CHANNEL_ID: process.env.GMAIL_LOG_CHANNEL_ID || null, - AUTOMATION_LOG_CHANNEL_ID: process.env.AUTOMATION_LOG_CHANNEL_ID || null, RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null, - SECURITY_LOG_CHANNEL_ID: process.env.SECURITY_LOG_CHANNEL_ID || null, - SYSTEM_LOG_CHANNEL_ID: process.env.SYSTEM_LOG_CHANNEL_ID || null, STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true', STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion', STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true', diff --git a/handlers/commands.js b/handlers/commands.js index 0b18279..f4c7425 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -123,12 +123,15 @@ async function runEscalation(interaction, ticket, nextTier, reason) { if (!isDiscordTicket && ticket.gmailThreadId) { try { - const emailBody = CONFIG.ESCALATION_MESSAGE.replace(/\\n/g, '\n').replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME); + const escalatorName = interaction.member?.displayName || interaction.user.username; + const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3'; + const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.${reason ? `\n\nReason: ${reason}` : ''}`; await sendTicketNotificationEmail( ticket, - `Ticket escalated to ${nextTier === 1 ? 'tier 2' : 'tier 3'}`, + null, emailBody, - interaction.member?.displayName || interaction.user.username + escalatorName, + interaction.user.id ); } catch (emailErr) { console.error('Escalation email failed (non-fatal):', emailErr.message); diff --git a/services/configSchema.js b/services/configSchema.js index 0d7d1cc..93e00a9 100644 --- a/services/configSchema.js +++ b/services/configSchema.js @@ -30,8 +30,7 @@ const ALLOWED_CONFIG_KEYS = new Set([ // Channel IDs 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', 'DISCORD_CHANNEL_ID', - 'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID', - 'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID', + 'RENAME_LOG_CHANNEL_ID', // Messages and labels 'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE', 'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE', diff --git a/services/debugLog.js b/services/debugLog.js index 4dba18c..3e3e686 100644 --- a/services/debugLog.js +++ b/services/debugLog.js @@ -81,51 +81,15 @@ async function logTicketEvent(action, fields, interaction = null) { // --- logGmail --- -async function logGmail(subject, sender, ticketNumber, game) { - const embed = new EmbedBuilder() - .setTitle('Email Ticket Created') - .setColor(0x00BFFF) - .addFields( - { name: 'Subject', value: String(subject || 'No subject').slice(0, 256), inline: false }, - { name: 'Sender', value: String(sender || 'unknown'), inline: true }, - { name: 'Ticket #', value: String(ticketNumber || '?'), inline: true }, - { name: 'Game', value: String(game || 'Not detected'), inline: true } - ) - .setTimestamp(); - await sendToChannel(CONFIG.GMAIL_LOG_CHANNEL_ID, embed); -} +async function logGmail(...args) { return; } // --- logAutomation --- -async function logAutomation(action, ticketChannelName, detail) { - const embed = new EmbedBuilder() - .setTitle(action) - .setColor(0x9B59B6) - .setTimestamp(); - if (ticketChannelName) { - embed.addFields({ name: 'Ticket', value: String(ticketChannelName), inline: true }); - } - if (detail) { - embed.addFields({ name: 'Detail', value: String(detail).slice(0, 1024), inline: false }); - } - await sendToChannel(CONFIG.AUTOMATION_LOG_CHANNEL_ID, embed); -} +async function logAutomation(...args) { return; } // --- logSecurity --- -async function logSecurity(action, user, detail, overrideClient = null, color = 0xFF6600) { - const embed = new EmbedBuilder() - .setTitle('Security Event') - .setColor(color) - .addFields( - { name: 'Action', value: String(action).slice(0, 256), inline: false }, - { name: 'User', value: user ? `${user.tag} (${user.id})` : 'Unknown', inline: true }, - { name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false }, - { name: 'Timestamp', value: new Date().toISOString(), inline: true } - ) - .setTimestamp(); - await sendToChannel(CONFIG.SECURITY_LOG_CHANNEL_ID, embed, overrideClient); -} +async function logSecurity(...args) { return; } // --- logIntegrity --- @@ -144,17 +108,7 @@ async function logIntegrity(issue, detail, overrideClient = null) { // --- logSystem --- -async function logSystem(message, fields = [], overrideClient = null, color = 0x0099ff) { - const embed = new EmbedBuilder() - .setTitle(message) - .setColor(color) - .setTimestamp(); - if (fields.length > 0) { - embed.addFields(fields.map(f => ({ name: f.name, value: String(f.value).slice(0, 1024), inline: f.inline ?? true }))); - } - embed.addFields({ name: 'Timestamp', value: new Date().toISOString(), inline: true }); - await sendToChannel(CONFIG.SYSTEM_LOG_CHANNEL_ID, embed, overrideClient); -} +async function logSystem(...args) { return; } module.exports = { setClient, diff --git a/services/gmail.js b/services/gmail.js index 064af23..0385528 100644 --- a/services/gmail.js +++ b/services/gmail.js @@ -11,6 +11,35 @@ const { readEnvFile } = require('./configPersistence'); function sanitizeHeaderValue(v) { return String(v || '').replace(/[\r\n]+/g, ' ').trim(); } const EMAIL_RE = /^[^@\s]+@[^@\s]+$/; +function buildCompanySigHtml() { + const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); + return ` + + + + + +
+ ${safeLogoUrl ? `Indifferent Broccoli` : ''} + + Indifferent Broccoli Support
+ https://indifferentbroccoli.com/
+ Join us on Discord
+
+ Host your own game server. Or not... we don't care. +
`; +} + +function buildCompanySigText() { + return [ + 'Indifferent Broccoli Support', + 'https://indifferentbroccoli.com/', + 'Join us on Discord: https://discord.gg/2vmfrrtvJY', + '', + "Host your own game server. Or not... we don't care." + ].join('\n'); +} + function getGmailClient() { const auth = new google.auth.OAuth2( process.env.GOOGLE_CLIENT_ID, @@ -154,7 +183,7 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro return; } - let subjectHeader = ticket.subject || 'Support'; + let originalSubject = null; let msgId = null; try { const thread = await gmail.users.threads.get({ @@ -162,67 +191,75 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro id: ticket.gmailThreadId }); const messages = thread.data.messages || []; - const lastMsg = [...messages].reverse()[0]; - if (lastMsg?.payload?.headers) { - const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value; - if (subj) subjectHeader = subj; - msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value); + const firstMsg = messages[0]; + if (firstMsg?.payload?.headers) { + const subj = firstMsg.payload.headers.find(h => h.name === 'Subject')?.value; + if (subj) originalSubject = subj; + msgId = sanitizeHeaderValue(firstMsg.payload.headers.find(h => h.name === 'Message-ID')?.value); } } catch (_) {} - const finalSubject = sanitizeHeaderValue(subjectLine || subjectHeader); - const utf8Subject = `=?utf-8?B?${Buffer.from(finalSubject).toString('base64')}?=`; - const label = escapeHtml(fromLabel || CONFIG.SUPPORT_NAME || 'Support'); - const safeBody = escapeHtml(messageBody || '').replace(/\n/g, '
'); - const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); - - // Get staff signature if userId provided + // Thread-safety: derive Subject from the last thread message; strip any leading + // Re:/RE:/Re : variants and re-prepend a single "Re: ". Fall back to subjectLine + // (legacy param) only if the thread lookup gave us nothing. + const baseSubject = originalSubject || subjectLine || ticket.subject || 'Support'; + const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, ''); + const safeSubject = sanitizeHeaderValue(`Re: ${stripped}`); + const utf8Subject = `=?utf-8?B?${Buffer.from(safeSubject).toString('base64')}?=`; + let signatureBlocks = { text: '', html: '' }; if (userId) { signatureBlocks = await getStaffSignatureBlocks(userId); } - - const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); - const serverDisplayName = label; - const safeCloseMessage = safeBody; - const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '
'); - const htmlBody = ` + // signatureBlocks.html arrives pre-escaped from getStaffSignatureBlocks. + const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '
') : ''; + const safeStaffSigText = signatureBlocks.text; + + const htmlBody = `
-

From: ${serverDisplayName} on Discord

-

Message:

-

${safeCloseMessage}

-

${safeCloseSignature}

-
- - - - - -
- ${safeLogoUrl ? `` : ''} - -

${serverDisplayName}

-
${safeSignature}
-
+

${escapeHtml(messageBody || '').replace(/\n/g, '
')}

+ ${safeStaffSigHtml ? `

${safeStaffSigHtml}

` : ''} + ${buildCompanySigHtml()}
`; - const rawHeaders = [ + const boundary = '000000000000' + Date.now().toString(16); + + const plainBody = []; + plainBody.push(messageBody || ''); + if (safeStaffSigText) { + plainBody.push(''); + plainBody.push(safeStaffSigText); + } + plainBody.push(''); + plainBody.push(...buildCompanySigText().split('\n')); + + const headers = [ `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, `To: ${recipientEmail}`, `Subject: ${utf8Subject}`, - msgId ? `In-Reply-To: ${msgId}` : '', - msgId ? `References: ${msgId}` : '', + msgId && `In-Reply-To: ${msgId}`, + msgId && `References: ${msgId}`, 'MIME-Version: 1.0', - 'Content-Type: text/html; charset="UTF-8"', - '', - htmlBody + `Content-Type: multipart/alternative; boundary="${boundary}"` ].filter(Boolean); - const raw = Buffer.from(rawHeaders.join('\r\n')) + const raw = Buffer.from([ + ...headers, + '', + `--${boundary}`, + 'Content-Type: text/plain; charset="UTF-8"', + '', + ...plainBody, + '', + `--${boundary}`, + 'Content-Type: text/html; charset="UTF-8"', + '', + htmlBody, + '', + `--${boundary}--` + ].join('\r\n')) .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); await gmail.users.messages.send({ userId: 'me', @@ -263,7 +300,6 @@ async function sendGmailReply( const utf8Subject = `=?utf-8?B?${Buffer.from( safeSubject ).toString('base64')}?=`; - const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); let signatureBlocks = { text: '', html: '' }; if (userId) { @@ -277,14 +313,7 @@ async function sendGmailReply(

${escapeHtml(replyText).replace(/\n/g, '
')}

${safeStaffSigHtml ? `

${safeStaffSigHtml}

` : ''} -
- ${safeLogoUrl ? `Indifferent Broccoli
` : ''} - Indifferent Broccoli Support
- https://indifferentbroccoli.com/
- Join us on Discord
-
- "We eat lag for breakfast. Whatever." -
+ ${buildCompanySigHtml()}
`; const boundary = '000000000000' + Date.now().toString(16); @@ -296,37 +325,35 @@ async function sendGmailReply( plainBody.push(safeStaffSigText); } plainBody.push(''); - plainBody.push('Indifferent Broccoli Support'); - plainBody.push('https://indifferentbroccoli.com/'); - plainBody.push('Join us on Discord: https://discord.gg/2vmfrrtvJY'); - plainBody.push(''); - plainBody.push('"We eat lag for breakfast. Whatever."'); + plainBody.push(...buildCompanySigText().split('\n')); - const raw = Buffer.from([ + const headers = [ `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, `To: ${safeRecipient}`, `Subject: ${utf8Subject}`, - safeMessageId ? `In-Reply-To: ${safeMessageId}` : '', - safeMessageId ? `References: ${safeMessageId}` : '', + safeMessageId && `In-Reply-To: ${safeMessageId}`, + safeMessageId && `References: ${safeMessageId}`, 'MIME-Version: 1.0', - 'Content-Type: multipart/alternative; boundary="' + boundary + '"', + `Content-Type: multipart/alternative; boundary="${boundary}"` + ].filter(Boolean); + + const raw = Buffer.from([ + ...headers, '', - '--' + boundary, + `--${boundary}`, 'Content-Type: text/plain; charset="UTF-8"', '', ...plainBody, '', - '--' + boundary, + `--${boundary}`, 'Content-Type: text/html; charset="UTF-8"', '', htmlBody, '', - '--' + boundary + '--' + `--${boundary}--` ].join('\r\n')) .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); await gmail.users.messages.send({ userId: 'me',