/**
* 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 `
`;
}
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.
// 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 `
`;
})
.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
});
}
module.exports = {
getGmailClient,
reloadGmailClient,
sendGmailReply,
sendTicketClosedEmail,
sendTicketNotificationEmail
};