imapflow 1.7.8 → 2.0.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/CHANGELOG.md +20 -0
- package/README.md +8 -2
- package/dist/cjs/charsets.d.ts +1 -0
- package/dist/cjs/charsets.js +294 -0
- package/dist/cjs/commands/append.d.ts +22 -0
- package/dist/cjs/commands/append.js +151 -0
- package/dist/cjs/commands/authenticate.d.ts +24 -0
- package/dist/cjs/commands/authenticate.js +223 -0
- package/dist/cjs/commands/capability.d.ts +8 -0
- package/dist/cjs/commands/capability.js +32 -0
- package/dist/cjs/commands/close.d.ts +8 -0
- package/dist/cjs/commands/close.js +39 -0
- package/dist/cjs/commands/compress.d.ts +8 -0
- package/dist/cjs/commands/compress.js +56 -0
- package/dist/cjs/commands/copy.d.ts +13 -0
- package/dist/cjs/commands/copy.js +44 -0
- package/dist/cjs/commands/copyuid-parser.d.ts +11 -0
- package/dist/cjs/commands/copyuid-parser.js +32 -0
- package/dist/cjs/commands/create.d.ts +11 -0
- package/dist/cjs/commands/create.js +80 -0
- package/dist/cjs/commands/delete.d.ts +11 -0
- package/dist/cjs/commands/delete.js +40 -0
- package/dist/cjs/commands/enable.d.ts +9 -0
- package/dist/cjs/commands/enable.js +61 -0
- package/dist/cjs/commands/esearch-parser.d.ts +17 -0
- package/dist/cjs/commands/esearch-parser.js +91 -0
- package/dist/cjs/commands/expunge.d.ts +12 -0
- package/dist/cjs/commands/expunge.js +60 -0
- package/dist/cjs/commands/fetch.d.ts +30 -0
- package/dist/cjs/commands/fetch.js +241 -0
- package/dist/cjs/commands/id.d.ts +10 -0
- package/dist/cjs/commands/id.js +80 -0
- package/dist/cjs/commands/idle.d.ts +9 -0
- package/dist/cjs/commands/idle.js +347 -0
- package/dist/cjs/commands/list.d.ts +16 -0
- package/dist/cjs/commands/list.js +518 -0
- package/dist/cjs/commands/login.d.ts +11 -0
- package/dist/cjs/commands/login.js +42 -0
- package/dist/cjs/commands/logout.d.ts +8 -0
- package/dist/cjs/commands/logout.js +47 -0
- package/dist/cjs/commands/move.d.ts +13 -0
- package/dist/cjs/commands/move.js +57 -0
- package/dist/cjs/commands/namespace.d.ts +25 -0
- package/dist/cjs/commands/namespace.js +139 -0
- package/dist/cjs/commands/noop.d.ts +8 -0
- package/dist/cjs/commands/noop.js +22 -0
- package/dist/cjs/commands/quota.d.ts +10 -0
- package/dist/cjs/commands/quota.js +119 -0
- package/dist/cjs/commands/rename.d.ts +12 -0
- package/dist/cjs/commands/rename.js +48 -0
- package/dist/cjs/commands/search.d.ts +15 -0
- package/dist/cjs/commands/search.js +228 -0
- package/dist/cjs/commands/select.d.ts +25 -0
- package/dist/cjs/commands/select.js +250 -0
- package/dist/cjs/commands/starttls.d.ts +8 -0
- package/dist/cjs/commands/starttls.js +30 -0
- package/dist/cjs/commands/status-fields.d.ts +14 -0
- package/dist/cjs/commands/status-fields.js +61 -0
- package/dist/cjs/commands/status.d.ts +12 -0
- package/dist/cjs/commands/status.js +108 -0
- package/dist/cjs/commands/store.d.ts +19 -0
- package/dist/cjs/commands/store.js +93 -0
- package/dist/cjs/commands/subscribe.d.ts +9 -0
- package/dist/cjs/commands/subscribe.js +31 -0
- package/dist/cjs/commands/unsubscribe.d.ts +9 -0
- package/dist/cjs/commands/unsubscribe.js +31 -0
- package/dist/cjs/connection-deadline.d.ts +49 -0
- package/dist/cjs/connection-deadline.js +91 -0
- package/dist/cjs/errors.d.ts +83 -0
- package/dist/cjs/errors.js +13 -0
- package/dist/cjs/handler/imap-compiler.d.ts +24 -0
- package/dist/cjs/handler/imap-compiler.js +285 -0
- package/dist/cjs/handler/imap-formal-syntax.d.ts +28 -0
- package/dist/cjs/handler/imap-formal-syntax.js +121 -0
- package/dist/cjs/handler/imap-handler.d.ts +9 -0
- package/dist/cjs/handler/imap-handler.js +10 -0
- package/dist/cjs/handler/imap-parser.d.ts +16 -0
- package/dist/cjs/handler/imap-parser.js +90 -0
- package/dist/cjs/handler/imap-stream.d.ts +181 -0
- package/dist/cjs/handler/imap-stream.js +446 -0
- package/dist/cjs/handler/limits.d.ts +25 -0
- package/dist/cjs/handler/limits.js +51 -0
- package/dist/cjs/handler/parser-instance.d.ts +68 -0
- package/dist/cjs/handler/parser-instance.js +223 -0
- package/dist/cjs/handler/token-parser.d.ts +91 -0
- package/dist/cjs/handler/token-parser.js +673 -0
- package/dist/cjs/handler/types.d.ts +91 -0
- package/dist/cjs/handler/types.js +4 -0
- package/dist/cjs/imap-commands.d.ts +16 -0
- package/dist/cjs/imap-commands.js +74 -0
- package/dist/cjs/imap-flow.d.ts +676 -0
- package/dist/cjs/imap-flow.js +3949 -0
- package/dist/cjs/jp-decoder.d.ts +12 -0
- package/dist/cjs/jp-decoder.js +79 -0
- package/dist/cjs/limited-passthrough.d.ts +25 -0
- package/dist/cjs/limited-passthrough.js +54 -0
- package/dist/cjs/logger.d.ts +3 -0
- package/dist/cjs/logger.js +11 -0
- package/dist/cjs/package-info.d.ts +3 -0
- package/dist/cjs/package-info.js +7 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/proxy-connection.d.ts +33 -0
- package/dist/cjs/proxy-connection.js +392 -0
- package/dist/cjs/search-compiler.d.ts +34 -0
- package/dist/cjs/search-compiler.js +476 -0
- package/dist/cjs/special-use.d.ts +22 -0
- package/dist/cjs/special-use.js +911 -0
- package/dist/cjs/tools.d.ts +427 -0
- package/dist/cjs/tools.js +1496 -0
- package/{lib/imap-flow.d.ts → dist/cjs/types.d.ts} +386 -516
- package/dist/cjs/types.js +5 -0
- package/dist/esm/charsets.d.ts +1 -0
- package/{lib → dist/esm}/charsets.js +1 -6
- package/dist/esm/commands/append.d.ts +22 -0
- package/{lib → dist/esm}/commands/append.js +22 -52
- package/dist/esm/commands/authenticate.d.ts +24 -0
- package/{lib → dist/esm}/commands/authenticate.js +62 -87
- package/dist/esm/commands/capability.d.ts +8 -0
- package/{lib → dist/esm}/commands/capability.js +6 -9
- package/dist/esm/commands/close.d.ts +8 -0
- package/{lib → dist/esm}/commands/close.js +6 -10
- package/dist/esm/commands/compress.d.ts +8 -0
- package/{lib → dist/esm}/commands/compress.js +7 -11
- package/dist/esm/commands/copy.d.ts +13 -0
- package/{lib → dist/esm}/commands/copy.js +12 -20
- package/dist/esm/commands/copyuid-parser.d.ts +11 -0
- package/{lib → dist/esm}/commands/copyuid-parser.js +9 -15
- package/dist/esm/commands/create.d.ts +11 -0
- package/{lib → dist/esm}/commands/create.js +13 -27
- package/dist/esm/commands/delete.d.ts +11 -0
- package/{lib → dist/esm}/commands/delete.js +9 -14
- package/dist/esm/commands/enable.d.ts +9 -0
- package/{lib → dist/esm}/commands/enable.js +23 -30
- package/dist/esm/commands/esearch-parser.d.ts +17 -0
- package/dist/esm/commands/esearch-parser.js +88 -0
- package/dist/esm/commands/expunge.d.ts +12 -0
- package/{lib → dist/esm}/commands/expunge.js +17 -22
- package/dist/esm/commands/fetch.d.ts +30 -0
- package/{lib → dist/esm}/commands/fetch.js +32 -64
- package/dist/esm/commands/id.d.ts +10 -0
- package/{lib → dist/esm}/commands/id.js +17 -23
- package/dist/esm/commands/idle.d.ts +9 -0
- package/{lib → dist/esm}/commands/idle.js +47 -81
- package/dist/esm/commands/list.d.ts +16 -0
- package/{lib → dist/esm}/commands/list.js +56 -121
- package/dist/esm/commands/login.d.ts +11 -0
- package/{lib → dist/esm}/commands/login.js +10 -15
- package/dist/esm/commands/logout.d.ts +8 -0
- package/{lib → dist/esm}/commands/logout.js +9 -11
- package/dist/esm/commands/move.d.ts +13 -0
- package/{lib → dist/esm}/commands/move.js +13 -21
- package/dist/esm/commands/namespace.d.ts +25 -0
- package/{lib → dist/esm}/commands/namespace.js +34 -44
- package/dist/esm/commands/noop.d.ts +8 -0
- package/{lib → dist/esm}/commands/noop.js +6 -7
- package/dist/esm/commands/quota.d.ts +10 -0
- package/{lib → dist/esm}/commands/quota.js +18 -36
- package/dist/esm/commands/rename.d.ts +12 -0
- package/{lib → dist/esm}/commands/rename.js +10 -15
- package/dist/esm/commands/search.d.ts +15 -0
- package/{lib → dist/esm}/commands/search.js +36 -135
- package/dist/esm/commands/select.d.ts +25 -0
- package/{lib → dist/esm}/commands/select.js +33 -64
- package/dist/esm/commands/starttls.d.ts +8 -0
- package/{lib → dist/esm}/commands/starttls.js +6 -8
- package/dist/esm/commands/status-fields.d.ts +14 -0
- package/{lib → dist/esm}/commands/status-fields.js +5 -16
- package/dist/esm/commands/status.d.ts +12 -0
- package/{lib → dist/esm}/commands/status.js +18 -29
- package/dist/esm/commands/store.d.ts +19 -0
- package/{lib → dist/esm}/commands/store.js +24 -37
- package/dist/esm/commands/subscribe.d.ts +9 -0
- package/{lib → dist/esm}/commands/subscribe.js +8 -12
- package/dist/esm/commands/unsubscribe.d.ts +9 -0
- package/{lib → dist/esm}/commands/unsubscribe.js +8 -12
- package/dist/esm/connection-deadline.d.ts +49 -0
- package/{lib → dist/esm}/connection-deadline.js +14 -25
- package/dist/esm/errors.d.ts +83 -0
- package/dist/esm/errors.js +9 -0
- package/dist/esm/handler/imap-compiler.d.ts +24 -0
- package/{lib → dist/esm}/handler/imap-compiler.js +22 -80
- package/dist/esm/handler/imap-formal-syntax.d.ts +28 -0
- package/dist/esm/handler/imap-formal-syntax.js +117 -0
- package/dist/esm/handler/imap-handler.d.ts +9 -0
- package/dist/esm/handler/imap-handler.js +9 -0
- package/dist/esm/handler/imap-parser.d.ts +16 -0
- package/{lib → dist/esm}/handler/imap-parser.js +31 -44
- package/dist/esm/handler/imap-stream.d.ts +181 -0
- package/{lib → dist/esm}/handler/imap-stream.js +29 -121
- package/dist/esm/handler/limits.d.ts +25 -0
- package/{lib → dist/esm}/handler/limits.js +13 -22
- package/dist/esm/handler/parser-instance.d.ts +68 -0
- package/{lib → dist/esm}/handler/parser-instance.js +19 -47
- package/dist/esm/handler/token-parser.d.ts +91 -0
- package/{lib → dist/esm}/handler/token-parser.js +71 -155
- package/dist/esm/handler/types.d.ts +91 -0
- package/dist/esm/handler/types.js +3 -0
- package/dist/esm/imap-commands.d.ts +16 -0
- package/dist/esm/imap-commands.js +67 -0
- package/dist/esm/imap-flow.d.ts +676 -0
- package/{lib → dist/esm}/imap-flow.js +761 -1789
- package/dist/esm/jp-decoder.d.ts +12 -0
- package/{lib → dist/esm}/jp-decoder.js +6 -21
- package/dist/esm/limited-passthrough.d.ts +25 -0
- package/{lib → dist/esm}/limited-passthrough.js +7 -20
- package/dist/esm/logger.d.ts +3 -0
- package/dist/esm/logger.js +4 -0
- package/dist/esm/package-info.d.ts +3 -0
- package/dist/esm/package-info.js +4 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/proxy-connection.d.ts +33 -0
- package/{lib → dist/esm}/proxy-connection.js +56 -127
- package/dist/esm/search-compiler.d.ts +34 -0
- package/{lib → dist/esm}/search-compiler.js +54 -110
- package/dist/esm/special-use.d.ts +22 -0
- package/dist/esm/special-use.js +907 -0
- package/dist/esm/tools.d.ts +427 -0
- package/dist/esm/tools.js +1446 -0
- package/dist/esm/types.d.ts +828 -0
- package/dist/esm/types.js +4 -0
- package/package.json +60 -20
- package/.gitattributes +0 -1
- package/.github/CODE_OF_CONDUCT.md +0 -76
- package/.github/FUNDING.yml +0 -4
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -40
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
- package/.github/contributing.md +0 -17
- package/.github/workflows/release.yaml +0 -36
- package/.github/workflows/stale.yml +0 -29
- package/.github/workflows/test.yml +0 -51
- package/.ncurc.js +0 -4
- package/.prettierignore +0 -4
- package/.prettierrc.js +0 -8
- package/.release-please-manifest.json +0 -3
- package/CLAUDE.md +0 -104
- package/Gruntfile.js +0 -23
- package/eslint.config.js +0 -45
- package/lib/handler/imap-formal-syntax.js +0 -189
- package/lib/handler/imap-handler.js +0 -17
- package/lib/imap-commands.js +0 -45
- package/lib/logger.js +0 -5
- package/lib/special-use.js +0 -923
- package/lib/tools.js +0 -1612
- package/release-please-config.json +0 -10
- package/test/authentication-test.js +0 -101
- package/test/auto-idle-test.js +0 -470
- package/test/bodystructure-test.js +0 -899
- package/test/charsets-test.js +0 -161
- package/test/commands-branches-test.js +0 -1095
- package/test/commands-integration-test.js +0 -11124
- package/test/commands-test.js +0 -73
- package/test/connection-edge-cases-test.js +0 -1828
- package/test/connection-test.js +0 -162
- package/test/copyuid-parser-test.js +0 -173
- package/test/fetch-generator-test.js +0 -218
- package/test/fixtures/fake-timers.js +0 -115
- package/test/fixtures/serialized-mimetorture.js +0 -2738
- package/test/fixtures/test-client.js +0 -101
- package/test/fixtures/test-tls.js +0 -8
- package/test/handler-branches-test.js +0 -310
- package/test/idle-polling-test.js +0 -518
- package/test/imap-compiler-test.js +0 -809
- package/test/imap-flow-compress-test.js +0 -166
- package/test/imap-flow-coverage-test.js +0 -612
- package/test/imap-flow-fetch-download-test.js +0 -909
- package/test/imap-flow-internals-test.js +0 -725
- package/test/imap-flow-methods-test.js +0 -889
- package/test/imap-flow-proxy-paths-test.js +0 -366
- package/test/imap-flow-secure-test.js +0 -573
- package/test/imap-flow-server-test.js +0 -1474
- package/test/imap-formal-syntax-test.js +0 -293
- package/test/imap-parser-test.js +0 -1474
- package/test/imap-stream-edge-cases-test.js +0 -666
- package/test/imap-stream-test.js +0 -177
- package/test/imapflow-test.js +0 -258
- package/test/integration/README.md +0 -52
- package/test/integration/dovecot-test.conf +0 -27
- package/test/integration/rev2-live-test.js +0 -431
- package/test/integration/run-rev2-tests.sh +0 -75
- package/test/integration-test.js +0 -83
- package/test/jp-decoder-test.js +0 -304
- package/test/limited-passthrough-test.js +0 -299
- package/test/memory-cleanup-test.js +0 -144
- package/test/memory-leak-test.js +0 -667
- package/test/parser-limits-test.js +0 -292
- package/test/proxy-connection-test.js +0 -738
- package/test/reliability-improvements-test.js +0 -548
- package/test/search-compiler-test.js +0 -1300
- package/test/search-test.js +0 -329
- package/test/special-use-test.js +0 -418
- package/test/starttls-injection-test.js +0 -181
- package/test/tag-correlation-test.js +0 -333
- package/test/timer-policy-test.js +0 -227
- package/test/token-parser-test.js +0 -456
- package/test/tools-test.js +0 -2013
- package/test/unhandled-rejection-test.js +0 -661
|
@@ -0,0 +1,3949 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module imapflow
|
|
4
|
+
*/
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
38
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
39
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
40
|
+
};
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.ImapFlow = exports.AuthenticationFailure = void 0;
|
|
43
|
+
const node_tls_1 = __importDefault(require("node:tls"));
|
|
44
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
45
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
46
|
+
const node_zlib_1 = __importDefault(require("node:zlib"));
|
|
47
|
+
const node_events_1 = require("node:events");
|
|
48
|
+
const node_stream_1 = require("node:stream");
|
|
49
|
+
const libmime_1 = __importDefault(require("libmime"));
|
|
50
|
+
const libqp_1 = __importDefault(require("libqp"));
|
|
51
|
+
const libbase64_1 = __importDefault(require("libbase64"));
|
|
52
|
+
const mailsplit_1 = require("@zone-eu/mailsplit");
|
|
53
|
+
const flowed_decoder_js_1 = __importDefault(require("@zone-eu/mailsplit/lib/flowed-decoder.js"));
|
|
54
|
+
const logger_js_1 = __importDefault(require("./logger.js"));
|
|
55
|
+
const packageInfo = __importStar(require("./package-info.js"));
|
|
56
|
+
const limited_passthrough_js_1 = require("./limited-passthrough.js");
|
|
57
|
+
const imap_stream_js_1 = require("./handler/imap-stream.js");
|
|
58
|
+
const imap_handler_js_1 = require("./handler/imap-handler.js");
|
|
59
|
+
const proxy_connection_js_1 = require("./proxy-connection.js");
|
|
60
|
+
const connection_deadline_js_1 = require("./connection-deadline.js");
|
|
61
|
+
const errors_js_1 = require("./errors.js");
|
|
62
|
+
const imap_commands_js_1 = __importDefault(require("./imap-commands.js"));
|
|
63
|
+
const tools_js_1 = require("./tools.js");
|
|
64
|
+
var errors_js_2 = require("./errors.js");
|
|
65
|
+
Object.defineProperty(exports, "AuthenticationFailure", { enumerable: true, get: function () { return errors_js_2.AuthenticationFailure; } });
|
|
66
|
+
const GREETING_TIMEOUT = 16 * 1000;
|
|
67
|
+
const UPGRADE_TIMEOUT = 10 * 1000;
|
|
68
|
+
const SOCKET_TIMEOUT = 5 * 60 * 1000;
|
|
69
|
+
// Ceiling for any throttle back-off wait. Both the connection-level back-off and the per-command
|
|
70
|
+
// retries derive their delay from server-supplied hints, which are unbounded.
|
|
71
|
+
const MAX_THROTTLE_DELAY = 5 * 60 * 1000;
|
|
72
|
+
// Default threshold for warning that a mailbox lock has been held for a long
|
|
73
|
+
// time. Intended to catch forgotten release() calls, not legitimate long ops
|
|
74
|
+
// (e.g. fetching hundreds of thousands of messages). Configurable via the
|
|
75
|
+
// ImapFlow constructor option `maxLockHoldTime`. Set to 0 or false to disable.
|
|
76
|
+
const HELD_LOCK_WARN_MS = 30 * 60 * 1000;
|
|
77
|
+
// How long the connection has to stay inactive before auto-IDLE starts. Long enough that a caller
|
|
78
|
+
// running a sequence of commands is not interrupted by an IDLE it immediately has to break.
|
|
79
|
+
// Configurable via the ImapFlow constructor option `autoIdleDelay`.
|
|
80
|
+
const AUTO_IDLE_DELAY = 15 * 1000;
|
|
81
|
+
// Headroom kept between the auto-IDLE delay and the socket inactivity watchdog, so IDLE reaches
|
|
82
|
+
// the wire before the watchdog can fire. See normalizeAutoIdleDelay().
|
|
83
|
+
const AUTO_IDLE_SOCKET_MARGIN = 1000;
|
|
84
|
+
// Commands whose client frames carry credentials; the raw traffic log withholds frame content
|
|
85
|
+
// while one of these is in flight. See the logRaw branch in write().
|
|
86
|
+
const RAW_SENSITIVE_COMMANDS = new Set(['LOGIN', 'AUTHENTICATE']);
|
|
87
|
+
// Stand-in payload for a withheld raw client frame. Fixed width, so the entry says nothing
|
|
88
|
+
// about the length of what it replaced.
|
|
89
|
+
const RAW_HIDDEN_PLACEHOLDER = Buffer.from('(* value hidden *)\r\n').toString('base64');
|
|
90
|
+
// Whether any attribute of a command is marked as a secret. Recurses into nested lists because
|
|
91
|
+
// the command compiler honors `sensitive` at any depth, and the two must agree on what counts.
|
|
92
|
+
function hasSensitiveAttribute(attributes) {
|
|
93
|
+
return []
|
|
94
|
+
.concat(attributes || [])
|
|
95
|
+
.some(node => (Array.isArray(node) ? hasSensitiveAttribute(node) : !!node && typeof node === 'object' && !Buffer.isBuffer(node) && !!node.sensitive));
|
|
96
|
+
}
|
|
97
|
+
// How deep flattenLoggedError() follows a chain of errors. Bounded because the chain comes from
|
|
98
|
+
// whatever failed, not from this library: a cause chain can be arbitrarily long, and the cycle
|
|
99
|
+
// check below only catches errors that repeat.
|
|
100
|
+
const MAX_ERROR_FLATTEN_DEPTH = 4;
|
|
101
|
+
// Recognizes an Error without instanceof, which fails for an error that crossed a realm boundary
|
|
102
|
+
// (worker thread, vm context) even though it serializes exactly the same way.
|
|
103
|
+
function isErrorLike(value) {
|
|
104
|
+
return (value instanceof Error ||
|
|
105
|
+
(!!value && typeof value === 'object' && typeof value.message === 'string' && typeof value.stack === 'string'));
|
|
106
|
+
}
|
|
107
|
+
// An Error carries `message` and `stack` on its prototype rather than as own enumerable
|
|
108
|
+
// properties, so JSON.stringify() renders one as `{}` and both logger fallback paths (the console
|
|
109
|
+
// fallback and emitLogs) would drop everything identifying it. Flattening happens here for both,
|
|
110
|
+
// so their shapes cannot drift apart.
|
|
111
|
+
//
|
|
112
|
+
// Nested errors are flattened too, because the top level is often not where the answer is: this
|
|
113
|
+
// library attaches the underlying failure as an enumerable `_err` (proxy setup, response
|
|
114
|
+
// processing, normalized connection deadlines), and Node reports a multi-address connect failure
|
|
115
|
+
// as an AggregateError whose members hold the per-address causes.
|
|
116
|
+
function flattenLoggedError(value, depth = 0, seen = new Set()) {
|
|
117
|
+
if (depth >= MAX_ERROR_FLATTEN_DEPTH) {
|
|
118
|
+
return isErrorLike(value) ? value.message : value;
|
|
119
|
+
}
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
return value.map(entry => flattenLoggedError(entry, depth + 1, seen));
|
|
122
|
+
}
|
|
123
|
+
if (!isErrorLike(value)) {
|
|
124
|
+
// Anything else is left alone: exploding a Buffer would produce one key per byte, and a
|
|
125
|
+
// Date would become a pair of undefined fields.
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
// A repeat renders as its message alone, so a chain that loops back does not restate a full
|
|
129
|
+
// stack for every level down to the depth cap
|
|
130
|
+
if (seen.has(value)) {
|
|
131
|
+
return value.message;
|
|
132
|
+
}
|
|
133
|
+
seen.add(value);
|
|
134
|
+
let flatErr = {
|
|
135
|
+
message: value.message,
|
|
136
|
+
stack: value.stack
|
|
137
|
+
};
|
|
138
|
+
// `cause` (passed through the Error options argument) and the AggregateError members are own
|
|
139
|
+
// properties but not enumerable, so Object.keys does not list them
|
|
140
|
+
for (let key of new Set([...Object.keys(value), 'cause', 'errors'])) {
|
|
141
|
+
if (key in value) {
|
|
142
|
+
flatErr[key] = flattenLoggedError(value[key], depth + 1, seen);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return flatErr;
|
|
146
|
+
}
|
|
147
|
+
// The largest delay setTimeout can honor (2^31 - 1 ms). Anything above fires after 1 ms instead,
|
|
148
|
+
// so the auto-IDLE delay cap has to stay inside this range even when socketTimeout is not.
|
|
149
|
+
const MAX_TIMER_DELAY = 2 ** 31 - 1;
|
|
150
|
+
const stateValues = {
|
|
151
|
+
NOT_AUTHENTICATED: 0x01,
|
|
152
|
+
AUTHENTICATED: 0x02,
|
|
153
|
+
SELECTED: 0x03,
|
|
154
|
+
LOGOUT: 0x04
|
|
155
|
+
};
|
|
156
|
+
const states = stateValues;
|
|
157
|
+
/**
|
|
158
|
+
* Normalizes the configured auto-IDLE delay into a value `setTimeout` can honor. Anything Node
|
|
159
|
+
* would silently turn into a 1ms timer - NaN, a negative number, a value above the 32-bit range -
|
|
160
|
+
* falls back to the default instead, because a 1ms delay means an IDLE/DONE round trip around
|
|
161
|
+
* every single command. The delay is also capped below `socketTimeout`, see AUTO_IDLE_SOCKET_MARGIN.
|
|
162
|
+
*
|
|
163
|
+
* @param value - The configured `autoIdleDelay` option.
|
|
164
|
+
* @param socketTimeout - The normalized socket inactivity timeout.
|
|
165
|
+
* @param log - Logger, used to report a value that could not be used as given.
|
|
166
|
+
* @param cid - Connection id for the log entry.
|
|
167
|
+
* @returns Delay in milliseconds.
|
|
168
|
+
*/
|
|
169
|
+
const normalizeAutoIdleDelay = (value, socketTimeout, log, cid) => {
|
|
170
|
+
const maxDelay = Math.max(0, Math.min(socketTimeout, MAX_TIMER_DELAY) - AUTO_IDLE_SOCKET_MARGIN);
|
|
171
|
+
const configured = value !== undefined && value !== null;
|
|
172
|
+
// Numeric strings are accepted, because configuration usually arrives from an environment
|
|
173
|
+
// variable or a JSON file. Booleans and blank strings are not: Number() would read them as 0,
|
|
174
|
+
// i.e. "IDLE around every command", the opposite of the "off" they suggest.
|
|
175
|
+
let delay = typeof value === 'number' || (typeof value === 'string' && value.trim()) ? Number(value) : NaN;
|
|
176
|
+
let reason = null;
|
|
177
|
+
if (!Number.isFinite(delay) || delay < 0) {
|
|
178
|
+
reason = 'not a non-negative finite number';
|
|
179
|
+
delay = AUTO_IDLE_DELAY;
|
|
180
|
+
}
|
|
181
|
+
if (delay > maxDelay) {
|
|
182
|
+
// An invalid value keeps its own reason: the cap then applies to the fallback default,
|
|
183
|
+
// not to anything the caller asked for.
|
|
184
|
+
reason = reason || `above socketTimeout (${socketTimeout} ms)`;
|
|
185
|
+
delay = maxDelay;
|
|
186
|
+
}
|
|
187
|
+
// Only an explicitly configured value is worth warning about. Capping the default because the
|
|
188
|
+
// caller picked a short socketTimeout is expected behavior, not a misconfiguration.
|
|
189
|
+
if (configured && reason) {
|
|
190
|
+
log.warn({ msg: 'Adjusted unusable autoIdleDelay option', requested: value, autoIdleDelay: delay, reason, cid });
|
|
191
|
+
}
|
|
192
|
+
return Math.floor(delay);
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* IMAP client class for accessing IMAP mailboxes
|
|
196
|
+
*/
|
|
197
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
|
198
|
+
class ImapFlow extends node_events_1.EventEmitter {
|
|
199
|
+
/**
|
|
200
|
+
* Current module version as a static class property
|
|
201
|
+
*/
|
|
202
|
+
static { this.version = packageInfo.version; }
|
|
203
|
+
constructor(options) {
|
|
204
|
+
super({ captureRejections: true });
|
|
205
|
+
this.options = options || {};
|
|
206
|
+
this.id = this.options.id || this.getRandomId();
|
|
207
|
+
this.clientInfo = Object.assign({
|
|
208
|
+
name: packageInfo.name,
|
|
209
|
+
version: packageInfo.version,
|
|
210
|
+
vendor: 'Postal Systems',
|
|
211
|
+
'support-url': 'https://github.com/postalsys/imapflow/issues'
|
|
212
|
+
}, this.options.clientInfo || {});
|
|
213
|
+
// remove diacritics
|
|
214
|
+
for (let key of Object.keys(this.clientInfo)) {
|
|
215
|
+
if (typeof this.clientInfo[key] === 'string') {
|
|
216
|
+
this.clientInfo[key] = this.clientInfo[key].normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
this.serverInfo = null; //updated by ID
|
|
220
|
+
this.log = this.getLogger();
|
|
221
|
+
this.secureConnection = !!this.options.secure;
|
|
222
|
+
// 993 is IMAPS, 143 is IMAP over cleartext/STARTTLS. The non-secure default used to be 110,
|
|
223
|
+
// which is POP3 - a client created without an explicit port could never connect.
|
|
224
|
+
this.port = Number(this.options.port) || (this.secureConnection ? 993 : 143);
|
|
225
|
+
this.host = this.options.host || 'localhost';
|
|
226
|
+
this.servername = this.options.servername ? this.options.servername : !node_net_1.default.isIP(this.host) ? this.host : false;
|
|
227
|
+
if (typeof this.options.secure === 'undefined' && this.port === 993) {
|
|
228
|
+
// if secure option is not set but port is 993, then default to secure
|
|
229
|
+
this.secureConnection = true;
|
|
230
|
+
}
|
|
231
|
+
// Normalized once so direct TLS, cleartext, proxied and STARTTLS-upgraded transports
|
|
232
|
+
// cannot end up with different inactivity watchdogs. As documented, 0 (and any other
|
|
233
|
+
// falsy or invalid value) means "use the default", not "disable".
|
|
234
|
+
this.socketTimeout = Number(this.options.socketTimeout) || SOCKET_TIMEOUT;
|
|
235
|
+
this.logRaw = this.options.logRaw;
|
|
236
|
+
this.streamer = new imap_stream_js_1.ImapStream({
|
|
237
|
+
logger: this.log,
|
|
238
|
+
cid: this.id,
|
|
239
|
+
logRaw: this.logRaw,
|
|
240
|
+
secureConnection: this.secureConnection,
|
|
241
|
+
maxLineLength: this.options.maxLineLength,
|
|
242
|
+
maxLiteralSize: this.options.maxLiteralSize,
|
|
243
|
+
maxResponseSize: this.options.maxResponseSize
|
|
244
|
+
});
|
|
245
|
+
this.reading = false;
|
|
246
|
+
this.socket = false;
|
|
247
|
+
this.writeSocket = false;
|
|
248
|
+
this._throttleWaits = new Set();
|
|
249
|
+
this._upgradeReject = null;
|
|
250
|
+
this.isClosed = false;
|
|
251
|
+
this.states = states;
|
|
252
|
+
this.state = this.states.NOT_AUTHENTICATED;
|
|
253
|
+
this.lockCounter = 0;
|
|
254
|
+
this.tagCounter = 0;
|
|
255
|
+
this.requestTagMap = new Map();
|
|
256
|
+
this.requestQueue = [];
|
|
257
|
+
this.currentRequest = false;
|
|
258
|
+
this._unknownTagCount = 0;
|
|
259
|
+
this._nextUnknownTagWarn = 1;
|
|
260
|
+
this.writeBytesCounter = 0;
|
|
261
|
+
this.commandParts = [];
|
|
262
|
+
this.rawSensitiveCommand = true;
|
|
263
|
+
this.capabilities = new Map();
|
|
264
|
+
this.authCapabilities = new Map();
|
|
265
|
+
this.rawCapabilities = null;
|
|
266
|
+
this.expectCapabilityUpdate = false; // force CAPABILITY after LOGIN
|
|
267
|
+
this._starttlsHadTrailingData = false;
|
|
268
|
+
this.enabled = new Set();
|
|
269
|
+
this.usable = false;
|
|
270
|
+
this.authenticated = false;
|
|
271
|
+
this.mailbox = false;
|
|
272
|
+
this.currentSelectCommand = false;
|
|
273
|
+
this.idling = false;
|
|
274
|
+
this.emitLogs = !!this.options.emitLogs;
|
|
275
|
+
this.lo = 0;
|
|
276
|
+
this.untaggedHandlers = {};
|
|
277
|
+
this.sectionHandlers = {};
|
|
278
|
+
this.commands = imap_commands_js_1.default;
|
|
279
|
+
this.folders = new Map();
|
|
280
|
+
this.currentLock = false;
|
|
281
|
+
this.locks = [];
|
|
282
|
+
this.idRequested = false;
|
|
283
|
+
this.maxIdleTime = this.options.maxIdleTime || false;
|
|
284
|
+
this.autoIdleDelay = normalizeAutoIdleDelay(this.options.autoIdleDelay, this.socketTimeout, this.log, this.id);
|
|
285
|
+
this._lastPollAt = 0;
|
|
286
|
+
this._openDownloads = 0;
|
|
287
|
+
this.missingIdleCommand = (this.options.missingIdleCommand || '').toString().toUpperCase().trim() || 'NOOP';
|
|
288
|
+
this.disableBinary = !!this.options.disableBinary;
|
|
289
|
+
this.skipListSubscribedArg = false;
|
|
290
|
+
this.skipListStatusArgs = false;
|
|
291
|
+
this.skipListAuxArgs = false;
|
|
292
|
+
this.skipLsub = false;
|
|
293
|
+
// Named error handler for proper cleanup. Certain error codes represent
|
|
294
|
+
// expected socket/network issues (buffer exhaustion, connection reset, broken pipe,
|
|
295
|
+
// timeout, unreachable host) that just need a silent connection close rather
|
|
296
|
+
// than emitting an error event to the caller.
|
|
297
|
+
this._streamerErrorHandler = (err) => {
|
|
298
|
+
if (['Z_BUF_ERROR', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'EHOSTUNREACH'].includes(err.code)) {
|
|
299
|
+
this.closeAfter();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
this.log.error({ err, cid: this.id });
|
|
303
|
+
this.emitError(err);
|
|
304
|
+
};
|
|
305
|
+
this.streamer.on('error', this._streamerErrorHandler);
|
|
306
|
+
this._connectCalled = false;
|
|
307
|
+
}
|
|
308
|
+
/** @internal */
|
|
309
|
+
emitError(err) {
|
|
310
|
+
if (!err) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
err._connId = err._connId || this.id;
|
|
314
|
+
// During a STARTTLS handshake the upgrade owns the single error path (its settle()
|
|
315
|
+
// helper). Route the error there so a streamer-originated failure is surfaced with its
|
|
316
|
+
// real code (instead of a generic ClosedAfterConnect*) and cannot hang a verifyOnly
|
|
317
|
+
// connect() waiting on a 'close' that never rejects. Fall back to closing if the upgrade
|
|
318
|
+
// has no pending rejector.
|
|
319
|
+
if (this.upgrading) {
|
|
320
|
+
let reject = this._upgradeReject;
|
|
321
|
+
this._upgradeReject = null;
|
|
322
|
+
if (typeof reject === 'function') {
|
|
323
|
+
// settle() clears the upgrade timer and flags, and closes the connection
|
|
324
|
+
reject(err);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
this.upgrading = false;
|
|
328
|
+
this.closeAfter();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// While the initial connect promise is still pending it owns error reporting:
|
|
332
|
+
// reject it once instead of emitting a duplicate 'error' event (which would also
|
|
333
|
+
// throw if the caller has not attached an 'error' listener yet).
|
|
334
|
+
if (typeof this.initialReject === 'function') {
|
|
335
|
+
let reject = this.initialReject;
|
|
336
|
+
this.initialResolve = false;
|
|
337
|
+
this.initialReject = false;
|
|
338
|
+
this.closeAfter();
|
|
339
|
+
reject(err);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
this.closeAfter();
|
|
343
|
+
this.emit('error', err);
|
|
344
|
+
}
|
|
345
|
+
/** @internal */
|
|
346
|
+
getRandomId() {
|
|
347
|
+
let rid = BigInt('0x' + node_crypto_1.default.randomBytes(13).toString('hex')).toString(36);
|
|
348
|
+
if (rid.length < 20) {
|
|
349
|
+
rid = '0'.repeat(20 - rid.length) + rid;
|
|
350
|
+
}
|
|
351
|
+
if (rid.length > 20) {
|
|
352
|
+
rid = rid.substr(0, 20);
|
|
353
|
+
}
|
|
354
|
+
return rid;
|
|
355
|
+
}
|
|
356
|
+
/** @internal */
|
|
357
|
+
write(chunk) {
|
|
358
|
+
if (!this.socket || this.socket.destroyed) {
|
|
359
|
+
// do not write after connection end or logout
|
|
360
|
+
throw this.createConnectionError('NoConnection', 'Socket is already closed', { rejectedFrom: 'writeNoSocket' });
|
|
361
|
+
}
|
|
362
|
+
if (this.state === this.states.LOGOUT) {
|
|
363
|
+
// should not happen
|
|
364
|
+
throw this.createConnectionError('StateLogout', 'Can not send data after logged out', { rejectedFrom: 'writeAfterLogout' });
|
|
365
|
+
}
|
|
366
|
+
if (this.writeSocket.destroyed) {
|
|
367
|
+
this.log.error({ msg: 'Write socket destroyed', cid: this.id });
|
|
368
|
+
this.close();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Append CRLF only to the final part of a command. When sending literals,
|
|
372
|
+
// commandParts holds the remaining parts (literal data, continuation); the CRLF
|
|
373
|
+
// delimiter is only added when no more parts remain (the command is complete).
|
|
374
|
+
let addLineBreak = !this.commandParts.length;
|
|
375
|
+
let data;
|
|
376
|
+
if (typeof chunk === 'string') {
|
|
377
|
+
if (addLineBreak) {
|
|
378
|
+
chunk += '\r\n';
|
|
379
|
+
}
|
|
380
|
+
data = Buffer.from(chunk, 'binary');
|
|
381
|
+
}
|
|
382
|
+
else if (Buffer.isBuffer(chunk)) {
|
|
383
|
+
if (addLineBreak) {
|
|
384
|
+
data = Buffer.concat([chunk, Buffer.from('\r\n')]);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
data = chunk;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
if (this.logRaw) {
|
|
394
|
+
// Client frames of an authentication exchange carry credentials: the LOGIN
|
|
395
|
+
// arguments, and for AUTHENTICATE also the continuation writes (SASL PLAIN
|
|
396
|
+
// response, AUTH=LOGIN password, OAuth token payload) that bypass send(). The
|
|
397
|
+
// parsed command log masks these, so the raw log must withhold them too, but
|
|
398
|
+
// `data` still carries the placeholder rather than being dropped - the field is
|
|
399
|
+
// part of the documented log format and consumers decode it unconditionally.
|
|
400
|
+
this.log.trace({
|
|
401
|
+
src: 'c',
|
|
402
|
+
msg: 'write to socket',
|
|
403
|
+
data: this.rawSensitiveCommand ? RAW_HIDDEN_PLACEHOLDER : data.toString('base64'),
|
|
404
|
+
...(this.rawSensitiveCommand ? { hidden: true } : {}),
|
|
405
|
+
compress: !!this._deflate,
|
|
406
|
+
secure: !!this.secureConnection,
|
|
407
|
+
cid: this.id
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
this.writeBytesCounter += data.length;
|
|
411
|
+
this.writeSocket.write(data);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Returns byte counters for the current connection.
|
|
415
|
+
*
|
|
416
|
+
* @param reset If `true` then resets the byte counters after returning the current values
|
|
417
|
+
* @returns Byte counters: bytes sent to and received from the server
|
|
418
|
+
*/
|
|
419
|
+
stats(reset) {
|
|
420
|
+
let result = {
|
|
421
|
+
sent: this.writeBytesCounter || 0,
|
|
422
|
+
received: (this.streamer && this.streamer.readBytesCounter) || 0
|
|
423
|
+
};
|
|
424
|
+
if (reset) {
|
|
425
|
+
this.writeBytesCounter = 0;
|
|
426
|
+
if (this.streamer) {
|
|
427
|
+
this.streamer.readBytesCounter = 0;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return result;
|
|
431
|
+
}
|
|
432
|
+
// Compiles and sends an IMAP command to the server. The command is compiled
|
|
433
|
+
// twice: once as an array (for sending, with literal data split into parts)
|
|
434
|
+
// and once as a string (for logging, with sensitive data masked).
|
|
435
|
+
// When LITERAL- or LITERAL+ extensions are available, the compiler can use
|
|
436
|
+
// non-synchronizing literals to avoid waiting for server "+" continuation.
|
|
437
|
+
/** @internal */
|
|
438
|
+
async send(data) {
|
|
439
|
+
if (this.state === this.states.LOGOUT) {
|
|
440
|
+
// already logged out
|
|
441
|
+
if (data.tag) {
|
|
442
|
+
let request = this.requestTagMap.get(data.tag);
|
|
443
|
+
if (request) {
|
|
444
|
+
this.requestTagMap.delete(data.tag);
|
|
445
|
+
request.reject(this.createNoConnectionError(false, { rejectedFrom: 'sendAfterLogout', command: request.command }));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
// Classify before the first await. Every frame of this command - the command line and
|
|
451
|
+
// any continuation write that follows it - belongs to it until the next send(), because
|
|
452
|
+
// trySend() keeps one command in flight at a time. Reading currentRequest inside write()
|
|
453
|
+
// instead would be racy: rejectCurrentRequest() can clear it while the two compiler
|
|
454
|
+
// awaits below are pending, and the credential frame would then be logged in the clear.
|
|
455
|
+
// Uppercased because the wire protocol is case-insensitive and exec() passes the
|
|
456
|
+
// caller's spelling through unchanged. The command list covers the mechanisms whose
|
|
457
|
+
// secret arrives in a continuation frame, which carries no attributes of its own; the
|
|
458
|
+
// `sensitive` marker catches anything that instead puts a secret on the command line,
|
|
459
|
+
// so marking an attribute is enough to keep a new command out of the raw log too.
|
|
460
|
+
this.rawSensitiveCommand =
|
|
461
|
+
RAW_SENSITIVE_COMMANDS.has(typeof data.command === 'string' ? data.command.toUpperCase() : '') || hasSensitiveAttribute(data.attributes);
|
|
462
|
+
// Compile with asArray=true: splits output into parts for literal handling.
|
|
463
|
+
// First part is the command text up to the first literal, remaining parts
|
|
464
|
+
// are stored in this.commandParts and sent after server "+" continuations.
|
|
465
|
+
let compiled = await (0, imap_handler_js_1.compiler)(data, {
|
|
466
|
+
asArray: true,
|
|
467
|
+
// LITERAL- is part of base IMAP4rev2
|
|
468
|
+
literalMinus: (0, tools_js_1.hasCapability)(this, 'LITERAL-') || this.capabilities.has('LITERAL+')
|
|
469
|
+
});
|
|
470
|
+
this.commandParts = compiled;
|
|
471
|
+
// Compile again for logging with isLogging=true: masks sensitive values
|
|
472
|
+
// like passwords while producing a human-readable command string
|
|
473
|
+
let logCompiled = await (0, imap_handler_js_1.compiler)(data, {
|
|
474
|
+
isLogging: true
|
|
475
|
+
});
|
|
476
|
+
/* c8 ignore next */ // send() is always invoked with a request object carrying options, so the {} fallback is unreachable
|
|
477
|
+
let options = data.options || {};
|
|
478
|
+
this.log.debug({ src: 'c', msg: logCompiled.toString(), cid: this.id, comment: options.comment });
|
|
479
|
+
// Send the first part (command text). If there are literal parts,
|
|
480
|
+
// the server will respond with "+" continuations and reader() will
|
|
481
|
+
// send each remaining part from this.commandParts.
|
|
482
|
+
this.write(this.commandParts.shift());
|
|
483
|
+
// The command is on the wire now. Tagged-response correlation requires this, so a server
|
|
484
|
+
// that guesses the next (sequential) tag cannot settle a command during the window between
|
|
485
|
+
// it becoming current and actually being written.
|
|
486
|
+
if (this.currentRequest && this.currentRequest.tag === data.tag) {
|
|
487
|
+
this.currentRequest.sent = true;
|
|
488
|
+
}
|
|
489
|
+
if (typeof options.onSend === 'function') {
|
|
490
|
+
// The command is already on the wire, so a throwing onSend callback must not
|
|
491
|
+
// reach trySend()'s catch - that would reject the request and dispatch the
|
|
492
|
+
// next command into the server's pending state for this one.
|
|
493
|
+
try {
|
|
494
|
+
options.onSend();
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
this.log.warn({ err, cid: this.id });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/** @internal */
|
|
502
|
+
async trySend() {
|
|
503
|
+
while (!this.currentRequest && this.requestQueue.length) {
|
|
504
|
+
this.currentRequest = this.requestQueue.shift();
|
|
505
|
+
try {
|
|
506
|
+
await this.send({
|
|
507
|
+
tag: this.currentRequest.tag,
|
|
508
|
+
command: this.currentRequest.command,
|
|
509
|
+
attributes: this.currentRequest.attributes,
|
|
510
|
+
options: this.currentRequest.options
|
|
511
|
+
});
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
// A failure here (most likely the compiler refusing an invalid
|
|
516
|
+
// user-supplied value) belongs to the command that was being dispatched.
|
|
517
|
+
// Without this the shifted request would stay currentRequest forever:
|
|
518
|
+
// nothing reached the wire, so no tagged response ever clears it, and
|
|
519
|
+
// every later command would queue behind it until the socket timeout.
|
|
520
|
+
// Reject the failed command and keep draining the queue.
|
|
521
|
+
this.commandParts = [];
|
|
522
|
+
this.rejectCurrentRequest(err);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/** @internal */
|
|
527
|
+
exec(command, attributes, options) {
|
|
528
|
+
if (this.state === this.states.LOGOUT || this.isClosed) {
|
|
529
|
+
return (0, tools_js_1.guardedReject)(this.createNoConnectionError(false, { rejectedFrom: 'execClosed', command }));
|
|
530
|
+
}
|
|
531
|
+
if (!this.socket || this.socket.destroyed) {
|
|
532
|
+
return (0, tools_js_1.guardedReject)(this.createConnectionError('EConnectionClosed', 'Connection closed', { rejectedFrom: 'execNoSocket', command }));
|
|
533
|
+
}
|
|
534
|
+
let tag = (++this.tagCounter).toString(16).toUpperCase();
|
|
535
|
+
let execOptions = options || {};
|
|
536
|
+
// Guarded: close() rejects this request synchronously, possibly before the caller has
|
|
537
|
+
// attached its handler. See guardedPromise().
|
|
538
|
+
return (0, tools_js_1.guardedPromise)((resolve, reject) => {
|
|
539
|
+
this.requestTagMap.set(tag, { command, attributes, options: execOptions, resolve, reject });
|
|
540
|
+
this.requestQueue.push({ tag, command, attributes, options: execOptions });
|
|
541
|
+
// trySend() settles dispatch failures itself, by rejecting the affected
|
|
542
|
+
// command through requestTagMap; this catch exists only so a throw from the
|
|
543
|
+
// dispatch machinery itself can never surface as a floating rejection.
|
|
544
|
+
this.trySend().catch(err => (0, tools_js_1.logConnectionError)(this, 'Failed to dispatch command', err));
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
// Resolves an untagged server response to the keyword it is dispatched on. IMAP untagged
|
|
548
|
+
// responses come in two forms:
|
|
549
|
+
// * CAPABILITY ... (keyword as command)
|
|
550
|
+
// * 42 FETCH (...) (numeric prefix + keyword)
|
|
551
|
+
// For numeric-prefixed responses the keyword sits in the first attribute, because `command`
|
|
552
|
+
// holds the sequence number. Also used for logging, so a failure reports FETCH rather than
|
|
553
|
+
// the message number that happened to precede it.
|
|
554
|
+
/** @internal */
|
|
555
|
+
normalizeUntaggedCommand(command, attributes) {
|
|
556
|
+
if (/^[0-9]+$/.test(command)) {
|
|
557
|
+
let type = attributes && attributes.length && typeof attributes[0].value === 'string'
|
|
558
|
+
? attributes[0].value.toUpperCase()
|
|
559
|
+
: false;
|
|
560
|
+
if (type) {
|
|
561
|
+
command = type;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return command.toUpperCase().trim();
|
|
565
|
+
}
|
|
566
|
+
// Handler priority: command-specific handlers (registered per exec() call) take
|
|
567
|
+
// precedence over global handlers (registered on the connection).
|
|
568
|
+
/** @internal */
|
|
569
|
+
getUntaggedHandler(command, attributes) {
|
|
570
|
+
command = this.normalizeUntaggedCommand(command, attributes);
|
|
571
|
+
// Check command-specific handler first (registered in exec() options.untagged)
|
|
572
|
+
if (this.currentRequest && this.currentRequest.options && this.currentRequest.options.untagged && this.currentRequest.options.untagged[command]) {
|
|
573
|
+
return this.currentRequest.options.untagged[command];
|
|
574
|
+
}
|
|
575
|
+
// Fall back to global handler (e.g., for CAPABILITY, BYE, etc.)
|
|
576
|
+
let handler = this.untaggedHandlers[command];
|
|
577
|
+
if (handler) {
|
|
578
|
+
return handler;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/** @internal */
|
|
582
|
+
getSectionHandler(key) {
|
|
583
|
+
if (this.sectionHandlers[key]) {
|
|
584
|
+
return this.sectionHandlers[key];
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// Releases a readable stream item exactly once. The item's `next` callback is the parser's
|
|
588
|
+
// backpressure token: until it is called, ImapStream stops feeding the connection. Every
|
|
589
|
+
// path out of response handling - success, handled error, or unexpected throw - has to go
|
|
590
|
+
// through here, otherwise the parser stalls permanently.
|
|
591
|
+
/** @internal */
|
|
592
|
+
releaseStreamData(data) {
|
|
593
|
+
if (!data || data.released) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
data.released = true;
|
|
597
|
+
if (typeof data.next === 'function') {
|
|
598
|
+
data.next();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// Records a tagged response whose tag was never issued by this connection. ImapFlow talks
|
|
602
|
+
// to a wide range of non-conforming servers, so this is tolerated rather than terminal, but
|
|
603
|
+
// it must not pass silently. Warnings are emitted for the first occurrence and then at
|
|
604
|
+
// powers of two so a server spraying stray tagged lines cannot flood the log, while the
|
|
605
|
+
// counter itself stays exact and is reported when the connection closes.
|
|
606
|
+
/** @internal */
|
|
607
|
+
countUnknownTag(tag) {
|
|
608
|
+
if (this.isClosed) {
|
|
609
|
+
// teardown crossover, not a server compatibility signal
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
this._unknownTagCount++;
|
|
613
|
+
if (this._unknownTagCount === this._nextUnknownTagWarn) {
|
|
614
|
+
this._nextUnknownTagWarn *= 2;
|
|
615
|
+
this.log.warn({
|
|
616
|
+
msg: 'Tagged response for an unknown tag',
|
|
617
|
+
tag,
|
|
618
|
+
unknownTagCount: this._unknownTagCount,
|
|
619
|
+
cid: this.id
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// Terminally fails the connection on a protocol violation: stop parsing, then report. Both
|
|
624
|
+
// steps are explicit here rather than destroying the parser *with* the error and relying on
|
|
625
|
+
// its error listener to report, so the reporting path does not depend on teardown ordering or
|
|
626
|
+
// on the streamer error handler's suppression list.
|
|
627
|
+
/** @internal */
|
|
628
|
+
failProtocol(err) {
|
|
629
|
+
if (this.streamer && !this.streamer.destroyed) {
|
|
630
|
+
// Destroyed without an error: nothing after a protocol violation may reach
|
|
631
|
+
// application state, and emitError() below owns reporting.
|
|
632
|
+
this.streamer.destroy();
|
|
633
|
+
}
|
|
634
|
+
this.emitError(err);
|
|
635
|
+
}
|
|
636
|
+
// Rejects the in-flight request, if any, exactly once. Used when response handling fails in
|
|
637
|
+
// a way that leaves the command's outcome unknown.
|
|
638
|
+
/** @internal */
|
|
639
|
+
rejectCurrentRequest(err) {
|
|
640
|
+
if (!this.currentRequest) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
let tag = this.currentRequest.tag;
|
|
644
|
+
this.currentRequest = false;
|
|
645
|
+
let request = this.requestTagMap.get(tag);
|
|
646
|
+
if (request) {
|
|
647
|
+
this.requestTagMap.delete(tag);
|
|
648
|
+
request.reject(err);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Waits out a throttle back-off.
|
|
653
|
+
*
|
|
654
|
+
* The delay is capped at MAX_THROTTLE_DELAY because it can come straight from a server hint
|
|
655
|
+
* (a Microsoft 365 "Suggested Backoff Time", say) and an uncapped hint would park the caller
|
|
656
|
+
* for weeks. The timer is unref'd and tracked so it can never outlive the client: a bare
|
|
657
|
+
* setTimeout here keeps a short-lived process alive for the full delay after close(), and
|
|
658
|
+
* leaves the caller waiting on a connection that is already gone.
|
|
659
|
+
*
|
|
660
|
+
* @param delay - Requested delay in milliseconds.
|
|
661
|
+
* @returns True if close() aborted the wait, false on normal expiry.
|
|
662
|
+
* @internal
|
|
663
|
+
*/
|
|
664
|
+
async throttleWait(delay) {
|
|
665
|
+
delay = Math.min(Math.max(Number(delay) || 0, 0), MAX_THROTTLE_DELAY);
|
|
666
|
+
return await new Promise(resolve => {
|
|
667
|
+
let entry = { resolve };
|
|
668
|
+
entry.timer = setTimeout(() => {
|
|
669
|
+
this._throttleWaits.delete(entry);
|
|
670
|
+
resolve(false);
|
|
671
|
+
}, delay);
|
|
672
|
+
(0, tools_js_1.unrefTimer)(entry.timer);
|
|
673
|
+
this._throttleWaits.add(entry);
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
/** @internal */
|
|
677
|
+
async reader() {
|
|
678
|
+
let data;
|
|
679
|
+
let processedCount = 0;
|
|
680
|
+
while ((data = this.streamer.read()) !== null) {
|
|
681
|
+
let keepReading;
|
|
682
|
+
try {
|
|
683
|
+
keepReading = await this.handleResponse(data);
|
|
684
|
+
}
|
|
685
|
+
catch (err) {
|
|
686
|
+
// Response handling past the parse step (log compilation, response shape
|
|
687
|
+
// assumptions, an untagged handler bug) must never throw out of this loop: the
|
|
688
|
+
// parser would keep waiting on its backpressure callback forever, which is a
|
|
689
|
+
// silent permanent hang. Fail closed instead.
|
|
690
|
+
keepReading = false;
|
|
691
|
+
let error = new Error('Failed to process server response');
|
|
692
|
+
error.code = 'ResponseProcessingFailed';
|
|
693
|
+
error._err = err;
|
|
694
|
+
this.log.error({ msg: 'Failed to process server response', err, cid: this.id });
|
|
695
|
+
this.rejectCurrentRequest(error);
|
|
696
|
+
this.failProtocol(error);
|
|
697
|
+
}
|
|
698
|
+
finally {
|
|
699
|
+
this.releaseStreamData(data);
|
|
700
|
+
}
|
|
701
|
+
if (!keepReading) {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
// Yield to event loop every 10 processed messages to prevent CPU blocking
|
|
705
|
+
processedCount++;
|
|
706
|
+
if (processedCount % 10 === 0) {
|
|
707
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Fails the in-flight command when a line that could not be parsed was addressed to its tag.
|
|
713
|
+
* Only the leading tag is read from the raw payload - the rest of the line is by definition
|
|
714
|
+
* not trustworthy - and only the command that is actually on the wire may be settled this way,
|
|
715
|
+
* the same invariant the parsed tagged-response path enforces.
|
|
716
|
+
*
|
|
717
|
+
* @param payload - Raw bytes of the line that failed to parse.
|
|
718
|
+
* @param parserError - The error the parser raised.
|
|
719
|
+
* @internal
|
|
720
|
+
*/
|
|
721
|
+
rejectUnparsedCompletion(payload, parserError) {
|
|
722
|
+
if (!this.currentRequest || !this.currentRequest.sent) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
// Prefer the tag the parser had already extracted before it failed - it went
|
|
726
|
+
// through the same leading-NUL workaround as every parsed response. Fall back
|
|
727
|
+
// to the raw bytes for lines whose tag itself was unparseable: skip the NUL
|
|
728
|
+
// padding buggy servers prepend and stop at the first byte a tag cannot contain.
|
|
729
|
+
let tag = parserError && parserError.parsedTag;
|
|
730
|
+
if (!tag) {
|
|
731
|
+
let match = payload.toString('latin1', 0, 64).match(/^\0*([^\s\x00-\x1f\x7f]+)/);
|
|
732
|
+
tag = match && match[1];
|
|
733
|
+
}
|
|
734
|
+
if (!tag || tag !== this.currentRequest.tag) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
let err = new Error('Failed to parse the server response for this command');
|
|
738
|
+
err.code = parserError.code || 'ParserError';
|
|
739
|
+
err.parserError = parserError;
|
|
740
|
+
this.rejectCurrentRequest(err);
|
|
741
|
+
this.trySend().catch(sendErr => (0, tools_js_1.logConnectionError)(this, 'Failed to dispatch command', sendErr));
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Handles a single parsed server response: telemetry, continuation requests, response-code
|
|
745
|
+
* section handlers, untagged handlers and tagged command completion.
|
|
746
|
+
*
|
|
747
|
+
* @param data - Readable item from the parser stream.
|
|
748
|
+
* @returns `true` to keep reading, `false` to stop (connection is failing).
|
|
749
|
+
* @internal
|
|
750
|
+
*/
|
|
751
|
+
async handleResponse(data) {
|
|
752
|
+
let parsed;
|
|
753
|
+
try {
|
|
754
|
+
parsed = await (0, imap_handler_js_1.parser)(data.payload, { literals: data.literals });
|
|
755
|
+
}
|
|
756
|
+
catch (err) {
|
|
757
|
+
// can not make sense of this. The payload can be up to the configured line
|
|
758
|
+
// cap (1GB by default), so log only a bounded prefix: a server looping
|
|
759
|
+
// unparseable garbage would otherwise turn this error log into a disk filler.
|
|
760
|
+
this.log.error({ src: 's', msg: data.payload.toString('latin1', 0, 1024), payloadBytes: data.payload.length, err, cid: this.id });
|
|
761
|
+
// An unparseable untagged line is junk that can be skipped, but the line may
|
|
762
|
+
// have been the in-flight command's tagged completion. Dropping that one
|
|
763
|
+
// silently strands the command: currentRequest is never cleared, so trySend()
|
|
764
|
+
// stops dispatching and every later command queues behind it until the socket
|
|
765
|
+
// timeout fires. The tag is recovered from the raw bytes (a tag is
|
|
766
|
+
// ASTRING-CHAR only, so it survives whatever made the rest unparseable) and
|
|
767
|
+
// the command is failed with the parser error instead of hanging.
|
|
768
|
+
this.rejectUnparsedCompletion(data.payload, err);
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
if (parsed.tag && !['*', '+'].includes(parsed.tag) && parsed.command) {
|
|
772
|
+
let payload = { response: parsed.command };
|
|
773
|
+
if (parsed.attributes &&
|
|
774
|
+
parsed.attributes[0] &&
|
|
775
|
+
parsed.attributes[0].section &&
|
|
776
|
+
parsed.attributes[0].section[0] &&
|
|
777
|
+
parsed.attributes[0].section[0].type === 'ATOM') {
|
|
778
|
+
payload.code = parsed.attributes[0].section[0].value;
|
|
779
|
+
}
|
|
780
|
+
// Outside the parse try/catch on purpose: a throwing user 'response' listener
|
|
781
|
+
// is not a parse failure and must not settle the in-flight command or fail the
|
|
782
|
+
// connection - the same contract untagged handlers get.
|
|
783
|
+
try {
|
|
784
|
+
this.emit('response', payload);
|
|
785
|
+
}
|
|
786
|
+
catch (err) {
|
|
787
|
+
this.log.warn({ err, cid: this.id });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
let logCompiled = await (0, imap_handler_js_1.compiler)(parsed, {
|
|
791
|
+
isLogging: true
|
|
792
|
+
});
|
|
793
|
+
if (/^\d+$/.test(parsed.command || '') && parsed.attributes && parsed.attributes[0] && parsed.attributes[0].value === 'FETCH') {
|
|
794
|
+
// too many FETCH responses, might want to filter these out
|
|
795
|
+
this.log.trace({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
|
|
796
|
+
}
|
|
797
|
+
else {
|
|
798
|
+
this.log.debug({ src: 's', msg: logCompiled.toString(), cid: this.id, nullBytesRemoved: parsed.nullBytesRemoved });
|
|
799
|
+
}
|
|
800
|
+
// IMAP "+" (continuation request) handling. The server sends "+" in two cases:
|
|
801
|
+
// 1. During IDLE or AUTHENTICATE, where a custom handler (onPlusTag) processes it
|
|
802
|
+
// 2. During literal data transfer, where we send the next queued literal chunk
|
|
803
|
+
if (parsed.tag === '+' && this.currentRequest && this.currentRequest.options && typeof this.currentRequest.options.onPlusTag === 'function') {
|
|
804
|
+
try {
|
|
805
|
+
await this.currentRequest.options.onPlusTag(parsed);
|
|
806
|
+
}
|
|
807
|
+
catch (err) {
|
|
808
|
+
// The handler ran across an await and may have closed the connection, which
|
|
809
|
+
// clears currentRequest, so the command name is read defensively
|
|
810
|
+
this.log.warn({
|
|
811
|
+
msg: 'Failed to process continuation response',
|
|
812
|
+
command: this.currentRequest ? this.currentRequest.command : undefined,
|
|
813
|
+
err,
|
|
814
|
+
cid: this.id
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
// Server acknowledged our literal size with "+", send the actual literal data
|
|
820
|
+
if (parsed.tag === '+' && this.commandParts.length) {
|
|
821
|
+
let content = this.commandParts.shift();
|
|
822
|
+
// A write() failure here (e.g. socket closed mid-command) must not fail the whole
|
|
823
|
+
// connection; the command's own tagged response or the close path reports it.
|
|
824
|
+
try {
|
|
825
|
+
this.write(content);
|
|
826
|
+
this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
|
|
827
|
+
}
|
|
828
|
+
catch (err) {
|
|
829
|
+
(0, tools_js_1.logConnectionError)(this, 'Failed to send literal continuation', err);
|
|
830
|
+
}
|
|
831
|
+
return true;
|
|
832
|
+
}
|
|
833
|
+
let section = parsed.attributes && parsed.attributes.length && parsed.attributes[0] && !parsed.attributes[0].value && parsed.attributes[0].section;
|
|
834
|
+
// section[0] can be a parsed NIL (null), e.g. from a "[NIL]" response code - the
|
|
835
|
+
// dereference must be guarded or one such line tears down the whole connection
|
|
836
|
+
if (section && section.length && section[0] && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
837
|
+
let sectionKey = section[0].value.toUpperCase().trim();
|
|
838
|
+
let sectionHandler = this.getSectionHandler(sectionKey);
|
|
839
|
+
if (sectionHandler) {
|
|
840
|
+
try {
|
|
841
|
+
await sectionHandler(section.slice(1));
|
|
842
|
+
}
|
|
843
|
+
catch (err) {
|
|
844
|
+
this.log.warn({ msg: 'Failed to process response section', section: sectionKey, err, cid: this.id });
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (parsed.tag === '*' && parsed.command) {
|
|
849
|
+
let untaggedHandler = this.getUntaggedHandler(parsed.command, parsed.attributes);
|
|
850
|
+
if (untaggedHandler) {
|
|
851
|
+
try {
|
|
852
|
+
await untaggedHandler(parsed);
|
|
853
|
+
}
|
|
854
|
+
catch (err) {
|
|
855
|
+
// Normalized only here: this runs for every untagged response, including
|
|
856
|
+
// every FETCH, and the keyword is needed only to describe a failure
|
|
857
|
+
this.log.warn({
|
|
858
|
+
msg: 'Failed to process untagged response',
|
|
859
|
+
command: this.normalizeUntaggedCommand(parsed.command, parsed.attributes),
|
|
860
|
+
err,
|
|
861
|
+
cid: this.id
|
|
862
|
+
});
|
|
863
|
+
return true;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// Tagged response correlation. A tagged response may only complete the command that was
|
|
868
|
+
// actually written to the socket (invariant 2), so the three cases below are kept apart:
|
|
869
|
+
// the active command completes, a command that has not been written yet is proof of
|
|
870
|
+
// desynchronization (queued behind another command, or current but not yet on the wire),
|
|
871
|
+
// and an entirely unknown tag is recorded but tolerated.
|
|
872
|
+
if (parsed.tag && !['*', '+'].includes(parsed.tag)) {
|
|
873
|
+
if (this.currentRequest && this.currentRequest.tag === parsed.tag && this.currentRequest.sent) {
|
|
874
|
+
let request = this.requestTagMap.get(parsed.tag);
|
|
875
|
+
this.requestTagMap.delete(parsed.tag);
|
|
876
|
+
this.currentRequest = false;
|
|
877
|
+
if (request) {
|
|
878
|
+
await this.settleRequest(request, parsed, !!data.trailingAfterLine);
|
|
879
|
+
}
|
|
880
|
+
// Send the next queued command only after the completed command's handler has
|
|
881
|
+
// applied its own state (e.g. select.ts publishing the new mailbox), so the next
|
|
882
|
+
// command cannot reach the wire against half-updated state. A failure here must
|
|
883
|
+
// not propagate, or the whole connection would be failed over a send error that
|
|
884
|
+
// the command's own promise already reports.
|
|
885
|
+
// Note: on a rejected command the handler's catch block runs on its own microtask
|
|
886
|
+
// chain, so only the success path is fully ordered.
|
|
887
|
+
try {
|
|
888
|
+
await this.trySend();
|
|
889
|
+
}
|
|
890
|
+
catch (err) {
|
|
891
|
+
this.log.warn({ err, cid: this.id });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
else if (this.requestTagMap.has(parsed.tag)) {
|
|
895
|
+
// The server answered a command that has not been written to the socket yet.
|
|
896
|
+
// Continuing would report unsent mutations as successful and leave every later
|
|
897
|
+
// response ambiguous, so reject this request and fail the connection closed.
|
|
898
|
+
let request = this.requestTagMap.get(parsed.tag);
|
|
899
|
+
this.requestTagMap.delete(parsed.tag);
|
|
900
|
+
let err = new Error('Server sent a tagged response for a command that was not in flight');
|
|
901
|
+
err.code = 'UnexpectedTag';
|
|
902
|
+
err.details = {
|
|
903
|
+
received: parsed.tag,
|
|
904
|
+
expected: this.currentRequest ? this.currentRequest.tag : null
|
|
905
|
+
};
|
|
906
|
+
this.log.error({ msg: 'Protocol desynchronization', err, cid: this.id });
|
|
907
|
+
request.reject(err);
|
|
908
|
+
this.failProtocol(err);
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
this.countUnknownTag(parsed.tag);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return true;
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Settles a request with its tagged completion response.
|
|
919
|
+
*
|
|
920
|
+
* On success the returned promise stays pending until the command handler calls `next()` on
|
|
921
|
+
* the response, which is what orders state application before the next queued command is
|
|
922
|
+
* dispatched. A command handler must therefore always release its own response before
|
|
923
|
+
* awaiting another command on the same connection.
|
|
924
|
+
*
|
|
925
|
+
* @param request - Pending request entry (resolve/reject and the compiled command).
|
|
926
|
+
* @param parsed - Parsed tagged response.
|
|
927
|
+
* @param hasTrailingData - Whether more input was already buffered after this line.
|
|
928
|
+
* @internal
|
|
929
|
+
*/
|
|
930
|
+
async settleRequest(request, parsed, hasTrailingData) {
|
|
931
|
+
switch ((parsed.command || '').toUpperCase()) {
|
|
932
|
+
case 'OK':
|
|
933
|
+
case 'BYE':
|
|
934
|
+
// hasTrailingData is forwarded so STARTTLS can detect a plaintext
|
|
935
|
+
// injection (data buffered after the tagged OK, before the handshake).
|
|
936
|
+
await new Promise(resolve => request.resolve({ response: parsed, next: resolve, hasTrailingData }));
|
|
937
|
+
break;
|
|
938
|
+
case 'NO':
|
|
939
|
+
case 'BAD': {
|
|
940
|
+
let txt = parsed.attributes &&
|
|
941
|
+
parsed.attributes
|
|
942
|
+
.filter(val => val.type === 'TEXT')
|
|
943
|
+
.map(val => val.value.trim())
|
|
944
|
+
.join(' ');
|
|
945
|
+
let err = new Error('Command failed');
|
|
946
|
+
err.response = parsed;
|
|
947
|
+
err.responseStatus = parsed.command.toUpperCase();
|
|
948
|
+
try {
|
|
949
|
+
err.executedCommand =
|
|
950
|
+
parsed.tag +
|
|
951
|
+
(await (0, imap_handler_js_1.compiler)(request, {
|
|
952
|
+
isLogging: true
|
|
953
|
+
})).toString();
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
// ignore
|
|
957
|
+
}
|
|
958
|
+
if (txt) {
|
|
959
|
+
err.responseText = txt;
|
|
960
|
+
if (err.responseStatus === 'NO' && txt.includes('Some of the requested messages no longer exist')) {
|
|
961
|
+
// Treat as successful response
|
|
962
|
+
// Kept at warn: the caller is handed fewer messages than it asked for and
|
|
963
|
+
// is told nothing else about it, so this entry is the only record that
|
|
964
|
+
// the response was truncated.
|
|
965
|
+
this.log.warn({ msg: 'Partial FETCH response', cid: this.id, err });
|
|
966
|
+
await new Promise(resolve => request.resolve({ response: parsed, next: resolve }));
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
let throttleDelay = false;
|
|
970
|
+
// MS365 throttling detection: Office 365 returns BAD with a human-readable
|
|
971
|
+
// backoff time when rate limits are hit. Parse the delay from the response text.
|
|
972
|
+
// Example: "tag BAD Request is throttled. Suggested Backoff Time: 92415 milliseconds"
|
|
973
|
+
if (/Request is throttled/i.test(txt) && /Backoff Time/i.test(txt)) {
|
|
974
|
+
let throttlingMatch = txt.match(/Backoff Time[:=\s]+(\d+)/i);
|
|
975
|
+
if (throttlingMatch && throttlingMatch[1] && !isNaN(throttlingMatch[1])) {
|
|
976
|
+
throttleDelay = Number(throttlingMatch[1]);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
// Wait and return a throttling error
|
|
980
|
+
if (throttleDelay) {
|
|
981
|
+
err.code = 'ETHROTTLE';
|
|
982
|
+
err.throttleReset = throttleDelay;
|
|
983
|
+
// The server-suggested delay can be very large, so throttleWait() caps it
|
|
984
|
+
let delayResponse = Math.min(throttleDelay, MAX_THROTTLE_DELAY);
|
|
985
|
+
this.log.warn({ msg: 'Throttling detected', cid: this.id, throttleDelay, delayResponse, err });
|
|
986
|
+
let aborted = await this.throttleWait(delayResponse);
|
|
987
|
+
if (aborted) {
|
|
988
|
+
// Connection closed during back-off: reject promptly with a
|
|
989
|
+
// connection error (carrying any server BYE reason) instead of
|
|
990
|
+
// waiting out the throttle delay.
|
|
991
|
+
request.reject(this.createNoConnectionError(this.byeReason, { rejectedFrom: 'throttleAbort', command: request.command }));
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
request.reject(err);
|
|
997
|
+
break;
|
|
998
|
+
}
|
|
999
|
+
default: {
|
|
1000
|
+
let err = new Error('Invalid server response');
|
|
1001
|
+
err.code = 'InvalidResponse';
|
|
1002
|
+
err.response = parsed;
|
|
1003
|
+
request.reject(err);
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
/** @internal */
|
|
1009
|
+
setEventHandlers() {
|
|
1010
|
+
// Bind the 'readable' event to kick off the reader loop.
|
|
1011
|
+
// The `this.reading` flag acts as a concurrency guard: if reader()
|
|
1012
|
+
// is already running, new 'readable' events are ignored. The reader
|
|
1013
|
+
// loop will keep draining data until the stream returns null.
|
|
1014
|
+
const onReadable = () => {
|
|
1015
|
+
if (!this.reading) {
|
|
1016
|
+
this.reading = true;
|
|
1017
|
+
this.reader()
|
|
1018
|
+
.catch(err => this.log.error({ err, cid: this.id }))
|
|
1019
|
+
.finally(() => {
|
|
1020
|
+
this.reading = false;
|
|
1021
|
+
// A 'readable' event that fired while the loop was winding down was
|
|
1022
|
+
// ignored by the guard above. Node emits the event on the next tick,
|
|
1023
|
+
// after this handler has run, but a runtime that implements nextTick
|
|
1024
|
+
// as a microtask (Cloudflare Workers) emits it before, and the response
|
|
1025
|
+
// the parser had pushed in the meantime would then sit unread until the
|
|
1026
|
+
// next chunk arrives - or, for the last response of an exchange, until
|
|
1027
|
+
// the socket times out. Anything already buffered is picked up here.
|
|
1028
|
+
if (this.streamer && !this.streamer.destroyed && this.streamer.readableLength > 0) {
|
|
1029
|
+
onReadable();
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
this.socketReadable = onReadable;
|
|
1035
|
+
this.streamer.on('readable', onReadable);
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Applies the transport options every established application socket needs: TCP keepalive and
|
|
1039
|
+
* the inactivity watchdog. Called for direct TLS, cleartext, proxied and STARTTLS-upgraded
|
|
1040
|
+
* sockets, so the watchdog cannot silently differ between transports (a STARTTLS session used
|
|
1041
|
+
* to end up with no armed timer at all).
|
|
1042
|
+
*
|
|
1043
|
+
* @param socket - The socket that now carries the IMAP session.
|
|
1044
|
+
* @internal
|
|
1045
|
+
*/
|
|
1046
|
+
configureSocket(socket) {
|
|
1047
|
+
/* c8 ignore next 3 */ // defensive: connect() only calls this with an established socket
|
|
1048
|
+
if (!socket) {
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
if (typeof socket.setKeepAlive === 'function') {
|
|
1052
|
+
socket.setKeepAlive(true, 5 * 1000);
|
|
1053
|
+
}
|
|
1054
|
+
if (typeof socket.setTimeout === 'function') {
|
|
1055
|
+
socket.setTimeout(this.socketTimeout);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/** @internal */
|
|
1059
|
+
setSocketHandlers() {
|
|
1060
|
+
// Clear any existing handlers first to prevent duplicates
|
|
1061
|
+
this.clearSocketHandlers();
|
|
1062
|
+
this._socketError =
|
|
1063
|
+
this._socketError ||
|
|
1064
|
+
((err) => {
|
|
1065
|
+
this.log.error({ err, cid: this.id });
|
|
1066
|
+
this.emitError(err);
|
|
1067
|
+
});
|
|
1068
|
+
this._socketClose = this._socketClose || (() => this.close());
|
|
1069
|
+
this._socketEnd = this._socketEnd || (() => this.close());
|
|
1070
|
+
/**
|
|
1071
|
+
* Socket timeout event handler.
|
|
1072
|
+
*
|
|
1073
|
+
* A quiet socket is only a dead connection when something was supposed to be talking. An
|
|
1074
|
+
* idling session, a download whose consumer stopped draining, and a held mailbox lock
|
|
1075
|
+
* whose owner is busy between commands are all expected to go quiet, so the handler keeps
|
|
1076
|
+
* such a connection alive with a NOOP instead of tearing it down. An in-flight command is
|
|
1077
|
+
* the opposite: its reply is overdue, a recovery NOOP would only queue up behind it and
|
|
1078
|
+
* never reach the wire, so the timeout is reported as an error. The IDLE command itself is
|
|
1079
|
+
* the one exception - it stays in flight for as long as idling lasts, and run() breaks it
|
|
1080
|
+
* through preCheck() before the NOOP is dispatched.
|
|
1081
|
+
*
|
|
1082
|
+
* IDLE is not restarted here: run() re-arms auto-IDLE once the NOOP settles, and
|
|
1083
|
+
* autoidle() knows whether the connection is actually free for IDLE - an open download or
|
|
1084
|
+
* a held lock keeps just the keepalive, and with disableAutoIdle nothing restarts at all.
|
|
1085
|
+
* If the server is dead the NOOP never settles, and the next timeout fires with the NOOP
|
|
1086
|
+
* as the stuck in-flight command, which lands in the error branch below.
|
|
1087
|
+
*
|
|
1088
|
+
* Emits the error event if the connection cannot be recovered
|
|
1089
|
+
*/
|
|
1090
|
+
this._socketTimeout =
|
|
1091
|
+
this._socketTimeout ||
|
|
1092
|
+
(() => {
|
|
1093
|
+
const err = new Error('Socket timeout');
|
|
1094
|
+
err.code = 'ETIMEOUT';
|
|
1095
|
+
const quietExpected = this.idling || this._openDownloads || this.currentLock;
|
|
1096
|
+
const commandStuck = this.currentRequest && !(this.idling && this.currentRequest.command === 'IDLE');
|
|
1097
|
+
if (quietExpected && !commandStuck) {
|
|
1098
|
+
if (!this.usable || !this.socket || this.socket.destroyed) {
|
|
1099
|
+
this.emitError(err);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
this.run('NOOP').catch(err => {
|
|
1103
|
+
this.log.warn({ msg: 'Connection recovery failed after timeout', err, cid: this.id });
|
|
1104
|
+
if (!this.isClosed) {
|
|
1105
|
+
this.close();
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
this.log.debug({ msg: 'Socket timeout', cid: this.id });
|
|
1111
|
+
this.emitError(err);
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
const socket = this.socket;
|
|
1115
|
+
socket.once('error', this._socketError);
|
|
1116
|
+
socket.once('close', this._socketClose);
|
|
1117
|
+
socket.once('end', this._socketEnd);
|
|
1118
|
+
socket.on('tlsClientError', this._socketError);
|
|
1119
|
+
socket.on('timeout', this._socketTimeout);
|
|
1120
|
+
if (this.writeSocket && this.writeSocket !== this.socket) {
|
|
1121
|
+
this.writeSocket.on('error', this._socketError);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
/** @internal */
|
|
1125
|
+
clearSocketHandlers() {
|
|
1126
|
+
if (!this.socket) {
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
// Remove temporary connection error handler if still present
|
|
1130
|
+
if (this._connectErrorHandler) {
|
|
1131
|
+
this.socket.removeListener('error', this._connectErrorHandler);
|
|
1132
|
+
this._connectErrorHandler = null;
|
|
1133
|
+
}
|
|
1134
|
+
if (this._socketError) {
|
|
1135
|
+
this.socket.removeListener('error', this._socketError);
|
|
1136
|
+
this.socket.removeListener('tlsClientError', this._socketError);
|
|
1137
|
+
if (this.writeSocket && this.writeSocket !== this.socket) {
|
|
1138
|
+
this.writeSocket.removeListener('error', this._socketError);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
if (this._socketTimeout) {
|
|
1142
|
+
this.socket.removeListener('timeout', this._socketTimeout);
|
|
1143
|
+
}
|
|
1144
|
+
if (this._socketClose) {
|
|
1145
|
+
this.socket.removeListener('close', this._socketClose);
|
|
1146
|
+
}
|
|
1147
|
+
if (this._socketEnd) {
|
|
1148
|
+
this.socket.removeListener('end', this._socketEnd);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
/** @internal */
|
|
1152
|
+
async startSession() {
|
|
1153
|
+
await this.run('CAPABILITY');
|
|
1154
|
+
if (this.capabilities.has('ID')) {
|
|
1155
|
+
this.idRequested = await this.run('ID', this.clientInfo);
|
|
1156
|
+
}
|
|
1157
|
+
await this.upgradeToSTARTTLS();
|
|
1158
|
+
await this.authenticate();
|
|
1159
|
+
if ((!this.idRequested || Object.keys(this.idRequested).length < 2) && this.capabilities.has('ID')) {
|
|
1160
|
+
// re-request ID after LOGIN
|
|
1161
|
+
this.idRequested = await this.run('ID', this.clientInfo);
|
|
1162
|
+
}
|
|
1163
|
+
// Make sure we have namespace set. This should also throw if Exchange actually failed authentication
|
|
1164
|
+
let nsResponse = await this.run('NAMESPACE');
|
|
1165
|
+
if (nsResponse && nsResponse.error && nsResponse.status === 'BAD' && /User is authenticated but not connected/i.test(nsResponse.text)) {
|
|
1166
|
+
// Not a NAMESPACE failure but authentication failure, so report as
|
|
1167
|
+
this.authenticated = false;
|
|
1168
|
+
let err = new errors_js_1.AuthenticationFailure('Authentication failed');
|
|
1169
|
+
err.response = nsResponse.text;
|
|
1170
|
+
throw err;
|
|
1171
|
+
}
|
|
1172
|
+
if (this.options.verifyOnly) {
|
|
1173
|
+
// List all folders and logout
|
|
1174
|
+
if (this.options.includeMailboxes) {
|
|
1175
|
+
this._mailboxList = await this.list();
|
|
1176
|
+
}
|
|
1177
|
+
return await this.logout();
|
|
1178
|
+
}
|
|
1179
|
+
// try to use compression (if supported)
|
|
1180
|
+
if (!this.options.disableCompression) {
|
|
1181
|
+
await this.compress();
|
|
1182
|
+
}
|
|
1183
|
+
if (!this.options.disableAutoEnable) {
|
|
1184
|
+
await this.autoEnable();
|
|
1185
|
+
}
|
|
1186
|
+
this.usable = true;
|
|
1187
|
+
}
|
|
1188
|
+
// Enable extensions if possible. IMAP4rev2 must be enabled explicitly on
|
|
1189
|
+
// servers that advertise both rev1 and rev2 (RFC 9051 Appendix A); a single
|
|
1190
|
+
// ENABLE call is used so the enabled set is built in one round trip.
|
|
1191
|
+
/** @internal */
|
|
1192
|
+
async autoEnable() {
|
|
1193
|
+
let enableList = ['CONDSTORE', 'UTF8=ACCEPT'].concat(this.options.qresync ? 'QRESYNC' : []).concat(this.options.disableIMAP4rev2 ? [] : 'IMAP4rev2');
|
|
1194
|
+
let enableResult = await this.run('ENABLE', enableList);
|
|
1195
|
+
if (enableResult === false && enableList.includes('IMAP4rev2')) {
|
|
1196
|
+
// RFC 5161 requires servers to ignore unknown ENABLE arguments, but a
|
|
1197
|
+
// broken implementation may reject the whole command over IMAP4rev2 -
|
|
1198
|
+
// retry without it so CONDSTORE/QRESYNC are not lost as collateral
|
|
1199
|
+
await this.run('ENABLE', enableList.filter(extension => extension !== 'IMAP4rev2'));
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
/** @internal */
|
|
1203
|
+
async compress() {
|
|
1204
|
+
if (!(await this.run('COMPRESS'))) {
|
|
1205
|
+
return; // was not able to negotiate compression
|
|
1206
|
+
}
|
|
1207
|
+
// Set up DEFLATE compression (RFC 4978). After COMPRESS is negotiated,
|
|
1208
|
+
// all data in both directions is wrapped in a zlib DEFLATE stream.
|
|
1209
|
+
// The incoming pipeline becomes: socket -> inflate -> streamer (parser).
|
|
1210
|
+
// The outgoing pipeline uses a manual pump (see readNext below) instead
|
|
1211
|
+
// of a normal pipe, because we need to flush after every IMAP command
|
|
1212
|
+
// to ensure the server receives complete commands promptly.
|
|
1213
|
+
this._deflate = node_zlib_1.default.createDeflateRaw({
|
|
1214
|
+
windowBits: 15,
|
|
1215
|
+
level: node_zlib_1.default.constants.Z_DEFAULT_COMPRESSION, // Use default compression level (6)
|
|
1216
|
+
memLevel: 8, // Memory usage level (8 is default)
|
|
1217
|
+
strategy: node_zlib_1.default.constants.Z_DEFAULT_STRATEGY,
|
|
1218
|
+
chunkSize: 16 * 1024 // Process in 16KB chunks to prevent CPU blocking
|
|
1219
|
+
});
|
|
1220
|
+
this._inflate = node_zlib_1.default.createInflateRaw({
|
|
1221
|
+
chunkSize: 16 * 1024 // Process in 16KB chunks to prevent CPU blocking
|
|
1222
|
+
});
|
|
1223
|
+
const socket = this.socket;
|
|
1224
|
+
// Reroute incoming data through inflate: socket -> inflate -> streamer.
|
|
1225
|
+
// The streamer's compress flag tells it to expect deflated framing.
|
|
1226
|
+
socket.unpipe(this.streamer);
|
|
1227
|
+
this.streamer.compress = true;
|
|
1228
|
+
socket.pipe(this._inflate).pipe(this.streamer);
|
|
1229
|
+
this._inflate.on('error', err => {
|
|
1230
|
+
// Only forward into the streamer while it is alive and still has an error
|
|
1231
|
+
// listener. After close() the streamer is destroyed and its listener removed,
|
|
1232
|
+
// so emitting 'error' would throw an unhandled error and crash the process.
|
|
1233
|
+
// (this.streamer is assigned once in the constructor and never nulled.)
|
|
1234
|
+
if (!this.streamer.destroyed && this.streamer.listenerCount('error')) {
|
|
1235
|
+
this.streamer.emit('error', err);
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
1238
|
+
// For outgoing data, replace the writeSocket with a PassThrough buffer.
|
|
1239
|
+
// We can't pipe writeSocket -> deflate -> socket directly because we need
|
|
1240
|
+
// to call deflate.flush() after each IMAP command to push all pending
|
|
1241
|
+
// compressed bytes to the server immediately (IMAP is request-response).
|
|
1242
|
+
const writeSocket = new node_stream_1.PassThrough({
|
|
1243
|
+
highWaterMark: 64 * 1024 // 64KB buffer limit to prevent excessive memory usage
|
|
1244
|
+
});
|
|
1245
|
+
this.writeSocket = writeSocket;
|
|
1246
|
+
/* c8 ignore start */ // destroySoon override is never invoked by ImapFlow (close() calls destroy()); kept for stream API completeness
|
|
1247
|
+
writeSocket.destroySoon = () => {
|
|
1248
|
+
try {
|
|
1249
|
+
if (this.socket) {
|
|
1250
|
+
this.socket.destroy();
|
|
1251
|
+
}
|
|
1252
|
+
writeSocket.end();
|
|
1253
|
+
}
|
|
1254
|
+
catch (err) {
|
|
1255
|
+
this.log.error({ err, msg: 'Failed to destroy PassThrough socket', cid: this.id });
|
|
1256
|
+
throw err;
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
/* c8 ignore stop */
|
|
1260
|
+
// The PassThrough reports its own `destroyed` state. It used to proxy the raw socket's
|
|
1261
|
+
// instead, which made close() skip destroying it and left the second raw-socket teardown
|
|
1262
|
+
// branch unreachable. write() checks the raw socket separately, so nothing depends on the
|
|
1263
|
+
// two states being conflated.
|
|
1264
|
+
// Manual pump loop: reads chunks from writeSocket, pushes them into
|
|
1265
|
+
// deflate, and flushes when the buffer is drained. This ensures each
|
|
1266
|
+
// IMAP command is fully compressed and flushed to the socket immediately.
|
|
1267
|
+
let reading = false;
|
|
1268
|
+
let processedChunks = 0;
|
|
1269
|
+
let readNext = async () => {
|
|
1270
|
+
try {
|
|
1271
|
+
reading = true;
|
|
1272
|
+
processedChunks = 0;
|
|
1273
|
+
let chunk;
|
|
1274
|
+
while (this.writeSocket && (chunk = this.writeSocket.read()) !== null) {
|
|
1275
|
+
if (this._deflate && this._deflate.write(chunk) === false) {
|
|
1276
|
+
this._deflate.once('drain', readNext);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
// Yield to event loop every 100 chunks to prevent CPU blocking
|
|
1280
|
+
processedChunks++;
|
|
1281
|
+
/* c8 ignore next 6 */ // requires 100+ queued chunks in a single pump pass; not reproducible deterministically
|
|
1282
|
+
if (processedChunks % 100 === 0) {
|
|
1283
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
1284
|
+
if (!this.writeSocket) {
|
|
1285
|
+
break;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
// flush data to socket
|
|
1290
|
+
if (this._deflate) {
|
|
1291
|
+
this._deflate.flush();
|
|
1292
|
+
}
|
|
1293
|
+
reading = false;
|
|
1294
|
+
/* c8 ignore next 3 */ // defensive: the pump body does not throw under normal operation
|
|
1295
|
+
}
|
|
1296
|
+
catch (ex) {
|
|
1297
|
+
this.emitError(ex);
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
writeSocket.on('readable', () => {
|
|
1301
|
+
if (!reading && this.writeSocket) {
|
|
1302
|
+
readNext();
|
|
1303
|
+
}
|
|
1304
|
+
});
|
|
1305
|
+
writeSocket.on('error', err => {
|
|
1306
|
+
if (this.socket) {
|
|
1307
|
+
this.socket.emit('error', err);
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
this._deflate.pipe(socket);
|
|
1311
|
+
this._deflate.on('error', err => {
|
|
1312
|
+
if (this.socket) {
|
|
1313
|
+
this.socket.emit('error', err);
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
/** @internal */
|
|
1318
|
+
_failSTARTTLS() {
|
|
1319
|
+
if (this.options.doSTARTTLS === true) {
|
|
1320
|
+
// STARTTLS configured as requirement
|
|
1321
|
+
let err = new Error('Server does not support STARTTLS');
|
|
1322
|
+
err.tlsFailed = true;
|
|
1323
|
+
throw err;
|
|
1324
|
+
}
|
|
1325
|
+
// Opportunistic STARTTLS. But it's not possible right now.
|
|
1326
|
+
// Attention: Could be a downgrade attack.
|
|
1327
|
+
return false;
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Tries to upgrade the connection to TLS using STARTTLS.
|
|
1331
|
+
* @throws if STARTTLS is required, but not possible.
|
|
1332
|
+
* @returns true, if the connection is now protected by TLS, either direct TLS or STARTTLS.
|
|
1333
|
+
*/
|
|
1334
|
+
async upgradeToSTARTTLS() {
|
|
1335
|
+
if (this.options.doSTARTTLS === true && this.options.secure === true) {
|
|
1336
|
+
throw new Error('Misconfiguration: Cannot set both secure=true for TLS and doSTARTTLS=true for STARTTLS.');
|
|
1337
|
+
}
|
|
1338
|
+
if (this.secureConnection) {
|
|
1339
|
+
// Already using direct TLS. No need for STARTTLS.
|
|
1340
|
+
return true;
|
|
1341
|
+
}
|
|
1342
|
+
if (this.options.doSTARTTLS === false) {
|
|
1343
|
+
// STARTTLS explictly disabled by config
|
|
1344
|
+
return false;
|
|
1345
|
+
}
|
|
1346
|
+
if (!this.capabilities.has('STARTTLS')) {
|
|
1347
|
+
return this._failSTARTTLS();
|
|
1348
|
+
}
|
|
1349
|
+
this.expectCapabilityUpdate = true;
|
|
1350
|
+
let canUpgrade = await this.run('STARTTLS');
|
|
1351
|
+
if (!canUpgrade) {
|
|
1352
|
+
return this._failSTARTTLS();
|
|
1353
|
+
}
|
|
1354
|
+
// STARTTLS plaintext-injection guard (RFC 3501 section 6.2.1): a compliant server stays
|
|
1355
|
+
// silent after the tagged STARTTLS OK until the TLS handshake, so any data that
|
|
1356
|
+
// followed the OK was injected by a MITM and must not be treated as if it arrived
|
|
1357
|
+
// over TLS. Two complementary best-effort checks fail closed before wrapping the
|
|
1358
|
+
// socket; injection that still races in afterwards corrupts the TLS handshake and
|
|
1359
|
+
// is rejected there instead (with a generic TLS error rather than STARTTLS_INJECTION).
|
|
1360
|
+
const failSTARTTLSInjection = () => {
|
|
1361
|
+
let err = new Error('Server sent data after the STARTTLS response and before the TLS handshake; possible plaintext-injection attack');
|
|
1362
|
+
err.code = 'STARTTLS_INJECTION';
|
|
1363
|
+
err.tlsFailed = true;
|
|
1364
|
+
this.closeAfter();
|
|
1365
|
+
return err;
|
|
1366
|
+
};
|
|
1367
|
+
// Check 1: the parser saw more input already buffered right after the tagged OK
|
|
1368
|
+
// (same TCP segment, or an already-queued chunk) - see hasTrailingData / starttls.ts.
|
|
1369
|
+
if (this._starttlsHadTrailingData) {
|
|
1370
|
+
throw failSTARTTLSInjection();
|
|
1371
|
+
}
|
|
1372
|
+
const socketPlain = this.socket;
|
|
1373
|
+
// STARTTLS upgrade sequence: detach the plain socket from the parser,
|
|
1374
|
+
// wrap it in a TLS socket, then reconnect the new TLS socket to the
|
|
1375
|
+
// parser. The plain socket becomes the underlying transport for TLS.
|
|
1376
|
+
socketPlain.unpipe(this.streamer);
|
|
1377
|
+
// Check 2: now that the parser is detached, any bytes still buffered on the plain
|
|
1378
|
+
// socket arrived after the OK and were not consumed by the handshake - i.e. injected.
|
|
1379
|
+
// This catches late/fragmented injection that the parse-time snapshot cannot see.
|
|
1380
|
+
let injectedTail = typeof socketPlain.read === 'function' ? socketPlain.read() : null;
|
|
1381
|
+
/* c8 ignore next 3 */ // late/fragmented post-OK injection is timing-dependent and not deterministically reproducible
|
|
1382
|
+
if (injectedTail && injectedTail.length) {
|
|
1383
|
+
throw failSTARTTLSInjection();
|
|
1384
|
+
}
|
|
1385
|
+
let upgraded = await new Promise((resolve, reject) => {
|
|
1386
|
+
let opts = Object.assign({
|
|
1387
|
+
socket: socketPlain,
|
|
1388
|
+
// host is required even though the socket is already connected: without
|
|
1389
|
+
// it, a connection made to an IP literal (servername=false) has its
|
|
1390
|
+
// certificate verified against Node's fallback name "localhost" instead
|
|
1391
|
+
// of the IP - accepting any "localhost" certificate for any IP-hosted
|
|
1392
|
+
// server, and rejecting legitimate IP-SAN certificates.
|
|
1393
|
+
host: this.host,
|
|
1394
|
+
servername: this.servername,
|
|
1395
|
+
port: this.port
|
|
1396
|
+
}, this.options.tls || {});
|
|
1397
|
+
this.clearSocketHandlers();
|
|
1398
|
+
let settled = false;
|
|
1399
|
+
// Single settlement path for the upgrade. Every terminal outcome - handshake
|
|
1400
|
+
// success, an error on the plain or the TLS socket, the upgrade timeout, an
|
|
1401
|
+
// explicit close(), or a streamer error routed here by emitError() - goes through
|
|
1402
|
+
// this helper exactly once. It owns clearing the upgrade timer, the exposed
|
|
1403
|
+
// rejector, the `upgrading` flag and the temporary handshake handlers, so a late
|
|
1404
|
+
// socket event cannot re-enter an already settled upgrade or leave state behind.
|
|
1405
|
+
const settle = (err, result) => {
|
|
1406
|
+
if (settled) {
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
settled = true;
|
|
1410
|
+
(0, tools_js_1.clearTimer)(this.upgradeTimeout);
|
|
1411
|
+
this.upgradeTimeout = null;
|
|
1412
|
+
this.upgrading = false;
|
|
1413
|
+
this._upgradeReject = null;
|
|
1414
|
+
socketPlain.removeListener('error', settle);
|
|
1415
|
+
if (this.socket && this.socket !== socketPlain) {
|
|
1416
|
+
this.socket.removeListener('error', settle);
|
|
1417
|
+
}
|
|
1418
|
+
if (err) {
|
|
1419
|
+
(0, tools_js_1.clearTimer)(this.connectTimeout);
|
|
1420
|
+
// Preserve the original error, marked as a TLS failure so callers can tell
|
|
1421
|
+
// an upgrade failure from an ordinary command failure.
|
|
1422
|
+
err.tlsFailed = true;
|
|
1423
|
+
this.closeAfter();
|
|
1424
|
+
return reject(err);
|
|
1425
|
+
}
|
|
1426
|
+
resolve(result);
|
|
1427
|
+
};
|
|
1428
|
+
// Exposed so emitError() and close() can settle the upgrade through the same path.
|
|
1429
|
+
this._upgradeReject = settle;
|
|
1430
|
+
// An error on either socket settles the upgrade, so settle() is the listener itself:
|
|
1431
|
+
// one function, one settlement, and removeListener() in settle() needs no separate
|
|
1432
|
+
// handler references. A TLS handshake failure (bad certificate, protocol mismatch)
|
|
1433
|
+
// is emitted on the new TLS socket rather than on the plain one, so both are covered.
|
|
1434
|
+
socketPlain.once('error', settle);
|
|
1435
|
+
/* c8 ignore start */ // UPGRADE_TIMEOUT is 10s; firing it deterministically would make the test suite hang
|
|
1436
|
+
this.upgradeTimeout = setTimeout(() => {
|
|
1437
|
+
let err = new Error('Failed to upgrade connection in required time');
|
|
1438
|
+
err.code = 'UPGRADE_TIMEOUT';
|
|
1439
|
+
settle(err);
|
|
1440
|
+
}, UPGRADE_TIMEOUT);
|
|
1441
|
+
/* c8 ignore stop */
|
|
1442
|
+
this.upgrading = true;
|
|
1443
|
+
let tlsSocket;
|
|
1444
|
+
try {
|
|
1445
|
+
tlsSocket = node_tls_1.default.connect(opts, () => {
|
|
1446
|
+
try {
|
|
1447
|
+
/* c8 ignore start */ // race: connection closed during the TLS handshake window
|
|
1448
|
+
if (this.isClosed) {
|
|
1449
|
+
return settle(this.createNoConnectionError(false, { rejectedFrom: 'tlsUpgrade' }));
|
|
1450
|
+
}
|
|
1451
|
+
/* c8 ignore stop */
|
|
1452
|
+
// TLS handshake complete. Reconnect the now-encrypted socket
|
|
1453
|
+
// to the IMAP parser stream and record the cipher details.
|
|
1454
|
+
this.secureConnection = true;
|
|
1455
|
+
this.streamer.secureConnection = true;
|
|
1456
|
+
tlsSocket.pipe(this.streamer);
|
|
1457
|
+
// Cloudflare Workers expose getCipher() but return null from it, so the
|
|
1458
|
+
// result is normalized to the documented `false`
|
|
1459
|
+
/* c8 ignore next */ // on Node an upgraded TLS socket always answers getCipher(), so the false fallback is unreachable
|
|
1460
|
+
this.tls = (typeof tlsSocket.getCipher === 'function' && tlsSocket.getCipher()) || false;
|
|
1461
|
+
if (this.tls) {
|
|
1462
|
+
this.tls.authorized = tlsSocket.authorized;
|
|
1463
|
+
this.log.info({
|
|
1464
|
+
src: 'tls',
|
|
1465
|
+
msg: 'Established TLS session',
|
|
1466
|
+
cid: this.id,
|
|
1467
|
+
authorized: this.tls.authorized,
|
|
1468
|
+
/* c8 ignore next */ // cipher.standardName is present on modern Node, so the .name fallback rarely runs
|
|
1469
|
+
algo: this.tls.standardName || this.tls.name,
|
|
1470
|
+
version: this.tls.version
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
// The plain socket is now only the TLS transport: drop its superseded
|
|
1474
|
+
// inactivity timer so no armed timer is left behind without a listener.
|
|
1475
|
+
if (typeof socketPlain.setTimeout === 'function') {
|
|
1476
|
+
socketPlain.setTimeout(0);
|
|
1477
|
+
}
|
|
1478
|
+
// Install the normal socket handlers only now that the handshake
|
|
1479
|
+
// succeeded. Doing this during the handshake would leave both settle() and
|
|
1480
|
+
// the generic _socketError on the socket; a handshake 'error' would then fire
|
|
1481
|
+
// BOTH (EventEmitter clones its listener array on emit), causing a duplicate
|
|
1482
|
+
// error and a possible unhandled 'error' crash. Keeping settle() as the sole
|
|
1483
|
+
// listener until here guarantees a single error path for the upgrade.
|
|
1484
|
+
this.setSocketHandlers();
|
|
1485
|
+
// Arm the inactivity watchdog on the socket that now carries the session.
|
|
1486
|
+
// Without this a STARTTLS-upgraded connection has no watchdog at all: the
|
|
1487
|
+
// timer was armed on the plain socket, while the timeout listener lives on
|
|
1488
|
+
// the TLS socket.
|
|
1489
|
+
this.configureSocket(this.socket);
|
|
1490
|
+
// settle() also removes the temporary handshake handlers
|
|
1491
|
+
settle(null, true);
|
|
1492
|
+
/* c8 ignore next 3 */ // defensive: the success callback body does not throw under normal operation
|
|
1493
|
+
}
|
|
1494
|
+
catch (ex) {
|
|
1495
|
+
this.emitError(ex);
|
|
1496
|
+
}
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
catch (err) {
|
|
1500
|
+
// tls.connect() refused the upgrade before any handshake (an option the runtime
|
|
1501
|
+
// does not implement, a socket it can not wrap). Settled through the same path
|
|
1502
|
+
// as a handshake failure, so the upgrade state and its timer are cleared and
|
|
1503
|
+
// the error is marked as a TLS failure rather than escaping the executor.
|
|
1504
|
+
settle(err);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
this.socket = tlsSocket;
|
|
1508
|
+
// Registered after tls.connect (the TLS socket now exists). This is the ONLY
|
|
1509
|
+
// error listener during the handshake window; the generic handlers are installed
|
|
1510
|
+
// by setSocketHandlers() inside the success callback above, so a handshake error
|
|
1511
|
+
// has a single error path.
|
|
1512
|
+
tlsSocket.once('error', settle);
|
|
1513
|
+
this.writeSocket = tlsSocket;
|
|
1514
|
+
});
|
|
1515
|
+
if (upgraded) {
|
|
1516
|
+
// RFC 9051 section 6.2.1: once TLS is started the client MUST discard the
|
|
1517
|
+
// cached capabilities and reissue CAPABILITY, because everything learned
|
|
1518
|
+
// before the handshake was plaintext an active attacker could rewrite.
|
|
1519
|
+
// Unconditional on purpose: a server that stamps [CAPABILITY ...] on the
|
|
1520
|
+
// STARTTLS OK itself clears expectCapabilityUpdate, so keying the discard
|
|
1521
|
+
// on that flag would keep exactly the pre-TLS list an attacker controls -
|
|
1522
|
+
// the list that then picks the AUTH mechanism and answers LOGINDISABLED.
|
|
1523
|
+
this.clearCapabilities();
|
|
1524
|
+
await this.run('CAPABILITY');
|
|
1525
|
+
}
|
|
1526
|
+
return upgraded;
|
|
1527
|
+
}
|
|
1528
|
+
/** @internal */
|
|
1529
|
+
async setAuthenticationState() {
|
|
1530
|
+
this.state = this.states.AUTHENTICATED;
|
|
1531
|
+
this.authenticated = true;
|
|
1532
|
+
if (this.expectCapabilityUpdate) {
|
|
1533
|
+
// update capabilities
|
|
1534
|
+
await this.run('CAPABILITY');
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
/** @internal */
|
|
1538
|
+
async authenticate() {
|
|
1539
|
+
if (this.state === this.states.LOGOUT) {
|
|
1540
|
+
throw new errors_js_1.AuthenticationFailure('Already logged out');
|
|
1541
|
+
}
|
|
1542
|
+
if (this.state !== this.states.NOT_AUTHENTICATED) {
|
|
1543
|
+
// nothing to do here, usually happens with PREAUTH greeting
|
|
1544
|
+
return true;
|
|
1545
|
+
}
|
|
1546
|
+
if (!this.options.auth) {
|
|
1547
|
+
throw new errors_js_1.AuthenticationFailure('Please configure the login');
|
|
1548
|
+
}
|
|
1549
|
+
this.expectCapabilityUpdate = true;
|
|
1550
|
+
let loginMethod = (this.options.auth.loginMethod || '').toString().trim().toUpperCase();
|
|
1551
|
+
if (!loginMethod && /\\|\//.test(this.options.auth.user)) {
|
|
1552
|
+
// Special override for MS Exchange when authenticating as some other user or non-email account
|
|
1553
|
+
loginMethod = 'LOGIN';
|
|
1554
|
+
}
|
|
1555
|
+
if (this.options.auth.accessToken) {
|
|
1556
|
+
this.authenticated = await this.run('AUTHENTICATE', this.options.auth.user, { accessToken: this.options.auth.accessToken });
|
|
1557
|
+
}
|
|
1558
|
+
else if (this.options.auth.pass) {
|
|
1559
|
+
if ((this.capabilities.has('AUTH=LOGIN') || this.capabilities.has('AUTH=PLAIN')) && loginMethod !== 'LOGIN') {
|
|
1560
|
+
this.authenticated = await this.run('AUTHENTICATE', this.options.auth.user, {
|
|
1561
|
+
password: this.options.auth.pass,
|
|
1562
|
+
loginMethod,
|
|
1563
|
+
authzid: this.options.auth.authzid
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
else {
|
|
1567
|
+
if (this.capabilities.has('LOGINDISABLED')) {
|
|
1568
|
+
throw new errors_js_1.AuthenticationFailure('Login is disabled');
|
|
1569
|
+
}
|
|
1570
|
+
this.authenticated = await this.run('LOGIN', this.options.auth.user, this.options.auth.pass);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
else {
|
|
1574
|
+
throw new errors_js_1.AuthenticationFailure('No password configured');
|
|
1575
|
+
}
|
|
1576
|
+
if (this.authenticated) {
|
|
1577
|
+
this.log.info({
|
|
1578
|
+
src: 'auth',
|
|
1579
|
+
msg: 'User authenticated',
|
|
1580
|
+
cid: this.id,
|
|
1581
|
+
user: this.options.auth.user
|
|
1582
|
+
});
|
|
1583
|
+
await this.setAuthenticationState();
|
|
1584
|
+
return true;
|
|
1585
|
+
}
|
|
1586
|
+
throw new errors_js_1.AuthenticationFailure('No matching authentication method');
|
|
1587
|
+
}
|
|
1588
|
+
/** @internal */
|
|
1589
|
+
beginSession(onUnhandledError) {
|
|
1590
|
+
(0, tools_js_1.clearTimer)(this.greetingTimeout);
|
|
1591
|
+
this.untaggedHandlers.OK = null;
|
|
1592
|
+
this.untaggedHandlers.PREAUTH = null;
|
|
1593
|
+
if (this.isClosed) {
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
// get out of current parsing "thread", so do not await for startSession
|
|
1597
|
+
this.startSession()
|
|
1598
|
+
.then(() => {
|
|
1599
|
+
if (typeof this.initialResolve === 'function') {
|
|
1600
|
+
let resolve = this.initialResolve;
|
|
1601
|
+
this.initialResolve = false;
|
|
1602
|
+
this.initialReject = false;
|
|
1603
|
+
return resolve();
|
|
1604
|
+
}
|
|
1605
|
+
})
|
|
1606
|
+
.catch(err => {
|
|
1607
|
+
this.log.error({ err, cid: this.id });
|
|
1608
|
+
if (typeof this.initialReject === 'function') {
|
|
1609
|
+
(0, tools_js_1.clearTimer)(this.greetingTimeout);
|
|
1610
|
+
let reject = this.initialReject;
|
|
1611
|
+
this.initialResolve = false;
|
|
1612
|
+
this.initialReject = false;
|
|
1613
|
+
return reject(err);
|
|
1614
|
+
}
|
|
1615
|
+
onUnhandledError(err);
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
/** @internal */
|
|
1619
|
+
async initialOK(message) {
|
|
1620
|
+
this.greeting = (message.attributes || [])
|
|
1621
|
+
.filter(entry => entry.type === 'TEXT')
|
|
1622
|
+
.map(entry => entry.value)
|
|
1623
|
+
.filter(entry => entry)
|
|
1624
|
+
.join('');
|
|
1625
|
+
// ALWAYS emit the error so users can handle it
|
|
1626
|
+
this.beginSession(err => this.emitError(err));
|
|
1627
|
+
}
|
|
1628
|
+
/** @internal */
|
|
1629
|
+
async initialPREAUTH() {
|
|
1630
|
+
if (this.isClosed) {
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
this.state = this.states.AUTHENTICATED;
|
|
1634
|
+
// documented contract for the `authenticated` property: `true` when the
|
|
1635
|
+
// connection was authenticated by a PREAUTH greeting (no credentials known)
|
|
1636
|
+
this.authenticated = true;
|
|
1637
|
+
this.beginSession(err => {
|
|
1638
|
+
this.log.error({ err, cid: this.id });
|
|
1639
|
+
this.closeAfter();
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
/** @internal */
|
|
1643
|
+
async serverBye(parsed) {
|
|
1644
|
+
// Extract BYE reason from response for better error messages
|
|
1645
|
+
let reason = parsed &&
|
|
1646
|
+
parsed.attributes &&
|
|
1647
|
+
parsed.attributes
|
|
1648
|
+
.filter(val => val.type === 'TEXT')
|
|
1649
|
+
.map(val => val.value.trim())
|
|
1650
|
+
.join(' ');
|
|
1651
|
+
this.byeReason = reason || 'Server closed connection';
|
|
1652
|
+
this.untaggedHandlers.BYE = null;
|
|
1653
|
+
this.state = this.states.LOGOUT;
|
|
1654
|
+
}
|
|
1655
|
+
// Drops every capability-derived field together - the counterpart of
|
|
1656
|
+
// updateCapabilitiesFromRaw() below, which sets them together. rawCapabilities is
|
|
1657
|
+
// public surface external consumers read, so a discard (RFC 9051 6.2.1 requires
|
|
1658
|
+
// one after STARTTLS) that missed it would leave the stale list visible if the
|
|
1659
|
+
// re-fetch fails.
|
|
1660
|
+
/** @internal */
|
|
1661
|
+
clearCapabilities() {
|
|
1662
|
+
this.capabilities.clear();
|
|
1663
|
+
this.authCapabilities.clear();
|
|
1664
|
+
this.rawCapabilities = null;
|
|
1665
|
+
}
|
|
1666
|
+
/** @internal */
|
|
1667
|
+
updateCapabilitiesFromRaw(rawCapabilities) {
|
|
1668
|
+
this.rawCapabilities = rawCapabilities;
|
|
1669
|
+
this.capabilities = (0, tools_js_1.updateCapabilities)(rawCapabilities);
|
|
1670
|
+
if (this.capabilities) {
|
|
1671
|
+
for (let [capa] of this.capabilities) {
|
|
1672
|
+
if (/^AUTH=/i.test(capa) && !this.authCapabilities.has(capa.toUpperCase())) {
|
|
1673
|
+
this.authCapabilities.set(capa.toUpperCase(), false);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
if (this.expectCapabilityUpdate) {
|
|
1678
|
+
this.expectCapabilityUpdate = false;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
/** @internal */
|
|
1682
|
+
async sectionCapability(section) {
|
|
1683
|
+
this.updateCapabilitiesFromRaw(section);
|
|
1684
|
+
}
|
|
1685
|
+
/** @internal */
|
|
1686
|
+
async untaggedCapability(untagged) {
|
|
1687
|
+
this.updateCapabilitiesFromRaw(untagged.attributes);
|
|
1688
|
+
}
|
|
1689
|
+
/** @internal */
|
|
1690
|
+
async untaggedExists(untagged) {
|
|
1691
|
+
if (!this.mailbox) {
|
|
1692
|
+
// mailbox closed, ignore
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
if (!untagged) {
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
// Not a usable count: anything but a bounded digit run. A digit run long enough
|
|
1699
|
+
// coerces to Infinity, which would corrupt mailbox state (resolveRange('*') would
|
|
1700
|
+
// compile to the literal "Infinity" and every range-based command would fail until
|
|
1701
|
+
// the next SELECT)
|
|
1702
|
+
let count = (0, tools_js_1.parseUintValue)(untagged.command, tools_js_1.MAX_UINT32_DIGITS);
|
|
1703
|
+
if (count === false) {
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
if (count === this.mailbox.exists) {
|
|
1707
|
+
// nothing changed?
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
// keep exists up to date
|
|
1711
|
+
let prevCount = this.mailbox.exists;
|
|
1712
|
+
this.mailbox.exists = count;
|
|
1713
|
+
this.emit('exists', {
|
|
1714
|
+
path: this.mailbox.path,
|
|
1715
|
+
count,
|
|
1716
|
+
prevCount
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
// Reports one expunged message, either through the caller's expungeHandler or as an
|
|
1720
|
+
// 'expunge' event. Shared by the EXPUNGE and VANISHED paths so the two cannot drift.
|
|
1721
|
+
/** @internal */
|
|
1722
|
+
async notifyExpunge(payload) {
|
|
1723
|
+
if (typeof this.options.expungeHandler !== 'function') {
|
|
1724
|
+
this.emit('expunge', payload);
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
try {
|
|
1728
|
+
await this.options.expungeHandler(payload);
|
|
1729
|
+
}
|
|
1730
|
+
catch (err) {
|
|
1731
|
+
// The throw comes from the caller's own handler, not from this library
|
|
1732
|
+
this.log.error({ msg: 'Failed to notify expunge event', payload, err, cid: this.id });
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
/** @internal */
|
|
1736
|
+
async untaggedExpunge(untagged) {
|
|
1737
|
+
if (!this.mailbox) {
|
|
1738
|
+
// mailbox closed, ignore
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
if (!untagged) {
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
// Same bound untaggedExists() applies: only a bounded decimal run is a usable sequence number
|
|
1745
|
+
let seq = (0, tools_js_1.parseUintValue)(untagged.command, tools_js_1.MAX_UINT32_DIGITS);
|
|
1746
|
+
if (seq && seq <= this.mailbox.exists) {
|
|
1747
|
+
this.mailbox.exists--;
|
|
1748
|
+
let payload = {
|
|
1749
|
+
path: this.mailbox.path,
|
|
1750
|
+
seq,
|
|
1751
|
+
vanished: false
|
|
1752
|
+
};
|
|
1753
|
+
await this.notifyExpunge(payload);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
/** @internal */
|
|
1757
|
+
async untaggedVanished(untagged, mailbox) {
|
|
1758
|
+
mailbox = mailbox || this.mailbox;
|
|
1759
|
+
if (!mailbox) {
|
|
1760
|
+
// mailbox closed, ignore
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
let tags = [];
|
|
1764
|
+
let uids = false;
|
|
1765
|
+
// A malformed VANISHED can carry no attributes at all, and one carrying only the
|
|
1766
|
+
// (EARLIER) tag leaves `uids` false - expandRange() handles that and yields nothing
|
|
1767
|
+
if (!untagged.attributes || !untagged.attributes.length) {
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
if (untagged.attributes.length > 1 && Array.isArray(untagged.attributes[0])) {
|
|
1771
|
+
tags = (0, tools_js_1.getStringList)(untagged.attributes[0]).map(value => value.toUpperCase());
|
|
1772
|
+
untagged.attributes.shift();
|
|
1773
|
+
}
|
|
1774
|
+
if (untagged.attributes[0] && typeof untagged.attributes[0].value === 'string') {
|
|
1775
|
+
uids = untagged.attributes[0].value;
|
|
1776
|
+
}
|
|
1777
|
+
let uidList = (0, tools_js_1.expandRange)(uids);
|
|
1778
|
+
for (let uid of uidList) {
|
|
1779
|
+
let payload = {
|
|
1780
|
+
path: mailbox.path,
|
|
1781
|
+
uid,
|
|
1782
|
+
vanished: true,
|
|
1783
|
+
earlier: tags.includes('EARLIER')
|
|
1784
|
+
};
|
|
1785
|
+
await this.notifyExpunge(payload);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
/** @internal */
|
|
1789
|
+
async untaggedFetch(untagged, mailbox) {
|
|
1790
|
+
mailbox = mailbox || this.mailbox;
|
|
1791
|
+
if (!mailbox) {
|
|
1792
|
+
// mailbox closed, ignore
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
let message = await (0, tools_js_1.formatMessageResponse)(untagged, mailbox);
|
|
1796
|
+
if (message.flags) {
|
|
1797
|
+
let updateEvent = {
|
|
1798
|
+
path: mailbox.path,
|
|
1799
|
+
seq: message.seq
|
|
1800
|
+
};
|
|
1801
|
+
if (message.uid) {
|
|
1802
|
+
updateEvent.uid = message.uid;
|
|
1803
|
+
}
|
|
1804
|
+
if (message.modseq) {
|
|
1805
|
+
updateEvent.modseq = message.modseq;
|
|
1806
|
+
}
|
|
1807
|
+
updateEvent.flags = message.flags;
|
|
1808
|
+
if (message.flagColor) {
|
|
1809
|
+
updateEvent.flagColor = message.flagColor;
|
|
1810
|
+
}
|
|
1811
|
+
this.emit('flags', updateEvent);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
/** @internal */
|
|
1815
|
+
async ensureSelectedMailbox(path) {
|
|
1816
|
+
if (!path) {
|
|
1817
|
+
return false;
|
|
1818
|
+
}
|
|
1819
|
+
if (!this.mailbox || !(0, tools_js_1.comparePaths)(this, this.mailbox.path, Array.isArray(path) ? (0, tools_js_1.normalizePath)(this, path) : path)) {
|
|
1820
|
+
return await this.mailboxOpen(path);
|
|
1821
|
+
}
|
|
1822
|
+
return true;
|
|
1823
|
+
}
|
|
1824
|
+
// Normalizes a message range from various input formats into an IMAP-compatible
|
|
1825
|
+
// sequence string (e.g., "1:5,7,10:*"). Handles: numbers, "*", {all:true},
|
|
1826
|
+
// {uid:value}, search query objects (resolved via SEARCH), and arrays of numbers.
|
|
1827
|
+
/** @internal */
|
|
1828
|
+
async resolveRange(range, options) {
|
|
1829
|
+
let value = range;
|
|
1830
|
+
if (typeof value === 'number' || typeof value === 'bigint') {
|
|
1831
|
+
value = value.toString();
|
|
1832
|
+
}
|
|
1833
|
+
// Replace "*" with the actual message count. Some servers reject bare "*"
|
|
1834
|
+
// in certain commands, and this also forces a sequence query (not UID).
|
|
1835
|
+
if (value === '*') {
|
|
1836
|
+
if (!this.mailbox.exists) {
|
|
1837
|
+
return false;
|
|
1838
|
+
}
|
|
1839
|
+
value = this.mailbox.exists.toString();
|
|
1840
|
+
options.uid = false; // sequence query
|
|
1841
|
+
}
|
|
1842
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
1843
|
+
if (value.all && Object.keys(value).length === 1) {
|
|
1844
|
+
value = '1:*';
|
|
1845
|
+
}
|
|
1846
|
+
else if (value.uid && Object.keys(value).length === 1) {
|
|
1847
|
+
value = value.uid;
|
|
1848
|
+
options.uid = true;
|
|
1849
|
+
}
|
|
1850
|
+
else {
|
|
1851
|
+
// Arbitrary search query object: run SEARCH to resolve it into
|
|
1852
|
+
// a set of UIDs, then pack into a compact range string.
|
|
1853
|
+
options.uid = true; // force UIDs instead of sequence numbers
|
|
1854
|
+
value = await this.run('SEARCH', value, options);
|
|
1855
|
+
if (value && value.length) {
|
|
1856
|
+
value = (0, tools_js_1.packMessageRange)(value);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
if (Array.isArray(value)) {
|
|
1861
|
+
value = value.join(',');
|
|
1862
|
+
}
|
|
1863
|
+
if (!value) {
|
|
1864
|
+
return false;
|
|
1865
|
+
}
|
|
1866
|
+
return value;
|
|
1867
|
+
}
|
|
1868
|
+
// The single definition of "the connection is not free". A held or queued mailbox lock, a
|
|
1869
|
+
// command in flight or queued, and an open download stream all mean a caller is
|
|
1870
|
+
// mid-sequence: starting IDLE there injects an IDLE/DONE round trip - or, with
|
|
1871
|
+
// `missingIdleCommand` set to SELECT or STATUS, a mailbox poll - between two of that
|
|
1872
|
+
// caller's own commands. Every one of those states ends by calling autoidle() again, so
|
|
1873
|
+
// declining while busy postpones IDLE, it never cancels it.
|
|
1874
|
+
/** @internal */
|
|
1875
|
+
connectionBusy() {
|
|
1876
|
+
return !!(this.currentLock || this.locks.length || this.currentRequest || this.requestQueue.length || this._openDownloads);
|
|
1877
|
+
}
|
|
1878
|
+
// Timer process-liveness policy: connection establishment and greeting deadlines keep the
|
|
1879
|
+
// process alive, because a caller is waiting on connect() to settle. Background timers
|
|
1880
|
+
// (auto-IDLE, IDLE restart, fallback polling, throttle back-off, the held-lock diagnostic) are
|
|
1881
|
+
// unref'd, so an otherwise idle process is not held open by them. Every timer is still cleared
|
|
1882
|
+
// explicitly on close().
|
|
1883
|
+
/** @internal */
|
|
1884
|
+
autoidle() {
|
|
1885
|
+
(0, tools_js_1.clearTimer)(this.idleStartTimer);
|
|
1886
|
+
if (this.options.disableAutoIdle || this.state !== this.states.SELECTED) {
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
if (this.connectionBusy()) {
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
this.idleStartTimer = setTimeout(() => {
|
|
1893
|
+
// Re-checked at fire time: paths that take ownership of the connection clear this
|
|
1894
|
+
// timer, but the guard must not depend on every one of them doing so - a single
|
|
1895
|
+
// missed clearTimeout would inject IDLE between a caller's own commands. Declining
|
|
1896
|
+
// postpones rather than cancels: whatever made the connection busy calls autoidle()
|
|
1897
|
+
// again when it finishes.
|
|
1898
|
+
if (this.state !== this.states.SELECTED || this.connectionBusy()) {
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
this.idle().catch(err => (0, tools_js_1.logConnectionError)(this, 'Auto-IDLE failed', err));
|
|
1902
|
+
}, this.autoIdleDelay);
|
|
1903
|
+
(0, tools_js_1.unrefTimer)(this.idleStartTimer);
|
|
1904
|
+
}
|
|
1905
|
+
// PUBLIC API METHODS
|
|
1906
|
+
/**
|
|
1907
|
+
* Initiates a connection against IMAP server. Throws if anything goes wrong. This is something you have to call before you can run any IMAP commands
|
|
1908
|
+
*
|
|
1909
|
+
* @throws Will throw an error if connection or authentication fails
|
|
1910
|
+
* @example
|
|
1911
|
+
* let client = new ImapFlow({...});
|
|
1912
|
+
* await client.connect();
|
|
1913
|
+
*/
|
|
1914
|
+
async connect() {
|
|
1915
|
+
if (this._connectCalled) {
|
|
1916
|
+
// Prevent re-using ImapFlow instances by allowing to call connect just once.
|
|
1917
|
+
throw new Error('Can not re-use ImapFlow instance');
|
|
1918
|
+
}
|
|
1919
|
+
this._connectCalled = true;
|
|
1920
|
+
// One deadline for the whole attempt, started before anything is resolved or negotiated.
|
|
1921
|
+
// Proxy DNS and proxy negotiation used to run entirely outside the timer, so a stalled
|
|
1922
|
+
// proxy could hang far beyond the documented connectionTimeout.
|
|
1923
|
+
let deadline = new connection_deadline_js_1.ConnectionDeadline(this.options.connectionTimeout);
|
|
1924
|
+
let connector = this.secureConnection ? node_tls_1.default : node_net_1.default;
|
|
1925
|
+
let opts = Object.assign({
|
|
1926
|
+
host: this.host,
|
|
1927
|
+
servername: this.servername,
|
|
1928
|
+
port: this.port
|
|
1929
|
+
}, this.options.tls || {});
|
|
1930
|
+
this.untaggedHandlers.OK = (...args) => this.initialOK(...args);
|
|
1931
|
+
this.untaggedHandlers.BYE = (...args) => this.serverBye(...args);
|
|
1932
|
+
this.untaggedHandlers.PREAUTH = () => this.initialPREAUTH();
|
|
1933
|
+
this.untaggedHandlers.CAPABILITY = (...args) => this.untaggedCapability(...args);
|
|
1934
|
+
this.sectionHandlers.CAPABILITY = (...args) => this.sectionCapability(...args);
|
|
1935
|
+
this.untaggedHandlers.EXISTS = (...args) => this.untaggedExists(...args);
|
|
1936
|
+
this.untaggedHandlers.EXPUNGE = (...args) => this.untaggedExpunge(...args);
|
|
1937
|
+
// these methods take an optional second argument, so make sure that some random IMAP tag is not used as the second argument
|
|
1938
|
+
this.untaggedHandlers.FETCH = untagged => this.untaggedFetch(untagged);
|
|
1939
|
+
this.untaggedHandlers.VANISHED = untagged => this.untaggedVanished(untagged);
|
|
1940
|
+
let socket = false;
|
|
1941
|
+
if (this.options.proxy) {
|
|
1942
|
+
try {
|
|
1943
|
+
socket = await (0, proxy_connection_js_1.proxyConnection)(this.log, this.options.proxy, this.host, this.port, { deadline });
|
|
1944
|
+
if (!socket) {
|
|
1945
|
+
throw new Error('Failed to setup proxy connection');
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
catch (err) {
|
|
1949
|
+
// Logged here rather than relying on proxy-connection.ts, which only reports
|
|
1950
|
+
// failures from inside the two connect helpers. An unsupported scheme, a proxy URL
|
|
1951
|
+
// that will not parse and a deadline that expired before the connect started all
|
|
1952
|
+
// reject before any logging happens there, so this is the one place that sees
|
|
1953
|
+
// every way proxy setup can fail.
|
|
1954
|
+
this.log.error({ msg: 'Failed to setup proxy connection', err, cid: this.id });
|
|
1955
|
+
if (err.code === 'CONNECT_TIMEOUT') {
|
|
1956
|
+
// The shared deadline expired during proxy setup. Report it as the documented
|
|
1957
|
+
// connection timeout rather than as a generic proxy failure.
|
|
1958
|
+
throw err;
|
|
1959
|
+
}
|
|
1960
|
+
let error = new Error('Failed to setup proxy connection');
|
|
1961
|
+
error.code = err.code || 'ProxyError';
|
|
1962
|
+
error._err = err;
|
|
1963
|
+
throw error;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
// Guarded: close() rejects a pending connect() synchronously. See guardedPromise().
|
|
1967
|
+
let connectPromise = (0, tools_js_1.guardedPromise)((resolve, reject) => {
|
|
1968
|
+
// Whatever the proxy phase already used is gone from the budget
|
|
1969
|
+
this.connectTimeout = setTimeout(() => {
|
|
1970
|
+
let err = deadline.error();
|
|
1971
|
+
this.log.error({ err, cid: this.id });
|
|
1972
|
+
this.closeAfter();
|
|
1973
|
+
reject(err);
|
|
1974
|
+
}, deadline.remaining());
|
|
1975
|
+
let onConnect = () => {
|
|
1976
|
+
try {
|
|
1977
|
+
(0, tools_js_1.clearTimer)(this.connectTimeout);
|
|
1978
|
+
// ImapFlow now owns the socket; drop the proxy's early error handler
|
|
1979
|
+
// (its "before connection setup" message no longer applies).
|
|
1980
|
+
(0, proxy_connection_js_1.detachEarlyErrorHandler)(socket);
|
|
1981
|
+
this.configureSocket(this.socket);
|
|
1982
|
+
this.greetingTimeout = setTimeout(() => {
|
|
1983
|
+
let err = new Error(
|
|
1984
|
+
/* c8 ignore next */ // the greeting-timeout test uses a plaintext socket; the secure-socket branch of this hint is not separately exercised
|
|
1985
|
+
`Failed to receive greeting from server in required time${!this.secureConnection ? '. Maybe should use TLS?' : ''}`);
|
|
1986
|
+
err.code = 'GREETING_TIMEOUT';
|
|
1987
|
+
err.details = {
|
|
1988
|
+
/* c8 ignore next */ // firing the timeout with the default (large) value would hang the suite, so only the explicit-option path is tested
|
|
1989
|
+
greetingTimeout: this.options.greetingTimeout || GREETING_TIMEOUT
|
|
1990
|
+
};
|
|
1991
|
+
this.log.error({ err, cid: this.id });
|
|
1992
|
+
this.closeAfter();
|
|
1993
|
+
reject(err);
|
|
1994
|
+
}, this.options.greetingTimeout || GREETING_TIMEOUT);
|
|
1995
|
+
const connected = this.socket;
|
|
1996
|
+
this.tls = (typeof connected.getCipher === 'function' && connected.getCipher()) || false;
|
|
1997
|
+
let logInfo = {
|
|
1998
|
+
src: 'connection',
|
|
1999
|
+
msg: `Established ${this.tls ? 'secure ' : ''}TCP connection`,
|
|
2000
|
+
cid: this.id,
|
|
2001
|
+
secure: !!this.tls,
|
|
2002
|
+
host: this.host,
|
|
2003
|
+
servername: this.servername,
|
|
2004
|
+
port: connected.remotePort,
|
|
2005
|
+
address: connected.remoteAddress,
|
|
2006
|
+
localAddress: connected.localAddress,
|
|
2007
|
+
localPort: connected.localPort
|
|
2008
|
+
};
|
|
2009
|
+
if (this.tls) {
|
|
2010
|
+
logInfo.authorized = this.tls.authorized = connected.authorized;
|
|
2011
|
+
/* c8 ignore next */ // cipher.standardName is present on modern Node, so the .name fallback rarely runs
|
|
2012
|
+
logInfo.algo = this.tls.standardName || this.tls.name;
|
|
2013
|
+
logInfo.version = this.tls.version;
|
|
2014
|
+
}
|
|
2015
|
+
this.log.info(logInfo);
|
|
2016
|
+
this.setSocketHandlers();
|
|
2017
|
+
this.setEventHandlers();
|
|
2018
|
+
connected.pipe(this.streamer);
|
|
2019
|
+
// executed by initial "* OK"
|
|
2020
|
+
this.initialResolve = resolve;
|
|
2021
|
+
this.initialReject = reject;
|
|
2022
|
+
/* c8 ignore next 4 */ // defensive: the onConnect setup body does not throw under normal operation
|
|
2023
|
+
}
|
|
2024
|
+
catch (ex) {
|
|
2025
|
+
// connect failed
|
|
2026
|
+
reject(ex);
|
|
2027
|
+
}
|
|
2028
|
+
};
|
|
2029
|
+
if (socket) {
|
|
2030
|
+
// socket is already established via proxy
|
|
2031
|
+
if (this.secureConnection) {
|
|
2032
|
+
// TLS socket requires a handshake
|
|
2033
|
+
opts.socket = socket;
|
|
2034
|
+
this.socket = connector.connect(opts, onConnect);
|
|
2035
|
+
}
|
|
2036
|
+
else {
|
|
2037
|
+
// cleartext socket is already usable
|
|
2038
|
+
this.socket = socket;
|
|
2039
|
+
setImmediate(onConnect);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
else {
|
|
2043
|
+
this.socket = connector.connect(opts, onConnect);
|
|
2044
|
+
}
|
|
2045
|
+
this.writeSocket = this.socket;
|
|
2046
|
+
// Store connection error handler for cleanup
|
|
2047
|
+
this._connectErrorHandler = (err) => {
|
|
2048
|
+
(0, tools_js_1.clearTimer)(this.connectTimeout);
|
|
2049
|
+
(0, tools_js_1.clearTimer)(this.greetingTimeout);
|
|
2050
|
+
this.closeAfter();
|
|
2051
|
+
this.log.error({ err, cid: this.id });
|
|
2052
|
+
reject(err);
|
|
2053
|
+
};
|
|
2054
|
+
this.socket.on('error', this._connectErrorHandler);
|
|
2055
|
+
});
|
|
2056
|
+
await connectPromise;
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Graceful connection close by sending logout command to server. TCP connection is closed once command is finished.
|
|
2060
|
+
*
|
|
2061
|
+
* @example
|
|
2062
|
+
* let client = new ImapFlow({...});
|
|
2063
|
+
* await client.connect();
|
|
2064
|
+
* ...
|
|
2065
|
+
* await client.logout();
|
|
2066
|
+
*/
|
|
2067
|
+
async logout() {
|
|
2068
|
+
return await this.run('LOGOUT');
|
|
2069
|
+
}
|
|
2070
|
+
/**
|
|
2071
|
+
* Close the TCP connection.
|
|
2072
|
+
* Unlike `close()`, return immediately from this function, allowing the
|
|
2073
|
+
* caller function to proceed, and run `close()` function afterwards.
|
|
2074
|
+
*/
|
|
2075
|
+
closeAfter() {
|
|
2076
|
+
setImmediate(() => this.close());
|
|
2077
|
+
}
|
|
2078
|
+
// Connection-scoped wrapper around the shared stamping helper; see buildConnectionError().
|
|
2079
|
+
/** @internal */
|
|
2080
|
+
createConnectionError(code, message, meta) {
|
|
2081
|
+
return (0, tools_js_1.buildConnectionError)(this.id, code, message, meta);
|
|
2082
|
+
}
|
|
2083
|
+
// The standard "connection not available" error, optionally annotated with the server's BYE
|
|
2084
|
+
// reason. Single source of truth so every NoConnection rejection is consistent.
|
|
2085
|
+
/** @internal */
|
|
2086
|
+
createNoConnectionError(byeReason, meta) {
|
|
2087
|
+
const error = this.createConnectionError('NoConnection', 'Connection not available', meta);
|
|
2088
|
+
if (byeReason) {
|
|
2089
|
+
error.reason = byeReason;
|
|
2090
|
+
}
|
|
2091
|
+
return error;
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* Closes TCP connection without notifying the server.
|
|
2095
|
+
*
|
|
2096
|
+
* @example
|
|
2097
|
+
* let client = new ImapFlow({...});
|
|
2098
|
+
* await client.connect();
|
|
2099
|
+
* ...
|
|
2100
|
+
* client.close();
|
|
2101
|
+
*/
|
|
2102
|
+
close() {
|
|
2103
|
+
try {
|
|
2104
|
+
// clear pending timers
|
|
2105
|
+
(0, tools_js_1.clearTimer)(this.idleStartTimer);
|
|
2106
|
+
(0, tools_js_1.clearTimer)(this.upgradeTimeout);
|
|
2107
|
+
(0, tools_js_1.clearTimer)(this.connectTimeout);
|
|
2108
|
+
(0, tools_js_1.clearTimer)(this.greetingTimeout);
|
|
2109
|
+
// Abort every in-flight throttle back-off so each waiter unblocks and its request is
|
|
2110
|
+
// settled promptly rather than after the full delay.
|
|
2111
|
+
for (let entry of this._throttleWaits) {
|
|
2112
|
+
(0, tools_js_1.clearTimer)(entry.timer);
|
|
2113
|
+
entry.resolve(true);
|
|
2114
|
+
}
|
|
2115
|
+
this._throttleWaits.clear();
|
|
2116
|
+
this.usable = false;
|
|
2117
|
+
// close() takes over ownership of the idling state: dropping the session token means a
|
|
2118
|
+
// poll or IDLE that unwinds after this point sees that it no longer owns the flag and
|
|
2119
|
+
// leaves it alone (see claimIdling() in commands/idle.ts).
|
|
2120
|
+
this._idleSession = null;
|
|
2121
|
+
this.idling = false;
|
|
2122
|
+
// An in-flight STARTTLS upgrade has to be settled through its own single settlement
|
|
2123
|
+
// path, otherwise the upgrade promise (and the session it belongs to) stays pending
|
|
2124
|
+
// for the lifetime of the process.
|
|
2125
|
+
if (typeof this._upgradeReject === 'function') {
|
|
2126
|
+
let reject = this._upgradeReject;
|
|
2127
|
+
this._upgradeReject = null;
|
|
2128
|
+
reject(this.createNoConnectionError(false, { rejectedFrom: 'upgrade' }));
|
|
2129
|
+
}
|
|
2130
|
+
if (typeof this.initialReject === 'function' && !this.options.verifyOnly) {
|
|
2131
|
+
(0, tools_js_1.clearTimer)(this.greetingTimeout);
|
|
2132
|
+
let reject = this.initialReject;
|
|
2133
|
+
this.initialResolve = false;
|
|
2134
|
+
this.initialReject = false;
|
|
2135
|
+
let err = new Error('Unexpected close');
|
|
2136
|
+
/* c8 ignore next */ // closing a pending connect over an already-secure socket (the TLS branch) is not separately exercised
|
|
2137
|
+
err.code = `ClosedAfterConnect${this.secureConnection ? 'TLS' : 'Text'}`;
|
|
2138
|
+
// Surface the server's BYE reason (e.g. "Too many connections") when the
|
|
2139
|
+
// connection was closed by an untagged BYE, so the caller sees why.
|
|
2140
|
+
if (this.byeReason) {
|
|
2141
|
+
err.reason = this.byeReason;
|
|
2142
|
+
}
|
|
2143
|
+
// Synchronous rejection is safe: connectPromise was built by guardedPromise(),
|
|
2144
|
+
// so the rejection is already observed. close() is synchronous, so all cleanup
|
|
2145
|
+
// completes before any microtask rejection handler runs.
|
|
2146
|
+
reject(err);
|
|
2147
|
+
}
|
|
2148
|
+
if (typeof this.preCheck === 'function') {
|
|
2149
|
+
// Runs while the connection is being torn down, so the rejection this sees is
|
|
2150
|
+
// almost always the NoConnection close() is about to raise itself.
|
|
2151
|
+
this.preCheck().catch(err => (0, tools_js_1.logConnectionError)(this, 'Failed to break IDLE while closing', err));
|
|
2152
|
+
}
|
|
2153
|
+
// Session-only public state must not survive the connection it describes: callers read
|
|
2154
|
+
// these properties in reconnect logic and would otherwise mistake cached objects for
|
|
2155
|
+
// live server state. Cleared during the first close only, so repeated close() calls
|
|
2156
|
+
// stay idempotent and cannot emit an event twice.
|
|
2157
|
+
// `byeReason` is deliberately kept: it explains why the session ended.
|
|
2158
|
+
//
|
|
2159
|
+
// `authenticated` is kept for a verifyOnly connection, where it is the result rather
|
|
2160
|
+
// than live state. That mode authenticates, optionally lists, and logs out before
|
|
2161
|
+
// connect() resolves, so clearing it here left every caller reading `false` off a
|
|
2162
|
+
// connection that had just authenticated successfully - there is no later moment at
|
|
2163
|
+
// which the answer could be read, and such a client is never reconnected.
|
|
2164
|
+
let closedMailbox = false;
|
|
2165
|
+
if (!this.isClosed) {
|
|
2166
|
+
closedMailbox = this.mailbox;
|
|
2167
|
+
this.mailbox = false;
|
|
2168
|
+
this.currentSelectCommand = false;
|
|
2169
|
+
if (!this.options.verifyOnly) {
|
|
2170
|
+
this.authenticated = false;
|
|
2171
|
+
}
|
|
2172
|
+
this.preCheck = false;
|
|
2173
|
+
}
|
|
2174
|
+
// Collect all pending requests to reject
|
|
2175
|
+
let pendingRequests = [];
|
|
2176
|
+
// reject command that is currently processed
|
|
2177
|
+
if (this.currentRequest && this.requestTagMap.has(this.currentRequest.tag)) {
|
|
2178
|
+
let tag = this.currentRequest.tag;
|
|
2179
|
+
let request = this.requestTagMap.get(tag);
|
|
2180
|
+
if (request) {
|
|
2181
|
+
this.requestTagMap.delete(tag);
|
|
2182
|
+
pendingRequests.push(request);
|
|
2183
|
+
}
|
|
2184
|
+
this.currentRequest = false;
|
|
2185
|
+
}
|
|
2186
|
+
// reject all other pending commands
|
|
2187
|
+
while (this.requestQueue.length) {
|
|
2188
|
+
let req = this.requestQueue.shift();
|
|
2189
|
+
if (req && this.requestTagMap.has(req.tag)) {
|
|
2190
|
+
let request = this.requestTagMap.get(req.tag);
|
|
2191
|
+
if (request) {
|
|
2192
|
+
this.requestTagMap.delete(req.tag);
|
|
2193
|
+
pendingRequests.push(request);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
// Reject pending requests and locks synchronously. Every promise rejected here was
|
|
2198
|
+
// built by guardedPromise(), so its rejection is already observed and cannot trigger
|
|
2199
|
+
// unhandledRejection. close() is synchronous, so all remaining cleanup runs before
|
|
2200
|
+
// any microtask rejection handler fires.
|
|
2201
|
+
//
|
|
2202
|
+
// The error travels on, though, through await chains and .then() links that
|
|
2203
|
+
// guardedPromise() knows nothing about. Read a crash stack ending here as "this is
|
|
2204
|
+
// the value that escaped", never as "this is the promise that escaped".
|
|
2205
|
+
let byeReason = this.byeReason;
|
|
2206
|
+
for (let request of pendingRequests) {
|
|
2207
|
+
request.reject(this.createNoConnectionError(byeReason, { rejectedFrom: 'pendingRequest', command: request.command }));
|
|
2208
|
+
}
|
|
2209
|
+
// Clear current lock - holder will see errors when they try operations.
|
|
2210
|
+
// Also clear the held-lock diagnostic timer so it doesn't fire post-close.
|
|
2211
|
+
if (this.currentLock && this.currentLock.heldWarnTimer) {
|
|
2212
|
+
(0, tools_js_1.clearTimer)(this.currentLock.heldWarnTimer);
|
|
2213
|
+
this.currentLock.heldWarnTimer = null;
|
|
2214
|
+
}
|
|
2215
|
+
this.currentLock = false;
|
|
2216
|
+
if (this.locks && this.locks.length) {
|
|
2217
|
+
let pendingLocks = this.locks.splice(0); // Take all locks and clear the array
|
|
2218
|
+
for (let lock of pendingLocks) {
|
|
2219
|
+
if (lock.acquireTimer) {
|
|
2220
|
+
(0, tools_js_1.clearTimer)(lock.acquireTimer);
|
|
2221
|
+
lock.acquireTimer = null;
|
|
2222
|
+
}
|
|
2223
|
+
if (typeof lock.reject === 'function') {
|
|
2224
|
+
lock.reject(this.createNoConnectionError(byeReason, { rejectedFrom: 'mailboxLock', path: lock.path }));
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
// cleanup compression streams if they exist
|
|
2229
|
+
if (this._inflate) {
|
|
2230
|
+
try {
|
|
2231
|
+
this._inflate.unpipe();
|
|
2232
|
+
this._inflate.destroy();
|
|
2233
|
+
this._inflate = null;
|
|
2234
|
+
}
|
|
2235
|
+
catch (err) {
|
|
2236
|
+
this.log.error({ err, msg: 'Failed to destroy inflate stream', cid: this.id });
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
if (this._deflate) {
|
|
2240
|
+
try {
|
|
2241
|
+
this._deflate.unpipe();
|
|
2242
|
+
this._deflate.destroy();
|
|
2243
|
+
this._deflate = null;
|
|
2244
|
+
}
|
|
2245
|
+
catch (err) {
|
|
2246
|
+
this.log.error({ err, msg: 'Failed to destroy deflate stream', cid: this.id });
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
// cleanup streamer
|
|
2250
|
+
if (this.streamer) {
|
|
2251
|
+
try {
|
|
2252
|
+
// remove our listeners explicitly by reference
|
|
2253
|
+
if (this.socketReadable) {
|
|
2254
|
+
this.streamer.removeListener('readable', this.socketReadable);
|
|
2255
|
+
}
|
|
2256
|
+
if (this._streamerErrorHandler) {
|
|
2257
|
+
this.streamer.removeListener('error', this._streamerErrorHandler);
|
|
2258
|
+
}
|
|
2259
|
+
if (!this.streamer.destroyed) {
|
|
2260
|
+
this.streamer.destroy();
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
catch (err) {
|
|
2264
|
+
this.log.error({ err, msg: 'Failed to cleanup streamer', cid: this.id });
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
// clear socket handlers
|
|
2268
|
+
this.clearSocketHandlers();
|
|
2269
|
+
// clear cached data
|
|
2270
|
+
this.folders.clear();
|
|
2271
|
+
this.requestTagMap.clear();
|
|
2272
|
+
this.state = this.states.LOGOUT;
|
|
2273
|
+
if (this.isClosed) {
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
// Set before teardown so a socket event that re-enters close() during destruction
|
|
2277
|
+
// cannot run this block a second time.
|
|
2278
|
+
this.isClosed = true;
|
|
2279
|
+
// Socket teardown, in one documented order. Each stream owns and reports its own
|
|
2280
|
+
// lifecycle, so each is destroyed exactly once:
|
|
2281
|
+
// 1. the compression PassThrough (writeSocket), if compression replaced it
|
|
2282
|
+
// 2. the raw socket, which is also writeSocket when compression is not active
|
|
2283
|
+
// The compression streams themselves were destroyed above.
|
|
2284
|
+
if (this.writeSocket && this.writeSocket !== this.socket && !this.writeSocket.destroyed) {
|
|
2285
|
+
try {
|
|
2286
|
+
this.writeSocket.destroy();
|
|
2287
|
+
}
|
|
2288
|
+
catch (err) {
|
|
2289
|
+
this.log.error({ err, cid: this.id });
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
if (this.socket && !this.socket.destroyed) {
|
|
2293
|
+
try {
|
|
2294
|
+
this.socket.destroy();
|
|
2295
|
+
}
|
|
2296
|
+
catch (err) {
|
|
2297
|
+
this.log.error({ err, cid: this.id });
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
// Null out all socket and handler references so the GC can collect
|
|
2301
|
+
// them even if the ImapFlow instance itself is still referenced.
|
|
2302
|
+
this.socket = null;
|
|
2303
|
+
this.writeSocket = null;
|
|
2304
|
+
this._inflate = null;
|
|
2305
|
+
this._deflate = null;
|
|
2306
|
+
this._streamerErrorHandler = null;
|
|
2307
|
+
this._connectErrorHandler = null;
|
|
2308
|
+
this._socketError = null;
|
|
2309
|
+
this._socketClose = null;
|
|
2310
|
+
this._socketEnd = null;
|
|
2311
|
+
this._socketTimeout = null;
|
|
2312
|
+
this.log.debug({
|
|
2313
|
+
msg: 'Connection closed',
|
|
2314
|
+
cid: this.id,
|
|
2315
|
+
...(this._unknownTagCount ? { unknownTagCount: this._unknownTagCount } : {})
|
|
2316
|
+
});
|
|
2317
|
+
// A mailbox that was still selected is now closed, so the transition is reported once,
|
|
2318
|
+
// whether the session ended with a clean logout or a lost transport. Emitted before
|
|
2319
|
+
// 'close' and only from the first close(), so no consumer sees it twice.
|
|
2320
|
+
if (closedMailbox) {
|
|
2321
|
+
this.emit('mailboxClose', closedMailbox);
|
|
2322
|
+
}
|
|
2323
|
+
this.emit('close');
|
|
2324
|
+
}
|
|
2325
|
+
catch (ex) {
|
|
2326
|
+
// close failed
|
|
2327
|
+
this.log.error({ err: ex, cid: this.id });
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
/**
|
|
2331
|
+
* Returns current quota
|
|
2332
|
+
*
|
|
2333
|
+
* @param path Optional mailbox path if you want to check quota for specific folder. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
|
|
2334
|
+
* @returns Quota information or `false` if QUOTA extension is not supported or requested path does not exist
|
|
2335
|
+
*
|
|
2336
|
+
* @example
|
|
2337
|
+
* let quota = await client.getQuota();
|
|
2338
|
+
* console.log(quota.storage.used, quota.storage.limit)
|
|
2339
|
+
*/
|
|
2340
|
+
async getQuota(path) {
|
|
2341
|
+
path = path || 'INBOX';
|
|
2342
|
+
return await this.run('QUOTA', path);
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* Lists available mailboxes as an Array
|
|
2346
|
+
*
|
|
2347
|
+
* @param options defines additional listing options
|
|
2348
|
+
* @returns An array of ListResponse objects
|
|
2349
|
+
*
|
|
2350
|
+
* @example
|
|
2351
|
+
* let list = await client.list();
|
|
2352
|
+
* list.forEach(mailbox=>console.log(mailbox.path));
|
|
2353
|
+
*/
|
|
2354
|
+
async list(options) {
|
|
2355
|
+
options = options || {};
|
|
2356
|
+
let folders = await this.run('LIST', '', '*', options);
|
|
2357
|
+
this.folders = new Map(folders.map(folder => [folder.path, folder]));
|
|
2358
|
+
return folders;
|
|
2359
|
+
}
|
|
2360
|
+
/**
|
|
2361
|
+
* Lists available mailboxes as a tree structured object
|
|
2362
|
+
*
|
|
2363
|
+
* @param options defines additional listing options
|
|
2364
|
+
* @returns Tree structured object
|
|
2365
|
+
*
|
|
2366
|
+
* @example
|
|
2367
|
+
* let tree = await client.listTree();
|
|
2368
|
+
* tree.folders.forEach(mailbox=>console.log(mailbox.path));
|
|
2369
|
+
*/
|
|
2370
|
+
async listTree(options) {
|
|
2371
|
+
options = options || {};
|
|
2372
|
+
let folders = await this.run('LIST', '', '*', options);
|
|
2373
|
+
this.folders = new Map(folders.map(folder => [folder.path, folder]));
|
|
2374
|
+
return (0, tools_js_1.getFolderTree)(folders);
|
|
2375
|
+
}
|
|
2376
|
+
/**
|
|
2377
|
+
* Performs a no-op call against server
|
|
2378
|
+
*/
|
|
2379
|
+
async noop() {
|
|
2380
|
+
await this.run('NOOP');
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* Creates a new mailbox folder and sets up subscription for the created mailbox. Throws on error.
|
|
2384
|
+
*
|
|
2385
|
+
* @param path Full mailbox path. Unicode is allowed. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
|
|
2386
|
+
* @returns Mailbox info
|
|
2387
|
+
* @throws Will throw an error if mailbox can not be created
|
|
2388
|
+
*
|
|
2389
|
+
* @example
|
|
2390
|
+
* let info = await client.mailboxCreate(['parent', 'child']);
|
|
2391
|
+
* console.log(info.path);
|
|
2392
|
+
* // "INBOX.parent.child" // assumes "INBOX." as namespace prefix and "." as delimiter
|
|
2393
|
+
*/
|
|
2394
|
+
async mailboxCreate(path) {
|
|
2395
|
+
return await this.run('CREATE', path);
|
|
2396
|
+
}
|
|
2397
|
+
/**
|
|
2398
|
+
* Renames a mailbox. Throws on error.
|
|
2399
|
+
*
|
|
2400
|
+
* @param path Path for the mailbox to rename. Unicode is allowed. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
|
|
2401
|
+
* @param newPath New path for the mailbox
|
|
2402
|
+
* @returns Mailbox info
|
|
2403
|
+
* @throws Will throw an error if mailbox does not exist or can not be renamed
|
|
2404
|
+
*
|
|
2405
|
+
* @example
|
|
2406
|
+
* let info = await client.mailboxRename('parent.child', 'Important stuff');
|
|
2407
|
+
* console.log(info.newPath);
|
|
2408
|
+
* // "INBOX.Important stuff" // assumes "INBOX." as namespace prefix
|
|
2409
|
+
*/
|
|
2410
|
+
async mailboxRename(path, newPath) {
|
|
2411
|
+
return await this.run('RENAME', path, newPath);
|
|
2412
|
+
}
|
|
2413
|
+
/**
|
|
2414
|
+
* Deletes a mailbox. Throws on error.
|
|
2415
|
+
*
|
|
2416
|
+
* @param path Path for the mailbox to delete. Unicode is allowed. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
|
|
2417
|
+
* @returns Mailbox info
|
|
2418
|
+
* @throws Will throw an error if mailbox does not exist or can not be deleted
|
|
2419
|
+
*
|
|
2420
|
+
* @example
|
|
2421
|
+
* let info = await client.mailboxDelete('Important stuff');
|
|
2422
|
+
* console.log(info.path);
|
|
2423
|
+
* // "INBOX.Important stuff" // assumes "INBOX." as namespace prefix
|
|
2424
|
+
*/
|
|
2425
|
+
async mailboxDelete(path) {
|
|
2426
|
+
return await this.run('DELETE', path);
|
|
2427
|
+
}
|
|
2428
|
+
/**
|
|
2429
|
+
* Subscribes to a mailbox
|
|
2430
|
+
*
|
|
2431
|
+
* @param path Path for the mailbox to subscribe to. Unicode is allowed. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
|
|
2432
|
+
* @returns `true` if subscription operation succeeded, `false` otherwise
|
|
2433
|
+
*
|
|
2434
|
+
* @example
|
|
2435
|
+
* await client.mailboxSubscribe('Important stuff');
|
|
2436
|
+
*/
|
|
2437
|
+
async mailboxSubscribe(path) {
|
|
2438
|
+
return await this.run('SUBSCRIBE', path);
|
|
2439
|
+
}
|
|
2440
|
+
/**
|
|
2441
|
+
* Unsubscribes from a mailbox
|
|
2442
|
+
*
|
|
2443
|
+
* @param path **Path for the mailbox** to unsubscribe from. Unicode is allowed. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
|
|
2444
|
+
* @returns `true` if unsubscription operation succeeded, `false` otherwise
|
|
2445
|
+
*
|
|
2446
|
+
* @example
|
|
2447
|
+
* await client.mailboxUnsubscribe('Important stuff');
|
|
2448
|
+
*/
|
|
2449
|
+
async mailboxUnsubscribe(path) {
|
|
2450
|
+
return await this.run('UNSUBSCRIBE', path);
|
|
2451
|
+
}
|
|
2452
|
+
/**
|
|
2453
|
+
* Opens a mailbox to access messages. You can perform message operations only against an opened mailbox.
|
|
2454
|
+
* Using {@link ImapFlow#getMailboxLock} instead of `mailboxOpen()` is preferred. Both do the same thing
|
|
2455
|
+
* but next `getMailboxLock()` call is not executed until previous one is released.
|
|
2456
|
+
*
|
|
2457
|
+
* @param path **Path for the mailbox** to open
|
|
2458
|
+
* @param options optional options
|
|
2459
|
+
* @returns Mailbox info
|
|
2460
|
+
* @throws Will throw an error if mailbox does not exist or can not be opened
|
|
2461
|
+
*
|
|
2462
|
+
* @example
|
|
2463
|
+
* let mailbox = await client.mailboxOpen('Important stuff');
|
|
2464
|
+
* console.log(mailbox.exists);
|
|
2465
|
+
* // 125
|
|
2466
|
+
*/
|
|
2467
|
+
async mailboxOpen(path, options) {
|
|
2468
|
+
return await this.run('SELECT', path, options);
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* Closes a previously opened mailbox
|
|
2472
|
+
*
|
|
2473
|
+
* @returns Did the operation succeed or not
|
|
2474
|
+
*
|
|
2475
|
+
* @example
|
|
2476
|
+
* let mailbox = await client.mailboxOpen('INBOX');
|
|
2477
|
+
* await client.mailboxClose();
|
|
2478
|
+
*/
|
|
2479
|
+
async mailboxClose() {
|
|
2480
|
+
return await this.run('CLOSE');
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Requests the status of the indicated mailbox. Only requested status values will be returned.
|
|
2484
|
+
*
|
|
2485
|
+
* @param path mailbox path to check for (unicode string). If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
|
|
2486
|
+
* @param query defines requested status items
|
|
2487
|
+
* @returns status of the indicated mailbox
|
|
2488
|
+
*
|
|
2489
|
+
* @example
|
|
2490
|
+
* let status = await client.status('INBOX', {unseen: true});
|
|
2491
|
+
* console.log(status.unseen);
|
|
2492
|
+
* // 123
|
|
2493
|
+
*/
|
|
2494
|
+
async status(path, query) {
|
|
2495
|
+
return await this.run('STATUS', path, query);
|
|
2496
|
+
}
|
|
2497
|
+
/**
|
|
2498
|
+
* Starts listening for new or deleted messages from the currently opened mailbox. Only required if `disableAutoIdle` is set to `true`
|
|
2499
|
+
* otherwise IDLE is started by default on connection inactivity. NB! If `idle()` is called manually then it does not
|
|
2500
|
+
* return until IDLE is finished which means you would have to call some other command out of scope.
|
|
2501
|
+
*
|
|
2502
|
+
* @returns Did the operation succeed or not
|
|
2503
|
+
*
|
|
2504
|
+
* @example
|
|
2505
|
+
* let mailbox = await client.mailboxOpen('INBOX');
|
|
2506
|
+
*
|
|
2507
|
+
* await client.idle();
|
|
2508
|
+
*/
|
|
2509
|
+
async idle() {
|
|
2510
|
+
if (!this.idling) {
|
|
2511
|
+
return await this.run('IDLE', this.maxIdleTime);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
/**
|
|
2515
|
+
* Sets flags for a message or message range
|
|
2516
|
+
*
|
|
2517
|
+
* @param range Range to filter the messages
|
|
2518
|
+
* @param flags Array of flags to set. Only flags that are permitted to set are used, other flags are ignored
|
|
2519
|
+
* @param options Store options
|
|
2520
|
+
* @returns Did the operation succeed or not
|
|
2521
|
+
*
|
|
2522
|
+
* @example
|
|
2523
|
+
* let mailbox = await client.mailboxOpen('INBOX');
|
|
2524
|
+
* // mark all unseen messages as seen (and remove other flags)
|
|
2525
|
+
* await client.messageFlagsSet({seen: false}, ['\Seen]);
|
|
2526
|
+
*/
|
|
2527
|
+
async messageFlagsSet(range, flags, options) {
|
|
2528
|
+
options = options || {};
|
|
2529
|
+
let resolved = await this.resolveRange(range, options);
|
|
2530
|
+
if (!resolved) {
|
|
2531
|
+
return false;
|
|
2532
|
+
}
|
|
2533
|
+
let queryOpts = Object.assign({
|
|
2534
|
+
operation: 'set'
|
|
2535
|
+
}, options);
|
|
2536
|
+
return await this.run('STORE', resolved, flags, queryOpts);
|
|
2537
|
+
}
|
|
2538
|
+
/**
|
|
2539
|
+
* Adds flags for a message or message range
|
|
2540
|
+
*
|
|
2541
|
+
* @param range Range to filter the messages
|
|
2542
|
+
* @param flags Array of flags to set. Only flags that are permitted to set are used, other flags are ignored
|
|
2543
|
+
* @param options Store options
|
|
2544
|
+
* @returns Did the operation succeed or not
|
|
2545
|
+
*
|
|
2546
|
+
* @example
|
|
2547
|
+
* let mailbox = await client.mailboxOpen('INBOX');
|
|
2548
|
+
* // mark all unseen messages as seen (and keep other flags as is)
|
|
2549
|
+
* await client.messageFlagsAdd({seen: false}, ['\Seen]);
|
|
2550
|
+
*/
|
|
2551
|
+
async messageFlagsAdd(range, flags, options) {
|
|
2552
|
+
options = options || {};
|
|
2553
|
+
let resolved = await this.resolveRange(range, options);
|
|
2554
|
+
if (!resolved) {
|
|
2555
|
+
return false;
|
|
2556
|
+
}
|
|
2557
|
+
let queryOpts = Object.assign({
|
|
2558
|
+
operation: 'add'
|
|
2559
|
+
}, options);
|
|
2560
|
+
return await this.run('STORE', resolved, flags, queryOpts);
|
|
2561
|
+
}
|
|
2562
|
+
/**
|
|
2563
|
+
* Remove specific flags from a message or message range
|
|
2564
|
+
*
|
|
2565
|
+
* @param range Range to filter the messages
|
|
2566
|
+
* @param flags Array of flags to remove. Only flags that are permitted to set are used, other flags are ignored
|
|
2567
|
+
* @param options Store options
|
|
2568
|
+
* @returns Did the operation succeed or not
|
|
2569
|
+
*
|
|
2570
|
+
* @example
|
|
2571
|
+
* let mailbox = await client.mailboxOpen('INBOX');
|
|
2572
|
+
* // mark all seen messages as unseen by removing \\Seen flag
|
|
2573
|
+
* await client.messageFlagsRemove({seen: true}, ['\Seen]);
|
|
2574
|
+
*/
|
|
2575
|
+
async messageFlagsRemove(range, flags, options) {
|
|
2576
|
+
options = options || {};
|
|
2577
|
+
let resolved = await this.resolveRange(range, options);
|
|
2578
|
+
if (!resolved) {
|
|
2579
|
+
return false;
|
|
2580
|
+
}
|
|
2581
|
+
let queryOpts = Object.assign({
|
|
2582
|
+
operation: 'remove'
|
|
2583
|
+
}, options);
|
|
2584
|
+
return await this.run('STORE', resolved, flags, queryOpts);
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* Sets a colored flag for an email. Only supported by mail clients like Apple Mail
|
|
2588
|
+
*
|
|
2589
|
+
* @param range Range to filter the messages
|
|
2590
|
+
* @param color The color to set. One of 'red', 'orange', 'yellow', 'green', 'blue', 'purple', and 'grey'
|
|
2591
|
+
* @param options Store options
|
|
2592
|
+
* @returns Did the operation succeed or not
|
|
2593
|
+
*
|
|
2594
|
+
* @example
|
|
2595
|
+
* let mailbox = await client.mailboxOpen('INBOX');
|
|
2596
|
+
* // add a purple flag for all emails
|
|
2597
|
+
* await client.setFlagColor('1:*', 'Purple');
|
|
2598
|
+
*/
|
|
2599
|
+
async setFlagColor(range, color, options) {
|
|
2600
|
+
options = options || {};
|
|
2601
|
+
let resolved = await this.resolveRange(range, options);
|
|
2602
|
+
if (!resolved) {
|
|
2603
|
+
return false;
|
|
2604
|
+
}
|
|
2605
|
+
let flagChanges = (0, tools_js_1.getColorFlags)(color);
|
|
2606
|
+
if (!flagChanges) {
|
|
2607
|
+
return false;
|
|
2608
|
+
}
|
|
2609
|
+
let addResults;
|
|
2610
|
+
let removeResults;
|
|
2611
|
+
if (flagChanges.add && flagChanges.add.length) {
|
|
2612
|
+
let queryOpts = Object.assign({
|
|
2613
|
+
operation: 'add'
|
|
2614
|
+
}, options, {
|
|
2615
|
+
useLabels: false, // override if set
|
|
2616
|
+
// prevent triggering a premature Flags change notification
|
|
2617
|
+
silent: flagChanges.remove && flagChanges.remove.length
|
|
2618
|
+
});
|
|
2619
|
+
addResults = await this.run('STORE', resolved, flagChanges.add, queryOpts);
|
|
2620
|
+
}
|
|
2621
|
+
if (flagChanges.remove && flagChanges.remove.length) {
|
|
2622
|
+
let queryOpts = Object.assign({
|
|
2623
|
+
operation: 'remove'
|
|
2624
|
+
}, options, { useLabels: false } // override if set
|
|
2625
|
+
);
|
|
2626
|
+
removeResults = await this.run('STORE', resolved, flagChanges.remove, queryOpts);
|
|
2627
|
+
}
|
|
2628
|
+
return addResults || removeResults || false;
|
|
2629
|
+
}
|
|
2630
|
+
/**
|
|
2631
|
+
* Delete messages from the currently opened mailbox. Method does not indicate info about deleted messages,
|
|
2632
|
+
* instead you should be using the `expunge` event for this
|
|
2633
|
+
*
|
|
2634
|
+
* @param range Range to filter the messages
|
|
2635
|
+
* @param options Range options
|
|
2636
|
+
* @returns Did the operation succeed or not
|
|
2637
|
+
*
|
|
2638
|
+
* @example
|
|
2639
|
+
* let mailbox = await client.mailboxOpen('INBOX');
|
|
2640
|
+
* // delete all seen messages
|
|
2641
|
+
* await client.messageDelete({seen: true});
|
|
2642
|
+
*/
|
|
2643
|
+
async messageDelete(range, options) {
|
|
2644
|
+
options = options || {};
|
|
2645
|
+
let resolved = await this.resolveRange(range, options);
|
|
2646
|
+
if (!resolved) {
|
|
2647
|
+
return false;
|
|
2648
|
+
}
|
|
2649
|
+
return await this.run('EXPUNGE', resolved, options);
|
|
2650
|
+
}
|
|
2651
|
+
/**
|
|
2652
|
+
* Appends a new message to a mailbox
|
|
2653
|
+
*
|
|
2654
|
+
* @param path Mailbox path to upload the message to (unicode string). If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
|
|
2655
|
+
* @param content RFC822 formatted email message
|
|
2656
|
+
* @param flags an array of flags to be set for the uploaded message
|
|
2657
|
+
* @param idate internal date to be set for the message
|
|
2658
|
+
* @returns info about uploaded message
|
|
2659
|
+
*
|
|
2660
|
+
* @example
|
|
2661
|
+
* await client.append('INBOX', rawMessageBuffer, ['\\Seen'], new Date(2000, 1, 1));
|
|
2662
|
+
*/
|
|
2663
|
+
async append(path, content, flags, idate) {
|
|
2664
|
+
return (await this.run('APPEND', path, content, flags, idate)) || false;
|
|
2665
|
+
}
|
|
2666
|
+
/**
|
|
2667
|
+
* Copies messages from current mailbox to destination mailbox
|
|
2668
|
+
*
|
|
2669
|
+
* @param range Range of messages to copy
|
|
2670
|
+
* @param destination Mailbox path to copy the messages to. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
|
|
2671
|
+
* @param options Range options
|
|
2672
|
+
* @returns info about copies messages
|
|
2673
|
+
*
|
|
2674
|
+
* @example
|
|
2675
|
+
* await client.mailboxOpen('INBOX');
|
|
2676
|
+
* // copy all messages to a mailbox called "Backup" (must exist)
|
|
2677
|
+
* let result = await client.messageCopy('1:*', 'Backup');
|
|
2678
|
+
* console.log('Copied %s messages', result.uidMap.size);
|
|
2679
|
+
*/
|
|
2680
|
+
async messageCopy(range, destination, options) {
|
|
2681
|
+
options = options || {};
|
|
2682
|
+
let resolved = await this.resolveRange(range, options);
|
|
2683
|
+
if (!resolved) {
|
|
2684
|
+
return false;
|
|
2685
|
+
}
|
|
2686
|
+
return await this.run('COPY', resolved, destination, options);
|
|
2687
|
+
}
|
|
2688
|
+
/**
|
|
2689
|
+
* Moves messages from current mailbox to destination mailbox
|
|
2690
|
+
*
|
|
2691
|
+
* @param range Range of messages to move
|
|
2692
|
+
* @param destination Mailbox path to move the messages to. If value is an array then it is joined using current delimiter symbols. Namespace prefix is added automatically if required.
|
|
2693
|
+
* @param options Range options
|
|
2694
|
+
* @returns info about moved messages
|
|
2695
|
+
*
|
|
2696
|
+
* @example
|
|
2697
|
+
* await client.mailboxOpen('INBOX');
|
|
2698
|
+
* // move all messages to a mailbox called "Trash" (must exist)
|
|
2699
|
+
* let result = await client.messageMove('1:*', 'Trash');
|
|
2700
|
+
* console.log('Moved %s messages', result.uidMap.size);
|
|
2701
|
+
*/
|
|
2702
|
+
async messageMove(range, destination, options) {
|
|
2703
|
+
options = options || {};
|
|
2704
|
+
let resolved = await this.resolveRange(range, options);
|
|
2705
|
+
if (!resolved) {
|
|
2706
|
+
return false;
|
|
2707
|
+
}
|
|
2708
|
+
return await this.run('MOVE', resolved, destination, options);
|
|
2709
|
+
}
|
|
2710
|
+
async search(query, options) {
|
|
2711
|
+
if (!this.mailbox) {
|
|
2712
|
+
// no mailbox selected, nothing to do
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
const result = (await this.run('SEARCH', query, options)) || false;
|
|
2716
|
+
// When returnOptions was requested but server lacked ESEARCH capability,
|
|
2717
|
+
// search.ts returns a plain number[]. Derive ESearchResult client-side.
|
|
2718
|
+
if (options && options.returnOptions && Array.isArray(result)) {
|
|
2719
|
+
const arr = result;
|
|
2720
|
+
// Normalize to uppercase so callers can use mixed-case strings like 'count'
|
|
2721
|
+
const normalizedOptions = options.returnOptions.map(o => (typeof o === 'string' ? o.toUpperCase() : o));
|
|
2722
|
+
const esearch = {};
|
|
2723
|
+
if (normalizedOptions.includes('COUNT')) {
|
|
2724
|
+
esearch.count = arr.length;
|
|
2725
|
+
}
|
|
2726
|
+
if (normalizedOptions.includes('MIN') && arr.length) {
|
|
2727
|
+
esearch.min = arr[0]; // already sorted ascending by search.ts
|
|
2728
|
+
}
|
|
2729
|
+
if (normalizedOptions.includes('MAX') && arr.length) {
|
|
2730
|
+
esearch.max = arr[arr.length - 1];
|
|
2731
|
+
}
|
|
2732
|
+
if (normalizedOptions.includes('ALL') && arr.length) {
|
|
2733
|
+
esearch.all = (0, tools_js_1.packMessageRange)(arr);
|
|
2734
|
+
}
|
|
2735
|
+
// PARTIAL cannot be derived client-side, omit it.
|
|
2736
|
+
// When returnOptions contains only { partial: ... } items and the server
|
|
2737
|
+
// lacks ESEARCH, PARTIAL cannot be derived client-side. Return the raw
|
|
2738
|
+
// number[] so the caller has actionable data. Note: this is an edge case,
|
|
2739
|
+
// callers targeting no-ESEARCH servers should avoid requesting PARTIAL
|
|
2740
|
+
// without COUNT or ALL.
|
|
2741
|
+
if (Object.keys(esearch).length === 0) {
|
|
2742
|
+
return result;
|
|
2743
|
+
}
|
|
2744
|
+
return esearch;
|
|
2745
|
+
}
|
|
2746
|
+
return result;
|
|
2747
|
+
}
|
|
2748
|
+
/**
|
|
2749
|
+
* Fetch messages from the currently opened mailbox
|
|
2750
|
+
*
|
|
2751
|
+
* @param range Range of messages to fetch
|
|
2752
|
+
* @param query Fetch query
|
|
2753
|
+
* @param options Fetch options
|
|
2754
|
+
* @yields Message data object
|
|
2755
|
+
*
|
|
2756
|
+
* @example
|
|
2757
|
+
* let mailbox = await client.mailboxOpen('INBOX');
|
|
2758
|
+
* // fetch UID for all messages in a mailbox
|
|
2759
|
+
* for await (let msg of client.fetch('1:*', {uid: true})){
|
|
2760
|
+
* console.log(msg.uid);
|
|
2761
|
+
* // NB! You can not run any IMAP commands in this loop
|
|
2762
|
+
* // otherwise you will end up in a deadloop
|
|
2763
|
+
* }
|
|
2764
|
+
*/
|
|
2765
|
+
async *fetch(range, query, options) {
|
|
2766
|
+
options = options || {};
|
|
2767
|
+
if (!this.mailbox) {
|
|
2768
|
+
// no mailbox selected, nothing to do
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
let resolved = await this.resolveRange(range, options);
|
|
2772
|
+
if (!resolved) {
|
|
2773
|
+
return false;
|
|
2774
|
+
}
|
|
2775
|
+
// Push/pull coordination for the async generator pattern:
|
|
2776
|
+
// The FETCH command handler pushes results into rowQueue via onUntaggedFetch.
|
|
2777
|
+
// The generator consumer pulls via getNext(). The `push` callback bridges the
|
|
2778
|
+
// two: when the consumer is waiting and the queue is empty, `push` is set to
|
|
2779
|
+
// a function that wakes up the consumer when new data arrives.
|
|
2780
|
+
let finished = false;
|
|
2781
|
+
let aborted = false;
|
|
2782
|
+
let push = false;
|
|
2783
|
+
let rowQueue = [];
|
|
2784
|
+
let getNext = () => new Promise((resolve, reject) => {
|
|
2785
|
+
let check = () => {
|
|
2786
|
+
if (rowQueue.length) {
|
|
2787
|
+
let entry = rowQueue.shift();
|
|
2788
|
+
if (entry.err) {
|
|
2789
|
+
return reject(entry.err);
|
|
2790
|
+
}
|
|
2791
|
+
return resolve(entry.value);
|
|
2792
|
+
}
|
|
2793
|
+
if (finished) {
|
|
2794
|
+
return resolve(null);
|
|
2795
|
+
}
|
|
2796
|
+
// No data available yet; register a wakeup callback
|
|
2797
|
+
push = () => {
|
|
2798
|
+
push = false;
|
|
2799
|
+
check();
|
|
2800
|
+
};
|
|
2801
|
+
};
|
|
2802
|
+
check();
|
|
2803
|
+
});
|
|
2804
|
+
// Fire-and-forget the FETCH command. It runs in the background while
|
|
2805
|
+
// the generator yields results. Each untagged FETCH response is paired
|
|
2806
|
+
// with a `next` callback that acts as backpressure: the FETCH handler
|
|
2807
|
+
// won't process the next response until the consumer calls next().
|
|
2808
|
+
this.run('FETCH', resolved, query, {
|
|
2809
|
+
uid: !!options.uid,
|
|
2810
|
+
binary: options.binary,
|
|
2811
|
+
changedSince: options.changedSince,
|
|
2812
|
+
onUntaggedFetch: (untagged, next) => {
|
|
2813
|
+
if (aborted) {
|
|
2814
|
+
next();
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
rowQueue.push({
|
|
2818
|
+
value: {
|
|
2819
|
+
response: untagged,
|
|
2820
|
+
next
|
|
2821
|
+
}
|
|
2822
|
+
});
|
|
2823
|
+
if (typeof push === 'function') {
|
|
2824
|
+
push();
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
})
|
|
2828
|
+
.then(() => {
|
|
2829
|
+
finished = true;
|
|
2830
|
+
if (typeof push === 'function') {
|
|
2831
|
+
push();
|
|
2832
|
+
}
|
|
2833
|
+
})
|
|
2834
|
+
.catch(err => {
|
|
2835
|
+
rowQueue.push({ err });
|
|
2836
|
+
if (typeof push === 'function') {
|
|
2837
|
+
push();
|
|
2838
|
+
}
|
|
2839
|
+
});
|
|
2840
|
+
let lastRes = null;
|
|
2841
|
+
try {
|
|
2842
|
+
let res;
|
|
2843
|
+
while ((res = await getNext())) {
|
|
2844
|
+
lastRes = res;
|
|
2845
|
+
if (this.isClosed || !this.socket || this.socket.destroyed) {
|
|
2846
|
+
throw this.createConnectionError('EConnectionClosed', 'Connection closed', { rejectedFrom: 'fetchStream', command: 'FETCH' });
|
|
2847
|
+
}
|
|
2848
|
+
yield res.response;
|
|
2849
|
+
// Signal the FETCH handler to process the next untagged response
|
|
2850
|
+
res.next();
|
|
2851
|
+
lastRes = null;
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
finally {
|
|
2855
|
+
aborted = true;
|
|
2856
|
+
// Release backpressure for the item that was yielded but whose
|
|
2857
|
+
// next() was not yet called (happens on break/return/throw)
|
|
2858
|
+
if (lastRes && typeof lastRes.next === 'function') {
|
|
2859
|
+
lastRes.next();
|
|
2860
|
+
}
|
|
2861
|
+
while (rowQueue.length) {
|
|
2862
|
+
let entry = rowQueue.shift();
|
|
2863
|
+
if (entry.value && typeof entry.value.next === 'function') {
|
|
2864
|
+
entry.value.next();
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
/**
|
|
2870
|
+
* Fetch messages from the currently opened mailbox.
|
|
2871
|
+
*
|
|
2872
|
+
* This method will fetch all messages before resolving the promise, unlike .fetch(), which
|
|
2873
|
+
* is an async generator. Do not use large ranges like 1:*, as this might exhaust all available
|
|
2874
|
+
* memory if the mailbox contains a large number of emails.
|
|
2875
|
+
* @param range Range of messages to fetch
|
|
2876
|
+
* @param query Fetch query
|
|
2877
|
+
* @param options Fetch options
|
|
2878
|
+
* @returns Array of Message data object
|
|
2879
|
+
*
|
|
2880
|
+
* @example
|
|
2881
|
+
* let mailbox = await client.mailboxOpen('INBOX');
|
|
2882
|
+
* // fetch UID for all messages in a mailbox
|
|
2883
|
+
* const messages = await client.fetchAll('1:*', {uid: true});
|
|
2884
|
+
* for (let msg of messages){
|
|
2885
|
+
* console.log(msg.uid);
|
|
2886
|
+
* }
|
|
2887
|
+
*/
|
|
2888
|
+
async fetchAll(range, query, options) {
|
|
2889
|
+
const results = [];
|
|
2890
|
+
const generator = this.fetch(range, query, options);
|
|
2891
|
+
for await (const message of generator) {
|
|
2892
|
+
results.push(message);
|
|
2893
|
+
}
|
|
2894
|
+
return results;
|
|
2895
|
+
}
|
|
2896
|
+
/**
|
|
2897
|
+
* Fetch a single message from the currently opened mailbox
|
|
2898
|
+
*
|
|
2899
|
+
* @param seq Single UID or sequence number of the message to fetch for
|
|
2900
|
+
* @param query Fetch query
|
|
2901
|
+
* @param options Fetch options
|
|
2902
|
+
* @returns Message data object
|
|
2903
|
+
*
|
|
2904
|
+
* @example
|
|
2905
|
+
* let mailbox = await client.mailboxOpen('INBOX');
|
|
2906
|
+
* // fetch UID for the last email in the selected mailbox
|
|
2907
|
+
* let lastMsg = await client.fetchOne('*', {uid: true})
|
|
2908
|
+
* console.log(lastMsg.uid);
|
|
2909
|
+
*/
|
|
2910
|
+
async fetchOne(seq, query, options) {
|
|
2911
|
+
if (!this.mailbox) {
|
|
2912
|
+
// no mailbox selected, nothing to do
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
if (seq === '*') {
|
|
2916
|
+
if (!this.mailbox.exists) {
|
|
2917
|
+
return false;
|
|
2918
|
+
}
|
|
2919
|
+
seq = this.mailbox.exists.toString();
|
|
2920
|
+
options = Object.assign({}, options || {}, { uid: false }); // force into a sequence query
|
|
2921
|
+
}
|
|
2922
|
+
let response = await this.run('FETCH', (seq || '').toString(), query, options);
|
|
2923
|
+
if (!response || !response.list || !response.list.length) {
|
|
2924
|
+
return false;
|
|
2925
|
+
}
|
|
2926
|
+
return response.list[0];
|
|
2927
|
+
}
|
|
2928
|
+
/**
|
|
2929
|
+
* Download either full rfc822 formatted message or a specific bodystructure part as a Stream.
|
|
2930
|
+
* Bodystructure parts are decoded so the resulting stream is a binary file. Text content
|
|
2931
|
+
* is automatically converted to UTF-8 charset.
|
|
2932
|
+
*
|
|
2933
|
+
* @param range UID or sequence number for the message to fetch
|
|
2934
|
+
* @param part If not set then downloads entire rfc822 formatted message, otherwise downloads specific bodystructure part
|
|
2935
|
+
* @param options Download options
|
|
2936
|
+
* @returns Download data object. Resolves with an empty object when no mailbox is selected or the message or part was not found
|
|
2937
|
+
*
|
|
2938
|
+
* @example
|
|
2939
|
+
* let mailbox = await client.mailboxOpen('INBOX');
|
|
2940
|
+
* // download body part nr '1.2' from latest message
|
|
2941
|
+
* let {meta, content} = await client.download('*', '1.2');
|
|
2942
|
+
* content.pipe(fs.createWriteStream(meta.filename));
|
|
2943
|
+
*/
|
|
2944
|
+
async download(range, part, options) {
|
|
2945
|
+
if (!this.mailbox) {
|
|
2946
|
+
// no mailbox selected, nothing to do
|
|
2947
|
+
return {};
|
|
2948
|
+
}
|
|
2949
|
+
let downloadOptions = Object.assign({
|
|
2950
|
+
chunkSize: 64 * 1024,
|
|
2951
|
+
maxBytes: Infinity
|
|
2952
|
+
}, options || {});
|
|
2953
|
+
let hasMore = true;
|
|
2954
|
+
let processed = 0;
|
|
2955
|
+
let chunkSize = Number(downloadOptions.chunkSize) || 64 * 1024;
|
|
2956
|
+
// Normalized once here so every bounded stage of the pipeline below agrees on the budget
|
|
2957
|
+
let maxBytes = (0, limited_passthrough_js_1.normalizeByteLimit)(downloadOptions.maxBytes);
|
|
2958
|
+
let uid = false;
|
|
2959
|
+
if (part === '1') {
|
|
2960
|
+
// Special handling for part "1": in single-node emails (no childNodes),
|
|
2961
|
+
// the body is accessed via "TEXT" rather than "1", and headers via
|
|
2962
|
+
// "HEADER" instead of "1.MIME". Check bodyStructure to detect this.
|
|
2963
|
+
let response = await this.fetchOne(range, { uid: true, bodyStructure: true }, downloadOptions);
|
|
2964
|
+
if (!response) {
|
|
2965
|
+
return { response: false, chunk: false };
|
|
2966
|
+
}
|
|
2967
|
+
if (!uid && response.uid) {
|
|
2968
|
+
uid = response.uid;
|
|
2969
|
+
// force UID from now on even if first range was a sequence number
|
|
2970
|
+
range = uid;
|
|
2971
|
+
downloadOptions.uid = true;
|
|
2972
|
+
}
|
|
2973
|
+
if (!response.bodyStructure.childNodes) {
|
|
2974
|
+
// single text message
|
|
2975
|
+
part = 'TEXT';
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
let getNextPart = async (query) => {
|
|
2979
|
+
query = query || {};
|
|
2980
|
+
let mimeKey;
|
|
2981
|
+
if (!part) {
|
|
2982
|
+
query.source = {
|
|
2983
|
+
start: processed,
|
|
2984
|
+
maxLength: chunkSize
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
else {
|
|
2988
|
+
part = part.toString().toLowerCase().trim();
|
|
2989
|
+
if (!query.bodyParts) {
|
|
2990
|
+
query.bodyParts = [];
|
|
2991
|
+
}
|
|
2992
|
+
if (query.size) {
|
|
2993
|
+
if (/^[\d.]+$/.test(part)) {
|
|
2994
|
+
// fetch meta as well
|
|
2995
|
+
mimeKey = part + '.mime';
|
|
2996
|
+
query.bodyParts.push(mimeKey);
|
|
2997
|
+
}
|
|
2998
|
+
else if (part === 'text') {
|
|
2999
|
+
mimeKey = 'header';
|
|
3000
|
+
query.bodyParts.push(mimeKey);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
query.bodyParts.push({
|
|
3004
|
+
key: part,
|
|
3005
|
+
start: processed,
|
|
3006
|
+
maxLength: chunkSize
|
|
3007
|
+
});
|
|
3008
|
+
}
|
|
3009
|
+
let response = await this.fetchOne(range, query, downloadOptions);
|
|
3010
|
+
if (!response) {
|
|
3011
|
+
return { response: false, chunk: false };
|
|
3012
|
+
}
|
|
3013
|
+
if (!uid && response.uid) {
|
|
3014
|
+
uid = response.uid;
|
|
3015
|
+
// force UID from now on even if first range was a sequence number
|
|
3016
|
+
range = uid;
|
|
3017
|
+
downloadOptions.uid = true;
|
|
3018
|
+
}
|
|
3019
|
+
let chunk = !part ? response.source : response.bodyParts && response.bodyParts.get(part);
|
|
3020
|
+
if (!chunk) {
|
|
3021
|
+
return {};
|
|
3022
|
+
}
|
|
3023
|
+
processed += chunk.length;
|
|
3024
|
+
hasMore = chunk.length >= chunkSize;
|
|
3025
|
+
let result = { chunk };
|
|
3026
|
+
if (query.size) {
|
|
3027
|
+
result.response = response;
|
|
3028
|
+
}
|
|
3029
|
+
if (query.bodyParts) {
|
|
3030
|
+
if (mimeKey === 'header') {
|
|
3031
|
+
result.mime = response.headers;
|
|
3032
|
+
}
|
|
3033
|
+
else {
|
|
3034
|
+
result.mime = response.bodyParts && mimeKey ? response.bodyParts.get(mimeKey) : undefined;
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
return result;
|
|
3038
|
+
};
|
|
3039
|
+
let { response, chunk, mime } = await getNextPart({
|
|
3040
|
+
size: true,
|
|
3041
|
+
uid: true
|
|
3042
|
+
});
|
|
3043
|
+
if (!response || !chunk) {
|
|
3044
|
+
// ???
|
|
3045
|
+
return {};
|
|
3046
|
+
}
|
|
3047
|
+
let meta = {
|
|
3048
|
+
expectedSize: response.size
|
|
3049
|
+
};
|
|
3050
|
+
if (!part) {
|
|
3051
|
+
meta.contentType = 'message/rfc822';
|
|
3052
|
+
}
|
|
3053
|
+
else if (mime) {
|
|
3054
|
+
let headers = new mailsplit_1.Headers(mime);
|
|
3055
|
+
let contentType = libmime_1.default.parseHeaderValue(headers.getFirst('Content-Type'));
|
|
3056
|
+
let transferEncoding = libmime_1.default.parseHeaderValue(headers.getFirst('Content-Transfer-Encoding'));
|
|
3057
|
+
let disposition = libmime_1.default.parseHeaderValue(headers.getFirst('Content-Disposition'));
|
|
3058
|
+
if (contentType.value.toLowerCase().trim()) {
|
|
3059
|
+
meta.contentType = contentType.value.toLowerCase().trim();
|
|
3060
|
+
}
|
|
3061
|
+
if (contentType.params.charset) {
|
|
3062
|
+
meta.charset = contentType.params.charset.toLowerCase().trim();
|
|
3063
|
+
}
|
|
3064
|
+
if (transferEncoding.value) {
|
|
3065
|
+
meta.encoding = transferEncoding.value
|
|
3066
|
+
.replace(/\(.*\)/g, '')
|
|
3067
|
+
.toLowerCase()
|
|
3068
|
+
.trim();
|
|
3069
|
+
}
|
|
3070
|
+
if (disposition.value) {
|
|
3071
|
+
/* c8 ignore next */ // a parsed disposition value is never all-whitespace, so the `false` fallback is unreachable
|
|
3072
|
+
meta.disposition = disposition.value.toLowerCase().trim() || false;
|
|
3073
|
+
try {
|
|
3074
|
+
meta.disposition = libmime_1.default.decodeWords(meta.disposition);
|
|
3075
|
+
}
|
|
3076
|
+
catch {
|
|
3077
|
+
// failed to parse disposition, keep as is (most probably an unknown charset is used)
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
if (contentType.params.format && contentType.params.format.toLowerCase().trim() === 'flowed') {
|
|
3081
|
+
meta.flowed = true;
|
|
3082
|
+
if (contentType.params.delsp && contentType.params.delsp.toLowerCase().trim() === 'yes') {
|
|
3083
|
+
meta.delSp = true;
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
let filename = disposition.params.filename || contentType.params.name || false;
|
|
3087
|
+
if (filename) {
|
|
3088
|
+
try {
|
|
3089
|
+
filename = libmime_1.default.decodeWords(filename);
|
|
3090
|
+
}
|
|
3091
|
+
catch {
|
|
3092
|
+
// failed to parse filename, keep as is (most probably an unknown charset is used)
|
|
3093
|
+
}
|
|
3094
|
+
meta.filename = filename;
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
let stream;
|
|
3098
|
+
let output;
|
|
3099
|
+
let fetchAborted = false;
|
|
3100
|
+
// Build a decoder pipeline that progressively transforms the raw FETCH data:
|
|
3101
|
+
// 1. Transfer-encoding decoder (base64 or quoted-printable -> binary)
|
|
3102
|
+
// 2. Format decoder (format=flowed -> plain text, if applicable)
|
|
3103
|
+
// 3. Charset decoder (non-UTF-8 -> UTF-8, for text parts only)
|
|
3104
|
+
// 4. Byte limiter (enforces maxBytes cap)
|
|
3105
|
+
// `stream` is the head of the pipeline (where raw chunks are written),
|
|
3106
|
+
// `output` is the tail (what the caller reads from).
|
|
3107
|
+
// Parts that arrived via FETCH BINARY (response.binaryParts) are already
|
|
3108
|
+
// decoded by the server - decoding again would corrupt the data, so stage 1
|
|
3109
|
+
// is skipped for them.
|
|
3110
|
+
let clientEncoding = response.binaryParts && part && response.binaryParts.has(part) ? false : meta.encoding;
|
|
3111
|
+
switch (clientEncoding) {
|
|
3112
|
+
case 'base64':
|
|
3113
|
+
output = stream = new libbase64_1.default.Decoder();
|
|
3114
|
+
break;
|
|
3115
|
+
case 'quoted-printable':
|
|
3116
|
+
output = stream = new libqp_1.default.Decoder();
|
|
3117
|
+
break;
|
|
3118
|
+
default:
|
|
3119
|
+
output = stream = new node_stream_1.PassThrough();
|
|
3120
|
+
}
|
|
3121
|
+
// Every byte-bounded stage of the pipeline. The fetch loop below stops as soon as any of
|
|
3122
|
+
// them has taken all it will accept. The limiter at the tail is not enough on its own: a
|
|
3123
|
+
// transform in the middle that buffers its whole input before emitting anything (the
|
|
3124
|
+
// format=flowed decoder, the Japanese charset decoder) leaves the tail limiter reporting
|
|
3125
|
+
// `limited === false` however much the server sends, so a download with a small maxBytes
|
|
3126
|
+
// would still pull the entire part off the wire.
|
|
3127
|
+
let limiters = [];
|
|
3128
|
+
let isLimited = () => limiters.some(entry => entry.limited);
|
|
3129
|
+
// Appending a stage means forwarding the current tail's errors to it before piping, so a
|
|
3130
|
+
// failure anywhere reaches the stream the caller is reading
|
|
3131
|
+
let pipeStage = (stage) => {
|
|
3132
|
+
output.on('error', err => {
|
|
3133
|
+
stage.emit('error', err);
|
|
3134
|
+
});
|
|
3135
|
+
output = output.pipe(stage);
|
|
3136
|
+
return stage;
|
|
3137
|
+
};
|
|
3138
|
+
let isTextNode = ['text/html', 'text/plain', 'text/x-amp-html'].includes(meta.contentType) || (part === '1' && !meta.contentType);
|
|
3139
|
+
if ((!meta.disposition || meta.disposition === 'inline') && isTextNode) {
|
|
3140
|
+
// RFC 3676 format=flowed text: unwrap soft line breaks
|
|
3141
|
+
if (meta.flowed) {
|
|
3142
|
+
// FlowedDecoder buffers its whole input before emitting, and being third party it
|
|
3143
|
+
// carries no bound of its own, so bound what it can ever be handed. Unwrapping only
|
|
3144
|
+
// removes bytes, so capping its input at maxBytes cannot push the delivered output
|
|
3145
|
+
// above the cap either.
|
|
3146
|
+
limiters.push(pipeStage(new limited_passthrough_js_1.LimitedPassthrough({ maxBytes })));
|
|
3147
|
+
pipeStage(new flowed_decoder_js_1.default(meta.delSp ? { delSp: true } : {}));
|
|
3148
|
+
}
|
|
3149
|
+
// Convert non-UTF-8 charsets to UTF-8 via a streaming decoder.
|
|
3150
|
+
// ASCII and UTF-8 need no conversion. Unknown charsets are left as-is.
|
|
3151
|
+
if (meta.charset && !['ascii', 'usascii', 'utf8'].includes(meta.charset.toLowerCase().replace(/[^a-z0-9]+/g, ''))) {
|
|
3152
|
+
try {
|
|
3153
|
+
let decoder = (0, tools_js_1.getDecoder)(meta.charset, maxBytes);
|
|
3154
|
+
// Safety listener attached first so the decoder always has at least
|
|
3155
|
+
// one 'error' listener. Prevents Node.js from throwing
|
|
3156
|
+
// ERR_UNHANDLED_ERROR if a later pipe setup step throws and leaves
|
|
3157
|
+
// the source-forwarding closure attached without a downstream
|
|
3158
|
+
// listener wired up. Any real listener the caller attaches still
|
|
3159
|
+
// fires in addition to this one.
|
|
3160
|
+
decoder.on('error', err => {
|
|
3161
|
+
this.log.warn({ err, charset: meta.charset, cid: this.id });
|
|
3162
|
+
});
|
|
3163
|
+
// The Japanese decoder buffers its whole input as well, and reports the same
|
|
3164
|
+
// `limited` flag the limiters do so the fetch loop can stop once it is full.
|
|
3165
|
+
// A streaming decoder has no such flag, which reads as false and is correct.
|
|
3166
|
+
limiters.push(pipeStage(decoder));
|
|
3167
|
+
// force to utf-8 for output
|
|
3168
|
+
meta.charset = 'utf-8';
|
|
3169
|
+
}
|
|
3170
|
+
catch {
|
|
3171
|
+
// do not decode charset
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
let limiter = pipeStage(new limited_passthrough_js_1.LimitedPassthrough({ maxBytes }));
|
|
3176
|
+
limiters.push(limiter);
|
|
3177
|
+
// Cleanup function
|
|
3178
|
+
const cleanup = () => {
|
|
3179
|
+
fetchAborted = true;
|
|
3180
|
+
if (stream && !stream.destroyed) {
|
|
3181
|
+
stream.destroy();
|
|
3182
|
+
}
|
|
3183
|
+
};
|
|
3184
|
+
// Listen for stream destruction
|
|
3185
|
+
output.once('error', cleanup);
|
|
3186
|
+
output.once('close', cleanup);
|
|
3187
|
+
let writeChunk = (chunk) => {
|
|
3188
|
+
if (isLimited() || fetchAborted || stream.destroyed) {
|
|
3189
|
+
return true;
|
|
3190
|
+
}
|
|
3191
|
+
return stream.write(chunk);
|
|
3192
|
+
};
|
|
3193
|
+
// Fetch remaining chunks in a loop, writing each to the decoder stream.
|
|
3194
|
+
// Stops when the server returns a short chunk (< chunkSize), the byte
|
|
3195
|
+
// limiter is satisfied, or the consumer destroys the output stream.
|
|
3196
|
+
let fetchAllParts = async () => {
|
|
3197
|
+
while (hasMore && !isLimited() && !fetchAborted) {
|
|
3198
|
+
let { chunk } = await getNextPart();
|
|
3199
|
+
if (!chunk || fetchAborted) {
|
|
3200
|
+
break;
|
|
3201
|
+
}
|
|
3202
|
+
// Handle backpressure
|
|
3203
|
+
if (writeChunk(chunk) === false) {
|
|
3204
|
+
// Wait for drain event before continuing
|
|
3205
|
+
try {
|
|
3206
|
+
await new Promise((resolve, reject) => {
|
|
3207
|
+
// finish() is the listener itself, as settle() is for the TLS upgrade:
|
|
3208
|
+
// 'drain' and 'close' emit no arguments, 'error' emits the error, and
|
|
3209
|
+
// removal needs no separate handler references. It removes only the
|
|
3210
|
+
// three listeners this wait installed - removeAllListeners('error')
|
|
3211
|
+
// also took off the forwarder pipeStage() attached to the head stream
|
|
3212
|
+
// when the pipeline was built, and the head must keep that forwarder
|
|
3213
|
+
// for the life of the download or a chunk failure has nowhere to go.
|
|
3214
|
+
const finish = (err) => {
|
|
3215
|
+
for (let event of ['drain', 'error', 'close']) {
|
|
3216
|
+
stream.removeListener(event, finish);
|
|
3217
|
+
}
|
|
3218
|
+
/* c8 ignore next 2 */ // stream error during a backpressure drain wait is timing-dependent
|
|
3219
|
+
if (err) {
|
|
3220
|
+
reject(err);
|
|
3221
|
+
}
|
|
3222
|
+
else {
|
|
3223
|
+
resolve();
|
|
3224
|
+
}
|
|
3225
|
+
};
|
|
3226
|
+
stream.once('drain', finish);
|
|
3227
|
+
stream.once('error', finish);
|
|
3228
|
+
stream.once('close', finish);
|
|
3229
|
+
});
|
|
3230
|
+
/* c8 ignore start */ // re-throw path only triggers on a stream error mid-drain, which is timing-dependent
|
|
3231
|
+
}
|
|
3232
|
+
catch (err) {
|
|
3233
|
+
// Re-throw only if not aborted
|
|
3234
|
+
if (!fetchAborted) {
|
|
3235
|
+
throw err;
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
/* c8 ignore stop */
|
|
3239
|
+
// Check if we should abort after waiting
|
|
3240
|
+
if (fetchAborted) {
|
|
3241
|
+
break;
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
};
|
|
3246
|
+
// A download is a sequence of chunk FETCHes with a backpressure wait in between. Those
|
|
3247
|
+
// gaps look exactly like an inactive connection, so without this auto-IDLE would start
|
|
3248
|
+
// between chunks and the next chunk would have to break it again - two extra round
|
|
3249
|
+
// trips per chunk, for as long as the consumer is slow. Counted before control returns
|
|
3250
|
+
// to the event loop: the head chunk's own FETCH already armed the auto-IDLE timer, and
|
|
3251
|
+
// with a very short autoIdleDelay that timer could otherwise fire before the deferred
|
|
3252
|
+
// chunk loop below has marked the download open.
|
|
3253
|
+
this._openDownloads++;
|
|
3254
|
+
let downloadDone = false;
|
|
3255
|
+
let finishDownload = () => {
|
|
3256
|
+
if (!downloadDone) {
|
|
3257
|
+
downloadDone = true;
|
|
3258
|
+
this._openDownloads--;
|
|
3259
|
+
this.autoidle();
|
|
3260
|
+
}
|
|
3261
|
+
};
|
|
3262
|
+
// Kick off the download pipeline asynchronously. The first chunk was
|
|
3263
|
+
// already fetched above (to get metadata); write it to the decoder
|
|
3264
|
+
// stream and then fetch remaining chunks via fetchAllParts().
|
|
3265
|
+
// setImmediate ensures the caller gets the {meta, content} return
|
|
3266
|
+
// value before streaming begins.
|
|
3267
|
+
let runFetchAllParts = () => {
|
|
3268
|
+
fetchAllParts()
|
|
3269
|
+
.catch(err => {
|
|
3270
|
+
if (!fetchAborted && stream && !stream.destroyed) {
|
|
3271
|
+
stream.emit('error', err);
|
|
3272
|
+
/* c8 ignore start */ // the else logs when a fetch error arrives after the stream was already torn down (timing-dependent)
|
|
3273
|
+
}
|
|
3274
|
+
else {
|
|
3275
|
+
// Log when error cannot be emitted to stream
|
|
3276
|
+
this.log.warn({
|
|
3277
|
+
msg: 'Download error after stream closed',
|
|
3278
|
+
err,
|
|
3279
|
+
fetchAborted,
|
|
3280
|
+
streamDestroyed: stream?.destroyed,
|
|
3281
|
+
cid: this.id
|
|
3282
|
+
});
|
|
3283
|
+
}
|
|
3284
|
+
/* c8 ignore stop */
|
|
3285
|
+
})
|
|
3286
|
+
.finally(() => {
|
|
3287
|
+
finishDownload();
|
|
3288
|
+
if (!fetchAborted && stream && !stream.destroyed) {
|
|
3289
|
+
stream.end();
|
|
3290
|
+
}
|
|
3291
|
+
})
|
|
3292
|
+
// Terminal guard: nothing consumes this chain, so a throw from either handler
|
|
3293
|
+
// above rejects a promise nobody holds and takes the process down on
|
|
3294
|
+
// unhandledRejection. Reaching it always means an invariant broke - the head
|
|
3295
|
+
// stream kept pipeStage()'s error forwarder for the life of the download, so
|
|
3296
|
+
// emit('error') above has somewhere to go - which is why it logs at error even
|
|
3297
|
+
// for a routine-looking connection code.
|
|
3298
|
+
.catch(err => this.log.error({ msg: 'Failed to fail the download stream', err, cid: this.id }));
|
|
3299
|
+
};
|
|
3300
|
+
setImmediate(() => {
|
|
3301
|
+
let writeResult;
|
|
3302
|
+
try {
|
|
3303
|
+
writeResult = writeChunk(chunk);
|
|
3304
|
+
}
|
|
3305
|
+
catch (err) {
|
|
3306
|
+
stream.emit('error', err);
|
|
3307
|
+
finishDownload();
|
|
3308
|
+
/* c8 ignore next 3 */ // emitting the error above triggers cleanup (fetchAborted=true), so this end() guard is already false here
|
|
3309
|
+
if (!fetchAborted && stream && !stream.destroyed) {
|
|
3310
|
+
stream.end();
|
|
3311
|
+
}
|
|
3312
|
+
return;
|
|
3313
|
+
}
|
|
3314
|
+
/* c8 ignore next 9 */ // `stream` is piped to the limiter before this runs, so the head write drains synchronously and always returns true (verified for chunkSize up to 8MB); the drain-wait branch is unreachable
|
|
3315
|
+
if (!writeResult) {
|
|
3316
|
+
// Initial chunk filled the buffer, wait for drain
|
|
3317
|
+
stream.once('drain', () => {
|
|
3318
|
+
if (!fetchAborted) {
|
|
3319
|
+
runFetchAllParts();
|
|
3320
|
+
}
|
|
3321
|
+
else {
|
|
3322
|
+
finishDownload();
|
|
3323
|
+
}
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3326
|
+
else {
|
|
3327
|
+
runFetchAllParts();
|
|
3328
|
+
}
|
|
3329
|
+
});
|
|
3330
|
+
return {
|
|
3331
|
+
meta,
|
|
3332
|
+
content: output
|
|
3333
|
+
};
|
|
3334
|
+
}
|
|
3335
|
+
/**
|
|
3336
|
+
* Fetch multiple attachments as Buffer values
|
|
3337
|
+
*
|
|
3338
|
+
* @param range UID or sequence number for the message to fetch
|
|
3339
|
+
* @param parts A list of bodystructure parts
|
|
3340
|
+
* @param options Download options
|
|
3341
|
+
* @returns Download data object, keyed by part
|
|
3342
|
+
*
|
|
3343
|
+
* @example
|
|
3344
|
+
* let mailbox = await client.mailboxOpen('INBOX');
|
|
3345
|
+
* // download body parts '2', and '3' from all messages in the selected mailbox
|
|
3346
|
+
* let response = await client.downloadMany('*', ['2', '3']);
|
|
3347
|
+
* process.stdout.write(response[2].content)
|
|
3348
|
+
* process.stdout.write(response[3].content)
|
|
3349
|
+
*/
|
|
3350
|
+
async downloadMany(range, parts, options) {
|
|
3351
|
+
if (!this.mailbox) {
|
|
3352
|
+
// no mailbox selected, nothing to do
|
|
3353
|
+
return {};
|
|
3354
|
+
}
|
|
3355
|
+
let downloadOptions = Object.assign({
|
|
3356
|
+
chunkSize: 64 * 1024,
|
|
3357
|
+
maxBytes: Infinity
|
|
3358
|
+
}, options || {});
|
|
3359
|
+
let query = { bodyParts: [] };
|
|
3360
|
+
for (let part of parts) {
|
|
3361
|
+
query.bodyParts.push(part + '.mime');
|
|
3362
|
+
query.bodyParts.push(part);
|
|
3363
|
+
}
|
|
3364
|
+
let response = await this.fetchOne(range, query, downloadOptions);
|
|
3365
|
+
if (!response || !response.bodyParts) {
|
|
3366
|
+
return { response: false };
|
|
3367
|
+
}
|
|
3368
|
+
let data = {};
|
|
3369
|
+
for (let [part, content] of response.bodyParts) {
|
|
3370
|
+
let keyParts = part.split('.mime');
|
|
3371
|
+
// The server chooses the BODY[...] keys it answers with: never let one be a
|
|
3372
|
+
// prototype-chain name, or the assignments below write onto Object.prototype
|
|
3373
|
+
// (process-wide pollution) instead of the result object.
|
|
3374
|
+
if ((0, tools_js_1.isUnsafeKey)(keyParts[0])) {
|
|
3375
|
+
continue;
|
|
3376
|
+
}
|
|
3377
|
+
if (keyParts.length === 1) {
|
|
3378
|
+
// content
|
|
3379
|
+
let key = keyParts[0];
|
|
3380
|
+
if (!data[key]) {
|
|
3381
|
+
data[key] = { content };
|
|
3382
|
+
}
|
|
3383
|
+
else {
|
|
3384
|
+
data[key].content = content;
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
else if (keyParts.length === 2) {
|
|
3388
|
+
// header
|
|
3389
|
+
let key = keyParts[0];
|
|
3390
|
+
if (!data[key]) {
|
|
3391
|
+
data[key] = {};
|
|
3392
|
+
}
|
|
3393
|
+
let entry = data[key];
|
|
3394
|
+
if (!entry.meta) {
|
|
3395
|
+
entry.meta = {};
|
|
3396
|
+
}
|
|
3397
|
+
let meta = entry.meta;
|
|
3398
|
+
let headers = new mailsplit_1.Headers(content);
|
|
3399
|
+
let contentType = libmime_1.default.parseHeaderValue(headers.getFirst('Content-Type'));
|
|
3400
|
+
let transferEncoding = libmime_1.default.parseHeaderValue(headers.getFirst('Content-Transfer-Encoding'));
|
|
3401
|
+
let disposition = libmime_1.default.parseHeaderValue(headers.getFirst('Content-Disposition'));
|
|
3402
|
+
if (contentType.value.toLowerCase().trim()) {
|
|
3403
|
+
meta.contentType = contentType.value.toLowerCase().trim();
|
|
3404
|
+
}
|
|
3405
|
+
if (contentType.params.charset) {
|
|
3406
|
+
meta.charset = contentType.params.charset.toLowerCase().trim();
|
|
3407
|
+
}
|
|
3408
|
+
if (transferEncoding.value) {
|
|
3409
|
+
meta.encoding = transferEncoding.value
|
|
3410
|
+
.replace(/\(.*\)/g, '')
|
|
3411
|
+
.toLowerCase()
|
|
3412
|
+
.trim();
|
|
3413
|
+
}
|
|
3414
|
+
if (disposition.value) {
|
|
3415
|
+
/* c8 ignore next */ // a parsed disposition value is never all-whitespace, so the `false` fallback is unreachable
|
|
3416
|
+
meta.disposition = disposition.value.toLowerCase().trim() || false;
|
|
3417
|
+
try {
|
|
3418
|
+
meta.disposition = libmime_1.default.decodeWords(meta.disposition);
|
|
3419
|
+
}
|
|
3420
|
+
catch {
|
|
3421
|
+
// failed to parse disposition, keep as is (most probably an unknown charset is used)
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
if (contentType.params.format && contentType.params.format.toLowerCase().trim() === 'flowed') {
|
|
3425
|
+
meta.flowed = true;
|
|
3426
|
+
if (contentType.params.delsp && contentType.params.delsp.toLowerCase().trim() === 'yes') {
|
|
3427
|
+
meta.delSp = true;
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
let filename = disposition.params.filename || contentType.params.name || false;
|
|
3431
|
+
if (filename) {
|
|
3432
|
+
try {
|
|
3433
|
+
filename = libmime_1.default.decodeWords(filename);
|
|
3434
|
+
}
|
|
3435
|
+
catch {
|
|
3436
|
+
// failed to parse filename, keep as is (most probably an unknown charset is used)
|
|
3437
|
+
}
|
|
3438
|
+
meta.filename = filename;
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
for (let part of Object.keys(data)) {
|
|
3443
|
+
let entry = data[part];
|
|
3444
|
+
// `meta` is only built from the companion BODY[<part>.MIME] item. A server may
|
|
3445
|
+
// legally answer with fewer items than were requested, and one part arriving
|
|
3446
|
+
// without its MIME headers must not cost the caller the whole download.
|
|
3447
|
+
let meta = entry.meta || {};
|
|
3448
|
+
entry.meta = meta;
|
|
3449
|
+
// parts that arrived via FETCH BINARY (response.binaryParts) are already
|
|
3450
|
+
// decoded by the server - decoding again would corrupt the data
|
|
3451
|
+
let clientEncoding = response.binaryParts && response.binaryParts.has(part) ? false : meta.encoding;
|
|
3452
|
+
switch (clientEncoding) {
|
|
3453
|
+
case 'base64':
|
|
3454
|
+
entry.content = entry.content ? libbase64_1.default.decode(entry.content.toString()) : null;
|
|
3455
|
+
break;
|
|
3456
|
+
case 'quoted-printable':
|
|
3457
|
+
entry.content = entry.content ? libqp_1.default.decode(entry.content.toString()) : null;
|
|
3458
|
+
break;
|
|
3459
|
+
default:
|
|
3460
|
+
// keep as is, already a buffer
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
return data;
|
|
3464
|
+
}
|
|
3465
|
+
/** @internal */
|
|
3466
|
+
async run(command, ...args) {
|
|
3467
|
+
command = command.toUpperCase();
|
|
3468
|
+
if (!this.commands.has(command)) {
|
|
3469
|
+
return false;
|
|
3470
|
+
}
|
|
3471
|
+
if (!this.socket || this.socket.destroyed) {
|
|
3472
|
+
throw this.createNoConnectionError(false, { rejectedFrom: 'noSocket', command });
|
|
3473
|
+
}
|
|
3474
|
+
(0, tools_js_1.clearTimer)(this.idleStartTimer);
|
|
3475
|
+
try {
|
|
3476
|
+
// The preCheck (breaking an active IDLE) sits inside the try on purpose: the
|
|
3477
|
+
// clearTimeout above is unconditional, so every exit - a failed command or a
|
|
3478
|
+
// preCheck that rejects - must still reach the finally, or auto-IDLE would stay
|
|
3479
|
+
// disarmed on an otherwise healthy connection until some later command succeeded.
|
|
3480
|
+
if (typeof this.preCheck === 'function') {
|
|
3481
|
+
await this.preCheck();
|
|
3482
|
+
}
|
|
3483
|
+
return await this.runInternal(command, ...args);
|
|
3484
|
+
}
|
|
3485
|
+
finally {
|
|
3486
|
+
// Re-arm auto-IDLE after every command, IDLE included. autoidle() clears any prior
|
|
3487
|
+
// timer and declines while the connection is busy or not SELECTED, so calling it
|
|
3488
|
+
// unconditionally is safe and is the single place the invariant lives. IDLE was once
|
|
3489
|
+
// carved out here on the theory that a command which broke it re-arms on its own way
|
|
3490
|
+
// out - but that only holds when a command broke it. When an IDLE or poll session ends
|
|
3491
|
+
// on its own (the server refused IDLE, ended it unsolicited, or a poll failed) this is
|
|
3492
|
+
// the only thing that re-arms it; without it such a connection would go dark until the
|
|
3493
|
+
// socket watchdog tore it down. When a command really did break IDLE, that command is
|
|
3494
|
+
// still in flight at this point, so autoidle() declines here and re-arms once it ends.
|
|
3495
|
+
this.autoidle();
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
/**
|
|
3499
|
+
* Dispatches a command without the IDLE handshake that `run()` performs.
|
|
3500
|
+
*
|
|
3501
|
+
* Used by callers that already own the connection's idle state - fallback polling issues its
|
|
3502
|
+
* commands through here, because `run()` would await `preCheck()`, and the preCheck it would
|
|
3503
|
+
* await belongs to the very polling session making the call, so the session would cancel
|
|
3504
|
+
* itself. Auto-IDLE is not restarted either, for the same reason: the caller is the idle loop.
|
|
3505
|
+
*
|
|
3506
|
+
* @param command Command name, as registered in the command registry.
|
|
3507
|
+
* @param args Arguments forwarded to the command implementation.
|
|
3508
|
+
* @returns Whatever the command implementation returns, or `false` for an
|
|
3509
|
+
* unknown command.
|
|
3510
|
+
* @internal
|
|
3511
|
+
*/
|
|
3512
|
+
async runInternal(command, ...args) {
|
|
3513
|
+
command = command.toUpperCase();
|
|
3514
|
+
if (!this.commands.has(command)) {
|
|
3515
|
+
return false;
|
|
3516
|
+
}
|
|
3517
|
+
if (!this.socket || this.socket.destroyed) {
|
|
3518
|
+
throw this.createNoConnectionError(false, { rejectedFrom: 'noSocket', command });
|
|
3519
|
+
}
|
|
3520
|
+
let handler = this.commands.get(command);
|
|
3521
|
+
return await handler(this, ...args);
|
|
3522
|
+
}
|
|
3523
|
+
// Mailbox lock queue processor. Implements a mutex pattern: only one lock
|
|
3524
|
+
// is active at a time. When the active lock is released, the next queued
|
|
3525
|
+
// lock is processed. The `processingLock` flag prevents concurrent runs
|
|
3526
|
+
// of this method (which could happen via setImmediate re-entry from release()).
|
|
3527
|
+
/** @internal */
|
|
3528
|
+
async processLocks() {
|
|
3529
|
+
const wasProcessing = this.processingLock;
|
|
3530
|
+
if (wasProcessing) {
|
|
3531
|
+
// Another processor is already running; it will pick up new locks
|
|
3532
|
+
this.log.trace({
|
|
3533
|
+
msg: 'Mailbox locking queued',
|
|
3534
|
+
path: this.mailbox && this.mailbox.path,
|
|
3535
|
+
pending: this.locks.length,
|
|
3536
|
+
idling: this.idling,
|
|
3537
|
+
activeLock: this.currentLock
|
|
3538
|
+
? {
|
|
3539
|
+
lockId: this.currentLock.lockId,
|
|
3540
|
+
...(this.currentLock.options?.description && { description: this.currentLock.options?.description })
|
|
3541
|
+
}
|
|
3542
|
+
: null
|
|
3543
|
+
});
|
|
3544
|
+
return;
|
|
3545
|
+
}
|
|
3546
|
+
this.processingLock = true;
|
|
3547
|
+
try {
|
|
3548
|
+
// Process all locks in queue until empty
|
|
3549
|
+
let processedCount = 0;
|
|
3550
|
+
while (this.locks.length > 0) {
|
|
3551
|
+
// Mutex invariant: at most one lock may be held at a time.
|
|
3552
|
+
// If a lock is already granted, stop processing; release() will
|
|
3553
|
+
// clear currentLock and reschedule us to pick up the next queued lock.
|
|
3554
|
+
if (this.currentLock) {
|
|
3555
|
+
break;
|
|
3556
|
+
}
|
|
3557
|
+
// Yield to event loop periodically to prevent CPU blocking
|
|
3558
|
+
processedCount++;
|
|
3559
|
+
if (processedCount % 5 === 0) {
|
|
3560
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
3561
|
+
}
|
|
3562
|
+
const lock = this.locks.shift();
|
|
3563
|
+
const { resolve, reject, path, options, lockId } = lock;
|
|
3564
|
+
// From here on the grant/reject path owns the outcome; the acquire
|
|
3565
|
+
// timer must not race with resolution.
|
|
3566
|
+
if (lock.acquireTimer) {
|
|
3567
|
+
(0, tools_js_1.clearTimer)(lock.acquireTimer);
|
|
3568
|
+
lock.acquireTimer = null;
|
|
3569
|
+
}
|
|
3570
|
+
const armHeldTimer = () => {
|
|
3571
|
+
let threshold = Number(options.maxLockHoldTime ?? this.options.maxLockHoldTime ?? HELD_LOCK_WARN_MS);
|
|
3572
|
+
if (!threshold || threshold <= 0) {
|
|
3573
|
+
return;
|
|
3574
|
+
}
|
|
3575
|
+
lock.heldAt = Date.now();
|
|
3576
|
+
// Background diagnostic: must not keep the process alive on its own
|
|
3577
|
+
lock.heldWarnTimer = setTimeout(() => {
|
|
3578
|
+
lock.heldWarnTimer = null;
|
|
3579
|
+
this.log.warn({
|
|
3580
|
+
msg: 'Mailbox lock held for a long time',
|
|
3581
|
+
lockId: lock.lockId,
|
|
3582
|
+
path,
|
|
3583
|
+
heldFor: Date.now() - lock.heldAt,
|
|
3584
|
+
/* c8 ignore next */ // the held-lock-warning diagnostic with a description set is a timing-dependent log detail
|
|
3585
|
+
...(options.description && { description: options.description }),
|
|
3586
|
+
cid: this.id
|
|
3587
|
+
});
|
|
3588
|
+
}, threshold);
|
|
3589
|
+
(0, tools_js_1.unrefTimer)(lock.heldWarnTimer);
|
|
3590
|
+
};
|
|
3591
|
+
// release() is captured per-lock. It must only clear this.currentLock
|
|
3592
|
+
// if the caller still owns it - otherwise a stale release (after a
|
|
3593
|
+
// disconnect replaced the lock, or a double-release from user code)
|
|
3594
|
+
// would clear the new holder's lock and allow concurrent access.
|
|
3595
|
+
const release = () => {
|
|
3596
|
+
if (this.currentLock === lock) {
|
|
3597
|
+
if (lock.heldWarnTimer) {
|
|
3598
|
+
(0, tools_js_1.clearTimer)(lock.heldWarnTimer);
|
|
3599
|
+
lock.heldWarnTimer = null;
|
|
3600
|
+
}
|
|
3601
|
+
this.log.trace({
|
|
3602
|
+
msg: 'Mailbox lock released',
|
|
3603
|
+
lockId: lock.lockId,
|
|
3604
|
+
path: this.mailbox && this.mailbox.path,
|
|
3605
|
+
pending: this.locks.length,
|
|
3606
|
+
idling: this.idling
|
|
3607
|
+
});
|
|
3608
|
+
this.currentLock = false;
|
|
3609
|
+
// autoidle() will not arm while a lock is held, so the release is what
|
|
3610
|
+
// restarts it. It re-checks the queue itself, so a lock waiting behind
|
|
3611
|
+
// this one still keeps IDLE off.
|
|
3612
|
+
this.autoidle();
|
|
3613
|
+
// Use setImmediate to avoid stack overflow
|
|
3614
|
+
setImmediate(() => {
|
|
3615
|
+
this.processLocks().catch(err => this.log.error({ err, cid: this.id }));
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
else {
|
|
3619
|
+
this.log.trace({
|
|
3620
|
+
msg: 'Ignoring stale lock release',
|
|
3621
|
+
lockId: lock.lockId,
|
|
3622
|
+
cid: this.id
|
|
3623
|
+
});
|
|
3624
|
+
}
|
|
3625
|
+
};
|
|
3626
|
+
if (!this.usable || !this.socket || this.socket.destroyed) {
|
|
3627
|
+
this.log.trace({ msg: 'Failed to acquire mailbox lock', path, lockId, idling: this.idling });
|
|
3628
|
+
reject(this.createNoConnectionError(false, { rejectedFrom: 'mailboxLock', path }));
|
|
3629
|
+
continue; // Process next lock in queue
|
|
3630
|
+
}
|
|
3631
|
+
// Both grant paths finish the same way. autoidle() is re-checked because a stale
|
|
3632
|
+
// auto-IDLE timer may still be armed at this point: on the SELECT path run()
|
|
3633
|
+
// re-arms auto-IDLE when the SELECT settles - a moment before currentLock is set -
|
|
3634
|
+
// and the fast path can inherit a timer from an earlier command. Either way the
|
|
3635
|
+
// timer must not fire inside the lock.
|
|
3636
|
+
const grantLock = () => {
|
|
3637
|
+
this.currentLock = lock;
|
|
3638
|
+
armHeldTimer();
|
|
3639
|
+
this.autoidle();
|
|
3640
|
+
resolve({ path, release });
|
|
3641
|
+
};
|
|
3642
|
+
if (this.mailbox && this.mailbox.path === path && !!this.mailbox.readOnly === !!options.readOnly) {
|
|
3643
|
+
// Fast path: mailbox is already selected with the right access mode
|
|
3644
|
+
this.log.trace({
|
|
3645
|
+
msg: 'Mailbox lock acquired [existing]',
|
|
3646
|
+
path,
|
|
3647
|
+
lockId,
|
|
3648
|
+
idling: this.idling,
|
|
3649
|
+
...(options.description && { description: options.description })
|
|
3650
|
+
});
|
|
3651
|
+
grantLock();
|
|
3652
|
+
break; // Stop processing; next lock waits for release()
|
|
3653
|
+
}
|
|
3654
|
+
try {
|
|
3655
|
+
// Need to SELECT/EXAMINE a different mailbox
|
|
3656
|
+
await this.mailboxOpen(path, options);
|
|
3657
|
+
this.log.trace({
|
|
3658
|
+
msg: 'Mailbox lock acquired [selected]',
|
|
3659
|
+
path,
|
|
3660
|
+
lockId,
|
|
3661
|
+
idling: this.idling,
|
|
3662
|
+
...(options.description && { description: options.description })
|
|
3663
|
+
});
|
|
3664
|
+
grantLock();
|
|
3665
|
+
break; // Wait for this lock to be released
|
|
3666
|
+
}
|
|
3667
|
+
catch (err) {
|
|
3668
|
+
if (err.responseStatus === 'NO') {
|
|
3669
|
+
// SELECT failed with NO: verify whether the mailbox exists
|
|
3670
|
+
// at all by running LIST. This sets mailboxMissing on the error
|
|
3671
|
+
// so the caller can distinguish "doesn't exist" from other failures.
|
|
3672
|
+
try {
|
|
3673
|
+
let folders = await this.run('LIST', '', path, { listOnly: true });
|
|
3674
|
+
if (!folders || !folders.length) {
|
|
3675
|
+
err.mailboxMissing = true;
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
catch (E) {
|
|
3679
|
+
this.log.trace({ msg: 'Failed to verify failed mailbox', path, err: E });
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
this.log.trace({
|
|
3683
|
+
msg: 'Failed to acquire mailbox lock',
|
|
3684
|
+
path,
|
|
3685
|
+
lockId,
|
|
3686
|
+
idling: this.idling,
|
|
3687
|
+
...(options.description && { description: options.description }),
|
|
3688
|
+
err
|
|
3689
|
+
});
|
|
3690
|
+
reject(err);
|
|
3691
|
+
// Continue to next lock in queue
|
|
3692
|
+
}
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
finally {
|
|
3696
|
+
this.processingLock = false;
|
|
3697
|
+
// New locks may have been queued while we were processing (e.g.,
|
|
3698
|
+
// a lock that failed immediately and the next getMailboxLock call
|
|
3699
|
+
// arrived before we finished). Schedule another run if needed.
|
|
3700
|
+
/* c8 ignore start */ // requires a lock to be enqueued during an in-flight processLocks pass; not reproducible deterministically
|
|
3701
|
+
if (this.locks.length && !this.currentLock) {
|
|
3702
|
+
setImmediate(() => {
|
|
3703
|
+
this.processLocks().catch(err => this.log.error({ err, cid: this.id }));
|
|
3704
|
+
});
|
|
3705
|
+
}
|
|
3706
|
+
/* c8 ignore stop */
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
/**
|
|
3710
|
+
* Opens a mailbox if not already open and returns a lock. Next call to `getMailboxLock()` is queued
|
|
3711
|
+
* until previous lock is released. This is suggested over {@link ImapFlow#mailboxOpen} as
|
|
3712
|
+
* `getMailboxLock()` gives you a weak transaction while `mailboxOpen()` has no guarantees whatsoever that another
|
|
3713
|
+
* mailbox is opened while you try to call multiple fetch or store commands.
|
|
3714
|
+
*
|
|
3715
|
+
* @param path **Path for the mailbox** to open
|
|
3716
|
+
* @param options optional options
|
|
3717
|
+
* @returns Mailbox lock
|
|
3718
|
+
* @throws Will throw an error if mailbox does not exist or can not be opened
|
|
3719
|
+
*
|
|
3720
|
+
* @example
|
|
3721
|
+
* let lock = await client.getMailboxLock('INBOX');
|
|
3722
|
+
* try {
|
|
3723
|
+
* // do something in the mailbox
|
|
3724
|
+
* } finally {
|
|
3725
|
+
* // use finally{} to make sure lock is released even if exception occurs
|
|
3726
|
+
* lock.release();
|
|
3727
|
+
* }
|
|
3728
|
+
*/
|
|
3729
|
+
getMailboxLock(path, options) {
|
|
3730
|
+
options = options || {};
|
|
3731
|
+
let lockPath = (0, tools_js_1.normalizePath)(this, path);
|
|
3732
|
+
let lockId = ++this.lockCounter;
|
|
3733
|
+
this.log.trace({
|
|
3734
|
+
msg: 'Requesting lock',
|
|
3735
|
+
path: lockPath,
|
|
3736
|
+
lockId,
|
|
3737
|
+
...(options.description && { description: options.description }),
|
|
3738
|
+
activeLock: this.currentLock
|
|
3739
|
+
? {
|
|
3740
|
+
lockId: this.currentLock.lockId,
|
|
3741
|
+
...(this.currentLock.options?.description && { description: this.currentLock.options?.description })
|
|
3742
|
+
}
|
|
3743
|
+
: null
|
|
3744
|
+
});
|
|
3745
|
+
const lockOptions = options;
|
|
3746
|
+
// Guarded: close() rejects every queued lock synchronously. See guardedPromise().
|
|
3747
|
+
let lockPromise = (0, tools_js_1.guardedPromise)((resolve, reject) => {
|
|
3748
|
+
let lockEntry = { resolve, reject, path: lockPath, options: lockOptions, lockId };
|
|
3749
|
+
this.locks.push(lockEntry);
|
|
3750
|
+
// Opt-in acquire timeout: if the lock has not been granted within
|
|
3751
|
+
// acquireTimeout ms, remove it from the queue and reject. Only
|
|
3752
|
+
// affects queued (pending) locks - once granted, the timer is cleared.
|
|
3753
|
+
if (Number(lockOptions.acquireTimeout) > 0) {
|
|
3754
|
+
lockEntry.acquireTimer = setTimeout(() => {
|
|
3755
|
+
lockEntry.acquireTimer = null;
|
|
3756
|
+
const idx = this.locks.indexOf(lockEntry);
|
|
3757
|
+
if (idx !== -1) {
|
|
3758
|
+
this.locks.splice(idx, 1);
|
|
3759
|
+
let err = new Error('Timed out waiting for mailbox lock');
|
|
3760
|
+
err.code = 'LockTimeout';
|
|
3761
|
+
err.lockId = lockEntry.lockId;
|
|
3762
|
+
reject(err);
|
|
3763
|
+
}
|
|
3764
|
+
}, Number(lockOptions.acquireTimeout));
|
|
3765
|
+
}
|
|
3766
|
+
this.processLocks().catch(err => reject(err));
|
|
3767
|
+
});
|
|
3768
|
+
return lockPromise;
|
|
3769
|
+
}
|
|
3770
|
+
/** @internal */
|
|
3771
|
+
getLogger() {
|
|
3772
|
+
let mainLogger = this.options.logger && typeof this.options.logger === 'object'
|
|
3773
|
+
? this.options.logger
|
|
3774
|
+
: logger_js_1.default.child({
|
|
3775
|
+
component: 'imap-connection',
|
|
3776
|
+
cid: this.id
|
|
3777
|
+
});
|
|
3778
|
+
let synteticLogger = {};
|
|
3779
|
+
let levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
|
|
3780
|
+
for (let level of levels) {
|
|
3781
|
+
synteticLogger[level] = (...args) => {
|
|
3782
|
+
// using {logger:false} disables logging
|
|
3783
|
+
if (this.options.logger !== false) {
|
|
3784
|
+
const logMethod = mainLogger[level];
|
|
3785
|
+
if (typeof logMethod !== 'function') {
|
|
3786
|
+
// we are checking to make sure the level is supported.
|
|
3787
|
+
// if it isn't supported but the level is error or fatal, log to console anyway.
|
|
3788
|
+
if (level === 'fatal' || level === 'error') {
|
|
3789
|
+
let entry = args[0];
|
|
3790
|
+
try {
|
|
3791
|
+
if (entry && typeof entry === 'object' && entry.err) {
|
|
3792
|
+
entry = Object.assign({}, entry, { err: flattenLoggedError(entry.err) });
|
|
3793
|
+
}
|
|
3794
|
+
console.error(JSON.stringify(entry));
|
|
3795
|
+
}
|
|
3796
|
+
catch {
|
|
3797
|
+
// Serializing failed (a circular structure, a BigInt, a throwing
|
|
3798
|
+
// getter). This fallback exists so an error is never lost, so hand
|
|
3799
|
+
// the entry to console.error itself - it inspects rather than
|
|
3800
|
+
// serializes, and handles all three - instead of dropping it.
|
|
3801
|
+
console.error(entry);
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
}
|
|
3805
|
+
else {
|
|
3806
|
+
logMethod.apply(mainLogger, args);
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
if (this.emitLogs && args && args[0] && typeof args[0] === 'object') {
|
|
3810
|
+
// Guarded for the same reason as the console fallback above: a log call must
|
|
3811
|
+
// never throw. Most of these run inside catch blocks in the protocol
|
|
3812
|
+
// machinery, where a throw would escape the handler that was recovering from
|
|
3813
|
+
// something else and strand the connection. A throwing property getter on the
|
|
3814
|
+
// logged error and a throwing 'log' listener both end up here.
|
|
3815
|
+
try {
|
|
3816
|
+
let logEntry = Object.assign({ level, t: Date.now(), cid: this.id, lo: ++this.lo }, args[0]);
|
|
3817
|
+
if (logEntry.err) {
|
|
3818
|
+
logEntry.err = flattenLoggedError(logEntry.err);
|
|
3819
|
+
}
|
|
3820
|
+
this.emit('log', logEntry);
|
|
3821
|
+
}
|
|
3822
|
+
catch {
|
|
3823
|
+
// Nothing to do with it: reporting the failure would re-enter this
|
|
3824
|
+
// same path
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3827
|
+
};
|
|
3828
|
+
}
|
|
3829
|
+
return synteticLogger;
|
|
3830
|
+
}
|
|
3831
|
+
/**
|
|
3832
|
+
* Detaches sockets from the IMAP pipeline. Useful for upgrading the connection
|
|
3833
|
+
* (e.g., STARTTLS) or transferring socket ownership.
|
|
3834
|
+
*
|
|
3835
|
+
* @returns Socket objects: `readSocket` is the read socket (inflated socket if compression is enabled, raw socket otherwise),
|
|
3836
|
+
* `writeSocket` the write socket and `socket` the raw underlying socket (same as readSocket/writeSocket when compression is disabled)
|
|
3837
|
+
*/
|
|
3838
|
+
unbind() {
|
|
3839
|
+
const socket = this.socket;
|
|
3840
|
+
socket.unpipe(this.streamer);
|
|
3841
|
+
if (this._inflate) {
|
|
3842
|
+
this._inflate.unpipe(this.streamer);
|
|
3843
|
+
}
|
|
3844
|
+
// Detach all of ImapFlow's socket listeners - the raw socket plus, when
|
|
3845
|
+
// compression is active, the PassThrough writeSocket - so the connection
|
|
3846
|
+
// is fully released to the caller.
|
|
3847
|
+
this.clearSocketHandlers();
|
|
3848
|
+
const readSocket = this._inflate || socket;
|
|
3849
|
+
const writeSocket = this.writeSocket || socket;
|
|
3850
|
+
// Defense-in-depth: when compression is active the raw socket is orphaned
|
|
3851
|
+
// (neither readSocket nor writeSocket) yet still live and still the target
|
|
3852
|
+
// of the deflate/writeSocket error forwarders. We just stripped our own
|
|
3853
|
+
// error listener, so any post-unbind error (e.g. an upstream ECONNRESET)
|
|
3854
|
+
// would become an unhandled 'error' that crashes the host process. Attach
|
|
3855
|
+
// a benign listener so the orphaned socket can never throw after handoff.
|
|
3856
|
+
// Non-compression path: socket === readSocket === writeSocket and the
|
|
3857
|
+
// caller owns it directly, so leave it untouched (no behavior change).
|
|
3858
|
+
if (socket !== readSocket && socket !== writeSocket) {
|
|
3859
|
+
socket.on('error', err => {
|
|
3860
|
+
this.log.debug({ msg: 'Suppressed error on unbound socket', err, cid: this.id });
|
|
3861
|
+
});
|
|
3862
|
+
}
|
|
3863
|
+
return {
|
|
3864
|
+
readSocket,
|
|
3865
|
+
writeSocket,
|
|
3866
|
+
socket
|
|
3867
|
+
};
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
exports.ImapFlow = ImapFlow;
|
|
3871
|
+
/**
|
|
3872
|
+
* Connection close event. **NB!** ImapFlow does not handle reconnects automatically.
|
|
3873
|
+
* So whenever a 'close' event occurs you must create a new connection yourself.
|
|
3874
|
+
*
|
|
3875
|
+
* @event ImapFlow#close
|
|
3876
|
+
*/
|
|
3877
|
+
/**
|
|
3878
|
+
* Error event. In most cases getting an error event also means that connection is closed
|
|
3879
|
+
* and pending operations should return with a failure.
|
|
3880
|
+
*
|
|
3881
|
+
* @event ImapFlow#error
|
|
3882
|
+
* @example
|
|
3883
|
+
* client.on('error', err=>{
|
|
3884
|
+
* console.log(`Error occurred: ${err.message}`);
|
|
3885
|
+
* });
|
|
3886
|
+
*/
|
|
3887
|
+
/**
|
|
3888
|
+
* Message count in currently opened mailbox changed
|
|
3889
|
+
*
|
|
3890
|
+
* @event ImapFlow#exists
|
|
3891
|
+
* @example
|
|
3892
|
+
* client.on('exists', data=>{
|
|
3893
|
+
* console.log(`Message count in "${data.path}" is ${data.count}`);
|
|
3894
|
+
* });
|
|
3895
|
+
*/
|
|
3896
|
+
/**
|
|
3897
|
+
* Deleted message sequence number in currently opened mailbox. One event is fired for every deleted email.
|
|
3898
|
+
*
|
|
3899
|
+
* @event ImapFlow#expunge
|
|
3900
|
+
* @example
|
|
3901
|
+
* client.on('expunge', data=>{
|
|
3902
|
+
* console.log(`Message #${data.seq} was deleted from "${data.path}"`);
|
|
3903
|
+
* });
|
|
3904
|
+
*/
|
|
3905
|
+
/**
|
|
3906
|
+
* Flags were updated for a message. Not all servers fire this event.
|
|
3907
|
+
*
|
|
3908
|
+
* @event ImapFlow#flags
|
|
3909
|
+
* @example
|
|
3910
|
+
* client.on('flags', data=>{
|
|
3911
|
+
* console.log(`Flag set for #${data.seq} is now "${Array.from(data.flags).join(', ')}"`);
|
|
3912
|
+
* });
|
|
3913
|
+
*/
|
|
3914
|
+
/**
|
|
3915
|
+
* Mailbox was opened
|
|
3916
|
+
*
|
|
3917
|
+
* @event ImapFlow#mailboxOpen
|
|
3918
|
+
* @example
|
|
3919
|
+
* client.on('mailboxOpen', mailbox => {
|
|
3920
|
+
* console.log(`Mailbox ${mailbox.path} was opened`);
|
|
3921
|
+
* });
|
|
3922
|
+
*/
|
|
3923
|
+
/**
|
|
3924
|
+
* Mailbox was closed
|
|
3925
|
+
*
|
|
3926
|
+
* Emitted both when a selected mailbox is closed explicitly, by `mailboxClose()` or by
|
|
3927
|
+
* selecting a different mailbox, and when the connection itself goes away while a mailbox
|
|
3928
|
+
* was still selected, whether through a clean logout or a lost transport. The transition is
|
|
3929
|
+
* reported once per selected mailbox, before the `close` event.
|
|
3930
|
+
*
|
|
3931
|
+
* @event ImapFlow#mailboxClose
|
|
3932
|
+
* @example
|
|
3933
|
+
* client.on('mailboxClose', mailbox => {
|
|
3934
|
+
* console.log(`Mailbox ${mailbox.path} was closed`);
|
|
3935
|
+
* });
|
|
3936
|
+
*/
|
|
3937
|
+
/**
|
|
3938
|
+
* Log event if `emitLogs=true`
|
|
3939
|
+
*
|
|
3940
|
+
* @event ImapFlow#log
|
|
3941
|
+
* @example
|
|
3942
|
+
* client.on('log', entry => {
|
|
3943
|
+
* console.log(`${entry.cid} ${entry.msg}`);
|
|
3944
|
+
* });
|
|
3945
|
+
*/
|
|
3946
|
+
// Both `import { ImapFlow } from 'imapflow'` and `import imapflow from 'imapflow'` work, the
|
|
3947
|
+
// latter matching the shape `require('imapflow')` has always had
|
|
3948
|
+
const imapflow = { ImapFlow, AuthenticationFailure: errors_js_1.AuthenticationFailure };
|
|
3949
|
+
exports.default = imapflow;
|