imapflow 1.2.8 → 1.2.10

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 (58) 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 +36 -58
  5. package/eslint.config.js +18 -16
  6. package/lib/charsets.js +15 -0
  7. package/lib/commands/append.js +62 -54
  8. package/lib/commands/authenticate.js +99 -52
  9. package/lib/commands/capability.js +12 -2
  10. package/lib/commands/close.js +11 -1
  11. package/lib/commands/compress.js +10 -1
  12. package/lib/commands/copy.js +18 -1
  13. package/lib/commands/create.js +15 -2
  14. package/lib/commands/delete.js +10 -1
  15. package/lib/commands/enable.js +12 -1
  16. package/lib/commands/expunge.js +18 -2
  17. package/lib/commands/fetch.js +39 -4
  18. package/lib/commands/id.js +22 -3
  19. package/lib/commands/idle.js +39 -4
  20. package/lib/commands/list.js +86 -48
  21. package/lib/commands/login.js +12 -1
  22. package/lib/commands/logout.js +11 -2
  23. package/lib/commands/move.js +17 -1
  24. package/lib/commands/namespace.js +32 -2
  25. package/lib/commands/noop.js +6 -1
  26. package/lib/commands/quota.js +33 -14
  27. package/lib/commands/rename.js +13 -1
  28. package/lib/commands/search.js +16 -1
  29. package/lib/commands/select.js +76 -33
  30. package/lib/commands/starttls.js +6 -1
  31. package/lib/commands/status.js +64 -52
  32. package/lib/commands/store.js +27 -4
  33. package/lib/commands/subscribe.js +7 -1
  34. package/lib/commands/unsubscribe.js +7 -1
  35. package/lib/handler/imap-compiler.js +44 -2
  36. package/lib/handler/imap-formal-syntax.js +51 -3
  37. package/lib/handler/imap-handler.js +8 -0
  38. package/lib/handler/imap-parser.js +23 -2
  39. package/lib/handler/imap-stream.js +84 -31
  40. package/lib/handler/parser-instance.js +61 -1
  41. package/lib/handler/token-parser.js +66 -9
  42. package/lib/imap-commands.js +11 -0
  43. package/lib/imap-flow.d.ts +6 -0
  44. package/lib/imap-flow.js +175 -46
  45. package/lib/jp-decoder.js +10 -0
  46. package/lib/limited-passthrough.js +12 -5
  47. package/lib/proxy-connection.js +18 -12
  48. package/lib/search-compiler.js +3 -11
  49. package/lib/special-use.js +23 -16
  50. package/lib/tools.js +218 -13
  51. package/package.json +5 -17
  52. package/test/commands-integration-test.js +33 -0
  53. package/test/connection-edge-cases-test.js +105 -0
  54. package/test/special-use-test.js +32 -0
  55. package/.babelrc +0 -6
  56. package/.eslintrc +0 -16
  57. package/assets/favicon.ico +0 -0
  58. package/jsdoc.json +0 -28
@@ -2,7 +2,15 @@
2
2
 
3
3
  const { encodePath, normalizePath } = require('../tools.js');
4
4
 
