emailengine-app 2.68.0 → 2.69.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/codeql/codeql-config.yml +16 -0
- package/.github/workflows/codeql.yml +102 -0
- package/.github/workflows/deploy.yml +8 -0
- package/.github/workflows/release.yaml +4 -0
- package/.github/workflows/test.yml +3 -0
- package/CHANGELOG.md +49 -0
- package/SECURITY.md +80 -0
- package/SECURITY.txt +27 -0
- package/config/default.toml +2 -0
- package/data/google-crawlers.json +13 -1
- package/lib/account.js +62 -25
- package/lib/api-routes/account-routes.js +493 -75
- package/lib/api-routes/blocklist-routes.js +337 -0
- package/lib/api-routes/delivery-test-routes.js +321 -0
- package/lib/api-routes/export-routes.js +1 -12
- package/lib/api-routes/gateway-routes.js +376 -0
- package/lib/api-routes/license-routes.js +142 -0
- package/lib/api-routes/mailbox-routes.js +318 -0
- package/lib/api-routes/message-routes.js +21 -129
- package/lib/api-routes/oauth2-app-routes.js +631 -0
- package/lib/api-routes/outbox-routes.js +173 -0
- package/lib/api-routes/pubsub-routes.js +98 -0
- package/lib/api-routes/route-helpers.js +45 -0
- package/lib/api-routes/settings-routes.js +331 -0
- package/lib/api-routes/stats-routes.js +77 -0
- package/lib/api-routes/submit-routes.js +472 -0
- package/lib/api-routes/template-routes.js +7 -55
- package/lib/api-routes/token-routes.js +297 -0
- package/lib/api-routes/webhook-route-routes.js +152 -0
- package/lib/email-client/gmail-client.js +14 -0
- package/lib/email-client/imap/mailbox.js +34 -11
- package/lib/email-client/imap/subconnection.js +20 -12
- package/lib/email-client/imap/sync-operations.js +130 -2
- package/lib/email-client/imap-client.js +116 -58
- package/lib/email-client/outlook-client.js +85 -13
- package/lib/export.js +60 -19
- package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
- package/lib/imapproxy/imap-core/lib/imap-command.js +7 -2
- package/lib/imapproxy/imap-core/lib/imap-connection.js +113 -23
- package/lib/imapproxy/imap-core/lib/imap-server.js +25 -1
- package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
- package/lib/imapproxy/imap-server.js +92 -29
- package/lib/message-port-stream.js +113 -16
- package/lib/reject-worker-calls.js +42 -0
- package/lib/routes-ui.js +37 -8778
- package/lib/schemas.js +26 -1
- package/lib/tools.js +73 -0
- package/lib/ui-routes/account-routes.js +40 -210
- package/lib/ui-routes/admin-config-routes.js +913 -487
- package/lib/ui-routes/admin-entities-routes.js +1 -0
- package/lib/ui-routes/auth-routes.js +1339 -0
- package/lib/ui-routes/dashboard-routes.js +188 -0
- package/lib/ui-routes/document-store-routes.js +800 -0
- package/lib/ui-routes/export-routes.js +217 -0
- package/lib/ui-routes/internals-routes.js +354 -0
- package/lib/ui-routes/network-config-routes.js +759 -0
- package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
- package/lib/ui-routes/route-helpers.js +316 -0
- package/lib/ui-routes/smtp-test-routes.js +236 -0
- package/lib/ui-routes/unsubscribe-routes.js +234 -0
- package/lib/webhook-request.js +36 -0
- package/package.json +17 -17
- package/sbom.json +1 -1
- package/server.js +217 -19
- package/static/licenses.html +52 -182
- package/translations/messages.pot +131 -151
- package/views/dashboard.hbs +7 -26
- package/views/internals/index.hbs +15 -0
- package/views/tokens/index.hbs +9 -0
- package/workers/api.js +198 -4401
- package/workers/export.js +87 -54
- package/workers/imap.js +29 -13
- package/workers/submit.js +20 -11
- package/workers/webhooks.js +6 -20
package/lib/export.js
CHANGED
|
@@ -114,6 +114,13 @@ async function getExportMaxAge() {
|
|
|
114
114
|
return DEFAULT_EXPORT_MAX_AGE;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// Resolve the retention window into the Redis `expire` TTL (seconds) and an absolute `expiresAt`
|
|
118
|
+
// timestamp (ms), used wherever an export key's expiry is (re)set.
|
|
119
|
+
async function getExportExpiry() {
|
|
120
|
+
const maxAge = await getExportMaxAge();
|
|
121
|
+
return { ttl: Math.ceil(maxAge / 1000), expiresAt: Date.now() + maxAge };
|
|
122
|
+
}
|
|
123
|
+
|
|
117
124
|
function toTimestamp(date) {
|
|
118
125
|
const ts = new Date(date).getTime();
|
|
119
126
|
if (isNaN(ts)) {
|
|
@@ -168,7 +175,8 @@ class Export {
|
|
|
168
175
|
startDate,
|
|
169
176
|
endDate,
|
|
170
177
|
textType: options.textType || '*',
|
|
171
|
-
|
|
178
|
+
// Preserve an explicit 0 ("unlimited" per the API contract); only fall back when unset.
|
|
179
|
+
maxBytes: Number.isInteger(options.maxBytes) ? options.maxBytes : 5 * 1024 * 1024,
|
|
172
180
|
includeAttachments: options.includeAttachments ? '1' : '0',
|
|
173
181
|
isEncrypted: isEncrypted ? '1' : '0',
|
|
174
182
|
foldersScanned: 0,
|
|
@@ -178,7 +186,6 @@ class Export {
|
|
|
178
186
|
messagesSkipped: 0,
|
|
179
187
|
bytesWritten: 0,
|
|
180
188
|
filePath,
|
|
181
|
-
lastProcessedScore: 0,
|
|
182
189
|
created: now,
|
|
183
190
|
expiresAt,
|
|
184
191
|
error: ''
|
|
@@ -325,11 +332,39 @@ class Export {
|
|
|
325
332
|
|
|
326
333
|
static async startProcessing(account, exportId) {
|
|
327
334
|
const exportKey = getExportKey(account, exportId);
|
|
328
|
-
const
|
|
329
|
-
const ttl =
|
|
330
|
-
const newExpiresAt = Date.now() + maxAge;
|
|
335
|
+
const queueKey = getExportQueueKey(account, exportId);
|
|
336
|
+
const { ttl, expiresAt } = await getExportExpiry();
|
|
331
337
|
|
|
332
|
-
|
|
338
|
+
// Reset progress and clear any previously indexed queue so that each (re)run -- including a
|
|
339
|
+
// BullMQ stalled-job reprocess -- rebuilds the queue and rewrites the (truncated) output file
|
|
340
|
+
// from scratch, keeping counters and file content consistent.
|
|
341
|
+
await redis
|
|
342
|
+
.multi()
|
|
343
|
+
.hmset(exportKey, {
|
|
344
|
+
status: 'processing',
|
|
345
|
+
phase: 'indexing',
|
|
346
|
+
expiresAt,
|
|
347
|
+
foldersScanned: 0,
|
|
348
|
+
foldersTotal: 0,
|
|
349
|
+
messagesQueued: 0,
|
|
350
|
+
messagesExported: 0,
|
|
351
|
+
messagesSkipped: 0,
|
|
352
|
+
bytesWritten: 0,
|
|
353
|
+
truncated: '0'
|
|
354
|
+
})
|
|
355
|
+
.del(queueKey)
|
|
356
|
+
.expire(exportKey, ttl)
|
|
357
|
+
.exec();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
static async extendExpiry(account, exportId) {
|
|
361
|
+
const exportKey = getExportKey(account, exportId);
|
|
362
|
+
const queueKey = getExportQueueKey(account, exportId);
|
|
363
|
+
const { ttl, expiresAt } = await getExportExpiry();
|
|
364
|
+
|
|
365
|
+
// Keep both the export hash and the pending-message queue alive for the full retention window
|
|
366
|
+
// while a long export is still running, and surface the refreshed expiry to status readers.
|
|
367
|
+
await redis.multi().hset(exportKey, 'expiresAt', expiresAt).expire(exportKey, ttl).expire(queueKey, ttl).exec();
|
|
333
368
|
}
|
|
334
369
|
|
|
335
370
|
static async queueMessage(account, exportId, messageInfo) {
|
|
@@ -355,19 +390,23 @@ class Export {
|
|
|
355
390
|
await multi.exec();
|
|
356
391
|
}
|
|
357
392
|
|
|
358
|
-
static async getNextBatch(account, exportId,
|
|
393
|
+
static async getNextBatch(account, exportId, limit) {
|
|
359
394
|
const queueKey = getExportQueueKey(account, exportId);
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
|
|
395
|
+
// Atomically pop the lowest-scored (oldest) messages so each is processed exactly once.
|
|
396
|
+
// This avoids cursor-based reprocessing and the boundary drops that an exclusive score bound
|
|
397
|
+
// caused when two messages shared the same score.
|
|
398
|
+
const results = await redis.zpopmin(queueKey, limit);
|
|
363
399
|
|
|
400
|
+
// ZPOPMIN returns [member, score, member, score, ...]; only the decoded member is needed.
|
|
364
401
|
const messages = [];
|
|
365
402
|
for (let i = 0; i < results.length; i += 2) {
|
|
366
403
|
try {
|
|
367
|
-
|
|
368
|
-
messages.push({ ...info, score: Number(results[i + 1]) });
|
|
404
|
+
messages.push(msgpack.decode(Buffer.from(results[i], 'base64url')));
|
|
369
405
|
} catch (err) {
|
|
370
406
|
logger.error({ msg: 'Failed to decode message info', account, exportId, err });
|
|
407
|
+
// ZPOPMIN already removed the entry, so it will never be exported;
|
|
408
|
+
// count it as skipped to keep messagesQueued === exported + skipped
|
|
409
|
+
await Export.incrementSkipped(account, exportId);
|
|
371
410
|
}
|
|
372
411
|
}
|
|
373
412
|
|
|
@@ -383,19 +422,20 @@ class Export {
|
|
|
383
422
|
await redis.hincrby(getExportKey(account, exportId), 'messagesSkipped', 1);
|
|
384
423
|
}
|
|
385
424
|
|
|
386
|
-
static async updateLastProcessedScore(account, exportId, score) {
|
|
387
|
-
await redis.hset(getExportKey(account, exportId), 'lastProcessedScore', score);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
425
|
static async complete(account, exportId) {
|
|
391
426
|
const exportKey = getExportKey(account, exportId);
|
|
392
427
|
const queueKey = getExportQueueKey(account, exportId);
|
|
393
428
|
|
|
429
|
+
// Start the retention window at completion time so a long-running export stays downloadable for
|
|
430
|
+
// the full retention period rather than the time left over from when processing started.
|
|
431
|
+
const { ttl, expiresAt } = await getExportExpiry();
|
|
432
|
+
|
|
394
433
|
await redis
|
|
395
434
|
.multi()
|
|
396
|
-
.hmset(exportKey, { status: 'completed', phase: 'complete' })
|
|
435
|
+
.hmset(exportKey, { status: 'completed', phase: 'complete', expiresAt })
|
|
397
436
|
.del(queueKey)
|
|
398
437
|
.srem(ACTIVE_EXPORTS_KEY, `${account}:${exportId}`)
|
|
438
|
+
.expire(exportKey, ttl)
|
|
399
439
|
.exec();
|
|
400
440
|
|
|
401
441
|
logger.info({ msg: 'Export completed', account, exportId });
|
|
@@ -437,8 +477,9 @@ class Export {
|
|
|
437
477
|
|
|
438
478
|
for (const entry of activeExports) {
|
|
439
479
|
try {
|
|
440
|
-
// Find ':exp_' as separator since account IDs may contain colons
|
|
441
|
-
|
|
480
|
+
// Find ':exp_' as separator since account IDs may contain colons. The export id is
|
|
481
|
+
// always the trailing ':exp_<hex>' segment, so match the last occurrence.
|
|
482
|
+
const separatorIndex = entry.lastIndexOf(':exp_');
|
|
442
483
|
if (separatorIndex === -1) continue;
|
|
443
484
|
const account = entry.substring(0, separatorIndex);
|
|
444
485
|
const exportId = entry.substring(separatorIndex + 1);
|
|
@@ -15,6 +15,20 @@ module.exports = {
|
|
|
15
15
|
});
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
if (this._server.options.disableSTARTTLS) {
|
|
19
|
+
// STARTTLS is not advertised in this mode (e.g. the EmailEngine proxy);
|
|
20
|
+
// refuse it instead of upgrading against the built-in fallback cert.
|
|
21
|
+
return callback(null, {
|
|
22
|
+
response: 'NO',
|
|
23
|
+
message: 'STARTTLS not available'
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Block any further (possibly pipelined / injected) commands until the TLS
|
|
28
|
+
// handshake completes. _onCommand ignores commands while _upgrading is set,
|
|
29
|
+
// so a command sent in the same chunk as STARTTLS cannot be executed.
|
|
30
|
+
this._upgrading = true;
|
|
31
|
+
|
|
18
32
|
setImmediate(upgrade.bind(null, this));
|
|
19
33
|
|
|
20
34
|
callback(null, {
|
|
@@ -94,6 +108,10 @@ function upgrade(connection) {
|
|
|
94
108
|
(cipher && cipher.name) || 'N/A'
|
|
95
109
|
);
|
|
96
110
|
|
|
111
|
+
// Discard any plaintext the parser buffered before the handshake so it
|
|
112
|
+
// cannot be injected into the now-encrypted session.
|
|
113
|
+
connection._parser.reset();
|
|
114
|
+
|
|
97
115
|
connection._socket.pipe(connection._parser);
|
|
98
116
|
connection.writeStream.pipe(connection._socket);
|
|
99
117
|
});
|
|
@@ -369,9 +369,14 @@ class IMAPCommand {
|
|
|
369
369
|
countBadResponses() {
|
|
370
370
|
this.connection._badCount++;
|
|
371
371
|
if (this.connection._badCount > MAX_BAD_COMMANDS) {
|
|
372
|
-
|
|
372
|
+
// Stop reading first so a command pipelined right after the offending input is not
|
|
373
|
+
// dispatched during the graceful-close window.
|
|
374
|
+
this.connection._stopReading();
|
|
375
|
+
this.connection.clearNotificationListener();
|
|
373
376
|
this.connection.send('* BYE Too many protocol errors');
|
|
374
|
-
|
|
377
|
+
// Graceful close so the BYE is flushed to the client before teardown (close() ends
|
|
378
|
+
// the socket and force-destroys via its own failsafe timer).
|
|
379
|
+
setImmediate(() => this.connection.close());
|
|
375
380
|
return false;
|
|
376
381
|
}
|
|
377
382
|
return true;
|
|
@@ -14,6 +14,12 @@ const packageInfo = require('../../../../package');
|
|
|
14
14
|
|
|
15
15
|
const SOCKET_TIMEOUT = 5 * 60 * 1000;
|
|
16
16
|
|
|
17
|
+
// Idle timeout applied to a proxied connection after auth handoff. Generous enough
|
|
18
|
+
// to not interrupt IMAP IDLE (which can sit idle for ~29 minutes) while still
|
|
19
|
+
// preventing a connection from being held open forever. Overridable via the
|
|
20
|
+
// proxyTimeout server option; 0 disables it (legacy behaviour).
|
|
21
|
+
const PROXY_SOCKET_TIMEOUT = 30 * 60 * 1000;
|
|
22
|
+
|
|
17
23
|
/**
|
|
18
24
|
* Creates a handler for new socket
|
|
19
25
|
*
|
|
@@ -52,11 +58,15 @@ class IMAPConnection extends EventEmitter {
|
|
|
52
58
|
this._upgrading = false;
|
|
53
59
|
|
|
54
60
|
// Parser instance for the incoming stream
|
|
55
|
-
this._parser = new IMAPStream();
|
|
61
|
+
this._parser = new IMAPStream({ maxLineLength: this._server.options.maxLineLength });
|
|
56
62
|
|
|
57
63
|
// Set handler for incoming commands
|
|
58
64
|
this._parser.oncommand = this._onCommand.bind(this);
|
|
59
65
|
|
|
66
|
+
// Close the connection if the parser rejects the input (e.g. an over-long
|
|
67
|
+
// command line) instead of letting the error reach the global handler.
|
|
68
|
+
this._parser.on('error', err => this._onParserError(err));
|
|
69
|
+
|
|
60
70
|
// Manage multi part command
|
|
61
71
|
this._currentCommand = false;
|
|
62
72
|
|
|
@@ -194,8 +204,12 @@ class IMAPConnection extends EventEmitter {
|
|
|
194
204
|
}
|
|
195
205
|
|
|
196
206
|
unbind() {
|
|
207
|
+
// Cancel any armed graceful-close failsafe so it cannot destroy the socket after we
|
|
208
|
+
// have handed it off to the proxy backend.
|
|
209
|
+
clearTimeout(this._closingTimeout);
|
|
210
|
+
|
|
197
211
|
this.writeStream.unpipe(this._socket);
|
|
198
|
-
this.
|
|
212
|
+
this._stopReading();
|
|
199
213
|
|
|
200
214
|
if (this._onCloseHandler) {
|
|
201
215
|
this._socket.removeListener('close', this._onCloseHandler);
|
|
@@ -206,10 +220,38 @@ class IMAPConnection extends EventEmitter {
|
|
|
206
220
|
if (this._onErrorHandler) {
|
|
207
221
|
this._socket.removeListener('error', this._onErrorHandler);
|
|
208
222
|
}
|
|
223
|
+
// Drop the auth-phase protocol timeout handler - after handoff it must not
|
|
224
|
+
// run, it would write IMAP responses into the proxied byte stream.
|
|
225
|
+
this._socket.setTimeout(0);
|
|
209
226
|
if (this._onTimeoutHandler) {
|
|
210
|
-
this._socket.
|
|
227
|
+
this._socket.removeListener('timeout', this._onTimeoutHandler);
|
|
211
228
|
}
|
|
212
229
|
|
|
230
|
+
// Re-arm a generous idle timeout so the proxied connection cannot be held
|
|
231
|
+
// open indefinitely (fd/connection exhaustion). Use a plain socket destroy
|
|
232
|
+
// rather than the protocol-level handler. proxyTimeout: 0 disables it.
|
|
233
|
+
let proxyTimeout = this._server.options.proxyTimeout;
|
|
234
|
+
if (proxyTimeout === undefined || proxyTimeout === null) {
|
|
235
|
+
proxyTimeout = PROXY_SOCKET_TIMEOUT;
|
|
236
|
+
}
|
|
237
|
+
if (proxyTimeout) {
|
|
238
|
+
this._socket.setTimeout(proxyTimeout);
|
|
239
|
+
this._socket.once('timeout', () => {
|
|
240
|
+
try {
|
|
241
|
+
this._socket.destroy();
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// ignore
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// After handoff to proxy mode the connection's own close/end handlers are gone,
|
|
249
|
+
// so _onClose can never run to remove this connection from the server set.
|
|
250
|
+
// Drop it from the set when the handed-off socket finally closes to avoid a leak.
|
|
251
|
+
this._socket.once('close', () => {
|
|
252
|
+
this._server.connections.delete(this);
|
|
253
|
+
});
|
|
254
|
+
|
|
213
255
|
return { socket: this._socket };
|
|
214
256
|
}
|
|
215
257
|
|
|
@@ -274,42 +316,53 @@ class IMAPConnection extends EventEmitter {
|
|
|
274
316
|
/**
|
|
275
317
|
* Close socket
|
|
276
318
|
*/
|
|
277
|
-
close(
|
|
319
|
+
close() {
|
|
278
320
|
if (this._closed || this._closing) {
|
|
279
321
|
return;
|
|
280
322
|
}
|
|
281
323
|
|
|
282
324
|
if (!this._socket.destroyed && this._socket.writable) {
|
|
283
|
-
this._socket
|
|
325
|
+
this._socket.end();
|
|
284
326
|
}
|
|
285
327
|
|
|
286
328
|
this._server.connections.delete(this);
|
|
287
329
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
this.
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
330
|
+
// allow socket to close gracefully in 1500ms or force it to close
|
|
331
|
+
this._closingTimeout = setTimeout(() => {
|
|
332
|
+
if (this._closed) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
294
335
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
336
|
+
try {
|
|
337
|
+
this._socket.destroy();
|
|
338
|
+
} catch (err) {
|
|
339
|
+
// ignore
|
|
340
|
+
}
|
|
300
341
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
342
|
+
setImmediate(() => this._onClose());
|
|
343
|
+
}, 1500);
|
|
304
344
|
|
|
305
345
|
this._closing = true;
|
|
306
|
-
if (force) {
|
|
307
|
-
setImmediate(() => this._onClose());
|
|
308
|
-
}
|
|
309
346
|
}
|
|
310
347
|
|
|
311
348
|
// PRIVATE METHODS
|
|
312
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Stop feeding the socket into the command parser. Called before teardown so a client
|
|
352
|
+
* cannot pipeline another command (e.g. LOGIN) after we have decided to close - which
|
|
353
|
+
* would otherwise be dispatched during the graceful-close window. Safe to call more than
|
|
354
|
+
* once; a no-op once the parser has been torn down in _onClose().
|
|
355
|
+
*/
|
|
356
|
+
_stopReading() {
|
|
357
|
+
try {
|
|
358
|
+
if (this._parser) {
|
|
359
|
+
this._socket.unpipe(this._parser);
|
|
360
|
+
}
|
|
361
|
+
} catch (err) {
|
|
362
|
+
// ignore
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
313
366
|
/**
|
|
314
367
|
* Setup socket event handlers
|
|
315
368
|
*/
|
|
@@ -327,6 +380,34 @@ class IMAPConnection extends EventEmitter {
|
|
|
327
380
|
this._socket.pipe(this._parser);
|
|
328
381
|
}
|
|
329
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Fired when the incoming-stream parser rejects the input (e.g. an over-long
|
|
385
|
+
* command line). Stops feeding the parser, tells the client, and closes.
|
|
386
|
+
* @param {Error} err - Parser error
|
|
387
|
+
*/
|
|
388
|
+
_onParserError(err) {
|
|
389
|
+
this._stopReading();
|
|
390
|
+
|
|
391
|
+
this.logger.info(
|
|
392
|
+
{
|
|
393
|
+
tnx: 'parser',
|
|
394
|
+
cid: this.id
|
|
395
|
+
},
|
|
396
|
+
'Closing connection: %s',
|
|
397
|
+
err && err.message
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
this.send('* BYE ' + ((err && err.message) || 'Protocol error'));
|
|
402
|
+
} catch (E) {
|
|
403
|
+
// ignore
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Graceful close so the BYE is flushed before teardown; close() force-destroys via
|
|
407
|
+
// its own failsafe timer if the socket does not close on its own.
|
|
408
|
+
setImmediate(() => this.close());
|
|
409
|
+
}
|
|
410
|
+
|
|
330
411
|
/**
|
|
331
412
|
* Fired when the socket is closed
|
|
332
413
|
* @event
|
|
@@ -482,8 +563,12 @@ class IMAPConnection extends EventEmitter {
|
|
|
482
563
|
return;
|
|
483
564
|
}
|
|
484
565
|
|
|
566
|
+
// Stop reading first so a command pipelined right after the timeout is not dispatched
|
|
567
|
+
// during the graceful-close window. Graceful close still flushes the BYE before teardown
|
|
568
|
+
// (close() has a failsafe timer).
|
|
569
|
+
this._stopReading();
|
|
485
570
|
this.send('* BYE Idle timeout, closing connection');
|
|
486
|
-
setImmediate(() => this.close(
|
|
571
|
+
setImmediate(() => this.close());
|
|
487
572
|
}
|
|
488
573
|
|
|
489
574
|
/**
|
|
@@ -497,6 +582,11 @@ class IMAPConnection extends EventEmitter {
|
|
|
497
582
|
|
|
498
583
|
callback = callback || (() => false);
|
|
499
584
|
|
|
585
|
+
if (this._closing || this._closed) {
|
|
586
|
+
// connection is tearing down - do not dispatch further commands
|
|
587
|
+
return callback();
|
|
588
|
+
}
|
|
589
|
+
|
|
500
590
|
if (this._upgrading) {
|
|
501
591
|
// ignore any commands before TLS upgrade is finished
|
|
502
592
|
return callback();
|
|
@@ -12,6 +12,10 @@ const base32 = require('base32.js');
|
|
|
12
12
|
|
|
13
13
|
const CLOSE_TIMEOUT = 1 * 1000; // how much to wait until pending connections are terminated
|
|
14
14
|
|
|
15
|
+
// A PROXY protocol v1 header is at most ~107 bytes. Bound the buffer so a trusted
|
|
16
|
+
// proxy source that never sends a newline cannot grow memory without limit.
|
|
17
|
+
const MAX_PROXY_HEADER_SIZE = 256;
|
|
18
|
+
|
|
15
19
|
/**
|
|
16
20
|
* Creates a IMAP server instance.
|
|
17
21
|
*
|
|
@@ -258,7 +262,7 @@ class IMAPServer extends EventEmitter {
|
|
|
258
262
|
},
|
|
259
263
|
'PROXY from %s through %s (%s)',
|
|
260
264
|
params[1].trim().toLowerCase(),
|
|
261
|
-
params[2].trim().toLowerCase(),
|
|
265
|
+
(params[2] || '').trim().toLowerCase(),
|
|
262
266
|
JSON.stringify(params)
|
|
263
267
|
);
|
|
264
268
|
}
|
|
@@ -273,6 +277,26 @@ class IMAPServer extends EventEmitter {
|
|
|
273
277
|
}
|
|
274
278
|
chunks.push(chunk);
|
|
275
279
|
chunklen += chunk.length;
|
|
280
|
+
|
|
281
|
+
if (chunklen > MAX_PROXY_HEADER_SIZE) {
|
|
282
|
+
socket.removeListener('readable', socketReader);
|
|
283
|
+
try {
|
|
284
|
+
socket.end('* BAD Invalid PROXY header\r\n');
|
|
285
|
+
} catch (E) {
|
|
286
|
+
// ignore
|
|
287
|
+
}
|
|
288
|
+
// end() only half-closes the socket; arm a short timeout so the fd is
|
|
289
|
+
// released even if the peer never closes its side (fd exhaustion).
|
|
290
|
+
socket.setTimeout(CLOSE_TIMEOUT);
|
|
291
|
+
socket.once('timeout', () => {
|
|
292
|
+
try {
|
|
293
|
+
socket.destroy();
|
|
294
|
+
} catch (E) {
|
|
295
|
+
// ignore
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
276
300
|
}
|
|
277
301
|
};
|
|
278
302
|
socket.on('readable', socketReader);
|
|
@@ -4,6 +4,11 @@ const stream = require('stream');
|
|
|
4
4
|
const Writable = stream.Writable;
|
|
5
5
|
const PassThrough = stream.PassThrough;
|
|
6
6
|
|
|
7
|
+
// Upper bound for a single command line (no CRLF yet). Generous - real IMAP command
|
|
8
|
+
// lines are short and bulk data is sent as size-bounded literals - but it prevents a
|
|
9
|
+
// client that never sends a newline from growing memory without limit (OOM).
|
|
10
|
+
const MAX_LINE_LENGTH = 1 * 1024 * 1024;
|
|
11
|
+
|
|
7
12
|
/**
|
|
8
13
|
* Incoming IMAP stream parser. Detects and emits command payloads.
|
|
9
14
|
* If literal values are encountered the command payload is split into parts
|
|
@@ -21,6 +26,9 @@ class IMAPStream extends Writable {
|
|
|
21
26
|
this.options = options || {};
|
|
22
27
|
Writable.call(this, this.options);
|
|
23
28
|
|
|
29
|
+
// largest single command line we will buffer before giving up
|
|
30
|
+
this._maxLineLength = Number(this.options.maxLineLength) || MAX_LINE_LENGTH;
|
|
31
|
+
|
|
24
32
|
// unprocessed chars from the last parsing iteration
|
|
25
33
|
this._remainder = '';
|
|
26
34
|
this._literal = false;
|
|
@@ -40,6 +48,18 @@ class IMAPStream extends Writable {
|
|
|
40
48
|
throw new Error('Command handler is not set');
|
|
41
49
|
}
|
|
42
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Discards any buffered or partial parser state. Used on TLS upgrade so that
|
|
53
|
+
* plaintext pipelined before STARTTLS cannot be parsed as commands inside the
|
|
54
|
+
* encrypted session (STARTTLS command injection).
|
|
55
|
+
*/
|
|
56
|
+
reset() {
|
|
57
|
+
this._remainder = '';
|
|
58
|
+
this._literal = false;
|
|
59
|
+
this._literalReady = false;
|
|
60
|
+
this._expecting = 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
// PRIVATE METHODS
|
|
44
64
|
|
|
45
65
|
/**
|
|
@@ -107,6 +127,12 @@ class IMAPStream extends Writable {
|
|
|
107
127
|
pos += line.length + match[0].length;
|
|
108
128
|
} else {
|
|
109
129
|
this._remainder = pos < data.length ? data.substr(pos) : '';
|
|
130
|
+
if (this._remainder.length > this._maxLineLength) {
|
|
131
|
+
// Drop the buffered data and fail the stream - the connection layer
|
|
132
|
+
// closes the socket on a parser error.
|
|
133
|
+
this._remainder = '';
|
|
134
|
+
return done(new Error('Command line too long'));
|
|
135
|
+
}
|
|
110
136
|
return done();
|
|
111
137
|
}
|
|
112
138
|
|
|
@@ -71,6 +71,30 @@ async function call(message, transferList) {
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// Resolve pending call() promises when the main thread answers. Without this any
|
|
75
|
+
// RPC issued from this worker (via the `call` passed into Account) would never settle
|
|
76
|
+
// and would reject on timeout. Mirrors the handlers in workers/imap.js and smtp.js.
|
|
77
|
+
parentPort.on('message', message => {
|
|
78
|
+
if (message && message.cmd === 'resp' && message.mid && callQueue.has(message.mid)) {
|
|
79
|
+
let { resolve, reject, timer } = callQueue.get(message.mid);
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
callQueue.delete(message.mid);
|
|
82
|
+
|
|
83
|
+
if (message.error) {
|
|
84
|
+
let err = new Error(message.error);
|
|
85
|
+
if (message.code) {
|
|
86
|
+
err.code = message.code;
|
|
87
|
+
}
|
|
88
|
+
if (message.statusCode) {
|
|
89
|
+
err.statusCode = message.statusCode;
|
|
90
|
+
}
|
|
91
|
+
return reject(err);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return resolve(message.response);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
74
98
|
async function metrics(logger, key, method, ...args) {
|
|
75
99
|
try {
|
|
76
100
|
parentPort.postMessage({
|
|
@@ -86,7 +110,7 @@ async function metrics(logger, key, method, ...args) {
|
|
|
86
110
|
|
|
87
111
|
const { ImapFlow } = require('imapflow');
|
|
88
112
|
const { IMAPServer, imapHandler } = require('./imap-core/index.js');
|
|
89
|
-
const { PassThrough } = require('
|
|
113
|
+
const { PassThrough } = require('stream');
|
|
90
114
|
|
|
91
115
|
const packageInfo = require('../../package.json');
|
|
92
116
|
const util = require('util');
|
|
@@ -113,7 +137,7 @@ class PassThroughLogger extends PassThrough {
|
|
|
113
137
|
data: chunk.toString('base64'),
|
|
114
138
|
compress: !!this.imapClient._deflate,
|
|
115
139
|
secure: !!this.imapClient.secureConnection,
|
|
116
|
-
cid: this.
|
|
140
|
+
cid: this.cid
|
|
117
141
|
});
|
|
118
142
|
}
|
|
119
143
|
|
|
@@ -361,7 +385,7 @@ const createServer = function (options = {}) {
|
|
|
361
385
|
message = util.format(message, ...args);
|
|
362
386
|
}
|
|
363
387
|
data.msg = message;
|
|
364
|
-
if (typeof
|
|
388
|
+
if (typeof serverLogger[level] === 'function') {
|
|
365
389
|
serverLogger[level](data);
|
|
366
390
|
} else {
|
|
367
391
|
serverLogger.debug(data);
|
|
@@ -408,39 +432,78 @@ const createServer = function (options = {}) {
|
|
|
408
432
|
logger: proxyLogger.child({ src: 'C' })
|
|
409
433
|
});
|
|
410
434
|
|
|
435
|
+
// Idempotent teardown for both legs of the proxy. ImapFlow.close() is
|
|
436
|
+
// safe after unbind(): it sends no LOGOUT, clears timers (including
|
|
437
|
+
// autoidle), removes the deflate/writeSocket error forwarders and
|
|
438
|
+
// destroys the upstream socket. Without it the upstream connection and
|
|
439
|
+
// its idle timer would leak (most visibly with COMPRESS enabled).
|
|
440
|
+
let proxyClosed = false;
|
|
441
|
+
const closeProxy = () => {
|
|
442
|
+
if (proxyClosed) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
proxyClosed = true;
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
downstream.imapClient.close();
|
|
449
|
+
} catch (err) {
|
|
450
|
+
proxyLogger.error({ msg: 'Failed to close upstream connection', err });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
if (upstream.socket && !upstream.socket.destroyed) {
|
|
455
|
+
upstream.socket.end();
|
|
456
|
+
}
|
|
457
|
+
} catch (err) {
|
|
458
|
+
// ignore
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// Every terminal event from either leg funnels into the idempotent
|
|
463
|
+
// closeProxy(). The helper keeps that wiring declarative; pass a message
|
|
464
|
+
// to log (level defaults to 'error', use 'info' for graceful closes).
|
|
465
|
+
const teardownOn = (emitter, event, msg, level = 'error') => {
|
|
466
|
+
emitter.on(event, err => {
|
|
467
|
+
if (msg) {
|
|
468
|
+
let entry = { msg };
|
|
469
|
+
if (err) {
|
|
470
|
+
entry.err = err;
|
|
471
|
+
}
|
|
472
|
+
proxyLogger[level](entry);
|
|
473
|
+
}
|
|
474
|
+
closeProxy();
|
|
475
|
+
});
|
|
476
|
+
};
|
|
477
|
+
|
|
411
478
|
downstream.readSocket.pipe(upstreamLogger).pipe(upstream.socket);
|
|
412
479
|
upstream.socket.pipe(downstreamLogger).pipe(downstream.writeSocket);
|
|
413
480
|
|
|
414
|
-
upstreamLogger
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
proxyLogger.error({ msg: 'Server error', err });
|
|
421
|
-
downstream.writeSocket.end();
|
|
422
|
-
downstream.readSocket.end();
|
|
423
|
-
});
|
|
481
|
+
teardownOn(upstreamLogger, 'error', 'Proxy stream error (to client)');
|
|
482
|
+
teardownOn(downstreamLogger, 'error', 'Proxy stream error (to upstream)');
|
|
483
|
+
teardownOn(upstream.socket, 'error', 'Client socket error');
|
|
484
|
+
teardownOn(upstream.socket, 'end', 'Client connection closed', 'info');
|
|
485
|
+
teardownOn(upstream.socket, 'close');
|
|
486
|
+
teardownOn(downstream.readSocket, 'end', 'Upstream connection closed', 'info');
|
|
424
487
|
|
|
425
488
|
downstream.readSocket.on('error', err => {
|
|
426
|
-
proxyLogger.error({ msg: '
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
489
|
+
proxyLogger.error({ msg: 'Upstream read error', err });
|
|
490
|
+
try {
|
|
491
|
+
// best-effort notice to the client before tearing down
|
|
492
|
+
upstream.socket.write('* BYE Upstream connection error\r\n');
|
|
493
|
+
} catch (e) {
|
|
494
|
+
// ignore
|
|
495
|
+
}
|
|
496
|
+
closeProxy();
|
|
433
497
|
});
|
|
434
498
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
});
|
|
499
|
+
// With COMPRESS enabled, readSocket/writeSocket are the inflate/deflate
|
|
500
|
+
// streams, not the raw upstream socket, and unbind() removed ImapFlow's
|
|
501
|
+
// own listeners from that socket. An upstream reset would then emit an
|
|
502
|
+
// 'error' with no listener and crash the worker - guard it explicitly.
|
|
503
|
+
if (downstream.imapClient.socket && downstream.imapClient.socket !== downstream.readSocket) {
|
|
504
|
+
teardownOn(downstream.imapClient.socket, 'error', 'Upstream socket error');
|
|
505
|
+
teardownOn(downstream.imapClient.socket, 'close');
|
|
506
|
+
}
|
|
444
507
|
|
|
445
508
|
proxyLogger.info({ msg: 'Proxy mode enabled' });
|
|
446
509
|
};
|