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:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user