|
@@ -186,6 +186,107 @@ test('IMAP exposes MIME body structures and individual parts for Roundcube', asy
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+test('IMAP exposes LF-only Maildir headers and text sections to Roundcube', async () => {
|
|
|
|
|
+ initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-lf-test-')), 'mail-access-secret');
|
|
|
|
|
+ const { mailbox } = createMailboxFixture('lf.example', 'lf-user');
|
|
|
|
|
+ const rawMessageBytes = Buffer.from([
|
|
|
|
|
+ 'From: Alice <alice@example.net>',
|
|
|
|
|
+ 'To: admin@lf.example',
|
|
|
|
|
+ 'Subject: LF-only imported',
|
|
|
|
|
+ ' continuation',
|
|
|
|
|
+ 'Message-ID: <lf-only@example.net>',
|
|
|
|
|
+ 'Content-Type: text/plain; charset=UTF-8',
|
|
|
|
|
+ 'X-Not-Selected: private metadata',
|
|
|
|
|
+ '',
|
|
|
|
|
+ 'LF-only body.',
|
|
|
|
|
+ 'Second line.'
|
|
|
|
|
+ ].join('\n'), 'utf8');
|
|
|
|
|
+ createImportedInboundMessage(mailbox, {
|
|
|
|
|
+ importSource: 'vesta:lf-only',
|
|
|
|
|
+ sourceKey: 'lf-only-message',
|
|
|
|
|
+ sender: 'alice@example.net',
|
|
|
|
|
+ recipients: ['admin@lf.example'],
|
|
|
|
|
+ subject: 'LF-only imported continuation',
|
|
|
|
|
+ messageId: '<lf-only@example.net>',
|
|
|
|
|
+ rawMessageBytes,
|
|
|
|
|
+ receivedAt: '2026-07-14T06:19:40.000Z'
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const [server] = startMailboxAccessServers({
|
|
|
|
|
+ hostname: 'mail.lf.example',
|
|
|
|
|
+ imapEnabled: true,
|
|
|
|
|
+ imapListeners: [{ port: 0, protocol: 'imap' }],
|
|
|
|
|
+ pop3Enabled: false,
|
|
|
|
|
+ pop3Listeners: [],
|
|
|
|
|
+ allowInsecureAuth: true
|
|
|
|
|
+ });
|
|
|
|
|
+ await waitForListening(server);
|
|
|
|
|
+
|
|
|
|
|
+ let client;
|
|
|
|
|
+ try {
|
|
|
|
|
+ client = await connectClient(server.address().port);
|
|
|
|
|
+ await client.readUntil(/\* OK .* IMAP ready\r\n/);
|
|
|
|
|
+ await client.command('A1 LOGIN "admin@lf.example" "mailbox-pass-123"', /A1 OK/);
|
|
|
|
|
+ await client.command('A2 SELECT INBOX', /A2 OK/);
|
|
|
|
|
+
|
|
|
|
|
+ const headerFieldsLabel = 'BODY[HEADER.FIELDS (DATE FROM TO CC REPLY-TO SUBJECT MESSAGE-ID REFERENCES CONTENT-TYPE X-PRIORITY X-MSMMAIL-PRIORITY IMPORTANCE)]';
|
|
|
|
|
+ const headerFieldsResponse = await client.commandBytes(
|
|
|
|
|
+ `A3 UID FETCH 1 (UID FLAGS RFC822.SIZE INTERNALDATE BODY.PEEK[HEADER.FIELDS (DATE FROM TO CC REPLY-TO SUBJECT MESSAGE-ID REFERENCES CONTENT-TYPE X-PRIORITY X-MSMMAIL-PRIORITY IMPORTANCE)])`,
|
|
|
|
|
+ /A3 OK FETCH completed\r\n$/
|
|
|
|
|
+ );
|
|
|
|
|
+ assert.deepEqual(extractFetchLiteral(headerFieldsResponse, headerFieldsLabel), Buffer.from([
|
|
|
|
|
+ 'From: Alice <alice@example.net>',
|
|
|
|
|
+ 'To: admin@lf.example',
|
|
|
|
|
+ 'Subject: LF-only imported',
|
|
|
|
|
+ ' continuation',
|
|
|
|
|
+ 'Message-ID: <lf-only@example.net>',
|
|
|
|
|
+ 'Content-Type: text/plain; charset=UTF-8',
|
|
|
|
|
+ '',
|
|
|
|
|
+ ''
|
|
|
|
|
+ ].join('\r\n'), 'utf8'));
|
|
|
|
|
+
|
|
|
|
|
+ const fullHeaderResponse = await client.commandBytes(
|
|
|
|
|
+ 'A4 UID FETCH 1 (RFC822.HEADER)',
|
|
|
|
|
+ /A4 OK FETCH completed\r\n$/
|
|
|
|
|
+ );
|
|
|
|
|
+ assert.deepEqual(extractFetchLiteral(fullHeaderResponse, 'RFC822.HEADER'), Buffer.from([
|
|
|
|
|
+ 'From: Alice <alice@example.net>',
|
|
|
|
|
+ 'To: admin@lf.example',
|
|
|
|
|
+ 'Subject: LF-only imported',
|
|
|
|
|
+ ' continuation',
|
|
|
|
|
+ 'Message-ID: <lf-only@example.net>',
|
|
|
|
|
+ 'Content-Type: text/plain; charset=UTF-8',
|
|
|
|
|
+ 'X-Not-Selected: private metadata',
|
|
|
|
|
+ '',
|
|
|
|
|
+ ''
|
|
|
|
|
+ ].join('\r\n'), 'utf8'));
|
|
|
|
|
+
|
|
|
|
|
+ const expectedBody = Buffer.from('LF-only body.\nSecond line.', 'utf8');
|
|
|
|
|
+ const rfc822TextResponse = await client.commandBytes(
|
|
|
|
|
+ 'A5 UID FETCH 1 (RFC822.TEXT)',
|
|
|
|
|
+ /A5 OK FETCH completed\r\n$/
|
|
|
|
|
+ );
|
|
|
|
|
+ assert.deepEqual(extractFetchLiteral(rfc822TextResponse, 'RFC822.TEXT'), expectedBody);
|
|
|
|
|
+
|
|
|
|
|
+ const bodyTextResponse = await client.commandBytes(
|
|
|
|
|
+ 'A6 UID FETCH 1 (BODY.PEEK[TEXT])',
|
|
|
|
|
+ /A6 OK FETCH completed\r\n$/
|
|
|
|
|
+ );
|
|
|
|
|
+ assert.deepEqual(extractFetchLiteral(bodyTextResponse, 'BODY[TEXT]'), expectedBody);
|
|
|
|
|
+
|
|
|
|
|
+ const fullMessageResponse = await client.commandBytes(
|
|
|
|
|
+ 'A7 UID FETCH 1 (BODY.PEEK[])',
|
|
|
|
|
+ /A7 OK FETCH completed\r\n$/
|
|
|
|
|
+ );
|
|
|
|
|
+ assert.deepEqual(extractFetchLiteral(fullMessageResponse, 'BODY[]'), rawMessageBytes);
|
|
|
|
|
+
|
|
|
|
|
+ await client.command('A8 LOGOUT', /A8 OK/);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ client?.close();
|
|
|
|
|
+ await closeServer(server);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
test('IMAP exposes standard folders expected by mainstream clients', async () => {
|
|
test('IMAP exposes standard folders expected by mainstream clients', async () => {
|
|
|
initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-folders-test-')), 'mail-access-secret');
|
|
initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-folders-test-')), 'mail-access-secret');
|
|
|
createMailboxFixture('folders.example', 'folders-user');
|
|
createMailboxFixture('folders.example', 'folders-user');
|
|
@@ -583,6 +684,24 @@ function connectClient(port) {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function extractFetchLiteral(response, label) {
|
|
|
|
|
+ const bytes = Buffer.isBuffer(response) ? response : Buffer.from(response || '');
|
|
|
|
|
+ const marker = Buffer.from(`${label} {`, 'ascii');
|
|
|
|
|
+ const markerIndex = bytes.indexOf(marker);
|
|
|
|
|
+ assert.notEqual(markerIndex, -1, `Missing ${label} literal marker`);
|
|
|
|
|
+ const sizeStart = markerIndex + marker.length;
|
|
|
|
|
+ const sizeEndMarker = Buffer.from('}\r\n', 'ascii');
|
|
|
|
|
+ const sizeEnd = bytes.indexOf(sizeEndMarker, sizeStart);
|
|
|
|
|
+ assert.notEqual(sizeEnd, -1, `Missing ${label} literal size terminator`);
|
|
|
|
|
+ const size = Number(bytes.subarray(sizeStart, sizeEnd).toString('ascii'));
|
|
|
|
|
+ assert.equal(Number.isInteger(size) && size >= 0, true, `Invalid ${label} literal size`);
|
|
|
|
|
+ const literalStart = sizeEnd + sizeEndMarker.length;
|
|
|
|
|
+ const literalEnd = literalStart + size;
|
|
|
|
|
+ assert.ok(literalEnd <= bytes.length, `Truncated ${label} literal`);
|
|
|
|
|
+ assert.deepEqual(bytes.subarray(literalEnd, literalEnd + 5), Buffer.from('\r\n)\r\n', 'ascii'));
|
|
|
|
|
+ return bytes.subarray(literalStart, literalEnd);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function waitForListening(server) {
|
|
function waitForListening(server) {
|
|
|
if (server.listening) return Promise.resolve();
|
|
if (server.listening) return Promise.resolve();
|
|
|
return new Promise((resolve) => server.once('listening', resolve));
|
|
return new Promise((resolve) => server.once('listening', resolve));
|