imapflow 1.3.5 → 1.3.7
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/codeql/codeql-config.yml +12 -0
- package/.github/workflows/codeql.yml +102 -0
- package/.github/workflows/stale.yml +5 -0
- package/.github/workflows/test.yml +3 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/lib/commands/authenticate.js +5 -5
- package/lib/commands/fetch.js +1 -0
- package/lib/commands/logout.js +1 -0
- package/lib/commands/select.js +2 -1
- package/lib/commands/starttls.js +3 -0
- package/lib/commands/store.js +1 -0
- package/lib/handler/imap-stream.js +23 -6
- package/lib/handler/token-parser.js +0 -5
- package/lib/imap-flow.d.ts +5 -0
- package/lib/imap-flow.js +205 -29
- package/lib/proxy-connection.js +26 -1
- package/lib/tools.js +12 -0
- package/package.json +1 -1
- package/test/commands-branches-test.js +1068 -0
- package/test/connection-edge-cases-test.js +138 -1
- package/test/fixtures/test-tls.js +8 -0
- package/test/handler-branches-test.js +334 -0
- package/test/imap-compiler-test.js +47 -0
- package/test/imap-flow-compress-test.js +154 -0
- package/test/imap-flow-coverage-test.js +609 -0
- package/test/imap-flow-fetch-download-test.js +801 -0
- package/test/imap-flow-internals-test.js +450 -0
- package/test/imap-flow-methods-test.js +738 -0
- package/test/imap-flow-proxy-paths-test.js +215 -0
- package/test/imap-flow-secure-test.js +327 -0
- package/test/imap-flow-server-test.js +1046 -0
- package/test/imap-formal-syntax-test.js +18 -0
- package/test/imap-parser-test.js +52 -0
- package/test/imap-stream-edge-cases-test.js +122 -0
- package/test/jp-decoder-test.js +48 -0
- package/test/limited-passthrough-test.js +17 -0
- package/test/proxy-connection-test.js +86 -0
- package/test/reliability-improvements-test.js +88 -0
- package/test/search-compiler-test.js +31 -0
- package/test/search-test.js +61 -0
- package/test/starttls-injection-test.js +181 -0
- package/test/token-parser-test.js +64 -0
- package/test/tools-test.js +360 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const { ImapFlow } = require('../lib/imap-flow');
|
|
5
|
+
const { ImapStream } = require('../lib/handler/imap-stream');
|
|
6
|
+
|
|
7
|
+
// Minimal pre-STARTTLS IMAP server. Sends the greeting, advertises STARTTLS, and on the
|
|
8
|
+
// STARTTLS command invokes `onStartTls(socket, tag)` so each test controls what (if
|
|
9
|
+
// anything) is written after the tagged OK. It never performs a real TLS handshake.
|
|
10
|
+
const createStartTlsServer = onStartTls =>
|
|
11
|
+
net.createServer(socket => {
|
|
12
|
+
socket.on('error', () => {});
|
|
13
|
+
socket.write('* OK [CAPABILITY IMAP4rev1 STARTTLS LOGINDISABLED] ready\r\n');
|
|
14
|
+
|
|
15
|
+
let buf = '';
|
|
16
|
+
socket.on('data', data => {
|
|
17
|
+
buf += data.toString('binary');
|
|
18
|
+
let idx;
|
|
19
|
+
while ((idx = buf.indexOf('\r\n')) >= 0) {
|
|
20
|
+
let line = buf.slice(0, idx);
|
|
21
|
+
buf = buf.slice(idx + 2);
|
|
22
|
+
let parts = line.split(' ');
|
|
23
|
+
let tag = parts[0];
|
|
24
|
+
let command = (parts[1] || '').toUpperCase();
|
|
25
|
+
if (command === 'CAPABILITY') {
|
|
26
|
+
socket.write('* CAPABILITY IMAP4rev1 STARTTLS LOGINDISABLED\r\n');
|
|
27
|
+
socket.write(`${tag} OK CAPABILITY done\r\n`);
|
|
28
|
+
} else if (command === 'STARTTLS') {
|
|
29
|
+
onStartTls(socket, tag);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const makeClient = port =>
|
|
36
|
+
new ImapFlow({
|
|
37
|
+
host: '127.0.0.1',
|
|
38
|
+
port,
|
|
39
|
+
secure: false,
|
|
40
|
+
doSTARTTLS: true,
|
|
41
|
+
servername: 'localhost',
|
|
42
|
+
tls: { rejectUnauthorized: false },
|
|
43
|
+
disableAutoIdle: true,
|
|
44
|
+
logger: false,
|
|
45
|
+
auth: { user: 'test', pass: 'test' }
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// A MITM injects a response in the SAME segment as the STARTTLS OK, before the handshake.
|
|
49
|
+
// This is caught by the parser-level trailing-data flag (Check 1 in upgradeToSTARTTLS).
|
|
50
|
+
// Injection that instead arrives after the OK in a separate segment is caught by the
|
|
51
|
+
// socket-level read after unpipe (Check 2) or, failing that, by TLS handshake corruption;
|
|
52
|
+
// those fragmented cases are timing-dependent and not asserted deterministically here.
|
|
53
|
+
module.exports['STARTTLS: rejects same-segment plaintext injection'] = async test => {
|
|
54
|
+
let server = createStartTlsServer((socket, tag) => {
|
|
55
|
+
// OK and an injected untagged CAPABILITY in a single write, no TLS handshake.
|
|
56
|
+
socket.write(`${tag} OK Begin TLS\r\n* CAPABILITY IMAP4rev1 INJECTED\r\n`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
60
|
+
let port = server.address().port;
|
|
61
|
+
|
|
62
|
+
let client = makeClient(port);
|
|
63
|
+
client.on('error', () => {});
|
|
64
|
+
|
|
65
|
+
let connectErr = null;
|
|
66
|
+
try {
|
|
67
|
+
await client.connect();
|
|
68
|
+
test.ok(false, 'connect() must reject when data is injected after the STARTTLS OK');
|
|
69
|
+
} catch (err) {
|
|
70
|
+
connectErr = err;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test.ok(connectErr, 'connect() rejected');
|
|
74
|
+
test.equal(connectErr.code, 'STARTTLS_INJECTION', 'rejection is flagged as an injection');
|
|
75
|
+
test.ok(connectErr.tlsFailed, 'rejection is treated as a TLS failure');
|
|
76
|
+
// The connection must fail closed: it never becomes usable and never authenticates.
|
|
77
|
+
// (The injected untagged response may be parsed transiently before teardown, so asserting
|
|
78
|
+
// it never reaches the capability set would be a timing-fragile false assurance.)
|
|
79
|
+
test.ok(!client.usable, 'the connection did not become usable (failed closed)');
|
|
80
|
+
test.ok(!client.authenticated, 'the connection never authenticated');
|
|
81
|
+
|
|
82
|
+
client.close();
|
|
83
|
+
server.close();
|
|
84
|
+
test.done();
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// A compliant server stays silent after the STARTTLS OK. The injection guard must NOT
|
|
88
|
+
// fire (no false positive); the upgrade proceeds to the TLS handshake, which then fails
|
|
89
|
+
// here only because this stub server never speaks TLS — proving the guard let it through.
|
|
90
|
+
module.exports['STARTTLS: clean OK is not flagged as injection'] = async test => {
|
|
91
|
+
let server = createStartTlsServer((socket, tag) => {
|
|
92
|
+
// Only the tagged OK, nothing after it, then drop the connection.
|
|
93
|
+
socket.write(`${tag} OK Begin TLS\r\n`);
|
|
94
|
+
setImmediate(() => socket.destroy());
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
98
|
+
let port = server.address().port;
|
|
99
|
+
|
|
100
|
+
let client = makeClient(port);
|
|
101
|
+
client.on('error', () => {});
|
|
102
|
+
|
|
103
|
+
let connectErr = null;
|
|
104
|
+
try {
|
|
105
|
+
await client.connect();
|
|
106
|
+
test.ok(false, 'connect() rejects because the stub never completes the TLS handshake');
|
|
107
|
+
} catch (err) {
|
|
108
|
+
connectErr = err;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test.ok(connectErr, 'connect() rejected (no TLS handshake on the stub server)');
|
|
112
|
+
test.notEqual(connectErr.code, 'STARTTLS_INJECTION', 'a clean OK must not be flagged as injection');
|
|
113
|
+
|
|
114
|
+
client.close();
|
|
115
|
+
server.close();
|
|
116
|
+
test.done();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// A STARTTLS handshake failure must reject connect() through a single error path: it must
|
|
120
|
+
// not also emit an 'error' event (which would double-report and crash a listener-less client).
|
|
121
|
+
module.exports['STARTTLS: handshake failure has a single error path'] = async test => {
|
|
122
|
+
let server = createStartTlsServer((socket, tag) => {
|
|
123
|
+
// Acknowledge STARTTLS, then drop the connection instead of doing TLS.
|
|
124
|
+
socket.write(`${tag} OK Begin TLS\r\n`);
|
|
125
|
+
setImmediate(() => socket.destroy());
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
129
|
+
let port = server.address().port;
|
|
130
|
+
|
|
131
|
+
let client = makeClient(port);
|
|
132
|
+
|
|
133
|
+
// A second error path would emit here (and crash a listener-less client).
|
|
134
|
+
let errorEvents = 0;
|
|
135
|
+
client.on('error', () => {
|
|
136
|
+
errorEvents++;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
let connectErr = null;
|
|
140
|
+
try {
|
|
141
|
+
await client.connect();
|
|
142
|
+
test.ok(false, 'connect() should reject on STARTTLS handshake failure');
|
|
143
|
+
} catch (err) {
|
|
144
|
+
connectErr = err;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Give any stray async error handler a chance to (wrongly) fire.
|
|
148
|
+
await new Promise(r => setTimeout(r, 50));
|
|
149
|
+
|
|
150
|
+
test.ok(connectErr, 'connect() rejected');
|
|
151
|
+
test.ok(connectErr.tlsFailed, 'rejection is flagged as a TLS failure');
|
|
152
|
+
test.equal(errorEvents, 0, 'no duplicate error event emitted (single error path)');
|
|
153
|
+
|
|
154
|
+
client.close();
|
|
155
|
+
server.close();
|
|
156
|
+
test.done();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Unit-level check of the per-command trailing flag the guard relies on: each pushed
|
|
160
|
+
// command records whether more input followed it, independently of later commands.
|
|
161
|
+
module.exports['STARTTLS: parser flags trailing data per command'] = test => {
|
|
162
|
+
const stream = new ImapStream({ cid: 'test' });
|
|
163
|
+
let flags = [];
|
|
164
|
+
|
|
165
|
+
stream.on('readable', () => {
|
|
166
|
+
let cmd;
|
|
167
|
+
while ((cmd = stream.read()) !== null) {
|
|
168
|
+
flags.push(cmd.trailingAfterLine);
|
|
169
|
+
cmd.next();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
stream.on('error', err => test.ifError(err));
|
|
173
|
+
stream.on('end', () => {
|
|
174
|
+
// First command had a second command after it -> true; the last one -> false.
|
|
175
|
+
test.deepEqual(flags, [true, false], 'trailingAfterLine is per-command and not overwritten');
|
|
176
|
+
test.done();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Two complete commands in a single write.
|
|
180
|
+
stream.end(Buffer.from('A OK first\r\nB OK second\r\n'));
|
|
181
|
+
};
|
|
@@ -390,3 +390,67 @@ module.exports['Token Parser: malformed REFERRAL with no closing bracket consume
|
|
|
390
390
|
test.equal(section[0].value, 'REFERRAL');
|
|
391
391
|
test.equal(section[1].value, 'imap://user@host/INBOX');
|
|
392
392
|
});
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// Additional error-path and edge-case coverage (E11, E12, E16, E17, E19-E22,
|
|
396
|
+
// E24, E27) plus the bare-"*" sequence and "~"-as-atom normalization paths.
|
|
397
|
+
// All use a non-status command (FETCH) so the TokenParser is actually invoked.
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
const expectParserError = (input, code) => test =>
|
|
401
|
+
asyncWrapper(test, async test => {
|
|
402
|
+
let err;
|
|
403
|
+
try {
|
|
404
|
+
await parser(input);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
err = e;
|
|
407
|
+
}
|
|
408
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
409
|
+
test.equal(err.code, code);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
module.exports['Token Parser: E11: unexpected ] throws ParserError11'] = expectParserError('* 1 FETCH (])', 'ParserError11');
|
|
413
|
+
module.exports['Token Parser: E12: ~ not followed by { throws ParserError12'] = expectParserError('* 1 FETCH ~%', 'ParserError12');
|
|
414
|
+
module.exports['Token Parser: E16: \\* followed by char throws ParserError17'] = expectParserError('* 1 FETCH \\*x', 'ParserError17');
|
|
415
|
+
module.exports['Token Parser: E19: partial ending with . throws ParserError19'] = expectParserError('* 1 FETCH BODY[]<0.>', 'ParserError19');
|
|
416
|
+
module.exports['Token Parser: E20: partial leading . throws ParserError20'] = expectParserError('* 1 FETCH BODY[]<.>', 'ParserError20');
|
|
417
|
+
module.exports['Token Parser: E21: non-digit in partial throws ParserError21'] = expectParserError('* 1 FETCH BODY[]<0.x>', 'ParserError21');
|
|
418
|
+
module.exports['Token Parser: E22: invalid leading-zero partial throws ParserError22'] = expectParserError('* 1 FETCH BODY[]<00>', 'ParserError22');
|
|
419
|
+
module.exports['Token Parser: E24: literal prefix not followed by CRLF throws ParserError24'] = expectParserError('* 1 FETCH {3}xyz', 'ParserError24');
|
|
420
|
+
module.exports['Token Parser: E27: whitespace mid-sequence throws ParserError27'] = expectParserError('* 1 FETCH 1: 2', 'ParserError27');
|
|
421
|
+
|
|
422
|
+
module.exports['Token Parser: bare * is normalized to an ATOM'] = test =>
|
|
423
|
+
asyncWrapper(test, async test => {
|
|
424
|
+
let r = await parser('* 1 FETCH *');
|
|
425
|
+
let last = r.attributes[r.attributes.length - 1];
|
|
426
|
+
test.equal(last.type, 'ATOM');
|
|
427
|
+
test.equal(last.value, '*');
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
module.exports['Token Parser: ~ followed by atom chars becomes an ATOM'] = test =>
|
|
431
|
+
asyncWrapper(test, async test => {
|
|
432
|
+
let r = await parser('* 1 FETCH ~abc');
|
|
433
|
+
let last = r.attributes[r.attributes.length - 1];
|
|
434
|
+
test.equal(last.type, 'ATOM');
|
|
435
|
+
test.equal(last.value, '~abc');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
module.exports['Token Parser: E16: invalid char in atom throws ParserError16'] = expectParserError('* 1 FETCH 3* 4', 'ParserError16');
|
|
439
|
+
|
|
440
|
+
module.exports['Token Parser: literal+ (non-synchronizing) marker is accepted'] = test =>
|
|
441
|
+
asyncWrapper(test, async test => {
|
|
442
|
+
// {3+} is a LITERAL+ prefix; with literalPlus enabled and no pre-parsed
|
|
443
|
+
// literals, the parser reads the literal bytes inline.
|
|
444
|
+
let r = await parser('* 1 FETCH {3+}\r\nabc', { literalPlus: true });
|
|
445
|
+
let lit = r.attributes[r.attributes.length - 1];
|
|
446
|
+
test.equal(lit.type, 'LITERAL');
|
|
447
|
+
test.equal(lit.value.toString(), 'abc');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
module.exports['Token Parser: literal prefix terminated by bare LF is accepted'] = test =>
|
|
451
|
+
asyncWrapper(test, async test => {
|
|
452
|
+
let r = await parser('* 1 FETCH {3}\nabc');
|
|
453
|
+
let lit = r.attributes[r.attributes.length - 1];
|
|
454
|
+
test.equal(lit.type, 'LITERAL');
|
|
455
|
+
test.equal(lit.value.toString(), 'abc');
|
|
456
|
+
});
|
package/test/tools-test.js
CHANGED
|
@@ -1188,6 +1188,366 @@ module.exports['Tools: formatMessageResponse normalizes non-ASCII mailbox path f
|
|
|
1188
1188
|
test.done();
|
|
1189
1189
|
};
|
|
1190
1190
|
|
|
1191
|
+
module.exports['Tools: formatMessageResponse parses the full attribute set'] = async test => {
|
|
1192
|
+
let untagged = await parser(
|
|
1193
|
+
'* 3 FETCH (UID 100 RFC822.SIZE 5000 FLAGS (\\Seen \\Flagged) MODSEQ (12345) ' +
|
|
1194
|
+
'X-GM-MSGID 999 X-GM-THRID 888 X-GM-LABELS (\\Important Work) ' +
|
|
1195
|
+
'INTERNALDATE "12-Jan-2020 10:00:00 +0000" BODY[1] {3}\r\n BODY[HEADER] {2}\r\n)',
|
|
1196
|
+
{ literals: [Buffer.from('abc'), Buffer.from('hi')] }
|
|
1197
|
+
);
|
|
1198
|
+
let mailbox = { path: 'INBOX', uidValidity: 1n }; // no uidNext/highestModseq -> exercise the bump branches
|
|
1199
|
+
let r = await tools.formatMessageResponse(untagged, mailbox);
|
|
1200
|
+
test.equal(r.uid, 100);
|
|
1201
|
+
test.equal(r.size, 5000);
|
|
1202
|
+
test.ok(r.flags.has('\\Seen'));
|
|
1203
|
+
test.equal(r.modseq, 12345n);
|
|
1204
|
+
test.equal(r.emailId, '999'); // X-GM-MSGID
|
|
1205
|
+
test.equal(r.threadId, '888'); // X-GM-THRID
|
|
1206
|
+
test.ok(r.labels.has('Work'));
|
|
1207
|
+
test.ok(r.internalDate instanceof Date);
|
|
1208
|
+
test.ok(Buffer.isBuffer(r.headers));
|
|
1209
|
+
test.ok(r.bodyParts.get('1'));
|
|
1210
|
+
// mailbox estimates bumped from the FETCH data
|
|
1211
|
+
test.equal(mailbox.uidNext, 101);
|
|
1212
|
+
test.equal(mailbox.highestModseq, 12345n);
|
|
1213
|
+
test.done();
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
module.exports['Tools: formatMessageResponse parses envelope and bodystructure'] = async test => {
|
|
1217
|
+
let untagged = await parser(
|
|
1218
|
+
'* 1 FETCH (ENVELOPE ("Mon, 2 Sep 2013 05:30:13 -0700" "Subject" ((NIL NIL "a" "b.com")) NIL NIL NIL NIL NIL NIL "<id@x>") ' +
|
|
1219
|
+
'BODYSTRUCTURE ("TEXT" "PLAIN" ("CHARSET" "utf-8") NIL NIL "7BIT" 12 0 NIL NIL NIL))'
|
|
1220
|
+
);
|
|
1221
|
+
let r = await tools.formatMessageResponse(untagged, { path: 'INBOX' });
|
|
1222
|
+
test.ok(r.envelope);
|
|
1223
|
+
test.equal(r.envelope.subject, 'Subject');
|
|
1224
|
+
test.ok(r.bodyStructure);
|
|
1225
|
+
test.equal(r.bodyStructure.type, 'text/plain');
|
|
1226
|
+
test.done();
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
module.exports['Tools: formatMessageResponse source from BODY[] and BINARY[] literals'] = async test => {
|
|
1230
|
+
let untagged = await parser('* 1 FETCH (BODY[] {5}\r\n)', { literals: [Buffer.from('HELLO')] });
|
|
1231
|
+
let r = await tools.formatMessageResponse(untagged, { path: 'INBOX' });
|
|
1232
|
+
test.ok(Buffer.isBuffer(r.source));
|
|
1233
|
+
test.equal(r.source.toString(), 'HELLO');
|
|
1234
|
+
test.done();
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
module.exports['Tools: formatMessageResponse keeps invalid INTERNALDATE as raw string'] = async test => {
|
|
1238
|
+
let untagged = await parser('* 1 FETCH (INTERNALDATE "not a date")');
|
|
1239
|
+
let r = await tools.formatMessageResponse(untagged, { path: 'INBOX' });
|
|
1240
|
+
test.equal(r.internalDate, 'not a date');
|
|
1241
|
+
test.done();
|
|
1242
|
+
};
|
|
1243
|
+
|
|
1244
|
+
module.exports['Tools: parseEnvelope parses every field'] = async test => {
|
|
1245
|
+
let untagged = await parser(
|
|
1246
|
+
'* 1 FETCH (ENVELOPE ("Mon, 2 Sep 2013 05:30:13 -0700" "Hello" ' +
|
|
1247
|
+
'((NIL NIL "from" "x.com")) ((NIL NIL "sender" "x.com")) ((NIL NIL "reply" "x.com")) ' +
|
|
1248
|
+
'((NIL NIL "to" "x.com")) ((NIL NIL "cc" "x.com")) ((NIL NIL "bcc" "x.com")) ' +
|
|
1249
|
+
'"<inreplyto@x>" "<msgid@x>"))'
|
|
1250
|
+
);
|
|
1251
|
+
let env = tools.parseEnvelope(untagged.attributes[1][1]);
|
|
1252
|
+
test.ok(env.date instanceof Date);
|
|
1253
|
+
test.equal(env.subject, 'Hello');
|
|
1254
|
+
test.equal(env.from[0].address, 'from@x.com');
|
|
1255
|
+
test.equal(env.sender[0].address, 'sender@x.com');
|
|
1256
|
+
test.equal(env.replyTo[0].address, 'reply@x.com');
|
|
1257
|
+
test.equal(env.to[0].address, 'to@x.com');
|
|
1258
|
+
test.equal(env.cc[0].address, 'cc@x.com');
|
|
1259
|
+
test.equal(env.bcc[0].address, 'bcc@x.com');
|
|
1260
|
+
test.equal(env.inReplyTo, '<inreplyto@x>');
|
|
1261
|
+
test.equal(env.messageId, '<msgid@x>');
|
|
1262
|
+
test.done();
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
module.exports['Tools: parseEnvelope keeps invalid date as string'] = async test => {
|
|
1266
|
+
let untagged = await parser('* 1 FETCH (ENVELOPE ("not a date" "Subj" NIL NIL NIL NIL NIL NIL NIL NIL))');
|
|
1267
|
+
let env = tools.parseEnvelope(untagged.attributes[1][1]);
|
|
1268
|
+
test.equal(env.date, 'not a date');
|
|
1269
|
+
test.done();
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
module.exports['Tools: parseBodystructure parses all single-part extension fields'] = async test => {
|
|
1273
|
+
let bs =
|
|
1274
|
+
'("TEXT" "PLAIN" ("CHARSET" "utf-8") "<cid@x>" "a description" "BASE64" 100 5 ' +
|
|
1275
|
+
'"d41d8cd9" ("attachment" ("filename" "f.txt")) ("en" "de") "http://loc/")';
|
|
1276
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
|
|
1277
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1278
|
+
test.equal(node.type, 'text/plain');
|
|
1279
|
+
test.equal(node.id, '<cid@x>');
|
|
1280
|
+
test.equal(node.description, 'a description');
|
|
1281
|
+
test.equal(node.encoding, 'base64');
|
|
1282
|
+
test.equal(node.size, 100);
|
|
1283
|
+
test.equal(node.lineCount, 5);
|
|
1284
|
+
test.equal(node.md5, 'd41d8cd9');
|
|
1285
|
+
test.equal(node.disposition, 'attachment');
|
|
1286
|
+
test.equal(node.dispositionParameters.filename, 'f.txt');
|
|
1287
|
+
test.deepEqual(node.language, ['en', 'de']);
|
|
1288
|
+
test.done();
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1291
|
+
module.exports['Tools: parseBodystructure parses message/rfc822 with envelope and child'] = async test => {
|
|
1292
|
+
let bs =
|
|
1293
|
+
'("MESSAGE" "RFC822" NIL NIL NIL "7BIT" 200 ' +
|
|
1294
|
+
'("date" "subj" NIL NIL NIL NIL NIL NIL "<reply>" "<msgid>") ' +
|
|
1295
|
+
'("TEXT" "PLAIN" NIL NIL NIL "7BIT" 10 1) 3)';
|
|
1296
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
|
|
1297
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1298
|
+
test.equal(node.type, 'message/rfc822');
|
|
1299
|
+
test.ok(node.envelope);
|
|
1300
|
+
test.equal(node.childNodes.length, 1);
|
|
1301
|
+
test.equal(node.lineCount, 3);
|
|
1302
|
+
test.done();
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
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"))';
|
|
1309
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
|
|
1310
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1311
|
+
test.equal(node.type, 'multipart/alternative');
|
|
1312
|
+
test.equal(node.childNodes.length, 2);
|
|
1313
|
+
test.equal(node.parameters.boundary, 'xyz');
|
|
1314
|
+
test.equal(node.disposition, 'inline');
|
|
1315
|
+
test.done();
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
module.exports['Tools: formatMessageResponse handles NIL, Buffer and unknown keys'] = async test => {
|
|
1319
|
+
let untagged = await parser('* 1 FETCH (UID NIL RFC822.SIZE NIL X-GM-MSGID {3}\r\n BODY[] NIL FOOBAR 123)', {
|
|
1320
|
+
literals: [Buffer.from('999')]
|
|
1321
|
+
});
|
|
1322
|
+
let r = await tools.formatMessageResponse(untagged, { path: 'INBOX' });
|
|
1323
|
+
test.equal(r.uid, 0); // NIL value -> getString returns false -> Number(false) = 0
|
|
1324
|
+
test.equal(r.size, 0);
|
|
1325
|
+
test.equal(r.emailId, '999'); // Buffer literal value -> getString stringifies it
|
|
1326
|
+
test.equal(r.source, false); // BODY[] NIL -> getBuffer returns false
|
|
1327
|
+
test.done();
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
module.exports['Tools: getFolderTree merges duplicate folder entries'] = test => {
|
|
1331
|
+
let tree = tools.getFolderTree([
|
|
1332
|
+
{ name: 'Parent', path: 'Parent', parent: [], flags: new Set(['\\HasChildren']), delimiter: '/' },
|
|
1333
|
+
{ name: 'Parent', path: 'Parent', parent: [], flags: new Set(['\\Noselect', '\\HasChildren']), specialUse: '\\Sent', delimiter: '/' }
|
|
1334
|
+
]);
|
|
1335
|
+
test.equal(tree.folders.length, 1);
|
|
1336
|
+
test.equal(tree.folders[0].disabled, true);
|
|
1337
|
+
test.equal(tree.folders[0].specialUse, '\\Sent');
|
|
1338
|
+
test.done();
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
module.exports['Tools: parseBodystructure handles minimal NIL fields'] = async test => {
|
|
1342
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ("APPLICATION" "OCTET-STREAM" NIL NIL NIL NIL NIL))');
|
|
1343
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1344
|
+
test.equal(node.type, 'application/octet-stream');
|
|
1345
|
+
test.equal(node.id, undefined);
|
|
1346
|
+
test.equal(node.encoding, undefined);
|
|
1347
|
+
test.done();
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
module.exports['Tools: parseBodystructure decodes RFC 2231 charset continuation params'] = async test => {
|
|
1351
|
+
let bs = "(\"TEXT\" \"PLAIN\" (\"name*0*\" \"utf-8''%E2%82%AC abc\" \"name*1\" \"def\") NIL NIL \"7BIT\" 10 1)";
|
|
1352
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
|
|
1353
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1354
|
+
test.equal(node.parameters.name, '€ abcdef');
|
|
1355
|
+
test.done();
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
module.exports['Tools: parseBodystructure handles empty-string extension fields'] = async test => {
|
|
1359
|
+
// Empty-string values exercise the `|| ''` / `|| 0` fallbacks in each field.
|
|
1360
|
+
let bs = '("TEXT" "PLAIN" ("CHARSET" "") "" "" "" 0 0 "" ("" ("" "")) ("") "")';
|
|
1361
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
|
|
1362
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1363
|
+
test.equal(node.id, '');
|
|
1364
|
+
test.equal(node.description, '');
|
|
1365
|
+
test.equal(node.size, 0);
|
|
1366
|
+
test.equal(node.md5, '');
|
|
1367
|
+
test.deepEqual(node.language, ['']);
|
|
1368
|
+
test.done();
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
module.exports['Tools: formatMessageResponse with no data list returns just seq'] = async test => {
|
|
1372
|
+
let untagged = await parser('* 5 FETCH');
|
|
1373
|
+
let r = await tools.formatMessageResponse(untagged, { path: 'INBOX' });
|
|
1374
|
+
test.equal(r.seq, 5);
|
|
1375
|
+
test.done();
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1378
|
+
module.exports['Tools: getFolderTree adds folders array when existing entry gains HasChildren'] = test => {
|
|
1379
|
+
let tree = tools.getFolderTree([
|
|
1380
|
+
{ name: 'P', path: 'P', parent: [], flags: new Set([]), delimiter: '/' },
|
|
1381
|
+
{ name: 'P', path: 'P', parent: [], flags: new Set(['\\HasChildren']), delimiter: '/' }
|
|
1382
|
+
]);
|
|
1383
|
+
test.ok(Array.isArray(tree.folders[0].folders), 'folders array created on merge');
|
|
1384
|
+
test.done();
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
module.exports['Tools: normalizePath joins array with empty delimiter fallback'] = test => {
|
|
1388
|
+
// namespace present but without a delimiter -> the (... || '') fallback joins directly
|
|
1389
|
+
let connection = { namespace: { prefix: '' } };
|
|
1390
|
+
test.equal(tools.normalizePath(connection, ['a', 'b']), 'ab');
|
|
1391
|
+
test.done();
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1394
|
+
module.exports['Tools: updateCapabilities defaults non-numeric APPENDLIMIT to 0'] = test => {
|
|
1395
|
+
let caps = tools.updateCapabilities([{ value: 'APPENDLIMIT=abc' }]);
|
|
1396
|
+
test.equal(caps.get('APPENDLIMIT'), 0);
|
|
1397
|
+
test.done();
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
module.exports['Tools: formatMessageResponse skips non-string label entries'] = async test => {
|
|
1401
|
+
let untagged = await parser('* 1 FETCH (X-GM-LABELS (\\Important NIL))');
|
|
1402
|
+
let r = await tools.formatMessageResponse(untagged, { path: 'INBOX' });
|
|
1403
|
+
test.deepEqual([...r.labels], ['\\Important']); // NIL entry filtered out
|
|
1404
|
+
test.done();
|
|
1405
|
+
};
|
|
1406
|
+
|
|
1407
|
+
module.exports['Tools: formatMessageResponse does not lower highestModseq'] = async test => {
|
|
1408
|
+
let mailbox = { path: 'INBOX', highestModseq: 99999n };
|
|
1409
|
+
let untagged = await parser('* 1 FETCH (MODSEQ (5) FLAGS (\\Seen))');
|
|
1410
|
+
await tools.formatMessageResponse(untagged, mailbox);
|
|
1411
|
+
test.equal(mailbox.highestModseq, 99999n); // unchanged: incoming modseq is lower
|
|
1412
|
+
test.done();
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
module.exports['Tools: parseBodystructure multipart with NIL subtype'] = async test => {
|
|
1416
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE (("TEXT" "PLAIN" NIL NIL NIL "7BIT" 1 1) NIL))');
|
|
1417
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1418
|
+
test.equal(node.type, 'multipart/');
|
|
1419
|
+
test.done();
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
module.exports['Tools: parseBodystructure content type with NIL type/subtype'] = async test => {
|
|
1423
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE (NIL NIL NIL NIL NIL "7BIT" 1))');
|
|
1424
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1425
|
+
test.equal(node.type, '/');
|
|
1426
|
+
test.done();
|
|
1427
|
+
};
|
|
1428
|
+
|
|
1429
|
+
module.exports['Tools: parseBodystructure message/rfc822 with NIL envelope/linecount'] = async test => {
|
|
1430
|
+
let bs = '("MESSAGE" "RFC822" NIL NIL NIL "7BIT" 100 NIL ("TEXT" "PLAIN" NIL NIL NIL "7BIT" 1 1) NIL NIL (NIL) NIL)';
|
|
1431
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
|
|
1432
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1433
|
+
test.equal(node.type, 'message/rfc822');
|
|
1434
|
+
test.done();
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
module.exports['Tools: parseBodystructure text with NIL language/location entries'] = async test => {
|
|
1438
|
+
let bs = '("TEXT" "PLAIN" NIL NIL NIL "7BIT" 100 NIL "md5" ("inline" NIL) (NIL) NIL)';
|
|
1439
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
|
|
1440
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1441
|
+
test.deepEqual(node.language, ['']);
|
|
1442
|
+
test.done();
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
module.exports['Tools: parseBodystructure empty-string size/linecount/language/location'] = async test => {
|
|
1446
|
+
let bs = '("TEXT" "PLAIN" NIL NIL NIL "7BIT" "" "" "" ("inline" NIL) ("") "")';
|
|
1447
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
|
|
1448
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1449
|
+
test.equal(node.size, 0);
|
|
1450
|
+
test.equal(node.lineCount, 0);
|
|
1451
|
+
test.deepEqual(node.language, ['']);
|
|
1452
|
+
test.done();
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1455
|
+
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) "")';
|
|
1459
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
|
|
1460
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1461
|
+
test.equal(node.type, 'message/rfc822');
|
|
1462
|
+
test.equal(node.lineCount, 0);
|
|
1463
|
+
test.done();
|
|
1464
|
+
};
|
|
1465
|
+
|
|
1466
|
+
module.exports['Tools: parseBodystructure decodes RFC 2231 with empty charset and special chars'] = async test => {
|
|
1467
|
+
// Empty charset before the first quote defaults to utf-8; '=' / '?' in the value
|
|
1468
|
+
// exercise the 2-char hex escape branch; the empty *1* continuation segment too.
|
|
1469
|
+
// value includes '=' / '?' (2-char hex escapes), a space (-> '_') and a TAB
|
|
1470
|
+
// (a <0x10 control char -> single-hex-digit escape needing a '0' pad).
|
|
1471
|
+
let bs = "(\"TEXT\" \"PLAIN\" (\"name*0*\" \"''a=b?c d\te\" \"name*1*\" \"\") NIL NIL \"7BIT\" 1 1)";
|
|
1472
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
|
|
1473
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1474
|
+
test.ok(node.parameters.name.includes('a=b?c'));
|
|
1475
|
+
test.done();
|
|
1476
|
+
};
|
|
1477
|
+
|
|
1478
|
+
module.exports['Tools: parseBodystructure parses empty-string body location'] = async test => {
|
|
1479
|
+
// A trailing element after location makes the (i < length-1) guard pass so the
|
|
1480
|
+
// empty-string location value goes through the `|| ''` fallback.
|
|
1481
|
+
let bs = '("TEXT" "PLAIN" NIL NIL NIL "7BIT" 1 1 NIL NIL NIL "" NIL)';
|
|
1482
|
+
let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
|
|
1483
|
+
let node = tools.parseBodystructure(untagged.attributes[1][1]);
|
|
1484
|
+
test.equal(node.location, '');
|
|
1485
|
+
test.done();
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
module.exports['Tools: expandRange descending range to non-numeric second bound'] = test => {
|
|
1489
|
+
test.deepEqual(tools.expandRange('5:abc'), [5, 4, 3, 2, 1, 0]);
|
|
1490
|
+
test.done();
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
module.exports['Tools: expandRange tolerates non-numeric entries'] = test => {
|
|
1494
|
+
test.deepEqual(tools.expandRange('abc,:5,1:3'), [0, 0, 1, 2, 3, 4, 5, 1, 2, 3]);
|
|
1495
|
+
test.done();
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
module.exports['Tools: expandRange handles descending ranges'] = test => {
|
|
1499
|
+
test.deepEqual(tools.expandRange('5:3'), [5, 4, 3]);
|
|
1500
|
+
test.done();
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1503
|
+
module.exports['Tools: encodePath keeps name as-is when iconv encode throws'] = test => {
|
|
1504
|
+
// The iconv module object is shared by reference; patch encode to throw so the
|
|
1505
|
+
// defensive catch is exercised, then restore.
|
|
1506
|
+
let original = iconv.encode;
|
|
1507
|
+
iconv.encode = () => {
|
|
1508
|
+
throw new Error('encode boom');
|
|
1509
|
+
};
|
|
1510
|
+
try {
|
|
1511
|
+
let connection = { enabled: new Set() };
|
|
1512
|
+
let result = tools.encodePath(connection, 'Tärä');
|
|
1513
|
+
test.equal(result, 'Tärä', 'falls back to the raw path');
|
|
1514
|
+
} finally {
|
|
1515
|
+
iconv.encode = original;
|
|
1516
|
+
}
|
|
1517
|
+
test.done();
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
module.exports['Tools: decodePath keeps name as-is when iconv decode throws'] = test => {
|
|
1521
|
+
let original = iconv.decode;
|
|
1522
|
+
iconv.decode = () => {
|
|
1523
|
+
throw new Error('decode boom');
|
|
1524
|
+
};
|
|
1525
|
+
try {
|
|
1526
|
+
let connection = { enabled: new Set() };
|
|
1527
|
+
let result = tools.decodePath(connection, 'Inbox&AOk-');
|
|
1528
|
+
test.equal(result, 'Inbox&AOk-');
|
|
1529
|
+
} finally {
|
|
1530
|
+
iconv.decode = original;
|
|
1531
|
+
}
|
|
1532
|
+
test.done();
|
|
1533
|
+
};
|
|
1534
|
+
|
|
1535
|
+
module.exports['Tools: formatMessageResponse id falls back when iconv encode throws'] = async test => {
|
|
1536
|
+
let original = iconv.encode;
|
|
1537
|
+
iconv.encode = () => {
|
|
1538
|
+
throw new Error('encode boom');
|
|
1539
|
+
};
|
|
1540
|
+
try {
|
|
1541
|
+
let untagged = await parser('* 1 FETCH (UID 5 FLAGS (\\Seen))');
|
|
1542
|
+
// non-ASCII path triggers the encode branch; the throw is swallowed
|
|
1543
|
+
let r = await tools.formatMessageResponse(untagged, { path: 'Tärä', uidValidity: 1n });
|
|
1544
|
+
test.ok(r.id, 'an id is still produced');
|
|
1545
|
+
} finally {
|
|
1546
|
+
iconv.encode = original;
|
|
1547
|
+
}
|
|
1548
|
+
test.done();
|
|
1549
|
+
};
|
|
1550
|
+
|
|
1191
1551
|
// ============================================
|
|
1192
1552
|
// packMessageRange
|
|
1193
1553
|
// ============================================
|