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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.3.3"
2
+ ".": "1.3.5"
3
3
  }
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-----
@@ -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 > 0; 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
- this.lineBuffer.push(chunk.slice(lineStart));
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;
@@ -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 plain socket error handler after successful upgrade
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
- this.socket.removeListener('error', this._socketError);
3840
- this.socket.removeListener('close', this._socketClose);
3841
- this.socket.removeListener('end', this._socketEnd);
3842
- this.socket.removeListener('tlsClientError', this._socketError);
3843
- this.socket.removeListener('timeout', this._socketTimeout);
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: this._inflate || this.socket,
3847
- writeSocket: this.writeSocket || this.socket
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 (/[0x80-0xff]/.test(path)) {
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
- list.sort((a, b) => a - b);
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",
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.6.0",
31
+ "@types/node": "25.9.1",
32
32
  "c8": "11.0.0",
33
- "eslint": "10.2.1",
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.9",
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.7",
51
+ "nodemailer": "8.0.10",
52
52
  "pino": "10.3.1",
53
- "socks": "2.8.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
+ });
@@ -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
+ };