diff --git a/.gitignore b/.gitignore index 00b41bc..3c809a1 100644 --- a/.gitignore +++ b/.gitignore @@ -49,9 +49,6 @@ cursor.yml *.local.yml .claude/ -*.bak -*.bak-* -*.bak -*.bak-* CLAUDE.md +*.bak* diff --git a/handlers/messages.js.bak3-20260421 b/handlers/messages.js.bak3-20260421 deleted file mode 100644 index 0bd7666..0000000 --- a/handlers/messages.js.bak3-20260421 +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Discord messageCreate handler – forwards staff replies to Gmail. - */ -const { mongoose } = require('../db-connection'); -const { CONFIG } = require('../config'); -const { extractRawEmail } = require('../utils'); -const { getGmailClient, sendGmailReply } = require('../services/gmail'); -const { updateTicketActivity } = require('../services/tickets'); -const { getNotifyDm } = require('../services/staffSettings'); - -const Ticket = mongoose.model('Ticket'); - -/** - * Handle a Discord message in a ticket channel → relay to Gmail (email tickets only). - */ -async function handleDiscordReply(m) { - if (m.author.bot || m.interaction) return; - - const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); - if (!ticket) return; - - // Track whether last message is from staff or customer - const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null); - const isStaffMember = memberForCheck && CONFIG.ROLE_ID_TO_PING && memberForCheck.roles.cache.has(CONFIG.ROLE_ID_TO_PING); - Ticket.updateOne( - { discordThreadId: m.channel.id }, - { $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } } - ).catch(() => {}); - - // DM the claimer if they have notifydm on and a non-staff user replied. - if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) { - const dmEnabled = await getNotifyDm(ticket.claimerId); - if (dmEnabled) { - const staffMember = await m.guild.members.fetch(ticket.claimerId).catch(() => null); - if (staffMember) { - const jumpLink = `https://discord.com/channels/${m.guild.id}/${m.channel.id}/${m.id}`; - await staffMember - .send( - `New customer reply in **${m.channel.name}**:\n> ${m.content.slice(0, 300)}\n[Jump to message](${jumpLink})` - ) - .catch(() => {}); - } - } - } - - const authorName = - m.member?.displayName || - m.member?.nickname || - m.author.globalName || - m.author.username; - - if (ticket.gmailThreadId.startsWith('discord-')) { - return; - } - - // Email tickets: send reply via Gmail. - try { - const gmail = getGmailClient(); - const thread = await gmail.users.threads.get({ - userId: 'me', - id: ticket.gmailThreadId - }); - - const last = [...thread.data.messages].reverse().find(msg => { - const from = - msg.payload.headers.find(h => h.name === 'From')?.value || ''; - return !from.toLowerCase().includes(CONFIG.MY_EMAIL); - }); - - if (!last) return; - - let recipient = - last.payload.headers.find(h => h.name === 'From')?.value || ''; - const replyTo = - last.payload.headers.find(h => h.name === 'Reply-To')?.value; - if (replyTo) recipient = replyTo; - - const subject = - last.payload.headers.find(h => h.name === 'Subject')?.value || - 'Support'; - const msgId = - last.payload.headers.find(h => h.name === 'Message-ID')?.value; - - const recipientEmail = extractRawEmail(recipient).toLowerCase(); - if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) { - console.warn('Bad recipient for reply:', recipientEmail); - return; - } - - await sendGmailReply( - ticket.gmailThreadId, - m.content, - recipientEmail, - subject, - authorName, - msgId, - m.author.id - ); - - await updateTicketActivity(ticket.gmailThreadId); - } catch (e) { - console.error('REPLY ERROR:', e); - } -} - -module.exports = { handleDiscordReply }; diff --git a/services/gmail.js.bak3-20260421 b/services/gmail.js.bak3-20260421 deleted file mode 100644 index ee41a33..0000000 --- a/services/gmail.js.bak3-20260421 +++ /dev/null @@ -1,346 +0,0 @@ -/** - * Gmail service – OAuth client, send reply, send ticket-closed email. - */ -const { google } = require('googleapis'); -const { CONFIG } = require('../config'); -const { extractRawEmail, escapeHtml } = require('../utils'); -const { getStaffSignatureBlocks } = require('./staffSignature'); -const { logError } = require('./debugLog'); -const { readEnvFile } = require('./configPersistence'); - -function sanitizeHeaderValue(v) { return String(v || '').replace(/[\r\n]+/g, ' ').trim(); } -const EMAIL_RE = /^[^@\s]+@[^@\s]+$/; - -function getGmailClient() { - const auth = new google.auth.OAuth2( - process.env.GOOGLE_CLIENT_ID, - process.env.GOOGLE_CLIENT_SECRET - ); - auth.setCredentials({ refresh_token: CONFIG.REFRESH_TOKEN }); - return google.gmail({ version: 'v1', auth }); -} - -/** - * Re-read REFRESH_TOKEN from .env, update in-memory config, and probe Google. - * Used by the internal /gmail/reload endpoint so the weekly reauth chore does - * not require a full container restart. - * - * 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. - * - * @returns {Promise<{emailAddress: string}>} - */ -async function reloadGmailClient() { - const envMap = readEnvFile(); - const newToken = envMap.get('REFRESH_TOKEN'); - if (!newToken) { - const err = new Error('REFRESH_TOKEN not set in .env'); - err.code = 'ENOTOKEN'; - throw err; - } - process.env.REFRESH_TOKEN = newToken; - CONFIG.REFRESH_TOKEN = newToken; - const gmail = getGmailClient(); - const profile = await gmail.users.getProfile({ userId: 'me' }); - return { emailAddress: profile.data.emailAddress }; -} - -async function sendTicketClosedEmail(ticket, discordDisplayName) { - try { - const gmail = getGmailClient(); - - // Send to the ticket sender (customer), not derived from thread (which can be support) - const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase(); - if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return; - if (!EMAIL_RE.test(recipientEmail)) { - logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {}); - return; - } - - let subjectHeader = ticket.subject || 'Support'; - let msgId = null; - try { - const thread = await gmail.users.threads.get({ - userId: 'me', - 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); - } - } catch (_) { - /* use ticket.subject and no In-Reply-To if thread fetch fails */ - } - - const finalSubject = sanitizeHeaderValue(`${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`); - const utf8Subject = `=?utf-8?B?${Buffer.from( - finalSubject - ).toString('base64')}?=`; - - const serverDisplayName = escapeHtml(discordDisplayName || CONFIG.SUPPORT_NAME || 'Support'); - const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); - const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); - const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '
'); - const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '
'); - const htmlBody = ` -
-

