imapflow 1.4.9 → 1.6.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 (48) hide show
  1. package/.github/workflows/test.yml +20 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/CLAUDE.md +3 -5
  5. package/lib/commands/fetch.js +18 -14
  6. package/lib/commands/idle.js +197 -104
  7. package/lib/commands/list.js +19 -8
  8. package/lib/commands/quota.js +3 -0
  9. package/lib/commands/select.js +5 -0
  10. package/lib/commands/status.js +10 -1
  11. package/lib/connection-deadline.js +98 -0
  12. package/lib/handler/imap-compiler.js +20 -14
  13. package/lib/handler/imap-stream.js +141 -50
  14. package/lib/handler/limits.js +43 -0
  15. package/lib/handler/token-parser.js +38 -1
  16. package/lib/imap-flow.d.ts +47 -5
  17. package/lib/imap-flow.js +594 -283
  18. package/lib/proxy-connection.js +393 -98
  19. package/lib/special-use.js +660 -51
  20. package/lib/tools.js +52 -3
  21. package/package.json +2 -2
  22. package/test/commands-branches-test.js +17 -1
  23. package/test/commands-integration-test.js +353 -2
  24. package/test/connection-edge-cases-test.js +4 -40
  25. package/test/fixtures/fake-timers.js +115 -0
  26. package/test/handler-branches-test.js +4 -28
  27. package/test/idle-polling-test.js +349 -0
  28. package/test/imap-compiler-test.js +85 -0
  29. package/test/imap-flow-compress-test.js +12 -0
  30. package/test/imap-flow-coverage-test.js +3 -3
  31. package/test/imap-flow-fetch-download-test.js +56 -0
  32. package/test/imap-flow-internals-test.js +23 -0
  33. package/test/imap-flow-proxy-paths-test.js +151 -0
  34. package/test/imap-flow-secure-test.js +182 -9
  35. package/test/imap-flow-server-test.js +229 -0
  36. package/test/imap-parser-test.js +112 -1
  37. package/test/imap-stream-test.js +46 -0
  38. package/test/integration/README.md +17 -5
  39. package/test/integration/rev2-live-test.js +125 -0
  40. package/test/integration/run-rev2-tests.sh +14 -0
  41. package/test/parser-limits-test.js +274 -0
  42. package/test/proxy-connection-test.js +553 -442
  43. package/test/reliability-improvements-test.js +87 -0
  44. package/test/search-compiler-test.js +17 -0
  45. package/test/special-use-test.js +337 -0
  46. package/test/tag-correlation-test.js +333 -0
  47. package/test/timer-policy-test.js +214 -0
  48. package/test/tools-test.js +42 -4
@@ -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: {
@@ -1023,6 +1103,155 @@ module.exports['Server: proxy connection failure rejects connect'] = async test
1023
1103
  test.done();
1024
1104
  };
1025
1105
 
