imapflow 1.3.3 → 1.3.5
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +16 -0
- package/SECURITY.md +88 -0
- package/SECURITY.txt +27 -0
- package/lib/commands/store.js +1 -1
- package/lib/handler/imap-stream.js +32 -2
- package/lib/handler/token-parser.js +7 -0
- package/lib/imap-flow.d.ts +6 -0
- package/lib/imap-flow.js +52 -9
- package/lib/tools.js +8 -2
- package/package.json +6 -6
- package/test/commands-integration-test.js +45 -0
- package/test/connection-edge-cases-test.js +68 -0
- package/test/imap-stream-edge-cases-test.js +68 -0
- package/test/token-parser-test.js +18 -0
- package/test/tools-test.js +69 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.5](https://github.com/postalsys/imapflow/compare/v1.3.4...v1.3.5) (2026-06-01)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* correct flag removal, literal parsing, path normalization, and harden the parser ([d15c053](https://github.com/postalsys/imapflow/commit/d15c0536f4b7db2ebca5e258fc885da0d194b5fd))
|
|
9
|
+
* prevent host process crash from socket errors after unbind() when COMPRESS=DEFLATE is active ([e30fca1](https://github.com/postalsys/imapflow/commit/e30fca1a42a4d2ab6f7a6a766effafe3df6b1aff))
|
|
10
|
+
|
|
11
|
+
## [1.3.4](https://github.com/postalsys/imapflow/compare/v1.3.3...v1.3.4) (2026-05-29)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* bumped deps ([593b805](https://github.com/postalsys/imapflow/commit/593b805ed88d62bf7200a1a2ef081ce49f9162c0))
|
|
17
|
+
* handle THREADID NIL in FETCH response ([#353](https://github.com/postalsys/imapflow/issues/353)) ([40318d6](https://github.com/postalsys/imapflow/commit/40318d69b866989d6e84f1d0652d118920a3162e))
|
|
18
|
+
|
|
3
19
|
## [1.3.3](https://github.com/postalsys/imapflow/compare/v1.3.2...v1.3.3) (2026-04-29)
|
|
4
20
|
|
|
5
21
|
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
ImapFlow is a Node.js IMAP client library. It opens TLS/cleartext connections to
|
|
4
|
+
IMAP servers, sends account credentials, and parses untrusted protocol responses
|
|
5
|
+
from those servers. Because it handles credentials and processes data from
|
|
6
|
+
remote servers that may be malicious or buggy, we take security reports
|
|
7
|
+
seriously and aim to respond quickly.
|
|
8
|
+
|
|
9
|
+
## Supported Versions
|
|
10
|
+
|
|
11
|
+
Security fixes are released only against the latest version. We do not backport
|
|
12
|
+
patches to older releases - upgrading to the current release line is the
|
|
13
|
+
supported way to receive security updates.
|
|
14
|
+
|
|
15
|
+
| Version | Supported |
|
|
16
|
+
| ------- | ------------------ |
|
|
17
|
+
| 1.x | :white_check_mark: |
|
|
18
|
+
| < 1.0 | :x: |
|
|
19
|
+
|
|
20
|
+
If you are on an older version, please upgrade. See the release notes at
|
|
21
|
+
<https://github.com/postalsys/imapflow/releases> before updating.
|
|
22
|
+
|
|
23
|
+
## Reporting a Vulnerability
|
|
24
|
+
|
|
25
|
+
**Please do not report security vulnerabilities through public GitHub issues,
|
|
26
|
+
pull requests, or discussions.**
|
|
27
|
+
|
|
28
|
+
Report privately through one of the following channels:
|
|
29
|
+
|
|
30
|
+
1. **GitHub Security Advisories (preferred).** Open a private report at
|
|
31
|
+
<https://github.com/postalsys/imapflow/security/advisories/new>. This keeps
|
|
32
|
+
the discussion private until a fix is published and lets us credit you.
|
|
33
|
+
2. **Email.** Send details to **andris@postalsys.com** (the contact listed in
|
|
34
|
+
[`SECURITY.txt`](SECURITY.txt)). Encrypt sensitive details with the PGP key
|
|
35
|
+
referenced there if possible.
|
|
36
|
+
|
|
37
|
+
When reporting, please include as much of the following as you can:
|
|
38
|
+
|
|
39
|
+
- The affected version(s) and environment (ImapFlow version, Node.js version,
|
|
40
|
+
OS).
|
|
41
|
+
- The component involved (e.g. the IMAP response stream/parser, literal and line
|
|
42
|
+
handling, TLS/STARTTLS upgrade, credential handling, the command compiler, or
|
|
43
|
+
the public client API).
|
|
44
|
+
- A clear description of the issue and its impact (e.g. memory exhaustion or
|
|
45
|
+
denial of service from a malicious server, parser crash, credential
|
|
46
|
+
disclosure, TLS verification bypass, injection into the IMAP command stream,
|
|
47
|
+
prototype pollution, information disclosure).
|
|
48
|
+
- A minimal proof of concept or reproduction steps - ideally a sample server
|
|
49
|
+
response or a short script that triggers the issue.
|
|
50
|
+
- Any suggested remediation, if you have one.
|
|
51
|
+
|
|
52
|
+
We are a small team, so there is no guaranteed response time - sometimes reports
|
|
53
|
+
are handled within hours, sometimes they take longer. Accepted issues are fixed
|
|
54
|
+
in a new release and coordinated through a GitHub Security Advisory, and
|
|
55
|
+
reporters who wish to be named are credited.
|
|
56
|
+
|
|
57
|
+
## CVEs
|
|
58
|
+
|
|
59
|
+
We track and disclose vulnerabilities through GitHub Security Advisories. We do
|
|
60
|
+
not request or manage CVE identifiers ourselves. If you need a CVE assigned for a
|
|
61
|
+
reported issue, please request one yourself - for example, through GitHub's own
|
|
62
|
+
CVE request flow on the published advisory, or another CNA.
|
|
63
|
+
|
|
64
|
+
## Scope
|
|
65
|
+
|
|
66
|
+
In scope: the ImapFlow library source in this repository - the IMAP response
|
|
67
|
+
stream and token parser (including handling of hostile or malformed server
|
|
68
|
+
responses, literals, line lengths, and resource/DoS bounds), the TLS and
|
|
69
|
+
STARTTLS upgrade path and certificate handling, credential handling during
|
|
70
|
+
authentication, the command compiler that builds outgoing IMAP commands, and the
|
|
71
|
+
public client API.
|
|
72
|
+
|
|
73
|
+
Out of scope:
|
|
74
|
+
|
|
75
|
+
- Vulnerabilities in your own application code that uses ImapFlow.
|
|
76
|
+
- Misconfiguration of your usage - for example, disabling TLS certificate
|
|
77
|
+
verification (`tls.rejectUnauthorized: false`), connecting over cleartext when
|
|
78
|
+
TLS is available, or passing untrusted input directly into mailbox paths or
|
|
79
|
+
command arguments without validation.
|
|
80
|
+
- Vulnerabilities in the IMAP servers you connect to, or in third-party
|
|
81
|
+
dependencies (please report those to their respective maintainers; we will
|
|
82
|
+
upgrade once a fix is available).
|
|
83
|
+
- Issues that require an already-compromised host or local access to the machine
|
|
84
|
+
running ImapFlow.
|
|
85
|
+
- Social-engineering reports and theoretical issues without a demonstrated,
|
|
86
|
+
concrete impact.
|
|
87
|
+
|
|
88
|
+
Thank you for helping keep ImapFlow and its users safe.
|
package/SECURITY.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-----BEGIN PGP SIGNED MESSAGE-----
|
|
2
|
+
Hash: SHA256
|
|
3
|
+
|
|
4
|
+
Contact: https://github.com/postalsys/imapflow/security/advisories/new
|
|
5
|
+
Contact: mailto:andris@postalsys.com
|
|
6
|
+
Expires: 2027-06-01T00:00:00.000Z
|
|
7
|
+
Encryption: https://keys.openpgp.org/vks/v1/by-fingerprint/5D952A46E1D8C931F6364E01DC6C83F4D584D364
|
|
8
|
+
Preferred-Languages: en, et
|
|
9
|
+
Canonical: https://github.com/postalsys/imapflow/blob/master/SECURITY.txt
|
|
10
|
+
Policy: https://github.com/postalsys/imapflow/blob/master/SECURITY.md
|
|
11
|
+
-----BEGIN PGP SIGNATURE-----
|
|
12
|
+
|
|
13
|
+
iQJiBAEBCABMFiEEXZUqRuHYyTH2Nk4B3GyD9NWE02QFAmodTFwbFIAAAAAABAAO
|
|
14
|
+
bWFudTIsMi41KzEuMTIsMCwzEhxhbmRyaXNAcmVpbm1hbi5ldQAKCRDcbIP01YTT
|
|
15
|
+
ZGcgD/42Wdd07wEclXDm/lM0Ax+aX9sPjwV33DAICqhDoILf8yG4yPkCRuuTCayw
|
|
16
|
+
QLTB1pCi+tk8xVs9TH88bDwinfGVqzbmUyHSa2h32TDx/0/b+rg2LO2Ru8JtJkX8
|
|
17
|
+
CMY8/UhkouaQnHKDjkbLniF8HpQsBzOiPzvkfX91BY4HBwzE8aCC8MXC0zyjBkQX
|
|
18
|
+
dqVJ/fYMQKPkblKemnrMB/XM1jui2M7vgzpqilLcZqYv5oNFQhgpd+JOEVKMDUZv
|
|
19
|
+
0WaLrFWRegFvFApS/cIvzu5RLIhDWuL0ao577Cpdj1QE7nznfbBqBfGEzZotOShs
|
|
20
|
+
CdzdGloSt9C1gVLWItsncXByXVuNrF8l67hoOAnRwO4c8PKg3b4CvzBelmDiCWrS
|
|
21
|
+
73LGWmFwp1luyLrfZREUYsF5hqHCr6ULmDEAVp0MRtzxL56YjiS20d1sa9v96P4O
|
|
22
|
+
KFCvOT5LbCK/KqAFzhRzRXd3sawt5SXcR2FGgjFrRYNriyHNSk0k1h5IYv1kNoPb
|
|
23
|
+
VN+DxsOhORd2EsqqOBqHoEES472OOsmLTYa0A5z+zyA0IcaY5hXsMDrX9OMIUHUW
|
|
24
|
+
4JAOviDYyejefVJd8SsLrAmQ82p1jPY0LnuIyLz/sXDoe7QduNZIFKoUYw3KVybM
|
|
25
|
+
y0X7yP92/buzcZY41FOQT8dBFGDzboWQo+RPEh16qXEV75ss2A==
|
|
26
|
+
=Wlrz
|
|
27
|
+
-----END PGP SIGNATURE-----
|
package/lib/commands/store.js
CHANGED
|
@@ -57,7 +57,7 @@ module.exports = async (connection, range, flags, options) => {
|
|
|
57
57
|
.map(flag => {
|
|
58
58
|
flag = formatFlag(flag);
|
|
59
59
|
|
|
60
|
-
if (!canUseFlag(connection.mailbox, flag) && operation !== 'remove') {
|
|
60
|
+
if (!canUseFlag(connection.mailbox, flag) && options.operation !== 'remove') {
|
|
61
61
|
return false;
|
|
62
62
|
}
|
|
63
63
|
|
|
@@ -16,6 +16,11 @@ const CURLY_CLOSE = 0x7d;
|
|
|
16
16
|
// Maximum allowed literal size: 1GB (1073741824 bytes)
|
|
17
17
|
const MAX_LITERAL_SIZE = 1024 * 1024 * 1024;
|
|
18
18
|
|
|
19
|
+
// Default maximum length of a single line (a response without a literal). Matches the literal cap:
|
|
20
|
+
// large literal-free responses (e.g. big SEARCH/LIST results) are legitimate, so this bound exists
|
|
21
|
+
// only to stop a server that never sends a line terminator, not to constrain normal traffic.
|
|
22
|
+
const MAX_LINE_SIZE = MAX_LITERAL_SIZE;
|
|
23
|
+
|
|
19
24
|
/**
|
|
20
25
|
* A Transform stream that parses raw IMAP protocol data from a socket into structured
|
|
21
26
|
* command/response objects. Reads binary input, splits it into lines delimited by LF,
|
|
@@ -34,6 +39,10 @@ class ImapStream extends Transform {
|
|
|
34
39
|
* @param {Object} [options.logger] - A pino-compatible logger instance. If not provided, a default child logger is created.
|
|
35
40
|
* @param {boolean} [options.logRaw] - If true, logs raw socket data at trace level.
|
|
36
41
|
* @param {boolean} [options.secureConnection] - Whether the connection uses TLS.
|
|
42
|
+
* @param {number} [options.maxLineLength] - Maximum allowed length (in bytes) of a single
|
|
43
|
+
* line (a response without a literal). Defaults to MAX_LITERAL_SIZE (1GB). Guards against a
|
|
44
|
+
* malicious or broken server that never sends a line terminator, which would otherwise grow
|
|
45
|
+
* the internal line buffer without bound.
|
|
37
46
|
*/
|
|
38
47
|
constructor(options) {
|
|
39
48
|
super({
|
|
@@ -55,10 +64,15 @@ class ImapStream extends Transform {
|
|
|
55
64
|
|
|
56
65
|
this.readBytesCounter = 0;
|
|
57
66
|
|
|
67
|
+
// Maximum length of a single line (response without a literal). Bounds the line buffer
|
|
68
|
+
// so a server that never sends a line terminator cannot exhaust memory.
|
|
69
|
+
this.maxLineLength = this.options.maxLineLength || MAX_LINE_SIZE;
|
|
70
|
+
|
|
58
71
|
this.state = LINE;
|
|
59
72
|
this.literalWaiting = 0;
|
|
60
73
|
this.inputBuffer = []; // lines
|
|
61
74
|
this.lineBuffer = []; // current line
|
|
75
|
+
this.lineBytes = 0; // bytes currently buffered for the in-progress line
|
|
62
76
|
this.literalBuffer = [];
|
|
63
77
|
this.literals = [];
|
|
64
78
|
|
|
@@ -102,7 +116,7 @@ class ImapStream extends Transform {
|
|
|
102
116
|
// Scan backwards through the line to find an IMAP literal marker: {size}\r\n
|
|
103
117
|
// The format is: '{' followed by one or more ASCII digits followed by '}'
|
|
104
118
|
let numBytes = [];
|
|
105
|
-
for (; pos
|
|
119
|
+
for (; pos >= 0; pos--) {
|
|
106
120
|
let c = line[pos];
|
|
107
121
|
if (c >= NUM_0 && c <= NUM_9) {
|
|
108
122
|
numBytes.unshift(c);
|
|
@@ -158,6 +172,7 @@ class ImapStream extends Transform {
|
|
|
158
172
|
|
|
159
173
|
this.inputBuffer.push(line);
|
|
160
174
|
this.lineBuffer = [];
|
|
175
|
+
this.lineBytes = 0;
|
|
161
176
|
|
|
162
177
|
// try to detect if this is a literal start
|
|
163
178
|
if (this.checkLiteralMarker(line)) {
|
|
@@ -190,7 +205,21 @@ class ImapStream extends Transform {
|
|
|
190
205
|
}
|
|
191
206
|
}
|
|
192
207
|
if (lineStart < chunk.length) {
|
|
193
|
-
|
|
208
|
+
// No line terminator was found in the remaining bytes; carry the tail over to
|
|
209
|
+
// the next chunk. Enforce the line-length cap here, since this is the only
|
|
210
|
+
// path that grows the line buffer across chunks.
|
|
211
|
+
let tail = chunk.slice(lineStart);
|
|
212
|
+
let lineLength = this.lineBytes + tail.length;
|
|
213
|
+
if (lineLength > this.maxLineLength) {
|
|
214
|
+
const err = new Error(`Line length ${lineLength} exceeds maximum allowed size of ${this.maxLineLength} bytes`);
|
|
215
|
+
err.code = 'LineTooLarge';
|
|
216
|
+
err.lineLength = lineLength;
|
|
217
|
+
err.maxSize = this.maxLineLength;
|
|
218
|
+
this.emit('error', err);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.lineBytes = lineLength;
|
|
222
|
+
this.lineBuffer.push(tail);
|
|
194
223
|
}
|
|
195
224
|
break;
|
|
196
225
|
}
|
|
@@ -303,6 +332,7 @@ class ImapStream extends Transform {
|
|
|
303
332
|
_destroy(err, callback) {
|
|
304
333
|
this.inputBuffer = [];
|
|
305
334
|
this.lineBuffer = [];
|
|
335
|
+
this.lineBytes = 0;
|
|
306
336
|
this.literalBuffer = [];
|
|
307
337
|
this.literals = [];
|
|
308
338
|
// Clear inputQueue and call any pending callbacks
|
|
@@ -349,6 +349,13 @@ class TokenParser {
|
|
|
349
349
|
this.currentNode.type = 'ATOM';
|
|
350
350
|
// jump i to the ']'
|
|
351
351
|
i = this.str.indexOf(']', i + 10);
|
|
352
|
+
if (i < 0) {
|
|
353
|
+
// Malformed REFERRAL with no closing ']'. Consume the rest
|
|
354
|
+
// of the string (there is no ']' to exclude) instead of
|
|
355
|
+
// computing a negative-index substring, which would yield
|
|
356
|
+
// garbage.
|
|
357
|
+
i = this.str.length;
|
|
358
|
+
}
|
|
352
359
|
this.currentNode.endPos = this.pos + i - 1;
|
|
353
360
|
this.currentNode.value = this.str.substring(this.currentNode.startPos - this.pos, this.currentNode.endPos - this.pos + 1);
|
|
354
361
|
this.currentNode = this.currentNode.parentNode;
|
package/lib/imap-flow.d.ts
CHANGED
|
@@ -60,6 +60,12 @@ export interface ImapFlowOptions {
|
|
|
60
60
|
greetingTimeout?: number;
|
|
61
61
|
/** How long to wait for socket inactivity before timing out the connection. Defaults to 5 minutes */
|
|
62
62
|
socketTimeout?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Maximum allowed length in bytes of a single response line (a response without a literal).
|
|
65
|
+
* Guards against a malicious or broken server that never sends a line terminator. Defaults to
|
|
66
|
+
* 1GB.
|
|
67
|
+
*/
|
|
68
|
+
maxLineLength?: number;
|
|
63
69
|
/**
|
|
64
70
|
* Threshold in milliseconds for warning that a mailbox lock has been held
|
|
65
71
|
* for a long time (diagnostic for forgotten release() calls). Defaults to
|
package/lib/imap-flow.js
CHANGED
|
@@ -291,7 +291,8 @@ class ImapFlow extends EventEmitter {
|
|
|
291
291
|
logger: this.log,
|
|
292
292
|
cid: this.id,
|
|
293
293
|
logRaw: this.logRaw,
|
|
294
|
-
secureConnection: this.secureConnection
|
|
294
|
+
secureConnection: this.secureConnection,
|
|
295
|
+
maxLineLength: this.options.maxLineLength
|
|
295
296
|
});
|
|
296
297
|
|
|
297
298
|
this.reading = false;
|
|
@@ -1180,6 +1181,24 @@ class ImapFlow extends EventEmitter {
|
|
|
1180
1181
|
reject(err);
|
|
1181
1182
|
}, UPGRADE_TIMEOUT);
|
|
1182
1183
|
|
|
1184
|
+
// A TLS handshake failure (bad certificate, protocol mismatch, etc.) is emitted on the
|
|
1185
|
+
// new TLS socket, not on the plain socket, so it must be handled here. Without this the
|
|
1186
|
+
// upgrade promise would only settle via the timeout and connect() would reject with a
|
|
1187
|
+
// generic "Unexpected close" instead of the actual TLS error.
|
|
1188
|
+
const tlsSocketErrorHandler = err => {
|
|
1189
|
+
clearTimeout(this.connectTimeout);
|
|
1190
|
+
clearTimeout(this.upgradeTimeout);
|
|
1191
|
+
if (!this.upgrading) {
|
|
1192
|
+
// already settled
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
this.upgrading = false;
|
|
1196
|
+
err.tlsFailed = true;
|
|
1197
|
+
this.clearSocketHandlers();
|
|
1198
|
+
this.closeAfter();
|
|
1199
|
+
reject(err);
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1183
1202
|
this.upgrading = true;
|
|
1184
1203
|
this.socket = tls.connect(opts, () => {
|
|
1185
1204
|
try {
|
|
@@ -1208,8 +1227,9 @@ class ImapFlow extends EventEmitter {
|
|
|
1208
1227
|
});
|
|
1209
1228
|
}
|
|
1210
1229
|
|
|
1211
|
-
// Clean up the
|
|
1230
|
+
// Clean up the error handlers after successful upgrade
|
|
1212
1231
|
socketPlain.removeListener('error', socketPlainErrorHandler);
|
|
1232
|
+
this.socket.removeListener('error', tlsSocketErrorHandler);
|
|
1213
1233
|
|
|
1214
1234
|
return resolve(true);
|
|
1215
1235
|
} catch (ex) {
|
|
@@ -1217,6 +1237,11 @@ class ImapFlow extends EventEmitter {
|
|
|
1217
1237
|
}
|
|
1218
1238
|
});
|
|
1219
1239
|
|
|
1240
|
+
// Registered after tls.connect (the TLS socket now exists) but before
|
|
1241
|
+
// setSocketHandlers() so it fires first on a handshake error and removes the generic
|
|
1242
|
+
// handlers, keeping this the single error path for the upgrade.
|
|
1243
|
+
this.socket.once('error', tlsSocketErrorHandler);
|
|
1244
|
+
|
|
1220
1245
|
this.writeSocket = this.socket;
|
|
1221
1246
|
|
|
1222
1247
|
this.setSocketHandlers();
|
|
@@ -3829,6 +3854,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3829
3854
|
* @returns {Object} Socket objects
|
|
3830
3855
|
* @returns {Object} return.readSocket The read socket (inflated socket if compression is enabled, raw socket otherwise)
|
|
3831
3856
|
* @returns {Object} return.writeSocket The write socket
|
|
3857
|
+
* @returns {Object} return.socket The raw underlying socket (same as readSocket/writeSocket when compression is disabled)
|
|
3832
3858
|
*/
|
|
3833
3859
|
unbind() {
|
|
3834
3860
|
this.socket.unpipe(this.streamer);
|
|
@@ -3836,15 +3862,32 @@ class ImapFlow extends EventEmitter {
|
|
|
3836
3862
|
this._inflate.unpipe(this.streamer);
|
|
3837
3863
|
}
|
|
3838
3864
|
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
this.
|
|
3843
|
-
|
|
3865
|
+
// Detach all of ImapFlow's socket listeners — the raw socket plus, when
|
|
3866
|
+
// compression is active, the PassThrough writeSocket — so the connection
|
|
3867
|
+
// is fully released to the caller.
|
|
3868
|
+
this.clearSocketHandlers();
|
|
3869
|
+
|
|
3870
|
+
const readSocket = this._inflate || this.socket;
|
|
3871
|
+
const writeSocket = this.writeSocket || this.socket;
|
|
3872
|
+
|
|
3873
|
+
// Defense-in-depth: when compression is active the raw socket is orphaned
|
|
3874
|
+
// (neither readSocket nor writeSocket) yet still live and still the target
|
|
3875
|
+
// of the deflate/writeSocket error forwarders. We just stripped our own
|
|
3876
|
+
// error listener, so any post-unbind error (e.g. an upstream ECONNRESET)
|
|
3877
|
+
// would become an unhandled 'error' that crashes the host process. Attach
|
|
3878
|
+
// a benign listener so the orphaned socket can never throw after handoff.
|
|
3879
|
+
// Non-compression path: socket === readSocket === writeSocket and the
|
|
3880
|
+
// caller owns it directly, so leave it untouched (no behavior change).
|
|
3881
|
+
if (this.socket !== readSocket && this.socket !== writeSocket) {
|
|
3882
|
+
this.socket.on('error', err => {
|
|
3883
|
+
this.log.debug({ msg: 'Suppressed error on unbound socket', err, cid: this.id });
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3844
3886
|
|
|
3845
3887
|
return {
|
|
3846
|
-
readSocket
|
|
3847
|
-
writeSocket
|
|
3888
|
+
readSocket,
|
|
3889
|
+
writeSocket,
|
|
3890
|
+
socket: this.socket
|
|
3848
3891
|
};
|
|
3849
3892
|
}
|
|
3850
3893
|
}
|
package/lib/tools.js
CHANGED
|
@@ -383,6 +383,10 @@ const tools = {
|
|
|
383
383
|
if (Array.isArray(attribute)) {
|
|
384
384
|
return attribute.map(entry => (entry && typeof entry.value === 'string' ? entry.value : false)).filter(entry => entry);
|
|
385
385
|
}
|
|
386
|
+
// NIL (parsed as null) and other non-array values yield an empty array,
|
|
387
|
+
// so callers can safely index into the result. RFC 8474 allows e.g.
|
|
388
|
+
// `THREADID NIL` when the server has no thread relation to report.
|
|
389
|
+
return [];
|
|
386
390
|
};
|
|
387
391
|
|
|
388
392
|
switch (key) {
|
|
@@ -490,7 +494,7 @@ const tools = {
|
|
|
490
494
|
|
|
491
495
|
// normalize path to use ascii, so we would always get the same ID
|
|
492
496
|
let path = mailbox.path;
|
|
493
|
-
if (/[
|
|
497
|
+
if (/[\u0080-\uffff]/.test(path)) {
|
|
494
498
|
try {
|
|
495
499
|
path = iconv.encode(path, 'utf-7-imap').toString();
|
|
496
500
|
} catch {
|
|
@@ -1065,7 +1069,9 @@ const tools = {
|
|
|
1065
1069
|
return '';
|
|
1066
1070
|
}
|
|
1067
1071
|
|
|
1068
|
-
|
|
1072
|
+
// Deduplicate before sorting so that repeated values do not produce
|
|
1073
|
+
// overlapping/non-canonical tokens (e.g. [1,1,2,3] -> "1:3", not "1,1:3").
|
|
1074
|
+
list = Array.from(new Set(list)).sort((a, b) => a - b);
|
|
1069
1075
|
|
|
1070
1076
|
let last = list[list.length - 1];
|
|
1071
1077
|
let result = [[last]];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.5",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"main": "lib/imap-flow.js",
|
|
6
6
|
"types": "lib/imap-flow.d.ts",
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
"homepage": "https://imapflow.com/",
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@eslint/js": "10.0.1",
|
|
31
|
-
"@types/node": "25.
|
|
31
|
+
"@types/node": "25.9.1",
|
|
32
32
|
"c8": "11.0.0",
|
|
33
|
-
"eslint": "10.
|
|
33
|
+
"eslint": "10.4.1",
|
|
34
34
|
"eslint-config-nodemailer": "1.2.0",
|
|
35
35
|
"eslint-config-prettier": "10.1.8",
|
|
36
36
|
"grunt": "1.6.2",
|
|
@@ -42,14 +42,14 @@
|
|
|
42
42
|
"typescript": "6.0.3"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@zone-eu/mailsplit": "5.4.
|
|
45
|
+
"@zone-eu/mailsplit": "5.4.12",
|
|
46
46
|
"encoding-japanese": "2.2.0",
|
|
47
47
|
"iconv-lite": "0.7.2",
|
|
48
48
|
"libbase64": "1.3.0",
|
|
49
49
|
"libmime": "5.3.8",
|
|
50
50
|
"libqp": "2.1.1",
|
|
51
|
-
"nodemailer": "8.0.
|
|
51
|
+
"nodemailer": "8.0.10",
|
|
52
52
|
"pino": "10.3.1",
|
|
53
|
-
"socks": "2.8.
|
|
53
|
+
"socks": "2.8.9"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -582,6 +582,51 @@ module.exports['Commands: store remove flags'] = async test => {
|
|
|
582
582
|
test.done();
|
|
583
583
|
};
|
|
584
584
|
|
|
585
|
+
module.exports['Commands: store remove keeps a flag not in permanentFlags'] = async test => {
|
|
586
|
+
// Mailbox permits only \Seen (no \*), so \Custom is not a permanent flag. Removal must still be
|
|
587
|
+
// sent: a flag does not need to be permitted to be removed. Regression guard — the check used
|
|
588
|
+
// to test the rewritten wire-form operation instead of options.operation and dropped the flag.
|
|
589
|
+
let execArgs = null;
|
|
590
|
+
const connection = createMockConnection({
|
|
591
|
+
state: 3,
|
|
592
|
+
mailbox: { permanentFlags: new Set(['\\Seen']) },
|
|
593
|
+
exec: async (cmd, attrs) => {
|
|
594
|
+
execArgs = { cmd, attrs };
|
|
595
|
+
return { next: () => {} };
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const result = await storeCommand(connection, '1:10', ['\\Custom'], { operation: 'remove' });
|
|
600
|
+
test.equal(result, true);
|
|
601
|
+
test.ok(execArgs, 'a STORE command should be issued');
|
|
602
|
+
test.equal(execArgs.attrs[1].value, '-FLAGS');
|
|
603
|
+
test.deepEqual(
|
|
604
|
+
execArgs.attrs[2].map(flag => flag.value),
|
|
605
|
+
['\\Custom'],
|
|
606
|
+
'the removed flag must be present in the command'
|
|
607
|
+
);
|
|
608
|
+
test.done();
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
module.exports['Commands: store add drops a flag not in permanentFlags'] = async test => {
|
|
612
|
+
// Control for the regression above: the permanentFlags guard must still apply to non-remove
|
|
613
|
+
// operations. Adding a flag the mailbox does not permit yields no command and a false result.
|
|
614
|
+
let execCalled = false;
|
|
615
|
+
const connection = createMockConnection({
|
|
616
|
+
state: 3,
|
|
617
|
+
mailbox: { permanentFlags: new Set(['\\Seen']) },
|
|
618
|
+
exec: async () => {
|
|
619
|
+
execCalled = true;
|
|
620
|
+
return { next: () => {} };
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const result = await storeCommand(connection, '1:10', ['\\Custom'], { operation: 'add' });
|
|
625
|
+
test.equal(result, false, 'adding a non-permitted flag should fail');
|
|
626
|
+
test.equal(execCalled, false, 'no STORE command should be issued');
|
|
627
|
+
test.done();
|
|
628
|
+
};
|
|
629
|
+
|
|
585
630
|
module.exports['Commands: store set flags'] = async test => {
|
|
586
631
|
let execArgs = null;
|
|
587
632
|
const connection = createMockConnection({
|
|
@@ -1067,6 +1067,8 @@ module.exports['Connection Edge: unbind removes socket listeners and returns soc
|
|
|
1067
1067
|
test.ok(result.writeSocket, 'Should return writeSocket');
|
|
1068
1068
|
test.equal(result.readSocket, client.socket);
|
|
1069
1069
|
test.equal(result.writeSocket, client.socket);
|
|
1070
|
+
// Non-compression path: the raw socket is the same object as read/write.
|
|
1071
|
+
test.equal(result.socket, client.socket);
|
|
1070
1072
|
|
|
1071
1073
|
test.done();
|
|
1072
1074
|
};
|
|
@@ -1103,6 +1105,72 @@ module.exports['Connection Edge: unbind with inflate stream'] = test => {
|
|
|
1103
1105
|
test.done();
|
|
1104
1106
|
};
|
|
1105
1107
|
|
|
1108
|
+
module.exports['Connection Edge: unbind survives post-handoff socket error (compression)'] = test => {
|
|
1109
|
+
let client = new ImapFlow({
|
|
1110
|
+
host: 'imap.example.com',
|
|
1111
|
+
port: 993,
|
|
1112
|
+
auth: { user: 'test', pass: 'test' }
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
// Mock the compression topology: raw socket plus a separate PassThrough-like
|
|
1116
|
+
// writeSocket, an inflate read stream and a deflate stream. The writeSocket
|
|
1117
|
+
// and deflate error forwarders re-emit onto the raw socket, exactly like compress().
|
|
1118
|
+
client.socket = new EventEmitter();
|
|
1119
|
+
client.socket.unpipe = () => {};
|
|
1120
|
+
|
|
1121
|
+
client.writeSocket = new EventEmitter();
|
|
1122
|
+
client.writeSocket.on('error', err => {
|
|
1123
|
+
if (client.socket) {
|
|
1124
|
+
client.socket.emit('error', err);
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
client._inflate = new EventEmitter();
|
|
1129
|
+
client._inflate.unpipe = () => {};
|
|
1130
|
+
|
|
1131
|
+
client._deflate = new EventEmitter();
|
|
1132
|
+
client._deflate.on('error', err => {
|
|
1133
|
+
if (client.socket) {
|
|
1134
|
+
client.socket.emit('error', err);
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
client.setSocketHandlers();
|
|
1139
|
+
|
|
1140
|
+
let result = client.unbind();
|
|
1141
|
+
|
|
1142
|
+
// Explicit contract: raw socket is now exposed alongside read/write sockets.
|
|
1143
|
+
test.equal(result.socket, client.socket, 'Should expose the raw socket');
|
|
1144
|
+
test.equal(result.readSocket, client._inflate);
|
|
1145
|
+
test.equal(result.writeSocket, client.writeSocket);
|
|
1146
|
+
|
|
1147
|
+
// ImapFlow's own _socketError must be detached from writeSocket; only the
|
|
1148
|
+
// forwarder closure remains.
|
|
1149
|
+
test.equal(client.writeSocket.listenerCount('error'), 1);
|
|
1150
|
+
|
|
1151
|
+
// Emitting 'error' on a listener-less EventEmitter throws synchronously, so
|
|
1152
|
+
// each of these would crash the host before the fix. After unbind() the
|
|
1153
|
+
// benign listener on the orphaned raw socket must swallow them all.
|
|
1154
|
+
test.doesNotThrow(() => {
|
|
1155
|
+
client.socket.emit('error', Object.assign(new Error('upstream reset'), { code: 'ECONNRESET' }));
|
|
1156
|
+
}, 'Direct socket error after unbind must not throw');
|
|
1157
|
+
|
|
1158
|
+
test.doesNotThrow(() => {
|
|
1159
|
+
client.writeSocket.emit('error', new Error('write failure'));
|
|
1160
|
+
}, 'writeSocket error forwarded to the raw socket must not throw');
|
|
1161
|
+
|
|
1162
|
+
test.doesNotThrow(() => {
|
|
1163
|
+
client._deflate.emit('error', new Error('deflate failure'));
|
|
1164
|
+
}, 'deflate error forwarded to the raw socket must not throw');
|
|
1165
|
+
|
|
1166
|
+
// close() must remain safe to call after unbind().
|
|
1167
|
+
test.doesNotThrow(() => {
|
|
1168
|
+
client.close();
|
|
1169
|
+
}, 'close() after unbind must not throw');
|
|
1170
|
+
|
|
1171
|
+
test.done();
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1106
1174
|
module.exports['Connection Edge: resolveRange with number input'] = async test => {
|
|
1107
1175
|
let client = new ImapFlow({
|
|
1108
1176
|
host: 'imap.example.com',
|
|
@@ -266,3 +266,71 @@ module.exports['logRaw option triggers trace logging'] = test => {
|
|
|
266
266
|
|
|
267
267
|
stream.end(Buffer.from('A CMD\r\n'));
|
|
268
268
|
};
|
|
269
|
+
|
|
270
|
+
module.exports['Adjacent literals with marker at line start'] = test => {
|
|
271
|
+
// After the first literal's data (12345) is consumed, parsing resumes at the very start of
|
|
272
|
+
// a line that is itself a literal marker ({3}). The marker begins at byte 0 of the resumed
|
|
273
|
+
// line, which the backward scan must still recognize. Previously the loop bound skipped
|
|
274
|
+
// index 0, so the second literal was silently dropped.
|
|
275
|
+
runStreamTest(
|
|
276
|
+
test,
|
|
277
|
+
cmd => {
|
|
278
|
+
test.equal(cmd.literals.length, 2, 'both adjacent literals must be extracted');
|
|
279
|
+
test.equal(cmd.literals[0].toString(), '12345', 'first literal content');
|
|
280
|
+
test.equal(cmd.literals[1].toString(), 'ABC', 'second literal content');
|
|
281
|
+
},
|
|
282
|
+
async stream => {
|
|
283
|
+
stream.end(Buffer.from('A LOGIN {5}\r\n12345{3}\r\nABC\r\n'));
|
|
284
|
+
},
|
|
285
|
+
1
|
|
286
|
+
);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
module.exports['Line length cap rejects oversized line'] = test => {
|
|
290
|
+
// A server that never sends a line terminator must not grow the line buffer without bound.
|
|
291
|
+
const stream = new ImapStream({ cid: 'test', maxLineLength: 16 });
|
|
292
|
+
let errored = false;
|
|
293
|
+
|
|
294
|
+
stream.on('error', err => {
|
|
295
|
+
errored = true;
|
|
296
|
+
test.equal(err.code, 'LineTooLarge', 'error code should be LineTooLarge');
|
|
297
|
+
test.equal(err.maxSize, 16, 'error should report the configured cap');
|
|
298
|
+
stream.destroy();
|
|
299
|
+
test.done();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
stream.on('end', () => {
|
|
303
|
+
if (!errored) {
|
|
304
|
+
test.ok(false, 'expected a LineTooLarge error');
|
|
305
|
+
test.done();
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// 24 bytes, no LF, written across chunks -> exceeds the 16 byte cap.
|
|
310
|
+
stream.write(Buffer.from('AAAAAAAA'));
|
|
311
|
+
stream.write(Buffer.from('BBBBBBBB'));
|
|
312
|
+
stream.write(Buffer.from('CCCCCCCC'));
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
module.exports['Line length cap allows line within limit'] = test => {
|
|
316
|
+
// A normal line under the configured cap must still parse cleanly.
|
|
317
|
+
const stream = new ImapStream({ cid: 'test', maxLineLength: 32 });
|
|
318
|
+
let payloads = [];
|
|
319
|
+
|
|
320
|
+
stream.on('readable', () => {
|
|
321
|
+
let cmd;
|
|
322
|
+
while ((cmd = stream.read()) !== null) {
|
|
323
|
+
payloads.push(cmd.payload.toString());
|
|
324
|
+
cmd.next();
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
stream.on('error', err => test.ifError(err));
|
|
329
|
+
|
|
330
|
+
stream.on('end', () => {
|
|
331
|
+
test.deepEqual(payloads, ['A NOOP'], 'line under the cap should parse');
|
|
332
|
+
test.done();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
stream.end(Buffer.from('A NOOP\r\n'));
|
|
336
|
+
};
|
|
@@ -372,3 +372,21 @@ module.exports['Token Parser: E34: digit after star throws ParserError34'] = tes
|
|
|
372
372
|
test.ok(err, 'expected an error to be thrown');
|
|
373
373
|
test.equal(err.code, 'ParserError34');
|
|
374
374
|
});
|
|
375
|
+
|
|
376
|
+
module.exports['Token Parser: REFERRAL response code is parsed as an IMAP URL atom'] = test =>
|
|
377
|
+
asyncWrapper(test, async test => {
|
|
378
|
+
let r = await parser('* OK [REFERRAL imap://user@host/INBOX] please use another server');
|
|
379
|
+
let section = r.attributes[0].section;
|
|
380
|
+
test.equal(section[0].value, 'REFERRAL');
|
|
381
|
+
test.equal(section[1].value, 'imap://user@host/INBOX');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
module.exports['Token Parser: malformed REFERRAL with no closing bracket consumes the rest'] = test =>
|
|
385
|
+
asyncWrapper(test, async test => {
|
|
386
|
+
// Missing ']' previously produced a negative-index substring (garbage). The whole
|
|
387
|
+
// remaining string should be captured as the URL instead, without throwing.
|
|
388
|
+
let r = await parser('* OK [REFERRAL imap://user@host/INBOX');
|
|
389
|
+
let section = r.attributes[0].section;
|
|
390
|
+
test.equal(section[0].value, 'REFERRAL');
|
|
391
|
+
test.equal(section[1].value, 'imap://user@host/INBOX');
|
|
392
|
+
});
|
package/test/tools-test.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const tools = require('../lib/tools');
|
|
4
|
+
const { parser } = require('../lib/handler/imap-handler');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const iconv = require('iconv-lite');
|
|
4
7
|
|
|
5
8
|
// Mock connection for testing
|
|
6
9
|
let createMockConnection = (options = {}) => ({
|
|
@@ -1137,3 +1140,69 @@ module.exports['Tools: getColorFlags with null returns result not null'] = test
|
|
|
1137
1140
|
test.ok(result.remove.includes('\\Flagged'));
|
|
1138
1141
|
test.done();
|
|
1139
1142
|
};
|
|
1143
|
+
|
|
1144
|
+
// ============================================
|
|
1145
|
+
// formatMessageResponse: OBJECTID NIL handling (RFC 8474)
|
|
1146
|
+
// ============================================
|
|
1147
|
+
|
|
1148
|
+
module.exports['Tools: formatMessageResponse handles THREADID NIL'] = async test => {
|
|
1149
|
+
// RFC 8474 allows `THREADID NIL` when the server has no thread relation to
|
|
1150
|
+
// report (e.g. Strato). The NIL token is parsed as null and must not crash.
|
|
1151
|
+
let untagged = await parser('* 1 FETCH (UID 1 EMAILID (E1) THREADID NIL FLAGS (\\Seen) MODSEQ (4312))');
|
|
1152
|
+
let result = await tools.formatMessageResponse(untagged, {});
|
|
1153
|
+
test.equal(result.seq, 1);
|
|
1154
|
+
test.equal(result.uid, 1);
|
|
1155
|
+
test.equal(result.emailId, 'E1');
|
|
1156
|
+
test.equal(result.threadId, undefined);
|
|
1157
|
+
test.ok(result.flags.has('\\Seen'));
|
|
1158
|
+
test.equal(result.modseq, 4312n);
|
|
1159
|
+
test.done();
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
module.exports['Tools: formatMessageResponse handles normal THREADID'] = async test => {
|
|
1163
|
+
let untagged = await parser('* 2 FETCH (THREADID (T9999) EMAILID (E2))');
|
|
1164
|
+
let result = await tools.formatMessageResponse(untagged, {});
|
|
1165
|
+
test.equal(result.threadId, 'T9999');
|
|
1166
|
+
test.equal(result.emailId, 'E2');
|
|
1167
|
+
test.done();
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
// ============================================
|
|
1171
|
+
// formatMessageResponse: non-ASCII mailbox path normalization for stable id
|
|
1172
|
+
// ============================================
|
|
1173
|
+
|
|
1174
|
+
module.exports['Tools: formatMessageResponse normalizes non-ASCII mailbox path for id'] = async test => {
|
|
1175
|
+
// No EMAILID, so the message id falls back to an md5 over [path, uidValidity, uid].
|
|
1176
|
+
// The mailbox path is non-ASCII and must be modified-UTF-7 normalized before hashing.
|
|
1177
|
+
// (A previous bogus regex /[0x80-0xff]/ never matched non-ASCII, skipping normalization.)
|
|
1178
|
+
let untagged = await parser('* 1 FETCH (UID 5 FLAGS (\\Seen))');
|
|
1179
|
+
let mailbox = { path: '日本語', uidValidity: 123n };
|
|
1180
|
+
let result = await tools.formatMessageResponse(untagged, mailbox);
|
|
1181
|
+
|
|
1182
|
+
let encodedPath = iconv.encode('日本語', 'utf-7-imap').toString();
|
|
1183
|
+
let expectedId = crypto.createHash('md5').update([encodedPath, '123', '5'].join(':')).digest('hex');
|
|
1184
|
+
let rawId = crypto.createHash('md5').update(['日本語', '123', '5'].join(':')).digest('hex');
|
|
1185
|
+
|
|
1186
|
+
test.equal(result.id, expectedId, 'id must hash the UTF-7 normalized path');
|
|
1187
|
+
test.notEqual(result.id, rawId, 'id must not hash the raw non-ASCII path');
|
|
1188
|
+
test.done();
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
// ============================================
|
|
1192
|
+
// packMessageRange
|
|
1193
|
+
// ============================================
|
|
1194
|
+
|
|
1195
|
+
module.exports['Tools: packMessageRange packs contiguous and gapped ranges'] = test => {
|
|
1196
|
+
test.equal(tools.packMessageRange([1, 2, 3, 5, 7, 8]), '1:3,5,7:8');
|
|
1197
|
+
test.equal(tools.packMessageRange(7), '7');
|
|
1198
|
+
test.equal(tools.packMessageRange([]), '');
|
|
1199
|
+
test.done();
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
module.exports['Tools: packMessageRange dedupes duplicate values'] = test => {
|
|
1203
|
+
// Duplicates must not produce overlapping/non-canonical tokens like "1,1:3".
|
|
1204
|
+
test.equal(tools.packMessageRange([1, 1, 2, 3]), '1:3');
|
|
1205
|
+
test.equal(tools.packMessageRange([3, 1, 2, 2, 5]), '1:3,5');
|
|
1206
|
+
test.equal(tools.packMessageRange([5, 5, 5]), '5');
|
|
1207
|
+
test.done();
|
|
1208
|
+
};
|