Files
broccolini-bot/services/gmail.js
indifferentketchup 6bae3e79b1 Gmail folder auto-advance + /forward command
- Reply-cycle auto-advance: staff reply files the thread to "Awaiting Reply",
  a customer response files it to "Needs Response" (new GMAIL_LABEL_AWAITING_REPLY
  / GMAIL_LABEL_NEEDS_RESPONSE labels + autoAdvanceFolder, which only moves
  threads still in the auto-cycle and leaves hand-filed folders alone)
- /forward: forward a ticket's email to another address (handlers/commands/forward.js
  + forward composition in services/gmail.js)
- Tests for the auto-advance cycle; label fixtures updated for the new labels
2026-06-05 02:46:50 +00:00

538 lines
20 KiB
JavaScript
Raw 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, getCleanBody } = 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>
<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 an occasional re-auth (the
* OAuth app is published, so the token is long-lived — re-auth is only needed
* on revoke/password-change, not on a schedule) 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 <date>, <sender> 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, '<br>');
return `<div class="gmail_quote">` +
`<div dir="ltr" class="gmail_attr">${attribution}<br></div>` +
`<blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex;">${quotedHtml}</blockquote>` +
`</div>`;
}
// Discord custom emoji token: <:name:id> (static) or <a:name:id> (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 = /&lt;(a?):(\w+):(\d+)&gt;/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 <img>.
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 <img>. 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 `<img src="${src}" alt=":${name}:" ` +
`width="20" height="20" style="vertical-align: middle;">`;
})
.replace(/\n/g, '<br>');
}
// 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, '<br>') : '';
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 <img>.
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 = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p>${messageTextToHtml(cleanText, cidById)}</p>
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
${buildCompanySigHtml()}
${quoteHtml ? `<br><br>${quoteHtml}` : ''}
</div>`;
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
});
}
// Recursively collect attachment parts (those with a filename + attachmentId)
// from a Gmail message payload, at any nesting depth.
function collectAttachmentParts(payload) {
const out = [];
const walk = part => {
if (!part) return;
if (part.filename && part.body?.attachmentId) {
out.push({
filename: part.filename,
mimeType: part.mimeType || 'application/octet-stream',
attachmentId: part.body.attachmentId,
size: part.body.size || 0
});
}
if (part.parts) for (const p of part.parts) walk(p);
};
if (payload?.parts) for (const p of payload.parts) walk(p);
else walk(payload);
return out;
}
// Forward an entire ticket thread to a third party as a BRAND-NEW email.
// The original customer is never looped in: To = target only, no Cc/Bcc, no
// threadId, no In-Reply-To/References. Returns counts for the confirmation reply.
const FORWARD_MAX_TOTAL_BYTES = 20 * 1024 * 1024; // ~20 MB attachment ceiling
const FORWARD_DIVIDER = '-'.repeat(40);
async function forwardThread(threadId, targetEmail, note = '') {
const safeTarget = sanitizeHeaderValue(extractRawEmail(targetEmail || '')).toLowerCase();
if (!EMAIL_RE.test(safeTarget)) {
const err = new Error(`Invalid forward recipient: ${safeTarget || '(empty)'}`);
err.code = 'EBADRECIPIENT';
throw err;
}
const gmail = getGmailClient();
const thread = await gmail.users.threads.get({ userId: 'me', id: threadId, format: 'full' });
const messages = thread.data.messages || [];
if (!messages.length) {
const err = new Error('Thread has no messages to forward.');
err.code = 'EEMPTY';
throw err;
}
const firstHeaders = messages[0]?.payload?.headers || [];
const baseSubject = firstHeaders.find(h => h.name === 'Subject')?.value || 'No subject';
const fwdSubject = sanitizeHeaderValue(`Fwd: ${String(baseSubject).replace(/^(?:\s*Fwd\s*:\s*)+/i, '')}`);
const encodedSubject = `=?utf-8?B?${Buffer.from(fwdSubject).toString('base64')}?=`;
const textBlocks = [];
const htmlBlocks = [];
const attachments = [];
let skipped = 0;
let totalBytes = 0;
for (const msg of messages) {
const h = msg.payload?.headers || [];
const from = h.find(x => x.name === 'From')?.value || 'Unknown';
const date = h.find(x => x.name === 'Date')?.value || '';
const body = (getCleanBody(msg.payload) || '').replace(/\r\n/g, '\n').trim();
textBlocks.push(`From: ${from}\nDate: ${date}\n\n${body}`);
htmlBlocks.push(
`<div style="margin-bottom:8px;color:#555;font-size:13px;">` +
`<strong>From:</strong> ${escapeHtml(from)}<br>` +
`<strong>Date:</strong> ${escapeHtml(date)}</div>` +
`<div>${escapeHtml(body).replace(/\n/g, '<br>')}</div>`
);
for (const att of collectAttachmentParts(msg.payload)) {
if (totalBytes + (att.size || 0) > FORWARD_MAX_TOTAL_BYTES) { skipped++; continue; }
try {
const res = await gmail.users.messages.attachments.get({
userId: 'me', messageId: msg.id, id: att.attachmentId
});
const std = (res.data.data || '').replace(/-/g, '+').replace(/_/g, '/');
const buf = Buffer.from(std, 'base64');
totalBytes += buf.length;
attachments.push({
filename: sanitizeHeaderValue(att.filename).replace(/"/g, ''),
mimeType: att.mimeType,
base64: buf.toString('base64')
});
} catch (_) {
skipped++;
}
}
}
const transcriptText = textBlocks.join(`\n\n${FORWARD_DIVIDER}\n\n`);
const transcriptHtml = htmlBlocks.join('<hr style="border:none;border-top:1px solid #ddd;margin:16px 0;">');
const noteText = note ? `${note}\n\n${FORWARD_DIVIDER}\n\n` : '';
const noteHtml = note
? `<p>${escapeHtml(note).replace(/\n/g, '<br>')}</p><hr style="border:none;border-top:1px solid #ddd;margin:16px 0;">`
: '';
const stamp = Date.now().toString(16);
const altBoundary = 'alt_' + stamp;
const altPart = [
`--${altBoundary}`,
'Content-Type: text/plain; charset="UTF-8"',
'',
noteText + transcriptText,
'',
`--${altBoundary}`,
'Content-Type: text/html; charset="UTF-8"',
'',
`<div style="font-family: sans-serif; font-size: 14px; color: #333;">${noteHtml}${transcriptHtml}</div>`,
'',
`--${altBoundary}--`
];
let topContentType;
let bodyLines;
if (attachments.length) {
const mixBoundary = 'mix_' + stamp;
topContentType = `multipart/mixed; boundary="${mixBoundary}"`;
bodyLines = [
`--${mixBoundary}`,
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
'',
...altPart,
''
];
for (const a of attachments) {
bodyLines.push(
`--${mixBoundary}`,
`Content-Type: ${a.mimeType}; name="${a.filename}"`,
'Content-Transfer-Encoding: base64',
`Content-Disposition: attachment; filename="${a.filename}"`,
'',
...(a.base64.match(/.{1,76}/g) || []),
''
);
}
bodyLines.push(`--${mixBoundary}--`);
} else {
topContentType = `multipart/alternative; boundary="${altBoundary}"`;
bodyLines = altPart;
}
// Deliberately omit threadId / In-Reply-To / References so this is a fresh
// conversation to the target only — the original sender is never in the loop.
const headers = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${safeTarget}`,
`Subject: ${encodedSubject}`,
'MIME-Version: 1.0',
`Content-Type: ${topContentType}`
];
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 } });
return { messageCount: messages.length, attachmentCount: attachments.length, skipped };
}
module.exports = {
getGmailClient,
reloadGmailClient,
sendGmailReply,
sendTicketClosedEmail,
sendTicketNotificationEmail,
forwardThread
};