imapflow 1.5.0 → 1.6.0
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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/CLAUDE.md +1 -1
- package/lib/commands/idle.js +197 -104
- package/lib/commands/list.js +15 -7
- package/lib/commands/quota.js +3 -0
- package/lib/commands/select.js +5 -0
- package/lib/commands/status.js +4 -0
- package/lib/connection-deadline.js +98 -0
- package/lib/handler/imap-compiler.js +8 -5
- package/lib/handler/imap-stream.js +141 -50
- package/lib/handler/limits.js +43 -0
- package/lib/handler/token-parser.js +31 -1
- package/lib/imap-flow.d.ts +33 -5
- package/lib/imap-flow.js +575 -281
- package/lib/proxy-connection.js +393 -98
- package/lib/special-use.js +660 -51
- package/lib/tools.js +17 -0
- package/package.json +2 -2
- package/test/commands-branches-test.js +17 -1
- package/test/commands-integration-test.js +24 -2
- package/test/fixtures/fake-timers.js +115 -0
- package/test/handler-branches-test.js +0 -25
- package/test/idle-polling-test.js +349 -0
- package/test/imap-flow-compress-test.js +12 -0
- package/test/imap-flow-coverage-test.js +3 -3
- package/test/imap-flow-internals-test.js +23 -0
- package/test/imap-flow-proxy-paths-test.js +151 -0
- package/test/imap-flow-secure-test.js +159 -0
- package/test/imap-flow-server-test.js +149 -0
- package/test/parser-limits-test.js +274 -0
- package/test/proxy-connection-test.js +553 -442
- package/test/reliability-improvements-test.js +87 -0
- package/test/special-use-test.js +337 -0
- package/test/tag-correlation-test.js +333 -0
- 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
|
+
};
|
package/test/special-use-test.js
CHANGED
|
@@ -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
|
+
};
|