Jelajahi Sumber

fix: decode inbound MIME content

AI-Co-Authored-By: Codex
chendeben 1 bulan lalu
induk
melakukan
7b45f158f9
8 mengubah file dengan 621 tambahan dan 111 penghapusan
  1. 85 5
      src/db.js
  2. 83 18
      src/inbound-mail.js
  3. 62 0
      src/inbound-mime-repair.js
  4. 102 66
      src/mail-access.js
  5. 12 0
      src/server.js
  6. 42 1
      test/inbound-mail.test.js
  7. 124 0
      test/inbound-mime-repair.test.js
  8. 111 21
      test/mail-access.test.js

+ 85 - 5
src/db.js

@@ -187,6 +187,7 @@ export function initDatabase(dataDir, secret = '') {
       subject TEXT NOT NULL DEFAULT '',
       message_id TEXT NOT NULL DEFAULT '',
       raw_message TEXT NOT NULL DEFAULT '',
+      raw_message_bytes BLOB,
       text_body TEXT NOT NULL DEFAULT '',
       html_body TEXT NOT NULL DEFAULT '',
       preview TEXT NOT NULL DEFAULT '',
@@ -261,6 +262,11 @@ export function initDatabase(dataDir, secret = '') {
       updated_at TEXT NOT NULL
     );
 
+    CREATE TABLE IF NOT EXISTS data_migrations (
+      name TEXT PRIMARY KEY,
+      completed_at TEXT NOT NULL
+    );
+
     CREATE TABLE IF NOT EXISTS webhooks (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       user_id INTEGER NOT NULL,
@@ -342,6 +348,7 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('inbound_mailboxes', 'quota_mb', 'INTEGER');
   ensureColumn('inbound_mailboxes', 'expires_at', 'TEXT');
   ensureColumn('inbound_messages', 'folder', "TEXT NOT NULL DEFAULT 'INBOX'");
+  ensureColumn('inbound_messages', 'raw_message_bytes', 'BLOB');
   ensureColumn('webhooks', 'mailbox_id', 'INTEGER');
   migrateWebhookDeliveriesForInbound();
   ensureColumn('api_tokens', 'scopes_json', "TEXT NOT NULL DEFAULT '[\"send\"]'");
@@ -1143,13 +1150,16 @@ export function createInboundMessage(mailbox, message = {}) {
   const textBody = String(message.textBody || '');
   const htmlBody = String(message.htmlBody || '');
   const rawMessage = String(message.rawMessage || '');
+  const rawMessageBytes = message.rawMessageBytes === undefined || message.rawMessageBytes === null
+    ? Buffer.from(rawMessage, 'utf8')
+    : Buffer.from(message.rawMessageBytes);
   if (!isStandardInboundFolder(folder)) createInboundFolder(mailbox, folder);
   const result = requireDb()
     .prepare(`
       INSERT INTO inbound_messages (
         mailbox_id, user_id, domain_id, folder, sender, recipients_json, subject, message_id,
-        raw_message, text_body, html_body, preview, read_state, received_at, created_at, updated_at
-      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'false', ?, ?, ?)
+        raw_message, raw_message_bytes, text_body, html_body, preview, read_state, received_at, created_at, updated_at
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'false', ?, ?, ?)
     `)
     .run(
       mailbox.id,
@@ -1161,9 +1171,10 @@ export function createInboundMessage(mailbox, message = {}) {
       String(message.subject || '').trim() || '(no subject)',
       String(message.messageId || '').trim(),
       rawMessage,
+      rawMessageBytes,
       textBody,
       htmlBody,
-      inboundPreview(textBody || htmlToText(htmlBody) || rawMessage),
+      inboundPreview(textBody || htmlToText(htmlBody)),
       receivedAt,
       receivedAt,
       receivedAt
@@ -1171,6 +1182,72 @@ export function createInboundMessage(mailbox, message = {}) {
   return getInboundMessage(mailbox.userId, result.lastInsertRowid);
 }
 
+export function isDataMigrationComplete(name) {
+  return Boolean(requireDb()
+    .prepare('SELECT 1 FROM data_migrations WHERE name = ?')
+    .get(String(name || '').trim()));
+}
+
+export function markDataMigrationComplete(name) {
+  const cleanName = String(name || '').trim();
+  if (!cleanName) throw new Error('数据迁移名称不能为空。');
+  requireDb()
+    .prepare('INSERT OR IGNORE INTO data_migrations (name, completed_at) VALUES (?, ?)')
+    .run(cleanName, now());
+}
+
+export function listInboundMimeRepairCandidates(afterId = 0, limit = 100) {
+  const cleanAfterId = Math.max(0, Number(afterId) || 0);
+  const cleanLimit = Math.min(500, Math.max(1, Number(limit) || 100));
+  return requireDb()
+    .prepare(`
+      SELECT id, sender, recipients_json, subject, message_id, raw_message,
+             text_body, html_body, preview
+      FROM inbound_messages
+      WHERE id > ?
+        AND deleted_at IS NULL
+        AND raw_message <> ''
+      ORDER BY id ASC
+      LIMIT ?
+    `)
+    .all(cleanAfterId, cleanLimit)
+    .map((row) => ({
+      id: row.id,
+      sender: row.sender,
+      recipients: safeJson(row.recipients_json, []),
+      subject: row.subject,
+      messageId: row.message_id,
+      rawMessage: row.raw_message,
+      textBody: row.text_body,
+      htmlBody: row.html_body,
+      preview: row.preview
+    }));
+}
+
+export function updateInboundMessageMimeContent(id, message = {}) {
+  const textBody = String(message.textBody || '');
+  const htmlBody = String(message.htmlBody || '');
+  const result = requireDb()
+    .prepare(`
+      UPDATE inbound_messages
+      SET sender = ?, recipients_json = ?, subject = ?, message_id = ?,
+          text_body = ?, html_body = ?, preview = ?, updated_at = ?
+      WHERE id = ? AND deleted_at IS NULL
+    `)
+    .run(
+      normalizeEmail(message.sender) || String(message.sender || '').trim(),
+      JSON.stringify(normalizeRecipientList(message.recipients)),
+      String(message.subject || '').trim() || '(no subject)',
+      String(message.messageId || '').trim(),
+      textBody,
+      htmlBody,
+      inboundPreview(textBody || htmlToText(htmlBody)),
+      now(),
+      Number(id)
+    );
+  return result.changes > 0;
+}
+
 export function listInboundMessages(userId, { mailboxId = null, folder = 'INBOX' } = {}) {
   const where = ['msg.user_id = ?', 'msg.deleted_at IS NULL'];
   const params = [userId];
@@ -1352,7 +1429,7 @@ export function listInboundMailboxProtocolMessages(mailbox, { folder = 'INBOX' }
       ORDER BY msg.id ASC
     `)
     .all(Number(mailbox.id), mailbox.userId, selectedFolder)
-    .map((row) => publicInboundMessage(row, { includeBody: true }));
+    .map((row) => publicInboundMessage(row, { includeBody: true, includeRawBytes: true }));
 }
 
 export function markInboundMessageRead(userId, id, read = true) {
@@ -3801,7 +3878,7 @@ function publicInboundMailbox(row, { includeHash = false, includeSecret = false
   };
 }
 
-function publicInboundMessage(row, { includeBody = false } = {}) {
+function publicInboundMessage(row, { includeBody = false, includeRawBytes = false } = {}) {
   if (!row) return null;
   return {
     id: row.id,
@@ -3824,6 +3901,9 @@ function publicInboundMessage(row, { includeBody = false } = {}) {
       rawMessage: row.raw_message,
       textBody: row.text_body,
       htmlBody: row.html_body
+    } : {}),
+    ...(includeRawBytes ? {
+      rawMessageBytes: row.raw_message_bytes ? Buffer.from(row.raw_message_bytes) : Buffer.from(row.raw_message || '', 'utf8')
     } : {})
   };
 }

+ 83 - 18
src/inbound-mail.js

@@ -3,8 +3,9 @@ import { Splitter, Streamer } from '@zone-eu/mailsplit';
 import { extractAddress, parseAddressList } from './mailer.js';
 
 export async function parseInboundMessage(rawMessage, envelopeRecipients = []) {
-  const source = String(rawMessage || '');
-  const textParts = await collectTextParts(source);
+  const sourceBuffer = Buffer.isBuffer(rawMessage) ? rawMessage : Buffer.from(String(rawMessage || ''));
+  const source = sourceBuffer.toString('utf8');
+  const textParts = await collectTextParts(sourceBuffer);
   const textBody = textParts.find((part) => part.contentType === 'text/plain')?.body || '';
   const htmlBody = textParts.find((part) => part.contentType === 'text/html')?.body || '';
   const recipients = normalizeRecipients(envelopeRecipients);
@@ -16,16 +17,18 @@ export async function parseInboundMessage(rawMessage, envelopeRecipients = []) {
   return {
     sender: extractAddress(extractHeader(source, 'from')) || extractAddress(extractHeader(source, 'sender')),
     recipients: recipients.length ? recipients : normalizeRecipients(headerRecipients),
-    subject: decodeHeader(extractHeader(source, 'subject')) || '(no subject)',
+    subject: decodeMimeHeader(extractHeader(source, 'subject')) || '(no subject)',
     messageId: extractHeader(source, 'message-id'),
     rawMessage: source,
     textBody,
     htmlBody,
-    preview: previewText(textBody || htmlToText(htmlBody) || source)
+    preview: previewText(textBody || htmlToText(htmlBody))
   };
 }
 
 async function collectTextParts(rawMessage) {
+  const sourceBuffer = Buffer.isBuffer(rawMessage) ? rawMessage : Buffer.from(String(rawMessage || ''));
+  const source = sourceBuffer.toString('utf8');
   const parts = [];
   const splitter = new Splitter({ ignoreEmbedded: true });
   const streamer = new Streamer((node) => (
@@ -56,11 +59,12 @@ async function collectTextParts(rawMessage) {
     drain.on('error', reject);
     splitter.on('error', reject);
     streamer.on('error', reject);
-    Readable.from([Buffer.from(rawMessage)]).pipe(splitter).pipe(streamer).pipe(drain);
+    Readable.from([sourceBuffer]).pipe(splitter).pipe(streamer).pipe(drain);
   });
 
-  if (!parts.length) {
-    const body = rawMessage.split(/\r?\n\r?\n/).slice(1).join('\n\n').trim();
+  const topLevelContentType = extractHeader(source, 'content-type').split(';', 1)[0].trim().toLowerCase();
+  if (!parts.length && (!topLevelContentType || topLevelContentType === 'text/plain')) {
+    const body = source.split(/\r?\n\r?\n/).slice(1).join('\n\n').trim();
     if (body) parts.push({ contentType: 'text/plain', body });
   }
 
@@ -86,21 +90,82 @@ function extractHeader(rawMessage, name) {
   return headers.find((header) => header.name === name.toLowerCase())?.value || '';
 }
 
-function decodeHeader(value) {
-  return String(value || '').replace(/=\?([^?]+)\?([bq])\?([^?]+)\?=/gi, (_, charset, encoding, encoded) => {
-    const buffer = encoding.toLowerCase() === 'b'
-      ? Buffer.from(encoded, 'base64')
-      : Buffer.from(encoded.replace(/_/g, ' ').replace(/=([a-f0-9]{2})/gi, (_hex, value) => (
-          String.fromCharCode(Number.parseInt(value, 16))
-        )), 'binary');
-    return decodeText(buffer, charset);
-  });
+export function decodeMimeHeader(value) {
+  const source = String(value || '');
+  const expression = /=\?([^?]+)\?([bq])\?([^?]+)\?=/gi;
+  let output = '';
+  let cursor = 0;
+  let previousWasEncoded = false;
+
+  for (const match of source.matchAll(expression)) {
+    const between = source.slice(cursor, match.index);
+    const decoded = decodeEncodedWord(match[1], match[2], match[3]);
+    if (decoded === null) {
+      output += between + match[0];
+      previousWasEncoded = false;
+    } else {
+      output += previousWasEncoded && /^[\t\r\n ]+$/.test(between) ? '' : between;
+      output += decoded;
+      previousWasEncoded = true;
+    }
+    cursor = match.index + match[0].length;
+  }
+
+  return output + source.slice(cursor);
+}
+
+function decodeEncodedWord(charset, encoding, encoded) {
+  const buffer = encoding.toLowerCase() === 'b'
+    ? decodeBase64Word(encoded)
+    : decodeQuotedWord(encoded);
+  if (!buffer) return null;
+  const normalizedCharset = String(charset || '').trim().toLowerCase() === 'utf8'
+    ? 'utf-8'
+    : String(charset || '').trim().toLowerCase();
+  try {
+    return new TextDecoder(normalizedCharset, { fatal: true }).decode(buffer);
+  } catch {
+    return null;
+  }
+}
+
+function decodeBase64Word(value) {
+  const encoded = String(value || '');
+  if (!/^[a-z0-9+/]+={0,2}$/i.test(encoded) || encoded.length % 4 === 1) return null;
+  const unpadded = encoded.replace(/=+$/, '');
+  const padded = unpadded.padEnd(Math.ceil(unpadded.length / 4) * 4, '=');
+  return Buffer.from(padded, 'base64');
+}
+
+function decodeQuotedWord(value) {
+  const encoded = String(value || '');
+  const bytes = [];
+  for (let index = 0; index < encoded.length; index += 1) {
+    const character = encoded[index];
+    if (character === '_') {
+      bytes.push(0x20);
+      continue;
+    }
+    if (character === '=') {
+      const hex = encoded.slice(index + 1, index + 3);
+      if (!/^[a-f0-9]{2}$/i.test(hex)) return null;
+      bytes.push(Number.parseInt(hex, 16));
+      index += 2;
+      continue;
+    }
+    const code = character.charCodeAt(0);
+    if (code < 0x21 || code > 0x7e) return null;
+    bytes.push(code);
+  }
+  return Buffer.from(bytes);
 }
 
 function decodeText(buffer, charset) {
   const normalized = String(charset || 'utf-8').trim().toLowerCase();
-  if (['iso-8859-1', 'latin1', 'latin-1'].includes(normalized)) return buffer.toString('latin1').trim();
-  return buffer.toString('utf8').trim();
+  const decoded = ['iso-8859-1', 'latin1', 'latin-1'].includes(normalized)
+    ? buffer.toString('latin1')
+    : buffer.toString('utf8');
+  return decoded.trim();
 }
 
 function normalizeRecipients(values) {

+ 62 - 0
src/inbound-mime-repair.js

@@ -0,0 +1,62 @@
+import {
+  isDataMigrationComplete,
+  listInboundMimeRepairCandidates,
+  markDataMigrationComplete,
+  updateInboundMessageMimeContent
+} from './db.js';
+import { decodeMimeHeader, parseInboundMessage } from './inbound-mail.js';
+
+const migrationName = 'inbound-mime-content-v1';
+
+export async function repairStoredInboundMime({ batchSize = 100 } = {}) {
+  if (isDataMigrationComplete(migrationName)) return { repaired: 0, failed: 0 };
+
+  let afterId = 0;
+  let repaired = 0;
+  let failed = 0;
+
+  while (true) {
+    const candidates = listInboundMimeRepairCandidates(afterId, batchSize);
+    if (!candidates.length) break;
+
+    for (const candidate of candidates) {
+      afterId = candidate.id;
+      try {
+        const parsed = await parseInboundMessage(candidate.rawMessage, candidate.recipients);
+        const decodedSubject = decodeMimeHeader(candidate.subject);
+        const rawBody = extractRawBody(candidate.rawMessage);
+        const storedBodyIsRaw = Boolean(rawBody) && candidate.textBody.trim() === rawBody.trim();
+        const previewIsRaw = candidate.preview === normalizePreview(candidate.rawMessage);
+        const nextMessage = {
+          sender: candidate.sender,
+          recipients: candidate.recipients,
+          subject: decodedSubject,
+          messageId: candidate.messageId,
+          textBody: storedBodyIsRaw ? parsed.textBody : candidate.textBody,
+          htmlBody: storedBodyIsRaw ? parsed.htmlBody : candidate.htmlBody
+        };
+        const changed = nextMessage.subject !== candidate.subject
+          || nextMessage.textBody !== candidate.textBody
+          || nextMessage.htmlBody !== candidate.htmlBody
+          || previewIsRaw;
+        const updated = changed && updateInboundMessageMimeContent(candidate.id, nextMessage);
+        if (updated) repaired += 1;
+      } catch {
+        failed += 1;
+      }
+    }
+  }
+
+  if (!failed) markDataMigrationComplete(migrationName);
+  return { repaired, failed };
+}
+
+function extractRawBody(rawMessage) {
+  const source = String(rawMessage || '');
+  const separator = source.match(/\r?\n\r?\n/);
+  return separator ? source.slice(separator.index + separator[0].length) : '';
+}
+
+function normalizePreview(value) {
+  return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
+}

+ 102 - 66
src/mail-access.js

@@ -12,6 +12,7 @@ import {
   softDeleteInboundMessages,
   verifyInboundMailboxCredential
 } from './db.js';
+import { parseInboundMessage } from './inbound-mail.js';
 
 export function startMailboxAccessServers(config) {
   const tlsMaterial = loadTlsMaterial(config);
@@ -81,7 +82,7 @@ class ImapSession {
   constructor(socket, config) {
     this.socket = socket;
     this.config = config;
-    this.buffer = '';
+    this.buffer = Buffer.alloc(0);
     this.authenticated = false;
     this.user = null;
     this.mailbox = null;
@@ -91,34 +92,46 @@ class ImapSession {
     this.deletedUids = new Set();
     this.authContinuation = null;
     this.pendingAppend = null;
+    this.appendProcessing = false;
     this.idleTag = '';
     this.onDataBound = (chunk) => this.onData(chunk);
-    socket.setEncoding('utf8');
     socket.on('data', this.onDataBound);
     socket.on('error', () => null);
     this.write(`* OK ${config.hostname} MailHub IMAP ready`);
   }
 
   onData(chunk) {
-    this.buffer += chunk;
+    const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ''), 'utf8');
+    if (incoming.length) this.buffer = Buffer.concat([this.buffer, incoming]);
+    if (this.appendProcessing) return;
     while (true) {
       if (this.pendingAppend) {
-        const literal = takeUtf8Literal(this.buffer, this.pendingAppend.bytes);
+        const literal = takeLiteralBytes(this.buffer, this.pendingAppend.bytes);
         if (!literal) return;
         this.buffer = literal.rest;
-        if (this.buffer.startsWith('\r\n')) this.buffer = this.buffer.slice(2);
-        else if (this.buffer.startsWith('\n')) this.buffer = this.buffer.slice(1);
+        if (this.buffer[0] === 0x0d && this.buffer[1] === 0x0a) this.buffer = this.buffer.subarray(2);
+        else if (this.buffer[0] === 0x0a) this.buffer = this.buffer.subarray(1);
         const pending = this.pendingAppend;
         this.pendingAppend = null;
-        this.finishAppend(pending, literal.value);
-        continue;
+        this.appendProcessing = true;
+        void this.finishAppend(pending, literal.value)
+          .catch((error) => {
+            console.error(`MailHub IMAP APPEND failed: ${error.message || error}`);
+            this.write(`${pending.tag} NO APPEND failed`);
+          })
+          .finally(() => {
+            this.appendProcessing = false;
+            this.onData('');
+          });
+        return;
       }
 
-      const index = this.buffer.indexOf('\n');
+      const index = this.buffer.indexOf(0x0a);
       if (index === -1) return;
-      const line = this.buffer.slice(0, index).replace(/\r$/, '');
-      this.buffer = this.buffer.slice(index + 1);
-      this.onLine(line);
+      let line = this.buffer.subarray(0, index);
+      this.buffer = this.buffer.subarray(index + 1);
+      if (line.at(-1) === 0x0d) line = line.subarray(0, -1);
+      this.onLine(line.toString('utf8'));
     }
   }
 
@@ -284,17 +297,14 @@ class ImapSession {
     this.write('+ Ready for literal data');
   }
 
-  finishAppend(pending, rawMessage) {
-    const normalizedRaw = normalizeRawMessage({ rawMessage });
-    const headers = parseMessageHeaders(normalizedRaw);
+  async finishAppend(pending, rawMessage) {
+    const parsedMessage = await parseInboundMessage(rawMessage);
+    const normalizedRaw = normalizeRawMessage(parsedMessage);
     const message = createInboundMessage(this.mailbox, {
+      ...parsedMessage,
       folder: pending.folder,
-      sender: extractFirstEmail(headers.from) || headers.from || '',
-      recipients: extractEmailAddresses(headers.to),
-      subject: headers.subject || '(no subject)',
-      messageId: headers['message-id'] || '',
       rawMessage: normalizedRaw,
-      textBody: bodyBlock(normalizedRaw).trim()
+      rawMessageBytes: rawMessage
     });
     if (pending.flags.has('\\SEEN')) markInboundMessageRead(this.mailbox.userId, message.id, true);
     this.write(`${pending.tag} OK APPEND completed`);
@@ -341,7 +351,10 @@ class ImapSession {
       this.write(`* ${entry.seq} FETCH (${attrs.join(' ')})`);
       return;
     }
-    const prefix = `* ${entry.seq} FETCH (${[...attrs, `${literal.label} {${Buffer.byteLength(literal.value, 'utf8')}}`].join(' ')}\r\n`;
+    const literalBytes = Buffer.isBuffer(literal.value)
+      ? literal.value.length
+      : Buffer.byteLength(literal.value, 'utf8');
+    const prefix = `* ${entry.seq} FETCH (${[...attrs, `${literal.label} {${literalBytes}}`].join(' ')}\r\n`;
     this.socket.write(prefix);
     this.socket.write(literal.value);
     this.socket.write('\r\n)\r\n');
@@ -406,9 +419,8 @@ class ImapSession {
       secureContext: this.config.secureContext
     });
     this.socket = secureSocket;
-    this.buffer = '';
+    this.buffer = Buffer.alloc(0);
     this.config = { ...this.config, tlsActive: true, startTlsAvailable: false };
-    secureSocket.setEncoding('utf8');
     secureSocket.on('data', this.onDataBound);
     secureSocket.on('error', () => null);
   }
@@ -522,17 +534,17 @@ class Pop3Session {
 
   stat() {
     const active = this.activeMessages();
-    this.write(`+OK ${active.length} ${active.reduce((total, item) => total + messageBytes(item.message), 0)}`);
+    this.write(`+OK ${active.length} ${active.reduce((total, item) => total + pop3MessageBytes(item.message), 0)}`);
   }
 
   list(rest) {
     if (rest) {
       const entry = this.messageByNumber(rest);
       if (!entry) return this.write('-ERR No such message');
-      return this.write(`+OK ${entry.index} ${messageBytes(entry.message)}`);
+      return this.write(`+OK ${entry.index} ${pop3MessageBytes(entry.message)}`);
     }
     this.write('+OK Message list follows');
-    for (const entry of this.activeMessages()) this.write(`${entry.index} ${messageBytes(entry.message)}`);
+    for (const entry of this.activeMessages()) this.write(`${entry.index} ${pop3MessageBytes(entry.message)}`);
     this.write('.');
   }
 
@@ -550,9 +562,10 @@ class Pop3Session {
   retr(rest) {
     const entry = this.messageByNumber(rest);
     if (!entry) return this.write('-ERR No such message');
-    const rawMessage = normalizeRawMessage(entry.message);
-    this.write(`+OK ${Buffer.byteLength(rawMessage, 'utf8')} octets`);
-    this.socket.write(`${dotStuff(rawMessage)}\r\n.\r\n`);
+    const rawMessage = pop3RawMessageBytes(entry.message);
+    this.write(`+OK ${rawMessage.length} octets`);
+    this.socket.write(dotStuffBytes(rawMessage));
+    this.socket.write('.\r\n');
   }
 
   top(rest) {
@@ -560,9 +573,13 @@ class Pop3Session {
     const entry = this.messageByNumber(messageNumber);
     if (!entry) return this.write('-ERR No such message');
     const lineCount = Math.max(0, Number(lineCountRaw || 0) || 0);
-    const preview = topLines(normalizeRawMessage(entry.message), lineCount);
+    const preview = Buffer.from(
+      topLines(pop3RawMessageBytes(entry.message).toString('latin1'), lineCount),
+      'latin1'
+    );
     this.write('+OK Top of message follows');
-    this.socket.write(`${dotStuff(preview)}\r\n.\r\n`);
+    this.socket.write(dotStuffBytes(ensureTrailingCrlf(preview)));
+    this.socket.write('.\r\n');
   }
 
   dele(rest) {
@@ -681,16 +698,13 @@ function tokenizeImap(value) {
   return tokens;
 }
 
-function takeUtf8Literal(input, byteCount) {
-  let bytes = 0;
-  let end = 0;
-  for (const char of String(input || '')) {
-    bytes += Buffer.byteLength(char, 'utf8');
-    end += char.length;
-    if (bytes === byteCount) return { value: input.slice(0, end), rest: input.slice(end) };
-    if (bytes > byteCount) return null;
-  }
-  return byteCount === 0 ? { value: '', rest: input } : null;
+function takeLiteralBytes(input, byteCount) {
+  const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input || '');
+  if (buffer.length < byteCount) return null;
+  return {
+    value: buffer.subarray(0, byteCount),
+    rest: buffer.subarray(byteCount)
+  };
 }
 
 function parseMessageHeaders(rawMessage) {
@@ -710,14 +724,6 @@ function parseMessageHeaders(rawMessage) {
   return headers;
 }
 
-function extractEmailAddresses(value) {
-  return String(value || '').match(/[^\s<>,;"]+@[^\s<>,;"]+/g) || [];
-}
-
-function extractFirstEmail(value) {
-  return extractEmailAddresses(value)[0] || '';
-}
-
 function splitFirst(value) {
   const input = String(value || '').trim();
   const index = input.search(/\s/);
@@ -777,16 +783,17 @@ function resolveSetValue(value, messages, byUid) {
 }
 
 function resolveFetchLiteral(items, message) {
-  const raw = normalizeRawMessage(message);
-  if (/\bRFC822\b(?!\.SIZE|\.HEADER|\.TEXT)/i.test(items)) return { label: 'RFC822', value: raw };
-  if (/RFC822\.HEADER/i.test(items)) return { label: 'RFC822.HEADER', value: headerBlock(raw) };
-  if (/RFC822\.TEXT/i.test(items)) return { label: 'RFC822.TEXT', value: bodyBlock(raw) };
+  const rawBytes = exactRawMessageBytes(message);
+  const raw = rawBytes.toString('latin1');
+  if (/\bRFC822\b(?!\.SIZE|\.HEADER|\.TEXT)/i.test(items)) return { label: 'RFC822', value: rawBytes };
+  if (/RFC822\.HEADER/i.test(items)) return { label: 'RFC822.HEADER', value: Buffer.from(headerBlock(raw), 'latin1') };
+  if (/RFC822\.TEXT/i.test(items)) return { label: 'RFC822.TEXT', value: Buffer.from(bodyBlock(raw), 'latin1') };
   const bodyMatch = String(items || '').match(/BODY(?:\.PEEK)?\[([^\]]*)\]/i);
   if (!bodyMatch) return null;
   const section = bodyMatch[1] || '';
   return {
     label: `BODY[${section}]`,
-    value: bodySection(raw, section)
+    value: section ? Buffer.from(bodySection(raw, section), 'latin1') : rawBytes
   };
 }
 
@@ -807,7 +814,7 @@ function bodySection(raw, section) {
 }
 
 function imapBodyStructure(message) {
-  return imapMimeNodeStructure(parseMimeNode(normalizeRawMessage(message)));
+  return imapMimeNodeStructure(parseMimeNode(exactRawMessageBytes(message).toString('latin1')));
 }
 
 function imapMimeNodeStructure(node) {
@@ -822,7 +829,7 @@ function imapMimeNodeStructure(node) {
     imapNString(node.headers['content-id'] || ''),
     imapNString(node.headers['content-description'] || ''),
     imapNString(node.encoding.toUpperCase()),
-    String(Buffer.byteLength(node.body, 'utf8'))
+    String(Buffer.byteLength(node.body, 'latin1'))
   ];
   if (node.contentType.primary === 'text') values.push(String(imapLineCount(node.body)));
   return `(${values.join(' ')})`;
@@ -982,7 +989,45 @@ function uidNext(messages) {
 }
 
 function messageBytes(message) {
-  return Buffer.byteLength(normalizeRawMessage(message), 'utf8');
+  return exactRawMessageBytes(message).length;
+}
+
+function pop3MessageBytes(message) {
+  return pop3RawMessageBytes(message).length;
+}
+
+function pop3RawMessageBytes(message) {
+  const normalized = exactRawMessageBytes(message)
+    .toString('latin1')
+    .replace(/\r?\n/g, '\r\n');
+  return ensureTrailingCrlf(Buffer.from(normalized, 'latin1'));
+}
+
+function ensureTrailingCrlf(value) {
+  const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value || '');
+  return buffer.length >= 2 && buffer.at(-2) === 0x0d && buffer.at(-1) === 0x0a
+    ? buffer
+    : Buffer.concat([buffer, Buffer.from('\r\n')]);
+}
+
+function dotStuffBytes(value) {
+  const input = Buffer.isBuffer(value) ? value : Buffer.from(value || '');
+  const extraDots = input.reduce((count, byte, index) => (
+    byte === 0x2e && (index === 0 || input[index - 1] === 0x0a) ? count + 1 : count
+  ), 0);
+  const output = Buffer.alloc(input.length + extraDots);
+  let offset = 0;
+  for (let index = 0; index < input.length; index += 1) {
+    if (input[index] === 0x2e && (index === 0 || input[index - 1] === 0x0a)) output[offset++] = 0x2e;
+    output[offset++] = input[index];
+  }
+  return output;
+}
+
+function exactRawMessageBytes(message) {
+  if (Buffer.isBuffer(message.rawMessageBytes)) return message.rawMessageBytes;
+  if (message.rawMessageBytes instanceof Uint8Array) return Buffer.from(message.rawMessageBytes);
+  return Buffer.from(normalizeRawMessage(message), 'utf8');
 }
 
 function normalizeRawMessage(message) {
@@ -1002,15 +1047,6 @@ function fallbackRawMessage(message) {
   ].filter((line, index) => line || index >= 5).join('\r\n');
 }
 
-function dotStuff(rawMessage) {
-  return String(rawMessage || '')
-    .replace(/\r?\n/g, '\r\n')
-    .split('\r\n')
-    .map((line) => line.startsWith('.') ? `.${line}` : line)
-    .join('\r\n')
-    .replace(/\r\n$/, '');
-}
-
 function topLines(rawMessage, lineCount) {
   const header = headerBlock(rawMessage).replace(/\r\n\r\n$/, '');
   const lines = bodyBlock(rawMessage).split('\r\n').slice(0, lineCount).join('\r\n');

+ 12 - 0
src/server.js

@@ -110,6 +110,7 @@ import {
   publicMailboxAccessListeners,
   startMailboxAccessServers
 } from './mail-access.js';
+import { repairStoredInboundMime } from './inbound-mime-repair.js';
 import {
   parseSubmissionListeners,
   publicSubmissionListeners,
@@ -211,6 +212,17 @@ const emailVerificationPurpose = 'email_verification';
 const passwordResetPurpose = 'password_reset';
 
 initDatabase(envConfig.dataDir, envConfig.sessionSecret);
+try {
+  const inboundMimeRepair = await repairStoredInboundMime();
+  if (inboundMimeRepair.repaired) {
+    console.log(`Reparsed ${inboundMimeRepair.repaired} stored inbound MIME message(s).`);
+  }
+  if (inboundMimeRepair.failed) {
+    console.warn(`Unable to reparse ${inboundMimeRepair.failed} stored inbound MIME message(s).`);
+  }
+} catch (error) {
+  console.warn(`Stored inbound MIME repair skipped: ${error.message || error}`);
+}
 const admin = seedAdminUser({
   username: envConfig.adminUser,
   email: envConfig.adminEmail,

+ 42 - 1
test/inbound-mail.test.js

@@ -1,7 +1,7 @@
 import assert from 'node:assert/strict';
 import { test } from 'node:test';
 
-import { parseInboundMessage } from '../src/inbound-mail.js';
+import { decodeMimeHeader, parseInboundMessage } from '../src/inbound-mail.js';
 
 test('parseInboundMessage extracts common headers and text bodies from MIME', async () => {
   const rawMessage = [
@@ -36,3 +36,44 @@ test('parseInboundMessage extracts common headers and text bodies from MIME', as
   assert.match(parsed.htmlBody, /<strong>HTML<\/strong>/);
   assert.equal(parsed.preview, 'Hello plain body.');
 });
+
+test('decodeMimeHeader joins folded UTF-8 encoded words without introducing spaces', () => {
+  const encoded = [
+    '=?UTF-8?Q?=E6=A0=B8=E4=BA=91?=',
+    ' =?UTF-8?Q?=E8=AE=A1=E7=AE=97=E5=B9=B3=E5=8F=B0?=',
+    ' =?UTF-8?Q?_MailHub_=E6=B5=8B=E8=AF=95?='
+  ].join('\r\n');
+
+  assert.equal(decodeMimeHeader(encoded), '核云计算平台 MailHub 测试');
+});
+
+test('decodeMimeHeader preserves plain text and malformed encoded words', () => {
+  assert.equal(decodeMimeHeader('literal?= =?UTF-8?Q?B?='), 'literal?= B');
+  assert.equal(decodeMimeHeader('=?UTF-8?B?!!!!?='), '=?UTF-8?B?!!!!?=');
+  assert.equal(decodeMimeHeader('=?UTF-8?B?/w==?='), '=?UTF-8?B?/w==?=');
+});
+
+test('parseInboundMessage does not expose attachment-only multipart wire data as text', async () => {
+  const rawMessage = [
+    'From: Alice <alice@example.net>',
+    'To: Support <support@inbound.example>',
+    'Subject: Attachment only',
+    'MIME-Version: 1.0',
+    'Content-Type: multipart/mixed; boundary="mixed"',
+    '',
+    '--mixed',
+    'Content-Type: application/pdf; name="report.pdf"',
+    'Content-Disposition: attachment; filename="report.pdf"',
+    'Content-Transfer-Encoding: base64',
+    '',
+    'JVBERi0xLjQ=',
+    '--mixed--',
+    ''
+  ].join('\r\n');
+
+  const parsed = await parseInboundMessage(rawMessage, ['support@inbound.example']);
+
+  assert.equal(parsed.textBody, '');
+  assert.equal(parsed.htmlBody, '');
+  assert.equal(parsed.preview, '');
+});

+ 124 - 0
test/inbound-mime-repair.test.js

@@ -0,0 +1,124 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  createDomain,
+  createInboundMailbox,
+  createInboundMessage,
+  createUser,
+  getInboundMessage,
+  initDatabase
+} from '../src/db.js';
+import { repairStoredInboundMime } from '../src/inbound-mime-repair.js';
+
+test('repairStoredInboundMime reparses legacy APPEND records and is idempotent', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-inbound-mime-repair-')), 'repair-secret');
+  const user = createUser({ username: 'repair-user', email: 'repair@example.com', password: 'password123' });
+  createDomain(user.id, {
+    domain: 'repair.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.repair.example',
+    sendingIp: '192.0.2.40',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = createInboundMailbox(user.id, {
+    address: 'admin@repair.example',
+    password: 'mailbox-pass-123'
+  });
+  const rawMessage = [
+    'From: Admin <admin@repair.example>',
+    'To: Bob <bob@example.net>',
+    'Subject: =?UTF-8?Q?=E6=A0=B8=E4=BA=91?=',
+    ' =?UTF-8?Q?=E8=AE=A1=E7=AE=97?=',
+    'Message-ID: <legacy-append@repair.example>',
+    'MIME-Version: 1.0',
+    'Content-Type: multipart/alternative; boundary="legacy-boundary"',
+    '',
+    '--legacy-boundary',
+    'Content-Type: text/plain; charset=UTF-8',
+    'Content-Transfer-Encoding: base64',
+    '',
+    Buffer.from('修复后的正文', 'utf8').toString('base64'),
+    '--legacy-boundary',
+    'Content-Type: text/html; charset=UTF-8',
+    '',
+    '<p>Repaired HTML body.</p>',
+    '--legacy-boundary--',
+    ''
+  ].join('\r\n');
+  const legacyMessage = createInboundMessage(mailbox, {
+    folder: 'Sent',
+    sender: 'admin@repair.example',
+    recipients: ['bob@example.net'],
+    subject: '=?UTF-8?Q?=E6=A0=B8=E4=BA=91?= =?UTF-8?Q?=E8=AE=A1=E7=AE=97?=',
+    messageId: '<legacy-append@repair.example>',
+    rawMessage,
+    textBody: rawMessage.split('\r\n\r\n').slice(1).join('\r\n\r\n')
+  });
+
+  const attachmentRaw = [
+    'From: Admin <admin@repair.example>',
+    'To: Bob <bob@example.net>',
+    'Subject: Attached notes',
+    'Content-Type: multipart/mixed; boundary="attachment-boundary"',
+    '',
+    '--attachment-boundary',
+    'Content-Type: text/plain; charset=UTF-8',
+    'Content-Disposition: attachment; filename="notes.txt"',
+    '',
+    'Attachment content',
+    '--attachment-boundary--',
+    ''
+  ].join('\r\n');
+  const attachmentMessage = createInboundMessage(mailbox, {
+    folder: 'Sent',
+    sender: 'admin@repair.example',
+    recipients: ['bob@example.net'],
+    subject: 'Attached notes',
+    rawMessage: attachmentRaw,
+    textBody: attachmentRaw.split('\r\n\r\n').slice(1).join('\r\n\r\n')
+  });
+
+  const legitimateMessage = createInboundMessage(mailbox, {
+    folder: 'Sent',
+    sender: 'admin@repair.example',
+    recipients: ['bob@example.net'],
+    subject: 'Legitimate body',
+    rawMessage: [
+      'From: Admin <admin@repair.example>',
+      'To: Bob <bob@example.net>',
+      'Subject: Legitimate body',
+      'Content-Type: multipart/alternative; boundary="legitimate-boundary"',
+      '',
+      '--legitimate-boundary',
+      'Content-Type: text/plain; charset=UTF-8',
+      '',
+      '-- signature',
+      '--legitimate-boundary--',
+      ''
+    ].join('\r\n'),
+    textBody: '-- signature'
+  });
+
+  assert.deepEqual(await repairStoredInboundMime(), { repaired: 2, failed: 0 });
+
+  const repaired = getInboundMessage(user.id, legacyMessage.id);
+  assert.equal(repaired.subject, '核云计算');
+  assert.equal(repaired.textBody, '修复后的正文');
+  assert.match(repaired.htmlBody, /Repaired HTML body/);
+  assert.equal(repaired.preview, '修复后的正文');
+  assert.match(repaired.rawMessage, /Subject: =\?UTF-8\?Q\?/);
+  const repairedAttachment = getInboundMessage(user.id, attachmentMessage.id);
+  assert.equal(repairedAttachment.textBody, '');
+  assert.equal(repairedAttachment.preview, '');
+  assert.equal(getInboundMessage(user.id, legitimateMessage.id).textBody, '-- signature');
+  assert.deepEqual(await repairStoredInboundMime(), { repaired: 0, failed: 0 });
+});

+ 111 - 21
test/mail-access.test.js

@@ -10,6 +10,7 @@ import {
   createInboundMailbox,
   createInboundMessage,
   createUser,
+  getInboundMessage,
   initDatabase,
   listInboundMessages
 } from '../src/db.js';
@@ -172,7 +173,7 @@ test('IMAP exposes standard folders expected by mainstream clients', async () =>
 
 test('IMAP APPEND stores sent messages in the Sent folder', async () => {
   initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-append-test-')), 'mail-access-secret');
-  createMailboxFixture('append.example', 'append-user');
+  const { user } = createMailboxFixture('append.example', 'append-user');
 
   const [server] = startMailboxAccessServers({
     hostname: 'mail.append.example',
@@ -189,10 +190,24 @@ test('IMAP APPEND stores sent messages in the Sent folder', async () => {
     const sentMessage = [
       'From: Admin <admin@append.example>',
       'To: Bob <bob@example.net>',
-      'Subject: Sent copy',
+      'Subject: =?UTF-8?Q?=E6=A0=B8=E4=BA=91?=',
+      ' =?UTF-8?Q?=E8=AE=A1=E7=AE=97?=',
       'Message-ID: <sent-copy@append.example>',
+      'MIME-Version: 1.0',
+      'Content-Type: multipart/alternative; boundary="sent-boundary"',
+      '',
+      '--sent-boundary',
+      'Content-Type: text/plain; charset=UTF-8',
+      'Content-Transfer-Encoding: base64',
+      '',
+      Buffer.from('工单正文', 'utf8').toString('base64'),
+      '--sent-boundary',
+      'Content-Type: text/html; charset=UTF-8',
+      'Content-Transfer-Encoding: quoted-printable',
       '',
-      'This copy belongs in Sent.'
+      '<p>Sent HTML body.</p>',
+      '--sent-boundary--',
+      ''
     ].join('\r\n');
 
     client = await connectClient(server.address().port);
@@ -204,12 +219,40 @@ test('IMAP APPEND stores sent messages in the Sent folder', async () => {
     assert.match(selectedSent, /\* 1 EXISTS/);
     const fetchedSent = await client.command('A4 UID FETCH 1:* (UID FLAGS BODY.PEEK[])', /A4 OK/);
     assert.match(fetchedSent, /FLAGS \(\\Seen\)/);
-    assert.match(fetchedSent, /Subject: Sent copy/);
-    assert.match(fetchedSent, /This copy belongs in Sent\./);
+    assert.match(fetchedSent, /Subject: =\?UTF-8\?Q\?/);
+    assert.match(fetchedSent, /--sent-boundary/);
+
+    const [storedSummary] = listInboundMessages(user.id, { folder: 'Sent' });
+    const storedMessage = getInboundMessage(user.id, storedSummary.id);
+    assert.equal(storedMessage.subject, '核云计算');
+    assert.equal(storedMessage.textBody, '工单正文');
+    assert.match(storedMessage.htmlBody, /Sent HTML body/);
+    assert.equal(storedMessage.preview, '工单正文');
+    assert.match(storedMessage.rawMessage, /--sent-boundary/);
+
+    const latin1Message = Buffer.concat([
+      Buffer.from([
+        'From: Admin <admin@append.example>',
+        'To: Bob <bob@example.net>',
+        'Subject: Latin1 copy',
+        'Content-Type: text/plain; charset=ISO-8859-1',
+        'Content-Transfer-Encoding: 8bit',
+        '',
+        'caf'
+      ].join('\r\n'), 'ascii'),
+      Buffer.from([0xe9])
+    ]);
+    await client.append(`A5 APPEND Sent {${latin1Message.length}}`, latin1Message, /A5 OK/);
+    const latin1Summary = listInboundMessages(user.id, { folder: 'Sent' })
+      .find((message) => message.subject === 'Latin1 copy');
+    assert.equal(getInboundMessage(user.id, latin1Summary.id).textBody, 'café');
+    await client.command('A6 SELECT Sent', /A6 OK/);
+    const latin1Fetch = await client.commandBytes('A7 UID FETCH 1:* (UID BODY.PEEK[])', /A7 OK/);
+    assert.equal(latin1Fetch.includes(latin1Message), true);
 
-    const selectedInbox = await client.command('A5 SELECT INBOX', /A5 OK/);
+    const selectedInbox = await client.command('A8 SELECT INBOX', /A8 OK/);
     assert.match(selectedInbox, /\* 0 EXISTS/);
-    await client.command('A6 LOGOUT', /A6 OK/);
+    await client.command('A9 LOGOUT', /A9 OK/);
     client.close();
   } finally {
     client?.close();
@@ -220,22 +263,47 @@ test('IMAP APPEND stores sent messages in the Sent folder', async () => {
 test('POP3 clients can retrieve and delete messages on quit', async () => {
   initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-pop3-test-')), 'mail-access-secret');
   const { user, mailbox } = createMailboxFixture('pop3.example', 'pop3-user');
+  const firstRawMessage = [
+    'From: Bob <bob@example.net>',
+    'To: admin@pop3.example',
+    'Subject: POP3 hello',
+    'Message-ID: <pop3-hello@example.net>',
+    '',
+    'Hello through POP3.'
+  ].join('\r\n');
   createInboundMessage(mailbox, {
     sender: 'bob@example.net',
     recipients: ['admin@pop3.example'],
     subject: 'POP3 hello',
     messageId: '<pop3-hello@example.net>',
-    rawMessage: [
-      'From: Bob <bob@example.net>',
+    rawMessage: firstRawMessage,
+    textBody: 'Hello through POP3.'
+  });
+  const latin1RawMessage = Buffer.concat([
+    Buffer.from([
+      'From: Alice <alice@example.net>',
       'To: admin@pop3.example',
-      'Subject: POP3 hello',
-      'Message-ID: <pop3-hello@example.net>',
+      'Subject: Latin1 POP3',
+      'Content-Type: text/plain; charset=ISO-8859-1',
+      'Content-Transfer-Encoding: 8bit',
       '',
-      'Hello through POP3.'
-    ].join('\r\n'),
-    textBody: 'Hello through POP3.'
+      'caf'
+    ].join('\r\n'), 'ascii'),
+    Buffer.from([0xe9])
+  ]);
+  createInboundMessage(mailbox, {
+    sender: 'alice@example.net',
+    recipients: ['admin@pop3.example'],
+    subject: 'Latin1 POP3',
+    rawMessage: latin1RawMessage.toString('latin1'),
+    rawMessageBytes: latin1RawMessage,
+    textBody: 'café'
   });
 
+  const firstPop3Message = Buffer.from(`${firstRawMessage}\r\n`, 'utf8');
+  const latin1Pop3Message = Buffer.concat([latin1RawMessage, Buffer.from('\r\n')]);
+  const totalOctets = firstPop3Message.length + latin1Pop3Message.length;
+
   const [server] = startMailboxAccessServers({
     hostname: 'mail.pop3.example',
     imapEnabled: false,
@@ -251,12 +319,22 @@ test('POP3 clients can retrieve and delete messages on quit', async () => {
     await client.readUntil(/\+OK .* POP3 ready\r\n/);
     assert.match(await client.command('USER admin@pop3.example', /\+OK/), /User accepted/);
     assert.match(await client.command('PASS mailbox-pass-123', /\+OK/), /ready/);
-    assert.match(await client.command('STAT', /\+OK \d+ \d+/), /\+OK 1 /);
+    assert.match(await client.command('STAT', /\+OK \d+ \d+/), new RegExp(`\\+OK 2 ${totalOctets}`));
+    const listed = await client.command('LIST', /\r\n\.\r\n/);
+    assert.match(listed, new RegExp(`1 ${firstPop3Message.length}\\r\\n`));
+    assert.match(listed, new RegExp(`2 ${latin1Pop3Message.length}\\r\\n`));
     assert.match(await client.command('UIDL 1', /\+OK 1 mh-1/), /\+OK 1 mh-1/);
     const retrieved = await client.command('RETR 1', /\r\n\.\r\n/);
     assert.match(retrieved, /Subject: POP3 hello/);
     assert.match(retrieved, /Hello through POP3\./);
+    const latin1Retrieved = await client.commandBytes('RETR 2', /\r\n\.\r\n/);
+    assert.deepEqual(latin1Retrieved, Buffer.concat([
+      Buffer.from(`+OK ${latin1Pop3Message.length} octets\r\n`),
+      latin1Pop3Message,
+      Buffer.from('.\r\n')
+    ]));
     assert.match(await client.command('DELE 1', /\+OK/), /deleted/);
+    assert.match(await client.command('DELE 2', /\+OK/), /deleted/);
     await client.command('QUIT', /\+OK Bye/);
     client.close();
     assert.equal(listInboundMessages(user.id).length, 0);
@@ -289,19 +367,23 @@ function createMailboxFixture(domainName, username) {
 function connectClient(port) {
   return new Promise((resolve, reject) => {
     const socket = net.createConnection({ host: '127.0.0.1', port });
-    socket.setEncoding('utf8');
     socket.setTimeout(5000);
     let buffer = '';
+    let rawBuffer = Buffer.alloc(0);
     const waiters = [];
 
     socket.on('data', (chunk) => {
-      buffer += chunk;
+      const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+      rawBuffer = Buffer.concat([rawBuffer, bytes]);
+      buffer += bytes.toString('utf8');
       for (const waiter of [...waiters]) {
         if (waiter.pattern.test(buffer)) {
           waiters.splice(waiters.indexOf(waiter), 1);
           const output = buffer;
+          const rawOutput = rawBuffer;
           buffer = '';
-          waiter.resolve(output);
+          rawBuffer = Buffer.alloc(0);
+          waiter.resolve(waiter.raw ? rawOutput : output);
         }
       }
     });
@@ -310,21 +392,29 @@ function connectClient(port) {
         socket.write(`${command}\r\n`);
         return this.readUntil(pattern);
       },
+      commandBytes(command, pattern) {
+        socket.write(`${command}\r\n`);
+        return this.readUntil(pattern, true);
+      },
       async append(command, literal, pattern) {
         socket.write(`${command}\r\n`);
         await this.readUntil(/^\+ /m);
-        socket.write(`${literal}\r\n`);
+        socket.write(literal);
+        socket.write('\r\n');
         return this.readUntil(pattern);
       },
-      readUntil(pattern) {
+      readUntil(pattern, raw = false) {
         if (pattern.test(buffer)) {
           const output = buffer;
+          const rawOutput = rawBuffer;
           buffer = '';
-          return Promise.resolve(output);
+          rawBuffer = Buffer.alloc(0);
+          return Promise.resolve(raw ? rawOutput : output);
         }
         return new Promise((waitResolve, waitReject) => {
           const waiter = {
             pattern,
+            raw,
             resolve(output) {
               clearTimeout(waiter.timer);
               waitResolve(output);