imapflow 2.2.6 → 2.2.7
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/CHANGELOG.md +9 -0
- package/dist/cjs/commands/append.js +10 -3
- package/dist/cjs/commands/authenticate.js +27 -18
- package/dist/cjs/commands/close.d.ts +12 -1
- package/dist/cjs/commands/close.js +4 -2
- package/dist/cjs/commands/delete.js +2 -1
- package/dist/cjs/commands/esearch-parser.js +8 -2
- package/dist/cjs/commands/fetch.js +35 -9
- package/dist/cjs/commands/id.js +8 -1
- package/dist/cjs/commands/idle.js +15 -5
- package/dist/cjs/commands/list.js +10 -1
- package/dist/cjs/commands/login.js +5 -1
- package/dist/cjs/commands/logout.js +7 -0
- package/dist/cjs/commands/namespace.js +7 -3
- package/dist/cjs/commands/quota.js +3 -1
- package/dist/cjs/commands/rename.js +2 -1
- package/dist/cjs/commands/select.js +5 -0
- package/dist/cjs/commands/status.js +6 -1
- package/dist/cjs/commands/store.d.ts +1 -1
- package/dist/cjs/commands/store.js +1 -2
- package/dist/cjs/download.js +82 -91
- package/dist/cjs/handler/imap-compiler.js +19 -10
- package/dist/cjs/handler/limits.d.ts +11 -0
- package/dist/cjs/handler/limits.js +16 -1
- package/dist/cjs/handler/parser-instance.d.ts +10 -0
- package/dist/cjs/handler/parser-instance.js +25 -10
- package/dist/cjs/handler/token-parser.js +36 -28
- package/dist/cjs/imap-flow.js +235 -89
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/cjs/proxy-connection.js +7 -7
- package/dist/cjs/search-compiler.js +30 -13
- package/dist/cjs/special-use.js +10 -5
- package/dist/cjs/tools.d.ts +10 -1
- package/dist/cjs/tools.js +30 -3
- package/dist/cjs/types.d.ts +19 -4
- package/dist/esm/commands/append.js +11 -4
- package/dist/esm/commands/authenticate.js +28 -19
- package/dist/esm/commands/close.d.ts +12 -1
- package/dist/esm/commands/close.js +5 -3
- package/dist/esm/commands/delete.js +2 -1
- package/dist/esm/commands/esearch-parser.js +8 -2
- package/dist/esm/commands/fetch.js +35 -9
- package/dist/esm/commands/id.js +9 -2
- package/dist/esm/commands/idle.js +16 -6
- package/dist/esm/commands/list.js +10 -1
- package/dist/esm/commands/login.js +6 -2
- package/dist/esm/commands/logout.js +7 -0
- package/dist/esm/commands/namespace.js +7 -3
- package/dist/esm/commands/quota.js +3 -1
- package/dist/esm/commands/rename.js +2 -1
- package/dist/esm/commands/select.js +5 -0
- package/dist/esm/commands/status.js +7 -2
- package/dist/esm/commands/store.d.ts +1 -1
- package/dist/esm/commands/store.js +1 -2
- package/dist/esm/download.js +82 -91
- package/dist/esm/handler/imap-compiler.js +19 -10
- package/dist/esm/handler/limits.d.ts +11 -0
- package/dist/esm/handler/limits.js +14 -0
- package/dist/esm/handler/parser-instance.d.ts +10 -0
- package/dist/esm/handler/parser-instance.js +25 -10
- package/dist/esm/handler/token-parser.js +37 -29
- package/dist/esm/imap-flow.js +235 -89
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/dist/esm/proxy-connection.js +7 -7
- package/dist/esm/search-compiler.js +30 -13
- package/dist/esm/special-use.js +10 -5
- package/dist/esm/tools.d.ts +10 -1
- package/dist/esm/tools.js +29 -3
- package/dist/esm/types.d.ts +19 -4
- package/package.json +1 -1
|
@@ -53,17 +53,12 @@ let toSequenceValue = (value) => [].concat(value).join(',');
|
|
|
53
53
|
let toSearchValue = (value) => UNICODE_PATTERN.test(value) && !value.includes('\0') ? { type: 'LITERAL', value: Buffer.from(value) } : { type: 'ATOM', value };
|
|
54
54
|
/**
|
|
55
55
|
* Adds a search option with its value(s) to the attributes array.
|
|
56
|
-
* Handles NOT operations and array values.
|
|
57
56
|
*
|
|
58
57
|
* @param attributes - Array to append the attribute to
|
|
59
58
|
* @param term - The search term (e.g., 'FROM', 'SUBJECT')
|
|
60
|
-
* @param value - The value for the search term (string
|
|
59
|
+
* @param value - The value for the search term (string or array)
|
|
61
60
|
*/
|
|
62
61
|
let setOpt = (attributes, term, value) => {
|
|
63
|
-
// Handle NOT operations for false or null values
|
|
64
|
-
if (value === false || value === null) {
|
|
65
|
-
attributes.push({ type: 'ATOM', value: 'NOT' });
|
|
66
|
-
}
|
|
67
62
|
attributes.push({ type: 'ATOM', value: term.toUpperCase() });
|
|
68
63
|
// Handle array values (e.g. HEADER name/value pairs)
|
|
69
64
|
if (Array.isArray(value)) {
|
|
@@ -253,6 +248,11 @@ const searchCompiler = (connection, query) => {
|
|
|
253
248
|
break;
|
|
254
249
|
// Email ID support (OBJECTID or Gmail extension)
|
|
255
250
|
case 'EMAILID':
|
|
251
|
+
// A falsy value means no criterion, as for the text fields. It used to
|
|
252
|
+
// compile into NOT EMAILID "false", which matched every message
|
|
253
|
+
if (!params[term]) {
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
256
|
if (connection.capabilities.has('OBJECTID')) {
|
|
257
257
|
setOpt(attributes, 'EMAILID', params[term]);
|
|
258
258
|
}
|
|
@@ -260,7 +260,7 @@ const searchCompiler = (connection, query) => {
|
|
|
260
260
|
// Fallback to Gmail message ID
|
|
261
261
|
setOpt(attributes, 'X-GM-MSGID', params[term]);
|
|
262
262
|
}
|
|
263
|
-
else
|
|
263
|
+
else {
|
|
264
264
|
// Dropping the criterion would widen the search to every message
|
|
265
265
|
// matching the rest of the query, which a delete or move acts on
|
|
266
266
|
fail('MissingServerExtension', 'Server does not support OBJECTID or X-GM-EXT-1 extension required for EMAILID');
|
|
@@ -268,6 +268,11 @@ const searchCompiler = (connection, query) => {
|
|
|
268
268
|
break;
|
|
269
269
|
// Thread ID support (OBJECTID or Gmail extension)
|
|
270
270
|
case 'THREADID':
|
|
271
|
+
// A falsy value means no criterion, as for the text fields. It used to
|
|
272
|
+
// compile into NOT THREADID "false", which matched every message
|
|
273
|
+
if (!params[term]) {
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
271
276
|
if (connection.capabilities.has('OBJECTID')) {
|
|
272
277
|
setOpt(attributes, 'THREADID', params[term]);
|
|
273
278
|
}
|
|
@@ -275,7 +280,7 @@ const searchCompiler = (connection, query) => {
|
|
|
275
280
|
// Fallback to Gmail thread ID
|
|
276
281
|
setOpt(attributes, 'X-GM-THRID', params[term]);
|
|
277
282
|
}
|
|
278
|
-
else
|
|
283
|
+
else {
|
|
279
284
|
// Dropping the criterion would widen the search to every message
|
|
280
285
|
// matching the rest of the query, which a delete or move acts on
|
|
281
286
|
fail('MissingServerExtension', 'Server does not support OBJECTID or X-GM-EXT-1 extension required for THREADID');
|
|
@@ -338,11 +343,11 @@ const searchCompiler = (connection, query) => {
|
|
|
338
343
|
if (!value) {
|
|
339
344
|
break;
|
|
340
345
|
}
|
|
341
|
-
// Use WITHIN extension for better timezone handling if available
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
+
// Use WITHIN extension for better timezone handling if available.
|
|
347
|
+
// The interval is an nz-number (RFC 5032), so a date that is not in the
|
|
348
|
+
// past (OLDER 0 would be answered with BAD) takes the date path instead
|
|
349
|
+
const withinSeconds = Math.round((Date.now() - value.getTime()) / 1000);
|
|
350
|
+
if (connection.capabilities.has('WITHIN') && withinSeconds >= 1) {
|
|
346
351
|
const withinKeyword = term.toUpperCase() === 'BEFORE' ? 'OLDER' : 'YOUNGER';
|
|
347
352
|
setOpt(attributes, withinKeyword, withinSeconds.toString());
|
|
348
353
|
break;
|
|
@@ -362,7 +367,15 @@ const searchCompiler = (connection, query) => {
|
|
|
362
367
|
case 'KEYWORD':
|
|
363
368
|
case 'UNKEYWORD':
|
|
364
369
|
{
|
|
370
|
+
if (typeof params[term] !== 'string') {
|
|
371
|
+
fail('InvalidSearchQuery', `Search value for ${term.toLowerCase()} must be a string`);
|
|
372
|
+
}
|
|
365
373
|
let flag = (0, tools_js_1.formatFlag)(params[term]);
|
|
374
|
+
// formatFlag() refuses \Recent, which is not a keyword. Dropping the
|
|
375
|
+
// criterion would widen the search, so the query is refused instead
|
|
376
|
+
if (flag === false) {
|
|
377
|
+
fail('InvalidSearchQuery', `${params[term]} can not be searched as a keyword, use the "recent" search key instead`);
|
|
378
|
+
}
|
|
366
379
|
// Compiled even when the mailbox does not allow the keyword: the
|
|
367
380
|
// correct answer is then the empty set, which dropping the
|
|
368
381
|
// criterion would turn into every message matching the rest
|
|
@@ -460,6 +473,10 @@ const searchCompiler = (connection, query) => {
|
|
|
460
473
|
walkOrTree(genOrTree(params[term]));
|
|
461
474
|
}
|
|
462
475
|
break;
|
|
476
|
+
default:
|
|
477
|
+
// An unknown key is refused: dropping it silently widened the search (a
|
|
478
|
+
// query of only unknown keys matched every message)
|
|
479
|
+
fail('InvalidSearchQuery', `Unknown search key "${term}"`);
|
|
463
480
|
}
|
|
464
481
|
});
|
|
465
482
|
};
|
package/dist/cjs/special-use.js
CHANGED
|
@@ -77,6 +77,7 @@ const TOKEN_SPLIT = /[\s\-_/.,()[\]]+/;
|
|
|
77
77
|
// we fall back to matching folder names against these lists of known
|
|
78
78
|
// translations in various languages (including non-Latin scripts).
|
|
79
79
|
exports.flags = ['\\All', '\\Archive', '\\Drafts', '\\Flagged', '\\Junk', '\\Sent', '\\Trash'];
|
|
80
|
+
const LOWERCASE_FLAGS = exports.flags.map(flag => flag.toLowerCase());
|
|
80
81
|
exports.names = {
|
|
81
82
|
'\\Sent': [
|
|
82
83
|
'aika',
|
|
@@ -863,9 +864,10 @@ for (let flag of Object.keys(exports.names)) {
|
|
|
863
864
|
}
|
|
864
865
|
}
|
|
865
866
|
// Fold a folder name into the form the name tables are stored in.
|
|
866
|
-
// Remove U+200E (LEFT-TO-RIGHT MARK) which some mail clients
|
|
867
|
-
// RTL languages like Arabic, Hebrew) insert into folder names for display
|
|
868
|
-
// These invisible marks would otherwise prevent exact string matching.
|
|
867
|
+
// Remove U+200E and U+200F (LEFT-TO-RIGHT and RIGHT-TO-LEFT MARK) which some mail clients
|
|
868
|
+
// (especially for RTL languages like Arabic, Hebrew) insert into folder names for display
|
|
869
|
+
// purposes. These invisible marks would otherwise prevent exact string matching. U+200B
|
|
870
|
+
// (ZERO WIDTH SPACE) is kept, Khmer names in the tables use it as their word separator.
|
|
869
871
|
// Normalize with NFKC last: the same folder name can arrive in different but
|
|
870
872
|
// equivalent forms depending on the client that created it, and matching is exact.
|
|
871
873
|
// NFKC rather than NFC because the compatibility folding is what maps halfwidth
|
|
@@ -874,7 +876,7 @@ for (let flag of Object.keys(exports.names)) {
|
|
|
874
876
|
function normalizeName(name) {
|
|
875
877
|
return name
|
|
876
878
|
.toLowerCase()
|
|
877
|
-
.replace(
|
|
879
|
+
.replace(/[\u200e\u200f]/g, '')
|
|
878
880
|
.trim()
|
|
879
881
|
.normalize('NFKC');
|
|
880
882
|
}
|
|
@@ -883,7 +885,10 @@ const specialUse = (hasSpecialUseExtension, folder) => {
|
|
|
883
885
|
// Extension-provided flags take precedence over name-based detection because they
|
|
884
886
|
// are authoritative - the server explicitly marks the folder's role.
|
|
885
887
|
if (hasSpecialUseExtension) {
|
|
886
|
-
|
|
888
|
+
// Flags are atoms and compare case-insensitively (RFC 9051 section 9)
|
|
889
|
+
const listed = new Set();
|
|
890
|
+
folder.flags.forEach(flag => listed.add(flag.toLowerCase()));
|
|
891
|
+
const flag = exports.flags.find((flag, i) => listed.has(LOWERCASE_FLAGS[i]));
|
|
887
892
|
if (flag) {
|
|
888
893
|
return { flag, source: 'extension' };
|
|
889
894
|
}
|
package/dist/cjs/tools.d.ts
CHANGED
|
@@ -119,6 +119,14 @@ export declare function unrefTimer<T extends NodeJS.Timeout | null | undefined>(
|
|
|
119
119
|
* @param err - The error to log
|
|
120
120
|
*/
|
|
121
121
|
export declare function logConnectionError(connection: ImapFlow, msg: string, err: ImapFlowError | null | undefined): void;
|
|
122
|
+
/**
|
|
123
|
+
* Whether a command failed because the server answered it with a tagged NO or BAD, as opposed
|
|
124
|
+
* to a lost connection, a timeout or a local failure that says nothing about the server's answer.
|
|
125
|
+
*
|
|
126
|
+
* @param err - The error the command failed with
|
|
127
|
+
* @returns True for a tagged NO or BAD
|
|
128
|
+
*/
|
|
129
|
+
export declare function isServerRefusal(err: ImapFlowError | null | undefined): boolean;
|
|
122
130
|
/**
|
|
123
131
|
* Checks whether IMAP4rev2 semantics are active for the connection: either the
|
|
124
132
|
* client enabled IMAP4rev2 explicitly, or the server is rev2-only (advertises
|
|
@@ -407,7 +415,8 @@ export declare function isUnsafeKey(key: unknown): boolean;
|
|
|
407
415
|
/**
|
|
408
416
|
* Reads a parsed attribute list of atoms or strings (a flag list, a capability list) into
|
|
409
417
|
* an array of strings. Any element can be a parsed NIL, and the list itself can be NIL,
|
|
410
|
-
* so both levels are guarded here rather than at each call site.
|
|
418
|
+
* so both levels are guarded here rather than at each call site. A literal (a Gmail label
|
|
419
|
+
* the server could not send quoted) arrives as a Buffer and is decoded as UTF-8.
|
|
411
420
|
*
|
|
412
421
|
* @param list - Parsed attribute list from a response.
|
|
413
422
|
* @returns The string values, in order, with unusable entries dropped.
|
package/dist/cjs/tools.js
CHANGED
|
@@ -12,6 +12,7 @@ exports.guardedReject = guardedReject;
|
|
|
12
12
|
exports.clearTimer = clearTimer;
|
|
13
13
|
exports.unrefTimer = unrefTimer;
|
|
14
14
|
exports.logConnectionError = logConnectionError;
|
|
15
|
+
exports.isServerRefusal = isServerRefusal;
|
|
15
16
|
exports.isRev2Active = isRev2Active;
|
|
16
17
|
exports.hasCapability = hasCapability;
|
|
17
18
|
exports.buildStatusQueryAttributes = buildStatusQueryAttributes;
|
|
@@ -266,6 +267,16 @@ function logConnectionError(connection, msg, err) {
|
|
|
266
267
|
let routine = !!err && CONNECTION_GONE_CODES.has(err.code) && !err.reason;
|
|
267
268
|
connection.log[routine ? 'debug' : 'warn']({ msg, err, cid: connection.id });
|
|
268
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Whether a command failed because the server answered it with a tagged NO or BAD, as opposed
|
|
272
|
+
* to a lost connection, a timeout or a local failure that says nothing about the server's answer.
|
|
273
|
+
*
|
|
274
|
+
* @param err - The error the command failed with
|
|
275
|
+
* @returns True for a tagged NO or BAD
|
|
276
|
+
*/
|
|
277
|
+
function isServerRefusal(err) {
|
|
278
|
+
return !!err && (err.responseStatus === 'NO' || err.responseStatus === 'BAD');
|
|
279
|
+
}
|
|
269
280
|
/**
|
|
270
281
|
* Checks whether IMAP4rev2 semantics are active for the connection: either the
|
|
271
282
|
* client enabled IMAP4rev2 explicitly, or the server is rev2-only (advertises
|
|
@@ -593,7 +604,12 @@ function getFolderTree(folders) {
|
|
|
593
604
|
}
|
|
594
605
|
return node;
|
|
595
606
|
};
|
|
596
|
-
|
|
607
|
+
// Parents are inserted before their children, as getTreeNode() can only descend into nodes
|
|
608
|
+
// that already exist. LIST gives no ordering guarantee, and the LIST command sorts special-use
|
|
609
|
+
// mailboxes first, which put a child such as "[Gmail]/Sent Mail" ahead of "[Gmail]" and left
|
|
610
|
+
// it at the root. The sort is stable, so siblings keep their listing order
|
|
611
|
+
let byDepth = [...folders].sort((a, b) => (a.parent ? a.parent.length : 0) - (b.parent ? b.parent.length : 0));
|
|
612
|
+
for (let folder of byDepth) {
|
|
597
613
|
let parent = getTreeNode(folder.parent);
|
|
598
614
|
// see if entry already exists
|
|
599
615
|
let existing = parent.folders && parent.folders.find(existing => existing.name === folder.name);
|
|
@@ -1412,7 +1428,8 @@ function isUnsafeKey(key) {
|
|
|
1412
1428
|
/**
|
|
1413
1429
|
* Reads a parsed attribute list of atoms or strings (a flag list, a capability list) into
|
|
1414
1430
|
* an array of strings. Any element can be a parsed NIL, and the list itself can be NIL,
|
|
1415
|
-
* so both levels are guarded here rather than at each call site.
|
|
1431
|
+
* so both levels are guarded here rather than at each call site. A literal (a Gmail label
|
|
1432
|
+
* the server could not send quoted) arrives as a Buffer and is decoded as UTF-8.
|
|
1416
1433
|
*
|
|
1417
1434
|
* @param list - Parsed attribute list from a response.
|
|
1418
1435
|
* @returns The string values, in order, with unusable entries dropped.
|
|
@@ -1421,7 +1438,17 @@ function getStringList(list) {
|
|
|
1421
1438
|
if (!Array.isArray(list)) {
|
|
1422
1439
|
return [];
|
|
1423
1440
|
}
|
|
1424
|
-
|
|
1441
|
+
let strings = [];
|
|
1442
|
+
for (let entry of list) {
|
|
1443
|
+
let value = entry && entry.value;
|
|
1444
|
+
if (Buffer.isBuffer(value)) {
|
|
1445
|
+
value = value.toString();
|
|
1446
|
+
}
|
|
1447
|
+
if (value && typeof value === 'string') {
|
|
1448
|
+
strings.push(value);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
return strings;
|
|
1425
1452
|
}
|
|
1426
1453
|
/**
|
|
1427
1454
|
* Parses an untrusted decimal value from a server response into a BigInt.
|
package/dist/cjs/types.d.ts
CHANGED
|
@@ -155,7 +155,15 @@ export interface ImapFlowOptions {
|
|
|
155
155
|
* 30 minutes. Set to 0 or false to disable.
|
|
156
156
|
*/
|
|
157
157
|
maxLockHoldTime?: number | false | undefined;
|
|
158
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* STARTTLS policy for a connection that was not opened with `secure: true`. If true, the
|
|
160
|
+
* connection must upgrade to TLS, and connecting fails when the server does not offer
|
|
161
|
+
* STARTTLS. If false, the connection stays in cleartext. If not set, the connection upgrades
|
|
162
|
+
* when the server offers STARTTLS and otherwise continues in cleartext with a warning logged:
|
|
163
|
+
* the capability list that decides this was itself received in cleartext, so an attacker on
|
|
164
|
+
* the path can strip it. Set `doSTARTTLS: true` whenever the credentials must never cross the
|
|
165
|
+
* wire unencrypted.
|
|
166
|
+
*/
|
|
159
167
|
doSTARTTLS?: boolean | undefined;
|
|
160
168
|
/** Custom instance ID string for logs */
|
|
161
169
|
id?: string | undefined;
|
|
@@ -646,8 +654,8 @@ export interface DownloadOptions {
|
|
|
646
654
|
/** How large content parts to ask from the server. Defaults to 65536 */
|
|
647
655
|
chunkSize?: number | undefined;
|
|
648
656
|
}
|
|
649
|
-
/** Options for downloadMany()
|
|
650
|
-
export type DownloadManyOptions = DownloadOptions
|
|
657
|
+
/** Options for downloadMany(): the download() options without `chunkSize`, as the parts come in one FETCH */
|
|
658
|
+
export type DownloadManyOptions = Pick<DownloadOptions, 'uid' | 'maxBytes'>;
|
|
651
659
|
export interface DownloadManyPart {
|
|
652
660
|
meta: DownloadMeta;
|
|
653
661
|
content?: Buffer | null | undefined;
|
|
@@ -769,6 +777,13 @@ export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
|
|
769
777
|
*/
|
|
770
778
|
export type InternalLogger = {
|
|
771
779
|
[level in LogLevel]: (obj: any) => void;
|
|
780
|
+
} & {
|
|
781
|
+
/**
|
|
782
|
+
* Whether an entry logged at this level reaches anyone (the logger, or a 'log' event
|
|
783
|
+
* listener). Set on the logger the connection builds; a logger assigned from outside may
|
|
784
|
+
* lack it, and every level then counts as enabled
|
|
785
|
+
*/
|
|
786
|
+
isLevelEnabled?: ((level: LogLevel) => boolean) | undefined;
|
|
772
787
|
};
|
|
773
788
|
export interface LogEvent {
|
|
774
789
|
/** Log level */
|
|
@@ -802,7 +817,7 @@ export interface ESearchResult {
|
|
|
802
817
|
partial?: {
|
|
803
818
|
/** The requested range, e.g. "1:100" */
|
|
804
819
|
range: string;
|
|
805
|
-
/** Matching UIDs in that range as compact sequence-set */
|
|
820
|
+
/** Matching UIDs in that range as compact sequence-set, an empty string when the range lies past the end of the results */
|
|
806
821
|
messages: string;
|
|
807
822
|
} | undefined;
|
|
808
823
|
/** Highest mod-sequence of the matching messages (RFC 7162, present when the search used a modseq criterion on a CONDSTORE session) */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatFlag, canUseFlag, formatDateTime, normalizePath, encodePath, comparePaths, parseBigIntValue, parseUintValue, MAX_UINT32_DIGITS, getSelectedMailbox, emitSafe, isAuthenticatedState, reportCommandError } from '../tools.js';
|
|
1
|
+
import { formatFlag, canUseFlag, formatDateTime, normalizePath, encodePath, comparePaths, parseBigIntValue, parseUintValue, MAX_UINT32_DIGITS, getSelectedMailbox, emitSafe, isAuthenticatedState, reportCommandError, logConnectionError } from '../tools.js';
|
|
2
2
|
/**
|
|
3
3
|
* Appends a message to a mailbox.
|
|
4
4
|
*
|
|
@@ -155,10 +155,17 @@ export default async function append(connection, destination, content, flags, id
|
|
|
155
155
|
}
|
|
156
156
|
// If we have a sequence number but no UID (server doesn't support UIDPLUS),
|
|
157
157
|
// look up the UID via SEARCH to provide a consistent result to the caller.
|
|
158
|
+
// The message is already stored, so a failed lookup only leaves the UID out:
|
|
159
|
+
// rejecting here would make a retrying caller append it twice.
|
|
158
160
|
if (map.seq && !map.uid) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
try {
|
|
162
|
+
let list = await connection.search({ seq: map.seq }, { uid: true });
|
|
163
|
+
if (Array.isArray(list) && list.length) {
|
|
164
|
+
map.uid = list[0];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
logConnectionError(connection, 'Failed to look up the UID of the appended message', err);
|
|
162
169
|
}
|
|
163
170
|
}
|
|
164
171
|
return map;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getStatusCode, getErrorText } from '../tools.js';
|
|
1
|
+
import { getStatusCode, getErrorText, isServerRefusal } from '../tools.js';
|
|
2
2
|
/**
|
|
3
3
|
* Handles authentication errors by enriching the error object with server response details.
|
|
4
4
|
*
|
|
@@ -11,7 +11,10 @@ async function handleAuthError(err, errorResponse) {
|
|
|
11
11
|
if (errorCode) {
|
|
12
12
|
err.serverResponseCode = errorCode;
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
// Only a tagged NO/BAD is the server refusing the credentials, see login.ts
|
|
15
|
+
if (isServerRefusal(err)) {
|
|
16
|
+
err.authenticationFailed = true;
|
|
17
|
+
}
|
|
15
18
|
err.response = await getErrorText(err.response);
|
|
16
19
|
if (errorResponse) {
|
|
17
20
|
err.oauthError = errorResponse;
|
|
@@ -108,27 +111,33 @@ async function authLogin(connection, username, password) {
|
|
|
108
111
|
try {
|
|
109
112
|
// SASL LOGIN is a challenge-response mechanism: the server sends base64-encoded
|
|
110
113
|
// prompts ("Username:" and "Password:") and the client responds with base64-encoded values.
|
|
114
|
+
let usernameSent = false;
|
|
111
115
|
let response = await connection.exec('AUTHENTICATE', [{ type: 'ATOM', value: 'LOGIN' }], {
|
|
112
116
|
onPlusTag: async (resp) => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
// Decode the server's base64 challenge to determine what it's asking for.
|
|
118
|
+
// Strip trailing colons and null bytes (\x00) that some servers append to the prompt.
|
|
119
|
+
let question = resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT'
|
|
120
|
+
? Buffer.from(resp.attributes[0].value, 'base64')
|
|
117
121
|
.toString()
|
|
118
122
|
.toLowerCase()
|
|
119
|
-
.replace(/[:\x00]*$/, '')
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
123
|
+
.replace(/[:\x00]*$/, '')
|
|
124
|
+
: '';
|
|
125
|
+
// Some servers send an empty first challenge, which by SASL LOGIN convention asks for the username
|
|
126
|
+
if (question === 'username' || question === 'user name' || (!question && !usernameSent)) {
|
|
127
|
+
let encodedUsername = Buffer.from(username).toString('base64');
|
|
128
|
+
connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN`, cid: connection.id });
|
|
129
|
+
connection.write(encodedUsername);
|
|
130
|
+
usernameSent = true;
|
|
131
|
+
}
|
|
132
|
+
else if (question === 'password') {
|
|
133
|
+
connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN`, cid: connection.id });
|
|
134
|
+
connection.write(Buffer.from(password).toString('base64'));
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
// Cancel the exchange (RFC 9051 section 6.2.2), so the server fails the command
|
|
138
|
+
// with a tagged BAD instead of waiting for an answer that never comes
|
|
139
|
+
connection.log.warn({ msg: 'Unknown AUTH=LOGIN challenge, cancelling', question, cid: connection.id });
|
|
140
|
+
connection.write('*');
|
|
132
141
|
}
|
|
133
142
|
}
|
|
134
143
|
});
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import type { ImapFlow } from '../imap-flow.js';
|
|
2
|
+
/**
|
|
3
|
+
* Options for the CLOSE command
|
|
4
|
+
*/
|
|
5
|
+
export interface CloseCommandOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Only deselect the mailbox: use UNSELECT (RFC 3691, folded into IMAP4rev2) when the server
|
|
8
|
+
* supports it, so messages flagged \Deleted are not expunged as a side effect. Falls back to CLOSE
|
|
9
|
+
*/
|
|
10
|
+
unselect?: boolean | undefined;
|
|
11
|
+
}
|
|
2
12
|
/**
|
|
3
13
|
* Closes the currently selected mailbox.
|
|
4
14
|
*
|
|
5
15
|
* @param connection - IMAP connection instance
|
|
16
|
+
* @param options - Close options
|
|
6
17
|
* @returns True on success, false on failure, or undefined if not in SELECTED state
|
|
7
18
|
*/
|
|
8
|
-
export default function close(connection: ImapFlow): Promise<boolean | undefined>;
|
|
19
|
+
export default function close(connection: ImapFlow, options?: CloseCommandOptions | undefined): Promise<boolean | undefined>;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { emitSafe } from '../tools.js';
|
|
1
|
+
import { emitSafe, hasCapability } from '../tools.js';
|
|
2
2
|
/**
|
|
3
3
|
* Closes the currently selected mailbox.
|
|
4
4
|
*
|
|
5
5
|
* @param connection - IMAP connection instance
|
|
6
|
+
* @param options - Close options
|
|
6
7
|
* @returns True on success, false on failure, or undefined if not in SELECTED state
|
|
7
8
|
*/
|
|
8
|
-
export default async function close(connection) {
|
|
9
|
+
export default async function close(connection, options) {
|
|
9
10
|
if (connection.state !== connection.states.SELECTED) {
|
|
10
11
|
// nothing to do here
|
|
11
12
|
return;
|
|
@@ -15,7 +16,8 @@ export default async function close(connection) {
|
|
|
15
16
|
// IMAP CLOSE (RFC 3501 6.4.2): permanently removes all messages flagged \Deleted
|
|
16
17
|
// from the currently selected mailbox (implicit expunge) and deselects it.
|
|
17
18
|
// Unlike EXPUNGE, CLOSE does not send individual untagged EXPUNGE responses.
|
|
18
|
-
|
|
19
|
+
// UNSELECT deselects the same way without removing anything.
|
|
20
|
+
response = await connection.exec(options?.unselect && hasCapability(connection, 'UNSELECT') ? 'UNSELECT' : 'CLOSE');
|
|
19
21
|
response.next();
|
|
20
22
|
// Transition from SELECTED back to AUTHENTICATED state.
|
|
21
23
|
// Clear mailbox metadata so subsequent operations know no mailbox is selected.
|
|
@@ -17,7 +17,8 @@ export default async function deleteMailbox(connection, path) {
|
|
|
17
17
|
// IMAP servers reject DELETE on the currently selected mailbox (RFC 3501 6.3.4).
|
|
18
18
|
let selected = getSelectedMailbox(connection);
|
|
19
19
|
if (selected && selected.path === path) {
|
|
20
|
-
|
|
20
|
+
// UNSELECT where possible, CLOSE would expunge messages flagged \Deleted
|
|
21
|
+
await connection.run('CLOSE', { unselect: true });
|
|
21
22
|
}
|
|
22
23
|
let response;
|
|
23
24
|
try {
|
|
@@ -69,9 +69,15 @@ export function parseEsearchResponse(attrs) {
|
|
|
69
69
|
const items = Array.isArray(listToken) ? listToken : null;
|
|
70
70
|
if (!items || items.length < 2)
|
|
71
71
|
break;
|
|
72
|
+
const range = items[0]?.value;
|
|
73
|
+
if (typeof range !== 'string')
|
|
74
|
+
break;
|
|
75
|
+
// RFC 9394 partial-results is a sequence-set or NIL, the latter when the requested
|
|
76
|
+
// range lies past the end of the results. NIL is reported as an empty set.
|
|
77
|
+
const messages = items[1]?.value;
|
|
72
78
|
result.partial = {
|
|
73
|
-
range
|
|
74
|
-
messages:
|
|
79
|
+
range,
|
|
80
|
+
messages: typeof messages === 'string' ? messages : ''
|
|
75
81
|
};
|
|
76
82
|
break;
|
|
77
83
|
}
|
|
@@ -25,11 +25,20 @@ export default async function fetch(connection, range, query, options) {
|
|
|
25
25
|
// Every pass returns or throws: the last throttled attempt throws instead of retrying.
|
|
26
26
|
const maxRetries = 4;
|
|
27
27
|
const baseDelay = 1000; // Start with 1 second delay
|
|
28
|
+
// The highest UID (sequence number for a plain FETCH) handed to the streaming consumer. A
|
|
29
|
+
// retried FETCH answers with every message again, so a retry skips up to it: servers answer
|
|
30
|
+
// in ascending order, which keeps this to one comparison per row rather than a set of every
|
|
31
|
+
// row delivered. The consumer was otherwise given the rows before the throttle twice.
|
|
32
|
+
let maxDelivered = 0;
|
|
28
33
|
for (let retryCount = 0;; retryCount++) {
|
|
29
34
|
let messages = {
|
|
30
35
|
count: 0,
|
|
31
36
|
list: []
|
|
32
37
|
};
|
|
38
|
+
// The first error the onUntaggedFetch consumer reported through next(err). Errors thrown
|
|
39
|
+
// by untagged handlers are only logged by the connection, so it is kept here and fails
|
|
40
|
+
// the command once the FETCH completes; later messages are no longer handed to the consumer.
|
|
41
|
+
let consumerError = null;
|
|
33
42
|
let response;
|
|
34
43
|
try {
|
|
35
44
|
/* c8 ignore next */ // range is guaranteed truthy by the early-return guard above, so the '*' fallback is unreachable
|
|
@@ -179,18 +188,32 @@ export default async function fetch(connection, range, query, options) {
|
|
|
179
188
|
// (useful for large result sets). Otherwise, collect all into messages.list.
|
|
180
189
|
FETCH: async (untagged) => {
|
|
181
190
|
messages.count++;
|
|
191
|
+
if (consumerError) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
182
194
|
let formatted = await formatMessageResponse(untagged, mailbox, connection.idHashAlgorithm);
|
|
183
195
|
if (typeof options.onUntaggedFetch === 'function') {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
196
|
+
/* c8 ignore next */ // a UID FETCH row without its UID is a non-compliant server, so the seq fallback is not exercised
|
|
197
|
+
let key = options.uid ? formatted.uid || formatted.seq : formatted.seq;
|
|
198
|
+
if (retryCount && key <= maxDelivered) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
maxDelivered = Math.max(maxDelivered, key);
|
|
202
|
+
try {
|
|
203
|
+
await new Promise((resolve, reject) => {
|
|
204
|
+
options.onUntaggedFetch(formatted, err => {
|
|
205
|
+
if (err) {
|
|
206
|
+
reject(err);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
resolve();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
192
212
|
});
|
|
193
|
-
}
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
consumerError = err;
|
|
216
|
+
}
|
|
194
217
|
}
|
|
195
218
|
else {
|
|
196
219
|
messages.list.push(formatted);
|
|
@@ -199,6 +222,9 @@ export default async function fetch(connection, range, query, options) {
|
|
|
199
222
|
}
|
|
200
223
|
});
|
|
201
224
|
response.next();
|
|
225
|
+
if (consumerError) {
|
|
226
|
+
throw consumerError;
|
|
227
|
+
}
|
|
202
228
|
return messages;
|
|
203
229
|
}
|
|
204
230
|
catch (err) {
|
package/dist/esm/commands/id.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatDateTime } from '../tools.js';
|
|
1
|
+
import { formatDateTime, isUnsafeKey } from '../tools.js';
|
|
2
2
|
/**
|
|
3
3
|
* Sends ID info to the server and updates server info data based on the response.
|
|
4
4
|
*
|
|
@@ -40,7 +40,14 @@ export default async function id(connection, clientInfo) {
|
|
|
40
40
|
key = val.value;
|
|
41
41
|
}
|
|
42
42
|
else if (typeof key === 'string' && typeof val.value === 'string') {
|
|
43
|
-
|
|
43
|
+
// The server picks the keys of this object, which the caller reads
|
|
44
|
+
// back as serverInfo: a prototype-chain name is skipped as it is for
|
|
45
|
+
// every other server-named key, so it can neither be shadowed nor
|
|
46
|
+
// written through
|
|
47
|
+
let name = key.toLowerCase().trim();
|
|
48
|
+
if (!isUnsafeKey(name)) {
|
|
49
|
+
map[name] = val.value;
|
|
50
|
+
}
|
|
44
51
|
}
|
|
45
52
|
});
|
|
46
53
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { guardedPromise, hasCapability, logConnectionError, restampConnectionError, unrefTimer, clearTimer, getSelectedMailbox } from '../tools.js';
|
|
1
|
+
import { guardedPromise, hasCapability, isServerRefusal, logConnectionError, restampConnectionError, unrefTimer, clearTimer, getSelectedMailbox } from '../tools.js';
|
|
2
2
|
const NOOP_INTERVAL = 2 * 60 * 1000;
|
|
3
3
|
/**
|
|
4
4
|
* Marks the connection as idling on behalf of one session and returns a release function.
|
|
@@ -57,8 +57,11 @@ async function runIdle(connection) {
|
|
|
57
57
|
path: connection.mailbox && connection.mailbox.path,
|
|
58
58
|
cid: connection.id
|
|
59
59
|
});
|
|
60
|
-
|
|
60
|
+
// Marked before the write: write() closes the connection when the transport is
|
|
61
|
+
// already gone, and close() breaks IDLE through this very function, which
|
|
62
|
+
// would otherwise write DONE again from inside itself
|
|
61
63
|
doneSent = true;
|
|
64
|
+
connection.write('DONE');
|
|
62
65
|
releaseIdling();
|
|
63
66
|
if (connection.preCheck === ownPreCheck) {
|
|
64
67
|
connection.preCheck = false; // unset itself
|
|
@@ -124,7 +127,10 @@ async function runIdle(connection) {
|
|
|
124
127
|
// A tagged NO or BAD only means the server refused IDLE; the connection is still usable,
|
|
125
128
|
// so the waiters are released by the finally block below and their own commands run.
|
|
126
129
|
// Anything else (close, lost socket, parser failure) fails the waiters too.
|
|
127
|
-
let refusedByServer =
|
|
130
|
+
let refusedByServer = isServerRefusal(err);
|
|
131
|
+
if (refusedByServer) {
|
|
132
|
+
connection.skipIdle = true;
|
|
133
|
+
}
|
|
128
134
|
if (preCheckWaitQueue.length && !refusedByServer) {
|
|
129
135
|
// One error for the whole queue: every waiter failed at the same site, for the same
|
|
130
136
|
// reason. Built inside the guard so a teardown with nothing queued - the common case -
|
|
@@ -310,7 +316,7 @@ export default async function idle(connection, maxIdleTime) {
|
|
|
310
316
|
// If server supports IDLE (RFC 2177, folded into base IMAP4rev2), use it for
|
|
311
317
|
// real-time push notifications. Otherwise, fall back to periodic polling with
|
|
312
318
|
// NOOP/STATUS/SELECT.
|
|
313
|
-
if (hasCapability(connection, 'IDLE')) {
|
|
319
|
+
if (hasCapability(connection, 'IDLE') && !connection.skipIdle) {
|
|
314
320
|
let idleTimer;
|
|
315
321
|
let stillIdling = false;
|
|
316
322
|
// IDLE loop: runs IDLE, and if maxIdleTime is reached, breaks and restarts to keep the
|
|
@@ -334,13 +340,17 @@ export default async function idle(connection, maxIdleTime) {
|
|
|
334
340
|
}
|
|
335
341
|
let resp = await runIdle(connection);
|
|
336
342
|
clearTimeout(idleTimer);
|
|
337
|
-
|
|
343
|
+
// A restart only makes sense with nothing queued behind the break (a CLOSE, say; run()
|
|
344
|
+
// re-arms auto-IDLE once that command is done) and the mailbox still selected on a
|
|
345
|
+
// usable connection
|
|
346
|
+
const canRestart = stillIdling && !connection.requestQueue.length && !!getSelectedMailbox(connection) && connection.usable;
|
|
347
|
+
if (!canRestart) {
|
|
338
348
|
return resp;
|
|
339
349
|
}
|
|
340
350
|
stillIdling = false;
|
|
341
351
|
}
|
|
342
352
|
}
|
|
343
|
-
// Fallback for servers without IDLE support: poll at regular intervals using
|
|
353
|
+
// Fallback for servers without IDLE support, or that refused it: poll at regular intervals using
|
|
344
354
|
// NOOP (default), STATUS, or SELECT depending on missingIdleCommand config.
|
|
345
355
|
return runPollingFallback(connection, maxIdleTime);
|
|
346
356
|
}
|