- Remove no-op log stubs (logGmail, logAutomation, logSecurity, logSystem) and ~17 callsites; dead counters in tickets.js and gmail-poll.js go too - Dedup three near-identical Gmail send paths into sendThreadedEmail helper - Drop dead Mongoose fields: broccoliniTicketId, lastSyncedBroccoliniArticleId, renameCount, renameWindowStart, reminderSent, staffChannelId, unclaimedRemindersSent, lastMessageAuthorIsStaff - Drop dead config fields and their .env.example entries - Inline api/botClient.js (3-line wrapper, 2 callers) - Trim unused exports across utils.js, tickets.js, configSchema.js, debugLog.js - Fix handlers/messages.js to use isStaff() — old partial check ignored ADDITIONAL_STAFF_ROLES, so those members were treated as customers - Drop unused deps p-queue + dotenv-expand; move mongodb to devDependencies Net: -583 LOC source + -57 LOC lockfile. All 23 modules load clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
240 lines
8.7 KiB
JavaScript
240 lines
8.7 KiB
JavaScript
/**
|
||
* 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 `
|
||
<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.
|
||
*/
|
||
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, '<br>') : '';
|
||
const safeStaffSigText = sigBlocks.text;
|
||
|
||
const htmlBody = `
|
||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||
<p>${escapeHtml(messageText || '').replace(/\n/g, '<br>')}</p>
|
||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||
${buildCompanySigHtml()}
|
||
</div>`;
|
||
|
||
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
|
||
};
|