imapflow 1.3.2 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/lib/commands/namespace.js +9 -4
- package/lib/tools.js +4 -0
- package/package.json +6 -6
- package/test/commands-integration-test.js +179 -0
- package/test/tools-test.js +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.4](https://github.com/postalsys/imapflow/compare/v1.3.3...v1.3.4) (2026-05-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* bumped deps ([593b805](https://github.com/postalsys/imapflow/commit/593b805ed88d62bf7200a1a2ef081ce49f9162c0))
|
|
9
|
+
* handle THREADID NIL in FETCH response ([#353](https://github.com/postalsys/imapflow/issues/353)) ([40318d6](https://github.com/postalsys/imapflow/commit/40318d69b866989d6e84f1d0652d118920a3162e))
|
|
10
|
+
|
|
11
|
+
## [1.3.3](https://github.com/postalsys/imapflow/compare/v1.3.2...v1.3.3) (2026-04-29)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* handle NIL delimiter in NAMESPACE response (RFC 2342) ([#351](https://github.com/postalsys/imapflow/issues/351)) ([10bda2a](https://github.com/postalsys/imapflow/commit/10bda2a1d398dc7bc6ea0ede50b1ae48e4b28cf7))
|
|
17
|
+
|
|
3
18
|
## [1.3.2](https://github.com/postalsys/imapflow/compare/v1.3.1...v1.3.2) (2026-04-17)
|
|
4
19
|
|
|
5
20
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Requests NAMESPACE info from the server.
|
|
5
5
|
*
|
|
6
6
|
* @param {Object} connection - IMAP connection instance
|
|
7
|
-
* @returns {Promise<{prefix: string, delimiter: string}|{error: boolean, status: string, text: string}>} The primary personal namespace, or an error object on failure
|
|
7
|
+
* @returns {Promise<{prefix: string, delimiter: string|null}|{error: boolean, status: string, text: string}>} The primary personal namespace, or an error object on failure
|
|
8
8
|
*/
|
|
9
9
|
module.exports = async connection => {
|
|
10
10
|
if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
|
|
@@ -114,7 +114,7 @@ async function getListPrefix(connection) {
|
|
|
114
114
|
* Parses namespace information from an IMAP NAMESPACE response attribute.
|
|
115
115
|
*
|
|
116
116
|
* @param {Array} attribute - Namespace attribute array from the server response
|
|
117
|
-
* @returns {Array<{prefix: string, delimiter: string}>|boolean} Array of namespace entries, or false if empty
|
|
117
|
+
* @returns {Array<{prefix: string, delimiter: string|null}>|boolean} Array of namespace entries, or false if empty
|
|
118
118
|
*/
|
|
119
119
|
function getNamsepaceInfo(attribute) {
|
|
120
120
|
if (!attribute || !attribute.length) {
|
|
@@ -122,10 +122,15 @@ function getNamsepaceInfo(attribute) {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
return attribute
|
|
125
|
-
.filter(
|
|
125
|
+
.filter(
|
|
126
|
+
entry =>
|
|
127
|
+
// RFC 2342 §5 allows the delimiter to be NIL when the namespace has
|
|
128
|
+
// no hierarchy. The token parser emits a literal `null` for NIL.
|
|
129
|
+
entry.length >= 2 && entry[0] && typeof entry[0].value === 'string' && (entry[1] === null || (entry[1] && typeof entry[1].value === 'string'))
|
|
130
|
+
)
|
|
126
131
|
.map(entry => {
|
|
127
132
|
let prefix = entry[0].value;
|
|
128
|
-
let delimiter = entry[1].value;
|
|
133
|
+
let delimiter = entry[1] === null ? null : entry[1].value;
|
|
129
134
|
|
|
130
135
|
// Append the delimiter to the prefix if it doesn't already end with one,
|
|
131
136
|
// so callers can construct full paths by simply concatenating prefix + name.
|
package/lib/tools.js
CHANGED
|
@@ -383,6 +383,10 @@ const tools = {
|
|
|
383
383
|
if (Array.isArray(attribute)) {
|
|
384
384
|
return attribute.map(entry => (entry && typeof entry.value === 'string' ? entry.value : false)).filter(entry => entry);
|
|
385
385
|
}
|
|
386
|
+
// NIL (parsed as null) and other non-array values yield an empty array,
|
|
387
|
+
// so callers can safely index into the result. RFC 8474 allows e.g.
|
|
388
|
+
// `THREADID NIL` when the server has no thread relation to report.
|
|
389
|
+
return [];
|
|
386
390
|
};
|
|
387
391
|
|
|
388
392
|
switch (key) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.4",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"main": "lib/imap-flow.js",
|
|
6
6
|
"types": "lib/imap-flow.d.ts",
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
"homepage": "https://imapflow.com/",
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@eslint/js": "10.0.1",
|
|
31
|
-
"@types/node": "25.
|
|
31
|
+
"@types/node": "25.9.1",
|
|
32
32
|
"c8": "11.0.0",
|
|
33
|
-
"eslint": "10.
|
|
33
|
+
"eslint": "10.4.0",
|
|
34
34
|
"eslint-config-nodemailer": "1.2.0",
|
|
35
35
|
"eslint-config-prettier": "10.1.8",
|
|
36
36
|
"grunt": "1.6.2",
|
|
@@ -42,14 +42,14 @@
|
|
|
42
42
|
"typescript": "6.0.3"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@zone-eu/mailsplit": "5.4.
|
|
45
|
+
"@zone-eu/mailsplit": "5.4.12",
|
|
46
46
|
"encoding-japanese": "2.2.0",
|
|
47
47
|
"iconv-lite": "0.7.2",
|
|
48
48
|
"libbase64": "1.3.0",
|
|
49
49
|
"libmime": "5.3.8",
|
|
50
50
|
"libqp": "2.1.1",
|
|
51
|
-
"nodemailer": "8.0.
|
|
51
|
+
"nodemailer": "8.0.10",
|
|
52
52
|
"pino": "10.3.1",
|
|
53
|
-
"socks": "2.8.
|
|
53
|
+
"socks": "2.8.9"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -6457,6 +6457,185 @@ module.exports['Commands: namespace handles NIL namespaces'] = async test => {
|
|
|
6457
6457
|
test.done();
|
|
6458
6458
|
};
|
|
6459
6459
|
|
|
6460
|
+
module.exports['Commands: namespace handles NIL delimiter (RFC 2342)'] = async test => {
|
|
6461
|
+
// RFC 2342 §5: NIL delimiter means the namespace has no hierarchy.
|
|
6462
|
+
// The token parser emits a literal `null` for NIL.
|
|
6463
|
+
|
|
6464
|
+
const connection = createMockConnection({
|
|
6465
|
+
state: 2,
|
|
6466
|
+
capabilities: new Map([['NAMESPACE', true]]),
|
|
6467
|
+
exec: async (cmd, args, opts) => {
|
|
6468
|
+
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
6469
|
+
await opts.untagged.NAMESPACE({
|
|
6470
|
+
attributes: [
|
|
6471
|
+
[
|
|
6472
|
+
[{ value: '' }, { value: '/' }],
|
|
6473
|
+
[{ value: '#hidden' }, null]
|
|
6474
|
+
],
|
|
6475
|
+
null,
|
|
6476
|
+
null
|
|
6477
|
+
]
|
|
6478
|
+
});
|
|
6479
|
+
}
|
|
6480
|
+
return { next: () => {} };
|
|
6481
|
+
}
|
|
6482
|
+
});
|
|
6483
|
+
|
|
6484
|
+
const result = await namespaceCommand(connection);
|
|
6485
|
+
|
|
6486
|
+
// Guard against handler crash so a regression surfaces as a failed
|
|
6487
|
+
// assertion rather than fatal-halting the whole nodeunit suite.
|
|
6488
|
+
if (result && result.error) {
|
|
6489
|
+
test.ok(false, 'namespace command returned error sentinel — likely crashed on NIL delimiter');
|
|
6490
|
+
test.done();
|
|
6491
|
+
return;
|
|
6492
|
+
}
|
|
6493
|
+
if (!Array.isArray(connection.namespaces && connection.namespaces.personal)) {
|
|
6494
|
+
test.ok(false, 'connection.namespaces.personal is not an array (handler crashed mid-untagged-callback)');
|
|
6495
|
+
test.done();
|
|
6496
|
+
return;
|
|
6497
|
+
}
|
|
6498
|
+
|
|
6499
|
+
test.equal(connection.namespaces.personal.length, 2, 'both personal entries should be parsed');
|
|
6500
|
+
|
|
6501
|
+
test.equal(connection.namespaces.personal[0].prefix, '');
|
|
6502
|
+
test.equal(connection.namespaces.personal[0].delimiter, '/');
|
|
6503
|
+
|
|
6504
|
+
// NIL-delimiter entry preserved with delimiter:null rather than dropped silently.
|
|
6505
|
+
test.equal(connection.namespaces.personal[1].prefix, '#hidden');
|
|
6506
|
+
test.equal(connection.namespaces.personal[1].delimiter, null);
|
|
6507
|
+
|
|
6508
|
+
// Default namespace pointer resolves to the first valid personal entry.
|
|
6509
|
+
test.equal(connection.namespace.prefix, '');
|
|
6510
|
+
test.equal(connection.namespace.delimiter, '/');
|
|
6511
|
+
|
|
6512
|
+
test.done();
|
|
6513
|
+
};
|
|
6514
|
+
|
|
6515
|
+
module.exports['Commands: namespace handles NIL delimiter as only personal entry'] = async test => {
|
|
6516
|
+
// Edge case: only a NIL-delimiter entry in the personal section.
|
|
6517
|
+
// connection.namespace must still be set and usable downstream.
|
|
6518
|
+
|
|
6519
|
+
const connection = createMockConnection({
|
|
6520
|
+
state: 2,
|
|
6521
|
+
capabilities: new Map([['NAMESPACE', true]]),
|
|
6522
|
+
exec: async (cmd, args, opts) => {
|
|
6523
|
+
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
6524
|
+
await opts.untagged.NAMESPACE({
|
|
6525
|
+
attributes: [[[{ value: '#hidden' }, null]], null, null]
|
|
6526
|
+
});
|
|
6527
|
+
}
|
|
6528
|
+
return { next: () => {} };
|
|
6529
|
+
}
|
|
6530
|
+
});
|
|
6531
|
+
|
|
6532
|
+
const result = await namespaceCommand(connection);
|
|
6533
|
+
|
|
6534
|
+
if (result && result.error) {
|
|
6535
|
+
test.ok(false, 'namespace command returned error sentinel — likely crashed on NIL delimiter');
|
|
6536
|
+
test.done();
|
|
6537
|
+
return;
|
|
6538
|
+
}
|
|
6539
|
+
if (!Array.isArray(connection.namespaces && connection.namespaces.personal)) {
|
|
6540
|
+
test.ok(false, 'connection.namespaces.personal is not an array (handler crashed mid-untagged-callback)');
|
|
6541
|
+
test.done();
|
|
6542
|
+
return;
|
|
6543
|
+
}
|
|
6544
|
+
|
|
6545
|
+
test.equal(connection.namespaces.personal.length, 1);
|
|
6546
|
+
test.equal(connection.namespaces.personal[0].prefix, '#hidden');
|
|
6547
|
+
test.equal(connection.namespaces.personal[0].delimiter, null);
|
|
6548
|
+
|
|
6549
|
+
test.ok(connection.namespace);
|
|
6550
|
+
test.equal(connection.namespace.prefix, '#hidden');
|
|
6551
|
+
test.equal(connection.namespace.delimiter, null);
|
|
6552
|
+
|
|
6553
|
+
test.done();
|
|
6554
|
+
};
|
|
6555
|
+
|
|
6556
|
+
module.exports['Commands: namespace handles NIL delimiter in other and shared sections'] = async test => {
|
|
6557
|
+
// NIL delimiter is also valid in the `other` and `shared` sections,
|
|
6558
|
+
// not just `personal`.
|
|
6559
|
+
|
|
6560
|
+
const connection = createMockConnection({
|
|
6561
|
+
state: 2,
|
|
6562
|
+
capabilities: new Map([['NAMESPACE', true]]),
|
|
6563
|
+
exec: async (cmd, args, opts) => {
|
|
6564
|
+
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
6565
|
+
await opts.untagged.NAMESPACE({
|
|
6566
|
+
attributes: [[[{ value: '' }, { value: '/' }]], [[{ value: 'Other Users/' }, null]], [[{ value: 'Public/' }, null]]]
|
|
6567
|
+
});
|
|
6568
|
+
}
|
|
6569
|
+
return { next: () => {} };
|
|
6570
|
+
}
|
|
6571
|
+
});
|
|
6572
|
+
|
|
6573
|
+
const result = await namespaceCommand(connection);
|
|
6574
|
+
|
|
6575
|
+
if (result && result.error) {
|
|
6576
|
+
test.ok(false, 'namespace command returned error sentinel — likely crashed on NIL delimiter');
|
|
6577
|
+
test.done();
|
|
6578
|
+
return;
|
|
6579
|
+
}
|
|
6580
|
+
|
|
6581
|
+
test.ok(Array.isArray(connection.namespaces.other), 'other should be an array');
|
|
6582
|
+
test.equal(connection.namespaces.other.length, 1);
|
|
6583
|
+
test.equal(connection.namespaces.other[0].prefix, 'Other Users/');
|
|
6584
|
+
test.equal(connection.namespaces.other[0].delimiter, null);
|
|
6585
|
+
|
|
6586
|
+
test.ok(Array.isArray(connection.namespaces.shared), 'shared should be an array');
|
|
6587
|
+
test.equal(connection.namespaces.shared.length, 1);
|
|
6588
|
+
test.equal(connection.namespaces.shared[0].prefix, 'Public/');
|
|
6589
|
+
test.equal(connection.namespaces.shared[0].delimiter, null);
|
|
6590
|
+
|
|
6591
|
+
test.done();
|
|
6592
|
+
};
|
|
6593
|
+
|
|
6594
|
+
module.exports['Commands: namespace skips malformed entries without crashing'] = async test => {
|
|
6595
|
+
// The filter must reject entries that don't match either the
|
|
6596
|
+
// (string-prefix, string-delimiter) or (string-prefix, NIL) shape,
|
|
6597
|
+
// so a single broken entry from a buggy server doesn't poison
|
|
6598
|
+
// the whole namespace list or crash the handler.
|
|
6599
|
+
|
|
6600
|
+
const connection = createMockConnection({
|
|
6601
|
+
state: 2,
|
|
6602
|
+
capabilities: new Map([['NAMESPACE', true]]),
|
|
6603
|
+
exec: async (cmd, args, opts) => {
|
|
6604
|
+
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
6605
|
+
await opts.untagged.NAMESPACE({
|
|
6606
|
+
attributes: [
|
|
6607
|
+
[
|
|
6608
|
+
[{ value: '' }, { value: '/' }], // valid
|
|
6609
|
+
[{ value: '#hidden' }, null], // valid (NIL delimiter)
|
|
6610
|
+
[{ value: 'broken' }], // too short — rejected
|
|
6611
|
+
[null, { value: '/' }], // null prefix — rejected
|
|
6612
|
+
[{ value: 123 }, { value: '/' }] // non-string prefix — rejected
|
|
6613
|
+
],
|
|
6614
|
+
null,
|
|
6615
|
+
null
|
|
6616
|
+
]
|
|
6617
|
+
});
|
|
6618
|
+
}
|
|
6619
|
+
return { next: () => {} };
|
|
6620
|
+
}
|
|
6621
|
+
});
|
|
6622
|
+
|
|
6623
|
+
const result = await namespaceCommand(connection);
|
|
6624
|
+
|
|
6625
|
+
if (result && result.error) {
|
|
6626
|
+
test.ok(false, 'namespace command returned error sentinel — likely crashed on malformed entry');
|
|
6627
|
+
test.done();
|
|
6628
|
+
return;
|
|
6629
|
+
}
|
|
6630
|
+
|
|
6631
|
+
test.equal(connection.namespaces.personal.length, 2, 'only the two well-formed entries should be kept');
|
|
6632
|
+
test.equal(connection.namespaces.personal[0].prefix, '');
|
|
6633
|
+
test.equal(connection.namespaces.personal[1].prefix, '#hidden');
|
|
6634
|
+
test.equal(connection.namespaces.personal[1].delimiter, null);
|
|
6635
|
+
|
|
6636
|
+
test.done();
|
|
6637
|
+
};
|
|
6638
|
+
|
|
6460
6639
|
module.exports['Commands: namespace handles multiple personal namespaces'] = async test => {
|
|
6461
6640
|
const connection = createMockConnection({
|
|
6462
6641
|
state: 2,
|
package/test/tools-test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const tools = require('../lib/tools');
|
|
4
|
+
const { parser } = require('../lib/handler/imap-handler');
|
|
4
5
|
|
|
5
6
|
// Mock connection for testing
|
|
6
7
|
let createMockConnection = (options = {}) => ({
|
|
@@ -1137,3 +1138,29 @@ module.exports['Tools: getColorFlags with null returns result not null'] = test
|
|
|
1137
1138
|
test.ok(result.remove.includes('\\Flagged'));
|
|
1138
1139
|
test.done();
|
|
1139
1140
|
};
|
|
1141
|
+
|
|
1142
|
+
// ============================================
|
|
1143
|
+
// formatMessageResponse: OBJECTID NIL handling (RFC 8474)
|
|
1144
|
+
// ============================================
|
|
1145
|
+
|
|
1146
|
+
module.exports['Tools: formatMessageResponse handles THREADID NIL'] = async test => {
|
|
1147
|
+
// RFC 8474 allows `THREADID NIL` when the server has no thread relation to
|
|
1148
|
+
// report (e.g. Strato). The NIL token is parsed as null and must not crash.
|
|
1149
|
+
let untagged = await parser('* 1 FETCH (UID 1 EMAILID (E1) THREADID NIL FLAGS (\\Seen) MODSEQ (4312))');
|
|
1150
|
+
let result = await tools.formatMessageResponse(untagged, {});
|
|
1151
|
+
test.equal(result.seq, 1);
|
|
1152
|
+
test.equal(result.uid, 1);
|
|
1153
|
+
test.equal(result.emailId, 'E1');
|
|
1154
|
+
test.equal(result.threadId, undefined);
|
|
1155
|
+
test.ok(result.flags.has('\\Seen'));
|
|
1156
|
+
test.equal(result.modseq, 4312n);
|
|
1157
|
+
test.done();
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
module.exports['Tools: formatMessageResponse handles normal THREADID'] = async test => {
|
|
1161
|
+
let untagged = await parser('* 2 FETCH (THREADID (T9999) EMAILID (E2))');
|
|
1162
|
+
let result = await tools.formatMessageResponse(untagged, {});
|
|
1163
|
+
test.equal(result.threadId, 'T9999');
|
|
1164
|
+
test.equal(result.emailId, 'E2');
|
|
1165
|
+
test.done();
|
|
1166
|
+
};
|