imapflow 2.2.6 → 2.2.8
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 +24 -0
- package/dist/cjs/commands/append.js +10 -3
- package/dist/cjs/commands/authenticate.js +42 -22
- 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/enable.js +6 -0
- package/dist/cjs/commands/esearch-parser.js +8 -2
- package/dist/cjs/commands/fetch.js +57 -14
- 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 +8 -8
- package/dist/cjs/download.js +220 -95
- 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.d.ts +2 -2
- package/dist/cjs/imap-flow.js +256 -95
- 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 +33 -13
- package/dist/cjs/special-use.js +10 -5
- package/dist/cjs/tools.d.ts +14 -4
- package/dist/cjs/tools.js +69 -9
- package/dist/cjs/types.d.ts +19 -4
- package/dist/esm/commands/append.js +11 -4
- package/dist/esm/commands/authenticate.js +43 -23
- 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/enable.js +6 -0
- package/dist/esm/commands/esearch-parser.js +8 -2
- package/dist/esm/commands/fetch.js +57 -14
- 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 +9 -9
- package/dist/esm/download.js +220 -95
- 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.d.ts +2 -2
- package/dist/esm/imap-flow.js +256 -95
- 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 +33 -13
- package/dist/esm/special-use.js +10 -5
- package/dist/esm/tools.d.ts +14 -4
- package/dist/esm/tools.js +68 -9
- package/dist/esm/types.d.ts +19 -4
- package/package.json +5 -3
|
@@ -241,11 +241,11 @@ const httpConnect = async ({ logger, proxyUrl, secureProxy, proxyHost, proxyPort
|
|
|
241
241
|
* @returns An IPv4 address.
|
|
242
242
|
*/
|
|
243
243
|
const resolveIPv4 = async (hostname, deadline) => {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
return
|
|
244
|
+
// lookup() goes through the system resolver (getaddrinfo), so hosts files and mDNS names
|
|
245
|
+
// resolve the same way they would for a direct connection. It rejects (ENOTFOUND) rather
|
|
246
|
+
// than returning nothing when the name has no IPv4 address
|
|
247
|
+
let { address } = await deadline.race(node_dns_1.default.promises.lookup(hostname, { family: 4 }));
|
|
248
|
+
return address;
|
|
249
249
|
};
|
|
250
250
|
/**
|
|
251
251
|
* Establishes a tunnel through a SOCKS proxy.
|
|
@@ -289,8 +289,8 @@ const socksConnect = async ({ logger, proxyUrl, protocol, proxyHost, proxyPort,
|
|
|
289
289
|
set_tcp_nodelay: true
|
|
290
290
|
};
|
|
291
291
|
if (proxyUrl.username || proxyUrl.password) {
|
|
292
|
-
connectionOpts.proxy.userId = proxyUrl.username;
|
|
293
|
-
connectionOpts.proxy.password = proxyUrl.password;
|
|
292
|
+
connectionOpts.proxy.userId = decodeUserInfo(proxyUrl.username);
|
|
293
|
+
connectionOpts.proxy.password = decodeUserInfo(proxyUrl.password);
|
|
294
294
|
}
|
|
295
295
|
// The dependency treats a zero timeout as its own 30 second default, so only a strictly
|
|
296
296
|
// positive remaining budget may be passed.
|
|
@@ -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,18 @@ 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, and values that are
|
|
375
|
+
// not atoms. Dropping the criterion would widen the search, so the query is
|
|
376
|
+
// refused instead
|
|
377
|
+
if (flag === false) {
|
|
378
|
+
fail('InvalidSearchQuery', /^\\recent$/i.test(params[term])
|
|
379
|
+
? `${params[term]} can not be searched as a keyword, use the "recent" search key instead`
|
|
380
|
+
: `${params[term]} is not a valid keyword`);
|
|
381
|
+
}
|
|
366
382
|
// Compiled even when the mailbox does not allow the keyword: the
|
|
367
383
|
// correct answer is then the empty set, which dropping the
|
|
368
384
|
// criterion would turn into every message matching the rest
|
|
@@ -460,6 +476,10 @@ const searchCompiler = (connection, query) => {
|
|
|
460
476
|
walkOrTree(genOrTree(params[term]));
|
|
461
477
|
}
|
|
462
478
|
break;
|
|
479
|
+
default:
|
|
480
|
+
// An unknown key is refused: dropping it silently widened the search (a
|
|
481
|
+
// query of only unknown keys matched every message)
|
|
482
|
+
fail('InvalidSearchQuery', `Unknown search key "${term}"`);
|
|
463
483
|
}
|
|
464
484
|
});
|
|
465
485
|
};
|
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
|
|
@@ -285,9 +293,10 @@ export declare function getColorFlags(color: string | null | undefined): {
|
|
|
285
293
|
* @param untagged - Parsed untagged IMAP response
|
|
286
294
|
* @param mailbox - Current mailbox state object
|
|
287
295
|
* @param idHashAlgorithm - Hash for the fallback message id, `md5` unless the client was told otherwise
|
|
296
|
+
* @param connection - Connection the response arrived on, decodes Gmail labels like mailbox names
|
|
288
297
|
* @returns Formatted message object with properties like seq, uid, flags, envelope, etc.
|
|
289
298
|
*/
|
|
290
|
-
export declare function formatMessageResponse(untagged: ImapResponse, mailbox: MailboxObject, idHashAlgorithm?: string): Promise<FetchMessageObject>;
|
|
299
|
+
export declare function formatMessageResponse(untagged: ImapResponse, mailbox: MailboxObject, idHashAlgorithm?: string, connection?: ImapFlow): Promise<FetchMessageObject>;
|
|
291
300
|
/**
|
|
292
301
|
* Strips surrounding double quotes from a name string.
|
|
293
302
|
*
|
|
@@ -356,8 +365,8 @@ export declare function formatDate(value: unknown): string | undefined;
|
|
|
356
365
|
*/
|
|
357
366
|
export declare function formatDateTime(value: unknown): string | undefined;
|
|
358
367
|
/**
|
|
359
|
-
* Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent)
|
|
360
|
-
* and capitalizes system flags properly.
|
|
368
|
+
* Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent) and for
|
|
369
|
+
* values that are not valid flags (keywords must be atoms), and capitalizes system flags properly.
|
|
361
370
|
*
|
|
362
371
|
* @param flag - Flag string to normalize
|
|
363
372
|
* @returns Normalized flag string, or false if the flag cannot be set
|
|
@@ -407,7 +416,8 @@ export declare function isUnsafeKey(key: unknown): boolean;
|
|
|
407
416
|
/**
|
|
408
417
|
* Reads a parsed attribute list of atoms or strings (a flag list, a capability list) into
|
|
409
418
|
* 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.
|
|
419
|
+
* so both levels are guarded here rather than at each call site. A literal (a Gmail label
|
|
420
|
+
* the server could not send quoted) arrives as a Buffer and is decoded as UTF-8.
|
|
411
421
|
*
|
|
412
422
|
* @param list - Parsed attribute list from a response.
|
|
413
423
|
* @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;
|
|
@@ -55,6 +56,7 @@ exports.packMessageRange = packMessageRange;
|
|
|
55
56
|
const libmime_1 = __importDefault(require("libmime"));
|
|
56
57
|
const charsets_js_1 = require("./charsets.js");
|
|
57
58
|
const imap_handler_js_1 = require("./handler/imap-handler.js");
|
|
59
|
+
const imap_formal_syntax_js_1 = __importDefault(require("./handler/imap-formal-syntax.js"));
|
|
58
60
|
const node_crypto_1 = require("node:crypto");
|
|
59
61
|
const jp_decoder_js_1 = require("./jp-decoder.js");
|
|
60
62
|
const iconv_lite_1 = __importDefault(require("iconv-lite"));
|
|
@@ -266,6 +268,16 @@ function logConnectionError(connection, msg, err) {
|
|
|
266
268
|
let routine = !!err && CONNECTION_GONE_CODES.has(err.code) && !err.reason;
|
|
267
269
|
connection.log[routine ? 'debug' : 'warn']({ msg, err, cid: connection.id });
|
|
268
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Whether a command failed because the server answered it with a tagged NO or BAD, as opposed
|
|
273
|
+
* to a lost connection, a timeout or a local failure that says nothing about the server's answer.
|
|
274
|
+
*
|
|
275
|
+
* @param err - The error the command failed with
|
|
276
|
+
* @returns True for a tagged NO or BAD
|
|
277
|
+
*/
|
|
278
|
+
function isServerRefusal(err) {
|
|
279
|
+
return !!err && (err.responseStatus === 'NO' || err.responseStatus === 'BAD');
|
|
280
|
+
}
|
|
269
281
|
/**
|
|
270
282
|
* Checks whether IMAP4rev2 semantics are active for the connection: either the
|
|
271
283
|
* client enabled IMAP4rev2 explicitly, or the server is rev2-only (advertises
|
|
@@ -325,7 +337,8 @@ function buildStatusQueryAttributes(connection, statusQuery) {
|
|
|
325
337
|
}
|
|
326
338
|
break;
|
|
327
339
|
case 'HIGHESTMODSEQ':
|
|
328
|
-
|
|
340
|
+
// QRESYNC implies CONDSTORE (RFC 7162 3.2.3)
|
|
341
|
+
if (connection.capabilities.has('CONDSTORE') || connection.capabilities.has('QRESYNC')) {
|
|
329
342
|
attributes.push({ type: 'ATOM', value: key.toUpperCase() });
|
|
330
343
|
}
|
|
331
344
|
break;
|
|
@@ -593,7 +606,12 @@ function getFolderTree(folders) {
|
|
|
593
606
|
}
|
|
594
607
|
return node;
|
|
595
608
|
};
|
|
596
|
-
|
|
609
|
+
// Parents are inserted before their children, as getTreeNode() can only descend into nodes
|
|
610
|
+
// that already exist. LIST gives no ordering guarantee, and the LIST command sorts special-use
|
|
611
|
+
// mailboxes first, which put a child such as "[Gmail]/Sent Mail" ahead of "[Gmail]" and left
|
|
612
|
+
// it at the root. The sort is stable, so siblings keep their listing order
|
|
613
|
+
let byDepth = [...folders].sort((a, b) => (a.parent ? a.parent.length : 0) - (b.parent ? b.parent.length : 0));
|
|
614
|
+
for (let folder of byDepth) {
|
|
597
615
|
let parent = getTreeNode(folder.parent);
|
|
598
616
|
// see if entry already exists
|
|
599
617
|
let existing = parent.folders && parent.folders.find(existing => existing.name === folder.name);
|
|
@@ -701,24 +719,40 @@ function getColorFlags(color) {
|
|
|
701
719
|
* @param untagged - Parsed untagged IMAP response
|
|
702
720
|
* @param mailbox - Current mailbox state object
|
|
703
721
|
* @param idHashAlgorithm - Hash for the fallback message id, `md5` unless the client was told otherwise
|
|
722
|
+
* @param connection - Connection the response arrived on, decodes Gmail labels like mailbox names
|
|
704
723
|
* @returns Formatted message object with properties like seq, uid, flags, envelope, etc.
|
|
705
724
|
*/
|
|
706
|
-
async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
|
|
725
|
+
async function formatMessageResponse(untagged, mailbox, idHashAlgorithm, connection) {
|
|
707
726
|
let map = {};
|
|
708
727
|
// The sequence number indexes into mailbox state, so an unusable one is dropped rather
|
|
709
728
|
// than coerced to NaN or Infinity
|
|
710
729
|
map.seq = parseUintValue(untagged.command, exports.MAX_UINT32_DIGITS) || undefined;
|
|
711
730
|
let key;
|
|
731
|
+
// the <origin> of a partial section ("BODY[2]<1024>"), kept for the value that follows
|
|
732
|
+
let origin = false;
|
|
733
|
+
let partialOrigins;
|
|
734
|
+
let recordOrigin = (sectionKey) => {
|
|
735
|
+
if (origin !== false) {
|
|
736
|
+
if (!partialOrigins) {
|
|
737
|
+
partialOrigins = new Map();
|
|
738
|
+
}
|
|
739
|
+
partialOrigins.set(sectionKey, origin);
|
|
740
|
+
}
|
|
741
|
+
};
|
|
712
742
|
let attributes = ((untagged.attributes && untagged.attributes[1]) || []);
|
|
713
743
|
for (let i = 0, len = attributes.length; i < len; i++) {
|
|
714
744
|
let attribute = attributes[i];
|
|
715
745
|
if (i % 2 === 0) {
|
|
746
|
+
origin = false;
|
|
716
747
|
key = (await (0, imap_handler_js_1.compiler)({
|
|
717
748
|
attributes: [attribute]
|
|
718
749
|
}))
|
|
719
750
|
.toString()
|
|
720
751
|
.toLowerCase()
|
|
721
|
-
.replace(
|
|
752
|
+
.replace(/<(\d+)(\.\d+)?>$/, (match, start) => {
|
|
753
|
+
origin = parseUintValue(start, exports.MAX_UINT32_DIGITS);
|
|
754
|
+
return '';
|
|
755
|
+
});
|
|
722
756
|
continue;
|
|
723
757
|
}
|
|
724
758
|
/* c8 ignore start */ // defensive: key is always a string produced by the compiler above
|
|
@@ -763,6 +797,7 @@ async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
|
|
|
763
797
|
case 'body[]':
|
|
764
798
|
case 'binary[]':
|
|
765
799
|
map.source = getBuffer(attribute);
|
|
800
|
+
recordOrigin('');
|
|
766
801
|
break;
|
|
767
802
|
case 'uid':
|
|
768
803
|
// A UID feeds mailbox.uidNext one line below, and from there every range
|
|
@@ -809,7 +844,8 @@ async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
|
|
|
809
844
|
map.threadId = getString(attribute);
|
|
810
845
|
break;
|
|
811
846
|
case 'x-gm-labels':
|
|
812
|
-
|
|
847
|
+
// labels are mailbox names, modified UTF-7 unless UTF-8 is enabled
|
|
848
|
+
map.labels = new Set(getArray(attribute).map(label => (connection ? decodePath(connection, label) : label)));
|
|
813
849
|
break;
|
|
814
850
|
case 'rfc822.size':
|
|
815
851
|
map.size = getUint(attribute) || 0;
|
|
@@ -855,6 +891,7 @@ async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
|
|
|
855
891
|
map.bodyParts = new Map();
|
|
856
892
|
}
|
|
857
893
|
map.bodyParts.set(partKey, value);
|
|
894
|
+
recordOrigin(partKey);
|
|
858
895
|
if (match[1].toLowerCase() === 'binary') {
|
|
859
896
|
// The part arrived via FETCH BINARY (RFC 3516, FETCH side folded
|
|
860
897
|
// into IMAP4rev2), so the server has already removed the
|
|
@@ -896,6 +933,10 @@ async function formatMessageResponse(untagged, mailbox, idHashAlgorithm) {
|
|
|
896
933
|
.update([path, mailbox.uidValidity?.toString() || '', map.uid.toString()].join(':'))
|
|
897
934
|
.digest('hex');
|
|
898
935
|
}
|
|
936
|
+
if (partialOrigins) {
|
|
937
|
+
// non-enumerable, so it stays out of logged and serialized fetch results
|
|
938
|
+
Object.defineProperty(map, 'partialOrigins', { value: partialOrigins, writable: true, configurable: true });
|
|
939
|
+
}
|
|
899
940
|
if (map.flags) {
|
|
900
941
|
let flagColor = getFlagColor(map.flags);
|
|
901
942
|
if (flagColor) {
|
|
@@ -1338,14 +1379,22 @@ function formatDateTime(value) {
|
|
|
1338
1379
|
let timeStr = date.toISOString().substring(11, 19);
|
|
1339
1380
|
return `${dateStr} ${timeStr} +0000`;
|
|
1340
1381
|
}
|
|
1382
|
+
// the memoized ATOM-CHAR set of RFC 9051 section 9
|
|
1383
|
+
const atomChars = imap_formal_syntax_js_1.default['ATOM-CHAR'];
|
|
1341
1384
|
/**
|
|
1342
|
-
* Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent)
|
|
1343
|
-
* and capitalizes system flags properly.
|
|
1385
|
+
* Normalizes a flag string. Returns false for non-settable flags (e.g. \Recent) and for
|
|
1386
|
+
* values that are not valid flags (keywords must be atoms), and capitalizes system flags properly.
|
|
1344
1387
|
*
|
|
1345
1388
|
* @param flag - Flag string to normalize
|
|
1346
1389
|
* @returns Normalized flag string, or false if the flag cannot be set
|
|
1347
1390
|
*/
|
|
1348
1391
|
function formatFlag(flag) {
|
|
1392
|
+
// RFC 9051 section 9: flag-keyword is an atom and flag-extension is "\\" atom, the same check
|
|
1393
|
+
// the compiler uses to decide what it can send unquoted
|
|
1394
|
+
let atom = flag.charAt(0) === '\\' ? flag.slice(1) : flag;
|
|
1395
|
+
if (!atom || imap_formal_syntax_js_1.default.verify(atom, atomChars()) >= 0) {
|
|
1396
|
+
return false;
|
|
1397
|
+
}
|
|
1349
1398
|
switch (flag.toLowerCase()) {
|
|
1350
1399
|
case '\\recent':
|
|
1351
1400
|
// can not set or remove
|
|
@@ -1412,7 +1461,8 @@ function isUnsafeKey(key) {
|
|
|
1412
1461
|
/**
|
|
1413
1462
|
* Reads a parsed attribute list of atoms or strings (a flag list, a capability list) into
|
|
1414
1463
|
* 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.
|
|
1464
|
+
* so both levels are guarded here rather than at each call site. A literal (a Gmail label
|
|
1465
|
+
* the server could not send quoted) arrives as a Buffer and is decoded as UTF-8.
|
|
1416
1466
|
*
|
|
1417
1467
|
* @param list - Parsed attribute list from a response.
|
|
1418
1468
|
* @returns The string values, in order, with unusable entries dropped.
|
|
@@ -1421,7 +1471,17 @@ function getStringList(list) {
|
|
|
1421
1471
|
if (!Array.isArray(list)) {
|
|
1422
1472
|
return [];
|
|
1423
1473
|
}
|
|
1424
|
-
|
|
1474
|
+
let strings = [];
|
|
1475
|
+
for (let entry of list) {
|
|
1476
|
+
let value = entry && entry.value;
|
|
1477
|
+
if (Buffer.isBuffer(value)) {
|
|
1478
|
+
value = value.toString();
|
|
1479
|
+
}
|
|
1480
|
+
if (value && typeof value === 'string') {
|
|
1481
|
+
strings.push(value);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return strings;
|
|
1425
1485
|
}
|
|
1426
1486
|
/**
|
|
1427
1487
|
* 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;
|
|
@@ -60,15 +63,26 @@ async function authOauth(connection, username, accessToken) {
|
|
|
60
63
|
// Empty breaker: XOAUTH2 expects an empty response to abort the SASL exchange
|
|
61
64
|
breaker = '';
|
|
62
65
|
}
|
|
66
|
+
let encoded = Buffer.from(oauthbearer).toString('base64');
|
|
67
|
+
// Without SASL-IR the payload may not ride on the command line (RFC 4959 section 3): it is
|
|
68
|
+
// sent as the answer to the first, empty continuation request instead
|
|
69
|
+
let payloadPending = !connection.capabilities.has('SASL-IR');
|
|
63
70
|
let errorResponse = false;
|
|
64
71
|
try {
|
|
65
|
-
let
|
|
66
|
-
|
|
67
|
-
{ type: 'ATOM', value:
|
|
68
|
-
|
|
72
|
+
let attributes = [{ type: 'ATOM', value: command }];
|
|
73
|
+
if (!payloadPending) {
|
|
74
|
+
attributes.push({ type: 'ATOM', value: encoded, sensitive: true });
|
|
75
|
+
}
|
|
76
|
+
let response = await connection.exec('AUTHENTICATE', attributes, {
|
|
69
77
|
// Server sends a "+" continuation if auth fails, with a base64 JSON error payload.
|
|
70
78
|
// We decode it for diagnostics, then send the breaker to terminate the exchange.
|
|
71
79
|
onPlusTag: async (resp) => {
|
|
80
|
+
if (payloadPending) {
|
|
81
|
+
payloadPending = false;
|
|
82
|
+
connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded response for AUTH=${command}`, cid: connection.id });
|
|
83
|
+
connection.write(encoded);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
72
86
|
if (resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT') {
|
|
73
87
|
try {
|
|
74
88
|
errorResponse = JSON.parse(Buffer.from(resp.attributes[0].value, 'base64').toString());
|
|
@@ -108,27 +122,33 @@ async function authLogin(connection, username, password) {
|
|
|
108
122
|
try {
|
|
109
123
|
// SASL LOGIN is a challenge-response mechanism: the server sends base64-encoded
|
|
110
124
|
// prompts ("Username:" and "Password:") and the client responds with base64-encoded values.
|
|
125
|
+
let usernameSent = false;
|
|
111
126
|
let response = await connection.exec('AUTHENTICATE', [{ type: 'ATOM', value: 'LOGIN' }], {
|
|
112
127
|
onPlusTag: async (resp) => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
128
|
+
// Decode the server's base64 challenge to determine what it's asking for.
|
|
129
|
+
// Strip trailing colons and null bytes (\x00) that some servers append to the prompt.
|
|
130
|
+
let question = resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT'
|
|
131
|
+
? Buffer.from(resp.attributes[0].value, 'base64')
|
|
117
132
|
.toString()
|
|
118
133
|
.toLowerCase()
|
|
119
|
-
.replace(/[:\x00]*$/, '')
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
.replace(/[:\x00]*$/, '')
|
|
135
|
+
: '';
|
|
136
|
+
// Some servers send an empty first challenge, which by SASL LOGIN convention asks for the username
|
|
137
|
+
if (question === 'username' || question === 'user name' || (!question && !usernameSent)) {
|
|
138
|
+
let encodedUsername = Buffer.from(username).toString('base64');
|
|
139
|
+
connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN`, cid: connection.id });
|
|
140
|
+
connection.write(encodedUsername);
|
|
141
|
+
usernameSent = true;
|
|
142
|
+
}
|
|
143
|
+
else if (question === 'password') {
|
|
144
|
+
connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN`, cid: connection.id });
|
|
145
|
+
connection.write(Buffer.from(password).toString('base64'));
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// Cancel the exchange (RFC 9051 section 6.2.2), so the server fails the command
|
|
149
|
+
// with a tagged BAD instead of waiting for an answer that never comes
|
|
150
|
+
connection.log.warn({ msg: 'Unknown AUTH=LOGIN challenge, cancelling', question, cid: connection.id });
|
|
151
|
+
connection.write('*');
|
|
132
152
|
}
|
|
133
153
|
}
|
|
134
154
|
});
|
|
@@ -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.
|