Files
broccolini-bot/services/gmail.js
indifferentketchup 840b6bfcf8 simplify: prune dead code, dedup gmail send, drop neutered log stubs
- 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>
2026-05-07 18:37:14 +00:00

240 lines
8.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
};