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.
- package/.ncurc.js +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/README.md +36 -58
- package/eslint.config.js +18 -16
- 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 +175 -46
- 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 +5 -17
- package/test/commands-integration-test.js +33 -0
- package/test/connection-edge-cases-test.js +105 -0
- package/test/special-use-test.js +32 -0
- package/.babelrc +0 -6
- package/.eslintrc +0 -16
- package/assets/favicon.ico +0 -0
- package/jsdoc.json +0 -28
package/lib/commands/status.js
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
const { encodePath, normalizePath } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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) {
|
package/lib/commands/store.js
CHANGED
|
@@ -2,7 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
const { formatFlag, canUseFlag, enhanceCommandError } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()));
|