This commit is contained in:
2026-04-20 18:05:36 +00:00
parent d73422555d
commit 33b1f276c6
26 changed files with 598 additions and 183 deletions

View File

@@ -5,6 +5,10 @@ const { google } = require('googleapis');
const { CONFIG } = require('../config');
const { extractRawEmail, escapeHtml } = require('../utils');
const { getStaffSignatureBlocks } = require('./staffSignature');
const { logError } = require('./debugLog');
function sanitizeHeaderValue(v) { return String(v || '').replace(/[\r\n]+/g, ' ').trim(); }
const EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
function getGmailClient() {
const auth = new google.auth.OAuth2(
@@ -20,8 +24,12 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
const gmail = getGmailClient();
// Send to the ticket sender (customer), not derived from thread (which can be support)
const recipientEmail = extractRawEmail(ticket.senderEmail || '').toLowerCase();
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 subjectHeader = ticket.subject || 'Support';
let msgId = null;
@@ -35,13 +43,13 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
if (lastMsg?.payload?.headers) {
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
if (subj) subjectHeader = subj;
msgId = lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value;
msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
}
} catch (_) {
/* use ticket.subject and no In-Reply-To if thread fetch fails */
}
const finalSubject = `${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`;
const finalSubject = sanitizeHeaderValue(`${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`);
const utf8Subject = `=?utf-8?B?${Buffer.from(
finalSubject
).toString('base64')}?=`;
@@ -72,7 +80,7 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
</div>`;
const rawHeaders = [
`From: ${CONFIG.MY_EMAIL}`,
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipientEmail}`,
`Subject: ${utf8Subject}`,
msgId ? `In-Reply-To: ${msgId}` : '',
@@ -113,8 +121,12 @@ const StaffSignature = mongoose.model('StaffSignature');
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel, userId = null) {
try {
const gmail = getGmailClient();
const recipientEmail = extractRawEmail(ticket.senderEmail || '').toLowerCase();
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 subjectHeader = ticket.subject || 'Support';
let msgId = null;
@@ -128,11 +140,11 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
if (lastMsg?.payload?.headers) {
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
if (subj) subjectHeader = subj;
msgId = lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value;
msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
}
} catch (_) {}
const finalSubject = subjectLine || subjectHeader;
const finalSubject = sanitizeHeaderValue(subjectLine || subjectHeader);
const utf8Subject = `=?utf-8?B?${Buffer.from(finalSubject).toString('base64')}?=`;
const label = escapeHtml(fromLabel || CONFIG.SUPPORT_NAME || 'Support');
const safeBody = escapeHtml(messageBody || '').replace(/\n/g, '<br>');
@@ -169,7 +181,7 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
</div>`;
const rawHeaders = [
`From: ${CONFIG.MY_EMAIL}`,
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipientEmail}`,
`Subject: ${utf8Subject}`,
msgId ? `In-Reply-To: ${msgId}` : '',
@@ -216,8 +228,16 @@ async function sendGmailReply(
) {
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(
`Re: ${subject}`
safeSubject
).toString('base64')}?=`;
const safeUser = escapeHtml(discordUser);
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
@@ -229,6 +249,7 @@ async function sendGmailReply(
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 safeCompanySigHtml = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
@@ -264,11 +285,11 @@ async function sendGmailReply(
plainBody.push(companySignatureText);
const raw = Buffer.from([
`From: ${CONFIG.MY_EMAIL}`,
`To: ${recipientEmail}`,
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${safeRecipient}`,
`Subject: ${utf8Subject}`,
messageId ? `In-Reply-To: ${messageId}` : '',
messageId ? `References: ${messageId}` : '',
safeMessageId ? `In-Reply-To: ${safeMessageId}` : '',
safeMessageId ? `References: ${safeMessageId}` : '',
'MIME-Version: 1.0',
'Content-Type: multipart/alternative; boundary="' + boundary + '"',
'',