imapflow 1.3.6 → 1.4.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 +14 -0
- package/lib/commands/authenticate.js +5 -5
- package/lib/commands/fetch.js +1 -0
- package/lib/commands/logout.js +1 -0
- package/lib/commands/select.js +2 -1
- package/lib/commands/store.js +1 -0
- package/lib/handler/token-parser.js +0 -5
- package/lib/imap-flow.d.ts +44 -26
- package/lib/imap-flow.js +36 -6
- package/lib/search-compiler.js +48 -0
- package/lib/tools.js +8 -0
- package/package.json +1 -1
- package/test/commands-branches-test.js +1068 -0
- package/test/fixtures/test-tls.js +8 -0
- package/test/handler-branches-test.js +334 -0
- package/test/imap-compiler-test.js +47 -0
- package/test/imap-flow-compress-test.js +154 -0
- package/test/imap-flow-coverage-test.js +609 -0
- package/test/imap-flow-fetch-download-test.js +801 -0
- package/test/imap-flow-internals-test.js +450 -0
- package/test/imap-flow-methods-test.js +738 -0
- package/test/imap-flow-proxy-paths-test.js +215 -0
- package/test/imap-flow-secure-test.js +327 -0
- package/test/imap-flow-server-test.js +1046 -0
- package/test/imap-formal-syntax-test.js +18 -0
- package/test/imap-parser-test.js +52 -0
- package/test/imap-stream-edge-cases-test.js +64 -0
- package/test/jp-decoder-test.js +48 -0
- package/test/limited-passthrough-test.js +17 -0
- package/test/search-compiler-test.js +111 -0
- package/test/search-test.js +61 -0
- package/test/token-parser-test.js +64 -0
- package/test/tools-test.js +360 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.0](https://github.com/postalsys/imapflow/compare/v1.3.7...v1.4.0) (2026-06-09)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add Gmail label search term to the search compiler ([4b2d173](https://github.com/postalsys/imapflow/commit/4b2d1736660441e95cd55a30ad6632a676fb605c))
|
|
9
|
+
|
|
10
|
+
## [1.3.7](https://github.com/postalsys/imapflow/compare/v1.3.6...v1.3.7) (2026-06-08)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* harden FLAGS guard and bring lib to full test coverage ([6666bec](https://github.com/postalsys/imapflow/commit/6666bec7f82072e5aa8a1e63736e35d5c6644d4c))
|
|
16
|
+
|
|
3
17
|
## [1.3.6](https://github.com/postalsys/imapflow/compare/v1.3.5...v1.3.6) (2026-06-05)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -7,7 +7,7 @@ const { getStatusCode, getErrorText } = require('../tools.js');
|
|
|
7
7
|
*
|
|
8
8
|
* @param {Error} err - The original authentication error
|
|
9
9
|
* @param {Object} [errorResponse] - Optional OAuth error response from the server
|
|
10
|
-
* @
|
|
10
|
+
* @returns {Error} The enriched error; the caller is expected to throw it
|
|
11
11
|
*/
|
|
12
12
|
async function handleAuthError(err, errorResponse) {
|
|
13
13
|
let errorCode = getStatusCode(err.response);
|
|
@@ -19,7 +19,7 @@ async function handleAuthError(err, errorResponse) {
|
|
|
19
19
|
if (errorResponse) {
|
|
20
20
|
err.oauthError = errorResponse;
|
|
21
21
|
}
|
|
22
|
-
|
|
22
|
+
return err;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -84,7 +84,7 @@ async function authOauth(connection, username, accessToken) {
|
|
|
84
84
|
|
|
85
85
|
return username;
|
|
86
86
|
} catch (err) {
|
|
87
|
-
await handleAuthError(err, errorResponse);
|
|
87
|
+
throw await handleAuthError(err, errorResponse);
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
@@ -132,7 +132,7 @@ async function authLogin(connection, username, password) {
|
|
|
132
132
|
|
|
133
133
|
return username;
|
|
134
134
|
} catch (err) {
|
|
135
|
-
await handleAuthError(err, errorResponse);
|
|
135
|
+
throw await handleAuthError(err, errorResponse);
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
@@ -169,7 +169,7 @@ async function authPlain(connection, username, password, authzid) {
|
|
|
169
169
|
// Return the identity we're authorized as (authzid if provided, otherwise username)
|
|
170
170
|
return authzid || username;
|
|
171
171
|
} catch (err) {
|
|
172
|
-
await handleAuthError(err, errorResponse);
|
|
172
|
+
throw await handleAuthError(err, errorResponse);
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
package/lib/commands/fetch.js
CHANGED
|
@@ -41,6 +41,7 @@ module.exports = async (connection, range, query, options) => {
|
|
|
41
41
|
|
|
42
42
|
let response;
|
|
43
43
|
try {
|
|
44
|
+
/* c8 ignore next */ // range is guaranteed truthy by the early-return guard above, so the '*' fallback is unreachable
|
|
44
45
|
let attributes = [{ type: 'SEQUENCE', value: (range || '*').toString() }];
|
|
45
46
|
|
|
46
47
|
let queryStructure = [];
|
package/lib/commands/logout.js
CHANGED
|
@@ -30,6 +30,7 @@ module.exports = async connection => {
|
|
|
30
30
|
}
|
|
31
31
|
connection.log.warn({ err, cid: connection.id });
|
|
32
32
|
return false;
|
|
33
|
+
/* c8 ignore next */ // the catch above is exhaustive (never re-throws), so finally is only ever reached via normal completion
|
|
33
34
|
} finally {
|
|
34
35
|
// Set state to LOGOUT before closing to prevent any further commands from
|
|
35
36
|
// being queued. The socket is closed unconditionally in this finally block
|
package/lib/commands/select.js
CHANGED
|
@@ -70,6 +70,7 @@ module.exports = async (connection, path, options) => {
|
|
|
70
70
|
// send as quoted STRING to avoid parser issues with the ampersand.
|
|
71
71
|
let selectCommand = {
|
|
72
72
|
command: !options.readOnly ? 'SELECT' : 'EXAMINE',
|
|
73
|
+
/* c8 ignore next */ // extraArgs is always initialised to an array, so the [] fallback is unreachable
|
|
73
74
|
arguments: [{ type: encodedPath.indexOf('&') >= 0 ? 'STRING' : 'ATOM', value: encodedPath }].concat(extraArgs || [])
|
|
74
75
|
};
|
|
75
76
|
|
|
@@ -164,7 +165,7 @@ module.exports = async (connection, path, options) => {
|
|
|
164
165
|
// Untagged FLAGS response lists all flags defined for this mailbox
|
|
165
166
|
// (both system flags and custom flags). Example: * FLAGS (\Seen \Answered \Flagged)
|
|
166
167
|
FLAGS: async untagged => {
|
|
167
|
-
if (!untagged.attributes ||
|
|
168
|
+
if (!untagged.attributes || !untagged.attributes.length || !Array.isArray(untagged.attributes[0])) {
|
|
168
169
|
return;
|
|
169
170
|
}
|
|
170
171
|
let flags = untagged.attributes[0].map(flag => (typeof flag.value === 'string' ? flag.value : false)).filter(flag => flag);
|
package/lib/commands/store.js
CHANGED
|
@@ -22,6 +22,7 @@ module.exports = async (connection, range, flags, options) => {
|
|
|
22
22
|
return false;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/* c8 ignore next */ // options.useLabels is dereferenced in the guard above, so options is always defined here
|
|
25
26
|
options = options || {};
|
|
26
27
|
|
|
27
28
|
// Build the IMAP STORE operation name. The format is:
|
|
@@ -10,7 +10,6 @@ const STATE_NORMAL = 0x003;
|
|
|
10
10
|
const STATE_PARTIAL = 0x004;
|
|
11
11
|
const STATE_SEQUENCE = 0x005;
|
|
12
12
|
const STATE_STRING = 0x006;
|
|
13
|
-
const STATE_TEXT = 0x007;
|
|
14
13
|
|
|
15
14
|
const RE_DIGITS = /^\d+$/;
|
|
16
15
|
const RE_SINGLE_DIGIT = /^\d$/;
|
|
@@ -706,10 +705,6 @@ class TokenParser {
|
|
|
706
705
|
|
|
707
706
|
this.currentNode.value += chr;
|
|
708
707
|
break;
|
|
709
|
-
|
|
710
|
-
case STATE_TEXT:
|
|
711
|
-
this.currentNode.value += chr;
|
|
712
|
-
break;
|
|
713
708
|
}
|
|
714
709
|
}
|
|
715
710
|
}
|
package/lib/imap-flow.d.ts
CHANGED
|
@@ -357,6 +357,8 @@ export interface SearchObject {
|
|
|
357
357
|
gmraw?: string;
|
|
358
358
|
/** Gmail raw search query (alias for gmraw) */
|
|
359
359
|
gmailraw?: string;
|
|
360
|
+
/** Gmail label filter (only for Gmail). Compiles to an X-GM-RAW "label:"/"-label:" query. "has" matches messages carrying all listed labels, "not" excludes messages carrying any listed label */
|
|
361
|
+
labels?: { has?: string[]; not?: string[] };
|
|
360
362
|
}
|
|
361
363
|
|
|
362
364
|
export interface FetchQueryObject {
|
|
@@ -373,12 +375,14 @@ export interface FetchQueryObject {
|
|
|
373
375
|
/** If true then include message size in the response */
|
|
374
376
|
size?: boolean;
|
|
375
377
|
/** If true then include full message in the response */
|
|
376
|
-
source?:
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
378
|
+
source?:
|
|
379
|
+
| boolean
|
|
380
|
+
| {
|
|
381
|
+
/** Include full message in the response starting from start byte */
|
|
382
|
+
start?: number;
|
|
383
|
+
/** Include full message in the response, up to maxLength bytes */
|
|
384
|
+
maxLength?: number;
|
|
385
|
+
};
|
|
382
386
|
/** If true then include thread ID in the response (only if server supports either OBJECTID or X-GM-EXT-1 extensions) */
|
|
383
387
|
threadId?: boolean;
|
|
384
388
|
/** If true then include GMail labels in the response (only if server supports X-GM-EXT-1 extension) */
|
|
@@ -740,14 +744,17 @@ export class ImapFlow extends EventEmitter {
|
|
|
740
744
|
mailboxClose(): Promise<boolean>;
|
|
741
745
|
|
|
742
746
|
/** Requests the status of the indicated mailbox */
|
|
743
|
-
status(
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
747
|
+
status(
|
|
748
|
+
path: string,
|
|
749
|
+
query: {
|
|
750
|
+
messages?: boolean;
|
|
751
|
+
recent?: boolean;
|
|
752
|
+
uidNext?: boolean;
|
|
753
|
+
uidValidity?: boolean;
|
|
754
|
+
unseen?: boolean;
|
|
755
|
+
highestModseq?: boolean;
|
|
756
|
+
}
|
|
757
|
+
): Promise<StatusObject>;
|
|
751
758
|
|
|
752
759
|
/** Starts listening for new or deleted messages from the currently opened mailbox */
|
|
753
760
|
idle(): Promise<boolean>;
|
|
@@ -779,10 +786,13 @@ export class ImapFlow extends EventEmitter {
|
|
|
779
786
|
/** Search messages from the currently opened mailbox — returns number[] (backward-compatible) */
|
|
780
787
|
search(query: SearchObject, options?: { uid?: boolean }): Promise<number[] | false>;
|
|
781
788
|
/** Search messages with ESEARCH RETURN options — returns ESearchResult */
|
|
782
|
-
search(
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
789
|
+
search(
|
|
790
|
+
query: SearchObject,
|
|
791
|
+
options: {
|
|
792
|
+
uid?: boolean;
|
|
793
|
+
returnOptions: Array<'MIN' | 'MAX' | 'COUNT' | 'ALL' | { partial: string }>;
|
|
794
|
+
}
|
|
795
|
+
): Promise<ESearchResult | number[] | false>;
|
|
786
796
|
|
|
787
797
|
/** Fetch messages from the currently opened mailbox */
|
|
788
798
|
fetch(range: SequenceString | number[] | SearchObject, query: FetchQueryObject, options?: FetchOptions): AsyncIterableIterator<FetchMessageObject>;
|
|
@@ -794,14 +804,22 @@ export class ImapFlow extends EventEmitter {
|
|
|
794
804
|
fetchOne(seq: SequenceString, query: FetchQueryObject, options?: FetchOptions): Promise<FetchMessageObject | false>;
|
|
795
805
|
|
|
796
806
|
/** Download either full rfc822 formatted message or a specific bodystructure part as a Stream */
|
|
797
|
-
download(
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
807
|
+
download(
|
|
808
|
+
range: SequenceString,
|
|
809
|
+
part?: string,
|
|
810
|
+
options?: {
|
|
811
|
+
uid?: boolean;
|
|
812
|
+
maxBytes?: number;
|
|
813
|
+
chunkSize?: number;
|
|
814
|
+
}
|
|
815
|
+
): Promise<DownloadObject>;
|
|
802
816
|
|
|
803
817
|
/** Fetch multiple attachments as Buffer values */
|
|
804
|
-
downloadMany(
|
|
818
|
+
downloadMany(
|
|
819
|
+
range: SequenceString,
|
|
820
|
+
parts: string[],
|
|
821
|
+
options?: { uid?: boolean }
|
|
822
|
+
): Promise<{
|
|
805
823
|
[part: string]: {
|
|
806
824
|
meta: {
|
|
807
825
|
contentType?: string;
|
|
@@ -811,7 +829,7 @@ export class ImapFlow extends EventEmitter {
|
|
|
811
829
|
encoding?: string;
|
|
812
830
|
};
|
|
813
831
|
content: Buffer | null;
|
|
814
|
-
}
|
|
832
|
+
};
|
|
815
833
|
}>;
|
|
816
834
|
|
|
817
835
|
/** Opens a mailbox if not already open and returns a lock */
|
|
@@ -849,4 +867,4 @@ export class ImapFlow extends EventEmitter {
|
|
|
849
867
|
|
|
850
868
|
/** Response event */
|
|
851
869
|
on(event: 'response', listener: (response: ResponseEvent) => void): this;
|
|
852
|
-
}
|
|
870
|
+
}
|
package/lib/imap-flow.js
CHANGED
|
@@ -576,6 +576,7 @@ class ImapFlow extends EventEmitter {
|
|
|
576
576
|
isLogging: true
|
|
577
577
|
});
|
|
578
578
|
|
|
579
|
+
/* c8 ignore next */ // send() is always invoked with a request object carrying options, so the {} fallback is unreachable
|
|
579
580
|
let options = data.options || {};
|
|
580
581
|
|
|
581
582
|
this.log.debug({ src: 'c', msg: logCompiled.toString(), cid: this.id, comment: options.comment });
|
|
@@ -925,12 +926,6 @@ class ImapFlow extends EventEmitter {
|
|
|
925
926
|
// Clear any existing handlers first to prevent duplicates
|
|
926
927
|
this.clearSocketHandlers();
|
|
927
928
|
|
|
928
|
-
// Remove temporary connection error handler if present
|
|
929
|
-
if (this._connectErrorHandler && this.socket) {
|
|
930
|
-
this.socket.removeListener('error', this._connectErrorHandler);
|
|
931
|
-
this._connectErrorHandler = null;
|
|
932
|
-
}
|
|
933
|
-
|
|
934
929
|
this._socketError =
|
|
935
930
|
this._socketError ||
|
|
936
931
|
(err => {
|
|
@@ -1108,6 +1103,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1108
1103
|
highWaterMark: 64 * 1024 // 64KB buffer limit to prevent excessive memory usage
|
|
1109
1104
|
});
|
|
1110
1105
|
|
|
1106
|
+
/* c8 ignore start */ // destroySoon override is never invoked by ImapFlow (close() calls destroy()); kept for stream API completeness
|
|
1111
1107
|
this.writeSocket.destroySoon = () => {
|
|
1112
1108
|
try {
|
|
1113
1109
|
if (this.socket) {
|
|
@@ -1119,6 +1115,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1119
1115
|
throw err;
|
|
1120
1116
|
}
|
|
1121
1117
|
};
|
|
1118
|
+
/* c8 ignore stop */
|
|
1122
1119
|
|
|
1123
1120
|
Object.defineProperty(this.writeSocket, 'destroyed', {
|
|
1124
1121
|
get: () => !this.socket || this.socket.destroyed
|
|
@@ -1142,6 +1139,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1142
1139
|
|
|
1143
1140
|
// Yield to event loop every 100 chunks to prevent CPU blocking
|
|
1144
1141
|
processedChunks++;
|
|
1142
|
+
/* c8 ignore next 6 */ // requires 100+ queued chunks in a single pump pass; not reproducible deterministically
|
|
1145
1143
|
if (processedChunks % 100 === 0) {
|
|
1146
1144
|
await new Promise(resolve => setImmediate(resolve));
|
|
1147
1145
|
if (!this.writeSocket) {
|
|
@@ -1156,6 +1154,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1156
1154
|
}
|
|
1157
1155
|
|
|
1158
1156
|
reading = false;
|
|
1157
|
+
/* c8 ignore next 3 */ // defensive: the pump body does not throw under normal operation
|
|
1159
1158
|
} catch (ex) {
|
|
1160
1159
|
this.emitError(ex);
|
|
1161
1160
|
}
|
|
@@ -1252,6 +1251,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1252
1251
|
// socket arrived after the OK and were not consumed by the handshake — i.e. injected.
|
|
1253
1252
|
// This catches late/fragmented injection that the parse-time snapshot cannot see.
|
|
1254
1253
|
let injectedTail = typeof this.socket.read === 'function' ? this.socket.read() : null;
|
|
1254
|
+
/* c8 ignore next 3 */ // late/fragmented post-OK injection is timing-dependent and not deterministically reproducible
|
|
1255
1255
|
if (injectedTail && injectedTail.length) {
|
|
1256
1256
|
throw failSTARTTLSInjection();
|
|
1257
1257
|
}
|
|
@@ -1270,6 +1270,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1270
1270
|
this.clearSocketHandlers();
|
|
1271
1271
|
|
|
1272
1272
|
// Store error handler for cleanup after successful upgrade
|
|
1273
|
+
/* c8 ignore start */ // plain-socket error during the TLS handshake window is timing-dependent (errors surface via the streamer/emitError path in tests)
|
|
1273
1274
|
const socketPlainErrorHandler = err => {
|
|
1274
1275
|
clearTimeout(this.connectTimeout);
|
|
1275
1276
|
clearTimeout(this.upgradeTimeout);
|
|
@@ -1282,8 +1283,10 @@ class ImapFlow extends EventEmitter {
|
|
|
1282
1283
|
err.tlsFailed = true;
|
|
1283
1284
|
reject(err);
|
|
1284
1285
|
};
|
|
1286
|
+
/* c8 ignore stop */
|
|
1285
1287
|
socketPlain.once('error', socketPlainErrorHandler);
|
|
1286
1288
|
|
|
1289
|
+
/* c8 ignore start */ // UPGRADE_TIMEOUT is 10s; firing it deterministically would make the test suite hang
|
|
1287
1290
|
this.upgradeTimeout = setTimeout(() => {
|
|
1288
1291
|
if (!this.upgrading) {
|
|
1289
1292
|
return;
|
|
@@ -1294,6 +1297,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1294
1297
|
err.code = 'UPGRADE_TIMEOUT';
|
|
1295
1298
|
reject(err);
|
|
1296
1299
|
}, UPGRADE_TIMEOUT);
|
|
1300
|
+
/* c8 ignore stop */
|
|
1297
1301
|
|
|
1298
1302
|
// A TLS handshake failure (bad certificate, protocol mismatch, etc.) is emitted on the
|
|
1299
1303
|
// new TLS socket, not on the plain socket, so it must be handled here. Without this the
|
|
@@ -1302,10 +1306,12 @@ class ImapFlow extends EventEmitter {
|
|
|
1302
1306
|
const tlsSocketErrorHandler = err => {
|
|
1303
1307
|
clearTimeout(this.connectTimeout);
|
|
1304
1308
|
clearTimeout(this.upgradeTimeout);
|
|
1309
|
+
/* c8 ignore start */ // the already-settled early return is a defensive double-fire guard, not separately exercised
|
|
1305
1310
|
if (!this.upgrading) {
|
|
1306
1311
|
// already settled
|
|
1307
1312
|
return;
|
|
1308
1313
|
}
|
|
1314
|
+
/* c8 ignore stop */
|
|
1309
1315
|
this.upgrading = false;
|
|
1310
1316
|
err.tlsFailed = true;
|
|
1311
1317
|
this.clearSocketHandlers();
|
|
@@ -1317,10 +1323,12 @@ class ImapFlow extends EventEmitter {
|
|
|
1317
1323
|
this.socket = tls.connect(opts, () => {
|
|
1318
1324
|
try {
|
|
1319
1325
|
clearTimeout(this.upgradeTimeout);
|
|
1326
|
+
/* c8 ignore start */ // race: connection closed during the TLS handshake window
|
|
1320
1327
|
if (this.isClosed) {
|
|
1321
1328
|
// not sure if this is possible?
|
|
1322
1329
|
return this.close();
|
|
1323
1330
|
}
|
|
1331
|
+
/* c8 ignore stop */
|
|
1324
1332
|
|
|
1325
1333
|
// TLS handshake complete. Reconnect the now-encrypted socket
|
|
1326
1334
|
// to the IMAP parser stream and record the cipher details.
|
|
@@ -1328,6 +1336,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1328
1336
|
this.upgrading = false;
|
|
1329
1337
|
this.streamer.secureConnection = true;
|
|
1330
1338
|
this.socket.pipe(this.streamer);
|
|
1339
|
+
/* c8 ignore next */ // an upgraded TLS socket always exposes getCipher(), so the false fallback is unreachable
|
|
1331
1340
|
this.tls = typeof this.socket.getCipher === 'function' ? this.socket.getCipher() : false;
|
|
1332
1341
|
if (this.tls) {
|
|
1333
1342
|
this.tls.authorized = this.socket.authorized;
|
|
@@ -1336,6 +1345,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1336
1345
|
msg: 'Established TLS session',
|
|
1337
1346
|
cid: this.id,
|
|
1338
1347
|
authorized: this.tls.authorized,
|
|
1348
|
+
/* c8 ignore next */ // cipher.standardName is present on modern Node, so the .name fallback rarely runs
|
|
1339
1349
|
algo: this.tls.standardName || this.tls.name,
|
|
1340
1350
|
version: this.tls.version
|
|
1341
1351
|
});
|
|
@@ -1356,6 +1366,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1356
1366
|
|
|
1357
1367
|
this._upgradeReject = null;
|
|
1358
1368
|
return resolve(true);
|
|
1369
|
+
/* c8 ignore next 3 */ // defensive: the success callback body does not throw under normal operation
|
|
1359
1370
|
} catch (ex) {
|
|
1360
1371
|
this.emitError(ex);
|
|
1361
1372
|
}
|
|
@@ -1805,6 +1816,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1805
1816
|
let err = new Error('Failed to establish connection in required time');
|
|
1806
1817
|
err.code = 'CONNECT_TIMEOUT';
|
|
1807
1818
|
err.details = {
|
|
1819
|
+
/* c8 ignore next */ // firing the timeout with the default (large) value would hang the suite, so only the explicit-option path is tested
|
|
1808
1820
|
connectionTimeout: this.options.connectionTimeout || CONNECT_TIMEOUT
|
|
1809
1821
|
};
|
|
1810
1822
|
this.log.error({ err, cid: this.id });
|
|
@@ -1825,10 +1837,12 @@ class ImapFlow extends EventEmitter {
|
|
|
1825
1837
|
|
|
1826
1838
|
this.greetingTimeout = setTimeout(() => {
|
|
1827
1839
|
let err = new Error(
|
|
1840
|
+
/* c8 ignore next */ // the greeting-timeout test uses a plaintext socket; the secure-socket branch of this hint is not separately exercised
|
|
1828
1841
|
`Failed to receive greeting from server in required time${!this.secureConnection ? '. Maybe should use TLS?' : ''}`
|
|
1829
1842
|
);
|
|
1830
1843
|
err.code = 'GREETING_TIMEOUT';
|
|
1831
1844
|
err.details = {
|
|
1845
|
+
/* c8 ignore next */ // firing the timeout with the default (large) value would hang the suite, so only the explicit-option path is tested
|
|
1832
1846
|
greetingTimeout: this.options.greetingTimeout || GREETING_TIMEOUT
|
|
1833
1847
|
};
|
|
1834
1848
|
this.log.error({ err, cid: this.id });
|
|
@@ -1853,6 +1867,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1853
1867
|
|
|
1854
1868
|
if (this.tls) {
|
|
1855
1869
|
logInfo.authorized = this.tls.authorized = this.socket.authorized;
|
|
1870
|
+
/* c8 ignore next */ // cipher.standardName is present on modern Node, so the .name fallback rarely runs
|
|
1856
1871
|
logInfo.algo = this.tls.standardName || this.tls.name;
|
|
1857
1872
|
logInfo.version = this.tls.version;
|
|
1858
1873
|
}
|
|
@@ -1866,6 +1881,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1866
1881
|
// executed by initial "* OK"
|
|
1867
1882
|
this.initialResolve = resolve;
|
|
1868
1883
|
this.initialReject = reject;
|
|
1884
|
+
/* c8 ignore next 4 */ // defensive: the onConnect setup body does not throw under normal operation
|
|
1869
1885
|
} catch (ex) {
|
|
1870
1886
|
// connect failed
|
|
1871
1887
|
reject(ex);
|
|
@@ -1976,6 +1992,7 @@ class ImapFlow extends EventEmitter {
|
|
|
1976
1992
|
this.initialResolve = false;
|
|
1977
1993
|
this.initialReject = false;
|
|
1978
1994
|
let err = new Error('Unexpected close');
|
|
1995
|
+
/* c8 ignore next */ // closing a pending connect over an already-secure socket (the TLS branch) is not separately exercised
|
|
1979
1996
|
err.code = `ClosedAfterConnect${this.secureConnection ? 'TLS' : 'Text'}`;
|
|
1980
1997
|
// Surface the server's BYE reason (e.g. "Too many connections") when the
|
|
1981
1998
|
// connection was closed by an untagged BYE, so the caller sees why.
|
|
@@ -3314,6 +3331,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3314
3331
|
}
|
|
3315
3332
|
|
|
3316
3333
|
if (disposition.value) {
|
|
3334
|
+
/* c8 ignore next */ // a parsed disposition value is never all-whitespace, so the `false` fallback is unreachable
|
|
3317
3335
|
meta.disposition = disposition.value.toLowerCase().trim() || false;
|
|
3318
3336
|
try {
|
|
3319
3337
|
meta.disposition = libmime.decodeWords(meta.disposition);
|
|
@@ -3444,6 +3462,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3444
3462
|
let resolved = false;
|
|
3445
3463
|
|
|
3446
3464
|
const finish = err => {
|
|
3465
|
+
/* c8 ignore next */ // the first call removes all three listeners, so a later drain/error/close can't re-enter finish; this guard is belt-and-suspenders
|
|
3447
3466
|
if (resolved) return;
|
|
3448
3467
|
resolved = true;
|
|
3449
3468
|
|
|
@@ -3452,6 +3471,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3452
3471
|
stream.removeAllListeners('error');
|
|
3453
3472
|
stream.removeAllListeners('close');
|
|
3454
3473
|
|
|
3474
|
+
/* c8 ignore next 2 */ // stream error during a backpressure drain wait is timing-dependent
|
|
3455
3475
|
if (err) {
|
|
3456
3476
|
reject(err);
|
|
3457
3477
|
} else {
|
|
@@ -3463,12 +3483,14 @@ class ImapFlow extends EventEmitter {
|
|
|
3463
3483
|
stream.once('error', err => finish(err));
|
|
3464
3484
|
stream.once('close', () => finish());
|
|
3465
3485
|
});
|
|
3486
|
+
/* c8 ignore start */ // re-throw path only triggers on a stream error mid-drain, which is timing-dependent
|
|
3466
3487
|
} catch (err) {
|
|
3467
3488
|
// Re-throw only if not aborted
|
|
3468
3489
|
if (!fetchAborted) {
|
|
3469
3490
|
throw err;
|
|
3470
3491
|
}
|
|
3471
3492
|
}
|
|
3493
|
+
/* c8 ignore stop */
|
|
3472
3494
|
|
|
3473
3495
|
// Check if we should abort after waiting
|
|
3474
3496
|
if (fetchAborted) {
|
|
@@ -3488,6 +3510,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3488
3510
|
.catch(err => {
|
|
3489
3511
|
if (!fetchAborted && stream && !stream.destroyed) {
|
|
3490
3512
|
stream.emit('error', err);
|
|
3513
|
+
/* c8 ignore start */ // the else logs when a fetch error arrives after the stream was already torn down (timing-dependent)
|
|
3491
3514
|
} else {
|
|
3492
3515
|
// Log when error cannot be emitted to stream
|
|
3493
3516
|
this.log.warn({
|
|
@@ -3498,6 +3521,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3498
3521
|
cid: this.id
|
|
3499
3522
|
});
|
|
3500
3523
|
}
|
|
3524
|
+
/* c8 ignore stop */
|
|
3501
3525
|
})
|
|
3502
3526
|
.finally(() => {
|
|
3503
3527
|
if (!fetchAborted && stream && !stream.destroyed) {
|
|
@@ -3512,12 +3536,14 @@ class ImapFlow extends EventEmitter {
|
|
|
3512
3536
|
writeResult = writeChunk(chunk);
|
|
3513
3537
|
} catch (err) {
|
|
3514
3538
|
stream.emit('error', err);
|
|
3539
|
+
/* c8 ignore next 3 */ // emitting the error above triggers cleanup (fetchAborted=true), so this end() guard is already false here
|
|
3515
3540
|
if (!fetchAborted && stream && !stream.destroyed) {
|
|
3516
3541
|
stream.end();
|
|
3517
3542
|
}
|
|
3518
3543
|
return;
|
|
3519
3544
|
}
|
|
3520
3545
|
|
|
3546
|
+
/* c8 ignore next 7 */ // `stream` is piped to the limiter before this runs, so the head write drains synchronously and always returns true (verified for chunkSize up to 8MB); the drain-wait branch is unreachable
|
|
3521
3547
|
if (!writeResult) {
|
|
3522
3548
|
// Initial chunk filled the buffer, wait for drain
|
|
3523
3549
|
stream.once('drain', () => {
|
|
@@ -3622,6 +3648,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3622
3648
|
}
|
|
3623
3649
|
|
|
3624
3650
|
if (disposition.value) {
|
|
3651
|
+
/* c8 ignore next */ // a parsed disposition value is never all-whitespace, so the `false` fallback is unreachable
|
|
3625
3652
|
data[key].meta.disposition = disposition.value.toLowerCase().trim() || false;
|
|
3626
3653
|
try {
|
|
3627
3654
|
data[key].meta.disposition = libmime.decodeWords(data[key].meta.disposition);
|
|
@@ -3761,6 +3788,7 @@ class ImapFlow extends EventEmitter {
|
|
|
3761
3788
|
lockId: lock.lockId,
|
|
3762
3789
|
path,
|
|
3763
3790
|
heldFor: Date.now() - lock.heldAt,
|
|
3791
|
+
/* c8 ignore next */ // the held-lock-warning diagnostic with a description set is a timing-dependent log detail
|
|
3764
3792
|
...(options.description && { description: options.description }),
|
|
3765
3793
|
cid: this.id
|
|
3766
3794
|
});
|
|
@@ -3868,11 +3896,13 @@ class ImapFlow extends EventEmitter {
|
|
|
3868
3896
|
// New locks may have been queued while we were processing (e.g.,
|
|
3869
3897
|
// a lock that failed immediately and the next getMailboxLock call
|
|
3870
3898
|
// arrived before we finished). Schedule another run if needed.
|
|
3899
|
+
/* c8 ignore start */ // requires a lock to be enqueued during an in-flight processLocks pass; not reproducible deterministically
|
|
3871
3900
|
if (this.locks.length && !this.currentLock) {
|
|
3872
3901
|
setImmediate(() => {
|
|
3873
3902
|
this.processLocks().catch(err => this.log.error({ err, cid: this.id }));
|
|
3874
3903
|
});
|
|
3875
3904
|
}
|
|
3905
|
+
/* c8 ignore stop */
|
|
3876
3906
|
}
|
|
3877
3907
|
}
|
|
3878
3908
|
|
package/lib/search-compiler.js
CHANGED
|
@@ -261,6 +261,54 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
261
261
|
}
|
|
262
262
|
break;
|
|
263
263
|
|
|
264
|
+
// Gmail label search. Compiles { has, not } into an X-GM-RAW "label:"/"-label:" query
|
|
265
|
+
// since Gmail labels are not a native IMAP SEARCH key. Gmail-only (X-GM-EXT-1).
|
|
266
|
+
case 'LABELS': {
|
|
267
|
+
let labelQuery = params[term];
|
|
268
|
+
if (!labelQuery || typeof labelQuery !== 'object') {
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Collapse whitespace/quotes and quote multi-word names so they survive as a single token
|
|
273
|
+
let formatLabel = name => {
|
|
274
|
+
name = (name || '')
|
|
275
|
+
.toString()
|
|
276
|
+
.replace(/[\s"]+/g, ' ')
|
|
277
|
+
.trim();
|
|
278
|
+
return name.indexOf(' ') >= 0 ? `"${name}"` : name;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
let rawParts = [];
|
|
282
|
+
for (let name of [].concat(labelQuery.has || [])) {
|
|
283
|
+
if (name) {
|
|
284
|
+
rawParts.push(`label:${formatLabel(name)}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (let name of [].concat(labelQuery.not || [])) {
|
|
288
|
+
if (name) {
|
|
289
|
+
rawParts.push(`-label:${formatLabel(name)}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Empty filter is a no-op on any server (do not require the extension)
|
|
294
|
+
if (!rawParts.length) {
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!connection.capabilities.has('X-GM-EXT-1')) {
|
|
299
|
+
let error = new Error('Server does not support X-GM-EXT-1 extension required for label search');
|
|
300
|
+
error.code = 'MissingServerExtension';
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let rawQuery = rawParts.join(' ');
|
|
305
|
+
if (isUnicodeString(rawQuery)) {
|
|
306
|
+
hasUnicode = true;
|
|
307
|
+
}
|
|
308
|
+
setOpt(attributes, 'X-GM-RAW', rawQuery);
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
|
|
264
312
|
// Date searches with WITHIN extension support
|
|
265
313
|
case 'BEFORE':
|
|
266
314
|
case 'SINCE':
|
package/lib/tools.js
CHANGED
|
@@ -353,10 +353,12 @@ const tools = {
|
|
|
353
353
|
.replace(/<\d+(\.\d+)?>$/, '');
|
|
354
354
|
continue;
|
|
355
355
|
}
|
|
356
|
+
/* c8 ignore start */ // defensive: key is always a string produced by the compiler above
|
|
356
357
|
if (typeof key !== 'string') {
|
|
357
358
|
// should not happen
|
|
358
359
|
continue;
|
|
359
360
|
}
|
|
361
|
+
/* c8 ignore stop */
|
|
360
362
|
|
|
361
363
|
let getString = attribute => {
|
|
362
364
|
if (!attribute) {
|
|
@@ -554,10 +556,12 @@ const tools = {
|
|
|
554
556
|
if (Buffer.isBuffer(obj.value)) {
|
|
555
557
|
return obj.value.toString();
|
|
556
558
|
}
|
|
559
|
+
/* c8 ignore next */ // defensive: envelope tokens are always string/Buffer/NIL, never another type
|
|
557
560
|
return obj.value;
|
|
558
561
|
};
|
|
559
562
|
|
|
560
563
|
let processAddresses = function (list) {
|
|
564
|
+
/* c8 ignore next 2 */ // defensive: processAddresses is only called with non-empty arrays, so the [] fallback is unreachable
|
|
561
565
|
return []
|
|
562
566
|
.concat(list || [])
|
|
563
567
|
.map(addr => {
|
|
@@ -612,10 +616,12 @@ const tools = {
|
|
|
612
616
|
}
|
|
613
617
|
|
|
614
618
|
if (entry[8] && entry[8].value) {
|
|
619
|
+
/* c8 ignore next */ // the guard ensures getStrValue is truthy here, so the '' fallback is unreachable
|
|
615
620
|
envelope.inReplyTo = (getStrValue(entry[8]) || '').toString().trim();
|
|
616
621
|
}
|
|
617
622
|
|
|
618
623
|
if (entry[9] && entry[9].value) {
|
|
624
|
+
/* c8 ignore next */ // the guard ensures getStrValue is truthy here, so the '' fallback is unreachable
|
|
619
625
|
envelope.messageId = (getStrValue(entry[9]) || '').toString().trim();
|
|
620
626
|
}
|
|
621
627
|
|
|
@@ -819,6 +825,7 @@ const tools = {
|
|
|
819
825
|
|
|
820
826
|
// envelope of the encapsulated message
|
|
821
827
|
if (node[i]) {
|
|
828
|
+
/* c8 ignore next */ // node[i] is truthy inside this guard, so the [] fallback is unreachable
|
|
822
829
|
curNode.envelope = tools.parseEnvelope([].concat(node[i] || []));
|
|
823
830
|
}
|
|
824
831
|
i++;
|
|
@@ -886,6 +893,7 @@ const tools = {
|
|
|
886
893
|
// body language
|
|
887
894
|
if (i < node.length - 1) {
|
|
888
895
|
if (node[i]) {
|
|
896
|
+
/* c8 ignore next */ // node[i] is truthy inside this guard, so the [] fallback is unreachable
|
|
889
897
|
curNode.language = [].concat(node[i] || []).map(val => ((val && val.value) || '').toString().toLowerCase());
|
|
890
898
|
}
|
|
891
899
|
i++;
|