imapflow 1.6.2 → 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.2"
2
+ ".": "1.6.3"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## [1.6.2](https://github.com/postalsys/imapflow/compare/v1.6.1...v1.6.2) (2026-07-28)
4
11
 
5
12
 
@@ -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)) {
@@ -485,10 +494,17 @@ module.exports = async (connection, reference, mailbox, options) => {
485
494
  // LIST was accepted but not a single mailbox came back subscribed, assume the
486
495
  // server silently ignored the option and fall back to LSUB anyway (an account
487
496
  // with no subscriptions legitimately looks the same).
488
- let needsLsub = !isRev2Active(connection) && (!successStage.subscribed || !entries.some(entry => entry.subscribed));
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
+ }
489
504
  if (needsLsub && !connection.skipLsub) {
490
505
  try {
491
506
  await runLsub();
507
+ subscriptionStateKnown = true;
492
508
  } catch (err) {
493
509
  if (isRejectedCommand(err)) {
494
510
  // Tagged BAD: the server does not implement LSUB despite advertising
@@ -525,6 +541,21 @@ module.exports = async (connection, reference, mailbox, options) => {
525
541
  }
526
542
  }
527
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
+
528
559
  // INBOX should always appear as subscribed regardless of LSUB results
529
560
  let inboxEntry = entries.find(entry => entry.specialUse === '\\Inbox');
530
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.2",
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",
@@ -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,
@@ -4333,8 +4437,9 @@ module.exports['Commands: list never falls back to LSUB on a rev2 session'] = as
4333
4437
 
4334
4438
  const result = await listCommand(connection, '', '*');
4335
4439
  test.equal(lsubCalled, false);
4336
- // No subscription source was available, so nothing claims the folder is subscribed
4337
- test.ok(!result.find(entry => entry.path === 'Folder1').subscribed);
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);
4338
4443
  test.done();
4339
4444
  };
4340
4445