/** * Gmail service – OAuth client, send reply, send ticket-closed/notification emails. */ const { google } = require('googleapis'); const { CONFIG } = require('../config'); const { extractRawEmail, escapeHtml, getCleanBody } = 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 an occasional re-auth (the * OAuth app is published, so the token is long-lived — re-auth is only needed * 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) * 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. // Build the "On , wrote:" attribution line for a quoted reply. function formatQuoteAttribution(quote) { const who = (quote.from || '').trim() || 'the sender'; const when = (quote.date || '').trim(); return when ? `On ${when}, ${who} wrote:` : `${who} wrote:`; } // Plain-text quoted block: attribution + each original line prefixed with "> ". // Returns null when there is nothing to quote. function buildQuoteText(quote) { if (!quote || !(quote.body || '').trim()) return null; const quoted = quote.body.replace(/\r\n/g, '\n').split('\n').map(l => `> ${l}`).join('\n'); return `${formatQuoteAttribution(quote)}\n${quoted}`; } // HTML quoted block. Mirrors Gmail's own reply markup (gmail_quote / gmail_attr // classes + the standard blockquote styling) so receiving clients recognize it // as quoted content and collapse it behind the "•••" toggle. Body is // attacker-controlled email content — escapeHtml it. function buildQuoteHtml(quote) { if (!quote || !(quote.body || '').trim()) return ''; const attribution = escapeHtml(formatQuoteAttribution(quote)); const quotedHtml = escapeHtml(quote.body.replace(/\r\n/g, '\n')).replace(/\n/g, '
'); return `
` + `
${attribution}
` + `
${quotedHtml}
` + `
`; } // Discord custom emoji token: <:name:id> (static) or (animated). const DISCORD_EMOJI_RE = /<(a?):(\w+):(\d+)>/g; // Same token after escapeHtml has turned the angle brackets into entities. const DISCORD_EMOJI_RE_ESCAPED = /<(a?):(\w+):(\d+)>/g; // Plain-text: collapse a custom-emoji token to its :name: shortcode. function discordEmojiToText(s) { return (s || '').replace(DISCORD_EMOJI_RE, (_m, _anim, name) => `:${name}:`); } // Collect the distinct custom emoji referenced in a message. function collectDiscordEmojis(s) { const seen = new Map(); for (const m of (s || '').matchAll(DISCORD_EMOJI_RE)) { const [, anim, name, id] = m; if (!seen.has(id)) seen.set(id, { id, name, ext: anim ? 'gif' : 'png' }); } return [...seen.values()]; } // Fetch one emoji's bytes from Discord's CDN for inline (cid:) embedding. // Returns null on any failure so the caller can fall back to a remote . async function fetchEmojiInline(emoji) { try { const res = await fetch(`https://cdn.discordapp.com/emojis/${emoji.id}.${emoji.ext}`); if (!res.ok) return null; const base64 = Buffer.from(await res.arrayBuffer()).toString('base64'); return { ...emoji, base64, cid: `emoji-${emoji.id}@broccolini` }; } catch { return null; } } // HTML: escape first (body is staff-authored but treated as untrusted), then // swap the now-escaped emoji tokens for an inline . Prefer a cid: reference // (embedded part, always renders); fall back to Discord's CDN when not embedded. // The id is digits-only and name is \w+, so neither can break out of the attribute. function messageTextToHtml(s, cidById = {}) { return escapeHtml(s || '') .replace(DISCORD_EMOJI_RE_ESCAPED, (_m, anim, name, id) => { const ext = anim ? 'gif' : 'png'; const src = cidById[id] ? `cid:${cidById[id]}` : `https://cdn.discordapp.com/emojis/${id}.${ext}`; return `:${name}:`; }) .replace(/\n/g, '
'); } // Strip Discord role mentions (<@&id>) — internal staff pings like @broccolini // that mean nothing to an email recipient. Collapse the whitespace left behind. function stripRoleMentions(s) { return (s || '') .replace(/<@&\d+>/g, '') .replace(/[^\S\r\n]{2,}/g, ' ') .replace(/[^\S\r\n]+\n/g, '\n') .trim(); } async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId, quote = null }) { const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' }; const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '
') : ''; const safeStaffSigText = sigBlocks.text; const cleanText = stripRoleMentions(messageText); // Embed any custom emoji inline (cid:) so they render without the recipient // having to load remote images. Failed fetches fall back to a remote . const inlineEmojis = (await Promise.all(collectDiscordEmojis(cleanText).map(fetchEmojiInline))).filter(Boolean); const cidById = {}; for (const e of inlineEmojis) cidById[e.id] = e.cid; const quoteHtml = buildQuoteHtml(quote); const htmlBody = `

