Prechádzať zdrojové kódy

fix: honor IMAP search criteria

AI-Co-Authored-By: Codex
chendeben 1 mesiac pred
rodič
commit
eff5228111
2 zmenil súbory, kde vykonal 142 pridanie a 3 odobranie
  1. 45 3
      src/mail-access.js
  2. 97 0
      test/mail-access.test.js

+ 45 - 3
src/mail-access.js

@@ -326,9 +326,14 @@ class ImapSession {
     return this.write(`${tag} BAD UID subcommand not implemented`);
   }
 
-  search(tag, _rest, byUid) {
-    if (!this.selected) this.reloadMessages();
-    const values = this.messages.map((message, index) => byUid ? message.id : index + 1);
+  search(tag, rest, byUid) {
+    if (!this.selected) return this.write(`${tag} NO Select a mailbox first`);
+    const criteria = parseImapSearchCriteria(rest);
+    if (criteria.error) return this.write(`${tag} BAD ${criteria.error}`);
+    const values = this.messages
+      .map((message, index) => ({ message, seq: index + 1 }))
+      .filter(({ message }) => matchesImapSearchCriteria(message, this.deletedUids, criteria.flags))
+      .map(({ message, seq }) => byUid ? message.id : seq);
     this.write(`* SEARCH ${values.join(' ')}`.trimEnd());
     this.write(`${tag} OK SEARCH completed`);
   }
@@ -729,6 +734,43 @@ function tokenizeImap(value) {
   return tokens;
 }
 
+function parseImapSearchCriteria(value) {
+  let tokens = tokenizeImap(value);
+  if (String(tokens[0] || '').toUpperCase() === 'CHARSET') {
+    if (!tokens[1]) return { error: 'SEARCH CHARSET expects a name', flags: [] };
+    if (tokens[1].toUpperCase() !== 'UTF-8') return { error: 'Unsupported SEARCH charset', flags: [] };
+    tokens = tokens.slice(2);
+  }
+  if (!tokens.length) return { error: 'SEARCH expects criteria', flags: [] };
+
+  const flagCriteria = {
+    SEEN: ['\\Seen', true],
+    UNSEEN: ['\\Seen', false],
+    DELETED: ['\\Deleted', true],
+    UNDELETED: ['\\Deleted', false],
+    FLAGGED: ['\\Flagged', true],
+    UNFLAGGED: ['\\Flagged', false],
+    ANSWERED: ['\\Answered', true],
+    UNANSWERED: ['\\Answered', false],
+    DRAFT: ['\\Draft', true],
+    UNDRAFT: ['\\Draft', false]
+  };
+  const flags = [];
+  for (const token of tokens) {
+    const criterion = token.toUpperCase();
+    if (criterion === 'ALL') continue;
+    if (!flagCriteria[criterion]) return { error: `Unsupported SEARCH criterion: ${token}`, flags: [] };
+    const [flag, present] = flagCriteria[criterion];
+    flags.push({ flag, present });
+  }
+  return { error: '', flags };
+}
+
+function matchesImapSearchCriteria(message, deletedUids, criteria) {
+  const flags = new Set(imapFlags(message, deletedUids).map((flag) => flag.toUpperCase()));
+  return criteria.every(({ flag, present }) => flags.has(flag.toUpperCase()) === present);
+}
+
 function takeLiteralBytes(input, byteCount) {
   const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input || '');
   if (buffer.length < byteCount) return null;

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

@@ -118,6 +118,92 @@ test('IMAP exposes imported Maildir flags and Dovecot keywords', async () => {
   }
 });
 
