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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
135
tests/gmailAttachments.test.js
Normal file
135
tests/gmailAttachments.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user