imapflow 1.4.8 → 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.
Files changed (44) hide show
  1. package/.github/workflows/test.yml +20 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/CLAUDE.md +12 -5
  5. package/Gruntfile.js +3 -1
  6. package/lib/commands/authenticate.js +8 -3
  7. package/lib/commands/enable.js +13 -4
  8. package/lib/commands/expunge.js +2 -2
  9. package/lib/commands/fetch.js +18 -14
  10. package/lib/commands/idle.js +6 -3
  11. package/lib/commands/list.js +241 -61
  12. package/lib/commands/move.js +2 -2
  13. package/lib/commands/namespace.js +3 -1
  14. package/lib/commands/search.js +88 -13
  15. package/lib/commands/status.js +19 -26
  16. package/lib/handler/imap-compiler.js +12 -9
  17. package/lib/handler/token-parser.js +7 -0
  18. package/lib/imap-flow.d.ts +19 -3
  19. package/lib/imap-flow.js +58 -9
  20. package/lib/search-compiler.js +15 -1
  21. package/lib/tools.js +173 -9
  22. package/package.json +3 -2
  23. package/test/commands-branches-test.js +11 -4
  24. package/test/commands-integration-test.js +1528 -108
  25. package/test/connection-edge-cases-test.js +4 -40
  26. package/test/fixtures/test-tls.js +2 -2
  27. package/test/handler-branches-test.js +4 -3
  28. package/test/imap-compiler-test.js +85 -0
  29. package/test/imap-flow-coverage-test.js +8 -1
  30. package/test/imap-flow-fetch-download-test.js +57 -4
  31. package/test/imap-flow-internals-test.js +2 -2
  32. package/test/imap-flow-methods-test.js +65 -6
  33. package/test/imap-flow-secure-test.js +25 -11
  34. package/test/imap-flow-server-test.js +80 -0
  35. package/test/imap-parser-test.js +113 -3
  36. package/test/imap-stream-test.js +46 -0
  37. package/test/integration/README.md +52 -0
  38. package/test/integration/dovecot-test.conf +27 -0
  39. package/test/integration/rev2-live-test.js +367 -0
  40. package/test/integration/run-rev2-tests.sh +75 -0
  41. package/test/reliability-improvements-test.js +4 -1
  42. package/test/search-compiler-test.js +36 -0
  43. package/test/search-test.js +52 -54
  44. package/test/tools-test.js +176 -19
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Runs the ImapFlow live integration tests against a real IMAP4rev2 server
5
+ # (Dovecot 2.4+ in Docker). Opt-in via `npm run test:rev2` - not part of the
6
+ # regular `npm test` run, which stays Docker-free.
7
+ #
8
+ # Environment overrides:
9
+ # IMAPFLOW_DOVECOT_IMAGE image to run (default dovecot/dovecot:2.4.4)
10
+ # IMAPFLOW_DOVECOT_PLATFORM e.g. linux/amd64; defaults to the host platform.
11
+ # Note: forcing linux/amd64 on Apple Silicon does
12
+ # not work - Rosetta cannot start Dovecot's
13
+ # privilege-separated login processes.
14
+ # IMAPFLOW_TEST_PORT host port to publish (default 31143)
15
+
16
+ CONTAINER_NAME="${IMAPFLOW_DOVECOT_CONTAINER:-imapflow-rev2-test}"
17
+ IMAGE="${IMAPFLOW_DOVECOT_IMAGE:-dovecot/dovecot:2.4.4}"
18
+ PORT="${IMAPFLOW_TEST_PORT:-31143}"
19
+
20
+ # Empty unless a platform override was requested; --platform=value keeps it a
21
+ # single argument so plain ${PLATFORM_ARG:+...} expansion works under set -u
22
+ PLATFORM_ARG="${IMAPFLOW_DOVECOT_PLATFORM:+--platform=$IMAPFLOW_DOVECOT_PLATFORM}"
23
+
24
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25
+ PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
26
+
27
+ cleanup() {
28
+ docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
29
+ }
30
+ trap cleanup EXIT
31
+ cleanup
32
+
33
+ # Guard against a stale image cached for the wrong architecture: `docker run`
34
+ # without --platform silently reuses a local image even when its architecture
35
+ # does not match the host (e.g. an amd64 image left behind on an arm64 host),
36
+ # and Dovecot then fails to start with confusing emulation errors. Only applies
37
+ # when no explicit platform override was requested.
38
+ if [ -z "${IMAPFLOW_DOVECOT_PLATFORM:-}" ] && docker image inspect "$IMAGE" >/dev/null 2>&1; then
39
+ image_arch="$(docker image inspect --format '{{.Architecture}}' "$IMAGE" 2>/dev/null || true)"
40
+ host_arch="$(docker version --format '{{.Server.Arch}}' 2>/dev/null || true)"
41
+ if [ -n "$image_arch" ] && [ -n "$host_arch" ] && [ "$image_arch" != "$host_arch" ]; then
42
+ echo "Local $IMAGE image is $image_arch but the Docker host is $host_arch - re-pulling for linux/$host_arch..."
43
+ docker pull --platform "linux/$host_arch" "$IMAGE"
44
+ fi
45
+ fi
46
+
47
+ docker run ${PLATFORM_ARG:+"$PLATFORM_ARG"} -d --name "$CONTAINER_NAME" \
48
+ -e USER_PASSWORD=pass \
49
+ -v "$SCRIPT_DIR/dovecot-test.conf:/etc/dovecot/conf.d/99-imapflow-test.conf:ro" \
50
+ -p "127.0.0.1:$PORT:31143" \
51
+ "$IMAGE" >/dev/null
52
+
53
+ echo "Waiting for Dovecot to accept IMAP connections on port $PORT..."
54
+ for i in $(seq 1 30); do
55
+ if node -e "
56
+ const net = require('net');
57
+ const socket = net.connect(Number(process.argv[1]), '127.0.0.1');
58
+ const bail = code => { socket.destroy(); process.exit(code); };
59
+ socket.on('data', chunk => bail(chunk.toString().startsWith('* OK') ? 0 : 1));
60
+ socket.on('error', () => bail(1));
61
+ setTimeout(() => bail(1), 2000);
62
+ " "$PORT" 2>/dev/null; then
63
+ echo "Dovecot is ready"
64
+ break
65
+ fi
66
+ if [ "$i" = 30 ]; then
67
+ echo "Dovecot container did not become ready" >&2
68
+ docker logs "$CONTAINER_NAME" >&2 || true
69
+ exit 1
70
+ fi
71
+ sleep 1
72
+ done
73
+
74
+ cd "$PROJECT_DIR"
75
+ IMAPFLOW_TEST_HOST=127.0.0.1 IMAPFLOW_TEST_PORT="$PORT" npx nodeunit test/integration/rev2-live-test.js
@@ -204,7 +204,10 @@ module.exports['Reliability: per-call maxLockHoldTime overrides constructor opti
204
204
  let lock = await client.getMailboxLock('INBOX', { maxLockHoldTime: 20 });
205
205
  await new Promise(r => setTimeout(r, 50));
206
206
 
207
- test.ok(warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'), 'Per-call override must take effect');
207
+ test.ok(
208
+ warnings.some(w => w && w.msg === 'Mailbox lock held for a long time'),
209
+ 'Per-call override must take effect'
210
+ );
208
211
 
209
212
  lock.release();
210
213
  await drain();
@@ -191,6 +191,25 @@ module.exports['Search Compiler: RECENT flag'] = test => {
191
191
  test.done();
192
192
  };
193
193
 
194
+ module.exports['Search Compiler: NEW/OLD/RECENT throw on rev2 sessions'] = test => {
195
+ // IMAP4rev2 (RFC 9051) removed the \Recent flag and these search keys -
196
+ // a rev2 server would reject the whole search with a tagged BAD
197
+ let connection = createMockConnection({ enabled: ['IMAP4REV2'] });
198
+ for (let key of ['new', 'old', 'recent']) {
199
+ try {
200
+ searchCompiler(connection, { [key]: true });
201
+ test.ok(false, `Should have thrown for ${key}`);
202
+ } catch (err) {
203
+ test.equal(err.code, 'MissingServerExtension');
204
+ }
205
+ }
206
+ // Falsy values compile to nothing and must not throw
207
+ test.equal(searchCompiler(connection, { recent: false }).length, 0);
208
+ // ALL is still part of the rev2 grammar
209
+ test.ok(hasAttr(searchCompiler(connection, { all: true }), 'ALL'));
210
+ test.done();
211
+ };
212
+
194
213
  module.exports['Search Compiler: Simple flags ignored when falsy'] = test => {
195
214
  let connection = createMockConnection();
196
215
  let compiled = searchCompiler(connection, {
@@ -900,6 +919,23 @@ module.exports['Search Compiler: Unicode skipped when UTF8=ACCEPT enabled'] = te
900
919
  test.done();
901
920
  };
902
921
 
922
+ module.exports['Search Compiler: Unicode on rev2 without UTF8=ACCEPT still adds CHARSET UTF-8'] = test => {
923
+ // RFC 9051 6.4.4: rev2 servers MUST assume UTF-8 when CHARSET is absent, and
924
+ // sending CHARSET UTF-8 is "redundant" but explicitly "permitted for improved
925
+ // compatibility" - pin the compiler's choice to send it until UTF8=ACCEPT is
926
+ // actually ENABLEd
927
+ let connection = createMockConnection({
928
+ capabilities: [['IMAP4rev2', true]],
929
+ enabled: new Set()
930
+ });
931
+ let compiled = searchCompiler(connection, { subject: 'Sõnum' });
932
+
933
+ let charset = findAttr(compiled, 'CHARSET');
934
+ test.ok(charset, 'CHARSET prefix expected for a unicode search value');
935
+ test.ok(hasAttr(compiled, 'UTF-8'));
936
+ test.done();
937
+ };
938
+
903
939
  module.exports['Search Compiler: GMRAW with Unicode adds CHARSET'] = test => {
904
940
  let connection = createMockConnection({
905
941
  capabilities: [['X-GM-EXT-1', true]],
@@ -14,6 +14,7 @@ function makeConnection({ hasEsearch = true } = {}) {
14
14
  state: 'SELECTED',
15
15
  states: { SELECTED: 'SELECTED' },
16
16
  capabilities: caps,
17
+ enabled: new Set(),
17
18
  exec: async () => ({ next: () => {} }),
18
19
  log: { warn: () => {} }
19
20
  };
@@ -72,23 +73,6 @@ module.exports['ESEARCH: parseEsearchResponse PARTIAL (Array form)'] = test => {
72
73
  test.done();
73
74
  };
74
75
 
75
- module.exports['ESEARCH: parseEsearchResponse PARTIAL (LIST object form)'] = test => {
76
- // Also handle {type: 'LIST', attributes: [...]} form for robustness
77
- const attrs = [
78
- { type: 'ATOM', value: 'PARTIAL' },
79
- {
80
- type: 'LIST',
81
- attributes: [
82
- { type: 'ATOM', value: '1:100' },
83
- { type: 'ATOM', value: '1001,1003:1010,1015' }
84
- ]
85
- }
86
- ];
87
- const result = parseEsearchResponse(attrs);
88
- test.deepEqual(result.partial, { range: '1:100', messages: '1001,1003:1010,1015' });
89
- test.done();
90
- };
91
-
92
76
  module.exports['ESEARCH: parseEsearchResponse COUNT + PARTIAL combined'] = test => {
93
77
  const attrs = [
94
78
  { type: 'ATOM', value: 'COUNT' },
@@ -116,12 +100,14 @@ module.exports['ESEARCH: emits RETURN clause when returnOptions present and serv
116
100
  capturedAttributes = JSON.stringify(attributes);
117
101
  return { next: () => {} };
118
102
  };
119
- searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT'] }).then(() => {
120
- test.equal(capturedCommand, 'UID SEARCH');
121
- test.ok(capturedAttributes.includes('"RETURN"'), 'should include RETURN atom');
122
- test.ok(capturedAttributes.includes('"COUNT"'), 'should include COUNT in return list');
123
- test.done();
124
- }).catch(err => test.done(err));
103
+ searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT'] })
104
+ .then(() => {
105
+ test.equal(capturedCommand, 'UID SEARCH');
106
+ test.ok(capturedAttributes.includes('"RETURN"'), 'should include RETURN atom');
107
+ test.ok(capturedAttributes.includes('"COUNT"'), 'should include COUNT in return list');
108
+ test.done();
109
+ })
110
+ .catch(err => test.done(err));
125
111
  };
126
112
 
127
113
  module.exports['ESEARCH: RETURN clause includes PARTIAL range atom'] = test => {
@@ -131,11 +117,13 @@ module.exports['ESEARCH: RETURN clause includes PARTIAL range atom'] = test => {
131
117
  capturedAttributes = JSON.stringify(attributes);
132
118
  return { next: () => {} };
133
119
  };
134
- searchCmd(conn, { seen: false }, { uid: true, returnOptions: [{ partial: '1:100' }] }).then(() => {
135
- test.ok(capturedAttributes.includes('"PARTIAL"'), 'should include PARTIAL atom');
136
- test.ok(capturedAttributes.includes('"1:100"'), 'should include range string');
137
- test.done();
138
- }).catch(err => test.done(err));
120
+ searchCmd(conn, { seen: false }, { uid: true, returnOptions: [{ partial: '1:100' }] })
121
+ .then(() => {
122
+ test.ok(capturedAttributes.includes('"PARTIAL"'), 'should include PARTIAL atom');
123
+ test.ok(capturedAttributes.includes('"1:100"'), 'should include range string');
124
+ test.done();
125
+ })
126
+ .catch(err => test.done(err));
139
127
  };
140
128
 
141
129
  module.exports['ESEARCH: no RETURN clause when server lacks ESEARCH capability'] = test => {
@@ -152,12 +140,14 @@ module.exports['ESEARCH: no RETURN clause when server lacks ESEARCH capability']
152
140
  }
153
141
  return { next: () => {} };
154
142
  };
155
- searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT', 'ALL'] }).then(result => {
156
- test.ok(!capturedAttributes.includes('"RETURN"'), 'should NOT include RETURN when no ESEARCH');
157
- test.ok(Array.isArray(result), 'should return number[] when ESEARCH unavailable');
158
- test.deepEqual(result, [1, 2, 3]);
159
- test.done();
160
- }).catch(err => test.done(err));
143
+ searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT', 'ALL'] })
144
+ .then(result => {
145
+ test.ok(!capturedAttributes.includes('"RETURN"'), 'should NOT include RETURN when no ESEARCH');
146
+ test.ok(Array.isArray(result), 'should return number[] when ESEARCH unavailable');
147
+ test.deepEqual(result, [1, 2, 3]);
148
+ test.done();
149
+ })
150
+ .catch(err => test.done(err));
161
151
  };
162
152
 
163
153
  module.exports['ESEARCH: parseEsearchResponse skips non-ATOM tokens'] = test => {
@@ -247,11 +237,13 @@ module.exports['ESEARCH: backward compat — no returnOptions returns number[]']
247
237
  return { next: () => {} };
248
238
  };
249
239
  // No returnOptions — must return number[] even if server has ESEARCH
250
- searchCmd(conn, { seen: true }, { uid: true }).then(result => {
251
- test.ok(Array.isArray(result));
252
- test.deepEqual(result, [10, 20]);
253
- test.done();
254
- }).catch(err => test.done(err));
240
+ searchCmd(conn, { seen: true }, { uid: true })
241
+ .then(result => {
242
+ test.ok(Array.isArray(result));
243
+ test.deepEqual(result, [10, 20]);
244
+ test.done();
245
+ })
246
+ .catch(err => test.done(err));
255
247
  };
256
248
 
257
249
  // ── imap-flow.js public API fallback test ─────────────────────────────────
@@ -271,16 +263,19 @@ module.exports['imap-flow: search() derives ESearchResult when server has no ESE
271
263
  // Stub run() to return a sorted number[]
272
264
  client.run = async () => [10, 20, 30, 40, 50];
273
265
 
274
- client.search({ seen: false }, { uid: true, returnOptions: ['COUNT', 'MIN', 'MAX', 'ALL'] }).then(result => {
275
- test.equal(typeof result, 'object', 'should return object, not array');
276
- test.ok(!Array.isArray(result), 'should not be an array');
277
- test.equal(result.count, 5);
278
- test.equal(result.min, 10);
279
- test.equal(result.max, 50);
280
- // packMessageRange([10,20,30,40,50]) → "10,20,30,40,50" (non-contiguous)
281
- test.ok(typeof result.all === 'string' && result.all.length > 0, 'all should be non-empty compact string');
282
- test.done();
283
- }).catch(err => test.done(err));
266
+ client
267
+ .search({ seen: false }, { uid: true, returnOptions: ['COUNT', 'MIN', 'MAX', 'ALL'] })
268
+ .then(result => {
269
+ test.equal(typeof result, 'object', 'should return object, not array');
270
+ test.ok(!Array.isArray(result), 'should not be an array');
271
+ test.equal(result.count, 5);
272
+ test.equal(result.min, 10);
273
+ test.equal(result.max, 50);
274
+ // packMessageRange([10,20,30,40,50]) → "10,20,30,40,50" (non-contiguous)
275
+ test.ok(typeof result.all === 'string' && result.all.length > 0, 'all should be non-empty compact string');
276
+ test.done();
277
+ })
278
+ .catch(err => test.done(err));
284
279
  };
285
280
 
286
281
  module.exports['imap-flow: search() fallback with empty result set'] = test => {
@@ -294,9 +289,12 @@ module.exports['imap-flow: search() fallback with empty result set'] = test => {
294
289
  client.state = client.states.SELECTED;
295
290
  client.capabilities = new Map();
296
291
  client.run = async () => [];
297
- client.search({}, { uid: true, returnOptions: ['COUNT', 'ALL'] }).then(result => {
298
- test.equal(result.count, 0);
299
- test.equal(result.all, undefined, 'all should be absent for empty result');
300
- test.done();
301
- }).catch(err => test.done(err));
292
+ client
293
+ .search({}, { uid: true, returnOptions: ['COUNT', 'ALL'] })
294
+ .then(result => {
295
+ test.equal(result.count, 0);
296
+ test.equal(result.all, undefined, 'all should be absent for empty result');
297
+ test.done();
298
+ })
299
+ .catch(err => test.done(err));
302
300
  };
@@ -7,7 +7,8 @@ const iconv = require('iconv-lite');
7
7
 
8
8
  // Mock connection for testing
9
9
  let createMockConnection = (options = {}) => ({
10
- enabled: new Set(options.utf8 ? ['UTF8=ACCEPT'] : []),
10
+ enabled: new Set(options.enabled || (options.utf8 ? ['UTF8=ACCEPT'] : [])),
11
+ capabilities: new Map(options.capabilities || []),
11
12
  namespace: options.namespace || null
12
13
  });
13
14
 
@@ -22,7 +23,7 @@ module.exports['Tools: encodePath with ASCII path'] = test => {
22
23
  test.done();
23
24
  };
24
25
 
25
- module.exports['Tools: encodePath with Unicode path (no UTF8)'] = test => {
26
+ module.exports['Tools: encodePath with ASCII path (no UTF8)'] = test => {
26
27
  let connection = createMockConnection({ utf8: false });
27
28
  let result = tools.encodePath(connection, 'Sent/Gesendete');
28
29
  // ASCII path should remain unchanged
@@ -30,6 +31,15 @@ module.exports['Tools: encodePath with Unicode path (no UTF8)'] = test => {
30
31
  test.done();
31
32
  };
32
33
 
34
+ module.exports['Tools: encodePath encodes Unicode to modified UTF-7 on rev1 (no UTF8)'] = test => {
35
+ let connection = createMockConnection({ utf8: false });
36
+ // 'õ' (U+00F5) -> UTF-16BE 00F5 -> modified-base64 'APU'
37
+ test.equal(tools.encodePath(connection, 'Tõrva'), 'T&APU-rva');
38
+ // a literal '&' must be escaped as '&-'
39
+ test.equal(tools.encodePath(connection, 'Test&Folder'), 'Test&-Folder');
40
+ test.done();
41
+ };
42
+
33
43
  module.exports['Tools: encodePath with Unicode when UTF8=ACCEPT enabled'] = test => {
34
44
  let connection = createMockConnection({ utf8: true });
35
45
  let result = tools.encodePath(connection, 'Posteingang/Ordner');
@@ -54,9 +64,10 @@ module.exports['Tools: decodePath with ASCII path'] = test => {
54
64
 
55
65
  module.exports['Tools: decodePath with ampersand'] = test => {
56
66
  let connection = createMockConnection({ utf8: false });
57
- // UTF-7-IMAP encoded string
58
- let result = tools.decodePath(connection, 'Test&-Folder');
59
- test.ok(typeof result === 'string');
67
+ // modified UTF-7: '&-' is the escaped form of a literal ampersand
68
+ test.equal(tools.decodePath(connection, 'Test&-Folder'), 'Test&Folder');
69
+ // and an encoded sequence round-trips back to Unicode
70
+ test.equal(tools.decodePath(connection, 'T&APU-rva'), 'Tõrva');
60
71
  test.done();
61
72
  };
62
73
 
@@ -162,6 +173,102 @@ module.exports['Tools: updateCapabilities with valid list'] = test => {
162
173
  test.done();
163
174
  };
164
175
 
176
+ module.exports['Tools: updateCapabilities normalizes IMAP4rev2 casing'] = test => {
177
+ let list = [{ value: 'IMAP4rev2' }, { value: 'imap4rev1' }];
178
+ let result = tools.updateCapabilities(list);
179
+ // Wire tokens are uppercased, but the rev1/rev2 keys use the RFC spelling
180
+ test.equal(result.get('IMAP4rev2'), true);
181
+ test.equal(result.get('IMAP4rev1'), true);
182
+ test.equal(result.has('IMAP4REV2'), false);
183
+ test.done();
184
+ };
185
+
186
+ module.exports['Tools: isRev2Active for rev2-only server'] = test => {
187
+ // rev2 without rev1 means rev2 is the base protocol, no ENABLE needed
188
+ let connection = createMockConnection({ capabilities: [['IMAP4rev2', true]] });
189
+ test.equal(tools.isRev2Active(connection), true);
190
+ test.done();
191
+ };
192
+
193
+ module.exports['Tools: isRev2Active for dual server without ENABLE'] = test => {
194
+ // Advertising both keeps the session in rev1 mode until ENABLE IMAP4rev2
195
+ let connection = createMockConnection({
196
+ capabilities: [
197
+ ['IMAP4rev1', true],
198
+ ['IMAP4rev2', true]
199
+ ]
200
+ });
201
+ test.equal(tools.isRev2Active(connection), false);
202
+ test.done();
203
+ };
204
+
205
+ module.exports['Tools: isRev2Active for dual server with ENABLE'] = test => {
206
+ let connection = createMockConnection({
207
+ capabilities: [
208
+ ['IMAP4rev1', true],
209
+ ['IMAP4rev2', true]
210
+ ],
211
+ enabled: ['IMAP4REV2']
212
+ });
213
+ test.equal(tools.isRev2Active(connection), true);
214
+ test.done();
215
+ };
216
+
217
+ module.exports['Tools: isRev2Active for rev1-only server'] = test => {
218
+ let connection = createMockConnection({ capabilities: [['IMAP4rev1', true]] });
219
+ test.equal(tools.isRev2Active(connection), false);
220
+ test.done();
221
+ };
222
+
223
+ module.exports['Tools: hasCapability with advertised token'] = test => {
224
+ let connection = createMockConnection({
225
+ capabilities: [
226
+ ['IMAP4rev1', true],
227
+ ['UIDPLUS', true]
228
+ ]
229
+ });
230
+ test.equal(tools.hasCapability(connection, 'UIDPLUS'), true);
231
+ test.equal(tools.hasCapability(connection, 'MOVE'), false);
232
+ test.done();
233
+ };
234
+
235
+ module.exports['Tools: hasCapability folds extensions into active rev2'] = test => {
236
+ let connection = createMockConnection({ capabilities: [['IMAP4rev2', true]] });
237
+ // RFC 9051 Appendix E folds these into base IMAP4rev2
238
+ for (let capability of ['UIDPLUS', 'MOVE', 'NAMESPACE', 'ESEARCH', 'LITERAL-', 'LIST-EXTENDED', 'LIST-STATUS', 'SPECIAL-USE', 'ENABLE']) {
239
+ test.equal(tools.hasCapability(connection, capability), true, `${capability} should be folded into rev2`);
240
+ }
241
+ // BINARY is intentionally not folded
242
+ test.equal(tools.hasCapability(connection, 'BINARY'), false);
243
+ test.done();
244
+ };
245
+
246
+ module.exports['Tools: hasCapability does not fold on unenabled dual server'] = test => {
247
+ let connection = createMockConnection({
248
+ capabilities: [
249
+ ['IMAP4rev1', true],
250
+ ['IMAP4rev2', true]
251
+ ]
252
+ });
253
+ // Session is in rev1 mode - only explicitly advertised tokens count
254
+ test.equal(tools.hasCapability(connection, 'UIDPLUS'), false);
255
+ test.done();
256
+ };
257
+
258
+ module.exports['Tools: encodePath keeps UTF-8 when rev2 is active'] = test => {
259
+ let connection = createMockConnection({ capabilities: [['IMAP4rev2', true]] });
260
+ // rev2 mailbox names are native UTF-8, modified UTF-7 must not be applied
261
+ test.equal(tools.encodePath(connection, 'T\u00f5rva'), 'T\u00f5rva');
262
+ test.done();
263
+ };
264
+
265
+ module.exports['Tools: decodePath keeps ampersand sequences when rev2 is active'] = test => {
266
+ let connection = createMockConnection({ capabilities: [['IMAP4rev2', true]] });
267
+ // Under rev2 an "&"-sequence is a literal name, not modified UTF-7
268
+ test.equal(tools.decodePath(connection, 'A&AOQ-B'), 'A&AOQ-B');
269
+ test.done();
270
+ };
271
+
165
272
  module.exports['Tools: updateCapabilities with APPENDLIMIT'] = test => {
166
273
  let list = [{ value: 'APPENDLIMIT=52428800' }];
167
274
  let result = tools.updateCapabilities(list);
@@ -493,6 +600,30 @@ module.exports['Tools: expandRange with same start/end'] = test => {
493
600
  test.done();
494
601
  };
495
602
 
603
+ module.exports['Tools: expandRange skips entries that are not valid nz-numbers'] = test => {
604
+ // Server-supplied garbage must not produce bogus ids or endless loops
605
+ test.deepEqual(tools.expandRange('Infinity:5'), []);
606
+ test.deepEqual(tools.expandRange('0:3'), []);
607
+ test.deepEqual(tools.expandRange('abc,4,1:x'), [4]);
608
+ test.deepEqual(tools.expandRange('*'), []);
609
+ test.deepEqual(tools.expandRange('4294967296'), []);
610
+ test.done();
611
+ };
612
+
613
+ module.exports['Tools: expandRange caps hostile range spans'] = test => {
614
+ // A hostile range like 1:4294967295 is cut off at the expansion limit
615
+ // instead of exhausting memory
616
+ let result = tools.expandRange('1:4294967295');
617
+ test.equal(result.length, 0x1000000);
618
+ test.equal(result[0], 1);
619
+ test.equal(result[result.length - 1], 0x1000000);
620
+
621
+ let reverse = tools.expandRange('4294967295:4278190080');
622
+ test.equal(reverse.length, 0x1000000);
623
+ test.equal(reverse[0], 4294967295);
624
+ test.done();
625
+ };
626
+
496
627
  // ============================================
497
628
  // packMessageRange tests
498
629
  // ============================================
@@ -1167,6 +1298,34 @@ module.exports['Tools: formatMessageResponse handles normal THREADID'] = async t
1167
1298
  test.done();
1168
1299
  };
1169
1300
 
1301
+ // ============================================
1302
+ // formatMessageResponse: BINARY vs BODY part tracking (RFC 3516 / RFC 9051)
1303
+ // ============================================
1304
+
1305
+ module.exports['Tools: formatMessageResponse records which parts arrived via BINARY'] = async test => {
1306
+ // Parts answered as BINARY[...] arrive with the content-transfer-encoding
1307
+ // already decoded by the server; consumers (download/downloadMany) use the
1308
+ // binaryParts set to skip their own decoder for exactly those parts
1309
+ let untagged = await parser('* 1 FETCH (UID 7 BINARY[1] {4}\r\n BODY[2] {4}\r\n)', {
1310
+ literals: [Buffer.from('AAAA'), Buffer.from('BBBB')]
1311
+ });
1312
+ let result = await tools.formatMessageResponse(untagged, {});
1313
+ test.equal(result.bodyParts.get('1').toString(), 'AAAA');
1314
+ test.equal(result.bodyParts.get('2').toString(), 'BBBB');
1315
+ test.ok(result.binaryParts, 'binaryParts set should exist when a BINARY part arrived');
1316
+ test.ok(result.binaryParts.has('1'), 'BINARY-answered part is recorded');
1317
+ test.ok(!result.binaryParts.has('2'), 'BODY-answered part is not recorded');
1318
+ test.done();
1319
+ };
1320
+
1321
+ module.exports['Tools: formatMessageResponse leaves binaryParts unset for plain BODY fetches'] = async test => {
1322
+ let untagged = await parser('* 1 FETCH (UID 8 BODY[1] {4}\r\n)', { literals: [Buffer.from('CCCC')] });
1323
+ let result = await tools.formatMessageResponse(untagged, {});
1324
+ test.equal(result.bodyParts.get('1').toString(), 'CCCC');
1325
+ test.equal(result.binaryParts, undefined);
1326
+ test.done();
1327
+ };
1328
+
1170
1329
  // ============================================
1171
1330
  // formatMessageResponse: non-ASCII mailbox path normalization for stable id
1172
1331
  // ============================================
@@ -1303,9 +1462,7 @@ module.exports['Tools: parseBodystructure parses message/rfc822 with envelope an
1303
1462
  };
1304
1463
 
1305
1464
  module.exports['Tools: parseBodystructure parses multipart with params and disposition'] = async test => {
1306
- let bs =
1307
- '(("TEXT" "PLAIN" NIL NIL NIL "7BIT" 10 1)("TEXT" "HTML" NIL NIL NIL "7BIT" 20 2) ' +
1308
- '"ALTERNATIVE" ("BOUNDARY" "xyz") ("inline" NIL) ("en"))';
1465
+ let bs = '(("TEXT" "PLAIN" NIL NIL NIL "7BIT" 10 1)("TEXT" "HTML" NIL NIL NIL "7BIT" 20 2) "ALTERNATIVE" ("BOUNDARY" "xyz") ("inline" NIL) ("en"))';
1309
1466
  let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
1310
1467
  let node = tools.parseBodystructure(untagged.attributes[1][1]);
1311
1468
  test.equal(node.type, 'multipart/alternative');
@@ -1348,7 +1505,7 @@ module.exports['Tools: parseBodystructure handles minimal NIL fields'] = async t
1348
1505
  };
1349
1506
 
1350
1507
  module.exports['Tools: parseBodystructure decodes RFC 2231 charset continuation params'] = async test => {
1351
- let bs = "(\"TEXT\" \"PLAIN\" (\"name*0*\" \"utf-8''%E2%82%AC abc\" \"name*1\" \"def\") NIL NIL \"7BIT\" 10 1)";
1508
+ let bs = '("TEXT" "PLAIN" ("name*0*" "utf-8\'\'%E2%82%AC abc" "name*1" "def") NIL NIL "7BIT" 10 1)';
1352
1509
  let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
1353
1510
  let node = tools.parseBodystructure(untagged.attributes[1][1]);
1354
1511
  test.equal(node.parameters.name, '€ abcdef');
@@ -1453,9 +1610,7 @@ module.exports['Tools: parseBodystructure empty-string size/linecount/language/l
1453
1610
  };
1454
1611
 
1455
1612
  module.exports['Tools: parseBodystructure message/rfc822 empty-string linecount'] = async test => {
1456
- let bs =
1457
- '("MESSAGE" "RFC822" NIL NIL NIL "7BIT" 100 ("d" "s" NIL NIL NIL NIL NIL NIL NIL NIL) ' +
1458
- '("TEXT" "PLAIN" NIL NIL NIL "7BIT" 1 1) "")';
1613
+ let bs = '("MESSAGE" "RFC822" NIL NIL NIL "7BIT" 100 ("d" "s" NIL NIL NIL NIL NIL NIL NIL NIL) ("TEXT" "PLAIN" NIL NIL NIL "7BIT" 1 1) "")';
1459
1614
  let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
1460
1615
  let node = tools.parseBodystructure(untagged.attributes[1][1]);
1461
1616
  test.equal(node.type, 'message/rfc822');
@@ -1468,7 +1623,7 @@ module.exports['Tools: parseBodystructure decodes RFC 2231 with empty charset an
1468
1623
  // exercise the 2-char hex escape branch; the empty *1* continuation segment too.
1469
1624
  // value includes '=' / '?' (2-char hex escapes), a space (-> '_') and a TAB
1470
1625
  // (a <0x10 control char -> single-hex-digit escape needing a '0' pad).
1471
- let bs = "(\"TEXT\" \"PLAIN\" (\"name*0*\" \"''a=b?c d\te\" \"name*1*\" \"\") NIL NIL \"7BIT\" 1 1)";
1626
+ let bs = '("TEXT" "PLAIN" ("name*0*" "\'\'a=b?c d\te" "name*1*" "") NIL NIL "7BIT" 1 1)';
1472
1627
  let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
1473
1628
  let node = tools.parseBodystructure(untagged.attributes[1][1]);
1474
1629
  test.ok(node.parameters.name.includes('a=b?c'));
@@ -1485,13 +1640,15 @@ module.exports['Tools: parseBodystructure parses empty-string body location'] =
1485
1640
  test.done();
1486
1641
  };
1487
1642
 
1488
- module.exports['Tools: expandRange descending range to non-numeric second bound'] = test => {
1489
- test.deepEqual(tools.expandRange('5:abc'), [5, 4, 3, 2, 1, 0]);
1643
+ module.exports['Tools: expandRange skips descending range to non-numeric second bound'] = test => {
1644
+ // Endpoints that are not valid nz-numbers make the whole entry invalid -
1645
+ // the pre-hardening behavior expanded '5:abc' down to 0
1646
+ test.deepEqual(tools.expandRange('5:abc'), []);
1490
1647
  test.done();
1491
1648
  };
1492
1649
 
1493
- module.exports['Tools: expandRange tolerates non-numeric entries'] = test => {
1494
- test.deepEqual(tools.expandRange('abc,:5,1:3'), [0, 0, 1, 2, 3, 4, 5, 1, 2, 3]);
1650
+ module.exports['Tools: expandRange skips non-numeric entries but keeps valid ones'] = test => {
1651
+ test.deepEqual(tools.expandRange('abc,:5,1:3'), [1, 2, 3]);
1495
1652
  test.done();
1496
1653
  };
1497
1654
 
@@ -1508,7 +1665,7 @@ module.exports['Tools: encodePath keeps name as-is when iconv encode throws'] =
1508
1665
  throw new Error('encode boom');
1509
1666
  };
1510
1667
  try {
1511
- let connection = { enabled: new Set() };
1668
+ let connection = { enabled: new Set(), capabilities: new Map() };
1512
1669
  let result = tools.encodePath(connection, 'Tärä');
1513
1670
  test.equal(result, 'Tärä', 'falls back to the raw path');
1514
1671
  } finally {
@@ -1523,7 +1680,7 @@ module.exports['Tools: decodePath keeps name as-is when iconv decode throws'] =
1523
1680
  throw new Error('decode boom');
1524
1681
  };
1525
1682
  try {
1526
- let connection = { enabled: new Set() };
1683
+ let connection = { enabled: new Set(), capabilities: new Map() };
1527
1684
  let result = tools.decodePath(connection, 'Inbox&AOk-');
1528
1685
  test.equal(result, 'Inbox&AOk-');
1529
1686
  } finally {