imapflow 2.4.1 → 2.4.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.3](https://github.com/postalsys/imapflow/compare/v2.4.2...v2.4.3) (2026-10-15)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * fail the command on a NO or BAD status line without a tag ([5a38e17](https://github.com/postalsys/imapflow/commit/5a38e171ad8405583b090872076f464c4dc6f0e2))
9
+ * send ID after STARTTLS instead of before it ([7d6f37a](https://github.com/postalsys/imapflow/commit/7d6f37a4e8eb2f9b0f921f6b004a1ba5e45b1867))
10
+ * send ID after STARTTLS instead of before it ([197ecdc](https://github.com/postalsys/imapflow/commit/197ecdc49f2f64c45ee98c4e4284534662e26942))
11
+
12
+ ## [2.4.2](https://github.com/postalsys/imapflow/compare/v2.4.1...v2.4.2) (2026-10-14)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **deps:** update libmime to 5.4.8 and mailsplit to 5.4.21 ([8eafb46](https://github.com/postalsys/imapflow/commit/8eafb46f858893670dda1be115899b8b761391ab))
18
+
3
19
  ## [2.4.1](https://github.com/postalsys/imapflow/compare/v2.4.0...v2.4.1) (2026-10-14)
4
20
 
5
21
 
@@ -20,6 +20,7 @@ export declare const ImapFlowErrorCode: {
20
20
  readonly LockTimeout: "LockTimeout";
21
21
  readonly ETHROTTLE: "ETHROTTLE";
22
22
  readonly UnexpectedTag: "UnexpectedTag";
23
+ readonly MissingTag: "MissingTag";
23
24
  readonly InvalidResponse: "InvalidResponse";
24
25
  readonly ResponseProcessingFailed: "ResponseProcessingFailed";
25
26
  readonly STARTTLS_INJECTION: "STARTTLS_INJECTION";
@@ -26,6 +26,7 @@ exports.ImapFlowErrorCode = {
26
26
  // the server
27
27
  ETHROTTLE: 'ETHROTTLE',
28
28
  UnexpectedTag: 'UnexpectedTag',
29
+ MissingTag: 'MissingTag',
29
30
  InvalidResponse: 'InvalidResponse',
30
31
  ResponseProcessingFailed: 'ResponseProcessingFailed',
31
32
  STARTTLS_INJECTION: 'STARTTLS_INJECTION',
@@ -781,7 +781,7 @@ class ImapFlow extends node_events_1.EventEmitter {
781
781
  */
782
782
  rejectUnparsedCompletion(payload, parserError) {
783
783
  if (!this.currentRequest || !this.currentRequest.sent) {
784
- return;
784
+ return false;
785
785
  }
786
786
  // Prefer the tag the parser had already extracted before it failed - it went
787
787
  // through the same leading-NUL workaround as every parsed response. Fall back
@@ -793,11 +793,45 @@ class ImapFlow extends node_events_1.EventEmitter {
793
793
  tag = match && match[1];
794
794
  }
795
795
  if (!tag || tag !== this.currentRequest.tag) {
796
- return;
796
+ return false;
797
797
  }
798
798
  let err = new Error('Failed to parse the server response for this command');
799
799
  err.code = parserError.code || 'ParserError';
800
800
  err.parserError = parserError;
801
+ this.failInFlight(err);
802
+ return true;
803
+ }
804
+ /**
805
+ * Fails the in-flight command when the server answered it with a NO or BAD status line that
806
+ * has no tag at all (Namecheap Private Email: `BAD "Must issue a STARTTLS command first"`).
807
+ * Only one command is ever on the wire, and leaving it pending would stall the connection
808
+ * until the socket timeout. Tags are uppercase hex, so a command can carry the tag BAD:
809
+ * callers check for the command's own tag first.
810
+ *
811
+ * @param payload - Raw bytes of the line.
812
+ * @returns `true` when the line failed the in-flight command
813
+ * @internal
814
+ */
815
+ rejectTaglessCompletion(payload) {
816
+ if (!this.currentRequest || !this.currentRequest.sent) {
817
+ return false;
818
+ }
819
+ let match = payload.toString('latin1', 0, 1024).match(/^\0*(BAD|NO)(?:\s+([^]*))?$/i);
820
+ if (!match) {
821
+ return false;
822
+ }
823
+ let err = (0, errors_js_1.createImapError)('Command failed', 'MissingTag', {
824
+ command: this.currentRequest.command,
825
+ responseStatus: match[1].toUpperCase(),
826
+ responseText: (match[2] || '').trim().replace(/^"(.*)"$/, '$1')
827
+ });
828
+ this.log.warn({ msg: 'Server sent a status response without a tag', err, cid: this.id });
829
+ this.failInFlight(err);
830
+ return true;
831
+ }
832
+ // Rejects the in-flight command and moves on to the next queued one
833
+ /** @internal */
834
+ failInFlight(err) {
801
835
  this.rejectCurrentRequest(err);
802
836
  this.trySend().catch(sendErr => (0, tools_js_1.logConnectionError)(this, 'Failed to dispatch command', sendErr));
803
837
  }
@@ -847,7 +881,9 @@ class ImapFlow extends node_events_1.EventEmitter {
847
881
  // timeout fires. The tag is recovered from the raw bytes (a tag is
848
882
  // ASTRING-CHAR only, so it survives whatever made the rest unparseable) and
849
883
  // the command is failed with the parser error instead of hanging.
850
- this.rejectUnparsedCompletion(data.payload, err);
884
+ if (!this.rejectUnparsedCompletion(data.payload, err)) {
885
+ this.rejectTaglessCompletion(data.payload);
886
+ }
851
887
  return true;
852
888
  }
853
889
  if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
@@ -952,7 +988,8 @@ class ImapFlow extends node_events_1.EventEmitter {
952
988
  // actually written to the socket (invariant 2), so the three cases below are kept apart:
953
989
  // the active command completes, a command that has not been written yet is proof of
954
990
  // desynchronization (queued behind another command, or current but not yet on the wire),
955
- // and an entirely unknown tag is recorded but tolerated.
991
+ // and an entirely unknown tag is recorded but tolerated, unless the line is a NO or BAD
992
+ // that came without a tag (see rejectTaglessCompletion()).
956
993
  if (parsed.tag && !['*', '+'].includes(parsed.tag)) {
957
994
  if (this.currentRequest && this.currentRequest.tag === parsed.tag && this.currentRequest.sent) {
958
995
  let request = this.takeCurrentRequest();
@@ -990,7 +1027,7 @@ class ImapFlow extends node_events_1.EventEmitter {
990
1027
  this.failProtocol(err);
991
1028
  return false;
992
1029
  }
993
- else {
1030
+ else if (!this.rejectTaglessCompletion(data.payload)) {
994
1031
  this.countUnknownTag(parsed.tag);
995
1032
  }
996
1033
  }
@@ -1266,10 +1303,14 @@ class ImapFlow extends node_events_1.EventEmitter {
1266
1303
  /** @internal */
1267
1304
  async startSession() {
1268
1305
  await this.run('CAPABILITY');
1306
+ await this.upgradeToSTARTTLS();
1307
+ // ID goes after STARTTLS: the client info should not travel in cleartext, and some
1308
+ // servers refuse every command but STARTTLS before the upgrade with an untagged BAD
1309
+ // the client can not tie to ID, which then waits for the socket timeout (Namecheap
1310
+ // Private Email). The capability list was refreshed by the upgrade.
1269
1311
  if (this.capabilities.has('ID')) {
1270
1312
  this.idRequested = await this.run('ID', this.clientInfo);
1271
1313
  }
1272
- await this.upgradeToSTARTTLS();
1273
1314
  await this.authenticate();
1274
1315
  if ((!this.idRequested || Object.keys(this.idRequested).length < 2) && this.capabilities.has('ID')) {
1275
1316
  // re-request ID after LOGIN
@@ -1,3 +1,3 @@
1
1
  export declare const name = "imapflow";
2
- export declare const version = "2.4.1";
2
+ export declare const version = "2.4.3";
3
3
  export declare const homepage = "https://imapflow.com/";
@@ -3,5 +3,5 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.homepage = exports.version = exports.name = void 0;
5
5
  exports.name = "imapflow";
6
- exports.version = "2.4.1";
6
+ exports.version = "2.4.3";
7
7
  exports.homepage = "https://imapflow.com/";
@@ -20,6 +20,7 @@ export declare const ImapFlowErrorCode: {
20
20
  readonly LockTimeout: "LockTimeout";
21
21
  readonly ETHROTTLE: "ETHROTTLE";
22
22
  readonly UnexpectedTag: "UnexpectedTag";
23
+ readonly MissingTag: "MissingTag";
23
24
  readonly InvalidResponse: "InvalidResponse";
24
25
  readonly ResponseProcessingFailed: "ResponseProcessingFailed";
25
26
  readonly STARTTLS_INJECTION: "STARTTLS_INJECTION";
@@ -22,6 +22,7 @@ export const ImapFlowErrorCode = {
22
22
  // the server
23
23
  ETHROTTLE: 'ETHROTTLE',
24
24
  UnexpectedTag: 'UnexpectedTag',
25
+ MissingTag: 'MissingTag',
25
26
  InvalidResponse: 'InvalidResponse',
26
27
  ResponseProcessingFailed: 'ResponseProcessingFailed',
27
28
  STARTTLS_INJECTION: 'STARTTLS_INJECTION',
@@ -740,7 +740,7 @@ export class ImapFlow extends EventEmitter {
740
740
  */
741
741
  rejectUnparsedCompletion(payload, parserError) {
742
742
  if (!this.currentRequest || !this.currentRequest.sent) {
743
- return;
743
+ return false;
744
744
  }
745
745
  // Prefer the tag the parser had already extracted before it failed - it went
746
746
  // through the same leading-NUL workaround as every parsed response. Fall back
@@ -752,11 +752,45 @@ export class ImapFlow extends EventEmitter {
752
752
  tag = match && match[1];
753
753
  }
754
754
  if (!tag || tag !== this.currentRequest.tag) {
755
- return;
755
+ return false;
756
756
  }
757
757
  let err = new Error('Failed to parse the server response for this command');
758
758
  err.code = parserError.code || 'ParserError';
759
759
  err.parserError = parserError;
760
+ this.failInFlight(err);
761
+ return true;
762
+ }
763
+ /**
764
+ * Fails the in-flight command when the server answered it with a NO or BAD status line that
765
+ * has no tag at all (Namecheap Private Email: `BAD "Must issue a STARTTLS command first"`).
766
+ * Only one command is ever on the wire, and leaving it pending would stall the connection
767
+ * until the socket timeout. Tags are uppercase hex, so a command can carry the tag BAD:
768
+ * callers check for the command's own tag first.
769
+ *
770
+ * @param payload - Raw bytes of the line.
771
+ * @returns `true` when the line failed the in-flight command
772
+ * @internal
773
+ */
774
+ rejectTaglessCompletion(payload) {
775
+ if (!this.currentRequest || !this.currentRequest.sent) {
776
+ return false;
777
+ }
778
+ let match = payload.toString('latin1', 0, 1024).match(/^\0*(BAD|NO)(?:\s+([^]*))?$/i);
779
+ if (!match) {
780
+ return false;
781
+ }
782
+ let err = createImapError('Command failed', 'MissingTag', {
783
+ command: this.currentRequest.command,
784
+ responseStatus: match[1].toUpperCase(),
785
+ responseText: (match[2] || '').trim().replace(/^"(.*)"$/, '$1')
786
+ });
787
+ this.log.warn({ msg: 'Server sent a status response without a tag', err, cid: this.id });
788
+ this.failInFlight(err);
789
+ return true;
790
+ }
791
+ // Rejects the in-flight command and moves on to the next queued one
792
+ /** @internal */
793
+ failInFlight(err) {
760
794
  this.rejectCurrentRequest(err);
761
795
  this.trySend().catch(sendErr => logConnectionError(this, 'Failed to dispatch command', sendErr));
762
796
  }
@@ -806,7 +840,9 @@ export class ImapFlow extends EventEmitter {
806
840
  // timeout fires. The tag is recovered from the raw bytes (a tag is
807
841
  // ASTRING-CHAR only, so it survives whatever made the rest unparseable) and
808
842
  // the command is failed with the parser error instead of hanging.
809
- this.rejectUnparsedCompletion(data.payload, err);
843
+ if (!this.rejectUnparsedCompletion(data.payload, err)) {
844
+ this.rejectTaglessCompletion(data.payload);
845
+ }
810
846
  return true;
811
847
  }
812
848
  if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
@@ -911,7 +947,8 @@ export class ImapFlow extends EventEmitter {
911
947
  // actually written to the socket (invariant 2), so the three cases below are kept apart:
912
948
  // the active command completes, a command that has not been written yet is proof of
913
949
  // desynchronization (queued behind another command, or current but not yet on the wire),
914
- // and an entirely unknown tag is recorded but tolerated.
950
+ // and an entirely unknown tag is recorded but tolerated, unless the line is a NO or BAD
951
+ // that came without a tag (see rejectTaglessCompletion()).
915
952
  if (parsed.tag && !['*', '+'].includes(parsed.tag)) {
916
953
  if (this.currentRequest && this.currentRequest.tag === parsed.tag && this.currentRequest.sent) {
917
954
  let request = this.takeCurrentRequest();
@@ -949,7 +986,7 @@ export class ImapFlow extends EventEmitter {
949
986
  this.failProtocol(err);
950
987
  return false;
951
988
  }
952
- else {
989
+ else if (!this.rejectTaglessCompletion(data.payload)) {
953
990
  this.countUnknownTag(parsed.tag);
954
991
  }
955
992
  }
@@ -1225,10 +1262,14 @@ export class ImapFlow extends EventEmitter {
1225
1262
  /** @internal */
1226
1263
  async startSession() {
1227
1264
  await this.run('CAPABILITY');
1265
+ await this.upgradeToSTARTTLS();
1266
+ // ID goes after STARTTLS: the client info should not travel in cleartext, and some
1267
+ // servers refuse every command but STARTTLS before the upgrade with an untagged BAD
1268
+ // the client can not tie to ID, which then waits for the socket timeout (Namecheap
1269
+ // Private Email). The capability list was refreshed by the upgrade.
1228
1270
  if (this.capabilities.has('ID')) {
1229
1271
  this.idRequested = await this.run('ID', this.clientInfo);
1230
1272
  }
1231
- await this.upgradeToSTARTTLS();
1232
1273
  await this.authenticate();
1233
1274
  if ((!this.idRequested || Object.keys(this.idRequested).length < 2) && this.capabilities.has('ID')) {
1234
1275
  // re-request ID after LOGIN
@@ -1,3 +1,3 @@
1
1
  export declare const name = "imapflow";
2
- export declare const version = "2.4.1";
2
+ export declare const version = "2.4.3";
3
3
  export declare const homepage = "https://imapflow.com/";
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/build.js from package.json. Do not edit by hand.
2
2
  export const name = "imapflow";
3
- export const version = "2.4.1";
3
+ export const version = "2.4.3";
4
4
  export const homepage = "https://imapflow.com/";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "2.4.1",
3
+ "version": "2.4.3",
4
4
  "description": "IMAP Client for Node",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/imap-flow.js",
@@ -81,14 +81,14 @@
81
81
  "types-node-legacy": "npm:@types/node@20.0.0",
82
82
  "typescript": "6.0.3",
83
83
  "typescript-eslint": "8.71.2",
84
- "wrangler": "4.150.0"
84
+ "wrangler": "4.151.0"
85
85
  },
86
86
  "dependencies": {
87
- "@zone-eu/mailsplit": "5.4.20",
87
+ "@zone-eu/mailsplit": "5.4.21",
88
88
  "encoding-japanese": "2.4.0",
89
89
  "iconv-lite": "0.7.3",
90
90
  "libbase64": "1.3.3",
91
- "libmime": "5.4.7",
91
+ "libmime": "5.4.8",
92
92
  "libqp": "2.1.2",
93
93
  "pino": "10.3.1",
94
94
  "socks": "2.8.10"