${messageTextToHtml(cleanText, cidById)}

${safeStaffSigHtml ? `

${safeStaffSigHtml}

` : ''} ${buildCompanySigHtml()} ${quoteHtml ? `

${quoteHtml}` : ''}
`; const plainBody = [discordEmojiToText(cleanText)]; if (safeStaffSigText) plainBody.push('', safeStaffSigText); plainBody.push('', ...buildCompanySigText().split('\n')); const quoteText = buildQuoteText(quote); if (quoteText) plainBody.push('', '', quoteText); const stamp = Date.now().toString(16); const altBoundary = 'alt_' + stamp; const altPart = [ `--${altBoundary}`, 'Content-Type: text/plain; charset="UTF-8"', '', ...plainBody, '', `--${altBoundary}`, 'Content-Type: text/html; charset="UTF-8"', '', htmlBody, '', `--${altBoundary}--` ]; // With no inline images the message stays a plain multipart/alternative. // With them, wrap the alternative + image parts in a multipart/related. let topContentType; let bodyLines; if (inlineEmojis.length) { const relBoundary = 'rel_' + stamp; topContentType = `multipart/related; boundary="${relBoundary}"`; bodyLines = [ `--${relBoundary}`, `Content-Type: multipart/alternative; boundary="${altBoundary}"`, '', ...altPart, '' ]; for (const e of inlineEmojis) { bodyLines.push( `--${relBoundary}`, `Content-Type: image/${e.ext === 'gif' ? 'gif' : 'png'}`, 'Content-Transfer-Encoding: base64', `Content-ID: <${e.cid}>`, `Content-Disposition: inline; filename="${e.name}.${e.ext}"`, '', ...(e.base64.match(/.{1,76}/g) || []), '' ); } bodyLines.push(`--${relBoundary}--`); } else { topContentType = `multipart/alternative; boundary="${altBoundary}"`; bodyLines = altPart; } 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: ${topContentType}` ].filter(Boolean); 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, 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'); // Editable via TICKET_CLOSE_MESSAGE in .env. Supports a {closer_name} // placeholder and \n for line breaks. const messageText = (CONFIG.TICKET_CLOSE_MESSAGE || '') .replace(/\\n/g, '\n') .replace(/\{closer_name\}/g, closerName); // Closing emails intentionally omit the staff signature (userId left out) // — only the resolution message and the company signature go out. await sendThreadedEmail(gmail, { threadId: ticket.gmailThreadId, recipient, encodedSubject, msgId, messageText }); } 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} messageBody - Plain or HTML message body * @param {string} [userId] - Discord user ID for signature (optional) */ async function sendTicketNotificationEmail(ticket, 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 || 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, quote = 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, quote }); } // 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( `
` + `From: ${escapeHtml(from)}
` + `Date: ${escapeHtml(date)}
` + `
${escapeHtml(body).replace(/\n/g, '
')}
` ); 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('
'); const noteText = note ? `${note}\n\n${FORWARD_DIVIDER}\n\n` : ''; const noteHtml = note ? `

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


` : ''; 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"', '', `
${noteHtml}${transcriptHtml}
`, '', `--${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 = { getGmailClient, reloadGmailClient, sendGmailReply, sendTicketClosedEmail, sendTicketNotificationEmail, forwardThread, collectAttachmentParts, fetchMessageAttachments };