+test('IMAP SEARCH filters seen state and rejects invalid contexts or criteria', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-search-test-')), 'mail-access-secret');
+  const { mailbox } = createMailboxFixture('search.example', 'search-user');
+  createImportedInboundMessage(mailbox, {
+    importSource: 'imap-search-test',
+    sourceKey: 'seen-message',
+    sender: 'seen@example.net',
+    recipients: ['admin@search.example'],
+    subject: 'Already seen',
+    messageId: '<seen@search.example>',
+    rawMessageBytes: Buffer.from('From: seen@example.net\r\nTo: admin@search.example\r\nSubject: Already seen\r\n\r\nSeen body.', 'utf8'),
+    flags: ['\\Seen'],
+    receivedAt: '2026-07-15T01:00:00.000Z'
+  });
+  const unseenMessage = createInboundMessage(mailbox, {
+    sender: 'unseen@example.net',
+    recipients: ['admin@search.example'],
+    subject: 'Still unread',
+    messageId: '<unseen@search.example>',
+    rawMessage: 'From: unseen@example.net\r\nTo: admin@search.example\r\nSubject: Still unread\r\n\r\nUnread body.',
+    textBody: 'Unread body.'
+  });
+
+  const [server] = startMailboxAccessServers({
+    hostname: 'mail.search.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@search.example" "mailbox-pass-123"', /A1 OK/);
+
+    const searchBeforeSelect = await client.command('A2 SEARCH ALL', /A2 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const uidSearchBeforeSelect = await client.command('A3 UID SEARCH ALL', /A3 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    await client.command('A4 SELECT INBOX', /A4 OK/);
+
+    const all = await client.command('A5 SEARCH ALL', /A5 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const unseen = await client.command('A6 SEARCH UNSEEN', /A6 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const seen = await client.command('A7 SEARCH SEEN', /A7 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const allUnseen = await client.command('A8 SEARCH ALL UNSEEN', /A8 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const uidUnseen = await client.command('A9 UID SEARCH UNSEEN', /A9 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const uidCharsetUnseen = await client.command('A10 UID SEARCH CHARSET UTF-8 UNSEEN', /A10 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+
+    const stored = await client.command(
+      `A11 UID STORE ${unseenMessage.id} +FLAGS.SILENT (\\Seen)`,
+      /A11 (?:OK|NO|BAD)[^\r\n]*\r\n$/
+    );
+    const unseenAfterStore = await client.command('A12 SEARCH UNSEEN', /A12 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const seenAfterStore = await client.command('A13 SEARCH SEEN', /A13 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const returnCriteria = await client.command('A14 SEARCH RETURN (ALL) ALL', /A14 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const unknownCriteria = await client.command('A15 SEARCH FROBNICATE', /A15 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    await client.command('A16 LOGOUT', /A16 OK/);
+
+    assert.match(searchBeforeSelect, /^A2 (?:NO|BAD) /m);
+    assert.doesNotMatch(searchBeforeSelect, /^\* SEARCH/m);
+    assert.match(uidSearchBeforeSelect, /^A3 (?:NO|BAD) /m);
+    assert.doesNotMatch(uidSearchBeforeSelect, /^\* SEARCH/m);
+
+    assertImapSearchResult(all, [1, 2]);
+    assertImapSearchResult(unseen, [2]);
+    assertImapSearchResult(seen, [1]);
+    assertImapSearchResult(allUnseen, [2]);
+    assertImapSearchResult(uidUnseen, [unseenMessage.id]);
+    assertImapSearchResult(uidCharsetUnseen, [unseenMessage.id]);
+    assert.match(stored, /^A11 OK STORE completed\r?$/m);
+    assert.doesNotMatch(stored, /^\* \d+ FETCH/m);
+    assertImapSearchResult(unseenAfterStore, []);
+    assertImapSearchResult(seenAfterStore, [1, 2]);
+
+    assert.match(returnCriteria, /^A14 BAD /m);
+    assert.doesNotMatch(returnCriteria, /^\* SEARCH/m);
+    assert.match(unknownCriteria, /^A15 BAD /m);
+    assert.doesNotMatch(unknownCriteria, /^\* SEARCH/m);
+  } finally {
+    client?.close();
+    await closeServer(server);
+  }
+});
+
 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');
@@ -702,6 +788,17 @@ function extractFetchLiteral(response, label) {
   return bytes.subarray(literalStart, literalEnd);
 }
 
+function assertImapSearchResult(response, expected) {
+  assert.match(response, /^\S+ OK SEARCH completed\r?$/m);
+  const match = response.match(/^\* SEARCH(?: ([0-9 ]+))?\r?$/m);
+  assert.ok(match, `Missing SEARCH response in: ${response}`);
+  const actual = String(match[1] || '')
+    .split(/\s+/)
+    .filter(Boolean)
+    .map(Number);
+  assert.deepEqual(actual, expected);
+}
+
 function waitForListening(server) {
   if (server.listening) return Promise.resolve();
   return new Promise((resolve) => server.once('listening', resolve));