imapflow 1.2.17 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +21 -0
- package/CLAUDE.md +5 -0
- package/lib/commands/search.js +133 -1
- package/lib/imap-flow.d.ts +25 -1
- package/lib/imap-flow.js +38 -4
- package/package.json +4 -4
- package/test/search-test.js +241 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.0](https://github.com/postalsys/imapflow/compare/v1.2.18...v1.3.0) (2026-04-08)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add ESEARCH (RFC 4731) and PARTIAL (RFC 9394) support ([#347](https://github.com/postalsys/imapflow/issues/347)) ([c9b608b](https://github.com/postalsys/imapflow/commit/c9b608bb049b5b17f274f97f0f8dbbc3b18a80f7))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* Bumped deps ([b584d2b](https://github.com/postalsys/imapflow/commit/b584d2b336e1229a676277209c1942176186ce94))
|
|
14
|
+
* correct ESEARCH search() return type and prevent async test hangs ([f2e6e92](https://github.com/postalsys/imapflow/commit/f2e6e922acafffc125f16a4eb128a52c01313a45))
|
|
15
|
+
* handle plain Array form for PARTIAL in ESEARCH parser ([8b74c25](https://github.com/postalsys/imapflow/commit/8b74c2569675ea7d3f2cea6a3fcd717a8c6b025e))
|
|
16
|
+
|
|
17
|
+
## [1.2.18](https://github.com/postalsys/imapflow/compare/v1.2.17...v1.2.18) (2026-03-25)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* use consistent keys for requestTagMap deletion during connection close ([92e1885](https://github.com/postalsys/imapflow/commit/92e188507c0376577f1680f42df747e2cc4b677a))
|
|
23
|
+
|
|
3
24
|
## [1.2.17](https://github.com/postalsys/imapflow/compare/v1.2.16...v1.2.17) (2026-03-25)
|
|
4
25
|
|
|
5
26
|
|
package/CLAUDE.md
ADDED
package/lib/commands/search.js
CHANGED
|
@@ -3,6 +3,79 @@
|
|
|
3
3
|
const { enhanceCommandError } = require('../tools.js');
|
|
4
4
|
const { searchCompiler } = require('../search-compiler.js');
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Parses the key-value attributes from an ESEARCH untagged response.
|
|
8
|
+
*
|
|
9
|
+
* Receives the attribute list AFTER stripping the leading (TAG "X") list
|
|
10
|
+
* and the UID atom — i.e. only the result keyword/value pairs remain.
|
|
11
|
+
*
|
|
12
|
+
* ALL and PARTIAL.messages are kept as compact sequence-set strings.
|
|
13
|
+
* Use expandRange() from tools.js if you need to expand them.
|
|
14
|
+
*
|
|
15
|
+
* @param {Array} attrs - Attribute array from the IMAP parser
|
|
16
|
+
* @returns {Object} ESearchResult object
|
|
17
|
+
*/
|
|
18
|
+
function parseEsearchResponse(attrs) {
|
|
19
|
+
const result = {};
|
|
20
|
+
let i = 0;
|
|
21
|
+
while (i < attrs.length) {
|
|
22
|
+
const token = attrs[i];
|
|
23
|
+
if (!token || token.type !== 'ATOM') {
|
|
24
|
+
i++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const key = token.value.toUpperCase();
|
|
28
|
+
if (i + 1 >= attrs.length) {
|
|
29
|
+
i++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
switch (key) {
|
|
33
|
+
case 'COUNT': {
|
|
34
|
+
const n = Number(attrs[++i]?.value);
|
|
35
|
+
if (!isNaN(n)) result.count = n;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
case 'MIN': {
|
|
39
|
+
const n = Number(attrs[++i]?.value);
|
|
40
|
+
if (!isNaN(n)) result.min = n;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
case 'MAX': {
|
|
44
|
+
const n = Number(attrs[++i]?.value);
|
|
45
|
+
if (!isNaN(n)) result.max = n;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case 'ALL': {
|
|
49
|
+
const allToken = attrs[++i];
|
|
50
|
+
if (allToken && typeof allToken.value === 'string') {
|
|
51
|
+
result.all = allToken.value;
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
case 'PARTIAL': {
|
|
56
|
+
const listToken = attrs[++i];
|
|
57
|
+
// Parser represents parenthesized groups as plain Arrays,
|
|
58
|
+
// but check both forms for robustness.
|
|
59
|
+
const items = Array.isArray(listToken) ? listToken : listToken && Array.isArray(listToken.attributes) ? listToken.attributes : null;
|
|
60
|
+
if (!items || items.length < 2) break;
|
|
61
|
+
result.partial = {
|
|
62
|
+
range: items[0].value,
|
|
63
|
+
messages: items[1].value
|
|
64
|
+
};
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
default:
|
|
68
|
+
// Skip the value token for unknown keys to keep the stream aligned.
|
|
69
|
+
// The loop's unconditional i++ at the bottom advances past the key;
|
|
70
|
+
// this extra i++ advances past the value token.
|
|
71
|
+
i++;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
i++;
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
6
79
|
/**
|
|
7
80
|
* Searches for messages matching the specified criteria.
|
|
8
81
|
*
|
|
@@ -10,7 +83,11 @@ const { searchCompiler } = require('../search-compiler.js');
|
|
|
10
83
|
* @param {Object|boolean} query - Search query object, or true/empty object to match all messages
|
|
11
84
|
* @param {Object} [options] - Search options
|
|
12
85
|
* @param {boolean} [options.uid] - If true, use UID SEARCH instead of SEARCH
|
|
13
|
-
* @
|
|
86
|
+
* @param {Array} [options.returnOptions] - ESEARCH RETURN options. When present AND the
|
|
87
|
+
* server advertises ESEARCH capability, triggers ESEARCH and returns an ESearchResult.
|
|
88
|
+
* Items are strings ('MIN','MAX','COUNT','ALL') or objects ({ partial: '1:100' }).
|
|
89
|
+
* When server lacks ESEARCH, falls back to plain SEARCH and returns number[].
|
|
90
|
+
* @returns {Promise<number[]|Object|boolean>}
|
|
14
91
|
*/
|
|
15
92
|
module.exports = async (connection, query, options) => {
|
|
16
93
|
if (connection.state !== connection.states.SELECTED) {
|
|
@@ -36,6 +113,58 @@ module.exports = async (connection, query, options) => {
|
|
|
36
113
|
return false;
|
|
37
114
|
}
|
|
38
115
|
|
|
116
|
+
const useEsearch = options.returnOptions && options.returnOptions.length > 0 && connection.capabilities.has('ESEARCH');
|
|
117
|
+
|
|
118
|
+
if (useEsearch) {
|
|
119
|
+
// Build RETURN (...) item list
|
|
120
|
+
const returnItems = [];
|
|
121
|
+
for (const opt of options.returnOptions) {
|
|
122
|
+
if (typeof opt === 'string') {
|
|
123
|
+
returnItems.push({ type: 'ATOM', value: opt.toUpperCase() });
|
|
124
|
+
} else if (opt && typeof opt.partial === 'string') {
|
|
125
|
+
// RFC 9394: PARTIAL is an atom followed by the range atom, both inside RETURN (...)
|
|
126
|
+
returnItems.push({ type: 'ATOM', value: 'PARTIAL' });
|
|
127
|
+
returnItems.push({ type: 'ATOM', value: opt.partial });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// If all returnOptions entries were invalid (e.g. objects lacking a string
|
|
132
|
+
// `partial` field), returnItems would be empty. Emitting "RETURN ()" is
|
|
133
|
+
// technically valid per RFC 4731 but returns nothing useful. Fall through
|
|
134
|
+
// to the legacy SEARCH path instead so the caller gets a usable result.
|
|
135
|
+
if (returnItems.length > 0) {
|
|
136
|
+
const returnClause = [{ type: 'ATOM', value: 'RETURN' }, returnItems];
|
|
137
|
+
|
|
138
|
+
let esearchResult = {};
|
|
139
|
+
let response;
|
|
140
|
+
try {
|
|
141
|
+
response = await connection.exec(options.uid ? 'UID SEARCH' : 'SEARCH', [...returnClause, ...attributes], {
|
|
142
|
+
untagged: {
|
|
143
|
+
ESEARCH: async untagged => {
|
|
144
|
+
if (!untagged || !untagged.attributes) return;
|
|
145
|
+
// Strip leading (TAG "X") list and optional UID atom.
|
|
146
|
+
// The IMAP parser represents parenthesized groups as
|
|
147
|
+
// plain Arrays, not objects with type: 'LIST'.
|
|
148
|
+
let attrs = untagged.attributes;
|
|
149
|
+
let start = 0;
|
|
150
|
+
if (attrs[start] && (Array.isArray(attrs[start]) || attrs[start].type === 'LIST')) start++;
|
|
151
|
+
if (attrs[start] && typeof attrs[start].value === 'string' && attrs[start].value.toUpperCase() === 'UID') start++;
|
|
152
|
+
esearchResult = parseEsearchResponse(attrs.slice(start));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
response.next();
|
|
157
|
+
return esearchResult;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
await enhanceCommandError(err);
|
|
160
|
+
connection.log.warn({ err, cid: connection.id });
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// returnItems was empty — fall through to legacy SEARCH path below
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Legacy SEARCH path (no returnOptions, or server lacks ESEARCH) ────
|
|
39
168
|
// Use a Set to deduplicate sequence numbers/UIDs -- servers may return
|
|
40
169
|
// duplicates across multiple untagged SEARCH responses.
|
|
41
170
|
let results = new Set();
|
|
@@ -63,3 +192,6 @@ module.exports = async (connection, query, options) => {
|
|
|
63
192
|
return false;
|
|
64
193
|
}
|
|
65
194
|
};
|
|
195
|
+
|
|
196
|
+
// Exported for unit testing — not intended as public library API
|
|
197
|
+
module.exports.parseEsearchResponse = parseEsearchResponse;
|
package/lib/imap-flow.d.ts
CHANGED
|
@@ -606,6 +606,25 @@ export interface ResponseEvent {
|
|
|
606
606
|
code?: string;
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
/** Result object returned by ESEARCH (RFC 4731) when returnOptions is specified */
|
|
610
|
+
export interface ESearchResult {
|
|
611
|
+
/** Total number of matching messages */
|
|
612
|
+
count?: number;
|
|
613
|
+
/** Lowest matching UID */
|
|
614
|
+
min?: number;
|
|
615
|
+
/** Highest matching UID */
|
|
616
|
+
max?: number;
|
|
617
|
+
/** All matching UIDs as compact sequence-set string (e.g. "1,5:10,20") */
|
|
618
|
+
all?: string;
|
|
619
|
+
/** Paged subset (RFC 9394 PARTIAL) */
|
|
620
|
+
partial?: {
|
|
621
|
+
/** The requested range, e.g. "1:100" */
|
|
622
|
+
range: string;
|
|
623
|
+
/** Matching UIDs in that range as compact sequence-set */
|
|
624
|
+
messages: string;
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
609
628
|
export class AuthenticationFailure extends Error {
|
|
610
629
|
authenticationFailed: true;
|
|
611
630
|
serverResponseCode?: string;
|
|
@@ -725,8 +744,13 @@ export class ImapFlow extends EventEmitter {
|
|
|
725
744
|
/** Moves messages from current mailbox to destination mailbox */
|
|
726
745
|
messageMove(range: SequenceString | number[] | SearchObject, destination: string, options?: { uid?: boolean }): Promise<CopyResponseObject | false>;
|
|
727
746
|
|
|
728
|
-
/** Search messages from the currently opened mailbox */
|
|
747
|
+
/** Search messages from the currently opened mailbox — returns number[] (backward-compatible) */
|
|
729
748
|
search(query: SearchObject, options?: { uid?: boolean }): Promise<number[] | false>;
|
|
749
|
+
/** Search messages with ESEARCH RETURN options — returns ESearchResult */
|
|
750
|
+
search(query: SearchObject, options: {
|
|
751
|
+
uid?: boolean;
|
|
752
|
+
returnOptions: Array<'MIN' | 'MAX' | 'COUNT' | 'ALL' | { partial: string }>;
|
|
753
|
+
}): Promise<ESearchResult | number[] | false>;
|
|
730
754
|
|
|
731
755
|
/** Fetch messages from the currently opened mailbox */
|
|
732
756
|
fetch(range: SequenceString | number[] | SearchObject, query: FetchQueryObject, options?: FetchOptions): AsyncIterableIterator<FetchMessageObject>;
|
package/lib/imap-flow.js
CHANGED
|
@@ -1799,9 +1799,10 @@ class ImapFlow extends EventEmitter {
|
|
|
1799
1799
|
|
|
1800
1800
|
// reject command that is currently processed
|
|
1801
1801
|
if (this.currentRequest && this.requestTagMap.has(this.currentRequest.tag)) {
|
|
1802
|
-
let
|
|
1802
|
+
let tag = this.currentRequest.tag;
|
|
1803
|
+
let request = this.requestTagMap.get(tag);
|
|
1803
1804
|
if (request) {
|
|
1804
|
-
this.requestTagMap.delete(
|
|
1805
|
+
this.requestTagMap.delete(tag);
|
|
1805
1806
|
pendingRequests.push(request);
|
|
1806
1807
|
}
|
|
1807
1808
|
this.currentRequest = false;
|
|
@@ -1813,7 +1814,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1813
1814
|
if (req && this.requestTagMap.has(req.tag)) {
|
|
1814
1815
|
let request = this.requestTagMap.get(req.tag);
|
|
1815
1816
|
if (request) {
|
|
1816
|
-
this.requestTagMap.delete(
|
|
1817
|
+
this.requestTagMap.delete(req.tag);
|
|
1817
1818
|
pendingRequests.push(request);
|
|
1818
1819
|
}
|
|
1819
1820
|
}
|
|
@@ -2598,7 +2599,40 @@ class ImapFlow extends EventEmitter {
|
|
|
2598
2599
|
return;
|
|
2599
2600
|
}
|
|
2600
2601
|
|
|
2601
|
-
|
|
2602
|
+
const result = (await this.run('SEARCH', query, options)) || false;
|
|
2603
|
+
|
|
2604
|
+
// When returnOptions was requested but server lacked ESEARCH capability,
|
|
2605
|
+
// search.js returns a plain number[]. Derive ESearchResult client-side.
|
|
2606
|
+
if (options && options.returnOptions && Array.isArray(result)) {
|
|
2607
|
+
const arr = result;
|
|
2608
|
+
// Normalize to uppercase so callers can use mixed-case strings like 'count'
|
|
2609
|
+
const normalizedOptions = options.returnOptions.map(o => (typeof o === 'string' ? o.toUpperCase() : o));
|
|
2610
|
+
const esearch = {};
|
|
2611
|
+
if (normalizedOptions.includes('COUNT')) {
|
|
2612
|
+
esearch.count = arr.length;
|
|
2613
|
+
}
|
|
2614
|
+
if (normalizedOptions.includes('MIN') && arr.length) {
|
|
2615
|
+
esearch.min = arr[0]; // already sorted ascending by search.js
|
|
2616
|
+
}
|
|
2617
|
+
if (normalizedOptions.includes('MAX') && arr.length) {
|
|
2618
|
+
esearch.max = arr[arr.length - 1];
|
|
2619
|
+
}
|
|
2620
|
+
if (normalizedOptions.includes('ALL') && arr.length) {
|
|
2621
|
+
esearch.all = packMessageRange(arr);
|
|
2622
|
+
}
|
|
2623
|
+
// PARTIAL cannot be derived client-side — omit it.
|
|
2624
|
+
// When returnOptions contains only { partial: ... } items and the server
|
|
2625
|
+
// lacks ESEARCH, PARTIAL cannot be derived client-side. Return the raw
|
|
2626
|
+
// number[] so the caller has actionable data. Note: this is an edge case
|
|
2627
|
+
// — callers targeting no-ESEARCH servers should avoid requesting PARTIAL
|
|
2628
|
+
// without COUNT or ALL.
|
|
2629
|
+
if (Object.keys(esearch).length === 0) {
|
|
2630
|
+
return result;
|
|
2631
|
+
}
|
|
2632
|
+
return esearch;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
return result;
|
|
2602
2636
|
}
|
|
2603
2637
|
|
|
2604
2638
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"main": "lib/imap-flow.js",
|
|
6
6
|
"types": "lib/imap-flow.d.ts",
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
"homepage": "https://imapflow.com/",
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@eslint/js": "10.0.1",
|
|
31
|
-
"@types/node": "25.5.
|
|
31
|
+
"@types/node": "25.5.2",
|
|
32
32
|
"c8": "11.0.0",
|
|
33
|
-
"eslint": "10.
|
|
33
|
+
"eslint": "10.2.0",
|
|
34
34
|
"eslint-config-nodemailer": "1.2.0",
|
|
35
35
|
"eslint-config-prettier": "10.1.8",
|
|
36
36
|
"grunt": "1.6.1",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"libbase64": "1.3.0",
|
|
49
49
|
"libmime": "5.3.7",
|
|
50
50
|
"libqp": "2.1.1",
|
|
51
|
-
"nodemailer": "8.0.
|
|
51
|
+
"nodemailer": "8.0.5",
|
|
52
52
|
"pino": "10.3.1",
|
|
53
53
|
"socks": "2.8.7"
|
|
54
54
|
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const searchCmd = require('../lib/commands/search');
|
|
4
|
+
const { parseEsearchResponse } = searchCmd;
|
|
5
|
+
const { ImapFlow } = require('../lib/imap-flow');
|
|
6
|
+
|
|
7
|
+
// Mock connection — capabilities is Map (matches real ImapFlow)
|
|
8
|
+
function makeConnection({ hasEsearch = true } = {}) {
|
|
9
|
+
const caps = new Map();
|
|
10
|
+
if (hasEsearch) {
|
|
11
|
+
caps.set('ESEARCH', true);
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
state: 'SELECTED',
|
|
15
|
+
states: { SELECTED: 'SELECTED' },
|
|
16
|
+
capabilities: caps,
|
|
17
|
+
exec: async () => ({ next: () => {} }),
|
|
18
|
+
log: { warn: () => {} }
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Parser tests ───────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
module.exports['ESEARCH: parseEsearchResponse COUNT only'] = test => {
|
|
25
|
+
const attrs = [
|
|
26
|
+
{ type: 'ATOM', value: 'COUNT' },
|
|
27
|
+
{ type: 'ATOM', value: '42' }
|
|
28
|
+
];
|
|
29
|
+
const result = parseEsearchResponse(attrs);
|
|
30
|
+
test.equal(result.count, 42);
|
|
31
|
+
test.equal(result.min, undefined);
|
|
32
|
+
test.equal(result.max, undefined);
|
|
33
|
+
test.done();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
module.exports['ESEARCH: parseEsearchResponse MIN MAX'] = test => {
|
|
37
|
+
const attrs = [
|
|
38
|
+
{ type: 'ATOM', value: 'MIN' },
|
|
39
|
+
{ type: 'ATOM', value: '1001' },
|
|
40
|
+
{ type: 'ATOM', value: 'MAX' },
|
|
41
|
+
{ type: 'ATOM', value: '9876' }
|
|
42
|
+
];
|
|
43
|
+
const result = parseEsearchResponse(attrs);
|
|
44
|
+
test.equal(result.min, 1001);
|
|
45
|
+
test.equal(result.max, 9876);
|
|
46
|
+
test.done();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
module.exports['ESEARCH: parseEsearchResponse ALL keeps compact string'] = test => {
|
|
50
|
+
const attrs = [
|
|
51
|
+
{ type: 'ATOM', value: 'ALL' },
|
|
52
|
+
{ type: 'ATOM', value: '1001,1005:1010,1020' }
|
|
53
|
+
];
|
|
54
|
+
const result = parseEsearchResponse(attrs);
|
|
55
|
+
// Must be preserved as compact string — NOT an array
|
|
56
|
+
test.equal(typeof result.all, 'string');
|
|
57
|
+
test.equal(result.all, '1001,1005:1010,1020');
|
|
58
|
+
test.done();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
module.exports['ESEARCH: parseEsearchResponse PARTIAL (Array form)'] = test => {
|
|
62
|
+
// Parser represents parenthesized groups as plain Arrays
|
|
63
|
+
const attrs = [
|
|
64
|
+
{ type: 'ATOM', value: 'PARTIAL' },
|
|
65
|
+
[
|
|
66
|
+
{ type: 'ATOM', value: '1:100' },
|
|
67
|
+
{ type: 'ATOM', value: '1001,1003:1010,1015' }
|
|
68
|
+
]
|
|
69
|
+
];
|
|
70
|
+
const result = parseEsearchResponse(attrs);
|
|
71
|
+
test.deepEqual(result.partial, { range: '1:100', messages: '1001,1003:1010,1015' });
|
|
72
|
+
test.done();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
module.exports['ESEARCH: parseEsearchResponse PARTIAL (LIST object form)'] = test => {
|
|
76
|
+
// Also handle {type: 'LIST', attributes: [...]} form for robustness
|
|
77
|
+
const attrs = [
|
|
78
|
+
{ type: 'ATOM', value: 'PARTIAL' },
|
|
79
|
+
{
|
|
80
|
+
type: 'LIST',
|
|
81
|
+
attributes: [
|
|
82
|
+
{ type: 'ATOM', value: '1:100' },
|
|
83
|
+
{ type: 'ATOM', value: '1001,1003:1010,1015' }
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
];
|
|
87
|
+
const result = parseEsearchResponse(attrs);
|
|
88
|
+
test.deepEqual(result.partial, { range: '1:100', messages: '1001,1003:1010,1015' });
|
|
89
|
+
test.done();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
module.exports['ESEARCH: parseEsearchResponse COUNT + PARTIAL combined'] = test => {
|
|
93
|
+
const attrs = [
|
|
94
|
+
{ type: 'ATOM', value: 'COUNT' },
|
|
95
|
+
{ type: 'ATOM', value: '34201' },
|
|
96
|
+
{ type: 'ATOM', value: 'PARTIAL' },
|
|
97
|
+
[
|
|
98
|
+
{ type: 'ATOM', value: '1:100' },
|
|
99
|
+
{ type: 'ATOM', value: '2001,2003:2020' }
|
|
100
|
+
]
|
|
101
|
+
];
|
|
102
|
+
const result = parseEsearchResponse(attrs);
|
|
103
|
+
test.equal(result.count, 34201);
|
|
104
|
+
test.deepEqual(result.partial, { range: '1:100', messages: '2001,2003:2020' });
|
|
105
|
+
test.done();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ── Command-building tests ─────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
module.exports['ESEARCH: emits RETURN clause when returnOptions present and server has ESEARCH'] = test => {
|
|
111
|
+
const conn = makeConnection({ hasEsearch: true });
|
|
112
|
+
let capturedCommand = null;
|
|
113
|
+
let capturedAttributes = null;
|
|
114
|
+
conn.exec = async (command, attributes) => {
|
|
115
|
+
capturedCommand = command;
|
|
116
|
+
capturedAttributes = JSON.stringify(attributes);
|
|
117
|
+
return { next: () => {} };
|
|
118
|
+
};
|
|
119
|
+
searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT'] }).then(() => {
|
|
120
|
+
test.equal(capturedCommand, 'UID SEARCH');
|
|
121
|
+
test.ok(capturedAttributes.includes('"RETURN"'), 'should include RETURN atom');
|
|
122
|
+
test.ok(capturedAttributes.includes('"COUNT"'), 'should include COUNT in return list');
|
|
123
|
+
test.done();
|
|
124
|
+
}).catch(err => test.done(err));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
module.exports['ESEARCH: RETURN clause includes PARTIAL range atom'] = test => {
|
|
128
|
+
const conn = makeConnection({ hasEsearch: true });
|
|
129
|
+
let capturedAttributes = null;
|
|
130
|
+
conn.exec = async (command, attributes) => {
|
|
131
|
+
capturedAttributes = JSON.stringify(attributes);
|
|
132
|
+
return { next: () => {} };
|
|
133
|
+
};
|
|
134
|
+
searchCmd(conn, { seen: false }, { uid: true, returnOptions: [{ partial: '1:100' }] }).then(() => {
|
|
135
|
+
test.ok(capturedAttributes.includes('"PARTIAL"'), 'should include PARTIAL atom');
|
|
136
|
+
test.ok(capturedAttributes.includes('"1:100"'), 'should include range string');
|
|
137
|
+
test.done();
|
|
138
|
+
}).catch(err => test.done(err));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
module.exports['ESEARCH: no RETURN clause when server lacks ESEARCH capability'] = test => {
|
|
142
|
+
const conn = makeConnection({ hasEsearch: false });
|
|
143
|
+
let capturedAttributes = null;
|
|
144
|
+
conn.exec = async (command, attributes, handlers) => {
|
|
145
|
+
capturedAttributes = JSON.stringify(attributes);
|
|
146
|
+
// Simulate plain SEARCH response
|
|
147
|
+
const searchHandler = handlers && handlers.untagged && handlers.untagged.SEARCH;
|
|
148
|
+
if (searchHandler) {
|
|
149
|
+
await searchHandler({
|
|
150
|
+
attributes: [{ value: '1' }, { value: '2' }, { value: '3' }]
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return { next: () => {} };
|
|
154
|
+
};
|
|
155
|
+
searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT', 'ALL'] }).then(result => {
|
|
156
|
+
test.ok(!capturedAttributes.includes('"RETURN"'), 'should NOT include RETURN when no ESEARCH');
|
|
157
|
+
test.ok(Array.isArray(result), 'should return number[] when ESEARCH unavailable');
|
|
158
|
+
test.deepEqual(result, [1, 2, 3]);
|
|
159
|
+
test.done();
|
|
160
|
+
}).catch(err => test.done(err));
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
module.exports['ESEARCH: parseEsearchResponse ignores unknown keywords'] = test => {
|
|
164
|
+
// Dovecot with CONDSTORE may append MODSEQ to ESEARCH responses
|
|
165
|
+
const attrs = [
|
|
166
|
+
{ type: 'ATOM', value: 'COUNT' },
|
|
167
|
+
{ type: 'ATOM', value: '5' },
|
|
168
|
+
{ type: 'ATOM', value: 'MODSEQ' },
|
|
169
|
+
{ type: 'ATOM', value: '12345' }
|
|
170
|
+
];
|
|
171
|
+
const result = parseEsearchResponse(attrs);
|
|
172
|
+
test.equal(result.count, 5);
|
|
173
|
+
test.equal(result.modseq, undefined, 'unknown keys should not appear in result');
|
|
174
|
+
test.done();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
module.exports['ESEARCH: backward compat — no returnOptions returns number[]'] = test => {
|
|
178
|
+
const conn = makeConnection({ hasEsearch: true });
|
|
179
|
+
conn.exec = async (command, attributes, handlers) => {
|
|
180
|
+
const searchHandler = handlers && handlers.untagged && handlers.untagged.SEARCH;
|
|
181
|
+
if (searchHandler) {
|
|
182
|
+
await searchHandler({
|
|
183
|
+
attributes: [{ value: '10' }, { value: '20' }]
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return { next: () => {} };
|
|
187
|
+
};
|
|
188
|
+
// No returnOptions — must return number[] even if server has ESEARCH
|
|
189
|
+
searchCmd(conn, { seen: true }, { uid: true }).then(result => {
|
|
190
|
+
test.ok(Array.isArray(result));
|
|
191
|
+
test.deepEqual(result, [10, 20]);
|
|
192
|
+
test.done();
|
|
193
|
+
}).catch(err => test.done(err));
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// ── imap-flow.js public API fallback test ─────────────────────────────────
|
|
197
|
+
module.exports['imap-flow: search() derives ESearchResult when server has no ESEARCH'] = test => {
|
|
198
|
+
const client = new ImapFlow({
|
|
199
|
+
host: 'imap.example.com',
|
|
200
|
+
port: 993,
|
|
201
|
+
auth: { user: 'test', pass: 'test' },
|
|
202
|
+
logger: false
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Simulate a selected mailbox and no ESEARCH capability
|
|
206
|
+
client.mailbox = { path: 'INBOX' };
|
|
207
|
+
client.state = client.states.SELECTED;
|
|
208
|
+
client.capabilities = new Map(); // no ESEARCH
|
|
209
|
+
|
|
210
|
+
// Stub run() to return a sorted number[]
|
|
211
|
+
client.run = async () => [10, 20, 30, 40, 50];
|
|
212
|
+
|
|
213
|
+
client.search({ seen: false }, { uid: true, returnOptions: ['COUNT', 'MIN', 'MAX', 'ALL'] }).then(result => {
|
|
214
|
+
test.equal(typeof result, 'object', 'should return object, not array');
|
|
215
|
+
test.ok(!Array.isArray(result), 'should not be an array');
|
|
216
|
+
test.equal(result.count, 5);
|
|
217
|
+
test.equal(result.min, 10);
|
|
218
|
+
test.equal(result.max, 50);
|
|
219
|
+
// packMessageRange([10,20,30,40,50]) → "10,20,30,40,50" (non-contiguous)
|
|
220
|
+
test.ok(typeof result.all === 'string' && result.all.length > 0, 'all should be non-empty compact string');
|
|
221
|
+
test.done();
|
|
222
|
+
}).catch(err => test.done(err));
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
module.exports['imap-flow: search() fallback with empty result set'] = test => {
|
|
226
|
+
const client = new ImapFlow({
|
|
227
|
+
host: 'imap.example.com',
|
|
228
|
+
port: 993,
|
|
229
|
+
auth: { user: 'test', pass: 'test' },
|
|
230
|
+
logger: false
|
|
231
|
+
});
|
|
232
|
+
client.mailbox = { path: 'INBOX' };
|
|
233
|
+
client.state = client.states.SELECTED;
|
|
234
|
+
client.capabilities = new Map();
|
|
235
|
+
client.run = async () => [];
|
|
236
|
+
client.search({}, { uid: true, returnOptions: ['COUNT', 'ALL'] }).then(result => {
|
|
237
|
+
test.equal(result.count, 0);
|
|
238
|
+
test.equal(result.all, undefined, 'all should be absent for empty result');
|
|
239
|
+
test.done();
|
|
240
|
+
}).catch(err => test.done(err));
|
|
241
|
+
};
|