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.
- package/.github/workflows/test.yml +20 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/CLAUDE.md +2 -4
- package/lib/commands/fetch.js +18 -14
- package/lib/commands/list.js +4 -1
- package/lib/commands/status.js +6 -1
- package/lib/handler/imap-compiler.js +12 -9
- package/lib/handler/token-parser.js +7 -0
- package/lib/imap-flow.d.ts +14 -0
- package/lib/imap-flow.js +19 -2
- package/lib/tools.js +35 -3
- package/package.json +1 -1
- package/test/commands-integration-test.js +329 -0
- package/test/connection-edge-cases-test.js +4 -40
- package/test/handler-branches-test.js +4 -3
- package/test/imap-compiler-test.js +85 -0
- package/test/imap-flow-fetch-download-test.js +56 -0
- package/test/imap-flow-secure-test.js +25 -11
- package/test/imap-flow-server-test.js +80 -0
- package/test/imap-parser-test.js +112 -1
- package/test/imap-stream-test.js +46 -0
- package/test/integration/README.md +17 -5
- package/test/integration/rev2-live-test.js +125 -0
- package/test/integration/run-rev2-tests.sh +14 -0
- package/test/search-compiler-test.js +17 -0
- 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: {
|
package/test/imap-parser-test.js
CHANGED
|
@@ -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
|
-
|
|
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 (((((((((((((((((((((((((');
|
package/test/imap-stream-test.js
CHANGED
|
@@ -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
|
|
7
|
-
|
|
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
|
|
22
|
-
|
|
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]],
|
package/test/tools-test.js
CHANGED
|
@@ -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
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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
|
// ============================================
|