imapflow 1.4.8 → 1.4.9

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.
@@ -14,6 +14,7 @@ function makeConnection({ hasEsearch = true } = {}) {
14
14
  state: 'SELECTED',
15
15
  states: { SELECTED: 'SELECTED' },
16
16
  capabilities: caps,
17
+ enabled: new Set(),
17
18
  exec: async () => ({ next: () => {} }),
18
19
  log: { warn: () => {} }
19
20
  };
@@ -72,23 +73,6 @@ module.exports['ESEARCH: parseEsearchResponse PARTIAL (Array form)'] = test => {
72
73
  test.done();
73
74
  };
74
75
 
75
- module.exports['ESEARCH: parseEsearchResponse PARTIAL (LIST object form)'] = test => {
76
- // Also handle {type: 'LIST', attributes: [...]} form for robustness
77
- const attrs = [
78
- { type: 'ATOM', value: 'PARTIAL' },
79
- {
80
- type: 'LIST',
81
- attributes: [
82
- { type: 'ATOM', value: '1:100' },
83
- { type: 'ATOM', value: '1001,1003:1010,1015' }
84
- ]
85
- }
86
- ];
87
- const result = parseEsearchResponse(attrs);
88
- test.deepEqual(result.partial, { range: '1:100', messages: '1001,1003:1010,1015' });
89
- test.done();
90
- };
91
-
92
76
  module.exports['ESEARCH: parseEsearchResponse COUNT + PARTIAL combined'] = test => {
93
77
  const attrs = [
94
78
  { type: 'ATOM', value: 'COUNT' },
@@ -116,12 +100,14 @@ module.exports['ESEARCH: emits RETURN clause when returnOptions present and serv
116
100
  capturedAttributes = JSON.stringify(attributes);
117
101
  return { next: () => {} };
118
102
  };
119
- searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT'] }).then(() => {
120
- test.equal(capturedCommand, 'UID SEARCH');
121
- test.ok(capturedAttributes.includes('"RETURN"'), 'should include RETURN atom');
122
- test.ok(capturedAttributes.includes('"COUNT"'), 'should include COUNT in return list');
123
- test.done();
124
- }).catch(err => test.done(err));
103
+ searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT'] })
104
+ .then(() => {
105
+ test.equal(capturedCommand, 'UID SEARCH');
106
+ test.ok(capturedAttributes.includes('"RETURN"'), 'should include RETURN atom');
107
+ test.ok(capturedAttributes.includes('"COUNT"'), 'should include COUNT in return list');
108
+ test.done();
109
+ })
110
+ .catch(err => test.done(err));
125
111
  };
126
112
 
127
113
  module.exports['ESEARCH: RETURN clause includes PARTIAL range atom'] = test => {
@@ -131,11 +117,13 @@ module.exports['ESEARCH: RETURN clause includes PARTIAL range atom'] = test => {
131
117
  capturedAttributes = JSON.stringify(attributes);
132
118
  return { next: () => {} };
133
119
  };
134
- searchCmd(conn, { seen: false }, { uid: true, returnOptions: [{ partial: '1:100' }] }).then(() => {
135
- test.ok(capturedAttributes.includes('"PARTIAL"'), 'should include PARTIAL atom');
136
- test.ok(capturedAttributes.includes('"1:100"'), 'should include range string');
137
- test.done();
138
- }).catch(err => test.done(err));
120
+ searchCmd(conn, { seen: false }, { uid: true, returnOptions: [{ partial: '1:100' }] })
121
+ .then(() => {
122
+ test.ok(capturedAttributes.includes('"PARTIAL"'), 'should include PARTIAL atom');
123
+ test.ok(capturedAttributes.includes('"1:100"'), 'should include range string');
124
+ test.done();
125
+ })
126
+ .catch(err => test.done(err));
139
127
  };
140
128
 
141
129
  module.exports['ESEARCH: no RETURN clause when server lacks ESEARCH capability'] = test => {
@@ -152,12 +140,14 @@ module.exports['ESEARCH: no RETURN clause when server lacks ESEARCH capability']
152
140
  }
153
141
  return { next: () => {} };
154
142
  };
