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

@@ -20,7 +20,7 @@ const {
detectGame,
sanitizeEmbedText
} = require('./utils');
const { getGmailClient } = require('./services/gmail');
const { getGmailClient, fetchMessageAttachments } = require('./services/gmail');
const { moveThreadToFolder, autoAdvanceFolder } = require('./services/gmailLabels');
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
const { logError } = require('./services/debugLog');
@@ -213,6 +213,34 @@ async function linkPreviousTranscripts(ticketChan, threadId, client) {
}
}
/**
* Best-effort: fetch the email's attachments and post them to the ticket channel.
* Files go out in one enqueued message (up to Discord's 10-file limit); any part
* that is too large or fails to download is named in a follow-up note so staff
* know to check Gmail. Never throws — attachment delivery must not break the
* ticket flow.
*/
async function postEmailAttachments(channel, gmail, email, client) {
try {
const { files, skipped } = await fetchMessageAttachments(email.data.id, email.data.payload, gmail);
if (files.length) {
await enqueueSend(channel, {
content: '**Email attachments:**',
files,
allowedMentions: { parse: [] }
});
}
if (skipped.length) {
await enqueueSend(channel, {
content: `⚠️ ${skipped.length} attachment(s) could not be posted (too large or failed to download) — check Gmail: ${skipped.map(s => `\`${s}\``).join(', ')}`,
allowedMentions: { parse: [] }
});
}
} catch (err) {
logError('postEmailAttachments', err, null, client).catch(() => {});
}
}
/** Drop UNREAD + INBOX labels from a Gmail message — equivalent to "archive + read". */
async function markGmailMessageRead(gmail, msgRef) {
await gmail.users.messages.batchModify({
@@ -355,6 +383,7 @@ async function poll(client) {
content: `**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
allowedMentions: { parse: [] }
});
await postEmailAttachments(ticketChan, gmail, email, client);
// Customer responded → advance the thread to Needs Response. A
// successful move strips INBOX+UNREAD (archives + marks read like
// markGmailMessageRead did). If the thread is manually filed (For Jake,
@@ -424,6 +453,7 @@ async function poll(client) {
content: `**Message:**\n${truncated}`,
allowedMentions: { parse: [] }
});
await postEmailAttachments(ticketChan, gmail, email, client);
// Welcome message skipped for email tickets the email body speaks for itself.
// Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js.

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
};

View File

@@ -0,0 +1,135 @@
/**
* fetchMessageAttachments — Gmail attachment → discord.js file descriptor tests.
*
* Uses a fake gmail client (no module mocking) so we exercise the real
* collectAttachmentParts walk, the base64url→Buffer decode, the size ceiling,
* the 10-file cap, and the best-effort skip-on-failure behavior.
*/
import { describe, it, expect, vi } from 'vitest';
import { collectAttachmentParts, fetchMessageAttachments } from '../services/gmail.js';
// Build a payload part carrying a real attachment (filename + attachmentId).
function attPart(filename, attachmentId, size, mimeType = 'image/png') {
return { filename, mimeType, body: { attachmentId, size } };
}
// Fake gmail whose attachments.get returns base64url of the given string per id.
function fakeGmail(dataById) {
return {
users: {
messages: {
attachments: {
get: vi.fn(async ({ id }) => {
if (!(id in dataById)) throw new Error('not found');
const b64url = Buffer.from(dataById[id]).toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return { data: { data: b64url } };
})
}
}
}
};
}
describe('collectAttachmentParts', () => {
it('finds attachment parts at any nesting depth, skips inline text', () => {
const payload = {
parts: [
{ mimeType: 'text/plain', body: { data: 'aGk=' } },
{ mimeType: 'multipart/mixed', parts: [attPart('log.txt', 'A1', 12)] }
]
};
const parts = collectAttachmentParts(payload);
expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({ filename: 'log.txt', attachmentId: 'A1', size: 12 });
});
it('carries over an embedded screenshot that has no filename', () => {
const payload = {
parts: [
{ mimeType: 'image/png', body: { attachmentId: 'CID1', size: 40000 } }
]
};
const parts = collectAttachmentParts(payload);
expect(parts).toHaveLength(1);
expect(parts[0]).toMatchObject({ filename: 'screenshot-1.png', attachmentId: 'CID1' });
});
it('skips a nameless text/html body served as an attachmentId part', () => {
const payload = {
parts: [
{ mimeType: 'text/html', body: { attachmentId: 'BODY', size: 200000 } }
]
};
expect(collectAttachmentParts(payload)).toEqual([]);
});
it('names a nameless non-image attachment "attachment-N"', () => {
const payload = {
parts: [
{ mimeType: 'application/pdf', body: { attachmentId: 'P1', size: 1000 } }
]
};
expect(collectAttachmentParts(payload)[0].filename).toBe('attachment-1.pdf');
});
});
describe('fetchMessageAttachments', () => {
it('decodes base64url attachment data into Buffers', async () => {
const payload = { parts: [attPart('hello.txt', 'A1', 5)] };
const gmail = fakeGmail({ A1: 'hello' });
const { files, skipped } = await fetchMessageAttachments('msg1', payload, gmail);
expect(skipped).toEqual([]);
expect(files).toHaveLength(1);
expect(files[0].name).toBe('hello.txt');
expect(Buffer.isBuffer(files[0].attachment)).toBe(true);
expect(files[0].attachment.toString()).toBe('hello');
});
it('skips parts over the 25 MB ceiling without fetching them', async () => {
const payload = { parts: [attPart('huge.bin', 'BIG', 26 * 1024 * 1024)] };
const gmail = fakeGmail({ BIG: 'x' });
const { files, skipped } = await fetchMessageAttachments('msg1', payload, gmail);
expect(files).toEqual([]);
expect(skipped).toEqual(['huge.bin']);
expect(gmail.users.messages.attachments.get).not.toHaveBeenCalled();
});
it('records a failed download as skipped, keeps the rest', async () => {
const payload = { parts: [attPart('ok.txt', 'A1', 2), attPart('gone.txt', 'MISSING', 2)] };
const gmail = fakeGmail({ A1: 'ok' });
const { files, skipped } = await fetchMessageAttachments('msg1', payload, gmail);
expect(files.map(f => f.name)).toEqual(['ok.txt']);
expect(skipped).toEqual(['gone.txt']);
});
it('caps at 10 files, skipping the overflow', async () => {
const data = {};
const parts = [];
for (let i = 0; i < 12; i++) {
const id = `A${i}`;
data[id] = `f${i}`;
parts.push(attPart(`file${i}.txt`, id, 2));
}
const { files, skipped } = await fetchMessageAttachments('msg1', { parts }, fakeGmail(data));
expect(files).toHaveLength(10);
expect(skipped).toHaveLength(2);
});
it('sanitizes CRLF and backticks out of filenames', async () => {
const payload = { parts: [attPart('bad\nname`.txt', 'A1', 2)] };
const gmail = fakeGmail({ A1: 'hi' });
const { files } = await fetchMessageAttachments('msg1', payload, gmail);
expect(files[0].name).toBe('bad name .txt');
});
});