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

@@ -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');
});
});