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
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Requests NAMESPACE info from the server.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} connection - IMAP connection instance
|
|
7
|
+
* @returns {Promise<{prefix: string, delimiter: string}|{error: boolean, status: string, text: string}>} The primary personal namespace, or an error object on failure
|
|
8
|
+
*/
|
|
4
9
|
module.exports = async connection => {
|
|
5
10
|
if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
|
|
6
11
|
// nothing to do here
|
|
@@ -8,8 +13,12 @@ module.exports = async connection => {
|
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
if (!connection.capabilities.has('NAMESPACE')) {
|
|
11
|
-
//
|
|
16
|
+
// Fallback: when the server does not support the NAMESPACE extension (RFC 2342),
|
|
17
|
+
// derive the prefix and delimiter from a LIST "" "" command, which returns
|
|
18
|
+
// the hierarchy delimiter and root name for the default mailbox hierarchy.
|
|
12
19
|
let { prefix, delimiter } = await getListPrefix(connection);
|
|
20
|
+
// Ensure the prefix ends with the delimiter so that appending a mailbox name
|
|
21
|
+
// produces a valid path (e.g., "INBOX." + "Sent" = "INBOX.Sent").
|
|
13
22
|
if (delimiter && prefix && prefix.charAt(prefix.length - 1) !== delimiter) {
|
|
14
23
|
prefix += delimiter;
|
|
15
24
|
}
|
|
@@ -28,6 +37,11 @@ module.exports = async connection => {
|
|
|
28
37
|
let map = {};
|
|
29
38
|
response = await connection.exec('NAMESPACE', false, {
|
|
30
39
|
untagged: {
|
|
40
|
+
// The NAMESPACE response (RFC 2342) contains exactly three sections:
|
|
41
|
+
// [0] = personal namespaces (user's own mailboxes)
|
|
42
|
+
// [1] = other users' namespaces (shared by other users)
|
|
43
|
+
// [2] = shared namespaces (public/organizational folders)
|
|
44
|
+
// Each section is either NIL or a list of (prefix, delimiter) pairs.
|
|
31
45
|
NAMESPACE: async untagged => {
|
|
32
46
|
if (!untagged.attributes || !untagged.attributes.length) {
|
|
33
47
|
return;
|
|
@@ -60,10 +74,18 @@ module.exports = async connection => {
|
|
|
60
74
|
}
|
|
61
75
|
};
|
|
62
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Derives namespace prefix and delimiter from a LIST command when NAMESPACE is not supported.
|
|
79
|
+
*
|
|
80
|
+
* @param {Object} connection - IMAP connection instance
|
|
81
|
+
* @returns {Promise<{prefix?: string, delimiter?: string, flags?: Set}>} Object with prefix, delimiter, and flags, or empty object on failure
|
|
82
|
+
*/
|
|
63
83
|
async function getListPrefix(connection) {
|
|
64
84
|
let response;
|
|
65
85
|
try {
|
|
66
86
|
let map = {};
|
|
87
|
+
// LIST "" "" is a special form that returns only the hierarchy delimiter
|
|
88
|
+
// and the root name, without listing any actual mailboxes.
|
|
67
89
|
response = await connection.exec('LIST', ['', ''], {
|
|
68
90
|
untagged: {
|
|
69
91
|
LIST: async untagged => {
|
|
@@ -88,6 +110,12 @@ async function getListPrefix(connection) {
|
|
|
88
110
|
}
|
|
89
111
|
}
|
|
90
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Parses namespace information from an IMAP NAMESPACE response attribute.
|
|
115
|
+
*
|
|
116
|
+
* @param {Array} attribute - Namespace attribute array from the server response
|
|
117
|
+
* @returns {Array<{prefix: string, delimiter: string}>|boolean} Array of namespace entries, or false if empty
|
|
118
|
+
*/
|
|
91
119
|
function getNamsepaceInfo(attribute) {
|
|
92
120
|
if (!attribute || !attribute.length) {
|
|
93
121
|
return false;
|
|
@@ -99,6 +127,8 @@ function getNamsepaceInfo(attribute) {
|
|
|
99
127
|
let prefix = entry[0].value;
|
|
100
128
|
let delimiter = entry[1].value;
|
|
101
129
|
|
|
130
|
+
// Append the delimiter to the prefix if it doesn't already end with one,
|
|
131
|
+
// so callers can construct full paths by simply concatenating prefix + name.
|
|
102
132
|
if (delimiter && prefix && prefix.charAt(prefix.length - 1) !== delimiter) {
|
|
103
133
|
prefix += delimiter;
|
|
104
134
|
}
|
package/lib/commands/noop.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Sends a NOOP command to the server.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} connection - IMAP connection instance
|
|
7
|
+
* @returns {Promise<boolean>} True on success, false on failure
|
|
8
|
+
*/
|
|
4
9
|
module.exports = async connection => {
|
|
5
10
|
try {
|
|
6
11
|
let response = await connection.exec('NOOP', false, { comment: 'Requested by command' });
|
package/lib/commands/quota.js
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Requests quota information for a mailbox.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {string} path - Mailbox path to query quota for
|
|
10
|
+
* @returns {Promise<{path: string, quotaRoot?: string, storage?: {usage: number, limit: number, status: string}, message?: {usage: number, limit: number, status: string}}|boolean|undefined>} Quota information object, false if QUOTA not supported or 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) || !path) {
|
|
8
14
|
// nothing to do here
|
|
@@ -17,6 +23,11 @@ module.exports = async (connection, path) => {
|
|
|
17
23
|
|
|
18
24
|
let map = { path };
|
|
19
25
|
|
|
26
|
+
// Parse a QUOTA response. The resource list uses a repeating triplet pattern (i % 3):
|
|
27
|
+
// position 0: resource name (e.g., "STORAGE", "MESSAGE")
|
|
28
|
+
// position 1: current usage
|
|
29
|
+
// position 2: limit
|
|
30
|
+
// Storage values are in KB on the wire; multiply by 1024 to report bytes.
|
|
20
31
|
let processQuotaResponse = untagged => {
|
|
21
32
|
let attributes = untagged.attributes && untagged.attributes[1];
|
|
22
33
|
if (!attributes || !attributes.length) {
|
|
@@ -25,10 +36,13 @@ module.exports = async (connection, path) => {
|
|
|
25
36
|
|
|
26
37
|
let key = false;
|
|
27
38
|
attributes.forEach((attribute, i) => {
|
|
28
|
-
|
|
39
|
+
const position = i % 3;
|
|
40
|
+
|
|
41
|
+
if (position === 0) {
|
|
29
42
|
key = attribute && typeof attribute.value === 'string' ? attribute.value.toLowerCase() : false;
|
|
30
43
|
return;
|
|
31
44
|
}
|
|
45
|
+
|
|
32
46
|
if (!key) {
|
|
33
47
|
return;
|
|
34
48
|
}
|
|
@@ -38,21 +52,18 @@ module.exports = async (connection, path) => {
|
|
|
38
52
|
return;
|
|
39
53
|
}
|
|
40
54
|
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
if (!map[key]) {
|
|
44
|
-
map[key] = {};
|
|
45
|
-
}
|
|
46
|
-
map[key].usage = value * (key === 'storage' ? 1024 : 1);
|
|
55
|
+
if (!map[key]) {
|
|
56
|
+
map[key] = {};
|
|
47
57
|
}
|
|
48
58
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (!map[key]) {
|
|
52
|
-
map[key] = {};
|
|
53
|
-
}
|
|
54
|
-
map[key].limit = value * (key === 'storage' ? 1024 : 1);
|
|
59
|
+
// Storage quota is reported in KB by IMAP; convert to bytes for consistency
|
|
60
|
+
const multiplier = key === 'storage' ? 1024 : 1;
|
|
55
61
|
|
|
62
|
+
if (position === 1) {
|
|
63
|
+
map[key].usage = value * multiplier;
|
|
64
|
+
} else if (position === 2) {
|
|
65
|
+
map[key].limit = value * multiplier;
|
|
66
|
+
// Calculate usage percentage for convenient display
|
|
56
67
|
if (map[key].limit) {
|
|
57
68
|
map[key].status = Math.round(((map[key].usage || 0) / map[key].limit) * 100) + '%';
|
|
58
69
|
}
|
|
@@ -63,8 +74,13 @@ module.exports = async (connection, path) => {
|
|
|
63
74
|
let quotaFound = false;
|
|
64
75
|
let response;
|
|
65
76
|
try {
|
|
77
|
+
// Two-step quota lookup: GETQUOTAROOT identifies the quota root for a mailbox,
|
|
78
|
+
// and the server usually sends the QUOTA response inline. Some servers only
|
|
79
|
+
// send the root name and require a separate GETQUOTA command.
|
|
66
80
|
response = await connection.exec('GETQUOTAROOT', [{ type: 'ATOM', value: encodePath(connection, path) }], {
|
|
67
81
|
untagged: {
|
|
82
|
+
// QUOTAROOT response tells us which quota root applies to this mailbox.
|
|
83
|
+
// A mailbox may have zero or one quota root.
|
|
68
84
|
QUOTAROOT: async untagged => {
|
|
69
85
|
let quotaRoot =
|
|
70
86
|
untagged.attributes && untagged.attributes[1] && typeof untagged.attributes[1].value === 'string'
|
|
@@ -74,6 +90,7 @@ module.exports = async (connection, path) => {
|
|
|
74
90
|
map.quotaRoot = quotaRoot;
|
|
75
91
|
}
|
|
76
92
|
},
|
|
93
|
+
// QUOTA response provides the actual resource usage and limits
|
|
77
94
|
QUOTA: async untagged => {
|
|
78
95
|
quotaFound = true;
|
|
79
96
|
processQuotaResponse(untagged);
|
|
@@ -83,6 +100,8 @@ module.exports = async (connection, path) => {
|
|
|
83
100
|
|
|
84
101
|
response.next();
|
|
85
102
|
|
|
103
|
+
// Fallback: if we got a quota root but no QUOTA response inline,
|
|
104
|
+
// explicitly request quota for that root.
|
|
86
105
|
if (map.quotaRoot && !quotaFound) {
|
|
87
106
|
response = await connection.exec('GETQUOTA', [{ type: 'ATOM', value: map.quotaRoot }], {
|
|
88
107
|
untagged: {
|
package/lib/commands/rename.js
CHANGED
|
@@ -2,16 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Renames an existing mailbox.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {string} path - Current mailbox path
|
|
10
|
+
* @param {string} newPath - New mailbox path
|
|
11
|
+
* @returns {Promise<{path: string, newPath: string}|undefined>} Object with old and new paths, or undefined if preconditions not met
|
|
12
|
+
* @throws {Error} If the RENAME command fails
|
|
13
|
+
*/
|
|
6
14
|
module.exports = async (connection, path, newPath) => {
|
|
7
15
|
if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
|
|
8
16
|
// nothing to do here
|
|
9
17
|
return;
|
|
10
18
|
}
|
|
11
19
|
|
|
20
|
+
// Normalize both paths (resolve special names, apply namespace prefix) and encode
|
|
21
|
+
// them for the IMAP wire format (modified UTF-7 for non-ASCII characters).
|
|
12
22
|
path = normalizePath(connection, path);
|
|
13
23
|
newPath = normalizePath(connection, newPath);
|
|
14
24
|
|
|
25
|
+
// Must close/deselect the mailbox before renaming if it's currently selected,
|
|
26
|
+
// as IMAP servers will not rename an active mailbox.
|
|
15
27
|
if (connection.state === connection.states.SELECTED && connection.mailbox.path === path) {
|
|
16
28
|
await connection.run('CLOSE');
|
|
17
29
|
}
|
package/lib/commands/search.js
CHANGED
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
const { enhanceCommandError } = require('../tools.js');
|
|
4
4
|
const { searchCompiler } = require('../search-compiler.js');
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Searches for messages matching the specified criteria.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} connection - IMAP connection instance
|
|
10
|
+
* @param {Object|boolean} query - Search query object, or true/empty object to match all messages
|
|
11
|
+
* @param {Object} [options] - Search options
|
|
12
|
+
* @param {boolean} [options.uid] - If true, use UID SEARCH instead of SEARCH
|
|
13
|
+
* @returns {Promise<number[]|boolean>} Sorted array of matching sequence numbers or UIDs, or false on failure
|
|
14
|
+
*/
|
|
7
15
|
module.exports = async (connection, query, options) => {
|
|
8
16
|
if (connection.state !== connection.states.SELECTED) {
|
|
9
17
|
// nothing to do here
|
|
@@ -14,6 +22,10 @@ module.exports = async (connection, query, options) => {
|
|
|
14
22
|
|
|
15
23
|
let attributes;
|
|
16
24
|
|
|
25
|
+
// Three query branches:
|
|
26
|
+
// 1. Empty/truthy/all-only query -> use IMAP "SEARCH ALL" to match every message
|
|
27
|
+
// 2. Non-empty object -> compile into IMAP SEARCH criteria via searchCompiler
|
|
28
|
+
// 3. Anything else (unexpected type) -> bail out with false
|
|
17
29
|
if (!query || query === true || (typeof query === 'object' && (!Object.keys(query).length || (Object.keys(query).length === 1 && query.all)))) {
|
|
18
30
|
// search for all messages
|
|
19
31
|
attributes = [{ type: 'ATOM', value: 'ALL' }];
|
|
@@ -24,6 +36,8 @@ module.exports = async (connection, query, options) => {
|
|
|
24
36
|
return false;
|
|
25
37
|
}
|
|
26
38
|
|
|
39
|
+
// Use a Set to deduplicate sequence numbers/UIDs -- servers may return
|
|
40
|
+
// duplicates across multiple untagged SEARCH responses.
|
|
27
41
|
let results = new Set();
|
|
28
42
|
let response;
|
|
29
43
|
try {
|
|
@@ -41,6 +55,7 @@ module.exports = async (connection, query, options) => {
|
|
|
41
55
|
}
|
|
42
56
|
});
|
|
43
57
|
response.next();
|
|
58
|
+
// Sort numerically for consistent, predictable output order
|
|
44
59
|
return Array.from(results).sort((a, b) => a - b);
|
|
45
60
|
} catch (err) {
|
|
46
61
|
await enhanceCommandError(err);
|
package/lib/commands/select.js
CHANGED
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Selects or examines a mailbox, making it the current mailbox for subsequent operations.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {string} path - Mailbox path to select
|
|
10
|
+
* @param {Object} [options] - Select options
|
|
11
|
+
* @param {boolean} [options.readOnly] - If true, use EXAMINE instead of SELECT (read-only access)
|
|
12
|
+
* @param {string} [options.changedSince] - QRESYNC modseq value to fetch changes since
|
|
13
|
+
* @param {BigInt} [options.uidValidity] - QRESYNC UID validity value
|
|
14
|
+
* @returns {Promise<Object|undefined>} Mailbox info object with path, flags, exists, uidNext, uidValidity, highestModseq, etc., or undefined if preconditions not met
|
|
15
|
+
* @throws {Error} If the SELECT/EXAMINE command fails
|
|
16
|
+
*/
|
|
6
17
|
module.exports = async (connection, path, options) => {
|
|
7
18
|
if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
|
|
8
19
|
// nothing to do here
|
|
@@ -12,6 +23,8 @@ module.exports = async (connection, path, options) => {
|
|
|
12
23
|
|
|
13
24
|
path = normalizePath(connection, path);
|
|
14
25
|
|
|
26
|
+
// Ensure we have folder metadata (delimiter, flags, specialUse) by running LIST if needed.
|
|
27
|
+
// This is cached in connection.folders to avoid repeated LIST calls.
|
|
15
28
|
if (!connection.folders.has(path)) {
|
|
16
29
|
let folders = await connection.run('LIST', '', path);
|
|
17
30
|
if (!folders) {
|
|
@@ -35,6 +48,9 @@ module.exports = async (connection, path, options) => {
|
|
|
35
48
|
});
|
|
36
49
|
}
|
|
37
50
|
|
|
51
|
+
// QRESYNC (RFC 7162): allows efficient mailbox resynchronization by sending
|
|
52
|
+
// the last known UIDVALIDITY and HIGHESTMODSEQ. Server responds with only
|
|
53
|
+
// the changes (new flags, expunged UIDs) since that point.
|
|
38
54
|
let extraArgs = [];
|
|
39
55
|
if (connection.enabled.has('QRESYNC') && options.changedSince && options.uidValidity) {
|
|
40
56
|
extraArgs.push([
|
|
@@ -49,6 +65,9 @@ module.exports = async (connection, path, options) => {
|
|
|
49
65
|
|
|
50
66
|
let encodedPath = encodePath(connection, path);
|
|
51
67
|
|
|
68
|
+
// SELECT opens the mailbox read-write; EXAMINE opens it read-only.
|
|
69
|
+
// Path encoding: if the encoded path contains '&' (UTF-7 encoding marker),
|
|
70
|
+
// send as quoted STRING to avoid parser issues with the ampersand.
|
|
52
71
|
let selectCommand = {
|
|
53
72
|
command: !options.readOnly ? 'SELECT' : 'EXAMINE',
|
|
54
73
|
arguments: [{ type: encodedPath.indexOf('&') >= 0 ? 'STRING' : 'ATOM', value: encodedPath }].concat(extraArgs || [])
|
|
@@ -56,15 +75,22 @@ module.exports = async (connection, path, options) => {
|
|
|
56
75
|
|
|
57
76
|
response = await connection.exec(selectCommand.command, selectCommand.arguments, {
|
|
58
77
|
untagged: {
|
|
78
|
+
// Untagged OK responses carry response codes in brackets, e.g.:
|
|
79
|
+
// * OK [UIDVALIDITY 1234] UIDs valid
|
|
80
|
+
// * OK [PERMANENTFLAGS (\Seen \Answered \*)] Flags permitted
|
|
81
|
+
// The section array holds the parsed bracket contents: section[0] is the
|
|
82
|
+
// key (e.g., "UIDVALIDITY"), section[1] is the value or list.
|
|
59
83
|
OK: async untagged => {
|
|
60
84
|
if (!untagged.attributes || !untagged.attributes.length) {
|
|
61
85
|
return;
|
|
62
86
|
}
|
|
63
87
|
let section = !untagged.attributes[0].value && untagged.attributes[0].section;
|
|
88
|
+
// Handle response codes with a key-value pair (section has 2+ elements)
|
|
64
89
|
if (section && section.length > 1 && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
65
90
|
let key = section[0].value.toLowerCase();
|
|
66
91
|
let value;
|
|
67
92
|
|
|
93
|
+
// Value can be a single string or a list of strings (e.g., PERMANENTFLAGS)
|
|
68
94
|
if (typeof section[1].value === 'string') {
|
|
69
95
|
value = section[1].value;
|
|
70
96
|
} else if (Array.isArray(section[1])) {
|
|
@@ -72,6 +98,10 @@ module.exports = async (connection, path, options) => {
|
|
|
72
98
|
}
|
|
73
99
|
|
|
74
100
|
switch (key) {
|
|
101
|
+
// CONDSTORE (RFC 7162): highest mod-sequence value for the mailbox.
|
|
102
|
+
// Used for incremental sync -- clients compare against their cached
|
|
103
|
+
// value to detect changes. Stored as BigInt since modseq values
|
|
104
|
+
// can exceed Number.MAX_SAFE_INTEGER.
|
|
75
105
|
case 'highestmodseq':
|
|
76
106
|
key = 'highestModseq';
|
|
77
107
|
if (/^[0-9]+$/.test(value)) {
|
|
@@ -79,6 +109,9 @@ module.exports = async (connection, path, options) => {
|
|
|
79
109
|
}
|
|
80
110
|
break;
|
|
81
111
|
|
|
112
|
+
// OBJECTID (RFC 8474): server-assigned unique mailbox identifier.
|
|
113
|
+
// Unlike path, this ID survives renames. Value comes as a
|
|
114
|
+
// parenthesized list, so extract the first (only) element.
|
|
82
115
|
case 'mailboxid':
|
|
83
116
|
key = 'mailboxId';
|
|
84
117
|
if (Array.isArray(value) && value.length) {
|
|
@@ -86,16 +119,23 @@ module.exports = async (connection, path, options) => {
|
|
|
86
119
|
}
|
|
87
120
|
break;
|
|
88
121
|
|
|
122
|
+
// Flags that the client can change permanently on messages in
|
|
123
|
+
// this mailbox. Includes \* if the server allows custom flags.
|
|
89
124
|
case 'permanentflags':
|
|
90
125
|
key = 'permanentFlags';
|
|
91
126
|
value = new Set(value);
|
|
92
127
|
break;
|
|
93
128
|
|
|
129
|
+
// The next UID that will be assigned to a new message in this
|
|
130
|
+
// mailbox. Useful for detecting new arrivals.
|
|
94
131
|
case 'uidnext':
|
|
95
132
|
key = 'uidNext';
|
|
96
133
|
value = Number(value);
|
|
97
134
|
break;
|
|
98
135
|
|
|
136
|
+
// Unique identifier validity value. If this changes between
|
|
137
|
+
// sessions, all previously cached UIDs are invalid and the
|
|
138
|
+
// client must re-sync from scratch.
|
|
99
139
|
case 'uidvalidity':
|
|
100
140
|
key = 'uidValidity';
|
|
101
141
|
if (/^[0-9]+$/.test(value)) {
|
|
@@ -107,9 +147,12 @@ module.exports = async (connection, path, options) => {
|
|
|
107
147
|
map[key] = value;
|
|
108
148
|
}
|
|
109
149
|
|
|
150
|
+
// Handle response codes with only a keyword (no value), e.g., [NOMODSEQ]
|
|
110
151
|
if (section && section.length === 1 && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
111
152
|
let key = section[0].value.toLowerCase();
|
|
112
153
|
switch (key) {
|
|
154
|
+
// NOMODSEQ means the mailbox does not support mod-sequences.
|
|
155
|
+
// CONDSTORE/QRESYNC features are unavailable for this mailbox.
|
|
113
156
|
case 'nomodseq':
|
|
114
157
|
key = 'noModseq';
|
|
115
158
|
map[key] = true;
|
|
@@ -117,6 +160,9 @@ module.exports = async (connection, path, options) => {
|
|
|
117
160
|
}
|
|
118
161
|
}
|
|
119
162
|
},
|
|
163
|
+
|
|
164
|
+
// Untagged FLAGS response lists all flags defined for this mailbox
|
|
165
|
+
// (both system flags and custom flags). Example: * FLAGS (\Seen \Answered \Flagged)
|
|
120
166
|
FLAGS: async untagged => {
|
|
121
167
|
if (!untagged.attributes || (!untagged.attributes.length && Array.isArray(untagged.attributes[0]))) {
|
|
122
168
|
return;
|
|
@@ -124,6 +170,9 @@ module.exports = async (connection, path, options) => {
|
|
|
124
170
|
let flags = untagged.attributes[0].map(flag => (typeof flag.value === 'string' ? flag.value : false)).filter(flag => flag);
|
|
125
171
|
map.flags = new Set(flags);
|
|
126
172
|
},
|
|
173
|
+
|
|
174
|
+
// Untagged EXISTS response: "* <count> EXISTS" tells us the total number
|
|
175
|
+
// of messages in the mailbox. The count is in the command field (numeric prefix).
|
|
127
176
|
EXISTS: async untagged => {
|
|
128
177
|
let num = Number(untagged.command);
|
|
129
178
|
if (isNaN(num)) {
|
|
@@ -132,58 +181,51 @@ module.exports = async (connection, path, options) => {
|
|
|
132
181
|
|
|
133
182
|
map.exists = num;
|
|
134
183
|
},
|
|
184
|
+
|
|
185
|
+
// VANISHED responses (QRESYNC): server reports UIDs that have been expunged
|
|
186
|
+
// since the client's last known state. Only received when QRESYNC was requested.
|
|
187
|
+
// A dummy mailbox object is passed because the mailbox isn't officially open yet.
|
|
135
188
|
VANISHED: async untagged => {
|
|
136
|
-
await connection.untaggedVanished(
|
|
137
|
-
untagged,
|
|
138
|
-
// mailbox is not yet open, so use a dummy mailbox object
|
|
139
|
-
{ path, uidNext: false, uidValidity: false }
|
|
140
|
-
);
|
|
189
|
+
await connection.untaggedVanished(untagged, { path, uidNext: false, uidValidity: false });
|
|
141
190
|
},
|
|
142
|
-
|
|
191
|
+
|
|
192
|
+
// Untagged FETCH during SELECT/EXAMINE: only occurs with QRESYNC, delivering
|
|
193
|
+
// updated flags for messages that changed since the client's last modseq.
|
|
143
194
|
FETCH: async untagged => {
|
|
144
|
-
await connection.untaggedFetch(
|
|
145
|
-
untagged,
|
|
146
|
-
// mailbox is not yet open, so use a dummy mailbox object
|
|
147
|
-
{ path, uidNext: false, uidValidity: false }
|
|
148
|
-
);
|
|
195
|
+
await connection.untaggedFetch(untagged, { path, uidNext: false, uidValidity: false });
|
|
149
196
|
}
|
|
150
197
|
}
|
|
151
198
|
});
|
|
152
199
|
|
|
200
|
+
// The tagged OK response to SELECT/EXAMINE includes [READ-ONLY] or [READ-WRITE]
|
|
201
|
+
// in its response code, indicating the access mode the server granted.
|
|
153
202
|
let section = !response.response.attributes[0].value && response.response.attributes[0].section;
|
|
154
203
|
if (section && section.length && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
155
|
-
|
|
156
|
-
case 'READ-ONLY':
|
|
157
|
-
map.readOnly = true;
|
|
158
|
-
break;
|
|
159
|
-
case 'READ-WRITE':
|
|
160
|
-
default:
|
|
161
|
-
map.readOnly = false;
|
|
162
|
-
break;
|
|
163
|
-
}
|
|
204
|
+
map.readOnly = section[0].value.toUpperCase() === 'READ-ONLY';
|
|
164
205
|
}
|
|
165
206
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
!map.highestModseq ||
|
|
172
|
-
// NOMODSEQ is not allowed
|
|
173
|
-
map.noModseq)
|
|
174
|
-
) {
|
|
175
|
-
// QRESYNC does not apply here, so unset it
|
|
207
|
+
// Validate QRESYNC preconditions (RFC 7162 Section 3.2.5):
|
|
208
|
+
// QRESYNC results are only valid if UIDVALIDITY matches, HIGHESTMODSEQ is
|
|
209
|
+
// present, and the mailbox supports mod-sequences. If any condition fails,
|
|
210
|
+
// the client cannot trust the incremental updates and must do a full resync.
|
|
211
|
+
if (map.qresync && (options.uidValidity !== map.uidValidity || !map.highestModseq || map.noModseq)) {
|
|
176
212
|
map.qresync = false;
|
|
177
213
|
}
|
|
178
214
|
|
|
215
|
+
// Transition mailbox state: save previous mailbox reference, temporarily
|
|
216
|
+
// clear it, then emit events and set the new mailbox.
|
|
179
217
|
let currentMailbox = connection.mailbox;
|
|
180
218
|
connection.mailbox = false;
|
|
181
219
|
|
|
220
|
+
// Emit mailboxClose if we're switching from a different mailbox.
|
|
221
|
+
// Re-selecting the same mailbox (e.g., for resync) does not trigger close/open.
|
|
182
222
|
if (currentMailbox && currentMailbox.path !== path) {
|
|
183
223
|
connection.emit('mailboxClose', currentMailbox);
|
|
184
224
|
}
|
|
185
225
|
|
|
186
226
|
connection.mailbox = map;
|
|
227
|
+
// Save the SELECT command for potential re-use (e.g., NOOP fallback polling
|
|
228
|
+
// re-issues the SELECT to detect changes on servers without IDLE support).
|
|
187
229
|
connection.currentSelectCommand = selectCommand;
|
|
188
230
|
connection.state = connection.states.SELECTED;
|
|
189
231
|
|
|
@@ -196,9 +238,10 @@ module.exports = async (connection, path, options) => {
|
|
|
196
238
|
} catch (err) {
|
|
197
239
|
await enhanceCommandError(err);
|
|
198
240
|
|
|
241
|
+
// If SELECT/EXAMINE fails while a mailbox was already selected, we must
|
|
242
|
+
// reset to AUTHENTICATED state since the server has implicitly deselected
|
|
243
|
+
// the previous mailbox on failure (RFC 3501 Section 6.3.1).
|
|
199
244
|
if (connection.state === connection.states.SELECTED) {
|
|
200
|
-
// reset selected state
|
|
201
|
-
|
|
202
245
|
let currentMailbox = connection.mailbox;
|
|
203
246
|
|
|
204
247
|
connection.mailbox = false;
|
package/lib/commands/starttls.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Initiates STARTTLS connection upgrade.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} connection - IMAP connection instance
|
|
7
|
+
* @returns {Promise<boolean>} True if STARTTLS was initiated, false if not supported or already secure
|
|
8
|
+
*/
|
|
4
9
|
module.exports = async connection => {
|
|
5
10
|
if (!connection.capabilities.has('STARTTLS') || connection.secureConnection) {
|
|
6
11
|
// nothing to do here
|