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/.ncurc.js
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.9](https://github.com/postalsys/imapflow/compare/v1.2.8...v1.2.9) (2026-02-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* trigger build ([fd7a4c2](https://github.com/postalsys/imapflow/commit/fd7a4c2012d0b7742c45a2aced2d05da8401aa9a))
|
|
9
|
+
|
|
3
10
|
## [1.2.8](https://github.com/postalsys/imapflow/compare/v1.2.7...v1.2.8) (2026-01-28)
|
|
4
11
|
|
|
5
12
|
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ ImapFlow is a modern and easy-to-use IMAP client library for Node.js.
|
|
|
7
7
|
|
|
8
8
|
The focus for ImapFlow is to provide easy to use API over IMAP. Using ImapFlow does not expect knowledge about specific IMAP details. A general understanding is good enough.
|
|
9
9
|
|
|
10
|
-
IMAP extensions are handled in the background, so, for example, you can always request `labels` value from a
|
|
10
|
+
IMAP extensions are handled in the background, so, for example, you can always request `labels` value from a `fetch()` call, but if the IMAP server does not support `X-GM-EXT-1` extension, then `labels` value is not included in the response.
|
|
11
11
|
|
|
12
12
|
## Source
|
|
13
13
|
|
|
@@ -100,10 +100,10 @@ await client.connect();
|
|
|
100
100
|
|
|
101
101
|
## Documentation
|
|
102
102
|
|
|
103
|
-
[API reference](https://imapflow.com/
|
|
103
|
+
[API reference](https://imapflow.com/docs/api/imapflow-client).
|
|
104
104
|
|
|
105
105
|
## License
|
|
106
106
|
|
|
107
|
-
© 2020-
|
|
107
|
+
© 2020-2025 Postal Systems OÜ
|
|
108
108
|
|
|
109
109
|
Licensed under **MIT-license**
|
package/lib/charsets.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// Subset of the IANA Character Sets registry (https://www.iana.org/assignments/character-sets/).
|
|
4
|
+
// Used to validate and resolve charset names found in MIME Content-Type parameters.
|
|
5
|
+
// This list covers the most commonly encountered charsets in email messages.
|
|
3
6
|
const CHARACTER_SETS = [
|
|
4
7
|
'US-ASCII',
|
|
5
8
|
'ISO-8859-1',
|
|
@@ -262,6 +265,14 @@ const CHARACTER_SETS = [
|
|
|
262
265
|
|
|
263
266
|
const CHARSET_MAP = new Map();
|
|
264
267
|
|
|
268
|
+
// Build a lookup map with normalized keys for fuzzy charset resolution.
|
|
269
|
+
// Normalization strategy:
|
|
270
|
+
// 1. Strip all underscores, hyphens, and spaces, then lowercase (e.g., "ISO-8859-1" -> "iso88591")
|
|
271
|
+
// 2. Create additional aliases for common alternative prefixes:
|
|
272
|
+
// - "windows" -> "win" (e.g., "windows1252" also matches as "win1252")
|
|
273
|
+
// - "usascii" -> "ascii" (common shorthand)
|
|
274
|
+
// - "iso8859" -> "latin" (e.g., "iso88591" also matches as "latin1")
|
|
275
|
+
// This handles the many variant spellings found in real-world email headers.
|
|
265
276
|
CHARACTER_SETS.forEach(entry => {
|
|
266
277
|
let key = entry.replace(/[_-\s]/g, '').toLowerCase();
|
|
267
278
|
let modifiedKey = key
|
|
@@ -274,6 +285,10 @@ CHARACTER_SETS.forEach(entry => {
|
|
|
274
285
|
}
|
|
275
286
|
});
|
|
276
287
|
|
|
288
|
+
// Resolves a charset name to its canonical IANA form using case-insensitive,
|
|
289
|
+
// symbol-stripping normalization. For example, "WIN-1252", "windows_1252",
|
|
290
|
+
// and "WINDOWS-1252" all resolve to "windows-1252".
|
|
291
|
+
// Returns null if the charset is not recognized.
|
|
277
292
|
module.exports.resolveCharset = charset => {
|
|
278
293
|
let key = charset.replace(/[_-\s]/g, '').toLowerCase();
|
|
279
294
|
if (CHARSET_MAP.has(key)) {
|
package/lib/commands/append.js
CHANGED
|
@@ -2,7 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
const { formatFlag, canUseFlag, formatDateTime, normalizePath, encodePath, comparePaths, enhanceCommandError } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Appends a message to a mailbox.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {string} destination - Destination mailbox path
|
|
10
|
+
* @param {Buffer|string} content - Message content (RFC 822 format)
|
|
11
|
+
* @param {string|string[]} [flags] - Message flags to set on the appended message
|
|
12
|
+
* @param {Date|string} [idate] - Internal date to set for the message
|
|
13
|
+
* @returns {Promise<{destination: string, path?: string, uid?: number, uidValidity?: BigInt, seq?: number}|undefined>} Append result with UID info if available, or undefined if preconditions not met
|
|
14
|
+
* @throws {Error} If the APPEND command fails or message exceeds APPENDLIMIT
|
|
15
|
+
*/
|
|
6
16
|
module.exports = async (connection, destination, content, flags, idate) => {
|
|
7
17
|
if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state) || !destination) {
|
|
8
18
|
// nothing to do here
|
|
@@ -13,6 +23,8 @@ module.exports = async (connection, destination, content, flags, idate) => {
|
|
|
13
23
|
content = Buffer.from(content);
|
|
14
24
|
}
|
|
15
25
|
|
|
26
|
+
// APPENDLIMIT capability (RFC 7889): server may advertise the maximum message
|
|
27
|
+
// size it accepts. Check before sending to avoid a wasted round-trip.
|
|
16
28
|
if (connection.capabilities.has('APPENDLIMIT')) {
|
|
17
29
|
let appendLimit = connection.capabilities.get('APPENDLIMIT');
|
|
18
30
|
if (typeof appendLimit === 'number' && appendLimit < content.length) {
|
|
@@ -24,28 +36,36 @@ module.exports = async (connection, destination, content, flags, idate) => {
|
|
|
24
36
|
|
|
25
37
|
destination = normalizePath(connection, destination);
|
|
26
38
|
|
|
39
|
+
// If appending to the currently selected mailbox, we can listen for the
|
|
40
|
+
// untagged EXISTS response to capture the new message's sequence number.
|
|
27
41
|
let expectExists = comparePaths(connection, connection.mailbox.path, destination);
|
|
28
42
|
|
|
43
|
+
// Validate and format flags. Only flags allowed by the mailbox's permanentFlags are included.
|
|
29
44
|
flags = (Array.isArray(flags) ? flags : [].concat(flags || []))
|
|
30
45
|
.map(flag => flag && formatFlag(flag.toString()))
|
|
31
46
|
.filter(flag => flag && canUseFlag(connection.mailbox, flag));
|
|
32
47
|
|
|
48
|
+
// APPEND command format: APPEND <mailbox> [<flags>] [<date-time>] <literal>
|
|
33
49
|
let attributes = [{ type: 'ATOM', value: encodePath(connection, destination) }];
|
|
34
50
|
|
|
51
|
+
// Internal date: the date the server should record for this message.
|
|
52
|
+
// Must be quoted (STRING type) per the IMAP date-time grammar.
|
|
35
53
|
idate = idate ? formatDateTime(idate) : false;
|
|
36
54
|
|
|
55
|
+
// Flags and date are optional; flags must come before date if both are present
|
|
37
56
|
if (flags.length || idate) {
|
|
38
57
|
attributes.push(flags.map(flag => ({ type: 'ATOM', value: flag })));
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
if (idate) {
|
|
42
|
-
attributes.push({ type: 'STRING', value: idate });
|
|
61
|
+
attributes.push({ type: 'STRING', value: idate });
|
|
43
62
|
}
|
|
44
63
|
|
|
64
|
+
// BINARY extension (RFC 3516): if the message content contains NUL bytes,
|
|
65
|
+
// use literal8 syntax (~{size}\r\n) instead of regular literal ({size}\r\n).
|
|
66
|
+
// Regular literals cannot contain NUL bytes per the IMAP grammar.
|
|
45
67
|
let isLiteral8 = false;
|
|
46
68
|
if (connection.capabilities.has('BINARY') && !connection.disableBinary) {
|
|
47
|
-
// Value is literal8 if it contains NULL bytes. The server must support the BINARY extension
|
|
48
|
-
// and if it does not then send the value as a regular literal and hope for the best
|
|
49
69
|
isLiteral8 = content.indexOf(Buffer.from([0])) >= 0;
|
|
50
70
|
}
|
|
51
71
|
|
|
@@ -56,72 +76,58 @@ module.exports = async (connection, destination, content, flags, idate) => {
|
|
|
56
76
|
map.path = connection.mailbox.path;
|
|
57
77
|
}
|
|
58
78
|
|
|
79
|
+
// Handler for untagged EXISTS: captures the new message count which gives
|
|
80
|
+
// us the sequence number of the appended message (it's the latest message).
|
|
81
|
+
const handleExistsUpdate = untagged => {
|
|
82
|
+
map.seq = Number(untagged.command);
|
|
83
|
+
|
|
84
|
+
// Update the connection's mailbox state and emit 'exists' event if the
|
|
85
|
+
// count changed (notifies listeners about the new message).
|
|
86
|
+
if (expectExists) {
|
|
87
|
+
let prevCount = connection.mailbox.exists;
|
|
88
|
+
if (map.seq !== prevCount) {
|
|
89
|
+
connection.mailbox.exists = map.seq;
|
|
90
|
+
connection.emit('exists', {
|
|
91
|
+
path: connection.mailbox.path,
|
|
92
|
+
count: map.seq,
|
|
93
|
+
prevCount
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
59
99
|
let response;
|
|
60
100
|
try {
|
|
61
101
|
response = await connection.exec('APPEND', attributes, {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
EXISTS: async untagged => {
|
|
65
|
-
map.seq = Number(untagged.command);
|
|
66
|
-
|
|
67
|
-
if (expectExists) {
|
|
68
|
-
let prevCount = connection.mailbox.exists;
|
|
69
|
-
if (map.seq !== prevCount) {
|
|
70
|
-
connection.mailbox.exists = map.seq;
|
|
71
|
-
connection.emit('exists', {
|
|
72
|
-
path: connection.mailbox.path,
|
|
73
|
-
count: map.seq,
|
|
74
|
-
prevCount
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
: false
|
|
102
|
+
// Only listen for EXISTS if we're appending to the currently selected mailbox
|
|
103
|
+
untagged: expectExists ? { EXISTS: handleExistsUpdate } : false
|
|
81
104
|
});
|
|
82
105
|
|
|
106
|
+
// UIDPLUS (RFC 4315): the server may include APPENDUID response code in
|
|
107
|
+
// the tagged OK. Format: [APPENDUID <uidValidity> <uid>]
|
|
83
108
|
let section = response.response.attributes && response.response.attributes[0] && response.response.attributes[0].section;
|
|
84
109
|
if (section && section.length) {
|
|
85
110
|
let responseCode = section[0] && typeof section[0].value === 'string' ? section[0].value : '';
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
map.uid = uid;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
break;
|
|
111
|
+
if (responseCode.toUpperCase() === 'APPENDUID') {
|
|
112
|
+
let uidValidity = section[1] && typeof section[1].value === 'string' && !isNaN(section[1].value) ? BigInt(section[1].value) : false;
|
|
113
|
+
let uid = section[2] && typeof section[2].value === 'string' && !isNaN(section[2].value) ? Number(section[2].value) : false;
|
|
114
|
+
if (uidValidity) {
|
|
115
|
+
map.uidValidity = uidValidity;
|
|
116
|
+
}
|
|
117
|
+
if (uid) {
|
|
118
|
+
map.uid = uid;
|
|
119
|
+
}
|
|
99
120
|
}
|
|
100
121
|
}
|
|
101
122
|
|
|
102
123
|
response.next();
|
|
103
124
|
|
|
125
|
+
// If we didn't get an EXISTS during APPEND (some servers don't send it
|
|
126
|
+
// until the next command), issue a NOOP to flush pending notifications.
|
|
104
127
|
if (expectExists && !map.seq) {
|
|
105
|
-
// try to use NOOP to get the new sequence number
|
|
106
128
|
try {
|
|
107
129
|
response = await connection.exec('NOOP', false, {
|
|
108
|
-
untagged: {
|
|
109
|
-
EXISTS: async untagged => {
|
|
110
|
-
map.seq = Number(untagged.command);
|
|
111
|
-
|
|
112
|
-
if (expectExists) {
|
|
113
|
-
let prevCount = connection.mailbox.exists;
|
|
114
|
-
if (map.seq !== prevCount) {
|
|
115
|
-
connection.mailbox.exists = map.seq;
|
|
116
|
-
connection.emit('exists', {
|
|
117
|
-
path: connection.mailbox.path,
|
|
118
|
-
count: map.seq,
|
|
119
|
-
prevCount
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
},
|
|
130
|
+
untagged: { EXISTS: handleExistsUpdate },
|
|
125
131
|
comment: 'Sequence not found from APPEND output'
|
|
126
132
|
});
|
|
127
133
|
response.next();
|
|
@@ -130,6 +136,8 @@ module.exports = async (connection, destination, content, flags, idate) => {
|
|
|
130
136
|
}
|
|
131
137
|
}
|
|
132
138
|
|
|
139
|
+
// If we have a sequence number but no UID (server doesn't support UIDPLUS),
|
|
140
|
+
// look up the UID via SEARCH to provide a consistent result to the caller.
|
|
133
141
|
if (map.seq && !map.uid) {
|
|
134
142
|
let list = await connection.search({ seq: map.seq }, { uid: true });
|
|
135
143
|
if (list && list.length) {
|
|
@@ -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 {
|