/** * Gmail service – OAuth client, send reply, send ticket-closed/notification emails. */ 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 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, 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. */ 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 }; } // Fetch the first message's Subject + Message-ID from a Gmail thread, used to // derive a faithful Re: subject and a proper In-Reply-To/References header. async function fetchThreadSubjectAndMsgId(gmail, threadId) { try { const thread = await gmail.users.threads.get({ userId: 'me', id: threadId }); const firstMsg = (thread.data.messages || [])[0]; const headers = firstMsg?.payload?.headers || []; return { subject: headers.find(h => h.name === 'Subject')?.value || null, msgId: sanitizeHeaderValue(headers.find(h => h.name === 'Message-ID')?.value) || null }; } catch (_) { return { subject: null, msgId: null }; } } // Strip leading "Re:" variants and re-prepend a single one, then RFC 2047 encode. function encodeReplySubject(baseSubject) { const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, ''); const safe = sanitizeHeaderValue(`Re: ${stripped}`); return `=?utf-8?B?${Buffer.from(safe).toString('base64')}?=`; } // Compose and send a multipart/alternative reply on an existing Gmail thread. async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId }) { const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' }; const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '
') : ''; const safeStaffSigText = sigBlocks.text; const htmlBody = `

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

${safeStaffSigHtml ? `

${safeStaffSigHtml}

` : ''} ${buildCompanySigHtml()}
`; const plainBody = [messageText || '']; if (safeStaffSigText) plainBody.push('', safeStaffSigText); plainBody.push('', ...buildCompanySigText().split('\n')); const boundary = '000000000000' + Date.now().toString(16); const headers = [ `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, `To: ${recipient}`, `Subject: ${encodedSubject}`, msgId && `In-Reply-To: ${msgId}`, msgId && `References: ${msgId}`, 'MIME-Version: 1.0', `Content-Type: multipart/alternative; boundary="${boundary}"` ].filter(Boolean); 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(/=+$/, ''); await gmail.users.messages.send({ userId: 'me', requestBody: { raw, threadId } }); } // Resolve and validate a customer recipient from a ticket's senderEmail. // Returns null and logs if invalid or self-addressed. function resolveCustomerRecipient(ticket, context) { const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase(); if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return null; if (!EMAIL_RE.test(recipientEmail)) { logError(`${context}: invalid recipient`, new Error(`Rejected: ${recipientEmail}`)).catch(() => {}); return null; } return recipientEmail; } async function sendTicketClosedEmail(ticket, closerName, userId = null) { try { const recipient = resolveCustomerRecipient(ticket, 'sendTicketClosedEmail'); if (!recipient) return; const gmail = getGmailClient(); const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId); const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support'); const messageText = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`; await sendThreadedEmail(gmail, { threadId: ticket.gmailThreadId, recipient, encodedSubject, msgId, messageText, userId }); } catch (err) { console.error('Ticket closed email error:', err); } } /** * Send a notification email in the ticket thread (e.g. escalation, high-priority). * @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject * @param {string} subjectLine - Fallback subject if the thread can't be queried * @param {string} messageBody - Plain or HTML message body * @param {string} [userId] - Discord user ID for signature (optional) */ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, userId = null) { try { const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail'); if (!recipient) return; const gmail = getGmailClient(); const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId); const encodedSubject = encodeReplySubject(subject || subjectLine || ticket.subject || 'Support'); await sendThreadedEmail(gmail, { threadId: ticket.gmailThreadId, recipient, encodedSubject, msgId, messageText: messageBody, userId }); } catch (err) { console.error('Ticket notification email error:', err); } } /** * Send a Gmail reply on an existing thread. Caller supplies subject + messageId * (typically pulled from the latest non-self message in the thread). */ async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null) { const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase(); if (!EMAIL_RE.test(safeRecipient)) { logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {}); return null; } const gmail = getGmailClient(); await sendThreadedEmail(gmail, { threadId, recipient: safeRecipient, encodedSubject: encodeReplySubject(subject || 'Support'), msgId: sanitizeHeaderValue(messageId) || null, messageText: replyText, userId }); } module.exports = { getGmailClient, reloadGmailClient, sendGmailReply, sendTicketClosedEmail, sendTicketNotificationEmail };