import { EventEmitter } from 'events'; import Imap, { type Box, type MailBoxes } from 'imap'; import { Readable } from 'stream'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { ImapSimple } from './imap-simple'; import { PartData } from './part-data'; type MockImap = EventEmitter & { connect: Mocked<() => unknown>; fetch: Mocked<() => unknown>; end: Mocked<() => unknown>; search: Mocked<(...args: Parameters) => unknown>; sort: Mocked<(...args: Parameters) => unknown>; openBox: Mocked< (boxName: string, onOpen: (error: Error | null, box?: Box) => unknown) => unknown >; closeBox: Mocked<(...args: Parameters) => unknown>; getBoxes: Mocked<(onBoxes: (error: Error | null, boxes?: MailBoxes) => unknown) => unknown>; addFlags: Mocked<(...args: Parameters) => unknown>; }; vi.mock('imap', () => { return { default: class InlineMockImap extends EventEmitter implements MockImap { connect = vi.fn(); fetch = vi.fn(); end = vi.fn(); search = vi.fn(); sort = vi.fn(); openBox = vi.fn(); closeBox = vi.fn(); addFlags = vi.fn(); getBoxes = vi.fn(); }, }; }); vi.mock('./part-data', () => ({ // eslint-disable-next-line @typescript-eslint/naming-convention PartData: { fromData: vi.fn(() => 'decoded') }, })); describe('ImapSimple', () => { function createImap() { const imap = new Imap({ user: 'testuser', password: 'testpass' }); return { imapSimple: new ImapSimple(imap), mockImap: imap as unknown as MockImap }; } describe('constructor', () => { it('should forward nonerror events', () => { const { imapSimple, mockImap } = createImap(); const onMail = vi.fn(); imapSimple.on('mail', onMail); mockImap.emit('mail', 3); expect(onMail).toHaveBeenCalledWith(3); }); it('should suppress ECONNRESET errors if ending', () => { const { imapSimple, mockImap } = createImap(); const onError = vi.fn(); imapSimple.on('error', onError); imapSimple.end(); mockImap.emit('error', { message: 'reset', code: 'ECONNRESET' }); expect(onError).not.toHaveBeenCalled(); }); it('should forward ECONNRESET errors if not ending', () => { const { imapSimple, mockImap } = createImap(); const onError = vi.fn(); imapSimple.on('error', onError); const error = { message: 'reset', code: 'ECONNRESET' }; mockImap.emit('error', error); expect(onError).toHaveBeenCalledWith(error); }); }); describe('search', () => { it('should resolve with messages returned from fetch', async () => { const { imapSimple, mockImap } = createImap(); const fetchEmitter = new EventEmitter(); const mockMessages = [{ uid: 1 }, { uid: 2 }, { uid: 3 }]; vi.mocked(mockImap.search).mockImplementation((_criteria, onResult) => onResult( null as unknown as Error, mockMessages.map((m) => m.uid), ), ); mockImap.fetch = vi.fn(() => fetchEmitter); const searchPromise = imapSimple.search(['UNSEEN', ['FROM', 'test@n8n.io']], { bodies: ['BODY'], }); expect(mockImap.search).toHaveBeenCalledWith( ['UNSEEN', ['FROM', 'test@n8n.io']], expect.any(Function), ); for (const message of mockMessages) { const messageEmitter = new EventEmitter(); const body = 'body' + message.uid; const bodyStream = Readable.from(body); fetchEmitter.emit('message', messageEmitter, message.uid); messageEmitter.emit('body', bodyStream, { which: 'TEXT', size: Buffer.byteLength(body) }); messageEmitter.emit('attributes', { uid: message.uid }); await new Promise((resolve) => { bodyStream.on('end', resolve); }); messageEmitter.emit('end'); } fetchEmitter.emit('end'); const messages = await searchPromise; expect(messages).toEqual([ { attributes: { uid: 1 }, parts: [{ body: 'body1', size: 5, which: 'TEXT' }], seqNo: 1, }, { attributes: { uid: 2 }, parts: [{ body: 'body2', size: 5, which: 'TEXT' }], seqNo: 2, }, { attributes: { uid: 3 }, parts: [{ body: 'body3', size: 5, which: 'TEXT' }], seqNo: 3, }, ]); }); }); describe('getPartData', () => { it('should return decoded part data', async () => { const { imapSimple, mockImap } = createImap(); const fetchEmitter = new EventEmitter(); mockImap.fetch = vi.fn(() => fetchEmitter); const message = { attributes: { uid: 123 } }; const part = { partID: '1.2', encoding: 'BASE64' }; const partDataPromise = imapSimple.getPartData(mock(message), mock(part)); const body = 'encoded-body'; const messageEmitter = new EventEmitter(); const bodyStream = Readable.from(body); fetchEmitter.emit('message', messageEmitter); messageEmitter.emit('body', bodyStream, { which: part.partID, size: Buffer.byteLength(body), }); messageEmitter.emit('attributes', {}); await new Promise((resolve) => bodyStream.on('end', resolve)); messageEmitter.emit('end'); fetchEmitter.emit('end'); const result = await partDataPromise; expect(PartData.fromData).toHaveBeenCalledWith('encoded-body', 'BASE64'); expect(result).toBe('decoded'); }); }); describe('openBox', () => { it('should open the mailbox', async () => { const { imapSimple, mockImap } = createImap(); const box = mock({ name: 'INBOX' }); vi.mocked(mockImap.openBox).mockImplementation((_boxName, onOpen) => onOpen(null as unknown as Error, box), ); await expect(imapSimple.openBox('INBOX')).resolves.toEqual(box); }); it('should reject on error', async () => { const { imapSimple, mockImap } = createImap(); vi.mocked(mockImap.openBox).mockImplementation((_boxName, onOpen) => onOpen(new Error('nope')), ); await expect(imapSimple.openBox('INBOX')).rejects.toThrow('nope'); }); }); describe('closeBox', () => { it('should close the mailbox with default autoExpunge=true', async () => { const { imapSimple, mockImap } = createImap(); vi.mocked(mockImap.closeBox).mockImplementation((_expunge, onClose) => onClose(null as unknown as Error), ); await expect(imapSimple.closeBox()).resolves.toBeUndefined(); expect(mockImap.closeBox).toHaveBeenCalledWith(true, expect.any(Function)); }); it('should close the mailbox with autoExpunge=false', async () => { const { imapSimple, mockImap } = createImap(); vi.mocked(mockImap.closeBox).mockImplementation((_expunge, onClose) => onClose(null as unknown as Error), ); await expect(imapSimple.closeBox(false)).resolves.toBeUndefined(); expect(mockImap.closeBox).toHaveBeenCalledWith(false, expect.any(Function)); }); it('should reject on error', async () => { const { imapSimple, mockImap } = createImap(); vi.mocked(mockImap.closeBox).mockImplementation((_expunge, onClose) => onClose(new Error('fail')), ); await expect(imapSimple.closeBox()).rejects.toThrow('fail'); }); }); describe('addFlags', () => { it('should add flags to messages and resolve', async () => { const { imapSimple, mockImap } = createImap(); vi.mocked(mockImap.addFlags).mockImplementation((_uids, _flags, onAdd) => onAdd(null as unknown as Error), ); await expect(imapSimple.addFlags([1, 2], ['\\Seen'])).resolves.toBeUndefined(); expect(mockImap.addFlags).toHaveBeenCalledWith([1, 2], ['\\Seen'], expect.any(Function)); }); it('should reject on error', async () => { const { imapSimple, mockImap } = createImap(); vi.mocked(mockImap.addFlags).mockImplementation((_uids, _flags, onAdd) => onAdd(new Error('add flags failed')), ); await expect(imapSimple.addFlags([1], '\\Seen')).rejects.toThrow('add flags failed'); }); }); describe('getBoxes', () => { it('should resolve with list of mailboxes', async () => { const { imapSimple, mockImap } = createImap(); // eslint-disable-next-line @typescript-eslint/naming-convention const boxes = mock({ INBOX: {}, Archive: {} }); vi.mocked(mockImap.getBoxes).mockImplementation((onBoxes) => onBoxes(null as unknown as Error, boxes), ); await expect(imapSimple.getBoxes()).resolves.toEqual(boxes); expect(mockImap.getBoxes).toHaveBeenCalledWith(expect.any(Function)); }); it('should reject on error', async () => { const { imapSimple, mockImap } = createImap(); vi.mocked(mockImap.getBoxes).mockImplementation((onBoxes) => onBoxes(new Error('getBoxes failed')), ); await expect(imapSimple.getBoxes()).rejects.toThrow('getBoxes failed'); }); }); });