imapflow 1.3.4 → 1.3.6

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/lib/imap-flow.js CHANGED
@@ -23,7 +23,7 @@ const libbase64 = require('libbase64');
23
23
  const FlowedDecoder = require('@zone-eu/mailsplit/lib/flowed-decoder');
24
24
  const { PassThrough } = require('stream');
25
25
 
26
- const { proxyConnection } = require('./proxy-connection');
26
+ const { proxyConnection, detachEarlyErrorHandler } = require('./proxy-connection');
27
27
 
28
28
  const {
29
29
  comparePaths,
@@ -291,13 +291,25 @@ 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,
296
+ maxLiteralSize: this.options.maxLiteralSize
295
297
  });
296
298
 
297
299
  this.reading = false;
298
300
  this.socket = false;
299
301
  this.writeSocket = false;
300
302
 
303
+ // Tracked throttle back-off timer (see reader()). Stored so close() can clear it
304
+ // and abort the wait instead of letting it keep the event loop alive for minutes.
305
+ this._throttleTimer = null;
306
+ this._throttleAbort = null;
307
+
308
+ // Pending rejector of the in-flight STARTTLS upgrade promise (see upgradeToSTARTTLS()).
309
+ // Stored so emitError() can route a streamer-originated error into the upgrade's single
310
+ // error path instead of dropping it (which could hang a verifyOnly connect()).
311
+ this._upgradeReject = null;
312
+
301
313
  this.isClosed = false;
302
314
 
303
315
  this.states = states;
