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.
- package/.ncurc.js +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/README.md +3 -3
- package/lib/charsets.js +15 -0
- package/lib/commands/append.js +62 -54
- package/lib/commands/authenticate.js +99 -52
- package/lib/commands/capability.js +12 -2
- package/lib/commands/close.js +11 -1
- package/lib/commands/compress.js +10 -1
- package/lib/commands/copy.js +18 -1
- package/lib/commands/create.js +15 -2
- package/lib/commands/delete.js +10 -1
- package/lib/commands/enable.js +12 -1
- package/lib/commands/expunge.js +18 -2
- package/lib/commands/fetch.js +39 -4
- package/lib/commands/id.js +22 -3
- package/lib/commands/idle.js +39 -4
- package/lib/commands/list.js +86 -48
- package/lib/commands/login.js +12 -1
- package/lib/commands/logout.js +11 -2
- package/lib/commands/move.js +17 -1
- package/lib/commands/namespace.js +32 -2
- package/lib/commands/noop.js +6 -1
- package/lib/commands/quota.js +33 -14
- package/lib/commands/rename.js +13 -1
- package/lib/commands/search.js +16 -1
- package/lib/commands/select.js +76 -33
- package/lib/commands/starttls.js +6 -1
- package/lib/commands/status.js +64 -52
- package/lib/commands/store.js +27 -4
- package/lib/commands/subscribe.js +7 -1
- package/lib/commands/unsubscribe.js +7 -1
- package/lib/handler/imap-compiler.js +44 -2
- package/lib/handler/imap-formal-syntax.js +51 -3
- package/lib/handler/imap-handler.js +8 -0
- package/lib/handler/imap-parser.js +23 -2
- package/lib/handler/imap-stream.js +84 -31
- package/lib/handler/parser-instance.js +61 -1
- package/lib/handler/token-parser.js +66 -9
- package/lib/imap-commands.js +11 -0
- package/lib/imap-flow.d.ts +6 -0
- package/lib/imap-flow.js +164 -42
- package/lib/jp-decoder.js +10 -0
- package/lib/limited-passthrough.js +12 -5
- package/lib/proxy-connection.js +18 -12
- package/lib/search-compiler.js +3 -11
- package/lib/special-use.js +23 -16
- package/lib/tools.js +218 -13
- package/package.json +4 -11
- package/test/commands-integration-test.js +33 -0
- package/test/special-use-test.js +32 -0
- package/assets/favicon.ico +0 -0
- 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
|
-
|
|
6
|
-
|
|
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
|
-
//
|
|
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]
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
this.literalBuffer = [];
|
|
178
|
-
this.state = LINE;
|
|
207
|
+
this.literalBuffer.push(partial);
|
|
208
|
+
this.literalWaiting -= bytesToRead;
|
|
179
209
|
|
|
180
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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]`);
|