Răsfoiți Sursa

fix: support IMAP MIME body structures

AI-Co-Authored-By: Codex
chendeben 1 lună în urmă
părinte
comite
96d4b1c715
2 a modificat fișierele cu 187 adăugiri și 1 ștergeri
  1. 118 1
      src/mail-access.js
  2. 69 0
      test/mail-access.test.js

+ 118 - 1
src/mail-access.js

@@ -332,8 +332,9 @@ class ImapSession {
     if (byUid || /\bUID\b/.test(upper)) attrs.push(`UID ${entry.message.id}`);
     if (!upper || /\bFLAGS\b/.test(upper)) attrs.push(`FLAGS (${imapFlags(entry.message, this.deletedUids).join(' ')})`);
     if (/\bINTERNALDATE\b/.test(upper)) attrs.push(`INTERNALDATE "${imapDate(entry.message.receivedAt)}"`);
-    if (/RFC822\.SIZE|BODY|RFC822/i.test(items)) attrs.push(`RFC822.SIZE ${messageBytes(entry.message)}`);
+    if (/RFC822\.SIZE|RFC822|BODY(?:\.PEEK)?\[/i.test(items)) attrs.push(`RFC822.SIZE ${messageBytes(entry.message)}`);
     if (/\bENVELOPE\b/.test(upper)) attrs.push(`ENVELOPE ${imapEnvelope(entry.message)}`);
+    if (/\bBODYSTRUCTURE\b/.test(upper)) attrs.push(`BODYSTRUCTURE ${imapBodyStructure(entry.message)}`);
 
     const literal = resolveFetchLiteral(items, entry.message);
     if (!literal) {
@@ -795,9 +796,125 @@ function bodySection(raw, section) {
   if (clean === 'HEADER') return headerBlock(raw);
   if (clean === 'TEXT') return bodyBlock(raw);
   if (clean.startsWith('HEADER.FIELDS')) return selectedHeaders(raw, clean);
+  const match = clean.match(/^(\d+(?:\.\d+)*)(?:\.(MIME|HEADER|TEXT))?$/);
+  if (match) {
+    const node = resolveMimeSection(parseMimeNode(raw), match[1]);
+    if (!node) return '';
+    if (match[2] === 'MIME' || match[2] === 'HEADER') return headerBlock(node.raw);
+    return node.body;
+  }
   return raw;
 }
 
+function imapBodyStructure(message) {
+  return imapMimeNodeStructure(parseMimeNode(normalizeRawMessage(message)));
+}
+
+function imapMimeNodeStructure(node) {
+  if (node.children.length) {
+    return `(${node.children.map(imapMimeNodeStructure).join(' ')}) ${imapNString(node.contentType.subtype.toUpperCase())} ${imapBodyParameters(node.contentType.parameters)}`;
+  }
+
+  const values = [
+    imapNString(node.contentType.primary.toUpperCase()),
+    imapNString(node.contentType.subtype.toUpperCase()),
+    imapBodyParameters(node.contentType.parameters),
+    imapNString(node.headers['content-id'] || ''),
+    imapNString(node.headers['content-description'] || ''),
+    imapNString(node.encoding.toUpperCase()),
+    String(Buffer.byteLength(node.body, 'utf8'))
+  ];
+  if (node.contentType.primary === 'text') values.push(String(imapLineCount(node.body)));
+  return `(${values.join(' ')})`;
+}
+
+function imapBodyParameters(parameters) {
+  const entries = Object.entries(parameters);
+  if (!entries.length) return 'NIL';
+  return `(${entries.map(([name, value]) => `${imapNString(name.toUpperCase())} ${imapNString(value)}`).join(' ')})`;
+}
+
+function imapLineCount(value) {
+  const body = String(value || '').replace(/\r\n$/, '');
+  return body ? body.split('\r\n').length : 0;
+}
+
+function parseMimeNode(rawMessage) {
+  const raw = String(rawMessage || '').replace(/\r?\n/g, '\r\n');
+  const headers = parseMessageHeaders(raw);
+  const contentType = parseMimeContentType(headers['content-type']);
+  const body = bodyBlock(raw);
+  const boundary = contentType.primary === 'multipart' ? contentType.parameters.boundary : '';
+  return {
+    raw,
+    headers,
+    body,
+    contentType,
+    encoding: normalizeTransferEncoding(headers['content-transfer-encoding']),
+    children: boundary ? splitMultipartParts(body, boundary).map(parseMimeNode) : []
+  };
+}
+
+function parseMimeContentType(value) {
+  const source = String(value || 'text/plain');
+  const mediaType = source.split(';', 1)[0].trim().toLowerCase();
+  const [primary = 'text', subtype = 'plain'] = mediaType.split('/');
+  return {
+    primary: normalizeMimeToken(primary, 'text'),
+    subtype: normalizeMimeToken(subtype, 'plain'),
+    parameters: parseMimeParameters(source)
+  };
+}
+
+function parseMimeParameters(value) {
+  const parameters = {};
+  const expression = /;\s*([^=;\s]+)\s*=\s*(?:"((?:\\.|[^"])*)"|([^;]*))/g;
+  for (const match of String(value || '').matchAll(expression)) {
+    const name = String(match[1] || '').trim().toLowerCase();
+    const parameterValue = String(match[2] ?? match[3] ?? '').trim().replace(/\\(.)/g, '$1');
+    if (name) parameters[name] = parameterValue;
+  }
+  return parameters;
+}
+
+function normalizeMimeToken(value, fallback) {
+  const token = String(value || '').trim().replace(/[^a-z0-9!#$&^_.+-]/gi, '');
+  return token || fallback;
+}
+
+function normalizeTransferEncoding(value) {
+  const encoding = String(value || '7bit').trim().toLowerCase();
+  return normalizeMimeToken(encoding, '7bit');
+}
+
+function splitMultipartParts(body, boundary) {
+  const marker = `--${boundary}`;
+  const parts = [];
+  let current = null;
+  for (const line of String(body || '').split('\r\n')) {
+    if (line === marker || line === `${marker}--`) {
+      if (current !== null) parts.push(current.join('\r\n'));
+      if (line === `${marker}--`) break;
+      current = [];
+      continue;
+    }
+    if (current) current.push(line);
+  }
+  return parts.filter((part) => part.trim());
+}
+
+function resolveMimeSection(root, section) {
+  const indexes = String(section || '').split('.').map(Number);
+  if (!indexes.every((index) => Number.isInteger(index) && index > 0)) return null;
+  if (!root.children.length) return indexes.length === 1 && indexes[0] === 1 ? root : null;
+  let node = root;
+  for (const index of indexes) {
+    node = node.children[index - 1];
+    if (!node) return null;
+  }
+  return node;
+}
+
 function selectedHeaders(raw, section) {
   const names = new Set((section.match(/\(([^)]*)\)/)?.[1] || '')
     .split(/\s+/)

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

@@ -64,6 +64,75 @@ test('IMAP clients can log in and fetch mailbox messages', async () => {
   }
 });
 
+test('IMAP exposes MIME body structures and individual parts for Roundcube', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-mime-test-')), 'mail-access-secret');
+  const { mailbox } = createMailboxFixture('mime.example', 'mime-user');
+  createInboundMessage(mailbox, {
+    sender: 'alice@example.net',
+    recipients: ['admin@mime.example'],
+    subject: 'MIME message',
+    messageId: '<mime-message@example.net>',
+    rawMessage: [
+      'From: Alice <alice@example.net>',
+      'To: admin@mime.example',
+      'Subject: MIME message',
+      'MIME-Version: 1.0',
+      'Content-Type: multipart/alternative; boundary="mailhub-boundary"',
+      '',
+      '--mailhub-boundary',
+      'Content-Type: text/plain; charset=UTF-8',
+      'Content-Transfer-Encoding: quoted-printable',
+      '',
+      'Plain message body.',
+      '--mailhub-boundary',
+      'Content-Type: text/html; charset=UTF-8',
+      '',
+      '<p>HTML message body.</p>',
+      '--mailhub-boundary--',
+      ''
+    ].join('\r\n'),
+    textBody: 'Plain message body.',
+    htmlBody: '<p>HTML message body.</p>'
+  });
+
+  const [server] = startMailboxAccessServers({
+    hostname: 'mail.mime.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/);
+    assert.match(await client.command('A1 LOGIN "admin@mime.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
+    await client.command('A2 SELECT INBOX', /A2 OK/);
+
+    const structure = await client.command('A3 UID FETCH 1 (UID BODYSTRUCTURE)', /A3 OK/);
+    assert.match(structure, /BODYSTRUCTURE \(\("TEXT" "PLAIN" \("CHARSET" "UTF-8"\)/);
+    assert.match(structure, /"HTML" \("CHARSET" "UTF-8"\).*"ALTERNATIVE" \("BOUNDARY" "mailhub-boundary"\)\)/);
+
+    const textPart = await client.command('A4 UID FETCH 1 (BODY.PEEK[1])', /A4 OK/);
+    assert.match(textPart, /BODY\[1\] \{\d+\}\r\nPlain message body\./);
+    assert.doesNotMatch(textPart, /Content-Type: text\/plain/);
+
+    const htmlPart = await client.command('A5 UID FETCH 1 (BODY.PEEK[2])', /A5 OK/);
+    assert.match(htmlPart, /BODY\[2\] \{\d+\}\r\n<p>HTML message body\.<\/p>/);
+
+    const mimeHeaders = await client.command('A6 UID FETCH 1 (BODY.PEEK[1.MIME])', /A6 OK/);
+    assert.match(mimeHeaders, /BODY\[1\.MIME\] \{\d+\}\r\nContent-Type: text\/plain; charset=UTF-8/);
+    await client.command('A7 LOGOUT', /A7 OK/);
+    client.close();
+  } 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');