imapflow 1.2.7 → 1.2.9

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.
Files changed (54) hide show
  1. package/.ncurc.js +1 -1
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +3 -3
  5. package/lib/charsets.js +15 -0
  6. package/lib/commands/append.js +62 -54
  7. package/lib/commands/authenticate.js +99 -52
  8. package/lib/commands/capability.js +12 -2
  9. package/lib/commands/close.js +11 -1
  10. package/lib/commands/compress.js +10 -1
  11. package/lib/commands/copy.js +18 -1
  12. package/lib/commands/create.js +15 -2
  13. package/lib/commands/delete.js +10 -1
  14. package/lib/commands/enable.js +12 -1
  15. package/lib/commands/expunge.js +18 -2
  16. package/lib/commands/fetch.js +39 -4
  17. package/lib/commands/id.js +22 -3
  18. package/lib/commands/idle.js +39 -4
  19. package/lib/commands/list.js +86 -48
  20. package/lib/commands/login.js +12 -1
  21. package/lib/commands/logout.js +11 -2
  22. package/lib/commands/move.js +17 -1
  23. package/lib/commands/namespace.js +32 -2
  24. package/lib/commands/noop.js +6 -1
  25. package/lib/commands/quota.js +33 -14
  26. package/lib/commands/rename.js +13 -1
  27. package/lib/commands/search.js +16 -1
  28. package/lib/commands/select.js +76 -33
  29. package/lib/commands/starttls.js +6 -1
  30. package/lib/commands/status.js +64 -52
  31. package/lib/commands/store.js +27 -4
  32. package/lib/commands/subscribe.js +7 -1
  33. package/lib/commands/unsubscribe.js +7 -1
  34. package/lib/handler/imap-compiler.js +44 -2
  35. package/lib/handler/imap-formal-syntax.js +51 -3
  36. package/lib/handler/imap-handler.js +8 -0
  37. package/lib/handler/imap-parser.js +23 -2
  38. package/lib/handler/imap-stream.js +84 -31
  39. package/lib/handler/parser-instance.js +61 -1
  40. package/lib/handler/token-parser.js +66 -9
  41. package/lib/imap-commands.js +11 -0
  42. package/lib/imap-flow.d.ts +6 -0
  43. package/lib/imap-flow.js +164 -42
  44. package/lib/jp-decoder.js +10 -0
  45. package/lib/limited-passthrough.js +12 -5
  46. package/lib/proxy-connection.js +18 -12
  47. package/lib/search-compiler.js +3 -11
  48. package/lib/special-use.js +23 -16
  49. package/lib/tools.js +218 -13
  50. package/package.json +4 -11
  51. package/test/commands-integration-test.js +33 -0
  52. package/test/special-use-test.js +32 -0
  53. package/assets/favicon.ico +0 -0
  54. package/jsdoc.json +0 -28