From: ${serverDisplayName} on Discord

-

Message:

-

${safeCloseMessage}

-

${safeCloseSignature}

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

${serverDisplayName}

-
${safeSignature}
-
-
`; - - const rawHeaders = [ - `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, - `To: ${recipientEmail}`, - `Subject: ${utf8Subject}`, - msgId ? `In-Reply-To: ${msgId}` : '', - msgId ? `References: ${msgId}` : '', - 'MIME-Version: 1.0', - 'Content-Type: text/html; charset="UTF-8"', - '', - htmlBody - ].filter(Boolean); - - const raw = Buffer.from(rawHeaders.join('\r\n')) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - - await gmail.users.messages.send({ - userId: 'me', - requestBody: { raw, threadId: ticket.gmailThreadId } - }); - } catch (err) { - console.error('Ticket closed email error:', err); - } -} - -// StaffSignature model is registered in models.js; re-import here for use in this file -const { mongoose } = require('../db-connection'); -const StaffSignature = mongoose.model('StaffSignature'); - -/** - * Send a notification email in the ticket thread (e.g. escalation, high-priority). - * @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject - * @param {string} subjectLine - Subject line (e.g. "Ticket escalated" or "Priority updated") - * @param {string} messageBody - Plain or HTML message body - * @param {string} [fromLabel] - Label for "From" (e.g. "Support on Discord") - * @param {string} [userId] - Discord user ID for signature (optional) - */ -async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel, userId = null) { - try { - const gmail = getGmailClient(); - const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase(); - if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return; - if (!EMAIL_RE.test(recipientEmail)) { - logError('sendTicketNotificationEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {}); - return; - } - - let subjectHeader = ticket.subject || 'Support'; - let msgId = null; - try { - const thread = await gmail.users.threads.get({ - userId: 'me', - 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); - } - } 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 - 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 = ` -
-

