fetchMessageAttachments downloads a Gmail message's downloadable parts as discord.js file descriptors, skipping parts over Discord's 25 MB ceiling and capping at 10 files per message. Nameless inline parts (CID screenshots) get a synthesized name; nameless text/* parts (the email body Gmail serves as an attachmentId-backed part) are skipped. The poll posts these on both new tickets and follow-ups, naming any skipped parts so staff know to check Gmail.
608 lines
23 KiB
JavaScript
608 lines
23 KiB
JavaScript
/**
|
||
* 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 = /<(a?):(\w+):(\d+)>/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
|
||
});
|
||
}
|
||
|
||
// Derive a name for an attachment part that has none — typically an embedded
|
||
// screenshot carried inline by Content-ID rather than as a named attachment.
|
||
// Uses the mime subtype as the extension so the file still opens correctly.
|
||
function synthAttachmentName(part, n) {
|
||
const subtype = String(part.mimeType || '').split('/')[1] || '';
|
||
const ext = (subtype.split(';')[0].replace(/[^a-z0-9]+/gi, '') || 'bin').toLowerCase();
|
||
const isImage = /^image\//i.test(part.mimeType || '');
|
||
return `${isImage ? 'screenshot' : 'attachment'}-${n}.${ext}`;
|
||
}
|
||
|
||
// Recursively collect downloadable parts (those backed by an attachmentId) from
|
||
// a Gmail message payload, at any nesting depth. Named parts are taken as-is;
|
||
// nameless non-text parts — embedded/inline screenshots referenced only by
|
||
// Content-ID — are kept with a synthesized name. Nameless text/* parts are
|
||
// skipped: Gmail serves a large email *body* as an attachmentId-backed text/html
|
||
// part with no filename, and that is the message, not an attachment.
|
||
function collectAttachmentParts(payload) {
|
||
const out = [];
|
||
const walk = part => {
|
||
if (!part) return;
|
||
const isText = /^text\//i.test(part.mimeType || '');
|
||
if (part.body?.attachmentId && (part.filename || !isText)) {
|
||
out.push({
|
||
filename: part.filename || synthAttachmentName(part, out.length + 1),
|
||
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;
|
||
}
|
||
|
||
// Discord's default per-message upload ceiling is 25 MB for any guild (boosting
|
||
// raises it, but 25 MB is the universal floor). Parts above this are skipped
|
||
// rather than risking a failed send. Discord also caps a single message at 10
|
||
// files. Both are conservative so a normal customer attachment always lands.
|
||
const DISCORD_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
|
||
const DISCORD_MAX_FILES_PER_MESSAGE = 10;
|
||
|
||
// Strip CR/LF and surrounding whitespace from an attachment filename so it is
|
||
// safe to use as a Discord file name and inside a backticked status line.
|
||
function sanitizeAttachmentName(name) {
|
||
return String(name || '').replace(/[\r\n`]+/g, ' ').trim() || 'attachment';
|
||
}
|
||
|
||
/**
|
||
* Fetch a single Gmail message's downloadable attachments as discord.js file
|
||
* descriptors ({ name, attachment: Buffer }). Skips parts over Discord's size
|
||
* ceiling and caps at 10 files. Best-effort: an individual fetch failure is
|
||
* recorded in `skipped`, never thrown — attachment delivery must not break the
|
||
* ticket flow.
|
||
*
|
||
* @param {string} messageId - Gmail message id (email.data.id)
|
||
* @param {object} payload - email.data.payload
|
||
* @param {object} gmail - authenticated gmail client (getGmailClient())
|
||
* @returns {Promise<{ files: Array<{name: string, attachment: Buffer}>, skipped: string[] }>}
|
||
*/
|
||
async function fetchMessageAttachments(messageId, payload, gmail) {
|
||
const parts = collectAttachmentParts(payload);
|
||
const files = [];
|
||
const skipped = [];
|
||
for (const att of parts) {
|
||
const name = sanitizeAttachmentName(att.filename);
|
||
if (files.length >= DISCORD_MAX_FILES_PER_MESSAGE || (att.size || 0) > DISCORD_ATTACHMENT_MAX_BYTES) {
|
||
skipped.push(name);
|
||
continue;
|
||
}
|
||
try {
|
||
const res = await gmail.users.messages.attachments.get({
|
||
userId: 'me', messageId, id: att.attachmentId
|
||
});
|
||
const std = (res.data.data || '').replace(/-/g, '+').replace(/_/g, '/');
|
||
const buf = Buffer.from(std, 'base64');
|
||
if (buf.length > DISCORD_ATTACHMENT_MAX_BYTES) {
|
||
skipped.push(name);
|
||
continue;
|
||
}
|
||
files.push({ name, attachment: buf });
|
||
} catch (_) {
|
||
skipped.push(name);
|
||
}
|
||
}
|
||
return { files, skipped };
|
||
}
|
||
|
||
// 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,
|
||
collectAttachmentParts,
|
||
fetchMessageAttachments
|
||
};
|