imapflow 1.7.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.7.0"
2
+ ".": "1.7.1"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.1](https://github.com/postalsys/imapflow/compare/v1.7.0...v1.7.1) (2026-08-14)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **logging:** mask credential frames in the raw log and keep error detail ([2d6563b](https://github.com/postalsys/imapflow/commit/2d6563b75410a2cd9d4fbca08a76230589b60079))
9
+
3
10
  ## [1.7.0](https://github.com/postalsys/imapflow/compare/v1.6.6...v1.7.0) (2026-08-11)
4
11
 
5
12
 
package/README.md CHANGED
@@ -5,7 +5,7 @@ Modern and easy-to-use IMAP client library for Node.js.
5
5
  [![npm](https://img.shields.io/npm/v/imapflow)](https://www.npmjs.com/package/imapflow)
6
6
  [![license](https://img.shields.io/npm/l/imapflow)](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
- > [!NOTE]
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({ errorResponse: resp.attributes[0].value, err });
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({ src: 'c', msg: loggedResponse, comment: `Encoded response for AUTH=PLAIN${authzid ? ' with authzid' : ''}` });
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
  });
@@ -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.log.warn({ err, cid: connection.id }));
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({ msg: `Initiated IDLE, waiting for server input`, lockId: connection.currentLock?.lockId, doneRequested });
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.log.warn({ err, cid: connection.id });
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.log.warn({ err, cid: connection.id });
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({ src: 'c', msg: `Running SELECT to detect changes in folder`, cid: connection.id });
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({ src: 'c', msg: `Running STATUS to detect changes in folder`, cid: connection.id });
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({ src: 'c', msg: `breaking NOOP loop`, cid: connection.id });
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.log.warn({ err, cid: connection.id });
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({ src: 'c', msg: `initiated NOOP loop`, cid: connection.id });
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.log.warn({ err, cid: connection.id }));
349
+ connection.preCheck().catch(err => logConnectionError(connection, 'Failed to break IDLE for restart', err));
343
350
  }
344
351
  }
345
352
  }, maxIdleTime);
@@ -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
- /** If true, log data read and written to socket encoded in base64 */
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;
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.log.warn({ err, cid: this.id }));
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 the handler for an untagged server response. IMAP untagged responses
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, we extract the keyword (FETCH, EXISTS, EXPUNGE, etc.)
807
- // and look up the handler by that keyword instead.
808
- // Handler priority: command-specific handlers (registered per exec() call) take
809
- // precedence over global handlers (registered on the connection).
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
- command = command.toUpperCase().trim();
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.log.warn({ err: sendErr, cid: this.id }));
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
- this.log.warn({ err, cid: this.id });
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.log.warn({ err, cid: this.id });
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 sectionHandler = this.getSectionHandler(section[0].value.toUpperCase().trim());
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
- this.log.warn({ err, cid: this.id });
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, info: 'Failed to destroy PassThrough socket', cid: this.id });
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
- if (typeof this.options.expungeHandler === 'function') {
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
- if (typeof this.options.expungeHandler === 'function') {
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.log.warn({ err, cid: this.id }));
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
- this.preCheck().catch(err => this.log.warn({ err, cid: this.id }));
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, info: 'Failed to destroy inflate stream', cid: this.id });
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, info: 'Failed to destroy deflate stream', cid: this.id });
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, info: 'Failed to cleanup streamer', cid: this.id });
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.trace({
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
 
@@ -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
- console.log(JSON.stringify(...args));
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
- let logEntry = Object.assign({ level, t: Date.now(), cid: this.id, lo: ++this.lo }, args[0]);
4649
- if (logEntry.err && typeof logEntry.err === 'object') {
4650
- let err = logEntry.err;
4651
- logEntry.err = {
4652
- stack: err.stack
4653
- };
4654
- // enumerable error fields
4655
- Object.keys(err).forEach(key => {
4656
- logEntry.err[key] = err[key];
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
  }
@@ -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. Any logger that serializes error properties would then write that password out in clear
57
- // text, so the credentials are dropped before the error is logged or handed to the caller.
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' && err.options) {
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 = new URL(connectionUrl);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
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.1.2",
33
+ "@types/node": "26.2.0",
34
34
  "c8": "12.0.0",
35
- "eslint": "10.8.0",
35
+ "eslint": "10.8.1",
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
- module.exports['Internals: write logs raw data when logRaw enabled'] = test => {
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.state = client.states.AUTHENTICATED;
187
- client.commandParts = [];
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
- test.ok(logs.some(l => l.src === 'c' && l.msg === 'write to socket'));
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.log
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 origConsoleLog = console.log;
368
+ let origConsoleError = console.error;
317
369
  let logged = [];
318
- console.log = (...args) => logged.push(args);
370
+ console.error = (...args) => logged.push(args);
319
371
  try {
320
- client.log.error({ msg: 'boom' });
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.log = origConsoleLog;
383
+ console.error = origConsoleError;
323
384
  }
324
- test.ok(logged.length >= 1);
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