imapflow 1.5.0 → 1.6.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.
Files changed (36) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +22 -0
  3. package/CLAUDE.md +1 -1
  4. package/lib/commands/idle.js +197 -104
  5. package/lib/commands/list.js +15 -7
  6. package/lib/commands/quota.js +3 -0
  7. package/lib/commands/select.js +5 -0
  8. package/lib/commands/status.js +4 -0
  9. package/lib/connection-deadline.js +98 -0
  10. package/lib/handler/imap-compiler.js +8 -5
  11. package/lib/handler/imap-stream.js +141 -50
  12. package/lib/handler/limits.js +43 -0
  13. package/lib/handler/token-parser.js +31 -1
  14. package/lib/imap-flow.d.ts +33 -5
  15. package/lib/imap-flow.js +583 -281
  16. package/lib/proxy-connection.js +393 -98
  17. package/lib/special-use.js +660 -51
  18. package/lib/tools.js +17 -0
  19. package/package.json +2 -2
  20. package/test/commands-branches-test.js +17 -1
  21. package/test/commands-integration-test.js +24 -2
  22. package/test/fixtures/fake-timers.js +115 -0
  23. package/test/handler-branches-test.js +0 -25
  24. package/test/idle-polling-test.js +349 -0
  25. package/test/imap-flow-compress-test.js +12 -0
  26. package/test/imap-flow-coverage-test.js +3 -3
  27. package/test/imap-flow-internals-test.js +23 -0
  28. package/test/imap-flow-proxy-paths-test.js +151 -0
  29. package/test/imap-flow-secure-test.js +159 -0
  30. package/test/imap-flow-server-test.js +186 -0
  31. package/test/parser-limits-test.js +274 -0
  32. package/test/proxy-connection-test.js +553 -442
  33. package/test/reliability-improvements-test.js +87 -0
  34. package/test/special-use-test.js +337 -0
  35. package/test/tag-correlation-test.js +333 -0
  36. package/test/timer-policy-test.js +214 -0
