imapflow 1.0.194 → 1.0.196

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.
@@ -8,7 +8,7 @@ jobs:
8
8
  test:
9
9
  strategy:
10
10
  matrix:
11
- node: [16.x, 18.x]
11
+ node: [20.x, 22.x, 24.x]
12
12
  os: [ubuntu-latest]
13
13
  runs-on: ${{ matrix.os }}
14
14
  steps:
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.196](https://github.com/postalsys/imapflow/compare/v1.0.195...v1.0.196) (2025-09-14)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * Added authzid option for AUTH=PLAIN authentication ([541c43a](https://github.com/postalsys/imapflow/commit/541c43ab0f15608c7d28f19c810e1a14bc8519bc))
9
+
10
+ ## [1.0.195](https://github.com/postalsys/imapflow/compare/v1.0.194...v1.0.195) (2025-08-26)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * Add async yielding to compression readNext() to prevent CPU blocking ([18e806d](https://github.com/postalsys/imapflow/commit/18e806d5485defe6d4a0ef3eb502b17f3b2d8c2d))
16
+ * Add async yielding to lock processing loop ([fbf1d9a](https://github.com/postalsys/imapflow/commit/fbf1d9a3aa93b4733a123553d8be0b1785f4135e))
17
+ * Add async yielding to processInput() to prevent CPU blocking ([6b619ce](https://github.com/postalsys/imapflow/commit/6b619ced453c3ee8556e18646ad476ad82a68fbf))
18
+ * Add async yielding to reader() method to prevent CPU blocking ([40574e3](https://github.com/postalsys/imapflow/commit/40574e322be27d8b3cdd1b32416e8395d7e395b5))
19
+ * Add exponential backoff to FETCH retry logic ([fb99e2d](https://github.com/postalsys/imapflow/commit/fb99e2d26c9cb29e69b6d33083ef101264b4b039))
20
+ * Add rate limiting for compression operations ([f54c1d0](https://github.com/postalsys/imapflow/commit/f54c1d0caf1bd8cc41a0779e12c915adbb55f346))
21
+ * Correct log source indicator for client commands ([bb3ea09](https://github.com/postalsys/imapflow/commit/bb3ea09c3135c131fc8bedd733311aa3fe176261))
22
+
3
23
  ## [1.0.194](https://github.com/postalsys/imapflow/compare/v1.0.193...v1.0.194) (2025-08-13)
4
24
 
5
25
 
package/README.md CHANGED
@@ -73,6 +73,31 @@ const main = async () => {
73
73
  main().catch(err => console.error(err));
74
74
  ```
75
75
 
76
+ ### Admin Impersonation / Delegation (SASL PLAIN with authzid)
77
+
78
+ ImapFlow supports admin impersonation for mail systems like Zimbra that allow administrators to access user mailboxes. This is done using the SASL PLAIN mechanism with an authorization identity (`authzid`).
79
+
80
+ ```js
81
+ const { ImapFlow } = require('imapflow');
82
+ const client = new ImapFlow({
83
+ host: 'mail.example.com',
84
+ port: 993,
85
+ secure: true,
86
+ auth: {
87
+ user: 'admin@example.com', // Admin credentials (authentication identity)
88
+ pass: 'adminpassword',
89
+ authzid: 'user@example.com', // User to impersonate (authorization identity)
90
+ loginMethod: 'AUTH=PLAIN' // Must use PLAIN mechanism for authzid
91
+ }
92
+ });
93
+
94
+ // Connection will authenticate as admin but authorize as the specified user
95
+ await client.connect();
96
+ // Now operating on user@example.com's mailbox as admin
97
+ ```
98
+
99
+ **Note:** The `authzid` parameter only works with the `AUTH=PLAIN` mechanism. The server must support admin delegation/impersonation for this to work.
100
+
76
101
  ## Documentation
77
102
 
78
103
  [API reference](https://imapflow.com/module-imapflow-ImapFlow.html).
@@ -110,14 +110,18 @@ async function authLogin(connection, username, password) {
110
110
  }
111
111
  }
112
112
 
113
- async function authPlain(connection, username, password) {
113
+ async function authPlain(connection, username, password, authzid) {
114
114
  let errorResponse = false;
115
115
  try {
116
116
  let response = await connection.exec('AUTHENTICATE', [{ type: 'ATOM', value: 'PLAIN' }], {
117
117
  onPlusTag: async () => {
118
- let encodedResponse = Buffer.from(['', username, password].join('\x00')).toString('base64');
119
- let loggedResponse = Buffer.from(['', username, '(* value hidden *)'].join('\x00')).toString('base64');
120
- connection.log.debug({ src: 'c', msg: loggedResponse, comment: `Encoded response for AUTH=PLAIN` });
118
+ // SASL PLAIN format: [authzid]\x00authcid\x00password
119
+ // authzid: authorization identity (who to impersonate)
120
+ // authcid: authentication identity (who is authenticating)
121
+ let authzidValue = authzid || '';
122
+ let encodedResponse = Buffer.from([authzidValue, username, password].join('\x00')).toString('base64');
123
+ let loggedResponse = Buffer.from([authzidValue, username, '(* value hidden *)'].join('\x00')).toString('base64');
124
+ connection.log.debug({ src: 'c', msg: loggedResponse, comment: `Encoded response for AUTH=PLAIN${authzid ? ' with authzid' : ''}` });
121
125
  connection.write(encodedResponse);
122
126
  }
123
127
  });
@@ -126,7 +130,8 @@ async function authPlain(connection, username, password) {
126
130
 
127
131
  connection.authCapabilities.set(`AUTH=PLAIN`, true);
128
132
 
129
- return username;
133
+ // Return the identity we're authorized as (authzid if provided, otherwise username)
134
+ return authzid || username;
130
135
  } catch (err) {
131
136
  let errorCode = getStatusCode(err.response);
132
137
  if (errorCode) {
@@ -142,7 +147,7 @@ async function authPlain(connection, username, password) {
142
147
  }
143
148
 
144
149
  // Authenticates user using LOGIN
145
- module.exports = async (connection, username, { accessToken, password, loginMethod }) => {
150
+ module.exports = async (connection, username, { accessToken, password, loginMethod, authzid }) => {
146
151
  if (connection.state !== connection.states.NOT_AUTHENTICATED) {
147
152
  // nothing to do here
148
153
  return;
@@ -157,7 +162,7 @@ module.exports = async (connection, username, { accessToken, password, loginMeth
157
162
 
158
163
  if (password) {
159
164
  if ((!loginMethod && connection.capabilities.has('AUTH=PLAIN')) || loginMethod === 'AUTH=PLAIN') {
160
- return await authPlain(connection, username, password);
165
+ return await authPlain(connection, username, password, authzid);
161
166
  }
162
167
 
163
168
  if ((!loginMethod && connection.capabilities.has('AUTH=LOGIN')) || loginMethod === 'AUTH=LOGIN') {
@@ -16,7 +16,10 @@ module.exports = async (connection, range, query, options) => {
16
16
  const commandKey = connection.capabilities.has('BINARY') && options.binary && !connection.disableBinary ? 'BINARY' : 'BODY';
17
17
 
18
18
  let retryCount = 0;
19
- while (retryCount < 4) {
19
+ const maxRetries = 4;
20
+ const baseDelay = 1000; // Start with 1 second delay
21
+
22
+ while (retryCount < maxRetries) {
20
23
  let messages = {
21
24
  count: 0,
22
25
  list: []
@@ -189,15 +192,25 @@ module.exports = async (connection, range, query, options) => {
189
192
  return messages;
190
193
  } catch (err) {
191
194
  if (err.code === 'ETHROTTLE') {
192
- // retrying
195
+ // Calculate exponential backoff delay
196
+ const backoffDelay = Math.min(baseDelay * Math.pow(2, retryCount), 30000); // Cap at 30 seconds
197
+
198
+ // Use throttle reset time if provided and longer than backoff
199
+ const delay = err.throttleReset && err.throttleReset > backoffDelay ? err.throttleReset : backoffDelay;
200
+
193
201
  connection.log.warn({
194
- msg: 'Retrying throttled request',
202
+ msg: 'Retrying throttled request with exponential backoff',
195
203
  cid: connection.id,
196
204
  code: err.code,
197
205
  response: err.responseText,
198
206
  throttleReset: err.throttleReset,
199
- retryCount
207
+ retryCount,
208
+ delayMs: delay
200
209
  });
210
+
211
+ // Wait before retrying
212
+ await new Promise(resolve => setTimeout(resolve, delay));
213
+
201
214
  retryCount++;
202
215
  continue;
203
216
  }
@@ -201,10 +201,17 @@ class ImapStream extends Transform {
201
201
 
202
202
  async processInput() {
203
203
  let data;
204
+ let processedCount = 0;
204
205
  while ((data = this.inputQueue.shift())) {
205
206
  await this.processInputChunk(data.chunk);
206
207
  // mark chunk as processed
207
208
  data.next();
209
+
210
+ // Yield to event loop every 10 chunks to prevent CPU blocking
211
+ processedCount++;
212
+ if (processedCount % 10 === 0) {
213
+ await new Promise(resolve => setImmediate(resolve));
214
+ }
208
215
  }
209
216
  }
210
217
 
@@ -23,6 +23,8 @@ export interface ImapFlowOptions {
23
23
  accessToken?: string;
24
24
  /** Optional login method override. Set to 'LOGIN', 'AUTH=LOGIN' or 'AUTH=PLAIN' to use specific method */
25
25
  loginMethod?: string;
26
+ /** Authorization identity for SASL PLAIN (used for admin impersonation/delegation). When set, authenticates as `user` but authorizes as `authzid` */
27
+ authzid?: string;
26
28
  };
27
29
  /** Client identification info sent to the server if server supports ID extension */
28
30
  clientInfo?: IdInfoObject;
package/lib/imap-flow.js CHANGED
@@ -158,6 +158,12 @@ class ImapFlow extends EventEmitter {
158
158
  * Optional login method for password-based authentication (e.g., "LOGIN", "AUTH=LOGIN", or "AUTH=PLAIN").
159
159
  * If not set, ImapFlow chooses based on available mechanisms.
160
160
  *
161
+ * @property {String} [auth.authzid]
162
+ * Authorization identity for SASL PLAIN authentication (used for admin impersonation/delegation).
163
+ * When set, authenticates as `auth.user` but authorizes as `auth.authzid`.
164
+ * This is typically used in mail systems like Zimbra for admin users to access other users' mailboxes.
165
+ * Only works with AUTH=PLAIN mechanism.
166
+ *
161
167
  * @property {IdInfoObject} [clientInfo]
162
168
  * Client identification info sent to the server (via the ID command).
163
169
  *
@@ -495,7 +501,7 @@ class ImapFlow extends EventEmitter {
495
501
 
496
502
  let options = data.options || {};
497
503
 
498
- this.log.debug({ src: 's', msg: logCompiled.toString(), cid: this.id, comment: options.comment });
504
+ this.log.debug({ src: 'c', msg: logCompiled.toString(), cid: this.id, comment: options.comment });
499
505
 
500
506
  this.write(this.commandParts.shift());
501
507
 
@@ -572,6 +578,7 @@ class ImapFlow extends EventEmitter {
572
578
 
573
579
  async reader() {
574
580
  let data;
581
+ let processedCount = 0;
575
582
  while ((data = this.streamer.read()) !== null) {
576
583
  let parsed;
577
584
 
@@ -737,6 +744,12 @@ class ImapFlow extends EventEmitter {
737
744
  }
738
745
 
739
746
  data.next();
747
+
748
+ // Yield to event loop every 10 processed messages to prevent CPU blocking
749
+ processedCount++;
750
+ if (processedCount % 10 === 0) {
751
+ await new Promise(resolve => setImmediate(resolve));
752
+ }
740
753
  }
741
754
  }
742
755
 
@@ -885,11 +898,17 @@ class ImapFlow extends EventEmitter {
885
898
  return; // was not able to negotiate compression
886
899
  }
887
900
 
888
- // create deflate/inflate streams
901
+ // create deflate/inflate streams with rate limiting options
889
902
  this._deflate = zlib.createDeflateRaw({
890
- windowBits: 15
903
+ windowBits: 15,
904
+ level: zlib.constants.Z_DEFAULT_COMPRESSION, // Use default compression level (6)
905
+ memLevel: 8, // Memory usage level (8 is default)
906
+ strategy: zlib.constants.Z_DEFAULT_STRATEGY,
907
+ chunkSize: 16 * 1024 // Process in 16KB chunks to prevent CPU blocking
908
+ });
909
+ this._inflate = zlib.createInflateRaw({
910
+ chunkSize: 16 * 1024 // Process in 16KB chunks to prevent CPU blocking
891
911
  });
892
- this._inflate = zlib.createInflateRaw();
893
912
 
894
913
  // route incoming socket via inflate stream
895
914
  this.socket.unpipe(this.streamer);
@@ -899,8 +918,10 @@ class ImapFlow extends EventEmitter {
899
918
  this.streamer.emit('error', err);
900
919
  });
901
920
 
902
- // route outgoing socket via deflate stream
903
- this.writeSocket = new PassThrough();
921
+ // route outgoing socket via deflate stream with rate limiting
922
+ this.writeSocket = new PassThrough({
923
+ highWaterMark: 64 * 1024 // 64KB buffer limit to prevent excessive memory usage
924
+ });
904
925
 
905
926
  this.writeSocket.destroySoon = () => {
906
927
  try {
@@ -921,15 +942,23 @@ class ImapFlow extends EventEmitter {
921
942
  // we need to force flush deflated data to socket so we can't
922
943
  // use normal pipes for this.writeSocket -> this._deflate -> this.socket
923
944
  let reading = false;
924
- let readNext = () => {
945
+ let processedChunks = 0;
946
+ let readNext = async () => {
925
947
  try {
926
948
  reading = true;
949
+ processedChunks = 0;
927
950
 
928
951
  let chunk;
929
952
  while ((chunk = this.writeSocket.read()) !== null) {
930
953
  if (this._deflate && this._deflate.write(chunk) === false) {
931
954
  return this._deflate.once('drain', readNext);
932
955
  }
956
+
957
+ // Yield to event loop every 100 chunks to prevent CPU blocking
958
+ processedChunks++;
959
+ if (processedChunks % 100 === 0) {
960
+ await new Promise(resolve => setImmediate(resolve));
961
+ }
933
962
  }
934
963
 
935
964
  // flush data to socket
@@ -1117,7 +1146,11 @@ class ImapFlow extends EventEmitter {
1117
1146
  this.authenticated = await this.run('AUTHENTICATE', this.options.auth.user, { accessToken: this.options.auth.accessToken });
1118
1147
  } else if (this.options.auth.pass) {
1119
1148
  if ((this.capabilities.has('AUTH=LOGIN') || this.capabilities.has('AUTH=PLAIN')) && loginMethod !== 'LOGIN') {
1120
- this.authenticated = await this.run('AUTHENTICATE', this.options.auth.user, { password: this.options.auth.pass, loginMethod });
1149
+ this.authenticated = await this.run('AUTHENTICATE', this.options.auth.user, {
1150
+ password: this.options.auth.pass,
1151
+ loginMethod,
1152
+ authzid: this.options.auth.authzid
1153
+ });
1121
1154
  } else {
1122
1155
  if (this.capabilities.has('LOGINDISABLED')) {
1123
1156
  throw new AuthenticationFailure('Login is disabled');
@@ -3236,6 +3269,7 @@ class ImapFlow extends EventEmitter {
3236
3269
 
3237
3270
  try {
3238
3271
  // Process all locks in queue until empty
3272
+ let processedCount = 0;
3239
3273
  while (this.locks.length > 0) {
3240
3274
  if (!this.locks.length) {
3241
3275
  this.log.trace({
@@ -3245,6 +3279,12 @@ class ImapFlow extends EventEmitter {
3245
3279
  return;
3246
3280
  }
3247
3281
 
3282
+ // Yield to event loop periodically to prevent CPU blocking
3283
+ processedCount++;
3284
+ if (processedCount % 5 === 0) {
3285
+ await new Promise(resolve => setImmediate(resolve));
3286
+ }
3287
+
3248
3288
  const release = () => {
3249
3289
  if (this.currentLock) {
3250
3290
  this.log.trace({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.0.194",
3
+ "version": "1.0.196",
4
4
  "description": "IMAP Client for Node",
5
5
  "main": "lib/imap-flow.js",
6
6
  "types": "lib/imap-flow.d.ts",
@@ -28,11 +28,11 @@
28
28
  },
29
29
  "homepage": "https://imapflow.com/",
30
30
  "devDependencies": {
31
- "@babel/eslint-parser": "7.28.0",
31
+ "@babel/eslint-parser": "7.28.4",
32
32
  "@babel/eslint-plugin": "7.27.1",
33
33
  "@babel/plugin-syntax-class-properties": "7.12.13",
34
- "@babel/preset-env": "7.28.0",
35
- "@types/node": "24.2.1",
34
+ "@babel/preset-env": "7.28.3",
35
+ "@types/node": "24.4.0",
36
36
  "eslint": "8.57.0",
37
37
  "eslint-config-nodemailer": "1.2.0",
38
38
  "eslint-config-prettier": "9.1.0",
@@ -42,18 +42,18 @@
42
42
  "grunt-eslint": "24.3.0",
43
43
  "imapflow-jsdoc-template": "3.4.0-imapflow.3",
44
44
  "jsdoc": "4.0.4",
45
- "st": "3.0.2",
45
+ "st": "3.0.3",
46
46
  "typescript": "5.9.2"
47
47
  },
48
48
  "dependencies": {
49
49
  "encoding-japanese": "2.2.0",
50
- "iconv-lite": "0.6.3",
50
+ "iconv-lite": "0.7.0",
51
51
  "libbase64": "1.3.0",
52
52
  "libmime": "5.3.7",
53
53
  "libqp": "2.1.1",
54
54
  "mailsplit": "5.4.6",
55
- "nodemailer": "7.0.5",
56
- "pino": "9.8.0",
55
+ "nodemailer": "7.0.6",
56
+ "pino": "9.9.5",
57
57
  "socks": "2.8.7"
58
58
  }
59
59
  }
@@ -11,7 +11,7 @@ module.exports['Authentication: Password auth configuration'] = test => {
11
11
  pass: 'testpass'
12
12
  }
13
13
  });
14
-
14
+
15
15
  test.equal(client.options.auth.user, 'testuser');
16
16
  test.equal(client.options.auth.pass, 'testpass');
17
17
  test.done();
@@ -25,7 +25,7 @@ module.exports['Authentication: OAuth2 auth configuration'] = test => {
25
25
  accessToken: 'oauth2_token_here'
26
26
  }
27
27
  });
28
-
28
+
29
29
  test.equal(client.options.auth.user, 'testuser');
30
30
  test.equal(client.options.auth.accessToken, 'oauth2_token_here');
31
31
  test.done();
@@ -40,14 +40,32 @@ module.exports['Authentication: Login method specification'] = test => {
40
40
  loginMethod: 'AUTH=PLAIN'
41
41
  }
42
42
  });
43
-
43
+
44
+ test.equal(client.options.auth.loginMethod, 'AUTH=PLAIN');
45
+ test.done();
46
+ };
47
+
48
+ module.exports['Authentication: SASL PLAIN with authzid for impersonation'] = test => {
49
+ let client = new ImapFlow({
50
+ host: 'imap.example.com',
51
+ auth: {
52
+ user: 'admin@example.com',
53
+ pass: 'adminpass',
54
+ authzid: 'user@example.com',
55
+ loginMethod: 'AUTH=PLAIN'
56
+ }
57
+ });
58
+
59
+ test.equal(client.options.auth.user, 'admin@example.com');
60
+ test.equal(client.options.auth.pass, 'adminpass');
61
+ test.equal(client.options.auth.authzid, 'user@example.com');
44
62
  test.equal(client.options.auth.loginMethod, 'AUTH=PLAIN');
45
63
  test.done();
46
64
  };
47
65
 
48
66
  module.exports['Authentication: AuthenticationFailure error structure'] = test => {
49
67
  let error = new AuthenticationFailure('Invalid credentials');
50
-
68
+
51
69
  test.ok(error instanceof Error);
52
70
  test.equal(error.constructor.name, 'AuthenticationFailure');
53
71
  test.equal(error.message, 'Invalid credentials');
@@ -63,7 +81,7 @@ module.exports['Authentication: Verify-only mode'] = test => {
63
81
  },
64
82
  verifyOnly: true
65
83
  });
66
-
84
+
67
85
  test.equal(client.options.verifyOnly, true);
68
86
  test.done();
69
87
  };
@@ -77,7 +95,7 @@ module.exports['Authentication: Disable auto IDLE'] = test => {
77
95
  },
78
96
  disableAutoIdle: true
79
97
  });
80
-
98
+
81
99
  test.equal(client.options.disableAutoIdle, true);
82
100
  test.done();
83
- };
101
+ };