@@ -4,6 +4,13 @@
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
+ */
7
14
  const formatRespEntry = (entry, returnEmpty) => {
8
15
  if (typeof entry === 'string') {
9
16
  return Buffer.from(entry);
@@ -25,7 +32,19 @@ const formatRespEntry = (entry, returnEmpty) => {
25
32
  };
26
33
 
27
34
  /**
28
- * Compiles an input object into
35
+ * Compiles an input object into a sequence of Buffers representing an IMAP protocol response string.
36
+ * Handles various node types including literals, strings, atoms, sections, sequences, and nested lists.
37
+ *
38
+ * @param {Object} response - The response object to compile.
39
+ * @param {string} [response.tag] - The IMAP command tag (e.g., "*" or a sequence number).
40
+ * @param {string} [response.command] - The IMAP command name.
41
+ * @param {Array|Object} [response.attributes] - The response attributes to compile into IMAP format.
42
+ * @param {Object} [options] - Compilation options.
43
+ * @param {boolean} [options.asArray] - If true, returns an array of Buffers (one per literal segment); otherwise returns a single concatenated Buffer.
44
+ * @param {boolean} [options.isLogging] - If true, redacts sensitive values and truncates long strings/literals for logging purposes.
45
+ * @param {boolean} [options.literalPlus] - If true, uses the LITERAL+ extension (appends "+" to literal length markers).
46
+ * @param {boolean} [options.literalMinus] - If true, uses the LITERAL- extension for literals up to 4096 bytes.
47
+ * @returns {Promise<Buffer[]|Buffer>} A promise that resolves to an array of Buffers (if asArray is true) or a single concatenated Buffer.
29
48
  */
30
49
  module.exports = async (response, options) => {
31
50
  let { asArray, isLogging, literalPlus, literalMinus } = options || {};
@@ -38,15 +57,22 @@ module.exports = async (response, options) => {
38
57
  let walk = async (node, options) => {
39
58
  options = options || {};
40
59
 
60
+ // Determine whether a space separator is needed before this node.
61
+ // Inspect the last byte written to decide context.
41
62
  let lastRespEntry = resp.length && resp[resp.length - 1];
42
63
  let lastRespByte = (lastRespEntry && lastRespEntry.length && lastRespEntry[lastRespEntry.length - 1]) || '';
43
64
  if (typeof lastRespByte === 'number') {
44
65
  lastRespByte = String.fromCharCode(lastRespByte);
45
66
  }
46
67
 
68
+ // Add a space separator unless:
69
+ // - The previous token was a LITERAL (literal data is self-delimiting after CRLF)
70
+ // - The last byte was '(', '<', or '[' (opening delimiters suppress the space)
71
+ // - This is the first token (resp is empty)
72
+ // - This is a sub-array element in a consecutive-list context (no space between adjacent lists)
47
73
  if (lastType === 'LITERAL' || (!['(', '<', '['].includes(lastRespByte) && resp.length)) {
48
74
  if (options.subArray) {
49
- // ignore separator
75
+ // ignore separator between consecutive sub-arrays in a list
50
76
  } else {
51
77
  resp.push(formatRespEntry(' '));
52
78
  }
@@ -108,16 +134,26 @@ module.exports = async (response, options) => {
108
134
  } else {
109
135
  let literalLength = !node.value ? 0 : Math.max(node.value.length, 0);
110
136
 
137
+ // canAppend: whether the literal data can be sent in the same buffer segment.
138
+ // With LITERAL+ (RFC 7888) the client does not wait for a continuation response.
139
+ // With LITERAL- (RFC 7888) the client can skip the wait only for literals <= 4096 bytes.
140
+ // When asArray is false we always append inline (single-buffer mode).
111
141
  let canAppend = !asArray || literalPlus || (literalMinus && literalLength <= 4096);
142
+ // Append '+' to the size marker when using LITERAL+ or LITERAL- (non-synchronizing)
112
143
  let usePlus = canAppend && (literalMinus || literalPlus);
113
144
 
145
+ // Emit the literal header: optional '~' prefix for literal8, then {size[+]}\r\n
114
146
  resp.push(formatRespEntry(`${node.isLiteral8 ? '~' : ''}{${literalLength}${usePlus ? '+' : ''}}\r\n`));
115
147
 
116
148
  if (canAppend) {
149
+ // Literal data follows immediately in the same buffer segment
117
150
  if (node.value && node.value.length) {
118
151
  resp.push(formatRespEntry(node.value));
119
152
  }
120
153
  } else {
154
+ // For synchronizing literals in asArray mode, split output into separate
155
+ // parts. The caller must send each part and wait for a continuation
156
+ // response from the server before sending the next.
121
157
  respParts.push(resp);
122
158
  resp = [].concat(formatRespEntry(node.value, true) || []);
123
159
  }
@@ -148,6 +184,9 @@ module.exports = async (response, options) => {
148
184
  val = (node.value || '').toString();
149
185
 
150
186
  if (!node.section || val) {
187
+ // Verify the value contains only valid ATOM-CHAR characters.
188
+ // Strip a leading backslash before checking (system flags like \Seen start with '\').
189
+ // If any character fails verification, quote-escape the entire value with JSON.stringify.
151
190
  if (node.value === '' || imapFormalSyntax.verify(val.charAt(0) === '\\' ? val.substr(1) : val, imapFormalSyntax['ATOM-CHAR']()) >= 0) {
152
191
  val = JSON.stringify(val);
153
192
  }
@@ -155,6 +194,8 @@ module.exports = async (response, options) => {
155
194
  resp.push(formatRespEntry(val));
156
195
  }
157
196
 
197
+ // Section bracket handling: emit [section-contents] after the ATOM value
198
+ // e.g., BODY[HEADER.FIELDS (Subject)] or BODY[1.MIME]
158
199
  if (node.section) {
159
200
  resp.push(formatRespEntry('['));
160
201
 
@@ -164,6 +205,7 @@ module.exports = async (response, options) => {
164
205
 
165
206
  resp.push(formatRespEntry(']'));
166
207
  }
208
+ // Partial range: emit <origin.length> after the section brackets
167
209
  if (node.partial) {
168
210
  resp.push(formatRespEntry(`<${node.partial.join('.')}>`));
169
211
  }
@@ -2,9 +2,27 @@
2
2
 
3
3
  'use strict';
4
4
 
5
- // IMAP Formal Syntax
6
- // http://tools.ietf.org/html/rfc3501#section-9
7
-
5
+ /**
6
+ * @module imap-formal-syntax
7
+ *
8
+ * Defines the IMAP formal syntax character classes and validation rules as specified
9
+ * in RFC 3501 Section 9 (http://tools.ietf.org/html/rfc3501#section-9).
10
+ *
11
+ * Each exported method returns a string of allowed characters for a given IMAP grammar
12
+ * production rule (e.g., ATOM-CHAR, ASTRING-CHAR, TEXT-CHAR). Results are memoized after
13
+ * the first call by replacing the method with a function that returns the cached value.
14
+ *
15
+ * Also exports a {@link module:imap-formal-syntax.verify|verify} function for validating
16
+ * strings against a set of allowed characters.
17
+ */
18
+
19
+ /**
20
+ * Generates a string containing all characters in the given Unicode code point range (inclusive).
21
+ *
22
+ * @param {number} start - The starting character code point.
23
+ * @param {number} end - The ending character code point.
24
+ * @returns {string} A string containing all characters from start to end.
25
+ */
8
26
  function expandRange(start, end) {
9
27
  let chars = [];
10
28
  for (let i = start; i <= end; i++) {
@@ -13,6 +31,13 @@ function expandRange(start, end) {
13
31
  return String.fromCharCode(...chars);
14
32
  }
15
33
 
34
+ /**
35
+ * Returns a new string with all characters from the exclude string removed from the source string.
36
+ *
37
+ * @param {string} source - The source string to filter.
38
+ * @param {string} exclude - A string of characters to exclude from the source.
39
+ * @returns {string} The source string with excluded characters removed.
40
+ */
16
41
  function excludeChars(source, exclude) {
17
42
  let sourceArr = Array.prototype.slice.call(source);
18
43
  for (let i = sourceArr.length - 1; i >= 0; i--) {
@@ -24,6 +49,7 @@ function excludeChars(source, exclude) {
24
49
  }
25
50
 
26
51
  module.exports = {
52
+ /** @returns {string} All 7-bit US-ASCII characters excluding NUL (0x01-0x7F). */
27
53
  CHAR() {
28
54
  let value = expandRange(0x01, 0x7f);
29
55
  this.CHAR = function () {
@@ -32,6 +58,7 @@ module.exports = {
32
58
  return value;
33
59
  },
34
60
 
61
+ /** @returns {string} All 8-bit characters excluding NUL (0x01-0xFF). */
35
62
  CHAR8() {
36
63
  let value = expandRange(0x01, 0xff);
37
64
  this.CHAR8 = function () {
@@ -40,10 +67,12 @@ module.exports = {
40
67
  return value;
41
68
  },
42
69
 
70
+ /** @returns {string} The space character (0x20). */
43
71
  SP() {
44
72
  return ' ';
45
73
  },
46
74
 
75
+ /** @returns {string} All control characters (0x00-0x1F and 0x7F). */
47
76
  CTL() {
48
77
  let value = expandRange(0x00, 0x1f) + '\x7F';
49
78
  this.CTL = function () {
@@ -52,10 +81,12 @@ module.exports = {
52
81
  return value;
53
82
  },
54
83
 
84
+ /** @returns {string} The double-quote character. */
55
85
  DQUOTE() {
56
86
  return '"';
57
87
  },
58
88
 
89
+ /** @returns {string} All uppercase and lowercase ASCII alphabetic characters (A-Z, a-z). */
59
90
  ALPHA() {
60
91
  let value = expandRange(0x41, 0x5a) + expandRange(0x61, 0x7a);
61
92
  this.ALPHA = function () {
@@ -64,6 +95,7 @@ module.exports = {
64
95
  return value;
65
96
  },
66
97
 
98
+ /** @returns {string} All ASCII digit characters (0-9). */
67
99
  DIGIT() {
68
100
  let value = expandRange(0x30, 0x39);
69
101
  this.DIGIT = function () {
@@ -72,6 +104,7 @@ module.exports = {
72
104
  return value;
73
105
  },
74
106
 
107
+ /** @returns {string} Characters allowed in an IMAP ATOM (CHAR minus atom-specials). */
75
108
  'ATOM-CHAR'() {
76
109
  let value = excludeChars(this.CHAR(), this['atom-specials']());
77
110
  this['ATOM-CHAR'] = function () {
@@ -80,6 +113,7 @@ module.exports = {
80
113
  return value;
81
114
  },
82
115
 
116
+ /** @returns {string} Characters allowed in an IMAP ASTRING (ATOM-CHAR plus resp-specials). */
83
117
  'ASTRING-CHAR'() {
84
118
  let value = this['ATOM-CHAR']() + this['resp-specials']();
85
119
  this['ASTRING-CHAR'] = function () {
@@ -88,6 +122,7 @@ module.exports = {
88
122
  return value;
89
123
  },
90
124
 
125
+ /** @returns {string} Characters allowed in IMAP text (CHAR minus CR and LF). */
91
126
  'TEXT-CHAR'() {
92
127
  let value = excludeChars(this.CHAR(), '\r\n');
93
128
  this['TEXT-CHAR'] = function () {
@@ -96,6 +131,7 @@ module.exports = {
96
131
  return value;
97
132
  },
98
133
 
134
+ /** @returns {string} Characters that are special in ATOMs and must be excluded: "(", ")", "{", SP, CTL, list-wildcards, quoted-specials, resp-specials. */
99
135
  'atom-specials'() {
100
136
  let value = '(' + ')' + '{' + this.SP() + this.CTL() + this['list-wildcards']() + this['quoted-specials']() + this['resp-specials']();
101
137
  this['atom-specials'] = function () {
@@ -104,10 +140,12 @@ module.exports = {
104
140
  return value;
105
141
  },
106
142
 
143
+ /** @returns {string} The LIST wildcard characters ("%" and "*"). */
107
144
  'list-wildcards'() {
108
145
  return '%' + '*';
109
146
  },
110
147
 
148
+ /** @returns {string} Characters that are special inside quoted strings (DQUOTE and backslash). */
111
149
  'quoted-specials'() {
112
150
  let value = this.DQUOTE() + '\\';
113
151
  this['quoted-specials'] = function () {
@@ -116,10 +154,12 @@ module.exports = {
116
154
  return value;
117
155
  },
118
156
 
157
+ /** @returns {string} The response-special character ("]"). */
119
158
  'resp-specials'() {
120
159
  return ']';
121
160
  },
122
161
 
162
+ /** @returns {string} Characters allowed in an IMAP tag (ASTRING-CHAR minus "+"). */
123
163
  tag() {
124
164
  let value = excludeChars(this['ASTRING-CHAR'](), '+');
125
165
  this.tag = function () {
@@ -128,6 +168,7 @@ module.exports = {
128
168
  return value;
129
169
  },
130
170
 
171
+ /** @returns {string} Characters allowed in an IMAP command name (ALPHA, DIGIT, and hyphen). */
131
172
  command() {
132
173
  let value = this.ALPHA() + this.DIGIT() + '-';
133
174
  this.command = function () {
@@ -136,6 +177,13 @@ module.exports = {
136
177
  return value;
137
178
  },
138
179
 
180
+ /**
181
+ * Verifies that every character in the given string is within the set of allowed characters.
182
+ *
183
+ * @param {string} str - The string to validate.
184
+ * @param {string} allowedChars - A string containing all allowed characters.
185
+ * @returns {number} The index of the first disallowed character, or -1 if all characters are valid.
186
+ */
139
187
  verify(str, allowedChars) {
140
188
  for (let i = 0, len = str.length; i < len; i++) {
141
189
  if (allowedChars.indexOf(str.charAt(i)) < 0) {
@@ -3,6 +3,14 @@
3
3
  const parser = require('./imap-parser');
4
4
  const compiler = require('./imap-compiler');
5
5
 
6
+ /**
7
+ * Re-exports the IMAP protocol parser and compiler as a single module.
8
+ *
9
+ * @property {Function} parser - Parses raw IMAP command/response buffers into structured objects.
10
+ * See {@link module:imap-parser} for details.
11
+ * @property {Function} compiler - Compiles structured response objects into IMAP protocol Buffers.
12
+ * See {@link module:imap-compiler} for details.
13
+ */
6
14
  module.exports = {
7
15
  parser,
8
16
  compiler
@@ -3,12 +3,29 @@
3
3
  const imapFormalSyntax = require('./imap-formal-syntax');
4
4
  const { ParserInstance } = require('./parser-instance');
5
5
 
6
+ /**
7
+ * Parses a raw IMAP command or response buffer into a structured object.
8
+ * Handles edge cases such as null-byte-padded responses from buggy servers and
9
+ * multi-word commands like UID and AUTHENTICATE.
10
+ *
11
+ * @param {Buffer|string} command - The raw IMAP command or response data to parse.
12
+ * @param {Object} [options] - Parser options passed through to the underlying ParserInstance and TokenParser.
13
+ * @param {boolean} [options.literalPlus] - Whether the LITERAL+ extension is in use.
14
+ * @param {Array<Buffer>} [options.literals] - Pre-parsed literal values extracted from the input stream.
15
+ * @returns {Promise<Object>} A promise that resolves to a parsed response object.
16
+ * @returns {string} return.tag - The IMAP tag (e.g., "*", "+", or a command tag like "A1").
17
+ * @returns {string} return.command - The IMAP command or response name (e.g., "OK", "FETCH").
18
+ * @returns {Array} [return.attributes] - Parsed attributes of the response.
19
+ * @returns {number} [return.nullBytesRemoved] - Number of leading null bytes removed, if any.
20
+ */
6
21
  module.exports = async (command, options) => {
7
22
  options = options || {};
8
23
 
9
24
  let nullBytesRemoved = 0;
10
25
 
11
- // special case with a buggy IMAP server where responses are padded with zero bytes
26
+ // Workaround for buggy IMAP servers that pad responses with leading NUL (\x00) bytes.
27
+ // Some servers (observed in the wild) prepend null bytes to their output, which would
28
+ // cause parsing to fail. We strip them and note how many were removed for diagnostics.
12
29
  if (command[0] === 0) {
13
30
  // find the first non null byte and trim
14
31
  let firstNonNull = -1;
@@ -19,7 +36,7 @@ module.exports = async (command, options) => {
19
36
  }
20
37
  }
21
38
  if (firstNonNull === -1) {
22
- // All bytes are null
39
+ // All bytes are null -- treat as a BAD response
23
40
  return { tag: '*', command: 'BAD', attributes: [] };
24
41
  }
25
42
  command = command.slice(firstNonNull);
@@ -40,6 +57,10 @@ module.exports = async (command, options) => {
40
57
  response.nullBytesRemoved = nullBytesRemoved;
41
58
  }
42
59
 
60
+ // Some IMAP commands are multi-word: "UID FETCH", "UID STORE", "UID COPY",
61
+ // "UID MOVE", "UID SEARCH", "UID EXPUNGE", and "AUTHENTICATE PLAIN", etc.
62
+ // For these, the first word is consumed as the command, then we read the
63
+ // subcommand and concatenate them (e.g., "UID" + " " + "FETCH" -> "UID FETCH").
43
64
  if (['UID', 'AUTHENTICATE'].indexOf((response.command || '').toUpperCase()) >= 0) {
44
65
  await parser.getSpace();
45
66
  response.command += ' ' + (await parser.getElement(imapFormalSyntax.command()));
@@ -16,7 +16,25 @@ const CURLY_CLOSE = 0x7d;
16
16
  // Maximum allowed literal size: 1GB (1073741824 bytes)
17
17
  const MAX_LITERAL_SIZE = 1024 * 1024 * 1024;
18
18
 
19
+ /**
20
+ * A Transform stream that parses raw IMAP protocol data from a socket into structured
21
+ * command/response objects. Reads binary input, splits it into lines delimited by LF,
22
+ * extracts literal data blocks based on IMAP literal size markers (e.g., "{123}\r\n"),
23
+ * and emits each complete command as a readable object containing the payload Buffer
24
+ * and any associated literal Buffers. Enforces a maximum literal size of 1GB.
25
+ *
26
+ * @extends Transform
27
+ */
19
28
  class ImapStream extends Transform {
29
+ /**
30
+ * Creates a new ImapStream instance.
31
+ *
32
+ * @param {Object} [options] - Stream options.
33
+ * @param {string} [options.cid] - Connection identifier used for logging.
34
+ * @param {Object} [options.logger] - A pino-compatible logger instance. If not provided, a default child logger is created.
35
+ * @param {boolean} [options.logRaw] - If true, logs raw socket data at trace level.
36
+ * @param {boolean} [options.secureConnection] - Whether the connection uses TLS.
37
+ */
20
38
  constructor(options) {
21
39
  super({
22
40
  //writableHighWaterMark: 3,
@@ -51,6 +69,15 @@ class ImapStream extends Transform {
51
69
  this.inputQueue = []; // unprocessed input chunks
52
70
  }
53
71
 
72
+ /**
73
+ * Checks whether the given line buffer ends with an IMAP literal size marker
74
+ * (e.g., "{123}\r\n"). If a valid marker is found and the literal size is within
75
+ * the allowed maximum, switches the stream state to LITERAL mode and records
76
+ * the expected number of literal bytes.
77
+ *
78
+ * @param {Buffer} line - The line buffer to check for a trailing literal marker.
79
+ * @returns {boolean} True if a valid literal marker was found and literal state was activated, false otherwise.
80
+ */
54
81
  checkLiteralMarker(line) {
55
82
  if (!line || !line.length) {
56
83
  return false;
@@ -58,23 +85,22 @@ class ImapStream extends Transform {
58
85
 
59
86
  let pos = line.length - 1;
60
87
 
61
- if (line[pos] === LF) {
62
- pos--;
63
- } else {
88
+ if (line[pos] !== LF) {
64
89
  return false;
65
90
  }
91
+ pos--;
92
+
66
93
  if (pos >= 0 && line[pos] === CR) {
67
94
  pos--;
68
95
  }
69
- if (pos < 0) {
70
- return false;
71
- }
72
96
 
73
- if (!pos || line[pos] !== CURLY_CLOSE) {
97
+ if (pos < 0 || !pos || line[pos] !== CURLY_CLOSE) {
74
98
  return false;
75
99
  }
76
100
  pos--;
77
101
 
102
+ // Scan backwards through the line to find an IMAP literal marker: {size}\r\n
103
+ // The format is: '{' followed by one or more ASCII digits followed by '}'
78
104
  let numBytes = [];
79
105
  for (; pos > 0; pos--) {
80
106
  let c = line[pos];
@@ -103,6 +129,16 @@ class ImapStream extends Transform {
103
129
  return false;
104
130
  }
105
131
 
132
+ /**
133
+ * Processes a single input chunk of raw data. In LINE state, scans for LF-terminated
134
+ * lines and checks for literal markers. In LITERAL state, collects the expected number
135
+ * of literal bytes. When a complete command (with all its literals) is assembled, it is
136
+ * pushed downstream as a readable object.
137
+ *
138
+ * @param {Buffer} chunk - The raw data chunk to process.
139
+ * @param {number} [startPos=0] - The byte offset within the chunk to start processing from.
140
+ * @returns {Promise<void>}
141
+ */
106
142
  async processInputChunk(chunk, startPos) {
107
143
  startPos = startPos || 0;
108
144
  if (startPos >= chunk.length) {
@@ -164,41 +200,34 @@ class ImapStream extends Transform {
164
200
  }
165
201
 
166
202
  case LITERAL: {
167
- // exactly until end of chunk
168
- if (chunk.length === startPos + this.literalWaiting) {
169
- if (!startPos) {
170
- this.literalBuffer.push(chunk);
171
- } else {
172
- this.literalBuffer.push(chunk.slice(startPos));
173
- }
203
+ const remainingInChunk = chunk.length - startPos;
204
+ const bytesToRead = Math.min(remainingInChunk, this.literalWaiting);
205
+ const partial = startPos === 0 && bytesToRead === chunk.length ? chunk : chunk.slice(startPos, startPos + bytesToRead);
174
206
 
175
- this.literalWaiting -= chunk.length;
176
- this.literals.push(Buffer.concat(this.literalBuffer));
177
- this.literalBuffer = [];
178
- this.state = LINE;
207
+ this.literalBuffer.push(partial);
208
+ this.literalWaiting -= bytesToRead;
179
209
 
180
- return;
181
- } else if (chunk.length > startPos + this.literalWaiting) {
182
- let partial = chunk.slice(startPos, startPos + this.literalWaiting);
183
- this.literalBuffer.push(partial);
184
- startPos += partial.length;
185
- this.literalWaiting -= partial.length;
210
+ if (this.literalWaiting === 0) {
186
211
  this.literals.push(Buffer.concat(this.literalBuffer));
187
212
  this.literalBuffer = [];
188
213
  this.state = LINE;
189
214
 
190
- return await this.processInputChunk(chunk, startPos);
191
- } else {
192
- let partial = chunk.slice(startPos);
193
- this.literalBuffer.push(partial);
194
- startPos += partial.length;
195
- this.literalWaiting -= partial.length;
196
- return;
215
+ if (remainingInChunk > bytesToRead) {
216
+ return await this.processInputChunk(chunk, startPos + bytesToRead);
217
+ }
197
218
  }
219
+ break;
198
220
  }
199
221
  }
200
222
  }
201
223
 
224
+ /**
225
+ * Drains the input queue by processing each queued chunk sequentially.
226
+ * Yields to the event loop every 10 chunks to prevent CPU blocking on
227
+ * large bursts of incoming data.
228
+ *
229
+ * @returns {Promise<void>}
230
+ */
202
231
  async processInput() {
203
232
  let data;
204
233
  let processedCount = 0;
@@ -215,6 +244,15 @@ class ImapStream extends Transform {
215
244
  }
216
245
  }
217
246
 
247
+ /**
248
+ * Transform stream implementation. Receives raw data chunks from the writable side,
249
+ * converts strings to Buffers, tracks total bytes read, optionally logs raw data,
250
+ * and queues the chunk for asynchronous processing.
251
+ *
252
+ * @param {Buffer|string} chunk - The incoming data chunk.
253
+ * @param {string} encoding - The encoding if chunk is a string.
254
+ * @param {Function} next - Callback to signal that this chunk has been consumed.
255
+ */
218
256
  _transform(chunk, encoding, next) {
219
257
  if (typeof chunk === 'string') {
220
258
  chunk = Buffer.from(chunk, encoding);
@@ -237,6 +275,9 @@ class ImapStream extends Transform {
237
275
  });
238
276
  }
239
277
 
278
+ // Queue the chunk for async processing. The 'next' callback serves as
279
+ // backpressure: it is called only after this chunk is fully processed,
280
+ // which signals the writable side that more data can be accepted.
240
281
  if (chunk && chunk.length) {
241
282
  this.inputQueue.push({ chunk, next });
242
283
  }
@@ -249,10 +290,22 @@ class ImapStream extends Transform {
249
290
  }
250
291
  }
251
292
 
293
+ /**
294
+ * Flush implementation called when the writable side ends. Signals completion immediately.
295
+ *
296
+ * @param {Function} next - Callback to signal flush completion.
297
+ */
252
298
  _flush(next) {
253
299
  next();
254
300
  }
255
301
 
302
+ /**
303
+ * Destroy implementation for cleanup. Clears all internal buffers, drains the input queue
304
+ * by invoking pending callbacks, and forwards the error (if any) to the callback.
305
+ *
306
+ * @param {Error|null} err - The error that caused destruction, or null.
307
+ * @param {Function} callback - Callback to signal destruction completion.
308
+ */
256
309
  _destroy(err, callback) {
257
310
  this.inputBuffer = [];
258
311
  this.lineBuffer = [];
@@ -6,7 +6,20 @@ const imapFormalSyntax = require('./imap-formal-syntax');
6
6
 
7
7
  const { TokenParser } = require('./token-parser');
8
8
 
9
+ /**
10
+ * Parses a single IMAP response line into its structural components: tag, command,
11
+ * and attributes. Handles status responses (OK, NO, BAD, PREAUTH, BYE) with their
12
+ * human-readable text and response codes, as well as continuation responses ("+").
13
+ */
9
14
  class ParserInstance {
15
+ /**
16
+ * Creates a new ParserInstance for parsing an IMAP response line.
17
+ *
18
+ * @param {Buffer|string} input - The raw IMAP response line to parse.
19
+ * @param {Object} [options] - Parser options passed through to the TokenParser for attribute parsing.
20
+ * @param {boolean} [options.literalPlus] - Whether the LITERAL+ extension is in use.
21
+ * @param {Array<Buffer>} [options.literals] - Pre-parsed literal values from the stream.
22
+ */
10
23
  constructor(input, options) {
11
24
  this.input = (input || '').toString();
12
25
  this.options = options || {};
@@ -14,6 +27,14 @@ class ParserInstance {
14
27
  this.pos = 0;
15
28
  }
16
29
 
30
+ /**
31
+ * Extracts and returns the IMAP tag from the beginning of the response.
32
+ * The tag is typically "*" for untagged responses, "+" for continuation requests,
33
+ * or a client-assigned command tag like "A1".
34
+ *
35
+ * @returns {Promise<string>} The parsed tag string.
36
+ * @throws {Error} If the tag contains invalid characters.
37
+ */
17
38
  async getTag() {
18
39
  if (!this.tag) {
19
40
  this.tag = await this.getElement(imapFormalSyntax.tag() + '*+', true);
@@ -21,6 +42,15 @@ class ParserInstance {
21
42
  return this.tag;
22
43
  }
23
44
 
45
+ /**
46
+ * Extracts and returns the IMAP command or response name from the input.
47
+ * For continuation responses (tag "+"), returns an empty string and stores
48
+ * the remainder as human-readable text. For status responses (OK, NO, BAD,
49
+ * PREAUTH, BYE), separates the optional response code from the human-readable text.
50
+ *
51
+ * @returns {Promise<string>} The parsed command string.
52
+ * @throws {Error} If the command contains invalid characters or input ends unexpectedly.
53
+ */
24
54
  async getCommand() {
25
55
  if (this.tag === '+') {
26
56
  // special case
@@ -34,6 +64,10 @@ class ParserInstance {
34
64
  this.command = await this.getElement(imapFormalSyntax.command());
35
65
  }
36
66
 
67
+ // Status responses have the format: TAG OK/NO/BAD [response-code] human-readable text
68
+ // Example: * OK [CAPABILITY IMAP4rev1] Server ready
69
+ // Example: A1 NO [AUTHENTICATIONFAILED] Invalid credentials
70
+ // We need to separate the optional [response-code] from the human-readable text.
37
71
  switch ((this.command || '').toString().toUpperCase()) {
38
72
  case 'OK':
39
73
  case 'NO':
@@ -69,6 +103,14 @@ class ParserInstance {
69
103
  return this.command;
70
104
  }
71
105
 
106
+ /**
107
+ * Extracts the next whitespace-delimited element from the input and validates it
108
+ * against the given syntax character set. Advances the parser position past the element.
109
+ *
110
+ * @param {string} syntax - A string of allowed characters for the element (as returned by imap-formal-syntax methods).
111
+ * @returns {Promise<string>} The extracted element string.
112
+ * @throws {Error} If the element contains characters not in the syntax set, or if input ends unexpectedly.
113
+ */
72
114
  async getElement(syntax) {
73
115
  let match, element, errPos;
74
116
 
@@ -83,7 +125,10 @@ class ParserInstance {
83
125
  element = match[0];
84
126
  if ((errPos = imapFormalSyntax.verify(element, syntax)) >= 0) {
85
127
  if (this.tag === 'Server' && element === 'Unavailable.') {
86
- // Exchange error
128
+ // Microsoft Exchange sometimes sends a non-standard response
129
+ // "Server Unavailable." instead of a proper IMAP tagged/untagged response.
130
+ // We detect this specific pattern and convert it into a synthetic BAD response
131
+ // so the rest of the parser can handle it gracefully.
87
132
  let error = new Error(`Server returned an error: ${this.input}`);
88
133
  error.code = 'ParserErrorExchange';
89
134
  error.parserContext = {
@@ -117,6 +162,13 @@ class ParserInstance {
117
162
  return element;
118
163
  }
119
164
 
165
+ /**
166
+ * Consumes a single space character from the current position in the input.
167
+ * Advances the parser position by one.
168
+ *
169
+ * @returns {Promise<void>}
170
+ * @throws {Error} If the current character is not a space, or if input has ended unexpectedly.
171
+ */
120
172
  async getSpace() {
121
173
  if (!this.remainder.length) {
122
174
  if (this.tag === '+' && this.pos === 1) {
@@ -141,6 +193,14 @@ class ParserInstance {
141
193
  this.remainder = this.remainder.substr(1);
142
194
  }
143
195
 
196
+ /**
197
+ * Parses the remaining input as IMAP attributes using the TokenParser.
198
+ * This handles complex structures including nested lists, literals, strings,
199
+ * atoms, sections, sequences, and partial ranges.
200
+ *
201
+ * @returns {Promise<Array>} A promise that resolves to an array of parsed attribute objects.
202
+ * @throws {Error} If the input contains unexpected whitespace, invalid characters, or ends unexpectedly.
203
+ */
144
204
  async getAttributes() {
145
205
  if (!this.remainder.length) {
146
206
  let error = new Error(`Unexpected end of input at position ${this.pos} [E6]`);