Browse Source

fix: support LF-only IMAP message sections

AI-Co-Authored-By: Codex
chendeben 1 month ago
parent
commit
1f1e6d17df
2 changed files with 135 additions and 5 deletions
  1. 16 5
      src/mail-access.js
  2. 119 0
      test/mail-access.test.js

+ 16 - 5
src/mail-access.js

@@ -967,20 +967,31 @@ function selectedHeaders(raw, section) {
       if (keep) output.push(line);
       continue;
     }
-    const name = line.slice(0, line.indexOf(':')).toLowerCase();
-    keep = names.has(name);
+    const separator = line.indexOf(':');
+    const name = separator === -1 ? '' : line.slice(0, separator).toLowerCase();
+    keep = Boolean(name) && names.has(name);
     if (keep) output.push(line);
   }
   return `${output.join('\r\n')}\r\n\r\n`;
 }
 
 function headerBlock(raw) {
-  return `${raw.split('\r\n\r\n', 1)[0] || ''}\r\n\r\n`;
+  const { header } = splitMessageSections(raw);
+  return `${header.split(/\r\n|\n|\r/).join('\r\n')}\r\n\r\n`;
 }
 
 function bodyBlock(raw) {
-  const index = raw.indexOf('\r\n\r\n');
-  return index === -1 ? '' : raw.slice(index + 4);
+  return splitMessageSections(raw).body;
+}
+
+function splitMessageSections(raw) {
+  const source = String(raw || '');
+  const separator = /\r\n\r\n|\n\n|\r\r/.exec(source);
+  if (!separator) return { header: source, body: '' };
+  return {
+    header: source.slice(0, separator.index),
+    body: source.slice(separator.index + separator[0].length)
+  };
 }
 
 function parseFlags(value) {

+ 119 - 0
test/mail-access.test.js

@@ -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 () => {
   initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-folders-test-')), 'mail-access-secret');
   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) {
   if (server.listening) return Promise.resolve();
   return new Promise((resolve) => server.once('listening', resolve));