1106
+ module.exports['Server: close clears public session state'] = async test => {
1107
+ // Callers inspect these properties in reconnect logic, so they must not keep describing a
1108
+ // session that is gone.
1109
+ let server = createServer();
1110
+ let port = await listen(server);
1111
+ let client = makeClient(port);
1112
+ client.on('error', () => {});
1113
+
1114
+ let events = [];
1115
+ client.on('mailboxClose', mailbox => events.push({ event: 'mailboxClose', path: mailbox.path }));
1116
+ client.on('close', () => events.push({ event: 'close' }));
1117
+
1118
+ await client.connect();
1119
+ await client.mailboxOpen('INBOX');
1120
+
1121
+ test.ok(client.mailbox, 'a mailbox is selected');
1122
+ test.ok(client.authenticated, 'the session is authenticated');
1123
+ test.ok(client.currentSelectCommand, 'the select command is remembered for polling');
1124
+
1125
+ client.close();
1126
+
1127
+ test.equal(client.mailbox, false, 'mailbox state cleared');
1128
+ test.equal(client.currentSelectCommand, false, 'saved select command cleared');
1129
+ test.equal(client.authenticated, false, 'authentication state cleared');
1130
+ test.equal(client.preCheck, false, 'preCheck cleared');
1131
+ test.equal(client.usable, false, 'connection no longer usable');
1132
+ test.equal(client.idling, false, 'idling cleared');
1133
+ test.equal(client.state, client.states.LOGOUT, 'state is LOGOUT');
1134
+
1135
+ // The selected mailbox transitions to closed exactly once, before 'close'
1136
+ test.deepEqual(events, [{ event: 'mailboxClose', path: 'INBOX' }, { event: 'close' }], 'mailboxClose is emitted once, ahead of close');
1137
+
1138
+ // Repeated close() is idempotent and emits nothing more
1139
+ client.close();
1140
+ client.close();
1141
+ test.equal(events.length, 2, 'no duplicate events from repeated close()');
1142
+
1143
+ server.close();
1144
+ test.done();
1145
+ };
1146
+
1147
+ module.exports['Server: close without a selected mailbox emits no mailboxClose'] = async test => {
1148
+ let server = createServer();
1149
+ let port = await listen(server);
1150
+ let client = makeClient(port);
1151
+ client.on('error', () => {});
1152
+
1153
+ let mailboxCloseCount = 0;
1154
+ client.on('mailboxClose', () => mailboxCloseCount++);
1155
+
1156
+ await client.connect();
1157
+ client.close();
1158
+
1159
+ test.equal(mailboxCloseCount, 0, 'nothing to close, nothing emitted');
1160
+ server.close();
1161
+ test.done();
1162
+ };
1163
+
1164
+ module.exports['Server: GETQUOTA fallback releases the parser'] = async test => {
1165
+ // Regression: the GETQUOTA fallback never handed its response back to the reader, so the
1166
+ // parser stayed blocked on its backpressure callback and every later command hung.
1167
+ let server = createServer({
1168
+ capabilities: 'IMAP4rev1 ID ENABLE NAMESPACE QUOTA',
1169
+ handlers: {
1170
+ GETQUOTAROOT(ctx) {
1171
+ // root only, no inline QUOTA response - forces the fallback command
1172
+ ctx.write('* QUOTAROOT "INBOX" "userquota"\r\n');
1173
+ ctx.ok('GETQUOTAROOT completed');
1174
+ },
1175
+ GETQUOTA(ctx) {
1176
+ ctx.write('* QUOTA "userquota" (STORAGE 512 1024)\r\n');
1177
+ ctx.ok('GETQUOTA completed');
1178
+ }
1179
+ }
1180
+ });
1181
+ let port = await listen(server);
1182
+ let client = makeClient(port);
1183
+ client.on('error', () => {});
1184
+
1185
+ await client.connect();
1186
+
1187
+ let quota = await client.getQuota();
1188
+ test.ok(quota, 'quota resolved');
1189
+ test.equal(quota.quotaRoot, 'userquota', 'quota root from the first command');
1190
+ test.equal(quota.storage.usage, 512 * 1024, 'quota usage from the fallback command');
1191
+
1192
+ // The parser must still be live: a following command has to complete.
1193
+ let noopResponse = await client.exec('NOOP', false, {});
1194
+ noopResponse.next();
1195
+ test.ok(noopResponse.response, 'the connection still processes commands after the fallback');
1196
+
1197
+ await client.logout();
1198
+ client.close();
1199
+ server.close();
1200
+ test.done();
1201
+ };
1202
+
1203
+ module.exports['Server: oversized literal cannot inject protocol'] = async test => {
1204
+ // Response-injection regression. With a lowered maxLiteralSize the parser rejects the
1205
+ // literal, and everything after the marker line is ordinary message content chosen by a
1206
+ // third party. None of it may be parsed: no untagged handler may fire and the forged
1207
+ // tagged completion may not settle the in-flight request.
1208
+ let server = createServer({
1209
+ handlers: {
1210
+ NOOP(ctx) {
1211
+ ctx.write(
1212
+ `* 1 FETCH (BODY[] {5000}\r\n` + //
1213
+ `INNOCENT MESSAGE TEXT\r\n` +
1214
+ `* 9999 EXISTS\r\n` +
1215
+ `${ctx.tag} OK forged completion\r\n`
1216
+ );
1217
+ }
1218
+ }
1219
+ });
1220
+ let port = await listen(server);
1221
+ let client = makeClient(port, { maxLiteralSize: 1024 });
1222
+
1223
+ let errors = [];
1224
+ let existsEvents = [];
1225
+ client.on('error', err => errors.push(err));
1226
+ client.on('exists', ev => existsEvents.push(ev));
1227
+
1228
+ await client.connect();
1229
+ let mailbox = await client.mailboxOpen('INBOX');
1230
+ test.equal(mailbox.exists, 3, 'mailbox opened with the server reported count');
1231
+
1232
+ let noopErr = null;
1233
+ try {
1234
+ // exec() surfaces the raw command outcome, so a forged tagged completion would show
1235
+ // up here as a resolved request (client.noop() would swallow it into `false`).
1236
+ await client.exec('NOOP', false, {});
1237
+ test.ok(false, 'the forged tagged completion must not resolve the in-flight command');
1238
+ } catch (err) {
1239
+ noopErr = err;
1240
+ }
1241
+
1242
+ test.ok(noopErr, 'the in-flight command rejected instead of accepting injected content');
1243
+ test.deepEqual(existsEvents, [], 'the injected untagged EXISTS never reached an untagged handler');
1244
+ test.ok(
1245
+ errors.some(err => err.code === 'LiteralTooLarge'),
1246
+ 'the limit violation is surfaced to the caller'
1247
+ );
1248
+ test.ok(client.isClosed || !client.usable, 'the connection failed closed');
1249
+
1250
+ client.close();
1251
+ server.close();
1252
+ test.done();
1253
+ };
1254
+
1026
1255
  module.exports['Server: socket close triggers close handling'] = async test => {
1027
1256
  let server = createServer();
1028
1257
  let port = await listen(server);
@@ -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" \