imapflow 1.4.9 → 1.5.0

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.
@@ -29,3 +29,23 @@ jobs:
29
29
  cache: npm
30
30
  - run: npm install
31
31
  - run: npm test
32
+
33
+ test-rev2:
34
+ # Live IMAP4rev2 integration tests against a real Dovecot 2.4 server in
35
+ # Docker on linux/amd64 (ubuntu runners are amd64 with Docker preinstalled).
36
+ # This is the only place the suite runs on amd64 - Apple Silicon dev
37
+ # machines cannot run the amd64 image under Rosetta.
38
+ name: Live IMAP4rev2 tests (Dovecot, linux/amd64)
39
+ timeout-minutes: 15
40
+ runs-on: ubuntu-latest
41
+ steps:
42
+ - uses: actions/checkout@v6
43
+ - name: Use Node.js 24.x
44
+ uses: actions/setup-node@v6
45
+ with:
46
+ node-version: 24.x
47
+ cache: npm
48
+ - run: npm install
49
+ - run: npm run test:rev2
50
+ env:
51
+ IMAPFLOW_DOVECOT_PLATFORM: linux/amd64
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.4.9"
2
+ ".": "1.5.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0](https://github.com/postalsys/imapflow/compare/v1.4.9...v1.5.0) (2026-07-23)
4
+
5
+
6
+ ### Features
7
+
8
+ * add STATUS SIZE/DELETED and rev2 BINARY fetch support, fix protocol bugs found in an RFC 9051 review ([04f5bf6](https://github.com/postalsys/imapflow/commit/04f5bf6c1a49e155dd23b2af062e570530b814b1))
9
+
3
10
  ## [1.4.9](https://github.com/postalsys/imapflow/compare/v1.4.8...v1.4.9) (2026-07-22)
4
11
 
5
12
 
package/CLAUDE.md CHANGED
@@ -81,7 +81,7 @@ CommonJS-compatible:
81
81
  1. Run `npm run format` and `npm run lint`
82
82
  2. Run `npm test` and keep it green
83
83
  3. For non-trivial changes, run `/simplify` to review changed code and `/security-review` to check for security issues before committing
84
- - After pushing, check the GitHub Actions runs for the push (e.g. `gh run list --branch master`) and report their status, including the CodeQL "CodeQL Advanced" code-scanning run. If a run fails for a strange or unrelated reason (for example a checkout step reporting "account suspended", HTTP 403, or other auth/infrastructure errors that have nothing to do with the change), check <https://www.githubstatus.com/> for an active GitHub incident before assuming the failure is caused by the change.
84
+ - After pushing, check the GitHub Actions runs for the push (e.g. `gh run list --branch master`) and report their status. If a run fails for a strange or unrelated reason (for example a checkout step reporting "account suspended", HTTP 403, or other auth/infrastructure errors that have nothing to do with the change), check <https://www.githubstatus.com/> for an active GitHub incident before assuming the failure is caused by the change.
85
85
 
86
86
  ## Relationship to EmailEngine
87
87
 
@@ -97,9 +97,7 @@ Packaging Constraints).
97
97
  ## Security
98
98
 
99
99
  Security policy and private reporting channels are documented in
100
- [`SECURITY.md`](SECURITY.md) / [`SECURITY.txt`](SECURITY.txt). Code scanning runs
101
- through the "CodeQL Advanced" GitHub Actions workflow
102
- (`.github/workflows/codeql.yml`, config in `.github/codeql/codeql-config.yml`).
100
+ [`SECURITY.md`](SECURITY.md) / [`SECURITY.txt`](SECURITY.txt).
103
101
 
104
102
  ## Release Process
105
103
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { formatMessageResponse } = require('../tools');
3
+ const { formatMessageResponse, isRev2Active } = require('../tools');
4
4
 
5
5
  /**
6
6
  * Fetches emails from the server.
@@ -25,8 +25,12 @@ module.exports = async (connection, range, query, options) => {
25
25
 
26
26
  let mailbox = connection.mailbox;
27
27
 
28
- // Use BINARY extension for fetching if supported and requested, otherwise fall back to BODY
29
- const commandKey = connection.capabilities.has('BINARY') && options.binary && !connection.disableBinary ? 'BINARY' : 'BODY';
28
+ // Use BINARY extension for fetching if supported and requested, otherwise fall back to BODY.
29
+ // RFC 9051 folds the FETCH side of the BINARY extension into base IMAP4rev2, so an active
30
+ // rev2 session can use it even without the BINARY capability token (the APPEND side is NOT
31
+ // folded in and stays gated on the token in append.js)
32
+ const canUseBinary = connection.capabilities.has('BINARY') || isRev2Active(connection);
33
+ const commandKey = canUseBinary && options.binary && !connection.disableBinary ? 'BINARY' : 'BODY';
30
34
 
31
35
  // Retry logic for ETHROTTLE errors (server rate limiting) with exponential backoff
32
36
  let retryCount = 0;
@@ -50,21 +54,21 @@ module.exports = async (connection, range, query, options) => {
50
54
  // PEEK avoids marking messages as \Seen. Section identifies what to fetch (HEADER, specific part, etc.)
51
55
  // Partial is an optional byte range [start, maxLength].
52
56
  let setBodyPeek = (attributes, partial) => {
57
+ let section = [].concat(attributes || []);
58
+
59
+ // BINARY may only address the empty section or a numeric part specifier
60
+ // (RFC 3516 / RFC 9051 section-binary) - HEADER, HEADER.FIELDS, TEXT and
61
+ // n.MIME are invalid after BINARY and must stay BODY fetches
62
+ let binaryAddressable =
63
+ !section.length || (section.length === 1 && typeof section[0].value === 'string' && /^\d+(\.\d+)*$/.test(section[0].value));
64
+
53
65
  let bodyPeek = {
54
66
  type: 'ATOM',
55
- value: `${commandKey}.PEEK`,
56
- section: [],
67
+ value: `${binaryAddressable ? commandKey : 'BODY'}.PEEK`,
68
+ section,
57
69
  partial
58
70
  };
59
71
 
60
- if (Array.isArray(attributes)) {
61
- attributes.forEach(attribute => {
62
- bodyPeek.section.push(attribute);
63
- });
64
- } else if (attributes) {
65
- bodyPeek.section.push(attributes);
66
- }
67
-
68
72
  queryStructure.push(bodyPeek);
69
73
  };
70
74
 
@@ -88,7 +92,7 @@ module.exports = async (connection, range, query, options) => {
88
92
  partial.push(Number(query.source.maxLength));
89
93
  }
90
94
  }
91
- queryStructure.push({ type: 'ATOM', value: `${commandKey}.PEEK`, section: [], partial });
95
+ setBodyPeek(null, partial);
92
96
  }
93
97
 
94
98
  // Always request a unique email ID for message deduplication.
@@ -235,7 +235,10 @@ module.exports = async (connection, reference, mailbox, options) => {
235
235
  UIDNEXT: { key: 'uidNext', parser: Number },
236
236
  UIDVALIDITY: { key: 'uidValidity', parser: BigInt },
237
237
  UNSEEN: { key: 'unseen', parser: Number },
238
- HIGHESTMODSEQ: { key: 'highestModseq', parser: BigInt }
238
+ HIGHESTMODSEQ: { key: 'highestModseq', parser: BigInt },
239
+ // IMAP4rev2 additions (RFC 9051): mailbox size and \Deleted count
240
+ SIZE: { key: 'size', parser: Number },
241
+ DELETED: { key: 'deleted', parser: Number }
239
242
  };
240
243
 
241
244
  let key;
@@ -86,7 +86,12 @@ module.exports = async (connection, path, query) => {
86
86
  updateMailbox: (val, conn) => {
87
87
  conn.mailbox.highestModseq = val;
88
88
  }
89
- }
89
+ },
90
+ // IMAP4rev2 additions (RFC 9051): total mailbox size in octets
91
+ // (number64, exact as a JS number up to 2^53-1) and count of
92
+ // messages with the \Deleted flag
93
+ SIZE: { key: 'size', parser: Number },
94
+ DELETED: { key: 'deleted', parser: Number }
90
95
  };
91
96
 
92
97
  let key;
@@ -130,15 +130,18 @@ module.exports = async (response, options) => {
130
130
  if (isLogging) {
131
131
  resp.push(formatRespEntry('"(* ' + node.value.length + 'B literal *)"'));
132
132
  } else {
133
- let literalLength = !node.value ? 0 : Math.max(node.value.length, 0);
134
-
135
- // canAppend: whether the literal data can be sent in the same buffer segment.
136
- // With LITERAL+ (RFC 7888) the client does not wait for a continuation response.
137
- // With LITERAL- (RFC 7888) the client can skip the wait only for literals <= 4096 bytes.
138
- // When asArray is false we always append inline (single-buffer mode).
139
- let canAppend = !asArray || literalPlus || (literalMinus && literalLength <= 4096);
140
- // Append '+' to the size marker when using LITERAL+ or LITERAL- (non-synchronizing)
141
- let usePlus = canAppend && (literalMinus || literalPlus);
133
+ // The literal size marker counts octets - string values are written as
134
+ // UTF-8, so their UTF-16 .length would undercount multi-byte characters
135
+ let literalLength = !node.value ? 0 : Buffer.isBuffer(node.value) ? node.value.length : Buffer.byteLength(node.value.toString());
136
+
137
+ // Append '+' to the size marker only when the extension actually permits a
138
+ // non-synchronizing literal of this size (RFC 7888): LITERAL+ always,
139
+ // LITERAL- only up to 4096 bytes
140
+ let usePlus = literalPlus || (literalMinus && literalLength <= 4096);
141
+ // canAppend: whether the literal data can be sent in the same buffer segment -
142
+ // non-synchronizing literals always, and everything in single-buffer mode
143
+ // (asArray false), which has no continuation flow
144
+ let canAppend = !asArray || usePlus;
142
145
 
143
146
  // Emit the literal header: optional '~' prefix for literal8, then {size[+]}\r\n
144
147
  resp.push(formatRespEntry(`${node.isLiteral8 ? '~' : ''}{${literalLength}${usePlus ? '+' : ''}}\r\n`));
@@ -580,6 +580,13 @@ class TokenParser {
580
580
  if (!this.currentNode.literalLength) {
581
581
  // special case where literal content length is 0
582
582
  // close the node right away, do not wait for additional input
583
+ if (this.options.literals && this.options.literals.length) {
584
+ // ImapStream queues a Buffer for every literal marker it
585
+ // extracts, including {0} - consume the queue entry so
586
+ // subsequent literals in the same response stay aligned
587
+ // with their markers instead of shifting by one
588
+ this.currentNode.value = this.options.literals.shift();
589
+ }
583
590
  this.currentNode.endPos = this.pos + i;
584
591
  this.currentNode.isClosed = true;
585
592
  this.currentNode = this.currentNode.parentNode;
@@ -207,6 +207,10 @@ export interface ListOptions {
207
207
  unseen?: boolean;
208
208
  /** If true request last known modseq value */
209
209
  highestModseq?: boolean;
210
+ /** If true request total mailbox size in octets (requires STATUS=SIZE or IMAP4rev2) */
211
+ size?: boolean;
212
+ /** If true request count of messages with \Deleted flag (requires IMAP4rev2) */
213
+ deleted?: boolean;
210
214
  };
