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