|
@@ -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 () => {
|
|
test('IMAP exposes MIME body structures and individual parts for Roundcube', async () => {
|
|
|
initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-mime-test-')), 'mail-access-secret');
|
|
initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-mime-test-')), 'mail-access-secret');
|
|
|
const { mailbox } = createMailboxFixture('mime.example', 'mime-user');
|
|
const { mailbox } = createMailboxFixture('mime.example', 'mime-user');
|
|
@@ -702,6 +788,17 @@ function extractFetchLiteral(response, label) {
|
|
|
return bytes.subarray(literalStart, literalEnd);
|
|
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) {
|
|
function waitForListening(server) {
|
|
|
if (server.listening) return Promise.resolve();
|
|
if (server.listening) return Promise.resolve();
|
|
|
return new Promise((resolve) => server.once('listening', resolve));
|
|
return new Promise((resolve) => server.once('listening', resolve));
|