155
- searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT', 'ALL'] }).then(result => {
156
- test.ok(!capturedAttributes.includes('"RETURN"'), 'should NOT include RETURN when no ESEARCH');
157
- test.ok(Array.isArray(result), 'should return number[] when ESEARCH unavailable');
158
- test.deepEqual(result, [1, 2, 3]);
159
- test.done();
160
- }).catch(err => test.done(err));
143
+ searchCmd(conn, { seen: false }, { uid: true, returnOptions: ['COUNT', 'ALL'] })
144
+ .then(result => {
145
+ test.ok(!capturedAttributes.includes('"RETURN"'), 'should NOT include RETURN when no ESEARCH');
146
+ test.ok(Array.isArray(result), 'should return number[] when ESEARCH unavailable');
147
+ test.deepEqual(result, [1, 2, 3]);
148
+ test.done();
149
+ })
150
+ .catch(err => test.done(err));
161
151
  };
162
152
 
163
153
  module.exports['ESEARCH: parseEsearchResponse skips non-ATOM tokens'] = test => {
@@ -247,11 +237,13 @@ module.exports['ESEARCH: backward compat — no returnOptions returns number[]']
247
237
  return { next: () => {} };
248
238
  };
249
239
  // No returnOptions — must return number[] even if server has ESEARCH
250
- searchCmd(conn, { seen: true }, { uid: true }).then(result => {
251
- test.ok(Array.isArray(result));
252
- test.deepEqual(result, [10, 20]);
253
- test.done();
254
- }).catch(err => test.done(err));
240
+ searchCmd(conn, { seen: true }, { uid: true })
241
+ .then(result => {
242
+ test.ok(Array.isArray(result));
243
+ test.deepEqual(result, [10, 20]);
244
+ test.done();
245
+ })
246
+ .catch(err => test.done(err));
255
247
  };
256
248
 
257
249
  // ── imap-flow.js public API fallback test ─────────────────────────────────
@@ -271,16 +263,19 @@ module.exports['imap-flow: search() derives ESearchResult when server has no ESE
271
263
  // Stub run() to return a sorted number[]
272
264
  client.run = async () => [10, 20, 30, 40, 50];
273
265
 
274
- client.search({ seen: false }, { uid: true, returnOptions: ['COUNT', 'MIN', 'MAX', 'ALL'] }).then(result => {
275
- test.equal(typeof result, 'object', 'should return object, not array');
276
- test.ok(!Array.isArray(result), 'should not be an array');
277
- test.equal(result.count, 5);
278
- test.equal(result.min, 10);
279
- test.equal(result.max, 50);
280
- // packMessageRange([10,20,30,40,50]) → "10,20,30,40,50" (non-contiguous)
281
- test.ok(typeof result.all === 'string' && result.all.length > 0, 'all should be non-empty compact string');
282
- test.done();
283
- }).catch(err => test.done(err));
266
+ client
267
+ .search({ seen: false }, { uid: true, returnOptions: ['COUNT', 'MIN', 'MAX', 'ALL'] })
268
+ .then(result => {
269
+ test.equal(typeof result, 'object', 'should return object, not array');
270
+ test.ok(!Array.isArray(result), 'should not be an array');
271
+ test.equal(result.count, 5);
272
+ test.equal(result.min, 10);
273
+ test.equal(result.max, 50);
274
+ // packMessageRange([10,20,30,40,50]) → "10,20,30,40,50" (non-contiguous)
275
+ test.ok(typeof result.all === 'string' && result.all.length > 0, 'all should be non-empty compact string');
276
+ test.done();
277
+ })
278
+ .catch(err => test.done(err));
284
279
  };
285
280
 
