imapflow 1.6.1 → 1.6.3

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.1"
2
+ ".": "1.6.3"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.3](https://github.com/postalsys/imapflow/compare/v1.6.2...v1.6.3) (2026-07-28)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * assume folders are subscribed when the server reports no subscription state ([f576e3b](https://github.com/postalsys/imapflow/commit/f576e3b0eb34ee1340b34f66e389b8c29408d80a))
9
+
10
+ ## [1.6.2](https://github.com/postalsys/imapflow/compare/v1.6.1...v1.6.2) (2026-07-28)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * never fall back to LSUB on an IMAP4rev2 session ([329f588](https://github.com/postalsys/imapflow/commit/329f58829592a3c9a9f69fa74387596a3e55eac3))
16
+
3
17
  ## [1.6.1](https://github.com/postalsys/imapflow/compare/v1.6.0...v1.6.1) (2026-07-27)
4
18
 
5
19
 
@@ -311,6 +311,14 @@ module.exports = async (connection, reference, mailbox, options) => {
311
311
  // decision below
312
312
  let successStage = null;
313
313
 
314
+ // Whether any source actually reported subscription state. RETURN (SUBSCRIBED)
315
+ // and LSUB are the only two, and a server can refuse both
316
+ let subscriptionStateKnown = false;
317
+
318
+ // A server may also volunteer \Subscribed on a plain LIST, which normalizeFlags
319
+ // folds into the entry - that counts as the state having been reported
320
+ let anyEntrySubscribed = () => entries.some(entry => entry.subscribed);
321
+
314
322
  let lastRejectedStage = null;
315
323
  let auxRetryInserted = false;
316
324
  for (let i = 0; i < stages.length; i++) {
@@ -346,6 +354,7 @@ module.exports = async (connection, reference, mailbox, options) => {
346
354
  }
347
355
  }
348
356
  successStage = stage;
357
+ subscriptionStateKnown = !!stage.subscribed;
349
358
  break;
350
359
  } catch (err) {
351
360
  if (i === stages.length - 1 || !isRejectedCommand(err)) {
@@ -478,20 +487,28 @@ module.exports = async (connection, reference, mailbox, options) => {
478
487
  response.next();
479
488
  };
480
489
 
481
- // Skipped when RETURN (SUBSCRIBED) already provided subscription state or when
482
- // this connection's server already rejected LSUB once. Safety net: if the
483
- // extended LIST was accepted but not a single mailbox came back subscribed on a
484
- // non-rev2 session, assume the server silently ignored RETURN (SUBSCRIBED) and
485
- // fall back to LSUB anyway (a rev2 session has no LSUB to fall back to, and an
486
- // account without any subscriptions legitimately looks the same).
487
- let needsLsub = !successStage.subscribed || (!isRev2Active(connection) && !entries.some(entry => entry.subscribed));
490
+ // Never sent on a rev2 session - LSUB is not part of that protocol version, and
491
+ // some servers break the rest of the session over the rejection, so this is
492
+ // decided up front rather than left to the skipLsub latch below. On rev1 it is
493
+ // skipped when RETURN (SUBSCRIBED) already answered. Safety net: if the extended
494
+ // LIST was accepted but not a single mailbox came back subscribed, assume the
495
+ // server silently ignored the option and fall back to LSUB anyway (an account
496
+ // with no subscriptions legitimately looks the same).
497
+ let needsLsub = !isRev2Active(connection) && (!successStage.subscribed || !anyEntrySubscribed());
498
+ if (needsLsub) {
499
+ // Reaching here means the listing did not settle the question after all -
500
+ // either no RETURN (SUBSCRIBED) was granted, or one was and the server
501
+ // ignored it. Only LSUB can answer now
502
+ subscriptionStateKnown = false;
503
+ }
488
504
  if (needsLsub && !connection.skipLsub) {
489
505
  try {
490
506
  await runLsub();
507
+ subscriptionStateKnown = true;
491
508
  } catch (err) {
492
509
  if (isRejectedCommand(err)) {
493
- // Tagged BAD: the server does not recognize the command (IMAP4rev2
494
- // removed LSUB) - skip LSUB for the rest of this connection
510
+ // Tagged BAD: the server does not implement LSUB despite advertising
511
+ // rev1 - skip it for the rest of this connection
495
512
  connection.skipLsub = true;
496
513
  } else if (err.responseStatus !== 'NO' || err.code === 'ETHROTTLE') {
497
514
  // Transport failures and throttling: rethrow, every follow-up
@@ -524,6 +541,21 @@ module.exports = async (connection, reference, mailbox, options) => {
524
541
  }
525
542
  }
526
543
 
544
+ // No source answered, so "not subscribed" was never actually reported for any of
545
+ // these folders - the state is unknown, not false. Reporting the whole listing as
546
+ // unsubscribed would hide every folder from a client that filters on subscription
547
+ // state, so assume subscribed instead. Phantom entries are excluded, the same way
548
+ // the rest of this file declines to trust them. NB! a transient LSUB NO lands here
549
+ // too, so the assumption can hold for one listing and be replaced by real state on
550
+ // the next
551
+ if (!subscriptionStateKnown && !anyEntrySubscribed()) {
552
+ for (let entry of entries) {
553
+ if (!entry.flags.has('\\NonExistent')) {
554
+ entry.subscribed = true;
555
+ }
556
+ }
557
+ }
558
+
527
559
  // INBOX should always appear as subscribed regardless of LSUB results
528
560
  let inboxEntry = entries.find(entry => entry.specialUse === '\\Inbox');
529
561
  if (inboxEntry && !inboxEntry.subscribed) {
@@ -129,7 +129,7 @@ export interface MailboxObject {
129
129
  specialUse?: string;
130
130
  /** True if mailbox was found from the output of LIST command */
131
131
  listed?: boolean;
132
- /** True if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers */
132
+ /** True if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers. Servers that answer neither report no subscription state at all, and every mailbox is then assumed to be subscribed */
133
133
  subscribed?: boolean;
134
134
  /** A Set of flags available to use in this mailbox. If it is not set or includes special flag "\*" then any flag can be used */
135
135
  permanentFlags?: Set<string>;
@@ -212,7 +212,7 @@ export interface ListResponse {
212
212
  specialUseSource?: 'user' | 'extension' | 'name';
213
213
  /** True if mailbox was found from the output of LIST command */
214
214
  listed: boolean;
215
- /** True if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers */
215
+ /** True if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers. Servers that answer neither report no subscription state at all, and every mailbox is then assumed to be subscribed */
216
216
  subscribed: boolean;
217
217
  /** If statusQuery was used, then this value includes the status response */
218
218
  status?: StatusObject;
@@ -268,7 +268,7 @@ export interface ListTreeResponse {
268
268
  specialUse?: string;
269
269
  /** True if mailbox was found from the output of LIST command */
270
270
  listed?: boolean;
271
- /** True if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers */
271
+ /** True if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers. Servers that answer neither report no subscription state at all, and every mailbox is then assumed to be subscribed */
272
272
  subscribed?: boolean;
273
273
  /** If true then this mailbox can not be selected in the UI */
274
274
  disabled?: boolean;
package/lib/imap-flow.js CHANGED
@@ -71,7 +71,7 @@ const states = {
71
71
  * @property {Set<string>} flags list of flags for this mailbox
72
72
  * @property {String} [specialUse] one of special-use flags (if applicable): "\All", "\Archive", "\Drafts", "\Flagged", "\Junk", "\Sent", "\Trash". Additionally INBOX has non-standard "\Inbox" flag set
73
73
  * @property {Boolean} listed `true` if mailbox was found from the output of LIST command
74
- * @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers
74
+ * @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers. Servers that answer neither report no subscription state at all, and every mailbox is then assumed to be subscribed
75
75
  * @property {Set<string>} permanentFlags A Set of flags available to use in this mailbox. If it is not set or includes special flag "\\\*" then any flag can be used.
76
76
  * @property {String} [mailboxId] unique mailbox ID if server has `OBJECTID` extension enabled
77
77
  * @property {BigInt} [highestModseq] latest known modseq value if server has CONDSTORE or XYMHIGHESTMODSEQ enabled
@@ -2518,7 +2518,7 @@ class ImapFlow extends EventEmitter {
2518
2518
  * @property {String} specialUse one of special-use flags (if applicable): "\All", "\Archive", "\Drafts", "\Flagged", "\Junk", "\Sent", "\Trash". Additionally INBOX has non-standard "\Inbox" flag set
2519
2519
  * @property {String} [specialUseSource] how `specialUse` was determined: `"user"` (from `specialUseHints`), `"extension"` (SPECIAL-USE or XLIST flag reported by the server) or `"name"` (matched against known localized folder names)
2520
2520
  * @property {Boolean} listed `true` if mailbox was found from the output of LIST command
2521
- * @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers
2521
+ * @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers. Servers that answer neither report no subscription state at all, and every mailbox is then assumed to be subscribed
2522
2522
  * @property {StatusObject} [status] If `statusQuery` was used, then this value includes the status response
2523
2523
  */
2524
2524
 
@@ -2569,7 +2569,7 @@ class ImapFlow extends EventEmitter {
2569
2569
  * @property {Set<string>} flags list of flags for this mailbox
2570
2570
  * @property {String} specialUse one of special-use flags (if applicable): "\All", "\Archive", "\Drafts", "\Flagged", "\Junk", "\Sent", "\Trash". Additionally INBOX has non-standard "\Inbox" flag set
2571
2571
  * @property {Boolean} listed `true` if mailbox was found from the output of LIST command
2572
- * @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers
2572
+ * @property {Boolean} subscribed `true` if the mailbox is subscribed - reported by LSUB or by LIST RETURN (SUBSCRIBED) on LIST-EXTENDED/IMAP4rev2 servers. Servers that answer neither report no subscription state at all, and every mailbox is then assumed to be subscribed
2573
2573
  * @property {Boolean} disabled If `true` then this mailbox can not be selected in the UI
2574
2574
  * @property {ListTreeResponse[]} folders An array of subfolders
2575
2575
  * @property {StatusObject} [status] If `statusQuery` was used, then this value includes the status response
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
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.1",
33
+ "@types/node": "26.1.2",
34
34
  "c8": "12.0.0",
35
- "eslint": "10.7.0",
35
+ "eslint": "10.8.0",
36
36
  "eslint-config-nodemailer": "1.2.0",
37
37
  "eslint-config-prettier": "10.1.8",
38
38
  "grunt": "1.6.2",
@@ -4168,7 +4168,7 @@ module.exports['Commands: list survives LSUB rejection'] = async test => {
4168
4168
  exec: async (cmd, attrs, opts) => {
4169
4169
  if (cmd === 'LSUB') {
4170
4170
  lsubCalls++;
4171
- // e.g. Exchange in IMAP4rev2 mode responds "BAD Command Argument Error"
4171
+ // e.g. Exchange responds "BAD Command Argument Error"
4172
4172
  throw commandError('Command failed', 'BAD');
4173
4173
  }
4174
4174
  if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
@@ -4189,6 +4189,9 @@ module.exports['Commands: list survives LSUB rejection'] = async test => {
4189
4189
  test.ok(inbox);
4190
4190
  // INBOX is always reported as subscribed even without LSUB data
4191
4191
  test.equal(inbox.subscribed, true);
4192
+ // Nothing reported subscription state, so it is unknown rather than false - every
4193
+ // folder is reported as subscribed instead of none of them
4194
+ test.equal(result.find(e => e.path === 'Folder1').subscribed, true);
4192
4195
 
4193
4196
  // The rejection is remembered - a follow-up listing skips LSUB entirely
4194
4197
  test.equal(connection.skipLsub, true);
@@ -4197,6 +4200,107 @@ module.exports['Commands: list survives LSUB rejection'] = async test => {
4197
4200
  test.done();
4198
4201
  };
4199
4202
 
4203
+ module.exports['Commands: list keeps a genuinely empty subscription set'] = async test => {
4204
+ // LSUB answers, it just has nothing to report. That is a real "nothing is
4205
+ // subscribed", not the unknown state, so it must not be overwritten
4206
+ const connection = createMockConnection({
4207
+ state: 3,
4208
+ exec: async (cmd, attrs, opts) => {
4209
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4210
+ await opts.untagged.LIST({
4211
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4212
+ });
4213
+ }
4214
+ return { next: () => {} };
4215
+ }
4216
+ });
4217
+
4218
+ const result = await listCommand(connection, '', '*');
4219
+ test.ok(!result.find(entry => entry.path === 'Folder1').subscribed);
4220
+ test.done();
4221
+ };
4222
+
4223
+ module.exports['Commands: list treats an ignored RETURN (SUBSCRIBED) plus a rejected LSUB as unknown'] = async test => {
4224
+ // The server accepts the RETURN option but reports no \Subscribed at all, which is
4225
+ // why the listing falls back to LSUB - and that is rejected too. Accepting the
4226
+ // command is not the same as answering it, so this is the unknown state
4227
+ const connection = createMockConnection({
4228
+ state: 3,
4229
+ capabilities: new Map([
4230
+ ['IMAP4rev1', true],
4231
+ ['LIST-EXTENDED', true]
4232
+ ]),
4233
+ exec: async (cmd, attrs, opts) => {
4234
+ if (cmd === 'LSUB') {
4235
+ throw commandError('Command failed', 'BAD');
4236
+ }
4237
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4238
+ await opts.untagged.LIST({
4239
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4240
+ });
4241
+ }
4242
+ return { next: () => {} };
4243
+ }
4244
+ });
4245
+
4246
+ const result = await listCommand(connection, '', '*');
4247
+ test.equal(result.find(entry => entry.path === 'Folder1').subscribed, true);
4248
+ test.done();
4249
+ };
4250
+
4251
+ module.exports['Commands: list leaves phantom folders out of the assumed subscription'] = async test => {
4252
+ const connection = createMockConnection({
4253
+ state: 3,
4254
+ capabilities: new Map([['IMAP4rev2', true]]),
4255
+ enabled: new Set(['IMAP4REV2']),
4256
+ skipListSubscribedArg: true,
4257
+ exec: async (cmd, attrs, opts) => {
4258
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4259
+ await opts.untagged.LIST({
4260
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4261
+ });
4262
+ await opts.untagged.LIST({
4263
+ attributes: [[{ value: '\\NonExistent' }], { value: '/' }, { value: 'Ghost' }]
4264
+ });
4265
+ }
4266
+ return { next: () => {} };
4267
+ }
4268
+ });
4269
+
4270
+ const result = await listCommand(connection, '', '*');
4271
+ test.equal(result.find(entry => entry.path === 'Folder1').subscribed, true);
4272
+ // A folder the server says does not exist is not claimed to be subscribed
4273
+ test.ok(!result.find(entry => entry.path === 'Ghost').subscribed);
4274
+ test.done();
4275
+ };
4276
+
4277
+ module.exports['Commands: list keeps subscription flags volunteered by a plain LIST'] = async test => {
4278
+ // No RETURN option was granted and LSUB is not available on rev2, but the server
4279
+ // reported \Subscribed on its own - that is real state and must not be widened
4280
+ const connection = createMockConnection({
4281
+ state: 3,
4282
+ capabilities: new Map([['IMAP4rev2', true]]),
4283
+ enabled: new Set(['IMAP4REV2']),
4284
+ skipListSubscribedArg: true,
4285
+ exec: async (cmd, attrs, opts) => {
4286
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4287
+ await opts.untagged.LIST({
4288
+ attributes: [[{ value: '\\Subscribed' }, { value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4289
+ });
4290
+ await opts.untagged.LIST({
4291
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder2' }]
4292
+ });
4293
+ }
4294
+ return { next: () => {} };
4295
+ }
4296
+ });
4297
+
4298
+ const result = await listCommand(connection, '', '*');
4299
+ test.equal(result.find(entry => entry.path === 'Folder1').subscribed, true);
4300
+ test.ok(!result.find(entry => entry.path === 'Folder2').subscribed);
4301
+ test.done();
4302
+ };
4303
+
4200
4304
  module.exports['Commands: list fails when LSUB dies without a server rejection'] = async test => {
4201
4305
  const connection = createMockConnection({
4202
4306
  state: 3,
@@ -4304,6 +4408,41 @@ module.exports['Commands: list retries with plain LIST when RETURN is rejected']
4304
4408
  test.done();
4305
4409
  };
4306
4410
 
4411
+ module.exports['Commands: list never falls back to LSUB on a rev2 session'] = async test => {
4412
+ let lsubCalled = false;
4413
+ // An Exchange-alike that advertises both revisions and negotiates rev2 via ENABLE,
4414
+ // having already proved it rejects RETURN (SUBSCRIBED). The listing therefore
4415
+ // carries no subscription state - and the LSUB that rev1 would fall back to is not
4416
+ // part of rev2, so asking anyway only risks upsetting the session.
4417
+ const connection = createMockConnection({
4418
+ state: 3,
4419
+ capabilities: new Map([
4420
+ ['IMAP4rev2', true],
4421
+ ['IMAP4rev1', true]
4422
+ ]),
4423
+ enabled: new Set(['IMAP4REV2']),
4424
+ skipListSubscribedArg: true,
4425
+ exec: async (cmd, attrs, opts) => {
4426
+ if (cmd === 'LSUB') {
4427
+ lsubCalled = true;
4428
+ }
4429
+ if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
4430
+ await opts.untagged.LIST({
4431
+ attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
4432
+ });
4433
+ }
4434
+ return { next: () => {} };
4435
+ }
4436
+ });
4437
+
4438
+ const result = await listCommand(connection, '', '*');
4439
+ test.equal(lsubCalled, false);
4440
+ // Neither source could answer, so the folder is reported as subscribed rather than
4441
+ // claiming the server said it is not
4442
+ test.equal(result.find(entry => entry.path === 'Folder1').subscribed, true);
4443
+ test.done();
4444
+ };
4445
+
4307
4446
  module.exports['Commands: list does not retry extended LIST on transport errors'] = async test => {
4308
4447
  let listCalls = 0;
4309
4448
  const connection = createMockConnection({