5
- // Requests info about a mailbox
5
+ /**
6
+ * Requests status information about a mailbox.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} path - Mailbox path to query
10
+ * @param {Object} query - Status data items to request (e.g., {messages: true, uidNext: true, unseen: true})
11
+ * @returns {Promise<{path: string, messages?: number, recent?: number, uidNext?: number, uidValidity?: BigInt, unseen?: number, highestModseq?: BigInt}|boolean>} Status information object, or false if preconditions not met or on failure
12
+ * @throws {Error} If the mailbox does not exist
13
+ */
6
14
  module.exports = async (connection, path, query) => {
7
15
  if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state) || !path) {
8
16
  // nothing to do here
@@ -12,8 +20,12 @@ module.exports = async (connection, path, query) => {
12
20
  path = normalizePath(connection, path);
13
21
  let encodedPath = encodePath(connection, path);
14
22
 
23
+ // Use quoted STRING if the encoded path contains '&' (modified UTF-7 marker),
24
+ // otherwise use unquoted ATOM. Same approach as in SELECT.
15
25
  let attributes = [{ type: encodedPath.indexOf('&') >= 0 ? 'STRING' : 'ATOM', value: encodedPath }];
16
26
 
27
+ // Build the list of STATUS data items the caller wants.
28
+ // HIGHESTMODSEQ requires the CONDSTORE extension to be available.
17
29
  let queryAttributes = [];
18
30
  Object.keys(query || {}).forEach(key => {
19
31
  if (!query[key]) {
@@ -48,14 +60,50 @@ module.exports = async (connection, path, query) => {
48
60
  let map = { path };
49
61
  response = await connection.exec('STATUS', attributes, {
50
62
  untagged: {
63
+ // STATUS response: * STATUS <mailbox> (<key> <value> <key> <value> ...)
64
+ // Parsed as alternating key-value pairs (i % 2 pattern).
51
65
  STATUS: async untagged => {
52
- // If STATUS is for current mailbox then update mailbox values
66
+ // If querying the currently selected mailbox, also update the
67
+ // connection's live mailbox state and emit events for changes.
53
68
  let updateCurrent = connection.state === connection.states.SELECTED && path === connection.mailbox.path;
54
69
 
55
70
  let list = untagged.attributes && Array.isArray(untagged.attributes[1]) ? untagged.attributes[1] : false;
56
71
  if (!list) {
57
72
  return;
58
73
  }
74
+ // Maps IMAP STATUS field names to their output key names, type parsers,
75
+ // and optional callbacks to update the live mailbox state.
76
+ const STATUS_FIELD_MAP = {
77
+ MESSAGES: {
78
+ key: 'messages',
79
+ parser: Number,
80
+ updateMailbox: (val, conn) => {
81
+ let prevCount = conn.mailbox.exists;
82
+ if (prevCount !== val) {
83
+ conn.mailbox.exists = val;
84
+ conn.emit('exists', { path, count: val, prevCount });
85
+ }
86
+ }
87
+ },
88
+ RECENT: { key: 'recent', parser: Number },
89
+ UIDNEXT: {
90
+ key: 'uidNext',
91
+ parser: Number,
92
+ updateMailbox: (val, conn) => {
93
+ conn.mailbox.uidNext = val;
94
+ }
95
+ },
96
+ UIDVALIDITY: { key: 'uidValidity', parser: BigInt },
97
+ UNSEEN: { key: 'unseen', parser: Number },
98
+ HIGHESTMODSEQ: {
99
+ key: 'highestModseq',
100
+ parser: BigInt,
101
+ updateMailbox: (val, conn) => {
102
+ conn.mailbox.highestModseq = val;
103
+ }
104
+ }
105
+ };
106
+
59
107
  let key;
60
108
  list.forEach((entry, i) => {
61
109
  if (i % 2 === 0) {
@@ -65,61 +113,22 @@ module.exports = async (connection, path, query) => {
65
113
  if (!key || !entry || typeof entry.value !== 'string') {
66
114
  return;
67
115
  }
68
- let value = false;
69
- switch (key.toUpperCase()) {
70
- case 'MESSAGES':
71
- key = 'messages';
72
- value = !isNaN(entry.value) ? Number(entry.value) : false;
73
- if (updateCurrent) {
74
- let prevCount = connection.mailbox.exists;
75
- if (prevCount !== value) {
76
- // somehow message count in current folder has changed?
77
- connection.mailbox.exists = value;
78
- connection.emit('exists', {
79
- path,
80
- count: value,
81
- prevCount
82
- });
83
- }
84
- }
85
- break;
86
-
87
- case 'RECENT':
88
- key = 'recent';
89
- value = !isNaN(entry.value) ? Number(entry.value) : false;
90
- break;
91
-
92
- case 'UIDNEXT':
93
- key = 'uidNext';
94
- value = !isNaN(entry.value) ? Number(entry.value) : false;
95
- if (updateCurrent) {
96
- connection.mailbox.uidNext = value;
97
- }
98
- break;
99
-
100
- case 'UIDVALIDITY':
101
- key = 'uidValidity';
102
- value = !isNaN(entry.value) ? BigInt(entry.value) : false;
103
- break;
104
-
105
- case 'UNSEEN':
106
- key = 'unseen';
107
- value = !isNaN(entry.value) ? Number(entry.value) : false;
108
- break;
109
-
110
- case 'HIGHESTMODSEQ':
111
- key = 'highestModseq';
112
- value = !isNaN(entry.value) ? BigInt(entry.value) : false;
113
- if (updateCurrent) {
114
- connection.mailbox.highestModseq = value;
115
- }
116
- break;
116
+
117
+ const fieldConfig = STATUS_FIELD_MAP[key.toUpperCase()];
118
+ if (!fieldConfig) {
119
+ return;
117
120
  }
121
+
122
+ const value = !isNaN(entry.value) ? fieldConfig.parser(entry.value) : false;
118
123
  if (value === false) {
119
124
  return;
120
125
  }
121
126
 
122
- map[key] = value;
127
+ map[fieldConfig.key] = value;
128
+
129
+ if (updateCurrent && fieldConfig.updateMailbox) {
130
+ fieldConfig.updateMailbox(value, connection);
131
+ }
123
132
  });
124
133
  }
125
134
  }
@@ -127,6 +136,9 @@ module.exports = async (connection, path, query) => {
127
136
  response.next();
128
137
  return map;
129
138
  } catch (err) {
139
+ // A NO response usually means the mailbox doesn't exist. Verify by
140
+ // running LIST -- if no results, throw a clear NotFound error instead
141
+ // of the generic IMAP error.
130
142
  if (err.responseStatus === 'NO') {
131
143
  let folders = await connection.run('LIST', '', path, { listOnly: true });
132
144
  if (folders && !folders.length) {
@@ -2,7 +2,20 @@
2
2
 
3
3
  const { formatFlag, canUseFlag, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Updates flags for a message
5
+ /**
6
+ * Updates flags or labels for messages in the selected mailbox.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} range - Message sequence number or UID range
10
+ * @param {string|string[]} flags - Flag(s) to set, add, or remove
11
+ * @param {Object} options - Store options
12
+ * @param {boolean} [options.uid] - If true, use UID STORE instead of STORE
13
+ * @param {boolean} [options.useLabels] - If true, operate on Gmail labels instead of flags
14
+ * @param {boolean} [options.silent] - If true, use .SILENT variant to suppress server response
15
+ * @param {string} [options.operation] - Operation type: 'set', 'add', or 'remove'
16
+ * @param {string} [options.unchangedSince] - Only update messages not changed since this modseq value
17
+ * @returns {Promise<boolean>} True on success, false on failure or if nothing to do
18
+ */
6
19
  module.exports = async (connection, range, flags, options) => {
7
20
  if (connection.state !== connection.states.SELECTED || !range || (options.useLabels && !connection.capabilities.has('X-GM-EXT-1'))) {
8
21
  // nothing to do here
@@ -10,19 +23,25 @@ module.exports = async (connection, range, flags, options) => {
10
23
  }
11
24
 
12
25
  options = options || {};
26
+
27
+ // Build the IMAP STORE operation name. The format is:
28
+ // [+|-]FLAGS[.SILENT] or [+|-]X-GM-LABELS
29
+ // Where: no prefix = replace all, + = add, - = remove
30
+ // .SILENT suppresses the server from sending back updated flags (saves bandwidth).
13
31
  let operation;
14
32
 
15
33
  operation = 'FLAGS';
16
34
 
17
35
  if (options.useLabels) {
36
+ // Gmail labels (X-GM-EXT-1 extension): operates on labels instead of IMAP flags
18
37
  operation = 'X-GM-LABELS';
19
38
  } else if (options.silent) {
20
39
  operation = `${operation}.SILENT`;
21
40
  }
22
41
 
42
+ // Prefix determines the operation: none = set (replace), + = add, - = remove
23
43
  switch ((options.operation || '').toLowerCase()) {
24
44
  case 'set':
25
- // do nothing, keep operation value as is
26
45
  break;
27
46
  case 'remove':
28
47
  operation = `-${operation}`;
@@ -33,12 +52,14 @@ module.exports = async (connection, range, flags, options) => {
33
52
  break;
34
53
  }
35
54
 
55
+ // Validate each flag: format it (normalize backslash prefix for system flags),
56
+ // then check if the mailbox's permanentFlags allow it. Removal is always allowed
57
+ // since it doesn't require the flag to be in permanentFlags.
36
58
  flags = (Array.isArray(flags) ? flags : [].concat(flags || []))
37
59
  .map(flag => {
38
60
  flag = formatFlag(flag);
39
61
 
40
62
  if (!canUseFlag(connection.mailbox, flag) && operation !== 'remove') {
41
- // it does not seem that we can set this flag
42
63
  return false;
43
64
  }
44
65
 
@@ -46,13 +67,15 @@ module.exports = async (connection, range, flags, options) => {
46
67
  })
47
68
  .filter(flag => flag);
48
69
 
70
+ // Allow empty flags only for 'set' operation (which clears all flags)
49
71
  if (!flags.length && options.operation !== 'set') {
50
- // nothing to do here
51
72
  return false;
52
73
  }
53
74
 
54
75
  let attributes = [{ type: 'SEQUENCE', value: range }, { type: 'ATOM', value: operation }, flags.map(flag => ({ type: 'ATOM', value: flag }))];
55
76
 
77
+ // CONDSTORE (RFC 7162): UNCHANGEDSINCE modifier prevents updating messages whose
78
+ // mod-sequence is higher than the specified value, avoiding overwriting concurrent changes.
56
79
  if (options.unchangedSince && connection.enabled.has('CONDSTORE') && !connection.mailbox.noModseq) {
57
80
  attributes.push([
58
81
  {
@@ -2,7 +2,13 @@
2
2
 
3
3
  const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Subscribes to a mailbox
5
+ /**
6
+ * Subscribes to a mailbox.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} path - Mailbox path to subscribe to
10
+ * @returns {Promise<boolean|undefined>} True on success, false on failure, or undefined if preconditions not met
11
+ */
6
12
  module.exports = async (connection, path) => {
7
13
  if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
8
14
  // nothing to do here
@@ -2,7 +2,13 @@
2
2
 
3
3
  const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
4
4
 
5
- // Unsubscribes from a mailbox
5
+ /**
6
+ * Unsubscribes from a mailbox.
7
+ *
8
+ * @param {Object} connection - IMAP connection instance
9
+ * @param {string} path - Mailbox path to unsubscribe from
10
+ * @returns {Promise<boolean|undefined>} True on success, false on failure, or undefined if preconditions not met
11
+ */
6
12
  module.exports = async (connection, path) => {
7
13
  if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
8
14
  // nothing to do here
@@ -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()));