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.
- package/.github/workflows/test.yml +20 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/CLAUDE.md +12 -5
- package/Gruntfile.js +3 -1
- package/lib/commands/authenticate.js +8 -3
- package/lib/commands/enable.js +13 -4
- package/lib/commands/expunge.js +2 -2
- package/lib/commands/fetch.js +18 -14
- package/lib/commands/idle.js +6 -3
- package/lib/commands/list.js +241 -61
- package/lib/commands/move.js +2 -2
- package/lib/commands/namespace.js +3 -1
- package/lib/commands/search.js +88 -13
- package/lib/commands/status.js +19 -26
- package/lib/handler/imap-compiler.js +12 -9
- package/lib/handler/token-parser.js +7 -0
- package/lib/imap-flow.d.ts +19 -3
- package/lib/imap-flow.js +58 -9
- package/lib/search-compiler.js +15 -1
- package/lib/tools.js +173 -9
- package/package.json +3 -2
- package/test/commands-branches-test.js +11 -4
- package/test/commands-integration-test.js +1528 -108
- package/test/connection-edge-cases-test.js +4 -40
- package/test/fixtures/test-tls.js +2 -2
- package/test/handler-branches-test.js +4 -3
- package/test/imap-compiler-test.js +85 -0
- package/test/imap-flow-coverage-test.js +8 -1
- package/test/imap-flow-fetch-download-test.js +57 -4
- package/test/imap-flow-internals-test.js +2 -2
- package/test/imap-flow-methods-test.js +65 -6
- package/test/imap-flow-secure-test.js +25 -11
- package/test/imap-flow-server-test.js +80 -0
- package/test/imap-parser-test.js +113 -3
- package/test/imap-stream-test.js +46 -0
- package/test/integration/README.md +52 -0
- package/test/integration/dovecot-test.conf +27 -0
- package/test/integration/rev2-live-test.js +367 -0
- package/test/integration/run-rev2-tests.sh +75 -0
- package/test/reliability-improvements-test.js +4 -1
- package/test/search-compiler-test.js +36 -0
- package/test/search-test.js +52 -54
- package/test/tools-test.js +176 -19
package/test/imap-parser-test.js
CHANGED
|
@@ -341,8 +341,7 @@ module.exports['IMAP Parser: empty continuation (+) response'] = test =>
|
|
|
341
341
|
test.equal(parsed.command, '');
|
|
342
342
|
});
|
|
343
343
|
|
|
344
|
-
module.exports['IMAP Parser: non-space after command throws ParserError5'] = test =>
|
|
345
|
-
expectErrorCode(test, 'ParserError5', () => parser('TAG1 CMD\tx'));
|
|
344
|
+
module.exports['IMAP Parser: non-space after command throws ParserError5'] = test => expectErrorCode(test, 'ParserError5', () => parser('TAG1 CMD\tx'));
|
|
346
345
|
|
|
347
346
|
module.exports['IMAP Parser: leading whitespace in attributes throws ParserError7'] = test =>
|
|
348
347
|
expectErrorCode(test, 'ParserError7', () => parser('TAG1 CMD \tx'));
|
|
@@ -436,6 +435,115 @@ module.exports['IMAP Parser: Literals: allow zero length literal in the end of a
|
|
|
436
435
|
])
|
|
437
436
|
);
|
|
438
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
|
+
|
|
439
547
|
module.exports['IMAP Parser: Section: empty'] = test =>
|
|
440
548
|
asyncWrapper(test, async test =>
|
|
441
549
|
test.deepEqual((await parser('TAG1 CMD BODY[]')).attributes, [
|
|
@@ -1285,7 +1393,9 @@ module.exports['IMAP Parser, FETCH with BODYSTRUCTURE'] = test =>
|
|
|
1285
1393
|
]);
|
|
1286
1394
|
});
|
|
1287
1395
|
|
|
1288
|
-
|
|
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 =>
|
|
1289
1399
|
asyncWrapper(test, async test => {
|
|
1290
1400
|
try {
|
|
1291
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
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Live IMAP4rev2 integration tests
|
|
2
|
+
|
|
3
|
+
Runs the ImapFlow client against a real IMAP4rev2 server - Dovecot 2.4+ in
|
|
4
|
+
Docker - instead of protocol mocks. Covers the ENABLE IMAP4rev2 negotiation,
|
|
5
|
+
LIST RETURN (SUBSCRIBED) without LSUB, subscription round-trips, UTF-8 mailbox
|
|
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.
|
|
11
|
+
|
|
12
|
+
## Running
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
npm run test:rev2
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Requires Docker. The script pulls `dovecot/dovecot:2.4.4`, starts a container
|
|
19
|
+
with the drop-in config from `dovecot-test.conf`, waits for the IMAP greeting
|
|
20
|
+
on `127.0.0.1:31143`, runs `rev2-live-test.js` with nodeunit, and always
|
|
21
|
+
removes the container afterwards.
|
|
22
|
+
|
|
23
|
+
These tests are intentionally not part of `npm test` - the Gruntfile nodeunit
|
|
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.
|
|
34
|
+
|
|
35
|
+
## Environment overrides
|
|
36
|
+
|
|
37
|
+
- `IMAPFLOW_DOVECOT_IMAGE` - image to run (default `dovecot/dovecot:2.4.4`;
|
|
38
|
+
any 2.4.2+ tag supports IMAP4rev2)
|
|
39
|
+
- `IMAPFLOW_DOVECOT_PLATFORM` - e.g. `linux/amd64`; defaults to the host
|
|
40
|
+
platform. Forcing `linux/amd64` on Apple Silicon does not work - Rosetta
|
|
41
|
+
cannot start Dovecot's privilege-separated login processes
|
|
42
|
+
(`rosetta error: mmap_anonymous_rw mmap failed`; reconfirmed with
|
|
43
|
+
dovecot/dovecot:2.4.4 in July 2026)
|
|
44
|
+
- `IMAPFLOW_TEST_PORT` - host port to publish (default 31143)
|
|
45
|
+
|
|
46
|
+
## Test account model
|
|
47
|
+
|
|
48
|
+
The container uses Dovecot's static passdb (`USER_PASSWORD=pass`): any
|
|
49
|
+
username authenticates with the password `pass` and gets its own empty mail
|
|
50
|
+
home, so every test connects as a brand-new user and needs no cleanup between
|
|
51
|
+
runs. Special-use mailboxes (Sent/Drafts/Junk/Trash) are auto-created and
|
|
52
|
+
subscribed via `dovecot-test.conf`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Drop-in configuration for the ImapFlow IMAP4rev2 integration test container.
|
|
2
|
+
# Mounted into /etc/dovecot/conf.d/ of the dovecot/dovecot:2.4.x image, which
|
|
3
|
+
# already ships with imap4rev2_enable = yes and mail_utf8_extensions = yes.
|
|
4
|
+
|
|
5
|
+
# The test client connects over plain TCP on the container-internal IMAP port
|
|
6
|
+
# (31143), so cleartext logins must be allowed
|
|
7
|
+
auth_allow_cleartext = yes
|
|
8
|
+
|
|
9
|
+
# Special-use mailboxes, auto-created and subscribed for every test user
|
|
10
|
+
namespace inbox {
|
|
11
|
+
mailbox Sent {
|
|
12
|
+
special_use = \Sent
|
|
13
|
+
auto = subscribe
|
|
14
|
+
}
|
|
15
|
+
mailbox Drafts {
|
|
16
|
+
special_use = \Drafts
|
|
17
|
+
auto = subscribe
|
|
18
|
+
}
|
|
19
|
+
mailbox Junk {
|
|
20
|
+
special_use = \Junk
|
|
21
|
+
auto = subscribe
|
|
22
|
+
}
|
|
23
|
+
mailbox Trash {
|
|
24
|
+
special_use = \Trash
|
|
25
|
+
auto = subscribe
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Live integration tests against a real IMAP4rev2 server (Dovecot 2.4+ in Docker).
|
|
4
|
+
// Started via `npm run test:rev2` (see run-rev2-tests.sh) - excluded from the
|
|
5
|
+
// Gruntfile nodeunit config so `npm test` stays Docker-free.
|
|
6
|
+
//
|
|
7
|
+
// The container uses a static passdb: any username logs in with the password
|
|
8
|
+
// "pass", and every user gets a fresh mail home, so each test connects with a
|
|
9
|
+
// unique user for isolation.
|
|
10
|
+
|
|
11
|
+
const { ImapFlow } = require('../../lib/imap-flow');
|
|
12
|
+
|
|
13
|
+
const HOST = process.env.IMAPFLOW_TEST_HOST || '127.0.0.1';
|
|
14
|
+
const PORT = Number(process.env.IMAPFLOW_TEST_PORT) || 31143;
|
|
15
|
+
|
|
16
|
+
let userCounter = 0;
|
|
17
|
+
|
|
18
|
+
const connectClient = async (options, logs) => {
|
|
19
|
+
let client = new ImapFlow(
|
|
20
|
+
Object.assign(
|
|
21
|
+
{
|
|
22
|
+
host: HOST,
|
|
23
|
+
port: PORT,
|
|
24
|
+
secure: false,
|
|
25
|
+
// The test container upgrades via STARTTLS with a self-signed cert
|
|
26
|
+
tls: { rejectUnauthorized: false },
|
|
27
|
+
auth: { user: `livetest-${Date.now()}-${++userCounter}`, pass: 'pass' },
|
|
28
|
+
logger: false,
|
|
29
|
+
emitLogs: !!logs
|
|
30
|
+
},
|
|
31
|
+
options || {}
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
if (logs) {
|
|
35
|
+
client.on('log', entry => logs.push(entry));
|
|
36
|
+
}
|
|
37
|
+
await client.connect();
|
|
38
|
+
return client;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const wireLines = logs => logs.filter(entry => entry && typeof entry.msg === 'string' && ['c', 's'].includes(entry.src));
|
|
42
|
+
const clientSent = (logs, needle) => wireLines(logs).some(entry => entry.src === 'c' && entry.msg.includes(needle));
|
|
43
|
+
// Matches only an untagged response for the given command ("* ESEARCH ...").
|
|
44
|
+
// A plain substring check would false-positive on capability listings - the
|
|
45
|
+
// login response advertises tokens like ESEARCH in its CAPABILITY list.
|
|
46
|
+
const serverSentUntagged = (logs, command) => {
|
|
47
|
+
let re = new RegExp(`^\\* ${command}( |$)`);
|
|
48
|
+
return wireLines(logs).some(entry => entry.src === 's' && re.test(entry.msg));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
module.exports['Live rev2: connect negotiates ENABLE IMAP4rev2'] = async test => {
|
|
52
|
+
const client = await connectClient();
|
|
53
|
+
try {
|
|
54
|
+
test.ok(client.capabilities.has('IMAP4rev2'), 'server should advertise IMAP4rev2');
|
|
55
|
+
test.ok(client.enabled.has('IMAP4REV2'), 'IMAP4rev2 should be enabled');
|
|
56
|
+
// The single merged ENABLE call must not lose the other extensions
|
|
57
|
+
test.ok(client.enabled.has('CONDSTORE'), 'CONDSTORE should still be enabled');
|
|
58
|
+
} finally {
|
|
59
|
+
await client.logout();
|
|
60
|
+
}
|
|
61
|
+
test.done();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
module.exports['Live rev2: list uses RETURN (SUBSCRIBED) and skips LSUB'] = async test => {
|
|
65
|
+
const logs = [];
|
|
66
|
+
const client = await connectClient(null, logs);
|
|
67
|
+
try {
|
|
68
|
+
const folders = await client.list();
|
|
69
|
+
|
|
70
|
+
test.ok(clientSent(logs, 'SUBSCRIBED'), 'LIST should request RETURN (SUBSCRIBED)');
|
|
71
|
+
test.ok(!clientSent(logs, 'LSUB'), 'LSUB must not be issued on a rev2 session');
|
|
72
|
+
|
|
73
|
+
const sent = folders.find(folder => folder.path === 'Sent');
|
|
74
|
+
test.ok(sent, 'Sent mailbox should be listed');
|
|
75
|
+
test.equal(sent.subscribed, true, 'Sent should be reported as subscribed');
|
|
76
|
+
test.equal(sent.specialUse, '\\Sent', 'special-use flag should be honored');
|
|
77
|
+
// \HasNoChildren must survive the extended LIST (RETURN (CHILDREN) requested)
|
|
78
|
+
test.ok(sent.flags.has('\\HasNoChildren'), 'child info should be present');
|
|
79
|
+
for (let folder of folders) {
|
|
80
|
+
test.ok(!folder.flags.has('\\Subscribed'), '\\Subscribed must be folded into the subscribed property');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const inbox = folders.find(folder => folder.path === 'INBOX');
|
|
84
|
+
test.ok(inbox, 'INBOX should be listed');
|
|
85
|
+
test.equal(inbox.subscribed, true, 'INBOX is always reported as subscribed');
|
|
86
|
+
} finally {
|
|
87
|
+
await client.logout();
|
|
88
|
+
}
|
|
89
|
+
test.done();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
module.exports['Live rev2: subscription state round-trips'] = async test => {
|
|
93
|
+
const client = await connectClient();
|
|
94
|
+
try {
|
|
95
|
+
await client.mailboxCreate('RoundTrip');
|
|
96
|
+
await client.mailboxSubscribe('RoundTrip');
|
|
97
|
+
let folders = await client.list();
|
|
98
|
+
test.equal(folders.find(folder => folder.path === 'RoundTrip').subscribed, true);
|
|
99
|
+
|
|
100
|
+
await client.mailboxUnsubscribe('RoundTrip');
|
|
101
|
+
folders = await client.list();
|
|
102
|
+
test.ok(!folders.find(folder => folder.path === 'RoundTrip').subscribed);
|
|
103
|
+
} finally {
|
|
104
|
+
await client.logout();
|
|
105
|
+
}
|
|
106
|
+
test.done();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
module.exports['Live rev2: deleted-but-subscribed folders stay out of listings'] = async test => {
|
|
110
|
+
const client = await connectClient();
|
|
111
|
+
try {
|
|
112
|
+
await client.mailboxCreate('Ghost');
|
|
113
|
+
await client.mailboxSubscribe('Ghost');
|
|
114
|
+
await client.mailboxDelete('Ghost');
|
|
115
|
+
|
|
116
|
+
const folders = await client.list();
|
|
117
|
+
// The RETURN (SUBSCRIBED) option must not resurrect phantom subscriptions
|
|
118
|
+
// the way a raw LSUB would
|
|
119
|
+
test.ok(!folders.find(folder => folder.path === 'Ghost'), 'deleted folder must not be listed');
|
|
120
|
+
} finally {
|
|
121
|
+
await client.logout();
|
|
122
|
+
}
|
|
123
|
+
test.done();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
module.exports['Live rev2: UTF-8 mailbox names round-trip'] = async test => {
|
|
127
|
+
const client = await connectClient();
|
|
128
|
+
try {
|
|
129
|
+
const name = 'T\u00f5rva \u00f5un';
|
|
130
|
+
await client.mailboxCreate(name);
|
|
131
|
+
const folders = await client.list();
|
|
132
|
+
const created = folders.find(folder => folder.path === name);
|
|
133
|
+
test.ok(created, 'UTF-8 mailbox should be listed under its literal name');
|
|
134
|
+
|
|
135
|
+
const mailbox = await client.mailboxOpen(name);
|
|
136
|
+
test.equal(mailbox.path, name);
|
|
137
|
+
await client.mailboxClose();
|
|
138
|
+
await client.mailboxDelete(name);
|
|
139
|
+
} finally {
|
|
140
|
+
await client.logout();
|
|
141
|
+
}
|
|
142
|
+
test.done();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
module.exports['Live rev2: statusQuery is answered inline via LIST-STATUS'] = async test => {
|
|
146
|
+
const logs = [];
|
|
147
|
+
const client = await connectClient(null, logs);
|
|
148
|
+
try {
|
|
149
|
+
await client.append('INBOX', Buffer.from('Subject: status probe\r\n\r\nstatus probe body\r\n'));
|
|
150
|
+
|
|
151
|
+
const folders = await client.list({ statusQuery: { messages: true, unseen: true } });
|
|
152
|
+
|
|
153
|
+
test.ok(clientSent(logs, 'STATUS'), 'LIST should carry RETURN (STATUS ...)');
|
|
154
|
+
const inbox = folders.find(folder => folder.path === 'INBOX');
|
|
155
|
+
test.equal(inbox.status.messages, 1, 'inline STATUS should report the appended message');
|
|
156
|
+
} finally {
|
|
157
|
+
await client.logout();
|
|
158
|
+
}
|
|
159
|
+
test.done();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
module.exports['Live rev2: plain search results are collected'] = async test => {
|
|
163
|
+
const logs = [];
|
|
164
|
+
const client = await connectClient(null, logs);
|
|
165
|
+
try {
|
|
166
|
+
await client.append('INBOX', Buffer.from('Subject: first\r\n\r\nfirst\r\n'));
|
|
167
|
+
await client.append('INBOX', Buffer.from('Subject: second\r\n\r\nsecond\r\n'));
|
|
168
|
+
|
|
169
|
+
await client.mailboxOpen('INBOX');
|
|
170
|
+
const results = await client.search({ all: true });
|
|
171
|
+
|
|
172
|
+
// RFC 9051 deprecates the untagged SEARCH response in favor of ESEARCH,
|
|
173
|
+
// but Dovecot 2.4 still answers a plain SEARCH with the legacy form even
|
|
174
|
+
// on an ENABLEd rev2 session (verified on the wire). The client accepts
|
|
175
|
+
// both forms, so assert that the results arrived via one of them - the
|
|
176
|
+
// ESEARCH-answered variant of a plain SEARCH is covered by the mock
|
|
177
|
+
// suite, and a real ESEARCH response is exercised live in the
|
|
178
|
+
// returnOptions test below.
|
|
179
|
+
test.ok(serverSentUntagged(logs, 'SEARCH') || serverSentUntagged(logs, 'ESEARCH'), 'results should arrive via an untagged SEARCH or ESEARCH response');
|
|
180
|
+
test.deepEqual(results, [1, 2]);
|
|
181
|
+
} finally {
|
|
182
|
+
await client.logout();
|
|
183
|
+
}
|
|
184
|
+
test.done();
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
module.exports['Live rev2: returnOptions search is answered via a real ESEARCH response'] = async test => {
|
|
188
|
+
const logs = [];
|
|
189
|
+
const client = await connectClient(null, logs);
|
|
190
|
+
try {
|
|
191
|
+
await client.append('INBOX', Buffer.from('Subject: first\r\n\r\nfirst\r\n'));
|
|
192
|
+
await client.append('INBOX', Buffer.from('Subject: second\r\n\r\nsecond\r\n'));
|
|
193
|
+
|
|
194
|
+
await client.mailboxOpen('INBOX');
|
|
195
|
+
const result = await client.search({ all: true }, { returnOptions: ['ALL', 'COUNT'] });
|
|
196
|
+
|
|
197
|
+
// SEARCH RETURN (...) makes Dovecot answer with a genuine untagged
|
|
198
|
+
// ESEARCH response - this exercises the ESEARCH parsing path against a
|
|
199
|
+
// real server
|
|
200
|
+
test.ok(serverSentUntagged(logs, 'ESEARCH'), 'server should reply with an untagged ESEARCH response');
|
|
201
|
+
test.ok(!serverSentUntagged(logs, 'SEARCH'), 'no legacy untagged SEARCH response is expected');
|
|
202
|
+
test.equal(result.count, 2);
|
|
203
|
+
test.equal(result.all, '1:2');
|
|
204
|
+
} finally {
|
|
205
|
+
await client.logout();
|
|
206
|
+
}
|
|
207
|
+
test.done();
|
|
208
|
+
};
|
|
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
|
+
|
|
335
|
+
module.exports['Live rev2: message lifecycle smoke test'] = async test => {
|
|
336
|
+
const client = await connectClient();
|
|
337
|
+
try {
|
|
338
|
+
await client.append('INBOX', Buffer.from('Subject: smoke\r\n\r\nsmoke body\r\n'), ['\\Seen']);
|
|
339
|
+
|
|
340
|
+
await client.mailboxOpen('INBOX');
|
|
341
|
+
const message = await client.fetchOne('1', { envelope: true, flags: true, uid: true });
|
|
342
|
+
test.equal(message.envelope.subject, 'smoke');
|
|
343
|
+
test.ok(message.flags.has('\\Seen'));
|
|
344
|
+
|
|
345
|
+
await client.mailboxCreate('Smoke');
|
|
346
|
+
await client.messageCopy('1', 'Smoke');
|
|
347
|
+
await client.messageMove('1', 'Smoke');
|
|
348
|
+
|
|
349
|
+
const status = await client.status('Smoke', { messages: true });
|
|
350
|
+
test.equal(status.messages, 2, 'copy plus move should land two messages');
|
|
351
|
+
|
|
352
|
+
// uid expunge - UIDPLUS is folded into base rev2, so only the requested
|
|
353
|
+
// message may disappear
|
|
354
|
+
await client.mailboxOpen('Smoke');
|
|
355
|
+
const first = await client.fetchOne('1', { uid: true });
|
|
356
|
+
await client.messageDelete(`${first.uid}`, { uid: true });
|
|
357
|
+
const statusAfter = await client.status('Smoke', { messages: true });
|
|
358
|
+
test.equal(statusAfter.messages, 1, 'UID EXPUNGE must remove only the targeted message');
|
|
359
|
+
|
|
360
|
+
await client.mailboxClose();
|
|
361
|
+
await client.mailboxRename('Smoke', 'Smoke2');
|
|
362
|
+
await client.mailboxDelete('Smoke2');
|
|
363
|
+
} finally {
|
|
364
|
+
await client.logout();
|
|
365
|
+
}
|
|
366
|
+
test.done();
|
|
367
|
+
};
|