imapflow 2.1.0 → 2.1.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.2](https://github.com/postalsys/imapflow/compare/v2.1.1...v2.1.2) (2026-09-28)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **deps:** update libmime to 5.4.6 and mailsplit to 5.4.19 ([f6afb32](https://github.com/postalsys/imapflow/commit/f6afb32cbb1df43993a1df2cbd003e013bc10fa8))
9
+
10
+ ## [2.1.1](https://github.com/postalsys/imapflow/compare/v2.1.0...v2.1.1) (2026-09-28)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * settle every pending connect on close, refuse empty search operands, keep NIL envelope rows, prefer top-level special-use folders ([27a93cc](https://github.com/postalsys/imapflow/commit/27a93ccd292fc95842bcc27cdc76e0cb0de613ba))
16
+
3
17
  ## [2.1.0](https://github.com/postalsys/imapflow/compare/v2.0.8...v2.1.0) (2026-09-27)
4
18
 
5
19
 
package/README.md CHANGED
@@ -68,6 +68,8 @@ const main = async () => {
68
68
  main().catch(console.error);
69
69
  ```
70
70
 
71
+ An `ImapFlow` instance holds a single connection and cannot reconnect. Once the connection has closed, or after `connect()` has been called once, create a new instance to connect again. Calling `connect()` a second time on the same instance throws an error with the code `InstanceReused`.
72
+
71
73
  See the [Quick Start guide](https://imapflow.com/docs/getting-started/quick-start) for more examples, including Gmail, Outlook, and Yahoo configuration.
72
74
 
73
75
  ## Documentation
@@ -151,10 +151,12 @@ async function list(connection, reference, mailbox, options) {
151
151
  if (!untagged.attributes || !untagged.attributes.length) {
152
152
  return;
153
153
  }
154
+ // A name sent as a literal arrives as a Buffer, so convert it once for both fields
155
+ let rawPath = ((untagged.attributes[2] && untagged.attributes[2].value) || '').toString();
154
156
  let entry = {
155
157
  // Decode from modified UTF-7 wire format and normalize the path
156
- path: (0, tools_js_1.normalizePath)(connection, (0, tools_js_1.decodePath)(connection, ((untagged.attributes[2] && untagged.attributes[2].value) || ''))),
157
- pathAsListed: ((untagged.attributes[2] && untagged.attributes[2].value) || ''),
158
+ path: (0, tools_js_1.normalizePath)(connection, (0, tools_js_1.decodePath)(connection, rawPath)),
159
+ pathAsListed: rawPath,
158
160
  flags: new Set((0, tools_js_1.getStringList)(untagged.attributes[0])),
159
161
  delimiter: (untagged.attributes[1] && untagged.attributes[1].value),
160
162
  listed: true
@@ -382,9 +384,11 @@ async function list(connection, reference, mailbox, options) {
382
384
  if (!untagged.attributes || !untagged.attributes.length) {
383
385
  return;
384
386
  }
387
+ // A name sent as a literal arrives as a Buffer, so convert it once for both fields
388
+ let rawPath = ((untagged.attributes[2] && untagged.attributes[2].value) || '').toString();
385
389
  let entry = {
386
- path: (0, tools_js_1.normalizePath)(connection, (0, tools_js_1.decodePath)(connection, ((untagged.attributes[2] && untagged.attributes[2].value) || ''))),
387
- pathAsListed: ((untagged.attributes[2] && untagged.attributes[2].value) || ''),
390
+ path: (0, tools_js_1.normalizePath)(connection, (0, tools_js_1.decodePath)(connection, rawPath)),
391
+ pathAsListed: rawPath,
388
392
  flags: new Set((0, tools_js_1.getStringList)(untagged.attributes[0])),
389
393
  delimiter: (untagged.attributes[1] && untagged.attributes[1].value),
390
394
  subscribed: true
@@ -450,15 +454,23 @@ async function list(connection, reference, mailbox, options) {
450
454
  // Resolve special-use conflicts. Each type goes to one mailbox and each mailbox gets
451
455
  // at most one type. Candidates are taken in priority order across all types (user >
452
456
  // extension > name, then alphabetically), so a mailbox claimed by a stronger match
453
- // leaves its other type to that type's next candidate instead of to nobody.
457
+ // leaves its other type to that type's next candidate instead of to nobody. Within a
458
+ // source a shallower mailbox wins before the alphabetical order is consulted, so that
459
+ // INBOX.Sent is preferred over INBOX.Archive.Sent.
454
460
  let candidates = Object.entries(specialUseMatches).flatMap(([type, matches]) => matches.map(match => ({ type, ...match })));
455
461
  candidates.sort((a, b) => {
456
462
  let aSource = SOURCE_SORT_ORDER.indexOf(a.source);
457
463
  let bSource = SOURCE_SORT_ORDER.indexOf(b.source);
458
- if (aSource === bSource) {
459
- return a.entry.path.localeCompare(b.entry.path);
464
+ if (aSource !== bSource) {
465
+ return aSource - bSource;
460
466
  }
461
- return aSource - bSource;
467
+ // parent is set on every listed entry before the candidates are ranked
468
+ let aDepth = a.entry.parent.length;
469
+ let bDepth = b.entry.parent.length;
470
+ if (aDepth !== bDepth) {
471
+ return aDepth - bDepth;
472
+ }
473
+ return a.entry.path.localeCompare(b.entry.path);
462
474
  });
463
475
  let assignedTypes = new Set();
464
476
  for (let { type, entry, source } of candidates) {
@@ -37,12 +37,14 @@ export declare const ImapFlowErrorCode: {
37
37
  readonly InvalidTokenValue: "InvalidTokenValue";
38
38
  readonly InvalidTextValue: "InvalidTextValue";
39
39
  readonly InvalidSequenceSet: "InvalidSequenceSet";
40
+ readonly InvalidSearchQuery: "InvalidSearchQuery";
40
41
  readonly DownloadOverflow: "DownloadOverflow";
41
42
  readonly DownloadIncomplete: "DownloadIncomplete";
42
43
  readonly ProxyError: "ProxyError";
43
44
  readonly EPROXY: "EPROXY";
44
45
  readonly UnsupportedProxyAddress: "UnsupportedProxyAddress";
45
46
  readonly ERR_INVALID_URL: "ERR_INVALID_URL";
47
+ readonly InstanceReused: "InstanceReused";
46
48
  };
47
49
  /** One of the {@link ImapFlowErrorCode} values */
48
50
  export type ImapFlowErrorCode = (typeof ImapFlowErrorCode)[keyof typeof ImapFlowErrorCode];
@@ -44,6 +44,7 @@ exports.ImapFlowErrorCode = {
44
44
  InvalidTokenValue: 'InvalidTokenValue',
45
45
  InvalidTextValue: 'InvalidTextValue',
46
46
  InvalidSequenceSet: 'InvalidSequenceSet',
47
+ InvalidSearchQuery: 'InvalidSearchQuery',
47
48
  // download()
48
49
  DownloadOverflow: 'DownloadOverflow',
49
50
  DownloadIncomplete: 'DownloadIncomplete',
@@ -51,7 +52,9 @@ exports.ImapFlowErrorCode = {
51
52
  ProxyError: 'ProxyError',
52
53
  EPROXY: 'EPROXY',
53
54
  UnsupportedProxyAddress: 'UnsupportedProxyAddress',
54
- ERR_INVALID_URL: 'ERR_INVALID_URL'
55
+ ERR_INVALID_URL: 'ERR_INVALID_URL',
56
+ // API misuse
57
+ InstanceReused: 'InstanceReused'
55
58
  };
56
59
  /**
57
60
  * Error subclass thrown when IMAP authentication fails.
@@ -1,11 +1,8 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.ImapStream = void 0;
7
4
  const node_stream_1 = require("node:stream");
8
- const logger_js_1 = __importDefault(require("../logger.js"));
5
+ const logger_js_1 = require("../logger.js");
9
6
  const limits_js_1 = require("./limits.js");
10
7
  const LINE = 0x01;
11
8
  const LITERAL = 0x02;
@@ -36,13 +33,12 @@ class ImapStream extends node_stream_1.Transform {
36
33
  });
37
34
  this.options = options || {};
38
35
  this.cid = this.options.cid;
39
- this.log =
40
- this.options.logger && typeof this.options.logger === 'object'
41
- ? this.options.logger
42
- : logger_js_1.default.child({
43
- component: 'imap-connection',
44
- cid: this.cid
45
- });
36
+ if (this.options.logger && typeof this.options.logger === 'object') {
37
+ this.log = this.options.logger;
38
+ }
39
+ else {
40
+ this.log = (0, logger_js_1.createConnectionLogger)({ cid: this.cid, logRaw: this.options.logRaw });
41
+ }
46
42
  this.readBytesCounter = 0;
47
43
  // Maximum length of a single line (response without a literal). Bounds the line buffer
48
44
  // so a server that never sends a line terminator cannot exhaust memory.
@@ -46,7 +46,7 @@ const node_crypto_1 = __importDefault(require("node:crypto"));
46
46
  const node_zlib_1 = __importDefault(require("node:zlib"));
47
47
  const node_events_1 = require("node:events");
48
48
  const node_stream_1 = require("node:stream");
49
- const logger_js_1 = __importDefault(require("./logger.js"));
49
+ const logger_js_1 = require("./logger.js");
50
50
  const packageInfo = __importStar(require("./package-info.js"));
51
51
  const imap_stream_js_1 = require("./handler/imap-stream.js");
52
52
  const imap_handler_js_1 = require("./handler/imap-handler.js");
@@ -1931,9 +1931,17 @@ class ImapFlow extends node_events_1.EventEmitter {
1931
1931
  async connect() {
1932
1932
  if (this._connectCalled) {
1933
1933
  // Prevent re-using ImapFlow instances by allowing to call connect just once.
1934
- throw new Error('Can not re-use ImapFlow instance');
1934
+ let err = new Error('Can not re-use ImapFlow instance');
1935
+ err.code = 'InstanceReused';
1936
+ throw err;
1935
1937
  }
1936
1938
  this._connectCalled = true;
1939
+ let closedError = () => this.createNoConnectionError(this.byeReason, { rejectedFrom: 'connect' });
1940
+ // close() already ran, so there is no connection to set up and nothing would ever
1941
+ // settle a connect attempt started now.
1942
+ if (this.isClosed) {
1943
+ throw closedError();
1944
+ }
1937
1945
  // One deadline for the whole attempt, started before anything is resolved or negotiated.
1938
1946
  // Proxy DNS and proxy negotiation used to run entirely outside the timer, so a stalled
1939
1947
  // proxy could hang far beyond the documented connectionTimeout.
@@ -1979,9 +1987,20 @@ class ImapFlow extends node_events_1.EventEmitter {
1979
1987
  error._err = err;
1980
1988
  throw error;
1981
1989
  }
1990
+ // close() during proxy setup found no socket to destroy and no connect to reject,
1991
+ // so the tunnel that just opened is dropped here instead of being handed to a
1992
+ // closed client.
1993
+ if (this.isClosed) {
1994
+ socket.destroy();
1995
+ throw closedError();
1996
+ }
1982
1997
  }
1983
1998
  // Guarded: close() rejects a pending connect() synchronously. See guardedPromise().
1984
1999
  let connectPromise = (0, tools_js_1.guardedPromise)((resolve, reject) => {
2000
+ // Stored before the transport is up, so a close() that lands before onConnect
2001
+ // still rejects this attempt through closeConnectSteps().
2002
+ this.initialResolve = resolve;
2003
+ this.initialReject = reject;
1985
2004
  // Whatever the proxy phase already used is gone from the budget
1986
2005
  this.connectTimeout = setTimeout(() => {
1987
2006
  let err = deadline.error();
@@ -2033,9 +2052,6 @@ class ImapFlow extends node_events_1.EventEmitter {
2033
2052
  this.setSocketHandlers();
2034
2053
  this.setEventHandlers();
2035
2054
  connected.pipe(this.streamer);
2036
- // executed by initial "* OK"
2037
- this.initialResolve = resolve;
2038
- this.initialReject = reject;
2039
2055
  /* c8 ignore next 4 */ // defensive: the onConnect setup body does not throw under normal operation
2040
2056
  }
2041
2057
  catch (ex) {
@@ -2219,7 +2235,10 @@ class ImapFlow extends node_events_1.EventEmitter {
2219
2235
  this._upgradeReject = null;
2220
2236
  reject(this.createNoConnectionError(false, { rejectedFrom: 'upgrade' }));
2221
2237
  }
2222
- if (typeof this.initialReject === 'function' && !this.options.verifyOnly) {
2238
+ // A verifyOnly session closes itself with LOGOUT and startSession() settles connect()
2239
+ // with the outcome, so the close is not a failure there. Before the greeting nothing
2240
+ // else can settle it (BYE greeting, FIN, close() while the transport is still coming up).
2241
+ if (typeof this.initialReject === 'function' && (!this.options.verifyOnly || !this.greetingReceived)) {
2223
2242
  (0, tools_js_1.clearTimer)(this.greetingTimeout);
2224
2243
  let reject = this.initialReject;
2225
2244
  this.initialResolve = false;
@@ -2530,7 +2549,26 @@ class ImapFlow extends node_events_1.EventEmitter {
2530
2549
  * // 125
2531
2550
  */
2532
2551
  async mailboxOpen(path, options) {
2533
- return await this.run('SELECT', path, options);
2552
+ try {
2553
+ return await this.run('SELECT', path, options);
2554
+ }
2555
+ catch (err) {
2556
+ if (err.responseStatus === 'NO') {
2557
+ // SELECT failed with NO: verify whether the mailbox exists at all by running
2558
+ // LIST. This sets mailboxMissing on the error so the caller can distinguish
2559
+ // "doesn't exist" from other failures, for getMailboxLock() callers as well.
2560
+ try {
2561
+ let folders = await this.run('LIST', '', (0, tools_js_1.normalizePath)(this, path), { listOnly: true });
2562
+ if (!folders || !folders.length) {
2563
+ err.mailboxMissing = true;
2564
+ }
2565
+ }
2566
+ catch (E) {
2567
+ this.log.trace({ msg: 'Failed to verify failed mailbox', path, err: E });
2568
+ }
2569
+ }
2570
+ throw err;
2571
+ }
2534
2572
  }
2535
2573
  /**
2536
2574
  * Closes a previously opened mailbox
@@ -3232,20 +3270,7 @@ class ImapFlow extends node_events_1.EventEmitter {
3232
3270
  break; // Wait for this lock to be released
3233
3271
  }
3234
3272
  catch (err) {
3235
- if (err.responseStatus === 'NO') {
3236
- // SELECT failed with NO: verify whether the mailbox exists
3237
- // at all by running LIST. This sets mailboxMissing on the error
3238
- // so the caller can distinguish "doesn't exist" from other failures.
3239
- try {
3240
- let folders = await this.run('LIST', '', path, { listOnly: true });
3241
- if (!folders || !folders.length) {
3242
- err.mailboxMissing = true;
3243
- }
3244
- }
3245
- catch (E) {
3246
- this.log.trace({ msg: 'Failed to verify failed mailbox', path, err: E });
3247
- }
3248
- }
3273
+ // mailboxOpen() has already marked a missing mailbox (mailboxMissing)
3249
3274
  this.log.trace({
3250
3275
  msg: 'Failed to acquire mailbox lock',
3251
3276
  path,
@@ -3336,12 +3361,14 @@ class ImapFlow extends node_events_1.EventEmitter {
3336
3361
  }
3337
3362
  /** @internal */
3338
3363
  getLogger() {
3339
- let mainLogger = this.options.logger && typeof this.options.logger === 'object'
3340
- ? this.options.logger
3341
- : logger_js_1.default.child({
3342
- component: 'imap-connection',
3343
- cid: this.id
3344
- });
3364
+ let mainLogger = {};
3365
+ if (this.options.logger && typeof this.options.logger === 'object') {
3366
+ mainLogger = this.options.logger;
3367
+ }
3368
+ else if (this.options.logger !== false) {
3369
+ // {logger:false} never consults mainLogger, so it does not create the default logger
3370
+ mainLogger = (0, logger_js_1.createConnectionLogger)({ cid: this.id, logRaw: this.options.logRaw });
3371
+ }
3345
3372
  let synteticLogger = {};
3346
3373
  let levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
3347
3374
  for (let level of levels) {
@@ -1,3 +1,21 @@
1
- import pino from 'pino';
2
- declare const logger: pino.Logger<never, boolean>;
3
- export default logger;
1
+ import { type Logger as PinoLogger } from 'pino';
2
+ /**
3
+ * Returns the shared default logger, used when a connection is given no logger of its own.
4
+ * Created on first use, so importing the library does not set up a stdout logger nobody asked
5
+ * for. Level info: a caller that asks for raw socket data (logRaw) lowers its own child logger
6
+ * to trace.
7
+ *
8
+ * @returns The default pino logger
9
+ */
10
+ export declare function getDefaultLogger(): PinoLogger;
11
+ /**
12
+ * Returns the child of the default logger a connection logs through when it was given no
13
+ * logger of its own.
14
+ *
15
+ * @param options - cid is the connection id to stamp on entries, logRaw asks for raw socket data
16
+ * @returns A child logger of the default logger
17
+ */
18
+ export declare function createConnectionLogger(options: {
19
+ cid?: string | undefined;
20
+ logRaw?: boolean | undefined;
21
+ }): PinoLogger;
@@ -3,9 +3,39 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getDefaultLogger = getDefaultLogger;
7
+ exports.createConnectionLogger = createConnectionLogger;
6
8
  const pino_1 = __importDefault(require("pino"));
7
- const logger = (0, pino_1.default)();
8
- logger.level = 'trace';
9
- exports.default = logger;
10
- module.exports = exports.default;
11
- Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
9
+ let logger = null;
10
+ /**
11
+ * Returns the shared default logger, used when a connection is given no logger of its own.
12
+ * Created on first use, so importing the library does not set up a stdout logger nobody asked
13
+ * for. Level info: a caller that asks for raw socket data (logRaw) lowers its own child logger
14
+ * to trace.
15
+ *
16
+ * @returns The default pino logger
17
+ */
18
+ function getDefaultLogger() {
19
+ if (!logger) {
20
+ logger = (0, pino_1.default)({ level: 'info' });
21
+ }
22
+ return logger;
23
+ }
24
+ /**
25
+ * Returns the child of the default logger a connection logs through when it was given no
26
+ * logger of its own.
27
+ *
28
+ * @param options - cid is the connection id to stamp on entries, logRaw asks for raw socket data
29
+ * @returns A child logger of the default logger
30
+ */
31
+ function createConnectionLogger(options) {
32
+ let child = getDefaultLogger().child({
33
+ component: 'imap-connection',
34
+ cid: options.cid
35
+ });
36
+ // Raw socket data is logged at trace level, so asking for it lowers the threshold
37
+ if (options.logRaw) {
38
+ child.level = 'trace';
39
+ }
40
+ return child;
41
+ }
@@ -1,3 +1,3 @@
1
1
  export declare const name = "imapflow";
2
- export declare const version = "2.1.0";
2
+ export declare const version = "2.1.2";
3
3
  export declare const homepage = "https://imapflow.com/";
@@ -3,5 +3,5 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.homepage = exports.version = exports.name = void 0;
5
5
  exports.name = "imapflow";
6
- exports.version = "2.1.0";
6
+ exports.version = "2.1.2";
7
7
  exports.homepage = "https://imapflow.com/";
@@ -9,7 +9,7 @@ export type SearchAttribute = ImapAttributeNode | SearchAttribute[];
9
9
  * Compiles a JavaScript object query into IMAP search command attributes.
10
10
  * Supports standard IMAP search criteria and extensions like OBJECTID and Gmail extensions.
11
11
  *
12
- * @param connection - IMAP connection object (capabilities, enabled extensions and the current mailbox are read)
12
+ * @param connection - IMAP connection object (capabilities and enabled extensions are read)
13
13
  * @param query - Search query object
14
14
  * @returns Array of IMAP search attributes
15
15
  * @throws {Error} When required server extensions are not available
@@ -91,6 +91,17 @@ let processDateField = (attributes, term, value) => {
91
91
  };
92
92
  // Pre-compiled regex for better performance
93
93
  const UNICODE_PATTERN = /[^\x00-\x7F]/;
94
+ /**
95
+ * Throws a coded search compilation error.
96
+ *
97
+ * @param code - Error code, one of the ImapFlowErrorCode values
98
+ * @param message - Error message
99
+ */
100
+ let fail = (code, message) => {
101
+ let error = new Error(message);
102
+ error.code = code;
103
+ throw error;
104
+ };
94
105
  /**
95
106
  * Checks if a string contains Unicode characters.
96
107
  * Used to determine if CHARSET UTF-8 needs to be specified.
@@ -110,7 +121,7 @@ let isUnicodeString = (str) => {
110
121
  * Compiles a JavaScript object query into IMAP search command attributes.
111
122
  * Supports standard IMAP search criteria and extensions like OBJECTID and Gmail extensions.
112
123
  *
113
- * @param connection - IMAP connection object (capabilities, enabled extensions and the current mailbox are read)
124
+ * @param connection - IMAP connection object (capabilities and enabled extensions are read)
114
125
  * @param query - Search query object
115
126
  * @returns Array of IMAP search attributes
116
127
  * @throws {Error} When required server extensions are not available
@@ -136,21 +147,29 @@ const searchCompiler = (connection, query) => {
136
147
  const attributes = [];
137
148
  // Track if we need to specify UTF-8 charset
138
149
  let hasUnicode = false;
139
- const mailbox = connection.mailbox;
140
150
  /**
141
151
  * Recursively walks through the query object and builds IMAP attributes.
142
152
  * @param params - Query parameters to process
143
153
  */
144
154
  const walk = (params) => {
145
- // Walks a query object and wraps the resulting attributes in a
146
- // sub-array so the IMAP compiler emits parentheses around them.
147
- // Used when a single search-key is required (NOT, OR operands)
148
- // but the condition has multiple keys (RFC 3501 Section 6.4.4).
149
- let walkGrouped = (obj) => {
155
+ // Compiles one NOT or OR operand, which the caller has already put its operator in
156
+ // front of. An operand with several keys is wrapped in a sub-array so the IMAP compiler
157
+ // emits parentheses around it, as the operator takes a single search-key
158
+ // (RFC 3501 Section 6.4.4). An operand that compiles to nothing (an invalid date, an
159
+ // empty object, ...) is refused: the operator would otherwise bind to whatever
160
+ // criterion follows it and invert or widen the search.
161
+ let walkOperand = (operator, obj) => {
150
162
  let startIdx = attributes.length;
151
- walk(obj);
152
- let subAttrs = attributes.splice(startIdx);
153
- attributes.push(subAttrs);
163
+ if (obj && typeof obj === 'object') {
164
+ walk(obj);
165
+ }
166
+ if (attributes.length === startIdx) {
167
+ fail('InvalidSearchQuery', `Search operand for ${operator} does not include any usable search criteria`);
168
+ }
169
+ if (Object.keys(obj).length > 1) {
170
+ let subAttrs = attributes.splice(startIdx);
171
+ attributes.push(subAttrs);
172
+ }
154
173
  };
155
174
  Object.keys(params || {}).forEach(term => {
156
175
  switch (term.toUpperCase()) {
@@ -196,9 +215,7 @@ const searchCompiler = (connection, query) => {
196
215
  // reject the whole search with a tagged BAD, so fail with a
197
216
  // descriptive error instead
198
217
  if ((0, tools_js_1.isRev2Active)(connection)) {
199
- let error = new Error(`The "${term.toLowerCase()}" search key does not exist in IMAP4rev2`);
200
- error.code = 'MissingServerExtension';
201
- throw error;
218
+ fail('MissingServerExtension', `The "${term.toLowerCase()}" search key does not exist in IMAP4rev2`);
202
219
  }
203
220
  setBoolOpt(attributes, term, true);
204
221
  }
@@ -244,6 +261,11 @@ const searchCompiler = (connection, query) => {
244
261
  // Fallback to Gmail message ID
245
262
  setOpt(attributes, 'X-GM-MSGID', params[term]);
246
263
  }
264
+ else if (params[term]) {
265
+ // Dropping the criterion would widen the search to every message
266
+ // matching the rest of the query, which a delete or move acts on
267
+ fail('MissingServerExtension', 'Server does not support OBJECTID or X-GM-EXT-1 extension required for EMAILID');
268
+ }
247
269
  break;
248
270
  // Thread ID support (OBJECTID or Gmail extension)
249
271
  case 'THREADID':
@@ -254,6 +276,11 @@ const searchCompiler = (connection, query) => {
254
276
  // Fallback to Gmail thread ID
255
277
  setOpt(attributes, 'X-GM-THRID', params[term]);
256
278
  }
279
+ else if (params[term]) {
280
+ // Dropping the criterion would widen the search to every message
281
+ // matching the rest of the query, which a delete or move acts on
282
+ fail('MissingServerExtension', 'Server does not support OBJECTID or X-GM-EXT-1 extension required for THREADID');
283
+ }
257
284
  break;
258
285
  // Gmail raw search
259
286
  case 'GMRAW':
@@ -265,9 +292,7 @@ const searchCompiler = (connection, query) => {
265
292
  setOpt(attributes, 'X-GM-RAW', params[term]);
266
293
  }
267
294
  else {
268
- let error = new Error('Server does not support X-GM-EXT-1 extension required for X-GM-RAW');
269
- error.code = 'MissingServerExtension';
270
- throw error;
295
+ fail('MissingServerExtension', 'Server does not support X-GM-EXT-1 extension required for X-GM-RAW');
271
296
  }
272
297
  break;
273
298
  // Gmail label search. Compiles { has, not } into an X-GM-RAW "label:"/"-label:" query
@@ -301,9 +326,7 @@ const searchCompiler = (connection, query) => {
301
326
  break;
302
327
  }
303
328
  if (!connection.capabilities.has('X-GM-EXT-1')) {
304
- let error = new Error('Server does not support X-GM-EXT-1 extension required for label search');
305
- error.code = 'MissingServerExtension';
306
- throw error;
329
+ fail('MissingServerExtension', 'Server does not support X-GM-EXT-1 extension required for label search');
307
330
  }
308
331
  let rawQuery = rawParts.join(' ');
309
332
  if (isUnicodeString(rawQuery)) {
@@ -347,8 +370,10 @@ const searchCompiler = (connection, query) => {
347
370
  case 'UNKEYWORD':
348
371
  {
349
372
  let flag = (0, tools_js_1.formatFlag)(params[term]);
350
- // Only add if flag is supported or already exists in mailbox
351
- if ((0, tools_js_1.canUseFlag)(mailbox, flag) || mailbox.flags.has(flag)) {
373
+ // Compiled even when the mailbox does not allow the keyword: the
374
+ // correct answer is then the empty set, which dropping the
375
+ // criterion would turn into every message matching the rest
376
+ if (flag) {
352
377
  setOpt(attributes, term, flag);
353
378
  }
354
379
  }
@@ -377,12 +402,7 @@ const searchCompiler = (connection, query) => {
377
402
  case 'NOT':
378
403
  if (params[term] && typeof params[term] === 'object') {
379
404
  attributes.push({ type: 'ATOM', value: 'NOT' });
380
- if (Object.keys(params[term]).length > 1) {
381
- walkGrouped(params[term]);
382
- }
383
- else {
384
- walk(params[term]);
385
- }
405
+ walkOperand('NOT', params[term]);
386
406
  }
387
407
  break;
388
408
  // OR operator - complex logic for building OR trees
@@ -441,14 +461,7 @@ const searchCompiler = (connection, query) => {
441
461
  entry.forEach(walkOrTree);
442
462
  return;
443
463
  }
444
- if (entry && typeof entry === 'object') {
445
- if (Object.keys(entry).length > 1) {
446
- walkGrouped(entry);
447
- }
448
- else {
449
- walk(entry);
450
- }
451
- }
464
+ walkOperand('OR', entry);
452
465
  };
453
466
  walkOrTree(genOrTree(params[term]));
454
467
  }
package/dist/cjs/tools.js CHANGED
@@ -816,11 +816,18 @@ async function formatMessageResponse(untagged, mailbox) {
816
816
  case 'flags':
817
817
  map.flags = new Set(getArray(attribute));
818
818
  break;
819
+ // A server with nothing to report can answer ENVELOPE NIL or BODYSTRUCTURE NIL.
820
+ // The field is left unset then: parsing NIL threw, and the whole message
821
+ // disappeared from the FETCH result.
819
822
  case 'envelope':
820
- map.envelope = parseEnvelope(attribute);
823
+ if (Array.isArray(attribute)) {
824
+ map.envelope = parseEnvelope(attribute);
825
+ }
821
826
  break;
822
827
  case 'bodystructure':
823
- map.bodyStructure = parseBodystructure(attribute);
828
+ if (Array.isArray(attribute)) {
829
+ map.bodyStructure = parseBodystructure(attribute);
830
+ }
824
831
  break;
825
832
  case 'internaldate': {
826
833
  let value = getString(attribute);
@@ -148,10 +148,12 @@ export default async function list(connection, reference, mailbox, options) {
148
148
  if (!untagged.attributes || !untagged.attributes.length) {
149
149
  return;
150
150
  }
151
+ // A name sent as a literal arrives as a Buffer, so convert it once for both fields
152
+ let rawPath = ((untagged.attributes[2] && untagged.attributes[2].value) || '').toString();
151
153
  let entry = {
152
154
  // Decode from modified UTF-7 wire format and normalize the path
153
- path: normalizePath(connection, decodePath(connection, ((untagged.attributes[2] && untagged.attributes[2].value) || ''))),
154
- pathAsListed: ((untagged.attributes[2] && untagged.attributes[2].value) || ''),
155
+ path: normalizePath(connection, decodePath(connection, rawPath)),
156
+ pathAsListed: rawPath,
155
157
  flags: new Set(getStringList(untagged.attributes[0])),
156
158
  delimiter: (untagged.attributes[1] && untagged.attributes[1].value),
157
159
  listed: true
@@ -379,9 +381,11 @@ export default async function list(connection, reference, mailbox, options) {
379
381
  if (!untagged.attributes || !untagged.attributes.length) {
380
382
  return;
381
383
  }
384
+ // A name sent as a literal arrives as a Buffer, so convert it once for both fields
385
+ let rawPath = ((untagged.attributes[2] && untagged.attributes[2].value) || '').toString();
382
386
  let entry = {
383
- path: normalizePath(connection, decodePath(connection, ((untagged.attributes[2] && untagged.attributes[2].value) || ''))),
384
- pathAsListed: ((untagged.attributes[2] && untagged.attributes[2].value) || ''),
387
+ path: normalizePath(connection, decodePath(connection, rawPath)),
388
+ pathAsListed: rawPath,
385
389
  flags: new Set(getStringList(untagged.attributes[0])),
386
390
  delimiter: (untagged.attributes[1] && untagged.attributes[1].value),
387
391
  subscribed: true
@@ -447,15 +451,23 @@ export default async function list(connection, reference, mailbox, options) {
447
451
  // Resolve special-use conflicts. Each type goes to one mailbox and each mailbox gets
448
452
  // at most one type. Candidates are taken in priority order across all types (user >
449
453
  // extension > name, then alphabetically), so a mailbox claimed by a stronger match
450
- // leaves its other type to that type's next candidate instead of to nobody.
454
+ // leaves its other type to that type's next candidate instead of to nobody. Within a
455
+ // source a shallower mailbox wins before the alphabetical order is consulted, so that
456
+ // INBOX.Sent is preferred over INBOX.Archive.Sent.
451
457
  let candidates = Object.entries(specialUseMatches).flatMap(([type, matches]) => matches.map(match => ({ type, ...match })));
452
458
  candidates.sort((a, b) => {
453
459
  let aSource = SOURCE_SORT_ORDER.indexOf(a.source);
454
460
  let bSource = SOURCE_SORT_ORDER.indexOf(b.source);
455
- if (aSource === bSource) {
456
- return a.entry.path.localeCompare(b.entry.path);
461
+ if (aSource !== bSource) {
462
+ return aSource - bSource;
457
463
  }
458
- return aSource - bSource;
464
+ // parent is set on every listed entry before the candidates are ranked
465
+ let aDepth = a.entry.parent.length;
466
+ let bDepth = b.entry.parent.length;
467
+ if (aDepth !== bDepth) {
468
+ return aDepth - bDepth;
469
+ }
470
+ return a.entry.path.localeCompare(b.entry.path);
459
471
  });
460
472
  let assignedTypes = new Set();
461
473
  for (let { type, entry, source } of candidates) {
@@ -37,12 +37,14 @@ export declare const ImapFlowErrorCode: {
37
37
  readonly InvalidTokenValue: "InvalidTokenValue";
38
38
  readonly InvalidTextValue: "InvalidTextValue";
39
39
  readonly InvalidSequenceSet: "InvalidSequenceSet";
40
+ readonly InvalidSearchQuery: "InvalidSearchQuery";
40
41
  readonly DownloadOverflow: "DownloadOverflow";
41
42
  readonly DownloadIncomplete: "DownloadIncomplete";
42
43
  readonly ProxyError: "ProxyError";
43
44
  readonly EPROXY: "EPROXY";
44
45
  readonly UnsupportedProxyAddress: "UnsupportedProxyAddress";
45
46
  readonly ERR_INVALID_URL: "ERR_INVALID_URL";
47
+ readonly InstanceReused: "InstanceReused";
46
48
  };
47
49
  /** One of the {@link ImapFlowErrorCode} values */
48
50
  export type ImapFlowErrorCode = (typeof ImapFlowErrorCode)[keyof typeof ImapFlowErrorCode];
@@ -41,6 +41,7 @@ export const ImapFlowErrorCode = {
41
41
  InvalidTokenValue: 'InvalidTokenValue',
42
42
  InvalidTextValue: 'InvalidTextValue',
43
43
  InvalidSequenceSet: 'InvalidSequenceSet',
44
+ InvalidSearchQuery: 'InvalidSearchQuery',
44
45
  // download()
45
46
  DownloadOverflow: 'DownloadOverflow',
46
47
  DownloadIncomplete: 'DownloadIncomplete',
@@ -48,7 +49,9 @@ export const ImapFlowErrorCode = {
48
49
  ProxyError: 'ProxyError',
49
50
  EPROXY: 'EPROXY',
50
51
  UnsupportedProxyAddress: 'UnsupportedProxyAddress',
51
- ERR_INVALID_URL: 'ERR_INVALID_URL'
52
+ ERR_INVALID_URL: 'ERR_INVALID_URL',
53
+ // API misuse
54
+ InstanceReused: 'InstanceReused'
52
55
  };
53
56
  /**
54
57
  * Error subclass thrown when IMAP authentication fails.
@@ -1,5 +1,5 @@
1
1
  import { Transform } from 'node:stream';
2
- import logger from '../logger.js';
2
+ import { createConnectionLogger } from '../logger.js';
3
3
  import { MAX_LITERAL_SIZE, MAX_LINE_SIZE, MAX_RESPONSE_SIZE, normalizeLimit, createLiteralTooLargeError } from './limits.js';
4
4
  const LINE = 0x01;
5
5
  const LITERAL = 0x02;
@@ -30,13 +30,12 @@ export class ImapStream extends Transform {
30
30
  });
31
31
  this.options = options || {};
32
32
  this.cid = this.options.cid;
33
- this.log =
34
- this.options.logger && typeof this.options.logger === 'object'
35
- ? this.options.logger
36
- : logger.child({
37
- component: 'imap-connection',
38
- cid: this.cid
39
- });
33
+ if (this.options.logger && typeof this.options.logger === 'object') {
34
+ this.log = this.options.logger;
35
+ }
36
+ else {
37
+ this.log = createConnectionLogger({ cid: this.cid, logRaw: this.options.logRaw });
38
+ }
40
39
  this.readBytesCounter = 0;
41
40
  // Maximum length of a single line (response without a literal). Bounds the line buffer
42
41
  // so a server that never sends a line terminator cannot exhaust memory.
@@ -7,7 +7,7 @@ import crypto from 'node:crypto';
7
7
  import zlib from 'node:zlib';
8
8
  import { EventEmitter } from 'node:events';
9
9
  import { PassThrough } from 'node:stream';
10
- import logger from './logger.js';
10
+ import { createConnectionLogger } from './logger.js';
11
11
  import * as packageInfo from './package-info.js';
12
12
  import { ImapStream } from './handler/imap-stream.js';
13
13
  import { parser, compiler } from './handler/imap-handler.js';
@@ -1890,9 +1890,17 @@ export class ImapFlow extends EventEmitter {
1890
1890
  async connect() {
1891
1891
  if (this._connectCalled) {
1892
1892
  // Prevent re-using ImapFlow instances by allowing to call connect just once.
1893
- throw new Error('Can not re-use ImapFlow instance');
1893
+ let err = new Error('Can not re-use ImapFlow instance');
1894
+ err.code = 'InstanceReused';
1895
+ throw err;
1894
1896
  }
1895
1897
  this._connectCalled = true;
1898
+ let closedError = () => this.createNoConnectionError(this.byeReason, { rejectedFrom: 'connect' });
1899
+ // close() already ran, so there is no connection to set up and nothing would ever
1900
+ // settle a connect attempt started now.
1901
+ if (this.isClosed) {
1902
+ throw closedError();
1903
+ }
1896
1904
  // One deadline for the whole attempt, started before anything is resolved or negotiated.
1897
1905
  // Proxy DNS and proxy negotiation used to run entirely outside the timer, so a stalled
1898
1906
  // proxy could hang far beyond the documented connectionTimeout.
@@ -1938,9 +1946,20 @@ export class ImapFlow extends EventEmitter {
1938
1946
  error._err = err;
1939
1947
  throw error;
1940
1948
  }
1949
+ // close() during proxy setup found no socket to destroy and no connect to reject,
1950
+ // so the tunnel that just opened is dropped here instead of being handed to a
1951
+ // closed client.
1952
+ if (this.isClosed) {
1953
+ socket.destroy();
1954
+ throw closedError();
1955
+ }
1941
1956
  }
1942
1957
  // Guarded: close() rejects a pending connect() synchronously. See guardedPromise().
1943
1958
  let connectPromise = guardedPromise((resolve, reject) => {
1959
+ // Stored before the transport is up, so a close() that lands before onConnect
1960
+ // still rejects this attempt through closeConnectSteps().
1961
+ this.initialResolve = resolve;
1962
+ this.initialReject = reject;
1944
1963
  // Whatever the proxy phase already used is gone from the budget
1945
1964
  this.connectTimeout = setTimeout(() => {
1946
1965
  let err = deadline.error();
@@ -1992,9 +2011,6 @@ export class ImapFlow extends EventEmitter {
1992
2011
  this.setSocketHandlers();
1993
2012
  this.setEventHandlers();
1994
2013
  connected.pipe(this.streamer);
1995
- // executed by initial "* OK"
1996
- this.initialResolve = resolve;
1997
- this.initialReject = reject;
1998
2014
  /* c8 ignore next 4 */ // defensive: the onConnect setup body does not throw under normal operation
1999
2015
  }
2000
2016
  catch (ex) {
@@ -2178,7 +2194,10 @@ export class ImapFlow extends EventEmitter {
2178
2194
  this._upgradeReject = null;
2179
2195
  reject(this.createNoConnectionError(false, { rejectedFrom: 'upgrade' }));
2180
2196
  }
2181
- if (typeof this.initialReject === 'function' && !this.options.verifyOnly) {
2197
+ // A verifyOnly session closes itself with LOGOUT and startSession() settles connect()
2198
+ // with the outcome, so the close is not a failure there. Before the greeting nothing
2199
+ // else can settle it (BYE greeting, FIN, close() while the transport is still coming up).
2200
+ if (typeof this.initialReject === 'function' && (!this.options.verifyOnly || !this.greetingReceived)) {
2182
2201
  clearTimer(this.greetingTimeout);
2183
2202
  let reject = this.initialReject;
2184
2203
  this.initialResolve = false;
@@ -2489,7 +2508,26 @@ export class ImapFlow extends EventEmitter {
2489
2508
  * // 125
2490
2509
  */
2491
2510
  async mailboxOpen(path, options) {
2492
- return await this.run('SELECT', path, options);
2511
+ try {
2512
+ return await this.run('SELECT', path, options);
2513
+ }
2514
+ catch (err) {
2515
+ if (err.responseStatus === 'NO') {
2516
+ // SELECT failed with NO: verify whether the mailbox exists at all by running
2517
+ // LIST. This sets mailboxMissing on the error so the caller can distinguish
2518
+ // "doesn't exist" from other failures, for getMailboxLock() callers as well.
2519
+ try {
2520
+ let folders = await this.run('LIST', '', normalizePath(this, path), { listOnly: true });
2521
+ if (!folders || !folders.length) {
2522
+ err.mailboxMissing = true;
2523
+ }
2524
+ }
2525
+ catch (E) {
2526
+ this.log.trace({ msg: 'Failed to verify failed mailbox', path, err: E });
2527
+ }
2528
+ }
2529
+ throw err;
2530
+ }
2493
2531
  }
2494
2532
  /**
2495
2533
  * Closes a previously opened mailbox
@@ -3191,20 +3229,7 @@ export class ImapFlow extends EventEmitter {
3191
3229
  break; // Wait for this lock to be released
3192
3230
  }
3193
3231
  catch (err) {
3194
- if (err.responseStatus === 'NO') {
3195
- // SELECT failed with NO: verify whether the mailbox exists
3196
- // at all by running LIST. This sets mailboxMissing on the error
3197
- // so the caller can distinguish "doesn't exist" from other failures.
3198
- try {
3199
- let folders = await this.run('LIST', '', path, { listOnly: true });
3200
- if (!folders || !folders.length) {
3201
- err.mailboxMissing = true;
3202
- }
3203
- }
3204
- catch (E) {
3205
- this.log.trace({ msg: 'Failed to verify failed mailbox', path, err: E });
3206
- }
3207
- }
3232
+ // mailboxOpen() has already marked a missing mailbox (mailboxMissing)
3208
3233
  this.log.trace({
3209
3234
  msg: 'Failed to acquire mailbox lock',
3210
3235
  path,
@@ -3295,12 +3320,14 @@ export class ImapFlow extends EventEmitter {
3295
3320
  }
3296
3321
  /** @internal */
3297
3322
  getLogger() {
3298
- let mainLogger = this.options.logger && typeof this.options.logger === 'object'
3299
- ? this.options.logger
3300
- : logger.child({
3301
- component: 'imap-connection',
3302
- cid: this.id
3303
- });
3323
+ let mainLogger = {};
3324
+ if (this.options.logger && typeof this.options.logger === 'object') {
3325
+ mainLogger = this.options.logger;
3326
+ }
3327
+ else if (this.options.logger !== false) {
3328
+ // {logger:false} never consults mainLogger, so it does not create the default logger
3329
+ mainLogger = createConnectionLogger({ cid: this.id, logRaw: this.options.logRaw });
3330
+ }
3304
3331
  let synteticLogger = {};
3305
3332
  let levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
3306
3333
  for (let level of levels) {
@@ -1,3 +1,21 @@
1
- import pino from 'pino';
2
- declare const logger: pino.Logger<never, boolean>;
3
- export default logger;
1
+ import { type Logger as PinoLogger } from 'pino';
2
+ /**
3
+ * Returns the shared default logger, used when a connection is given no logger of its own.
4
+ * Created on first use, so importing the library does not set up a stdout logger nobody asked
5
+ * for. Level info: a caller that asks for raw socket data (logRaw) lowers its own child logger
6
+ * to trace.
7
+ *
8
+ * @returns The default pino logger
9
+ */
10
+ export declare function getDefaultLogger(): PinoLogger;
11
+ /**
12
+ * Returns the child of the default logger a connection logs through when it was given no
13
+ * logger of its own.
14
+ *
15
+ * @param options - cid is the connection id to stamp on entries, logRaw asks for raw socket data
16
+ * @returns A child logger of the default logger
17
+ */
18
+ export declare function createConnectionLogger(options: {
19
+ cid?: string | undefined;
20
+ logRaw?: boolean | undefined;
21
+ }): PinoLogger;
@@ -1,4 +1,34 @@
1
1
  import pino from 'pino';
2
- const logger = pino();
3
- logger.level = 'trace';
4
- export default logger;
2
+ let logger = null;
3
+ /**
4
+ * Returns the shared default logger, used when a connection is given no logger of its own.
5
+ * Created on first use, so importing the library does not set up a stdout logger nobody asked
6
+ * for. Level info: a caller that asks for raw socket data (logRaw) lowers its own child logger
7
+ * to trace.
8
+ *
9
+ * @returns The default pino logger
10
+ */
11
+ export function getDefaultLogger() {
12
+ if (!logger) {
13
+ logger = pino({ level: 'info' });
14
+ }
15
+ return logger;
16
+ }
17
+ /**
18
+ * Returns the child of the default logger a connection logs through when it was given no
19
+ * logger of its own.
20
+ *
21
+ * @param options - cid is the connection id to stamp on entries, logRaw asks for raw socket data
22
+ * @returns A child logger of the default logger
23
+ */
24
+ export function createConnectionLogger(options) {
25
+ let child = getDefaultLogger().child({
26
+ component: 'imap-connection',
27
+ cid: options.cid
28
+ });
29
+ // Raw socket data is logged at trace level, so asking for it lowers the threshold
30
+ if (options.logRaw) {
31
+ child.level = 'trace';
32
+ }
33
+ return child;
34
+ }
@@ -1,3 +1,3 @@
1
1
  export declare const name = "imapflow";
2
- export declare const version = "2.1.0";
2
+ export declare const version = "2.1.2";
3
3
  export declare const homepage = "https://imapflow.com/";
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/build.js from package.json. Do not edit by hand.
2
2
  export const name = "imapflow";
3
- export const version = "2.1.0";
3
+ export const version = "2.1.2";
4
4
  export const homepage = "https://imapflow.com/";
@@ -9,7 +9,7 @@ export type SearchAttribute = ImapAttributeNode | SearchAttribute[];
9
9
  * Compiles a JavaScript object query into IMAP search command attributes.
10
10
  * Supports standard IMAP search criteria and extensions like OBJECTID and Gmail extensions.
11
11
  *
12
- * @param connection - IMAP connection object (capabilities, enabled extensions and the current mailbox are read)
12
+ * @param connection - IMAP connection object (capabilities and enabled extensions are read)
13
13
  * @param query - Search query object
14
14
  * @returns Array of IMAP search attributes
15
15
  * @throws {Error} When required server extensions are not available
@@ -1,5 +1,5 @@
1
1
  /* eslint no-control-regex:0 */
2
- import { formatDate, formatFlag, canUseFlag, toValidDate, isRev2Active } from './tools.js';
2
+ import { formatDate, formatFlag, toValidDate, isRev2Active } from './tools.js';
3
3
  /**
4
4
  * Sets a boolean flag in the IMAP search attributes.
5
5
  * Automatically handles UN- prefixing for falsy values.
@@ -88,6 +88,17 @@ let processDateField = (attributes, term, value) => {
88
88
  };
89
89
  // Pre-compiled regex for better performance
90
90
  const UNICODE_PATTERN = /[^\x00-\x7F]/;
91
+ /**
92
+ * Throws a coded search compilation error.
93
+ *
94
+ * @param code - Error code, one of the ImapFlowErrorCode values
95
+ * @param message - Error message
96
+ */
97
+ let fail = (code, message) => {
98
+ let error = new Error(message);
99
+ error.code = code;
100
+ throw error;
101
+ };
91
102
  /**
92
103
  * Checks if a string contains Unicode characters.
93
104
  * Used to determine if CHARSET UTF-8 needs to be specified.
@@ -107,7 +118,7 @@ let isUnicodeString = (str) => {
107
118
  * Compiles a JavaScript object query into IMAP search command attributes.
108
119
  * Supports standard IMAP search criteria and extensions like OBJECTID and Gmail extensions.
109
120
  *
110
- * @param connection - IMAP connection object (capabilities, enabled extensions and the current mailbox are read)
121
+ * @param connection - IMAP connection object (capabilities and enabled extensions are read)
111
122
  * @param query - Search query object
112
123
  * @returns Array of IMAP search attributes
113
124
  * @throws {Error} When required server extensions are not available
@@ -133,21 +144,29 @@ export const searchCompiler = (connection, query) => {
133
144
  const attributes = [];
134
145
  // Track if we need to specify UTF-8 charset
135
146
  let hasUnicode = false;
136
- const mailbox = connection.mailbox;
137
147
  /**
138
148
  * Recursively walks through the query object and builds IMAP attributes.
139
149
  * @param params - Query parameters to process
140
150
  */
141
151
  const walk = (params) => {
142
- // Walks a query object and wraps the resulting attributes in a
143
- // sub-array so the IMAP compiler emits parentheses around them.
144
- // Used when a single search-key is required (NOT, OR operands)
145
- // but the condition has multiple keys (RFC 3501 Section 6.4.4).
146
- let walkGrouped = (obj) => {
152
+ // Compiles one NOT or OR operand, which the caller has already put its operator in
153
+ // front of. An operand with several keys is wrapped in a sub-array so the IMAP compiler
154
+ // emits parentheses around it, as the operator takes a single search-key
155
+ // (RFC 3501 Section 6.4.4). An operand that compiles to nothing (an invalid date, an
156
+ // empty object, ...) is refused: the operator would otherwise bind to whatever
157
+ // criterion follows it and invert or widen the search.
158
+ let walkOperand = (operator, obj) => {
147
159
  let startIdx = attributes.length;
148
- walk(obj);
149
- let subAttrs = attributes.splice(startIdx);
150
- attributes.push(subAttrs);
160
+ if (obj && typeof obj === 'object') {
161
+ walk(obj);
162
+ }
163
+ if (attributes.length === startIdx) {
164
+ fail('InvalidSearchQuery', `Search operand for ${operator} does not include any usable search criteria`);
165
+ }
166
+ if (Object.keys(obj).length > 1) {
167
+ let subAttrs = attributes.splice(startIdx);
168
+ attributes.push(subAttrs);
169
+ }
151
170
  };
152
171
  Object.keys(params || {}).forEach(term => {
153
172
  switch (term.toUpperCase()) {
@@ -193,9 +212,7 @@ export const searchCompiler = (connection, query) => {
193
212
  // reject the whole search with a tagged BAD, so fail with a
194
213
  // descriptive error instead
195
214
  if (isRev2Active(connection)) {
196
- let error = new Error(`The "${term.toLowerCase()}" search key does not exist in IMAP4rev2`);
197
- error.code = 'MissingServerExtension';
198
- throw error;
215
+ fail('MissingServerExtension', `The "${term.toLowerCase()}" search key does not exist in IMAP4rev2`);
199
216
  }
200
217
  setBoolOpt(attributes, term, true);
201
218
  }
@@ -241,6 +258,11 @@ export const searchCompiler = (connection, query) => {
241
258
  // Fallback to Gmail message ID
242
259
  setOpt(attributes, 'X-GM-MSGID', params[term]);
243
260
  }
261
+ else if (params[term]) {
262
+ // Dropping the criterion would widen the search to every message
263
+ // matching the rest of the query, which a delete or move acts on
264
+ fail('MissingServerExtension', 'Server does not support OBJECTID or X-GM-EXT-1 extension required for EMAILID');
265
+ }
244
266
  break;
245
267
  // Thread ID support (OBJECTID or Gmail extension)
246
268
  case 'THREADID':
@@ -251,6 +273,11 @@ export const searchCompiler = (connection, query) => {
251
273
  // Fallback to Gmail thread ID
252
274
  setOpt(attributes, 'X-GM-THRID', params[term]);
253
275
  }
276
+ else if (params[term]) {
277
+ // Dropping the criterion would widen the search to every message
278
+ // matching the rest of the query, which a delete or move acts on
279
+ fail('MissingServerExtension', 'Server does not support OBJECTID or X-GM-EXT-1 extension required for THREADID');
280
+ }
254
281
  break;
255
282
  // Gmail raw search
256
283
  case 'GMRAW':
@@ -262,9 +289,7 @@ export const searchCompiler = (connection, query) => {
262
289
  setOpt(attributes, 'X-GM-RAW', params[term]);
263
290
  }
264
291
  else {
265
- let error = new Error('Server does not support X-GM-EXT-1 extension required for X-GM-RAW');
266
- error.code = 'MissingServerExtension';
267
- throw error;
292
+ fail('MissingServerExtension', 'Server does not support X-GM-EXT-1 extension required for X-GM-RAW');
268
293
  }
269
294
  break;
270
295
  // Gmail label search. Compiles { has, not } into an X-GM-RAW "label:"/"-label:" query
@@ -298,9 +323,7 @@ export const searchCompiler = (connection, query) => {
298
323
  break;
299
324
  }
300
325
  if (!connection.capabilities.has('X-GM-EXT-1')) {
301
- let error = new Error('Server does not support X-GM-EXT-1 extension required for label search');
302
- error.code = 'MissingServerExtension';
303
- throw error;
326
+ fail('MissingServerExtension', 'Server does not support X-GM-EXT-1 extension required for label search');
304
327
  }
305
328
  let rawQuery = rawParts.join(' ');
306
329
  if (isUnicodeString(rawQuery)) {
@@ -344,8 +367,10 @@ export const searchCompiler = (connection, query) => {
344
367
  case 'UNKEYWORD':
345
368
  {
346
369
  let flag = formatFlag(params[term]);
347
- // Only add if flag is supported or already exists in mailbox
348
- if (canUseFlag(mailbox, flag) || mailbox.flags.has(flag)) {
370
+ // Compiled even when the mailbox does not allow the keyword: the
371
+ // correct answer is then the empty set, which dropping the
372
+ // criterion would turn into every message matching the rest
373
+ if (flag) {
349
374
  setOpt(attributes, term, flag);
350
375
  }
351
376
  }
@@ -374,12 +399,7 @@ export const searchCompiler = (connection, query) => {
374
399
  case 'NOT':
375
400
  if (params[term] && typeof params[term] === 'object') {
376
401
  attributes.push({ type: 'ATOM', value: 'NOT' });
377
- if (Object.keys(params[term]).length > 1) {
378
- walkGrouped(params[term]);
379
- }
380
- else {
381
- walk(params[term]);
382
- }
402
+ walkOperand('NOT', params[term]);
383
403
  }
384
404
  break;
385
405
  // OR operator - complex logic for building OR trees
@@ -438,14 +458,7 @@ export const searchCompiler = (connection, query) => {
438
458
  entry.forEach(walkOrTree);
439
459
  return;
440
460
  }
441
- if (entry && typeof entry === 'object') {
442
- if (Object.keys(entry).length > 1) {
443
- walkGrouped(entry);
444
- }
445
- else {
446
- walk(entry);
447
- }
448
- }
461
+ walkOperand('OR', entry);
449
462
  };
450
463
  walkOrTree(genOrTree(params[term]));
451
464
  }
package/dist/esm/tools.js CHANGED
@@ -761,11 +761,18 @@ export async function formatMessageResponse(untagged, mailbox) {
761
761
  case 'flags':
762
762
  map.flags = new Set(getArray(attribute));
763
763
  break;
764
+ // A server with nothing to report can answer ENVELOPE NIL or BODYSTRUCTURE NIL.
765
+ // The field is left unset then: parsing NIL threw, and the whole message
766
+ // disappeared from the FETCH result.
764
767
  case 'envelope':
765
- map.envelope = parseEnvelope(attribute);
768
+ if (Array.isArray(attribute)) {
769
+ map.envelope = parseEnvelope(attribute);
770
+ }
766
771
  break;
767
772
  case 'bodystructure':
768
- map.bodyStructure = parseBodystructure(attribute);
773
+ if (Array.isArray(attribute)) {
774
+ map.bodyStructure = parseBodystructure(attribute);
775
+ }
769
776
  break;
770
777
  case 'internaldate': {
771
778
  let value = getString(attribute);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "IMAP Client for Node",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/imap-flow.js",
@@ -81,12 +81,12 @@
81
81
  "wrangler": "4.142.0"
82
82
  },
83
83
  "dependencies": {
84
- "@zone-eu/mailsplit": "5.4.17",
84
+ "@zone-eu/mailsplit": "5.4.19",
85
85
  "encoding-japanese": "2.4.0",
86
86
  "iconv-lite": "0.7.3",
87
- "libbase64": "1.3.0",
88
- "libmime": "5.4.4",
89
- "libqp": "2.1.1",
87
+ "libbase64": "1.3.1",
88
+ "libmime": "5.4.6",
89
+ "libqp": "2.1.2",
90
90
  "pino": "10.3.1",
91
91
  "socks": "2.8.10"
92
92
  },