211
215
  /** Set specific paths as special use folders */
212
216
  specialUseHints?: {
@@ -282,6 +286,10 @@ export interface StatusObject {
282
286
  unseen?: number;
283
287
  /** Last known modseq value (if CONDSTORE extension is enabled) */
284
288
  highestModseq?: bigint;
289
+ /** Total size of the mailbox in octets (only if requested and the server supports STATUS=SIZE or IMAP4rev2) */
290
+ size?: number;
291
+ /** Count of messages with \Deleted flag (only if requested and IMAP4rev2 is active) */
292
+ deleted?: number;
285
293
  }
286
294
 
287
295
  export type SequenceString = string | number | bigint;
@@ -493,6 +501,8 @@ export interface FetchMessageObject {
493
501
  internalDate?: Date | string;
494
502
  /** A Map of message body parts where key is requested part identifier and value is a Buffer */
495
503
  bodyParts?: Map<string, Buffer>;
504
+ /** Part identifiers from bodyParts that arrived via FETCH BINARY, i.e. with the content-transfer-encoding already decoded by the server */
505
+ binaryParts?: Set<string>;
496
506
  /** Requested header lines as Buffer */
497
507
  headers?: Buffer;
498
508
  /** Account unique ID for this email */
@@ -755,6 +765,10 @@ export class ImapFlow extends EventEmitter {
755
765
  uidValidity?: boolean;
756
766
  unseen?: boolean;
757
767
  highestModseq?: boolean;
768
+ /** Requires STATUS=SIZE or IMAP4rev2 */
769
+ size?: boolean;
770
+ /** Requires IMAP4rev2 */
771
+ deleted?: boolean;
758
772
  }
759
773
  ): Promise<StatusObject>;
760
774
 
package/lib/imap-flow.js CHANGED
@@ -1541,6 +1541,9 @@ class ImapFlow extends EventEmitter {
1541
1541
  return;
1542
1542
  }
1543
1543
  this.state = this.states.AUTHENTICATED;
1544
+ // documented contract for the `authenticated` property: `true` when the
1545
+ // connection was authenticated by a PREAUTH greeting (no credentials known)
1546
+ this.authenticated = true;
1544
1547
  this.beginSession(err => {
1545
1548
  this.log.error({ err, cid: this.id });
1546
1549
  this.closeAfter();
@@ -2258,6 +2261,8 @@ class ImapFlow extends EventEmitter {
2258
2261
  * @property {Boolean} [statusQuery.uidValidity] if `true` request mailbox `UIDVALIDITY` value
2259
2262
  * @property {Boolean} [statusQuery.unseen] if `true` request count of unseen messages
2260
2263
  * @property {Boolean} [statusQuery.highestModseq] if `true` request last known modseq value
2264
+ * @property {Boolean} [statusQuery.size] if `true` request total mailbox size in octets (requires STATUS=SIZE or IMAP4rev2)
2265
+ * @property {Boolean} [statusQuery.deleted] if `true` request count of messages with \\Deleted flag (requires IMAP4rev2)
2261
2266
  * @property {Object} [specialUseHints] set specific paths as special use folders, this would override special use flags provided from the server
2262
2267
  * @property {String} [specialUseHints.sent] Path to "Sent Mail" folder
2263
2268
  * @property {String} [specialUseHints.trash] Path to "Trash" folder
@@ -2462,6 +2467,8 @@ class ImapFlow extends EventEmitter {
2462
2467
  * @property {BigInt} [uidValidity] Mailbox `UIDVALIDITY` value
2463
2468
  * @property {Number} [unseen] Count of unseen messages
2464
2469
  * @property {BigInt} [highestModseq] Last known modseq value (if CONDSTORE extension is enabled)
2470
+ * @property {Number} [size] Total size of the mailbox in octets (only if requested and the server supports STATUS=SIZE or IMAP4rev2)
2471
+ * @property {Number} [deleted] Count of messages with \\Deleted flag (only if requested and IMAP4rev2 is active)
2465
2472
  */
2466
2473
 
2467
2474
  /**
@@ -2475,6 +2482,8 @@ class ImapFlow extends EventEmitter {
2475
2482
  * @param {Boolean} query.uidValidity if `true` request mailbox `UIDVALIDITY` value
2476
2483
  * @param {Boolean} query.unseen if `true` request count of unseen messages
2477
2484
  * @param {Boolean} query.highestModseq if `true` request last known modseq value
2485
+ * @param {Boolean} query.size if `true` request total mailbox size in octets (requires STATUS=SIZE or IMAP4rev2)
2486
+ * @param {Boolean} query.deleted if `true` request count of messages with \\Deleted flag (requires IMAP4rev2)
2478
2487
  * @returns {Promise<StatusObject>} status of the indicated mailbox
2479
2488
  *
2480
2489
  * @example
@@ -2969,6 +2978,7 @@ class ImapFlow extends EventEmitter {
2969
2978
  * @property {MessageStructureObject} [bodyStructure] message body structure
2970
2979
  * @property {Date} [internalDate] message internal date
2971
2980
  * @property {Map<string, Buffer>} [bodyParts] a Map of message body parts where key is requested part identifier and value is a Buffer
2981
+ * @property {Set<string>} [binaryParts] part identifiers from `bodyParts` that arrived via FETCH BINARY, i.e. with the content-transfer-encoding already decoded by the server
2972
2982
  * @property {Buffer} [headers] Requested header lines as Buffer
2973
2983
  */
2974
2984
 
@@ -3403,7 +3413,11 @@ class ImapFlow extends EventEmitter {
3403
3413
  // 4. Byte limiter (enforces maxBytes cap)
3404
3414
  // `stream` is the head of the pipeline (where raw chunks are written),
3405
3415
  // `output` is the tail (what the caller reads from).
3406
- switch (meta.encoding) {
3416
+ // Parts that arrived via FETCH BINARY (response.binaryParts) are already
3417
+ // decoded by the server - decoding again would corrupt the data, so stage 1
3418
+ // is skipped for them.
3419
+ let clientEncoding = response.binaryParts && response.binaryParts.has(part) ? false : meta.encoding;
3420
+ switch (clientEncoding) {
3407
3421
  case 'base64':
3408
3422
  output = stream = new libbase64.Decoder();
3409
3423
  break;
@@ -3713,7 +3727,10 @@ class ImapFlow extends EventEmitter {
3713
3727
  for (let part of Object.keys(data)) {
3714
3728
  let meta = data[part].meta;
3715
3729
 
3716
- switch (meta.encoding) {
3730
+ // parts that arrived via FETCH BINARY (response.binaryParts) are already
3731
+ // decoded by the server - decoding again would corrupt the data
3732
+ let clientEncoding = response.binaryParts && response.binaryParts.has(part) ? false : meta.encoding;
3733
+ switch (clientEncoding) {
3717
3734
  case 'base64':
3718
3735
  data[part].content = data[part].content ? libbase64.decode(data[part].content.toString()) : null;
3719
3736
  break;
package/lib/tools.js CHANGED
@@ -19,9 +19,10 @@ const EXPANDED_RANGE_LIMIT = 0x1000000;
19
19
  // Extensions that RFC 9051 (IMAP4rev2) folds into the base protocol (Appendix E).
20
20
  // When IMAP4rev2 is active, these are available even without their own capability
21
21
  // token. BINARY is deliberately excluded - RFC 9051 only folds in the FETCH side,
22
- // and the BINARY consumers have safe fallbacks of their own. The set mirrors the
23
- // Appendix E list in full, including entries no call site consults yet, so any
24
- // future capability check gets the rev2 folding for free.
22
+ // which fetch.js handles with its own isRev2Active check, while the APPEND side
23
+ // stays gated on the BINARY token. The set mirrors the Appendix E list in full,
24
+ // including entries no call site consults yet, so any future capability check
25
+ // gets the rev2 folding for free.
25
26
  const IMAP4REV2_FOLDED_CAPABILITIES = new Set([
26
27
  'ENABLE',
27
28
  'ESEARCH',
@@ -115,6 +116,24 @@ const tools = {
115
116
  attributes.push({ type: 'ATOM', value: key.toUpperCase() });
116
117
  }
117
118
  break;
119
+
120
+ case 'SIZE':
121
+ // STATUS SIZE requires the STATUS=SIZE extension (RFC 8438), which
122
+ // RFC 9051 folds into base IMAP4rev2
123
+ if (tools.hasCapability(connection, 'STATUS=SIZE')) {
124
+ attributes.push({ type: 'ATOM', value: key.toUpperCase() });
125
+ }
126
+ break;
127
+
128
+ case 'DELETED':
129
+ // STATUS DELETED is a base IMAP4rev2 addition (RFC 9051 Appendix E
130
+ // item 3) with no standalone capability - requesting it from a plain
131
+ // rev1 server would get the whole STATUS request rejected. RFC 9208
132
+ // additionally makes it mandatory when QUOTA=RES-MESSAGE is advertised.
133
+ if (tools.isRev2Active(connection) || connection.capabilities.has('QUOTA=RES-MESSAGE')) {
134
+ attributes.push({ type: 'ATOM', value: key.toUpperCase() });
135
+ }
136
+ break;
118
137
  }
119
138
  });
120
139
 
@@ -591,6 +610,19 @@ const tools = {
591
610
  map.bodyParts = new Map();
592
611
  }
593
612
  map.bodyParts.set(partKey, value);
613
+
614
+ if (match[1].toLowerCase() === 'binary') {
615
+ // The part arrived via FETCH BINARY (RFC 3516, FETCH side folded
616
+ // into IMAP4rev2), so the server has already removed the
617
+ // content-transfer-encoding - consumers must not decode it again.
618
+ // Recorded from the actual response, not predicted from the
619
+ // request, so it stays correct even if a server answers a BINARY
620
+ // request with a BODY response or vice versa.
621
+ if (!map.binaryParts) {
622
+ map.binaryParts = new Set();
623
+ }
624
+ map.binaryParts.add(partKey);
625
+ }
594
626
  break;
595
627
  }
596
628
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.4.9",
3
+ "version": "1.5.0",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",