imapflow 1.2.18 → 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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.2.18"
2
+ ".": "1.3.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
3
17
  ## [1.2.18](https://github.com/postalsys/imapflow/compare/v1.2.17...v1.2.18) (2026-03-25)
4
18
 
5
19
 
package/CLAUDE.md ADDED
@@ -0,0 +1,5 @@
1
+ # ImapFlow
2
+
3
+ ## Release Process
4
+
5
+ Changelog and version numbers are managed automatically by the release-please GitHub Action. Do not check for or suggest CHANGELOG entries or version bumps during code reviews.
@@ -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
- * @returns {Promise<number[]|boolean>} Sorted array of matching sequence numbers or UIDs, or false on failure
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;
@@ -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
@@ -2599,7 +2599,40 @@ class ImapFlow extends EventEmitter {
2599
2599
  return;
2600
2600
  }
2601
2601
 
2602
- return (await this.run('SEARCH', query, options)) || false;
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;
2603
2636
  }
2604
2637
 
2605
2638
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.2.18",
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.0",
31
+ "@types/node": "25.5.2",
32
32
  "c8": "11.0.0",
33
- "eslint": "10.1.0",
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.4",
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
+ };