From: ${serverDisplayName} on Discord

-

Message:

-

${safeCloseMessage}

-

${safeCloseSignature}

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

${serverDisplayName}

-
${safeSignature}
-
-
`; - - const rawHeaders = [ - `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, - `To: ${recipientEmail}`, - `Subject: ${utf8Subject}`, - msgId ? `In-Reply-To: ${msgId}` : '', - msgId ? `References: ${msgId}` : '', - 'MIME-Version: 1.0', - 'Content-Type: text/html; charset="UTF-8"', - '', - htmlBody - ].filter(Boolean); - - const raw = Buffer.from(rawHeaders.join('\r\n')) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - - await gmail.users.messages.send({ - userId: 'me', - requestBody: { raw, threadId: ticket.gmailThreadId } - }); - } catch (err) { - console.error('Ticket notification email error:', err); - } -} - -/** - * Send a Gmail reply to a ticket - * @param {string} threadId - Gmail thread ID - * @param {string} replyText - Reply text - * @param {string} recipientEmail - Recipient email - * @param {string} subject - Subject line - * @param {string} authorName - Replier's Discord server display name (caller resolves from member.displayName) - * @param {string} messageId - Message ID (optional) - * @param {string} userId - Discord user ID for optional personal valediction/tagline (optional) - */ -async function sendGmailReply( - threadId, - replyText, - recipientEmail, - subject, - authorName, - messageId, - userId = null -) { - const gmail = getGmailClient(); - - const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase(); - if (!EMAIL_RE.test(safeRecipient)) { - logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {}); - return null; - } - const safeMessageId = sanitizeHeaderValue(messageId); - const safeSubject = sanitizeHeaderValue(`Re: ${subject}`); - - const utf8Subject = `=?utf-8?B?${Buffer.from( - safeSubject - ).toString('base64')}?=`; - const safeAuthor = escapeHtml(authorName || ''); - - let signatureBlocks = { text: '', html: '' }; - if (userId) { - signatureBlocks = await getStaffSignatureBlocks(userId); - } - // signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here. - const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '
') : ''; - const safeStaffSigText = signatureBlocks.text; - - const htmlBody = ` -
-

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

- ${safeStaffSigHtml ? `

${safeStaffSigHtml}

` : ''} -
- ${safeAuthor}
- Indifferent Broccoli Support
- https://indifferentbroccoli.com/
- Join us on Discord
-
- "We eat lag for breakfast. Whatever." -
-
`; - - const boundary = '000000000000' + Date.now().toString(16); - - const plainBody = []; - plainBody.push(replyText); - if (safeStaffSigText) { - plainBody.push(''); - plainBody.push(safeStaffSigText); - } - plainBody.push(''); - plainBody.push(authorName || ''); - 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."'); - - const raw = Buffer.from([ - `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, - `To: ${safeRecipient}`, - `Subject: ${utf8Subject}`, - safeMessageId ? `In-Reply-To: ${safeMessageId}` : '', - safeMessageId ? `References: ${safeMessageId}` : '', - 'MIME-Version: 1.0', - 'Content-Type: multipart/alternative; boundary="' + boundary + '"', - '', - '--' + 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(/=+$/, ''); - - await gmail.users.messages.send({ - userId: 'me', - requestBody: { raw, threadId } - }); -} - -module.exports = { - getGmailClient, - reloadGmailClient, - sendGmailReply, - sendTicketClosedEmail, - sendTicketNotificationEmail -};