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.
@@ -797,6 +797,8 @@ module.exports['Server: PREAUTH greeting skips login'] = async test => {
797
797
  await client.connect();
798
798
  test.equal(client.state, client.states.AUTHENTICATED);
799
799
  test.ok(client.usable);
800
+ // documented contract: `true` if the connection was authenticated by PREAUTH
801
+ test.strictEqual(client.authenticated, true);
800
802
 
801
803
  await client.logout();
802
804
  client.close();
@@ -845,6 +847,84 @@ module.exports['Server: unsolicited EXISTS and VANISHED reach the untagged handl
845
847
  test.done();
846
848
  };
847
849
 
850
+ module.exports['Server: rev2-shaped SELECT response is tolerated through the full pipeline'] = async test => {
851
+ // RFC 9051 requires rev2 servers to include an untagged LIST in the SELECT
852
+ // response and a CLOSED response code when another mailbox was selected, and
853
+ // to omit RECENT/UNSEEN. The client must consume such a response cleanly.
854
+ let server = createServer({
855
+ capabilities: 'IMAP4rev2 ID ENABLE NAMESPACE',
856
+ handlers: {
857
+ SELECT(ctx) {
858
+ ctx.write('* OK [CLOSED] Previous mailbox closed\r\n');
859
+ ctx.write('* 3 EXISTS\r\n');
860
+ ctx.write('* LIST () "/" INBOX\r\n');
861
+ ctx.write('* FLAGS (\\Seen \\Answered \\Flagged \\Deleted \\Draft)\r\n');
862
+ ctx.write('* OK [PERMANENTFLAGS (\\Seen \\*)] Limited\r\n');
863
+ ctx.write('* OK [UIDVALIDITY 12345] UIDs valid\r\n');
864
+ ctx.write('* OK [UIDNEXT 100] Predicted next UID\r\n');
865
+ ctx.ok('[READ-WRITE] SELECT completed');
866
+ }
867
+ }
868
+ });
869
+ let port = await listen(server);
870
+ let client = makeClient(port);
871
+ let errors = [];
872
+ client.on('error', err => errors.push(err));
873
+
874
+ await client.connect();
875
+ test.ok(client.capabilities.has('IMAP4rev2'));
876
+
877
+ let mailbox = await client.mailboxOpen('INBOX');
878
+ test.equal(mailbox.path, 'INBOX');
879
+ test.equal(mailbox.exists, 3);
880
+ test.equal(mailbox.uidNext, 100);
881
+ test.equal(mailbox.uidValidity, 12345n);
882
+
883
+ // re-select drives the CLOSED response code through the live pipeline again
884
+ let again = await client.mailboxOpen('INBOX');
885
+ test.equal(again.exists, 3);
886
+ test.equal(errors.length, 0, 'no errors from the rev2-shaped response');
887
+
888
+ await client.logout();
889
+ client.close();
890
+ server.close();
891
+ test.done();
892
+ };
893
+
894
+ module.exports['Server: unsolicited STATUS for another mailbox is tolerated'] = async test => {
895
+ // RFC 9051 (Appendix E item 20): with rev2, servers may push updates that are
896
+ // unrelated to the selected mailbox (e.g. a STATUS for another mailbox during
897
+ // IDLE). The client must ignore them without corrupting the selected state.
898
+ let server = createServer({
899
+ handlers: {
900
+ NOOP(ctx) {
901
+ ctx.write('* STATUS "Other" (MESSAGES 5 UNSEEN 1)\r\n');
902
+ ctx.ok('NOOP completed');
903
+ }
904
+ }
905
+ });
906
+ let port = await listen(server);
907
+ let client = makeClient(port);
908
+ let errors = [];
909
+ client.on('error', err => errors.push(err));
910
+
911
+ await client.connect();
912
+ await client.mailboxOpen('INBOX');
913
+ test.equal(client.mailbox.exists, 3);
914
+
915
+ await client.noop();
916
+ await new Promise(r => setTimeout(r, 20));
917
+
918
+ test.equal(client.mailbox.path, 'INBOX', 'selected mailbox unchanged');
919
+ test.equal(client.mailbox.exists, 3, 'selected mailbox message count unchanged');
920
+ test.equal(errors.length, 0, 'unsolicited STATUS must not raise errors');
921
+
922
+ await client.logout();
923
+ client.close();
924
+ server.close();
925
+ test.done();
926
+ };
927
+
848
928
  module.exports['Server: invalid tagged response rejects with InvalidResponse'] = async test => {
849
929
  let server = createServer({
850
930
  handlers: {
@@ -435,6 +435,115 @@ module.exports['IMAP Parser: Literals: allow zero length literal in the end of a
435
435
  ])
436
436
  );
437
437
 
438
+ module.exports['IMAP Parser: Literals: zero length literal keeps the literal queue aligned'] = test =>
439
+ asyncWrapper(test, async test =>
440
+ // ImapStream queues a Buffer for every literal marker it extracts, including
441
+ // {0}, so the parser must consume exactly one queue entry per marker.
442
+ // Otherwise every literal after a {0} in the same response is silently
443
+ // shifted to the wrong value (RFC 9051 4.3 allows {0} as an empty string).
444
+ test.deepEqual((await parser('TAG1 CMD ({0}\r\n {5}\r\n)', { literals: [Buffer.from(''), Buffer.from('hello')] })).attributes, [
445
+ [
446
+ {
447
+ type: 'LITERAL',
448
+ value: Buffer.from('')
449
+ },
450
+ {
451
+ type: 'LITERAL',
452
+ value: Buffer.from('hello')
453
+ }
454
+ ]
455
+ ])
456
+ );
457
+
458
+ module.exports['IMAP Parser: Literals: zero length literal between literals keeps values aligned'] = test =>
459
+ asyncWrapper(test, async test =>
460
+ test.deepEqual(
461
+ (await parser('TAG1 CMD ({3}\r\n {0}\r\n {5}\r\n)', { literals: [Buffer.from('abc'), Buffer.from(''), Buffer.from('world')] })).attributes,
462
+ [
463
+ [
464
+ {
465
+ type: 'LITERAL',
466
+ value: Buffer.from('abc')
467
+ },
468
+ {
469
+ type: 'LITERAL',
470
+ value: Buffer.from('')
471
+ },
472
+ {
473
+ type: 'LITERAL',
474
+ value: Buffer.from('world')
475
+ }
476
+ ]
477
+ ]
478
+ )
479
+ );
480
+
481
+ // RFC 9051 updated resp-text to allow empty text: resp-text = ["[" resp-text-code "]" SP] [text]
482
+ module.exports['IMAP Parser: resp-text: bare OK with no text'] = test =>
483
+ asyncWrapper(test, async test => test.deepEqual(await parser('* OK'), { tag: '*', command: 'OK' }));
484
+
485
+ module.exports['IMAP Parser: resp-text: tagged OK with no text'] = test =>
486
+ asyncWrapper(test, async test => test.deepEqual(await parser('TAG1 OK'), { tag: 'TAG1', command: 'OK' }));
487
+
488
+ module.exports['IMAP Parser: resp-text: response code with no trailing text'] = test =>
489
+ asyncWrapper(test, async test => {
490
+ let parsed = await parser('* OK [UIDNEXT 5]');
491
+ test.equal(parsed.command, 'OK');
492
+ test.deepEqual(parsed.attributes, [
493
+ {
494
+ type: 'ATOM',
495
+ value: '',
496
+ section: [
497
+ { type: 'ATOM', value: 'UIDNEXT' },
498
+ { type: 'ATOM', value: '5' }
499
+ ]
500
+ }
501
+ ]);
502
+ });
503
+
504
+ module.exports['IMAP Parser: resp-text: response code with trailing space and no text'] = test =>
505
+ asyncWrapper(test, async test => {
506
+ let parsed = await parser('* OK [READ-WRITE] ');
507
+ test.equal(parsed.command, 'OK');
508
+ test.deepEqual(parsed.attributes, [
509
+ {
510
+ type: 'ATOM',
511
+ value: '',
512
+ section: [{ type: 'ATOM', value: 'READ-WRITE' }]
513
+ }
514
+ ]);
515
+ });
516
+
517
+ // RFC 9051 uses number64 (up to 2^63-1) for message and body sizes - values beyond
518
+ // 2^32 must survive the tokenizer without truncation
519
+ module.exports['IMAP Parser: number64: RFC822.SIZE beyond 32 bits'] = test =>
520
+ asyncWrapper(test, async test => {
521
+ let parsed = await parser('* 1 FETCH (RFC822.SIZE 12345678901234)');
522
+ test.deepEqual(parsed.attributes, [
523
+ { type: 'ATOM', value: 'FETCH' },
524
+ [
525
+ { type: 'ATOM', value: 'RFC822.SIZE' },
526
+ { type: 'ATOM', value: '12345678901234' }
527
+ ]
528
+ ]);
529
+ });
530
+
531
+ module.exports['IMAP Parser: number64: response code argument beyond 32 bits'] = test =>
532
+ asyncWrapper(test, async test => {
533
+ let parsed = await parser('* OK [HIGHESTMODSEQ 90060115194045007] Ok');
534
+ test.deepEqual(parsed.attributes, [
535
+ {
536
+ type: 'ATOM',
537
+ value: '',
538
+ section: [
539
+ { type: 'ATOM', value: 'HIGHESTMODSEQ' },
540
+ { type: 'ATOM', value: '90060115194045007' }
541
+ ]
542
+ },
543
+ { type: 'TEXT', value: 'Ok' }
544
+ ]);
545
+ });
546
+
438
547
  module.exports['IMAP Parser: Section: empty'] = test =>
439
548
  asyncWrapper(test, async test =>
440
549
  test.deepEqual((await parser('TAG1 CMD BODY[]')).attributes, [
@@ -1284,7 +1393,9 @@ module.exports['IMAP Parser, FETCH with BODYSTRUCTURE'] = test =>
1284
1393
  ]);
1285
1394
  });
1286
1395
 
1287
- module.exports['IMAP Parser, FETCH with BODYSTRUCTURE'] = test =>
1396
+ // NB! must not share a name with the deep-BODYSTRUCTURE test above - a duplicate
1397
+ // module.exports key silently overwrites the earlier test and it never runs
1398
+ module.exports['IMAP Parser, FETCH exceeding max nesting depth'] = test =>
1288
1399
  asyncWrapper(test, async test => {
1289
1400
  try {
1290
1401
  let parsed = await parser('* 1 FETCH (UID 1 (((((((((((((((((((((((((');
@@ -66,6 +66,52 @@ A LOGOUT
66
66
  writer().catch(err => test.ifError(err));
67
67
  };
68
68
 
69
+ module.exports['Literal8 marker activates literal extraction'] = test => {
70
+ // RFC 9051 folds the FETCH side of BINARY into base IMAP4rev2 - servers answer
71
+ // BINARY fetches with literal8 syntax (~{n}) whose content may contain NULs.
72
+ // The stream must treat the trailing {n} as a literal marker regardless of the
73
+ // '~' prefix and hand the raw bytes over unmodified.
74
+ let input = Buffer.from('* 1 FETCH (BINARY[1] ~{5}\r\nhel\x00o)\r\n', 'binary');
75
+
76
+ let stream = new ImapStream();
77
+
78
+ let reading = false;
79
+ let reader = async () => {
80
+ let cmd;
81
+ while ((cmd = stream.read()) !== null) {
82
+ test.equal(cmd.payload.toString('binary'), '* 1 FETCH (BINARY[1] ~{5}\r\n)');
83
+ test.equal(cmd.literals.length, 1);
84
+ test.deepEqual(cmd.literals[0], Buffer.from('hel\x00o', 'binary'));
85
+
86
+ // and the parser consumes the literal8 into a LITERAL node with the NUL intact
87
+ let parsed = await parser(cmd.payload, { literals: cmd.literals });
88
+ test.deepEqual(parsed.attributes[1][1], { type: 'LITERAL', value: Buffer.from('hel\x00o', 'binary') });
89
+ cmd.next();
90
+ }
91
+ };
92
+
93
+ stream.on('readable', () => {
94
+ if (!reading) {
95
+ reading = true;
96
+ reader()
97
+ .catch(err => test.ifError(err))
98
+ .finally(() => {
99
+ reading = false;
100
+ });
101
+ }
102
+ });
103
+
104
+ stream.on('error', err => {
105
+ test.ifError(err);
106
+ });
107
+
108
+ stream.on('end', () => {
109
+ test.done();
110
+ });
111
+
112
+ stream.end(input);
113
+ };
114
+
69
115
  module.exports['Single byte'] = test => {
70
116
  let input = Buffer.from(
71
117
  `A CAPABILITY
@@ -3,8 +3,11 @@
3
3
  Runs the ImapFlow client against a real IMAP4rev2 server - Dovecot 2.4+ in
4
4
  Docker - instead of protocol mocks. Covers the ENABLE IMAP4rev2 negotiation,
5
5
  LIST RETURN (SUBSCRIBED) without LSUB, subscription round-trips, UTF-8 mailbox
6
- names, inline LIST-STATUS, ESEARCH responses to plain SEARCH, and a message
7
- lifecycle smoke test with UID EXPUNGE.
6
+ names, inline LIST-STATUS (including the rev2 SIZE and DELETED status items),
7
+ ESEARCH responses to plain SEARCH, STATUS SIZE/DELETED, the rev2-shaped SELECT
8
+ response (untagged LIST, CLOSED on re-select), the folded-in FETCH BINARY,
9
+ COPYUID from the untagged OK on MOVE, and a message lifecycle smoke test with
10
+ UID EXPUNGE.
8
11
 
9
12
  ## Running
10
13
 
@@ -18,8 +21,16 @@ on `127.0.0.1:31143`, runs `rev2-live-test.js` with nodeunit, and always
18
21
  removes the container afterwards.
19
22
 
20
23
  These tests are intentionally not part of `npm test` - the Gruntfile nodeunit
21
- config excludes `test/integration/**`, so CI and plain test runs stay
22
- Docker-free.
24
+ config excludes `test/integration/**`, so plain test runs stay Docker-free.
25
+ CI runs this suite in a dedicated `test-rev2` job on `ubuntu-latest` (amd64
26
+ with Docker preinstalled), forcing `IMAPFLOW_DOVECOT_PLATFORM=linux/amd64` -
27
+ that job is the authoritative linux/amd64 run, since Apple Silicon machines
28
+ cannot execute the amd64 image (see below).
29
+
30
+ If a local image for the configured tag exists but was pulled for a different
31
+ architecture than the Docker host (e.g. an amd64 image left behind on an arm64
32
+ host), the runner script detects the mismatch and re-pulls the host-native
33
+ variant before starting the container.
23
34
 
24
35
  ## Environment overrides
25
36
 
@@ -28,7 +39,8 @@ Docker-free.
28
39
  - `IMAPFLOW_DOVECOT_PLATFORM` - e.g. `linux/amd64`; defaults to the host
29
40
  platform. Forcing `linux/amd64` on Apple Silicon does not work - Rosetta
30
41
  cannot start Dovecot's privilege-separated login processes
31
- (`rosetta error: mmap_anonymous_rw mmap failed`)
42
+ (`rosetta error: mmap_anonymous_rw mmap failed`; reconfirmed with
43
+ dovecot/dovecot:2.4.4 in July 2026)
32
44
  - `IMAPFLOW_TEST_PORT` - host port to publish (default 31143)
33
45
 
34
46
  ## Test account model
@@ -207,6 +207,131 @@ module.exports['Live rev2: returnOptions search is answered via a real ESEARCH r
207
207
  test.done();
208
208
  };
209
209
 
210
+ module.exports['Live rev2: STATUS reports SIZE and DELETED'] = async test => {
211
+ const logs = [];
212
+ const client = await connectClient(null, logs);
213
+ try {
214
+ const raw = Buffer.from('Subject: sized\r\n\r\nsized body\r\n');
215
+ await client.append('INBOX', raw, ['\\Deleted']);
216
+ await client.append('INBOX', Buffer.from('Subject: kept\r\n\r\nkept body\r\n'));
217
+
218
+ const status = await client.status('INBOX', { messages: true, size: true, deleted: true });
219
+
220
+ test.ok(clientSent(logs, 'SIZE'), 'STATUS should request SIZE');
221
+ test.ok(clientSent(logs, 'DELETED'), 'STATUS should request DELETED');
222
+ test.equal(status.messages, 2);
223
+ test.equal(status.deleted, 1, 'one message carries the \\Deleted flag');
224
+ test.ok(Number.isSafeInteger(status.size) && status.size >= raw.length, 'mailbox size should cover at least the first appended message');
225
+ } finally {
226
+ await client.logout();
227
+ }
228
+ test.done();
229
+ };
230
+
231
+ module.exports['Live rev2: statusQuery returns SIZE and DELETED inline via LIST-STATUS'] = async test => {
232
+ const client = await connectClient();
233
+ try {
234
+ await client.append('INBOX', Buffer.from('Subject: probe\r\n\r\nprobe body\r\n'), ['\\Deleted']);
235
+
236
+ const folders = await client.list({ statusQuery: { messages: true, size: true, deleted: true } });
237
+ const inbox = folders.find(folder => folder.path === 'INBOX');
238
+ test.equal(inbox.status.messages, 1);
239
+ test.equal(inbox.status.deleted, 1);
240
+ test.ok(Number.isSafeInteger(inbox.status.size) && inbox.status.size > 0);
241
+ } finally {
242
+ await client.logout();
243
+ }
244
+ test.done();
245
+ };
246
+
247
+ module.exports['Live rev2: SELECT response carries an untagged LIST and re-select gets CLOSED'] = async test => {
248
+ const logs = [];
249
+ const client = await connectClient(null, logs);
250
+ try {
251
+ await client.mailboxCreate('Closer');
252
+ const mailbox = await client.mailboxOpen('INBOX');
253
+ test.equal(mailbox.path, 'INBOX');
254
+
255
+ // RFC 9051 6.3.1: the SELECT response includes an untagged LIST for the
256
+ // selected mailbox - the client must consume it without issue
257
+ test.ok(serverSentUntagged(logs, 'LIST'), 'rev2 SELECT should include an untagged LIST response');
258
+
259
+ // switching mailboxes must produce a CLOSED response code for the old one
260
+ await client.mailboxOpen('Closer');
261
+ const closed = wireLines(logs).some(entry => entry.src === 's' && entry.msg.includes('[CLOSED]'));
262
+ test.ok(closed, 're-select should carry a CLOSED response code');
263
+ test.equal(client.mailbox.path, 'Closer', 'client state should track the newly selected mailbox');
264
+
265
+ await client.mailboxClose();
266
+ await client.mailboxDelete('Closer');
267
+ } finally {
268
+ await client.logout();
269
+ }
270
+ test.done();
271
+ };
272
+
273
+ module.exports['Live rev2: binary fetch uses the folded-in FETCH BINARY'] = async test => {
274
+ const logs = [];
275
+ const client = await connectClient(null, logs);
276
+ try {
277
+ // BINARY sections only allow numeric part specifiers, so use a multipart
278
+ // message - part "1" of a single-part message would resolve to the TEXT
279
+ // section, which must stay a BODY fetch. The base64 encoding gives the
280
+ // server-side BINARY decoding something to undo.
281
+ const content = [
282
+ 'Subject: bin',
283
+ 'MIME-Version: 1.0',
284
+ 'Content-Type: multipart/mixed; boundary=bb',
285
+ '',
286
+ '--bb',
287
+ 'Content-Type: text/plain',
288
+ 'Content-Transfer-Encoding: base64',
289
+ '',
290
+ Buffer.from('binary body').toString('base64'),
291
+ '--bb--',
292
+ ''
293
+ ].join('\r\n');
294
+ await client.append('INBOX', Buffer.from(content));
295
+
296
+ await client.mailboxOpen('INBOX');
297
+ const { content: downloadStream } = await client.download('1', '1', { binary: true });
298
+ const chunks = [];
299
+ for await (let chunk of downloadStream) {
300
+ chunks.push(chunk);
301
+ }
302
+
303
+ test.ok(clientSent(logs, 'BINARY.PEEK[1]'), 'client should issue a BINARY fetch for the numeric part on a rev2 session');
304
+ test.equal(Buffer.concat(chunks).toString().trim(), 'binary body', 'BINARY fetch should return the decoded content');
305
+ } finally {
306
+ await client.logout();
307
+ }
308
+ test.done();
309
+ };
310
+
311
+ module.exports['Live rev2: MOVE reports COPYUID from the untagged OK'] = async test => {
312
+ const client = await connectClient();
313
+ try {
314
+ await client.append('INBOX', Buffer.from('Subject: mover\r\n\r\nmover body\r\n'));
315
+ await client.mailboxCreate('Moved');
316
+
317
+ await client.mailboxOpen('INBOX');
318
+ const result = await client.messageMove('1', 'Moved');
319
+
320
+ // RFC 9051 6.4.8: the server is REQUIRED to send COPYUID in an untagged OK
321
+ // before the EXPUNGEs - verify the client captured it
322
+ test.ok(result, 'move should succeed');
323
+ test.equal(result.path, 'INBOX');
324
+ test.equal(result.destination, 'Moved');
325
+ test.ok(result.uidMap && result.uidMap.size === 1, 'COPYUID must be captured from the untagged OK');
326
+
327
+ await client.mailboxClose();
328
+ await client.mailboxDelete('Moved');
329
+ } finally {
330
+ await client.logout();
331
+ }
332
+ test.done();
333
+ };
334
+
210
335
  module.exports['Live rev2: message lifecycle smoke test'] = async test => {
211
336
  const client = await connectClient();
212
337
  try {
@@ -30,6 +30,20 @@ cleanup() {
30
30
  trap cleanup EXIT
31
31
  cleanup
32
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
+
33
47
  docker run ${PLATFORM_ARG:+"$PLATFORM_ARG"} -d --name "$CONTAINER_NAME" \
34
48
  -e USER_PASSWORD=pass \
35
49
  -v "$SCRIPT_DIR/dovecot-test.conf:/etc/dovecot/conf.d/99-imapflow-test.conf:ro" \
@@ -919,6 +919,23 @@ module.exports['Search Compiler: Unicode skipped when UTF8=ACCEPT enabled'] = te
919
919
  test.done();
920
920
  };
921
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
+
922
939
  module.exports['Search Compiler: GMRAW with Unicode adds CHARSET'] = test => {
923
940
  let connection = createMockConnection({
924
941
  capabilities: [['X-GM-EXT-1', true]],
@@ -23,7 +23,7 @@ module.exports['Tools: encodePath with ASCII path'] = test => {
23
23
  test.done();
24
24
  };
25
25
 
26
- module.exports['Tools: encodePath with Unicode path (no UTF8)'] = test => {
26
+ module.exports['Tools: encodePath with ASCII path (no UTF8)'] = test => {
27
27
  let connection = createMockConnection({ utf8: false });
28
28
  let result = tools.encodePath(connection, 'Sent/Gesendete');
29
29
  // ASCII path should remain unchanged
@@ -31,6 +31,15 @@ module.exports['Tools: encodePath with Unicode path (no UTF8)'] = test => {
31
31
  test.done();
32
32
  };
33
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
+
34
43
  module.exports['Tools: encodePath with Unicode when UTF8=ACCEPT enabled'] = test => {
35
44
  let connection = createMockConnection({ utf8: true });
36
45
  let result = tools.encodePath(connection, 'Posteingang/Ordner');
@@ -55,9 +64,10 @@ module.exports['Tools: decodePath with ASCII path'] = test => {
55
64
 
56
65
  module.exports['Tools: decodePath with ampersand'] = test => {
57
66
  let connection = createMockConnection({ utf8: false });
58
- // UTF-7-IMAP encoded string
59
- let result = tools.decodePath(connection, 'Test&-Folder');
60
- 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');
61
71
  test.done();
62
72
  };
63
73
 
@@ -1288,6 +1298,34 @@ module.exports['Tools: formatMessageResponse handles normal THREADID'] = async t
1288
1298
  test.done();
1289
1299
  };
1290
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
+
1291
1329
  // ============================================
1292
1330
  // formatMessageResponse: non-ASCII mailbox path normalization for stable id
1293
1331
  // ============================================