imapflow 1.7.0 → 1.7.2
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +2 -3
- package/lib/commands/authenticate.js +15 -5
- package/lib/commands/idle.js +20 -13
- package/lib/imap-flow.d.ts +17 -6
- package/lib/imap-flow.js +202 -61
- package/lib/proxy-connection.js +14 -4
- package/lib/tools.js +58 -7
- package/package.json +3 -3
- package/test/imap-flow-internals-test.js +154 -18
- package/test/tools-test.js +113 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.7.2](https://github.com/postalsys/imapflow/compare/v1.7.1...v1.7.2) (2026-08-21)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **envelope:** stop inventing an address from a NIL host field ([253a7b0](https://github.com/postalsys/imapflow/commit/253a7b0747ebe3c59fb177d46eb9843da6b5e91e))
|
|
9
|
+
* **types:** accept string[] paths in status, getQuota, append, copy and move ([92f3607](https://github.com/postalsys/imapflow/commit/92f360749fd9518532f0d908bf246fb9402f2eaa)), closes [#382](https://github.com/postalsys/imapflow/issues/382)
|
|
10
|
+
|
|
11
|
+
## [1.7.1](https://github.com/postalsys/imapflow/compare/v1.7.0...v1.7.1) (2026-08-14)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* **logging:** mask credential frames in the raw log and keep error detail ([2d6563b](https://github.com/postalsys/imapflow/commit/2d6563b75410a2cd9d4fbca08a76230589b60079))
|
|
17
|
+
|
|
3
18
|
## [1.7.0](https://github.com/postalsys/imapflow/compare/v1.6.6...v1.7.0) (2026-08-11)
|
|
4
19
|
|
|
5
20
|
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Modern and easy-to-use IMAP client library for Node.js.
|
|
|
5
5
|
[](https://www.npmjs.com/package/imapflow)
|
|
6
6
|
[](https://github.com/postalsys/imapflow/blob/master/LICENSE)
|
|
7
7
|
|
|
8
|
-
ImapFlow provides a clean, promise-based API for working with IMAP, so you don't need in-depth knowledge of the protocol. IMAP extensions are detected and handled automatically. You write the same code regardless of server capabilities, and ImapFlow adapts behind the scenes.
|
|
8
|
+
ImapFlow provides a clean, promise-based API for working with IMAP, so you don't need in-depth knowledge of the protocol. IMAP extensions are detected and handled automatically. You write the same code regardless of server capabilities, and ImapFlow adapts behind the scenes. ImapFlow is the IMAP engine that powers [EmailEngine](https://emailengine.app/?utm_source=imapflow-readme&utm_medium=readme&utm_campaign=oss-docs&utm_content=intro), a self-hosted email API built by the same team.
|
|
9
9
|
|
|
10
10
|
## Features
|
|
11
11
|
|
|
@@ -77,8 +77,7 @@ Full documentation is available at **[imapflow.com](https://imapflow.com/docs/)*
|
|
|
77
77
|
- [Mailbox Management](https://imapflow.com/docs/guides/mailbox-management) - creating, renaming, and deleting mailboxes
|
|
78
78
|
- [API Reference](https://imapflow.com/docs/api/imapflow-client) - complete method and event documentation
|
|
79
79
|
|
|
80
|
-
> [
|
|
81
|
-
> If you are looking for a complete email integration solution, ImapFlow was built for [EmailEngine](https://emailengine.app/), a self-hosted email gateway that provides REST API access to IMAP and SMTP accounts.
|
|
80
|
+
> ImapFlow was built for **[EmailEngine](https://emailengine.app/?utm_source=imapflow-readme&utm_medium=readme&utm_campaign=oss-docs&utm_content=note)**, a self-hosted email API that turns Gmail, Microsoft 365, and IMAP accounts into REST endpoints, with managed OAuth2 and webhooks for incoming mail. If you need a production email integration rather than an IMAP client, start there.
|
|
82
81
|
|
|
83
82
|
## License
|
|
84
83
|
|
|
@@ -81,11 +81,16 @@ async function authOauth(connection, username, accessToken) {
|
|
|
81
81
|
try {
|
|
82
82
|
errorResponse = JSON.parse(Buffer.from(resp.attributes[0].value, 'base64').toString());
|
|
83
83
|
} catch (err) {
|
|
84
|
-
connection.log.debug({
|
|
84
|
+
connection.log.debug({
|
|
85
|
+
msg: 'Failed to parse OAuth error response',
|
|
86
|
+
errorResponse: resp.attributes[0].value,
|
|
87
|
+
err,
|
|
88
|
+
cid: connection.id
|
|
89
|
+
});
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
92
|
|
|
88
|
-
connection.log.debug({ src: 'c', msg: breaker, comment: `Error response for ${command}
|
|
93
|
+
connection.log.debug({ src: 'c', msg: breaker, comment: `Error response for ${command}`, cid: connection.id });
|
|
89
94
|
connection.write(breaker);
|
|
90
95
|
}
|
|
91
96
|
}
|
|
@@ -126,10 +131,10 @@ async function authLogin(connection, username, password) {
|
|
|
126
131
|
|
|
127
132
|
if (question === 'username' || question === 'user name') {
|
|
128
133
|
let encodedUsername = Buffer.from(username).toString('base64');
|
|
129
|
-
connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN
|
|
134
|
+
connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN`, cid: connection.id });
|
|
130
135
|
connection.write(encodedUsername);
|
|
131
136
|
} else if (question === 'password') {
|
|
132
|
-
connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN
|
|
137
|
+
connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN`, cid: connection.id });
|
|
133
138
|
connection.write(Buffer.from(password).toString('base64'));
|
|
134
139
|
} else {
|
|
135
140
|
throw new Error(`Unknown LOGIN question "${question}"`);
|
|
@@ -169,7 +174,12 @@ async function authPlain(connection, username, password, authzid) {
|
|
|
169
174
|
let authzidValue = authzid || '';
|
|
170
175
|
let encodedResponse = Buffer.from([authzidValue, username, password].join('\x00')).toString('base64');
|
|
171
176
|
let loggedResponse = Buffer.from([authzidValue, username, '(* value hidden *)'].join('\x00')).toString('base64');
|
|
172
|
-
connection.log.debug({
|
|
177
|
+
connection.log.debug({
|
|
178
|
+
src: 'c',
|
|
179
|
+
msg: loggedResponse,
|
|
180
|
+
comment: `Encoded response for AUTH=PLAIN${authzid ? ' with authzid' : ''}`,
|
|
181
|
+
cid: connection.id
|
|
182
|
+
});
|
|
173
183
|
connection.write(encodedResponse);
|
|
174
184
|
}
|
|
175
185
|
});
|
package/lib/commands/idle.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { hasCapability, unrefTimer } = require('../tools.js');
|
|
3
|
+
const { hasCapability, logConnectionError, unrefTimer } = require('../tools.js');
|
|
4
4
|
|
|
5
5
|
const NOOP_INTERVAL = 2 * 60 * 1000;
|
|
6
6
|
|
|
@@ -64,7 +64,8 @@ async function runIdle(connection) {
|
|
|
64
64
|
msg: `DONE`,
|
|
65
65
|
comment: `breaking IDLE`,
|
|
66
66
|
lockId: connection.currentLock?.lockId,
|
|
67
|
-
path: connection.mailbox && connection.mailbox.path
|
|
67
|
+
path: connection.mailbox && connection.mailbox.path,
|
|
68
|
+
cid: connection.id
|
|
68
69
|
});
|
|
69
70
|
connection.write('DONE');
|
|
70
71
|
doneSent = true;
|
|
@@ -95,10 +96,11 @@ async function runIdle(connection) {
|
|
|
95
96
|
queued: preCheckWaitQueue.length,
|
|
96
97
|
doneRequested,
|
|
97
98
|
canEnd,
|
|
98
|
-
doneSent
|
|
99
|
+
doneSent,
|
|
100
|
+
cid: connection.id
|
|
99
101
|
});
|
|
100
102
|
|
|
101
|
-
preCheck().catch(err => connection
|
|
103
|
+
preCheck().catch(err => logConnectionError(connection, 'Failed to break IDLE', err));
|
|
102
104
|
|
|
103
105
|
return handler;
|
|
104
106
|
};
|
|
@@ -112,13 +114,18 @@ async function runIdle(connection) {
|
|
|
112
114
|
// After this, the server will push untagged responses for mailbox changes.
|
|
113
115
|
// We can now safely send DONE if a break was already requested.
|
|
114
116
|
onPlusTag: async () => {
|
|
115
|
-
connection.log.debug({
|
|
117
|
+
connection.log.debug({
|
|
118
|
+
msg: `Initiated IDLE, waiting for server input`,
|
|
119
|
+
lockId: connection.currentLock?.lockId,
|
|
120
|
+
doneRequested,
|
|
121
|
+
cid: connection.id
|
|
122
|
+
});
|
|
116
123
|
canEnd = true;
|
|
117
124
|
if (doneRequested) {
|
|
118
125
|
try {
|
|
119
126
|
await preCheck();
|
|
120
127
|
} catch (err) {
|
|
121
|
-
connection
|
|
128
|
+
logConnectionError(connection, 'Failed to break IDLE', err);
|
|
122
129
|
}
|
|
123
130
|
}
|
|
124
131
|
},
|
|
@@ -128,7 +135,7 @@ async function runIdle(connection) {
|
|
|
128
135
|
response.next();
|
|
129
136
|
return;
|
|
130
137
|
} catch (err) {
|
|
131
|
-
connection
|
|
138
|
+
logConnectionError(connection, 'IDLE session failed', err);
|
|
132
139
|
while (preCheckWaitQueue.length) {
|
|
133
140
|
let { reject } = preCheckWaitQueue.shift();
|
|
134
141
|
reject(err);
|
|
@@ -166,12 +173,12 @@ async function pollOnce(connection, session) {
|
|
|
166
173
|
|
|
167
174
|
switch (connection.missingIdleCommand) {
|
|
168
175
|
case 'SELECT':
|
|
169
|
-
connection.log.debug({
|
|
176
|
+
connection.log.debug({ msg: `Running SELECT to detect changes in folder`, cid: connection.id });
|
|
170
177
|
await connection.runInternal('SELECT', path, { readOnly: session.selectCommand.command === 'EXAMINE' });
|
|
171
178
|
break;
|
|
172
179
|
|
|
173
180
|
case 'STATUS': {
|
|
174
|
-
connection.log.debug({
|
|
181
|
+
connection.log.debug({ msg: `Running STATUS to detect changes in folder`, cid: connection.id });
|
|
175
182
|
// HIGHESTMODSEQ is filtered out again unless the server advertises CONDSTORE, so a
|
|
176
183
|
// CONDSTORE session keeps mailbox.highestModseq current without asking a plain
|
|
177
184
|
// server for an item it does not know.
|
|
@@ -240,7 +247,7 @@ async function runPollingFallback(connection, maxIdleTime) {
|
|
|
240
247
|
};
|
|
241
248
|
|
|
242
249
|
session.preCheck = async () => {
|
|
243
|
-
connection.log.debug({
|
|
250
|
+
connection.log.debug({ msg: `Breaking NOOP loop`, cid: connection.id });
|
|
244
251
|
cancel();
|
|
245
252
|
};
|
|
246
253
|
connection.preCheck = session.preCheck;
|
|
@@ -269,7 +276,7 @@ async function runPollingFallback(connection, maxIdleTime) {
|
|
|
269
276
|
scheduleNextPoll(interval);
|
|
270
277
|
})
|
|
271
278
|
.catch(err => {
|
|
272
|
-
connection
|
|
279
|
+
logConnectionError(connection, 'Failed to poll for mailbox changes', err);
|
|
273
280
|
cancel();
|
|
274
281
|
});
|
|
275
282
|
};
|
|
@@ -280,7 +287,7 @@ async function runPollingFallback(connection, maxIdleTime) {
|
|
|
280
287
|
unrefTimer(session.timer);
|
|
281
288
|
}
|
|
282
289
|
|
|
283
|
-
connection.log.debug({
|
|
290
|
+
connection.log.debug({ msg: `Initiated NOOP loop`, cid: connection.id });
|
|
284
291
|
|
|
285
292
|
// Every auto-IDLE restart begins a fresh polling session, so an unconditional first
|
|
286
293
|
// poll would tie the poll rate to how often the caller runs commands rather than to
|
|
@@ -339,7 +346,7 @@ module.exports = async (connection, maxIdleTime) => {
|
|
|
339
346
|
stillIdling = true;
|
|
340
347
|
// request IDLE break if IDLE has been running for allowed time
|
|
341
348
|
connection.log.trace({ msg: 'Max allowed IDLE time reached', cid: connection.id });
|
|
342
|
-
connection.preCheck().catch(err => connection
|
|
349
|
+
connection.preCheck().catch(err => logConnectionError(connection, 'Failed to break IDLE for restart', err));
|
|
343
350
|
}
|
|
344
351
|
}
|
|
345
352
|
}, maxIdleTime);
|
package/lib/imap-flow.d.ts
CHANGED
|
@@ -45,7 +45,10 @@ export interface ImapFlowOptions {
|
|
|
45
45
|
tls?: ConnectionOptions;
|
|
46
46
|
/** Custom logger instance. Set to false to disable logging */
|
|
47
47
|
logger?: Logger | false;
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* If true, log data read and written to socket encoded in base64. Client frames that carry
|
|
50
|
+
* credentials are replaced with a fixed placeholder and marked with `hidden: true`.
|
|
51
|
+
*/
|
|
49
52
|
logRaw?: boolean;
|
|
50
53
|
/** If true, emit 'log' events */
|
|
51
54
|
emitLogs?: boolean;
|
|
@@ -784,7 +787,7 @@ export class ImapFlow extends EventEmitter {
|
|
|
784
787
|
close(): void;
|
|
785
788
|
|
|
786
789
|
/** Returns current quota */
|
|
787
|
-
getQuota(path?: string): Promise<QuotaResponse | false>;
|
|
790
|
+
getQuota(path?: string | string[]): Promise<QuotaResponse | false>;
|
|
788
791
|
|
|
789
792
|
/** Lists available mailboxes as an Array */
|
|
790
793
|
list(options?: ListOptions): Promise<ListResponse[]>;
|
|
@@ -818,7 +821,7 @@ export class ImapFlow extends EventEmitter {
|
|
|
818
821
|
|
|
819
822
|
/** Requests the status of the indicated mailbox */
|
|
820
823
|
status(
|
|
821
|
-
path: string,
|
|
824
|
+
path: string | string[],
|
|
822
825
|
query: {
|
|
823
826
|
messages?: boolean;
|
|
824
827
|
recent?: boolean;
|
|
@@ -852,13 +855,21 @@ export class ImapFlow extends EventEmitter {
|
|
|
852
855
|
messageDelete(range: SequenceString | number[] | SearchObject, options?: { uid?: boolean }): Promise<boolean>;
|
|
853
856
|
|
|
854
857
|
/** Appends a new message to a mailbox */
|
|
855
|
-
append(path: string, content: string | Buffer, flags?: string[], idate?: Date | string): Promise<AppendResponseObject | false>;
|
|
858
|
+
append(path: string | string[], content: string | Buffer, flags?: string[], idate?: Date | string): Promise<AppendResponseObject | false>;
|
|
856
859
|
|
|
857
860
|
/** Copies messages from current mailbox to destination mailbox */
|
|
858
|
-
messageCopy(
|
|
861
|
+
messageCopy(
|
|
862
|
+
range: SequenceString | number[] | SearchObject,
|
|
863
|
+
destination: string | string[],
|
|
864
|
+
options?: { uid?: boolean }
|
|
865
|
+
): Promise<CopyResponseObject | false>;
|
|
859
866
|
|
|
860
867
|
/** Moves messages from current mailbox to destination mailbox */
|
|
861
|
-
messageMove(
|
|
868
|
+
messageMove(
|
|
869
|
+
range: SequenceString | number[] | SearchObject,
|
|
870
|
+
destination: string | string[],
|
|
871
|
+
options?: { uid?: boolean }
|
|
872
|
+
): Promise<CopyResponseObject | false>;
|
|
862
873
|
|
|
863
874
|
/** Search messages from the currently opened mailbox — returns number[] (backward-compatible) */
|
|
864
875
|
search(query: SearchObject, options?: { uid?: boolean }): Promise<number[] | false>;
|
package/lib/imap-flow.js
CHANGED
|
@@ -38,6 +38,7 @@ const {
|
|
|
38
38
|
AuthenticationFailure,
|
|
39
39
|
getColorFlags,
|
|
40
40
|
hasCapability,
|
|
41
|
+
logConnectionError,
|
|
41
42
|
unrefTimer,
|
|
42
43
|
parseUintValue,
|
|
43
44
|
isUnsafeKey,
|
|
@@ -73,6 +74,78 @@ const AUTO_IDLE_DELAY = 15 * 1000;
|
|
|
73
74
|
// the wire before the watchdog can fire. See normalizeAutoIdleDelay().
|
|
74
75
|
const AUTO_IDLE_SOCKET_MARGIN = 1000;
|
|
75
76
|
|
|
77
|
+
// Commands whose client frames carry credentials; the raw traffic log withholds frame content
|
|
78
|
+
// while one of these is in flight. See the logRaw branch in write().
|
|
79
|
+
const RAW_SENSITIVE_COMMANDS = new Set(['LOGIN', 'AUTHENTICATE']);
|
|
80
|
+
|
|
81
|
+
// Stand-in payload for a withheld raw client frame. Fixed width, so the entry says nothing
|
|
82
|
+
// about the length of what it replaced.
|
|
83
|
+
const RAW_HIDDEN_PLACEHOLDER = Buffer.from('(* value hidden *)\r\n').toString('base64');
|
|
84
|
+
|
|
85
|
+
// Whether any attribute of a command is marked as a secret. Recurses into nested lists because
|
|
86
|
+
// the command compiler honors `sensitive` at any depth, and the two must agree on what counts.
|
|
87
|
+
function hasSensitiveAttribute(attributes) {
|
|
88
|
+
return [].concat(attributes || []).some(node => (Array.isArray(node) ? hasSensitiveAttribute(node) : !!node && node.sensitive));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// How deep flattenLoggedError() follows a chain of errors. Bounded because the chain comes from
|
|
92
|
+
// whatever failed, not from this library: a cause chain can be arbitrarily long, and the cycle
|
|
93
|
+
// check below only catches errors that repeat.
|
|
94
|
+
const MAX_ERROR_FLATTEN_DEPTH = 4;
|
|
95
|
+
|
|
96
|
+
// Recognizes an Error without instanceof, which fails for an error that crossed a realm boundary
|
|
97
|
+
// (worker thread, vm context) even though it serializes exactly the same way.
|
|
98
|
+
function isErrorLike(value) {
|
|
99
|
+
return value instanceof Error || (!!value && typeof value === 'object' && typeof value.message === 'string' && typeof value.stack === 'string');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// An Error carries `message` and `stack` on its prototype rather than as own enumerable
|
|
103
|
+
// properties, so JSON.stringify() renders one as `{}` and both logger fallback paths (the console
|
|
104
|
+
// fallback and emitLogs) would drop everything identifying it. Flattening happens here for both,
|
|
105
|
+
// so their shapes cannot drift apart.
|
|
106
|
+
//
|
|
107
|
+
// Nested errors are flattened too, because the top level is often not where the answer is: this
|
|
108
|
+
// library attaches the underlying failure as an enumerable `_err` (proxy setup, response
|
|
109
|
+
// processing, normalized connection deadlines), and Node reports a multi-address connect failure
|
|
110
|
+
// as an AggregateError whose members hold the per-address causes.
|
|
111
|
+
function flattenLoggedError(value, depth = 0, seen = new Set()) {
|
|
112
|
+
if (depth >= MAX_ERROR_FLATTEN_DEPTH) {
|
|
113
|
+
return isErrorLike(value) ? value.message : value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (Array.isArray(value)) {
|
|
117
|
+
return value.map(entry => flattenLoggedError(entry, depth + 1, seen));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!isErrorLike(value)) {
|
|
121
|
+
// Anything else is left alone: exploding a Buffer would produce one key per byte, and a
|
|
122
|
+
// Date would become a pair of undefined fields.
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A repeat renders as its message alone, so a chain that loops back does not restate a full
|
|
127
|
+
// stack for every level down to the depth cap
|
|
128
|
+
if (seen.has(value)) {
|
|
129
|
+
return value.message;
|
|
130
|
+
}
|
|
131
|
+
seen.add(value);
|
|
132
|
+
|
|
133
|
+
let flatErr = {
|
|
134
|
+
message: value.message,
|
|
135
|
+
stack: value.stack
|
|
136
|
+
};
|
|
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
|
+
|
|
146
|
+
return flatErr;
|
|
147
|
+
}
|
|
148
|
+
|
|
76
149
|
// The largest delay setTimeout can honor (2^31 - 1 ms). Anything above fires after 1 ms instead,
|
|
77
150
|
// so the auto-IDLE delay cap has to stay inside this range even when socketTimeout is not.
|
|
78
151
|
const MAX_TIMER_DELAY = 2 ** 31 - 1;
|
|
@@ -271,6 +344,7 @@ class ImapFlow extends EventEmitter {
|
|
|
271
344
|
*
|
|
272
345
|
* @property {Boolean} [logRaw=false]
|
|
273
346
|
* If `true`, logs all raw data (read and written) in base64 encoding. You can pipe such logs to [eerawlog](https://github.com/postalsys/eerawlog) command for readable output.
|
|
347
|
+
* Client frames that carry credentials are replaced with a fixed placeholder and the entry is marked with `hidden: true`.
|
|
274
348
|
*
|
|
275
349
|
* @property {Boolean} [emitLogs=false]
|
|
276
350
|
* If `true`, emits `'log'` events with the same data passed to the logger.
|
|
@@ -438,6 +512,13 @@ class ImapFlow extends EventEmitter {
|
|
|
438
512
|
|
|
439
513
|
this.commandParts = [];
|
|
440
514
|
|
|
515
|
+
// Whether the command currently being written carries credentials. send() sets this for
|
|
516
|
+
// every command before its first frame reaches the socket, and the raw traffic log reads
|
|
517
|
+
// it; every write belongs to the command send() dispatched last, because trySend() keeps
|
|
518
|
+
// one command in flight at a time. The initial value only covers a write before the
|
|
519
|
+
// first command, which no current path performs. See write().
|
|
520
|
+
this.rawSensitiveCommand = true;
|
|
521
|
+
|
|
441
522
|
/**
|
|
442
523
|
* Active IMAP capabilities. Value is either `true` for toggleable capabilities (eg. `UIDPLUS`)
|
|
443
524
|
* or a number for capabilities with a value (eg. `APPENDLIMIT`)
|
|
@@ -632,10 +713,17 @@ class ImapFlow extends EventEmitter {
|
|
|
632
713
|
}
|
|
633
714
|
|
|
634
715
|
if (this.logRaw) {
|
|
716
|
+
// Client frames of an authentication exchange carry credentials: the LOGIN
|
|
717
|
+
// arguments, and for AUTHENTICATE also the continuation writes (SASL PLAIN
|
|
718
|
+
// response, AUTH=LOGIN password, OAuth token payload) that bypass send(). The
|
|
719
|
+
// parsed command log masks these, so the raw log must withhold them too, but
|
|
720
|
+
// `data` still carries the placeholder rather than being dropped - the field is
|
|
721
|
+
// part of the documented log format and consumers decode it unconditionally.
|
|
635
722
|
this.log.trace({
|
|
636
723
|
src: 'c',
|
|
637
724
|
msg: 'write to socket',
|
|
638
|
-
data: chunk.toString('base64'),
|
|
725
|
+
data: this.rawSensitiveCommand ? RAW_HIDDEN_PLACEHOLDER : chunk.toString('base64'),
|
|
726
|
+
...(this.rawSensitiveCommand ? { hidden: true } : {}),
|
|
639
727
|
compress: !!this._deflate,
|
|
640
728
|
secure: !!this.secureConnection,
|
|
641
729
|
cid: this.id
|
|
@@ -691,6 +779,19 @@ class ImapFlow extends EventEmitter {
|
|
|
691
779
|
return;
|
|
692
780
|
}
|
|
693
781
|
|
|
782
|
+
// Classify before the first await. Every frame of this command - the command line and
|
|
783
|
+
// any continuation write that follows it - belongs to it until the next send(), because
|
|
784
|
+
// trySend() keeps one command in flight at a time. Reading currentRequest inside write()
|
|
785
|
+
// instead would be racy: rejectCurrentRequest() can clear it while the two compiler
|
|
786
|
+
// awaits below are pending, and the credential frame would then be logged in the clear.
|
|
787
|
+
// Uppercased because the wire protocol is case-insensitive and exec() passes the
|
|
788
|
+
// caller's spelling through unchanged. The command list covers the mechanisms whose
|
|
789
|
+
// secret arrives in a continuation frame, which carries no attributes of its own; the
|
|
790
|
+
// `sensitive` marker catches anything that instead puts a secret on the command line,
|
|
791
|
+
// so marking an attribute is enough to keep a new command out of the raw log too.
|
|
792
|
+
this.rawSensitiveCommand =
|
|
793
|
+
RAW_SENSITIVE_COMMANDS.has(typeof data.command === 'string' ? data.command.toUpperCase() : '') || hasSensitiveAttribute(data.attributes);
|
|
794
|
+
|
|
694
795
|
// Compile with asArray=true: splits output into parts for literal handling.
|
|
695
796
|
// First part is the command text up to the first literal, remaining parts
|
|
696
797
|
// are stored in this.commandParts and sent after server "+" continuations.
|
|
@@ -788,7 +889,7 @@ class ImapFlow extends EventEmitter {
|
|
|
788
889
|
// trySend() settles dispatch failures itself, by rejecting the affected
|
|
789
890
|
// command through requestTagMap; this catch exists only so a throw from the
|
|
790
891
|
// dispatch machinery itself can never surface as a floating rejection.
|
|
791
|
-
this.trySend().catch(err => this
|
|
892
|
+
this.trySend().catch(err => logConnectionError(this, 'Failed to dispatch command', err));
|
|
792
893
|
});
|
|
793
894
|
|
|
794
895
|
// Prevent unhandled promise rejection if close() rejects this request
|
|
@@ -799,15 +900,14 @@ class ImapFlow extends EventEmitter {
|
|
|
799
900
|
return promise;
|
|
800
901
|
}
|
|
801
902
|
|
|
802
|
-
// Resolves
|
|
803
|
-
// come in two forms:
|
|
903
|
+
// Resolves an untagged server response to the keyword it is dispatched on. IMAP untagged
|
|
904
|
+
// responses come in two forms:
|
|
804
905
|
// * CAPABILITY ... (keyword as command)
|
|
805
906
|
// * 42 FETCH (...) (numeric prefix + keyword)
|
|
806
|
-
// For numeric-prefixed responses
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
|
|
810
|
-
getUntaggedHandler(command, attributes) {
|
|
907
|
+
// For numeric-prefixed responses the keyword sits in the first attribute, because `command`
|
|
908
|
+
// holds the sequence number. Also used for logging, so a failure reports FETCH rather than
|
|
909
|
+
// the message number that happened to precede it.
|
|
910
|
+
normalizeUntaggedCommand(command, attributes) {
|
|
811
911
|
if (/^[0-9]+$/.test(command)) {
|
|
812
912
|
let type = attributes && attributes.length && typeof attributes[0].value === 'string' ? attributes[0].value.toUpperCase() : false;
|
|
813
913
|
if (type) {
|
|
@@ -815,7 +915,13 @@ class ImapFlow extends EventEmitter {
|
|
|
815
915
|
}
|
|
816
916
|
}
|
|
817
917
|
|
|
818
|
-
|
|
918
|
+
return command.toUpperCase().trim();
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Handler priority: command-specific handlers (registered per exec() call) take
|
|
922
|
+
// precedence over global handlers (registered on the connection).
|
|
923
|
+
getUntaggedHandler(command, attributes) {
|
|
924
|
+
command = this.normalizeUntaggedCommand(command, attributes);
|
|
819
925
|
// Check command-specific handler first (registered in exec() options.untagged)
|
|
820
926
|
if (this.currentRequest && this.currentRequest.options && this.currentRequest.options.untagged && this.currentRequest.options.untagged[command]) {
|
|
821
927
|
return this.currentRequest.options.untagged[command];
|
|
@@ -993,7 +1099,7 @@ class ImapFlow extends EventEmitter {
|
|
|
993
1099
|
err.parserError = parserError;
|
|
994
1100
|
this.rejectCurrentRequest(err);
|
|
995
1101
|
|
|
996
|
-
this.trySend().catch(sendErr => this
|
|
1102
|
+
this.trySend().catch(sendErr => logConnectionError(this, 'Failed to dispatch command', sendErr));
|
|
997
1103
|
}
|
|
998
1104
|
|
|
999
1105
|
/**
|
|
@@ -1064,7 +1170,9 @@ class ImapFlow extends EventEmitter {
|
|
|
1064
1170
|
try {
|
|
1065
1171
|
await this.currentRequest.options.onPlusTag(parsed);
|
|
1066
1172
|
} catch (err) {
|
|
1067
|
-
|
|
1173
|
+
// The handler ran across an await and may have closed the connection, which
|
|
1174
|
+
// clears currentRequest, so the command name is read defensively
|
|
1175
|
+
this.log.warn({ msg: 'Failed to process continuation response', command: this.currentRequest?.command, err, cid: this.id });
|
|
1068
1176
|
}
|
|
1069
1177
|
return true;
|
|
1070
1178
|
}
|
|
@@ -1078,7 +1186,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1078
1186
|
this.write(content);
|
|
1079
1187
|
this.log.debug({ src: 'c', msg: `(* ${content.length}B continuation *)`, cid: this.id });
|
|
1080
1188
|
} catch (err) {
|
|
1081
|
-
this
|
|
1189
|
+
logConnectionError(this, 'Failed to send literal continuation', err);
|
|
1082
1190
|
}
|
|
1083
1191
|
return true;
|
|
1084
1192
|
}
|
|
@@ -1087,12 +1195,13 @@ class ImapFlow extends EventEmitter {
|
|
|
1087
1195
|
// section[0] can be a parsed NIL (null), e.g. from a "[NIL]" response code - the
|
|
1088
1196
|
// dereference must be guarded or one such line tears down the whole connection
|
|
1089
1197
|
if (section && section.length && section[0] && section[0].type === 'ATOM' && typeof section[0].value === 'string') {
|
|
1090
|
-
let
|
|
1198
|
+
let sectionKey = section[0].value.toUpperCase().trim();
|
|
1199
|
+
let sectionHandler = this.getSectionHandler(sectionKey);
|
|
1091
1200
|
if (sectionHandler) {
|
|
1092
1201
|
try {
|
|
1093
1202
|
await sectionHandler(section.slice(1));
|
|
1094
1203
|
} catch (err) {
|
|
1095
|
-
this.log.warn({ err, cid: this.id });
|
|
1204
|
+
this.log.warn({ msg: 'Failed to process response section', section: sectionKey, err, cid: this.id });
|
|
1096
1205
|
}
|
|
1097
1206
|
}
|
|
1098
1207
|
}
|
|
@@ -1103,7 +1212,14 @@ class ImapFlow extends EventEmitter {
|
|
|
1103
1212
|
try {
|
|
1104
1213
|
await untaggedHandler(parsed);
|
|
1105
1214
|
} catch (err) {
|
|
1106
|
-
|
|
1215
|
+
// Normalized only here: this runs for every untagged response, including
|
|
1216
|
+
// every FETCH, and the keyword is needed only to describe a failure
|
|
1217
|
+
this.log.warn({
|
|
1218
|
+
msg: 'Failed to process untagged response',
|
|
1219
|
+
command: this.normalizeUntaggedCommand(parsed.command, parsed.attributes),
|
|
1220
|
+
err,
|
|
1221
|
+
cid: this.id
|
|
1222
|
+
});
|
|
1107
1223
|
return true;
|
|
1108
1224
|
}
|
|
1109
1225
|
}
|
|
@@ -1214,6 +1330,9 @@ class ImapFlow extends EventEmitter {
|
|
|
1214
1330
|
|
|
1215
1331
|
if (err.responseStatus === 'NO' && txt.includes('Some of the requested messages no longer exist')) {
|
|
1216
1332
|
// Treat as successful response
|
|
1333
|
+
// Kept at warn: the caller is handed fewer messages than it asked for and
|
|
1334
|
+
// is told nothing else about it, so this entry is the only record that
|
|
1335
|
+
// the response was truncated.
|
|
1217
1336
|
this.log.warn({ msg: 'Partial FETCH response', cid: this.id, err });
|
|
1218
1337
|
await new Promise(resolve => request.resolve({ response: parsed, next: resolve }));
|
|
1219
1338
|
break;
|
|
@@ -1525,7 +1644,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1525
1644
|
}
|
|
1526
1645
|
this.writeSocket.end();
|
|
1527
1646
|
} catch (err) {
|
|
1528
|
-
this.log.error({ err,
|
|
1647
|
+
this.log.error({ err, msg: 'Failed to destroy PassThrough socket', cid: this.id });
|
|
1529
1648
|
throw err;
|
|
1530
1649
|
}
|
|
1531
1650
|
};
|
|
@@ -2031,6 +2150,22 @@ class ImapFlow extends EventEmitter {
|
|
|
2031
2150
|
});
|
|
2032
2151
|
}
|
|
2033
2152
|
|
|
2153
|
+
// Reports one expunged message, either through the caller's expungeHandler or as an
|
|
2154
|
+
// 'expunge' event. Shared by the EXPUNGE and VANISHED paths so the two cannot drift.
|
|
2155
|
+
async notifyExpunge(payload) {
|
|
2156
|
+
if (typeof this.options.expungeHandler !== 'function') {
|
|
2157
|
+
this.emit('expunge', payload);
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
try {
|
|
2162
|
+
await this.options.expungeHandler(payload);
|
|
2163
|
+
} catch (err) {
|
|
2164
|
+
// The throw comes from the caller's own handler, not from this library
|
|
2165
|
+
this.log.error({ msg: 'Failed to notify expunge event', payload, err, cid: this.id });
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2034
2169
|
async untaggedExpunge(untagged) {
|
|
2035
2170
|
if (!this.mailbox) {
|
|
2036
2171
|
// mailbox closed, ignore
|
|
@@ -2051,15 +2186,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2051
2186
|
vanished: false
|
|
2052
2187
|
};
|
|
2053
2188
|
|
|
2054
|
-
|
|
2055
|
-
try {
|
|
2056
|
-
await this.options.expungeHandler(payload);
|
|
2057
|
-
} catch (err) {
|
|
2058
|
-
this.log.error({ msg: 'Failed to notify expunge event', payload, error: err, cid: this.id });
|
|
2059
|
-
}
|
|
2060
|
-
} else {
|
|
2061
|
-
this.emit('expunge', payload);
|
|
2062
|
-
}
|
|
2189
|
+
await this.notifyExpunge(payload);
|
|
2063
2190
|
}
|
|
2064
2191
|
}
|
|
2065
2192
|
|
|
@@ -2098,15 +2225,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2098
2225
|
earlier: tags.includes('EARLIER')
|
|
2099
2226
|
};
|
|
2100
2227
|
|
|
2101
|
-
|
|
2102
|
-
try {
|
|
2103
|
-
await this.options.expungeHandler(payload);
|
|
2104
|
-
} catch (err) {
|
|
2105
|
-
this.log.error({ msg: 'Failed to notify expunge event', payload, error: err, cid: this.id });
|
|
2106
|
-
}
|
|
2107
|
-
} else {
|
|
2108
|
-
this.emit('expunge', payload);
|
|
2109
|
-
}
|
|
2228
|
+
await this.notifyExpunge(payload);
|
|
2110
2229
|
}
|
|
2111
2230
|
}
|
|
2112
2231
|
|
|
@@ -2234,7 +2353,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2234
2353
|
if (this.state !== this.states.SELECTED || this.connectionBusy()) {
|
|
2235
2354
|
return;
|
|
2236
2355
|
}
|
|
2237
|
-
this.idle().catch(err => this
|
|
2356
|
+
this.idle().catch(err => logConnectionError(this, 'Auto-IDLE failed', err));
|
|
2238
2357
|
}, this.autoIdleDelay);
|
|
2239
2358
|
unrefTimer(this.idleStartTimer);
|
|
2240
2359
|
}
|
|
@@ -2295,16 +2414,21 @@ class ImapFlow extends EventEmitter {
|
|
|
2295
2414
|
throw new Error('Failed to setup proxy connection');
|
|
2296
2415
|
}
|
|
2297
2416
|
} catch (err) {
|
|
2417
|
+
// Logged here rather than relying on proxy-connection.js, which only reports
|
|
2418
|
+
// failures from inside the two connect helpers. An unsupported scheme, a proxy URL
|
|
2419
|
+
// that will not parse and a deadline that expired before the connect started all
|
|
2420
|
+
// reject before any logging happens there, so this is the one place that sees
|
|
2421
|
+
// every way proxy setup can fail.
|
|
2422
|
+
this.log.error({ msg: 'Failed to setup proxy connection', err, cid: this.id });
|
|
2423
|
+
|
|
2298
2424
|
if (err.code === 'CONNECT_TIMEOUT') {
|
|
2299
2425
|
// The shared deadline expired during proxy setup. Report it as the documented
|
|
2300
2426
|
// connection timeout rather than as a generic proxy failure.
|
|
2301
|
-
this.log.error({ err, cid: this.id });
|
|
2302
2427
|
throw err;
|
|
2303
2428
|
}
|
|
2304
2429
|
let error = new Error('Failed to setup proxy connection');
|
|
2305
2430
|
error.code = err.code || 'ProxyError';
|
|
2306
2431
|
error._err = err;
|
|
2307
|
-
this.log.error({ error, cid: this.id });
|
|
2308
2432
|
throw error;
|
|
2309
2433
|
}
|
|
2310
2434
|
}
|
|
@@ -2513,7 +2637,9 @@ class ImapFlow extends EventEmitter {
|
|
|
2513
2637
|
}
|
|
2514
2638
|
|
|
2515
2639
|
if (typeof this.preCheck === 'function') {
|
|
2516
|
-
|
|
2640
|
+
// Runs while the connection is being torn down, so the rejection this sees is
|
|
2641
|
+
// almost always the NoConnection close() is about to raise itself.
|
|
2642
|
+
this.preCheck().catch(err => logConnectionError(this, 'Failed to break IDLE while closing', err));
|
|
2517
2643
|
}
|
|
2518
2644
|
|
|
2519
2645
|
// Session-only public state must not survive the connection it describes: callers read
|
|
@@ -2606,7 +2732,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2606
2732
|
this._inflate.destroy();
|
|
2607
2733
|
this._inflate = null;
|
|
2608
2734
|
} catch (err) {
|
|
2609
|
-
this.log.error({ err,
|
|
2735
|
+
this.log.error({ err, msg: 'Failed to destroy inflate stream', cid: this.id });
|
|
2610
2736
|
}
|
|
2611
2737
|
}
|
|
2612
2738
|
|
|
@@ -2616,7 +2742,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2616
2742
|
this._deflate.destroy();
|
|
2617
2743
|
this._deflate = null;
|
|
2618
2744
|
} catch (err) {
|
|
2619
|
-
this.log.error({ err,
|
|
2745
|
+
this.log.error({ err, msg: 'Failed to destroy deflate stream', cid: this.id });
|
|
2620
2746
|
}
|
|
2621
2747
|
}
|
|
2622
2748
|
|
|
@@ -2634,7 +2760,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2634
2760
|
this.streamer.destroy();
|
|
2635
2761
|
}
|
|
2636
2762
|
} catch (err) {
|
|
2637
|
-
this.log.error({ err,
|
|
2763
|
+
this.log.error({ err, msg: 'Failed to cleanup streamer', cid: this.id });
|
|
2638
2764
|
}
|
|
2639
2765
|
}
|
|
2640
2766
|
|
|
@@ -2687,7 +2813,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2687
2813
|
this._socketEnd = null;
|
|
2688
2814
|
this._socketTimeout = null;
|
|
2689
2815
|
|
|
2690
|
-
this.log.
|
|
2816
|
+
this.log.debug({
|
|
2691
2817
|
msg: 'Connection closed',
|
|
2692
2818
|
cid: this.id,
|
|
2693
2819
|
...(this._unknownTagCount ? { unknownTagCount: this._unknownTagCount } : {})
|
|
@@ -2703,7 +2829,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2703
2829
|
this.emit('close');
|
|
2704
2830
|
} catch (ex) {
|
|
2705
2831
|
// close failed
|
|
2706
|
-
this.log.error(ex);
|
|
2832
|
+
this.log.error({ err: ex, cid: this.id });
|
|
2707
2833
|
}
|
|
2708
2834
|
}
|
|
2709
2835
|
|
|
@@ -2722,7 +2848,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2722
2848
|
/**
|
|
2723
2849
|
* Returns current quota
|
|
2724
2850
|
*
|
|
2725
|
-
* @param {
|
|
2851
|
+
* @param {string|array} [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.
|
|
2726
2852
|
* @returns {Promise<QuotaResponse|Boolean>} Quota information or `false` if QUOTA extension is not supported or requested path does not exist
|
|
2727
2853
|
*
|
|
2728
2854
|
* @example
|
|
@@ -2975,7 +3101,7 @@ class ImapFlow extends EventEmitter {
|
|
|
2975
3101
|
/**
|
|
2976
3102
|
* Requests the status of the indicated mailbox. Only requested status values will be returned.
|
|
2977
3103
|
*
|
|
2978
|
-
* @param {
|
|
3104
|
+
* @param {string|array} 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.
|
|
2979
3105
|
* @param {Object} query defines requested status items
|
|
2980
3106
|
* @param {Boolean} query.messages if `true` request count of messages
|
|
2981
3107
|
* @param {Boolean} query.recent if `true` request count of messages with \\Recent tag
|
|
@@ -3263,7 +3389,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3263
3389
|
/**
|
|
3264
3390
|
* Appends a new message to a mailbox
|
|
3265
3391
|
*
|
|
3266
|
-
* @param {
|
|
3392
|
+
* @param {string|array} 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.
|
|
3267
3393
|
* @param {string|Buffer} content RFC822 formatted email message
|
|
3268
3394
|
* @param {string[]} [flags] an array of flags to be set for the uploaded message
|
|
3269
3395
|
* @param {Date|string} [idate=now] internal date to be set for the message
|
|
@@ -3289,7 +3415,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3289
3415
|
* Copies messages from current mailbox to destination mailbox
|
|
3290
3416
|
*
|
|
3291
3417
|
* @param {SequenceString | Number[] | SearchObject} range Range of messages to copy
|
|
3292
|
-
* @param {
|
|
3418
|
+
* @param {string|array} 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.
|
|
3293
3419
|
* @param {Object} [options]
|
|
3294
3420
|
* @param {Boolean} [options.uid] If `true` then uses UID {@link SequenceString} instead of sequence numbers
|
|
3295
3421
|
* @returns {Promise<CopyResponseObject>} info about copies messages
|
|
@@ -3313,7 +3439,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3313
3439
|
* Moves messages from current mailbox to destination mailbox
|
|
3314
3440
|
*
|
|
3315
3441
|
* @param {SequenceString | Number[] | SearchObject} range Range of messages to move
|
|
3316
|
-
* @param {
|
|
3442
|
+
* @param {string|array} 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.
|
|
3317
3443
|
* @param {Object} [options]
|
|
3318
3444
|
* @param {Boolean} [options.uid] If `true` then uses UID {@link SequenceString} instead of sequence numbers
|
|
3319
3445
|
* @returns {Promise<CopyResponseObject>} info about moved messages
|
|
@@ -4637,7 +4763,19 @@ class ImapFlow extends EventEmitter {
|
|
|
4637
4763
|
// we are checking to make sure the level is supported.
|
|
4638
4764
|
// if it isn't supported but the level is error or fatal, log to console anyway.
|
|
4639
4765
|
if (level === 'fatal' || level === 'error') {
|
|
4640
|
-
|
|
4766
|
+
let entry = args[0];
|
|
4767
|
+
try {
|
|
4768
|
+
if (entry && typeof entry === 'object' && entry.err) {
|
|
4769
|
+
entry = Object.assign({}, entry, { err: flattenLoggedError(entry.err) });
|
|
4770
|
+
}
|
|
4771
|
+
console.error(JSON.stringify(entry));
|
|
4772
|
+
} catch {
|
|
4773
|
+
// Serializing failed (a circular structure, a BigInt, a throwing
|
|
4774
|
+
// getter). This fallback exists so an error is never lost, so hand
|
|
4775
|
+
// the entry to console.error itself - it inspects rather than
|
|
4776
|
+
// serializes, and handles all three - instead of dropping it.
|
|
4777
|
+
console.error(entry);
|
|
4778
|
+
}
|
|
4641
4779
|
}
|
|
4642
4780
|
} else {
|
|
4643
4781
|
mainLogger[level](...args);
|
|
@@ -4645,18 +4783,21 @@ class ImapFlow extends EventEmitter {
|
|
|
4645
4783
|
}
|
|
4646
4784
|
|
|
4647
4785
|
if (this.emitLogs && args && args[0] && typeof args[0] === 'object') {
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
logEntry.err
|
|
4657
|
-
}
|
|
4786
|
+
// Guarded for the same reason as the console fallback above: a log call must
|
|
4787
|
+
// never throw. Most of these run inside catch blocks in the protocol
|
|
4788
|
+
// machinery, where a throw would escape the handler that was recovering from
|
|
4789
|
+
// something else and strand the connection. A throwing property getter on the
|
|
4790
|
+
// logged error and a throwing 'log' listener both end up here.
|
|
4791
|
+
try {
|
|
4792
|
+
let logEntry = Object.assign({ level, t: Date.now(), cid: this.id, lo: ++this.lo }, args[0]);
|
|
4793
|
+
if (logEntry.err) {
|
|
4794
|
+
logEntry.err = flattenLoggedError(logEntry.err);
|
|
4795
|
+
}
|
|
4796
|
+
this.emit('log', logEntry);
|
|
4797
|
+
} catch {
|
|
4798
|
+
// Nothing to do with it: reporting the failure would re-enter this
|
|
4799
|
+
// same path
|
|
4658
4800
|
}
|
|
4659
|
-
this.emit('log', logEntry);
|
|
4660
4801
|
}
|
|
4661
4802
|
};
|
|
4662
4803
|
}
|
package/lib/proxy-connection.js
CHANGED
|
@@ -53,11 +53,13 @@ const decodeUserInfo = value => {
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
// The socks client attaches its full options object - proxy password included - to the errors it
|
|
56
|
-
// throws
|
|
57
|
-
//
|
|
56
|
+
// throws, and Node's URL errors carry the rejected string in `input`. Any logger that serializes
|
|
57
|
+
// error properties would then write that password out in clear text, so the credentials are
|
|
58
|
+
// dropped before the error is logged or handed to the caller.
|
|
58
59
|
const stripProxyCredentials = err => {
|
|
59
|
-
if (err && typeof err === 'object'
|
|
60
|
+
if (err && typeof err === 'object') {
|
|
60
61
|
delete err.options;
|
|
62
|
+
delete err.input;
|
|
61
63
|
}
|
|
62
64
|
return err;
|
|
63
65
|
};
|
|
@@ -402,7 +404,15 @@ const proxyConnection = async (logger, connectionUrl, host, port, options) => {
|
|
|
402
404
|
let deadline = options.deadline || new ConnectionDeadline(options.connectionTimeout);
|
|
403
405
|
deadline.check();
|
|
404
406
|
|
|
405
|
-
let proxyUrl
|
|
407
|
+
let proxyUrl;
|
|
408
|
+
try {
|
|
409
|
+
proxyUrl = new URL(connectionUrl);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
// new URL() attaches the string it rejected to err.input, which here is the full proxy
|
|
412
|
+
// endpoint including its password. Any logger that serializes error properties would
|
|
413
|
+
// write that out in clear text, so the cause is reported without carrying the value.
|
|
414
|
+
throw proxyError('Invalid proxy URL', err.code || 'ERR_INVALID_URL');
|
|
415
|
+
}
|
|
406
416
|
let protocol = proxyUrl.protocol.replace(/:$/, '').toLowerCase();
|
|
407
417
|
|
|
408
418
|
// ImapFlow performs no DNS lookup of its own for the proxy endpoint: net, tls and the SOCKS
|
package/lib/tools.js
CHANGED
|
@@ -11,6 +11,9 @@ const iconv = require('iconv-lite');
|
|
|
11
11
|
|
|
12
12
|
const FLAG_COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'grey'];
|
|
13
13
|
|
|
14
|
+
// Error codes that only mean the connection is no longer usable. See logConnectionError().
|
|
15
|
+
const CONNECTION_GONE_CODES = new Set(['NoConnection', 'EConnectionClosed', 'StateLogout']);
|
|
16
|
+
|
|
14
17
|
// Upper bound for expanding server-supplied sequence ranges (see expandRange). 2^24
|
|
15
18
|
// entries in total is far beyond any legitimate mailbox while keeping the worst-case
|
|
16
19
|
// expansion of a hostile range set bounded.
|
|
@@ -84,6 +87,32 @@ const tools = {
|
|
|
84
87
|
return timer;
|
|
85
88
|
},
|
|
86
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Logs a failure from background connection work at the level its cause deserves.
|
|
92
|
+
*
|
|
93
|
+
* Background work (IDLE sessions, polling timers, auto-IDLE) is interrupted by every normal
|
|
94
|
+
* disconnect, so a rejection carrying one of the CONNECTION_GONE_CODES is expected rather
|
|
95
|
+
* than notable and goes to debug. The three codes describe the same situation reached
|
|
96
|
+
* through different guards: write() throws NoConnection or StateLogout, exec() rejects
|
|
97
|
+
* EConnectionClosed for the window where the socket is destroyed but close() has not run
|
|
98
|
+
* yet, and close() rejects pending requests with NoConnection.
|
|
99
|
+
*
|
|
100
|
+
* A connection error carrying `reason` is the exception. That field holds the server's
|
|
101
|
+
* untagged BYE text ("Too many simultaneous connections", "Account is disabled"), which
|
|
102
|
+
* serverBye() only records - this log call is the one place it becomes visible, and it is
|
|
103
|
+
* usually the answer to why a client is reconnecting in a loop. Those stay at warn.
|
|
104
|
+
*
|
|
105
|
+
* Shared so the classification cannot drift between the call sites that make this decision.
|
|
106
|
+
*
|
|
107
|
+
* @param {Object} connection - IMAP connection instance
|
|
108
|
+
* @param {String} msg - What failed, so the entries stay distinguishable in the log
|
|
109
|
+
* @param {Error} err - The error to log
|
|
110
|
+
*/
|
|
111
|
+
logConnectionError(connection, msg, err) {
|
|
112
|
+
let routine = !!err && CONNECTION_GONE_CODES.has(err.code) && !err.reason;
|
|
113
|
+
connection.log[routine ? 'debug' : 'warn']({ msg, err, cid: connection.id });
|
|
114
|
+
},
|
|
115
|
+
|
|
87
116
|
/**
|
|
88
117
|
* Checks whether IMAP4rev2 semantics are active for the connection: either the
|
|
89
118
|
* client enabled IMAP4rev2 explicitly, or the server is rev2-only (advertises
|
|
@@ -735,6 +764,17 @@ const tools = {
|
|
|
735
764
|
return name;
|
|
736
765
|
},
|
|
737
766
|
|
|
767
|
+
/**
|
|
768
|
+
* Decodes an ENVELOPE text field for display: encoded words first, then the
|
|
769
|
+
* surrounding quotes some servers leave in place.
|
|
770
|
+
*
|
|
771
|
+
* @param {String} value - Raw field value from an ENVELOPE response
|
|
772
|
+
* @returns {String} Decoded, unquoted text
|
|
773
|
+
*/
|
|
774
|
+
decodeText(value) {
|
|
775
|
+
return tools.processName(libmime.decodeWords(value));
|
|
776
|
+
},
|
|
777
|
+
|
|
738
778
|
/**
|
|
739
779
|
* Parses a raw IMAP ENVELOPE response into a structured envelope object.
|
|
740
780
|
*
|
|
@@ -766,14 +806,25 @@ const tools = {
|
|
|
766
806
|
// throwing on the dereference and dropping the message
|
|
767
807
|
return false;
|
|
768
808
|
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
809
|
+
|
|
810
|
+
let name = tools.decodeText(getStrValue(addr[0]));
|
|
811
|
+
let mailbox = getStrValue(addr[2]) || '';
|
|
812
|
+
let host = getStrValue(addr[3]) || '';
|
|
813
|
+
|
|
814
|
+
if (!host) {
|
|
815
|
+
// RFC 9051 7.5.2: a NIL host field marks RFC 5322 group syntax, it is not
|
|
816
|
+
// an empty domain. A non-NIL mailbox then holds the group name phrase, a
|
|
817
|
+
// NIL one closes the group. Joining the fields anyway would invent an
|
|
818
|
+
// address that never appeared in the message, eg. "undisclosed-recipients@",
|
|
819
|
+
// so surface the group name as a display name and leave the address empty.
|
|
820
|
+
// End-of-group markers carry neither and the filter below drops them.
|
|
821
|
+
// The mirror case, a NIL mailbox with a host, is left alone on purpose:
|
|
822
|
+
// the grammar gives it no meaning, so a server sending it is simply
|
|
823
|
+
// malformed rather than signalling anything we could act on.
|
|
824
|
+
return { name: name || (mailbox && tools.decodeText(mailbox)), address: '' };
|
|
772
825
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
address
|
|
776
|
-
};
|
|
826
|
+
|
|
827
|
+
return { name, address: `${mailbox}@${host}` };
|
|
777
828
|
})
|
|
778
829
|
.filter(addr => addr && (addr.name || addr.address));
|
|
779
830
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"main": "lib/imap-flow.js",
|
|
6
6
|
"types": "lib/imap-flow.d.ts",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"homepage": "https://imapflow.com/",
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@eslint/js": "10.0.1",
|
|
33
|
-
"@types/node": "26.
|
|
33
|
+
"@types/node": "26.2.0",
|
|
34
34
|
"c8": "12.0.0",
|
|
35
|
-
"eslint": "10.
|
|
35
|
+
"eslint": "10.9.0",
|
|
36
36
|
"eslint-config-nodemailer": "1.2.0",
|
|
37
37
|
"eslint-config-prettier": "10.1.8",
|
|
38
38
|
"grunt": "1.6.3",
|
|
@@ -170,27 +170,79 @@ module.exports['Internals: write returns false for non-string non-buffer'] = tes
|
|
|
170
170
|
test.done();
|
|
171
171
|
};
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
// A writable client whose raw traffic log is captured, for the two logRaw branches
|
|
174
|
+
const makeRawLogClient = rawSensitiveCommand => {
|
|
174
175
|
let logs = [];
|
|
175
|
-
let client = makeClient({ logRaw: true });
|
|
176
|
-
client.log = {
|
|
177
|
-
trace: o => logs.push(o),
|
|
178
|
-
debug: () => {},
|
|
179
|
-
warn: () => {},
|
|
180
|
-
error: () => {},
|
|
181
|
-
info: () => {}
|
|
182
|
-
};
|
|
183
176
|
let written = [];
|
|
177
|
+
let client = makeClient({ logRaw: true });
|
|
178
|
+
client.log = { trace: o => logs.push(o), debug: () => {}, warn: () => {}, error: () => {}, info: () => {} };
|
|
184
179
|
client.socket = { destroyed: false };
|
|
185
180
|
client.writeSocket = { destroyed: false, write: c => written.push(c) };
|
|
186
|
-
client.
|
|
187
|
-
client
|
|
181
|
+
client.rawSensitiveCommand = rawSensitiveCommand;
|
|
182
|
+
return { client, logs, written };
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
module.exports['Internals: write logs raw data when logRaw enabled'] = test => {
|
|
186
|
+
let { client, logs, written } = makeRawLogClient(false);
|
|
188
187
|
client.write('A NOOP');
|
|
189
|
-
|
|
188
|
+
let entry = logs.find(l => l.src === 'c' && l.msg === 'write to socket');
|
|
189
|
+
test.ok(entry);
|
|
190
|
+
test.equal(Buffer.from(entry.data, 'base64').toString(), 'A NOOP\r\n');
|
|
191
|
+
test.ok(!entry.hidden);
|
|
190
192
|
test.equal(written.length, 1);
|
|
191
193
|
test.done();
|
|
192
194
|
};
|
|
193
195
|
|
|
196
|
+
module.exports['Internals: write withholds raw data for a credential-bearing command'] = test => {
|
|
197
|
+
// send() sets this for LOGIN/AUTHENTICATE before the first frame reaches the socket
|
|
198
|
+
let { client, logs, written } = makeRawLogClient(true);
|
|
199
|
+
client.write('A1 LOGIN "user" "hunter2"');
|
|
200
|
+
let entry = logs.find(l => l.src === 'c' && l.msg === 'write to socket');
|
|
201
|
+
test.ok(entry);
|
|
202
|
+
test.ok(entry.hidden);
|
|
203
|
+
// The placeholder is fixed width, so the entry cannot disclose the password length
|
|
204
|
+
test.equal(Buffer.from(entry.data, 'base64').toString(), '(* value hidden *)\r\n');
|
|
205
|
+
// The frame itself is still written to the socket unchanged
|
|
206
|
+
test.equal(written[0].toString(), 'A1 LOGIN "user" "hunter2"\r\n');
|
|
207
|
+
test.done();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
module.exports['Internals: send marks credential-bearing commands for the raw log'] = async test => {
|
|
211
|
+
let client = makeClient();
|
|
212
|
+
let written = [];
|
|
213
|
+
client.socket = { destroyed: false };
|
|
214
|
+
client.writeSocket = { destroyed: false, write: c => written.push(c) };
|
|
215
|
+
|
|
216
|
+
// Lower case on purpose: the wire protocol is case-insensitive and exec() passes the
|
|
217
|
+
// caller's spelling through unchanged, so the classification must normalize it
|
|
218
|
+
await client.send({
|
|
219
|
+
tag: 'A1',
|
|
220
|
+
command: 'login',
|
|
221
|
+
attributes: [
|
|
222
|
+
{ type: 'STRING', value: 'user' },
|
|
223
|
+
{ type: 'STRING', value: 'hunter2', sensitive: true }
|
|
224
|
+
],
|
|
225
|
+
options: {}
|
|
226
|
+
});
|
|
227
|
+
test.equal(client.rawSensitiveCommand, true);
|
|
228
|
+
|
|
229
|
+
await client.send({ tag: 'A2', command: 'NOOP', attributes: [], options: {} });
|
|
230
|
+
test.equal(client.rawSensitiveCommand, false);
|
|
231
|
+
|
|
232
|
+
// A command outside the list still masks if it marks an attribute sensitive, so the
|
|
233
|
+
// declarative marker alone is enough to keep a new command out of the raw log. Nested
|
|
234
|
+
// because the command compiler honors the marker at any depth.
|
|
235
|
+
await client.send({
|
|
236
|
+
tag: 'A3',
|
|
237
|
+
command: 'SETMETADATA',
|
|
238
|
+
attributes: [{ type: 'ATOM', value: 'INBOX' }, [{ type: 'STRING', value: 'token', sensitive: true }]],
|
|
239
|
+
options: {}
|
|
240
|
+
});
|
|
241
|
+
test.equal(client.rawSensitiveCommand, true);
|
|
242
|
+
|
|
243
|
+
test.done();
|
|
244
|
+
};
|
|
245
|
+
|
|
194
246
|
module.exports['Internals: write appends CRLF only on final part'] = test => {
|
|
195
247
|
let client = makeClient();
|
|
196
248
|
let written = [];
|
|
@@ -304,7 +356,7 @@ module.exports['Internals: getLogger uses provided logger object'] = test => {
|
|
|
304
356
|
};
|
|
305
357
|
|
|
306
358
|
module.exports['Internals: getLogger falls back to console for missing fatal/error level'] = test => {
|
|
307
|
-
// Logger object missing the 'error' method -> falls through to console.
|
|
359
|
+
// Logger object missing the 'error' method -> falls through to console.error
|
|
308
360
|
let partial = {
|
|
309
361
|
trace() {},
|
|
310
362
|
debug() {},
|
|
@@ -313,15 +365,99 @@ module.exports['Internals: getLogger falls back to console for missing fatal/err
|
|
|
313
365
|
// no error, no fatal
|
|
314
366
|
};
|
|
315
367
|
let client = makeClient({ logger: partial });
|
|
316
|
-
let
|
|
368
|
+
let origConsoleError = console.error;
|
|
317
369
|
let logged = [];
|
|
318
|
-
console.
|
|
370
|
+
console.error = (...args) => logged.push(args);
|
|
319
371
|
try {
|
|
320
|
-
|
|
372
|
+
let err = new Error('boom failure');
|
|
373
|
+
err.code = 'XBOOM';
|
|
374
|
+
// The answer is often one level down: this library attaches the underlying failure
|
|
375
|
+
// as an enumerable `_err`
|
|
376
|
+
err._err = Object.assign(new Error('inner failure'), { code: 'ECONNREFUSED' });
|
|
377
|
+
client.log.error({ msg: 'boom', err });
|
|
378
|
+
// A circular structure must not throw out of the log call, and must not be dropped
|
|
379
|
+
let circular = { msg: 'loop' };
|
|
380
|
+
circular.self = circular;
|
|
381
|
+
client.log.error(circular);
|
|
321
382
|
} finally {
|
|
322
|
-
console.
|
|
383
|
+
console.error = origConsoleError;
|
|
323
384
|
}
|
|
324
|
-
test.
|
|
385
|
+
test.equal(logged.length, 2);
|
|
386
|
+
// The Error was flattened, so message, stack and enumerable fields survive stringify
|
|
387
|
+
let entry = JSON.parse(logged[0][0]);
|
|
388
|
+
test.equal(entry.msg, 'boom');
|
|
389
|
+
test.equal(entry.err.message, 'boom failure');
|
|
390
|
+
test.equal(entry.err.code, 'XBOOM');
|
|
391
|
+
test.ok(entry.err.stack);
|
|
392
|
+
test.equal(entry.err._err.message, 'inner failure');
|
|
393
|
+
test.equal(entry.err._err.code, 'ECONNREFUSED');
|
|
394
|
+
// Unserializable entries still reach console.error, just not as JSON
|
|
395
|
+
test.equal(logged[1][0].msg, 'loop');
|
|
396
|
+
test.done();
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
module.exports['Internals: getLogger keeps cause and AggregateError members'] = test => {
|
|
400
|
+
let client = makeClient({ emitLogs: true });
|
|
401
|
+
let entries = [];
|
|
402
|
+
client.on('log', entry => entries.push(entry));
|
|
403
|
+
|
|
404
|
+
let inner = Object.assign(new Error('inner failure'), { code: 'ECONNREFUSED' });
|
|
405
|
+
client.log.error({ msg: 'wrapped', err: new Error('outer failure', { cause: inner }) });
|
|
406
|
+
// Node reports a multi-address connect failure as an AggregateError
|
|
407
|
+
client.log.error({ msg: 'aggregate', err: new AggregateError([inner], 'all attempts failed') });
|
|
408
|
+
|
|
409
|
+
test.equal(entries[0].err.cause.message, 'inner failure');
|
|
410
|
+
test.equal(entries[0].err.cause.code, 'ECONNREFUSED');
|
|
411
|
+
test.equal(entries[1].err.errors.length, 1);
|
|
412
|
+
test.equal(entries[1].err.errors[0].message, 'inner failure');
|
|
413
|
+
test.done();
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
module.exports['Internals: getLogger bounds a looping and a deep error chain'] = test => {
|
|
417
|
+
let client = makeClient({ emitLogs: true });
|
|
418
|
+
let entries = [];
|
|
419
|
+
client.on('log', entry => entries.push(entry));
|
|
420
|
+
|
|
421
|
+
// A chain that loops back must terminate rather than recurse forever
|
|
422
|
+
let looping = new Error('looping failure');
|
|
423
|
+
looping._err = looping;
|
|
424
|
+
client.log.error({ msg: 'loop', err: looping });
|
|
425
|
+
test.equal(entries[0].err.message, 'looping failure');
|
|
426
|
+
test.equal(entries[0].err._err, 'looping failure');
|
|
427
|
+
|
|
428
|
+
// A chain longer than the depth cap is truncated rather than walked to the end
|
|
429
|
+
let deep = new Error('level 0');
|
|
430
|
+
for (let i = 1; i <= 6; i++) {
|
|
431
|
+
deep = Object.assign(new Error(`level ${i}`), { _err: deep });
|
|
432
|
+
}
|
|
433
|
+
client.log.error({ msg: 'deep', err: deep });
|
|
434
|
+
test.equal(entries[1].err._err._err._err.message, 'level 3');
|
|
435
|
+
// Past the cap the chain collapses to messages instead of being walked to the end
|
|
436
|
+
test.equal(entries[1].err._err._err._err._err, 'level 2');
|
|
437
|
+
|
|
438
|
+
test.done();
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
module.exports['Internals: getLogger never throws out of a log call'] = test => {
|
|
442
|
+
let client = makeClient({ emitLogs: true });
|
|
443
|
+
let entries = [];
|
|
444
|
+
client.on('log', entry => entries.push(entry));
|
|
445
|
+
|
|
446
|
+
// A throwing property getter on the logged error must not escape
|
|
447
|
+
let hostile = {
|
|
448
|
+
get message() {
|
|
449
|
+
throw new Error('getter blew up');
|
|
450
|
+
},
|
|
451
|
+
stack: 'x'
|
|
452
|
+
};
|
|
453
|
+
test.doesNotThrow(() => client.log.warn({ msg: 'hostile', err: hostile }));
|
|
454
|
+
|
|
455
|
+
// Neither must a throwing 'log' listener
|
|
456
|
+
client.on('log', () => {
|
|
457
|
+
throw new Error('listener blew up');
|
|
458
|
+
});
|
|
459
|
+
test.doesNotThrow(() => client.log.warn({ msg: 'still fine' }));
|
|
460
|
+
|
|
325
461
|
test.done();
|
|
326
462
|
};
|
|
327
463
|
|
package/test/tools-test.js
CHANGED
|
@@ -716,6 +716,25 @@ module.exports['Tools: processName with short quoted'] = test => {
|
|
|
716
716
|
test.done();
|
|
717
717
|
};
|
|
718
718
|
|
|
719
|
+
// ============================================
|
|
720
|
+
// decodeText tests
|
|
721
|
+
// ============================================
|
|
722
|
+
|
|
723
|
+
module.exports['Tools: decodeText decodes encoded words and strips quotes'] = test => {
|
|
724
|
+
test.equal(tools.decodeText('=?utf-8?Q?T=C3=B5nu?='), 'Tõnu');
|
|
725
|
+
test.equal(tools.decodeText('"=?utf-8?Q?T=C3=B5nu?="'), 'Tõnu');
|
|
726
|
+
test.equal(tools.decodeText('Plain Name'), 'Plain Name');
|
|
727
|
+
test.done();
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
module.exports['Tools: decodeText tolerates missing values'] = test => {
|
|
731
|
+
// getStrValue returns false for a NIL envelope field
|
|
732
|
+
test.equal(tools.decodeText(false), '');
|
|
733
|
+
test.equal(tools.decodeText(null), '');
|
|
734
|
+
test.equal(tools.decodeText(undefined), '');
|
|
735
|
+
test.done();
|
|
736
|
+
};
|
|
737
|
+
|
|
719
738
|
// ============================================
|
|
720
739
|
// getFolderTree tests
|
|
721
740
|
// ============================================
|
|
@@ -885,6 +904,100 @@ module.exports['Tools: parseEnvelope with empty address parts'] = test => {
|
|
|
885
904
|
test.done();
|
|
886
905
|
};
|
|
887
906
|
|
|
907
|
+
module.exports['Tools: parseEnvelope keeps group syntax out of the address'] = test => {
|
|
908
|
+
// RFC 9051 7.5.2: a NIL host marks group syntax, so "undisclosed-recipients:;" must
|
|
909
|
+
// not turn into the invented address "undisclosed-recipients@"
|
|
910
|
+
let entry = [
|
|
911
|
+
null, // date
|
|
912
|
+
null, // subject
|
|
913
|
+
[], // from
|
|
914
|
+
[], // sender
|
|
915
|
+
[], // reply-to
|
|
916
|
+
[
|
|
917
|
+
[null, null, { value: 'undisclosed-recipients' }, null], // start of group
|
|
918
|
+
[null, null, null, null] // end of group
|
|
919
|
+
], // to
|
|
920
|
+
[], // cc
|
|
921
|
+
[], // bcc
|
|
922
|
+
null, // in-reply-to
|
|
923
|
+
null // message-id
|
|
924
|
+
];
|
|
925
|
+
|
|
926
|
+
let result = tools.parseEnvelope(entry);
|
|
927
|
+
// The end-of-group marker carries neither name nor address and is dropped
|
|
928
|
+
test.deepEqual(result.to, [{ name: 'undisclosed-recipients', address: '' }]);
|
|
929
|
+
test.done();
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
module.exports['Tools: parseEnvelope keeps group members alongside the markers'] = test => {
|
|
933
|
+
let entry = [
|
|
934
|
+
null, // date
|
|
935
|
+
null, // subject
|
|
936
|
+
[], // from
|
|
937
|
+
[], // sender
|
|
938
|
+
[], // reply-to
|
|
939
|
+
[
|
|
940
|
+
[null, null, { value: 'Team' }, null], // start of group
|
|
941
|
+
[{ value: 'Member One' }, null, { value: 'one' }, { value: 'example.com' }],
|
|
942
|
+
[{ value: 'Member Two' }, null, { value: 'two' }, { value: 'example.com' }],
|
|
943
|
+
[null, null, null, null] // end of group
|
|
944
|
+
], // to
|
|
945
|
+
[], // cc
|
|
946
|
+
[], // bcc
|
|
947
|
+
null, // in-reply-to
|
|
948
|
+
null // message-id
|
|
949
|
+
];
|
|
950
|
+
|
|
951
|
+
let result = tools.parseEnvelope(entry);
|
|
952
|
+
test.deepEqual(result.to, [
|
|
953
|
+
{ name: 'Team', address: '' },
|
|
954
|
+
{ name: 'Member One', address: 'one@example.com' },
|
|
955
|
+
{ name: 'Member Two', address: 'two@example.com' }
|
|
956
|
+
]);
|
|
957
|
+
test.done();
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
module.exports['Tools: parseEnvelope does not join a NIL host onto a mailbox'] = test => {
|
|
961
|
+
// Some servers parse a malformed header such as
|
|
962
|
+
// "To: user@example.com user@example.com" into a personal name plus a mailbox
|
|
963
|
+
// with a NIL host. Joining those produced the invalid address "example.com@".
|
|
964
|
+
let entry = [
|
|
965
|
+
null, // date
|
|
966
|
+
null, // subject
|
|
967
|
+
[], // from
|
|
968
|
+
[], // sender
|
|
969
|
+
[], // reply-to
|
|
970
|
+
[[{ value: 'user@example.com user@' }, null, { value: 'example.com' }, null]], // to
|
|
971
|
+
[], // cc
|
|
972
|
+
[], // bcc
|
|
973
|
+
null, // in-reply-to
|
|
974
|
+
null // message-id
|
|
975
|
+
];
|
|
976
|
+
|
|
977
|
+
let result = tools.parseEnvelope(entry);
|
|
978
|
+
test.deepEqual(result.to, [{ name: 'user@example.com user@', address: '' }]);
|
|
979
|
+
test.done();
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
module.exports['Tools: parseEnvelope decodes an encoded group name'] = test => {
|
|
983
|
+
let entry = [
|
|
984
|
+
null, // date
|
|
985
|
+
null, // subject
|
|
986
|
+
[], // from
|
|
987
|
+
[], // sender
|
|
988
|
+
[], // reply-to
|
|
989
|
+
[[null, null, { value: '=?utf-8?Q?T=C3=B5ny?=' }, null]], // to
|
|
990
|
+
[], // cc
|
|
991
|
+
[], // bcc
|
|
992
|
+
null, // in-reply-to
|
|
993
|
+
null // message-id
|
|
994
|
+
];
|
|
995
|
+
|
|
996
|
+
let result = tools.parseEnvelope(entry);
|
|
997
|
+
test.deepEqual(result.to, [{ name: 'Tõny', address: '' }]);
|
|
998
|
+
test.done();
|
|
999
|
+
};
|
|
1000
|
+
|
|
888
1001
|
// ============================================
|
|
889
1002
|
// getStructuredParams tests
|
|
890
1003
|
// ============================================
|