@@ -326,6 +338,10 @@ class ImapFlow extends EventEmitter {
326
338
 
327
339
  this.expectCapabilityUpdate = false; // force CAPABILITY after LOGIN
328
340
 
341
+ // Set true if the server sent data after the STARTTLS OK and before the TLS
342
+ // handshake (a plaintext-injection signal). See upgradeToSTARTTLS().
343
+ this._starttlsHadTrailingData = false;
344
+
329
345
  /**
330
346
  * Enabled capabilities. Usually `CONDSTORE` and `UTF8=ACCEPT` if server supports these.
331
347
  * @type {Set<string>}
@@ -404,6 +420,34 @@ class ImapFlow extends EventEmitter {
404
420
  }
405
421
  err._connId = err._connId || this.id;
406
422
 
423
+ // During a STARTTLS handshake the upgrade promise owns the single error path
424
+ // (tlsSocketErrorHandler -> reject). Route the error there so a streamer-originated
425
+ // failure is surfaced with its real code (instead of a generic ClosedAfterConnect*)
426
+ // and cannot hang a verifyOnly connect() waiting on a 'close' that never rejects.
427
+ // Fall back to closing if the upgrade has no pending rejector.
428
+ if (this.upgrading) {
429
+ this.upgrading = false;
430
+ this.closeAfter();
431
+ if (typeof this._upgradeReject === 'function') {
432
+ let reject = this._upgradeReject;
433
+ this._upgradeReject = null;
434
+ reject(err);
435
+ }
436
+ return;
437
+ }
438
+
439
+ // While the initial connect promise is still pending it owns error reporting:
440
+ // reject it once instead of emitting a duplicate 'error' event (which would also
441
+ // throw if the caller has not attached an 'error' listener yet).
442
+ if (typeof this.initialReject === 'function') {
443
+ let reject = this.initialReject;
444
+ this.initialResolve = false;
445
+ this.initialReject = false;
446
+ this.closeAfter();
447
+ reject(err);
448
+ return;
449
+ }
450
+
407
451
  this.closeAfter();
408
452
  this.emit('error', err);
409
453
  }
@@ -688,8 +732,14 @@ class ImapFlow extends EventEmitter {
688
732
  // Server acknowledged our literal size with "+", send the actual literal data
689
733
  if (parsed.tag === '+' && this.commandParts.length) {
690
734
  let content = this.commandParts.shift();
691
- this.write(content);
692
- this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
735
+ // A write() failure here (e.g. socket closed mid-command) must not propagate
736
+ // out of the loop and skip data.next(), which would stall the parser stream.
737
+ try {
738
+ this.write(content);
739
+ this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
740
+ } catch (err) {
741
+ this.log.warn({ err, cid: this.id });
742
+ }
693
743
  data.next();
694
744
  continue;
695
745
  }
@@ -724,15 +774,22 @@ class ImapFlow extends EventEmitter {
724
774
  this.requestTagMap.delete(parsed.tag);
725
775
 
726
776
  if (this.currentRequest && this.currentRequest.tag === parsed.tag) {
727
- // send next pending command
777
+ // send next pending command. A failure here must not propagate out of the
778
+ // loop and skip data.next() below, which would stall the parser stream.
728
779
  this.currentRequest = false;
729
- await this.trySend();
780
+ try {
781
+ await this.trySend();
782
+ } catch (err) {
783
+ this.log.warn({ err, cid: this.id });
784
+ }
730
785
  }
731
786
 
732
787
  switch (parsed.command.toUpperCase()) {
733
788
  case 'OK':
734
789
  case 'BYE':
735
- await new Promise(resolve => request.resolve({ response: parsed, next: resolve }));
790
+ // hasTrailingData is forwarded so STARTTLS can detect a plaintext
791
+ // injection (data buffered after the tagged OK, before the handshake).
792
+ await new Promise(resolve => request.resolve({ response: parsed, next: resolve, hasTrailingData: !!data.trailingAfterLine }));
736
793
  break;
737
794
 
738
795
  case 'NO':
@@ -795,7 +852,29 @@ class ImapFlow extends EventEmitter {
795
852
  }
796
853
 
797
854
  this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err });
798
- await new Promise(r => setTimeout(r, delayResponse));
855
+
856
+ // Tracked, abortable wait. Storing the timer lets close() clear it so
857
+ // the back-off never keeps the event loop alive, and storing the resolve
858
+ // lets close() abort the wait promptly (aborted=true) instead of blocking
859
+ // the reader for up to 5 minutes and rejecting long after the connection
860
+ // is gone. Normal expiry resolves with aborted=false.
861
+ let aborted = await new Promise(resolve => {
862
+ this._throttleAbort = resolve;
863
+ this._throttleTimer = setTimeout(() => resolve(false), delayResponse);
864
+ if (typeof this._throttleTimer.unref === 'function') {
865
+ this._throttleTimer.unref();
866
+ }
867
+ });
868
+ this._throttleTimer = null;
869
+ this._throttleAbort = null;
870
+
871
+ if (aborted) {
872
+ // Connection closed during back-off: reject promptly with a
873
+ // connection error (carrying any server BYE reason) instead of
874
+ // waiting out the throttle delay.
875
+ request.reject(this.createNoConnectionError(this.byeReason));
876
+ break;
877
+ }
799
878
  }
800
879
  }
801
880
 
@@ -1012,7 +1091,13 @@ class ImapFlow extends EventEmitter {
1012
1091
  this.streamer.compress = true;
1013
1092
  this.socket.pipe(this._inflate).pipe(this.streamer);
1014
1093
  this._inflate.on('error', err => {
1015
- this.streamer.emit('error', err);
1094
+ // Only forward into the streamer while it is alive and still has an error
1095
+ // listener. After close() the streamer is destroyed and its listener removed,
1096
+ // so emitting 'error' would throw an unhandled error and crash the process.
1097
+ // (this.streamer is assigned once in the constructor and never nulled.)
1098
+ if (!this.streamer.destroyed && this.streamer.listenerCount('error')) {
1099
+ this.streamer.emit('error', err);
1100
+ }
1016
1101
  });
1017
1102
 
1018
1103
  // For outgoing data, replace the writeSocket with a PassThrough buffer.
@@ -1138,11 +1223,41 @@ class ImapFlow extends EventEmitter {
1138
1223
  return this._failSTARTTLS();
1139
1224
  }
1140
1225
 
1226
+ // STARTTLS plaintext-injection guard (RFC 3501 §6.2.1): a compliant server stays
1227
+ // silent after the tagged STARTTLS OK until the TLS handshake, so any data that
1228
+ // followed the OK was injected by a MITM and must not be treated as if it arrived
1229
+ // over TLS. Two complementary best-effort checks fail closed before wrapping the
1230
+ // socket; injection that still races in afterwards corrupts the TLS handshake and
1231
+ // is rejected there instead (with a generic TLS error rather than STARTTLS_INJECTION).
1232
+ const failSTARTTLSInjection = () => {
1233
+ let err = new Error('Server sent data after the STARTTLS response and before the TLS handshake; possible plaintext-injection attack');
1234
+ err.code = 'STARTTLS_INJECTION';
1235
+ err.tlsFailed = true;
1236
+ this.closeAfter();
1237
+ return err;
1238
+ };
1239
+
1240
+ // Check 1: the parser saw more input already buffered right after the tagged OK
1241
+ // (same TCP segment, or an already-queued chunk) — see hasTrailingData / starttls.js.
1242
+ if (this._starttlsHadTrailingData) {
1243
+ throw failSTARTTLSInjection();
1244
+ }
1245
+
1141
1246
  // STARTTLS upgrade sequence: detach the plain socket from the parser,
1142
1247
  // wrap it in a TLS socket, then reconnect the new TLS socket to the
1143
1248
  // parser. The plain socket becomes the underlying transport for TLS.
1144
1249
  this.socket.unpipe(this.streamer);
1250
+
1251
+ // Check 2: now that the parser is detached, any bytes still buffered on the plain
1252
+ // socket arrived after the OK and were not consumed by the handshake — i.e. injected.
1253
+ // This catches late/fragmented injection that the parse-time snapshot cannot see.
1254
+ let injectedTail = typeof this.socket.read === 'function' ? this.socket.read() : null;
1255
+ if (injectedTail && injectedTail.length) {
1256
+ throw failSTARTTLSInjection();
1257
+ }
1145
1258
  let upgraded = await new Promise((resolve, reject) => {
1259
+ // Expose this rejector so emitError() can settle the upgrade with a streamer error.
1260
+ this._upgradeReject = reject;
1146
1261
  let socketPlain = this.socket;
1147
1262
  let opts = Object.assign(
1148
1263
  {
@@ -1180,6 +1295,24 @@ class ImapFlow extends EventEmitter {
1180
1295
  reject(err);
1181
1296
  }, UPGRADE_TIMEOUT);
1182
1297
 
1298
+ // A TLS handshake failure (bad certificate, protocol mismatch, etc.) is emitted on the
1299
+ // new TLS socket, not on the plain socket, so it must be handled here. Without this the
1300
+ // upgrade promise would only settle via the timeout and connect() would reject with a
1301
+ // generic "Unexpected close" instead of the actual TLS error.
1302
+ const tlsSocketErrorHandler = err => {
1303
+ clearTimeout(this.connectTimeout);
1304
+ clearTimeout(this.upgradeTimeout);
1305
+ if (!this.upgrading) {
1306
+ // already settled
1307
+ return;
1308
+ }
1309
+ this.upgrading = false;
1310
+ err.tlsFailed = true;
1311
+ this.clearSocketHandlers();
1312
+ this.closeAfter();
1313
+ reject(err);
1314
+ };
1315
+
1183
1316
  this.upgrading = true;
1184
1317
  this.socket = tls.connect(opts, () => {
1185
1318
  try {
@@ -1208,18 +1341,33 @@ class ImapFlow extends EventEmitter {
1208
1341
  });
1209
1342
  }
1210
1343
 
1211
- // Clean up the plain socket error handler after successful upgrade
1344
+ // Clean up the error handlers after successful upgrade
1212
1345
  socketPlain.removeListener('error', socketPlainErrorHandler);
1346
+ this.socket.removeListener('error', tlsSocketErrorHandler);
1347
+
1348
+ // Install the normal socket handlers only now that the handshake
1349
+ // succeeded. Doing this during the handshake would leave both
1350
+ // tlsSocketErrorHandler and the generic _socketError on the socket;
1351
+ // a handshake 'error' would then fire BOTH (EventEmitter clones its
1352
+ // listener array on emit), causing a duplicate error and a possible
1353
+ // unhandled 'error' crash. Keeping tlsSocketErrorHandler as the sole
1354
+ // listener until here guarantees a single error path for the upgrade.
1355
+ this.setSocketHandlers();
1213
1356
 
1357
+ this._upgradeReject = null;
1214
1358
  return resolve(true);
1215
1359
  } catch (ex) {
1216
1360
  this.emitError(ex);
1217
1361
  }
1218
1362
  });
1219
1363
 
1220
- this.writeSocket = this.socket;
1364
+ // Registered after tls.connect (the TLS socket now exists). This is the ONLY
1365
+ // error listener during the handshake window; the generic handlers are installed
1366
+ // by setSocketHandlers() inside the success callback above, so a handshake error
1367
+ // has a single error path (tlsSocketErrorHandler -> reject).
1368
+ this.socket.once('error', tlsSocketErrorHandler);
1221
1369
 
1222
- this.setSocketHandlers();
1370
+ this.writeSocket = this.socket;
1223
1371
  });
1224
1372
 
1225
1373
  if (upgraded && this.expectCapabilityUpdate) {
@@ -1667,6 +1815,11 @@ class ImapFlow extends EventEmitter {
1667
1815
  let onConnect = () => {
1668
1816
  try {
1669
1817
  clearTimeout(this.connectTimeout);
1818
+
1819
+ // ImapFlow now owns the socket; drop the proxy's early error handler
1820
+ // (its "before connection setup" message no longer applies).
1821
+ detachEarlyErrorHandler(socket);
1822
+
1670
1823
  this.socket.setKeepAlive(true, 5 * 1000);
1671
1824
  this.socket.setTimeout(this.options.socketTimeout || SOCKET_TIMEOUT);
1672
1825
 
@@ -1777,6 +1930,17 @@ class ImapFlow extends EventEmitter {
1777
1930
  setImmediate(() => this.close());
1778
1931
  }
1779
1932
 
1933
+ // Builds the standard "connection not available" error, optionally annotated with the
1934
+ // server's BYE reason. Single source of truth so every NoConnection rejection is consistent.
1935
+ createNoConnectionError(byeReason) {
1936
+ const error = new Error('Connection not available');
1937
+ error.code = 'NoConnection';
1938
+ if (byeReason) {
1939
+ error.reason = byeReason;
1940
+ }
1941
+ return error;
1942
+ }
1943
+
1780
1944
  /**
1781
1945
  * Closes TCP connection without notifying the server.
1782
1946
  *
@@ -1794,6 +1958,15 @@ class ImapFlow extends EventEmitter {
1794
1958
  clearTimeout(this.connectTimeout);
1795
1959
  clearTimeout(this.greetingTimeout);
1796
1960
 
1961
+ // Abort any in-flight throttle back-off so the reader unblocks and the
1962
+ // throttled request is rejected promptly rather than after the full delay.
1963
+ clearTimeout(this._throttleTimer);
1964
+ this._throttleTimer = null;
1965
+ if (typeof this._throttleAbort === 'function') {
1966
+ this._throttleAbort(true);
1967
+ this._throttleAbort = null;
1968
+ }
1969
+
1797
1970
  this.usable = false;
1798
1971
  this.idling = false;
1799
1972
 
@@ -1804,6 +1977,11 @@ class ImapFlow extends EventEmitter {
1804
1977
  this.initialReject = false;
1805
1978
  let err = new Error('Unexpected close');
1806
1979
  err.code = `ClosedAfterConnect${this.secureConnection ? 'TLS' : 'Text'}`;
1980
+ // Surface the server's BYE reason (e.g. "Too many connections") when the
1981
+ // connection was closed by an untagged BYE, so the caller sees why.
1982
+ if (this.byeReason) {
1983
+ err.reason = this.byeReason;
1984
+ }
1807
1985
  // Synchronous rejection is safe: connectPromise.catch(noop) is already
1808
1986
  // attached, so the rejection is observed immediately. close() is synchronous,
1809
1987
  // so all cleanup completes before any microtask rejection handler runs.
@@ -1840,15 +2018,8 @@ class ImapFlow extends EventEmitter {
1840
2018
  }
1841
2019
  }
1842
2020
 
1843
- // Helper to create connection error
1844
- const createNoConnectionError = byeReason => {
1845
- const error = new Error('Connection not available');
1846
- error.code = 'NoConnection';
1847
- if (byeReason) {
1848
- error.reason = byeReason;
1849
- }
1850
- return error;
1851
- };
2021
+ // Helper to create connection error (delegates to the shared builder)
2022
+ const createNoConnectionError = byeReason => this.createNoConnectionError(byeReason);
1852
2023
 
1853
2024
  // Reject pending requests and locks synchronously. Each exec() and
1854
2025
  // getMailboxLock() promise already has .catch(noop) attached, so the
@@ -3829,6 +4000,7 @@ class ImapFlow extends EventEmitter {
3829
4000
  * @returns {Object} Socket objects
3830
4001
  * @returns {Object} return.readSocket The read socket (inflated socket if compression is enabled, raw socket otherwise)
3831
4002
  * @returns {Object} return.writeSocket The write socket
4003
+ * @returns {Object} return.socket The raw underlying socket (same as readSocket/writeSocket when compression is disabled)
3832
4004
  */
3833
4005
  unbind() {
3834
4006
  this.socket.unpipe(this.streamer);
@@ -3836,15 +4008,32 @@ class ImapFlow extends EventEmitter {
3836
4008
  this._inflate.unpipe(this.streamer);
3837
4009
  }
3838
4010
 
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);
4011
+ // Detach all of ImapFlow's socket listeners — the raw socket plus, when
4012
+ // compression is active, the PassThrough writeSocket — so the connection
4013
+ // is fully released to the caller.
4014
+ this.clearSocketHandlers();
4015
+
4016
+ const readSocket = this._inflate || this.socket;
4017
+ const writeSocket = this.writeSocket || this.socket;
4018
+
4019
+ // Defense-in-depth: when compression is active the raw socket is orphaned
4020
+ // (neither readSocket nor writeSocket) yet still live and still the target
4021
+ // of the deflate/writeSocket error forwarders. We just stripped our own
4022
+ // error listener, so any post-unbind error (e.g. an upstream ECONNRESET)
4023
+ // would become an unhandled 'error' that crashes the host process. Attach
4024
+ // a benign listener so the orphaned socket can never throw after handoff.
4025
+ // Non-compression path: socket === readSocket === writeSocket and the
4026
+ // caller owns it directly, so leave it untouched (no behavior change).
4027
+ if (this.socket !== readSocket && this.socket !== writeSocket) {
4028
+ this.socket.on('error', err => {
4029
+ this.log.debug({ msg: 'Suppressed error on unbound socket', err, cid: this.id });
4030
+ });
4031
+ }
3844
4032
 
3845
4033
  return {
3846
- readSocket: this._inflate || this.socket,
3847
- writeSocket: this.writeSocket || this.socket
4034
+ readSocket,
4035
+ writeSocket,
4036
+ socket: this.socket
3848
4037
  };
3849
4038
  }
