/** * 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} messageId - Message ID (optional) * @param {string} userId - Discord user ID for optional personal valediction/tagline (optional) */ async function sendGmailReply( threadId, replyText, recipientEmail, subject, 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 safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); 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}

` : ''}
${safeLogoUrl ? `Indifferent Broccoli
` : ''} 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('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 };