@@ -380,6 +380,10 @@ module.exports['Reliability: decoder emit(error) does not crash when user has no
380
380
  const stubThrottleResponse = (client, backoffMs) => {
381
381
  let request = { tag: 'A001', command: 'FETCH', resolve: () => {}, reject: () => {} };
382
382
  client.requestTagMap = new Map([['A001', request]]);
383
+ // The tagged response may only complete a command that was actually written to the socket, so
384
+ // the stub has to present A001 as the active, already sent request - otherwise it is protocol
385
+ // desynchronization.
386
+ client.currentRequest = { tag: 'A001', command: 'FETCH', sent: true };
383
387
 
384
388
  let done = false;
385
389
  client.streamer.read = () => {
@@ -459,3 +463,86 @@ module.exports['Reliability: throttle back-off still rejects ETHROTTLE on normal
459
463
  client.close();
460
464
  test.done();
461
465
  };
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // reader(): the parser backpressure callback is a resource that must always be released
469
+ // ---------------------------------------------------------------------------
470
+
471
+ module.exports['Reliability: an unexpected response-handling failure releases the parser and fails closed'] = async test => {
472
+ // Several steps of response handling (log compilation, response shape assumptions, a handler
473
+ // bug) sit outside the parse try block. A throw there used to propagate out of the reader loop
474
+ // and leave ImapStream waiting on its backpressure callback forever - a silent permanent hang.
475
+ let client = new ImapFlow({
476
+ host: 'imap.example.com',
477
+ port: 993,
478
+ auth: { user: 'test', pass: 'test' },
479
+ logger: false
480
+ });
481
+ client.socket = { destroyed: false, destroy: () => {} };
482
+ client.writeSocket = client.socket;
483
+
484
+ let rejected = null;
485
+ let request = { tag: 'A001', command: 'NOOP', resolve: () => {}, reject: err => (rejected = err) };
486
+ client.requestTagMap = new Map([['A001', request]]);
487
+ client.currentRequest = { tag: 'A001', command: 'NOOP', sent: true };
488
+
489
+ let errors = [];
490
+ client.on('error', err => errors.push(err));
491
+
492
+ let released = 0;
493
+ let served = false;
494
+ client.streamer.read = () => {
495
+ if (served) {
496
+ return null;
497
+ }
498
+ served = true;
499
+ return {
500
+ payload: Buffer.from('A001 OK NOOP done'),
501
+ literals: [],
502
+ next: () => released++
503
+ };
504
+ };
505
+
506
+ // Stand in for any unexpected failure during response handling
507
+ client.handleResponse = async () => {
508
+ throw new Error('handler blew up');
509
+ };
510
+
511
+ await client.reader();
512
+
513
+ test.equal(released, 1, 'the parser backpressure callback is released exactly once');
514
+ test.ok(rejected, 'the in-flight request is rejected instead of hanging');
515
+ test.equal(rejected.code, 'ResponseProcessingFailed');
516
+ test.ok(client.streamer.destroyed, 'the parser stream is destroyed, so nothing further is parsed');
517
+
518
+ await new Promise(resolve => setImmediate(resolve));
519
+ test.ok(
520
+ errors.some(err => err.code === 'ResponseProcessingFailed'),
521
+ 'the failure is reported to the caller'
522
+ );
523
+
524
+ client.close();
525
+ test.done();
526
+ };
527
+
528
+ module.exports['Reliability: releaseStreamData is idempotent'] = async test => {
529
+ let client = new ImapFlow({
530
+ host: 'imap.example.com',
531
+ port: 993,
532
+ auth: { user: 'test', pass: 'test' },
533
+ logger: false
534
+ });
535
+
536
+ let released = 0;
537
+ let data = { next: () => released++ };
538
+
539
+ client.releaseStreamData(data);
540
+ client.releaseStreamData(data);
541
+ client.releaseStreamData(data);
542
+
543
+ test.equal(released, 1, 'a readable item is only ever released once');
544
+ test.doesNotThrow(() => client.releaseStreamData(null), 'releasing nothing is a no-op');
545
+
546
+ client.close();
547
+ test.done();
548
+ };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const specialUse = require('../lib/special-use');
4
+ const fs = require('fs');
4
5
 
5
6
  module.exports['Special Use: flags array'] = test => {
6
7
  test.ok(Array.isArray(specialUse.flags));
@@ -79,3 +80,339 @@ module.exports['Special Use: specialUse returns null flag when no match'] = test
79
80
  test.equal(result.source, undefined);
80
81
  test.done();
81
82
  };
83
+
84
+ // ============================================
85
+ // Localized folder name detection
86
+ // ============================================
87
+
88
+ // Helper: resolve a folder name the way a server without SPECIAL-USE would be handled.
89
+ const byName = name => specialUse.specialUse(false, { flags: new Set(), name }).flag;
90
+
91
+ // Regression: Exchange/Outlook does not advertise SPECIAL-USE, so a Russian-locale
92
+ // mailbox has to resolve purely by folder name. Junk and Archive used to fall through.
93
+ module.exports['Special Use: Russian Outlook mailbox resolves every special-use folder'] = test => {
94
+ test.equal(byName('Отправленные'), '\\Sent');
95
+ test.equal(byName('Черновики'), '\\Drafts');
96
+ test.equal(byName('Удаленные'), '\\Trash');
97
+ test.equal(byName('Нежелательная почта'), '\\Junk');
98
+ test.equal(byName('Архив'), '\\Archive');
99
+ test.done();
100
+ };
101
+
102
+ // Non-mail folders exposed over IMAP by Exchange must stay unclassified.
103
+ module.exports['Special Use: Russian calendar and contacts folders stay unmatched'] = test => {
104
+ test.equal(byName('Календарь'), null);
105
+ test.equal(byName('Контакты'), null);
106
+ test.done();
107
+ };
108
+
109
+ // Russian webmail (Yandex, Mail.ru, Rambler) uses different words than Outlook.
110
+ module.exports['Special Use: Russian webmail folder names'] = test => {
111
+ test.equal(byName('Корзина'), '\\Trash');
112
+ test.equal(byName('Удалённые'), '\\Trash'); // spelled with a real "ё"
113
+ test.equal(byName('Спам'), '\\Junk');
114
+ test.done();
115
+ };
116
+
117
+ module.exports['Special Use: Archive folder names across locales'] = test => {
118
+ test.equal(byName('Archive'), '\\Archive');
119
+ test.equal(byName('Архив'), '\\Archive');
120
+ test.equal(byName('Архів'), '\\Archive');
121
+ test.equal(byName('Archiv'), '\\Archive');
122
+ test.equal(byName('Archivo'), '\\Archive');
123
+ test.equal(byName('Arquivo'), '\\Archive');
124
+ test.equal(byName('Archivio'), '\\Archive');
125
+ test.equal(byName('Archief'), '\\Archive');
126
+ test.equal(byName('Arkiv'), '\\Archive');
127
+ test.equal(byName('Arşiv'), '\\Archive');
128
+ test.equal(byName('アーカイブ'), '\\Archive');
129
+ test.equal(byName('보관함'), '\\Archive');
130
+ test.done();
131
+ };
132
+
133
+ module.exports['Special Use: Junk folder names across locales'] = test => {
134
+ test.equal(byName('Junk Email'), '\\Junk');
135
+ test.equal(byName('Нежелательная почта'), '\\Junk');
136
+ test.equal(byName('Небажана пошта'), '\\Junk');
137
+ test.equal(byName('Ongewenste e-mail'), '\\Junk');
138
+ test.equal(byName('Lixo Eletrônico'), '\\Junk');
139
+ test.equal(byName('Neželjena pošta'), '\\Junk');
140
+ test.equal(byName('迷惑メール'), '\\Junk');
141
+ test.equal(byName('정크 메일'), '\\Junk');
142
+ test.done();
143
+ };
144
+
145
+ module.exports['Special Use: Trash folder names across locales'] = test => {
146
+ test.equal(byName('Deleted Items'), '\\Trash');
147
+ test.equal(byName('Corbeille'), '\\Trash');
148
+ test.equal(byName('Papierkorb'), '\\Trash');
149
+ test.equal(byName('Papelera'), '\\Trash');
150
+ test.equal(byName('Cestino'), '\\Trash');
151
+ test.equal(byName('Kosz'), '\\Trash');
152
+ test.equal(byName('ゴミ箱'), '\\Trash');
153
+ test.equal(byName('휴지통'), '\\Trash');
154
+ test.done();
155
+ };
156
+
157
+ // Matching lowercases the folder name, so non-ASCII scripts must fold too.
158
+ module.exports['Special Use: name matching is case insensitive for non-ASCII names'] = test => {
159
+ test.equal(byName('НЕЖЕЛАТЕЛЬНАЯ ПОЧТА'), '\\Junk');
160
+ test.equal(byName('АРХИВ'), '\\Archive');
161
+ test.equal(byName('Корзина'), '\\Trash');
162
+ test.done();
163
+ };
164
+
165
+ // Surrounding whitespace and the LTR mark injected by some clients are stripped.
166
+ module.exports['Special Use: strips whitespace and LTR marks before matching'] = test => {
167
+ test.equal(byName(' Архив '), '\\Archive');
168
+ test.equal(byName('\u200eНежелательная почта'), '\\Junk');
169
+ test.done();
170
+ };
171
+
172
+ // Exchange and Outlook use a two word naming style ("Sent Items") that differs from
173
+ // the one word style most webmail uses ("Sent"), and they never advertise SPECIAL-USE,
174
+ // so these names have to resolve by name alone.
175
+ module.exports['Special Use: Exchange and Outlook localized folder names'] = test => {
176
+ test.equal(byName('Sent Items'), '\\Sent');
177
+ test.equal(byName('Deleted Items'), '\\Trash');
178
+ test.equal(byName('Junk Email'), '\\Junk');
179
+ test.equal(byName('Gesendete Elemente'), '\\Sent');
180
+ test.equal(byName('Gelöschte Elemente'), '\\Trash');
181
+ test.equal(byName('Éléments envoyés'), '\\Sent');
182
+ test.equal(byName('Elementos eliminados'), '\\Trash');
183
+ test.equal(byName('Posta eliminata'), '\\Trash');
184
+ test.equal(byName('Verzonden items'), '\\Sent');
185
+ test.equal(byName('Elementy usunięte'), '\\Trash');
186
+ test.equal(byName('Elemente șterse'), '\\Trash');
187
+ test.equal(byName('Sendte elementer'), '\\Sent');
188
+ test.equal(byName('Gönderilmiş Öğeler'), '\\Sent');
189
+ test.equal(byName('Önemsiz E-posta'), '\\Junk');
190
+ test.equal(byName('送信済みアイテム'), '\\Sent');
191
+ test.equal(byName('削除済みアイテム'), '\\Trash');
192
+ test.equal(byName('보낸 편지함'), '\\Sent');
193
+ test.done();
194
+ };
195
+
196
+ // Names harvested from the localization catalogs of Roundcube, SOGo and Thunderbird.
197
+ // These are the cases that motivated, and are now covered without, approximate matching.
198
+ module.exports['Special Use: names taken from mail client localization catalogs'] = test => {
199
+ test.equal(byName('Gesendet'), '\\Sent'); // de, Thunderbird
200
+ test.equal(byName('Skickat'), '\\Sent'); // sv
201
+ test.equal(byName('Gelöscht'), '\\Trash'); // de, Roundcube
202
+ test.equal(byName('Poubelle'), '\\Trash'); // fr
203
+ test.equal(byName('Paperera'), '\\Trash'); // ca
204
+ test.equal(byName('Rämps'), '\\Junk'); // et
205
+ test.equal(byName('Skräp'), '\\Junk'); // sv
206
+ test.equal(byName('Ongewenst'), '\\Junk'); // nl
207
+ test.equal(byName('Basura'), '\\Junk'); // es
208
+ test.equal(byName('Arkisto'), '\\Archive'); // fi
209
+ test.done();
210
+ };
211
+
212
+ // ============================================
213
+ // Relaxed matching (decorated names and morphological variants)
214
+ // ============================================
215
+
216
+ const sourceOf = name => specialUse.specialUse(false, { flags: new Set(), name }).source;
217
+
218
+ // Approximate matching is deliberately NOT done. A shared prefix is not enough
219
+ // evidence: at five characters English "conceal" reaches Dutch "concepten" and
220
+ // "article" reaches Romanian "articole", and a wrongly claimed Trash or Junk folder
221
+ // makes a client delete into, or permanently expunge from, an ordinary folder.
222
+ // Morphological variants belong in the tables as real entries instead.
223
+ module.exports['Special Use: morphological variants are not guessed'] = test => {
224
+ test.equal(byName('Prügikast'), '\\Trash'); // exact table entry
225
+ test.equal(byName('Prügi'), null); // truncation, not matched
226
+ test.equal(byName('Prügikorv'), null); // different ending, not matched
227
+ test.equal(byName('Conceal'), null);
228
+ test.equal(byName('Article'), null);
229
+ test.equal(byName('Postal'), null);
230
+ test.equal(byName('Element'), null);
231
+ test.done();
232
+ };
233
+
234
+ // Known name decorated with a generic mail noun that carries no meaning of its own.
235
+ module.exports['Special Use: names decorated with generic mail nouns'] = test => {
236
+ test.equal(byName('Sent Mail'), '\\Sent');
237
+ test.equal(byName('Отправленные письма'), '\\Sent');
238
+ test.equal(byName('Удаленные элементы'), '\\Trash');
239
+ test.equal(byName('Deleted Mail'), '\\Trash');
240
+ test.equal(byName('Spam Messages'), '\\Junk');
241
+ test.equal(byName('Черновики письма'), '\\Drafts');
242
+ test.equal(byName('My Drafts'), '\\Drafts');
243
+ test.equal(byName('Saadetud e-kirjad'), '\\Sent'); // hyphenated "e-" family
244
+ test.done();
245
+ };
246
+
247
+ // Relaxed hits are a guess and must be reported as a distinct, lower priority source
248
+ // so that an exactly named folder wins the slot when both exist in one mailbox.
249
+ module.exports['Special Use: relaxed matches report a distinct source'] = test => {
250
+ test.equal(sourceOf('Prügikast'), 'name');
251
+ test.equal(sourceOf('Sent'), 'name');
252
+ test.equal(sourceOf('Отправленные письма'), 'name-guess');
253
+ test.equal(sourceOf('Sent Mail'), 'name-guess');
254
+ test.done();
255
+ };
256
+
257
+ // The precision guards. A wrongly flagged Trash or Junk folder is destructive, so
258
+ // anything that leaves more than one meaningful word behind must be refused.
259
+ module.exports['Special Use: user folders that merely contain a known word are refused'] = test => {
260
+ test.equal(byName('Sent to clients'), null);
261
+ test.equal(byName('Archive 2023'), null);
262
+ test.equal(byName('Junk food recipes'), null);
263
+ test.equal(byName('Drafts of my novel'), null);
264
+ test.equal(byName('Trash talk'), null);
265
+ test.equal(byName('Spam reports'), null);
266
+ test.equal(byName('Deleted scenes'), null);
267
+ test.equal(byName('Corbeilles de fruits'), null);
268
+ test.done();
269
+ };
270
+
271
+ // Ordinary words that share a prefix with an entry must never be classified.
272
+ module.exports['Special Use: words sharing only a prefix do not match'] = test => {
273
+ test.equal(byName('Draftsman'), null);
274
+ test.equal(byName('Sentinel'), null);
275
+ test.equal(byName('Sentiments'), null);
276
+ test.equal(byName('Junkyard'), null);
277
+ test.equal(byName('Spammers'), null);
278
+ test.equal(byName('Binder'), null);
279
+ test.equal(byName('Postbox'), null);
280
+ test.equal(byName('Papers'), null);
281
+ test.done();
282
+ };
283
+
284
+ // Every source specialUse() can return has to be ranked by the conflict resolution in
285
+ // lib/commands/list.js. A new tier added here without being added there would silently
286
+ // sort ahead of an explicit user hint, so pin the vocabulary from this side.
287
+ module.exports['Special Use: reports only sources that list.js ranks'] = test => {
288
+ const RANKED = ['user', 'extension', 'name', 'name-guess'];
289
+ const listSource = fs.readFileSync(`${__dirname}/../lib/commands/list.js`, 'utf8');
290
+ const declared = listSource.match(/const SOURCE_SORT_ORDER = \[([^\]]*)\]/);
291
+
292
+ test.ok(declared, 'SOURCE_SORT_ORDER not found in lib/commands/list.js');
293
+ test.deepEqual(
294
+ declared[1]
295
+ .split(',')
296
+ .map(part => part.trim().replace(/^'|'$/g, ''))
297
+ .filter(Boolean),
298
+ RANKED
299
+ );
300
+
301
+ // and every source this module actually emits is one of them
302
+ const emitted = new Set();
303
+ emitted.add(specialUse.specialUse(true, { flags: new Set(['\\Sent']), name: 'x' }).source);
304
+ emitted.add(specialUse.specialUse(false, { flags: new Set(), name: 'Sent' }).source);
305
+ emitted.add(specialUse.specialUse(false, { flags: new Set(), name: 'Sent Mail' }).source);
306
+ for (let source of emitted) {
307
+ test.ok(RANKED.includes(source), `unranked source: ${source}`);
308
+ }
309
+ test.done();
310
+ };
311
+
312
+ // ============================================
313
+ // Structural guards for the name tables
314
+ // ============================================
315
+
316
+ // A name listed under two different flags would make detection depend on key order.
317
+ module.exports['Special Use: no folder name is claimed by two flags'] = test => {
318
+ let owner = new Map();
319
+ let collisions = [];
320
+ for (let flag of Object.keys(specialUse.names)) {
321
+ for (let name of specialUse.names[flag]) {
322
+ if (owner.has(name) && owner.get(name) !== flag) {
323
+ collisions.push(`${name}: ${owner.get(name)} vs ${flag}`);
324
+ }
325
+ owner.set(name, flag);
326
+ }
327
+ }
328
+ test.deepEqual(collisions, []);
329
+ test.done();
330
+ };
331
+
332
+ // Lookups normalize the incoming name to NFKC, so an entry stored in any other
333
+ // normalization form can never match, however the server spells the folder.
334
+ module.exports['Special Use: every name entry is stored in NFKC form'] = test => {
335
+ let problems = [];
336
+ for (let flag of Object.keys(specialUse.names)) {
337
+ for (let name of specialUse.names[flag]) {
338
+ if (name !== name.normalize('NFKC')) {
339
+ problems.push(`${flag}: not NFKC: ${name}`);
340
+ }
341
+ }
342
+ }
343
+ test.deepEqual(problems, []);
344
+ test.done();
345
+ };
346
+
347
+ // Servers echo back whatever normalization form the creating client used, so both
348
+ // canonically equivalent spellings of a name must resolve to the same flag.
349
+ module.exports['Special Use: matching is independent of Unicode normalization form'] = test => {
350
+ let mismatches = [];
351
+ for (let flag of Object.keys(specialUse.names)) {
352
+ for (let name of specialUse.names[flag]) {
353
+ for (let form of ['NFC', 'NFD', 'NFKC', 'NFKD']) {
354
+ let resolved = specialUse.specialUse(false, { flags: new Set(), name: name.normalize(form) }).flag;
355
+ if (resolved !== flag) {
356
+ mismatches.push(`${flag}: "${name}" as ${form} resolved to ${resolved}`);
357
+ }
358
+ }
359
+ }
360
+ }
361
+ test.deepEqual(mismatches, []);
362
+ test.done();
363
+ };
364
+
365
+ // Compatibility folding is the reason lookups normalize with NFKC rather than NFC.
366
+ // The Japanese Sent entry was stored with halfwidth katakana, which NFC does not
367
+ // fold, so it never matched the fullwidth spelling that servers actually send.
368
+ module.exports['Special Use: halfwidth and fullwidth forms fold onto the same entry'] = test => {
369
+ // U+FF92 U+FF70 U+FF99 halfwidth vs U+30E1 U+30FC U+30EB fullwidth katakana
370
+ const halfwidth = '送信済み' + String.fromCodePoint(0xff92, 0xff70, 0xff99);
371
+ const fullwidth = '送信済み' + String.fromCodePoint(0x30e1, 0x30fc, 0x30eb);
372
+
373
+ test.equal(byName(halfwidth), '\\Sent');
374
+ test.equal(byName(fullwidth), '\\Sent');
375
+ // Fullwidth Latin, which some CJK clients use for ASCII folder names
376
+ test.equal(byName('Sent'), '\\Sent');
377
+ test.equal(byName('Trash'), '\\Trash');
378
+ test.done();
379
+ };
380
+
381
+ // Devanagari and Bengali nukta letters are Unicode composition exclusions: the
382
+ // precomposed character is NOT the NFC form, so these two entries used to match
383
+ // only if the server happened to send the precomposed spelling.
384
+ module.exports['Special Use: nukta drafts folders match in either spelling'] = test => {
385
+ // U+095E DEVANAGARI LETTER PHA WITH NUKTA vs U+092B U+093C
386
+ const hindi = String.fromCodePoint(0x0921, 0x094d, 0x0930, 0x093e, 0x095e, 0x094d, 0x091f);
387
+ // U+09DC BENGALI LETTER RRA vs U+09A1 U+09BC
388
+ const bengali = String.fromCodePoint(0x0996, 0x09b8, 0x09dc, 0x09be);
389
+
390
+ test.equal(byName(hindi), '\\Drafts');
391
+ test.equal(byName(hindi.normalize('NFC')), '\\Drafts');
392
+ test.equal(byName(bengali), '\\Drafts');
393
+ test.equal(byName(bengali.normalize('NFC')), '\\Drafts');
394
+ test.done();
395
+ };
396
+
397
+ // Lookups compare against a lowercased and trimmed name, so entries stored in any
398
+ // other form are dead weight that can never match.
399
+ module.exports['Special Use: every name entry is lowercase, trimmed and unique'] = test => {
400
+ let problems = [];
401
+ for (let flag of Object.keys(specialUse.names)) {
402
+ let seen = new Set();
403
+ for (let name of specialUse.names[flag]) {
404
+ if (name !== name.toLowerCase()) {
405
+ problems.push(`${flag}: not lowercase: ${name}`);
406
+ }
407
+ if (name !== name.trim()) {
408
+ problems.push(`${flag}: not trimmed: ${JSON.stringify(name)}`);
409
+ }
410
+ if (seen.has(name)) {
411
+ problems.push(`${flag}: duplicate: ${name}`);
412
+ }
413
+ seen.add(name);
414
+ }
415
+ }
416
+ test.deepEqual(problems, []);
417
+ test.done();
418
+ };