imapflow 1.6.6 → 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.6.6"
2
+ ".": "1.7.1"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
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
+
10
+ ## [1.7.0](https://github.com/postalsys/imapflow/compare/v1.6.6...v1.7.0) (2026-08-11)
11
+
12
+
13
+ ### Features
14
+
15
+ * **idle:** make the auto-IDLE delay configurable ([311fe0c](https://github.com/postalsys/imapflow/commit/311fe0ccb0d75d4ddbe797bf5bc755df5bf0485f))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **idle:** align the socket watchdog with the auto-IDLE busy guard ([d5e7191](https://github.com/postalsys/imapflow/commit/d5e71915ac7a1829b7e1340da6195a5e3e9984b2))
21
+ * **idle:** validate autoIdleDelay and keep auto-IDLE off a busy connection ([aeafdf6](https://github.com/postalsys/imapflow/commit/aeafdf62fab3b29cd488d9b8cbdffc8896107d6e))
22
+
3
23
  ## [1.6.6](https://github.com/postalsys/imapflow/compare/v1.6.5...v1.6.6) (2026-08-07)
4
24
 
5
25
 
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;
@@ -257,24 +264,45 @@ async function runPollingFallback(connection, maxIdleTime) {
257
264
 
258
265
  pollOnce(connection, session)
259
266
  .then(() => {
267
+ // Stamped only after a poll actually completed: a failed poll must not
268
+ // satisfy the resumed schedule below, or the next session would defer
269
+ // its first poll a full interval past an attempt that checked nothing.
270
+ connection._lastPollAt = Date.now();
260
271
  // Cancellation is re-checked here: the session may have been broken while
261
272
  // this poll was in flight, and an orphaned poller must not schedule again.
262
273
  if (session.cancelled) {
263
274
  return;
264
275
  }
265
- session.timer = setTimeout(runPoll, interval);
266
- // Background polling must not keep the process alive
267
- unrefTimer(session.timer);
276
+ scheduleNextPoll(interval);
268
277
  })
269
278
  .catch(err => {
270
- connection.log.warn({ err, cid: connection.id });
279
+ logConnectionError(connection, 'Failed to poll for mailbox changes', err);
271
280
  cancel();
272
281
  });
273
282
  };
274
283
 
275
- connection.log.debug({ src: 'c', msg: `initiated NOOP loop`, cid: connection.id });
276
- // Keep the immediate first poll
277
- runPoll();
284
+ function scheduleNextPoll(delay) {
285
+ session.timer = setTimeout(runPoll, delay);
286
+ // Background polling must not keep the process alive
287
+ unrefTimer(session.timer);
288
+ }
289
+
290
+ connection.log.debug({ msg: `Initiated NOOP loop`, cid: connection.id });
291
+
292
+ // Every auto-IDLE restart begins a fresh polling session, so an unconditional first
293
+ // poll would tie the poll rate to how often the caller runs commands rather than to
294
+ // `interval`: with a short autoIdleDelay, a command every few seconds turns into a
295
+ // poll every few seconds. The last poll timestamp lives on the connection, so a new
296
+ // session resumes the previous one's schedule instead of restarting it.
297
+ // Clamped at zero because a backward wall-clock step (NTP, VM resume) leaves the
298
+ // stamp in the future; however large the jump, the next poll must never be more
299
+ // than one full interval away.
300
+ let sinceLastPoll = Math.max(0, Date.now() - (connection._lastPollAt || 0));
301
+ if (sinceLastPoll >= interval) {
302
+ runPoll();
303
+ } else {
304
+ scheduleNextPoll(interval - sinceLastPoll);
305
+ }
278
306
  });
279
307
  } finally {
280
308
  session.cancelled = true;
@@ -318,7 +346,7 @@ module.exports = async (connection, maxIdleTime) => {
318
346
  stillIdling = true;
319
347
  // request IDLE break if IDLE has been running for allowed time
320
348
  connection.log.trace({ msg: 'Max allowed IDLE time reached', cid: connection.id });
321
- 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));
322
350
  }
323
351
  }
324
352
  }, maxIdleTime);
@@ -30,11 +30,25 @@ export interface ImapFlowOptions {
30
30
  clientInfo?: IdInfoObject;
31
31
  /** If true, then do not start IDLE when connection is established */
32
32
  disableAutoIdle?: boolean;
33
+ /**
34
+ * How long (in ms) the connection has to be inactive before IDLE is started automatically.
35
+ * Keep it above the pause your own code usually leaves between two commands, otherwise every
36
+ * command is followed by an IDLE that the next command has to break, costing two extra
37
+ * round-trips per command. To turn auto-IDLE off use `disableAutoIdle` rather than a very
38
+ * large delay: the value is capped below `socketTimeout`, because auto-IDLE has to start
39
+ * before the inactivity watchdog fires. On servers without IDLE support this controls when
40
+ * the polling fallback starts, not how often it polls - the poll interval is `maxIdleTime`,
41
+ * capped at 2 minutes. Default: 15000 ms.
42
+ */
43
+ autoIdleDelay?: number;
33
44
  /** Additional TLS options (see Node.js TLS documentation) */
34
45
  tls?: ConnectionOptions;
35
46
  /** Custom logger instance. Set to false to disable logging */
36
47
  logger?: Logger | false;
37
- /** 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
+ */
38
52
  logRaw?: boolean;
39
53
  /** If true, emit 'log' events */
40
54
  emitLogs?: boolean;