3850
4039
  }
@@ -15,6 +15,29 @@ const hidePassword = proxyUrl => {
15
15
  }
16
16
  };
17
17
 
18
+ // Attaches a benign 'error' listener as soon as the proxied socket exists, so an early
19
+ // socket error (before ImapFlow installs its own handlers) cannot surface as an unhandled
20
+ // 'error' event and crash the process. The handler is stored on the socket so the caller
21
+ // can remove it once it takes ownership of the socket.
22
+ const attachEarlyErrorHandler = (logger, socket) => {
23
+ if (!socket || typeof socket.on !== 'function') {
24
+ return;
25
+ }
26
+ socket._earlyErrorHandler = err => {
27
+ logger.error({ msg: 'Proxy socket error before connection setup', err });
28
+ };
29
+ socket.on('error', socket._earlyErrorHandler);
30
+ };
31
+
32
+ // Removes the handler installed by attachEarlyErrorHandler once the caller takes ownership
33
+ // of the socket. Keeps the internal `_earlyErrorHandler` contract inside this module.
34
+ const detachEarlyErrorHandler = socket => {
35
+ if (socket && socket._earlyErrorHandler) {
36
+ socket.removeListener('error', socket._earlyErrorHandler);
37
+ socket._earlyErrorHandler = null;
38
+ }
39
+ };
40
+
18
41
  const proxyConnection = async (logger, connectionUrl, host, port) => {
19
42
  let proxyUrl = new URL(connectionUrl);
20
43
 
@@ -44,6 +67,7 @@ const proxyConnection = async (logger, connectionUrl, host, port) => {
44
67
  port,
45
68
  host
46
69
  });
