Post inbound email attachments to the ticket channel

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.
This commit is contained in:
2026-06-05 03:08:21 +00:00
parent e77be9a3e4
commit 61e8ea32e1
3 changed files with 241 additions and 6 deletions

View File

@@ -367,15 +367,30 @@ async function sendGmailReply(threadId, replyText, recipientEmail, subject, mess
});
}
// Recursively collect attachment parts (those with a filename + attachmentId)
// from a Gmail message payload, at any nesting depth.
// 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;
if (part.filename && part.body?.attachmentId) {
const isText = /^text\//i.test(part.mimeType || '');
if (part.body?.attachmentId && (part.filename || !isText)) {
out.push({
filename: part.filename,
filename: part.filename || synthAttachmentName(part, out.length + 1),
mimeType: part.mimeType || 'application/octet-stream',
attachmentId: part.body.attachmentId,
size: part.body.size || 0
@@ -388,6 +403,59 @@ function collectAttachmentParts(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.
@@ -533,5 +601,7 @@ module.exports = {
sendGmailReply,
sendTicketClosedEmail,
sendTicketNotificationEmail,
forwardThread
forwardThread,
collectAttachmentParts,
fetchMessageAttachments
};