382 lines
13 KiB
JavaScript
382 lines
13 KiB
JavaScript
/**
|
||
* 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 `
|
||
<table border="0" cellpadding="0" cellspacing="0" style="margin-top: 16px;">
|
||
<tr>
|
||
<td style="padding-right: 12px; vertical-align: top;">
|
||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65" alt="Indifferent Broccoli">` : ''}
|
||
</td>
|
||
<td style="border-left: 1px solid #ddd; padding-left: 12px; vertical-align: top; font-size: 13px; color: #333;">
|
||
Indifferent Broccoli Support<br>
|
||
<a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</a><br>
|
||
Join us on <a href="https://discord.gg/2vmfrrtvJY">Discord</a><br>
|
||
<br>
|
||
<em>Host your own game server. Or not... we don't care.</em>
|
||
</td>
|
||
</tr>
|
||
</table>`;
|
||
}
|
||
|
||
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, '<br>') : '';
|
||
const safeStaffSigText = signatureBlocks.text;
|
||
|
||
const htmlBody = `
|
||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||
<p>${escapeHtml(messageBody).replace(/\n/g, '<br>')}</p>
|
||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||
${buildCompanySigHtml()}
|
||
</div>`;
|
||
|
||
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, '<br>') : '';
|
||
const safeStaffSigText = signatureBlocks.text;
|
||
|
||
const htmlBody = `
|
||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||
<p>${escapeHtml(messageBody || '').replace(/\n/g, '<br>')}</p>
|
||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||
${buildCompanySigHtml()}
|
||
</div>`;
|
||
|
||
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, '<br>') : '';
|
||
const safeStaffSigText = signatureBlocks.text;
|
||
|
||
const htmlBody = `
|
||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||
<p>${escapeHtml(replyText).replace(/\n/g, '<br>')}</p>
|
||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||
${buildCompanySigHtml()}
|
||
</div>`;
|
||
|
||
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
|
||
};
|