imapflow 1.6.3 → 1.6.5

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.6.3"
2
+ ".": "1.6.5"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.5](https://github.com/postalsys/imapflow/compare/v1.6.4...v1.6.5) (2026-07-29)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * resolve regressions from the rev2 hardening commit and harden further ([0739df9](https://github.com/postalsys/imapflow/commit/0739df93709d2c7d572e977c2b37b6d219e69e30))
9
+
10
+ ## [1.6.4](https://github.com/postalsys/imapflow/compare/v1.6.3...v1.6.4) (2026-07-29)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * block IMAP command injection and harden rev2 protocol handling ([e107292](https://github.com/postalsys/imapflow/commit/e107292bc907b29d94b0d810dc01e9680f820bfa))
16
+
3
17
  ## [1.6.3](https://github.com/postalsys/imapflow/compare/v1.6.2...v1.6.3) (2026-07-28)
4
18
 
5
19
 
@@ -19,10 +19,37 @@ module.exports = async connection => {
19
19
  let response;
20
20
  try {
21
21
  response = await connection.exec('COMPRESS', [{ type: 'ATOM', value: 'DEFLATE' }]);
22
- response.next();
23
- return true;
24
22
  } catch (err) {
23
+ // The server declined (NO/BAD): nothing switched, staying uncompressed is safe.
25
24
  connection.log.warn({ err, cid: connection.id });
26
25
  return false;
27
26
  }
27
+
28
+ // Everything after the tagged OK is already deflate-framed (RFC 4978 section 4) -
29
+ // the server switches at the OK, so declining the upgrade at this point is not a
30
+ // protocol option. The socket stays piped into the plaintext parser until the
31
+ // transport swaps in the inflater, so bytes that arrived in the same chunk as the
32
+ // OK have been consumed as cleartext and are missing from the head of the deflate
33
+ // stream: the session is unrecoverable in both directions. Fail it immediately
34
+ // (the same way STARTTLS treats post-OK trailing data) instead of letting it die
35
+ // slowly on garbage. Closing this window without failing needs the stream to hand
36
+ // back its unconsumed tail on unpipe so the transport can feed it into the
37
+ // inflater - not something a command module can reach from here.
38
+ if (response.hasTrailingData) {
39
+ let error = new Error('Server sent data between the COMPRESS response and the compression layer switch');
40
+ error.code = 'COMPRESS_TRAILING_DATA';
41
+ connection.log.error({ err: error, cid: connection.id });
42
+ // Schedule the close before releasing parser backpressure, so the buffered
43
+ // deflate-framed bytes cannot settle anything before teardown begins. This is
44
+ // why the decision lives here rather than at the connection layer the way the
45
+ // STARTTLS guard does (starttls.js records a flag, upgradeToSTARTTLS decides):
46
+ // only the command module holds the response before its backpressure release,
47
+ // so only it can order teardown ahead of that release.
48
+ connection.closeAfter();
49
+ response.next();
50
+ throw error;
51
+ }
52
+
53
+ response.next();
54
+ return true;
28
55
  };
@@ -27,6 +27,8 @@ const stripEsearchPrefix = attrs => {
27
27
  *
28
28
  * ALL and PARTIAL.messages are kept as compact sequence-set strings.
29
29
  * Use expandRange() from tools.js if you need to expand them.
30
+ * MODSEQ (RFC 7162, sent when the search used a MODSEQ criterion) is
31
+ * returned as a BigInt.
30
32
  *
31
33
  * @param {Array} attrs - Attribute array from the IMAP parser
32
34
  * @returns {Object} ESearchResult object
@@ -61,6 +63,14 @@ function parseEsearchResponse(attrs) {
61
63
  if (!isNaN(n)) result.max = n;
62
64
  break;
63
65
  }
66
+ case 'MODSEQ': {
67
+ // RFC 7162 section 3.1.5: present when the SEARCH used a MODSEQ
68
+ // criterion on a CONDSTORE-enabled session. BigInt because
69
+ // mod-sequence values are unsigned 63-bit
70
+ const value = attrs[++i]?.value;
71
+ if (typeof value === 'string' && /^\d+$/.test(value)) result.modseq = BigInt(value);
72
+ break;
73
+ }
64
74
  case 'ALL': {
65
75
  const allToken = attrs[++i];
66
76
  if (allToken && typeof allToken.value === 'string') {
@@ -4,31 +4,53 @@
4
4
 
5
5
  const imapFormalSyntax = require('./imap-formal-syntax');
6
6
 
7
- /**
8
- * Formats a response entry into a Buffer.
9
- *
10
- * @param {string|number|Buffer} entry - The value to convert to a Buffer.
11
- * @param {boolean} [returnEmpty] - If true, returns null instead of an empty Buffer when the entry is not a recognized type.
12
- * @returns {Buffer|null} The entry as a Buffer, or null if returnEmpty is true and the entry is not a recognized type.
13
- */
14
- const formatRespEntry = (entry, returnEmpty) => {
15
- if (typeof entry === 'string') {
16
- return Buffer.from(entry);
17
- }
7
+ // A single element of a sequence-set as defined by the RFC 9051 grammar: a number
8
+ // or a range, where "*" stands for the largest number in use. Digit strings are not
9
+ // range-checked here (a server rejects "0" or an overlong number on its own); the
10
+ // point of the check is that nothing outside this alphabet can reach the wire.
11
+ const SEQ_RANGE = /^(\d+|\*)(:(\d+|\*))?$/;
12
+
13
+ // Validates a full sequence-set: comma-separated SEQ_RANGE elements, or "$"
14
+ // (RFC 5182 SEARCHRES), which references the previous SEARCH result and is only
15
+ // valid as the entire set. Split into per-element tests on purpose - a whole-set
16
+ // regex with an unbounded repeat group overflows the regex engine's backtrack
17
+ // stack with an uncoded RangeError on valid sets in the million-element range,
18
+ // while the per-element regex is bounded.
19
+ const isValidSequenceSet = value => value === '$' || value.split(',').every(part => SEQ_RANGE.test(part));
20
+
21
+ // Numeric tokens may only put the digit alphabet on the wire. Anything that does
22
+ // not round to a bounded non-negative integer (NaN, Infinity, negatives, unsafe
23
+ // magnitudes) degrades to 0 - the fallback the NaN coercion has always used.
24
+ const safeNumber = value => {
25
+ let num = Math.round(Number(value));
26
+ return Number.isSafeInteger(num) && num >= 0 ? num : 0;
27
+ };
18
28
 
19
- if (typeof entry === 'number') {
20
- return Buffer.from(entry.toString());
21
- }
29
+ // Characters that cannot appear in an IMAP quoted string: CR and LF terminate a
30
+ // command line, and NUL is outside the CHAR production entirely. A value carrying
31
+ // any of them has to be sent as a literal, so quoting it is never correct.
32
+ const NOT_QUOTABLE = /[\r\n\0]/;
22
33
 
23
- if (Buffer.isBuffer(entry)) {
24
- return entry;
25
- }
34
+ // A line terminator ends an IMAP command, so no token may carry one to the wire.
35
+ const CRLF = /[\r\n]/;
26
36
 
27
- if (returnEmpty) {
28
- return null;
37
+ /**
38
+ * Quotes a value as an IMAP quoted string. Only DQUOTE and backslash are escaped -
39
+ * the IMAP grammar defines no other escape sequence, so JSON-style escaping (which
40
+ * turns a tab into a literal backslash-t and a control character into \\uXXXX) would
41
+ * silently change the value the server receives.
42
+ *
43
+ * @param {string} value - The value to quote.
44
+ * @returns {string} The quoted string, ready to be written to the wire.
45
+ * @throws {Error} If the value contains CR, LF or NUL, which a quoted string cannot carry.
46
+ */
47
+ const quoteString = value => {
48
+ if (NOT_QUOTABLE.test(value)) {
49
+ let error = new Error('Unquotable character in IMAP string value');
50
+ error.code = 'InvalidStringValue';
51
+ throw error;
29
52
  }
30
-
31
- return Buffer.alloc(0);
53
+ return '"' + value.replace(/["\\]/g, char => '\\' + char) + '"';
32
54
  };
33
55
 
34
56
  /**
@@ -50,7 +72,43 @@ module.exports = async (response, options) => {
50
72
  let { asArray, isLogging, literalPlus, literalMinus } = options || {};
51
73
  const respParts = [];
52
74
 
53
- let resp = [].concat(formatRespEntry(response.tag, true) || []).concat(response.command ? formatRespEntry(' ' + response.command) : []);
75
+ // Formats an entry (string, number or Buffer) into the Buffer that is written to
76
+ // the wire, and is the choke point every emission passes through: a line
77
+ // terminator ends an IMAP command, so no token - the tag and command name
78
+ // included - may put one on the wire. The literal size marker and literal data
79
+ // are the only emissions where CRLF is legitimate; those call sites opt out with
80
+ // `raw`. Never enforced when logging: re-encoding an incoming server response for
81
+ // the log or for error text must not throw, whatever the server sent. With
82
+ // `returnEmpty`, an unrecognized entry type yields null instead of an empty
83
+ // Buffer.
84
+ const emitEntry = (entry, opts) => {
85
+ let { returnEmpty, raw } = opts || {};
86
+ if (!raw && !isLogging && (typeof entry === 'string' || Buffer.isBuffer(entry)) && CRLF.test(entry.toString('latin1'))) {
87
+ let error = new Error('Line terminator in IMAP token');
88
+ error.code = 'InvalidTokenValue';
89
+ throw error;
90
+ }
91
+
92
+ if (typeof entry === 'string') {
93
+ return Buffer.from(entry);
94
+ }
95
+
96
+ if (typeof entry === 'number') {
97
+ return Buffer.from(entry.toString());
98
+ }
99
+
100
+ if (Buffer.isBuffer(entry)) {
101
+ return entry;
102
+ }
103
+
104
+ if (returnEmpty) {
105
+ return null;
106
+ }
107
+
108
+ return Buffer.alloc(0);
109
+ };
110
+
111
+ let resp = [].concat(emitEntry(response.tag, { returnEmpty: true }) || []).concat(response.command ? emitEntry(' ' + response.command) : []);
54
112
  let val;
55
113
  let lastType;
56
114
 
@@ -75,7 +133,7 @@ module.exports = async (response, options) => {
75
133
  // adjacent lists).
76
134
  if (lastType === 'LITERAL' || (!['(', '<', '['].includes(lastRespByte) && resp.length)) {
77
135
  if (!options.subArray) {
78
- resp.push(formatRespEntry(' '));
136
+ resp.push(emitEntry(' '));
79
137
  }
80
138
  }
81
139
 
@@ -86,7 +144,7 @@ module.exports = async (response, options) => {
86
144
 
87
145
  if (Array.isArray(node)) {
88
146
  lastType = 'LIST';
89
- resp.push(formatRespEntry('('));
147
+ resp.push(emitEntry('('));
90
148
 
91
149
  // check if we need to skip separator WS between two arrays
92
150
  let subArray = node.length > 1 && Array.isArray(node[0]);
@@ -98,40 +156,40 @@ module.exports = async (response, options) => {
98
156
  await walk(child, { subArray });
99
157
  }
100
158
 
101
- resp.push(formatRespEntry(')'));
159
+ resp.push(emitEntry(')'));
102
160
  return;
103
161
  }
104
162
 
105
163
  if (!node && typeof node !== 'string' && typeof node !== 'number' && !Buffer.isBuffer(node)) {
106
- resp.push(formatRespEntry('NIL'));
164
+ resp.push(emitEntry('NIL'));
107
165
  return;
108
166
  }
109
167
 
110
168
  if (typeof node === 'string' || Buffer.isBuffer(node)) {
111
169
  if (isLogging && node.length > 100) {
112
- resp.push(formatRespEntry('"(* ' + node.length + 'B string *)"'));
170
+ resp.push(emitEntry('"(* ' + node.length + 'B string *)"'));
113
171
  } else {
114
- resp.push(formatRespEntry(JSON.stringify(node.toString())));
172
+ resp.push(emitEntry(isLogging ? JSON.stringify(node.toString()) : quoteString(node.toString())));
115
173
  }
116
174
  return;
117
175
  }
118
176
 
119
177
  if (typeof node === 'number') {
120
- resp.push(formatRespEntry(Math.round(node) || 0)); // Only integers allowed
178
+ resp.push(emitEntry(safeNumber(node))); // Only bounded non-negative integers allowed
121
179
  return;
122
180
  }
123
181
 
124
182
  lastType = node.type;
125
183
 
126
184
  if (isLogging && node.sensitive) {
127
- resp.push(formatRespEntry('"(* value hidden *)"'));
185
+ resp.push(emitEntry('"(* value hidden *)"'));
128
186
  return;
129
187
  }
130
188
 
131
189
  switch (node.type.toUpperCase()) {
132
190
  case 'LITERAL':
133
191
  if (isLogging) {
134
- resp.push(formatRespEntry('"(* ' + node.value.length + 'B literal *)"'));
192
+ resp.push(emitEntry('"(* ' + node.value.length + 'B literal *)"'));
135
193
  } else {
136
194
  // The literal size marker counts octets - string values are written as
137
195
  // UTF-8, so their UTF-16 .length would undercount multi-byte characters
@@ -147,40 +205,77 @@ module.exports = async (response, options) => {
147
205
  let canAppend = !asArray || usePlus;
148
206
 
149
207
  // Emit the literal header: optional '~' prefix for literal8, then {size[+]}\r\n
150
- resp.push(formatRespEntry(`${node.isLiteral8 ? '~' : ''}{${literalLength}${usePlus ? '+' : ''}}\r\n`));
208
+ resp.push(emitEntry(`${node.isLiteral8 ? '~' : ''}{${literalLength}${usePlus ? '+' : ''}}\r\n`, { raw: true }));
151
209
 
152
210
  if (canAppend) {
153
211
  // Literal data follows immediately in the same buffer segment
154
212
  if (node.value && node.value.length) {
155
- resp.push(formatRespEntry(node.value));
213
+ resp.push(emitEntry(node.value, { raw: true }));
156
214
  }
157
215
  } else {
158
216
  // For synchronizing literals in asArray mode, split output into separate
159
217
  // parts. The caller must send each part and wait for a continuation
160
218
  // response from the server before sending the next.
161
219
  respParts.push(resp);
162
- resp = [].concat(formatRespEntry(node.value, true) || []);
220
+ resp = [].concat(emitEntry(node.value, { returnEmpty: true, raw: true }) || []);
163
221
  }
164
222
  }
165
223
  break;
166
224
 
167
225
  case 'STRING':
168
226
  if (isLogging && node.value.length > 100) {
169
- resp.push(formatRespEntry('"(* ' + node.value.length + 'B string *)"'));
227
+ resp.push(emitEntry('"(* ' + node.value.length + 'B string *)"'));
170
228
  } else {
171
- resp.push(formatRespEntry(JSON.stringify((node.value || '').toString())));
229
+ val = (node.value || '').toString();
230
+ resp.push(emitEntry(isLogging ? JSON.stringify(val) : quoteString(val)));
172
231
  }
173
232
  break;
174
233
 
175
- case 'TEXT':
176
234
  case 'SEQUENCE':
235
+ // Sequence sets are written verbatim - they are the one token type with
236
+ // no quoting to fall back on. Callers build them from user-supplied
237
+ // ranges, so validate here, at the single point every outgoing sequence
238
+ // set passes through, rather than trusting each command module. Skipped
239
+ // when logging: the incoming token parser accepts sequence-shaped tokens
240
+ // this strict grammar rejects (an ESEARCH set like "1:2:3", a folder
241
+ // name like "12:30:00"), and re-compiling a server response for the log
242
+ // or for error text must never throw.
243
+ if (!isLogging && (typeof node.value === 'string' || typeof node.value === 'number' || Buffer.isBuffer(node.value))) {
244
+ val = node.value.toString();
245
+ if (val && !isValidSequenceSet(val)) {
246
+ let error = new Error('Invalid sequence set value');
247
+ error.code = 'InvalidSequenceSet';
248
+ throw error;
249
+ }
250
+ }
251
+ if (node.value) {
252
+ // raw: the validated alphabet cannot contain a line terminator, and
253
+ // re-scanning a potentially multi-megabyte set in the choke point
254
+ // would double the cost of exactly the sets this branch exists for
255
+ resp.push(emitEntry(node.value, { raw: true }));
256
+ }
257
+ break;
258
+
259
+ case 'TEXT':
260
+ // Response text is written verbatim. Only the parser produces it today, for
261
+ // incoming lines, so this is a re-encoding path rather than a command-building
262
+ // one. The emitEntry choke point would refuse a line terminator here too;
263
+ // this check runs first only to raise the more specific InvalidTextValue code.
177
264
  if (node.value) {
178
- resp.push(formatRespEntry(node.value));
265
+ if (!isLogging && CRLF.test(node.value.toString())) {
266
+ let error = new Error('Line terminator in IMAP text value');
267
+ error.code = 'InvalidTextValue';
268
+ throw error;
269
+ }
270
+ resp.push(emitEntry(node.value));
179
271
  }
180
272
  break;
181
273
 
182
274
  case 'NUMBER':
183
- resp.push(formatRespEntry(node.value || 0));
275
+ // Coerced rather than written through: formatRespEntry passes a string or
276
+ // Buffer straight to the wire, so a numeric token carrying a string value
277
+ // would be another verbatim channel
278
+ resp.push(emitEntry(safeNumber(node.value)));
184
279
  break;
185
280
 
186
281
  case 'ATOM':
@@ -190,28 +285,33 @@ module.exports = async (response, options) => {
190
285
  if (!node.section || val) {
191
286
  // Verify the value contains only valid ATOM-CHAR characters.
192
287
  // Strip a leading backslash before checking (system flags like \Seen start with '\').
193
- // If any character fails verification, quote-escape the entire value with JSON.stringify.
288
+ // If any character fails verification, fall back to an IMAP quoted string
289
+ // (JSON.stringify is used only for log output, where values are display-escaped).
194
290
  if (node.value === '' || imapFormalSyntax.verify(val.charAt(0) === '\\' ? val.substr(1) : val, imapFormalSyntax['ATOM-CHAR']()) >= 0) {
195
- val = JSON.stringify(val);
291
+ val = isLogging ? JSON.stringify(val) : quoteString(val);
196
292
  }
197
293
 
198
- resp.push(formatRespEntry(val));
294
+ resp.push(emitEntry(val));
199
295
  }
200
296
 
201
297
  // Section bracket handling: emit [section-contents] after the ATOM value
202
298
  // e.g., BODY[HEADER.FIELDS (Subject)] or BODY[1.MIME]
203
299
  if (node.section) {
204
- resp.push(formatRespEntry('['));
300
+ resp.push(emitEntry('['));
205
301
 
206
302
  for (let child of node.section) {
207
303
  await walk(child);
208
304
  }
209
305
 
210
- resp.push(formatRespEntry(']'));
306
+ resp.push(emitEntry(']'));
211
307
  }
212
- // Partial range: emit <origin.length> after the section brackets
308
+ // Partial range: emit <origin.length> after the section brackets. Coerced
309
+ // rather than joined as-is: this is the last token component written
310
+ // verbatim, and the choke point is only worth relying on if it holds for
311
+ // all of them. Every producer already passes numbers, so nothing changes
312
+ // for them.
213
313
  if (node.partial) {
214
- resp.push(formatRespEntry(`<${node.partial.join('.')}>`));
314
+ resp.push(emitEntry(`<${node.partial.map(safeNumber).join('.')}>`));
215
315
  }
216
316
  break;
217
317
  }
@@ -81,6 +81,13 @@ module.exports = async (command, options) => {
81
81
  if (err.code === 'ParserErrorExchange' && err.parserContext && err.parserContext.value) {
82
82
  return err.parserContext.value;
83
83
  }
84
+ if (response.tag) {
85
+ // The tag had already been parsed when the rest of the line failed. Expose it
86
+ // so the connection can settle the command this line was addressed to - unlike
87
+ // re-deriving the tag from the raw bytes, this inherits the leading-NUL
88
+ // workaround above.
89
+ err.parsedTag = response.tag;
90
+ }
84
91
  throw err;
85
92
  }
86
93
 
@@ -165,16 +165,36 @@ class ImapStream extends Transform {
165
165
  pos--;
166
166
 
167
167
  // Scan backwards through the line to find an IMAP literal marker: {size}\r\n
168
- // The format is: '{' followed by one or more ASCII digits followed by '}'
169
- let numBytes = [];
168
+ // The format is: '{' followed by one or more ASCII digits followed by '}'.
169
+ // Only the digit run's bounds are tracked - a single linear pass, unlike
170
+ // collecting digits into a growing array, which would make a line of n digits
171
+ // cost O(n^2). The run length is deliberately not capped: the RFC "number"
172
+ // production permits leading zeros, so a long digit run can still denote a
173
+ // small, valid size, and treating the marker as an ordinary line instead
174
+ // would feed the announced literal body to the line parser and desynchronize
175
+ // the session.
176
+ let digitsEnd = pos;
170
177
  for (; pos >= 0; pos--) {
171
178
  let c = line[pos];
172
179
  if (c >= NUM_0 && c <= NUM_9) {
173
- numBytes.unshift(c);
174
180
  continue;
175
181
  }
176
- if (c === CURLY_OPEN && numBytes.length) {
177
- const literalSize = Number(Buffer.from(numBytes).toString());
182
+ if (c === CURLY_OPEN && pos < digitsEnd) {
183
+ // Skip leading zeros so only the significant digits are converted: a
184
+ // marker padded with megabytes of zeros must not cost a string
185
+ // allocation and Number() parse of the whole run.
186
+ let digitsStart = pos + 1;
187
+ while (digitsStart < digitsEnd && line[digitsStart] === NUM_0) {
188
+ digitsStart++;
189
+ }
190
+
191
+ // More significant digits than any number64 has cannot fit any
192
+ // permissible maxLiteralSize; fail closed without materializing them
193
+ if (digitsEnd + 1 - digitsStart > 19) {
194
+ return this.failStream(createLiteralTooLargeError(Infinity, this.maxLiteralSize, 'the widest permissible literal size (19 digits)'));
195
+ }
196
+
197
+ const literalSize = Number(line.toString('latin1', digitsStart, digitsEnd + 1));
178
198
 
179
199
  if (literalSize > this.maxLiteralSize) {
180
200
  return this.failStream(createLiteralTooLargeError(literalSize, this.maxLiteralSize));
@@ -77,8 +77,13 @@ class ParserInstance {
77
77
  {
78
78
  let match = this.remainder.match(/^\s+\[/);
79
79
  if (match) {
80
+ // Find the ']' that closes the response code. Inner brackets are
81
+ // tracked because servers do put bracketed values inside a code
82
+ // (e.g. a "[css3-page]" keyword in a PERMANENTFLAGS list), which a
83
+ // first-']' scan would cut in half.
80
84
  let nesting = 1;
81
- for (let i = match[0].length; i <= this.remainder.length; i++) {
85
+ let end = -1;
86
+ for (let i = match[0].length; i < this.remainder.length; i++) {
82
87
  let c = this.remainder[i];
83
88
 
84
89
  if (c === '[') {
@@ -87,11 +92,25 @@ class ParserInstance {
87
92
  nesting--;
88
93
  }
89
94
  if (!nesting) {
90
- this.humanReadable = this.remainder.substring(i + 1).trim();
91
- this.remainder = this.remainder.substring(0, i + 1);
95
+ end = i;
92
96
  break;
93
97
  }
94
98
  }
99
+
100
+ // Unbalanced '[' inside the code: the RFC 9051 free-text form
101
+ // (`atom [SP 1*<any TEXT-CHAR except "]">]`) permits '[' but not
102
+ // ']', so the code really does end at the first ']' here. Without
103
+ // this fallback the scan finds no closing bracket at all and the
104
+ // human-readable text - what every error message is built from -
105
+ // is swallowed into the response code.
106
+ if (end < 0) {
107
+ end = this.remainder.indexOf(']', match[0].length);
108
+ }
109
+
110
+ if (end >= 0) {
111
+ this.humanReadable = this.remainder.substring(end + 1).trim();
112
+ this.remainder = this.remainder.substring(0, end + 1);
113
+ }
95
114
  } else {
96
115
  this.humanReadable = this.remainder.trim();
97
116
  this.remainder = '';
@@ -699,6 +699,8 @@ export interface ESearchResult {
699
699
  /** Matching UIDs in that range as compact sequence-set */
700
700
  messages: string;
701
701
  };
702
+ /** Highest mod-sequence of the matching messages (RFC 7162, present when the search used a modseq criterion on a CONDSTORE session) */
703
+ modseq?: bigint;
702
704
  }
703
705
 
704
706
  export class AuthenticationFailure extends Error {