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
|
@@ -2,18 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
const { getStatusCode, getErrorText } = require('../tools.js');
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Handles authentication errors by enriching the error object with server response details.
|
|
7
|
+
*
|
|
8
|
+
* @param {Error} err - The original authentication error
|
|
9
|
+
* @param {Object} [errorResponse] - Optional OAuth error response from the server
|
|
10
|
+
* @throws {Error} Always throws the enriched error
|
|
11
|
+
*/
|
|
12
|
+
async function handleAuthError(err, errorResponse) {
|
|
13
|
+
let errorCode = getStatusCode(err.response);
|
|
14
|
+
if (errorCode) {
|
|
15
|
+
err.serverResponseCode = errorCode;
|
|
16
|
+
}
|
|
17
|
+
err.authenticationFailed = true;
|
|
18
|
+
err.response = await getErrorText(err.response);
|
|
19
|
+
if (errorResponse) {
|
|
20
|
+
err.oauthError = errorResponse;
|
|
21
|
+
}
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Authenticates using OAuth (OAUTHBEARER or XOAUTH2).
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} connection - IMAP connection instance
|
|
29
|
+
* @param {string} username - The username to authenticate with
|
|
30
|
+
* @param {string} accessToken - The OAuth2 access token
|
|
31
|
+
* @returns {Promise<string>} The authenticated username
|
|
32
|
+
* @throws {Error} If authentication fails
|
|
33
|
+
*/
|
|
5
34
|
async function authOauth(connection, username, accessToken) {
|
|
6
35
|
let oauthbearer;
|
|
7
36
|
let command;
|
|
8
37
|
let breaker;
|
|
9
38
|
|
|
10
39
|
if (connection.capabilities.has('AUTH=OAUTHBEARER')) {
|
|
40
|
+
// OAUTHBEARER payload per RFC 7628: fields separated by \x01 (SASL GS2 framing).
|
|
41
|
+
// Format: "n,a=<user>," \x01 "host=..." \x01 "port=..." \x01 "auth=Bearer <token>" \x01 \x01
|
|
42
|
+
// The trailing empty strings produce the required double-\x01 terminator.
|
|
11
43
|
oauthbearer = [`n,a=${username},`, `host=${connection.servername}`, `port=993`, `auth=Bearer ${accessToken}`, '', ''].join('\x01');
|
|
12
44
|
command = 'OAUTHBEARER';
|
|
45
|
+
// "AQ==" is base64 for \x01 -- sent as the error continuation to abort the SASL exchange
|
|
13
46
|
breaker = 'AQ==';
|
|
14
47
|
} else if (connection.capabilities.has('AUTH=XOAUTH') || connection.capabilities.has('AUTH=XOAUTH2')) {
|
|
48
|
+
// XOAUTH2 payload (Google-specific): simpler format, also \x01-delimited.
|
|
49
|
+
// Format: "user=<user>" \x01 "auth=Bearer <token>" \x01 \x01
|
|
15
50
|
oauthbearer = [`user=${username}`, `auth=Bearer ${accessToken}`, '', ''].join('\x01');
|
|
16
51
|
command = 'XOAUTH2';
|
|
52
|
+
// Empty breaker: XOAUTH2 expects an empty response to abort the SASL exchange
|
|
17
53
|
breaker = '';
|
|
18
54
|
}
|
|
19
55
|
|
|
@@ -26,6 +62,8 @@ async function authOauth(connection, username, accessToken) {
|
|
|
26
62
|
{ type: 'ATOM', value: Buffer.from(oauthbearer).toString('base64'), sensitive: true }
|
|
27
63
|
],
|
|
28
64
|
{
|
|
65
|
+
// Server sends a "+" continuation if auth fails, with a base64 JSON error payload.
|
|
66
|
+
// We decode it for diagnostics, then send the breaker to terminate the exchange.
|
|
29
67
|
onPlusTag: async resp => {
|
|
30
68
|
if (resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT') {
|
|
31
69
|
try {
|
|
@@ -46,46 +84,43 @@ async function authOauth(connection, username, accessToken) {
|
|
|
46
84
|
|
|
47
85
|
return username;
|
|
48
86
|
} catch (err) {
|
|
49
|
-
|
|
50
|
-
if (errorCode) {
|
|
51
|
-
err.serverResponseCode = errorCode;
|
|
52
|
-
}
|
|
53
|
-
err.authenticationFailed = true;
|
|
54
|
-
err.response = await getErrorText(err.response);
|
|
55
|
-
if (errorResponse) {
|
|
56
|
-
err.oauthError = errorResponse;
|
|
57
|
-
}
|
|
58
|
-
throw err;
|
|
87
|
+
await handleAuthError(err, errorResponse);
|
|
59
88
|
}
|
|
60
89
|
}
|
|
61
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Authenticates using the SASL LOGIN mechanism.
|
|
93
|
+
*
|
|
94
|
+
* @param {Object} connection - IMAP connection instance
|
|
95
|
+
* @param {string} username - The username to authenticate with
|
|
96
|
+
* @param {string} password - The password to authenticate with
|
|
97
|
+
* @returns {Promise<string>} The authenticated username
|
|
98
|
+
* @throws {Error} If authentication fails
|
|
99
|
+
*/
|
|
62
100
|
async function authLogin(connection, username, password) {
|
|
63
101
|
let errorResponse = false;
|
|
64
102
|
try {
|
|
103
|
+
// SASL LOGIN is a challenge-response mechanism: the server sends base64-encoded
|
|
104
|
+
// prompts ("Username:" and "Password:") and the client responds with base64-encoded values.
|
|
65
105
|
let response = await connection.exec('AUTHENTICATE', [{ type: 'ATOM', value: 'LOGIN' }], {
|
|
66
106
|
onPlusTag: async resp => {
|
|
67
107
|
if (resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT') {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
default: {
|
|
86
|
-
let error = new Error(`Unknown LOGIN question "${question}"`);
|
|
87
|
-
throw error;
|
|
88
|
-
}
|
|
108
|
+
// Decode the server's base64 challenge to determine what it's asking for.
|
|
109
|
+
// Strip trailing colons and null bytes (\x00) that some servers append to the prompt.
|
|
110
|
+
let question = Buffer.from(resp.attributes[0].value, 'base64')
|
|
111
|
+
.toString()
|
|
112
|
+
.toLowerCase()
|
|
113
|
+
.replace(/[:\x00]*$/, ''); // eslint-disable-line no-control-regex
|
|
114
|
+
|
|
115
|
+
if (question === 'username' || question === 'user name') {
|
|
116
|
+
let encodedUsername = Buffer.from(username).toString('base64');
|
|
117
|
+
connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN` });
|
|
118
|
+
connection.write(encodedUsername);
|
|
119
|
+
} else if (question === 'password') {
|
|
120
|
+
connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN` });
|
|
121
|
+
connection.write(Buffer.from(password).toString('base64'));
|
|
122
|
+
} else {
|
|
123
|
+
throw new Error(`Unknown LOGIN question "${question}"`);
|
|
89
124
|
}
|
|
90
125
|
}
|
|
91
126
|
}
|
|
@@ -97,19 +132,20 @@ async function authLogin(connection, username, password) {
|
|
|
97
132
|
|
|
98
133
|
return username;
|
|
99
134
|
} catch (err) {
|
|
100
|
-
|
|
101
|
-
if (errorCode) {
|
|
102
|
-
err.serverResponseCode = errorCode;
|
|
103
|
-
}
|
|
104
|
-
err.authenticationFailed = true;
|
|
105
|
-
err.response = await getErrorText(err.response);
|
|
106
|
-
if (errorResponse) {
|
|
107
|
-
err.oauthError = errorResponse;
|
|
108
|
-
}
|
|
109
|
-
throw err;
|
|
135
|
+
await handleAuthError(err, errorResponse);
|
|
110
136
|
}
|
|
111
137
|
}
|
|
112
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Authenticates using the SASL PLAIN mechanism.
|
|
141
|
+
*
|
|
142
|
+
* @param {Object} connection - IMAP connection instance
|
|
143
|
+
* @param {string} username - The authentication identity (authcid)
|
|
144
|
+
* @param {string} password - The password to authenticate with
|
|
145
|
+
* @param {string} [authzid] - Optional authorization identity to impersonate
|
|
146
|
+
* @returns {Promise<string>} The authorized identity (authzid if provided, otherwise username)
|
|
147
|
+
* @throws {Error} If authentication fails
|
|
148
|
+
*/
|
|
113
149
|
async function authPlain(connection, username, password, authzid) {
|
|
114
150
|
let errorResponse = false;
|
|
115
151
|
try {
|
|
@@ -133,26 +169,37 @@ async function authPlain(connection, username, password, authzid) {
|
|
|
133
169
|
// Return the identity we're authorized as (authzid if provided, otherwise username)
|
|
134
170
|
return authzid || username;
|
|
135
171
|
} catch (err) {
|
|
136
|
-
|
|
137
|
-
if (errorCode) {
|
|
138
|
-
err.serverResponseCode = errorCode;
|
|
139
|
-
}
|
|
140
|
-
err.authenticationFailed = true;
|
|
141
|
-
err.response = await getErrorText(err.response);
|
|
142
|
-
if (errorResponse) {
|
|
143
|
-
err.oauthError = errorResponse;
|
|
144
|
-
}
|
|
145
|
-
throw err;
|
|
172
|
+
await handleAuthError(err, errorResponse);
|
|
146
173
|
}
|
|
147
174
|
}
|
|
148
175
|
|
|
149
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Authenticates user using the best available method.
|
|
178
|
+
*
|
|
179
|
+
* @param {Object} connection - IMAP connection instance
|
|
180
|
+
* @param {string} username - The username to authenticate with
|
|
181
|
+
* @param {Object} credentials - Authentication credentials
|
|
182
|
+
* @param {string} [credentials.accessToken] - OAuth2 access token for OAUTHBEARER/XOAUTH2 authentication
|
|
183
|
+
* @param {string} [credentials.password] - Password for PLAIN or LOGIN authentication
|
|
184
|
+
* @param {string} [credentials.loginMethod] - Force a specific login method (e.g., 'AUTH=PLAIN', 'AUTH=LOGIN')
|
|
185
|
+
* @param {string} [credentials.authzid] - Authorization identity for PLAIN authentication
|
|
186
|
+
* @returns {Promise<string|undefined>} The authenticated username, or undefined if already authenticated
|
|
187
|
+
* @throws {Error} If no supported authentication mechanism is available or if authentication fails
|
|
188
|
+
*/
|
|
150
189
|
module.exports = async (connection, username, { accessToken, password, loginMethod, authzid }) => {
|
|
151
190
|
if (connection.state !== connection.states.NOT_AUTHENTICATED) {
|
|
152
191
|
// nothing to do here
|
|
153
192
|
return;
|
|
154
193
|
}
|
|
155
194
|
|
|
195
|
+
// Authentication method selection order:
|
|
196
|
+
// 1. OAuth (OAUTHBEARER > XOAUTH2) -- preferred when an accessToken is provided,
|
|
197
|
+
// as it avoids transmitting passwords entirely.
|
|
198
|
+
// 2. SASL PLAIN -- preferred over LOGIN because it supports authzid (impersonation)
|
|
199
|
+
// and sends credentials in a single round trip.
|
|
200
|
+
// 3. SASL LOGIN -- fallback; an older challenge-response mechanism (two round trips).
|
|
201
|
+
// If loginMethod is explicitly set, it overrides the automatic capability-based selection.
|
|
202
|
+
|
|
156
203
|
if (accessToken) {
|
|
157
204
|
// AUTH=OAUTHBEARER and AUTH=XOAUTH in the context of OAuth2 or very similar so we can handle these together
|
|
158
205
|
if (connection.capabilities.has('AUTH=OAUTHBEARER') || connection.capabilities.has('AUTH=XOAUTH') || connection.capabilities.has('AUTH=XOAUTH2')) {
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Refreshes capabilities from server.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} connection - IMAP connection instance
|
|
7
|
+
* @returns {Promise<Map|boolean>} Server capabilities map, or false on failure
|
|
8
|
+
*/
|
|
9
|
+
// Capabilities are normally received and updated by the global response handler
|
|
10
|
+
// (e.g., from the server greeting or after authentication). This explicit CAPABILITY
|
|
11
|
+
// command is only needed when capabilities must be refreshed on demand, such as
|
|
12
|
+
// after STARTTLS or when the server signals a capability change.
|
|
4
13
|
module.exports = async connection => {
|
|
5
14
|
if (connection.capabilities.size && !connection.expectCapabilityUpdate) {
|
|
6
15
|
return connection.capabilities;
|
|
@@ -8,7 +17,8 @@ module.exports = async connection => {
|
|
|
8
17
|
|
|
9
18
|
let response;
|
|
10
19
|
try {
|
|
11
|
-
// untagged
|
|
20
|
+
// The actual parsing of the untagged CAPABILITY response is handled by the
|
|
21
|
+
// global handler, not here. We just trigger the server to send it.
|
|
12
22
|
response = await connection.exec('CAPABILITY');
|
|
13
23
|
|
|
14
24
|
response.next();
|
package/lib/commands/close.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Closes the currently selected mailbox.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} connection - IMAP connection instance
|
|
7
|
+
* @returns {Promise<boolean|undefined>} True on success, false on failure, or undefined if not in SELECTED state
|
|
8
|
+
*/
|
|
4
9
|
module.exports = async connection => {
|
|
5
10
|
if (connection.state !== connection.states.SELECTED) {
|
|
6
11
|
// nothing to do here
|
|
@@ -9,9 +14,14 @@ module.exports = async connection => {
|
|
|
9
14
|
|
|
10
15
|
let response;
|
|
11
16
|
try {
|
|
17
|
+
// IMAP CLOSE (RFC 3501 6.4.2): permanently removes all messages flagged \Deleted
|
|
18
|
+
// from the currently selected mailbox (implicit expunge) and deselects it.
|
|
19
|
+
// Unlike EXPUNGE, CLOSE does not send individual untagged EXPUNGE responses.
|
|
12
20
|
response = await connection.exec('CLOSE');
|
|
13
21
|
response.next();
|
|
14
22
|
|
|
23
|
+
// Transition from SELECTED back to AUTHENTICATED state.
|
|
24
|
+
// Clear mailbox metadata so subsequent operations know no mailbox is selected.
|
|
15
25
|
let currentMailbox = connection.mailbox;
|
|
16
26
|
connection.mailbox = false;
|
|
17
27
|
connection.currentSelectCommand = false;
|
package/lib/commands/compress.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Requests DEFLATE compression from the server.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} connection - IMAP connection instance
|
|
7
|
+
* @returns {Promise<boolean>} True if compression was enabled, false otherwise
|
|
8
|
+
*/
|
|
9
|
+
// COMPRESS=DEFLATE (RFC 4978): enables zlib compression on the IMAP connection
|
|
10
|
+
// to reduce bandwidth. Once enabled, all subsequent data in both directions is compressed.
|
|
4
11
|
module.exports = async connection => {
|
|
12
|
+
// Skip if the server doesn't support COMPRESS=DEFLATE, or if compression
|
|
13
|
+
// is already active (connection._inflate exists) to avoid double-compression.
|
|
5
14
|
if (!connection.capabilities.has('COMPRESS=DEFLATE') || connection._inflate) {
|
|
6
15
|
// nothing to do here
|
|
7
16
|
return false;
|
package/lib/commands/copy.js
CHANGED
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
const { normalizePath, encodePath, expandRange, enhanceCommandError } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Copies messages from the current mailbox to another mailbox.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {string} range - Message sequence number or UID range
|
|
10
|
+
* @param {string} destination - Destination mailbox path
|
|
11
|
+
* @param {Object} [options] - Copy options
|
|
12
|
+
* @param {boolean} [options.uid] - If true, use UID COPY instead of COPY
|
|
13
|
+
* @returns {Promise<{path: string, destination: string, uidValidity?: BigInt, uidMap?: Map}|boolean|undefined>} Copy result with UID mapping if available, false on failure, or undefined if preconditions not met
|
|
14
|
+
*/
|
|
6
15
|
module.exports = async (connection, range, destination, options) => {
|
|
7
16
|
if (connection.state !== connection.states.SELECTED || !range || !destination) {
|
|
8
17
|
// nothing to do here
|
|
@@ -23,18 +32,26 @@ module.exports = async (connection, range, destination, options) => {
|
|
|
23
32
|
response.next();
|
|
24
33
|
|
|
25
34
|
let map = { path: connection.mailbox.path, destination };
|
|
35
|
+
|
|
36
|
+
// UIDPLUS (RFC 4315): the server may include a COPYUID response code in the
|
|
37
|
+
// tagged OK response, providing a mapping from source UIDs to destination UIDs.
|
|
38
|
+
// Format: [COPYUID <uidValidity> <sourceUids> <destUids>]
|
|
26
39
|
let section = response.response.attributes && response.response.attributes[0] && response.response.attributes[0].section;
|
|
27
40
|
let responseCode = section && section.length && section[0] && typeof section[0].value === 'string' ? section[0].value : '';
|
|
28
41
|
switch (responseCode) {
|
|
29
42
|
case 'COPYUID':
|
|
30
43
|
{
|
|
44
|
+
// section[1] = destination mailbox UIDVALIDITY
|
|
31
45
|
let uidValidity = section[1] && typeof section[1].value === 'string' && !isNaN(section[1].value) ? BigInt(section[1].value) : false;
|
|
32
46
|
if (uidValidity) {
|
|
33
47
|
map.uidValidity = uidValidity;
|
|
34
48
|
}
|
|
35
49
|
|
|
50
|
+
// section[2] = source UID set, section[3] = destination UID set
|
|
51
|
+
// Both can be ranges (e.g., "1:3") which expandRange() converts to arrays
|
|
36
52
|
let sourceUids = section[2] && typeof section[2].value === 'string' ? expandRange(section[2].value) : false;
|
|
37
53
|
let destinationUids = section[3] && typeof section[3].value === 'string' ? expandRange(section[3].value) : false;
|
|
54
|
+
// Build a source->destination UID map for the caller to track where messages went
|
|
38
55
|
if (sourceUids && destinationUids && sourceUids.length === destinationUids.length) {
|
|
39
56
|
map.uidMap = new Map(sourceUids.map((uid, i) => [uid, destinationUids[i]]));
|
|
40
57
|
}
|
package/lib/commands/create.js
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
const { encodePath, normalizePath, getStatusCode, enhanceCommandError } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Creates a new mailbox and subscribes to it.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {string} path - Mailbox path to create
|
|
10
|
+
* @returns {Promise<{path: string, created: boolean, mailboxId?: string}|undefined>} Object with path and creation status, or undefined if preconditions not met
|
|
11
|
+
* @throws {Error} If the CREATE command fails (except when mailbox already exists)
|
|
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
|
|
@@ -18,6 +25,8 @@ module.exports = async (connection, path) => {
|
|
|
18
25
|
};
|
|
19
26
|
response = await connection.exec('CREATE', [{ type: 'ATOM', value: encodePath(connection, path) }]);
|
|
20
27
|
|
|
28
|
+
// Parse the response code section (e.g., [MAILBOXID (<id>)]) from the tagged OK response.
|
|
29
|
+
// IMAP response code attributes are structured as alternating key-value pairs.
|
|
21
30
|
let section =
|
|
22
31
|
response.response.attributes &&
|
|
23
32
|
response.response.attributes[0] &&
|
|
@@ -29,6 +38,7 @@ module.exports = async (connection, path) => {
|
|
|
29
38
|
if (section) {
|
|
30
39
|
let key;
|
|
31
40
|
section.forEach((attribute, i) => {
|
|
41
|
+
// IMAP key-value pairs: even indices (i % 2 === 0) are keys, odd indices are values
|
|
32
42
|
if (i % 2 === 0) {
|
|
33
43
|
key = attribute && typeof attribute.value === 'string' ? attribute.value : false;
|
|
34
44
|
return;
|
|
@@ -55,12 +65,15 @@ module.exports = async (connection, path) => {
|
|
|
55
65
|
map.created = true;
|
|
56
66
|
response.next();
|
|
57
67
|
|
|
58
|
-
//
|
|
68
|
+
// Auto-subscribe after creation so the new mailbox appears in LSUB listings
|
|
69
|
+
// and is visible to clients that only show subscribed folders.
|
|
59
70
|
await connection.run('SUBSCRIBE', path);
|
|
60
71
|
|
|
61
72
|
return map;
|
|
62
73
|
} catch (err) {
|
|
63
74
|
let errorCode = getStatusCode(err.response);
|
|
75
|
+
// ALREADYEXISTS (RFC 5530) means the mailbox already exists on the server.
|
|
76
|
+
// This is not a true error -- we return created:false to indicate nothing was created.
|
|
64
77
|
if (errorCode === 'ALREADYEXISTS') {
|
|
65
78
|
// no need to do anything, mailbox already exists
|
|
66
79
|
return {
|
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
|