imapflow 1.4.9 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/test.yml +20 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +22 -0
- package/CLAUDE.md +3 -5
- package/lib/commands/fetch.js +18 -14
- package/lib/commands/idle.js +197 -104
- package/lib/commands/list.js +19 -8
- package/lib/commands/quota.js +3 -0
- package/lib/commands/select.js +5 -0
- package/lib/commands/status.js +10 -1
- package/lib/connection-deadline.js +98 -0
- package/lib/handler/imap-compiler.js +20 -14
- package/lib/handler/imap-stream.js +141 -50
- package/lib/handler/limits.js +43 -0
- package/lib/handler/token-parser.js +38 -1
- package/lib/imap-flow.d.ts +47 -5
- package/lib/imap-flow.js +594 -283
- package/lib/proxy-connection.js +393 -98
- package/lib/special-use.js +660 -51
- package/lib/tools.js +52 -3
- package/package.json +2 -2
- package/test/commands-branches-test.js +17 -1
- package/test/commands-integration-test.js +353 -2
- package/test/connection-edge-cases-test.js +4 -40
- package/test/fixtures/fake-timers.js +115 -0
- package/test/handler-branches-test.js +4 -28
- package/test/idle-polling-test.js +349 -0
- package/test/imap-compiler-test.js +85 -0
- package/test/imap-flow-compress-test.js +12 -0
- package/test/imap-flow-coverage-test.js +3 -3
- package/test/imap-flow-fetch-download-test.js +56 -0
- package/test/imap-flow-internals-test.js +23 -0
- package/test/imap-flow-proxy-paths-test.js +151 -0
- package/test/imap-flow-secure-test.js +182 -9
- package/test/imap-flow-server-test.js +229 -0
- package/test/imap-parser-test.js +112 -1
- package/test/imap-stream-test.js +46 -0
- package/test/integration/README.md +17 -5
- package/test/integration/rev2-live-test.js +125 -0
- package/test/integration/run-rev2-tests.sh +14 -0
- package/test/parser-limits-test.js +274 -0
- package/test/proxy-connection-test.js +553 -442
- package/test/reliability-improvements-test.js +87 -0
- package/test/search-compiler-test.js +17 -0
- package/test/special-use-test.js +337 -0
- package/test/tag-correlation-test.js +333 -0
- package/test/timer-policy-test.js +214 -0
- package/test/tools-test.js +42 -4
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Deterministic stand-in for global.setTimeout/clearTimeout, so tests can drive timer-based
|
|
4
|
+
// loops (auto-IDLE, IDLE restart, fallback polling) without wall-clock sleeps.
|
|
5
|
+
//
|
|
6
|
+
// It is the suite's existing "swap global.setTimeout for the duration of a test and restore it
|
|
7
|
+
// in finally" idiom, made reusable and able to handle recursively scheduled timers: firing a
|
|
8
|
+
// timer whose callback schedules the next one records the new timer instead of losing it.
|
|
9
|
+
//
|
|
10
|
+
// Only timers scheduled while installed are faked. Everything else (setImmediate, socket
|
|
11
|
+
// timeouts, nodeunit's own bookkeeping) is untouched, and clearTimeout still forwards real
|
|
12
|
+
// handles to the real implementation. Install for as short a window as possible and always
|
|
13
|
+
// restore in a finally block.
|
|
14
|
+
const installFakeTimers = () => {
|
|
15
|
+
const realSetTimeout = global.setTimeout;
|
|
16
|
+
const realClearTimeout = global.clearTimeout;
|
|
17
|
+
|
|
18
|
+
let seq = 0;
|
|
19
|
+
const timers = new Map();
|
|
20
|
+
// Every timer ever scheduled, including cleared and fired ones, so tests can assert timer
|
|
21
|
+
// identity and cleanup (which timer was armed, was it unref'd, was it cleared) without sleeps.
|
|
22
|
+
const history = [];
|
|
23
|
+
|
|
24
|
+
global.setTimeout = (fn, delay, ...args) => {
|
|
25
|
+
const id = ++seq;
|
|
26
|
+
const timer = { id, fn, delay, args, unrefd: false, cleared: false, fired: false };
|
|
27
|
+
timers.set(id, timer);
|
|
28
|
+
history.push(timer);
|
|
29
|
+
return {
|
|
30
|
+
_fakeTimerId: id,
|
|
31
|
+
unref() {
|
|
32
|
+
const timer = timers.get(id);
|
|
33
|
+
if (timer) {
|
|
34
|
+
timer.unrefd = true;
|
|
35
|
+
}
|
|
36
|
+
return this;
|
|
37
|
+
},
|
|
38
|
+
ref() {
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
global.clearTimeout = handle => {
|
|
45
|
+
if (handle && handle._fakeTimerId) {
|
|
46
|
+
const timer = timers.get(handle._fakeTimerId);
|
|
47
|
+
if (timer) {
|
|
48
|
+
timer.cleared = true;
|
|
49
|
+
timers.delete(timer.id);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
return realClearTimeout(handle);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Lets pending asynchronous work (a poll's promise chain) settle between timer firings.
|
|
57
|
+
const drain = async (turns = 3) => {
|
|
58
|
+
for (let i = 0; i < turns; i++) {
|
|
59
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
// Scheduled timers that have not fired or been cleared yet, in scheduling order
|
|
65
|
+
pending: () => Array.from(timers.values()).map(timer => ({ id: timer.id, delay: timer.delay, unrefd: timer.unrefd })),
|
|
66
|
+
count: () => timers.size,
|
|
67
|
+
|
|
68
|
+
// Every timer scheduled while installed, in scheduling order, with its final state
|
|
69
|
+
history: () => history.map(timer => ({ id: timer.id, delay: timer.delay, unrefd: timer.unrefd, cleared: timer.cleared, fired: timer.fired })),
|
|
70
|
+
|
|
71
|
+
// Fires every currently pending timer once, oldest first, letting async callbacks settle.
|
|
72
|
+
// Timers scheduled by those callbacks stay pending for the next fire() call, which is what
|
|
73
|
+
// makes a recursive loop (poll -> schedule -> poll) observable one step at a time.
|
|
74
|
+
fire: async () => {
|
|
75
|
+
const due = Array.from(timers.values());
|
|
76
|
+
for (const timer of due) {
|
|
77
|
+
if (!timers.has(timer.id)) {
|
|
78
|
+
// cleared by an earlier callback in this batch
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
timers.delete(timer.id);
|
|
82
|
+
timer.fired = true;
|
|
83
|
+
timer.fn(...timer.args);
|
|
84
|
+
await drain();
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
drain,
|
|
89
|
+
|
|
90
|
+
restore: () => {
|
|
91
|
+
global.setTimeout = realSetTimeout;
|
|
92
|
+
global.clearTimeout = realClearTimeout;
|
|
93
|
+
timers.clear();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Runs `fn` with faked timers installed, restoring the real ones afterwards. Always use this
|
|
100
|
+
* rather than installing by hand: leaking faked globals into the rest of the run breaks
|
|
101
|
+
* unrelated tests.
|
|
102
|
+
*
|
|
103
|
+
* @param {Function} fn - Receives the timer controller.
|
|
104
|
+
* @returns {Promise<*>} Whatever `fn` returns.
|
|
105
|
+
*/
|
|
106
|
+
const withFakeTimers = async fn => {
|
|
107
|
+
const timers = installFakeTimers();
|
|
108
|
+
try {
|
|
109
|
+
return await fn(timers);
|
|
110
|
+
} finally {
|
|
111
|
+
timers.restore();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
module.exports = { installFakeTimers, withFakeTimers };
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
-
const proxyquire = require('proxyquire').noCallThru();
|
|
6
|
-
|
|
7
5
|
const { parser, compiler } = require('../lib/handler/imap-handler');
|
|
8
6
|
const { ImapStream } = require('../lib/handler/imap-stream');
|
|
9
7
|
const { ParserInstance } = require('../lib/handler/parser-instance');
|
|
@@ -115,8 +113,9 @@ module.exports['imap-compiler: synchronizing literal with non-buffer value seeds
|
|
|
115
113
|
{
|
|
116
114
|
tag: '*',
|
|
117
115
|
command: 'CMD',
|
|
118
|
-
// value is an object that is neither string/number/Buffer
|
|
119
|
-
//
|
|
116
|
+
// value is an object that is neither string/number/Buffer, so the size
|
|
117
|
+
// marker derives from the UTF-8 byte length of its stringified form
|
|
118
|
+
// ('[object Object]', 15 bytes) while formatRespEntry(value, true)
|
|
120
119
|
// returns null -> the seeded resp falls back to [] (line 156 right side).
|
|
121
120
|
attributes: [{ type: 'LITERAL', value: { length: 10 } }]
|
|
122
121
|
},
|
|
@@ -125,7 +124,7 @@ module.exports['imap-compiler: synchronizing literal with non-buffer value seeds
|
|
|
125
124
|
).map(entry => entry.toString());
|
|
126
125
|
// Only the header segment is emitted; the empty seeded data segment is dropped
|
|
127
126
|
// because `if (resp.length)` is false at the end.
|
|
128
|
-
test.deepEqual(out, ['* CMD {
|
|
127
|
+
test.deepEqual(out, ['* CMD {15}\r\n']);
|
|
129
128
|
});
|
|
130
129
|
|
|
131
130
|
// STRING node with falsy value -> `(node.value || '')` right side (line 165).
|
|
@@ -303,29 +302,6 @@ module.exports['search-compiler: keyword already present in mailbox flags is acc
|
|
|
303
302
|
};
|
|
304
303
|
|
|
305
304
|
// ============================================================================
|
|
306
|
-
// proxy-connection.js
|
|
307
|
-
// ============================================================================
|
|
308
|
-
|
|
309
|
-
// attachEarlyErrorHandler guard (lines 23-25): the proxy client returns a truthy socket
|
|
310
|
-
// that lacks an `.on` method, so the guard returns early.
|
|
311
|
-
module.exports['proxy-connection: socket without .on skips early error handler attach'] = async test => {
|
|
312
|
-
let logger = { info: () => {}, error: () => {} };
|
|
313
|
-
let bareSocket = { write() {}, end() {} }; // truthy, but no .on
|
|
314
|
-
const { proxyConnection } = proxyquire('../lib/proxy-connection', {
|
|
315
|
-
'nodemailer/lib/smtp-connection/http-proxy-client': (url, port, host, cb) => {
|
|
316
|
-
cb(null, bareSocket);
|
|
317
|
-
},
|
|
318
|
-
socks: { SocksClient: {} },
|
|
319
|
-
dns: { promises: { resolve: async () => ['127.0.0.1'] } },
|
|
320
|
-
net: { isIP: () => true }
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
let socket = await proxyConnection(logger, 'http://proxy.example.com:8080', '192.168.1.1', 993);
|
|
324
|
-
test.equal(socket, bareSocket, 'socket returned unchanged');
|
|
325
|
-
test.ok(!('_earlyErrorHandler' in socket), 'no early error handler was attached to a socket without .on');
|
|
326
|
-
test.done();
|
|
327
|
-
};
|
|
328
|
-
|
|
329
305
|
// Sanity: untagged parse still round-trips, just to keep the parser import exercised.
|
|
330
306
|
module.exports['sanity: parser/compiler round-trip'] = test =>
|
|
331
307
|
asyncWrapper(test, async test => {
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Fallback polling lifecycle (servers without IDLE): one explicitly identified polling session
|
|
4
|
+
// that cannot run or reschedule after cancellation, reuses the real STATUS/SELECT state
|
|
5
|
+
// handling, and always leaves `idling` describing reality.
|
|
6
|
+
|
|
7
|
+
const idleCommand = require('../lib/commands/idle.js');
|
|
8
|
+
const imapCommands = require('../lib/imap-commands.js');
|
|
9
|
+
const { withFakeTimers } = require('./fixtures/fake-timers');
|
|
10
|
+
|
|
11
|
+
const createConnection = (overrides = {}) => {
|
|
12
|
+
const states = { NOT_AUTHENTICATED: 1, AUTHENTICATED: 2, SELECTED: 3, LOGOUT: 4 };
|
|
13
|
+
|
|
14
|
+
const connection = {
|
|
15
|
+
states,
|
|
16
|
+
state: states.SELECTED,
|
|
17
|
+
id: 'polling-test',
|
|
18
|
+
capabilities: new Map(overrides.capabilities || []), // no IDLE -> polling fallback
|
|
19
|
+
enabled: new Set(overrides.enabled || []),
|
|
20
|
+
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
21
|
+
mailbox: {
|
|
22
|
+
path: 'INBOX',
|
|
23
|
+
exists: 3,
|
|
24
|
+
uidNext: 10,
|
|
25
|
+
uidValidity: BigInt(1),
|
|
26
|
+
highestModseq: BigInt(1)
|
|
27
|
+
},
|
|
28
|
+
namespace: { delimiter: '/', prefix: '' },
|
|
29
|
+
socket: { destroyed: false },
|
|
30
|
+
currentSelectCommand: { command: 'SELECT', arguments: [{ type: 'ATOM', value: 'INBOX' }] },
|
|
31
|
+
missingIdleCommand: 'NOOP',
|
|
32
|
+
idling: false,
|
|
33
|
+
preCheck: false,
|
|
34
|
+
events: [],
|
|
35
|
+
commands: [],
|
|
36
|
+
log: { warn: () => {}, info: () => {}, error: () => {}, debug: () => {}, trace: () => {} },
|
|
37
|
+
emit(event, payload) {
|
|
38
|
+
connection.events.push({ event, payload });
|
|
39
|
+
},
|
|
40
|
+
run: async () => false,
|
|
41
|
+
// Fallback polling dispatches through runInternal(), which ImapFlow resolves against the
|
|
42
|
+
// command registry - so a poll runs the real SELECT/STATUS implementation.
|
|
43
|
+
runInternal: async (command, ...args) => {
|
|
44
|
+
let handler = imapCommands.get(command.toUpperCase());
|
|
45
|
+
return handler ? await handler(connection, ...args) : false;
|
|
46
|
+
},
|
|
47
|
+
exec: async (command, attributes, options) => {
|
|
48
|
+
connection.commands.push(command);
|
|
49
|
+
return connection.respond(command, attributes, options);
|
|
50
|
+
},
|
|
51
|
+
respond: async () => ({ next: () => {}, response: { attributes: [{ value: 'OK' }] } })
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// capabilities/enabled are already converted into a Map/Set above
|
|
55
|
+
let rest = Object.assign({}, overrides);
|
|
56
|
+
delete rest.capabilities;
|
|
57
|
+
delete rest.enabled;
|
|
58
|
+
|
|
59
|
+
return Object.assign(connection, rest);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
module.exports['Polling: break before the first poll stops the loop'] = async test => {
|
|
63
|
+
await withFakeTimers(async timers => {
|
|
64
|
+
let connection = createConnection({
|
|
65
|
+
exec: async command => {
|
|
66
|
+
connection.commands.push(command);
|
|
67
|
+
// break while the very first poll is still in flight
|
|
68
|
+
await connection.preCheck();
|
|
69
|
+
return { next: () => {} };
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
74
|
+
await timers.drain();
|
|
75
|
+
await idlePromise;
|
|
76
|
+
|
|
77
|
+
test.deepEqual(connection.commands, ['NOOP'], 'only the immediate first poll ran');
|
|
78
|
+
test.equal(connection.idling, false, 'idling reset after the break');
|
|
79
|
+
test.equal(connection.preCheck, false, 'the session released preCheck');
|
|
80
|
+
test.equal(timers.count(), 0, 'no polling timer is left armed');
|
|
81
|
+
|
|
82
|
+
// Advancing several intervals must not produce another command
|
|
83
|
+
await timers.fire();
|
|
84
|
+
await timers.fire();
|
|
85
|
+
test.deepEqual(connection.commands, ['NOOP'], 'a cancelled session never polls again');
|
|
86
|
+
test.done();
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
module.exports['Polling: break during an in-flight poll cannot reschedule'] = async test => {
|
|
91
|
+
await withFakeTimers(async timers => {
|
|
92
|
+
let releasePoll;
|
|
93
|
+
let pollStarted;
|
|
94
|
+
let connection = createConnection({
|
|
95
|
+
exec: async command => {
|
|
96
|
+
connection.commands.push(command);
|
|
97
|
+
await new Promise(resolve => {
|
|
98
|
+
releasePoll = resolve;
|
|
99
|
+
if (pollStarted) {
|
|
100
|
+
pollStarted();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
return { next: () => {} };
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
let started = new Promise(resolve => (pollStarted = resolve));
|
|
108
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
109
|
+
await started;
|
|
110
|
+
|
|
111
|
+
// Cancel while the poll is still awaiting its response, then let it finish
|
|
112
|
+
await connection.preCheck();
|
|
113
|
+
releasePoll();
|
|
114
|
+
await idlePromise;
|
|
115
|
+
|
|
116
|
+
test.deepEqual(connection.commands, ['NOOP'], 'the in-flight poll completed but scheduled nothing');
|
|
117
|
+
test.equal(timers.count(), 0, 'the completing poll did not arm a new timer');
|
|
118
|
+
test.equal(connection.idling, false, 'idling reset');
|
|
119
|
+
|
|
120
|
+
await timers.fire();
|
|
121
|
+
test.deepEqual(connection.commands, ['NOOP'], 'no later command is sent');
|
|
122
|
+
test.done();
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
module.exports['Polling: break after a poll but before the next timer fires stops the loop'] = async test => {
|
|
127
|
+
await withFakeTimers(async timers => {
|
|
128
|
+
let connection = createConnection();
|
|
129
|
+
|
|
130
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
131
|
+
await timers.drain();
|
|
132
|
+
|
|
133
|
+
test.deepEqual(connection.commands, ['NOOP'], 'the immediate first poll ran');
|
|
134
|
+
test.equal(timers.count(), 1, 'the next poll is scheduled');
|
|
135
|
+
test.ok(timers.pending()[0].unrefd, 'the background polling timer does not keep the process alive');
|
|
136
|
+
|
|
137
|
+
await connection.preCheck();
|
|
138
|
+
await idlePromise;
|
|
139
|
+
|
|
140
|
+
test.equal(timers.count(), 0, 'the armed timer was cleared on cancellation');
|
|
141
|
+
|
|
142
|
+
await timers.fire();
|
|
143
|
+
await timers.fire();
|
|
144
|
+
test.deepEqual(connection.commands, ['NOOP'], 'no command after cancellation');
|
|
145
|
+
test.done();
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
module.exports['Polling: an old session does not clear a newer session preCheck'] = async test => {
|
|
150
|
+
await withFakeTimers(async timers => {
|
|
151
|
+
let releasePoll;
|
|
152
|
+
let pollStarted;
|
|
153
|
+
let connection = createConnection({
|
|
154
|
+
exec: async command => {
|
|
155
|
+
connection.commands.push(command);
|
|
156
|
+
if (connection.commands.length === 1) {
|
|
157
|
+
await new Promise(resolve => {
|
|
158
|
+
releasePoll = resolve;
|
|
159
|
+
pollStarted();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return { next: () => {} };
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
let started = new Promise(resolve => (pollStarted = resolve));
|
|
167
|
+
let firstSession = idleCommand(connection, 60000);
|
|
168
|
+
await started;
|
|
169
|
+
|
|
170
|
+
// Cancel the first session while its poll is in flight, then immediately re-enter IDLE
|
|
171
|
+
await connection.preCheck();
|
|
172
|
+
let secondSession = idleCommand(connection, 60000);
|
|
173
|
+
await timers.drain();
|
|
174
|
+
|
|
175
|
+
let newPreCheck = connection.preCheck;
|
|
176
|
+
test.equal(typeof newPreCheck, 'function', 'the new session installed its own preCheck');
|
|
177
|
+
|
|
178
|
+
// The stale poll now finishes: it must not touch the new session's ownership
|
|
179
|
+
releasePoll();
|
|
180
|
+
await firstSession;
|
|
181
|
+
|
|
182
|
+
test.equal(connection.preCheck, newPreCheck, 'the stale session left the newer preCheck in place');
|
|
183
|
+
test.equal(connection.idling, true, 'the stale session did not clear the newer idling state');
|
|
184
|
+
|
|
185
|
+
await connection.preCheck();
|
|
186
|
+
await secondSession;
|
|
187
|
+
test.equal(connection.idling, false, 'the newer session cleaned up on its own break');
|
|
188
|
+
test.done();
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
module.exports['Polling: STATUS polling applies mailbox state and emits exists only on change'] = async test => {
|
|
193
|
+
await withFakeTimers(async timers => {
|
|
194
|
+
let messages = 3;
|
|
195
|
+
let connection = createConnection({
|
|
196
|
+
capabilities: [['CONDSTORE', true]],
|
|
197
|
+
missingIdleCommand: 'STATUS',
|
|
198
|
+
respond: async (command, attributes, options) => {
|
|
199
|
+
if (command === 'STATUS') {
|
|
200
|
+
let statusHandler = options.untagged.STATUS;
|
|
201
|
+
await statusHandler({
|
|
202
|
+
attributes: [
|
|
203
|
+
{ type: 'STRING', value: 'INBOX' },
|
|
204
|
+
[
|
|
205
|
+
{ type: 'ATOM', value: 'MESSAGES' },
|
|
206
|
+
{ type: 'ATOM', value: String(messages) },
|
|
207
|
+
{ type: 'ATOM', value: 'UIDNEXT' },
|
|
208
|
+
{ type: 'ATOM', value: '11' },
|
|
209
|
+
{ type: 'ATOM', value: 'HIGHESTMODSEQ' },
|
|
210
|
+
{ type: 'ATOM', value: '42' }
|
|
211
|
+
]
|
|
212
|
+
]
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return { next: () => {}, response: { attributes: [{ value: 'OK' }] } };
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
220
|
+
await timers.drain();
|
|
221
|
+
|
|
222
|
+
test.deepEqual(connection.commands, ['STATUS'], 'STATUS was used for polling');
|
|
223
|
+
test.equal(connection.mailbox.uidNext, 11, 'uidNext updated from the poll');
|
|
224
|
+
test.equal(connection.mailbox.highestModseq, BigInt(42), 'highestModseq updated on a CONDSTORE session');
|
|
225
|
+
test.deepEqual(connection.events, [], 'an unchanged message count emits no event');
|
|
226
|
+
|
|
227
|
+
// Second poll reports a new count -> exactly one exists event
|
|
228
|
+
messages = 5;
|
|
229
|
+
await timers.fire();
|
|
230
|
+
|
|
231
|
+
test.equal(connection.mailbox.exists, 5, 'exists updated from the poll');
|
|
232
|
+
test.equal(connection.events.length, 1, 'one event for the changed count');
|
|
233
|
+
test.equal(connection.events[0].event, 'exists');
|
|
234
|
+
test.equal(connection.events[0].payload.count, 5);
|
|
235
|
+
test.equal(connection.events[0].payload.prevCount, 3);
|
|
236
|
+
|
|
237
|
+
await connection.preCheck();
|
|
238
|
+
await idlePromise;
|
|
239
|
+
test.done();
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
module.exports['Polling: STATUS omits HIGHESTMODSEQ without CONDSTORE'] = async test => {
|
|
244
|
+
await withFakeTimers(async timers => {
|
|
245
|
+
let requestedItems = [];
|
|
246
|
+
let connection = createConnection({
|
|
247
|
+
missingIdleCommand: 'STATUS',
|
|
248
|
+
respond: async (command, attributes) => {
|
|
249
|
+
if (command === 'STATUS') {
|
|
250
|
+
requestedItems = attributes[1].map(entry => entry.value);
|
|
251
|
+
}
|
|
252
|
+
return { next: () => {}, response: { attributes: [{ value: 'OK' }] } };
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
257
|
+
await timers.drain();
|
|
258
|
+
|
|
259
|
+
test.ok(requestedItems.includes('MESSAGES'), 'MESSAGES is polled');
|
|
260
|
+
test.ok(requestedItems.includes('UIDNEXT'), 'UIDNEXT is polled');
|
|
261
|
+
test.ok(!requestedItems.includes('HIGHESTMODSEQ'), 'HIGHESTMODSEQ is not requested without CONDSTORE');
|
|
262
|
+
|
|
263
|
+
await connection.preCheck();
|
|
264
|
+
await idlePromise;
|
|
265
|
+
test.done();
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
module.exports['Polling: failed SELECT polling deselects and stops the loop'] = async test => {
|
|
270
|
+
await withFakeTimers(async timers => {
|
|
271
|
+
let connection = createConnection({
|
|
272
|
+
missingIdleCommand: 'SELECT',
|
|
273
|
+
respond: async command => {
|
|
274
|
+
if (command === 'SELECT') {
|
|
275
|
+
let err = new Error('Command failed');
|
|
276
|
+
err.responseStatus = 'NO';
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
return { next: () => {}, response: { attributes: [{ value: 'OK' }] } };
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
284
|
+
await timers.drain();
|
|
285
|
+
await idlePromise;
|
|
286
|
+
|
|
287
|
+
test.equal(connection.state, connection.states.AUTHENTICATED, 'a failed reselect drops to AUTHENTICATED');
|
|
288
|
+
test.equal(connection.mailbox, false, 'mailbox state cleared');
|
|
289
|
+
test.equal(connection.currentSelectCommand, false, 'the saved select command is cleared');
|
|
290
|
+
test.equal(connection.idling, false, 'idling reset');
|
|
291
|
+
test.equal(connection.preCheck, false, 'preCheck released');
|
|
292
|
+
test.equal(timers.count(), 0, 'polling stopped');
|
|
293
|
+
test.ok(
|
|
294
|
+
connection.events.some(entry => entry.event === 'mailboxClose'),
|
|
295
|
+
'the mailbox close transition was emitted, as with a caller-issued SELECT'
|
|
296
|
+
);
|
|
297
|
+
test.done();
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
module.exports['Polling: a closed connection stops the loop instead of polling'] = async test => {
|
|
302
|
+
await withFakeTimers(async timers => {
|
|
303
|
+
let connection = createConnection();
|
|
304
|
+
|
|
305
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
306
|
+
await timers.drain();
|
|
307
|
+
test.deepEqual(connection.commands, ['NOOP'], 'first poll ran on a live connection');
|
|
308
|
+
|
|
309
|
+
// Transport dies between polls
|
|
310
|
+
connection.socket.destroyed = true;
|
|
311
|
+
await timers.fire();
|
|
312
|
+
await idlePromise;
|
|
313
|
+
|
|
314
|
+
test.deepEqual(connection.commands, ['NOOP'], 'no command is sent on a dead transport');
|
|
315
|
+
test.equal(connection.idling, false, 'idling reset when the loop stops');
|
|
316
|
+
test.equal(connection.preCheck, false, 'preCheck released');
|
|
317
|
+
test.done();
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
module.exports['Polling: a rejected poll resets idling'] = async test => {
|
|
322
|
+
await withFakeTimers(async timers => {
|
|
323
|
+
let connection = createConnection({
|
|
324
|
+
exec: async command => {
|
|
325
|
+
connection.commands.push(command);
|
|
326
|
+
let err = new Error('Connection not available');
|
|
327
|
+
err.code = 'NoConnection';
|
|
328
|
+
throw err;
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
let idlePromise = idleCommand(connection, 60000);
|
|
333
|
+
await timers.drain();
|
|
334
|
+
await idlePromise;
|
|
335
|
+
|
|
336
|
+
test.equal(connection.idling, false, 'idling reset after a rejected poll');
|
|
337
|
+
test.equal(connection.preCheck, false, 'preCheck released after a rejected poll');
|
|
338
|
+
test.equal(timers.count(), 0, 'nothing left scheduled');
|
|
339
|
+
test.done();
|
|
340
|
+
});
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
module.exports['Polling: no polling session without a saved select command'] = async test => {
|
|
344
|
+
let connection = createConnection({ currentSelectCommand: false });
|
|
345
|
+
await idleCommand(connection, 60000);
|
|
346
|
+
test.deepEqual(connection.commands, [], 'nothing polled');
|
|
347
|
+
test.equal(connection.idling, false, 'idling untouched');
|
|
348
|
+
test.done();
|
|
349
|
+
};
|
|
@@ -362,6 +362,91 @@ module.exports['IMAP Compiler: LITERAL literal8'] = test =>
|
|
|
362
362
|
)
|
|
363
363
|
);
|
|
364
364
|
|
|
365
|
+
// RFC 7888 (folded into IMAP4rev2): with LITERAL- a non-synchronizing literal
|
|
366
|
+
// marker {n+} is only allowed for literals of up to 4096 bytes - anything larger
|
|
367
|
+
// must use the synchronizing {n} form, in every compilation mode
|
|
368
|
+
module.exports['IMAP Compiler: LITERAL- oversized literal stays synchronizing in single-buffer mode'] = test =>
|
|
369
|
+
asyncWrapper(test, async test => {
|
|
370
|
+
let payload = 'a'.repeat(4097);
|
|
371
|
+
let compiled = (
|
|
372
|
+
await compiler(
|
|
373
|
+
{
|
|
374
|
+
tag: '*',
|
|
375
|
+
command: 'CMD',
|
|
376
|
+
attributes: [
|
|
377
|
+
{
|
|
378
|
+
type: 'LITERAL',
|
|
379
|
+
value: payload
|
|
380
|
+
}
|
|
381
|
+
]
|
|
382
|
+
},
|
|
383
|
+
{ literalMinus: true }
|
|
384
|
+
)
|
|
385
|
+
).toString();
|
|
386
|
+
test.ok(compiled.startsWith('* CMD {4097}\r\n'), `must not use a non-synchronizing marker: ${compiled.slice(0, 20)}`);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
module.exports['IMAP Compiler: LITERAL- boundary: exactly 4096 bytes is non-synchronizing'] = test =>
|
|
390
|
+
asyncWrapper(test, async test => {
|
|
391
|
+
let payload = 'a'.repeat(4096);
|
|
392
|
+
let parts = (
|
|
393
|
+
await compiler(
|
|
394
|
+
{
|
|
395
|
+
tag: '*',
|
|
396
|
+
command: 'CMD',
|
|
397
|
+
attributes: [
|
|
398
|
+
{
|
|
399
|
+
type: 'LITERAL',
|
|
400
|
+
value: payload
|
|
401
|
+
}
|
|
402
|
+
]
|
|
403
|
+
},
|
|
404
|
+
{ asArray: true, literalMinus: true }
|
|
405
|
+
)
|
|
406
|
+
).map(entry => entry.toString());
|
|
407
|
+
test.equal(parts.length, 1, 'literal must be appended inline without a continuation break');
|
|
408
|
+
test.ok(parts[0].startsWith('* CMD {4096+}\r\n'));
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
module.exports['IMAP Compiler: LITERAL- boundary: 4097 bytes falls back to synchronizing'] = test =>
|
|
412
|
+
asyncWrapper(test, async test => {
|
|
413
|
+
let payload = 'a'.repeat(4097);
|
|
414
|
+
let parts = (
|
|
415
|
+
await compiler(
|
|
416
|
+
{
|
|
417
|
+
tag: '*',
|
|
418
|
+
command: 'CMD',
|
|
419
|
+
attributes: [
|
|
420
|
+
{
|
|
421
|
+
type: 'LITERAL',
|
|
422
|
+
value: payload
|
|
423
|
+
}
|
|
424
|
+
]
|
|
425
|
+
},
|
|
426
|
+
{ asArray: true, literalMinus: true }
|
|
427
|
+
)
|
|
428
|
+
).map(entry => entry.toString());
|
|
429
|
+
test.equal(parts.length, 2, 'literal must wait for a continuation response');
|
|
430
|
+
test.ok(parts[0].endsWith('{4097}\r\n'), `marker must be synchronizing: ${parts[0].slice(-12)}`);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// The literal size marker counts octets, not UTF-16 code units - a unicode string
|
|
434
|
+
// value must declare its UTF-8 byte length
|
|
435
|
+
module.exports['IMAP Compiler: LITERAL declares byte length for unicode string values'] = test =>
|
|
436
|
+
asyncWrapper(test, async test => {
|
|
437
|
+
let compiled = await compiler({
|
|
438
|
+
tag: '*',
|
|
439
|
+
command: 'CMD',
|
|
440
|
+
attributes: [
|
|
441
|
+
{
|
|
442
|
+
type: 'LITERAL',
|
|
443
|
+
value: 'Sõnumid'
|
|
444
|
+
}
|
|
445
|
+
]
|
|
446
|
+
});
|
|
447
|
+
test.equal(compiled.toString(), '* CMD {8}\r\nSõnumid');
|
|
448
|
+
});
|
|
449
|
+
|
|
365
450
|
module.exports['IMAP Compiler: LITERAL array 1'] = test =>
|
|
366
451
|
asyncWrapper(test, async test =>
|
|
367
452
|
test.deepEqual(
|
|
@@ -143,11 +143,23 @@ module.exports['Compress: session negotiates DEFLATE and runs commands'] = async
|
|
|
143
143
|
client.write(Buffer.alloc(128 * 1024, 0x61));
|
|
144
144
|
await new Promise(r => setTimeout(r, 50));
|
|
145
145
|
|
|
146
|
+
// Each stream owns and reports its own lifecycle: the compression PassThrough used to proxy
|
|
147
|
+
// the raw socket's `destroyed` getter, which made close() skip destroying it.
|
|
148
|
+
let rawSocket = client.socket;
|
|
149
|
+
let passThrough = client.writeSocket;
|
|
150
|
+
test.notEqual(passThrough, rawSocket, 'compression replaced the write socket with a PassThrough');
|
|
151
|
+
test.equal(passThrough.destroyed, false, 'the PassThrough reports its own state while alive');
|
|
152
|
+
|
|
146
153
|
await client.logout();
|
|
147
154
|
client.close();
|
|
148
155
|
// close() must tear down the compression streams
|
|
149
156
|
test.ok(!client._deflate, 'deflate cleaned up after close');
|
|
150
157
|
test.ok(!client._inflate, 'inflate cleaned up after close');
|
|
158
|
+
test.ok(passThrough.destroyed, 'the compression PassThrough was destroyed');
|
|
159
|
+
test.ok(rawSocket.destroyed, 'the raw socket was destroyed');
|
|
160
|
+
|
|
161
|
+
// Repeated close() stays idempotent
|
|
162
|
+
test.doesNotThrow(() => client.close());
|
|
151
163
|
|
|
152
164
|
server.close();
|
|
153
165
|
test.done();
|
|
@@ -524,7 +524,7 @@ module.exports['Coverage: reader swallows a throwing untagged handler'] = async
|
|
|
524
524
|
module.exports['Coverage: reader swallows a throwing trySend after a tagged response'] = async test => {
|
|
525
525
|
let client = makeClient();
|
|
526
526
|
let resolved = false;
|
|
527
|
-
client.currentRequest = { tag: 'A1' };
|
|
527
|
+
client.currentRequest = { tag: 'A1', sent: true };
|
|
528
528
|
client.requestTagMap.set('A1', {
|
|
529
529
|
command: 'NOOP',
|
|
530
530
|
attributes: [],
|
|
@@ -549,7 +549,7 @@ module.exports['Coverage: reader handles compiler failure when building executed
|
|
|
549
549
|
let client = makeClient();
|
|
550
550
|
let rejected = null;
|
|
551
551
|
// A request whose attributes cannot be compiled -> the executedCommand try/catch is hit
|
|
552
|
-
client.currentRequest = { tag: 'A1' };
|
|
552
|
+
client.currentRequest = { tag: 'A1', sent: true };
|
|
553
553
|
client.requestTagMap.set('A1', {
|
|
554
554
|
command: 'NOOP',
|
|
555
555
|
// a circular/odd attribute that makes the logging compiler throw
|
|
@@ -576,7 +576,7 @@ module.exports['Coverage: reader handles compiler failure when building executed
|
|
|
576
576
|
module.exports['Coverage: reader caps very large throttle backoff'] = async test => {
|
|
577
577
|
let client = makeClient();
|
|
578
578
|
let rejected = null;
|
|
579
|
-
client.currentRequest = { tag: 'A1' };
|
|
579
|
+
client.currentRequest = { tag: 'A1', sent: true };
|
|
580
580
|
client.requestTagMap.set('A1', {
|
|
581
581
|
command: 'FETCH',
|
|
582
582
|
attributes: [],
|