浏览代码

feat: support standard IMAP folders

AI-Co-Authored-By: Codex
chendeben 1 月之前
父节点
当前提交
20269e3576
共有 3 个文件被更改,包括 375 次插入26 次删除
  1. 123 9
      src/db.js
  2. 144 16
      src/mail-access.js
  3. 108 1
      test/mail-access.test.js

+ 123 - 9
src/db.js

@@ -19,6 +19,7 @@ import {
 let db;
 let secretKey = '';
 export const USER_STATUSES = new Set(['pending_email', 'pending_review', 'active', 'disabled']);
+export const STANDARD_INBOUND_FOLDERS = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Archive'];
 const auditSecretKeyPattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
 const auditDescriptorKeyPattern = /^(field|name|path|key|header)$/i;
 const auditDescriptorValuePattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
@@ -177,6 +178,7 @@ export function initDatabase(dataDir, secret = '') {
       mailbox_id INTEGER NOT NULL,
       user_id INTEGER NOT NULL,
       domain_id INTEGER NOT NULL,
+      folder TEXT NOT NULL DEFAULT 'INBOX',
       sender TEXT NOT NULL DEFAULT '',
       recipients_json TEXT NOT NULL DEFAULT '[]',
       subject TEXT NOT NULL DEFAULT '',
@@ -195,6 +197,20 @@ export function initDatabase(dataDir, secret = '') {
       FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
     );
 
+    CREATE TABLE IF NOT EXISTS inbound_folders (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      mailbox_id INTEGER NOT NULL,
+      user_id INTEGER NOT NULL,
+      name TEXT NOT NULL,
+      subscribed TEXT NOT NULL DEFAULT 'true',
+      created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL,
+      deleted_at TEXT,
+      UNIQUE(mailbox_id, name),
+      FOREIGN KEY(mailbox_id) REFERENCES inbound_mailboxes(id) ON DELETE CASCADE,
+      FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+    );
+
     CREATE TABLE IF NOT EXISTS api_tokens (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       user_id INTEGER NOT NULL,
@@ -291,6 +307,8 @@ export function initDatabase(dataDir, secret = '') {
     CREATE INDEX IF NOT EXISTS idx_inbound_mailboxes_domain_id ON inbound_mailboxes(domain_id);
     CREATE INDEX IF NOT EXISTS idx_inbound_messages_user_received ON inbound_messages(user_id, received_at);
     CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_received ON inbound_messages(mailbox_id, received_at);
+    CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_folder_received ON inbound_messages(mailbox_id, folder, received_at);
+    CREATE INDEX IF NOT EXISTS idx_inbound_folders_mailbox ON inbound_folders(mailbox_id, deleted_at);
   `);
   ensureColumn('domains', 'user_id', 'INTEGER');
   ensureColumn('domains', 'dns_credential_id', 'INTEGER');
@@ -313,6 +331,7 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('inbound_mailboxes', 'forward_to_json', "TEXT NOT NULL DEFAULT '[]'");
   ensureColumn('inbound_mailboxes', 'keep_forwarded', "TEXT NOT NULL DEFAULT 'true'");
   ensureColumn('inbound_mailboxes', 'quota_mb', 'INTEGER');
+  ensureColumn('inbound_messages', 'folder', "TEXT NOT NULL DEFAULT 'INBOX'");
   db.exec(`
     CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
     CREATE INDEX IF NOT EXISTS idx_domains_smtp_relay_id ON domains(smtp_relay_id);
@@ -325,6 +344,8 @@ export function initDatabase(dataDir, secret = '') {
       WHERE tracking_token_hash IS NOT NULL AND tracking_token_hash != '';
     CREATE INDEX IF NOT EXISTS idx_smtp_credentials_user_id ON smtp_credentials(user_id);
     CREATE INDEX IF NOT EXISTS idx_smtp_relays_user_id ON smtp_relays(user_id);
+    CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_folder_received ON inbound_messages(mailbox_id, folder, received_at);
+    CREATE INDEX IF NOT EXISTS idx_inbound_folders_mailbox ON inbound_folders(mailbox_id, deleted_at);
   `);
   normalizeSendEventQueueIds();
   normalizeDkimPublicKeys();
@@ -1069,20 +1090,23 @@ export function resolveInboundRecipient(address) {
 export function createInboundMessage(mailbox, message = {}) {
   if (!mailbox?.id || !mailbox?.userId || !mailbox?.domainId) throw new Error('收信邮箱不存在。');
   const receivedAt = message.receivedAt || now();
+  const folder = normalizeInboundFolder(message.folder) || 'INBOX';
   const textBody = String(message.textBody || '');
   const htmlBody = String(message.htmlBody || '');
   const rawMessage = String(message.rawMessage || '');
+  if (!isStandardInboundFolder(folder)) createInboundFolder(mailbox, folder);
   const result = requireDb()
     .prepare(`
       INSERT INTO inbound_messages (
-        mailbox_id, user_id, domain_id, sender, recipients_json, subject, message_id,
+        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', ?, ?, ?)
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'false', ?, ?, ?)
     `)
     .run(
       mailbox.id,
       mailbox.userId,
       mailbox.domainId,
+      folder,
       normalizeEmail(message.sender) || String(message.sender || '').trim(),
       JSON.stringify(normalizeRecipientList(message.recipients)),
       String(message.subject || '').trim() || '(no subject)',
@@ -1098,13 +1122,17 @@ export function createInboundMessage(mailbox, message = {}) {
   return getInboundMessage(mailbox.userId, result.lastInsertRowid);
 }
 
-export function listInboundMessages(userId, { mailboxId = null } = {}) {
+export function listInboundMessages(userId, { mailboxId = null, folder = 'INBOX' } = {}) {
   const where = ['msg.user_id = ?', 'msg.deleted_at IS NULL'];
   const params = [userId];
   if (mailboxId) {
     where.push('msg.mailbox_id = ?');
     params.push(Number(mailboxId));
   }
+  if (folder !== null) {
+    where.push('msg.folder = ?');
+    params.push(normalizeInboundFolder(folder) || 'INBOX');
+  }
   return requireDb()
     .prepare(`
       SELECT msg.*, m.address AS mailbox_address, d.domain
@@ -1131,18 +1159,64 @@ export function getInboundMessage(userId, id) {
   return publicInboundMessage(row, { includeBody: true });
 }
 
-export function listInboundMailboxProtocolMessages(mailbox) {
+export function listInboundFolders(mailbox) {
+  if (!mailbox?.id || !mailbox?.userId) return [...STANDARD_INBOUND_FOLDERS];
+  const custom = requireDb()
+    .prepare(`
+      SELECT name
+      FROM inbound_folders
+      WHERE mailbox_id = ? AND user_id = ? AND deleted_at IS NULL
+      ORDER BY name COLLATE NOCASE
+    `)
+    .all(Number(mailbox.id), mailbox.userId)
+    .map((row) => row.name)
+    .filter((name) => !isStandardInboundFolder(name));
+  return [...STANDARD_INBOUND_FOLDERS, ...custom];
+}
+
+export function createInboundFolder(mailbox, folder) {
+  if (!mailbox?.id || !mailbox?.userId) throw new Error('收信邮箱不存在。');
+  const name = normalizeInboundFolder(folder);
+  if (!name) throw new Error('IMAP 文件夹名称不正确。');
+  if (isStandardInboundFolder(name)) return { name, standard: true };
+  const createdAt = now();
+  requireDb()
+    .prepare(`
+      INSERT OR IGNORE INTO inbound_folders (mailbox_id, user_id, name, subscribed, created_at, updated_at)
+      VALUES (?, ?, ?, 'true', ?, ?)
+    `)
+    .run(Number(mailbox.id), mailbox.userId, name, createdAt, createdAt);
+  return { name, standard: false };
+}
+
+export function inboundFolderExists(mailbox, folder) {
+  if (!mailbox?.id || !mailbox?.userId) return false;
+  const name = normalizeInboundFolder(folder);
+  if (!name) return false;
+  if (isStandardInboundFolder(name)) return true;
+  return Boolean(requireDb()
+    .prepare(`
+      SELECT id
+      FROM inbound_folders
+      WHERE mailbox_id = ? AND user_id = ? AND name = ? AND deleted_at IS NULL
+      LIMIT 1
+    `)
+    .get(Number(mailbox.id), mailbox.userId, name));
+}
+
+export function listInboundMailboxProtocolMessages(mailbox, { folder = 'INBOX' } = {}) {
   if (!mailbox?.id || !mailbox?.userId) return [];
+  const selectedFolder = normalizeInboundFolder(folder) || 'INBOX';
   return requireDb()
     .prepare(`
       SELECT msg.*, m.address AS mailbox_address, d.domain
       FROM inbound_messages msg
       JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
       JOIN domains d ON d.id = msg.domain_id
-      WHERE msg.mailbox_id = ? AND msg.user_id = ? AND msg.deleted_at IS NULL
+      WHERE msg.mailbox_id = ? AND msg.user_id = ? AND msg.folder = ? AND msg.deleted_at IS NULL
       ORDER BY msg.id ASC
     `)
-    .all(Number(mailbox.id), mailbox.userId)
+    .all(Number(mailbox.id), mailbox.userId, selectedFolder)
     .map((row) => publicInboundMessage(row, { includeBody: true }));
 }
 
@@ -1155,20 +1229,24 @@ export function markInboundMessageRead(userId, id, read = true) {
   return getInboundMessage(userId, id);
 }
 
-export function softDeleteInboundMessages(userId, mailboxId, ids) {
+export function softDeleteInboundMessages(userId, mailboxId, ids, { folder = null } = {}) {
   const cleanIds = [...new Set((Array.isArray(ids) ? ids : [ids])
     .map((id) => Number(id))
     .filter((id) => Number.isInteger(id) && id > 0))];
   if (!cleanIds.length) return 0;
+  const folderClause = folder === null ? '' : 'AND folder = ?';
   const placeholders = cleanIds.map(() => '?').join(', ');
   const updatedAt = now();
+  const params = folder === null
+    ? [updatedAt, updatedAt, userId, Number(mailboxId), ...cleanIds]
+    : [updatedAt, updatedAt, userId, Number(mailboxId), normalizeInboundFolder(folder) || 'INBOX', ...cleanIds];
   const result = requireDb()
     .prepare(`
       UPDATE inbound_messages
       SET deleted_at = ?, updated_at = ?
-      WHERE user_id = ? AND mailbox_id = ? AND deleted_at IS NULL AND id IN (${placeholders})
+      WHERE user_id = ? AND mailbox_id = ? AND deleted_at IS NULL ${folderClause} AND id IN (${placeholders})
     `)
-    .run(updatedAt, updatedAt, userId, Number(mailboxId), ...cleanIds);
+    .run(...params);
   return result.changes;
 }
 
@@ -3044,6 +3122,14 @@ function moveInboundDomainResources(domainId, targetUserId) {
   const messageResult = requireDb()
     .prepare('UPDATE inbound_messages SET user_id = ?, updated_at = ? WHERE domain_id = ? AND deleted_at IS NULL')
     .run(targetUserId, updatedAt, domainId);
+  requireDb()
+    .prepare(`
+      UPDATE inbound_folders
+      SET user_id = ?, updated_at = ?
+      WHERE deleted_at IS NULL
+        AND mailbox_id IN (SELECT id FROM inbound_mailboxes WHERE domain_id = ?)
+    `)
+    .run(targetUserId, updatedAt, domainId);
   return {
     mailboxes: mailboxResult.changes,
     messages: messageResult.changes
@@ -3070,6 +3156,15 @@ function moveInboundResourcesForUserDomains(sourceUserId, targetUserId) {
         AND domain_id IN (SELECT id FROM domains WHERE user_id = ?)
     `)
     .run(targetUserId, updatedAt, sourceUserId, sourceUserId);
+  requireDb()
+    .prepare(`
+      UPDATE inbound_folders
+      SET user_id = ?, updated_at = ?
+      WHERE user_id = ?
+        AND deleted_at IS NULL
+        AND mailbox_id IN (SELECT id FROM inbound_mailboxes WHERE domain_id IN (SELECT id FROM domains WHERE user_id = ?))
+    `)
+    .run(targetUserId, updatedAt, sourceUserId, sourceUserId);
   return {
     mailboxes: mailboxResult.changes,
     messages: messageResult.changes
@@ -3272,6 +3367,7 @@ function publicInboundMessage(row, { includeBody = false } = {}) {
     userId: row.user_id,
     domainId: row.domain_id,
     domain: row.domain || '',
+    folder: row.folder || 'INBOX',
     mailboxAddress: row.mailbox_address || '',
     sender: row.sender,
     recipients: safeJson(row.recipients_json, []),
@@ -3677,6 +3773,24 @@ function normalizeInboundAddress(value) {
   return `${localPart}@${domain}`;
 }
 
+function normalizeInboundFolder(value, fallback = '') {
+  const raw = String(value || fallback || '').trim().replace(/^"|"$/g, '').replace(/\\/g, '/');
+  if (!raw || /[\r\n\u0000]/.test(raw)) return '';
+  if (raw.toUpperCase() === 'INBOX') return 'INBOX';
+  const standard = STANDARD_INBOUND_FOLDERS.find((folder) => folder.toLowerCase() === raw.toLowerCase());
+  if (standard) return standard;
+  return raw
+    .split('/')
+    .map((part) => part.trim())
+    .filter(Boolean)
+    .join('/');
+}
+
+function isStandardInboundFolder(value) {
+  const clean = normalizeInboundFolder(value);
+  return STANDARD_INBOUND_FOLDERS.some((folder) => folder === clean);
+}
+
 function normalizeCatchAllAddress(value) {
   const clean = String(value || '').trim().toLowerCase();
   if (!clean) return '';

+ 144 - 16
src/mail-access.js

@@ -2,6 +2,11 @@ import net from 'node:net';
 import tls from 'node:tls';
 import { readFileSync } from 'node:fs';
 import {
+  STANDARD_INBOUND_FOLDERS,
+  createInboundFolder,
+  createInboundMessage,
+  inboundFolderExists,
+  listInboundFolders,
   listInboundMailboxProtocolMessages,
   markInboundMessageRead,
   softDeleteInboundMessages,
@@ -80,10 +85,12 @@ class ImapSession {
     this.authenticated = false;
     this.user = null;
     this.mailbox = null;
+    this.selectedFolder = 'INBOX';
     this.selected = false;
     this.messages = [];
     this.deletedUids = new Set();
     this.authContinuation = null;
+    this.pendingAppend = null;
     this.idleTag = '';
     this.onDataBound = (chunk) => this.onData(chunk);
     socket.setEncoding('utf8');
@@ -94,8 +101,21 @@ class ImapSession {
 
   onData(chunk) {
     this.buffer += chunk;
-    let index;
-    while ((index = this.buffer.indexOf('\n')) !== -1) {
+    while (true) {
+      if (this.pendingAppend) {
+        const literal = takeUtf8Literal(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);
+        const pending = this.pendingAppend;
+        this.pendingAppend = null;
+        this.finishAppend(pending, literal.value);
+        continue;
+      }
+
+      const index = this.buffer.indexOf('\n');
+      if (index === -1) return;
       const line = this.buffer.slice(0, index).replace(/\r$/, '');
       this.buffer = this.buffer.slice(index + 1);
       this.onLine(line);
@@ -139,9 +159,11 @@ class ImapSession {
     if (command === 'ID') return this.write(`${tag} OK ID completed`);
     if (command === 'SELECT' || command === 'EXAMINE') return this.select(tag, rest, command === 'EXAMINE');
     if (command === 'STATUS') return this.status(tag, rest);
+    if (command === 'CREATE') return this.createFolder(tag, rest);
+    if (command === 'APPEND') return this.append(tag, rest);
     if (command === 'SEARCH') return this.search(tag, rest, false);
     if (command === 'UID') return this.uid(tag, rest);
-    if (!this.selected) return this.write(`${tag} NO Select INBOX first`);
+    if (!this.selected) return this.write(`${tag} NO Select a mailbox first`);
     if (command === 'FETCH') return this.fetch(tag, rest, false);
     if (command === 'STORE') return this.store(tag, rest, false);
     if (command === 'EXPUNGE') return this.expunge(tag);
@@ -151,7 +173,7 @@ class ImapSession {
   }
 
   capability(tag) {
-    const capabilities = ['IMAP4rev1', 'UIDPLUS', 'IDLE', 'NAMESPACE'];
+    const capabilities = ['IMAP4rev1', 'UIDPLUS', 'IDLE', 'NAMESPACE', 'SPECIAL-USE'];
     if (this.config.startTlsAvailable && !this.config.tlsActive) capabilities.push('STARTTLS');
     if (this.canAuthenticate()) capabilities.push('AUTH=PLAIN');
     this.write(`* CAPABILITY ${capabilities.join(' ')}`);
@@ -199,7 +221,9 @@ class ImapSession {
   }
 
   list(tag) {
-    this.write('* LIST (\\HasNoChildren) "/" "INBOX"');
+    for (const folder of listInboundFolders(this.mailbox)) {
+      this.write(`* LIST (${imapFolderAttributes(folder).join(' ')}) "/" ${imapNString(folder)}`);
+    }
     this.write(`${tag} OK LIST completed`);
   }
 
@@ -210,7 +234,9 @@ class ImapSession {
 
   select(tag, rest, readOnly) {
     const [mailboxName] = tokenizeImap(rest);
-    if (!isInbox(mailboxName)) return this.write(`${tag} NO Only INBOX is available`);
+    const folder = normalizeImapFolder(mailboxName);
+    if (!inboundFolderExists(this.mailbox, folder)) return this.write(`${tag} NO Mailbox does not exist`);
+    this.selectedFolder = folder;
     this.reloadMessages();
     this.selected = true;
     this.write('* FLAGS (\\Seen \\Deleted)');
@@ -224,13 +250,56 @@ class ImapSession {
 
   status(tag, rest) {
     const [mailboxName] = tokenizeImap(rest);
-    if (!isInbox(mailboxName)) return this.write(`${tag} NO Only INBOX is available`);
-    const messages = mailboxProtocolMessages(this.mailbox);
+    const folder = normalizeImapFolder(mailboxName);
+    if (!inboundFolderExists(this.mailbox, folder)) return this.write(`${tag} NO Mailbox does not exist`);
+    const messages = mailboxProtocolMessages(this.mailbox, folder);
     const unseen = messages.filter((message) => !message.read).length;
-    this.write(`* STATUS "INBOX" (MESSAGES ${messages.length} UNSEEN ${unseen} UIDNEXT ${uidNext(messages)} UIDVALIDITY ${this.mailbox.id})`);
+    this.write(`* STATUS ${imapNString(folder)} (MESSAGES ${messages.length} UNSEEN ${unseen} UIDNEXT ${uidNext(messages)} UIDVALIDITY ${this.mailbox.id})`);
     this.write(`${tag} OK STATUS completed`);
   }
 
+  createFolder(tag, rest) {
+    const [mailboxName] = tokenizeImap(rest);
+    const folder = normalizeImapFolder(mailboxName);
+    if (!folder) return this.write(`${tag} BAD CREATE expects a mailbox name`);
+    createInboundFolder(this.mailbox, folder);
+    this.write(`${tag} OK CREATE completed`);
+  }
+
+  append(tag, rest) {
+    const literalMatch = String(rest || '').match(/\{(\d+)\+?\}\s*$/);
+    if (!literalMatch) return this.write(`${tag} BAD APPEND expects a literal message`);
+    const bytes = Number(literalMatch[1]);
+    if (!Number.isInteger(bytes) || bytes < 0) return this.write(`${tag} BAD APPEND literal size is invalid`);
+    const prefix = rest.slice(0, literalMatch.index).trim();
+    const [mailboxName] = tokenizeImap(prefix);
+    const folder = normalizeImapFolder(mailboxName);
+    if (!inboundFolderExists(this.mailbox, folder)) return this.write(`${tag} NO Mailbox does not exist`);
+    this.pendingAppend = {
+      tag,
+      folder,
+      flags: parseFlags(prefix),
+      bytes
+    };
+    this.write('+ Ready for literal data');
+  }
+
+  finishAppend(pending, rawMessage) {
+    const normalizedRaw = normalizeRawMessage({ rawMessage });
+    const headers = parseMessageHeaders(normalizedRaw);
+    const message = createInboundMessage(this.mailbox, {
+      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()
+    });
+    if (pending.flags.has('\\SEEN')) markInboundMessageRead(this.mailbox.userId, message.id, true);
+    this.write(`${pending.tag} OK APPEND completed`);
+  }
+
   uid(tag, rest) {
     const parsed = rest.match(/^(\S+)(?:\s+(.*))?$/);
     if (!parsed) return this.write(`${tag} BAD UID expects a subcommand`);
@@ -304,7 +373,7 @@ class ImapSession {
     const entries = this.messages
       .map((message, index) => ({ message, seq: index + 1 }))
       .filter((entry) => this.deletedUids.has(entry.message.id));
-    softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, entries.map((entry) => entry.message.id));
+    softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, entries.map((entry) => entry.message.id), { folder: this.selectedFolder });
     for (const entry of entries.reverse()) this.write(`* ${entry.seq} EXPUNGE`);
     this.deletedUids.clear();
     this.reloadMessages();
@@ -313,7 +382,7 @@ class ImapSession {
 
   closeMailbox(tag) {
     const ids = [...this.deletedUids];
-    if (ids.length) softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, ids);
+    if (ids.length) softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, ids, { folder: this.selectedFolder });
     this.deletedUids.clear();
     this.selected = false;
     this.messages = [];
@@ -326,7 +395,7 @@ class ImapSession {
   }
 
   reloadMessages() {
-    this.messages = mailboxProtocolMessages(this.mailbox);
+    this.messages = mailboxProtocolMessages(this.mailbox, this.selectedFolder);
   }
 
   upgradeToTls() {
@@ -571,8 +640,8 @@ function publicProtocolLabel(protocol, tlsEnabled) {
   return tlsEnabled ? 'POP3 + STLS' : 'POP3';
 }
 
-function mailboxProtocolMessages(mailbox) {
-  return listInboundMailboxProtocolMessages(mailbox).map((message) => ({
+function mailboxProtocolMessages(mailbox, folder = 'INBOX') {
+  return listInboundMailboxProtocolMessages(mailbox, { folder }).map((message) => ({
     ...message,
     rawMessage: normalizeRawMessage(message)
   }));
@@ -611,6 +680,43 @@ 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 parseMessageHeaders(rawMessage) {
+  const headers = {};
+  let current = '';
+  for (const line of headerBlock(rawMessage).replace(/\r\n\r\n$/, '').split('\r\n')) {
+    if (!line) continue;
+    if (/^[\t ]/.test(line) && current) {
+      headers[current] = `${headers[current]} ${line.trim()}`.trim();
+      continue;
+    }
+    const separator = line.indexOf(':');
+    if (separator === -1) continue;
+    current = line.slice(0, separator).trim().toLowerCase();
+    headers[current] = line.slice(separator + 1).trim();
+  }
+  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/);
@@ -618,8 +724,30 @@ function splitFirst(value) {
   return [input.slice(0, index), input.slice(index + 1).trim()];
 }
 
-function isInbox(value) {
-  return String(value || '').replace(/^"|"$/g, '').toUpperCase() === 'INBOX';
+function normalizeImapFolder(value) {
+  const raw = String(value || '').trim().replace(/^"|"$/g, '').replace(/\\/g, '/');
+  if (!raw || /[\r\n\u0000]/.test(raw)) return '';
+  if (raw.toUpperCase() === 'INBOX') return 'INBOX';
+  const standard = STANDARD_INBOUND_FOLDERS.find((folder) => folder.toLowerCase() === raw.toLowerCase());
+  if (standard) return standard;
+  return raw
+    .split('/')
+    .map((part) => part.trim())
+    .filter(Boolean)
+    .join('/');
+}
+
+function imapFolderAttributes(folder) {
+  const attrs = ['\\HasNoChildren'];
+  const specialUse = {
+    Sent: '\\Sent',
+    Drafts: '\\Drafts',
+    Trash: '\\Trash',
+    Junk: '\\Junk',
+    Archive: '\\Archive'
+  }[folder];
+  if (specialUse) attrs.push(specialUse);
+  return attrs;
 }
 
 function resolveMessageSet(set, messages, byUid) {

+ 108 - 1
test/mail-access.test.js

@@ -64,6 +64,91 @@ test('IMAP clients can log in and fetch mailbox messages', async () => {
   }
 });
 
+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');
+
+  const [server] = startMailboxAccessServers({
+    hostname: 'mail.folders.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@folders.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
+
+    const listed = await client.command('A2 LIST "" "*"', /A2 OK/);
+    assert.match(listed, /\* LIST .* "INBOX"/);
+    assert.match(listed, /\* LIST .*\\Sent.* "Sent"/);
+    assert.match(listed, /\* LIST .*\\Drafts.* "Drafts"/);
+    assert.match(listed, /\* LIST .*\\Trash.* "Trash"/);
+    assert.match(listed, /\* LIST .*\\Junk.* "Junk"/);
+    assert.match(listed, /\* LIST .*\\Archive.* "Archive"/);
+
+    const selected = await client.command('A3 SELECT Sent', /A3 OK/);
+    assert.match(selected, /\* 0 EXISTS/);
+    await client.command('A4 LOGOUT', /A4 OK/);
+    client.close();
+  } finally {
+    client?.close();
+    await closeServer(server);
+  }
+});
+
+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 [server] = startMailboxAccessServers({
+    hostname: 'mail.append.example',
+    imapEnabled: true,
+    imapListeners: [{ port: 0, protocol: 'imap' }],
+    pop3Enabled: false,
+    pop3Listeners: [],
+    allowInsecureAuth: true
+  });
+  await waitForListening(server);
+
+  let client;
+  try {
+    const sentMessage = [
+      'From: Admin <admin@append.example>',
+      'To: Bob <bob@example.net>',
+      'Subject: Sent copy',
+      'Message-ID: <sent-copy@append.example>',
+      '',
+      'This copy belongs in Sent.'
+    ].join('\r\n');
+
+    client = await connectClient(server.address().port);
+    await client.readUntil(/\* OK .* IMAP ready\r\n/);
+    assert.match(await client.command('A1 LOGIN "admin@append.example" "mailbox-pass-123"', /A1 OK/), /LOGIN completed/);
+    await client.append(`A2 APPEND Sent (\\Seen) {${Buffer.byteLength(sentMessage, 'utf8')}}`, sentMessage, /A2 OK/);
+
+    const selectedSent = await client.command('A3 SELECT Sent', /A3 OK/);
+    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\./);
+
+    const selectedInbox = await client.command('A5 SELECT INBOX', /A5 OK/);
+    assert.match(selectedInbox, /\* 0 EXISTS/);
+    await client.command('A6 LOGOUT', /A6 OK/);
+    client.close();
+  } finally {
+    client?.close();
+    await closeServer(server);
+  }
+});
+
 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');
@@ -157,6 +242,12 @@ function connectClient(port) {
         socket.write(`${command}\r\n`);
         return this.readUntil(pattern);
       },
+      async append(command, literal, pattern) {
+        socket.write(`${command}\r\n`);
+        await this.readUntil(/^\+ /m);
+        socket.write(`${literal}\r\n`);
+        return this.readUntil(pattern);
+      },
       readUntil(pattern) {
         if (pattern.test(buffer)) {
           const output = buffer;
@@ -164,7 +255,23 @@ function connectClient(port) {
           return Promise.resolve(output);
         }
         return new Promise((waitResolve, waitReject) => {
-          waiters.push({ pattern, resolve: waitResolve, reject: waitReject });
+          const waiter = {
+            pattern,
+            resolve(output) {
+              clearTimeout(waiter.timer);
+              waitResolve(output);
+            },
+            reject(error) {
+              clearTimeout(waiter.timer);
+              waitReject(error);
+            },
+            timer: null
+          };
+          waiter.timer = setTimeout(() => {
+            waiters.splice(waiters.indexOf(waiter), 1);
+            waitReject(new Error(`Timed out waiting for ${pattern}; buffered response: ${buffer}`));
+          }, 5000);
+          waiters.push(waiter);
         });
       },
       close() {