286
281
  module.exports['imap-flow: search() fallback with empty result set'] = test => {
@@ -294,9 +289,12 @@ module.exports['imap-flow: search() fallback with empty result set'] = test => {
294
289
  client.state = client.states.SELECTED;
295
290
  client.capabilities = new Map();
296
291
  client.run = async () => [];
297
- client.search({}, { uid: true, returnOptions: ['COUNT', 'ALL'] }).then(result => {
298
- test.equal(result.count, 0);
299
- test.equal(result.all, undefined, 'all should be absent for empty result');
300
- test.done();
301
- }).catch(err => test.done(err));
292
+ client
293
+ .search({}, { uid: true, returnOptions: ['COUNT', 'ALL'] })
294
+ .then(result => {
295
+ test.equal(result.count, 0);
296
+ test.equal(result.all, undefined, 'all should be absent for empty result');
297
+ test.done();
298
+ })
299
+ .catch(err => test.done(err));
302
300
  };
@@ -7,7 +7,8 @@ const iconv = require('iconv-lite');
7
7
 
8
8
  // Mock connection for testing
9
9
  let createMockConnection = (options = {}) => ({
10
- enabled: new Set(options.utf8 ? ['UTF8=ACCEPT'] : []),
10
+ enabled: new Set(options.enabled || (options.utf8 ? ['UTF8=ACCEPT'] : [])),
11
+ capabilities: new Map(options.capabilities || []),
11
12
  namespace: options.namespace || null
12
13
  });
13
14
 
@@ -162,6 +163,102 @@ module.exports['Tools: updateCapabilities with valid list'] = test => {
162
163
  test.done();
163
164
  };
164
165
 
166
+ module.exports['Tools: updateCapabilities normalizes IMAP4rev2 casing'] = test => {
167
+ let list = [{ value: 'IMAP4rev2' }, { value: 'imap4rev1' }];
168
+ let result = tools.updateCapabilities(list);
169
+ // Wire tokens are uppercased, but the rev1/rev2 keys use the RFC spelling
170
+ test.equal(result.get('IMAP4rev2'), true);
171
+ test.equal(result.get('IMAP4rev1'), true);
172
+ test.equal(result.has('IMAP4REV2'), false);
173
+ test.done();
174
+ };
175
+
176
+ module.exports['Tools: isRev2Active for rev2-only server'] = test => {
177
+ // rev2 without rev1 means rev2 is the base protocol, no ENABLE needed
178
+ let connection = createMockConnection({ capabilities: [['IMAP4rev2', true]] });
179
+ test.equal(tools.isRev2Active(connection), true);
180
+ test.done();
181
+ };
182
+
183
+ module.exports['Tools: isRev2Active for dual server without ENABLE'] = test => {
184
+ // Advertising both keeps the session in rev1 mode until ENABLE IMAP4rev2
185
+ let connection = createMockConnection({
186
+ capabilities: [
187
+ ['IMAP4rev1', true],
188
+ ['IMAP4rev2', true]
189
+ ]
190
+ });
191
+ test.equal(tools.isRev2Active(connection), false);
192
+ test.done();
193
+ };
194
+
195
+ module.exports['Tools: isRev2Active for dual server with ENABLE'] = test => {
196
+ let connection = createMockConnection({
197
+ capabilities: [
198
+ ['IMAP4rev1', true],
199
+ ['IMAP4rev2', true]
200
+ ],
201
+ enabled: ['IMAP4REV2']
202
+ });
203
+ test.equal(tools.isRev2Active(connection), true);
204
+ test.done();
205
+ };
206
+
207
+ module.exports['Tools: isRev2Active for rev1-only server'] = test => {
208
+ let connection = createMockConnection({ capabilities: [['IMAP4rev1', true]] });
209
+ test.equal(tools.isRev2Active(connection), false);
210
+ test.done();
211
+ };
212
+
213
+ module.exports['Tools: hasCapability with advertised token'] = test => {
214
+ let connection = createMockConnection({
215
+ capabilities: [
216
+ ['IMAP4rev1', true],
217
+ ['UIDPLUS', true]
218
+ ]
219
+ });
220
+ test.equal(tools.hasCapability(connection, 'UIDPLUS'), true);
221
+ test.equal(tools.hasCapability(connection, 'MOVE'), false);
222
+ test.done();
223
+ };
224
+
225
+ module.exports['Tools: hasCapability folds extensions into active rev2'] = test => {
226
+ let connection = createMockConnection({ capabilities: [['IMAP4rev2', true]] });
227
+ // RFC 9051 Appendix E folds these into base IMAP4rev2
228
+ for (let capability of ['UIDPLUS', 'MOVE', 'NAMESPACE', 'ESEARCH', 'LITERAL-', 'LIST-EXTENDED', 'LIST-STATUS', 'SPECIAL-USE', 'ENABLE']) {
229
+ test.equal(tools.hasCapability(connection, capability), true, `${capability} should be folded into rev2`);
230
+ }
231
+ // BINARY is intentionally not folded
232
+ test.equal(tools.hasCapability(connection, 'BINARY'), false);
233
+ test.done();
234
+ };
235
+
236
+ module.exports['Tools: hasCapability does not fold on unenabled dual server'] = test => {
237
+ let connection = createMockConnection({
238
+ capabilities: [
239
+ ['IMAP4rev1', true],
240
+ ['IMAP4rev2', true]
241
+ ]
242
+ });
243
+ // Session is in rev1 mode - only explicitly advertised tokens count
244
+ test.equal(tools.hasCapability(connection, 'UIDPLUS'), false);
245
+ test.done();
246
+ };
247
+
248
+ module.exports['Tools: encodePath keeps UTF-8 when rev2 is active'] = test => {
249
+ let connection = createMockConnection({ capabilities: [['IMAP4rev2', true]] });
250
+ // rev2 mailbox names are native UTF-8, modified UTF-7 must not be applied
251
+ test.equal(tools.encodePath(connection, 'T\u00f5rva'), 'T\u00f5rva');
252
+ test.done();
253
+ };
254
+
255
+ module.exports['Tools: decodePath keeps ampersand sequences when rev2 is active'] = test => {
256
+ let connection = createMockConnection({ capabilities: [['IMAP4rev2', true]] });
257
+ // Under rev2 an "&"-sequence is a literal name, not modified UTF-7
258
+ test.equal(tools.decodePath(connection, 'A&AOQ-B'), 'A&AOQ-B');
259
+ test.done();
260
+ };
261
+
165
262
  module.exports['Tools: updateCapabilities with APPENDLIMIT'] = test => {
166
263
  let list = [{ value: 'APPENDLIMIT=52428800' }];
167
264
  let result = tools.updateCapabilities(list);
@@ -493,6 +590,30 @@ module.exports['Tools: expandRange with same start/end'] = test => {
493
590
  test.done();
494
591
  };
495
592
 
593
+ module.exports['Tools: expandRange skips entries that are not valid nz-numbers'] = test => {
594
+ // Server-supplied garbage must not produce bogus ids or endless loops
595
+ test.deepEqual(tools.expandRange('Infinity:5'), []);
596
+ test.deepEqual(tools.expandRange('0:3'), []);
597
+ test.deepEqual(tools.expandRange('abc,4,1:x'), [4]);
598
+ test.deepEqual(tools.expandRange('*'), []);
599
+ test.deepEqual(tools.expandRange('4294967296'), []);
600
+ test.done();
601
+ };
602
+
603
+ module.exports['Tools: expandRange caps hostile range spans'] = test => {
604
+ // A hostile range like 1:4294967295 is cut off at the expansion limit
605
+ // instead of exhausting memory
606
+ let result = tools.expandRange('1:4294967295');
607
+ test.equal(result.length, 0x1000000);
608
+ test.equal(result[0], 1);
609
+ test.equal(result[result.length - 1], 0x1000000);
610
+
611
+ let reverse = tools.expandRange('4294967295:4278190080');
612
+ test.equal(reverse.length, 0x1000000);
613
+ test.equal(reverse[0], 4294967295);
614
+ test.done();
615
+ };
616
+
496
617
  // ============================================
497
618
  // packMessageRange tests
498
619
  // ============================================
@@ -1303,9 +1424,7 @@ module.exports['Tools: parseBodystructure parses message/rfc822 with envelope an
1303
1424
  };
1304
1425
 
1305
1426
  module.exports['Tools: parseBodystructure parses multipart with params and disposition'] = async test => {
1306
- let bs =
1307
- '(("TEXT" "PLAIN" NIL NIL NIL "7BIT" 10 1)("TEXT" "HTML" NIL NIL NIL "7BIT" 20 2) ' +
1308
- '"ALTERNATIVE" ("BOUNDARY" "xyz") ("inline" NIL) ("en"))';
1427
+ let bs = '(("TEXT" "PLAIN" NIL NIL NIL "7BIT" 10 1)("TEXT" "HTML" NIL NIL NIL "7BIT" 20 2) "ALTERNATIVE" ("BOUNDARY" "xyz") ("inline" NIL) ("en"))';
1309
1428
  let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
1310
1429
  let node = tools.parseBodystructure(untagged.attributes[1][1]);
1311
1430
  test.equal(node.type, 'multipart/alternative');
@@ -1348,7 +1467,7 @@ module.exports['Tools: parseBodystructure handles minimal NIL fields'] = async t
1348
1467
  };
1349
1468
 
1350
1469
  module.exports['Tools: parseBodystructure decodes RFC 2231 charset continuation params'] = async test => {
1351
- let bs = "(\"TEXT\" \"PLAIN\" (\"name*0*\" \"utf-8''%E2%82%AC abc\" \"name*1\" \"def\") NIL NIL \"7BIT\" 10 1)";
1470
+ let bs = '("TEXT" "PLAIN" ("name*0*" "utf-8\'\'%E2%82%AC abc" "name*1" "def") NIL NIL "7BIT" 10 1)';
1352
1471
  let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
1353
1472
  let node = tools.parseBodystructure(untagged.attributes[1][1]);
1354
1473
  test.equal(node.parameters.name, '€ abcdef');
@@ -1453,9 +1572,7 @@ module.exports['Tools: parseBodystructure empty-string size/linecount/language/l
1453
1572
  };
1454
1573
 
1455
1574
  module.exports['Tools: parseBodystructure message/rfc822 empty-string linecount'] = async test => {
1456
- let bs =
1457
- '("MESSAGE" "RFC822" NIL NIL NIL "7BIT" 100 ("d" "s" NIL NIL NIL NIL NIL NIL NIL NIL) ' +
1458
- '("TEXT" "PLAIN" NIL NIL NIL "7BIT" 1 1) "")';
1575
+ let bs = '("MESSAGE" "RFC822" NIL NIL NIL "7BIT" 100 ("d" "s" NIL NIL NIL NIL NIL NIL NIL NIL) ("TEXT" "PLAIN" NIL NIL NIL "7BIT" 1 1) "")';
1459
1576
  let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
1460
1577
  let node = tools.parseBodystructure(untagged.attributes[1][1]);
1461
1578
  test.equal(node.type, 'message/rfc822');
@@ -1468,7 +1585,7 @@ module.exports['Tools: parseBodystructure decodes RFC 2231 with empty charset an
1468
1585
  // exercise the 2-char hex escape branch; the empty *1* continuation segment too.
1469
1586
  // value includes '=' / '?' (2-char hex escapes), a space (-> '_') and a TAB
1470
1587
  // (a <0x10 control char -> single-hex-digit escape needing a '0' pad).
1471
- let bs = "(\"TEXT\" \"PLAIN\" (\"name*0*\" \"''a=b?c d\te\" \"name*1*\" \"\") NIL NIL \"7BIT\" 1 1)";
1588
+ let bs = '("TEXT" "PLAIN" ("name*0*" "\'\'a=b?c d\te" "name*1*" "") NIL NIL "7BIT" 1 1)';
1472
1589
  let untagged = await parser('* 1 FETCH (BODYSTRUCTURE ' + bs + ')');
1473
1590
  let node = tools.parseBodystructure(untagged.attributes[1][1]);
1474
1591
  test.ok(node.parameters.name.includes('a=b?c'));
@@ -1485,13 +1602,15 @@ module.exports['Tools: parseBodystructure parses empty-string body location'] =
1485
1602
  test.done();
1486
1603
  };
1487
1604
 
1488
- module.exports['Tools: expandRange descending range to non-numeric second bound'] = test => {
1489
- test.deepEqual(tools.expandRange('5:abc'), [5, 4, 3, 2, 1, 0]);
1605
+ module.exports['Tools: expandRange skips descending range to non-numeric second bound'] = test => {
1606
+ // Endpoints that are not valid nz-numbers make the whole entry invalid -
1607
+ // the pre-hardening behavior expanded '5:abc' down to 0
1608
+ test.deepEqual(tools.expandRange('5:abc'), []);
1490
1609
  test.done();
1491
1610
  };
1492
1611
 
1493
- module.exports['Tools: expandRange tolerates non-numeric entries'] = test => {
1494
- test.deepEqual(tools.expandRange('abc,:5,1:3'), [0, 0, 1, 2, 3, 4, 5, 1, 2, 3]);
1612
+ module.exports['Tools: expandRange skips non-numeric entries but keeps valid ones'] = test => {
1613
+ test.deepEqual(tools.expandRange('abc,:5,1:3'), [1, 2, 3]);
1495
1614
  test.done();
1496
1615
  };
1497
1616
 
@@ -1508,7 +1627,7 @@ module.exports['Tools: encodePath keeps name as-is when iconv encode throws'] =
1508
1627
  throw new Error('encode boom');
1509
1628
  };
1510
1629
  try {
1511
- let connection = { enabled: new Set() };
1630
+ let connection = { enabled: new Set(), capabilities: new Map() };
1512
1631
  let result = tools.encodePath(connection, 'Tärä');
1513
1632
  test.equal(result, 'Tärä', 'falls back to the raw path');
1514
1633
  } finally {
@@ -1523,7 +1642,7 @@ module.exports['Tools: decodePath keeps name as-is when iconv decode throws'] =
1523
1642
  throw new Error('decode boom');
1524
1643
  };
1525
1644
  try {
1526
- let connection = { enabled: new Set() };
1645
+ let connection = { enabled: new Set(), capabilities: new Map() };
1527
1646
  let result = tools.decodePath(connection, 'Inbox&AOk-');
1528
1647
  test.equal(result, 'Inbox&AOk-');
1529
1648
  } finally {