imapkit 4.3.1 → 5.0.1
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/README.md +187 -67
- package/bin/help.txt +20 -5
- package/bin/imapkit.js +64 -24
- package/dist/cjs/commands/append.js +1 -1
- package/dist/cjs/commands/create.js +2 -2
- package/dist/cjs/commands/delete.js +2 -14
- package/dist/cjs/commands/handlers/fetch.js +2 -1
- package/dist/cjs/commands/handlers/flags.d.ts +3 -3
- package/dist/cjs/commands/handlers/flags.js +3 -3
- package/dist/cjs/commands/handlers/store.js +7 -7
- package/dist/cjs/commands/list.js +17 -3
- package/dist/cjs/commands/rename.js +6 -46
- package/dist/cjs/commands/select.js +1 -0
- package/dist/cjs/commands/subscribe.js +7 -8
- package/dist/cjs/commands/unsubscribe.js +3 -9
- package/dist/cjs/control.d.ts +425 -0
- package/dist/cjs/control.js +637 -0
- package/dist/cjs/dates.d.ts +9 -1
- package/dist/cjs/dates.js +20 -0
- package/dist/cjs/envelope.d.ts +1 -1
- package/dist/cjs/envelope.js +26 -1
- package/dist/cjs/index.d.ts +9 -1
- package/dist/cjs/index.js +9 -2
- package/dist/cjs/load-plugins.d.ts +3 -1
- package/dist/cjs/load-plugins.js +28 -4
- package/dist/cjs/plugins/acl.js +109 -42
- package/dist/cjs/plugins/binary.js +2 -1
- package/dist/cjs/plugins/condstore.js +7 -0
- package/dist/cjs/plugins/context-search.js +2 -2
- package/dist/cjs/plugins/create-special-use.d.ts +0 -5
- package/dist/cjs/plugins/create-special-use.js +11 -1
- package/dist/cjs/plugins/imap4rev2.js +5 -0
- package/dist/cjs/plugins/index.js +1 -3
- package/dist/cjs/plugins/metadata-server.d.ts +6 -2
- package/dist/cjs/plugins/metadata-server.js +3 -1
- package/dist/cjs/plugins/metadata.d.ts +5 -1
- package/dist/cjs/plugins/metadata.js +143 -57
- package/dist/cjs/plugins/objectid.js +10 -0
- package/dist/cjs/plugins/quota.js +50 -2
- package/dist/cjs/plugins/savedate.js +1 -1
- package/dist/cjs/plugins/special-use.d.ts +0 -8
- package/dist/cjs/plugins/special-use.js +23 -0
- package/dist/cjs/quirks.d.ts +28 -0
- package/dist/cjs/quirks.js +125 -0
- package/dist/cjs/random.d.ts +10 -0
- package/dist/cjs/random.js +22 -0
- package/dist/cjs/rest.d.ts +54 -0
- package/dist/cjs/rest.js +567 -0
- package/dist/cjs/script.d.ts +33 -4
- package/dist/cjs/script.js +64 -4
- package/dist/cjs/server.d.ts +199 -9
- package/dist/cjs/server.js +603 -227
- package/dist/cjs/smtp-listener.d.ts +3 -2
- package/dist/cjs/smtp-listener.js +10 -6
- package/dist/cjs/storage-schema.d.ts +139 -0
- package/dist/cjs/storage-schema.js +222 -0
- package/dist/cjs/store-operations.d.ts +171 -0
- package/dist/cjs/store-operations.js +425 -0
- package/dist/cjs/types.d.ts +26 -1
- package/dist/esm/commands/append.js +1 -1
- package/dist/esm/commands/create.js +2 -2
- package/dist/esm/commands/delete.js +2 -14
- package/dist/esm/commands/handlers/fetch.js +2 -1
- package/dist/esm/commands/handlers/flags.d.ts +3 -3
- package/dist/esm/commands/handlers/flags.js +3 -3
- package/dist/esm/commands/handlers/store.js +7 -7
- package/dist/esm/commands/list.js +17 -3
- package/dist/esm/commands/rename.js +6 -46
- package/dist/esm/commands/select.js +1 -0
- package/dist/esm/commands/subscribe.js +7 -8
- package/dist/esm/commands/unsubscribe.js +3 -9
- package/dist/esm/control.d.ts +425 -0
- package/dist/esm/control.js +630 -0
- package/dist/esm/dates.d.ts +9 -1
- package/dist/esm/dates.js +20 -1
- package/dist/esm/envelope.d.ts +1 -1
- package/dist/esm/envelope.js +26 -1
- package/dist/esm/index.d.ts +9 -1
- package/dist/esm/index.js +5 -2
- package/dist/esm/load-plugins.d.ts +3 -1
- package/dist/esm/load-plugins.js +28 -4
- package/dist/esm/plugins/acl.js +109 -42
- package/dist/esm/plugins/binary.js +2 -1
- package/dist/esm/plugins/condstore.js +7 -0
- package/dist/esm/plugins/context-search.js +2 -2
- package/dist/esm/plugins/create-special-use.d.ts +0 -5
- package/dist/esm/plugins/create-special-use.js +8 -1
- package/dist/esm/plugins/imap4rev2.js +5 -0
- package/dist/esm/plugins/index.js +1 -3
- package/dist/esm/plugins/metadata-server.d.ts +6 -2
- package/dist/esm/plugins/metadata-server.js +3 -1
- package/dist/esm/plugins/metadata.d.ts +5 -1
- package/dist/esm/plugins/metadata.js +143 -57
- package/dist/esm/plugins/objectid.js +10 -0
- package/dist/esm/plugins/quota.js +50 -2
- package/dist/esm/plugins/savedate.js +1 -1
- package/dist/esm/plugins/special-use.d.ts +0 -8
- package/dist/esm/plugins/special-use.js +23 -0
- package/dist/esm/quirks.d.ts +28 -0
- package/dist/esm/quirks.js +121 -0
- package/dist/esm/random.d.ts +10 -0
- package/dist/esm/random.js +19 -0
- package/dist/esm/rest.d.ts +54 -0
- package/dist/esm/rest.js +559 -0
- package/dist/esm/script.d.ts +33 -4
- package/dist/esm/script.js +63 -4
- package/dist/esm/server.d.ts +199 -9
- package/dist/esm/server.js +606 -230
- package/dist/esm/smtp-listener.d.ts +3 -2
- package/dist/esm/smtp-listener.js +10 -6
- package/dist/esm/storage-schema.d.ts +139 -0
- package/dist/esm/storage-schema.js +218 -0
- package/dist/esm/store-operations.d.ts +171 -0
- package/dist/esm/store-operations.js +410 -0
- package/dist/esm/types.d.ts +26 -1
- package/dist/plugin-help.json +3 -16
- package/package.json +11 -3
- package/dist/cjs/plugins/xtoybird.d.ts +0 -2
- package/dist/cjs/plugins/xtoybird.js +0 -236
- package/dist/esm/plugins/xtoybird.d.ts +0 -2
- package/dist/esm/plugins/xtoybird.js +0 -218
package/dist/esm/server.js
CHANGED
|
@@ -7,14 +7,19 @@ import loadPlugins from './load-plugins.js';
|
|
|
7
7
|
import { commands as builtinCommands } from './commands/index.js';
|
|
8
8
|
import { getCommandOptions, commandOptions } from './command-states.js';
|
|
9
9
|
import validateMailboxName from './mailbox-name.js';
|
|
10
|
-
import { MONTHS, monthIndex,
|
|
10
|
+
import { MONTHS, monthIndex, isDateTime } from './dates.js';
|
|
11
11
|
import fetchHandlers from './commands/handlers/fetch.js';
|
|
12
12
|
import { hasSequenceSetKey } from './commands/handlers/search.js';
|
|
13
13
|
import { isSequenceSet } from './numbers.js';
|
|
14
|
-
import { restoreNilAtoms } from './arguments.js';
|
|
14
|
+
import { restoreNilAtoms, isAtom } from './arguments.js';
|
|
15
15
|
import { refuseMissingTarget } from './commands/append.js';
|
|
16
|
+
import { DEFAULT_SESSION_TIMEOUT, storeError, expungeMessages, notifyFlagChanges } from './store-operations.js';
|
|
17
|
+
import { Control } from './control.js';
|
|
18
|
+
import { resolveQuirks } from './quirks.js';
|
|
19
|
+
import { validateStorage } from './storage-schema.js';
|
|
20
|
+
import { createRestServer } from './rest.js';
|
|
16
21
|
import * as bundledCert from './cert.js';
|
|
17
|
-
import { ServerScript, sendOutput, handleLine, literalResponse, releaseDeferred } from './script.js';
|
|
22
|
+
import { ServerScript, sendOutput, handleLine, handleQuiet, literalResponse, releaseDeferred } from './script.js';
|
|
18
23
|
// longest command line (not counting literals) accepted from a client
|
|
19
24
|
const MAX_LINE_LENGTH = 1024 * 1024;
|
|
20
25
|
// milliseconds between the last output and the RST of a script rule with `close: 'reset'`
|
|
@@ -60,17 +65,35 @@ function stateError(command, state) {
|
|
|
60
65
|
return command + ' is not allowed in the ' + state + ' state';
|
|
61
66
|
}
|
|
62
67
|
/**
|
|
63
|
-
*
|
|
68
|
+
* Checks if a queued notification changes message sequence numbers by removing messages: an EXPUNGE response, or the
|
|
69
|
+
* EXISTS that follows the EXPUNGE responses of another session (it carries the snapshot of the old message list)
|
|
64
70
|
*
|
|
65
|
-
* @param {
|
|
66
|
-
* @
|
|
67
|
-
* @return {Error} Error object
|
|
71
|
+
* @param {Object} notification Queued notification
|
|
72
|
+
* @return {Boolean} true if the notification must wait while EXPUNGE responses are not allowed
|
|
68
73
|
*/
|
|
69
|
-
function
|
|
70
|
-
|
|
71
|
-
err.code = code;
|
|
72
|
-
return err;
|
|
74
|
+
function isPendingExpunge(notification) {
|
|
75
|
+
return !!notification.mailboxCopy || isAtom(notification.attributes && notification.attributes[1], 'EXPUNGE');
|
|
73
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Checks if a notification is an untagged EXISTS response
|
|
79
|
+
*
|
|
80
|
+
* @param {Object} [notification] Notification to send
|
|
81
|
+
* @return {Boolean} true for an EXISTS response
|
|
82
|
+
*/
|
|
83
|
+
function isExists(notification) {
|
|
84
|
+
return !!notification && !notification.command && isAtom(notification.attributes && notification.attributes[1], 'EXISTS');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Answers AUTHENTICATE with a mechanism no plugin supports: NO, not a syntax error (RFC 3501 section 6.2.2). It runs
|
|
88
|
+
* through the command queue, so the state check comes first (AUTHENTICATE is only valid in the Not Authenticated
|
|
89
|
+
* state, RFC 3501 section 6.2)
|
|
90
|
+
*/
|
|
91
|
+
// usesSequenceNumbers() of each running command
|
|
92
|
+
const sequenceNumberUse = new WeakMap();
|
|
93
|
+
const unsupportedMechanism = (connection, parsed, data, callback) => {
|
|
94
|
+
connection.sendStatus(parsed, data, 'NO', 'Unsupported authentication mechanism', false, 'UNKNOWN COMMAND');
|
|
95
|
+
callback();
|
|
96
|
+
};
|
|
74
97
|
/**
|
|
75
98
|
* Creates a new IMAP server, call `listen()` on it to start accepting connections
|
|
76
99
|
*
|
|
@@ -159,11 +182,33 @@ class IMAPServer extends Stream {
|
|
|
159
182
|
// the session whose command is running, see IMAPServer#notify
|
|
160
183
|
this.activeConnection = null;
|
|
161
184
|
this.sessionCounter = 0;
|
|
162
|
-
// rules that make the server misbehave on purpose, from the script option or server.script.add()
|
|
185
|
+
// rules that make the server misbehave on purpose, from the script option or server.script.add(), and the rules
|
|
186
|
+
// of the quirk presets after them
|
|
163
187
|
this.script = new ServerScript(this, this.options.script);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
188
|
+
const quirks = resolveQuirks(this.options.quirks);
|
|
189
|
+
if (quirks.rules.length) {
|
|
190
|
+
this.script.add(quirks.rules);
|
|
191
|
+
}
|
|
192
|
+
this.loadUsers();
|
|
193
|
+
// plugins register their control operations
|
|
194
|
+
this.control = new Control(this);
|
|
195
|
+
// a quirk preset can leave plugins out (no-move, no-uidplus)
|
|
196
|
+
loadPlugins(this, this.options.plugins, quirks.removePlugins);
|
|
197
|
+
if (this.options.storage) {
|
|
198
|
+
// a typo in a fixture fails here with the path of the problem
|
|
199
|
+
validateStorage(this.options.storage);
|
|
200
|
+
}
|
|
201
|
+
this.systemFlags = [].concat(this.options.systemFlags || ['\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen']);
|
|
202
|
+
this.loadStorage();
|
|
203
|
+
this.smtpServer = null;
|
|
204
|
+
this.restServer = null;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Sets the users from the `users` option, or the default user. The users are deep copied, so that runtime changes
|
|
208
|
+
* never leak into the caller's objects or into other servers built from the same fixture. Without a prototype,
|
|
209
|
+
* user names like "__proto__" or "toString" are plain keys
|
|
210
|
+
*/
|
|
211
|
+
loadUsers() {
|
|
167
212
|
this.users = Object.assign(Object.create(null), this.options.users
|
|
168
213
|
? structuredClone(this.options.users)
|
|
169
214
|
: {
|
|
@@ -171,12 +216,16 @@ class IMAPServer extends Stream {
|
|
|
171
216
|
password: 'testpass',
|
|
172
217
|
xoauth2: {
|
|
173
218
|
accessToken: 'testtoken',
|
|
174
|
-
sessionTimeout:
|
|
219
|
+
sessionTimeout: DEFAULT_SESSION_TIMEOUT
|
|
175
220
|
}
|
|
176
221
|
}
|
|
177
222
|
});
|
|
178
|
-
|
|
179
|
-
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Builds the mailboxes from the `storage` option, a deep copy of it. Message and mailbox handlers of plugins run
|
|
226
|
+
* on every message and mailbox
|
|
227
|
+
*/
|
|
228
|
+
loadStorage() {
|
|
180
229
|
// indexFolders() below turns the storage option into namespaces and mailboxes in place
|
|
181
230
|
this.storage = (this.options.storage
|
|
182
231
|
? structuredClone(this.options.storage)
|
|
@@ -184,6 +233,7 @@ class IMAPServer extends Stream {
|
|
|
184
233
|
INBOX: {},
|
|
185
234
|
'': {}
|
|
186
235
|
});
|
|
236
|
+
this.referenceNamespace = false;
|
|
187
237
|
this.uidvalidityCounter = 0; // highest UIDVALIDITY in use, new mailboxes get a higher one
|
|
188
238
|
// subscribed mailbox names (RFC 3501 section 6.3.6). Names, not mailboxes: a subscription outlives
|
|
189
239
|
// DELETE and stays with the old name on RENAME (RFC 9051 section 6.3.6), see trackSubscription
|
|
@@ -191,10 +241,67 @@ class IMAPServer extends Stream {
|
|
|
191
241
|
this.folderCache = Object.create(null);
|
|
192
242
|
this.indexFolders(true);
|
|
193
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Starts accepting connections
|
|
246
|
+
*
|
|
247
|
+
* @param {Number} [port] Port to listen on, a free port if not set
|
|
248
|
+
* @param {String} [host] Address to listen on, all addresses if not set
|
|
249
|
+
* @return {Promise<Number>} resolves with the port the server listens on
|
|
250
|
+
*/
|
|
251
|
+
async start(port, host) {
|
|
252
|
+
const imapPort = await listenOn(this.server, port, host);
|
|
253
|
+
await Promise.all([this.options.smtp ? this.startSmtp() : null, this.options.rest ? this.startRest() : null]);
|
|
254
|
+
return imapPort;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Starts the REST API of the `rest` option, see src/rest.ts
|
|
258
|
+
*
|
|
259
|
+
* @return {Promise<Number>} resolves with the HTTP port
|
|
260
|
+
*/
|
|
261
|
+
async startRest() {
|
|
262
|
+
const { port, host, token } = this.options.rest || {};
|
|
263
|
+
this.restServer = createRestServer(this, { host, token });
|
|
264
|
+
return listenOn(this.restServer, port, host || '127.0.0.1');
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Starts the SMTP server of the `smtp` option. smtp-server is an optional dependency, loaded only here
|
|
268
|
+
*
|
|
269
|
+
* @return {Promise<Number>} resolves with the SMTP port
|
|
270
|
+
*/
|
|
271
|
+
startSmtp() {
|
|
272
|
+
const { port, host } = this.options.smtp || {};
|
|
273
|
+
return this.loadSmtpListener().then(({ startSMTPServer }) => new Promise((resolve, reject) => {
|
|
274
|
+
const smtp = startSMTPServer(port || 0, this, smtpPort => {
|
|
275
|
+
smtp.removeListener('error', reject);
|
|
276
|
+
resolve(smtpPort);
|
|
277
|
+
}, host);
|
|
278
|
+
smtp.once('error', reject);
|
|
279
|
+
this.smtpServer = smtp;
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Loads the SMTP listener, which needs the smtp-server package
|
|
284
|
+
*
|
|
285
|
+
* @return {Promise<Object>} the smtp-listener module
|
|
286
|
+
*/
|
|
287
|
+
loadSmtpListener() {
|
|
288
|
+
return import('./smtp-listener.js').catch((err) => {
|
|
289
|
+
throw new Error('The smtp option needs the smtp-server package, install it with: npm install smtp-server (' + err.message + ')');
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Stops the server and closes every connection
|
|
294
|
+
*
|
|
295
|
+
* @return {Promise} resolves when the server is closed
|
|
296
|
+
*/
|
|
297
|
+
stop() {
|
|
298
|
+
return this.control.shutdown({ graceful: false });
|
|
299
|
+
}
|
|
194
300
|
listen(...args) {
|
|
195
301
|
this.server.listen.apply(this.server, args);
|
|
196
302
|
}
|
|
197
303
|
close(callback) {
|
|
304
|
+
this.closeListeners(true);
|
|
198
305
|
this.server.close(callback);
|
|
199
306
|
// close() only completes once all connections are gone
|
|
200
307
|
this.connections.forEach((connection) => {
|
|
@@ -203,6 +310,31 @@ class IMAPServer extends Stream {
|
|
|
203
310
|
}
|
|
204
311
|
});
|
|
205
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* Stops the SMTP and REST listeners of `start()`
|
|
315
|
+
*
|
|
316
|
+
* @param {Boolean} force true closes the open REST connections too, otherwise only the idle ones (a request that
|
|
317
|
+
* is answered, like POST /v1/shutdown, finishes first)
|
|
318
|
+
*/
|
|
319
|
+
closeListeners(force) {
|
|
320
|
+
if (this.smtpServer) {
|
|
321
|
+
this.smtpServer.close(() => false);
|
|
322
|
+
this.smtpServer = null;
|
|
323
|
+
}
|
|
324
|
+
if (this.restServer) {
|
|
325
|
+
// event streams never end on their own, a graceful shutdown would wait for them forever
|
|
326
|
+
this.restServer.endStreams?.();
|
|
327
|
+
this.restServer.close();
|
|
328
|
+
// keep-alive connections would hold the server open
|
|
329
|
+
if (force) {
|
|
330
|
+
this.restServer.closeAllConnections?.();
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
this.restServer.closeIdleConnections?.();
|
|
334
|
+
}
|
|
335
|
+
this.restServer = null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
206
338
|
/**
|
|
207
339
|
* Returns TLS key and certificate. Without credentials in the options the
|
|
208
340
|
* bundled self-signed certificate for localhost is used.
|
|
@@ -322,13 +454,31 @@ class IMAPServer extends Stream {
|
|
|
322
454
|
*
|
|
323
455
|
* @param {String} type Kind of change
|
|
324
456
|
* @param {String} path Storage name of the mailbox
|
|
325
|
-
* @param {Object} [details] `{ oldPath, mailbox }`: the earlier name of a renamed mailbox, the mailbox
|
|
326
|
-
* object that a DELETE removed
|
|
457
|
+
* @param {Object} [details] `{ oldPath, mailbox, created }`: the earlier name of a renamed mailbox, the mailbox
|
|
458
|
+
* object that a DELETE removed, the mailboxes a CREATE made (superior levels first)
|
|
327
459
|
*/
|
|
328
460
|
mailboxChanged(type, path, details) {
|
|
329
461
|
const event = Object.assign({ type, path, oldPath: null, mailbox: null }, details, { origin: this.activeConnection });
|
|
330
462
|
this.emit('mailbox', event);
|
|
331
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Runs a change with `origin` as the session that caused it, the `origin` of the notifications and events it
|
|
466
|
+
* makes. Commands run with their session, the control API with null
|
|
467
|
+
*
|
|
468
|
+
* @param {Object|null} origin Session, or null for a change from outside
|
|
469
|
+
* @param {Function} fn Change
|
|
470
|
+
* @return {*} the result of fn
|
|
471
|
+
*/
|
|
472
|
+
withOrigin(origin, fn) {
|
|
473
|
+
const previous = this.activeConnection;
|
|
474
|
+
this.activeConnection = origin;
|
|
475
|
+
try {
|
|
476
|
+
return fn();
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
this.activeConnection = previous;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
332
482
|
/**
|
|
333
483
|
* Retrieves the handler of an IMAP command
|
|
334
484
|
*
|
|
@@ -352,8 +502,7 @@ class IMAPServer extends Stream {
|
|
|
352
502
|
let seen = 0;
|
|
353
503
|
let unseen = 0;
|
|
354
504
|
// flags stay defined in the mailbox once a message had them, see rememberFlags
|
|
355
|
-
const permanentFlags =
|
|
356
|
-
(mailbox.knownFlags || []).forEach(flag => this.ensureFlag(permanentFlags, flag));
|
|
505
|
+
const permanentFlags = this.permanentFlagList(mailbox);
|
|
357
506
|
let recent = 0;
|
|
358
507
|
// \Recent sets of the sessions that have this mailbox selected
|
|
359
508
|
const recentSets = [];
|
|
@@ -399,20 +548,8 @@ class IMAPServer extends Stream {
|
|
|
399
548
|
* @return {Boolean} Returns true if the date string is in IMAP date-time format
|
|
400
549
|
*/
|
|
401
550
|
validateInternalDate(date) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
// date-time from RFC 3501 section 9, month names are case-insensitive like all ABNF strings
|
|
406
|
-
const match = date.match(/^( \d|\d\d)-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{4}) (\d{2}):(\d{2}):(\d{2}) [-+](\d{2})(\d{2})$/i);
|
|
407
|
-
if (!match) {
|
|
408
|
-
return false;
|
|
409
|
-
}
|
|
410
|
-
// the values must also make a real date and time
|
|
411
|
-
return (isRealDate(match[1], monthIndex(match[2]), match[3]) &&
|
|
412
|
-
Number(match[4]) < 24 &&
|
|
413
|
-
Number(match[5]) < 60 &&
|
|
414
|
-
Number(match[6]) < 61 &&
|
|
415
|
-
Number(match[8]) < 60);
|
|
551
|
+
// date-time from RFC 3501 section 9
|
|
552
|
+
return isDateTime(date);
|
|
416
553
|
}
|
|
417
554
|
/**
|
|
418
555
|
* Converts a date object to a valid date-time string format
|
|
@@ -461,22 +598,22 @@ class IMAPServer extends Stream {
|
|
|
461
598
|
*/
|
|
462
599
|
createMailbox(path, defaultMailbox) {
|
|
463
600
|
if (!path) {
|
|
464
|
-
throw
|
|
601
|
+
throw storeError('Invalid mailbox name', 'CANNOT');
|
|
465
602
|
}
|
|
466
603
|
// Ensure case insensitive INBOX
|
|
467
604
|
if (path.toUpperCase() === 'INBOX') {
|
|
468
|
-
throw
|
|
605
|
+
throw storeError('INBOX can not be modified', 'ALREADYEXISTS');
|
|
469
606
|
}
|
|
470
607
|
const { namespace, storage } = this.getPersonalNamespace(path);
|
|
471
608
|
path = this.stripSeparator(path, storage.separator);
|
|
472
609
|
if (this.folderCache[path] && this.folderCache[path].flags.indexOf('\\Noselect') < 0) {
|
|
473
|
-
throw
|
|
610
|
+
throw storeError('Mailbox already exists', 'ALREADYEXISTS');
|
|
474
611
|
}
|
|
475
612
|
const folderPath = path.substr(namespace.length).split(storage.separator);
|
|
476
613
|
if (folderPath.some(name => !name)) {
|
|
477
614
|
// an empty hierarchy level ("foo//bar", "/foo", "foo//"). RFC 5530 section 3 has this very case as the
|
|
478
615
|
// example of CANNOT, Dovecot refuses it too
|
|
479
|
-
throw
|
|
616
|
+
throw storeError('Mailbox names can not have empty hierarchy levels', 'CANNOT');
|
|
480
617
|
}
|
|
481
618
|
let parent = storage;
|
|
482
619
|
let curPath = namespace;
|
|
@@ -487,7 +624,7 @@ class IMAPServer extends Stream {
|
|
|
487
624
|
curPath += (curPath.length ? storage.separator : '') + folderName;
|
|
488
625
|
let folder = this.getMailbox(curPath) || false;
|
|
489
626
|
if (folder && folder.flags && folder.flags.indexOf('\\Noinferiors') >= 0) {
|
|
490
|
-
throw
|
|
627
|
+
throw storeError('Can not create subfolders for ' + folder.path, 'CANNOT');
|
|
491
628
|
}
|
|
492
629
|
// a \Noselect placeholder that is created again is replaced with a new mailbox that only keeps
|
|
493
630
|
// the children, nothing else of a deleted mailbox may come back (RFC 3501 section 6.3.3)
|
|
@@ -529,17 +666,17 @@ class IMAPServer extends Stream {
|
|
|
529
666
|
deleteMailbox(path, keepContents) {
|
|
530
667
|
// Ensure case insensitive INBOX
|
|
531
668
|
if (path.toUpperCase() === 'INBOX') {
|
|
532
|
-
throw
|
|
669
|
+
throw storeError('INBOX can not be modified', 'CANNOT');
|
|
533
670
|
}
|
|
534
671
|
const { namespace, storage } = this.getPersonalNamespace(path);
|
|
535
672
|
const mailbox = this.folderCache[this.stripSeparator(path, storage.separator)];
|
|
536
673
|
if (!mailbox) {
|
|
537
|
-
throw
|
|
674
|
+
throw storeError('Mailbox does not exist', 'NONEXISTENT');
|
|
538
675
|
}
|
|
539
676
|
if (mailbox.flags.indexOf('\\Noselect') >= 0 && Object.keys(mailbox.folders || {}).length) {
|
|
540
677
|
// RFC 9051 section 6.3.5: deleting a \Noselect name that has inferior names is an error, the RFC 5530
|
|
541
678
|
// section 3 HASCHILDREN response code tells the client to delete the children first
|
|
542
|
-
throw
|
|
679
|
+
throw storeError('Mailbox has children, delete them first', 'HASCHILDREN');
|
|
543
680
|
}
|
|
544
681
|
const levels = mailbox.path.split(storage.separator);
|
|
545
682
|
const folderName = levels.pop();
|
|
@@ -589,7 +726,7 @@ class IMAPServer extends Stream {
|
|
|
589
726
|
const prefix = key.length ? key.substr(0, key.length - this.storage[key].separator.length) : key;
|
|
590
727
|
if (key.length && (path === prefix || path.substr(0, key.length) === key)) {
|
|
591
728
|
if (path === prefix) {
|
|
592
|
-
throw
|
|
729
|
+
throw storeError('Used mailbox name is a namespace value', 'CANNOT');
|
|
593
730
|
}
|
|
594
731
|
namespace = key;
|
|
595
732
|
}
|
|
@@ -599,10 +736,10 @@ class IMAPServer extends Stream {
|
|
|
599
736
|
});
|
|
600
737
|
const storage = this.storage[namespace];
|
|
601
738
|
if (!storage) {
|
|
602
|
-
throw
|
|
739
|
+
throw storeError('Unknown namespace', 'CANNOT');
|
|
603
740
|
}
|
|
604
741
|
if (storage.type !== 'personal') {
|
|
605
|
-
throw
|
|
742
|
+
throw storeError('Permission denied', 'NOPERM');
|
|
606
743
|
}
|
|
607
744
|
return { namespace, storage };
|
|
608
745
|
}
|
|
@@ -843,6 +980,43 @@ class IMAPServer extends Stream {
|
|
|
843
980
|
const knownFlags = (mailbox.knownFlags = mailbox.knownFlags || []);
|
|
844
981
|
flags.forEach(flag => this.ensureFlag(knownFlags, flag));
|
|
845
982
|
}
|
|
983
|
+
/**
|
|
984
|
+
* Checks if a flag can be stored on messages of a mailbox: any flag when the mailbox allows new keywords
|
|
985
|
+
* (PERMANENTFLAGS has `\*`), otherwise only a flag in the PERMANENTFLAGS list that getStatus() builds, the
|
|
986
|
+
* `permanentFlags` of the mailbox and every flag its messages had. RFC 3501 section 7.1 (RFC 9051 section 7.1):
|
|
987
|
+
* PERMANENTFLAGS "indicates which of the known flags the client can change permanently", STORE ignores the others
|
|
988
|
+
*
|
|
989
|
+
* @param {Object} mailbox Mailbox object
|
|
990
|
+
* @param {String} flag Normalized flag
|
|
991
|
+
* @return {Boolean} true if the flag is a permanent flag of the mailbox
|
|
992
|
+
*/
|
|
993
|
+
isPermanentFlag(mailbox, flag) {
|
|
994
|
+
// the lists of permanentFlagList(), without building it: this runs for every flag STORE and APPEND set
|
|
995
|
+
return (mailbox.allowPermanentFlags || (mailbox.permanentFlags || []).indexOf(flag) >= 0 || (!!mailbox.knownFlags && mailbox.knownFlags.indexOf(flag) >= 0));
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* The flags of the PERMANENTFLAGS list (without `\*`): the `permanentFlags` of the mailbox and every flag its
|
|
999
|
+
* messages have or had (`knownFlags`, see rememberFlags)
|
|
1000
|
+
*
|
|
1001
|
+
* @param {Object} mailbox Mailbox object
|
|
1002
|
+
* @return {Array} flags, a new list
|
|
1003
|
+
*/
|
|
1004
|
+
permanentFlagList(mailbox) {
|
|
1005
|
+
const list = [].concat(mailbox.permanentFlags || []);
|
|
1006
|
+
(mailbox.knownFlags || []).forEach(flag => this.ensureFlag(list, flag));
|
|
1007
|
+
return list;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* The current time for dates the server sets itself (INTERNALDATE of a message without one, SAVEDATE). The `now`
|
|
1011
|
+
* option fixes it for repeatable tests: a Date, a timestamp, or a function that returns one
|
|
1012
|
+
*
|
|
1013
|
+
* @return {Date} current time
|
|
1014
|
+
*/
|
|
1015
|
+
now() {
|
|
1016
|
+
const now = this.options.now;
|
|
1017
|
+
const value = typeof now === 'function' ? now() : now;
|
|
1018
|
+
return value === undefined || value === null ? new Date() : new Date(value);
|
|
1019
|
+
}
|
|
846
1020
|
normalizeDateTime(value) {
|
|
847
1021
|
if (value instanceof Date) {
|
|
848
1022
|
return this.formatInternalDate(value);
|
|
@@ -861,7 +1035,7 @@ class IMAPServer extends Stream {
|
|
|
861
1035
|
* @param mailbox Mailbox of the message
|
|
862
1036
|
*/
|
|
863
1037
|
processMessage(message, mailbox) {
|
|
864
|
-
message.internaldate = this.normalizeDateTime(message.internaldate ||
|
|
1038
|
+
message.internaldate = this.normalizeDateTime(message.internaldate || this.now());
|
|
865
1039
|
message.flags = [].concat(message.flags || []);
|
|
866
1040
|
if (message.flags.indexOf('\\Recent') >= 0) {
|
|
867
1041
|
// \Recent is not a stored flag, it belongs to the first session that selects the mailbox
|
|
@@ -903,9 +1077,10 @@ class IMAPServer extends Stream {
|
|
|
903
1077
|
*/
|
|
904
1078
|
appendMessage(path, flags, internaldate, raw, ignoreConnection, properties) {
|
|
905
1079
|
const mailbox = typeof path === 'string' ? this.getMailbox(path) : path;
|
|
906
|
-
// processMessage() below sets the UID
|
|
1080
|
+
// processMessage() below sets the UID. Flags the mailbox can not store are left out (APPEND, COPY: the flags
|
|
1081
|
+
// SHOULD be set, RFC 3501 sections 6.3.11 and 6.4.7), as STORE ignores them (RFC 3501 section 7.1, PERMANENTFLAGS)
|
|
907
1082
|
const message = Object.assign({}, properties, {
|
|
908
|
-
flags: flags,
|
|
1083
|
+
flags: flags.filter(flag => this.isPermanentFlag(mailbox, flag)),
|
|
909
1084
|
internaldate: internaldate,
|
|
910
1085
|
raw: raw,
|
|
911
1086
|
recent: true
|
|
@@ -1091,49 +1266,48 @@ class IMAPServer extends Stream {
|
|
|
1091
1266
|
return flags;
|
|
1092
1267
|
}
|
|
1093
1268
|
matchFolders(referenceName, match, exportName, folders) {
|
|
1094
|
-
let includeINBOX = false;
|
|
1095
1269
|
const source = folders || this.folderCache;
|
|
1096
1270
|
const toName = exportName || ((name) => name);
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
//
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
const name = toName(key);
|
|
1107
|
-
if (key !== 'INBOX' && reference.substr(0, name.length) === name && (nsKey === false || name.length > nsName.length)) {
|
|
1108
|
-
nsKey = key;
|
|
1109
|
-
nsName = name;
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
if (nsKey === false) {
|
|
1113
|
-
return [];
|
|
1114
|
-
}
|
|
1115
|
-
const namespace = this.storage[nsKey];
|
|
1116
|
-
const lookup = reference + match;
|
|
1117
|
-
const result = [];
|
|
1118
|
-
const pattern = '^' +
|
|
1271
|
+
// RFC 3501 section 6.3.8 (RFC 9051 section 6.3.9): "An empty ("" string) reference name argument
|
|
1272
|
+
// indicates that the mailbox name is interpreted as by SELECT", and without break out characters
|
|
1273
|
+
// "the canonical form is normally the reference name appended with the mailbox name". The pattern
|
|
1274
|
+
// matches full mailbox names, also in a personal namespace with a prefix such as "INBOX.". With the
|
|
1275
|
+
// namespace convention "#" is a break out character "and must be treated as such" (RFC 3501 section 6.3.8,
|
|
1276
|
+
// RFC 9051 section 6.3.9): a pattern that starts with it is a name of its own, the reference is ignored
|
|
1277
|
+
const lookup = match.charAt(0) === '#' ? match : (referenceName || '') + match;
|
|
1278
|
+
// "%" does not match the hierarchy delimiter, which is the one of the namespace a name belongs to
|
|
1279
|
+
const toRegExp = (separator, flags = '') => new RegExp('^' +
|
|
1119
1280
|
lookup
|
|
1120
1281
|
// escape regex symbols
|
|
1121
1282
|
.replace(/([\\^$+?!.():=[\]{}|,-])/g, '\\$1')
|
|
1122
1283
|
.replace(/[*]/g, '.*')
|
|
1123
|
-
.replace(/[%]/g, '[^' +
|
|
1124
|
-
'$';
|
|
1125
|
-
|
|
1126
|
-
//
|
|
1127
|
-
|
|
1284
|
+
.replace(/[%]/g, '[^' + separator.replace(/([\\^$+*?!.():=[\]{}|,-])/g, '\\$1') + ']*') +
|
|
1285
|
+
'$', flags);
|
|
1286
|
+
// RFC 3501 section 6.3.8 allows to "hide" otherwise accessible mailboxes from the wildcards: the
|
|
1287
|
+
// mailboxes of namespaces other than the personal one are only matched when the pattern names
|
|
1288
|
+
// the prefix of the namespace before any wildcard (LIST "" "user.%", LIST "#news." "*"). The pattern
|
|
1289
|
+
// of every namespace that is matched, null for a hidden one
|
|
1290
|
+
const fixedPrefix = lookup.replace(/[*%].*$/, '');
|
|
1291
|
+
const queries = new Map();
|
|
1292
|
+
Object.keys(this.storage).forEach(key => {
|
|
1293
|
+
const name = toName(key);
|
|
1294
|
+
const visible = key === this.referenceNamespace || fixedPrefix.substr(0, name.length) === name;
|
|
1295
|
+
queries.set(key, visible ? toRegExp(this.storage[key].separator) : null);
|
|
1296
|
+
});
|
|
1297
|
+
const result = [];
|
|
1298
|
+
// "The special name INBOX is included in the output from LIST, if [...] the uppercase string "INBOX"
|
|
1299
|
+
// matches the interpreted reference and mailbox name arguments", INBOX is case-insensitive
|
|
1300
|
+
if (source.INBOX && toRegExp((this.storage.INBOX && this.storage.INBOX.separator) || '/', 'i').test('INBOX')) {
|
|
1128
1301
|
result.push(source.INBOX);
|
|
1129
1302
|
}
|
|
1130
1303
|
Object.keys(source).forEach(path => {
|
|
1131
1304
|
const folder = source[path];
|
|
1132
|
-
|
|
1305
|
+
const query = path !== 'INBOX' && folder.namespace !== false && folder.namespace !== 'INBOX' ? queries.get(folder.namespace) : null;
|
|
1306
|
+
if (!query) {
|
|
1133
1307
|
return;
|
|
1134
1308
|
}
|
|
1135
1309
|
const name = toName(path);
|
|
1136
|
-
if (
|
|
1310
|
+
if (query.test(name) && (folder.flags.indexOf('\\NonExistent') < 0 || name === lookup)) {
|
|
1137
1311
|
result.push(folder);
|
|
1138
1312
|
}
|
|
1139
1313
|
});
|
|
@@ -1251,6 +1425,7 @@ class IMAPConnection {
|
|
|
1251
1425
|
this.notificationQueue = [];
|
|
1252
1426
|
this.server.on('notify', this._notificationCallback);
|
|
1253
1427
|
this.server.connections.add(this);
|
|
1428
|
+
this.emitSession('open');
|
|
1254
1429
|
this.scriptOutput('greeting', '* OK ImapKit ready for rumble\r\n', {});
|
|
1255
1430
|
}
|
|
1256
1431
|
/**
|
|
@@ -1285,7 +1460,7 @@ class IMAPConnection {
|
|
|
1285
1460
|
}
|
|
1286
1461
|
this._outputQueue = this._outputQueue || [];
|
|
1287
1462
|
this._outputQueue.push(Object.assign({}, operation, { transport: this.transport }));
|
|
1288
|
-
if (!this._outputTimer) {
|
|
1463
|
+
if (!this._outputTimer && !this._outputWaiting) {
|
|
1289
1464
|
this.flushOutput();
|
|
1290
1465
|
}
|
|
1291
1466
|
}
|
|
@@ -1306,10 +1481,28 @@ class IMAPConnection {
|
|
|
1306
1481
|
return;
|
|
1307
1482
|
}
|
|
1308
1483
|
if (operation.data && operation.chunk && operation.data.length > operation.chunk) {
|
|
1309
|
-
// the rest waits for chunkDelay, like a delay of its own
|
|
1310
|
-
|
|
1484
|
+
// the rest waits for chunkDelay, like a delay of its own. Without a delay each piece waits for the next
|
|
1485
|
+
// event loop turn, and Nagle's algorithm must not hold it back to merge it with the next one
|
|
1486
|
+
const piece = operation.data.subarray(0, operation.chunk);
|
|
1311
1487
|
operation.data = operation.data.subarray(operation.chunk);
|
|
1488
|
+
if (!operation.chunkDelay || operation.chunkDelay === 'tick') {
|
|
1489
|
+
// the next piece waits until this one was handed to the system, and one more event loop turn, so
|
|
1490
|
+
// the pieces leave as separate segments without a wall clock delay (#89)
|
|
1491
|
+
this.socket?.setNoDelay?.(true);
|
|
1492
|
+
this._outputWaiting = true;
|
|
1493
|
+
this.writeLayer(piece, operation.transport || null, () => {
|
|
1494
|
+
if (this._outputWaiting && this._outputQueue === queue) {
|
|
1495
|
+
this._outputTimer = nextTurn(() => {
|
|
1496
|
+
this._outputTimer = null;
|
|
1497
|
+
this._outputWaiting = false;
|
|
1498
|
+
this.flushOutput();
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1312
1504
|
operation.delay = operation.chunkDelay;
|
|
1505
|
+
this.writeLayer(piece, operation.transport || null);
|
|
1313
1506
|
continue;
|
|
1314
1507
|
}
|
|
1315
1508
|
if (operation.data) {
|
|
@@ -1329,8 +1522,10 @@ class IMAPConnection {
|
|
|
1329
1522
|
clearOutputQueue() {
|
|
1330
1523
|
if (this._outputTimer) {
|
|
1331
1524
|
clearTimeout(this._outputTimer);
|
|
1525
|
+
clearImmediate(this._outputTimer);
|
|
1332
1526
|
this._outputTimer = null;
|
|
1333
1527
|
}
|
|
1528
|
+
this._outputWaiting = false;
|
|
1334
1529
|
this._outputQueue = null;
|
|
1335
1530
|
this._deferredOutput = null;
|
|
1336
1531
|
}
|
|
@@ -1339,13 +1534,17 @@ class IMAPConnection {
|
|
|
1339
1534
|
*
|
|
1340
1535
|
* @param {Buffer} data Output
|
|
1341
1536
|
* @param {Object|null} transport Transport layer
|
|
1537
|
+
* @param {Function} [callback] Called once the data was handed to the system (a transport: on the next turn)
|
|
1342
1538
|
*/
|
|
1343
|
-
writeLayer(data, transport) {
|
|
1539
|
+
writeLayer(data, transport, callback) {
|
|
1344
1540
|
if (transport) {
|
|
1345
1541
|
transport.write(data);
|
|
1542
|
+
if (callback) {
|
|
1543
|
+
setImmediate(callback);
|
|
1544
|
+
}
|
|
1346
1545
|
}
|
|
1347
1546
|
else {
|
|
1348
|
-
this.writeRaw(data);
|
|
1547
|
+
this.writeRaw(data, callback);
|
|
1349
1548
|
}
|
|
1350
1549
|
}
|
|
1351
1550
|
/**
|
|
@@ -1399,17 +1598,70 @@ class IMAPConnection {
|
|
|
1399
1598
|
*
|
|
1400
1599
|
* @param {Buffer} data Data to send
|
|
1401
1600
|
*/
|
|
1402
|
-
writeRaw(data) {
|
|
1601
|
+
writeRaw(data, callback) {
|
|
1403
1602
|
if (this.socket && !this.socket.destroyed) {
|
|
1404
|
-
this.socket.write(data);
|
|
1603
|
+
this.socket.write(data, callback);
|
|
1604
|
+
this.touch();
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Notes input or output for the quiet events of script rules (#88)
|
|
1609
|
+
*/
|
|
1610
|
+
touch() {
|
|
1611
|
+
if (!this.server.script.watches('quiet')) {
|
|
1612
|
+
// nothing waits for quiet sessions, a rule added later counts from then on
|
|
1613
|
+
this._lastActivity = undefined;
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
this._lastActivity = Date.now();
|
|
1617
|
+
if (!this._quietTimer) {
|
|
1618
|
+
this.watchQuiet();
|
|
1405
1619
|
}
|
|
1406
1620
|
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Starts waiting for the next quiet event, if a rule waits for one and the session is not waiting already. When
|
|
1623
|
+
* the time is up, the first rule that matches handles the event (`send`, `close`), the output it sends starts
|
|
1624
|
+
* the next quiet time
|
|
1625
|
+
*
|
|
1626
|
+
* @param {Number} [checked] Quiet time the rules were checked for already, only longer ones are waited for
|
|
1627
|
+
*/
|
|
1628
|
+
watchQuiet(checked = 0) {
|
|
1629
|
+
if (this._quietTimer || !this.socket || this._closing) {
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
const last = this._lastActivity || Date.now();
|
|
1633
|
+
this._lastActivity = last;
|
|
1634
|
+
// the time is measured from the last activity, a rule whose time is up already fires right away
|
|
1635
|
+
const next = this.server.script.nextQuiet(checked);
|
|
1636
|
+
if (next === null) {
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
this._quietTimer = setTimeout(() => {
|
|
1640
|
+
this._quietTimer = null;
|
|
1641
|
+
if (!this.isOpen() || this._lastActivity !== last) {
|
|
1642
|
+
// there was activity in between, it started a new wait
|
|
1643
|
+
return this.watchQuiet();
|
|
1644
|
+
}
|
|
1645
|
+
// a timer can fire a millisecond before Date.now() says the time is up
|
|
1646
|
+
const quiet = Math.max(Date.now() - last, next);
|
|
1647
|
+
const found = this.server.script.check(this, 'quiet', { data: '', quiet });
|
|
1648
|
+
if (found) {
|
|
1649
|
+
handleQuiet(this, found.rule, found.context);
|
|
1650
|
+
}
|
|
1651
|
+
if (this._lastActivity === last) {
|
|
1652
|
+
// nothing was sent, wait for the rules with a longer quiet time
|
|
1653
|
+
this.watchQuiet(quiet);
|
|
1654
|
+
}
|
|
1655
|
+
}, Math.max(0, next - (Date.now() - last)));
|
|
1656
|
+
this._quietTimer.unref();
|
|
1657
|
+
}
|
|
1407
1658
|
/**
|
|
1408
1659
|
* Handles data from the socket, through the transport layer if there is one
|
|
1409
1660
|
*
|
|
1410
1661
|
* @param {Buffer} chunk Received data
|
|
1411
1662
|
*/
|
|
1412
1663
|
receive(chunk) {
|
|
1664
|
+
this.touch();
|
|
1413
1665
|
if (this.transport) {
|
|
1414
1666
|
this.transport.receive(chunk);
|
|
1415
1667
|
}
|
|
@@ -1542,11 +1794,15 @@ class IMAPConnection {
|
|
|
1542
1794
|
* notifications that were not sent yet are dropped. Sends nothing, the caller answers the command
|
|
1543
1795
|
*/
|
|
1544
1796
|
closeMailbox() {
|
|
1797
|
+
const wasSelected = !!this.selectedMailbox;
|
|
1545
1798
|
this.state = 'Authenticated';
|
|
1546
1799
|
this.selectedMailbox = false;
|
|
1547
1800
|
this.readOnly = false;
|
|
1548
1801
|
this.recent = null;
|
|
1549
1802
|
this.notificationQueue = [];
|
|
1803
|
+
if (wasSelected) {
|
|
1804
|
+
this.emitSession('unselect');
|
|
1805
|
+
}
|
|
1550
1806
|
}
|
|
1551
1807
|
onClose() {
|
|
1552
1808
|
if (this.socket) {
|
|
@@ -1558,8 +1814,14 @@ class IMAPConnection {
|
|
|
1558
1814
|
this.transport = null;
|
|
1559
1815
|
}
|
|
1560
1816
|
this.clearOutputQueue();
|
|
1817
|
+
if (this._quietTimer) {
|
|
1818
|
+
clearTimeout(this._quietTimer);
|
|
1819
|
+
this._quietTimer = null;
|
|
1820
|
+
}
|
|
1561
1821
|
this.server.removeListener('notify', this._notificationCallback);
|
|
1562
|
-
this.server.connections.delete(this)
|
|
1822
|
+
if (this.server.connections.delete(this)) {
|
|
1823
|
+
this.emitSession('close');
|
|
1824
|
+
}
|
|
1563
1825
|
}
|
|
1564
1826
|
onError(err) {
|
|
1565
1827
|
if (this.options.debug) {
|
|
@@ -1902,7 +2164,7 @@ class IMAPConnection {
|
|
|
1902
2164
|
* @return {Boolean} true if an EXPUNGE response is pending
|
|
1903
2165
|
*/
|
|
1904
2166
|
hasPendingExpunge() {
|
|
1905
|
-
return this.notificationQueue.some(
|
|
2167
|
+
return this.notificationQueue.some(isPendingExpunge);
|
|
1906
2168
|
}
|
|
1907
2169
|
/**
|
|
1908
2170
|
* Tells the other sessions that have the selected mailbox open about changed flags, they get
|
|
@@ -1911,8 +2173,8 @@ class IMAPConnection {
|
|
|
1911
2173
|
* @param {Array} messages Messages with changed flags
|
|
1912
2174
|
*/
|
|
1913
2175
|
notifyFlagChanges(messages) {
|
|
1914
|
-
if (
|
|
1915
|
-
this.server
|
|
2176
|
+
if (this.selectedMailbox) {
|
|
2177
|
+
notifyFlagChanges(this.server, this.selectedMailbox, messages, this);
|
|
1916
2178
|
}
|
|
1917
2179
|
}
|
|
1918
2180
|
/**
|
|
@@ -2109,22 +2371,52 @@ class IMAPConnection {
|
|
|
2109
2371
|
prepareNotifications(queue) {
|
|
2110
2372
|
return queue;
|
|
2111
2373
|
}
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2374
|
+
/**
|
|
2375
|
+
* Checks if EXPUNGE responses must wait while a command runs: during FETCH, STORE and SEARCH (RFC 3501 section
|
|
2376
|
+
* 7.4.1), during the commands that extensions add to this list (see the noExpunge command option), and during UID
|
|
2377
|
+
* SEARCH with message numbers in the search criteria (RFC 7162 section 3.2.10.2 for VANISHED, EXPUNGE may wait as
|
|
2378
|
+
* well, RFC 3501 only allows it during UID commands)
|
|
2379
|
+
*
|
|
2380
|
+
* @param {Object} data Parsed command
|
|
2381
|
+
* @return {Boolean} true if the command holds back EXPUNGE responses
|
|
2382
|
+
*/
|
|
2383
|
+
holdsExpunge(data) {
|
|
2384
|
+
const options = this.server.getCommandOptions(data.command);
|
|
2385
|
+
return options.noExpunge || (options.searchCriteria !== false && this.usesSequenceNumbers(data));
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* Sends the queued notifications. During a command that holds back EXPUNGE responses (see holdsExpunge), or with
|
|
2389
|
+
* `beforeCommand` before a command that refers to messages by sequence number, only the notifications queued before
|
|
2390
|
+
* the first pending EXPUNGE go out: new messages (EXISTS) and flag changes. RFC 3501 section 5.2: "A server MUST send
|
|
2391
|
+
* mailbox size updates automatically if a mailbox size change is observed during the processing of a command",
|
|
2392
|
+
* section 7.4.1 forbids only EXPUNGE during FETCH, STORE and SEARCH. What was queued after the EXPUNGE waits with it,
|
|
2393
|
+
* an EXISTS sent before it would describe a list that the client can not know yet
|
|
2394
|
+
*
|
|
2395
|
+
* @param {Object} [data] Parsed command that runs, or null between commands
|
|
2396
|
+
* @param {Boolean} [beforeCommand] true when the command has not run yet, then a command with message sequence
|
|
2397
|
+
* numbers (COPY, MOVE) also waits with the EXPUNGE responses, its numbers refer to the messages before them
|
|
2398
|
+
*/
|
|
2399
|
+
processNotifications(data, beforeCommand) {
|
|
2121
2400
|
if (!this.notificationQueue.length) {
|
|
2122
2401
|
return;
|
|
2123
2402
|
}
|
|
2124
|
-
|
|
2125
|
-
this.notificationQueue = [];
|
|
2403
|
+
let queue = this.notificationQueue;
|
|
2126
2404
|
// Flag updates use the sequence numbers this session knows: before the EXPUNGE responses of
|
|
2127
2405
|
// the snapshot are sent, the snapshot, afterwards the current message list
|
|
2406
|
+
const snapshot = queue.find(notification => notification.mailboxCopy);
|
|
2407
|
+
let held = [];
|
|
2408
|
+
if (data && (this.holdsExpunge(data) || (beforeCommand && this.usesSequenceNumbers(data)))) {
|
|
2409
|
+
const first = queue.findIndex(isPendingExpunge);
|
|
2410
|
+
if (first >= 0) {
|
|
2411
|
+
held = queue.slice(first);
|
|
2412
|
+
queue = queue.slice(0, first);
|
|
2413
|
+
}
|
|
2414
|
+
if (!queue.length) {
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
this.notificationQueue = held;
|
|
2419
|
+
queue = this.prepareNotifications(queue);
|
|
2128
2420
|
const snapshotIndex = queue.findIndex(notification => notification.mailboxCopy);
|
|
2129
2421
|
const sequenceMaps = new Map();
|
|
2130
2422
|
const getSequence = (messages) => {
|
|
@@ -2138,15 +2430,57 @@ class IMAPConnection {
|
|
|
2138
2430
|
const current = this.selectedMailbox ? this.selectedMailbox.messages : [];
|
|
2139
2431
|
// a message changed several times is reported once, its FETCH response carries the current flags
|
|
2140
2432
|
const reported = new Set();
|
|
2433
|
+
// before the snapshot (or with all of it still held back, then the split at the first pending expunge leaves
|
|
2434
|
+
// no snapshot in the queue) the session knows the old list
|
|
2435
|
+
const sessionList = (i) => (snapshot && (snapshotIndex < 0 || i < snapshotIndex) ? snapshot.mailboxCopy : current);
|
|
2436
|
+
// the last EXISTS response of a run of EXISTS responses, and if the run announces new messages
|
|
2437
|
+
let lastExists = -1;
|
|
2438
|
+
let newMessages = false;
|
|
2141
2439
|
queue.forEach((notification, i) => {
|
|
2142
2440
|
if (notification.flagUpdate) {
|
|
2143
|
-
|
|
2144
|
-
this.sendFlagUpdate(notification.flagUpdate, getSequence(i < snapshotIndex ? queue[snapshotIndex].mailboxCopy : current), reported);
|
|
2441
|
+
this.sendFlagUpdate(notification.flagUpdate, getSequence(sessionList(i)), reported);
|
|
2145
2442
|
}
|
|
2146
2443
|
else {
|
|
2147
2444
|
this.send(notification);
|
|
2148
2445
|
}
|
|
2446
|
+
if (isExists(notification)) {
|
|
2447
|
+
lastExists = i;
|
|
2448
|
+
// the EXISTS after the EXPUNGE responses of another session carries the snapshot instead of a message
|
|
2449
|
+
newMessages = newMessages || !!notification.message;
|
|
2450
|
+
}
|
|
2451
|
+
const next = queue[i + 1];
|
|
2452
|
+
if (lastExists < 0 || isExists(next) || (next && next.fetchedMessage)) {
|
|
2453
|
+
// not the end of a run of EXISTS responses (and the FETCH responses NOTIFY sends after them)
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
// RFC 3501 section 7.3.2: the RECENT response "occurs as a result of a SELECT or EXAMINE command, and if the
|
|
2457
|
+
// size of the mailbox changes (e.g., new messages)". One for consecutive EXISTS responses that announce new
|
|
2458
|
+
// messages, with the number of \Recent messages among those the client was told about. After expunges the
|
|
2459
|
+
// EXPUNGE responses report the change (RFC 3501 section 7.4.1). It goes after the FETCH responses that NOTIFY
|
|
2460
|
+
// sends for new messages, RFC 5465 section 5.2: "an unsolicited EXISTS response, followed by an unsolicited
|
|
2461
|
+
// FETCH response [...] The server MAY also send a RECENT response"
|
|
2462
|
+
if (newMessages) {
|
|
2463
|
+
this.sendRecent(Number(queue[lastExists].attributes[0]), getSequence(sessionList(lastExists)));
|
|
2464
|
+
}
|
|
2465
|
+
lastExists = -1;
|
|
2466
|
+
newMessages = false;
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2469
|
+
/**
|
|
2470
|
+
* Sends an untagged RECENT response with the number of \Recent messages among the first `count` messages
|
|
2471
|
+
*
|
|
2472
|
+
* @param {Number} count Number of messages the client was told about
|
|
2473
|
+
* @param {Map} sequence Message to the sequence number this session knows it by
|
|
2474
|
+
*/
|
|
2475
|
+
sendRecent(count, sequence) {
|
|
2476
|
+
let recent = 0;
|
|
2477
|
+
(this.recent || new Set()).forEach(message => {
|
|
2478
|
+
const seq = sequence.get(message);
|
|
2479
|
+
if (seq && seq <= count) {
|
|
2480
|
+
recent++;
|
|
2481
|
+
}
|
|
2149
2482
|
});
|
|
2483
|
+
this.send({ tag: '*', notification: true, attributes: [recent, { type: 'ATOM', value: 'RECENT' }] }, 'RECENT NOTIFICATION');
|
|
2150
2484
|
}
|
|
2151
2485
|
/**
|
|
2152
2486
|
* Compile a command object to a response string and write it to socket.
|
|
@@ -2168,7 +2502,7 @@ class IMAPConnection {
|
|
|
2168
2502
|
*/
|
|
2169
2503
|
send(response, description, parsed, data, ...extra) {
|
|
2170
2504
|
// nothing goes out once the connection is closing (RFC 3501 section 7.1.5)
|
|
2171
|
-
if (!this.
|
|
2505
|
+
if (!this.isOpen()) {
|
|
2172
2506
|
return;
|
|
2173
2507
|
}
|
|
2174
2508
|
if (!response.notification && response.tag !== '*') {
|
|
@@ -2178,6 +2512,9 @@ class IMAPConnection {
|
|
|
2178
2512
|
this.server.outputHandlers.forEach(handler => {
|
|
2179
2513
|
handler(this, response, description, parsed, data, ...extra);
|
|
2180
2514
|
});
|
|
2515
|
+
if (this._commandStart && parsed && response.tag === this._commandStart.tag && parsed.tag === response.tag) {
|
|
2516
|
+
this.commandCompleted(String(response.command || '').toUpperCase());
|
|
2517
|
+
}
|
|
2181
2518
|
// No need to display this response to user
|
|
2182
2519
|
if (response.skipResponse) {
|
|
2183
2520
|
return;
|
|
@@ -2187,10 +2524,11 @@ class IMAPConnection {
|
|
|
2187
2524
|
response.tag === parsed.tag &&
|
|
2188
2525
|
response.command === 'OK' &&
|
|
2189
2526
|
this.hasPendingExpunge() &&
|
|
2190
|
-
this.
|
|
2527
|
+
this.holdsExpunge(parsed) &&
|
|
2191
2528
|
!(Array.isArray(response.attributes) && response.attributes.some(attr => attr && attr.type === 'SECTION'))) {
|
|
2192
2529
|
// After the output handlers, they might add a response code of their own (MODIFIED of CONDSTORE).
|
|
2193
|
-
// FETCH, STORE, SEARCH and the like can not report the EXPUNGE of another session (RFC 3501 section 7.4.1),
|
|
2530
|
+
// FETCH, STORE, SEARCH and the like can not report the EXPUNGE of another session (RFC 3501 section 7.4.1), nor
|
|
2531
|
+
// can UID SEARCH with message numbers in the criteria (see holdsExpunge),
|
|
2194
2532
|
// EXPUNGEISSUED tells the client to issue NOOP soon (RFC 5530 section 3, RFC 9051 section 7.1)
|
|
2195
2533
|
response.attributes = [{ type: 'SECTION', section: [{ type: 'ATOM', value: 'EXPUNGEISSUED' }] }].concat(response.attributes || []);
|
|
2196
2534
|
}
|
|
@@ -2334,6 +2672,15 @@ class IMAPConnection {
|
|
|
2334
2672
|
* @return {Boolean} true if the command uses message sequence numbers
|
|
2335
2673
|
*/
|
|
2336
2674
|
usesSequenceNumbers(parsed) {
|
|
2675
|
+
// the notification checks ask several times per command, the answer does not change
|
|
2676
|
+
let cached = sequenceNumberUse.get(parsed);
|
|
2677
|
+
if (cached === undefined) {
|
|
2678
|
+
cached = this.findSequenceNumbers(parsed);
|
|
2679
|
+
sequenceNumberUse.set(parsed, cached);
|
|
2680
|
+
}
|
|
2681
|
+
return cached;
|
|
2682
|
+
}
|
|
2683
|
+
findSequenceNumbers(parsed) {
|
|
2337
2684
|
const { sequenceSet, searchCriteria } = this.server.getCommandOptions(parsed.command);
|
|
2338
2685
|
if (sequenceSet !== false) {
|
|
2339
2686
|
// other forms of sequence sets, like "$" of SEARCHRES (RFC 5182 section 2.3), do not use numbers
|
|
@@ -2512,12 +2859,14 @@ class IMAPConnection {
|
|
|
2512
2859
|
this.refusePipelined(parsed, data);
|
|
2513
2860
|
return;
|
|
2514
2861
|
}
|
|
2515
|
-
|
|
2862
|
+
// an unknown SASL mechanism is answered by its own handler, after the central checks (state, script rules)
|
|
2863
|
+
const fallback = /^AUTHENTICATE /i.test(parsed.command) && !this.server.getCommandHandler(parsed.command) ? unsupportedMechanism : undefined;
|
|
2864
|
+
if (fallback || this.server.getCommandHandler(parsed.command)) {
|
|
2516
2865
|
if (this.isAmbiguous(parsed)) {
|
|
2517
2866
|
this.sendStatus(parsed, data, 'BAD', 'Commands with message sequence numbers must wait for the completion of earlier commands');
|
|
2518
2867
|
return;
|
|
2519
2868
|
}
|
|
2520
|
-
const element = { parsed, data };
|
|
2869
|
+
const element = { parsed, data, handler: fallback };
|
|
2521
2870
|
if (scripted) {
|
|
2522
2871
|
// processQueue runs it once the script rule released the queue
|
|
2523
2872
|
this._commandQueue.unshift(element);
|
|
@@ -2527,19 +2876,6 @@ class IMAPConnection {
|
|
|
2527
2876
|
}
|
|
2528
2877
|
this.processQueue();
|
|
2529
2878
|
}
|
|
2530
|
-
else if (/^AUTHENTICATE /i.test(parsed.command)) {
|
|
2531
|
-
// an unsupported mechanism is a NO, not a syntax error (RFC 3501 section 6.2.2)
|
|
2532
|
-
this.send({
|
|
2533
|
-
tag: parsed.tag,
|
|
2534
|
-
command: 'NO',
|
|
2535
|
-
attributes: [
|
|
2536
|
-
{
|
|
2537
|
-
type: 'TEXT',
|
|
2538
|
-
value: 'Unsupported authentication mechanism'
|
|
2539
|
-
}
|
|
2540
|
-
]
|
|
2541
|
-
}, 'UNKNOWN COMMAND', parsed, data);
|
|
2542
|
-
}
|
|
2543
2879
|
else {
|
|
2544
2880
|
this.send({
|
|
2545
2881
|
tag: parsed.tag,
|
|
@@ -2553,6 +2889,64 @@ class IMAPConnection {
|
|
|
2553
2889
|
}, 'UNKNOWN COMMAND', parsed, data);
|
|
2554
2890
|
}
|
|
2555
2891
|
}
|
|
2892
|
+
/**
|
|
2893
|
+
* Emits the `command` event when the tagged response of a command goes out, and the `session` events "login"
|
|
2894
|
+
* (the command authenticated the session) and "logout" (back to Not Authenticated, UNAUTHENTICATE). Every command
|
|
2895
|
+
* that changes these does so before its tagged response. "select" and "unselect" come from SELECT and
|
|
2896
|
+
* closeMailbox(). Tests wait for these instead of polling
|
|
2897
|
+
*
|
|
2898
|
+
* @param {String} status OK, NO or BAD
|
|
2899
|
+
*/
|
|
2900
|
+
commandCompleted(status) {
|
|
2901
|
+
const start = this._commandStart;
|
|
2902
|
+
this._commandStart = null;
|
|
2903
|
+
const server = this.server;
|
|
2904
|
+
if (!server.listenerCount('command') && !server.listenerCount('session')) {
|
|
2905
|
+
return;
|
|
2906
|
+
}
|
|
2907
|
+
server.emit('command', { session: this.sessionNumber, tag: start.tag, command: start.command, status, user: this.username || null });
|
|
2908
|
+
const authenticated = (state) => state === 'Authenticated' || state === 'Selected';
|
|
2909
|
+
if (authenticated(start.state) !== authenticated(this.state) && this.state !== 'Logout') {
|
|
2910
|
+
this.emitSession(authenticated(this.state) ? 'login' : 'logout');
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
/**
|
|
2914
|
+
* Emits a `session` event, `{ type, session, ...fields }`
|
|
2915
|
+
*
|
|
2916
|
+
* @param {String} type Event type
|
|
2917
|
+
* @param {Object} [fields] More fields of the event
|
|
2918
|
+
*/
|
|
2919
|
+
emitSession(type, fields) {
|
|
2920
|
+
if (this.server.listenerCount('session')) {
|
|
2921
|
+
this.server.emit('session', Object.assign({ type, session: this.describe() }, fields));
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
/**
|
|
2925
|
+
* Describes the session without credentials or sockets, for the control API and the `session` events
|
|
2926
|
+
*
|
|
2927
|
+
* @return {Object} `{ session, user, state, mailbox, readOnly, enabled, secure, compressed, remoteAddress }`
|
|
2928
|
+
*/
|
|
2929
|
+
describe() {
|
|
2930
|
+
return {
|
|
2931
|
+
session: this.sessionNumber,
|
|
2932
|
+
user: this.username || null,
|
|
2933
|
+
state: this.state,
|
|
2934
|
+
mailbox: this.selectedMailbox ? this.selectedMailbox.path : null,
|
|
2935
|
+
readOnly: !!this.selectedMailbox && !!this.readOnly,
|
|
2936
|
+
enabled: Array.isArray(this.enabled) ? this.enabled.slice() : [],
|
|
2937
|
+
secure: !!this.secureConnection,
|
|
2938
|
+
compressed: !!this.transport,
|
|
2939
|
+
remoteAddress: (this.socket && this.socket.remoteAddress) || null
|
|
2940
|
+
};
|
|
2941
|
+
}
|
|
2942
|
+
/**
|
|
2943
|
+
* Checks if output can still be sent: the socket is open and the connection is not closing (BYE, LOGOUT)
|
|
2944
|
+
*
|
|
2945
|
+
* @return {Boolean} true for a live session
|
|
2946
|
+
*/
|
|
2947
|
+
isOpen() {
|
|
2948
|
+
return !!this.socket && !this.socket.destroyed && !this._closing;
|
|
2949
|
+
}
|
|
2556
2950
|
/**
|
|
2557
2951
|
* Handles a command line with the script rule that matched it, after the rule's delay. With `run` the line
|
|
2558
2952
|
* goes through the parser and the command handler as usual afterwards
|
|
@@ -2593,6 +2987,8 @@ class IMAPConnection {
|
|
|
2593
2987
|
const command = element.parsed.command.toUpperCase();
|
|
2594
2988
|
this._runningCommand = element;
|
|
2595
2989
|
const options = this.server.getCommandOptions(command);
|
|
2990
|
+
// the session events of the command are worked out when its tagged response goes out, see commandCompleted
|
|
2991
|
+
this._commandStart = { tag: element.parsed.tag, command, state: this.state };
|
|
2596
2992
|
let done = false;
|
|
2597
2993
|
const next = () => {
|
|
2598
2994
|
if (done) {
|
|
@@ -2647,61 +3043,66 @@ class IMAPConnection {
|
|
|
2647
3043
|
return next();
|
|
2648
3044
|
}
|
|
2649
3045
|
}
|
|
2650
|
-
if (
|
|
2651
|
-
|
|
2652
|
-
//
|
|
2653
|
-
//
|
|
2654
|
-
//
|
|
2655
|
-
//
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
//
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
this.
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
3046
|
+
if (this.state === 'Selected' &&
|
|
3047
|
+
(command.startsWith('UID ') || options.noExpunge || options.sequenceSet !== false || options.searchCriteria !== false)) {
|
|
3048
|
+
// A command that refers to messages runs on the message list the client was told about. Only these: CLOSE
|
|
3049
|
+
// must not send pending EXPUNGE responses (RFC 3501 section 6.4.2) and SELECT leaves the old mailbox. RFC 3501 section 5.2:
|
|
3050
|
+
// "A server MUST send mailbox size updates automatically if a mailbox size change is observed during the
|
|
3051
|
+
// processing of a command", so the new messages and flag changes of other sessions are reported first,
|
|
3052
|
+
// before the command resolves its sequence set or search criteria.
|
|
3053
|
+
// EXPUNGE responses may be sent during UID commands (RFC 3501 section 7.4.1), so these report the expunges of
|
|
3054
|
+
// other sessions first as well, then the command runs on the current mailbox, where the UIDs of the expunged
|
|
3055
|
+
// messages do not exist and are ignored (RFC 3501 section 6.4.8). The ghost handling of STORE, COPY and MOVE
|
|
3056
|
+
// (RFC 2180 section 4) only applies to their sequence number forms, these and UID SEARCH with message numbers
|
|
3057
|
+
// in the criteria keep the EXPUNGE responses for later, see processNotifications
|
|
3058
|
+
this.processNotifications(element.parsed, true);
|
|
3059
|
+
}
|
|
3060
|
+
// changes made while the handler runs are attributed to this session (the `origin` of notifications)
|
|
3061
|
+
this.server.withOrigin(this, () => {
|
|
3062
|
+
try {
|
|
3063
|
+
const inputHandler = this.inputHandler;
|
|
3064
|
+
(element.handler || this.server.getCommandHandler(element.parsed.command))(this, element.parsed, element.data, next);
|
|
3065
|
+
if (this.inputHandler && this.inputHandler !== inputHandler) {
|
|
3066
|
+
// the command reads the lines that follow (IDLE, AUTHENTICATE), script rules match them with it
|
|
3067
|
+
this.inputCommand = { tag: element.parsed.tag, command: element.parsed.command };
|
|
3068
|
+
this.emitSession('waiting', { command });
|
|
3069
|
+
}
|
|
2666
3070
|
}
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
3071
|
+
catch (E) {
|
|
3072
|
+
const ex = E;
|
|
3073
|
+
const badInput = ex.imapResponse === 'BAD';
|
|
3074
|
+
if (!badInput && this.options.debug) {
|
|
3075
|
+
console.error('Error processing command:', ex, '\n', ex.stack);
|
|
3076
|
+
}
|
|
3077
|
+
this.send({
|
|
3078
|
+
tag: element.parsed.tag,
|
|
3079
|
+
command: badInput ? 'BAD' : 'NO',
|
|
3080
|
+
attributes: [].concat(badInput
|
|
3081
|
+
? []
|
|
3082
|
+
: {
|
|
3083
|
+
type: 'SECTION',
|
|
3084
|
+
section: [
|
|
3085
|
+
{
|
|
3086
|
+
type: 'ATOM',
|
|
3087
|
+
value: 'SERVERBUG'
|
|
3088
|
+
}
|
|
3089
|
+
]
|
|
3090
|
+
}, {
|
|
3091
|
+
type: 'TEXT',
|
|
3092
|
+
value: badInput ? ex.message : 'Server error: ' + ex.message
|
|
3093
|
+
})
|
|
3094
|
+
}, badInput ? 'INVALID COMMAND' : 'SERVER ERROR', element.parsed, element.data);
|
|
3095
|
+
// keep the connection usable, otherwise every later command would hang
|
|
3096
|
+
next();
|
|
2673
3097
|
}
|
|
2674
|
-
|
|
2675
|
-
tag: element.parsed.tag,
|
|
2676
|
-
command: badInput ? 'BAD' : 'NO',
|
|
2677
|
-
attributes: [].concat(badInput
|
|
2678
|
-
? []
|
|
2679
|
-
: {
|
|
2680
|
-
type: 'SECTION',
|
|
2681
|
-
section: [
|
|
2682
|
-
{
|
|
2683
|
-
type: 'ATOM',
|
|
2684
|
-
value: 'SERVERBUG'
|
|
2685
|
-
}
|
|
2686
|
-
]
|
|
2687
|
-
}, {
|
|
2688
|
-
type: 'TEXT',
|
|
2689
|
-
value: badInput ? ex.message : 'Server error: ' + ex.message
|
|
2690
|
-
})
|
|
2691
|
-
}, badInput ? 'INVALID COMMAND' : 'SERVER ERROR', element.parsed, element.data);
|
|
2692
|
-
// keep the connection usable, otherwise every later command would hang
|
|
2693
|
-
next();
|
|
2694
|
-
}
|
|
2695
|
-
finally {
|
|
2696
|
-
this.server.activeConnection = null;
|
|
2697
|
-
}
|
|
3098
|
+
});
|
|
2698
3099
|
}
|
|
2699
3100
|
/**
|
|
2700
3101
|
* Removes messages with \Deleted flag
|
|
2701
3102
|
*
|
|
2702
3103
|
* @param {Object} mailbox Mailbox to check for
|
|
2703
3104
|
* @param {Boolean} [ignoreSelf] If set to true, does not send any notices to itself
|
|
2704
|
-
* @param {Boolean} [
|
|
3105
|
+
* @param {Boolean} [ignoreExists] If set to true, does not send EXISTS notice to itself
|
|
2705
3106
|
*/
|
|
2706
3107
|
expungeDeleted(mailbox, ignoreSelf, ignoreExists) {
|
|
2707
3108
|
this.expungeSpecificMessages(mailbox, (message) => {
|
|
@@ -2710,80 +3111,55 @@ class IMAPConnection {
|
|
|
2710
3111
|
}
|
|
2711
3112
|
/**
|
|
2712
3113
|
* Given a set of messages in a mailbox (possibly via getMessageRange), remove
|
|
2713
|
-
* them from the mailbox and generate EXPUNGE notifications.
|
|
3114
|
+
* them from the mailbox and generate EXPUNGE notifications, see expungeMessages() in store-operations.ts
|
|
2714
3115
|
*
|
|
2715
3116
|
* @param {Object} mailbox Mailbox to check for
|
|
2716
3117
|
* @param {Function|Array} messagesOrFilterFunc An Array of messages in the
|
|
2717
3118
|
* folder that should be removed or a filtering function that indicates
|
|
2718
3119
|
* messages to be removed by returning true.
|
|
2719
3120
|
* @param {Boolean} [ignoreSelf] If set to true, does not send any notices to itself
|
|
2720
|
-
* @param {Boolean} [
|
|
3121
|
+
* @param {Boolean} [ignoreExists] If set to true, does not send EXISTS notice to itself
|
|
2721
3122
|
* @param {Boolean} [highestFirst] If set to true, the EXPUNGE responses go from the highest UID to the lowest
|
|
2722
3123
|
* (MESSAGELIMIT, RFC 9738 section 3.1), otherwise from the lowest
|
|
2723
3124
|
*/
|
|
2724
3125
|
expungeSpecificMessages(mailbox, messagesOrFilterFunc, ignoreSelf, ignoreExists, highestFirst) {
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
else {
|
|
2731
|
-
filterFunc = messagesOrFilterFunc;
|
|
2732
|
-
}
|
|
2733
|
-
// sequence numbers of the removed messages, each one as it is after the earlier EXPUNGE responses. From the
|
|
2734
|
-
// highest message down, the earlier responses do not change the sequence numbers of the later ones
|
|
2735
|
-
const expunged = [];
|
|
2736
|
-
const kept = [];
|
|
2737
|
-
mailbox.messages.forEach((message, i) => {
|
|
2738
|
-
if (filterFunc(message)) {
|
|
2739
|
-
message.ghost = true;
|
|
2740
|
-
expunged.push({ seq: highestFirst ? i + 1 : kept.length + 1, message });
|
|
2741
|
-
}
|
|
2742
|
-
else {
|
|
2743
|
-
kept.push(message);
|
|
2744
|
-
}
|
|
2745
|
-
});
|
|
2746
|
-
if (!expunged.length) {
|
|
2747
|
-
return;
|
|
2748
|
-
}
|
|
2749
|
-
// old copy is required for those sessions that run FETCH before
|
|
2750
|
-
// displaying the EXPUNGE notice
|
|
2751
|
-
const mailboxCopy = mailbox.messages.slice();
|
|
2752
|
-
// update the list in place, other code might hold a reference to it
|
|
2753
|
-
kept.forEach((message, i) => {
|
|
2754
|
-
mailbox.messages[i] = message;
|
|
2755
|
-
});
|
|
2756
|
-
mailbox.messages.length = kept.length;
|
|
2757
|
-
// lets plugins track the removal (e.g. mod-sequences of CONDSTORE and QRESYNC) before any notification
|
|
2758
|
-
this.server.emit('expunge', mailbox, expunged.map(entry => entry.message), this);
|
|
2759
|
-
(highestFirst ? expunged.slice().reverse() : expunged).forEach(entry => {
|
|
2760
|
-
this.server.notify({
|
|
2761
|
-
tag: '*',
|
|
2762
|
-
attributes: [
|
|
2763
|
-
entry.seq,
|
|
2764
|
-
{
|
|
2765
|
-
type: 'ATOM',
|
|
2766
|
-
value: 'EXPUNGE'
|
|
2767
|
-
}
|
|
2768
|
-
],
|
|
2769
|
-
// the removed message, for plugins that report it differently (e.g. VANISHED of QRESYNC)
|
|
2770
|
-
message: entry.message
|
|
2771
|
-
}, mailbox, ignoreSelf ? this : false);
|
|
3126
|
+
expungeMessages(this.server, mailbox, messagesOrFilterFunc, {
|
|
3127
|
+
origin: this,
|
|
3128
|
+
skip: ignoreSelf ? this : null,
|
|
3129
|
+
skipExists: ignoreExists ? this : null,
|
|
3130
|
+
highestFirst: !!highestFirst
|
|
2772
3131
|
});
|
|
2773
|
-
this.server.notify({
|
|
2774
|
-
tag: '*',
|
|
2775
|
-
attributes: [
|
|
2776
|
-
mailbox.messages.length,
|
|
2777
|
-
{
|
|
2778
|
-
type: 'ATOM',
|
|
2779
|
-
value: 'EXISTS'
|
|
2780
|
-
}
|
|
2781
|
-
],
|
|
2782
|
-
// distribute the old mailbox data with the notification
|
|
2783
|
-
mailboxCopy: mailboxCopy
|
|
2784
|
-
}, mailbox, ignoreSelf || ignoreExists ? this : false);
|
|
2785
3132
|
}
|
|
2786
3133
|
}
|
|
3134
|
+
// setImmediate does not let Deno send what sockets were given, see nextTurn
|
|
3135
|
+
const IS_DENO = 'Deno' in globalThis;
|
|
3136
|
+
/**
|
|
3137
|
+
* Runs a function on the next event loop turn that also handles network I/O: setImmediate on Node and Bun, a zero
|
|
3138
|
+
* timer on Deno, where setImmediate runs before the sockets send what was written (#89)
|
|
3139
|
+
*
|
|
3140
|
+
* @param {Function} fn Function to run
|
|
3141
|
+
* @return {Object} the timer, clearOutputQueue clears it
|
|
3142
|
+
*/
|
|
3143
|
+
function nextTurn(fn) {
|
|
3144
|
+
return IS_DENO ? setTimeout(fn, 0) : setImmediate(fn);
|
|
3145
|
+
}
|
|
3146
|
+
/**
|
|
3147
|
+
* Starts listening, resolves with the port or rejects with the listen error (EADDRINUSE)
|
|
3148
|
+
*
|
|
3149
|
+
* @param {Object} server net, tls or http server
|
|
3150
|
+
* @param {Number} [port] Port, a free one if not set
|
|
3151
|
+
* @param {String} [host] Address, all addresses if not set
|
|
3152
|
+
* @return {Promise<Number>} the port
|
|
3153
|
+
*/
|
|
3154
|
+
function listenOn(server, port, host) {
|
|
3155
|
+
return new Promise((resolve, reject) => {
|
|
3156
|
+
server.once('error', reject);
|
|
3157
|
+
server.listen({ port: port || 0, host }, () => {
|
|
3158
|
+
server.removeListener('error', reject);
|
|
3159
|
+
resolve(server.address().port);
|
|
3160
|
+
});
|
|
3161
|
+
});
|
|
3162
|
+
}
|
|
2787
3163
|
/**
|
|
2788
3164
|
* Destroys a socket with a TCP RST (`close: 'reset'` of a script rule). resetAndDestroy (Node 16.17) needs a socket with
|
|
2789
3165
|
* a TCP handle, a TLS socket throws ERR_INVALID_HANDLE_TYPE (#84), so the RST goes to the TCP socket under the TLS
|