70
+ attachEarlyErrorHandler(logger, socket);
47
71
  }
48
72
  return socket;
49
73
  } catch (err) {
@@ -106,6 +130,7 @@ const proxyConnection = async (logger, connectionUrl, host, port) => {
106
130
  port,
107
131
  host
108
132
  });
133
+ attachEarlyErrorHandler(logger, info.socket);
109
134
  }
110
135
  return info.socket;
111
136
  } catch (err) {
@@ -123,4 +148,4 @@ const proxyConnection = async (logger, connectionUrl, host, port) => {
123
148
  }
124
149
  };
125
150
 
126
- module.exports = { proxyConnection };
151
+ module.exports = { proxyConnection, detachEarlyErrorHandler };
package/lib/tools.js CHANGED
@@ -494,7 +494,7 @@ const tools = {
494
494
 
495
495
  // normalize path to use ascii, so we would always get the same ID
496
496
  let path = mailbox.path;
497
- if (/[0x80-0xff]/.test(path)) {
497
+ if (/[\u0080-\uffff]/.test(path)) {
498
498
  try {
499
499
  path = iconv.encode(path, 'utf-7-imap').toString();
500
500
  } catch {
@@ -502,6 +502,10 @@ const tools = {
502
502
  }
503
503
  }
504
504
 
505
+ // Non-cryptographic identifier: MD5 is used only to derive a stable, compact
506
+ // account-unique id from non-secret data (path:uidValidity:uid). No security
507
+ // property (collision/preimage resistance, secrecy) is relied upon, so a fast
508
+ // hash is the appropriate choice here — not a security-sensitive use.
505
509
  map.id =
506
510
  map.emailId ||
507
511
  createHash('md5')
@@ -1069,7 +1073,9 @@ const tools = {
1069
1073
  return '';
1070
1074
  }
1071
1075
 
1072
- list.sort((a, b) => a - b);
1076
+ // Deduplicate before sorting so that repeated values do not produce
1077
+ // overlapping/non-canonical tokens (e.g. [1,1,2,3] -> "1:3", not "1,1:3").
1078
+ list = Array.from(new Set(list)).sort((a, b) => a - b);
1073
1079
 
1074
1080
  let last = list[list.length - 1];
1075
1081
  let result = [[last]];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -30,7 +30,7 @@
30
30
  "@eslint/js": "10.0.1",
31
31
  "@types/node": "25.9.1",
32
32
  "c8": "11.0.0",
33
- "eslint": "10.4.0",
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",
@@ -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({