/**
* 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 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.
*
* @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, closerName, userId = null) {
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 originalSubject = null;
let msgId = null;
try {
const thread = await gmail.users.threads.get({
userId: 'me',
id: ticket.gmailThreadId
});
const messages = thread.data.messages || [];
const firstMsg = messages[0];
if (firstMsg?.payload?.headers) {
const subj = firstMsg.payload.headers.find(h => h.name === 'Subject')?.value;
if (subj) originalSubject = subj;
msgId = sanitizeHeaderValue(firstMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
}
} catch (_) {}
const baseSubject = originalSubject || ticket.subject || 'Support';
const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, '');
const safeSubject = sanitizeHeaderValue(`Re: ${stripped}`);
const utf8Subject = `=?utf-8?B?${Buffer.from(safeSubject).toString('base64')}?=`;
const messageBody = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`;
let signatureBlocks = { text: '', html: '' };
if (userId) {
signatureBlocks = await getStaffSignatureBlocks(userId);
}
// signatureBlocks.html arrives pre-escaped from getStaffSignatureBlocks.
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '
') : '';
const safeStaffSigText = signatureBlocks.text;
const htmlBody = `
${escapeHtml(messageBody).replace(/\n/g, '
')}
${safeStaffSigHtml ? `
${safeStaffSigHtml}
` : ''}
${buildCompanySigHtml()}
`;
const boundary = '000000000000' + Date.now().toString(16);
const plainBody = [];
plainBody.push(messageBody);
if (safeStaffSigText) {
plainBody.push('');
plainBody.push(safeStaffSigText);
}
plainBody.push('');
plainBody.push(...buildCompanySigText().split('\n'));
const headers = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipientEmail}`,
`Subject: ${utf8Subject}`,
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: 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 originalSubject = null;
let msgId = null;
try {
const thread = await gmail.users.threads.get({
userId: 'me',
id: ticket.gmailThreadId
});
const messages = thread.data.messages || [];
const firstMsg = messages[0];
if (firstMsg?.payload?.headers) {
const subj = firstMsg.payload.headers.find(h => h.name === 'Subject')?.value;
if (subj) originalSubject = subj;
msgId = sanitizeHeaderValue(firstMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
}
} catch (_) {}
// Thread-safety: derive Subject from the last thread message; strip any leading
// Re:/RE:/Re : variants and re-prepend a single "Re: ". Fall back to subjectLine
// (legacy param) only if the thread lookup gave us nothing.
const baseSubject = originalSubject || subjectLine || ticket.subject || 'Support';
const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, '');
const safeSubject = sanitizeHeaderValue(`Re: ${stripped}`);
const utf8Subject = `=?utf-8?B?${Buffer.from(safeSubject).toString('base64')}?=`;
let signatureBlocks = { text: '', html: '' };
if (userId) {
signatureBlocks = await getStaffSignatureBlocks(userId);
}
// signatureBlocks.html arrives pre-escaped from getStaffSignatureBlocks.
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '
') : '';
const safeStaffSigText = signatureBlocks.text;
const htmlBody = `
${escapeHtml(messageBody || '').replace(/\n/g, '
')}
${safeStaffSigHtml ? `
${safeStaffSigHtml}
` : ''}
${buildCompanySigHtml()}
`;
const boundary = '000000000000' + Date.now().toString(16);
const plainBody = [];
plainBody.push(messageBody || '');
if (safeStaffSigText) {
plainBody.push('');
plainBody.push(safeStaffSigText);
}
plainBody.push('');
plainBody.push(...buildCompanySigText().split('\n'));
const headers = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipientEmail}`,
`Subject: ${utf8Subject}`,
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: 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')}?=`;
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}
` : ''}
${buildCompanySigHtml()}
`;
const boundary = '000000000000' + Date.now().toString(16);
const plainBody = [];
plainBody.push(replyText);
if (safeStaffSigText) {
plainBody.push('');
plainBody.push(safeStaffSigText);
}
plainBody.push('');
plainBody.push(...buildCompanySigText().split('\n'));
const headers = [
`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}"`
].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 }
});
}
module.exports = {
getGmailClient,
reloadGmailClient,
sendGmailReply,
sendTicketClosedEmail,
sendTicketNotificationEmail
};