imapflow 1.2.8 → 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 +7 -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
package/lib/commands/delete.js
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Deletes an existing mailbox.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {string} path - Mailbox path to delete
|
|
10
|
+
* @returns {Promise<{path: string}|undefined>} Object with the deleted path, or undefined if preconditions not met
|
|
11
|
+
* @throws {Error} If the DELETE command fails
|
|
12
|
+
*/
|
|
6
13
|
module.exports = async (connection, path) => {
|
|
7
14
|
if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
|
|
8
15
|
// nothing to do here
|
|
@@ -11,6 +18,8 @@ module.exports = async (connection, path) => {
|
|
|
11
18
|
|
|
12
19
|
path = normalizePath(connection, path);
|
|
13
20
|
|
|
21
|
+
// If the mailbox to delete is currently selected, we must close/deselect it first.
|
|
22
|
+
// IMAP servers reject DELETE on the currently selected mailbox (RFC 3501 6.3.4).
|
|
14
23
|
if (connection.state === connection.states.SELECTED && connection.mailbox.path === path) {
|
|
15
24
|
await connection.run('CLOSE');
|
|
16
25
|
}
|
package/lib/commands/enable.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Enables IMAP extensions on the server.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} connection - IMAP connection instance
|
|
7
|
+
* @param {string[]} extensionList - List of extension names to enable
|
|
8
|
+
* @returns {Promise<Set|boolean|undefined>} Set of enabled extensions, false on failure, or undefined if not applicable
|
|
9
|
+
*/
|
|
4
10
|
module.exports = async (connection, extensionList) => {
|
|
5
11
|
if (!connection.capabilities.has('ENABLE') || connection.state !== connection.states.AUTHENTICATED) {
|
|
6
12
|
// nothing to do here
|
|
7
13
|
return;
|
|
8
14
|
}
|
|
9
15
|
|
|
16
|
+
// Pre-filter: only request extensions the server actually advertised in its
|
|
17
|
+
// CAPABILITY response. Requesting unsupported extensions would cause an error.
|
|
10
18
|
extensionList = extensionList.filter(extension => connection.capabilities.has(extension.toUpperCase()));
|
|
11
19
|
if (!extensionList.length) {
|
|
12
20
|
return;
|
|
@@ -20,6 +28,9 @@ module.exports = async (connection, extensionList) => {
|
|
|
20
28
|
extensionList.map(extension => ({ type: 'ATOM', value: extension.toUpperCase() })),
|
|
21
29
|
{
|
|
22
30
|
untagged: {
|
|
31
|
+
// The untagged ENABLED response is a flat list of extension names
|
|
32
|
+
// (e.g., "* ENABLED CONDSTORE UTF8=ACCEPT"), NOT key-value pairs.
|
|
33
|
+
// Each attribute is a single extension identifier.
|
|
23
34
|
ENABLED: async untagged => {
|
|
24
35
|
if (!untagged.attributes || !untagged.attributes.length) {
|
|
25
36
|
return;
|
package/lib/commands/expunge.js
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
const { enhanceCommandError } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Deletes specified messages by flagging them as Deleted and expunging.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {string} range - Message sequence number or UID range
|
|
10
|
+
* @param {Object} [options] - Expunge options
|
|
11
|
+
* @param {boolean} [options.uid] - If true, use UID EXPUNGE when UIDPLUS is available
|
|
12
|
+
* @returns {Promise<boolean|undefined>} True on success, false on failure, or undefined if preconditions not met
|
|
13
|
+
*/
|
|
6
14
|
module.exports = async (connection, range, options) => {
|
|
7
15
|
if (connection.state !== connection.states.SELECTED || !range) {
|
|
8
16
|
// nothing to do here
|
|
@@ -11,8 +19,14 @@ module.exports = async (connection, range, options) => {
|
|
|
11
19
|
|
|
12
20
|
options = options || {};
|
|
13
21
|
|
|
22
|
+
// Two-step deletion process per IMAP protocol:
|
|
23
|
+
// Step 1: Mark the target messages with the \Deleted flag.
|
|
14
24
|
await connection.messageFlagsAdd(range, ['\\Deleted'], options);
|
|
15
25
|
|
|
26
|
+
// Step 2: Issue EXPUNGE to permanently remove \Deleted messages.
|
|
27
|
+
// With UIDPLUS (RFC 4315): "UID EXPUNGE <uids>" removes only the specified UIDs,
|
|
28
|
+
// leaving other \Deleted messages untouched -- important for concurrent access.
|
|
29
|
+
// Without UIDPLUS: plain "EXPUNGE" removes ALL messages flagged \Deleted in the mailbox.
|
|
16
30
|
let byUid = options.uid && connection.capabilities.has('UIDPLUS');
|
|
17
31
|
let command = byUid ? 'UID EXPUNGE' : 'EXPUNGE';
|
|
18
32
|
let attributes = byUid ? [{ type: 'SEQUENCE', value: range }] : false;
|
|
@@ -21,7 +35,9 @@ module.exports = async (connection, range, options) => {
|
|
|
21
35
|
try {
|
|
22
36
|
response = await connection.exec(command, attributes);
|
|
23
37
|
|
|
24
|
-
//
|
|
38
|
+
// CONDSTORE (RFC 7162): the server may return HIGHESTMODSEQ in the response code
|
|
39
|
+
// (e.g., "A OK [HIGHESTMODSEQ 9122] Expunge completed").
|
|
40
|
+
// Track this so the client can detect concurrent mailbox changes via mod-sequences.
|
|
25
41
|
let section = response.response.attributes && response.response.attributes[0] && response.response.attributes[0].section;
|
|
26
42
|
let responseCode = section && section.length && section[0] && typeof section[0].value === 'string' ? section[0].value : '';
|
|
27
43
|
if (responseCode.toUpperCase() === 'HIGHESTMODSEQ') {
|
package/lib/commands/fetch.js
CHANGED
|
@@ -2,7 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
const { formatMessageResponse } = require('../tools');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Fetches emails from the server.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {string} range - Message sequence number or UID range
|
|
10
|
+
* @param {Object} query - Fetch query specifying which data to retrieve (e.g., flags, envelope, bodyStructure, headers, source, bodyParts)
|
|
11
|
+
* @param {Object} [options] - Fetch options
|
|
12
|
+
* @param {boolean} [options.uid] - If true, use UID FETCH instead of FETCH
|
|
13
|
+
* @param {boolean} [options.binary] - If true, use BINARY fetch when available
|
|
14
|
+
* @param {string} [options.changedSince] - Only fetch messages changed since this modseq value
|
|
15
|
+
* @param {Function} [options.onUntaggedFetch] - Callback for processing each fetched message individually
|
|
16
|
+
* @returns {Promise<{count: number, list: Object[]}|undefined>} Object with message count and list, or undefined if not in SELECTED state
|
|
17
|
+
*/
|
|
6
18
|
module.exports = async (connection, range, query, options) => {
|
|
7
19
|
if (connection.state !== connection.states.SELECTED || !range) {
|
|
8
20
|
// nothing to do here
|
|
@@ -13,8 +25,10 @@ module.exports = async (connection, range, query, options) => {
|
|
|
13
25
|
|
|
14
26
|
let mailbox = connection.mailbox;
|
|
15
27
|
|
|
28
|
+
// Use BINARY extension for fetching if supported and requested, otherwise fall back to BODY
|
|
16
29
|
const commandKey = connection.capabilities.has('BINARY') && options.binary && !connection.disableBinary ? 'BINARY' : 'BODY';
|
|
17
30
|
|
|
31
|
+
// Retry logic for ETHROTTLE errors (server rate limiting) with exponential backoff
|
|
18
32
|
let retryCount = 0;
|
|
19
33
|
const maxRetries = 4;
|
|
20
34
|
const baseDelay = 1000; // Start with 1 second delay
|
|
@@ -31,6 +45,9 @@ module.exports = async (connection, range, query, options) => {
|
|
|
31
45
|
|
|
32
46
|
let queryStructure = [];
|
|
33
47
|
|
|
48
|
+
// Helper to build BODY.PEEK[section]<partial> or BINARY.PEEK[section]<partial> atoms.
|
|
49
|
+
// PEEK avoids marking messages as \Seen. Section identifies what to fetch (HEADER, specific part, etc.)
|
|
50
|
+
// Partial is an optional byte range [start, maxLength].
|
|
34
51
|
let setBodyPeek = (attributes, partial) => {
|
|
35
52
|
let bodyPeek = {
|
|
36
53
|
type: 'ATOM',
|
|
@@ -50,6 +67,7 @@ module.exports = async (connection, range, query, options) => {
|
|
|
50
67
|
queryStructure.push(bodyPeek);
|
|
51
68
|
};
|
|
52
69
|
|
|
70
|
+
// IMAP fetch macros (ALL, FAST, FULL) and standard data items map directly to IMAP atoms
|
|
53
71
|
['all', 'fast', 'full', 'uid', 'flags', 'bodyStructure', 'envelope', 'internalDate'].forEach(key => {
|
|
54
72
|
if (query[key]) {
|
|
55
73
|
queryStructure.push({ type: 'ATOM', value: key.toUpperCase() });
|
|
@@ -60,6 +78,7 @@ module.exports = async (connection, range, query, options) => {
|
|
|
60
78
|
queryStructure.push({ type: 'ATOM', value: 'RFC822.SIZE' });
|
|
61
79
|
}
|
|
62
80
|
|
|
81
|
+
// Fetch full message source, optionally with byte range (start/maxLength)
|
|
63
82
|
if (query.source) {
|
|
64
83
|
let partial;
|
|
65
84
|
if (typeof query.source === 'object' && (query.source.start || query.source.maxLength)) {
|
|
@@ -71,13 +90,15 @@ module.exports = async (connection, range, query, options) => {
|
|
|
71
90
|
queryStructure.push({ type: 'ATOM', value: `${commandKey}.PEEK`, section: [], partial });
|
|
72
91
|
}
|
|
73
92
|
|
|
74
|
-
//
|
|
93
|
+
// Always request a unique email ID for message deduplication.
|
|
94
|
+
// Prefer OBJECTID (RFC 8474) over Gmail's X-GM-MSGID extension.
|
|
75
95
|
if (connection.capabilities.has('OBJECTID')) {
|
|
76
96
|
queryStructure.push({ type: 'ATOM', value: 'EMAILID' });
|
|
77
97
|
} else if (connection.capabilities.has('X-GM-EXT-1')) {
|
|
78
98
|
queryStructure.push({ type: 'ATOM', value: 'X-GM-MSGID' });
|
|
79
99
|
}
|
|
80
100
|
|
|
101
|
+
// Thread ID: OBJECTID's THREADID or Gmail's X-GM-THRID
|
|
81
102
|
if (query.threadId) {
|
|
82
103
|
if (connection.capabilities.has('OBJECTID')) {
|
|
83
104
|
queryStructure.push({ type: 'ATOM', value: 'THREADID' });
|
|
@@ -86,6 +107,7 @@ module.exports = async (connection, range, query, options) => {
|
|
|
86
107
|
}
|
|
87
108
|
}
|
|
88
109
|
|
|
110
|
+
// Gmail labels are only available with X-GM-EXT-1 extension
|
|
89
111
|
if (query.labels) {
|
|
90
112
|
if (connection.capabilities.has('X-GM-EXT-1')) {
|
|
91
113
|
queryStructure.push({ type: 'ATOM', value: 'X-GM-LABELS' });
|
|
@@ -97,11 +119,13 @@ module.exports = async (connection, range, query, options) => {
|
|
|
97
119
|
queryStructure.push({ type: 'ATOM', value: 'MODSEQ' });
|
|
98
120
|
}
|
|
99
121
|
|
|
100
|
-
//
|
|
122
|
+
// Always include UID in the response even if not explicitly requested,
|
|
123
|
+
// since we use it internally for message identification and tracking
|
|
101
124
|
if (!query.uid) {
|
|
102
125
|
queryStructure.push({ type: 'ATOM', value: 'UID' });
|
|
103
126
|
}
|
|
104
127
|
|
|
128
|
+
// Headers: fetch all headers or only specific ones via HEADER.FIELDS
|
|
105
129
|
if (query.headers) {
|
|
106
130
|
if (Array.isArray(query.headers)) {
|
|
107
131
|
setBodyPeek([{ type: 'ATOM', value: 'HEADER.FIELDS' }, query.headers.map(header => ({ type: 'ATOM', value: header }))]);
|
|
@@ -110,6 +134,8 @@ module.exports = async (connection, range, query, options) => {
|
|
|
110
134
|
}
|
|
111
135
|
}
|
|
112
136
|
|
|
137
|
+
// Fetch specific body parts by MIME part number (e.g., "1", "1.2", "2.MIME")
|
|
138
|
+
// Each part can optionally include a byte range (start/maxLength)
|
|
113
139
|
if (query.bodyParts && query.bodyParts.length) {
|
|
114
140
|
query.bodyParts.forEach(part => {
|
|
115
141
|
if (!part) {
|
|
@@ -138,12 +164,16 @@ module.exports = async (connection, range, query, options) => {
|
|
|
138
164
|
});
|
|
139
165
|
}
|
|
140
166
|
|
|
167
|
+
// IMAP requires a single item to not be wrapped in parentheses, but
|
|
168
|
+
// multiple items must be in a list. If only one item, unwrap the array.
|
|
141
169
|
if (queryStructure.length === 1) {
|
|
142
170
|
queryStructure = queryStructure.pop();
|
|
143
171
|
}
|
|
144
172
|
|
|
145
173
|
attributes.push(queryStructure);
|
|
146
174
|
|
|
175
|
+
// CONDSTORE extension: only fetch messages with modseq higher than the given value.
|
|
176
|
+
// QRESYNC adds VANISHED to also get expunged UIDs since last sync.
|
|
147
177
|
if (options.changedSince && connection.enabled.has('CONDSTORE') && !mailbox.noModseq) {
|
|
148
178
|
let changedSinceArgs = [
|
|
149
179
|
{
|
|
@@ -168,6 +198,9 @@ module.exports = async (connection, range, query, options) => {
|
|
|
168
198
|
|
|
169
199
|
response = await connection.exec(options.uid ? 'UID FETCH' : 'FETCH', attributes, {
|
|
170
200
|
untagged: {
|
|
201
|
+
// Each matching message triggers an untagged FETCH response.
|
|
202
|
+
// If onUntaggedFetch callback is provided, stream messages to it one by one
|
|
203
|
+
// (useful for large result sets). Otherwise, collect all into messages.list.
|
|
171
204
|
FETCH: async untagged => {
|
|
172
205
|
messages.count++;
|
|
173
206
|
let formatted = await formatMessageResponse(untagged, mailbox);
|
|
@@ -192,7 +225,9 @@ module.exports = async (connection, range, query, options) => {
|
|
|
192
225
|
return messages;
|
|
193
226
|
} catch (err) {
|
|
194
227
|
if (err.code === 'ETHROTTLE') {
|
|
195
|
-
//
|
|
228
|
+
// Server returned a throttle error (rate limiting). Retry with exponential backoff.
|
|
229
|
+
// Delay doubles each retry: 1s, 2s, 4s, 8s (capped at 30s).
|
|
230
|
+
// If server provides a throttleReset hint, use that if longer.
|
|
196
231
|
const backoffDelay = Math.min(baseDelay * Math.pow(2, retryCount), 30000); // Cap at 30 seconds
|
|
197
232
|
|
|
198
233
|
// Use throttle reset time if provided and longer than backoff
|
package/lib/commands/id.js
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
const { formatDateTime } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Sends ID info to the server and updates server info data based on the response.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {Object} clientInfo - Client identification key-value pairs to send to the server
|
|
10
|
+
* @returns {Promise<Object|boolean|undefined>} Server information map, false on failure, or undefined if ID not supported
|
|
11
|
+
*/
|
|
12
|
+
// RFC 2971: The ID command exchanges client/server implementation info
|
|
13
|
+
// (name, version, vendor, etc.) for diagnostic and compatibility purposes.
|
|
6
14
|
module.exports = async (connection, clientInfo) => {
|
|
7
15
|
if (!connection.capabilities.has('ID')) {
|
|
8
16
|
// nothing to do here
|
|
@@ -13,7 +21,8 @@ module.exports = async (connection, clientInfo) => {
|
|
|
13
21
|
try {
|
|
14
22
|
let map = {};
|
|
15
23
|
|
|
16
|
-
//
|
|
24
|
+
// Convert the clientInfo object into a flat array of alternating key-value strings
|
|
25
|
+
// for the IMAP wire format: ("key1" "value1" "key2" "value2" ...)
|
|
17
26
|
let formattedClientInfo = !clientInfo
|
|
18
27
|
? null
|
|
19
28
|
: Object.keys(clientInfo)
|
|
@@ -28,6 +37,8 @@ module.exports = async (connection, clientInfo) => {
|
|
|
28
37
|
|
|
29
38
|
response = await connection.exec('ID', [formattedClientInfo], {
|
|
30
39
|
untagged: {
|
|
40
|
+
// Parse the server's ID response: a flat list of alternating key-value atoms.
|
|
41
|
+
// Even indices (i % 2 === 0) are keys, odd indices are the corresponding values.
|
|
31
42
|
ID: async untagged => {
|
|
32
43
|
let params = untagged.attributes && untagged.attributes[0];
|
|
33
44
|
let key;
|
|
@@ -50,10 +61,18 @@ module.exports = async (connection, clientInfo) => {
|
|
|
50
61
|
}
|
|
51
62
|
};
|
|
52
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Formats a client info value for the ID command.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} key - The info key name
|
|
68
|
+
* @param {*} value - The value to format
|
|
69
|
+
* @returns {string} Formatted value string
|
|
70
|
+
*/
|
|
53
71
|
function formatValue(key, value) {
|
|
54
72
|
switch (key.toLowerCase()) {
|
|
55
73
|
case 'date':
|
|
56
|
-
//
|
|
74
|
+
// RFC 2971 requires the "date" field to use IMAP date-time format
|
|
75
|
+
// (e.g., "06-Feb-2026 12:00:00 +0000"), not ISO 8601 or other formats.
|
|
57
76
|
return formatDateTime(value);
|
|
58
77
|
default:
|
|
59
78
|
// Other values are strings without newlines
|
package/lib/commands/idle.js
CHANGED
|
@@ -2,18 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
const NOOP_INTERVAL = 2 * 60 * 1000;
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Runs a single IDLE session on the connection.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @returns {Promise<void|boolean>} Void on success, false on failure
|
|
10
|
+
*/
|
|
5
11
|
async function runIdle(connection) {
|
|
6
12
|
let response;
|
|
7
13
|
|
|
14
|
+
// Queue of promises waiting for IDLE to break. When another command needs to run,
|
|
15
|
+
// it calls connection.preCheck() which queues a promise here and sends DONE to break IDLE.
|
|
8
16
|
let preCheckWaitQueue = [];
|
|
9
17
|
try {
|
|
10
18
|
connection.idling = true;
|
|
11
19
|
|
|
12
|
-
//
|
|
20
|
+
// State flags for the IDLE lifecycle:
|
|
21
|
+
// - doneRequested: someone wants to break IDLE (e.g., to run another command)
|
|
22
|
+
// - doneSent: we've already sent the DONE command to server
|
|
23
|
+
// - canEnd: server has acknowledged IDLE with "+" continuation, so DONE can be sent
|
|
13
24
|
let doneRequested = false;
|
|
14
25
|
let doneSent = false;
|
|
15
26
|
let canEnd = false;
|
|
16
27
|
|
|
28
|
+
// preCheck sends DONE to break out of IDLE. Called when another command
|
|
29
|
+
// needs to run on this connection (e.g., a FETCH or STORE from user code).
|
|
17
30
|
let preCheck = async () => {
|
|
18
31
|
doneRequested = true;
|
|
19
32
|
if (canEnd && !doneSent) {
|
|
@@ -37,6 +50,8 @@ async function runIdle(connection) {
|
|
|
37
50
|
}
|
|
38
51
|
};
|
|
39
52
|
|
|
53
|
+
// Public interface for breaking IDLE. Returns a promise that resolves when
|
|
54
|
+
// IDLE is actually broken and the connection is free for other commands.
|
|
40
55
|
let connectionPreCheck = () => {
|
|
41
56
|
let handler = new Promise((resolve, reject) => {
|
|
42
57
|
preCheckWaitQueue.push({ resolve, reject });
|
|
@@ -57,9 +72,13 @@ async function runIdle(connection) {
|
|
|
57
72
|
return handler;
|
|
58
73
|
};
|
|
59
74
|
|
|
75
|
+
// Register preCheck on the connection so other code (e.g., getMailboxLock) can break IDLE
|
|
60
76
|
connection.preCheck = connectionPreCheck;
|
|
61
77
|
|
|
62
78
|
response = await connection.exec('IDLE', false, {
|
|
79
|
+
// Server responds with "+" continuation to acknowledge IDLE mode.
|
|
80
|
+
// After this, the server will push untagged responses for mailbox changes.
|
|
81
|
+
// We can now safely send DONE if a break was already requested.
|
|
63
82
|
onPlusTag: async () => {
|
|
64
83
|
connection.log.debug({ msg: `Initiated IDLE, waiting for server input`, lockId: connection.currentLock?.lockId, doneRequested });
|
|
65
84
|
canEnd = true;
|
|
@@ -76,7 +95,8 @@ async function runIdle(connection) {
|
|
|
76
95
|
}
|
|
77
96
|
});
|
|
78
97
|
|
|
79
|
-
// unset
|
|
98
|
+
// Clean up: unset preCheck and resolve any remaining waiters before processing the response.
|
|
99
|
+
// Usually preCheck is already cleared by the DONE handler, but this handles edge cases.
|
|
80
100
|
if (typeof connection.preCheck === 'function' && connection.preCheck === connectionPreCheck) {
|
|
81
101
|
connection.log.trace({
|
|
82
102
|
msg: 'Clearing pre-check function',
|
|
@@ -109,16 +129,26 @@ async function runIdle(connection) {
|
|
|
109
129
|
}
|
|
110
130
|
}
|
|
111
131
|
|
|
112
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Listens for changes in the selected mailbox using IDLE or NOOP polling fallback.
|
|
134
|
+
*
|
|
135
|
+
* @param {Object} connection - IMAP connection instance
|
|
136
|
+
* @param {number} [maxIdleTime] - Maximum time in milliseconds to stay in IDLE before restarting
|
|
137
|
+
* @returns {Promise<void|boolean|undefined>} Void on success, false on failure, or undefined if not in SELECTED state
|
|
138
|
+
*/
|
|
113
139
|
module.exports = async (connection, maxIdleTime) => {
|
|
114
140
|
if (connection.state !== connection.states.SELECTED) {
|
|
115
141
|
// nothing to do here
|
|
116
142
|
return;
|
|
117
143
|
}
|
|
118
144
|
|
|
145
|
+
// If server supports IDLE (RFC 2177), use it for real-time push notifications.
|
|
146
|
+
// Otherwise, fall back to periodic polling with NOOP/STATUS/SELECT.
|
|
119
147
|
if (connection.capabilities.has('IDLE')) {
|
|
120
148
|
let idleTimer;
|
|
121
149
|
let stillIdling = false;
|
|
150
|
+
// IDLE loop: runs IDLE, and if maxIdleTime is reached, breaks and restarts
|
|
151
|
+
// to keep the connection alive (some servers drop long-running IDLEs).
|
|
122
152
|
let runIdleLoop = async () => {
|
|
123
153
|
if (maxIdleTime) {
|
|
124
154
|
idleTimer = setTimeout(() => {
|
|
@@ -143,13 +173,15 @@ module.exports = async (connection, maxIdleTime) => {
|
|
|
143
173
|
return runIdleLoop();
|
|
144
174
|
}
|
|
145
175
|
|
|
176
|
+
// Fallback for servers without IDLE support: poll at regular intervals using
|
|
177
|
+
// NOOP (default), STATUS, or SELECT depending on missingIdleCommand config.
|
|
146
178
|
let idleTimer;
|
|
147
179
|
return new Promise(resolve => {
|
|
148
180
|
if (!connection.currentSelectCommand) {
|
|
149
181
|
return resolve();
|
|
150
182
|
}
|
|
151
183
|
|
|
152
|
-
//
|
|
184
|
+
// Set up preCheck so other commands can break the polling loop
|
|
153
185
|
connection.preCheck = async () => {
|
|
154
186
|
connection.preCheck = false; // unset itself
|
|
155
187
|
clearTimeout(idleTimer);
|
|
@@ -160,6 +192,9 @@ module.exports = async (connection, maxIdleTime) => {
|
|
|
160
192
|
|
|
161
193
|
let selectCommand = connection.currentSelectCommand;
|
|
162
194
|
|
|
195
|
+
// Run one polling check. The method used depends on configuration:
|
|
196
|
+
// SELECT re-selects the mailbox (may detect changes), STATUS queries mailbox counters,
|
|
197
|
+
// NOOP is the simplest but relies on server pushing untagged responses.
|
|
163
198
|
let idleCheck = async () => {
|
|
164
199
|
let response;
|
|
165
200
|
switch (connection.missingIdleCommand) {
|